抽离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 失效时自动降级匿名连接,并提示“登录已过期”的热修记录。
|
- [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_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 拆成独立存储槽。
|
- [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):任务完成后按文件边界自动提交的脚本与协作约定。
|
- [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_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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
|
- [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 {
|
import {
|
||||||
buildStoryMomentFromRuntimeOptions,
|
buildStoryMomentFromRuntimeOptions,
|
||||||
getRuntimeClientVersion,
|
getRuntimeClientVersion,
|
||||||
|
getRuntimeStoryState,
|
||||||
getRuntimeSessionId,
|
getRuntimeSessionId,
|
||||||
isServerRuntimeFunctionId,
|
isServerRuntimeFunctionId,
|
||||||
isTask5RuntimeFunctionId,
|
isTask5RuntimeFunctionId,
|
||||||
|
resetRuntimeStoryTransport,
|
||||||
resolveRuntimeStoryAction,
|
resolveRuntimeStoryAction,
|
||||||
resolveRuntimeStoryMoment,
|
resolveRuntimeStoryMoment,
|
||||||
|
setRuntimeStoryTransport,
|
||||||
shouldUseServerRuntimeOptions,
|
shouldUseServerRuntimeOptions,
|
||||||
} from './runtimeStoryService';
|
} from './runtimeStoryService';
|
||||||
|
|
||||||
describe('runtimeStoryService', () => {
|
describe('runtimeStoryService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
requestJsonMock.mockReset();
|
requestJsonMock.mockReset();
|
||||||
|
resetRuntimeStoryTransport();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds runtime action requests against the dedicated story endpoint', async () => {
|
function createMockRuntimeResponse(overrides: Record<string, unknown> = {}) {
|
||||||
requestJsonMock.mockResolvedValue({
|
return {
|
||||||
sessionId: 'runtime-main',
|
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,
|
serverVersion: 2,
|
||||||
viewModel: {},
|
|
||||||
presentation: {
|
presentation: {
|
||||||
actionText: '继续交谈',
|
actionText: '继续交谈',
|
||||||
resultText: '后端已结算',
|
resultText: '后端已结算',
|
||||||
storyText: '后端已结算',
|
storyText: '后端已结算',
|
||||||
options: [],
|
options: [],
|
||||||
},
|
},
|
||||||
patches: [],
|
}));
|
||||||
snapshot: {
|
|
||||||
version: 2,
|
|
||||||
savedAt: '2026-04-08T00:00:00.000Z',
|
|
||||||
bottomTab: 'adventure',
|
|
||||||
gameState: {},
|
|
||||||
currentStory: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await resolveRuntimeStoryAction({
|
await resolveRuntimeStoryAction({
|
||||||
sessionId: 'runtime-custom',
|
sessionId: 'runtime-custom',
|
||||||
@@ -83,25 +115,15 @@ describe('runtimeStoryService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('merges custom runtime payload fields into the action request body', async () => {
|
it('merges custom runtime payload fields into the action request body', async () => {
|
||||||
requestJsonMock.mockResolvedValue({
|
requestJsonMock.mockResolvedValue(createMockRuntimeResponse({
|
||||||
sessionId: 'runtime-main',
|
|
||||||
serverVersion: 3,
|
serverVersion: 3,
|
||||||
viewModel: {},
|
|
||||||
presentation: {
|
presentation: {
|
||||||
actionText: '使用凝神灵液',
|
actionText: '使用凝神灵液',
|
||||||
resultText: '后端已结算物品使用',
|
resultText: '后端已结算物品使用',
|
||||||
storyText: '后端已结算物品使用',
|
storyText: '后端已结算物品使用',
|
||||||
options: [],
|
options: [],
|
||||||
},
|
},
|
||||||
patches: [],
|
}));
|
||||||
snapshot: {
|
|
||||||
version: 3,
|
|
||||||
savedAt: '2026-04-08T00:00:00.000Z',
|
|
||||||
bottomTab: 'adventure',
|
|
||||||
gameState: {},
|
|
||||||
currentStory: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await resolveRuntimeStoryAction({
|
await resolveRuntimeStoryAction({
|
||||||
option: {
|
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', () => {
|
it('keeps disabled runtime options when rebuilding a story moment', () => {
|
||||||
const story = buildStoryMomentFromRuntimeOptions({
|
const story = buildStoryMomentFromRuntimeOptions({
|
||||||
storyText: '服务端返回的新故事',
|
storyText: '服务端返回的新故事',
|
||||||
|
|||||||
@@ -45,6 +45,25 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse<
|
|||||||
>;
|
>;
|
||||||
export type { RuntimeStoryChoicePayload };
|
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>(
|
function requestRuntimeStoryJson<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit,
|
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(
|
function createRuntimeStoryOption(
|
||||||
option: RuntimeStoryOptionView,
|
option: RuntimeStoryOptionView,
|
||||||
_gameState?: Pick<GameState, 'currentEncounter'>,
|
_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(
|
export async function getRuntimeStoryState(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options: RuntimeStoryServiceOptions = {},
|
options: RuntimeStoryServiceOptions = {},
|
||||||
) {
|
) {
|
||||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
return normalizeRuntimeStoryResponse(
|
||||||
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
|
await runtimeStoryTransport.getState(sessionId, options),
|
||||||
{ method: 'GET' },
|
|
||||||
'读取运行时故事状态失败',
|
|
||||||
options,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
|
||||||
...response,
|
|
||||||
snapshot: rehydrateSavedSnapshot(
|
|
||||||
response.snapshot as HydratedSavedGameSnapshot,
|
|
||||||
),
|
|
||||||
} satisfies RuntimeStoryResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveRuntimeStoryAction(
|
export async function resolveRuntimeStoryAction(
|
||||||
params: {
|
params: RuntimeStoryActionRequest,
|
||||||
sessionId?: string;
|
|
||||||
clientVersion?: number;
|
|
||||||
option: Pick<StoryOption, 'functionId' | 'actionText'>;
|
|
||||||
targetId?: string;
|
|
||||||
payload?: RuntimeStoryChoicePayload;
|
|
||||||
},
|
|
||||||
options: RuntimeStoryServiceOptions = {},
|
options: RuntimeStoryServiceOptions = {},
|
||||||
) {
|
) {
|
||||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
return normalizeRuntimeStoryResponse(
|
||||||
'/actions/resolve',
|
await runtimeStoryTransport.resolveAction(params, options),
|
||||||
{
|
|
||||||
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 {
|
|
||||||
...response,
|
|
||||||
snapshot: rehydrateSavedSnapshot(
|
|
||||||
response.snapshot as HydratedSavedGameSnapshot,
|
|
||||||
),
|
|
||||||
} satisfies RuntimeStoryResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||||
|
|||||||
Reference in New Issue
Block a user