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

@@ -814,9 +814,11 @@ export function buildBattlePlan({
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: simulatedState.currentNpcBattleOutcome === 'spar_complete'
? false
: simulatedState.sceneHostileNpcs.length > 0,
inBattle:
simulatedState.currentNpcBattleOutcome === 'spar_complete' ||
simulatedState.playerHp <= 0
? false
: simulatedState.sceneHostileNpcs.length > 0,
sceneHostileNpcs: resetCombatPresentation(simulatedState.sceneHostileNpcs, simulatedState.playerX),
},
};

View File

@@ -523,7 +523,7 @@ describe('createStoryChoiceActions', () => {
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
});
it('reopens npc chat instead of running generic follow-up after local npc victory', async () => {
it('uses deterministic continue option after local npc victory', async () => {
const encounter: Encounter = {
id: 'npc-opponent',
kind: 'npc',
@@ -611,31 +611,31 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option);
expect(handleNpcBattleConversationContinuation).toHaveBeenCalledWith(
expect(handleNpcBattleConversationContinuation).not.toHaveBeenCalled();
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
nextState: expect.objectContaining({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
}),
encounter,
actionText: '挥刀抢攻',
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
battleMode: 'fight',
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
inBattle: false,
}),
);
expect(generateStoryForState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalledWith(
createFallbackStory('战后续写'),
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '山道客已经败下阵来。胜利奖励:无战利品。',
options: [
expect.objectContaining({
functionId: 'story_continue_adventure',
actionText: '继续前进',
}),
],
}),
);
});
it('injects an escape resolution into the immediate story context before ai continuation', async () => {
it('settles escape locally without ai continuation', async () => {
const mockedGenerateNextStep = vi.mocked(generateNextStep);
mockedGenerateNextStep.mockResolvedValue({
storyText: '你落到山道外侧,呼吸总算稳了下来。',
options: [],
});
const state = {
...createBaseState(),
@@ -667,6 +667,7 @@ describe('createStoryChoiceActions', () => {
playerX: -1.2,
};
const setBattleReward = vi.fn();
const setCurrentStory = vi.fn();
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
const buildStoryContextFromState = vi.fn(() => ({
playerHp: 100,
@@ -685,7 +686,7 @@ describe('createStoryChoiceActions', () => {
currentStory: createFallbackStory(),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward,
@@ -723,20 +724,11 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option);
expect(mockedGenerateNextStep).toHaveBeenCalledTimes(1);
const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
]);
expect(buildStoryContextFromState).toHaveBeenCalledWith(
expect(mockedGenerateNextStep).not.toHaveBeenCalled();
expect(buildStoryContextFromState).not.toHaveBeenCalled();
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
inBattle: false,
sceneHostileNpcs: [],
}),
expect.objectContaining({
lastFunctionId: 'battle_escape_breakout',
recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
text: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
}),
);
expect(setBattleReward).toHaveBeenCalledTimes(1);

View File

@@ -0,0 +1,213 @@
import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets';
import {
advanceSceneActRuntimeState,
buildInitialSceneActRuntimeState,
getSceneConnectionDirectionText,
resolveSceneActProgression,
} from '../../services/customWorldSceneActRuntime';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
type GameState,
type ScenePresetInfo,
type StoryMoment,
type StoryOption,
} from '../../types';
const CONTINUE_ADVENTURE_FUNCTION_ID = 'story_continue_adventure';
const TRAVEL_NEXT_SCENE_FUNCTION_ID = 'idle_travel_next_scene';
function buildBaseFlowVisuals(): StoryOption['visuals'] {
return {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 0.9,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
};
}
function buildContinueOption(): StoryOption {
return {
functionId: CONTINUE_ADVENTURE_FUNCTION_ID,
actionText: '继续前进',
text: '继续前进',
priority: 1,
visuals: buildBaseFlowVisuals(),
};
}
function buildTravelOption(scene: ScenePresetInfo, actionText: string): StoryOption {
return {
functionId: TRAVEL_NEXT_SCENE_FUNCTION_ID,
actionText,
text: actionText,
priority: 2,
visuals: buildBaseFlowVisuals(),
runtimePayload: {
targetSceneId: scene.id,
},
};
}
export function buildSceneTravelOptions(state: GameState): StoryOption[] {
if (!state.worldType) {
return [];
}
const currentSceneId = state.currentScenePreset?.id ?? null;
const currentScene = currentSceneId
? getScenePresetById(state.worldType, currentSceneId)
: null;
const connectionOptions =
currentScene?.connections
?.map((connection) => {
const scene = getScenePresetById(state.worldType!, connection.sceneId);
if (!scene || scene.id === currentSceneId) {
return null;
}
const directionText = getSceneConnectionDirectionText(connection.relativePosition);
return buildTravelOption(scene, `${directionText},前往${scene.name}`);
})
.filter((option): option is StoryOption => Boolean(option)) ?? [];
if (connectionOptions.length > 0) {
return connectionOptions;
}
return getScenePresetsByWorld(state.worldType)
.filter((scene) => scene.id !== currentSceneId)
.slice(0, 4)
.map((scene) => buildTravelOption(scene, `前往${scene.name}`));
}
export function buildPostBattleVictoryState(state: GameState) {
return {
...state,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
} satisfies GameState;
}
export function buildPostBattleVictoryStory(
state: GameState,
resultText: string,
fallbackOptions: StoryOption[] = [],
): { state: GameState; story: StoryMoment } {
const progress = resolveSceneActProgression({
profile: state.customWorldProfile,
sceneId: state.currentScenePreset?.id ?? null,
storyEngineMemory: state.storyEngineMemory,
});
const nextActState = progress
? advanceSceneActRuntimeState({ progress })
: null;
const nextState = nextActState
? {
...state,
storyEngineMemory: {
...(state.storyEngineMemory ?? createEmptyStoryEngineMemoryState()),
currentSceneActState: nextActState,
},
}
: state;
if (progress?.isLastAct) {
return {
state: nextState,
story: {
text: resultText,
options: buildSceneTravelOptions(nextState),
streaming: false,
},
};
}
const deferredOptions =
fallbackOptions.length > 0
? fallbackOptions
: buildSceneTravelOptions(nextState);
return {
state: nextState,
story: {
text: resultText,
options: [buildContinueOption()],
deferredOptions,
deferredRuntimeState: nextActState
? {
storyEngineMemory: nextState.storyEngineMemory,
}
: undefined,
streaming: false,
},
};
}
export function buildRevivedFirstSceneState(state: GameState): GameState {
const firstScene = state.worldType
? getScenePresetsByWorld(state.worldType)[0] ?? state.currentScenePreset
: state.currentScenePreset;
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const firstActState = buildInitialSceneActRuntimeState({
profile: state.customWorldProfile,
sceneId: firstScene?.id ?? null,
storyEngineMemory: undefined,
});
return {
...state,
currentScenePreset: firstScene,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right',
playerHp: state.playerMaxHp,
playerMana: state.playerMaxMana,
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: false,
storyEngineMemory: {
...storyEngineMemory,
currentSceneActState: firstActState,
},
};
}
export function buildDeathStory(state: GameState): StoryMoment {
const firstSceneName =
state.worldType
? getScenePresetsByWorld(state.worldType)[0]?.name
: state.currentScenePreset?.name;
return {
text: firstSceneName
? `你在战斗中倒下,随后在${firstSceneName}重新醒来。`
: '你在战斗中倒下,随后重新醒来。',
options: [buildContinueOption()],
streaming: false,
};
}

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;
}

View File

@@ -18,6 +18,12 @@ import {
StoryOption,
} from '../../types';
import { resolveRpgRuntimeChoice } from '.';
import {
buildDeathStory,
buildPostBattleVictoryState,
buildPostBattleVictoryStory,
buildRevivedFirstSceneState,
} from './postBattleFlow';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<
@@ -42,6 +48,8 @@ function sleep(ms: number) {
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
}
const PLAYER_REVIVE_DELAY_MS = 3000;
export function buildReasonedOptionCatalog(options: StoryOption[]) {
const seenFunctionIds = new Set<string>();
@@ -320,6 +328,44 @@ export async function runServerRuntimeChoiceAction(params: {
turnVisualMs: params.turnVisualMs ?? 820,
});
}
const battle = response?.presentation.battle;
if (battle && hydratedSnapshot.gameState.playerHp <= 0) {
const deathState = {
...hydratedSnapshot.gameState,
animationState: AnimationState.DIE,
playerActionMode: 'idle' as const,
inBattle: false,
activeCombatEffects: [],
scrollWorld: false,
};
params.setGameState(deathState);
await sleep(PLAYER_REVIVE_DELAY_MS);
const revivedState = buildRevivedFirstSceneState(deathState);
params.setGameState(revivedState);
params.setCurrentStory(buildDeathStory(revivedState));
return;
}
if (
battle?.outcome === 'victory' ||
battle?.outcome === 'spar_complete'
) {
const resultText =
response?.presentation.resultText || nextStory.text || params.option.actionText;
const postBattleState = buildPostBattleVictoryState(
hydratedSnapshot.gameState,
);
const postBattle = buildPostBattleVictoryStory(
postBattleState,
resultText,
nextStory.options,
);
params.setGameState(postBattle.state);
params.setCurrentStory(postBattle.story);
return;
}
params.setGameState(hydratedSnapshot.gameState);
params.setCurrentStory(nextStory);
} catch (error) {
@@ -424,4 +470,15 @@ async function playServerBattlePresentation(params: {
playerActionMode: 'idle',
});
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.45)));
if (params.finalState.playerHp <= 0) {
params.setGameState({
...params.finalState,
animationState: AnimationState.DIE,
playerActionMode: 'idle',
inBattle: false,
activeCombatEffects: [],
scrollWorld: false,
});
}
}