Files
Genarrative/src/hooks/story/choiceActions.ts

547 lines
18 KiB
TypeScript

import type {
Dispatch,
SetStateAction,
} from 'react';
import {
buildEncounterEntryState,
hasEncounterEntity,
} from '../../data/encounterTransition';
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
import { addInventoryItems } from '../../data/npcInteractions';
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { generateNextStep } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import {
AnimationState,
type Character,
type Encounter,
type GameState,
type StoryMoment,
type StoryOption,
} from '../../types';
import type { EscapePlaybackSync } from '../combat/escapeFlow';
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
import type {
CommitGeneratedStateWithEncounterEntry,
GenerateStoryForState,
} from './progressionActions';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
type BuildStoryFromResponse = (
state: GameState,
character: Character,
response: StoryMoment,
availableOptions: StoryOption[] | null,
optionCatalog?: StoryOption[] | null,
) => StoryMoment;
type BuildNpcStory = (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
type BuildStoryContextFromState = (
state: GameState,
extras?: { lastFunctionId?: string | null; observeSignsRequested?: boolean },
) => StoryGenerationContext;
type UpdateQuestLog = (
state: GameState,
updater: (quests: GameState['quests']) => GameState['quests'],
) => GameState;
type IncrementRuntimeStats = (
state: GameState,
increments: RuntimeStatsIncrements,
) => GameState;
function sleep(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function buildReasonedOptionCatalog(options: StoryOption[]) {
const seenFunctionIds = new Set<string>();
return options.filter(option => {
if (seenFunctionIds.has(option.functionId)) {
return false;
}
seenFunctionIds.add(option.functionId);
return true;
});
}
function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'],
): BattleRewardSummary | null {
if (!state.worldType || state.currentBattleNpcId || !state.inBattle || afterSequence.inBattle) {
return null;
}
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
const defeatedHostileNpcs = activeHostileNpcs.filter(hostileNpc =>
!nextHostileNpcs.some(nextHostileNpc => nextHostileNpc.id === hostileNpc.id),
);
if (defeatedHostileNpcs.length === 0) {
return null;
}
const rolledItems = rollHostileNpcLoot(
state,
defeatedHostileNpcs.map(hostileNpc => ({
id: hostileNpc.id,
name: hostileNpc.name,
})),
);
return {
id: `battle-reward-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
defeatedHostileNpcs: defeatedHostileNpcs.map(hostileNpc => ({
id: hostileNpc.id,
name: hostileNpc.name,
})),
items: addInventoryItems([], rolledItems),
};
}
export function createStoryChoiceActions({
gameState,
currentStory,
isLoading,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
setBattleReward,
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState,
buildStoryFromResponse,
buildFallbackStoryForState,
generateStoryForState,
getAvailableOptionsForState,
getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
buildNpcStory,
updateQuestLog,
incrementRuntimeStats,
getCampCompanionTravelScene,
startOpeningAdventure,
enterNpcInteraction,
handleNpcInteraction,
handleTreasureInteraction,
commitGeneratedStateWithEncounterEntry,
finalizeNpcBattleResult,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
fallbackCompanionName,
turnVisualMs,
}: {
gameState: GameState;
currentStory: StoryMoment | null;
isLoading: boolean;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: EscapePlaybackSync,
) => Promise<GameState>;
buildStoryContextFromState: BuildStoryContextFromState;
buildStoryFromResponse: BuildStoryFromResponse;
buildFallbackStoryForState: BuildFallbackStoryForState;
generateStoryForState: GenerateStoryForState;
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
buildNpcStory: BuildNpcStory;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean;
handleTreasureInteraction: (option: StoryOption) => void | Promise<void> | boolean;
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
finalizeNpcBattleResult: (
state: GameState,
character: Character,
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
battleOutcome: GameState['currentNpcBattleOutcome'],
) => { nextState: GameState; resultText: string } | null;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
fallbackCompanionName: string;
turnVisualMs: number;
}) {
const handleCampTravelHome = async (option: StoryOption, character: Character) => {
const targetScene = getCampCompanionTravelScene(gameState, character);
if (!targetScene) {
return;
}
setBattleReward(null);
setAiError(null);
const companionName = isNpcEncounter(gameState.currentEncounter)
? gameState.currentEncounter.npcName
: fallbackCompanionName;
const travelRunState: GameState = {
...gameState,
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.RUN,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: true,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
const travelBaseState: GameState = incrementRuntimeStats({
...gameState,
ambientIdleMode: undefined,
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
}, {
scenesTraveled: 1,
});
const travelPreviewState: GameState = {
...travelBaseState,
...createSceneEncounterPreview(travelBaseState),
};
const resolvedState = hasEncounterEntity(travelPreviewState)
? resolveSceneEncounterPreview(travelPreviewState)
: travelBaseState;
const entryState = buildEncounterEntryState(resolvedState, CALL_OUT_ENTRY_X_METERS);
setIsLoading(true);
setGameState(travelRunState);
await sleep(turnVisualMs);
await commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
character,
option.actionText,
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
option.functionId,
);
return;
};
const handleChoice = async (option: StoryOption) => {
const character = gameState.playerCharacter;
if (!gameState.worldType || !character || isLoading) return;
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
setCurrentStory({
...currentStory,
options: currentStory.deferredOptions,
deferredOptions: undefined,
});
return;
}
if (isCampTravelHomeOption(option)) {
await handleCampTravelHome(option, character);
return;
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isInitialCompanionEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
) {
setAiError(null);
void startOpeningAdventure();
return;
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isRegularNpcEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
) {
setAiError(null);
enterNpcInteraction(gameState.currentEncounter, option.actionText);
return;
}
if (option.interaction?.kind === 'npc') {
setAiError(null);
handleNpcInteraction(option);
return;
}
if (option.interaction?.kind === 'treasure') {
setAiError(null);
handleTreasureInteraction(option);
return;
}
setBattleReward(null);
setAiError(null);
setIsLoading(true);
const baseChoiceState = (
isRegularNpcEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
&& !option.interaction
)
? {
...gameState,
currentEncounter: null,
npcInteractionActive: false,
}
: gameState;
let fallbackState = baseChoiceState;
try {
const history = baseChoiceState.storyHistory;
const resolvedChoice = buildResolvedChoiceState(baseChoiceState, option, character);
const projectedState = resolvedChoice.afterSequence;
const shouldUseLocalNpcVictory = Boolean(
baseChoiceState.currentBattleNpcId &&
resolvedChoice.optionKind === 'battle' &&
(
projectedState.currentNpcBattleOutcome ||
(baseChoiceState.currentNpcBattleMode === 'fight' && !projectedState.inBattle)
),
);
const projectedBattleReward = shouldUseLocalNpcVictory
? null
: buildHostileNpcBattleReward(baseChoiceState, projectedState, getResolvedSceneHostileNpcs);
const projectedStateWithBattleReward = projectedBattleReward
? {
...projectedState,
playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items),
}
: projectedState;
fallbackState = projectedStateWithBattleReward;
const projectedAvailableOptions = getAvailableOptionsForState(
projectedStateWithBattleReward,
character,
);
const responsePromise = shouldUseLocalNpcVictory
? Promise.resolve(null)
: generateNextStep(
gameState.worldType,
character,
getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
history,
option.actionText,
buildStoryContextFromState(projectedStateWithBattleReward, {
lastFunctionId: option.functionId,
observeSignsRequested: option.functionId === 'idle_observe_signs',
}),
projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined,
);
const responseSettledPromise = responsePromise.then(() => undefined, () => undefined);
const playbackSync: EscapePlaybackSync | undefined = resolvedChoice.optionKind === 'escape'
? { waitForStoryResponse: responseSettledPromise }
: undefined;
const actionPromise = playResolvedChoice(
baseChoiceState,
option,
character,
resolvedChoice,
playbackSync,
);
const [actionResult, responseResult] = await Promise.allSettled([actionPromise, responsePromise]);
if (actionResult.status === 'rejected') {
throw actionResult.reason;
}
let afterSequence = shouldUseLocalNpcVictory ? resolvedChoice.afterSequence : actionResult.value;
if (projectedBattleReward) {
afterSequence = {
...afterSequence,
playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items),
};
}
fallbackState = afterSequence;
if (shouldUseLocalNpcVictory) {
const victory = finalizeNpcBattleResult(
afterSequence,
character,
baseChoiceState.currentNpcBattleMode!,
afterSequence.currentNpcBattleOutcome,
);
if (victory) {
const historyBase = baseChoiceState.currentNpcBattleMode === 'spar'
? (afterSequence.sparStoryHistoryBefore ?? [])
: baseChoiceState.storyHistory;
const nextHistory = [
...historyBase,
createHistoryMoment(victory.resultText, 'result'),
];
const nextState = {
...victory.nextState,
storyHistory: nextHistory,
};
const postBattleOptionCatalog = baseChoiceState.currentNpcBattleMode === 'spar' && nextState.currentEncounter
? buildReasonedOptionCatalog(
buildNpcStory(
nextState,
character,
nextState.currentEncounter,
).options,
)
: null;
fallbackState = nextState;
setGameState(nextState);
try {
const nextStory = await generateStoryForState({
state: nextState,
character,
history: nextHistory,
choice: option.actionText,
lastFunctionId: option.functionId,
optionCatalog: postBattleOptionCatalog,
});
setCurrentStory(nextStory);
} catch (storyError) {
console.error('Failed to continue npc battle resolution story:', storyError);
setAiError(storyError instanceof Error ? storyError.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText));
}
return;
}
}
if (responseResult.status === 'rejected') {
throw responseResult.reason;
}
const response = responseResult.value!;
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId
? []
: getResolvedSceneHostileNpcs(baseChoiceState)
.map(hostileNpc => hostileNpc.id)
.filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId));
const nextHistory = [
...baseChoiceState.storyHistory,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(response.storyText, 'result', response.options),
];
const nextState = incrementRuntimeStats({
...updateQuestLog(
afterSequence,
quests => applyQuestProgressFromHostileNpcDefeat(
quests,
baseChoiceState.currentScenePreset?.id ?? null,
defeatedHostileNpcIds,
),
),
lastObserveSignsSceneId: option.functionId === 'idle_observe_signs'
? (afterSequence.currentScenePreset?.id ?? null)
: afterSequence.lastObserveSignsSceneId ?? null,
lastObserveSignsReport: option.functionId === 'idle_observe_signs'
? response.storyText
: afterSequence.lastObserveSignsReport ?? null,
storyHistory: nextHistory,
}, {
hostileNpcsDefeated: defeatedHostileNpcIds.length,
});
setGameState(nextState);
if (projectedBattleReward) {
setBattleReward(projectedBattleReward);
}
setCurrentStory(
buildStoryFromResponse(
nextState,
character,
{
text: response.storyText,
options: response.options,
},
projectedAvailableOptions,
),
);
} catch (error) {
console.error('Failed to get next step:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(fallbackState, character));
} finally {
setIsLoading(false);
}
};
return {
handleChoice,
};
}