抽离runtime story可替换传输层

This commit is contained in:
2026-04-20 09:53:05 +00:00
parent 06a8853167
commit e8beb0a988
4 changed files with 370 additions and 71 deletions

View File

@@ -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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。

View File

@@ -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`

View File

@@ -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<string, unknown> = {}) {
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: '服务端返回的新故事',

View File

@@ -45,6 +45,25 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse<
>;
export type { RuntimeStoryChoicePayload };
export type RuntimeStoryActionRequest = {
sessionId?: string;
clientVersion?: number;
option: Pick<StoryOption, 'functionId' | 'actionText'>;
targetId?: string;
payload?: RuntimeStoryChoicePayload;
};
export type RuntimeStoryTransport = {
getState: (
sessionId: string,
options?: RuntimeStoryServiceOptions,
) => Promise<RuntimeStoryResponse>;
resolveAction: (
params: RuntimeStoryActionRequest,
options?: RuntimeStoryServiceOptions,
) => Promise<RuntimeStoryResponse>;
};
function requestRuntimeStoryJson<T>(
path: string,
init: RequestInit,
@@ -62,6 +81,67 @@ function requestRuntimeStoryJson<T>(
);
}
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<RuntimeStoryResponse>(
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
}
async function resolveRuntimeStoryActionFromHttp(
params: RuntimeStoryActionRequest,
options: RuntimeStoryServiceOptions = {},
) {
return requestRuntimeStoryJson<RuntimeStoryResponse>(
'/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<GameState, 'currentEncounter'>,
@@ -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<RuntimeStoryResponse>(
`/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<StoryOption, 'functionId' | 'actionText'>;
targetId?: string;
payload?: RuntimeStoryChoicePayload;
},
params: RuntimeStoryActionRequest,
options: RuntimeStoryServiceOptions = {},
) {
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
'/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) {