1109 lines
35 KiB
TypeScript
1109 lines
35 KiB
TypeScript
import type { Dispatch, SetStateAction } from 'react';
|
|
|
|
import { buildRelationState } from '../../data/attributeResolver';
|
|
import { hasEncounterEntity } from '../../data/encounterTransition';
|
|
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
|
|
import {
|
|
addInventoryItems,
|
|
applyStoryChoiceToStanceProfile,
|
|
buildNpcChatResultText,
|
|
buildNpcHelpCommitActionText,
|
|
buildNpcHelpResultText,
|
|
buildNpcHelpReward,
|
|
buildNpcLeaveResultText,
|
|
buildNpcSparResultText,
|
|
createNpcBattleMonster,
|
|
describeNpcAffinityInWords,
|
|
generateNpcHelpReward,
|
|
getChatAffinityOutcome,
|
|
getNpcLootItems,
|
|
getNpcSparMaxHp,
|
|
markNpcFirstMeaningfulContactResolved,
|
|
NPC_SPAR_AFFINITY_GAIN,
|
|
removeInventoryItem,
|
|
} from '../../data/npcInteractions';
|
|
import {
|
|
acceptQuest,
|
|
applyQuestProgressFromHostileNpcDefeat,
|
|
applyQuestProgressFromNpcTalk,
|
|
applyQuestProgressFromSpar,
|
|
buildQuestAcceptResultText,
|
|
buildQuestForEncounter,
|
|
buildQuestTurnInResultText,
|
|
findQuestById,
|
|
getQuestForIssuer,
|
|
markQuestTurnedIn,
|
|
} from '../../data/questFlow';
|
|
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
|
import {
|
|
createSceneCallOutEncounter,
|
|
resolveSceneEncounterPreview,
|
|
} from '../../data/sceneEncounterPreviews';
|
|
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
|
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
|
|
import type { StoryGenerationContext } from '../../services/aiTypes';
|
|
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
|
import {
|
|
appendStoryEngineCarrierMemory,
|
|
syncNpcNarrativeState,
|
|
} from '../../services/storyEngine/echoMemory';
|
|
import { createHistoryMoment } from '../../services/storyHistory';
|
|
import type {
|
|
Character,
|
|
Encounter,
|
|
GameState,
|
|
InventoryItem,
|
|
NpcBattleMode,
|
|
NpcBattleOutcome,
|
|
StoryMoment,
|
|
StoryOption,
|
|
} from '../../types';
|
|
import { AnimationState } from '../../types';
|
|
import type { CommitGeneratedState } from '../generatedState';
|
|
|
|
type CommitGeneratedStateWithEncounterEntry = (
|
|
entryState: GameState,
|
|
resolvedState: GameState,
|
|
character: Character,
|
|
actionText: string,
|
|
resultText: string,
|
|
lastFunctionId?: string,
|
|
) => Promise<void> | void;
|
|
|
|
type GenerateStoryForState = (params: {
|
|
state: GameState;
|
|
character: Character;
|
|
history: StoryMoment[];
|
|
choice?: string;
|
|
lastFunctionId?: string | null;
|
|
optionCatalog?: StoryOption[] | null;
|
|
}) => Promise<StoryMoment>;
|
|
|
|
type NpcInteractionFlowActions = {
|
|
openTradeModal: (encounter: Encounter, actionText: string) => void;
|
|
openGiftModal: (encounter: Encounter, actionText: string) => void;
|
|
openRecruitModal: (encounter: Encounter, actionText: string) => void;
|
|
startRecruitmentSequence: (
|
|
encounter: Encounter,
|
|
actionText: string,
|
|
) => Promise<void>;
|
|
};
|
|
|
|
type BuildStoryContextExtras = {
|
|
lastFunctionId?: string | null;
|
|
openingCampBackground?: string | null;
|
|
openingCampDialogue?: string | null;
|
|
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
|
};
|
|
|
|
function buildCampCompanionChatResultText(
|
|
encounter: Encounter,
|
|
affinityGain: number,
|
|
nextAffinity: number,
|
|
) {
|
|
const teamworkText =
|
|
affinityGain > 0
|
|
? '你也更能感觉到,下一步和对方并肩时会顺手一些。'
|
|
: '至少你们把接下来的节奏重新校准了一遍。';
|
|
return `${encounter.npcName}和你交换了一番想法,${describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
|
|
}
|
|
|
|
function isNpcEncounter(
|
|
encounter: GameState['currentEncounter'],
|
|
): encounter is Encounter {
|
|
return Boolean(encounter?.kind === 'npc');
|
|
}
|
|
|
|
export function createStoryNpcEncounterActions({
|
|
gameState,
|
|
setGameState,
|
|
setCurrentStory,
|
|
setAiError,
|
|
setIsLoading,
|
|
commitGeneratedState,
|
|
commitGeneratedStateWithEncounterEntry,
|
|
appendHistory,
|
|
buildOpeningCampChatContext,
|
|
buildStoryContextFromState,
|
|
buildFallbackStoryForState,
|
|
buildDialogueStoryMoment,
|
|
generateStoryForState,
|
|
getStoryGenerationHostileNpcs,
|
|
getTypewriterDelay,
|
|
getAvailableOptionsForState,
|
|
sanitizeOptions,
|
|
sortOptions,
|
|
buildContinueAdventureOption,
|
|
getNpcEncounterKey,
|
|
getResolvedNpcState,
|
|
updateNpcState,
|
|
cloneInventoryItemForOwner,
|
|
resolveNpcInteractionDecision,
|
|
npcInteractionFlow,
|
|
}: {
|
|
gameState: GameState;
|
|
setGameState: Dispatch<SetStateAction<GameState>>;
|
|
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
|
setAiError: Dispatch<SetStateAction<string | null>>;
|
|
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
|
commitGeneratedState: CommitGeneratedState;
|
|
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
|
|
appendHistory: (
|
|
state: GameState,
|
|
actionText: string,
|
|
resultText: string,
|
|
) => GameState['storyHistory'];
|
|
buildOpeningCampChatContext: (
|
|
state: GameState,
|
|
character: Character,
|
|
encounter: Encounter,
|
|
) => BuildStoryContextExtras;
|
|
buildStoryContextFromState: (
|
|
state: GameState,
|
|
extras?: BuildStoryContextExtras,
|
|
) => StoryGenerationContext;
|
|
buildFallbackStoryForState: (
|
|
state: GameState,
|
|
character: Character,
|
|
fallbackText?: string,
|
|
) => StoryMoment;
|
|
buildDialogueStoryMoment: (
|
|
npcName: string,
|
|
text: string,
|
|
options: StoryOption[],
|
|
streaming?: boolean,
|
|
) => StoryMoment;
|
|
generateStoryForState: GenerateStoryForState;
|
|
getStoryGenerationHostileNpcs: (
|
|
state: GameState,
|
|
) => GameState['sceneHostileNpcs'];
|
|
getTypewriterDelay: (char: string) => number;
|
|
getAvailableOptionsForState: (
|
|
state: GameState,
|
|
character: Character,
|
|
) => StoryOption[] | null;
|
|
sanitizeOptions: (
|
|
options: StoryOption[],
|
|
character: Character,
|
|
state: GameState,
|
|
) => StoryOption[];
|
|
sortOptions: (options: StoryOption[]) => StoryOption[];
|
|
buildContinueAdventureOption: () => StoryOption;
|
|
getNpcEncounterKey: (encounter: Encounter) => string;
|
|
getResolvedNpcState: (
|
|
state: GameState,
|
|
encounter: Encounter,
|
|
) => GameState['npcStates'][string];
|
|
updateNpcState: (
|
|
state: GameState,
|
|
encounter: Encounter,
|
|
updater: (
|
|
npcState: GameState['npcStates'][string],
|
|
) => GameState['npcStates'][string],
|
|
) => GameState;
|
|
cloneInventoryItemForOwner: (
|
|
item: InventoryItem,
|
|
owner: 'player' | 'npc',
|
|
quantity?: number,
|
|
) => InventoryItem;
|
|
resolveNpcInteractionDecision: (
|
|
state: GameState,
|
|
option: StoryOption,
|
|
) => { kind: string };
|
|
npcInteractionFlow: NpcInteractionFlowActions;
|
|
}) {
|
|
const updateQuestLog = (
|
|
state: GameState,
|
|
updater: (quests: GameState['quests']) => GameState['quests'],
|
|
) => ({
|
|
...state,
|
|
quests: updater(state.quests),
|
|
});
|
|
|
|
const incrementRuntimeStats = (
|
|
state: GameState,
|
|
increments: Parameters<typeof incrementGameRuntimeStats>[1],
|
|
) => ({
|
|
...state,
|
|
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
|
|
});
|
|
|
|
const finalizeNpcBattleResult = (
|
|
state: GameState,
|
|
character: Character,
|
|
battleMode: NpcBattleMode,
|
|
battleOutcome: NpcBattleOutcome | null,
|
|
) => {
|
|
if (!state.currentBattleNpcId) return null;
|
|
|
|
const battleNpcId = state.currentBattleNpcId;
|
|
const npcState = state.npcStates[battleNpcId];
|
|
if (!npcState) return null;
|
|
const activeBattleHostiles = state.sceneHostileNpcs;
|
|
|
|
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
|
|
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
|
|
const restoredEncounter = state.sparReturnEncounter;
|
|
const progressedQuests = applyQuestProgressFromSpar(
|
|
state.quests,
|
|
battleNpcId,
|
|
);
|
|
const nextState = {
|
|
...state,
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
currentEncounter: restoredEncounter,
|
|
npcInteractionActive: true,
|
|
sceneHostileNpcs: [],
|
|
npcStates: {
|
|
...state.npcStates,
|
|
[battleNpcId]: {
|
|
...markNpcFirstMeaningfulContactResolved(npcState),
|
|
affinity: nextAffinity,
|
|
relationState: buildRelationState(nextAffinity),
|
|
stanceProfile: applyStoryChoiceToStanceProfile(
|
|
npcState.stanceProfile,
|
|
'npc_chat',
|
|
{ affinityGain: NPC_SPAR_AFFINITY_GAIN },
|
|
),
|
|
},
|
|
},
|
|
quests: progressedQuests,
|
|
playerX: 0,
|
|
playerHp: state.sparPlayerHpBefore ?? state.playerHp,
|
|
playerMaxHp: state.sparPlayerMaxHpBefore ?? state.playerMaxHp,
|
|
playerFacing: 'right' as const,
|
|
animationState: state.animationState,
|
|
activeCombatEffects: [],
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
};
|
|
|
|
return {
|
|
nextState,
|
|
resultText: buildNpcSparResultText(
|
|
activeBattleHostiles[0]?.name ?? '对方',
|
|
NPC_SPAR_AFFINITY_GAIN,
|
|
nextAffinity,
|
|
),
|
|
};
|
|
}
|
|
|
|
const lootItems = getNpcLootItems(npcState, character).map((item) =>
|
|
cloneInventoryItemForOwner(item, 'player'),
|
|
);
|
|
const defeatedHostileNpcIds = activeBattleHostiles.map(
|
|
(hostileNpc) => hostileNpc.id,
|
|
);
|
|
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
|
|
state.quests,
|
|
state.currentScenePreset?.id ?? null,
|
|
defeatedHostileNpcIds,
|
|
);
|
|
let nextNpcInventory = npcState.inventory;
|
|
|
|
for (const item of lootItems) {
|
|
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
|
|
}
|
|
|
|
const nextState: GameState = appendStoryEngineCarrierMemory(
|
|
incrementRuntimeStats(
|
|
{
|
|
...state,
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
sceneHostileNpcs: [],
|
|
playerInventory: addInventoryItems(state.playerInventory, lootItems),
|
|
quests: progressedQuests,
|
|
npcStates: {
|
|
...state.npcStates,
|
|
[battleNpcId]: {
|
|
...markNpcFirstMeaningfulContactResolved(npcState),
|
|
affinity: 0,
|
|
relationState: buildRelationState(0),
|
|
recruited: false,
|
|
inventory: nextNpcInventory,
|
|
},
|
|
},
|
|
playerX: 0,
|
|
playerFacing: 'right' as const,
|
|
animationState: state.animationState,
|
|
activeCombatEffects: [],
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
},
|
|
{
|
|
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
|
},
|
|
),
|
|
lootItems,
|
|
);
|
|
|
|
const lootText =
|
|
lootItems.length > 0
|
|
? lootItems.map((item) => item.name).join(', ')
|
|
: '无战利品';
|
|
const defeatedNames =
|
|
activeBattleHostiles.map((hostileNpc) => hostileNpc.name).join('、') ||
|
|
battleNpcId ||
|
|
'对手';
|
|
return {
|
|
nextState,
|
|
resultText: `${defeatedNames}已经败下阵来。胜利奖励:${lootText}。`,
|
|
};
|
|
};
|
|
|
|
const commitNpcChatState = async (
|
|
nextState: GameState,
|
|
character: Character,
|
|
encounter: Encounter,
|
|
actionText: string,
|
|
resultText: string,
|
|
lastFunctionId?: string,
|
|
options: {
|
|
contextNpcStateOverride?: GameState['npcStates'][string] | null;
|
|
preserveResultTextInHistory?: boolean;
|
|
revealMode?: 'deferred_options' | 'immediate_story';
|
|
} = {},
|
|
) => {
|
|
const provisionalHistory = appendHistory(gameState, actionText, resultText);
|
|
const provisionalState = {
|
|
...nextState,
|
|
storyHistory: provisionalHistory,
|
|
};
|
|
const provisionalOpeningCampContext = buildOpeningCampChatContext(
|
|
provisionalState,
|
|
character,
|
|
encounter,
|
|
);
|
|
|
|
setGameState(provisionalState);
|
|
setAiError(null);
|
|
setIsLoading(true);
|
|
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
|
|
|
|
let dialogueText = '';
|
|
let streamedTargetText = '';
|
|
let displayedText = '';
|
|
let streamCompleted = false;
|
|
|
|
const typewriterPromise = (async () => {
|
|
while (
|
|
!streamCompleted ||
|
|
displayedText.length < streamedTargetText.length
|
|
) {
|
|
if (displayedText.length >= streamedTargetText.length) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
|
continue;
|
|
}
|
|
|
|
const nextChar = streamedTargetText[displayedText.length];
|
|
if (!nextChar) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
|
continue;
|
|
}
|
|
displayedText += nextChar;
|
|
setCurrentStory(
|
|
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
|
|
);
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
|
|
);
|
|
}
|
|
})();
|
|
|
|
try {
|
|
dialogueText = await streamNpcChatDialogue(
|
|
gameState.worldType!,
|
|
character,
|
|
encounter,
|
|
getStoryGenerationHostileNpcs(provisionalState),
|
|
provisionalHistory,
|
|
buildStoryContextFromState(provisionalState, {
|
|
lastFunctionId,
|
|
...provisionalOpeningCampContext,
|
|
encounterNpcStateOverride: options.contextNpcStateOverride,
|
|
}),
|
|
actionText,
|
|
resultText,
|
|
{
|
|
onUpdate: (text) => {
|
|
streamedTargetText = text;
|
|
},
|
|
},
|
|
);
|
|
streamedTargetText = dialogueText;
|
|
streamCompleted = true;
|
|
await typewriterPromise;
|
|
|
|
const finalDialogueText = dialogueText || resultText;
|
|
const finalHistory = options.preserveResultTextInHistory
|
|
? finalDialogueText && finalDialogueText !== resultText
|
|
? [
|
|
...provisionalHistory,
|
|
createHistoryMoment(finalDialogueText, 'result'),
|
|
]
|
|
: provisionalHistory
|
|
: appendHistory(gameState, actionText, finalDialogueText);
|
|
const progressedQuests = applyQuestProgressFromNpcTalk(
|
|
nextState.quests,
|
|
encounter.id ?? encounter.npcName,
|
|
);
|
|
const finalState = {
|
|
...nextState,
|
|
quests: progressedQuests,
|
|
storyHistory: finalHistory,
|
|
};
|
|
const finalOpeningCampContext = buildOpeningCampChatContext(
|
|
finalState,
|
|
character,
|
|
encounter,
|
|
);
|
|
setGameState(finalState);
|
|
|
|
if (options.revealMode === 'immediate_story') {
|
|
setCurrentStory(
|
|
buildDialogueStoryMoment(
|
|
encounter.npcName,
|
|
finalDialogueText,
|
|
[],
|
|
false,
|
|
),
|
|
);
|
|
await new Promise((resolve) => window.setTimeout(resolve, 260));
|
|
|
|
const nextStory = await generateStoryForState({
|
|
state: finalState,
|
|
character,
|
|
history: finalHistory,
|
|
choice: actionText,
|
|
lastFunctionId,
|
|
});
|
|
const recoveredState = applyStoryReasoningRecovery(finalState);
|
|
setGameState(recoveredState);
|
|
setCurrentStory(nextStory);
|
|
return;
|
|
}
|
|
|
|
const availableOptions = getAvailableOptionsForState(
|
|
finalState,
|
|
character,
|
|
);
|
|
|
|
const response = await generateNextStep(
|
|
gameState.worldType!,
|
|
character,
|
|
getStoryGenerationHostileNpcs(finalState),
|
|
finalHistory,
|
|
actionText,
|
|
buildStoryContextFromState(finalState, {
|
|
lastFunctionId,
|
|
...finalOpeningCampContext,
|
|
}),
|
|
availableOptions ? { availableOptions } : undefined,
|
|
);
|
|
const resolvedOptions = sortOptions(
|
|
availableOptions
|
|
? response.options
|
|
: sanitizeOptions(response.options, character, finalState),
|
|
);
|
|
const recoveredState = applyStoryReasoningRecovery(finalState);
|
|
setGameState(recoveredState);
|
|
|
|
setCurrentStory({
|
|
...buildDialogueStoryMoment(
|
|
encounter.npcName,
|
|
dialogueText || resultText,
|
|
[buildContinueAdventureOption()],
|
|
false,
|
|
),
|
|
deferredOptions: resolvedOptions,
|
|
});
|
|
} catch (error) {
|
|
streamCompleted = true;
|
|
await typewriterPromise;
|
|
console.error('Failed to stream npc chat story:', error);
|
|
setAiError(
|
|
error instanceof Error ? error.message : '角色对话智能生成不可用。',
|
|
);
|
|
if (options.revealMode === 'immediate_story') {
|
|
setCurrentStory(
|
|
buildFallbackStoryForState(provisionalState, character, resultText),
|
|
);
|
|
return;
|
|
}
|
|
const fallbackOptions =
|
|
getAvailableOptionsForState(provisionalState, character) ?? [];
|
|
setCurrentStory(
|
|
displayedText
|
|
? {
|
|
...buildDialogueStoryMoment(
|
|
encounter.npcName,
|
|
displayedText,
|
|
fallbackOptions.length > 0
|
|
? [buildContinueAdventureOption()]
|
|
: [],
|
|
false,
|
|
),
|
|
deferredOptions:
|
|
fallbackOptions.length > 0
|
|
? sortOptions(fallbackOptions)
|
|
: undefined,
|
|
}
|
|
: buildFallbackStoryForState(provisionalState, character, resultText),
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
|
|
const playerCharacter = gameState.playerCharacter;
|
|
if (!playerCharacter) return false;
|
|
|
|
const nextState: GameState = {
|
|
...gameState,
|
|
npcInteractionActive: true,
|
|
};
|
|
|
|
void commitGeneratedState(
|
|
nextState,
|
|
playerCharacter,
|
|
actionText,
|
|
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
|
|
NPC_PREVIEW_TALK_FUNCTION.id,
|
|
);
|
|
return true;
|
|
};
|
|
|
|
const handleNpcInteraction = (option: StoryOption) => {
|
|
const playerCharacter = gameState.playerCharacter;
|
|
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
|
|
return false;
|
|
}
|
|
|
|
const encounter = gameState.currentEncounter;
|
|
const npcState = getResolvedNpcState(gameState, encounter);
|
|
const interactionDecision = resolveNpcInteractionDecision(
|
|
gameState,
|
|
option,
|
|
);
|
|
|
|
if (interactionDecision.kind === 'trade_modal') {
|
|
npcInteractionFlow.openTradeModal(encounter, option.actionText);
|
|
return true;
|
|
}
|
|
|
|
if (interactionDecision.kind === 'gift_modal') {
|
|
npcInteractionFlow.openGiftModal(encounter, option.actionText);
|
|
return true;
|
|
}
|
|
|
|
if (interactionDecision.kind === 'recruit_modal') {
|
|
npcInteractionFlow.openRecruitModal(encounter, option.actionText);
|
|
return true;
|
|
}
|
|
|
|
if (interactionDecision.kind === 'recruit_immediate') {
|
|
void npcInteractionFlow.startRecruitmentSequence(
|
|
encounter,
|
|
option.actionText,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
switch (option.interaction.action) {
|
|
case 'help': {
|
|
setAiError(null);
|
|
setIsLoading(true);
|
|
void (async () => {
|
|
let committed = false;
|
|
|
|
try {
|
|
const reward = await generateNpcHelpReward(encounter, gameState);
|
|
let cooldowns = gameState.playerSkillCooldowns;
|
|
for (
|
|
let index = 0;
|
|
index < (reward.cooldownBonus ?? 0);
|
|
index += 1
|
|
) {
|
|
cooldowns = Object.fromEntries(
|
|
Object.entries(cooldowns).map(([skillId, turns]) => [
|
|
skillId,
|
|
Math.max(0, turns - 1),
|
|
]),
|
|
);
|
|
}
|
|
|
|
let nextState = updateNpcState(
|
|
gameState,
|
|
encounter,
|
|
(currentNpcState) => ({
|
|
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
|
helpUsed: true,
|
|
stanceProfile: applyStoryChoiceToStanceProfile(
|
|
currentNpcState.stanceProfile,
|
|
'npc_help',
|
|
),
|
|
}),
|
|
);
|
|
|
|
nextState = appendStoryEngineCarrierMemory({
|
|
...nextState,
|
|
playerHp: Math.min(
|
|
nextState.playerMaxHp,
|
|
nextState.playerHp + (reward.hp ?? 0),
|
|
),
|
|
playerMana: Math.min(
|
|
nextState.playerMaxMana,
|
|
nextState.playerMana + (reward.mana ?? 0),
|
|
),
|
|
playerSkillCooldowns: cooldowns,
|
|
playerInventory:
|
|
reward.items.length > 0
|
|
? addInventoryItems(
|
|
nextState.playerInventory,
|
|
reward.items.map((item) =>
|
|
cloneInventoryItemForOwner(
|
|
item,
|
|
'player',
|
|
item.quantity,
|
|
),
|
|
),
|
|
)
|
|
: nextState.playerInventory,
|
|
} as GameState, reward.items);
|
|
|
|
await commitNpcChatState(
|
|
nextState,
|
|
playerCharacter,
|
|
encounter,
|
|
buildNpcHelpCommitActionText(encounter, reward),
|
|
buildNpcHelpResultText(encounter, reward),
|
|
option.functionId,
|
|
{
|
|
contextNpcStateOverride:
|
|
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
|
preserveResultTextInHistory: true,
|
|
revealMode: 'immediate_story',
|
|
},
|
|
);
|
|
committed = true;
|
|
} catch (error) {
|
|
console.error('Failed to resolve npc help reward:', error);
|
|
const reward = buildNpcHelpReward(encounter, gameState);
|
|
let cooldowns = gameState.playerSkillCooldowns;
|
|
for (
|
|
let index = 0;
|
|
index < (reward.cooldownBonus ?? 0);
|
|
index += 1
|
|
) {
|
|
cooldowns = Object.fromEntries(
|
|
Object.entries(cooldowns).map(([skillId, turns]) => [
|
|
skillId,
|
|
Math.max(0, turns - 1),
|
|
]),
|
|
);
|
|
}
|
|
|
|
let nextState = updateNpcState(
|
|
gameState,
|
|
encounter,
|
|
(currentNpcState) => ({
|
|
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
|
helpUsed: true,
|
|
stanceProfile: applyStoryChoiceToStanceProfile(
|
|
currentNpcState.stanceProfile,
|
|
'npc_help',
|
|
),
|
|
}),
|
|
);
|
|
|
|
nextState = appendStoryEngineCarrierMemory({
|
|
...nextState,
|
|
playerHp: Math.min(
|
|
nextState.playerMaxHp,
|
|
nextState.playerHp + (reward.hp ?? 0),
|
|
),
|
|
playerMana: Math.min(
|
|
nextState.playerMaxMana,
|
|
nextState.playerMana + (reward.mana ?? 0),
|
|
),
|
|
playerSkillCooldowns: cooldowns,
|
|
playerInventory:
|
|
reward.items.length > 0
|
|
? addInventoryItems(
|
|
nextState.playerInventory,
|
|
reward.items.map((item) =>
|
|
cloneInventoryItemForOwner(
|
|
item,
|
|
'player',
|
|
item.quantity,
|
|
),
|
|
),
|
|
)
|
|
: nextState.playerInventory,
|
|
} as GameState, reward.items);
|
|
|
|
await commitNpcChatState(
|
|
nextState,
|
|
playerCharacter,
|
|
encounter,
|
|
buildNpcHelpCommitActionText(encounter, reward),
|
|
buildNpcHelpResultText(encounter, reward),
|
|
option.functionId,
|
|
{
|
|
contextNpcStateOverride:
|
|
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
|
preserveResultTextInHistory: true,
|
|
revealMode: 'immediate_story',
|
|
},
|
|
);
|
|
committed = true;
|
|
} finally {
|
|
if (!committed) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
})();
|
|
return true;
|
|
}
|
|
case 'chat': {
|
|
const chatOutcome = getChatAffinityOutcome({
|
|
playerCharacter,
|
|
encounter,
|
|
npcState,
|
|
actionText: option.actionText,
|
|
worldType: gameState.worldType,
|
|
customWorldProfile: gameState.customWorldProfile,
|
|
});
|
|
const affinityGain = chatOutcome.affinityGain;
|
|
const attributeSummary = chatOutcome.summary;
|
|
let nextAffinity = npcState.affinity;
|
|
const nextState = updateNpcState(
|
|
gameState,
|
|
encounter,
|
|
(currentNpcState) => {
|
|
nextAffinity = currentNpcState.affinity + affinityGain;
|
|
return {
|
|
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
|
affinity: nextAffinity,
|
|
relationState: buildRelationState(nextAffinity),
|
|
chattedCount: currentNpcState.chattedCount + 1,
|
|
stanceProfile: applyStoryChoiceToStanceProfile(
|
|
currentNpcState.stanceProfile,
|
|
'npc_chat',
|
|
{ affinityGain },
|
|
),
|
|
};
|
|
},
|
|
);
|
|
void commitNpcChatState(
|
|
nextState,
|
|
playerCharacter,
|
|
encounter,
|
|
option.actionText,
|
|
npcState.recruited
|
|
? buildCampCompanionChatResultText(
|
|
encounter,
|
|
affinityGain,
|
|
nextAffinity,
|
|
)
|
|
: buildNpcChatResultText(
|
|
encounter,
|
|
affinityGain,
|
|
nextAffinity,
|
|
attributeSummary,
|
|
),
|
|
option.functionId,
|
|
{
|
|
contextNpcStateOverride: npcState,
|
|
},
|
|
);
|
|
return true;
|
|
}
|
|
case 'quest_accept': {
|
|
const existingQuest = getQuestForIssuer(
|
|
gameState.quests,
|
|
getNpcEncounterKey(encounter),
|
|
);
|
|
if (existingQuest) return true;
|
|
setAiError(null);
|
|
setIsLoading(true);
|
|
void (async () => {
|
|
let committed = false;
|
|
|
|
try {
|
|
const quest =
|
|
(await generateQuestForNpcEncounter({
|
|
state: gameState,
|
|
encounter,
|
|
})) ??
|
|
buildQuestForEncounter({
|
|
issuerNpcId: getNpcEncounterKey(encounter),
|
|
issuerNpcName: encounter.npcName,
|
|
roleText: encounter.context,
|
|
scene: gameState.currentScenePreset,
|
|
worldType: gameState.worldType,
|
|
});
|
|
if (!quest) {
|
|
return;
|
|
}
|
|
|
|
const nextState = incrementRuntimeStats(
|
|
updateNpcState(
|
|
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
|
|
encounter,
|
|
(currentNpcState) => ({
|
|
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
|
stanceProfile: applyStoryChoiceToStanceProfile(
|
|
currentNpcState.stanceProfile,
|
|
'npc_quest_accept',
|
|
),
|
|
}),
|
|
),
|
|
{questsAccepted: 1},
|
|
);
|
|
await commitGeneratedState(
|
|
nextState,
|
|
playerCharacter,
|
|
option.actionText,
|
|
buildQuestAcceptResultText(quest),
|
|
option.functionId,
|
|
);
|
|
committed = true;
|
|
} catch (error) {
|
|
console.error('Failed to accept npc quest:', error);
|
|
const fallbackQuest = buildQuestForEncounter({
|
|
issuerNpcId: getNpcEncounterKey(encounter),
|
|
issuerNpcName: encounter.npcName,
|
|
roleText: encounter.context,
|
|
scene: gameState.currentScenePreset,
|
|
worldType: gameState.worldType,
|
|
});
|
|
if (!fallbackQuest) {
|
|
return;
|
|
}
|
|
|
|
const nextState = incrementRuntimeStats(
|
|
updateNpcState(
|
|
updateQuestLog(gameState, (quests) =>
|
|
acceptQuest(quests, fallbackQuest),
|
|
),
|
|
encounter,
|
|
(currentNpcState) => ({
|
|
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
|
stanceProfile: applyStoryChoiceToStanceProfile(
|
|
currentNpcState.stanceProfile,
|
|
'npc_quest_accept',
|
|
),
|
|
}),
|
|
),
|
|
{questsAccepted: 1},
|
|
);
|
|
await commitGeneratedState(
|
|
nextState,
|
|
playerCharacter,
|
|
option.actionText,
|
|
buildQuestAcceptResultText(fallbackQuest),
|
|
option.functionId,
|
|
);
|
|
committed = true;
|
|
} finally {
|
|
if (!committed) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
})();
|
|
return true;
|
|
}
|
|
case 'quest_turn_in': {
|
|
const questId = option.interaction.questId;
|
|
const quest = questId ? findQuestById(gameState.quests, questId) : null;
|
|
if (!quest || quest.status !== 'completed') return true;
|
|
|
|
const nextState = appendStoryEngineCarrierMemory({
|
|
...updateQuestLog(gameState, (quests) =>
|
|
markQuestTurnedIn(quests, quest.id),
|
|
),
|
|
npcStates: {
|
|
...gameState.npcStates,
|
|
[getNpcEncounterKey(encounter)]: {
|
|
...syncNpcNarrativeState({
|
|
encounter,
|
|
npcState: {
|
|
...npcState,
|
|
...markNpcFirstMeaningfulContactResolved(npcState),
|
|
affinity: npcState.affinity + quest.reward.affinityBonus,
|
|
relationState: buildRelationState(
|
|
npcState.affinity + quest.reward.affinityBonus,
|
|
),
|
|
},
|
|
customWorldProfile: gameState.customWorldProfile,
|
|
storyEngineMemory: gameState.storyEngineMemory,
|
|
}),
|
|
},
|
|
},
|
|
playerCurrency: gameState.playerCurrency + quest.reward.currency,
|
|
playerInventory: addInventoryItems(
|
|
gameState.playerInventory,
|
|
quest.reward.items,
|
|
),
|
|
} as GameState, quest.reward.items);
|
|
|
|
void commitGeneratedState(
|
|
nextState,
|
|
playerCharacter,
|
|
option.actionText,
|
|
buildQuestTurnInResultText(quest),
|
|
option.functionId,
|
|
);
|
|
return true;
|
|
}
|
|
case 'leave': {
|
|
const baseState: GameState = {
|
|
...gameState,
|
|
ambientIdleMode: undefined,
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
sceneHostileNpcs: [],
|
|
playerX: 0,
|
|
playerFacing: 'right' as const,
|
|
animationState: gameState.animationState,
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
};
|
|
const entryState = {
|
|
...baseState,
|
|
...createSceneCallOutEncounter(baseState),
|
|
} as GameState;
|
|
const resolvedState = hasEncounterEntity(entryState)
|
|
? resolveSceneEncounterPreview(entryState)
|
|
: baseState;
|
|
|
|
void commitGeneratedStateWithEncounterEntry(
|
|
entryState,
|
|
resolvedState,
|
|
playerCharacter,
|
|
option.actionText,
|
|
buildNpcLeaveResultText(encounter),
|
|
option.functionId,
|
|
);
|
|
return true;
|
|
}
|
|
case 'fight': {
|
|
const battleMonster = createNpcBattleMonster(encounter, npcState, 'fight', {
|
|
worldType: gameState.worldType,
|
|
customWorldProfile: gameState.customWorldProfile,
|
|
});
|
|
const nextState = {
|
|
...gameState,
|
|
npcStates: {
|
|
...gameState.npcStates,
|
|
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
|
|
npcState,
|
|
),
|
|
},
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
sceneHostileNpcs: [battleMonster],
|
|
playerX: 0,
|
|
playerFacing: 'right' as const,
|
|
animationState: AnimationState.IDLE,
|
|
playerActionMode: 'idle' as const,
|
|
scrollWorld: false,
|
|
inBattle: true,
|
|
currentBattleNpcId: getNpcEncounterKey(encounter),
|
|
currentNpcBattleMode: 'fight' as const,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
};
|
|
void commitGeneratedState(
|
|
nextState,
|
|
playerCharacter,
|
|
option.actionText,
|
|
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
|
|
option.functionId,
|
|
);
|
|
return true;
|
|
}
|
|
case 'spar': {
|
|
const sparPlayerMaxHp = getNpcSparMaxHp(
|
|
playerCharacter,
|
|
gameState.worldType,
|
|
gameState.customWorldProfile,
|
|
);
|
|
const battleMonster = createNpcBattleMonster(encounter, npcState, 'spar', {
|
|
worldType: gameState.worldType,
|
|
customWorldProfile: gameState.customWorldProfile,
|
|
});
|
|
const nextState = {
|
|
...gameState,
|
|
npcStates: {
|
|
...gameState.npcStates,
|
|
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
|
|
npcState,
|
|
),
|
|
},
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
sceneHostileNpcs: [battleMonster],
|
|
playerX: 0,
|
|
playerHp: sparPlayerMaxHp,
|
|
playerMaxHp: sparPlayerMaxHp,
|
|
playerFacing: 'right' as const,
|
|
animationState: AnimationState.IDLE,
|
|
playerActionMode: 'idle' as const,
|
|
scrollWorld: false,
|
|
inBattle: true,
|
|
currentBattleNpcId: getNpcEncounterKey(encounter),
|
|
currentNpcBattleMode: 'spar' as const,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: encounter,
|
|
sparPlayerHpBefore: gameState.playerHp,
|
|
sparPlayerMaxHpBefore: gameState.playerMaxHp,
|
|
sparStoryHistoryBefore: gameState.storyHistory,
|
|
};
|
|
void commitGeneratedState(
|
|
nextState,
|
|
playerCharacter,
|
|
option.actionText,
|
|
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
|
|
option.functionId,
|
|
);
|
|
return true;
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
return {
|
|
enterNpcInteraction,
|
|
handleNpcInteraction,
|
|
finalizeNpcBattleResult,
|
|
};
|
|
}
|