From e8beb0a988b924252368a94c1cf8ca778db4e0ce Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 20 Apr 2026 09:53:05 +0000 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E7=A6=BBruntime=20story=E5=8F=AF?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E4=BC=A0=E8=BE=93=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/technical/README.md | 1 + ...PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md | 105 ++++++++++ src/services/runtimeStoryService.test.ts | 195 +++++++++++++++--- src/services/runtimeStoryService.ts | 140 ++++++++----- 4 files changed, 370 insertions(+), 71 deletions(-) create mode 100644 docs/technical/RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md diff --git a/docs/technical/README.md b/docs/technical/README.md index 3ae257d1..115be2a9 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -11,6 +11,7 @@ - [SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md](./SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md):本地 token 失效时自动降级匿名连接,并提示“登录已过期”的热修记录。 - [STDB_AUTH_TAIL_PHASE1_AUTO_GUEST_CREDENTIAL_REMOVAL_2026-04-20.md](./STDB_AUTH_TAIL_PHASE1_AUTO_GUEST_CREDENTIAL_REMOVAL_2026-04-20.md):Auth 尾巴清理第一段,删除前端自动游客用户名/密码残留。 - [STDB_AUTH_TAIL_PHASE2_TOKEN_SLOT_SPLIT_2026-04-20.md](./STDB_AUTH_TAIL_PHASE2_TOKEN_SLOT_SPLIT_2026-04-20.md):将 STDB token 与旧 HTTP Bearer token 拆成独立存储槽。 +- [RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md](./RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md):把 `runtimeStoryService` 改成可替换 transport,为后续 STDB provider 接入预留稳定边界。 - [TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md](./TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md):任务完成后按文件边界自动提交的脚本与协作约定。 - [NODE_DEV_STARTUP_HOTFIX_2026-04-20.md](./NODE_DEV_STARTUP_HOTFIX_2026-04-20.md):`npm run dev` 启动失败的热修记录、根因与验证结果。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 diff --git a/docs/technical/RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md b/docs/technical/RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md new file mode 100644 index 00000000..7e7a2d8e --- /dev/null +++ b/docs/technical/RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md @@ -0,0 +1,105 @@ +# Runtime Story 迁移到 STDB Phase 1:传输层抽离(2026-04-20) + +更新时间:`2026-04-20` + +## 1. 本轮目标 + +本轮只处理 `runtime story` 迁移的第一阶段基线问题: + +1. 把 `src/services/runtimeStoryService.ts` 从“直接硬编码 HTTP 请求”改成“可替换 transport” +2. 保持上层 `hook / coordinator / 页面` 的调用方式不变 +3. 给后续接入 SpacetimeDB provider 预留稳定插槽 + +本轮**不处理**以下事项: + +1. 不把 `/api/runtime/story/*` 直接替换成 STDB +2. 不迁移 `server-node/src/modules/story/*` 里的 runtime 结算逻辑 +3. 不改动 `runtimeStoryCoordinator`、页面层和选项分发层的上层契约 +4. 不改动任何剧情、文案、函数能力面和业务规则 + +## 2. 为什么先抽 transport + +`runtime story` 当前不是一个单薄的读写接口,而是一整条运行时状态机链路: + +1. 读取状态:`GET /api/runtime/story/state/:sessionId` +2. 结算动作:`POST /api/runtime/story/actions/resolve` +3. 上层 `runtimeStoryCoordinator` 依赖其统一返回快照、presentation、viewModel + +如果直接在这一轮把上层调用点改成 STDB,会产生两个问题: + +1. 需要同时修改服务层、hook 层、状态持久化和测试,范围过大 +2. 很容易把“迁移后端传输”误做成“重写 runtime story 业务流程” + +因此本轮先把问题缩成一条更稳的工程边界: + +1. `runtimeStoryService` 负责对外暴露稳定 API +2. `transport` 负责具体从 HTTP 或未来 STDB 获取响应 +3. 响应统一在服务层做快照 rehydrate,避免各 transport 各自复制一份归一化逻辑 + +## 3. 本轮代码改动 + +### 3.1 `runtimeStoryService.ts` + +新增以下类型与入口: + +1. `RuntimeStoryActionRequest` +2. `RuntimeStoryTransport` +3. `setRuntimeStoryTransport(...)` +4. `resetRuntimeStoryTransport()` + +默认实现策略: + +1. 把原来的 HTTP GET/POST 逻辑收进内部默认 transport +2. 模块级 `runtimeStoryTransport` 默认指向 HTTP transport +3. 公开函数 `getRuntimeStoryState(...)` / `resolveRuntimeStoryAction(...)` 不改签名 +4. 公开函数统一对 transport 返回值执行 `rehydrateSavedSnapshot(...)` + +这样后续接入 STDB 时只需要: + +1. 提供一个新的 `RuntimeStoryTransport` +2. 在合适的初始化位置注入 `setRuntimeStoryTransport(...)` +3. 无需继续修改调用 `runtimeStoryService` 的上层代码 + +### 3.2 `runtimeStoryService.test.ts` + +本轮新增两类防回退测试: + +1. 可以替换 transport,且调用方仍沿用既有公开 API +2. 调用 `resetRuntimeStoryTransport()` 后,会回到默认 HTTP 路径 + +同时保留原有断言: + +1. HTTP 请求路径和 body 结构不变 +2. runtime payload 合并规则不变 +3. option interaction / disabled 状态 / snapshot story 优先级不变 + +## 4. 当前迁移边界 + +完成本轮后,`runtime story` 的迁移边界变为: + +1. 上层依赖的是 `runtimeStoryService`,而不是 HTTP 地址 +2. 传输层已经可替换,但默认实现仍是 Express HTTP +3. STDB provider 可以在后续阶段单独接入,不再需要先动 hook 和页面 + +也就是说,这一轮交付的是“迁移支点”,不是“业务已经迁移完成”。 + +## 5. 后续阶段建议 + +建议继续按以下顺序推进: + +1. Phase 2:设计并实现 `runtime story` 的 STDB transport/provider +2. Phase 3:把现有 runtime story 读写 contract 映射到 STDB 表 / reducer / subscription +3. Phase 4:验证 Express `/api/runtime/story/*` 是否还能保留为兼容壳层,或者彻底下线 + +这样可以持续保持每一阶段都具备: + +1. 清晰边界 +2. 最小改动面 +3. 可测试回退点 + +## 6. 本轮涉及文件 + +1. `src/services/runtimeStoryService.ts` +2. `src/services/runtimeStoryService.test.ts` +3. `docs/technical/RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md` +4. `docs/technical/README.md` diff --git a/src/services/runtimeStoryService.test.ts b/src/services/runtimeStoryService.test.ts index 76cb56d9..86874277 100644 --- a/src/services/runtimeStoryService.test.ts +++ b/src/services/runtimeStoryService.test.ts @@ -17,39 +17,71 @@ import { AnimationState } from '../types'; import { buildStoryMomentFromRuntimeOptions, getRuntimeClientVersion, + getRuntimeStoryState, getRuntimeSessionId, isServerRuntimeFunctionId, isTask5RuntimeFunctionId, + resetRuntimeStoryTransport, resolveRuntimeStoryAction, resolveRuntimeStoryMoment, + setRuntimeStoryTransport, shouldUseServerRuntimeOptions, } from './runtimeStoryService'; describe('runtimeStoryService', () => { beforeEach(() => { requestJsonMock.mockReset(); + resetRuntimeStoryTransport(); }); - it('builds runtime action requests against the dedicated story endpoint', async () => { - requestJsonMock.mockResolvedValue({ + function createMockRuntimeResponse(overrides: Record = {}) { + return { sessionId: 'runtime-main', + serverVersion: 1, + viewModel: { + player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 }, + encounter: null, + companions: [], + availableOptions: [], + status: { + inBattle: false, + npcInteractionActive: false, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + }, + }, + presentation: { + actionText: '', + resultText: '', + storyText: '', + options: [], + battle: null, + toast: null, + }, + patches: [], + snapshot: { + version: 1, + savedAt: '2026-04-20T00:00:00.000Z', + bottomTab: 'adventure', + gameState: { + inBattle: false, + } as never, + currentStory: null, + }, + ...overrides, + }; + } + + it('builds runtime action requests against the dedicated story endpoint', async () => { + requestJsonMock.mockResolvedValue(createMockRuntimeResponse({ serverVersion: 2, - viewModel: {}, presentation: { actionText: '继续交谈', resultText: '后端已结算', storyText: '后端已结算', options: [], }, - patches: [], - snapshot: { - version: 2, - savedAt: '2026-04-08T00:00:00.000Z', - bottomTab: 'adventure', - gameState: {}, - currentStory: null, - }, - }); + })); await resolveRuntimeStoryAction({ sessionId: 'runtime-custom', @@ -83,25 +115,15 @@ describe('runtimeStoryService', () => { }); it('merges custom runtime payload fields into the action request body', async () => { - requestJsonMock.mockResolvedValue({ - sessionId: 'runtime-main', + requestJsonMock.mockResolvedValue(createMockRuntimeResponse({ serverVersion: 3, - viewModel: {}, presentation: { actionText: '使用凝神灵液', resultText: '后端已结算物品使用', storyText: '后端已结算物品使用', options: [], }, - patches: [], - snapshot: { - version: 3, - savedAt: '2026-04-08T00:00:00.000Z', - bottomTab: 'adventure', - gameState: {}, - currentStory: null, - }, - }); + })); await resolveRuntimeStoryAction({ option: { @@ -136,6 +158,131 @@ describe('runtimeStoryService', () => { ); }); + it('allows replacing the runtime story transport without changing callers', async () => { + const getStateMock = vi.fn().mockResolvedValue(createMockRuntimeResponse({ + sessionId: 'runtime-transport', + serverVersion: 11, + presentation: { + actionText: '', + resultText: '', + storyText: '来自替换 transport 的状态', + options: [], + battle: null, + toast: null, + }, + snapshot: { + version: 11, + savedAt: '2026-04-20T00:00:00.000Z', + bottomTab: 'adventure', + gameState: { + worldType: 'WUXIA', + currentBattleNpcId: 'npc-bandit', + currentEncounter: { + kind: 'npc', + id: 'npc-bandit', + npcName: '断桥匪首', + hostile: true, + }, + npcStates: {}, + sceneHostileNpcs: [], + currentNpcBattleMode: 'fight', + inBattle: true, + }, + currentStory: null, + }, + })); + const resolveActionMock = vi.fn().mockResolvedValue(createMockRuntimeResponse({ + sessionId: 'runtime-transport', + serverVersion: 12, + viewModel: { + player: { hp: 18, maxHp: 20, mana: 8, maxMana: 8 }, + encounter: null, + companions: [], + availableOptions: [], + status: { + inBattle: false, + npcInteractionActive: true, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + }, + }, + presentation: { + actionText: '继续交谈', + resultText: '来自替换 transport 的动作结果', + storyText: '来自替换 transport 的动作结果', + options: [], + battle: null, + toast: null, + }, + patches: [], + snapshot: { + version: 12, + savedAt: '2026-04-20T00:00:00.000Z', + bottomTab: 'adventure', + gameState: {}, + currentStory: null, + }, + })); + + setRuntimeStoryTransport({ + getState: getStateMock, + resolveAction: resolveActionMock, + }); + + const state = await getRuntimeStoryState('runtime-transport'); + const action = await resolveRuntimeStoryAction({ + option: { + functionId: 'npc_chat', + actionText: '继续交谈', + }, + }); + + expect(getStateMock).toHaveBeenCalledWith('runtime-transport', {}); + expect(resolveActionMock).toHaveBeenCalledWith( + { + option: { + functionId: 'npc_chat', + actionText: '继续交谈', + }, + }, + {}, + ); + expect(requestJsonMock).not.toHaveBeenCalled(); + expect(state.presentation.storyText).toBe('来自替换 transport 的状态'); + expect(action.presentation.resultText).toBe('来自替换 transport 的动作结果'); + }); + + it('restores the default HTTP transport after reset', async () => { + setRuntimeStoryTransport({ + getState: vi.fn(), + resolveAction: vi.fn(), + }); + resetRuntimeStoryTransport(); + + requestJsonMock.mockResolvedValue(createMockRuntimeResponse({ + serverVersion: 5, + presentation: { + actionText: '', + resultText: '', + storyText: '恢复 HTTP transport', + options: [], + battle: null, + toast: null, + }, + })); + + await getRuntimeStoryState('runtime-main'); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/story/state/runtime-main', + expect.objectContaining({ + method: 'GET', + }), + '读取运行时故事状态失败', + expect.any(Object), + ); + }); + it('keeps disabled runtime options when rebuilding a story moment', () => { const story = buildStoryMomentFromRuntimeOptions({ storyText: '服务端返回的新故事', diff --git a/src/services/runtimeStoryService.ts b/src/services/runtimeStoryService.ts index dd41476f..d4dc79c1 100644 --- a/src/services/runtimeStoryService.ts +++ b/src/services/runtimeStoryService.ts @@ -45,6 +45,25 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse< >; export type { RuntimeStoryChoicePayload }; +export type RuntimeStoryActionRequest = { + sessionId?: string; + clientVersion?: number; + option: Pick; + targetId?: string; + payload?: RuntimeStoryChoicePayload; +}; + +export type RuntimeStoryTransport = { + getState: ( + sessionId: string, + options?: RuntimeStoryServiceOptions, + ) => Promise; + resolveAction: ( + params: RuntimeStoryActionRequest, + options?: RuntimeStoryServiceOptions, + ) => Promise; +}; + function requestRuntimeStoryJson( path: string, init: RequestInit, @@ -62,6 +81,67 @@ function requestRuntimeStoryJson( ); } +function normalizeRuntimeStoryResponse( + response: RuntimeStoryActionResponse< + HydratedGameState, + StoryMoment + >, +): RuntimeStoryResponse { + return { + ...response, + snapshot: rehydrateSavedSnapshot( + response.snapshot as HydratedSavedGameSnapshot, + ), + } satisfies RuntimeStoryResponse; +} + +async function getRuntimeStoryStateFromHttp( + sessionId: string, + options: RuntimeStoryServiceOptions = {}, +) { + return requestRuntimeStoryJson( + `/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`, + { method: 'GET' }, + '读取运行时故事状态失败', + options, + ); +} + +async function resolveRuntimeStoryActionFromHttp( + params: RuntimeStoryActionRequest, + options: RuntimeStoryServiceOptions = {}, +) { + return requestRuntimeStoryJson( + '/actions/resolve', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: params.sessionId || DEFAULT_SESSION_ID, + clientVersion: params.clientVersion, + action: { + type: 'story_choice', + functionId: params.option.functionId, + targetId: params.targetId, + payload: { + optionText: params.option.actionText, + ...(params.payload ?? {}), + }, + }, + }), + }, + '执行运行时动作失败', + options, + ); +} + +const httpRuntimeStoryTransport: RuntimeStoryTransport = { + getState: getRuntimeStoryStateFromHttp, + resolveAction: resolveRuntimeStoryActionFromHttp, +}; + +let runtimeStoryTransport: RuntimeStoryTransport = httpRuntimeStoryTransport; + function createRuntimeStoryOption( option: RuntimeStoryOptionView, _gameState?: Pick, @@ -169,64 +249,30 @@ export function resolveRuntimeStoryMoment(params: { }); } +export function setRuntimeStoryTransport(transport: RuntimeStoryTransport) { + runtimeStoryTransport = transport; +} + +export function resetRuntimeStoryTransport() { + runtimeStoryTransport = httpRuntimeStoryTransport; +} + export async function getRuntimeStoryState( sessionId: string, options: RuntimeStoryServiceOptions = {}, ) { - const response = await requestRuntimeStoryJson( - `/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`, - { method: 'GET' }, - '读取运行时故事状态失败', - options, + return normalizeRuntimeStoryResponse( + await runtimeStoryTransport.getState(sessionId, options), ); - - return { - ...response, - snapshot: rehydrateSavedSnapshot( - response.snapshot as HydratedSavedGameSnapshot, - ), - } satisfies RuntimeStoryResponse; } export async function resolveRuntimeStoryAction( - params: { - sessionId?: string; - clientVersion?: number; - option: Pick; - targetId?: string; - payload?: RuntimeStoryChoicePayload; - }, + params: RuntimeStoryActionRequest, options: RuntimeStoryServiceOptions = {}, ) { - const response = await requestRuntimeStoryJson( - '/actions/resolve', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: params.sessionId || DEFAULT_SESSION_ID, - clientVersion: params.clientVersion, - action: { - type: 'story_choice', - functionId: params.option.functionId, - targetId: params.targetId, - payload: { - optionText: params.option.actionText, - ...(params.payload ?? {}), - }, - }, - }), - }, - '执行运行时动作失败', - options, + return normalizeRuntimeStoryResponse( + await runtimeStoryTransport.resolveAction(params, options), ); - - return { - ...response, - snapshot: rehydrateSavedSnapshot( - response.snapshot as HydratedSavedGameSnapshot, - ), - } satisfies RuntimeStoryResponse; } export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {