283 lines
8.5 KiB
TypeScript
283 lines
8.5 KiB
TypeScript
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||
import { generateNextStep } from '../../services/aiService';
|
||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||
import { createHistoryMoment } from '../../services/storyHistory';
|
||
import type {
|
||
Character,
|
||
Encounter,
|
||
GameState,
|
||
StoryMoment,
|
||
StoryOption,
|
||
} from '../../types';
|
||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||
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;
|
||
currentStory?: StoryMoment | null;
|
||
},
|
||
) => StoryGenerationContext;
|
||
|
||
type UpdateQuestLog = (
|
||
state: GameState,
|
||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||
) => GameState;
|
||
|
||
type IncrementRuntimeStats = (
|
||
state: GameState,
|
||
increments: RuntimeStatsIncrements,
|
||
) => GameState;
|
||
|
||
function isBackendOwnedCombatChoice(option: StoryOption) {
|
||
return (
|
||
option.functionId.startsWith('battle_') ||
|
||
option.functionId === 'inventory_use'
|
||
);
|
||
}
|
||
|
||
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,
|
||
) => 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 {
|
||
if (isBackendOwnedCombatChoice(params.option)) {
|
||
throw new Error(
|
||
`战斗与物品动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`,
|
||
);
|
||
}
|
||
|
||
const history = baseChoiceState.storyHistory;
|
||
const resolvedChoice = params.buildResolvedChoiceState(
|
||
baseChoiceState,
|
||
params.option,
|
||
params.character,
|
||
);
|
||
if (
|
||
resolvedChoice.optionKind === 'battle' ||
|
||
resolvedChoice.optionKind === 'escape'
|
||
) {
|
||
throw new Error(
|
||
`战斗与逃脱动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`,
|
||
);
|
||
}
|
||
|
||
const projectedState = resolvedChoice.afterSequence;
|
||
const projectedStateWithBattleReward = projectedState;
|
||
fallbackState = projectedStateWithBattleReward;
|
||
const projectedAvailableOptions = params.getAvailableOptionsForState(
|
||
projectedStateWithBattleReward,
|
||
params.character,
|
||
);
|
||
const combatResolutionContextText = null;
|
||
const historyForStoryGeneration = combatResolutionContextText
|
||
? [
|
||
...history,
|
||
createHistoryMoment(params.option.actionText, 'action'),
|
||
createHistoryMoment(combatResolutionContextText, 'result'),
|
||
]
|
||
: history;
|
||
|
||
const responsePromise = 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,
|
||
currentStory: params.currentStory,
|
||
}),
|
||
projectedAvailableOptions
|
||
? { availableOptions: projectedAvailableOptions }
|
||
: undefined,
|
||
);
|
||
const actionPromise = params.playResolvedChoice(
|
||
baseChoiceState,
|
||
params.option,
|
||
params.character,
|
||
resolvedChoice,
|
||
);
|
||
const [actionResult, responseResult] = await Promise.allSettled([
|
||
actionPromise,
|
||
responsePromise,
|
||
]);
|
||
|
||
if (actionResult.status === 'rejected') {
|
||
throw actionResult.reason;
|
||
}
|
||
|
||
const afterSequence = actionResult.value;
|
||
fallbackState = afterSequence;
|
||
|
||
if (responseResult.status === 'rejected') {
|
||
throw responseResult.reason;
|
||
}
|
||
|
||
const response = responseResult.value!;
|
||
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(
|
||
{
|
||
...afterSequence,
|
||
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,
|
||
},
|
||
{},
|
||
);
|
||
|
||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||
params.setGameState(recoveredState);
|
||
|
||
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);
|
||
}
|
||
}
|