Close DDD refactor and remove generated asset proxy
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user