Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -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: '服务端返回的新故事',