Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user