1
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user