Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -15,14 +15,19 @@ vi.mock('../apiClient', async () => {
|
||||
|
||||
import { AnimationState } from '../../types';
|
||||
import {
|
||||
beginRpgStorySession,
|
||||
beginRpgRuntimeStorySession,
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
continueRpgStorySession,
|
||||
getRpgStorySessionState,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStorySessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
loadRpgRuntimeInventoryView,
|
||||
resolveRpgRuntimeStoryProjectionMoment,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
@@ -33,6 +38,134 @@ describe('rpgRuntimeStoryClient', () => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('creates story sessions through the new story session endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: '营地开场',
|
||||
latestNarrativeText: '篝火正在燃烧。',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAt: '2026-04-29T00:00:00.000Z',
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
storyEvent: {
|
||||
eventId: 'storyevt-main',
|
||||
storySessionId: 'storysess-main',
|
||||
eventKind: 'session_started',
|
||||
narrativeText: '篝火正在燃烧。',
|
||||
choiceFunctionId: null,
|
||||
createdAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await beginRpgStorySession({
|
||||
runtimeSessionId: 'runtime-main',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: '营地开场',
|
||||
});
|
||||
|
||||
expect(result.storySession.storySessionId).toBe('storysess-main');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/story/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
runtimeSessionId: 'runtime-main',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: '营地开场',
|
||||
}),
|
||||
}),
|
||||
'创建故事会话失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('continues story sessions through the new story session endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '你继续向前。',
|
||||
latestChoiceFunctionId: 'story_continue',
|
||||
status: 'active',
|
||||
version: 2,
|
||||
createdAt: '2026-04-29T00:00:00.000Z',
|
||||
updatedAt: '2026-04-29T00:00:01.000Z',
|
||||
},
|
||||
storyEvent: {
|
||||
eventId: 'storyevt-next',
|
||||
storySessionId: 'storysess-main',
|
||||
eventKind: 'story_continued',
|
||||
narrativeText: '你继续向前。',
|
||||
choiceFunctionId: 'story_continue',
|
||||
createdAt: '2026-04-29T00:00:01.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await continueRpgStorySession({
|
||||
storySessionId: ' storysess-main ',
|
||||
narrativeText: '你继续向前。',
|
||||
choiceFunctionId: 'story_continue',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/story/sessions/continue',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
storySessionId: 'storysess-main',
|
||||
narrativeText: '你继续向前。',
|
||||
choiceFunctionId: 'story_continue',
|
||||
}),
|
||||
}),
|
||||
'继续故事会话失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads story session state through the new state endpoint', async () => {
|
||||
requestJsonMock.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-29T00:00:00.000Z',
|
||||
updatedAt: '2026-04-29T00:00:02.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
});
|
||||
|
||||
await getRpgStorySessionState({ storySessionId: ' storysess-main ' });
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/story/sessions/storysess-main/state',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取故事会话状态失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('starts runtime sessions through the backend bootstrap endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-server-1',
|
||||
@@ -185,145 +318,180 @@ describe('rpgRuntimeStoryClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reads runtime story state by server session id', async () => {
|
||||
it('reads runtime story state by story session id', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '服务端故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 4,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 4,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
actor: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
currency: 0,
|
||||
currencyText: '0 铜钱',
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
options: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '服务端故事',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
|
||||
await getRpgRuntimeStoryState({
|
||||
sessionId: 'runtime-main',
|
||||
storySessionId: 'storysess-main',
|
||||
clientVersion: 7,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/state/runtime-main',
|
||||
'/api/story/sessions/storysess-main/runtime-projection',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取运行时故事状态失败',
|
||||
'读取运行时故事投影失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads backend inventory view from runtime story state', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-inventory',
|
||||
serverVersion: 5,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
inventory: {
|
||||
playerCurrency: 90,
|
||||
currencyText: '90 铜钱',
|
||||
inBattle: false,
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [
|
||||
{
|
||||
id: 'synthesis-refined-ingot',
|
||||
name: '压炼锭材',
|
||||
kind: 'synthesis',
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
currencyText: '18 铜钱',
|
||||
requirements: [
|
||||
{
|
||||
id: 'material:any',
|
||||
label: '任意材料',
|
||||
quantity: 3,
|
||||
owned: 3,
|
||||
},
|
||||
],
|
||||
canCraft: true,
|
||||
action: {
|
||||
functionId: 'forge_craft',
|
||||
actionText: '制作精炼锭材',
|
||||
payload: { recipeId: 'synthesis-refined-ingot' },
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
it('rejects missing story session id instead of falling back to runtime id', async () => {
|
||||
expect(() =>
|
||||
getRpgRuntimeStorySessionId({
|
||||
storySessionId: '',
|
||||
}),
|
||||
).toThrow('运行时故事会话不存在,无法读取服务端投影');
|
||||
|
||||
await expect(
|
||||
loadRpgRuntimeInventoryView({
|
||||
gameState: {
|
||||
runtimeSessionId: 'runtime-inventory',
|
||||
storySessionId: null,
|
||||
runtimeActionVersion: 5,
|
||||
},
|
||||
currentStory: null,
|
||||
} as never,
|
||||
}),
|
||||
).rejects.toThrow('运行时故事会话不存在,无法读取服务端投影');
|
||||
|
||||
await expect(
|
||||
continueRpgStorySession({
|
||||
storySessionId: '',
|
||||
narrativeText: '继续',
|
||||
}),
|
||||
).rejects.toThrow('故事会话不存在,无法继续故事');
|
||||
|
||||
await expect(
|
||||
getRpgStorySessionState({
|
||||
storySessionId: '',
|
||||
}),
|
||||
).rejects.toThrow('故事会话不存在,无法读取故事会话状态');
|
||||
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads backend inventory view from story runtime projection', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-inventory',
|
||||
runtimeSessionId: 'runtime-inventory',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '背包状态',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 5,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 5,
|
||||
actor: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
currency: 90,
|
||||
currencyText: '90 铜钱',
|
||||
},
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [
|
||||
{
|
||||
id: 'synthesis-refined-ingot',
|
||||
name: '压炼锭材',
|
||||
kind: 'synthesis',
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
currencyText: '18 铜钱',
|
||||
requirements: [
|
||||
{
|
||||
id: 'material:any',
|
||||
label: '任意材料',
|
||||
quantity: 3,
|
||||
owned: 3,
|
||||
},
|
||||
],
|
||||
canCraft: true,
|
||||
action: {
|
||||
functionId: 'forge_craft',
|
||||
actionText: '制作精炼锭材',
|
||||
payload: { recipeId: 'synthesis-refined-ingot' },
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
options: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
|
||||
const view = await loadRpgRuntimeInventoryView({
|
||||
gameState: {
|
||||
runtimeSessionId: 'runtime-inventory',
|
||||
storySessionId: 'storysess-inventory',
|
||||
runtimeActionVersion: 5,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(view.forgeRecipes[0]?.action.functionId).toBe('forge_craft');
|
||||
expect(view.playerCurrency).toBe(90);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/state/runtime-inventory',
|
||||
'/api/story/sessions/storysess-inventory/runtime-projection',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取运行时故事状态失败',
|
||||
'读取运行时故事投影失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -415,9 +583,78 @@ describe('rpgRuntimeStoryClient', () => {
|
||||
expect(getRpgRuntimeSessionId({ runtimeSessionId: '' })).toBe(
|
||||
'runtime-main',
|
||||
);
|
||||
expect(getRpgRuntimeStorySessionId({ storySessionId: ' storysess-1 ' })).toBe(
|
||||
'storysess-1',
|
||||
);
|
||||
expect(getRpgRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('builds story moments from story runtime projection options', () => {
|
||||
const story = resolveRpgRuntimeStoryProjectionMoment({
|
||||
projection: {
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '兜底故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 5,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 5,
|
||||
actor: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
currency: 0,
|
||||
currencyText: '0 铜钱',
|
||||
},
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
},
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
detailText: '推进当前话题',
|
||||
scope: 'npc',
|
||||
payload: { npcId: 'npc-merchant' },
|
||||
enabled: false,
|
||||
reason: '对方暂时不想说话',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '服务端投影故事',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端投影故事');
|
||||
expect(story.options[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
disabled: true,
|
||||
disabledReason: '对方暂时不想说话',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves runtime option interaction metadata from the server response', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
|
||||
Reference in New Issue
Block a user