Close DDD refactor and remove generated asset proxy

This commit is contained in:
kdletters
2026-05-02 00:27:22 +08:00
parent fd08262bf0
commit 9d9913095d
605 changed files with 11811 additions and 10106 deletions

View File

@@ -5,15 +5,15 @@ vi.mock('../../services/aiService', () => ({
}));
const {
isRpgRuntimeServerFunctionIdMock,
isServerRuntimeFunctionIdMock,
runServerRuntimeChoiceActionMock,
} = vi.hoisted(() => ({
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
isServerRuntimeFunctionIdMock: vi.fn(() => false),
runServerRuntimeChoiceActionMock: vi.fn(),
}));
vi.mock('../../services/rpg-runtime', () => ({
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
isServerRuntimeFunctionId: isServerRuntimeFunctionIdMock,
}));
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
@@ -164,8 +164,8 @@ const neverNpcEncounter = (
describe('createStoryChoiceActions', () => {
beforeEach(() => {
isRpgRuntimeServerFunctionIdMock.mockReset();
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
isServerRuntimeFunctionIdMock.mockReset();
isServerRuntimeFunctionIdMock.mockReturnValue(false);
runServerRuntimeChoiceActionMock.mockReset();
});
@@ -389,7 +389,7 @@ describe('createStoryChoiceActions', () => {
const setCurrentStory = vi.fn();
const handleNpcInteraction = vi.fn(() => true);
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
isServerRuntimeFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: {
@@ -493,7 +493,7 @@ describe('createStoryChoiceActions', () => {
};
const handleNpcInteraction = vi.fn(() => true);
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
isServerRuntimeFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
@@ -575,7 +575,7 @@ describe('createStoryChoiceActions', () => {
}));
const playResolvedChoice = vi.fn().mockResolvedValue(state);
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
isServerRuntimeFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
@@ -681,7 +681,7 @@ describe('createStoryChoiceActions', () => {
inBattle: true,
});
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
isServerRuntimeFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
@@ -757,7 +757,7 @@ describe('createStoryChoiceActions', () => {
const buildResolvedChoiceState = vi.fn();
const playResolvedChoice = vi.fn();
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
isServerRuntimeFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
@@ -849,7 +849,7 @@ describe('createStoryChoiceActions', () => {
const playResolvedChoice = vi.fn();
const commitGeneratedStateWithEncounterEntry = vi.fn();
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
isServerRuntimeFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,

View File

@@ -4,7 +4,7 @@
} from 'react';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { isRpgRuntimeServerFunctionId } from '../../services/rpg-runtime';
import { isServerRuntimeFunctionId } from '../../services/rpg-runtime';
import {
type Character,
type Encounter,
@@ -191,7 +191,7 @@ export function createStoryChoiceActions({
return;
}
if (isRpgRuntimeServerFunctionId(option.functionId)) {
if (isServerRuntimeFunctionId(option.functionId)) {
await runServerRuntimeChoiceAction({
gameState,
currentStory,

View File

@@ -2,7 +2,7 @@
import type { RuntimeStoryInventoryActionView } from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
import {
loadRpgRuntimeInventoryView,
loadRuntimeInventoryView,
type RuntimeStoryChoicePayload,
type RuntimeStoryInventoryView,
} from '../../services/rpg-runtime';
@@ -53,7 +53,7 @@ export function useStoryInventoryActions({
const controller = new AbortController();
void loadRpgRuntimeInventoryView(
void loadRuntimeInventoryView(
{
gameState: {
storySessionId,
@@ -129,7 +129,7 @@ export function useStoryInventoryActions({
setGameState(hydratedSnapshot.gameState);
setCurrentStory(nextStory);
setServerInventoryView(response.viewModel.inventory);
setServerInventoryView(response.inventoryView);
return true;
} catch (error) {
console.error('Failed to resolve inventory runtime action on the server:', error);

View File

@@ -1,13 +1,13 @@
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStorySessionId,
getRpgStoryRuntimeProjection,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryProjectionMoment,
resolveRpgRuntimeStoryMoment,
buildRuntimeSnapshotFromProjection,
buildStoryMomentFromRuntimeProjection,
getRuntimeClientVersion,
getRuntimeStorySessionId,
getStoryRuntimeProjection,
resolveRuntimeStoryAction,
resolveRuntimeStoryMoment,
type RuntimeStoryChoicePayload,
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import type { GameState, StoryMoment, StoryOption } from '../../types';
@@ -22,11 +22,11 @@ export async function loadServerRuntimeOptionCatalog(params: {
}) {
// 中文注释:状态目录只从服务端持久化 session 读取,
// 前端不再上传本地 GameState 快照参与动作合法性解析。
const response = await getRpgStoryRuntimeProjection({
storySessionId: getRpgRuntimeStorySessionId(params.gameState),
clientVersion: getRpgRuntimeClientVersion(params.gameState),
const response = await getStoryRuntimeProjection({
storySessionId: getRuntimeStorySessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
});
const options = resolveRpgRuntimeStoryProjectionMoment({
const options = buildStoryMomentFromRuntimeProjection({
projection: response,
gameState: params.gameState,
}).options;
@@ -52,25 +52,25 @@ export async function resumeServerRuntimeStory(
// 中文注释:继续游戏后向服务端刷新一次状态,
// 让长期离线的本地快照重新对齐服务端当前 runtime view model。
const response = await getRpgStoryRuntimeProjection({
storySessionId: getRpgRuntimeStorySessionId(hydratedSnapshot.gameState),
const response = await getStoryRuntimeProjection({
storySessionId: getRuntimeStorySessionId(hydratedSnapshot.gameState),
});
const runtimeOptions = response.options;
const nextStory =
response.currentNarrativeText || runtimeOptions.length > 0
? resolveRpgRuntimeStoryProjectionMoment({
? buildStoryMomentFromRuntimeProjection({
projection: response,
gameState: hydratedSnapshot.gameState,
})
: hydratedSnapshot.currentStory;
const projectedSnapshot = buildRuntimeSnapshotFromProjection(
response,
hydratedSnapshot.bottomTab,
);
const resumedSnapshot = {
...hydratedSnapshot,
gameState: {
...hydratedSnapshot.gameState,
runtimeSessionId: response.storySession.runtimeSessionId,
storySessionId: response.storySession.storySessionId,
runtimeActionVersion: response.serverVersion,
},
gameState: projectedSnapshot.gameState,
currentStory: nextStory,
} satisfies HydratedSavedGameSnapshot;
return {
@@ -88,9 +88,9 @@ export async function resolveServerRuntimeChoice(params: {
}) {
// 中文注释:正式动作结算统一先走服务端;
// 前端这里只提交 action/payload并消费后端已经补齐的快照与表现数据。
const response = await resolveRpgRuntimeStoryAction({
sessionId: getRpgRuntimeSessionId(params.gameState),
clientVersion: getRpgRuntimeClientVersion(params.gameState),
const response = await resolveRuntimeStoryAction({
storySessionId: getRuntimeStorySessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
option: params.option,
targetId:
params.option.interaction?.kind === 'npc'
@@ -98,17 +98,17 @@ export async function resolveServerRuntimeChoice(params: {
: undefined,
payload: params.payload,
});
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const hydratedSnapshot = response.snapshot;
return {
response,
hydratedSnapshot,
nextStory: resolveRpgRuntimeStoryMoment({
nextStory: resolveRuntimeStoryMoment({
response,
hydratedSnapshot,
fallbackGameState: params.gameState,
fallbackStoryText:
response.presentation.storyText ||
response.presentation?.storyText ||
hydratedSnapshot.currentStory?.text ||
params.option.actionText,
}),

View File

@@ -3,13 +3,11 @@
const {
getStoryRuntimeProjectionMock,
resolveRuntimeStoryActionMock,
getRuntimeSessionIdMock,
getRuntimeStorySessionIdMock,
getRuntimeClientVersionMock,
} = vi.hoisted(() => ({
getStoryRuntimeProjectionMock: vi.fn(),
resolveRuntimeStoryActionMock: vi.fn(),
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
getRuntimeStorySessionIdMock: vi.fn(() => 'storysess-main'),
getRuntimeClientVersionMock: vi.fn(() => 0),
}));
@@ -24,14 +22,8 @@ vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
return {
...actual,
getRpgStoryRuntimeProjection: getStoryRuntimeProjectionMock,
resolveRpgRuntimeStoryAction: resolveRuntimeStoryActionMock,
getRpgRuntimeSessionId: getRuntimeSessionIdMock,
getRpgRuntimeStorySessionId: getRuntimeStorySessionIdMock,
getRpgRuntimeClientVersion: getRuntimeClientVersionMock,
getStoryRuntimeProjection: getStoryRuntimeProjectionMock,
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
getRuntimeSessionId: getRuntimeSessionIdMock,
getRuntimeStorySessionId: getRuntimeStorySessionIdMock,
getRuntimeClientVersion: getRuntimeClientVersionMock,
};
@@ -40,12 +32,20 @@ vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { GameState, StoryMoment, StoryOption } from '../../types';
import { WorldType } from '../../types';
import type { StoryRuntimeProjectionResponse } from '../../../packages/shared/src/contracts/story';
import {
loadServerRuntimeOptionCatalog,
resolveServerRuntimeChoice,
resumeServerRuntimeStory,
} from './runtimeStoryCoordinator';
type RuntimeProjectionOverrides = Omit<
Partial<StoryRuntimeProjectionResponse>,
'storySession'
> & {
storySession?: Partial<StoryRuntimeProjectionResponse['storySession']>;
};
function createStory(text: string): StoryMoment {
return {
text,
@@ -61,6 +61,115 @@ function createGameState(): GameState {
} as GameState;
}
function createStorySession(
overrides: Partial<StoryRuntimeProjectionResponse['storySession']> = {},
) {
return {
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:01.000Z',
...overrides,
};
}
function createRuntimeProjection(
overrides: RuntimeProjectionOverrides = {},
): StoryRuntimeProjectionResponse {
const storySession = createStorySession(overrides.storySession);
const serverVersion = overrides.serverVersion ?? storySession.version;
return {
storySession,
storyEvents: overrides.storyEvents ?? [],
serverVersion,
gameState: {
runtimeSessionId: storySession.runtimeSessionId,
storySessionId: storySession.storySessionId,
runtimeActionVersion: serverVersion,
currentScene: 'Story',
playerEquipment: { weapon: null, armor: null, relic: null },
...(overrides.gameState ?? {}),
},
actor: overrides.actor ?? {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: overrides.inventory ?? {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: overrides.options ?? [],
status: overrides.status ?? {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText:
overrides.currentNarrativeText ?? storySession.latestNarrativeText,
actionResultText: overrides.actionResultText ?? null,
toast: overrides.toast ?? null,
};
}
function createRuntimeResponse(params: {
snapshot: HydratedSavedGameSnapshot;
functionId?: string;
options?: StoryRuntimeProjectionResponse['options'];
presentation?: {
storyText?: string;
resultText?: string;
battle?: {
targetId?: string;
damageDealt?: number;
damageTaken?: number;
outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'defeat' | 'escaped';
} | null;
};
}) {
const projection = createRuntimeProjection({
storySession: {
latestNarrativeText: params.snapshot.currentStory?.text ?? '服务端故事',
latestChoiceFunctionId: params.functionId ?? null,
version: params.snapshot.version,
},
serverVersion: params.snapshot.version,
gameState: params.snapshot.gameState as never,
options: params.options ?? [],
currentNarrativeText: params.snapshot.currentStory?.text ?? '服务端故事',
});
return {
sessionId: projection.storySession.runtimeSessionId,
serverVersion: projection.serverVersion,
projection,
snapshot: params.snapshot,
inventoryView: {
playerCurrency: projection.actor.currency,
currencyText: projection.actor.currencyText,
inBattle: projection.status.inBattle,
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
presentation: params.presentation,
};
}
function createTravelGameState(): GameState {
return {
runtimeSessionId: 'runtime-main',
@@ -258,8 +367,6 @@ describe('runtimeStoryCoordinator', () => {
beforeEach(() => {
getStoryRuntimeProjectionMock.mockReset();
resolveRuntimeStoryActionMock.mockReset();
getRuntimeSessionIdMock.mockReset();
getRuntimeSessionIdMock.mockReturnValue('runtime-main');
getRuntimeStorySessionIdMock.mockReset();
getRuntimeStorySessionIdMock.mockReturnValue('storysess-main');
getRuntimeClientVersionMock.mockReset();
@@ -270,36 +377,12 @@ describe('runtimeStoryCoordinator', () => {
const gameState = createGameState();
const currentStory = createStory('当前故事');
getStoryRuntimeProjectionMock.mockResolvedValue({
getStoryRuntimeProjectionMock.mockResolvedValue(createRuntimeProjection({
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,
actor: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: [
{
functionId: 'npc_chat',
@@ -315,9 +398,7 @@ describe('runtimeStoryCoordinator', () => {
currentNpcBattleOutcome: null,
},
currentNarrativeText: '服务端故事',
actionResultText: null,
toast: null,
});
}));
const options = await loadServerRuntimeOptionCatalog({
gameState,
@@ -374,41 +455,22 @@ describe('runtimeStoryCoordinator', () => {
bottomTab: 'adventure',
} as HydratedSavedGameSnapshot;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 96,
maxHp: 100,
mana: 18,
maxMana: 20,
resolveRuntimeStoryActionMock.mockResolvedValue(createRuntimeResponse({
snapshot: hydratedSnapshot,
functionId: 'npc_chat',
options: [
{
functionId: 'npc_help',
actionText: '请求援手',
scope: 'npc',
enabled: true,
},
encounter: null,
companions: [],
availableOptions: [
{
functionId: 'npc_help',
actionText: '请求援手',
scope: 'npc',
},
],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
],
presentation: {
actionText: '继续交谈',
resultText: '关系已有变化',
storyText: '',
options: [],
},
patches: [],
snapshot: hydratedSnapshot,
});
}));
const result = await resolveServerRuntimeChoice({
gameState,
@@ -420,7 +482,7 @@ describe('runtimeStoryCoordinator', () => {
});
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
storySessionId: 'storysess-main',
clientVersion: 7,
option,
targetId: 'npc-opponent',
@@ -464,22 +526,11 @@ describe('runtimeStoryCoordinator', () => {
currentStory: createStory('本地快照故事'),
bottomTab: 'inventory' as const,
} as HydratedSavedGameSnapshot;
getStoryRuntimeProjectionMock.mockResolvedValue({
getStoryRuntimeProjectionMock.mockResolvedValue(createRuntimeProjection({
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,
actor: {
hp: 90,
@@ -509,9 +560,7 @@ describe('runtimeStoryCoordinator', () => {
currentNpcBattleOutcome: null,
},
currentNarrativeText: '服务端恢复后的故事',
actionResultText: null,
toast: null,
});
}));
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
@@ -565,7 +614,7 @@ describe('runtimeStoryCoordinator', () => {
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
});
it('rehydrates npc_fight server snapshots before returning runtime choices', async () => {
it('uses npc_fight server snapshots before returning runtime choices', async () => {
const gameState = createGameState();
const currentStory = createStory('当前故事');
const option = {
@@ -588,50 +637,22 @@ describe('runtimeStoryCoordinator', () => {
} as StoryOption;
const rawBattleSnapshot = createRuntimeNpcBattleSnapshot();
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 42,
maxHp: 50,
mana: 20,
maxMana: 20,
resolveRuntimeStoryActionMock.mockResolvedValue(createRuntimeResponse({
snapshot: rawBattleSnapshot,
functionId: 'npc_fight',
options: [
{
functionId: 'battle_probe_pressure',
actionText: '稳步试探',
scope: 'combat',
enabled: true,
},
encounter: {
id: 'npc-bandit',
kind: 'npc',
npcName: '断桥匪首',
hostile: true,
affinity: -12,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_probe_pressure',
actionText: '稳步试探',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
],
presentation: {
actionText: '直接开战',
resultText: '当前冲突正式转入战斗结算。',
storyText: '断桥匪首已经摆开架势。',
options: [],
},
patches: [],
snapshot: rawBattleSnapshot,
});
}));
const result = await resolveServerRuntimeChoice({
gameState,
@@ -644,11 +665,6 @@ describe('runtimeStoryCoordinator', () => {
id: 'npc-bandit',
hp: 21,
maxHp: 32,
encounter: expect.objectContaining({
kind: 'npc',
id: 'npc-bandit',
npcName: '断桥匪首',
}),
}),
);
expect(result.nextStory.options[0]).toEqual(
@@ -693,48 +709,7 @@ describe('runtimeStoryCoordinator', () => {
},
} as StoryOption;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 42,
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_attack_basic',
actionText: '普通攻击',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '直接开战',
resultText: '当前冲突正式转入战斗结算。',
storyText: '断桥匪首已经摆开架势。',
options: [],
},
patches: [],
resolveRuntimeStoryActionMock.mockResolvedValue(createRuntimeResponse({
snapshot: createRuntimeNpcBattleSnapshot({
currentEncounter: {
kind: 'npc',
@@ -750,7 +725,20 @@ describe('runtimeStoryCoordinator', () => {
currentBattleNpcId: 'npc-bandit',
currentNpcBattleMode: 'fight',
}),
});
functionId: 'npc_fight',
options: [
{
functionId: 'battle_attack_basic',
actionText: '普通攻击',
scope: 'combat',
enabled: true,
},
],
presentation: {
resultText: '当前冲突正式转入战斗结算。',
storyText: '断桥匪首已经摆开架势。',
},
}));
const result = await resolveServerRuntimeChoice({
gameState,
@@ -803,41 +791,22 @@ describe('runtimeStoryCoordinator', () => {
},
} as HydratedSavedGameSnapshot;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 38,
maxHp: 40,
mana: 12,
maxMana: 16,
resolveRuntimeStoryActionMock.mockResolvedValue(createRuntimeResponse({
snapshot: serverSnapshot,
functionId: 'idle_travel_next_scene',
options: [
{
functionId: 'idle_observe_signs',
actionText: '观察周围迹象',
scope: 'story',
enabled: true,
},
encounter: null,
companions: [],
availableOptions: [
{
functionId: 'idle_observe_signs',
actionText: '观察周围迹象',
scope: 'story',
},
],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
],
presentation: {
actionText: '前往相邻场景',
resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。',
storyText: '',
options: [],
},
patches: [],
snapshot: serverSnapshot,
});
}));
const result = await resolveServerRuntimeChoice({
gameState,
@@ -865,23 +834,16 @@ describe('runtimeStoryCoordinator', () => {
storySessionId: 'storysess-main',
});
getStoryRuntimeProjectionMock.mockResolvedValue({
getStoryRuntimeProjectionMock.mockResolvedValue(createRuntimeProjection({
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,
gameState: {
...localHydratedSnapshot.gameState,
runtimeActionVersion: 8,
},
actor: {
hp: 39,
maxHp: 50,
@@ -910,9 +872,7 @@ describe('runtimeStoryCoordinator', () => {
currentNpcBattleOutcome: null,
},
currentNarrativeText: '断桥匪首还在步步逼近。',
actionResultText: null,
toast: null,
});
}));
const result = await resumeServerRuntimeStory(localHydratedSnapshot);

View File

@@ -103,7 +103,11 @@ async function playServerBattlePresentation(params: {
setGameState: (state: GameState) => void;
turnVisualMs: number;
}) {
const battle = params.response.presentation.battle;
const presentation =
params.response && 'presentation' in params.response
? params.response.presentation
: undefined;
const battle = presentation?.battle;
if (!battle || !params.baseState.inBattle) {
return;
}
@@ -138,7 +142,7 @@ async function playServerBattlePresentation(params: {
? {
...hostileNpc,
animation: isRecoveryOrItem ? ('move' as const) : ('attack' as const),
action: params.response.presentation.resultText || hostileNpc.action,
action: presentation?.resultText || hostileNpc.action,
}
: hostileNpc,
),

View File

@@ -21,6 +21,7 @@ export function buildStoryContextFromState(
): StoryGenerationContext {
return {
runtimeSessionId: state.runtimeSessionId ?? null,
storySessionId: state.storySessionId ?? null,
runtimeActionVersion: state.runtimeActionVersion,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,

View File

@@ -2,7 +2,7 @@
StoryGenerationContext,
StoryRequestOptions,
} from '../../services/aiService';
import { shouldUseRpgRuntimeServerOptions } from '../../services/rpg-runtime';
import { shouldUseServerRuntimeOptions } from '../../services/rpg-runtime';
import type {
AIResponse,
Character,
@@ -78,7 +78,7 @@ export async function resolveStoryRequestOptions(params: {
? null
: params.getAvailableOptionsForState(params.state, params.character);
if (optionCatalog || !shouldUseRpgRuntimeServerOptions(availableOptions)) {
if (optionCatalog || !shouldUseServerRuntimeOptions(availableOptions)) {
return {
availableOptions,
optionCatalog,

View File

@@ -9,7 +9,7 @@ import { createEmptyEquipmentLoadout } from '../../data/equipmentEffects';
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
import { getScenePreset } from '../../data/scenePresets';
import { beginRpgRuntimeStorySession } from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import { beginRuntimeStorySession } from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
@@ -169,7 +169,7 @@ export function useRpgSessionBootstrap() {
return;
}
void beginRpgRuntimeStorySession({
void beginRuntimeStorySession({
worldType: resolvedWorldType,
customWorldProfile: launchState.customWorldProfile,
character,
@@ -199,4 +199,3 @@ export function useRpgSessionBootstrap() {
export type RpgSessionBootstrapResult = ReturnType<
typeof useRpgSessionBootstrap
>;

View File

@@ -3,7 +3,7 @@
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { isAbortError } from '../../services/apiClient';
import {
getRpgRuntimeSessionId,
getRuntimeSessionId,
rpgSnapshotClient,
} from '../../services/rpg-runtime';
import type { GameState, StoryMoment } from '../../types';
@@ -223,7 +223,7 @@ export function useRpgSessionPersistence({
const timeoutId = window.setTimeout(() => {
void persistSnapshot({
payload: {
sessionId: getRpgRuntimeSessionId(gameState),
sessionId: getRuntimeSessionId(gameState),
bottomTab,
},
logLabel: 'failed to autosave remote snapshot',
@@ -251,7 +251,7 @@ export function useRpgSessionPersistence({
// 差别只在于调用方可显式覆盖本次 checkpoint 的 session 与 UI tab。
const snapshot = await persistSnapshot({
payload: {
sessionId: getRpgRuntimeSessionId(nextGameState),
sessionId: getRuntimeSessionId(nextGameState),
bottomTab: nextBottomTab,
},
logLabel: 'failed to save remote snapshot',

View File

@@ -23,7 +23,7 @@ vi.mock('../services/rpg-entry', () => ({
}));
vi.mock('../services/rpg-runtime', () => ({
getRpgRuntimeSessionId: (gameState: Pick<GameState, 'runtimeSessionId'>) =>
getRuntimeSessionId: (gameState: Pick<GameState, 'runtimeSessionId'>) =>
gameState.runtimeSessionId?.trim() || 'runtime-main',
rpgSnapshotClient: {
getSnapshot: storageMocks.getSaveSnapshot,

View File

@@ -18,8 +18,8 @@ import { useRpgSessionBootstrap } from './rpg-session';
const aiServiceMocks = vi.hoisted(() => ({
streamNpcChatTurn: vi.fn(),
}));
const rpgRuntimeStoryClientMocks = vi.hoisted(() => ({
beginRpgRuntimeStorySession: vi.fn(),
const runtimeStoryClientMocks = vi.hoisted(() => ({
beginRuntimeStorySession: vi.fn(),
}));
vi.mock('../services/aiService', async () => {
@@ -41,8 +41,8 @@ vi.mock('../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
return {
...actual,
beginRpgRuntimeStorySession:
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession,
beginRuntimeStorySession:
runtimeStoryClientMocks.beginRuntimeStorySession,
};
});
@@ -588,7 +588,7 @@ function GameFlowHarness({
);
const selectedCharacter = playableCharacters[0] ?? null;
if (selectedCharacter) {
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession.mockResolvedValue(
runtimeStoryClientMocks.beginRuntimeStorySession.mockResolvedValue(
buildRuntimeStoryBootstrapSnapshot({
profile,
character: selectedCharacter,