This commit is contained in:
430
src/hooks/rpg-runtime-story/storyChoiceContinuation.ts
Normal file
430
src/hooks/rpg-runtime-story/storyChoiceContinuation.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
} from './storyChoiceRuntime';
|
||||
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 HandleNpcBattleConversationContinuation = (params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
|
||||
}) => boolean;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type UpdateQuestLog = (
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) => GameState;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
export async function runLocalStoryChoiceContinuation(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
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: (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
}) {
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(true);
|
||||
|
||||
const baseChoiceState =
|
||||
params.isRegularNpcEncounter(params.gameState.currentEncounter) &&
|
||||
!params.gameState.npcInteractionActive &&
|
||||
!params.option.interaction
|
||||
? {
|
||||
...params.gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
}
|
||||
: params.gameState;
|
||||
|
||||
let fallbackState = baseChoiceState;
|
||||
|
||||
try {
|
||||
const history = baseChoiceState.storyHistory;
|
||||
const resolvedChoice = params.buildResolvedChoiceState(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
);
|
||||
const projectedState = resolvedChoice.afterSequence;
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(projectedState.currentNpcBattleOutcome ||
|
||||
(baseChoiceState.currentNpcBattleMode === 'fight' &&
|
||||
!projectedState.inBattle)),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
: await buildHostileNpcBattleReward(
|
||||
baseChoiceState,
|
||||
projectedState,
|
||||
resolvedChoice.optionKind,
|
||||
params.getResolvedSceneHostileNpcs,
|
||||
);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...projectedState,
|
||||
playerInventory: addInventoryItems(
|
||||
projectedState.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
)
|
||||
: projectedState;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = params.getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
params.character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(combatResolutionContextText, 'result'),
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory
|
||||
? Promise.resolve(null)
|
||||
: generateNextStep(
|
||||
params.gameState.worldType!,
|
||||
params.character,
|
||||
params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
params.option.actionText,
|
||||
params.buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: params.option.functionId,
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
: undefined,
|
||||
);
|
||||
const responseSettledPromise = responsePromise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const playbackSync: EscapePlaybackSync | undefined =
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? { waitForStoryResponse: responseSettledPromise }
|
||||
: undefined;
|
||||
const actionPromise = params.playResolvedChoice(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.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 = appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...afterSequence,
|
||||
playerInventory: addInventoryItems(
|
||||
afterSequence.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
);
|
||||
}
|
||||
fallbackState = afterSequence;
|
||||
|
||||
if (shouldUseLocalNpcVictory) {
|
||||
const victory = params.finalizeNpcBattleResult(
|
||||
afterSequence,
|
||||
params.character,
|
||||
baseChoiceState.currentNpcBattleMode!,
|
||||
afterSequence.currentNpcBattleOutcome,
|
||||
);
|
||||
if (victory) {
|
||||
const historyBase =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar'
|
||||
? (afterSequence.sparStoryHistoryBefore ?? [])
|
||||
: baseChoiceState.storyHistory;
|
||||
const nextHistory = [
|
||||
...historyBase,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(victory.resultText, 'result'),
|
||||
];
|
||||
const nextState = {
|
||||
...victory.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
const postBattleOptionCatalog =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar' &&
|
||||
nextState.currentEncounter
|
||||
? buildReasonedOptionCatalog(
|
||||
params.buildNpcStory(
|
||||
nextState,
|
||||
params.character,
|
||||
nextState.currentEncounter,
|
||||
).options,
|
||||
)
|
||||
: null;
|
||||
fallbackState = nextState;
|
||||
params.setGameState(nextState);
|
||||
if (
|
||||
nextState.currentEncounter &&
|
||||
params.handleNpcBattleConversationContinuation({
|
||||
nextState,
|
||||
encounter: nextState.currentEncounter,
|
||||
character: params.character,
|
||||
actionText: params.option.actionText,
|
||||
resultText: victory.resultText,
|
||||
battleMode: baseChoiceState.currentNpcBattleMode!,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nextStory = await params.generateStoryForState({
|
||||
state: nextState,
|
||||
character: params.character,
|
||||
history: nextHistory,
|
||||
choice: params.option.actionText,
|
||||
lastFunctionId: params.option.functionId,
|
||||
optionCatalog: postBattleOptionCatalog,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error(
|
||||
'Failed to continue npc battle resolution story:',
|
||||
storyError,
|
||||
);
|
||||
params.setAiError(
|
||||
storyError instanceof Error
|
||||
? storyError.message
|
||||
: '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(
|
||||
nextState,
|
||||
params.character,
|
||||
victory.resultText,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (responseResult.status === 'rejected') {
|
||||
throw responseResult.reason;
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds =
|
||||
baseChoiceState.currentBattleNpcId ||
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? []
|
||||
: params
|
||||
.getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map((hostileNpc) => hostileNpc.id)
|
||||
.filter(
|
||||
(hostileNpcId) =>
|
||||
!params
|
||||
.getResolvedSceneHostileNpcs(afterSequence)
|
||||
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
|
||||
);
|
||||
const nextHistory = combatResolutionContextText
|
||||
? [
|
||||
...historyForStoryGeneration,
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
]
|
||||
: [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
];
|
||||
|
||||
const nextState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.updateQuestLog(afterSequence, (quests) =>
|
||||
applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
lastObserveSignsSceneId:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
: afterSequence.lastObserveSignsSceneId ?? null,
|
||||
lastObserveSignsReport:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? response.storyText
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
params.setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
params.setCurrentStory(
|
||||
params.buildStoryFromResponse(
|
||||
recoveredState,
|
||||
params.character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
projectedAvailableOptions,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get next step:', error);
|
||||
params.setAiError(
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(fallbackState, params.character),
|
||||
);
|
||||
} finally {
|
||||
params.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user