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

@@ -6,19 +6,36 @@ vi.mock('../../services/aiService', () => ({
const {
isRpgRuntimeServerFunctionIdMock,
runServerRuntimeChoiceActionMock,
} = vi.hoisted(() => ({
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
runServerRuntimeChoiceActionMock: vi.fn(),
}));
vi.mock('../../services/rpg-runtime', () => ({
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
}));
import { generateNextStep } from '../../services/aiService';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { createStoryChoiceActions } from './choiceActions';
vi.mock('./storyChoiceRuntime', async () => {
return {
runCampTravelHomeChoice: vi.fn(),
runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock,
shouldOpenLocalRuntimeNpcModal: (option: StoryOption) =>
(
option.interaction?.kind === 'npc' ||
!option.interaction
) &&
(
option.functionId === 'npc_chat' ||
option.functionId === 'npc_trade' ||
option.functionId === 'npc_gift'
),
};
});
function createTestCharacter(): Character {
return {
id: 'test-hero',
@@ -150,6 +167,7 @@ describe('createStoryChoiceActions', () => {
beforeEach(() => {
isRpgRuntimeServerFunctionIdMock.mockReset();
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
runServerRuntimeChoiceActionMock.mockReset();
});
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
@@ -290,19 +308,13 @@ describe('createStoryChoiceActions', () => {
options: [continueOption],
deferredOptions,
deferredRuntimeState: {
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: 'scene-bridge',
chapterId: 'scene-bridge-chapter',
currentActId: 'scene-bridge-act-2',
currentActIndex: 1,
completedActIds: ['scene-bridge-act-1'],
visitedActIds: ['scene-bridge-act-1', 'scene-bridge-act-2'],
},
currentScenePreset: {
id: 'scene-bridge',
name: '断桥',
description: '桥上雾气很重。',
imageSrc: '/scene-bridge.png',
treasureHints: [],
npcs: [],
},
},
};
@@ -355,13 +367,14 @@ describe('createStoryChoiceActions', () => {
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
currentActId: 'scene-bridge-act-2',
}),
currentScenePreset: expect.objectContaining({
id: 'scene-bridge',
}),
}),
);
expect(setGameState.mock.calls[0]?.[0]).not.toHaveProperty(
'storyEngineMemory',
);
expect(setCurrentStory).toHaveBeenCalledWith({
...currentStory,
options: deferredOptions,
@@ -527,360 +540,7 @@ describe('createStoryChoiceActions', () => {
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
});
it('uses deterministic continue option after local npc victory', async () => {
const encounter: Encounter = {
id: 'npc-opponent',
kind: 'npc',
npcName: '山道客',
npcDescription: '拦路旧敌',
npcAvatar: '/npc.png',
context: '山道旧案',
};
const state = {
...createBaseState(),
currentEncounter: encounter,
npcInteractionActive: true,
};
const option = createBattleOption();
const afterSequence = {
...state,
inBattle: false,
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_victory' as const,
};
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const handleNpcBattleConversationContinuation = vi.fn(() => true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation,
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => ({
nextState: {
...afterSequence,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
inBattle: false,
},
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
})),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(handleNpcBattleConversationContinuation).not.toHaveBeenCalled();
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
inBattle: false,
}),
);
expect(generateStoryForState).not.toHaveBeenCalled();
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '山道客已经败下阵来。胜利奖励:无战利品。',
options: [
expect.objectContaining({
functionId: 'story_continue_adventure',
actionText: '继续前进',
}),
],
}),
);
});
it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => {
vi.useFakeTimers();
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
const state = {
...createBaseState(),
currentScenePreset: firstScene,
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: firstScene.id,
chapterId: `${firstScene.id}-chapter`,
currentActId: `${firstScene.id}-act-2`,
currentActIndex: 1,
completedActIds: [`${firstScene.id}-act-1`],
visitedActIds: [`${firstScene.id}-act-1`, `${firstScene.id}-act-2`],
},
},
currentEncounter: {
id: 'npc-opponent',
kind: 'npc' as const,
npcName: '山道客',
npcDescription: '拦路旧敌',
npcAvatar: '/npc.png',
context: '山道旧案',
},
npcInteractionActive: true,
};
const option = createBattleOption();
const afterSequence = {
...state,
playerHp: 0,
inBattle: false,
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_defeat' as const,
};
const finalizeNpcBattleResult = vi.fn(() => ({
nextState: afterSequence,
resultText: '不应该进入胜利结算',
}));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 0,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory('fallback')),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult,
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
const choicePromise = handleChoice(option);
await vi.advanceTimersByTimeAsync(3000);
await choicePromise;
vi.useRealTimers();
expect(finalizeNpcBattleResult).not.toHaveBeenCalled();
expect(setGameState).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
playerHp: 0,
inBattle: false,
currentNpcBattleOutcome: 'fight_defeat',
animationState: AnimationState.DIE,
}),
);
expect(setGameState).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
currentScenePreset: expect.objectContaining({
id: firstScene.id,
}),
playerHp: 100,
playerMana: 20,
inBattle: false,
currentNpcBattleOutcome: null,
}),
);
const revivedState = setGameState.mock.calls[1]?.[0] as GameState;
expect(revivedState.currentBattleNpcId).toBeNull();
expect(revivedState.currentNpcBattleMode).toBeNull();
expect(revivedState.currentNpcBattleOutcome).toBeNull();
expect(
revivedState.currentEncounter !== null || revivedState.sceneHostileNpcs.length > 0,
).toBe(true);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('重新醒来'),
}),
);
vi.useRealTimers();
});
it('settles escape locally without ai continuation', async () => {
const mockedGenerateNextStep = vi.mocked(generateNextStep);
const state = {
...createBaseState(),
currentBattleNpcId: null,
currentNpcBattleMode: null,
sceneHostileNpcs: [
{
id: 'wolf-1',
name: '山狼',
action: '低伏逼近',
description: '一头山狼',
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.4,
speed: 7,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const option = createBattleOption('battle_escape_breakout');
const afterSequence = {
...state,
inBattle: false,
sceneHostileNpcs: [],
playerX: -1.2,
};
const setBattleReward = vi.fn();
const setCurrentStory = vi.fn();
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
const buildStoryContextFromState = vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: -1.2,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
}));
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward,
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'escape' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState,
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(mockedGenerateNextStep).not.toHaveBeenCalled();
expect(buildStoryContextFromState).not.toHaveBeenCalled();
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
}),
);
expect(setBattleReward).toHaveBeenCalledTimes(1);
expect(setBattleReward).toHaveBeenCalledWith(null);
expect(incrementRuntimeStats).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ hostileNpcsDefeated: 0 }),
);
});
it('keeps battle attack and skill choices on the local combat path even if runtime server supports them', async () => {
it('routes battle attack and skill choices to the backend resolver even while in battle', async () => {
const state = {
...createBaseState(),
sceneHostileNpcs: [
@@ -969,17 +629,20 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option);
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
state,
option,
state.playerCharacter!,
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: state,
option,
character: state.playerCharacter,
}),
);
expect(playResolvedChoice).toHaveBeenCalled();
expect(setGameState).toHaveBeenCalled();
expect(setCurrentStory).toHaveBeenCalled();
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
expect(playResolvedChoice).not.toHaveBeenCalled();
expect(setGameState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalled();
});
it('keeps stale battle panel choices on the local combat path when combat presentation is still visible', async () => {
it('routes stale battle panel choices to the backend resolver when combat presentation is still visible', async () => {
const battleOption = createBattleOption('battle_attack_basic');
const state = {
...createBaseState(),
@@ -1072,11 +735,80 @@ describe('createStoryChoiceActions', () => {
await handleChoice(battleOption);
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
state,
battleOption,
state.playerCharacter!,
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: state,
currentStory,
option: battleOption,
character: state.playerCharacter,
}),
);
expect(playResolvedChoice).toHaveBeenCalled();
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
expect(playResolvedChoice).not.toHaveBeenCalled();
});
it('routes inventory_use combat choices to the backend resolver', async () => {
const state = createBaseState();
const option: StoryOption = {
...createBattleOption('inventory_use'),
runtimePayload: {
itemId: 'focus-tonic',
},
};
const buildResolvedChoiceState = vi.fn();
const playResolvedChoice = vi.fn();
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => [option]),
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
getResolvedSceneHostileNpcs: vi.fn(
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: state,
option,
character: state.playerCharacter,
}),
);
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
expect(playResolvedChoice).not.toHaveBeenCalled();
});
});

View File

@@ -77,39 +77,6 @@ type IncrementRuntimeStats = (
increments: RuntimeStatsIncrements,
) => GameState;
function isImmediateCombatChoice(option: StoryOption) {
return (
option.functionId.startsWith('battle_') ||
option.functionId === 'inventory_use'
);
}
function shouldResolveCombatChoiceLocally(
gameState: GameState,
currentStory: StoryMoment | null,
option: StoryOption,
) {
if (!isImmediateCombatChoice(option)) {
return false;
}
if (gameState.inBattle) {
return true;
}
const hasBattleMarkers =
Boolean(gameState.currentBattleNpcId || gameState.currentNpcBattleMode) ||
gameState.sceneHostileNpcs.some((hostileNpc) => hostileNpc.hp > 0);
const storyStillShowsBattleChoices = Boolean(
currentStory?.options.some(isImmediateCombatChoice),
);
// 中文注释:真实运行态里可能短暂出现“可见层仍在战斗,但逻辑态 inBattle
// 已经被提前切回 false”的窗口。如果这时玩家点击了还在面板上的 battle_* /
// inventory_use 选项,必须继续走本地逐帧战斗链,不能误分流到服务端直结算。
return hasBattleMarkers || storyStillShowsBattleChoices;
}
export function createStoryChoiceActions({
gameState,
currentStory,
@@ -213,9 +180,6 @@ export function createStoryChoiceActions({
currentScenePreset:
currentStory.deferredRuntimeState.currentScenePreset ??
gameState.currentScenePreset,
storyEngineMemory:
currentStory.deferredRuntimeState.storyEngineMemory ??
gameState.storyEngineMemory,
});
}
setCurrentStory({
@@ -252,10 +216,7 @@ export function createStoryChoiceActions({
return;
}
if (
isRpgRuntimeServerFunctionId(option.functionId) &&
!shouldResolveCombatChoiceLocally(gameState, currentStory, option)
) {
if (isRpgRuntimeServerFunctionId(option.functionId)) {
await runServerRuntimeChoiceAction({
gameState,
currentStory,

View File

@@ -1,14 +1,11 @@
import { useMemo, type Dispatch, type SetStateAction } from 'react';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import type { RuntimeStoryInventoryActionView } from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
import {
EQUIPMENT_EQUIP_FUNCTION,
EQUIPMENT_UNEQUIP_FUNCTION,
FORGE_CRAFT_FUNCTION,
FORGE_DISMANTLE_FUNCTION,
FORGE_REFORGE_FUNCTION,
INVENTORY_USE_FUNCTION,
} from '../../data/functionCatalog';
import { getForgeRecipeViews } from '../../data/forgeSystem';
loadRpgRuntimeInventoryView,
type RuntimeStoryChoicePayload,
type RuntimeStoryInventoryView,
} from '../../services/rpg-runtime';
import type { Character, GameState, StoryMoment } from '../../types';
import { resolveRpgRuntimeChoice } from '.';
import type { InventoryFlowUi } from './uiTypes';
@@ -41,20 +38,71 @@ export function useStoryInventoryActions({
setIsLoading,
buildFallbackStoryForState,
} = runtime;
const forgeRecipes = useMemo(
() =>
getForgeRecipeViews(
gameState.playerInventory,
gameState.playerCurrency,
gameState.worldType,
),
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
);
const [serverInventoryView, setServerInventoryView] =
useState<RuntimeStoryInventoryView | null>(null);
const runtimeSessionId = gameState.runtimeSessionId;
const runtimeActionVersion = gameState.runtimeActionVersion;
const currentScene = gameState.currentScene;
const hasPlayerCharacter = Boolean(gameState.playerCharacter);
useEffect(() => {
if (!hasPlayerCharacter || currentScene !== 'Story') {
setServerInventoryView(null);
return;
}
const controller = new AbortController();
void loadRpgRuntimeInventoryView(
{
gameState: {
runtimeSessionId,
runtimeActionVersion,
},
},
{ signal: controller.signal },
)
.then((view) => {
setServerInventoryView(view);
})
.catch((error) => {
if (controller.signal.aborted) {
return;
}
console.error('Failed to load inventory runtime view:', error);
setAiError(error instanceof Error ? error.message : '背包视图同步失败');
});
return () => {
controller.abort();
};
}, [
currentScene,
hasPlayerCharacter,
runtimeActionVersion,
runtimeSessionId,
setAiError,
]);
const rejectInventoryAction = (message: string) => {
setAiError(message);
return false;
};
const findBackpackItemView = (itemId: string) =>
serverInventoryView?.backpackItems.find(
(candidate) => candidate.item.id === itemId,
) ?? null;
const findEquipmentSlotView = (slot: 'weapon' | 'armor' | 'relic') =>
serverInventoryView?.equipmentSlots.find(
(candidate) => candidate.slotId === slot,
) ?? null;
const resolveServerInventoryAction = async (params: {
functionId: string;
actionText: string;
payload: Record<string, unknown>;
payload?: RuntimeStoryChoicePayload;
}) => {
const character = gameState.playerCharacter;
if (
@@ -69,7 +117,7 @@ export function useStoryInventoryActions({
setIsLoading(true);
try {
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
const { response, hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
gameState,
currentStory,
option: {
@@ -81,6 +129,7 @@ export function useStoryInventoryActions({
setGameState(hydratedSnapshot.gameState);
setCurrentStory(nextStory);
setServerInventoryView(response.viewModel.inventory);
return true;
} catch (error) {
console.error('Failed to resolve inventory runtime action on the server:', error);
@@ -94,100 +143,80 @@ export function useStoryInventoryActions({
}
};
const useInventoryItem = async (itemId: string) => {
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
const submitInventoryAction = async (
action: RuntimeStoryInventoryActionView | undefined,
fallbackReason: string,
) => {
if (!action) {
return rejectInventoryAction(fallbackReason);
}
if (!action.enabled) {
return rejectInventoryAction(action.reason ?? fallbackReason);
}
return resolveServerInventoryAction({
functionId: action.functionId,
actionText: action.actionText,
payload: action.payload as RuntimeStoryChoicePayload | undefined,
});
};
const useInventoryItem = async (itemId: string) =>
submitInventoryAction(
findBackpackItemView(itemId)?.actions.use,
'后端背包视图尚未提供该物品的使用动作。',
);
if (!item) {
return false;
}
return resolveServerInventoryAction({
functionId: INVENTORY_USE_FUNCTION.id,
actionText: `使用${item.name}`,
payload: { itemId },
});
};
const equipInventoryItem = async (itemId: string) => {
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
const equipInventoryItem = async (itemId: string) =>
submitInventoryAction(
findBackpackItemView(itemId)?.actions.equip,
'后端背包视图尚未提供该物品的装备动作。',
);
if (!item) {
return false;
}
return resolveServerInventoryAction({
functionId: EQUIPMENT_EQUIP_FUNCTION.id,
actionText: `装备${item.name}`,
payload: { itemId },
});
};
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => {
const equippedItem = gameState.playerEquipment[slot];
if (!equippedItem) {
return false;
}
return resolveServerInventoryAction({
functionId: EQUIPMENT_UNEQUIP_FUNCTION.id,
actionText: `卸下${equippedItem.name}`,
payload: { slotId: slot },
});
};
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') =>
submitInventoryAction(
findEquipmentSlotView(slot)?.unequip,
'后端装备视图尚未提供该槽位的卸装动作。',
);
const craftRecipe = async (recipeId: string) => {
const recipe = forgeRecipes.find(
const recipe = serverInventoryView?.forgeRecipes.find(
(candidate) => candidate.id === recipeId,
);
if (!recipe) {
return false;
return rejectInventoryAction('后端锻造视图尚未提供该配方。');
}
if (!recipe.canCraft) {
return rejectInventoryAction(
recipe.disabledReason ?? recipe.action.reason ?? '当前配方不可制作。',
);
}
return resolveServerInventoryAction({
functionId: FORGE_CRAFT_FUNCTION.id,
actionText: `制作${recipe.resultLabel}`,
payload: { recipeId },
});
return submitInventoryAction(recipe.action, '当前配方不可制作。');
};
const dismantleItem = async (itemId: string) => {
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
const dismantleItem = async (itemId: string) =>
submitInventoryAction(
findBackpackItemView(itemId)?.actions.dismantle,
'后端背包视图尚未提供该物品的拆解动作。',
);
if (!item) {
return false;
}
return resolveServerInventoryAction({
functionId: FORGE_DISMANTLE_FUNCTION.id,
actionText: `拆解${item.name}`,
payload: { itemId },
});
};
const reforgeItem = async (itemId: string) => {
const item = gameState.playerInventory.find(
(candidate) => candidate.id === itemId,
const reforgeItem = async (itemId: string) =>
submitInventoryAction(
findBackpackItemView(itemId)?.actions.reforge,
'后端背包视图尚未提供该物品的重铸动作。',
);
if (!item) {
return false;
}
return resolveServerInventoryAction({
functionId: FORGE_REFORGE_FUNCTION.id,
actionText: `重铸${item.name}`,
payload: { itemId },
});
};
return {
inventoryUi: {
useInventoryItem,
equipInventoryItem,
unequipItem,
forgeRecipes,
playerCurrency: serverInventoryView?.playerCurrency ?? null,
currencyText: serverInventoryView?.currencyText ?? null,
backpackItems: serverInventoryView?.backpackItems ?? [],
equipmentSlots: serverInventoryView?.equipmentSlots ?? [],
forgeRecipes: serverInventoryView?.forgeRecipes ?? [],
craftRecipe,
dismantleItem,
reforgeItem,

View File

@@ -0,0 +1,323 @@
/* @vitest-environment jsdom */
import { render, waitFor } from '@testing-library/react';
import { useEffect, useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { resolveRpgRuntimeChoiceMock } = vi.hoisted(() => ({
resolveRpgRuntimeChoiceMock: vi.fn(),
}));
vi.mock('.', () => ({
resolveRpgRuntimeChoice: resolveRpgRuntimeChoiceMock,
}));
import type { StoryGenerationContext } from '../../services/aiTypes';
import type { Character, Encounter, GameState, StoryMoment } from '../../types';
import { AnimationState, WorldType } from '../../types';
import { useStoryNpcInteractionFlow } from './npcInteraction';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试角色',
personality: '谨慎',
skills: [],
} as unknown as Character;
}
function createEncounter(): Encounter {
return {
id: 'npc-merchant',
kind: 'npc',
npcName: '梁伯',
npcDescription: '守着小摊的老人',
npcAvatar: '',
context: '行商',
};
}
function createGameState(overrides: Partial<GameState> = {}): GameState {
const encounter = createEncounter();
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 0,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: encounter,
npcInteractionActive: true,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 40,
playerMaxHp: 40,
playerMana: 16,
playerMaxMana: 16,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
runtimeNpcInteraction: {
npcId: 'npc-merchant',
npcName: '梁伯',
playerCurrency: 0,
currencyName: '铜钱',
trade: {
buyItems: [
{
itemId: 'merchant-tonic',
item: {
id: 'merchant-tonic',
category: '消耗品',
name: '回气散',
quantity: 2,
rarity: 'uncommon',
tags: ['mana'],
},
mode: 'buy',
unitPrice: 29,
maxQuantity: 2,
canSubmit: false,
reason: '当前钱币不足。',
},
],
sellItems: [
{
itemId: 'player-ingot',
item: {
id: 'player-ingot',
category: '材料',
name: '精炼锭材',
quantity: 1,
rarity: 'rare',
tags: ['material'],
},
mode: 'sell',
unitPrice: 23,
maxQuantity: 1,
canSubmit: true,
reason: null,
},
],
},
gift: {
items: [
{
itemId: 'gift-herb',
item: {
id: 'gift-herb',
category: '材料',
name: '暖息草',
quantity: 1,
rarity: 'rare',
tags: ['material', 'mana'],
},
affinityGain: 16,
canSubmit: true,
reason: null,
},
],
},
},
npcStates: {
'npc-merchant': {
affinity: 0,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
function createRuntime(gameState: GameState) {
return {
currentStory: null,
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
buildStoryContextFromState: vi.fn(
() => ({}) as unknown as StoryGenerationContext,
),
buildFallbackStoryForState: vi.fn(
() =>
({
text: 'fallback',
options: [],
}) satisfies StoryMoment,
),
buildDialogueStoryMoment: vi.fn(
(npcName: string, text: string) =>
({
text,
options: [],
displayMode: 'dialogue',
dialogue: text
? [{ speaker: 'npc' as const, speakerName: npcName, text }]
: [],
}) satisfies StoryMoment,
),
generateStoryForState: vi.fn(
async () =>
({
text: 'next',
options: [],
}) satisfies StoryMoment,
),
getStoryGenerationHostileNpcs: vi.fn(() => gameState.sceneHostileNpcs),
getTypewriterDelay: vi.fn(() => 0),
};
}
function Harness({
action,
initialState,
}: {
action: 'buy' | 'gift';
initialState: GameState;
}) {
const [gameState, setGameState] = useState(initialState);
const openedRef = useRef(false);
const confirmedRef = useRef(false);
const runtime = createRuntime(gameState);
const flow = useStoryNpcInteractionFlow({
gameState,
setGameState,
getNpcEncounterKey: encounter => encounter.id ?? encounter.npcName,
getResolvedNpcState: (state, encounter) =>
state.npcStates[encounter.id ?? encounter.npcName]!,
updateNpcState: (state) => state,
cloneInventoryItemForOwner: (item) => item,
runtime,
});
useEffect(() => {
if (openedRef.current) {
return;
}
openedRef.current = true;
const encounter = initialState.currentEncounter as Encounter;
if (action === 'buy') {
flow.openTradeModal(encounter, '交易');
return;
}
flow.openGiftModal(encounter, '赠送礼物');
}, [action, flow, initialState]);
useEffect(() => {
if (confirmedRef.current) {
return;
}
if (action === 'buy' && flow.npcUi.tradeModal) {
confirmedRef.current = true;
flow.npcUi.confirmTrade();
return;
}
if (action === 'gift' && flow.npcUi.giftModal) {
confirmedRef.current = true;
flow.npcUi.confirmGift();
}
}, [action, flow.npcUi]);
return null;
}
describe('useStoryNpcInteractionFlow', () => {
beforeEach(() => {
resolveRpgRuntimeChoiceMock.mockReset();
resolveRpgRuntimeChoiceMock.mockResolvedValue({
hydratedSnapshot: {
gameState: createGameState({
playerCurrency: 12,
}),
},
nextStory: {
text: 'server resolved',
options: [],
} satisfies StoryMoment,
});
});
it('submits npc trade to the server even when the server view marks local currency insufficient', async () => {
render(<Harness action="buy" initialState={createGameState()} />);
await waitFor(() => expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalled());
expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalledWith(
expect.objectContaining({
option: expect.objectContaining({
functionId: 'npc_trade',
interaction: {
kind: 'npc',
npcId: 'npc-merchant',
action: 'trade',
},
}),
payload: {
mode: 'buy',
itemId: 'merchant-tonic',
quantity: 1,
},
}),
);
});
it('submits npc gift from the server gift view without checking local inventory first', async () => {
render(<Harness action="gift" initialState={createGameState()} />);
await waitFor(() => expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalled());
expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalledWith(
expect.objectContaining({
option: expect.objectContaining({
functionId: 'npc_gift',
interaction: {
kind: 'npc',
npcId: 'npc-merchant',
action: 'gift',
},
}),
payload: {
itemId: 'gift-herb',
},
}),
);
});
});

View File

@@ -7,34 +7,22 @@ import { useState } from 'react';
import {
getCharacterById,
} from '../../data/characterPresets';
import {
getNpcBuybackPrice,
getNpcPurchasePrice,
} from '../../data/economy';
import {
buildNpcGiftModalState,
buildNpcRecruitModalState,
buildNpcTradeModalState,
buildNpcTradeModalIntroText,
} from '../../data/functionCatalog';
import {
addInventoryItems,
buildNpcGiftCommitActionText,
buildNpcGiftResultText,
buildNpcTradeTransactionActionText,
buildNpcTradeTransactionResultText,
getGiftCandidates,
getPreferredGiftItemId,
removeInventoryItem,
syncNpcTradeInventory,
} from '../../data/npcInteractions';
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService';
import { streamNpcRecruitDialogue } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
Encounter,
GameState,
InventoryItem,
RuntimeNpcTradeItemView,
StoryMoment,
StoryOption,
} from '../../types';
@@ -154,13 +142,17 @@ function normalizeRecruitDialogue(
return compactLines.slice(0, 6).join('\n');
}
function normalizeTradeQuantity(quantity: number) {
return Math.max(1, Math.floor(Number.isFinite(quantity) ? quantity : 1));
}
export function useStoryNpcInteractionFlow({
gameState,
setGameState,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
cloneInventoryItemForOwner,
getResolvedNpcState: _getResolvedNpcState,
updateNpcState: _updateNpcState,
cloneInventoryItemForOwner: _cloneInventoryItemForOwner,
runtime,
}: {
gameState: GameState;
@@ -183,184 +175,6 @@ export function useStoryNpcInteractionFlow({
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
const getTradeNpcItem = (state: GameState, modal: TradeModalState) => {
const npcState = getResolvedNpcState(state, modal.encounter);
return npcState.inventory.find(item => item.id === modal.selectedNpcItemId) ?? null;
};
const getTradePlayerItem = (state: GameState, modal: TradeModalState) =>
state.playerInventory.find(item => item.id === modal.selectedPlayerItemId) ?? null;
const getTradeUnitPrice = (state: GameState, modal: TradeModalState) => {
if (modal.mode === 'buy') {
const npcItem = getTradeNpcItem(state, modal);
const npcState = getResolvedNpcState(state, modal.encounter);
return npcItem ? getNpcPurchasePrice(npcItem, npcState.affinity) : 0;
}
const playerItem = getTradePlayerItem(state, modal);
const npcState = getResolvedNpcState(state, modal.encounter);
return playerItem ? getNpcBuybackPrice(playerItem, npcState.affinity) : 0;
};
const getTradeMaxQuantity = (state: GameState, modal: TradeModalState) => {
if (modal.mode === 'buy') {
return getTradeNpcItem(state, modal)?.quantity ?? 0;
}
return getTradePlayerItem(state, modal)?.quantity ?? 0;
};
const clampTradeQuantity = (state: GameState, modal: TradeModalState, quantity: number) => {
const maxQuantity = getTradeMaxQuantity(state, modal);
if (maxQuantity <= 0) return 1;
return Math.max(1, Math.min(maxQuantity, Math.floor(quantity)));
};
const commitNpcReactionAndGenerate = async ({
nextState,
encounter,
actionText,
resultText,
lastFunctionId,
contextNpcStateOverride,
}: {
nextState: GameState;
encounter: Encounter;
actionText: string;
resultText: string;
lastFunctionId: string;
contextNpcStateOverride?: GameState['npcStates'][string] | null;
}) => {
if (!gameState.playerCharacter || !gameState.worldType) {
return;
}
const provisionalHistory = [
...gameState.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(resultText, 'result'),
];
const provisionalState = {
...nextState,
storyHistory: provisionalHistory,
};
setGameState(provisionalState);
runtime.setAiError(null);
runtime.setIsLoading(true);
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
);
let dialogueText = '';
let streamedTargetText = '';
let displayedText = '';
let streamCompleted = false;
const typewriterPromise = (async () => {
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
if (displayedText.length >= streamedTargetText.length) {
await new Promise(resolve => window.setTimeout(resolve, 40));
continue;
}
const nextChar = streamedTargetText[displayedText.length];
if (!nextChar) {
await new Promise(resolve => window.setTimeout(resolve, 40));
continue;
}
displayedText += nextChar;
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(
encounter.npcName,
displayedText,
[],
true,
),
);
await new Promise(resolve =>
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
);
}
})();
try {
dialogueText = await streamNpcChatDialogue(
gameState.worldType,
gameState.playerCharacter,
encounter,
runtime.getStoryGenerationHostileNpcs(provisionalState),
provisionalHistory,
runtime.buildStoryContextFromState(provisionalState, {
lastFunctionId,
encounterNpcStateOverride: contextNpcStateOverride,
}),
actionText,
resultText,
{
onUpdate: text => {
streamedTargetText = text;
},
},
);
streamedTargetText = dialogueText;
streamCompleted = true;
await typewriterPromise;
const finalDialogueText = dialogueText.trim() || displayedText.trim();
const finalHistory = finalDialogueText
? [...provisionalHistory, createHistoryMoment(finalDialogueText, 'result')]
: provisionalHistory;
const finalState = {
...nextState,
storyHistory: finalHistory,
};
setGameState(finalState);
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(
encounter.npcName,
finalDialogueText || resultText,
[],
false,
),
);
await new Promise(resolve => window.setTimeout(resolve, 260));
const nextStory = await runtime.generateStoryForState({
state: finalState,
character: gameState.playerCharacter,
history: finalHistory,
choice: actionText,
lastFunctionId,
});
runtime.setCurrentStory(nextStory);
} catch (error) {
streamCompleted = true;
await typewriterPromise;
console.error('Failed to continue npc interaction reaction:', error);
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
const fallbackHistory = provisionalHistory;
const fallbackState = {
...nextState,
storyHistory: fallbackHistory,
};
setGameState(fallbackState);
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(
fallbackState,
gameState.playerCharacter,
resultText,
),
);
} finally {
runtime.setIsLoading(false);
}
};
const resolveRecruitmentOnServer = async (params: {
encounter: Encounter;
actionText: string;
@@ -516,45 +330,68 @@ export function useStoryNpcInteractionFlow({
});
};
const openTradeModal = (encounter: Encounter, actionText: string) => {
const currentNpcState = getResolvedNpcState(gameState, encounter);
const npcState = syncNpcTradeInventory(
gameState,
encounter,
currentNpcState,
const getRuntimeTradeItems = (
mode: 'buy' | 'sell',
): RuntimeNpcTradeItemView[] =>
mode === 'buy'
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
const findRuntimeTradeItem = (modal: TradeModalState) => {
const itemId =
modal.mode === 'buy'
? modal.selectedNpcItemId
: modal.selectedPlayerItemId;
if (!itemId) return null;
return (
getRuntimeTradeItems(modal.mode).find((item) => item.itemId === itemId) ??
null
);
};
if (
gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState
|| npcState !== currentNpcState
) {
setGameState(updateNpcState(gameState, encounter, () => npcState));
}
const findRuntimeGiftItem = (itemId: string | null) => {
if (!itemId) return null;
return (
gameState.runtimeNpcInteraction?.gift.items.find(
(item) => item.itemId === itemId,
) ?? null
);
};
const openTradeModal = (encounter: Encounter, actionText: string) => {
setTradeModal(
buildNpcTradeModalState(
gameState,
{
encounter,
actionText,
npcState.inventory,
),
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId:
gameState.runtimeNpcInteraction?.trade.buyItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
gameState.runtimeNpcInteraction?.trade.sellItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
},
);
};
const openGiftModal = (encounter: Encounter, actionText: string) => {
const selectedItemId = getPreferredGiftItemId(
gameState.playerInventory,
encounter,
{
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
},
);
if (!selectedItemId) return;
const selectedItemId =
gameState.runtimeNpcInteraction?.gift.items.find((item) => item.canSubmit)
?.itemId ??
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
null;
setGiftModal(
buildNpcGiftModalState(
gameState,
encounter,
actionText,
selectedItemId,
@@ -630,53 +467,24 @@ export function useStoryNpcInteractionFlow({
if (!tradeModal || !gameState.playerCharacter) return;
const encounter = tradeModal.encounter;
const quantity = clampTradeQuantity(gameState, tradeModal, tradeModal.selectedQuantity);
const unitPrice = getTradeUnitPrice(gameState, tradeModal);
const totalPrice = unitPrice * quantity;
if (tradeModal.mode === 'buy') {
const npcItem = getTradeNpcItem(gameState, tradeModal);
if (!npcItem || quantity <= 0) return;
if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return;
setTradeModal(null);
void resolveServerNpcAction({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'buy',
item: npcItem,
quantity,
}),
functionId: 'npc_trade',
action: 'trade',
payload: {
mode: 'buy',
itemId: npcItem.id,
quantity,
},
});
return;
}
const playerItem = getTradePlayerItem(gameState, tradeModal);
if (!playerItem || quantity <= 0) return;
if (playerItem.quantity < quantity) return;
const quantity = normalizeTradeQuantity(tradeModal.selectedQuantity);
const tradeItem = findRuntimeTradeItem(tradeModal);
if (!tradeItem) return;
setTradeModal(null);
void resolveServerNpcAction({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'sell',
item: playerItem,
mode: tradeModal.mode,
item: tradeItem.item,
quantity,
}),
functionId: 'npc_trade',
action: 'trade',
payload: {
mode: 'sell',
itemId: playerItem.id,
mode: tradeModal.mode,
itemId: tradeItem.itemId,
quantity,
},
});
@@ -686,17 +494,17 @@ export function useStoryNpcInteractionFlow({
if (!giftModal || !gameState.playerCharacter) return;
const encounter = giftModal.encounter;
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
const giftItem = findRuntimeGiftItem(giftModal.selectedItemId);
if (!giftItem) return;
setGiftModal(null);
void resolveServerNpcAction({
encounter,
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
actionText: `${giftItem.item.name}赠给${encounter.npcName}`,
functionId: 'npc_gift',
action: 'gift',
payload: {
itemId: giftItem.id,
itemId: giftItem.itemId,
},
});
};
@@ -708,44 +516,40 @@ export function useStoryNpcInteractionFlow({
recruitModal,
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
if (!current) return current;
const nextModal = {
return {
...current,
mode,
selectedNpcItemId: current.selectedNpcItemId ?? getResolvedNpcState(gameState, current.encounter).inventory[0]?.id ?? null,
selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.playerInventory[0]?.id ?? null,
selectedNpcItemId:
current.selectedNpcItemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
current.selectedPlayerItemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
};
return {
...nextModal,
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
};
}),
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
const nextModal = {
return {
...current,
selectedNpcItemId: itemId,
};
return {
...nextModal,
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
selectedQuantity: 1,
};
}),
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
const nextModal = {
return {
...current,
selectedPlayerItemId: itemId,
};
return {
...nextModal,
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
selectedQuantity: 1,
};
}),
setTradeQuantity: (quantity: number) => setTradeModal(current => current
? {
...current,
selectedQuantity: clampTradeQuantity(gameState, current, quantity),
selectedQuantity: normalizeTradeQuantity(quantity),
}
: current),
closeTradeModal: () => setTradeModal(null),

View File

@@ -1,327 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const { ensureSceneEncounterPreviewMock } = vi.hoisted(() => ({
ensureSceneEncounterPreviewMock: vi.fn(),
}));
vi.mock('../../data/sceneEncounterPreviews', () => ({
ensureSceneEncounterPreview: ensureSceneEncounterPreviewMock,
}));
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import { AnimationState, type GameState, WorldType } from '../../types';
import { buildRevivedFirstSceneState } from './postBattleFlow';
function createBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
chapters: [
{
id: `${label}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${label}先收着话。`,
content: `${label}把真正目的藏在后面。`,
contextSnippet: `${label}表面上仍在试探。`,
},
{
id: `${label}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${label}提到旧事会迟疑。`,
content: `${label}背后压着旧伤。`,
contextSnippet: `${label}仍被旧事牵制。`,
},
{
id: `${label}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${label}真正执念并不在表面。`,
content: `${label}真正想守住的是另一条暗线。`,
contextSnippet: `${label}另有没说出口的理由。`,
},
{
id: `${label}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${label}手里还扣着底牌。`,
content: `${label}掌握能改写局势的最后证据。`,
contextSnippet: `${label}最后底牌还没翻出。`,
},
],
};
}
function createStoryRole(id: string, name: string, hostile = false) {
return {
id,
name,
title: `${name}的头衔`,
role: hostile ? '敌对角色' : '同幕角色',
description: `${name}的测试描述`,
backstory: `${name}的测试背景`,
personality: '冷静克制',
motivation: hostile ? '阻拦玩家继续向前' : '观察局势变化',
combatStyle: hostile ? '正面压制' : '后排支援',
initialAffinity: hostile ? -20 : 12,
relationshipHooks: [],
tags: [],
backstoryReveal: createBackstoryReveal(name),
skills: [],
initialItems: [],
};
}
function createReviveState(): GameState {
const customWorldProfile = {
id: 'custom-revive-test',
name: '复活回场测试世界',
subtitle: '首幕站位恢复',
summary: '用于验证复活后第一幕 NPC 会按既有 encounter preview 恢复。',
settingText: '围绕开局营地与第一幕对峙角色展开的自定义世界。',
tone: '紧张、克制',
playerGoal: '复活后重新回到第一幕并面对主交互角色。',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '复活回场测试世界',
settingSummary: '首幕站位恢复',
tone: '紧张、克制',
conflictCore: '复活后重新面对主交互角色',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [
createStoryRole('npc-front', '正面对手', true),
createStoryRole('npc-back-1', '后排甲'),
createStoryRole('npc-back-2', '后排乙'),
],
items: [],
landmarks: [],
camp: {
id: 'custom-scene-camp',
name: '开局营地',
description: '用于复活回场测试。',
visualDescription: '营地火光映着即将重开的第一幕。',
imageSrc: '/camp.png',
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
connections: [],
narrativeResidues: null,
},
sceneChapterBlueprints: [
{
id: 'custom-scene-camp-chapter',
sceneId: 'custom-scene-camp',
title: '开局章节',
summary: '复活后应回到这里的第一幕。',
sceneTaskDescription: '',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'custom-scene-camp-act-1',
sceneId: 'custom-scene-camp',
title: '第一幕',
summary: '主交互角色与后排角色一同出现。',
stageCoverage: ['opening'],
backgroundImageSrc: '/act-1.png',
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-front',
oppositeNpcId: 'npc-front',
eventDescription: '第一幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '重新进入首幕',
transitionHook: '首幕回场',
},
{
id: 'custom-scene-camp-act-2',
sceneId: 'custom-scene-camp',
title: '第二幕',
summary: '这是死亡前已经推进到的幕。',
stageCoverage: ['expansion'],
backgroundImageSrc: '/act-2.png',
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-front',
oppositeNpcId: 'npc-front',
eventDescription: '第二幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '推进第二幕',
transitionHook: '第二幕推进',
},
],
},
],
} as NonNullable<GameState['customWorldProfile']>;
setRuntimeCustomWorldProfile(customWorldProfile);
const firstScene = getScenePresetsByWorld(WorldType.CUSTOM)[0]!;
return {
worldType: WorldType.CUSTOM,
customWorldProfile,
playerCharacter: {
id: 'hero',
name: '测试主角',
title: '旅人',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
},
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.DIE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: firstScene,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 100,
playerMana: 0,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-front': {
affinity: -20,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
'npc-back-1': {
affinity: 8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
'npc-back-2': {
affinity: 6,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-front',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: 'fight_defeat',
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: 'custom-scene-camp',
chapterId: 'custom-scene-camp-chapter',
currentActId: 'custom-scene-camp-act-2',
currentActIndex: 1,
completedActIds: ['custom-scene-camp-act-1'],
visitedActIds: ['custom-scene-camp-act-1', 'custom-scene-camp-act-2'],
},
},
} as GameState;
}
describe('postBattleFlow', () => {
afterEach(() => {
ensureSceneEncounterPreviewMock.mockReset();
setRuntimeCustomWorldProfile(null);
});
it('rebuilds revived first-scene state through encounter preview restoration', () => {
const reviveState = createReviveState();
const previewRestoredState = {
...reviveState,
currentEncounter: {
id: 'npc-front',
kind: 'npc' as const,
characterId: 'npc-front',
npcName: '正面对手',
npcDescription: '正面对手的测试描述',
npcAvatar: '正',
context: '敌对角色',
xMeters: 12,
},
};
ensureSceneEncounterPreviewMock.mockReturnValue(previewRestoredState);
const revived = buildRevivedFirstSceneState(reviveState);
expect(ensureSceneEncounterPreviewMock).toHaveBeenCalledWith(
expect.objectContaining({
currentScenePreset: expect.objectContaining({
id: 'custom-scene-camp',
}),
currentEncounter: null,
sceneHostileNpcs: [],
playerHp: 100,
playerMana: 20,
inBattle: false,
currentNpcBattleOutcome: null,
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
currentActId: 'custom-scene-camp-act-1',
currentActIndex: 0,
}),
}),
}),
);
expect(revived).toBe(previewRestoredState);
});
});

View File

@@ -1,229 +0,0 @@
import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
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,
});
const revivedBaseState = {
...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,
},
} satisfies GameState;
// 中文注释:角色复活后要回到“开局进入世界”同一套首幕 encounter preview
// 构建链,而不是只清空战斗态。这样第一幕主交互 NPC 与同幕陪衬 NPC
// 会按既有槽位一起恢复,避免退化成所有人站成一排。
return ensureSceneEncounterPreview(revivedBaseState);
}
export function buildDeathStory(
state: GameState,
deferredOptions?: StoryOption[],
): StoryMoment {
const firstSceneName =
state.worldType
? getScenePresetsByWorld(state.worldType)[0]?.name
: state.currentScenePreset?.name;
return {
text: firstSceneName
? `你在战斗中倒下,随后在${firstSceneName}重新醒来。`
: '你在战斗中倒下,随后重新醒来。',
options: [buildContinueOption()],
// 中文注释:复活后的“继续前进”只负责揭示已经准备好的首场景入口,
// 不能再次触发普通剧情推演,否则容易直接被推进到主 NPC 执行态。
deferredOptions:
deferredOptions && deferredOptions.length > 0
? deferredOptions
: undefined,
streaming: false,
};
}

View File

@@ -1,86 +1,9 @@
import type { Dispatch, SetStateAction } from 'react';
import {
acceptQuest,
buildChapterQuestForScene,
getChapterQuestForScene,
} from '../../data/questFlow';
import { resolveSceneChapterBlueprint } from '../../services/customWorldSceneActRuntime';
import {
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from '../../services/storyEngine/actorNarrativeProfile';
import { resolveCurrentActState } from '../../services/storyEngine/actPlanner';
import { buildAuthorialConstraintPack } from '../../services/storyEngine/authorialConstraintPack';
import { evaluateBranchBudget } from '../../services/storyEngine/branchBudgetPlanner';
import {
advanceCampaignState,
resolveCampaignState,
} from '../../services/storyEngine/campaignDirector';
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
import {
buildCampEvent,
evaluateCampEventOpportunity,
} from '../../services/storyEngine/campEventDirector';
import {
advanceChapterState,
resolveCurrentChapterState,
} from '../../services/storyEngine/chapterDirector';
import {
advanceCompanionArc,
buildCompanionArcStates,
} from '../../services/storyEngine/companionArcDirector';
import {
applyCompanionReactionToStance,
buildCompanionReactionBatch,
} from '../../services/storyEngine/companionReactionDirector';
import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector';
import { appendConsequenceRecord } from '../../services/storyEngine/consequenceLedger';
import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport';
import { resolveEndingState } from '../../services/storyEngine/endingResolver';
import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer';
import { buildFactionTensionState } from '../../services/storyEngine/factionTensionState';
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
import { buildNarrativeCodex } from '../../services/storyEngine/narrativeCodex';
import { runNarrativeConsistencyChecks } from '../../services/storyEngine/narrativeConsistencyChecks';
import { buildNarrativeQaReport } from '../../services/storyEngine/narrativeQaReport';
import {
recordReplaySeed,
replayNarrativeRun,
} from '../../services/storyEngine/narrativeRegressionReplay';
import { captureNarrativeTelemetry } from '../../services/storyEngine/narrativeTelemetry';
import { updatePlayerStyleProfileFromAction } from '../../services/storyEngine/playerStyleProfiler';
import { runPlaythroughMatrix } from '../../services/storyEngine/playthroughMatrixLab';
import { buildContinueGameDigest } from '../../services/storyEngine/recapDigest';
import { buildReleaseGateReport } from '../../services/storyEngine/releaseGateReport';
import { buildSaveMigrationManifest } from '../../services/storyEngine/saveMigrationManifest';
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
import {
buildSetpieceDirective,
evaluateSetpieceOpportunity,
} from '../../services/storyEngine/setpieceDirector';
import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle';
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract';
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
import {
collectStorySignals,
resolveSignalsToThreadUpdates,
} from '../../services/storyEngine/threadSignalRouter';
import {
buildEncounterVisibilitySlice,
createEmptyStoryEngineMemoryState,
} from '../../services/storyEngine/visibilityEngine';
import {
applyWorldMutationsToGameState,
resolveWorldMutations,
} from '../../services/storyEngine/worldMutationRouter';
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
@@ -93,516 +16,6 @@ import type { CommitGeneratedState } from '../generatedState';
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
const ENCOUNTER_ENTRY_TICK_MS = 180;
function dedupeStrings(values: Array<string | null | undefined>, limit = 10) {
return [
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
].slice(0, limit);
}
function hydrateStoryEngineMemory(state: GameState): GameState {
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
if (!state.customWorldProfile || state.currentEncounter?.kind !== 'npc') {
return {
...state,
storyEngineMemory,
};
}
const role =
state.customWorldProfile.storyNpcs.find(
(npc) =>
npc.id === state.currentEncounter?.id ||
npc.name === state.currentEncounter?.npcName,
) ??
state.customWorldProfile.playableNpcs.find(
(npc) =>
npc.id === state.currentEncounter?.id ||
npc.name === state.currentEncounter?.npcName,
);
if (!role) {
return {
...state,
storyEngineMemory,
};
}
const themePack =
state.customWorldProfile.themePack ??
buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph ??
buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
const narrativeProfile = normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
const npcState =
state.npcStates[
state.currentEncounter.id ?? state.currentEncounter.npcName
];
const activeThreadIds =
storyEngineMemory.activeThreadIds.length > 0
? storyEngineMemory.activeThreadIds
: narrativeProfile.relatedThreadIds.slice(0, 4);
const visibilitySlice = buildEncounterVisibilitySlice({
narrativeProfile,
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
disclosureStage:
npcState?.affinity != null
? npcState.affinity < 15
? 'guarded'
: npcState.affinity < 45
? 'partial'
: npcState.affinity < 75
? 'honest'
: 'deep'
: 'guarded',
isFirstMeaningfulContact: npcState?.firstMeaningfulContactResolved !== true,
seenBackstoryChapterIds: npcState?.seenBackstoryChapterIds ?? [],
storyEngineMemory,
activeThreadIds,
});
return {
...state,
storyEngineMemory: {
...storyEngineMemory,
discoveredFactIds: dedupeStrings(
[
...storyEngineMemory.discoveredFactIds,
...visibilitySlice.sayableFactIds,
],
16,
),
activeThreadIds: dedupeStrings(
[...storyEngineMemory.activeThreadIds, ...activeThreadIds],
6,
),
},
};
}
function findNewInventoryItems(previousState: GameState, nextState: GameState) {
const previousIds = new Set(
previousState.playerInventory.map((item) => item.id),
);
return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
}
function ensureSceneChapterQuestState(params: {
previousState: GameState;
nextState: GameState;
}) {
const storyEngineMemory =
params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const scene = params.nextState.currentScenePreset;
if (
params.nextState.currentScene !== 'Story' ||
!params.nextState.worldType ||
!scene?.id
) {
return {
...params.nextState,
storyEngineMemory,
};
}
const openedSceneChapterIds = dedupeStrings(
[...(storyEngineMemory.openedSceneChapterIds ?? [])],
64,
);
if (openedSceneChapterIds.includes(scene.id)) {
return {
...params.nextState,
storyEngineMemory: {
...storyEngineMemory,
openedSceneChapterIds,
currentSceneActState:
buildInitialSceneActRuntimeState({
profile: params.nextState.customWorldProfile,
sceneId: scene.id,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
},
};
}
const nextMemory = {
...storyEngineMemory,
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
currentSceneActState:
buildInitialSceneActRuntimeState({
profile: params.nextState.customWorldProfile,
sceneId: scene.id,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
};
const existingChapterQuest = getChapterQuestForScene(
params.nextState.quests,
scene.id,
);
if (existingChapterQuest) {
return {
...params.nextState,
storyEngineMemory: nextMemory,
};
}
const sceneChapter = resolveSceneChapterBlueprint(
params.nextState.customWorldProfile,
scene.id,
);
const sceneChapterContext = sceneChapter
? {
sceneTaskDescription: sceneChapter.sceneTaskDescription,
actEventDescriptions: sceneChapter.acts
.map((act) => act.eventDescription)
.filter(Boolean),
primaryNpcName:
params.nextState.customWorldProfile?.storyNpcs.find(
(npc) => npc.id === sceneChapter.acts[0]?.primaryNpcId,
)?.name ?? sceneChapter.acts[0]?.primaryNpcId ?? null,
}
: null;
const chapterQuest = buildChapterQuestForScene({
scene,
worldType: params.nextState.worldType,
sceneChapterContext,
context: {
worldType: params.nextState.worldType,
actState: params.nextState.storyEngineMemory?.actState ?? null,
recentStoryMoments: params.nextState.storyHistory.slice(-6),
playerCharacter: params.nextState.playerCharacter,
playerProgression: params.nextState.playerProgression ?? null,
},
});
if (!chapterQuest) {
return {
...params.nextState,
storyEngineMemory: nextMemory,
};
}
return {
...params.nextState,
storyEngineMemory: nextMemory,
quests: acceptQuest(params.nextState.quests, chapterQuest),
};
}
function applyStoryEngineEchoes(params: {
previousState: GameState;
nextState: GameState;
actionText: string;
lastFunctionId?: string | null;
}) {
const hydratedState = hydrateStoryEngineMemory(params.nextState);
const contracts = hydratedState.customWorldProfile
? (hydratedState.customWorldProfile.threadContracts ??
buildThreadContractsFromProfile(hydratedState.customWorldProfile))
: [];
const newItems = findNewInventoryItems(params.previousState, hydratedState);
const signals = collectStorySignals({
prevState: params.previousState,
nextState: hydratedState,
actionText: params.actionText,
lastFunctionId: params.lastFunctionId,
rewardItems: newItems,
});
const stateWithSignals = resolveSignalsToThreadUpdates({
state: hydratedState,
signals,
contracts,
});
const stateWithSceneChapter = ensureSceneChapterQuestState({
previousState: params.previousState,
nextState: stateWithSignals,
});
const reactions = buildCompanionReactionBatch({
state: stateWithSceneChapter,
signals,
actionText: params.actionText,
});
const stateWithReactions = applyCompanionReactionToStance({
state: stateWithSceneChapter,
reactions,
});
const storyEngineMemory =
stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const chapterState = advanceChapterState({
previousChapter:
stateWithReactions.chapterState ??
storyEngineMemory.currentChapter ??
null,
nextChapter: resolveCurrentChapterState({
state: stateWithReactions,
}),
});
const journeyBeat = resolveCurrentJourneyBeat({
state: {
...stateWithReactions,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
},
},
chapterState,
});
const companionArcStates = advanceCompanionArc({
previous: storyEngineMemory.companionArcStates,
next: buildCompanionArcStates({
state: stateWithReactions,
reactions,
}),
});
const campEvent = evaluateCampEventOpportunity({
state: stateWithReactions,
chapterState,
journeyBeat,
companionArcStates,
})
? buildCampEvent({
state: stateWithReactions,
chapterState,
journeyBeat,
companionArcStates,
})
: null;
const worldMutations = resolveWorldMutations({
state: stateWithReactions,
signals,
chapterState,
});
const stateWithMutations = applyWorldMutationsToGameState({
state: stateWithReactions,
mutations: worldMutations,
});
const setpieceDirective = evaluateSetpieceOpportunity({
state: stateWithMutations,
chapterState,
journeyBeat,
})
? buildSetpieceDirective({
state: stateWithMutations,
chapterState,
journeyBeat,
})
: null;
const chronicle = appendChronicleEntries({
state: stateWithMutations,
chapterState,
worldMutations,
reactions,
signals,
campEvent,
setpieceDirective,
});
const factionTensionStates = buildFactionTensionState(
stateWithMutations.customWorldProfile,
storyEngineMemory,
);
const actState = resolveCurrentActState({
state: stateWithMutations,
chapterState,
});
const campaignState = advanceCampaignState({
previous:
storyEngineMemory.campaignState ??
stateWithMutations.campaignState ??
null,
next: resolveCampaignState({
state: stateWithMutations,
actState,
}),
});
const consequenceLedger = appendConsequenceRecord({
existing: storyEngineMemory.consequenceLedger,
signals,
reactions,
worldMutations,
campEvent,
});
const authorialConstraintPack = buildAuthorialConstraintPack({
profile: stateWithMutations.customWorldProfile,
});
const compiledPacks = stateWithMutations.customWorldProfile
? compileCampaignFromWorldProfile({
profile: stateWithMutations.customWorldProfile,
})
: null;
const activeScenarioPack =
resolveScenarioPack(stateWithMutations.activeScenarioPackId) ??
compiledPacks?.scenarioPack ??
null;
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
const playerStyleProfile = updatePlayerStyleProfileFromAction({
current: storyEngineMemory.playerStyleProfile,
actionText: params.actionText,
});
const companionResolutions = resolveAllCompanionResolutions({
state: stateWithMutations,
arcStates: companionArcStates,
ledger: consequenceLedger,
reactions,
});
const endingState =
actState?.status === 'finale' || actState?.status === 'resolved'
? resolveEndingState({
state: stateWithMutations,
companionResolutions,
factionTensionStates,
})
: (storyEngineMemory.endingState ?? null);
const epilogueSummary = endingState
? buildEpilogueSummary({
endingState,
companionResolutions,
})
: null;
const currentJourneyBeatId =
journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null;
const branchBudgetStatus = evaluateBranchBudget({
consequenceLedger,
authorialConstraintPack,
endingFamilyCount: endingState ? 1 : 0,
});
const baseMemoryForQa = {
...storyEngineMemory,
currentChapter: chapterState,
currentJourneyBeatId,
currentJourneyBeat: journeyBeat,
companionArcStates,
worldMutations,
chronicle,
factionTensionStates,
currentCampEvent: campEvent,
currentSetpieceDirective: setpieceDirective,
campaignState,
actState,
consequenceLedger,
companionResolutions,
endingState,
authorialConstraintPack,
branchBudgetStatus,
playerStyleProfile,
};
const consistencyIssues = runNarrativeConsistencyChecks({
memory: baseMemoryForQa,
threadContracts: contracts,
branchBudgetStatus,
});
const narrativeQaReport = buildNarrativeQaReport({
issues: consistencyIssues,
});
const simulationRunResults =
activeScenarioPack && activeCampaignPack
? runPlaythroughMatrix({
scenarioPackId: activeScenarioPack.id,
campaignPack: activeCampaignPack,
memory: {
...baseMemoryForQa,
narrativeQaReport,
},
seeds: ['baseline', 'companion', 'explore'],
})
: [];
const replaySummary = simulationRunResults[0]
? replayNarrativeRun({
recordedSeed: recordReplaySeed({
seed: simulationRunResults[0].seed,
label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`,
}),
result: simulationRunResults[0],
}).summary
: null;
const releaseGateReport = buildReleaseGateReport({
qaReport: narrativeQaReport,
simulationResults: simulationRunResults,
unresolvedThreadCount:
stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0,
});
const saveMigrationManifest = buildSaveMigrationManifest({
version: 'story-engine-v5',
});
const telemetrySnapshot = captureNarrativeTelemetry({
memory: {
...baseMemoryForQa,
narrativeQaReport,
},
qaReport: narrativeQaReport,
});
const contentDiffReport = buildContentDiffReport({
previousProfile: params.previousState.customWorldProfile,
nextProfile: stateWithMutations.customWorldProfile,
previousCampaignPack: null,
nextCampaignPack: activeCampaignPack,
});
const narrativeCodex = buildNarrativeCodex({
...stateWithMutations,
chapterState,
campaignState,
storyEngineMemory: {
...baseMemoryForQa,
narrativeQaReport,
releaseGateReport,
simulationRunResults,
},
});
const continueDigest =
buildContinueGameDigest({
state: {
...stateWithMutations,
chapterState,
campaignState,
storyEngineMemory: {
...baseMemoryForQa,
currentJourneyBeatId,
narrativeQaReport,
releaseGateReport,
simulationRunResults,
narrativeCodex,
saveMigrationManifest,
},
},
}) +
[
epilogueSummary,
replaySummary,
telemetrySnapshot.summary,
contentDiffReport.summary,
`发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`,
]
.filter(Boolean)
.join('\n');
return {
...stateWithMutations,
chapterState,
campaignState,
activeScenarioPackId:
activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null,
activeCampaignPackId:
activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null,
storyEngineMemory: {
...baseMemoryForQa,
currentJourneyBeatId,
continueGameDigest: continueDigest,
narrativeQaReport,
narrativeCodex,
releaseGateReport,
simulationRunResults,
saveMigrationManifest,
recentCompanionReactions: [
...(storyEngineMemory.recentCompanionReactions ?? []),
...reactions,
].slice(-6),
},
};
}
export type GenerateStoryForState = (params: {
state: GameState;
character: Character;
@@ -664,15 +77,10 @@ export function createStoryProgressionActions({
lastFunctionId,
) => {
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState,
nextState: {
...nextState,
storyHistory: nextHistory,
} as GameState,
actionText,
lastFunctionId,
});
const stateWithHistory = {
...nextState,
storyHistory: nextHistory,
} as GameState;
setGameState(stateWithHistory);
setAiError(null);
@@ -686,13 +94,7 @@ export function createStoryProgressionActions({
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryEngineEchoes({
previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory),
actionText,
lastFunctionId,
});
setGameState(recoveredState);
setGameState(stateWithHistory);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue scripted story:', error);
@@ -744,15 +146,10 @@ export function createStoryProgressionActions({
}
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState,
nextState: {
...resolvedState,
storyHistory: nextHistory,
} as GameState,
actionText,
lastFunctionId,
});
const stateWithHistory = {
...resolvedState,
storyHistory: nextHistory,
} as GameState;
setGameState(stateWithHistory);
@@ -764,13 +161,7 @@ export function createStoryProgressionActions({
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryEngineEchoes({
previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory),
actionText,
lastFunctionId,
});
setGameState(recoveredState);
setGameState(stateWithHistory);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue encounter-entry story:', error);

View File

@@ -1,9 +1,3 @@
import { createNpcBattleMonster } from '../../data/npcInteractions';
import {
buildNpcBattleFormationFromEncounter,
RESOLVED_ENTITY_X_METERS,
} from '../../data/sceneEncounterPreviews';
import { getForwardScenePreset } from '../../data/scenePresets';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
@@ -14,94 +8,8 @@ import {
resolveRpgRuntimeStoryMoment,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
type RuntimeStorySnapshotRequest,
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import type { GameState, SceneHostileNpc, StoryMoment, StoryOption } from '../../types';
import { buildMapTravelResolution } from './storyGenerationState';
function isNpcBattleAlignmentDebugEnabled() {
if (typeof window === 'undefined') {
return false;
}
return (
window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' ||
window.location.search.includes('npcBattleAlignmentDebug=1')
);
}
function logNpcBattleAlignment(label: string, monsters: GameState['sceneHostileNpcs']) {
if (!isNpcBattleAlignmentDebugEnabled()) {
return;
}
console.info(
`[npc-battle-alignment] ${label}`,
monsters.map((monster) => ({
id: monster.id,
encounterId: monster.encounter?.id ?? null,
encounterName: monster.encounter?.npcName ?? null,
xMeters: monster.xMeters,
yOffset: monster.yOffset,
facing: monster.facing,
animation: monster.animation,
})),
);
}
function cloneBattleFormation(monsters: GameState['sceneHostileNpcs']) {
return monsters.map(
(monster) =>
({
...monster,
encounter: monster.encounter
? {
...monster.encounter,
}
: monster.encounter,
}) satisfies SceneHostileNpc,
);
}
function alignBattleFormationToVisibleFormation(params: {
visibleFormation: GameState['sceneHostileNpcs'];
battleFormation: GameState['sceneHostileNpcs'];
}) {
const { visibleFormation, battleFormation } = params;
if (visibleFormation.length === 0 || battleFormation.length === 0) {
return battleFormation;
}
const visibleFormationByEncounterId = new Map(
visibleFormation.map((monster) => [
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id,
monster,
]),
);
return battleFormation.map((monster) => {
const encounterKey =
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id;
const visibleMonster = visibleFormationByEncounterId.get(encounterKey);
if (!visibleMonster) {
return monster;
}
return {
...monster,
xMeters: visibleMonster.xMeters,
yOffset: visibleMonster.yOffset,
facing: visibleMonster.facing,
encounter: monster.encounter
? {
...monster.encounter,
xMeters:
visibleMonster.encounter?.xMeters ?? visibleMonster.xMeters,
}
: monster.encounter,
} satisfies SceneHostileNpc;
});
}
import type { GameState, StoryMoment, StoryOption } from '../../types';
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
return response.viewModel.availableOptions.length > 0
@@ -109,209 +17,6 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
: response.presentation.options;
}
function buildRuntimeSnapshotRequest(
gameState: GameState,
currentStory: StoryMoment | null,
): RuntimeStorySnapshotRequest {
return {
gameState,
bottomTab: 'adventure',
currentStory,
};
}
function resolveServerTravelTargetSceneId(params: {
previousState: GameState;
snapshotState: GameState;
}) {
const { previousState, snapshotState } = params;
const snapshotSceneId = snapshotState.currentScenePreset?.id ?? null;
if (
snapshotSceneId &&
snapshotSceneId !== previousState.currentScenePreset?.id
) {
return snapshotSceneId;
}
if (!previousState.worldType) {
return null;
}
return (
getForwardScenePreset(
previousState.worldType,
previousState.currentScenePreset?.id,
)?.id ??
previousState.currentScenePreset?.forwardSceneId ??
null
);
}
function bridgeServerSceneTravelSnapshot(params: {
previousState: GameState;
hydratedSnapshot: HydratedSavedGameSnapshot;
functionId: string;
}) {
const { previousState, hydratedSnapshot, functionId } = params;
if (functionId !== 'idle_travel_next_scene' || !previousState.worldType) {
return hydratedSnapshot;
}
const targetSceneId = resolveServerTravelTargetSceneId({
previousState,
snapshotState: hydratedSnapshot.gameState,
});
if (!targetSceneId) {
return hydratedSnapshot;
}
const travelResolution = buildMapTravelResolution(previousState, targetSceneId);
if (!travelResolution) {
return hydratedSnapshot;
}
return {
...hydratedSnapshot,
gameState: {
...hydratedSnapshot.gameState,
// 中文注释:服务端 compat 当前只保证“本轮旅行动作已经结算完成”,
// 前端这里复用既有地图旅行真相,补齐下一幕场景 preset、遭遇预览和任务推进结果。
currentScenePreset: travelResolution.nextState.currentScenePreset,
currentEncounter: travelResolution.nextState.currentEncounter,
npcInteractionActive: travelResolution.nextState.npcInteractionActive,
sceneHostileNpcs: travelResolution.nextState.sceneHostileNpcs,
playerX: travelResolution.nextState.playerX,
playerFacing: travelResolution.nextState.playerFacing,
animationState: travelResolution.nextState.animationState,
playerActionMode: travelResolution.nextState.playerActionMode,
activeCombatEffects: travelResolution.nextState.activeCombatEffects,
scrollWorld: travelResolution.nextState.scrollWorld,
inBattle: travelResolution.nextState.inBattle,
lastObserveSignsSceneId: travelResolution.nextState.lastObserveSignsSceneId,
lastObserveSignsReport: travelResolution.nextState.lastObserveSignsReport,
currentBattleNpcId: travelResolution.nextState.currentBattleNpcId,
currentNpcBattleMode: travelResolution.nextState.currentNpcBattleMode,
currentNpcBattleOutcome: travelResolution.nextState.currentNpcBattleOutcome,
sparReturnEncounter: travelResolution.nextState.sparReturnEncounter,
sparPlayerHpBefore: travelResolution.nextState.sparPlayerHpBefore,
sparPlayerMaxHpBefore: travelResolution.nextState.sparPlayerMaxHpBefore,
sparStoryHistoryBefore: travelResolution.nextState.sparStoryHistoryBefore,
runtimeStats: {
...hydratedSnapshot.gameState.runtimeStats,
scenesTraveled:
travelResolution.nextState.runtimeStats.scenesTraveled,
},
quests:
hydratedSnapshot.gameState.quests.length > 0
? hydratedSnapshot.gameState.quests
: travelResolution.nextState.quests,
},
} satisfies HydratedSavedGameSnapshot;
}
function bridgeServerNpcBattleSnapshot(params: {
previousState: GameState;
hydratedSnapshot: HydratedSavedGameSnapshot;
functionId: string;
}) {
const { previousState, hydratedSnapshot, functionId } = params;
if (functionId !== 'npc_fight' && functionId !== 'npc_spar') {
return hydratedSnapshot;
}
const snapshotState = hydratedSnapshot.gameState;
const isNpcBattleActive =
snapshotState.inBattle &&
Boolean(snapshotState.currentBattleNpcId) &&
Boolean(snapshotState.currentNpcBattleMode);
const hasResolvedBattleMonster = snapshotState.sceneHostileNpcs.length > 0;
const sourceEncounter =
previousState.currentEncounter?.kind === 'npc'
? previousState.currentEncounter
: null;
// 中文注释:作品测试/幕预览里最容易出现的错位,是服务端已经把
// currentBattleNpcId / currentNpcBattleMode 切进战斗,但快照里没有把
// sceneHostileNpcs 一起带回。这样前端本地 battlePlan 会直接判定
// “场上没有敌人”,点击 battle_* 后立刻把整场战斗收掉。
// 这里统一在网关层补齐 NPC 战场快照,保证后续本地逐轮回合一定有敌方单位可结算。
if (!isNpcBattleActive || !sourceEncounter) {
return hydratedSnapshot;
}
const fallbackNpcState =
snapshotState.npcStates[
snapshotState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
] ??
previousState.npcStates[
previousState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
] ?? {
affinity: sourceEncounter.initialAffinity ?? (sourceEncounter.hostile ? -10 : 0),
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
};
const battleMode =
snapshotState.currentNpcBattleMode === 'spar' ? 'spar' : 'fight';
const fallbackFormationFromSceneAct = buildNpcBattleFormationFromEncounter({
state: previousState,
encounter: {
...sourceEncounter,
xMeters: sourceEncounter.xMeters ?? RESOLVED_ENTITY_X_METERS,
},
mode: battleMode,
});
const fallbackFormation =
previousState.sceneHostileNpcs.length > 0
? cloneBattleFormation(previousState.sceneHostileNpcs)
: fallbackFormationFromSceneAct.length > 0
? fallbackFormationFromSceneAct
: [
createNpcBattleMonster(
sourceEncounter,
fallbackNpcState,
battleMode,
{
worldType: snapshotState.worldType,
customWorldProfile: snapshotState.customWorldProfile,
},
),
];
const resolvedBattleFormation = hasResolvedBattleMonster
? alignBattleFormationToVisibleFormation({
visibleFormation: previousState.sceneHostileNpcs,
battleFormation: snapshotState.sceneHostileNpcs,
})
: fallbackFormation;
logNpcBattleAlignment('previous-visible-formation', previousState.sceneHostileNpcs);
logNpcBattleAlignment('server-battle-formation', snapshotState.sceneHostileNpcs);
logNpcBattleAlignment('resolved-battle-formation', resolvedBattleFormation);
return {
...hydratedSnapshot,
gameState: {
...snapshotState,
// 中文注释:优先沿用进入战斗前已经可见的阵容与站位;
// 若上一帧还没有 battle combatants则从幕预览/当前遭遇恢复完整 NPC 编队,
// 避免只补出一个前排角色,造成后排消失和敌方位置突变。
sceneHostileNpcs: resolvedBattleFormation,
currentEncounter: null,
npcInteractionActive: false,
// 中文注释:服务端兼容链路若未带回战前遭遇,则沿用进入战斗前的原始 encounter
// 让后续 fight_victory / spar_complete 都能恢复到正确站位,而不是战斗中的临时坐标。
sparReturnEncounter:
snapshotState.sparReturnEncounter ??
(previousState.currentEncounter?.kind === 'npc'
? previousState.currentEncounter
: null),
},
} satisfies HydratedSavedGameSnapshot;
}
/**
* 前端访问服务端 runtime story 的统一网关。
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
@@ -320,10 +25,11 @@ export async function loadServerRuntimeOptionCatalog(params: {
gameState: GameState;
currentStory: StoryMoment | null;
}) {
// 中文注释:状态目录只从服务端持久化 session 读取,
// 前端不再上传本地 GameState 快照参与动作合法性解析。
const response = await getRpgRuntimeStoryState({
sessionId: getRpgRuntimeSessionId(params.gameState),
clientVersion: getRpgRuntimeClientVersion(params.gameState),
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
});
const options = resolveRpgRuntimeStoryMoment({
response,
@@ -351,6 +57,8 @@ export async function resumeServerRuntimeStory(
};
}
// 中文注释:继续游戏后向服务端刷新一次状态,
// 让长期离线的本地快照重新对齐服务端当前 runtime view model。
const response = await getRpgRuntimeStoryState({
sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState),
});
@@ -383,6 +91,8 @@ export async function resolveServerRuntimeChoice(params: {
Partial<Pick<StoryOption, 'interaction'>>;
payload?: RuntimeStoryChoicePayload;
}) {
// 中文注释:正式动作结算统一先走服务端;
// 前端这里只提交 action/payload并消费后端已经补齐的快照与表现数据。
const response = await resolveRpgRuntimeStoryAction({
sessionId: getRpgRuntimeSessionId(params.gameState),
clientVersion: getRpgRuntimeClientVersion(params.gameState),
@@ -392,17 +102,8 @@ export async function resolveServerRuntimeChoice(params: {
? params.option.interaction.npcId
: undefined,
payload: params.payload,
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
});
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
previousState: params.gameState,
hydratedSnapshot: bridgeServerNpcBattleSnapshot({
previousState: params.gameState,
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
functionId: params.option.functionId,
}),
functionId: params.option.functionId,
});
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
return {
response,

View File

@@ -257,7 +257,7 @@ describe('runtimeStoryCoordinator', () => {
getRuntimeClientVersionMock.mockReturnValue(7);
});
it('loads runtime option catalogs through the persisted server snapshot flow', async () => {
it('loads runtime option catalogs through the persisted server state flow', async () => {
const gameState = createGameState();
const currentStory = createStory('当前故事');
@@ -311,11 +311,6 @@ describe('runtimeStoryCoordinator', () => {
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState,
bottomTab: 'adventure',
currentStory,
},
});
expect(options).toEqual([
expect.objectContaining({
@@ -416,11 +411,6 @@ describe('runtimeStoryCoordinator', () => {
payload: {
note: 'server-runtime-test',
},
snapshot: {
gameState,
bottomTab: 'adventure',
currentStory,
},
});
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
expect(result.nextStory).toEqual(
@@ -653,7 +643,7 @@ describe('runtimeStoryCoordinator', () => {
);
});
it('backfills npc battle monsters when npc_fight snapshot marks battle active but omits sceneHostileNpcs', async () => {
it('does not patch incomplete npc_fight snapshots in the frontend gateway', async () => {
const gameState = {
...createTravelGameState(),
currentEncounter: {
@@ -753,419 +743,17 @@ describe('runtimeStoryCoordinator', () => {
option,
});
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toHaveLength(1);
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toEqual([]);
expect(result.hydratedSnapshot.gameState.currentEncounter).toEqual(
expect.objectContaining({
encounter: expect.objectContaining({
id: 'npc-bandit',
npcName: '断桥匪首',
}),
renderKind: 'npc',
id: 'npc-bandit',
npcName: '断桥匪首',
}),
);
expect(result.hydratedSnapshot.gameState.currentEncounter).toBeNull();
expect(result.hydratedSnapshot.gameState.npcInteractionActive).toBe(false);
});
it('preserves previous hostile formation when npc_fight snapshot omits battle members', async () => {
const gameState = {
...createTravelGameState(),
currentEncounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
initialAffinity: -20,
},
npcInteractionActive: true,
sceneHostileNpcs: [
{
id: 'npc-opponent-npc-front',
name: '正面对手',
action: '摆开架势,随时准备出手',
description: '正面对手',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.8,
speed: 8,
hp: 88,
maxHp: 88,
renderKind: 'npc',
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
xMeters: 3.2,
},
},
{
id: 'npc-opponent-npc-back-1',
name: '后排甲',
action: '摆开架势,随时准备出手',
description: '后排甲',
animation: 'idle',
xMeters: 4.28,
yOffset: 62,
facing: 'left',
attackRange: 1.8,
speed: 7,
hp: 76,
maxHp: 76,
renderKind: 'npc',
encounter: {
id: 'npc-back-1',
kind: 'npc',
npcName: '后排甲',
npcDescription: '后排甲',
npcAvatar: '/npc-back-1.png',
context: '桥口',
hostile: true,
xMeters: 4.28,
},
},
] as GameState['sceneHostileNpcs'],
} as GameState;
const currentStory = createStory('当前故事');
const option = {
functionId: 'npc_fight',
actionText: '直接开战',
text: '直接开战',
interaction: {
kind: 'npc',
npcId: 'npc-front',
action: 'fight',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 42,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
hostile: true,
affinity: -20,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_attack_basic',
actionText: '普通攻击',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '直接开战',
resultText: '当前冲突正式转入战斗结算。',
storyText: '正面对手带着同伴压了上来。',
options: [],
},
patches: [],
snapshot: createRuntimeNpcBattleSnapshot({
currentEncounter: {
kind: 'npc',
id: 'npc-front',
npcName: '正面对手',
npcDescription: '正面对手',
context: '桥口',
hostile: true,
} as GameState['currentEncounter'],
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: true,
currentBattleNpcId: 'npc-front',
currentNpcBattleMode: 'fight',
}),
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
});
expect(
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
encounterId: monster.encounter?.id,
xMeters: monster.xMeters,
yOffset: monster.yOffset,
})),
).toEqual([
{
encounterId: 'npc-front',
xMeters: 3.2,
yOffset: 0,
},
{
encounterId: 'npc-back-1',
xMeters: 4.28,
yOffset: 62,
},
]);
expect(result.hydratedSnapshot.gameState.sparReturnEncounter).toEqual(
gameState.currentEncounter,
);
});
it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => {
const gameState = {
...createTravelGameState(),
currentEncounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
initialAffinity: -20,
},
npcInteractionActive: true,
sceneHostileNpcs: [
{
id: 'npc-opponent-npc-front',
name: '正面对手',
action: '摆开架势,随时准备出手',
description: '正面对手',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.8,
speed: 8,
hp: 88,
maxHp: 88,
renderKind: 'npc',
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
xMeters: 3.2,
},
},
{
id: 'npc-opponent-npc-back-1',
name: '后排甲',
action: '摆开架势,随时准备出手',
description: '后排甲',
animation: 'idle',
xMeters: 4.28,
yOffset: 62,
facing: 'left',
attackRange: 1.8,
speed: 7,
hp: 76,
maxHp: 76,
renderKind: 'npc',
encounter: {
id: 'npc-back-1',
kind: 'npc',
npcName: '后排甲',
npcDescription: '后排甲',
npcAvatar: '/npc-back-1.png',
context: '桥口',
hostile: true,
xMeters: 4.28,
},
},
] as GameState['sceneHostileNpcs'],
} as GameState;
const currentStory = createStory('当前故事');
const option = {
functionId: 'npc_fight',
actionText: '直接开战',
text: '直接开战',
interaction: {
kind: 'npc',
npcId: 'npc-front',
action: 'fight',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 42,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
hostile: true,
affinity: -20,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_attack_basic',
actionText: '普通攻击',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '直接开战',
resultText: '当前冲突正式转入战斗结算。',
storyText: '正面对手带着同伴压了上来。',
options: [],
},
patches: [],
snapshot: createRuntimeNpcBattleSnapshot({
currentEncounter: {
kind: 'npc',
id: 'npc-front',
npcName: '正面对手',
npcDescription: '正面对手',
context: '桥口',
hostile: true,
} as GameState['currentEncounter'],
npcInteractionActive: false,
sceneHostileNpcs: [
{
id: 'npc-opponent-npc-front',
name: '正面对手',
action: '摆开架势,随时准备出手',
description: '正面对手',
animation: 'idle',
xMeters: 1.4,
yOffset: 0,
facing: 'left',
attackRange: 1.8,
speed: 8,
hp: 88,
maxHp: 88,
renderKind: 'npc',
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
xMeters: 1.4,
},
},
{
id: 'npc-opponent-npc-back-1',
name: '后排甲',
action: '摆开架势,随时准备出手',
description: '后排甲',
animation: 'idle',
xMeters: 2.1,
yOffset: 16,
facing: 'left',
attackRange: 1.8,
speed: 7,
hp: 76,
maxHp: 76,
renderKind: 'npc',
encounter: {
id: 'npc-back-1',
kind: 'npc',
npcName: '后排甲',
npcDescription: '后排甲',
npcAvatar: '/npc-back-1.png',
context: '桥口',
hostile: true,
xMeters: 2.1,
},
},
] as GameState['sceneHostileNpcs'],
inBattle: true,
currentBattleNpcId: 'npc-front',
currentNpcBattleMode: 'fight',
}),
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
});
expect(
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
encounterId: monster.encounter?.id,
xMeters: monster.xMeters,
yOffset: monster.yOffset,
})),
).toEqual([
{
encounterId: 'npc-front',
xMeters: 3.2,
yOffset: 0,
},
{
encounterId: 'npc-back-1',
xMeters: 4.28,
yOffset: 62,
},
]);
});
it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => {
it('uses idle_travel_next_scene snapshots as returned by the backend resolver', async () => {
const gameState = createTravelGameState();
const currentStory = createStory('桥口这一段已经收束。');
const option = {
@@ -1247,13 +835,13 @@ describe('runtimeStoryCoordinator', () => {
);
expect(
result.hydratedSnapshot.gameState.runtimeStats.scenesTraveled,
).toBe(1);
).toBe(0);
expect(
Boolean(
result.hydratedSnapshot.gameState.currentEncounter ||
result.hydratedSnapshot.gameState.sceneHostileNpcs.length > 0,
),
).toBe(true);
).toBe(false);
});
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {

View File

@@ -176,7 +176,7 @@ describe('sessionActions', () => {
expect(rewardClaim).toHaveProperty('handoff');
});
it('refreshes chapter state after a chapter quest is turned in', () => {
it('does not rewrite backend-owned chapter state after a chapter quest is turned in', () => {
const baseState = {
...createBaseState(),
currentScenePreset: {
@@ -243,7 +243,7 @@ describe('sessionActions', () => {
throw new Error('Expected reward claim result');
}
expect(rewardClaim.nextState.chapterState?.stage).toBe('aftermath');
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('aftermath');
expect(rewardClaim.nextState.chapterState?.stage).toBe('climax');
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('climax');
});
});

View File

@@ -9,13 +9,7 @@ import {
markQuestCompletionNotified,
markQuestTurnedIn,
} from '../../data/questFlow';
import {
advanceChapterState,
resolveCurrentChapterState,
} from '../../services/storyEngine/chapterDirector';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
GameState,
StoryMoment,
@@ -53,7 +47,7 @@ export function applyQuestRewardClaim(
const issuerNpcState = state.npcStates[quest.issuerNpcId];
const nextState = appendStoryEngineCarrierMemory({
const nextState: GameState = {
...state,
quests: markQuestTurnedIn(state.quests, questId),
playerCurrency: state.playerCurrency + quest.reward.currency,
@@ -67,30 +61,11 @@ export function applyQuestRewardClaim(
},
}
: state.npcStates,
}, quest.reward.items);
const chapterState = advanceChapterState({
previousChapter:
nextState.chapterState
?? nextState.storyEngineMemory?.currentChapter
?? null,
nextChapter: resolveCurrentChapterState({
state: nextState,
}),
});
const storyEngineMemory =
nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const synchronizedNextState: GameState = {
...nextState,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
},
};
return {
nextState: synchronizedNextState,
handoff: buildGoalHandoffFromState(synchronizedNextState),
nextState,
handoff: buildGoalHandoffFromState(nextState),
};
}

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(

View File

@@ -1,34 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
rollHostileNpcLootMock,
resolveServerRuntimeChoiceMock,
} = vi.hoisted(() => ({
rollHostileNpcLootMock: vi.fn(),
const { resolveServerRuntimeChoiceMock } = vi.hoisted(() => ({
resolveServerRuntimeChoiceMock: vi.fn(),
}));
vi.mock('../../data/hostileNpcPresets', async () => {
const actual =
await vi.importActual<typeof import('../../data/hostileNpcPresets')>(
'../../data/hostileNpcPresets',
);
return {
...actual,
rollHostileNpcLoot: rollHostileNpcLootMock,
};
});
vi.mock('.', () => ({
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
}));
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
import { WorldType } from '../../types/core';
import {
buildCombatResolutionContextText,
buildHostileNpcBattleReward,
buildReasonedOptionCatalog,
runServerRuntimeChoiceAction,
shouldOpenLocalRuntimeNpcModal,
} from './storyChoiceRuntime';
@@ -56,10 +38,10 @@ function createCharacter(): Character {
} as unknown as Character;
}
function createStory(text: string): StoryMoment {
function createStory(text: string, options: StoryOption[] = []): StoryMoment {
return {
text,
options: [],
options,
};
}
@@ -140,23 +122,9 @@ function createState(overrides: Partial<GameState> = {}): GameState {
describe('storyChoiceRuntime', () => {
beforeEach(() => {
rollHostileNpcLootMock.mockReset();
resolveServerRuntimeChoiceMock.mockReset();
});
it('deduplicates option catalogs by function id for post-battle recovery', () => {
const options = buildReasonedOptionCatalog([
createOption('npc_chat'),
createOption('npc_chat'),
createOption('npc_help'),
]);
expect(options.map((option) => option.functionId)).toEqual([
'npc_chat',
'npc_help',
]);
});
it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
expect(
shouldOpenLocalRuntimeNpcModal(
@@ -190,117 +158,6 @@ describe('storyChoiceRuntime', () => {
).toBe(false);
});
it('builds escape and victory context text for local battle resolution', () => {
const baseState = createState({
inBattle: true,
sceneHostileNpcs: [
{ id: 'wolf', name: '山狼' },
] as GameState['sceneHostileNpcs'],
});
expect(
buildCombatResolutionContextText({
baseState,
afterSequence: {
...baseState,
inBattle: false,
sceneHostileNpcs: [],
},
optionKind: 'escape',
projectedBattleReward: null,
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
}),
).toContain('你已成功逃脱');
expect(
buildCombatResolutionContextText({
baseState: {
...baseState,
currentBattleNpcId: null,
},
afterSequence: {
...baseState,
inBattle: false,
sceneHostileNpcs: [],
},
optionKind: 'battle',
projectedBattleReward: {
id: 'reward-1',
defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }],
items: [
{ id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] },
],
},
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
}),
).toContain('战利品:狼牙。');
});
it('builds defeated hostile rewards from locally resolved battle states', async () => {
rollHostileNpcLootMock.mockResolvedValue([
{
id: 'loot-1',
category: '材料',
name: '狼牙',
quantity: 1,
rarity: 'common',
tags: [],
},
]);
const reward = await buildHostileNpcBattleReward(
createState({
inBattle: true,
sceneHostileNpcs: [
{ id: 'wolf', name: '山狼' },
] as GameState['sceneHostileNpcs'],
currentBattleNpcId: null,
}),
createState({
inBattle: false,
sceneHostileNpcs: [],
}),
'battle',
(state) => state.sceneHostileNpcs,
);
expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1);
expect(reward?.items[0]).toEqual(
expect.objectContaining({
name: '狼牙',
}),
);
});
it('keeps defeated hostile reward render keys unique for duplicate monster ids', async () => {
rollHostileNpcLootMock.mockResolvedValue([]);
const reward = await buildHostileNpcBattleReward(
createState({
inBattle: true,
sceneHostileNpcs: [
{ id: 'monster-16', name: '雷翼甲' },
{ id: 'monster-16', name: '雷翼乙', xMeters: 4.1, yOffset: 32 },
] as GameState['sceneHostileNpcs'],
currentBattleNpcId: null,
}),
createState({
inBattle: false,
sceneHostileNpcs: [],
}),
'battle',
(state) => state.sceneHostileNpcs,
);
expect(reward?.defeatedHostileNpcs).toHaveLength(2);
expect(reward?.defeatedHostileNpcs.map((npc) => npc.id)).toEqual([
'monster-16',
'monster-16',
]);
expect(new Set(reward?.defeatedHostileNpcs.map((npc) => npc.renderKey)).size)
.toBe(2);
});
it('applies server runtime responses and falls back locally when the request fails', async () => {
const gameState = createState();
const currentStory = createStory('当前故事');
@@ -452,9 +309,9 @@ describe('storyChoiceRuntime', () => {
expect(setGameState).toHaveBeenLastCalledWith(finalState);
});
it('routes server defeat outcomes into death and revive flow instead of victory settlement', async () => {
it('uses the server-returned defeat revive snapshot without local death reconstruction', async () => {
const gameState = createState({
worldType: 'WUXIA',
worldType: WorldType.WUXIA,
inBattle: true,
playerHp: 6,
playerMaxHp: 30,
@@ -467,7 +324,7 @@ describe('storyChoiceRuntime', () => {
imageSrc: '/scene-a.png',
connectedSceneIds: [],
connections: [],
forwardSceneId: null,
forwardSceneId: undefined,
treasureHints: [],
npcs: [],
},
@@ -488,16 +345,45 @@ describe('storyChoiceRuntime', () => {
},
],
});
const finalState = createState({
const serverRevivedState = createState({
...gameState,
inBattle: false,
playerHp: 0,
currentEncounter: null,
playerHp: 30,
playerMana: 10,
currentEncounter: {
kind: 'npc',
id: 'wolf',
npcName: '山狼',
npcDescription: '林间伏击的野兽',
npcAvatar: '狼',
context: '复活后的首场景威胁',
hostile: true,
},
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_defeat',
currentNpcBattleOutcome: null,
currentScenePreset: {
id: 'wuxia-bamboo-road',
name: '竹林古道',
description: '风穿竹影,路面狭长。',
imageSrc: '/scene-a.png',
connectedSceneIds: ['wuxia-mountain-gate'],
connections: [
{
sceneId: 'wuxia-mountain-gate',
relativePosition: 'forward',
summary: '沿主路继续深入前方区域',
},
],
forwardSceneId: 'wuxia-mountain-gate',
treasureHints: [],
npcs: [],
},
});
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const serverDeathStory = createStory('你在战斗中倒下,随后在竹林古道重新醒来。', [
createOption('story_continue_adventure'),
]);
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
response: {
@@ -512,9 +398,9 @@ describe('storyChoiceRuntime', () => {
},
},
hydratedSnapshot: {
gameState: finalState,
gameState: serverRevivedState,
},
nextStory: createStory('不会进入胜利文本'),
nextStory: serverDeathStory,
});
await runServerRuntimeChoiceAction({
@@ -527,10 +413,7 @@ describe('storyChoiceRuntime', () => {
setIsLoading: vi.fn(),
setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () =>
createStory('fallback', [
createOption('idle_explore_forward'),
]),
buildFallbackStoryForState: () => createStory('fallback'),
turnVisualMs: 1,
});
@@ -541,21 +424,8 @@ describe('storyChoiceRuntime', () => {
inBattle: false,
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('重新醒来'),
options: [
expect.objectContaining({
functionId: 'story_continue_adventure',
}),
],
}),
);
expect(setCurrentStory).not.toHaveBeenCalledWith(
expect.objectContaining({
text: '不会进入胜利文本',
}),
);
expect(setGameState).toHaveBeenLastCalledWith(serverRevivedState);
expect(setCurrentStory).toHaveBeenCalledWith(serverDeathStory);
});
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {

View File

@@ -2,8 +2,6 @@
buildEncounterEntryState,
hasEncounterEntity,
} from '../../data/encounterTransition';
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
import { addInventoryItems } from '../../data/npcInteractions';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
@@ -18,12 +16,6 @@ import {
StoryOption,
} from '../../types';
import { resolveRpgRuntimeChoice } from '.';
import {
buildDeathStory,
buildPostBattleVictoryState,
buildPostBattleVictoryStory,
buildRevivedFirstSceneState,
} from './postBattleFlow';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<
@@ -48,68 +40,6 @@ 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>();
return options.filter((option) => {
if (seenFunctionIds.has(option.functionId)) {
return false;
}
seenFunctionIds.add(option.functionId);
return true;
});
}
export function buildCombatResolutionContextText(params: {
baseState: GameState;
afterSequence: GameState;
optionKind: 'battle' | 'escape' | 'idle';
projectedBattleReward: BattleRewardSummary | null;
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
}) {
const {
baseState,
afterSequence,
optionKind,
projectedBattleReward,
getResolvedSceneHostileNpcs,
} = params;
if (optionKind === 'escape') {
const hostileNames = getResolvedSceneHostileNpcs(baseState)
.map((hostileNpc) => hostileNpc.name)
.join('、');
return hostileNames
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
}
if (
!baseState.inBattle ||
afterSequence.inBattle ||
Boolean(baseState.currentBattleNpcId)
) {
return null;
}
const hostileNames = getResolvedSceneHostileNpcs(baseState)
.map((hostileNpc) => hostileNpc.name)
.join('、');
const lootText =
projectedBattleReward?.items.length
? `战利品:${projectedBattleReward.items
.map((item) => item.name)
.join('、')}`
: '';
return hostileNames
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
}
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
return (
(
@@ -124,63 +54,6 @@ export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
);
}
export async function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
optionKind: 'battle' | 'escape' | 'idle',
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
): Promise<BattleRewardSummary | null> {
if (
optionKind === 'escape' ||
!state.worldType ||
state.currentBattleNpcId ||
!state.inBattle ||
afterSequence.inBattle
) {
return null;
}
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
const defeatedHostileNpcs = activeHostileNpcs.filter(
(hostileNpc) =>
!nextHostileNpcs.some(
(nextHostileNpc) => nextHostileNpc.id === hostileNpc.id,
),
);
if (defeatedHostileNpcs.length === 0) {
return null;
}
const rolledItems = await rollHostileNpcLoot(
state,
defeatedHostileNpcs.map((hostileNpc) => ({
id: hostileNpc.id,
name: hostileNpc.name,
})),
);
return {
id: `battle-reward-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`,
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc, index) => ({
id: hostileNpc.id,
name: hostileNpc.name,
// 中文注释:同一场战斗可能击败多个同 preset 怪物,奖励弹层 key 不能只用怪物 id。
renderKey: [
hostileNpc.id,
hostileNpc.name,
hostileNpc.xMeters,
hostileNpc.yOffset ?? 0,
index,
].join(':'),
})),
items: addInventoryItems([], rolledItems),
};
}
export async function runCampTravelHomeChoice(params: {
gameState: GameState;
option: StoryOption;
@@ -337,47 +210,6 @@ export async function runServerRuntimeChoiceAction(params: {
});
}
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);
const revivedDeferredOptions =
params.buildFallbackStoryForState(revivedState, params.character).options;
params.setGameState(revivedState);
params.setCurrentStory(
buildDeathStory(revivedState, revivedDeferredOptions),
);
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) {
@@ -459,14 +291,15 @@ async function playServerBattlePresentation(params: {
const finalTarget = params.finalState.sceneHostileNpcs.find(
(hostileNpc) => hostileNpc.id === targetId,
);
const playerDefeated = battle.outcome === 'defeat';
const targetDefeated =
battle.outcome === 'victory' ||
battle.outcome === 'spar_complete' ||
(battle.outcome !== 'defeat' && !finalTarget && (battle.damageDealt ?? 0) > 0);
params.setGameState({
...actingState,
playerHp: params.finalState.playerHp,
playerMana: params.finalState.playerMana,
playerHp: playerDefeated ? 0 : params.finalState.playerHp,
playerMana: playerDefeated ? params.baseState.playerMana : params.finalState.playerMana,
playerSkillCooldowns: params.finalState.playerSkillCooldowns,
activeBuildBuffs: params.finalState.activeBuildBuffs,
sceneHostileNpcs: actingState.sceneHostileNpcs.map((hostileNpc) => {
@@ -483,9 +316,12 @@ async function playServerBattlePresentation(params: {
});
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.45)));
if (params.finalState.playerHp <= 0) {
if (playerDefeated || params.finalState.playerHp <= 0) {
// 中文注释:这里只是 presentation 的临时倒地视觉,
// 正式复活位置、血蓝和故事仍以随后提交的服务端 snapshot 为准。
params.setGameState({
...params.finalState,
...actingState,
playerHp: 0,
animationState: AnimationState.DIE,
playerActionMode: 'idle',
inBattle: false,

View File

@@ -1,60 +1,5 @@
import { getCharacterById } from '../../data/characterPresets';
import {
NPC_CHAT_FUNCTION,
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
} from '../../data/functionCatalog';
import {
buildInitialNpcState,
describeNpcAffinityInWords,
getNpcConversationDirective,
isNpcFirstMeaningfulContact,
} from '../../data/npcInteractions';
import { buildSceneEntityCatalogText } from '../../data/scenePresets';
import { hasMixedNarrativeLanguage } from '../../services/narrativeLanguage';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from '../../services/storyEngine/actorNarrativeProfile';
import { applyAdaptiveTuningToPromptContext } from '../../services/storyEngine/adaptiveNarrativeTuner';
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
import {
buildCampEvent,
evaluateCampEventOpportunity,
} from '../../services/storyEngine/campEventDirector';
import {
advanceChapterState,
resolveCurrentChapterState,
} from '../../services/storyEngine/chapterDirector';
import {
advanceCompanionArc,
buildCompanionArcStates,
} from '../../services/storyEngine/companionArcDirector';
import { buildGoalStackState } from '../../services/storyEngine/goalDirector';
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
import { buildVisibilitySliceFromFacts } from '../../services/storyEngine/knowledgeContract';
import { buildKnowledgeGraph } from '../../services/storyEngine/knowledgeGraph';
import { buildRecentCarrierEchoes } from '../../services/storyEngine/narrativeCarrierCatalog';
import { buildChapterRecap } from '../../services/storyEngine/recapDigest';
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
import { buildSceneNarrativeDirective } from '../../services/storyEngine/sceneNarrativeDirector';
import {
buildSetpieceDirective,
evaluateSetpieceOpportunity,
} from '../../services/storyEngine/setpieceDirector';
import { buildChronicleSummary } from '../../services/storyEngine/storyChronicle';
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
import {
buildEncounterVisibilitySlice,
createEmptyStoryEngineMemoryState,
} from '../../services/storyEngine/visibilityEngine';
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
import type { GameState } from '../../types';
import { getCharacterChatRecord } from './characterChat';
import { getNpcEncounterKey } from './storyGenerationState';
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
export type StoryContextBuilderExtras = {
pendingSceneEncounter?: boolean;
@@ -66,560 +11,35 @@ export type StoryContextBuilderExtras = {
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
function buildPartyRelationshipNotes(state: GameState) {
const lines: string[] = [];
const seenCharacterIds = new Set<string>();
const appendNote = (characterId: string, roleLabel: string) => {
if (seenCharacterIds.has(characterId)) return;
const character = getCharacterById(characterId);
const summary = getCharacterChatRecord(state, characterId).summary.trim();
if (hasMixedNarrativeLanguage(summary)) return;
if (!character || !summary) return;
seenCharacterIds.add(characterId);
lines.push(
`- ${character.name} (${character.title} / ${roleLabel}): ${summary}`,
);
};
state.companions.forEach((companion) =>
appendNote(companion.characterId, '当前同行'),
);
state.roster.forEach((companion) =>
appendNote(companion.characterId, '营地待命'),
);
return lines.length > 0 ? lines.join('\n') : null;
}
function describeScenePressureLevel(
pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined,
) {
switch (pressureLevel) {
case 'low':
return '低';
case 'medium':
return '中';
case 'high':
return '高';
case 'extreme':
return '极高';
default:
return null;
}
}
function buildRecentConversationEventText(state: GameState) {
const recentText = state.storyHistory
.slice(-6)
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
) {
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
}
if (/|||/u.test(recentText)) {
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
}
return null;
}
function inferConversationSituation(
state: GameState,
extras: Pick<
StoryContextBuilderExtras,
'lastFunctionId' | 'openingCampDialogue'
>,
) {
if (state.inBattle) return 'shared_danger_coordination' as const;
if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID)
return 'camp_first_contact' as const;
if (
state.currentEncounter?.specialBehavior === 'camp_companion' &&
extras.openingCampDialogue?.trim()
) {
return 'camp_followup' as const;
}
const recentText = state.storyHistory
.slice(-6)
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
) {
return 'post_battle_breath' as const;
}
if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id)
return 'private_followup' as const;
return 'first_contact_cautious' as const;
}
function inferConversationPressure(
state: GameState,
situation: ReturnType<typeof inferConversationSituation>,
) {
const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
if (state.inBattle || hpRatio < 0.35) return 'high' as const;
if (
situation === 'post_battle_breath' ||
situation === 'shared_danger_coordination'
)
return 'medium' as const;
if (situation === 'camp_first_contact' || situation === 'camp_followup')
return 'low' as const;
return 'medium' as const;
}
function describeConversationSituation(
situation: ReturnType<typeof inferConversationSituation>,
) {
switch (situation) {
case 'camp_first_contact':
return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。';
case 'camp_followup':
return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。';
case 'post_battle_breath':
return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。';
case 'shared_danger_coordination':
return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。';
case 'private_followup':
return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。';
default:
return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。';
}
}
function describeConversationTalkPriority(
situation: ReturnType<typeof inferConversationSituation>,
) {
switch (situation) {
case 'camp_first_contact':
return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。';
case 'camp_followup':
return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。';
case 'post_battle_breath':
return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。';
case 'shared_danger_coordination':
return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。';
case 'private_followup':
return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。';
default:
return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。';
}
}
function resolveEncounterNarrativeProfile(state: GameState) {
const encounter = state.currentEncounter;
if (!encounter || encounter.kind !== 'npc') {
return null;
}
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
}
if (!state.customWorldProfile) {
return null;
}
const role =
state.customWorldProfile.storyNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
)
?? state.customWorldProfile.playableNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return null;
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
}
function resolveActiveThreadIds(
state: GameState,
encounterNarrativeProfile: ReturnType<typeof resolveEncounterNarrativeProfile>,
) {
if (state.storyEngineMemory?.activeThreadIds?.length) {
return state.storyEngineMemory.activeThreadIds.slice(0, 4);
}
if (encounterNarrativeProfile?.relatedThreadIds.length) {
return encounterNarrativeProfile.relatedThreadIds.slice(0, 4);
}
if (!state.customWorldProfile) {
return [];
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
}
/**
* 运行时 story prompt context 的正式投影已经迁到 server-rs。
* 前端只保留 session 与少量请求元信息,方便旧调用面继续复用同一个函数签名。
*/
export function buildStoryContextFromState(
state: GameState,
extras: StoryContextBuilderExtras = {},
): StoryGenerationContext {
const conversationSituation = inferConversationSituation(state, extras);
const conversationPressure = inferConversationPressure(
state,
conversationSituation,
);
const recentSharedEvent = buildRecentConversationEventText(state);
const encounterNpcState =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return extras.encounterNpcStateOverride
?? state.npcStates[getNpcEncounterKey(encounter)]
?? buildInitialNpcState(encounter, state.worldType, state);
})()
: null;
const encounterDirective =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return encounterNpcState
? getNpcConversationDirective(encounter, encounterNpcState)
: null;
})()
: null;
const isFirstMeaningfulContact =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return encounterNpcState
? isNpcFirstMeaningfulContact(encounter, encounterNpcState)
: false;
})()
: false;
const firstContactRelationStance = (() => {
if (
!isFirstMeaningfulContact ||
!state.currentEncounter ||
state.currentEncounter.kind !== 'npc'
) {
return null;
}
const stance = encounterNpcState?.relationState?.stance ?? null;
if (
stance === 'guarded' ||
stance === 'neutral' ||
stance === 'cooperative' ||
stance === 'bonded'
) {
return stance;
}
return null;
})();
const encounterAffinityText =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return encounterNpcState
? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, {
recruited: encounterNpcState.recruited,
})
: null;
})()
: null;
const baseSceneDescription = state.currentScenePreset?.description ?? null;
const sceneMutationDescription = [
state.currentScenePreset?.mutationStateText
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
: null,
describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)
? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}`
: null,
]
.filter(Boolean)
.join('\n');
const observeSignsSceneDescription =
extras.observeSignsRequested && state.worldType
? [
baseSceneDescription,
sceneMutationDescription,
'当前可观察实体池:',
buildSceneEntityCatalogText(
state.worldType,
state.currentScenePreset?.id ?? null,
),
]
.filter(Boolean)
.join('\n')
: [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n');
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const knowledgeFacts =
state.customWorldProfile?.knowledgeFacts
?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []);
const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state);
const activeThreadIds = resolveActiveThreadIds(
{
...state,
storyEngineMemory,
} as GameState,
encounterNarrativeProfile,
);
const visibilitySlice =
state.currentEncounter?.kind === 'npc'
? (() => {
const relevantFacts = knowledgeFacts.filter((fact) =>
fact.ownerActorIds.includes(state.currentEncounter?.id ?? '')
|| fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '')
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
);
return relevantFacts.length > 0
? buildVisibilitySliceFromFacts({
facts: relevantFacts,
discoveredFactIds: [
...storyEngineMemory.discoveredFactIds,
...(encounterNpcState?.revealedFacts ?? []),
...(encounterNpcState?.seenBackstoryChapterIds ?? []).map(
(chapterId) =>
relevantFacts.find((fact) =>
fact.aliases?.includes(chapterId) || fact.id.includes(chapterId),
)?.id ?? '',
),
],
activeThreadIds,
disclosureStage: encounterDirective?.disclosureStage ?? null,
isFirstMeaningfulContact,
})
: buildEncounterVisibilitySlice({
narrativeProfile: encounterNarrativeProfile,
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
disclosureStage: encounterDirective?.disclosureStage ?? null,
isFirstMeaningfulContact,
seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [],
storyEngineMemory,
activeThreadIds,
});
})()
: null;
const sceneNarrativeDirective = buildSceneNarrativeDirective({
return {
runtimeSessionId: state.runtimeSessionId ?? null,
runtimeActionVersion: state.runtimeActionVersion,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
playerMaxMana: state.playerMaxMana,
inBattle: state.inBattle,
playerX: state.playerX,
playerFacing: state.playerFacing,
playerAnimation: state.animationState,
skillCooldowns: state.playerSkillCooldowns,
sceneId: state.currentScenePreset?.id ?? null,
sceneName: state.currentScenePreset?.name ?? null,
encounterId: state.currentEncounter?.id ?? null,
encounterName: state.currentEncounter?.npcName ?? null,
recentActions: state.storyHistory.slice(-3).map((moment) => moment.text),
activeThreadIds,
visibilitySlice,
encounterNarrativeProfile,
disclosureStage: encounterDirective?.disclosureStage ?? null,
isFirstMeaningfulContact,
affinity: encounterNpcState?.affinity ?? null,
});
const chapterState = advanceChapterState({
previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null,
nextChapter: resolveCurrentChapterState({
state: {
...state,
storyEngineMemory,
},
}),
});
const journeyBeat = resolveCurrentJourneyBeat({
state: {
...state,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
},
} as GameState,
chapterState,
});
const companionArcStates = advanceCompanionArc({
previous: storyEngineMemory.companionArcStates,
next: buildCompanionArcStates({
state,
reactions: storyEngineMemory.recentCompanionReactions,
}),
});
const currentCampEvent = evaluateCampEventOpportunity({
state,
chapterState,
journeyBeat,
companionArcStates,
})
? buildCampEvent({
state,
chapterState,
journeyBeat,
companionArcStates,
})
: null;
const setpieceDirective = evaluateSetpieceOpportunity({
state,
chapterState,
journeyBeat,
})
? buildSetpieceDirective({
state,
chapterState,
journeyBeat,
})
: null;
const recentWorldMutations = storyEngineMemory.worldMutations ?? [];
const recentChronicleSummary = buildChronicleSummary({
...state,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
companionArcStates,
},
} as GameState);
const compiledPacks = state.customWorldProfile
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
: null;
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
currentSceneId: state.currentScenePreset?.id ?? null,
chapterState,
journeyBeat,
setpieceDirective,
currentCampEvent,
currentSceneName: state.currentScenePreset?.name ?? null,
});
const activeScenarioPack =
resolveScenarioPack(state.activeScenarioPackId)
?? compiledPacks?.scenarioPack
?? null;
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
const fallbackChapterRecap = buildChapterRecap({
state: { ...state, chapterState } as GameState,
});
const safeEncounterRelationshipSummary =
state.currentEncounter?.characterId
? getCharacterChatRecord(state, state.currentEncounter.characterId)
.summary
.trim()
: '';
return applyAdaptiveTuningToPromptContext({
context: {
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
playerMaxMana: state.playerMaxMana,
inBattle: state.inBattle,
playerX: state.playerX,
playerFacing: state.playerFacing,
playerAnimation: state.animationState,
skillCooldowns: state.playerSkillCooldowns,
sceneId: state.currentScenePreset?.id ?? null,
sceneName: state.currentScenePreset?.name ?? null,
sceneDescription: observeSignsSceneDescription,
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
lastFunctionId: extras.lastFunctionId ?? null,
observeSignsRequested: extras.observeSignsRequested ?? false,
recentActionResult: extras.recentActionResult ?? null,
lastObserveSignsReport:
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
? (state.lastObserveSignsReport ?? null)
: null,
encounterKind: state.currentEncounter?.kind ?? null,
encounterName: state.currentEncounter?.npcName ?? null,
encounterDescription: state.currentEncounter?.npcDescription ?? null,
encounterContext: state.currentEncounter?.context ?? null,
encounterId: state.currentEncounter?.id ?? null,
encounterCharacterId: state.currentEncounter?.characterId ?? null,
encounterGender: state.currentEncounter?.gender ?? null,
encounterCustomProfile: state.currentEncounter
? {
title: state.currentEncounter.title ?? '',
description: state.currentEncounter.npcDescription ?? '',
backstory: state.currentEncounter.backstory ?? '',
personality: state.currentEncounter.personality ?? '',
motivation: state.currentEncounter.motivation ?? '',
combatStyle: state.currentEncounter.combatStyle ?? '',
relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])],
tags: [...(state.currentEncounter.tags ?? [])],
backstoryReveal: state.currentEncounter.backstoryReveal,
skills: [...(state.currentEncounter.skills ?? [])],
initialItems: [...(state.currentEncounter.initialItems ?? [])],
imageSrc: state.currentEncounter.imageSrc,
visual: state.currentEncounter.visual,
narrativeProfile: state.currentEncounter.narrativeProfile,
}
: null,
encounterAffinity: encounterDirective?.affinity ?? null,
encounterAffinityText,
encounterStanceProfile: encounterNpcState?.stanceProfile ?? null,
encounterConversationStyle: encounterDirective?.style ?? null,
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
encounterAnswerMode: encounterDirective?.answerMode ?? null,
encounterAllowedTopics: encounterDirective?.allowTopics ?? null,
encounterBlockedTopics: encounterDirective?.blockedTopics ?? null,
isFirstMeaningfulContact,
firstContactRelationStance,
conversationSituation,
conversationPressure,
recentSharedEvent:
recentSharedEvent ?? describeConversationSituation(conversationSituation),
talkPriority: describeConversationTalkPriority(conversationSituation),
visibilitySlice,
sceneNarrativeDirective,
campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null,
actState: storyEngineMemory.actState ?? null,
chapterState,
journeyBeat,
goalStack,
currentCampEvent,
setpieceDirective,
activeScenarioPack,
activeCampaignPack,
encounterNarrativeProfile,
knowledgeFacts,
activeThreadIds,
companionArcStates,
companionResolutions: storyEngineMemory.companionResolutions ?? [],
consequenceLedger: storyEngineMemory.consequenceLedger ?? [],
authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null,
playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null,
recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [],
recentCarrierEchoes: buildRecentCarrierEchoes(state),
recentWorldMutations,
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
recentChronicleSummary:
recentChronicleSummary.trim() &&
!hasMixedNarrativeLanguage(recentChronicleSummary)
? recentChronicleSummary
: fallbackChapterRecap,
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
encounterRelationshipSummary: state.currentEncounter?.characterId
? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary)
? safeEncounterRelationshipSummary || null
: null
: null,
partyRelationshipNotes: buildPartyRelationshipNotes(state),
customWorldProfile: state.customWorldProfile ?? null,
openingCampBackground: extras.openingCampBackground ?? null,
openingCampDialogue: extras.openingCampDialogue ?? null,
},
profile: storyEngineMemory.playerStyleProfile ?? null,
});
sceneDescription: state.currentScenePreset?.description ?? null,
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
lastFunctionId: extras.lastFunctionId ?? null,
observeSignsRequested: extras.observeSignsRequested ?? false,
recentActionResult: extras.recentActionResult ?? null,
customWorldProfile: null,
openingCampBackground: extras.openingCampBackground ?? null,
openingCampDialogue: extras.openingCampDialogue ?? null,
};
}

View File

@@ -156,6 +156,51 @@ function createBaseState(): GameState {
activeCombatEffects: [],
playerCurrency: 10,
playerInventory: [createInventoryItem('player-potion', 'Potion')],
runtimeNpcInteraction: {
npcId: 'npc-trader',
npcName: 'Trader Lin',
playerCurrency: 10,
currencyName: '铜钱',
trade: {
buyItems: [
{
itemId: 'npc-herb',
item: createInventoryItem('npc-herb', 'Herb'),
mode: 'buy',
unitPrice: 3,
maxQuantity: 1,
canSubmit: true,
reason: null,
},
],
sellItems: [
{
itemId: 'player-potion',
item: createInventoryItem('player-potion', 'Potion'),
mode: 'sell',
unitPrice: 1,
maxQuantity: 1,
canSubmit: true,
reason: null,
},
],
},
gift: {
items: [
{
itemId: 'jade-token',
item: createInventoryItem('jade-token', 'Jade Token', {
rarity: 'rare',
category: '专属',
tags: ['merchant'],
}),
affinityGain: 16,
canSubmit: true,
reason: null,
},
],
},
},
playerEquipment: {
weapon: null,
armor: null,
@@ -202,7 +247,7 @@ function createInteractionOption(action: Extract<NonNullable<StoryOption['intera
}
describe('storyGenerationState', () => {
it('opens the trade modal with the first npc and player inventory items selected', () => {
it('opens the trade modal with server-selected npc and player items', () => {
const decision = resolveNpcInteractionDecision(
createBaseState(),
createInteractionOption('trade'),
@@ -218,14 +263,39 @@ describe('storyGenerationState', () => {
expect(decision.modal.selectedQuantity).toBe(1);
});
it('skips zero-quantity player items when opening the trade modal', () => {
it('prefers the first server-submittable sell item when opening the trade modal', () => {
const baseState = createBaseState();
const decision = resolveNpcInteractionDecision(
{
...createBaseState(),
playerInventory: [
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
createInventoryItem('player-herb', 'Herb'),
],
...baseState,
runtimeNpcInteraction: {
...baseState.runtimeNpcInteraction!,
trade: {
buyItems: baseState.runtimeNpcInteraction!.trade.buyItems,
sellItems: [
{
itemId: 'empty-slot',
item: createInventoryItem('empty-slot', 'Empty Slot', {
quantity: 0,
}),
mode: 'sell',
unitPrice: 1,
maxQuantity: 0,
canSubmit: false,
reason: '背包数量不足。',
},
{
itemId: 'player-herb',
item: createInventoryItem('player-herb', 'Herb'),
mode: 'sell',
unitPrice: 2,
maxQuantity: 1,
canSubmit: true,
reason: null,
},
],
},
},
},
createInteractionOption('trade'),
);
@@ -257,21 +327,9 @@ describe('storyGenerationState', () => {
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
});
it('opens the gift modal with the preferred gift candidate selected', () => {
const state = {
...createBaseState(),
playerInventory: [
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
createInventoryItem('jade-token', 'Jade Token', {
rarity: 'rare',
category: '专属',
tags: ['merchant'],
}),
],
};
it('opens the gift modal with the server-selected gift candidate', () => {
const decision = resolveNpcInteractionDecision(
state,
createBaseState(),
createInteractionOption('gift'),
);
@@ -284,9 +342,13 @@ describe('storyGenerationState', () => {
});
it('does not open the gift modal when there are no gift candidates', () => {
const baseState = createBaseState();
const state = {
...createBaseState(),
playerInventory: [],
...baseState,
runtimeNpcInteraction: {
...baseState.runtimeNpcInteraction!,
gift: { items: [] },
},
};
const decision = resolveNpcInteractionDecision(

View File

@@ -10,11 +10,7 @@
import {
applyQuestProgressFromSceneReached,
} from '../../data/questFlow';
import {
buildInitialNpcState,
getPreferredGiftItemId,
MAX_COMPANIONS,
} from '../../data/npcInteractions';
import { MAX_COMPANIONS } from '../../data/npcInteractions';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import { getScenePresetById } from '../../data/scenePresets';
@@ -53,11 +49,10 @@ export function getNpcEncounterKey(encounter: Encounter) {
return encounter.id ?? encounter.npcName;
}
function getResolvedNpcState(state: GameState, encounter: Encounter) {
return (
state.npcStates[getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType)
);
function findPreferredTradeItemId(
items: Array<{ itemId: string; canSubmit: boolean }>,
) {
return items.find(item => item.canSubmit)?.itemId ?? items[0]?.itemId ?? null;
}
export function resolveNpcInteractionDecision(
@@ -73,29 +68,29 @@ export function resolveNpcInteractionDecision(
}
const encounter = state.currentEncounter;
const npcState = getResolvedNpcState(state, encounter);
switch (option.functionId) {
case NPC_TRADE_FUNCTION.id:
return {
kind: 'trade_modal',
modal: buildNpcTradeModalState(
state,
encounter,
option.actionText,
npcState.inventory,
findPreferredTradeItemId(
state.runtimeNpcInteraction?.trade.buyItems ?? [],
),
findPreferredTradeItemId(
state.runtimeNpcInteraction?.trade.sellItems ?? [],
),
),
};
case NPC_GIFT_FUNCTION.id:
{
const selectedGiftItemId = getPreferredGiftItemId(
state.playerInventory,
encounter,
{
worldType: state.worldType,
customWorldProfile: state.customWorldProfile,
},
);
const selectedGiftItemId =
state.runtimeNpcInteraction?.gift.items.find(item => item.canSubmit)
?.itemId ??
state.runtimeNpcInteraction?.gift.items[0]?.itemId ??
null;
if (!selectedGiftItemId) {
return { kind: 'none' };
}
@@ -103,7 +98,6 @@ export function resolveNpcInteractionDecision(
return {
kind: 'gift_modal',
modal: buildNpcGiftModalState(
state,
encounter,
option.actionText,
selectedGiftItemId,

View File

@@ -60,6 +60,8 @@ type StoryInteractionCoordinatorParams = {
export function createStoryInteractionCoordinatorConfig(
params: StoryInteractionCoordinatorParams,
) {
// 中文注释sharedRuntime 是宝箱流和背包流共享的最小运行时上下文,
// 这两类动作不需要拿到完整 NPC / 对话链配置,因此先抽一层轻量公共配置。
const sharedRuntime = {
currentStory: params.currentStory,
setGameState: params.setGameState,
@@ -87,6 +89,8 @@ export function createStoryInteractionCoordinatorConfig(
cloneInventoryItemForOwner:
params.runtimeSupport.cloneInventoryItemForOwner,
runtime: {
// 中文注释NPC 交互流需要最完整的故事上下文,
// 包括对话故事构建、继续生成、打字机延迟和敌对 NPC 推断。
currentStory: params.currentStory,
setCurrentStory: params.setCurrentStory,
setAiError: params.setAiError,
@@ -100,6 +104,8 @@ export function createStoryInteractionCoordinatorConfig(
},
},
npcEncounterActions: {
// 中文注释npcEncounterActions 是最重的一组配置,
// 它同时服务“进入 NPC 遭遇”“提交 NPC 动作”“战斗后恢复对话”等整条分支。
gameState: params.gameState,
currentStory: params.currentStory,
setGameState: params.setGameState,

View File

@@ -118,6 +118,8 @@ describe('storyRequestCoordinator', () => {
const buildStoryContextFromState = vi.fn(
(_state, extras) =>
({
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 3,
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
@@ -177,7 +179,8 @@ describe('storyRequestCoordinator', () => {
history,
'继续交谈',
expect.objectContaining({
sceneId: 'inn_room',
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 3,
lastFunctionId: 'npc_chat',
}),
{

View File

@@ -1,4 +1,9 @@
import type {
RuntimeStoryEquipmentSlotView,
RuntimeStoryForgeRecipeView,
RuntimeStoryInventoryItemView,
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
import type {
Encounter,
GoalHandoff,
GoalPulseEvent,
@@ -52,22 +57,11 @@ export interface InventoryFlowUi {
useInventoryItem: (itemId: string) => Promise<boolean>;
equipInventoryItem: (itemId: string) => Promise<boolean>;
unequipItem: (slot: 'weapon' | 'armor' | 'relic') => Promise<boolean>;
forgeRecipes: Array<{
id: string;
name: string;
kind: 'synthesis' | 'forge';
description: string;
resultLabel: string;
currencyCost: number;
currencyText: string;
requirements: Array<{
id: string;
label: string;
quantity: number;
owned: number;
}>;
canCraft: boolean;
}>;
playerCurrency: number | null;
currencyText: string | null;
backpackItems: RuntimeStoryInventoryItemView[];
equipmentSlots: RuntimeStoryEquipmentSlotView[];
forgeRecipes: RuntimeStoryForgeRecipeView[];
craftRecipe: (recipeId: string) => Promise<boolean>;
dismantleItem: (itemId: string) => Promise<boolean>;
reforgeItem: (itemId: string) => Promise<boolean>;

View File

@@ -62,6 +62,8 @@ export function createClearStoryInteractionUi(params: {
clearNpcInteractionUi: () => void;
}) {
return () => {
// 中文注释story 选择面板和 NPC 交互面板是两套独立 UI
// 清理运行时交互态时必须同时重置,避免战斗/对话切换后残留旧弹层。
params.clearStoryChoiceUi();
params.clearNpcInteractionUi();
};
@@ -120,6 +122,8 @@ export function useRpgRuntimeInteractionFlow({
}
if (isNpcEncounter(gameState.currentEncounter)) {
// 中文注释:当场景里已经解析出 NPC 遭遇,且当前不在战斗/加载中时,
// 自动进入 NPC 交互态,让开场相遇和旅行后遭遇都能无缝落到对话/互动面板。
enterNpcInteraction(
gameState.currentEncounter,
`${gameState.currentEncounter.npcName}搭话`,
@@ -180,6 +184,8 @@ export function useRpgRuntimeInteractionFlow({
);
},
};
// 中文注释choice coordinator 只关心“点下某个 story option 后怎么结算”,
// NPC 战斗结束后要不要回到对话态,则通过 runtimeSupport 在这里桥接进去。
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
...runtimeSupport,
handleNpcBattleConversationContinuation: ({
@@ -248,6 +254,8 @@ export function useRpgRuntimeInteractionFlow({
return false;
}
// 中文注释:聊天提交是 fire-and-forget
// 调用方只需要知道“当前能不能发给 NPC”不需要阻塞等待整轮对话结束。
void handleNpcChatTurn(encounter, input);
return true;
},
@@ -263,6 +271,8 @@ export function useRpgRuntimeInteractionFlow({
return false;
}
// 中文注释NPC 聊天的“换一组回应建议”当前通过轮转 options 实现,
// 不额外发请求,优先复用本轮已经拿到的候选动作。
interactionConfig.npcEncounterActions.setCurrentStory({
...story,
options: [...restOptions, firstOption],

View File

@@ -22,13 +22,9 @@ import { resolveFunctionOption } from '../../data/stateFunctions';
import { streamNpcChatTurn } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
advanceSceneActRuntimeState,
getSceneConnectionDirectionText,
resolveLimitedPrimaryNpcChatState,
resolveSceneActProgression,
} from '../../services/customWorldSceneActRuntime';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
Character,
Encounter,
@@ -513,44 +509,41 @@ export function createStoryNpcEncounterActions({
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
}
const nextState: GameState = appendStoryEngineCarrierMemory(
incrementRuntimeStats(
{
...state,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
npcStates: {
...state.npcStates,
[battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity,
relationState: buildRelationState(npcState.affinity),
recruited: false,
inventory: nextNpcInventory,
},
const nextState: GameState = incrementRuntimeStats(
{
...state,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
npcStates: {
...state.npcStates,
[battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity,
relationState: buildRelationState(npcState.affinity),
recruited: false,
inventory: nextNpcInventory,
},
playerX: 0,
playerFacing: 'right' as const,
animationState: state.animationState,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
),
lootItems,
playerX: 0,
playerFacing: 'right' as const,
animationState: state.animationState,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
);
const lootText =
@@ -985,57 +978,6 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
playerCharacter: Character,
) => {
const progression = resolveSceneActProgression({
profile: gameState.customWorldProfile,
sceneId: gameState.currentScenePreset?.id ?? null,
storyEngineMemory: gameState.storyEngineMemory,
});
if (!progression) {
return {
deferredRuntimeState: null,
options: currentStory?.deferredOptions?.length
? currentStory.deferredOptions
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
};
}
if (!progression.isLastAct) {
const nextActState = advanceSceneActRuntimeState({ progress: progression });
const nextStoryEngineMemory = nextActState
? {
...(gameState.storyEngineMemory ??
createEmptyStoryEngineMemoryState()),
currentSceneActState: nextActState,
}
: gameState.storyEngineMemory;
const nextState = {
...gameState,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
storyEngineMemory: nextStoryEngineMemory,
};
const nextOptions = collapseNpcChatOptions(
getAvailableOptionsForState(nextState, playerCharacter) ?? [],
);
return {
deferredRuntimeState: {
currentScenePreset: nextState.currentScenePreset,
storyEngineMemory: nextState.storyEngineMemory,
},
options:
nextOptions.length > 0
? nextOptions
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
};
}
const travelOptions = buildSceneConnectionTravelOptions(gameState);
return {
@@ -1794,12 +1736,8 @@ export function createStoryNpcEncounterActions({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentScenePreset:
progressionResult.deferredRuntimeState?.currentScenePreset ??
gameState.currentScenePreset,
storyEngineMemory:
progressionResult.deferredRuntimeState?.storyEngineMemory ??
gameState.storyEngineMemory,
currentScenePreset: gameState.currentScenePreset,
storyEngineMemory: gameState.storyEngineMemory,
};
setGameState(nextState);

View File

@@ -80,6 +80,8 @@ export function useRpgRuntimeStory({
buildStoryContextFromState,
});
// 中文注释controller 负责“当前故事是什么”,
// flow 负责“用户点下去以后发生什么”,两者在这里被装成统一运行时 story 出口。
const runtimeController = useRpgRuntimeStoryController({
gameState,
setGameState,
@@ -125,6 +127,7 @@ export function useRpgRuntimeStory({
turnVisualMs: TURN_VISUAL_MS,
});
// 中文注释:这里返回的对象就是 runtime shell / adventure panel 直接消费的故事域 API。
return {
currentStory: runtimeController.currentStory,
isLoading: runtimeController.isLoading,

View File

@@ -43,6 +43,8 @@ function createGameState(params: {
} = {}): GameState {
return {
worldType: WorldType.CUSTOM,
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 4,
customWorldProfile: null,
playerCharacter: createCharacter(),
currentScene: 'Story',
@@ -89,6 +91,8 @@ function buildStoryContextFromState(
_state: GameState,
): StoryGenerationContext {
return {
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 4,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
@@ -187,8 +191,8 @@ describe('useRpgRuntimeStoryController', () => {
expect.objectContaining({ id: 'hero' }),
[],
expect.objectContaining({
sceneId: 'scene-opening',
sceneName: '证券交易所大厅',
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 4,
}),
undefined,
);

View File

@@ -57,6 +57,8 @@ export function useRpgRuntimeStoryController(params: {
[],
);
// 中文注释presentation 层负责把服务端/AI 返回的原始故事数据
// 编译成前端当前可直接展示的 StoryMoment。
const buildStoryFromResponse = useCallback(
(
state: GameState,
@@ -135,6 +137,8 @@ export function useRpgRuntimeStoryController(params: {
gameState.currentScenePreset?.id ?? 'scene',
gameState.storyHistory.length,
].join(':');
// 中文注释:开场剧情只允许同一份“玩家 + 场景 + 历史长度”请求飞一次,
// 防止 React 严格模式、状态抖动或异步回填触发重复开局生成。
if (openingStoryRequestKeyRef.current === requestKey) {
return;
}
@@ -162,6 +166,8 @@ export function useRpgRuntimeStoryController(params: {
}
console.error('Failed to start opening RPG story:', error);
// 中文注释:即使 AI / 服务端首段故事失败,也要兜底出一个本地可玩的故事壳,
// 否则冒险面板会直接卡死在无 story 的空白状态。
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
})
@@ -195,6 +201,8 @@ export function useRpgRuntimeStoryController(params: {
isLoading,
setIsLoading,
preparedOpeningAdventure: null,
// 中文注释:这几个 opening adventure 相关字段先按空实现保留,
// 目的是兼容旧调用面,同时避免新 runtime 链再把预制开场逻辑塞回 controller。
startOpeningAdventure: async () => undefined,
resetPreparedOpeningAdventure: () => undefined,
buildStoryContextFromState,

View File

@@ -97,6 +97,8 @@ export function useRpgRuntimeStoryFlow({
buildOpeningCampChatContext,
resetPreparedOpeningAdventure,
} = runtimeController;
// 中文注释interactionConfig 是“剧情交互协调器”的配置快照;
// 后续选项刷新、动作提交、fallback 叙事都会共用这套上下文。
const interactionConfig = createStoryInteractionCoordinatorConfig({
gameState,
setGameState,
@@ -131,6 +133,8 @@ export function useRpgRuntimeStoryFlow({
gameState,
currentStory,
});
// 中文注释:这一层把“战斗/NPC/背包/地图旅行”等具体交互入口分发到对应流程,
// 保证冒险面板只调用统一的 handleChoice / handleNpcChatInput 等接口。
const {
handleChoice,
battleRewardUi,
@@ -175,6 +179,7 @@ export function useRpgRuntimeStoryFlow({
clearCharacterChatModal,
});
// 中文注释:最终返回的是已经过目标选项协调、交互分发和 story state 收束后的稳定输出。
return {
displayedOptions,
canRefreshOptions,

View File

@@ -19,6 +19,8 @@ export function createClearStoryRuntimeUi(params: {
clearCharacterChatModal: () => void;
}) {
return () => {
// 中文注释story runtime 的“清场”不只是清掉故事文本,
// 还要把目标 UI、交互 UI、错误态、加载态和角色私聊弹层一起回收。
params.clearStoryGoalOptionUi();
params.clearStoryInteractionUi();
params.setAiError(null);
@@ -81,6 +83,8 @@ export function useRpgRuntimeStoryState(params: {
buildFallbackStoryForState: params.buildFallbackStoryForState,
});
// 中文注释quest 相关按钮属于运行时 story UI 的一部分,
// 但真正的状态迁移统一交给 sessionActions当前层只负责对外暴露稳定接口。
return {
questUi: {
acknowledgeQuestCompletion,

View File

@@ -4,7 +4,10 @@ import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/run
import { useAuthUi } from '../../components/auth/AuthUiContext';
import type { CustomWorldRuntimeLaunchOptions } from '../../components/platform-entry/platformEntryTypes';
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
import {
activateRosterCompanion,
benchActiveCompanion,
} from '../../data/companionRoster';
import { syncGameStatePlayTime } from '../../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { useBackgroundMusic } from '../useBackgroundMusic';
@@ -33,10 +36,14 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
handleCharacterSelect: selectCharacter,
} = useRpgSessionBootstrap();
// 中文注释:战斗播放与结算仍然沿用独立 combat flow
// runtime session 只消费它暴露出来的“选项结算结果”和“动画播放入口”。
const combatFlow = useCombatFlow({
setGameState,
});
// 中文注释:剧情流是运行时主链的另一半。
// 这里把 GameState 交给 runtime story由它负责剧情文本、选项、NPC 交互与任务 UI。
const storyFlow = useRpgRuntimeStory({
gameState,
setGameState,
@@ -46,6 +53,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
const { companionRenderStates, buildCompanionRenderStates } =
useNpcInteractionFlow(gameState);
// 中文注释:持久化层统一负责继续游戏、自动存档与退出保存,
// session 只把当前运行态快照和 story 水位传进去。
const persistence = useRpgSessionPersistence({
authenticatedUserId: authUi?.user?.id ?? null,
gameState,
@@ -70,6 +79,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
return;
}
// 中文注释:游玩时长统计不跟每一帧绑定,而是固定 15 秒增量同步,
// 这样既能累计活跃时长,也不会因为高频 setState 拉高运行态噪音。
const intervalId = window.setInterval(() => {
setGameState((currentState) => {
if (
@@ -90,6 +101,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
options?: CustomWorldRuntimeLaunchOptions,
) => {
// 中文注释:切换世界前先清空上一局 story 控制器,
// 避免旧世界的 currentStory / 选项残留到新开局。
storyFlow.resetStoryState();
selectCustomWorld(customWorldProfile, {
mode: options?.mode,
@@ -100,6 +113,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
const handleCharacterSelect = (
character: Parameters<typeof selectCharacter>[0],
) => {
// 中文注释:角色确认意味着正式进入新 run
// 这里同样先清理 story 层,保证开场剧情重新按当前角色生成。
storyFlow.resetStoryState();
selectCharacter(character);
};
@@ -110,6 +125,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
};
const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => {
// 中文注释:继续游戏是异步恢复链,内部会重新向服务端刷新 runtime story
// 所以这里显式丢给 persistence 异步执行。
void persistence.continueSavedGame(snapshot);
};
@@ -120,9 +137,9 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
};
const handleSaveAndExit = () => {
const syncedGameState = syncGameStatePlayTime(gameState);
// 中文注释:退出保存只请求服务端基于已存快照创建 checkpoint
// 游玩时长的最终刷新由后端 checkpoint 负责,不再上传本地同步后的 GameState。
void persistence.saveCurrentGame({
gameState: syncedGameState,
bottomTab,
currentStory: storyFlow.currentStory,
});

View File

@@ -1,176 +1,29 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import {
buildCustomWorldRuntimeCharacters,
createCharacterSkillCooldowns,
getCharacterMaxHp,
getCharacterMaxMana,
setRuntimeCharacterOverrides,
} from '../../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
import { getInitialPlayerCurrency } from '../../data/economy';
import {
applyEquipmentLoadoutToState,
buildInitialEquipmentLoadout,
createEmptyEquipmentLoadout,
} from '../../data/equipmentEffects';
import {
buildInitialNpcState,
buildInitialPlayerInventory,
} from '../../data/npcInteractions';
import { createEmptyEquipmentLoadout } from '../../data/equipmentEffects';
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
import {
ensureSceneEncounterPreview,
RESOLVED_ENTITY_X_METERS,
} from '../../data/sceneEncounterPreviews';
import {
buildEncounterFromSceneNpc,
getScenePreset,
getScenePresetById,
getWorldCampScenePreset,
} from '../../data/scenePresets';
import {
findCustomWorldRoleByReference,
resolveCustomWorldRoleIdReference,
resolveCustomWorldRoleIdReferences,
} from '../../services/customWorldRoleReferences';
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
import { getScenePreset } from '../../data/scenePresets';
import { beginRpgRuntimeStorySession } from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
Character,
CustomWorldProfile,
Encounter,
EquipmentLoadout,
GameState,
GameRuntimeMode,
InventoryItem,
SceneActBlueprint,
SceneChapterBlueprint,
SceneNpc,
WorldType,
} from '../../types';
import type { BottomTab } from './rpgSessionTypes';
const PLAYER_BASE_MAX_HP = 180;
function mergeStarterInventoryItems<
T extends { category: string; name: string },
>(explicitItems: T[], fallbackItems: T[]) {
const merged = new Map<string, T>();
[...explicitItems, ...fallbackItems].forEach((item) => {
merged.set(`${item.category}:${item.name}`, item);
});
return [...merged.values()];
}
function normalizeExplicitStarterCategory(category: string) {
const normalized = category.trim();
return normalized === '专属物' ? '专属物品' : normalized;
}
function inferExplicitStarterSlot(category: string) {
const normalized = normalizeExplicitStarterCategory(category);
if (normalized === '武器') return 'weapon' as const;
if (normalized === '护甲') return 'armor' as const;
if (
normalized === '饰品' ||
normalized === '稀有品' ||
normalized === '专属物品'
) {
return 'relic' as const;
}
return null;
}
function buildExplicitCustomWorldRoleStarterState(
profile: CustomWorldProfile,
character: Character,
) {
const role =
profile.playableNpcs.find((entry) => entry.id === character.id) ??
profile.storyNpcs.find((entry) => entry.id === character.id) ??
profile.playableNpcs.find((entry) => entry.name === character.name) ??
profile.storyNpcs.find((entry) => entry.name === character.name) ??
null;
const inventory = role
? role.initialItems.map((item, index) => {
const category = normalizeExplicitStarterCategory(item.category);
return {
id: `custom-role-item:${role.id}:${index + 1}`,
category,
name: item.name,
quantity: Math.max(1, item.quantity),
rarity: item.rarity,
tags: [...item.tags],
description: item.description,
equipmentSlotId: inferExplicitStarterSlot(category),
runtimeMetadata: {
origin: 'ai_compiled' as const,
generationChannel: 'discovery' as const,
seedKey: `${role.id}:${index + 1}`,
relationAnchor: {
type: 'npc' as const,
npcId: role.id,
npcName: role.name,
roleText: role.role,
},
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
},
} satisfies InventoryItem;
})
: [];
const equipment: EquipmentLoadout = createEmptyEquipmentLoadout();
inventory.forEach((item) => {
const slot = item.equipmentSlotId;
if (!slot || equipment[slot]) {
return;
}
equipment[slot] = item;
});
return {
inventory,
equipment,
};
}
function createInitialCampEncounter(
worldType: WorldType | null,
playerCharacter: Character,
): Encounter | null {
if (!worldType) return null;
const campScenePreset =
getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
const npcCandidates = (campScenePreset?.npcs ?? [])
.filter((npc: SceneNpc) => Boolean(npc.characterId))
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
if (npcCandidates.length === 0) return null;
const npc =
npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
if (!npc) return null;
return {
id: npc.id,
kind: 'npc',
characterId: npc.characterId,
npcName: npc.name,
npcDescription: npc.description,
npcAvatar: npc.avatar,
context: npc.role,
gender: npc.gender,
xMeters: RESOLVED_ENTITY_X_METERS,
};
}
function createInitialGameState(): GameState {
function createSelectionGameState(): GameState {
return {
worldType: null,
customWorldProfile: null,
@@ -222,261 +75,20 @@ function createInitialGameState(): GameState {
};
}
function resolveOpeningActScenePreset(
profile: CustomWorldProfile | null,
): NonNullable<GameState['currentScenePreset']> | null {
if (!profile) {
return null;
}
const openingChapter = profile.sceneChapterBlueprints?.[0] ?? null;
const openingSceneIds = [
openingChapter?.acts[0]?.sceneId,
openingChapter?.sceneId,
...(openingChapter?.linkedLandmarkIds ?? []),
]
.map((sceneId) => sceneId?.trim() ?? '')
.filter(Boolean);
for (const sceneId of openingSceneIds) {
const directScene = resolveCustomWorldScenePresetByConfiguredId(
profile,
sceneId,
);
if (directScene) {
return directScene;
}
}
const fallbackLandmarkIndex = profile.landmarks.findIndex(
(landmark) => landmark.sceneNpcIds.length > 0,
);
if (fallbackLandmarkIndex >= 0) {
return getScenePresetById(
WorldType.CUSTOM,
`custom-scene-landmark-${fallbackLandmarkIndex + 1}`,
);
}
const firstLandmarkId = profile.landmarks[0]?.id?.trim() ?? '';
if (firstLandmarkId) {
const firstLandmarkScene = getScenePresetById(
WorldType.CUSTOM,
'custom-scene-landmark-1',
);
if (firstLandmarkScene) {
return firstLandmarkScene;
}
}
return profile.landmarks.length > 0
? getScenePresetById(WorldType.CUSTOM, 'custom-scene-landmark-1')
: null;
}
function resolveOpeningSceneActBlueprint(
profile: CustomWorldProfile | null,
): { chapter: SceneChapterBlueprint; act: SceneActBlueprint } | null {
const openingChapter = profile?.sceneChapterBlueprints?.[0] ?? null;
const openingAct = openingChapter?.acts[0] ?? null;
return openingChapter && openingAct
? { chapter: openingChapter, act: openingAct }
: null;
}
function resolveCustomWorldScenePresetByConfiguredId(
profile: CustomWorldProfile,
sceneId: string | null | undefined,
): NonNullable<GameState['currentScenePreset']> | null {
const normalizedSceneId = sceneId?.trim() ?? '';
if (!normalizedSceneId) {
return null;
}
const directScene = getScenePresetById(WorldType.CUSTOM, normalizedSceneId);
if (directScene) {
return directScene;
}
const campId = profile.camp?.id?.trim() ?? '';
if (
normalizedSceneId === campId ||
normalizedSceneId === 'custom-scene-camp'
) {
return getScenePresetById(WorldType.CUSTOM, 'custom-scene-camp');
}
const landmarkIndex = profile.landmarks.findIndex(
(landmark) => landmark.id === normalizedSceneId,
);
if (landmarkIndex < 0) {
return null;
}
return getScenePresetById(
WorldType.CUSTOM,
`custom-scene-landmark-${landmarkIndex + 1}`,
);
}
function resolveOpeningActNpcIdPriority(
profile: CustomWorldProfile,
openingAct: SceneActBlueprint,
) {
return resolveCustomWorldRoleIdReferences(profile, [
openingAct.oppositeNpcId,
openingAct.primaryNpcId,
...openingAct.encounterNpcIds,
]);
}
function doRoleReferencesMatch(
profile: CustomWorldProfile | null,
left: string | null | undefined,
right: string | null | undefined,
) {
const normalizedLeft = resolveCustomWorldRoleIdReference(profile, left);
const normalizedRight = resolveCustomWorldRoleIdReference(profile, right);
return Boolean(normalizedLeft && normalizedLeft === normalizedRight);
}
function findSceneNpcByRuntimeRoleId(
scenePreset: GameState['currentScenePreset'],
profile: CustomWorldProfile | null,
roleId: string,
) {
return (
scenePreset?.npcs?.find(
(npc) =>
doRoleReferencesMatch(profile, npc.id, roleId) ||
doRoleReferencesMatch(profile, npc.characterId, roleId) ||
doRoleReferencesMatch(profile, npc.name, roleId) ||
doRoleReferencesMatch(profile, npc.title, roleId),
) ?? null
);
}
function buildOpeningEncounterFromCustomWorldRole(
profile: CustomWorldProfile,
roleId: string,
): Encounter | null {
const role =
findCustomWorldRoleByReference(profile, roleId);
if (!role) {
return null;
}
const isHostile = role.initialAffinity < 0;
return {
id: role.id,
kind: 'npc',
characterId: role.id,
npcName: role.name,
npcDescription: role.description,
npcAvatar: role.imageSrc ?? role.name.slice(0, 1) ?? '?',
context: role.role,
xMeters: RESOLVED_ENTITY_X_METERS,
initialAffinity: role.initialAffinity,
hostile: isHostile,
title: role.title,
backstory: role.backstory,
personality: role.personality,
motivation: role.motivation,
combatStyle: role.combatStyle,
relationshipHooks: [...role.relationshipHooks],
tags: [...role.tags],
backstoryReveal: role.backstoryReveal,
skills: role.skills.map((skill) => ({ ...skill })),
initialItems: role.initialItems.map((item) => ({
...item,
tags: [...item.tags],
})),
imageSrc: role.imageSrc,
visual: (role as { visual?: Encounter['visual'] }).visual,
narrativeProfile: role.narrativeProfile,
attributeProfile: role.attributeProfile,
};
}
function resolveOpeningActEncounter(params: {
profile: CustomWorldProfile | null;
scenePreset: GameState['currentScenePreset'];
playerCharacter: Character;
}) {
const opening = resolveOpeningSceneActBlueprint(params.profile);
if (!opening || !params.profile) {
return null;
}
for (const npcId of resolveOpeningActNpcIdPriority(params.profile, opening.act)) {
if (
doRoleReferencesMatch(
params.profile,
npcId,
params.playerCharacter.id,
) ||
doRoleReferencesMatch(
params.profile,
npcId,
params.playerCharacter.name,
)
) {
continue;
}
const sceneNpc = findSceneNpcByRuntimeRoleId(
params.scenePreset,
params.profile,
npcId,
);
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
return {
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
xMeters: RESOLVED_ENTITY_X_METERS,
};
}
const roleEncounter = buildOpeningEncounterFromCustomWorldRole(
params.profile,
npcId,
);
if (roleEncounter) {
return roleEncounter;
}
}
return null;
}
function buildOpeningStoryEngineMemory(
profile: CustomWorldProfile | null,
sceneId: string | null | undefined,
) {
const storyEngineMemory = createEmptyStoryEngineMemoryState();
return {
...storyEngineMemory,
currentSceneActState:
buildInitialSceneActRuntimeState({
profile,
sceneId,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
};
}
/**
* RPG session bootstrap 主实现。
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
*/
export function useRpgSessionBootstrap() {
const [gameState, setGameState] = useState<GameState>(() =>
createInitialGameState(),
createSelectionGameState(),
);
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
const [isMapOpen, setIsMapOpen] = useState(false);
useEffect(() => {
// 中文注释:当前运行中的自定义世界 profile 需要同步给静态数据层,
// 这样角色预设、场景预设、运行时引用解析才能读取到同一份世界真相。
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
setRuntimeCharacterOverrides(
gameState.customWorldProfile
@@ -486,9 +98,11 @@ export function useRpgSessionBootstrap() {
}, [gameState.customWorldProfile]);
const resetGame = () => {
// 中文注释reset 不只清 GameState还要把底部 tab 和地图弹层一起还原,
// 避免返回入口后 UI 仍停留在上一次冒险的局部状态。
setBottomTab('adventure');
setIsMapOpen(false);
setGameState(createInitialGameState());
setGameState(createSelectionGameState());
};
const handleCustomWorldSelect = (
@@ -505,11 +119,12 @@ export function useRpgSessionBootstrap() {
);
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
setIsMapOpen(false);
setGameState((prev) =>
ensureSceneEncounterPreview({
...prev,
worldType: resolvedWorldType,
customWorldProfile,
setGameState((prev) => ({
// 中文注释:世界刚选中时只进入“已装入世界,但尚未选角”的中间态;
// 正式开局 GameState 必须等待角色确认后由 server-rs 统一生成。
...prev,
worldType: resolvedWorldType,
customWorldProfile,
runtimeMode,
runtimePersistenceDisabled,
currentScenePreset: initialScenePreset,
@@ -532,153 +147,38 @@ export function useRpgSessionBootstrap() {
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
}),
);
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
}));
};
const handleBackToWorldSelect = () => {
setBottomTab('adventure');
setIsMapOpen(false);
setGameState(createInitialGameState());
setGameState(createSelectionGameState());
};
const handleCharacterSelect = (character: Character) => {
setBottomTab('adventure');
setIsMapOpen(false);
setGameState((prev) => {
const resolvedWorldType = prev.worldType;
const resolvedCustomWorldProfile = prev.customWorldProfile;
const initialScenePreset =
resolvedWorldType === WorldType.CUSTOM
? (resolveOpeningActScenePreset(resolvedCustomWorldProfile) ??
getWorldCampScenePreset(resolvedWorldType) ??
getScenePreset(resolvedWorldType, 0))
: resolvedWorldType
? (getWorldCampScenePreset(resolvedWorldType) ??
getScenePreset(resolvedWorldType, 0))
: null;
const initialEncounter =
resolvedWorldType === WorldType.CUSTOM
? resolveOpeningActEncounter({
profile: resolvedCustomWorldProfile,
scenePreset: initialScenePreset,
playerCharacter: character,
})
: createInitialCampEncounter(resolvedWorldType, character);
const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
: null;
const initialEquipment = buildInitialEquipmentLoadout(
character,
resolvedCustomWorldProfile,
);
const explicitStarterItems =
resolvedWorldType === WorldType.CUSTOM
? buildExplicitCustomWorldRoleStarterState(
resolvedCustomWorldProfile!,
character,
)
: null;
const mergedStarterEquipment = {
weapon:
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
};
const playerMaxHp = getCharacterMaxHp(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
);
const launchState = gameState;
const resolvedWorldType = launchState.worldType;
if (!resolvedWorldType) {
return;
}
const openingState = applyEquipmentLoadoutToState(
{
...prev,
playerCharacter: character,
runtimeMode:
resolvedWorldType === WorldType.CUSTOM
? (prev.runtimeMode ?? 'play')
: (prev.runtimeMode ?? 'play'),
runtimePersistenceDisabled:
resolvedWorldType === WorldType.CUSTOM
? prev.runtimePersistenceDisabled === true
: prev.runtimePersistenceDisabled,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
storyHistory: [],
storyEngineMemory:
resolvedWorldType === WorldType.CUSTOM
? buildOpeningStoryEngineMemory(
resolvedCustomWorldProfile,
initialScenePreset?.id,
)
: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: prev.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId: prev.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: playerMaxHp,
playerMaxHp: playerMaxHp,
playerMana: getCharacterMaxMana(character),
playerMaxMana: getCharacterMaxMana(character),
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(
resolvedWorldType,
resolvedCustomWorldProfile,
),
playerInventory: mergeStarterInventoryItems<InventoryItem>(
explicitStarterItems?.inventory ?? [],
buildInitialPlayerInventory(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
),
),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates:
initialEncounter && initialNpcState
? {
[initialEncounter.id!]: initialNpcState,
}
: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
mergedStarterEquipment,
);
return resolvedWorldType === WorldType.CUSTOM
? openingState
: ensureSceneEncounterPreview(openingState);
void beginRpgRuntimeStorySession({
worldType: resolvedWorldType,
customWorldProfile: launchState.customWorldProfile,
character,
runtimeMode: launchState.runtimeMode ?? 'play',
disablePersistence: launchState.runtimePersistenceDisabled === true,
}).then((response) => {
// 中文注释:开局正式 GameState 由 server-rs 生成并持久化;
// 前端只接收后端快照,避免浏览器继续承担初始背包、装备、遭遇和 NPC 状态裁决。
setGameState(response.snapshot.gameState);
});
};
@@ -699,3 +199,4 @@ export function useRpgSessionBootstrap() {
export type RpgSessionBootstrapResult = ReturnType<
typeof useRpgSessionBootstrap
>;

View File

@@ -2,7 +2,10 @@
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { isAbortError } from '../../services/apiClient';
import { rpgSnapshotClient } from '../../services/rpg-runtime';
import {
getRpgRuntimeSessionId,
rpgSnapshotClient,
} from '../../services/rpg-runtime';
import type { GameState, StoryMoment } from '../../types';
import { resumeServerRuntimeStory } from '../rpg-runtime-story/runtimeStoryCoordinator';
import type { BottomTab } from './rpgSessionTypes';
@@ -10,6 +13,8 @@ import type { BottomTab } from './rpgSessionTypes';
const AUTO_SAVE_DELAY_MS = 400;
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
// 中文注释preview / test 模式、非 Story 场景、未选角、以及流式输出中的故事都不应入正式存档,
// 否则容易把临时态或半成品叙事写进继续游戏链路。
return (
gameState.runtimePersistenceDisabled !== true &&
gameState.runtimeMode !== 'preview' &&
@@ -30,6 +35,8 @@ function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
}
function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
// 中文注释:远端快照允许缺少局部 UI 状态;
// 这里统一补底部 tab 的兜底值,避免恢复后落到非法面板名。
return {
gameState: snapshot.gameState,
currentStory: snapshot.currentStory ?? null,
@@ -75,6 +82,8 @@ export function useRpgSessionPersistence({
const saveRequestIdRef = useRef(0);
const abortActiveSave = useCallback(() => {
// 中文注释:自动存档是“后写覆盖前写”的串行语义;
// 新一次保存开始前,主动打断旧请求,避免旧快照回写覆盖最新状态。
saveControllerRef.current?.abort();
saveControllerRef.current = null;
setIsPersistingSnapshot(false);
@@ -83,9 +92,8 @@ export function useRpgSessionPersistence({
const persistSnapshot = useCallback(
async (params: {
payload: {
gameState: GameState;
sessionId: string;
bottomTab: BottomTab;
currentStory: StoryMoment | null;
};
logLabel: string;
}) => {
@@ -103,11 +111,12 @@ export function useRpgSessionPersistence({
setPersistenceError(null);
try {
// 中文注释:这里不再上传整份本地快照;
// 前端只告诉后端“当前 session 需要 checkpoint”真实 GameState 由服务端快照表读取。
const snapshot = await rpgSnapshotClient.putSnapshot(
{
gameState: params.payload.gameState,
sessionId: params.payload.sessionId,
bottomTab: params.payload.bottomTab,
currentStory: params.payload.currentStory,
},
{ signal: controller.signal },
);
@@ -158,6 +167,8 @@ export function useRpgSessionPersistence({
hydrateControllerRef.current = controller;
setIsHydratingSnapshot(true);
// 中文注释:登录后第一时间探测一次远端快照,
// 让入口页能够准确判断“继续游戏”按钮是否可见。
void rpgSnapshotClient
.getSnapshot({ signal: controller.signal })
.then((snapshot) => {
@@ -207,12 +218,13 @@ export function useRpgSessionPersistence({
if (!canPersist) return;
// 中文注释:自动存档做一个很短的去抖,
// 避免同一轮状态连锁更新时重复打多次快照请求。
const timeoutId = window.setTimeout(() => {
void persistSnapshot({
payload: {
gameState,
sessionId: getRpgRuntimeSessionId(gameState),
bottomTab,
currentStory,
},
logLabel: 'failed to autosave remote snapshot',
});
@@ -235,11 +247,12 @@ export function useRpgSessionPersistence({
return false;
}
// 中文注释:手动存档和自动存档走同一套底层 persist 逻辑,
// 差别只在于调用方可显式覆盖本次 checkpoint 的 session 与 UI tab。
const snapshot = await persistSnapshot({
payload: {
gameState: nextGameState,
sessionId: getRpgRuntimeSessionId(nextGameState),
bottomTab: nextBottomTab,
currentStory: nextStory,
},
logLabel: 'failed to save remote snapshot',
});
@@ -300,6 +313,8 @@ export function useRpgSessionPersistence({
resetStoryState();
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
// 中文注释:继续游戏不是简单把旧 currentStory 塞回去,
// 还要向服务端刷新一遍 runtime story拿到当前服务端判定的可选动作与视图模型。
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
(error) => {
if (!isAbortError(error)) {

View File

@@ -23,6 +23,8 @@ vi.mock('../services/rpg-entry', () => ({
}));
vi.mock('../services/rpg-runtime', () => ({
getRpgRuntimeSessionId: (gameState: Pick<GameState, 'runtimeSessionId'>) =>
gameState.runtimeSessionId?.trim() || 'runtime-main',
rpgSnapshotClient: {
getSnapshot: storageMocks.getSaveSnapshot,
putSnapshot: storageMocks.putSaveSnapshot,
@@ -30,7 +32,11 @@ vi.mock('../services/rpg-runtime', () => ({
},
}));
function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) {
function SettingsHarness({
authenticatedUserId,
}: {
authenticatedUserId: string | null;
}) {
const settings = useGameSettings(authenticatedUserId);
return (
@@ -50,14 +56,18 @@ function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string
function PersistenceHarness({
authenticatedUserId,
gameState = {} as GameState,
currentStory = null as StoryMoment | null,
}: {
authenticatedUserId: string | null;
gameState?: GameState;
currentStory?: StoryMoment | null;
}) {
const persistence = useRpgSessionPersistence({
authenticatedUserId,
gameState: {} as GameState,
gameState,
bottomTab: 'adventure' as BottomTab,
currentStory: null as StoryMoment | null,
currentStory,
isLoading: false,
setGameState: () => {},
setBottomTab: () => {},
@@ -67,7 +77,9 @@ function PersistenceHarness({
return (
<div>
<div data-testid="saved-game">{persistence.hasSavedGame ? 'yes' : 'no'}</div>
<div data-testid="saved-game">
{persistence.hasSavedGame ? 'yes' : 'no'}
</div>
<div data-testid="hydrating">
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
</div>
@@ -161,3 +173,64 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => {
expect(screen.getByTestId('saved-game').textContent).toBe('no');
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
});
test('runtime autosave requests backend checkpoint without uploading local state', async () => {
vi.useFakeTimers();
storageMocks.getSaveSnapshot.mockResolvedValue(null);
storageMocks.putSaveSnapshot.mockResolvedValue({
version: 2,
savedAt: '2026-04-28T10:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
runtimeSessionId: 'runtime-main',
currentScene: 'Story',
},
});
const gameState = {
runtimeSessionId: 'runtime-main',
runtimePersistenceDisabled: false,
runtimeMode: 'play',
currentScene: 'Story',
worldType: 'CUSTOM',
playerCharacter: { id: 'hero_001' },
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
} as unknown as GameState;
const story = { text: '开场', options: [], streaming: false } as StoryMoment;
render(
<PersistenceHarness
authenticatedUserId="user-1"
gameState={gameState}
currentStory={story}
/>,
);
await act(async () => {
await Promise.resolve();
});
expect(storageMocks.getSaveSnapshot).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(400);
await Promise.resolve();
await Promise.resolve();
});
expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith(
{
sessionId: 'runtime-main',
bottomTab: 'adventure',
},
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
});