547 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|