抽离runtime story可替换传输层
This commit is contained in:
@@ -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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
|
||||
|
||||
@@ -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`
|
||||
@@ -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: '服务端返回的新故事',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user