Persist custom world asset configs in runtime snapshots

This commit is contained in:
2026-04-18 17:00:46 +08:00
parent 7ce61e9879
commit ac801fe05f
29 changed files with 3397 additions and 400 deletions

View File

@@ -12,6 +12,7 @@ vi.mock('./apiClient', async () => {
};
});
import { AnimationState } from '../types';
import {
buildStoryMomentFromRuntimeOptions,
getRuntimeClientVersion,
@@ -19,9 +20,9 @@ import {
isServerRuntimeFunctionId,
isTask5RuntimeFunctionId,
resolveRuntimeStoryAction,
resolveRuntimeStoryMoment,
shouldUseServerRuntimeOptions,
} from './runtimeStoryService';
import { AnimationState } from '../types';
describe('runtimeStoryService', () => {
beforeEach(() => {
@@ -246,4 +247,99 @@ describe('runtimeStoryService', () => {
action: 'trade',
});
});
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
const story = resolveRuntimeStoryMoment({
response: {
sessionId: 'runtime-main',
serverVersion: 4,
viewModel: {
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
encounter: null,
companions: [],
availableOptions: [],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '继续交谈',
resultText: '后端已结算',
storyText: '普通文本',
options: [],
battle: null,
toast: null,
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never,
},
hydratedSnapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never,
fallbackStoryText: '普通文本',
});
expect(story.displayMode).toBe('dialogue');
expect(story.deferredOptions).toHaveLength(1);
expect(story.text).toContain('梁伯');
});
});

View File

@@ -168,6 +168,45 @@ export function buildStoryMomentFromRuntimeOptions(params: {
} satisfies StoryMoment;
}
function shouldPreferSnapshotStory(story: StoryMoment | null) {
return Boolean(
story &&
(
story.displayMode === 'dialogue' ||
story.deferredOptions?.length ||
story.dialogue?.length
),
);
}
export function resolveRuntimeStoryMoment(params: {
response: RuntimeStoryResponse;
hydratedSnapshot: HydratedSavedGameSnapshot;
fallbackGameState?: Pick<GameState, 'currentEncounter'>;
fallbackStoryText?: string;
}) {
if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) {
return params.hydratedSnapshot.currentStory!;
}
const options =
params.response.viewModel.availableOptions.length > 0
? params.response.viewModel.availableOptions
: params.response.presentation.options;
return buildStoryMomentFromRuntimeOptions({
storyText:
params.response.presentation.storyText ||
params.hydratedSnapshot.currentStory?.text ||
params.fallbackStoryText ||
'',
options,
gameState: params.hydratedSnapshot.gameState.currentEncounter
? params.hydratedSnapshot.gameState
: params.fallbackGameState,
});
}
export async function getRuntimeStoryState(
sessionId: string,
options: RuntimeStoryServiceOptions = {},