Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -40,7 +40,7 @@ export function useStoryInventoryActions({
} = runtime;
const [serverInventoryView, setServerInventoryView] =
useState<RuntimeStoryInventoryView | null>(null);
const runtimeSessionId = gameState.runtimeSessionId;
const storySessionId = gameState.storySessionId;
const runtimeActionVersion = gameState.runtimeActionVersion;
const currentScene = gameState.currentScene;
const hasPlayerCharacter = Boolean(gameState.playerCharacter);
@@ -56,7 +56,7 @@ export function useStoryInventoryActions({
void loadRpgRuntimeInventoryView(
{
gameState: {
runtimeSessionId,
storySessionId,
runtimeActionVersion,
},
},
@@ -80,7 +80,7 @@ export function useStoryInventoryActions({
currentScene,
hasPlayerCharacter,
runtimeActionVersion,
runtimeSessionId,
storySessionId,
setAiError,
]);

View File

@@ -3,20 +3,15 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapsho
import {
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStoryState,
getRpgRuntimeStorySessionId,
getRpgStoryRuntimeProjection,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryProjectionMoment,
resolveRpgRuntimeStoryMoment,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import type { GameState, StoryMoment, StoryOption } from '../../types';
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
return response.viewModel.availableOptions.length > 0
? response.viewModel.availableOptions
: response.presentation.options;
}
/**
* 前端访问服务端 runtime story 的统一网关。
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
@@ -27,15 +22,13 @@ export async function loadServerRuntimeOptionCatalog(params: {
}) {
// 中文注释:状态目录只从服务端持久化 session 读取,
// 前端不再上传本地 GameState 快照参与动作合法性解析。
const response = await getRpgRuntimeStoryState({
sessionId: getRpgRuntimeSessionId(params.gameState),
const response = await getRpgStoryRuntimeProjection({
storySessionId: getRpgRuntimeStorySessionId(params.gameState),
clientVersion: getRpgRuntimeClientVersion(params.gameState),
});
const options = resolveRpgRuntimeStoryMoment({
response,
hydratedSnapshot: response.snapshot,
fallbackGameState: params.gameState,
fallbackStoryText: response.presentation.storyText,
const options = resolveRpgRuntimeStoryProjectionMoment({
projection: response,
gameState: params.gameState,
}).options;
return options.length > 0 ? options : null;
@@ -59,24 +52,26 @@ export async function resumeServerRuntimeStory(
// 中文注释:继续游戏后向服务端刷新一次状态,
// 让长期离线的本地快照重新对齐服务端当前 runtime view model。
const response = await getRpgRuntimeStoryState({
sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState),
const response = await getRpgStoryRuntimeProjection({
storySessionId: getRpgRuntimeStorySessionId(hydratedSnapshot.gameState),
});
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const runtimeOptions = getRuntimeResponseOptions(response);
const runtimeOptions = response.options;
const nextStory =
response.presentation.storyText || runtimeOptions.length > 0
? resolveRpgRuntimeStoryMoment({
response,
hydratedSnapshot: resumedSnapshot,
fallbackGameState: hydratedSnapshot.gameState,
fallbackStoryText:
response.presentation.storyText ||
resumedSnapshot.currentStory?.text ||
hydratedSnapshot.currentStory?.text ||
'',
response.currentNarrativeText || runtimeOptions.length > 0
? resolveRpgRuntimeStoryProjectionMoment({
projection: response,
gameState: hydratedSnapshot.gameState,
})
: resumedSnapshot.currentStory;
: hydratedSnapshot.currentStory;
const resumedSnapshot = {
...hydratedSnapshot,
gameState: {
...hydratedSnapshot.gameState,
runtimeSessionId: response.storySession.runtimeSessionId,
storySessionId: response.storySession.storySessionId,
runtimeActionVersion: response.serverVersion,
},
} satisfies HydratedSavedGameSnapshot;
return {
hydratedSnapshot: resumedSnapshot,

View File

@@ -1,14 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
getRuntimeStoryStateMock,
getStoryRuntimeProjectionMock,
resolveRuntimeStoryActionMock,
getRuntimeSessionIdMock,
getRuntimeStorySessionIdMock,
getRuntimeClientVersionMock,
} = vi.hoisted(() => ({
getRuntimeStoryStateMock: vi.fn(),
getStoryRuntimeProjectionMock: vi.fn(),
resolveRuntimeStoryActionMock: vi.fn(),
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
getRuntimeStorySessionIdMock: vi.fn(() => 'storysess-main'),
getRuntimeClientVersionMock: vi.fn(() => 0),
}));
@@ -22,13 +24,15 @@ vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
return {
...actual,
getRpgRuntimeStoryState: getRuntimeStoryStateMock,
getRpgStoryRuntimeProjection: getStoryRuntimeProjectionMock,
resolveRpgRuntimeStoryAction: resolveRuntimeStoryActionMock,
getRpgRuntimeSessionId: getRuntimeSessionIdMock,
getRpgRuntimeStorySessionId: getRuntimeStorySessionIdMock,
getRpgRuntimeClientVersion: getRuntimeClientVersionMock,
getRuntimeStoryState: getRuntimeStoryStateMock,
getStoryRuntimeProjection: getStoryRuntimeProjectionMock,
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
getRuntimeSessionId: getRuntimeSessionIdMock,
getRuntimeStorySessionId: getRuntimeStorySessionIdMock,
getRuntimeClientVersion: getRuntimeClientVersionMock,
};
});
@@ -52,6 +56,7 @@ function createStory(text: string): StoryMoment {
function createGameState(): GameState {
return {
runtimeSessionId: 'runtime-main',
storySessionId: 'storysess-main',
runtimeActionVersion: 7,
} as GameState;
}
@@ -59,6 +64,7 @@ function createGameState(): GameState {
function createTravelGameState(): GameState {
return {
runtimeSessionId: 'runtime-main',
storySessionId: 'storysess-main',
runtimeActionVersion: 7,
worldType: WorldType.WUXIA,
currentScene: 'Story',
@@ -172,6 +178,7 @@ function createRuntimeNpcBattleSnapshot(
},
runtimeActionVersion: 8,
runtimeSessionId: 'runtime-main',
storySessionId: 'storysess-main',
currentScene: 'Story',
runtimeStats: {
playTimeMs: 0,
@@ -249,10 +256,12 @@ function createRuntimeNpcBattleSnapshot(
describe('runtimeStoryCoordinator', () => {
beforeEach(() => {
getRuntimeStoryStateMock.mockReset();
getStoryRuntimeProjectionMock.mockReset();
resolveRuntimeStoryActionMock.mockReset();
getRuntimeSessionIdMock.mockReset();
getRuntimeSessionIdMock.mockReturnValue('runtime-main');
getRuntimeStorySessionIdMock.mockReset();
getRuntimeStorySessionIdMock.mockReturnValue('storysess-main');
getRuntimeClientVersionMock.mockReset();
getRuntimeClientVersionMock.mockReturnValue(7);
});
@@ -261,46 +270,53 @@ describe('runtimeStoryCoordinator', () => {
const gameState = createGameState();
const currentStory = createStory('当前故事');
getRuntimeStoryStateMock.mockResolvedValue({
sessionId: 'runtime-main',
getStoryRuntimeProjectionMock.mockResolvedValue({
storySession: {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '服务端故事',
latestChoiceFunctionId: null,
status: 'active',
version: 3,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:00.000Z',
},
storyEvents: [],
serverVersion: 3,
viewModel: {
player: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
},
encounter: null,
companions: [],
availableOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
scope: 'npc',
},
],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
actor: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
scope: 'npc',
enabled: true,
},
],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
presentation: {
actionText: '',
resultText: '',
storyText: '服务端故事',
options: [],
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
currentNarrativeText: '服务端故事',
actionResultText: null,
toast: null,
});
const options = await loadServerRuntimeOptionCatalog({
@@ -308,8 +324,8 @@ describe('runtimeStoryCoordinator', () => {
currentStory,
});
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
expect(getStoryRuntimeProjectionMock).toHaveBeenCalledWith({
storySessionId: 'storysess-main',
clientVersion: 7,
});
expect(options).toEqual([
@@ -443,73 +459,72 @@ describe('runtimeStoryCoordinator', () => {
},
runtimeActionVersion: 7,
runtimeSessionId: 'runtime-main',
storySessionId: 'storysess-main',
} as unknown as GameState,
currentStory: createStory('本地快照故事'),
bottomTab: 'inventory' as const,
} as HydratedSavedGameSnapshot;
const serverHydratedSnapshot = {
version: 8,
savedAt: '2026-04-08T00:00:00.000Z',
gameState: {
currentScene: 'Story',
worldType: 'wuxia',
playerCharacter: {
id: 'hero',
},
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
runtimeActionVersion: 8,
getStoryRuntimeProjectionMock.mockResolvedValue({
storySession: {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
} as unknown as GameState,
currentStory: createStory('服务端快照故事'),
bottomTab: 'character' as const,
} as HydratedSavedGameSnapshot;
getRuntimeStoryStateMock.mockResolvedValue({
sessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '服务端恢复后的故事',
latestChoiceFunctionId: null,
status: 'active',
version: 8,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:00.000Z',
},
storyEvents: [],
serverVersion: 8,
viewModel: {
player: {
hp: 90,
maxHp: 100,
mana: 16,
maxMana: 20,
},
encounter: null,
companions: [],
availableOptions: [
{
functionId: 'npc_help',
actionText: '请求援手',
scope: 'npc',
},
],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
actor: {
hp: 90,
maxHp: 100,
mana: 16,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
presentation: {
actionText: '',
resultText: '',
storyText: '服务端恢复后的故事',
options: [],
inventory: {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
patches: [],
snapshot: serverHydratedSnapshot,
options: [
{
functionId: 'npc_help',
actionText: '请求援手',
scope: 'npc',
enabled: true,
},
],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText: '服务端恢复后的故事',
actionResultText: null,
toast: null,
});
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
expect(getStoryRuntimeProjectionMock).toHaveBeenCalledWith({
storySessionId: 'storysess-main',
});
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
expect(result.hydratedSnapshot.gameState).toEqual(
expect.objectContaining({
runtimeSessionId: 'runtime-main',
storySessionId: 'storysess-main',
runtimeActionVersion: 8,
}),
);
expect(result.nextStory).toEqual(
expect.objectContaining({
text: '服务端恢复后的故事',
@@ -545,7 +560,7 @@ describe('runtimeStoryCoordinator', () => {
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(getRuntimeStoryStateMock).not.toHaveBeenCalled();
expect(getStoryRuntimeProjectionMock).not.toHaveBeenCalled();
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
});
@@ -844,78 +859,71 @@ describe('runtimeStoryCoordinator', () => {
).toBe(false);
});
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
it('refreshes mid-battle story options from projection while keeping local snapshot shape', async () => {
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
runtimeActionVersion: 7,
});
const rawServerBattleSnapshot = createRuntimeNpcBattleSnapshot({
runtimeActionVersion: 8,
playerHp: 39,
sceneHostileNpcs: [
{
id: 'npc-bandit',
name: '断桥匪首',
hp: 14,
maxHp: 32,
description: '拦路的刀客',
},
] as unknown as GameState['sceneHostileNpcs'],
storySessionId: 'storysess-main',
});
getRuntimeStoryStateMock.mockResolvedValue({
sessionId: 'runtime-main',
getStoryRuntimeProjectionMock.mockResolvedValue({
storySession: {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '断桥匪首还在步步逼近。',
latestChoiceFunctionId: null,
status: 'active',
version: 8,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:00.000Z',
},
storyEvents: [],
serverVersion: 8,
viewModel: {
player: {
hp: 39,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-bandit',
kind: 'npc',
npcName: '断桥匪首',
hostile: true,
affinity: -12,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_guard_break',
actionText: '破架重击',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
actor: {
hp: 39,
maxHp: 50,
mana: 20,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
presentation: {
actionText: '',
resultText: '',
storyText: '断桥匪首还在步步逼近。',
options: [],
inventory: {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
patches: [],
snapshot: rawServerBattleSnapshot,
options: [
{
functionId: 'battle_guard_break',
actionText: '破架重击',
scope: 'combat',
enabled: true,
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
currentNarrativeText: '断桥匪首还在步步逼近。',
actionResultText: null,
toast: null,
});
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
expect(getStoryRuntimeProjectionMock).toHaveBeenCalledWith({
storySessionId: 'storysess-main',
});
expect(result.hydratedSnapshot.gameState.runtimeActionVersion).toBe(8);
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
expect.objectContaining({
id: 'npc-bandit',
hp: 14,
hp: 21,
maxHp: 32,
encounter: expect.objectContaining({
kind: 'npc',

View File

@@ -18,6 +18,9 @@ import { useRpgSessionBootstrap } from './rpg-session';
const aiServiceMocks = vi.hoisted(() => ({
streamNpcChatTurn: vi.fn(),
}));
const rpgRuntimeStoryClientMocks = vi.hoisted(() => ({
beginRpgRuntimeStorySession: vi.fn(),
}));
vi.mock('../services/aiService', async () => {
const actual =
@@ -31,6 +34,18 @@ vi.mock('../services/aiService', async () => {
};
});
vi.mock('../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
const actual = await vi.importActual<
typeof import('../services/rpg-runtime/rpgRuntimeStoryClient')
>('../services/rpg-runtime/rpgRuntimeStoryClient');
return {
...actual,
beginRpgRuntimeStorySession:
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession,
};
});
function buildBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
@@ -401,6 +416,163 @@ function readSnapshot() {
};
}
function findRuntimeNpc(profile: ReturnType<typeof buildSavedProfile>) {
const npc = profile.storyNpcs.find((candidate) => candidate.id === 'story-act-only');
if (!npc) {
throw new Error('test npc story-act-only not found');
}
return npc;
}
function buildRuntimeStoryBootstrapSnapshot(params: {
profile: ReturnType<typeof buildSavedProfile>;
character: NonNullable<ReturnType<typeof buildCustomWorldPlayableCharacters>[number]>;
}) {
const npc = findRuntimeNpc(params.profile);
const playableSource = params.profile.playableNpcs.find(
(candidate) => candidate.id === params.character.id,
);
const initialItems = playableSource?.initialItems ?? [];
const currentScenePreset = {
id: 'custom-scene-camp',
name: '回潮暂栖所',
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
imageSrc: '',
connectedSceneIds: ['custom-scene-landmark-1', 'custom-scene-landmark-2'],
};
const weapon = initialItems.find(
(item) => item.id === 'item-playable-1',
);
const relic = initialItems.find(
(item) => item.id === 'item-playable-3',
);
return {
sessionId: 'runtime-main',
serverVersion: 1,
snapshot: {
version: 2,
savedAt: '2026-04-29T00:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: WorldType.CUSTOM,
customWorldProfile: params.profile,
playerCharacter: params.character,
runtimeSessionId: 'runtime-main',
storySessionId: 'storysess-main',
runtimeActionVersion: 1,
runtimeMode: 'play',
runtimePersistenceDisabled: false,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
playerProgression: {
level: 1,
currentLevelXp: 0,
totalXp: 0,
xpToNextLevel: 100,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: ['chapter-1'],
currentSceneActState: {
sceneId: 'custom-scene-camp',
chapterId: 'chapter-1',
currentActId: 'act-1',
currentActIndex: 0,
completedActIds: [],
visitedActIds: ['act-1'],
},
},
chapterState: null,
campaignState: null,
activeScenarioPackId: 'scenario-pack:tide',
activeCampaignPackId: 'campaign-pack:tide',
characterChats: {},
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: 'idle',
currentEncounter: {
id: 'story-act-only',
kind: 'npc',
npcName: npc.name,
npcDescription: npc.description,
npcAvatar: '',
context: '陆衡拿着异常账本,在开盘前拦住玩家。',
characterId: npc.id,
initialAffinity: npc.initialAffinity,
title: npc.title,
backstory: npc.backstory,
personality: npc.personality,
motivation: npc.motivation,
combatStyle: npc.combatStyle,
relationshipHooks: npc.relationshipHooks,
tags: npc.tags,
backstoryReveal: npc.backstoryReveal,
skills: npc.skills,
initialItems: npc.initialItems,
narrativeProfile: npc.narrativeProfile,
},
npcInteractionActive: false,
currentScenePreset,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 180,
playerMaxHp: 180,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: initialItems,
playerEquipment: {
weapon: weapon ?? null,
armor: {
id: 'test-armor',
category: '防具',
name: '潮雾外衣',
quantity: 1,
rarity: 'common',
tags: ['防具'],
equipmentSlotId: 'armor',
},
relic: relic ?? null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
},
};
}
function GameFlowHarness({
openingOppositeNpcId,
}: {
@@ -415,6 +587,14 @@ function GameFlowHarness({
[profile],
);
const selectedCharacter = playableCharacters[0] ?? null;
if (selectedCharacter) {
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession.mockResolvedValue(
buildRuntimeStoryBootstrapSnapshot({
profile,
character: selectedCharacter,
}),
);
}
const {
gameState,
setGameState,