This commit is contained in:
2026-04-26 20:50:58 +08:00
parent a3a9bfa194
commit 67161bd6d1
142 changed files with 3349 additions and 10674 deletions

View File

@@ -5,6 +5,7 @@ import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import { AnimationState } from '../../types';
import type {
Character,
Encounter,
@@ -13,11 +14,17 @@ import type {
StoryOption,
} from '../../types';
import type { EscapePlaybackSync } from '../combat/escapeFlow';
import type { BattlePlan } from '../combat/battlePlan';
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
import {
buildDeathStory,
buildPostBattleVictoryState,
buildPostBattleVictoryStory,
buildRevivedFirstSceneState,
} from './postBattleFlow';
import {
buildCombatResolutionContextText,
buildHostileNpcBattleReward,
buildReasonedOptionCatalog,
} from './storyChoiceRuntime';
import type { BattleRewardSummary } from './uiTypes';
@@ -77,6 +84,73 @@ type IncrementRuntimeStats = (
increments: RuntimeStatsIncrements,
) => GameState;
const PLAYER_REVIVE_DELAY_MS = 3000;
function sleep(ms: number) {
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
}
function buildLocalCombatResultText(params: {
option: StoryOption;
battlePlan: BattlePlan | null;
afterSequence: GameState;
combatResolutionContextText: string | null;
}) {
if (params.combatResolutionContextText) {
return params.combatResolutionContextText;
}
const turns = params.battlePlan?.turns ?? [];
const dealtDamage = turns
.filter((turn) => turn.actor === 'player' || turn.actor === 'companion')
.reduce((sum, turn) => sum + turn.damage, 0);
const takenDamage = turns
.filter((turn) => turn.actor === 'monster' && turn.target === 'player')
.reduce((sum, turn) => sum + turn.damage, 0);
if (params.afterSequence.playerHp <= 0) {
return takenDamage > 0
? `你承受了${takenDamage}点伤害,气血归零。`
: '你在战斗中倒下,气血归零。';
}
const details = [
dealtDamage > 0 ? `造成${dealtDamage}点伤害` : null,
takenDamage > 0 ? `承受${takenDamage}点伤害` : null,
].filter(Boolean);
return details.length > 0
? `${params.option.actionText}完成,${details.join('')}`
: `${params.option.actionText}完成,双方仍在对峙。`;
}
function buildDeterministicStoryForState(params: {
state: GameState;
character: Character;
resultText: string;
availableOptions: StoryOption[] | null;
buildFallbackStoryForState: BuildFallbackStoryForState;
}) {
if (params.availableOptions?.length) {
return {
text: params.resultText,
options: params.availableOptions,
streaming: false,
} satisfies StoryMoment;
}
const fallbackStory = params.buildFallbackStoryForState(
params.state,
params.character,
params.resultText,
);
return {
...fallbackStory,
text: params.resultText,
streaming: false,
} satisfies StoryMoment;
}
export async function runLocalStoryChoiceContinuation(params: {
gameState: GameState;
currentStory: StoryMoment | null;
@@ -159,6 +233,9 @@ export async function runLocalStoryChoiceContinuation(params: {
params.character,
);
const projectedState = resolvedChoice.afterSequence;
const shouldUseDeterministicCombatFlow =
resolvedChoice.optionKind === 'battle' ||
resolvedChoice.optionKind === 'escape';
const shouldUseLocalNpcVictory = Boolean(
baseChoiceState.currentBattleNpcId &&
resolvedChoice.optionKind === 'battle' &&
@@ -206,7 +283,7 @@ export async function runLocalStoryChoiceContinuation(params: {
]
: history;
const responsePromise = shouldUseLocalNpcVictory
const responsePromise = shouldUseLocalNpcVictory || shouldUseDeterministicCombatFlow
? Promise.resolve(null)
: generateNextStep(
params.gameState.worldType!,
@@ -229,7 +306,7 @@ export async function runLocalStoryChoiceContinuation(params: {
() => undefined,
);
const playbackSync: EscapePlaybackSync | undefined =
resolvedChoice.optionKind === 'escape'
resolvedChoice.optionKind === 'escape' && !shouldUseDeterministicCombatFlow
? { waitForStoryResponse: responseSettledPromise }
: undefined;
const actionPromise = params.playResolvedChoice(
@@ -286,66 +363,122 @@ export async function runLocalStoryChoiceContinuation(params: {
...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,
),
);
}
const postBattleState = buildPostBattleVictoryState(nextState);
const postBattle = buildPostBattleVictoryStory(
postBattleState,
victory.resultText,
params.getAvailableOptionsForState(postBattleState, params.character) ?? [],
);
fallbackState = postBattle.state;
params.setGameState(postBattle.state);
params.setCurrentStory(postBattle.story);
return;
}
}
if (shouldUseDeterministicCombatFlow) {
const defeatedHostileNpcIds =
resolvedChoice.optionKind === 'escape' || baseChoiceState.currentBattleNpcId
? []
: params
.getResolvedSceneHostileNpcs(baseChoiceState)
.map((hostileNpc) => hostileNpc.id)
.filter(
(hostileNpcId) =>
!params
.getResolvedSceneHostileNpcs(afterSequence)
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
);
const resultText = buildLocalCombatResultText({
option: params.option,
battlePlan: resolvedChoice.battlePlan,
afterSequence,
combatResolutionContextText,
});
const nextHistory = [
...baseChoiceState.storyHistory,
createHistoryMoment(params.option.actionText, 'action'),
createHistoryMoment(resultText, 'result'),
];
const nextState = params.incrementRuntimeStats(
{
...params.updateQuestLog(afterSequence, (quests) =>
applyQuestProgressFromHostileNpcDefeat(
quests,
baseChoiceState.currentScenePreset?.id ?? null,
defeatedHostileNpcIds,
),
),
storyHistory: nextHistory,
},
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
);
if (projectedBattleReward) {
params.setBattleReward(projectedBattleReward);
}
if (nextState.playerHp <= 0) {
const deathState = {
...nextState,
animationState: AnimationState.DIE,
playerActionMode: 'idle' as const,
inBattle: false,
activeCombatEffects: [],
scrollWorld: false,
};
fallbackState = deathState;
params.setGameState(deathState);
await sleep(PLAYER_REVIVE_DELAY_MS);
const revivedState = {
...buildRevivedFirstSceneState(deathState),
storyHistory: [
...nextHistory,
createHistoryMoment('你在第一个场景第一幕重新醒来。', 'result'),
],
};
fallbackState = revivedState;
params.setGameState(revivedState);
params.setCurrentStory(buildDeathStory(revivedState));
return;
}
if (
resolvedChoice.optionKind === 'battle' &&
(!nextState.inBattle || nextState.currentNpcBattleOutcome === 'spar_complete')
) {
const postBattleState = buildPostBattleVictoryState(nextState);
const postBattle = buildPostBattleVictoryStory(
postBattleState,
resultText,
params.getAvailableOptionsForState(postBattleState, params.character) ?? [],
);
fallbackState = postBattle.state;
params.setGameState(postBattle.state);
params.setCurrentStory(postBattle.story);
return;
}
const availableOptions = params.getAvailableOptionsForState(
nextState,
params.character,
);
fallbackState = nextState;
params.setGameState(nextState);
params.setCurrentStory(
buildDeterministicStoryForState({
state: nextState,
character: params.character,
resultText,
availableOptions,
buildFallbackStoryForState: params.buildFallbackStoryForState,
}),
);
return;
}
if (responseResult.status === 'rejected') {
throw responseResult.reason;
}