837
src/hooks/story/npcEncounterActions.ts
Normal file
837
src/hooks/story/npcEncounterActions.ts
Normal file
@@ -0,0 +1,837 @@
|
||||
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,
|
||||
buildNpcChatResultText,
|
||||
buildNpcHelpResultText,
|
||||
buildNpcHelpReward,
|
||||
buildNpcLeaveResultText,
|
||||
buildNpcSparResultText,
|
||||
createNpcBattleMonster,
|
||||
getChatAffinityOutcome,
|
||||
getNpcLootItems,
|
||||
getNpcSparMaxHp,
|
||||
markNpcFirstMeaningfulContactResolved,
|
||||
NPC_SPAR_AFFINITY_GAIN,
|
||||
removeInventoryItem,
|
||||
} from '../../data/npcInteractions';
|
||||
import {
|
||||
acceptQuest,
|
||||
applyQuestProgressFromHostileNpcDefeat,
|
||||
applyQuestProgressFromSpar,
|
||||
buildQuestAcceptResultText,
|
||||
buildQuestForEncounter,
|
||||
buildQuestTurnInResultText,
|
||||
findQuestById,
|
||||
getQuestForIssuer,
|
||||
markQuestTurnedIn,
|
||||
} from '../../data/questFlow';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import {
|
||||
createSceneCallOutEncounter,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
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 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
|
||||
? 'You also feel a little more confident about how you will work together next.'
|
||||
: 'You at least realign your rhythm for what comes next.';
|
||||
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,
|
||||
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;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneMonsters'];
|
||||
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;
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
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(
|
||||
NPC_SPAR_AFFINITY_GAIN,
|
||||
nextAffinity,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const lootItems = getNpcLootItems(npcState, character).map((item) =>
|
||||
cloneInventoryItemForOwner(item, 'player'),
|
||||
);
|
||||
const defeatedHostileNpcIds = (
|
||||
state.sceneHostileNpcs ?? state.sceneMonsters
|
||||
).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 = 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,
|
||||
},
|
||||
);
|
||||
|
||||
const lootText =
|
||||
lootItems.length > 0
|
||||
? lootItems.map((item) => item.name).join(', ')
|
||||
: '无战利品';
|
||||
return {
|
||||
nextState,
|
||||
resultText: `胜利奖励:${lootText}。`,
|
||||
};
|
||||
};
|
||||
|
||||
const commitNpcChatState = async (
|
||||
nextState: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
contextNpcStateOverride?: GameState['npcStates'][string] | null,
|
||||
) => {
|
||||
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),
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(provisionalState, {
|
||||
lastFunctionId,
|
||||
...provisionalOpeningCampContext,
|
||||
encounterNpcStateOverride: contextNpcStateOverride,
|
||||
}),
|
||||
actionText,
|
||||
resultText,
|
||||
{
|
||||
onUpdate: (text) => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
);
|
||||
streamedTargetText = dialogueText;
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
|
||||
const finalHistory = appendHistory(
|
||||
gameState,
|
||||
actionText,
|
||||
dialogueText || resultText,
|
||||
);
|
||||
const finalState = {
|
||||
...nextState,
|
||||
storyHistory: finalHistory,
|
||||
};
|
||||
const availableOptions = getAvailableOptionsForState(
|
||||
finalState,
|
||||
character,
|
||||
);
|
||||
const finalOpeningCampContext = buildOpeningCampChatContext(
|
||||
finalState,
|
||||
character,
|
||||
encounter,
|
||||
);
|
||||
setGameState(finalState);
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
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 : 'NPC 对话 AI 不可用。',
|
||||
);
|
||||
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) => {
|
||||
if (!gameState.playerCharacter) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.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) => {
|
||||
if (
|
||||
!gameState.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': {
|
||||
const reward = buildNpcHelpReward(encounter);
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
nextState = {
|
||||
...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.item
|
||||
? addInventoryItems(nextState.playerInventory, [
|
||||
cloneInventoryItemForOwner(reward.item, 'player'),
|
||||
])
|
||||
: nextState.playerInventory,
|
||||
};
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildNpcHelpResultText(encounter, reward),
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'chat': {
|
||||
const chatOutcome = getChatAffinityOutcome({
|
||||
playerCharacter: gameState.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,
|
||||
};
|
||||
},
|
||||
);
|
||||
void commitNpcChatState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
encounter,
|
||||
option.actionText,
|
||||
npcState.recruited
|
||||
? buildCampCompanionChatResultText(
|
||||
encounter,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
)
|
||||
: buildNpcChatResultText(
|
||||
encounter,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
attributeSummary,
|
||||
),
|
||||
option.functionId,
|
||||
npcState,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'quest_accept': {
|
||||
const existingQuest = getQuestForIssuer(
|
||||
gameState.quests,
|
||||
getNpcEncounterKey(encounter),
|
||||
);
|
||||
if (existingQuest) return true;
|
||||
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: getNpcEncounterKey(encounter),
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: gameState.currentScenePreset,
|
||||
worldType: gameState.worldType,
|
||||
});
|
||||
if (!quest) return true;
|
||||
|
||||
const nextState = incrementRuntimeStats(
|
||||
updateNpcState(
|
||||
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
|
||||
encounter,
|
||||
(currentNpcState) =>
|
||||
markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
),
|
||||
{ questsAccepted: 1 },
|
||||
);
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(quest),
|
||||
option.functionId,
|
||||
);
|
||||
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 = {
|
||||
...updateQuestLog(gameState, (quests) =>
|
||||
markQuestTurnedIn(quests, quest.id),
|
||||
),
|
||||
npcStates: {
|
||||
...gameState.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: {
|
||||
...npcState,
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: npcState.affinity + quest.reward.affinityBonus,
|
||||
relationState: buildRelationState(
|
||||
npcState.affinity + quest.reward.affinityBonus,
|
||||
),
|
||||
},
|
||||
},
|
||||
playerCurrency: gameState.playerCurrency + quest.reward.currency,
|
||||
playerInventory: addInventoryItems(
|
||||
gameState.playerInventory,
|
||||
quest.reward.items,
|
||||
),
|
||||
};
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.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,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildNpcLeaveResultText(encounter),
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'fight': {
|
||||
const nextState = {
|
||||
...gameState,
|
||||
npcStates: {
|
||||
...gameState.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
|
||||
npcState,
|
||||
),
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
createNpcBattleMonster(encounter, npcState, 'fight'),
|
||||
],
|
||||
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,
|
||||
gameState.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(gameState.playerCharacter);
|
||||
const nextState = {
|
||||
...gameState,
|
||||
npcStates: {
|
||||
...gameState.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
|
||||
npcState,
|
||||
),
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
createNpcBattleMonster(encounter, npcState, 'spar'),
|
||||
],
|
||||
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,
|
||||
gameState.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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user