This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -1,11 +1,7 @@
import { addInventoryItems } from '../../data/npcInteractions';
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
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 { AnimationState } from '../../types';
import type {
Character,
Encounter,
@@ -13,19 +9,7 @@ import type {
StoryMoment,
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,
} from './storyChoiceRuntime';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<
@@ -84,78 +68,10 @@ 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;
}
function isLocalNpcBattleVictoryOutcome(
battleOutcome: GameState['currentNpcBattleOutcome'],
) {
function isBackendOwnedCombatChoice(option: StoryOption) {
return (
battleOutcome === 'fight_victory' || battleOutcome === 'spar_complete'
option.functionId.startsWith('battle_') ||
option.functionId === 'inventory_use'
);
}
@@ -179,7 +95,6 @@ export async function runLocalStoryChoiceContinuation(params: {
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: EscapePlaybackSync,
) => Promise<GameState>;
buildStoryContextFromState: BuildStoryContextFromState;
buildStoryFromResponse: BuildStoryFromResponse;
@@ -234,53 +149,32 @@ export async function runLocalStoryChoiceContinuation(params: {
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 shouldUseDeterministicCombatFlow =
resolvedChoice.optionKind === 'battle' ||
resolvedChoice.optionKind === 'escape';
const shouldUseLocalNpcVictory = Boolean(
baseChoiceState.currentBattleNpcId &&
resolvedChoice.optionKind === 'battle' &&
isLocalNpcBattleVictoryOutcome(projectedState.currentNpcBattleOutcome),
);
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;
const projectedStateWithBattleReward = 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 combatResolutionContextText = null;
const historyForStoryGeneration = combatResolutionContextText
? [
...history,
@@ -289,38 +183,27 @@ export async function runLocalStoryChoiceContinuation(params: {
]
: history;
const responsePromise = shouldUseLocalNpcVictory || shouldUseDeterministicCombatFlow
? 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 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,
}),
projectedAvailableOptions
? { availableOptions: projectedAvailableOptions }
: undefined,
);
const playbackSync: EscapePlaybackSync | undefined =
resolvedChoice.optionKind === 'escape' && !shouldUseDeterministicCombatFlow
? { waitForStoryResponse: responseSettledPromise }
: undefined;
const actionPromise = params.playResolvedChoice(
baseChoiceState,
params.option,
params.character,
resolvedChoice,
playbackSync,
);
const [actionResult, responseResult] = await Promise.allSettled([
actionPromise,
@@ -331,186 +214,14 @@ export async function runLocalStoryChoiceContinuation(params: {
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,
);
}
const afterSequence = actionResult.value;
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 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;
const revivedDeferredOptions =
params.buildFallbackStoryForState(revivedState, params.character).options;
params.setGameState(revivedState);
params.setCurrentStory(
buildDeathStory(revivedState, revivedDeferredOptions),
);
return;
}
if (
resolvedChoice.optionKind === 'battle' &&
(
nextState.currentNpcBattleOutcome === 'fight_victory' ||
nextState.currentNpcBattleOutcome === 'spar_complete' ||
(!baseChoiceState.currentBattleNpcId && !nextState.inBattle)
)
) {
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;
}
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,
@@ -524,13 +235,7 @@ export async function runLocalStoryChoiceContinuation(params: {
const nextState = params.incrementRuntimeStats(
{
...params.updateQuestLog(afterSequence, (quests) =>
applyQuestProgressFromHostileNpcDefeat(
quests,
baseChoiceState.currentScenePreset?.id ?? null,
defeatedHostileNpcIds,
),
),
...afterSequence,
lastObserveSignsSceneId:
params.option.functionId === 'idle_observe_signs'
? (afterSequence.currentScenePreset?.id ?? null)
@@ -541,16 +246,11 @@ export async function runLocalStoryChoiceContinuation(params: {
: afterSequence.lastObserveSignsReport ?? null,
storyHistory: nextHistory,
},
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
{},
);
const recoveredState = applyStoryReasoningRecovery(nextState);
params.setGameState(recoveredState);
if (projectedBattleReward) {
params.setBattleReward(projectedBattleReward);
}
params.setCurrentStory(
params.buildStoryFromResponse(