11 Commits

41 changed files with 2152 additions and 397 deletions

View File

@@ -250,13 +250,27 @@ node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0
这类项目里,后者几乎一定导致返工。
## 13. 一句话总结
## 13. 任务完成后的提交约定
为避免工作区长期堆积未提交改动,同时避免把无关脏改动混进同一提交,后续按下面的默认规则执行:
1. 当用户明确表示“当前任务完成”或直接要求提交时,立即执行一次 git 提交。
2. 自动提交只包含本任务相关文件,不把工作区里已有的无关改动一起提交。
3. 提交前先检查 `git status`,确认涉及文件和本任务边界一致。
4. 如果无法安全区分本任务改动和无关改动,则不自动提交,先明确说明阻塞点。
5. 提交信息统一使用中文简洁摘要,直接概括本次任务结果,不强制使用 `feat:``fix:` 前缀。
6. 没有实际文件改动时不创建空提交。
7. 默认不改写历史,不做 `amend`,每个完成任务对应一次普通提交。
这条规则是项目协作约定,不依赖 git hook、husky 或自动监听脚本。
## 14. 一句话总结
这个项目最重要的经验不是“做了多少页面和功能”,而是:
**必须把 AI 文本生成、本地规则、动画演出、场景状态、编辑器工具这几套系统严格分层,再通过 function 和统一状态流把它们重新接起来。**
## 14. 相关文档
## 15. 相关文档
如需继续细看已有沉淀,可结合以下文档一起阅读:

View File

@@ -8,6 +8,7 @@
## 推荐入口
1. [PROJECT_WORK_EXPERIENCE_PLAYBOOK.md](./PROJECT_WORK_EXPERIENCE_PLAYBOOK.md):最完整的项目开发手册,适合先建立全局认识。
当前也包含“任务完成后自动提交”的协作约定。
2. [PROJECT_DEVELOPMENT_EXPERIENCE.md](./PROJECT_DEVELOPMENT_EXPERIENCE.md):项目级经验浓缩版,适合快速回顾。
3. [ADVENTURE_RUNTIME_DEV_EXPERIENCE.md](./ADVENTURE_RUNTIME_DEV_EXPERIENCE.md)专门看运行时、战斗、演出、NPC 流程时优先读。
4. [MOBILE_UI_DEV_EXPERIENCE.md](./MOBILE_UI_DEV_EXPERIENCE.md):做移动端/游戏 UI 时的布局和交互经验。

View File

@@ -9,6 +9,13 @@
- [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。
- [SPACETIME_DEV_URI_HOTFIX_2026-04-20.md](./SPACETIME_DEV_URI_HOTFIX_2026-04-20.md):修复开发默认配置把 Spacetime 连接误指向 Vite `3000` 端口的问题。
- [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 接入预留稳定边界。
- [RUNTIME_STORY_TO_STDB_PHASE2_CONTRACT_DESIGN_2026-04-20.md](./RUNTIME_STORY_TO_STDB_PHASE2_CONTRACT_DESIGN_2026-04-20.md):梳理 runtime story 从 Express 迁到 STDB 所需的聚合 view、procedure、mapper 与前端 provider 设计。
- [RUNTIME_STORY_TO_STDB_PHASE2A_COMPAT_BRIDGE_2026-04-20.md](./RUNTIME_STORY_TO_STDB_PHASE2A_COMPAT_BRIDGE_2026-04-20.md):确认 runtime story 当前 STDB/Express 快照真源分裂,并补一层只改 story 边界的 STDB 兼容桥。
- [STORY_WORLD_TO_STDB_CAPABILITY_FIRST_PLAN_2026-04-20.md](./STORY_WORLD_TO_STDB_CAPABILITY_FIRST_PLAN_2026-04-20.md):明确 story / custom world 后续迁移顺序应先拆 capability/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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
- [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md)Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。

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

@@ -0,0 +1,136 @@
# Runtime Story 向 STDB 迁移 Phase 2A兼容桥2026-04-20
## 1. 本轮目标
这轮不是直接把 `runtime story` 全量改成 SpacetimeDB provider而是先补一层最小兼容桥解决当前仓库里已经实际出现的两类断裂
1. `runtime story` 前置快照写入已经走 STDB
2. `/api/runtime/story/*` 仍然从 `server-node` 的旧 `RuntimeRepository` 读取 Postgres 快照
3. 前端认证主链路已经切到 STDB旧 HTTP Bearer token 不再保证可持续刷新
因此本轮目标是:
1. 保持 `runtimeStoryCoordinator` 和上层页面契约不变
2. 保持 `server-node/src/modules/story/storyActionService.ts` 现有规则实现不重写
3. 只在 `runtime story` 边界补齐:
- 前端请求可携带 STDB token
- `server-node` 的 story route 可直接使用 STDB token 解析身份
- `server-node` 的 story route 可直接从 STDB 读写 snapshot
## 2. 当前问题确认
### 2.1 快照真源已经分裂
当前前端 [`src/hooks/story/runtimeStoryCoordinator.ts`](/home/Genarrative/src/hooks/story/runtimeStoryCoordinator.ts) 在读取或执行 runtime story 之前,会先调用:
1. [`putSaveSnapshot`](/home/Genarrative/src/services/storageService.ts)
2. 该调用实际走 STDB `saveSnapshot` procedure
但旧的 `/api/runtime/story/*` 仍然走:
1. [`storyActionRoutes.ts`](/home/Genarrative/server-node/src/modules/story/storyActionRoutes.ts)
2. [`storyActionService.ts`](/home/Genarrative/server-node/src/modules/story/storyActionService.ts)
3. [`RuntimeRepository.getSnapshot()`](/home/Genarrative/server-node/src/repositories/runtimeRepository.ts)
也就是:
1. 写快照在 STDB
2. 读快照在 Postgres
这已经不是“未来可能出现”的风险,而是当前迁移阶段的真实断链。
### 2.2 旧 HTTP token 不再是可靠前提
当前前端认证主链路已经切到:
1. [`src/services/authService.ts`](/home/Genarrative/src/services/authService.ts)
2. [`src/spacetime/client.ts`](/home/Genarrative/src/spacetime/client.ts)
也就是说:
1. 页面登录、恢复会话、匿名建连主要依赖 STDB token
2. 旧 HTTP access token 仅剩兼容用途
3. `/api/auth/refresh` 也不再保证始终存在有效 refresh cookie
因此继续假设 `/api/runtime/story/*` 一定能依赖旧 JWT Bearer是不稳的。
## 3. 本轮兼容桥方案
### 3.1 前端
[`runtimeStoryService.ts`](/home/Genarrative/src/services/runtimeStoryService.ts) 继续保留 HTTP transport但在请求 `/api/runtime/story/*` 时:
1. 优先携带现有 HTTP access token
2. 若不存在 HTTP access token则回退携带 STDB token
3. 新增专用 header明确这是一条 runtime story 的 STDB 兼容认证链路
这样上层调用不变,但 `server-node` 侧已经能识别 STDB token。
### 3.2 后端鉴权
`server-node``/api/runtime/story/*` 单独补一层兼容认证:
1. 先尝试旧 JWT Bearer
2. 若 Bearer 不是 JWT 或 JWT 校验失败,则尝试按 STDB token 建连解析身份
3. 解析成功后,把 `request.userId` 设为 STDB account id
注意:
1. 这不是把所有 `server-node` 路由都改成 STDB 认证
2. 只对 runtime story 这条仍未迁完的兼容链路生效
### 3.3 快照读写
`storyActionService` 现有规则仍保持不变,但其读取与写回的 `runtimeRepository` 改为支持 STDB snapshot 的兼容仓储。
优先级:
1. runtime story 若通过 STDB token 认证进入
2. 则快照直接从 STDB `my_snapshot` / `save_snapshot` / `delete_snapshot` 读取和写回
3. 旧 JWT 路径继续维持原来的 Postgres 仓储行为
这样可以最小化改动:
1. 不重写 story rule
2. 不要求 runtime story 前端立刻切成 STDB provider
3. 先让“当前真实主快照”恢复一致
## 4. 为什么不选其他方案
### 4.1 不恢复前端双写到 Postgres
不选原因:
1. 会把已经迁到 STDB 的快照主链路重新拉回双写状态
2. 会继续制造写时漂移和清理成本
3. 与 express -> stdb 迁移方向相反
### 4.2 不把所有 `/api/runtime/story/*` 立刻删掉
不选原因:
1. 现有 `storyActionService.ts` 承接了大量 battle / npc / inventory / quest 规则
2. 一次性重写成 STDB procedure 风险过高
3. 当前更紧急的是先修复“已断开的运行时快照来源”
## 5. 本轮边界
本轮只做:
1. runtime story 的 STDB token 兼容认证
2. runtime story 的 STDB snapshot 兼容读写
3. 保持现有前端 coordinator / response contract 不变
本轮不做:
1. 新的 STDB runtime story table / procedure / view 正式 contract
2. `runtimeStoryService` 切为真正的 STDB transport
3. 删除 `/api/runtime/story/*`
## 6. 下一步
Phase 2A 稳定后,再继续:
1.`get state``resolve action` 的正式 contract 收到 STDB
2. 在前端接入真正的 STDB transport
3. 删除 `/api/runtime/story/*` 这层兼容桥

View File

@@ -0,0 +1,338 @@
# Runtime Story 迁移到 STDB Phase 2后端 Contract 与 Provider 设计2026-04-20
更新时间:`2026-04-20`
## 1. 本轮定位
`Phase 1` 已经把 [`runtimeStoryService.ts`](/home/Genarrative/src/services/runtimeStoryService.ts) 改造成可替换 transport但当前仓库里还没有真正承接 `runtime story` 的 SpacetimeDB schema / procedure / view。
因此本轮不直接写 STDB provider 代码,而是先把 `Phase 2` 需要落地的 contract 补成可直接编码的设计,避免下一轮实现时范围漂移。
## 2. 当前真实现状
### 2.1 Express 已承接的 runtime story 能力
当前 Express 运行时主链位于:
1. [`storyActionRoutes.ts`](/home/Genarrative/server-node/src/modules/story/storyActionRoutes.ts)
2. [`storyActionService.ts`](/home/Genarrative/server-node/src/modules/story/storyActionService.ts)
3. [`runtimeSession.ts`](/home/Genarrative/server-node/src/modules/story/runtimeSession.ts)
当前实际对外 contract
1. `GET /api/runtime/story/state/:sessionId`
2. `POST /api/runtime/story/actions/resolve`
统一响应类型来自 [`packages/shared/src/contracts/story.ts`](/home/Genarrative/packages/shared/src/contracts/story.ts)
1. `RuntimeStoryActionResponse`
2. `RuntimeStoryViewModel`
3. `RuntimeStoryPresentation`
4. `RuntimeStoryPatch`
这条链路已经承接的业务范围不只是“读一个故事文本”,而是:
1. NPC 交互
2. 战斗动作
3. inventory use
4. quest accept / turn in
5. treasure inspect / secure / leave
6. runtime snapshot version 冲突检查
### 2.2 STDB 当前已具备但还不够的能力
当前 STDB 已有:
1. `my_snapshot` view
2. `save_snapshot` / `delete_snapshot`
3. `my_runtime_settings`
4. 一批认证、资料库、浏览历史相关 view / procedure
代码位置:
1. [`spacetimedb/src/runtime.rs`](/home/Genarrative/spacetimedb/src/runtime.rs)
2. [`spacetimedb/src/types.rs`](/home/Genarrative/spacetimedb/src/types.rs)
3. [`src/spacetime/generated/index.ts`](/home/Genarrative/src/spacetime/generated/index.ts)
当前 STDB **没有**的关键能力:
1. 没有 `runtime story state` view
2. 没有 `resolve runtime story action` procedure
3. 没有 `runtime action` 的版本冲突返回
4. 没有 `RuntimeStoryViewModel / Presentation / Patch` 对应的 STDB 类型
5. 没有面向客户端订阅的 story action result / event 承接面
结论:
1. 现在直接写前端 STDB transport/provider 没有后端可对接
2. 先补清楚 STDB contract 才能进入下一轮编码
## 3. Phase 2 的目标
`Phase 2` 只做“让前端有能力通过 STDB 读取/提交 runtime story”不在这一阶段重写全部业务规则。
建议目标:
1. STDB 先承接 `runtime story state get`
2. STDB 先承接 `runtime story action resolve`
3. 前端新增 STDB transport/provider
4. 允许 provider 通过 feature flag 或初始化注入切换
5. 在 STDB provider 能返回与 Express 同形 contract 之前,不改 `runtimeStoryCoordinator`
## 4. 建议的 STDB schema 设计
### 4.1 不要把完整 runtime story state 拆成大量订阅碎表
本阶段不建议一上来把:
1. player status
2. encounter
3. companions
4. available options
5. patches
6. presentation
全部拆成大量 public table。
原因:
1. 当前前端上层消费的是真正的“聚合响应”
2. 这条链路还在迁移期,过早拆散会放大前端改动面
3. 现有 STDB 运行时基础设施本身就是以 `snapshot json + view` 为主
因此 Phase 2 先采用“聚合 view + 聚合 procedure return/event”的收口方式更稳。
### 4.2 建议新增的自定义类型
建议在 [`spacetimedb/src/types.rs`](/home/Genarrative/spacetimedb/src/types.rs) 中新增:
1. `RuntimeStoryPlayerView`
2. `RuntimeStoryEncounterView`
3. `RuntimeStoryCompanionView`
4. `RuntimeStoryStatusView`
5. `RuntimeStoryOptionInteraction`
6. `RuntimeStoryOptionView`
7. `RuntimeBattlePresentation`
8. `RuntimeStoryPresentationView`
9. `RuntimeStoryPatchView`
10. `RuntimeStoryAggregateView`
11. `RuntimeStoryActionInput`
12. `RuntimeStoryActionResult`
设计原则:
1. 字段名尽量对齐前端已有 shared contract
2. 如果 Rust 命名必须使用 snake_case则在 TypeScript mapper 层做一次转换
3. 不要在前端重新推导 interaction / option legality / patch 语义
### 4.3 建议新增的 view
建议新增:
1. `my_runtime_story_state(session_id: String) -> Option<RuntimeStoryAggregateView>`
字段建议至少包含:
1. `session_id`
2. `server_version`
3. `player`
4. `encounter`
5. `companions`
6. `available_options`
7. `status`
8. `story_text`
9. `presentation_options`
10. `toast`
11. `battle`
12. `snapshot_version`
13. `snapshot_saved_at_ms`
14. `snapshot_bottom_tab`
15. `snapshot_game_state_json`
16. `snapshot_current_story_json`
说明:
1. 这一版 view 直接返回“聚合态”
2. 其职责等价于 Express 的 `getRuntimeStoryState(...)`
3. 前端 transport 从这一个 view 就能拼出 `RuntimeStoryActionResponse`
### 4.4 建议新增的 procedure
建议新增:
1. `resolve_runtime_story_action(meta, session_id, client_version, action) -> RuntimeStoryActionResult`
返回字段建议至少包含:
1. `ok`
2. `message`
3. `code`
4. `session_id`
5. `server_version`
6. `player`
7. `encounter`
8. `companions`
9. `available_options`
10. `action_text`
11. `result_text`
12. `story_text`
13. `presentation_options`
14. `toast`
15. `battle`
16. `patches`
17. `snapshot_version`
18. `snapshot_saved_at_ms`
19. `snapshot_bottom_tab`
20. `snapshot_game_state_json`
21. `snapshot_current_story_json`
22. `conflict_client_version`
23. `conflict_server_version`
为什么 procedure 先返回聚合结果而不是只写表:
1. 当前前端 `resolveServerRuntimeChoice(...)` 依赖“一次提交,一次拿回完整结果”
2. STDB reducer 不返回值,当前仓库已在其他 runtime 写链路上采用 `procedure + with_tx` 模式
3. 这能最小化前端改动,且与现有 `save_snapshot` 做法一致
## 5. 业务实现建议
### 5.1 先复用 Express 现有运行时算法,不要一上来双写两份规则
当前最危险的做法是:
1. 在 Rust 里全量重写一遍 `runtimeSession.ts + storyActionService.ts`
2. 同时还保留 Express 版
这样会立刻引入双份规则漂移。
建议 Phase 2 先做:
1.`runtime story` 的领域算法抽到 shared-friendly contract 文档层
2. 先在 STDB 里承接最小版本
3. 以 Express 现有行为为基线做回归比对
如果下一轮必须直接编码,优先顺序建议是:
1. `get state`
2. `story / npc / combat` 的核心 option pool
3. `inventory_use`
4. `npc_trade / npc_gift`
5. `npc_quest_accept / npc_quest_turn_in`
6. `treasure_*`
### 5.2 快照仍然是当前真相源
在完成真正的细粒度 runtime 表设计前,建议继续以 `saved_snapshot_row` 中的:
1. `game_state_json`
2. `current_story_json`
作为 runtime story 的输入与输出真相源。
也就是说 Phase 2 的 STDB runtime story 过程应当:
1. 读取当前账号 `saved_snapshot_row`
2. 在 procedure 内解析 JSON
3. 完成 runtime story 结算
4. 把新快照重新写回 `saved_snapshot_row`
5. 同时返回聚合 story response
这样与 Express 当前语义最接近,也最利于迁移验证。
## 6. 前端 provider 设计
### 6.1 transport 入口
前端下一轮建议新增:
1. `src/services/runtimeStoryStdbTransport.ts`
职责:
1. 通过 `ensureSpacetimeConnection()` 建连
2. 读取 `my_runtime_story_state(...)` 或对应聚合 view
3. 调用 `resolveRuntimeStoryAction(...)` procedure
4. 映射成现有 `RuntimeStoryResponse`
### 6.2 mapper 入口
建议新增:
1. `src/spacetime/runtimeStoryMappers.ts`
职责:
1. 将 STDB 生成绑定类型映射为前端 shared contract
2. 保持 `RuntimeStoryResponse` 结构与 Express 路径一致
3. 统一做 `snake_case -> camelCase`
4. 统一解析 snapshot JSON
### 6.3 provider 切换方式
建议不要在页面层直接判断用 HTTP 还是 STDB。
建议由初始化层统一注入:
1. 默认仍是 HTTP transport
2. 在 STDB 后端 contract 完成并验证通过后,再显式执行 `setRuntimeStoryTransport(stdbTransport)`
注入点可选:
1. `src/main.tsx`
2. `src/components/auth/AuthGate.tsx`
3. 单独的 runtime bootstrap 模块
推荐:
1. 单独 bootstrap 模块
原因:
1. 不把 provider 选择逻辑塞进 UI 组件
2. 便于测试环境替换
## 7. 验证方案
下一轮实现后至少要补这些验证:
1. STDB `get state` 返回的 `availableOptions / interaction / snapshot` 与 Express 基线一致
2. STDB `resolve action` 能正确处理 version conflict
3. `runtimeStoryService` 在切到 STDB transport 后,`runtimeStoryCoordinator` 单测无需改契约
4. `save_snapshot -> get state -> resolve action -> snapshot persisted` 全链路可重复
建议加一组“Express vs STDB 同输入同输出”的基线测试,优先覆盖:
1. `npc_chat`
2. `battle_attack_basic`
3. `inventory_use`
4. `npc_trade`
5. `npc_quest_accept`
## 8. 下一块最小实现建议
在这份设计之后,最小可执行实现块建议是:
1. 只在 STDB 中新增 `runtime story get state` 聚合 view
2. 前端只实现只读 STDB transport 的 `getState`
3. `resolveAction` 仍暂时走 HTTP
原因:
1. 这能先验证 view / mapper / provider 注入是否稳定
2. 改动面小于一次性把 `resolve action` 也切过去
3. 能把风险从“全链路迁移”拆成“先读后写”
当这一步稳定后,再做:
1. STDB `resolve_runtime_story_action` procedure
2. 前端 `resolveAction` 切换
## 9. 本轮涉及的关键参考
1. [`src/services/runtimeStoryService.ts`](/home/Genarrative/src/services/runtimeStoryService.ts)
2. [`src/hooks/story/runtimeStoryCoordinator.ts`](/home/Genarrative/src/hooks/story/runtimeStoryCoordinator.ts)
3. [`server-node/src/modules/story/storyActionService.ts`](/home/Genarrative/server-node/src/modules/story/storyActionService.ts)
4. [`server-node/src/modules/story/runtimeSession.ts`](/home/Genarrative/server-node/src/modules/story/runtimeSession.ts)
5. [`spacetimedb/src/runtime.rs`](/home/Genarrative/spacetimedb/src/runtime.rs)
6. [`spacetimedb/src/types.rs`](/home/Genarrative/spacetimedb/src/types.rs)
7. [`packages/shared/src/contracts/story.ts`](/home/Genarrative/packages/shared/src/contracts/story.ts)

View File

@@ -0,0 +1,68 @@
# STDB Auth 尾巴清理 Phase 1移除前端自动游客凭证残留2026-04-20
## 1. 本轮目标
这轮只处理认证迁移里最安全、且不会打断现有 `/api/runtime/story/*` 的一段尾巴:
1. 删除前端本地自动游客用户名/密码残留
2. 保留当前 `ACCESS_TOKEN_KEY`,因为它仍同时承载:
- Spacetime token
-`/api/runtime/story/*` Bearer token
3. 不处理 Node JWT middleware 本身
4. 不处理 runtime story 向 STDB 的正式迁移
## 2. 背景问题
当前前端虽然已经用 `ensureAutoAuthUser()` 走 STDB 匿名连接,但代码里仍保留一套旧时代残留:
1. `AutoAuthCredentials`
2. `createAutoAuthCredentials()`
3. `authEntryWithStoredCredentials()`
4. `apiClient.ts` 中的 `AUTO_AUTH_USERNAME_KEY / AUTO_AUTH_PASSWORD_KEY`
这些内容已经不再承担真实登录能力,只会继续制造两个误导:
1. 让人误以为当前游客登录仍依赖浏览器本地用户名/密码恢复
2. 让 STDB token 收口与旧 JWT 清理边界持续混在一起
## 3. 本轮落地
代码调整:
1. `src/services/authService.ts`
- 删除 `AutoAuthCredentials`
- 删除 `createAutoAuthCredentials()`
- 删除 `authEntryWithStoredCredentials()`
- `ensureAutoAuthUser()` 改为只返回 `AuthUser`
- `clearAuthSession()` / `logoutAuthUser()` / `logoutAllAuthSessions()` 不再清理自动游客凭证
2. `src/services/apiClient.ts`
- 删除自动游客用户名/密码的 localStorage key 和相关 helper
3. `src/components/auth/AuthGate.tsx`
- 匿名建连成功后直接消费 `AuthUser`
4. 测试同步调整:
- `src/components/auth/AuthGate.test.tsx`
- `src/services/authService.test.ts`
## 4. 当前边界
这轮刻意不动:
1. `ACCESS_TOKEN_KEY`
2. `/api/auth/refresh`
3. `fetchWithApiAuth()` 的 Bearer 注入
4. `server-node/src/middleware/auth.ts`
原因很简单:
1. 当前 `runtimeStoryService.ts` 仍在访问 `/api/runtime/story/*`
2. Express 这条链路仍要求旧 JWT Bearer
3. 如果现在直接删 `ACCESS_TOKEN_KEY` 语义,会把 STDB token 与 runtime story 旧链路一起打断
因此这轮是 Auth 尾巴清理的第一段,不是最终收口。
## 5. 下一步建议
下一步应继续处理:
1. 为 STDB token 与旧 HTTP bearer 拆出独立存储语义
2. 或直接推进 `runtimeStoryService.ts` 迁到 STDB随后删除 `/api/runtime/story/*` 对旧 JWT 的依赖

View File

@@ -0,0 +1,73 @@
# STDB Auth 尾巴清理 Phase 2拆分 STDB Token 与旧 HTTP Bearer 存储槽2026-04-20
## 1. 本轮目标
在不打断现有 `/api/runtime/story/*` 旧链路的前提下,把两种不同语义的 token 从同一个 localStorage key 中拆开:
1. Spacetime 连接 token
2. 旧 Express Bearer access token
## 2. 背景问题
此前前端把这两种 token 混存到同一个 key
- `genarrative.auth.access-token.v1`
这会带来两个直接问题:
1. `AuthGate``authService` 无法区分“当前是在恢复 STDB 会话”还是“当前只是旧 HTTP Bearer 还在”
2. 后续删除 `/api/auth/refresh``/api/runtime/story/*` 时,很容易互相打断
## 3. 本轮落地
### 3.1 新的存储语义
- HTTP Bearer:
- `genarrative.auth.http-access-token.v1`
- Spacetime token:
- `genarrative.auth.spacetime-token.v1`
### 3.2 代码调整
1. `src/services/apiClient.ts`
- 保留旧 HTTP token API
- `getStoredAccessToken`
- `setStoredAccessToken`
- `clearStoredAccessToken`
- 新增 STDB token API
- `getStoredSpacetimeToken`
- `setStoredSpacetimeToken`
- `clearStoredSpacetimeToken`
2. `src/spacetime/client.ts`
- Spacetime 连接改为只读写 STDB token key
3. `src/services/authService.ts`
- 账号恢复链只依据 STDB token 判断是否尝试恢复连接
4. `src/components/auth/AuthGate.tsx`
- UI 启动时只依据 STDB token 判断是否走会话恢复
### 3.3 当前保留不变的旧链路
1. `fetchWithApiAuth()`
2. `/api/auth/refresh`
3. `/api/runtime/story/*` 的旧 Bearer 注入
这些内容仍继续只使用 HTTP token key。
## 4. 当前阶段意义
这一步不是最终删除旧 JWT而是先把 STDB 会话和旧 Express 会话拆成两个独立层:
1. STDB auth 可以继续独立演进
2. runtime story 旧链路暂时还能工作
3. 后续迁 story 到 STDB 时,可以单独移除 HTTP token 相关逻辑
## 5. 验证
已补定向测试覆盖:
1. `src/services/apiClient.test.ts`
- 验证 HTTP token 与 STDB token 独立存储
2. `src/services/authService.test.ts`
- 验证 STDB token 失效回退匿名连接
3. `src/components/auth/AuthGate.test.tsx`
- 验证 UI 恢复链改为读取 STDB token

View File

@@ -0,0 +1,103 @@
# Story / World 向 STDB 迁移能力先迁、逻辑后搬实施方案2026-04-20
更新时间:`2026-04-20`
## 1. 为什么先迁能力,不先整块搬逻辑
当前 `runtime story``custom world` 都不是“只差把逻辑换个地方跑”。
真实问题有两类:
1. 依赖面过大,很多 service 直接吃 `RuntimeRepositoryPort` 这类大接口
2. `LLM / SSE / HTTP request / 账号鉴权 / snapshot/session 存取` 仍然缠在一起
如果在这个阶段直接把整块逻辑搬去 STDB会出现两种风险
1. 把 Express 耦合一并搬过去,结果不是迁移而是复制
2. 在 STDB 里同时承接“确定性规则”和“非确定性外部能力”,边界会继续变脏
因此后续迁移顺序固定为:
1. 先拆能力口子
2. 再给能力口子补 STDB provider
3. 最后再搬只依赖这些能力口子的纯逻辑
## 2. 迁移分层
### 2.1 可以先收进 STDB 的能力
这类能力适合先做成 capability/provider
1. runtime snapshot 读取与写回
2. custom world session 读取与写回
3. custom world profile / works 聚合读取
4. runtime story state 聚合读取
5. runtime story action 聚合写入结果
它们的共同特点:
1. 以数据真源为主
2. 可以明确建模为 table / view / procedure
3. 前后端 contract 可以稳定冻结
### 2.2 不能直接塞进 reducer 的能力
以下能力不应和纯规则一起直接塞进 STDB reducer
1. LLM 调用
2. SSE 流式输出
3. 图片 / 资产生成
4. 长任务轮询与异步编排
5. 旧 HTTP 鉴权链路
这些能力应该保留在 Express 或 procedure / orchestrator 边界,直到它们被进一步拆清。
## 3. 当前代码收口原则
这一轮先不扩大兼容桥,而是先把依赖面收紧:
1. `storyActionService` 只依赖 `RuntimeStoryCapability`
2. `CustomWorldSessionStore` 只依赖 `CustomWorldSessionCapability`
3. `CustomWorldAgentSessionStore` 只依赖 `CustomWorldSessionCapability`
4. `listCustomWorldWorkSummaries` 只依赖 `CustomWorldWorkSummaryCapability`
这一步的意义不是“已经迁完 STDB”而是
1. 后续替换 provider 时,不再需要动整块 `RuntimeRepositoryPort`
2. 测试 stub 会被迫只实现真实需要的能力
3. 可以更准确地区分“真源能力迁移”和“业务逻辑迁移”
## 4. 后续实施顺序
### 4.1 Story
1. 继续收紧 `runtime story` 所需 capability
2.`get state / resolve action` 设计正式 STDB provider contract
3.`storyActionService` 改为吃 provider而不是直接认具体仓储
4. 再把可确定性的结算逻辑逐段迁入 STDB
### 4.2 Custom World
1. 先把 `session / works / profile` 各自 capability 固化
2.`custom world session` 提供 STDB provider
3.`agent session` 的会话存取提供 STDB provider
4. 保留 `LLM / asset / stream` 在 orchestrator 边界
5. 等会话和作品真源稳定后,再搬纯状态推导逻辑
## 5. 本轮落地点
本轮只做一件事:
1.`story/custom world` 的 service 依赖从大仓储接口改成最小 capability
本轮不做:
1. 新增更大范围兼容桥
2. 直接把 `custom world agent` 整块搬进 STDB
3. 把 LLM / stream / asset orchestration 一起迁移
## 6. 验收标准
1. `server-node` 编译通过
2. 相关测试 stub 不再依赖完整 `RuntimeRepositoryPort`
3. 文档明确固定“能力先迁、逻辑后搬”的顺序

View File

@@ -0,0 +1,80 @@
# 任务完成自动提交工作流2026-04-20
## 1. 目标
为避免“任务已完成但改动长期停留在工作区”与“提交时把无关改动一并打包”的问题,仓库补充一条统一工作流:
1. 当任务明确完成时,立即执行一次提交。
2. 只提交当前任务相关文件。
3. 提交信息使用中文简洁摘要。
4. 不依赖 `git hook` 或后台自动监听。
## 2. 实现方式
新增脚本:
- `scripts/commit-task.mjs`
职责:
1. 接收提交信息 `-m/--message`
2. 接收本次任务要提交的文件列表
3. 先检查这些文件是否确实存在未提交改动
4. 只暂存传入文件
5. 只在存在暂存内容时执行提交
## 3. 使用方式
命令格式:
```bash
node scripts/commit-task.mjs -m "中文提交摘要" <file...>
```
示例:
```bash
node scripts/commit-task.mjs \
-m "补充任务完成后自动提交协作约定" \
docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md \
docs/experience/README.md
```
## 4. 行为约束
1. 不传 `-m` 会直接失败。
2. 不传文件列表会直接失败。
3. 指定文件没有改动时不会创建空提交。
4. 脚本不会自动把未指定文件加入提交。
5. 脚本不做 `amend`,不改写历史。
6. 即使索引里已经有其他已暂存文件,脚本也只会提交本次显式传入的文件。
## 5. 适用范围
适用于:
1. 单任务完成后的常规提交
2. 工作区存在其他无关改动时的定向提交
3. 需要明确控制提交边界的日常开发
不适用于:
1. 需要 squash 或改写历史的场景
2. 需要交互式挑选 hunk 的场景
3. 无法清晰界定当前任务文件边界的场景
## 6. 当前协作约定
默认规则:
1. 用户明确表示任务完成,或直接要求提交时,优先使用这条工作流。
2. 若当前任务文件边界清晰,则直接提交。
3. 若边界不清晰,则先停下并说明风险,不强行提交。
## 7. 验证
当前已补:
1. `scripts/commit-task.test.ts`
2. 覆盖“只提交指定文件,保留未指定改动在工作区”的核心行为
3. 覆盖“已有其他 staged 改动时不混入当前提交”的边界行为

View File

@@ -20,6 +20,7 @@
"build:raw": "node scripts/vite-cli.mjs build",
"preview": "node scripts/vite-cli.mjs preview",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"commit:task": "node scripts/commit-task.mjs",
"check:encoding": "node scripts/check-encoding.mjs",
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
"lint:guardrails": "npm run lint:eslint",

57
scripts/commit-task.mjs Normal file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
const repoRoot = process.env.GENARRATIVE_COMMIT_REPO_ROOT?.trim() || '/home/Genarrative';
function fail(message) {
console.error(`[commit-task] ${message}`);
process.exit(1);
}
function runGit(args, options = {}) {
const result = spawnSync('git', args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'pipe'],
...options,
});
if (result.status !== 0) {
const stderr = result.stderr?.trim();
fail(stderr || `git ${args.join(' ')} 执行失败`);
}
return result.stdout?.trim() || '';
}
const args = process.argv.slice(2);
const messageIndex = args.findIndex((arg) => arg === '-m' || arg === '--message');
if (messageIndex === -1) {
fail('缺少提交信息,请使用 -m "中文摘要"');
}
const message = args[messageIndex + 1]?.trim() || '';
if (!message) {
fail('提交信息不能为空');
}
const fileArgs = args.filter((_, index) => index !== messageIndex && index !== messageIndex + 1);
if (fileArgs.length === 0) {
fail('至少传入一个需要提交的文件路径');
}
const statusOutput = runGit(['status', '--short', '--', ...fileArgs]);
if (!statusOutput) {
fail('指定文件没有可提交改动');
}
runGit(['add', '--', ...fileArgs]);
const stagedOutput = runGit(['diff', '--cached', '--name-only', '--', ...fileArgs]);
if (!stagedOutput) {
fail('暂存后没有检测到可提交内容');
}
runGit(['commit', '-m', message, '--', ...fileArgs], { stdio: 'inherit' });

136
scripts/commit-task.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { afterEach, describe, expect, it } from 'vitest';
function run(command: string, args: string[], cwd: string, env?: NodeJS.ProcessEnv) {
return spawnSync(command, args, {
cwd,
encoding: 'utf8',
env: {
...process.env,
...env,
},
});
}
function mustRun(command: string, args: string[], cwd: string, env?: NodeJS.ProcessEnv) {
const result = run(command, args, cwd, env);
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(' ')} failed:\n${result.stdout}\n${result.stderr}`,
);
}
return result;
}
describe('commit-task script', () => {
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop()!, { recursive: true, force: true });
}
});
it('commits only the specified files', () => {
const repoRoot = mkdtempSync(resolve(tmpdir(), 'genarrative-commit-task-'));
tempDirs.push(repoRoot);
mustRun('git', ['init'], repoRoot);
mustRun('git', ['config', 'user.name', 'Codex Test'], repoRoot);
mustRun('git', ['config', 'user.email', 'codex@example.com'], repoRoot);
mkdirSync(resolve(repoRoot, 'docs'), { recursive: true });
mkdirSync(resolve(repoRoot, 'notes'), { recursive: true });
writeFileSync(resolve(repoRoot, 'docs', 'task.md'), 'v1\n', 'utf8');
writeFileSync(resolve(repoRoot, 'notes', 'other.md'), 'other v1\n', 'utf8');
mustRun('git', ['add', '.'], repoRoot);
mustRun('git', ['commit', '-m', 'init'], repoRoot);
writeFileSync(resolve(repoRoot, 'docs', 'task.md'), 'v2\n', 'utf8');
writeFileSync(resolve(repoRoot, 'notes', 'other.md'), 'other v2\n', 'utf8');
const scriptPath = resolve('/home/Genarrative/scripts/commit-task.mjs');
const commitResult = mustRun(
'node',
[scriptPath, '-m', '提交任务文件', 'docs/task.md'],
repoRoot,
{
GENARRATIVE_COMMIT_REPO_ROOT: repoRoot,
},
);
expect(commitResult.stdout).toContain('提交任务文件');
const committedTaskContent = mustRun(
'git',
['show', 'HEAD:docs/task.md'],
repoRoot,
).stdout;
expect(committedTaskContent).toBe('v2\n');
const committedOtherContent = mustRun(
'git',
['show', 'HEAD:notes/other.md'],
repoRoot,
).stdout;
expect(committedOtherContent).toBe('other v1\n');
const statusResult = mustRun('git', ['status', '--short'], repoRoot);
expect(statusResult.stdout).toContain(' M notes/other.md');
expect(statusResult.stdout).not.toContain('docs/task.md');
});
it('does not include unrelated staged files in the commit', () => {
const repoRoot = mkdtempSync(resolve(tmpdir(), 'genarrative-commit-task-'));
tempDirs.push(repoRoot);
mustRun('git', ['init'], repoRoot);
mustRun('git', ['config', 'user.name', 'Codex Test'], repoRoot);
mustRun('git', ['config', 'user.email', 'codex@example.com'], repoRoot);
mkdirSync(resolve(repoRoot, 'docs'), { recursive: true });
mkdirSync(resolve(repoRoot, 'notes'), { recursive: true });
writeFileSync(resolve(repoRoot, 'docs', 'task.md'), 'v1\n', 'utf8');
writeFileSync(resolve(repoRoot, 'notes', 'other.md'), 'other v1\n', 'utf8');
mustRun('git', ['add', '.'], repoRoot);
mustRun('git', ['commit', '-m', 'init'], repoRoot);
writeFileSync(resolve(repoRoot, 'docs', 'task.md'), 'v2\n', 'utf8');
writeFileSync(resolve(repoRoot, 'notes', 'other.md'), 'other v2\n', 'utf8');
mustRun('git', ['add', 'notes/other.md'], repoRoot);
const scriptPath = resolve('/home/Genarrative/scripts/commit-task.mjs');
mustRun(
'node',
[scriptPath, '-m', '只提交任务文件', 'docs/task.md'],
repoRoot,
{
GENARRATIVE_COMMIT_REPO_ROOT: repoRoot,
},
);
const committedTaskContent = mustRun(
'git',
['show', 'HEAD:docs/task.md'],
repoRoot,
).stdout;
expect(committedTaskContent).toBe('v2\n');
const committedOtherContent = mustRun(
'git',
['show', 'HEAD:notes/other.md'],
repoRoot,
).stdout;
expect(committedOtherContent).toBe('other v1\n');
const statusResult = mustRun('git', ['status', '--short'], repoRoot);
expect(statusResult.stdout).toContain('M notes/other.md');
expect(statusResult.stdout).not.toContain('docs/task.md');
});
});

View File

@@ -128,6 +128,10 @@ function createTestConfig(
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
return {

View File

@@ -80,6 +80,10 @@ export type AppConfig = {
refreshCookieSameSite: 'Lax' | 'Strict' | 'None';
refreshCookiePath: string;
};
spacetime: {
uri: string;
databaseName: string;
};
};
type LoadConfigOptions = {
@@ -509,5 +513,17 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
'/api/auth',
),
},
spacetime: {
uri: readString(
env,
'SPACETIME_URI',
readString(env, 'VITE_SPACETIME_URI', 'wss://maincloud.spacetimedb.com'),
),
databaseName: readString(
env,
'SPACETIME_DATABASE_NAME',
readString(env, 'VITE_SPACETIME_DATABASE_NAME', 'xushi-p4wfr'),
),
},
};
}

View File

@@ -87,6 +87,10 @@ function createTestConfig(databaseUrl: string): AppConfig {
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
}

View File

@@ -97,6 +97,10 @@ function createTestConfig(testName: string): AppConfig {
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
}

View File

@@ -11,6 +11,11 @@ import {
getRuntimeStoryState,
resolveRuntimeStoryAction,
} from './storyActionService.js';
import {
authenticateRuntimeStoryViaSpacetime,
createRuntimeStorySnapshotRepository,
shouldUseSpacetimeStoryAuth,
} from './storySpacetimeBridge.js';
const actionPayloadSchema = z.record(z.string(), z.unknown());
@@ -29,7 +34,17 @@ export function createStoryActionRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.use(
asyncHandler(async (request, _response, next) => {
if (shouldUseSpacetimeStoryAuth(request)) {
await authenticateRuntimeStoryViaSpacetime(request, context);
next();
return;
}
request.runtimeStoryAuthMode = 'jwt';
await requireAuth(request, _response, next);
}),
);
router.post(
'/actions/resolve',
@@ -41,7 +56,11 @@ export function createStoryActionRoutes(context: AppContext) {
sendApiResponse(
response,
await resolveRuntimeStoryAction({
runtimeRepository: context.runtimeRepository,
runtimeRepository: createRuntimeStorySnapshotRepository({
request,
runtimeRepository: context.runtimeRepository,
config: context.config,
}),
llmClient: context.llmClient,
userId: request.userId!,
request: payload,
@@ -62,7 +81,11 @@ export function createStoryActionRoutes(context: AppContext) {
sendApiResponse(
response,
await getRuntimeStoryState({
runtimeRepository: context.runtimeRepository,
runtimeRepository: createRuntimeStorySnapshotRepository({
request,
runtimeRepository: context.runtimeRepository,
config: context.config,
}),
userId: request.userId!,
sessionId,
}),

View File

@@ -6,8 +6,9 @@ import type {
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
import type { RuntimeStoryCapability } from '../../services/runtimeCapabilities.js';
import {
buildStrictNpcChatDialoguePrompt,
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
@@ -77,6 +78,8 @@ type GeneratedStoryPayload = {
savedCurrentStory: JsonRecord;
};
type RuntimeSnapshotRepositoryPort = RuntimeStoryCapability;
const CONTINUE_ADVENTURE_OPTION = {
functionId: 'story_continue_adventure',
actionText: '继续冒险',
@@ -855,7 +858,7 @@ function resolveStoryFlowAction(
}
export async function resolveRuntimeStoryAction(params: {
runtimeRepository: RuntimeRepositoryPort;
runtimeRepository: RuntimeSnapshotRepositoryPort;
llmClient?: UpstreamLlmClient;
userId: string;
request: RuntimeStoryActionRequest;
@@ -1058,7 +1061,7 @@ export async function resolveRuntimeStoryAction(params: {
}
export async function getRuntimeStoryState(params: {
runtimeRepository: RuntimeRepositoryPort;
runtimeRepository: Pick<RuntimeSnapshotRepositoryPort, 'getSnapshot'>;
userId: string;
sessionId: string;
}) {

View File

@@ -0,0 +1,248 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Request } from 'express';
import type { AppConfig } from '../../config.js';
import {
authenticateRuntimeStoryViaSpacetime,
createRuntimeStorySnapshotRepository,
resetRuntimeStoryBridgeConnectorForTest,
setRuntimeStoryBridgeConnectorForTest,
shouldUseSpacetimeStoryAuth,
} from './storySpacetimeBridge.js';
function createTestConfig(): AppConfig {
return {
nodeEnv: 'test',
projectRoot: '/tmp/genarrative-test',
publicDir: '/tmp/genarrative-test/public',
logsDir: '/tmp/genarrative-test/logs',
dataDir: '/tmp/genarrative-test/data',
rawEnv: {},
databaseUrl: 'pg-mem://genarrative-test',
serverAddr: ':0',
logLevel: 'silent',
editorApiEnabled: true,
assetsApiEnabled: true,
jwtSecret: 'test-secret',
jwtExpiresIn: '7d',
jwtIssuer: 'genarrative-test',
llm: {
baseUrl: 'https://example.invalid',
apiKey: '',
model: 'test-model',
},
dashScope: {
baseUrl: 'https://example.invalid',
apiKey: '',
imageModel: 'test-image-model',
requestTimeoutMs: 1000,
},
smsAuth: {
enabled: true,
provider: 'mock',
endpoint: 'dypnsapi.aliyuncs.com',
accessKeyId: '',
accessKeySecret: '',
signName: 'Test Sign',
templateCode: '100001',
templateParamKey: 'code',
countryCode: '86',
schemeName: '',
codeLength: 6,
codeType: 1,
validTimeSeconds: 300,
intervalSeconds: 60,
duplicatePolicy: 1,
caseAuthPolicy: 1,
returnVerifyCode: false,
mockVerifyCode: '123456',
maxSendPerPhonePerDay: 20,
maxSendPerIpPerHour: 30,
maxVerifyFailuresPerPhonePerHour: 12,
maxVerifyFailuresPerIpPerHour: 24,
captchaTtlSeconds: 180,
captchaTriggerVerifyFailuresPerPhone: 3,
captchaTriggerVerifyFailuresPerIp: 5,
blockPhoneFailureThreshold: 6,
blockIpFailureThreshold: 10,
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
wechatAuth: {
enabled: true,
provider: 'mock',
appId: '',
appSecret: '',
authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect',
accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token',
userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo',
callbackPath: '/api/auth/wechat/callback',
defaultRedirectPath: '/',
mockUserId: 'mock_wechat_user',
mockUnionId: 'mock_wechat_union',
mockDisplayName: '微信旅人',
mockAvatarUrl: '',
},
authSession: {
refreshCookieName: 'genarrative_refresh_session',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
}
function createRequest(headers: Record<string, string> = {}) {
const normalizedHeaders = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
);
return {
header(name: string) {
return normalizedHeaders[name.toLowerCase()] ?? undefined;
},
ip: '127.0.0.1',
userId: undefined,
runtimeStoryAuthMode: undefined,
} as unknown as Request;
}
test('shouldUseSpacetimeStoryAuth only accepts the runtime story spacetime marker', () => {
assert.equal(
shouldUseSpacetimeStoryAuth(
createRequest({
'x-genarrative-runtime-story-auth': 'spacetime-token',
}),
),
true,
);
assert.equal(
shouldUseSpacetimeStoryAuth(
createRequest({
'x-genarrative-runtime-story-auth': 'http-access-token',
}),
),
false,
);
});
test('authenticateRuntimeStoryViaSpacetime resolves account id from bridge connection', async () => {
setRuntimeStoryBridgeConnectorForTest(async () => ({
db: {
my_auth_state: {
iter: () =>
[
{
accountId: 'stdb-account-01',
},
][Symbol.iterator](),
},
},
disconnect() {},
}));
const request = createRequest({
authorization: 'Bearer stdb-token',
});
await authenticateRuntimeStoryViaSpacetime(request, {
config: createTestConfig(),
});
assert.equal(request.userId, 'stdb-account-01');
assert.equal(request.runtimeStoryAuthMode, 'spacetime');
resetRuntimeStoryBridgeConnectorForTest();
});
test('createRuntimeStorySnapshotRepository uses STDB snapshot rows when request is marked as spacetime', async () => {
let savedSnapshotInput: Record<string, unknown> | null = null;
setRuntimeStoryBridgeConnectorForTest(async () => ({
db: {
my_snapshot: {
iter: () =>
[
{
version: 8,
savedAtMs: BigInt(Date.parse('2026-04-20T00:00:00.000Z')),
gameStateJson: JSON.stringify({
worldType: 'WUXIA',
currentScene: 'Story',
}),
bottomTab: 'adventure',
currentStoryJson: JSON.stringify({
text: '桥接后的快照',
options: [],
}),
},
][Symbol.iterator](),
},
},
procedures: {
async saveSnapshot(input: Record<string, unknown>) {
savedSnapshotInput = input;
return {
ok: true,
message: 'ok',
};
},
},
disconnect() {},
}));
const request = createRequest({
authorization: 'Bearer stdb-token',
'user-agent': 'test-agent',
});
request.runtimeStoryAuthMode = 'spacetime';
const repository = createRuntimeStorySnapshotRepository({
request,
config: createTestConfig(),
runtimeRepository: {
async getSnapshot() {
throw new Error('should not hit legacy repository');
},
async putSnapshot() {
throw new Error('should not hit legacy repository');
},
} as never,
});
const snapshot = await repository.getSnapshot('ignored-user-id');
assert.equal(snapshot?.version, 8);
assert.equal(snapshot?.gameState.worldType, 'WUXIA');
const persisted = await repository.putSnapshot('ignored-user-id', {
savedAt: '2026-04-20T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {
worldType: 'WUXIA',
currentScene: 'Story',
},
currentStory: {
text: '桥接后的快照',
options: [],
},
});
assert.equal(persisted.version, 8);
assert.equal(savedSnapshotInput?.bottomTab, 'adventure');
assert.equal(
savedSnapshotInput?.currentStoryJson,
JSON.stringify({
text: '桥接后的快照',
options: [],
}),
);
resetRuntimeStoryBridgeConnectorForTest();
});

View File

@@ -0,0 +1,207 @@
import type { Request } from 'express';
import { DbConnection } from '../../../../src/spacetime/generated/index.ts';
import type {
RequestMeta,
SnapshotView,
} from '../../../../src/spacetime/generated/types.ts';
import { conflict, unauthorized } from '../../errors.js';
import type {
SavedSnapshot,
} from '../../repositories/runtimeRepository.js';
import type { AppConfig } from '../../config.js';
import { hydrateSavedSnapshot } from '../runtime/runtimeSnapshotHydration.js';
import type { RuntimeStoryCapability } from '../../services/runtimeCapabilities.js';
const STORY_STDB_AUTH_HEADER = 'x-genarrative-runtime-story-auth';
type RuntimeSnapshotRepositoryPort = RuntimeStoryCapability;
type StoryStdbBridgeContext = {
config: AppConfig;
};
type StoryBridgeDbConnection = Awaited<ReturnType<typeof connectWithToken>>;
function readBearerToken(request: Request) {
const authorization = request.header('authorization')?.trim() || '';
if (!authorization.startsWith('Bearer ')) {
return '';
}
return authorization.slice('Bearer '.length).trim();
}
function buildRequestMeta(request: Request): RequestMeta {
const userAgent = request.header('user-agent')?.trim() || undefined;
const forwardedFor = request.header('x-forwarded-for')?.split(',')[0]?.trim();
const ip = forwardedFor || request.ip || undefined;
return {
clientType: 'server-node-runtime-story-bridge',
userAgent,
ip,
};
}
function toSavedSnapshot(row: SnapshotView): SavedSnapshot {
return hydrateSavedSnapshot({
version: Number(row.version),
savedAt: new Date(Number(row.savedAtMs)).toISOString(),
gameState: JSON.parse(row.gameStateJson) as Record<string, unknown>,
bottomTab: row.bottomTab,
currentStory: row.currentStoryJson
? (JSON.parse(row.currentStoryJson) as Record<string, unknown> | null)
: null,
})!;
}
async function connectWithToken(config: AppConfig, token: string) {
return new Promise<DbConnection>((resolve, reject) => {
let settled = false;
const finish = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
callback();
};
DbConnection.builder()
.withUri(config.spacetime.uri)
.withDatabaseName(config.spacetime.databaseName)
.withLightMode(true)
.withToken(token)
.onConnect((connection) => {
connection
.subscriptionBuilder()
.onApplied(() => {
finish(() => {
resolve(connection);
});
})
.onError((_ctx, error) => {
finish(() => {
connection.disconnect();
reject(error);
});
})
.subscribeToAllTables();
})
.onConnectError((_ctx, error) => {
finish(() => reject(error));
})
.onDisconnect((_ctx, error) => {
if (!settled) {
finish(() => reject(error ?? new Error('Spacetime 连接已断开')));
}
})
.build();
});
}
async function withBridgeConnection<T>(
config: AppConfig,
token: string,
run: (connection: StoryBridgeDbConnection) => Promise<T>,
) {
const connection = await runtimeStoryBridgeConnector(config, token);
try {
return await run(connection);
} finally {
connection.disconnect();
}
}
let runtimeStoryBridgeConnector = connectWithToken;
export function setRuntimeStoryBridgeConnectorForTest(
connector: typeof connectWithToken,
) {
runtimeStoryBridgeConnector = connector;
}
export function resetRuntimeStoryBridgeConnectorForTest() {
runtimeStoryBridgeConnector = connectWithToken;
}
export function shouldUseSpacetimeStoryAuth(request: Request) {
return (
request.header(STORY_STDB_AUTH_HEADER)?.trim() === 'spacetime-token'
);
}
export async function authenticateRuntimeStoryViaSpacetime(
request: Request,
context: StoryStdbBridgeContext,
) {
const token = readBearerToken(request);
if (!token) {
throw unauthorized('缺少 runtime story 兼容认证 token');
}
const accountId = await withBridgeConnection(
context.config,
token,
async (connection) => {
const row = Array.from(connection.db.my_auth_state.iter())[0] ?? null;
const nextAccountId = row?.accountId?.trim() || '';
if (!nextAccountId) {
throw unauthorized('runtime story STDB 账号态不存在');
}
return nextAccountId;
},
);
request.userId = accountId;
request.runtimeStoryAuthMode = 'spacetime';
}
export function createRuntimeStorySnapshotRepository(params: {
request: Request;
runtimeRepository: RuntimeStoryCapability;
config: AppConfig;
}): RuntimeSnapshotRepositoryPort {
if (params.request.runtimeStoryAuthMode !== 'spacetime') {
return params.runtimeRepository;
}
const token = readBearerToken(params.request);
if (!token) {
throw unauthorized('缺少 runtime story STDB token');
}
const requestMeta = buildRequestMeta(params.request);
return {
async getSnapshot() {
return withBridgeConnection(params.config, token, async (connection) => {
const row = Array.from(connection.db.my_snapshot.iter())[0] ?? null;
return row ? toSavedSnapshot(row) : null;
});
},
async putSnapshot(_userId: string, payload) {
return withBridgeConnection(params.config, token, async (connection) => {
const result = await connection.procedures.saveSnapshot({
meta: requestMeta,
savedAtMs: BigInt(Date.parse(payload.savedAt) || Date.now()),
gameStateJson: JSON.stringify(payload.gameState),
bottomTab: payload.bottomTab,
currentStoryJson:
payload.currentStory === null || payload.currentStory === undefined
? undefined
: JSON.stringify(payload.currentStory),
});
if (!result.ok) {
throw conflict(result.message || 'runtime story STDB 快照写入失败');
}
const row = Array.from(connection.db.my_snapshot.iter())[0] ?? null;
if (!row) {
throw conflict('runtime story STDB 快照写入后未返回最新快照');
}
return toSavedSnapshot(row);
});
},
};
}

View File

@@ -99,6 +99,10 @@ function createTestConfig(testName: string): AppConfig {
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
}

View File

@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import {
buildPendingClarifications,
evaluateCreatorIntentReadiness,
@@ -15,8 +14,13 @@ import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
import type {
CustomWorldSessionCapability,
CustomWorldWorkSummaryCapability,
} from './runtimeCapabilities.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
function createRuntimeRepositoryStub(): CustomWorldSessionCapability &
CustomWorldWorkSummaryCapability {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
@@ -35,51 +39,9 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
};
return {
async getSnapshot(_userId) {
return null;
},
async putSnapshot(_userId, _payload) {
throw new Error('not implemented');
},
async deleteSnapshot(_userId) {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listProfileSaveArchives() {
return [];
},
async resumeProfileSaveArchive() {
return null;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},

View File

@@ -2,13 +2,17 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
import type {
CustomWorldSessionCapability,
CustomWorldWorkSummaryCapability,
} from './runtimeCapabilities.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
function createRuntimeRepositoryStub(): CustomWorldSessionCapability &
CustomWorldWorkSummaryCapability {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
@@ -27,51 +31,9 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
};
return {
async getSnapshot(_userId) {
return null;
},
async putSnapshot(_userId, _payload) {
throw new Error('not implemented');
},
async deleteSnapshot(_userId) {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listProfileSaveArchives() {
return [];
},
async resumeProfileSaveArchive() {
return null;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},

View File

@@ -2,14 +2,18 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
import type {
CustomWorldSessionCapability,
CustomWorldWorkSummaryCapability,
} from './runtimeCapabilities.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
function createRuntimeRepositoryStub(): CustomWorldSessionCapability &
CustomWorldWorkSummaryCapability {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
@@ -28,51 +32,9 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
};
return {
async getSnapshot(_userId) {
return null;
},
async putSnapshot(_userId, _payload) {
throw new Error('not implemented');
},
async deleteSnapshot(_userId) {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listProfileSaveArchives() {
return [];
},
async resumeProfileSaveArchive() {
return null;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},

View File

@@ -2,18 +2,17 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import type { CustomWorldSessionCapability } from './runtimeCapabilities.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
function createRuntimeRepositoryStub(): CustomWorldSessionCapability {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
@@ -27,51 +26,6 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
};
return {
async getSnapshot() {
return null;
},
async putSnapshot(_userId, payload) {
return payload;
},
async deleteSnapshot() {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listProfileSaveArchives() {
return [];
},
async resumeProfileSaveArchive() {
return null;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},

View File

@@ -13,7 +13,6 @@ import type {
EightAnchorContent,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import {
buildPendingClarifications,
evaluateCreatorIntentReadiness,
@@ -33,6 +32,7 @@ import {
estimateProgressPercentFromAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
import type { CustomWorldSessionCapability } from './runtimeCapabilities.js';
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
'custom-world-agent-session-';
@@ -565,7 +565,7 @@ function toSnapshot(
}
export class CustomWorldAgentSessionStore {
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
constructor(private readonly runtimeRepository: CustomWorldSessionCapability) {}
private async persist(record: CustomWorldAgentSessionRecord) {
await this.runtimeRepository.upsertCustomWorldSession(

View File

@@ -7,7 +7,7 @@ import type {
CustomWorldSessionRecord,
CustomWorldSessionStatus,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import type { CustomWorldSessionCapability } from './runtimeCapabilities.js';
export type CustomWorldSession = {
sessionId: string;
@@ -111,7 +111,7 @@ function buildClarificationQuestions(
export class CustomWorldSessionStore {
constructor(
private readonly runtimeRepository: RuntimeRepositoryPort,
private readonly runtimeRepository: CustomWorldSessionCapability,
) {}
async create(

View File

@@ -6,7 +6,6 @@ import type {
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import {
@@ -26,6 +25,7 @@ import {
buildDraftSummaryFromEightAnchorContent,
buildDraftTitleFromEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
import type { CustomWorldWorkSummaryCapability } from './runtimeCapabilities.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
@@ -171,7 +171,7 @@ function isLibraryEntry(
export async function listCustomWorldWorkSummaries(
userId: string,
dependencies: {
runtimeRepository: RuntimeRepositoryPort;
runtimeRepository: CustomWorldWorkSummaryCapability;
customWorldAgentSessions: CustomWorldAgentSessionStore;
},
) {

View File

@@ -0,0 +1,53 @@
import type { CustomWorldProfileRecord, CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type {
RuntimeRepositoryPort,
SavedSnapshot,
} from '../repositories/runtimeRepository.js';
export type RuntimeSnapshotCapability = Pick<
RuntimeRepositoryPort,
'getSnapshot' | 'putSnapshot'
>;
export type CustomWorldProfileCapability = Pick<
RuntimeRepositoryPort,
'listCustomWorldProfiles'
>;
export type CustomWorldSessionCapability = {
listCustomWorldSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
getCustomWorldSession(
userId: string,
sessionId: string,
): Promise<CustomWorldSessionRecord | null>;
upsertCustomWorldSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
): Promise<CustomWorldSessionRecord>;
};
export type RuntimeStoryCapability = RuntimeSnapshotCapability;
export type CustomWorldWorkSummaryCapability = CustomWorldProfileCapability;
export function createRuntimeSnapshotCapability(
runtimeRepository: RuntimeRepositoryPort,
): RuntimeSnapshotCapability {
return runtimeRepository;
}
export function createCustomWorldSessionCapability(
runtimeRepository: RuntimeRepositoryPort,
): CustomWorldSessionCapability {
return runtimeRepository;
}
export function createCustomWorldProfileCapability(
runtimeRepository: RuntimeRepositoryPort,
): CustomWorldProfileCapability {
return runtimeRepository;
}
export type RuntimeStorySnapshot = SavedSnapshot;
export type RuntimeCustomWorldProfile = CustomWorldProfileRecord;

View File

@@ -39,6 +39,10 @@ function createAliyunSmsConfig(): AppConfig {
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
} as AppConfig;
}

View File

@@ -4,6 +4,7 @@ declare global {
requestId: string;
requestStartedAt: number;
userId?: string;
runtimeStoryAuthMode?: 'jwt' | 'spacetime';
auth?: {
userId: string;
tokenVersion: number;

View File

@@ -9,7 +9,7 @@ import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
getStoredAccessToken: vi.fn(),
getStoredSpacetimeToken: vi.fn(),
ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
@@ -21,7 +21,7 @@ const authMocks = vi.hoisted(() => ({
vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
getStoredAccessToken: authMocks.getStoredAccessToken,
getStoredSpacetimeToken: authMocks.getStoredSpacetimeToken,
}));
vi.mock('../../services/authService', () => ({
@@ -89,7 +89,7 @@ const mockUser: AuthUser = {
beforeEach(() => {
vi.clearAllMocks();
authMocks.getStoredAccessToken.mockReturnValue(null);
authMocks.getStoredSpacetimeToken.mockReturnValue(null);
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.getCurrentAuthUser.mockReset();
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
@@ -99,11 +99,7 @@ beforeEach(() => {
});
authMocks.startWechatLogin.mockResolvedValue(undefined);
authMocks.ensureAutoAuthUser.mockResolvedValue({
user: mockUser,
credentials: {
username: 'guest_tester',
password: 'auto_password',
},
...mockUser,
});
});
@@ -140,7 +136,7 @@ test('auth gate keeps platform content visible when phone login is available', a
});
test('auth gate renders bind phone screen for pending bind users', async () => {
authMocks.getStoredAccessToken.mockReturnValue('token');
authMocks.getStoredSpacetimeToken.mockReturnValue('token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: {
...mockUser,
@@ -160,7 +156,7 @@ test('auth gate renders bind phone screen for pending bind users', async () => {
});
test('auth gate shows recovery notice after token fallback', async () => {
authMocks.getStoredAccessToken.mockReturnValue('token');
authMocks.getStoredSpacetimeToken.mockReturnValue('token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],

View File

@@ -10,7 +10,7 @@ import {
import { useGameSettings } from '../../hooks/useGameSettings';
import {
AUTH_STATE_EVENT,
getStoredAccessToken,
getStoredSpacetimeToken,
} from '../../services/apiClient';
import {
type AuthAuditLogEntry,
@@ -170,7 +170,7 @@ export function AuthGate({ children }: AuthGateProps) {
setStatus('recovering');
try {
const { user: nextUser } = await ensureAutoAuthUser();
const nextUser = await ensureAutoAuthUser();
if (!isActive) {
return;
}
@@ -253,7 +253,7 @@ export function AuthGate({ children }: AuthGateProps) {
setShowLoginModal(true);
}
const token = getStoredAccessToken();
const token = getStoredSpacetimeToken();
if (!token) {
await resolveGuestFallback();
return;

View File

@@ -3,10 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
clearStoredAccessToken,
clearStoredSpacetimeToken,
fetchWithApiAuth,
getStoredAccessToken,
getStoredSpacetimeToken,
requestJson,
setStoredAccessToken,
setStoredSpacetimeToken,
} from './apiClient';
function createMemoryStorage() {
@@ -63,6 +66,7 @@ describe('apiClient', () => {
});
fetchMock.mockReset();
clearStoredAccessToken();
clearStoredSpacetimeToken();
});
it('attaches auth headers and clears stale tokens on unauthorized responses', async () => {
@@ -157,6 +161,19 @@ describe('apiClient', () => {
);
});
it('stores spacetime tokens independently from http access tokens', () => {
setStoredAccessToken('http-token', { emit: false });
setStoredSpacetimeToken('stdb-token', { emit: false });
expect(getStoredAccessToken()).toBe('http-token');
expect(getStoredSpacetimeToken()).toBe('stdb-token');
clearStoredSpacetimeToken({ emit: false });
expect(getStoredAccessToken()).toBe('http-token');
expect(getStoredSpacetimeToken()).toBe('');
});
it('retries transient get requests before unwrapping the response envelope', async () => {
fetchMock
.mockRejectedValueOnce(new TypeError('network unavailable'))

View File

@@ -8,9 +8,8 @@ import {
unwrapApiResponse,
} from '../../packages/shared/src/http';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
const HTTP_ACCESS_TOKEN_KEY = 'genarrative.auth.http-access-token.v1';
const SPACETIME_TOKEN_KEY = 'genarrative.auth.spacetime-token.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
@@ -332,15 +331,16 @@ function emitAuthStateChange() {
}
}
export function getStoredAccessToken() {
function readStoredToken(storageKey: string) {
if (!canUseLocalStorage()) {
return '';
}
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
return window.localStorage.getItem(storageKey)?.trim() || '';
}
export function setStoredAccessToken(
function writeStoredToken(
storageKey: string,
token: string,
options: {
emit?: boolean;
@@ -352,16 +352,17 @@ export function setStoredAccessToken(
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
window.localStorage.setItem(storageKey, nextToken);
} else {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(storageKey);
}
if (options.emit !== false) {
emitAuthStateChange();
}
}
export function clearStoredAccessToken(
function removeStoredToken(
storageKey: string,
options: {
emit?: boolean;
} = {},
@@ -370,50 +371,52 @@ export function clearStoredAccessToken(
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(storageKey);
if (options.emit !== false) {
emitAuthStateChange();
}
}
export function getStoredAutoAuthCredentials() {
if (!canUseLocalStorage()) {
return null;
}
const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || '';
const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || '';
if (!username || !password) {
return null;
}
return {
username,
password,
};
export function getStoredAccessToken() {
return readStoredToken(HTTP_ACCESS_TOKEN_KEY);
}
export function setStoredAutoAuthCredentials(credentials: {
username: string;
password: string;
}) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim());
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
export function setStoredAccessToken(
token: string,
options: {
emit?: boolean;
} = {},
) {
writeStoredToken(HTTP_ACCESS_TOKEN_KEY, token, options);
}
export function clearStoredAutoAuthCredentials() {
if (!canUseLocalStorage()) {
return;
}
export function clearStoredAccessToken(
options: {
emit?: boolean;
} = {},
) {
removeStoredToken(HTTP_ACCESS_TOKEN_KEY, options);
}
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
emitAuthStateChange();
export function getStoredSpacetimeToken() {
return readStoredToken(SPACETIME_TOKEN_KEY);
}
export function setStoredSpacetimeToken(
token: string,
options: {
emit?: boolean;
} = {},
) {
writeStoredToken(SPACETIME_TOKEN_KEY, token, options);
}
export function clearStoredSpacetimeToken(
options: {
emit?: boolean;
} = {},
) {
removeStoredToken(SPACETIME_TOKEN_KEY, options);
}
function withAuthorizationHeaders(

View File

@@ -2,12 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
setStoredAccessToken,
clearStoredSpacetimeToken,
setStoredSpacetimeToken,
} from './apiClient';
import {
createAutoAuthCredentials,
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
@@ -184,15 +182,7 @@ describe('authService with SpacetimeDB', () => {
spacetimeMocks.ensureSpacetimeConnection.mockReset();
spacetimeMocks.disconnectSpacetimeConnection.mockReset();
clearStoredAccessToken();
clearStoredAutoAuthCredentials();
});
it('creates credentials that match current guest username/password constraints', () => {
const credentials = createAutoAuthCredentials();
expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u);
clearStoredSpacetimeToken();
});
it('extracts captcha challenge details from api errors', () => {
@@ -250,7 +240,7 @@ describe('authService with SpacetimeDB', () => {
it('falls back to anonymous auth when stored token connection stalls', async () => {
vi.useFakeTimers();
setStoredAccessToken('expired-token', { emit: false });
setStoredSpacetimeToken('expired-token', { emit: false });
const stalledConnection = new Promise<never>(() => {});
spacetimeMocks.ensureSpacetimeConnection
@@ -278,7 +268,7 @@ describe('authService with SpacetimeDB', () => {
message: '登录已过期,已切换为匿名账号。',
});
expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalled();
expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBeNull();
expect(window.localStorage.getItem('genarrative.auth.spacetime-token.v1')).toBeNull();
} finally {
vi.useRealTimers();
}

View File

@@ -33,19 +33,13 @@ import {
} from '../spacetime/mappers';
import {
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
getStoredAccessToken,
clearStoredSpacetimeToken,
getStoredSpacetimeToken,
} from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
export type AutoAuthCredentials = {
username: string;
password: string;
};
export type AuthSessionSnapshot = {
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
availableLoginMethods: AuthLoginMethod[];
@@ -65,30 +59,10 @@ export type ConsumedAuthCallback = {
error: string | null;
};
let pendingAutoAuthUser: Promise<{
user: AuthUser;
credentials: AutoAuthCredentials;
}> | null = null;
let pendingAutoAuthUser: Promise<AuthUser> | null = null;
const TOKEN_RECOVERY_TIMEOUT_MS = 3500;
function buildRandomSegment(length: number) {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
const cryptoApi = globalThis.crypto;
if (!cryptoApi?.getRandomValues) {
return Array.from(
{ length },
() => alphabet[Math.floor(Math.random() * alphabet.length)],
).join('');
}
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(
'',
);
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
window.setTimeout(resolve, ms);
@@ -176,7 +150,7 @@ async function readCurrentSessionWithConnectionTimeout(timeoutMs: number | null)
}
async function readCurrentSessionWithRetry() {
const hasStoredToken = Boolean(getStoredAccessToken());
const hasStoredToken = Boolean(getStoredSpacetimeToken());
try {
const session = await readCurrentSessionWithConnectionTimeout(
@@ -192,7 +166,7 @@ async function readCurrentSessionWithRetry() {
}
disconnectSpacetimeConnection();
clearStoredAccessToken({ emit: false });
clearStoredSpacetimeToken({ emit: false });
const session = await readCurrentSessionFromConnection();
return {
...session,
@@ -255,17 +229,9 @@ export function getCaptchaChallengeFromError(
return null;
}
export function createAutoAuthCredentials(): AutoAuthCredentials {
return {
username: `guest_${buildRandomSegment(12)}`,
password: `auto_${buildRandomSegment(24)}_${buildRandomSegment(8)}`,
};
}
export function clearAuthSession() {
disconnectSpacetimeConnection();
clearStoredAccessToken();
clearStoredAutoAuthCredentials();
clearStoredSpacetimeToken();
}
export async function sendPhoneLoginCode(
@@ -346,20 +312,9 @@ export async function authEntry(_username: string, _password: string) {
return session.user;
}
export async function authEntryWithStoredCredentials(
credentials: AutoAuthCredentials,
) {
const user = await authEntry(credentials.username, credentials.password);
return user;
}
export async function ensureAutoAuthUser() {
pendingAutoAuthUser ??= (async () => {
const user = await authEntry('guest', 'guest');
return {
user,
credentials: createAutoAuthCredentials(),
};
return authEntry('guest', 'guest');
})();
try {
@@ -460,7 +415,6 @@ export async function liftAuthRiskBlock(_scopeType: 'phone' | 'ip') {
export async function logoutAuthUser() {
disconnectSpacetimeConnection({ clearToken: true });
clearStoredAutoAuthCredentials();
return {
ok: true,
} satisfies LogoutResponse;
@@ -475,7 +429,6 @@ export async function logoutAllAuthSessions() {
throw new Error(result.message || '退出全部设备失败');
}
disconnectSpacetimeConnection({ clearToken: true });
clearStoredAutoAuthCredentials();
return {
ok: true,
} satisfies AuthLogoutAllResponse;

View File

@@ -10,46 +10,86 @@ vi.mock('./apiClient', async () => {
return {
...actual,
requestJson: requestJsonMock,
getStoredAccessToken: vi.fn(() => ''),
getStoredSpacetimeToken: vi.fn(() => ''),
};
});
import { AnimationState } from '../types';
import {
getStoredAccessToken,
getStoredSpacetimeToken,
} from './apiClient';
import {
buildStoryMomentFromRuntimeOptions,
getRuntimeClientVersion,
getRuntimeStoryState,
getRuntimeSessionId,
isServerRuntimeFunctionId,
isTask5RuntimeFunctionId,
resetRuntimeStoryTransport,
resolveRuntimeStoryAction,
resolveRuntimeStoryMoment,
setRuntimeStoryTransport,
shouldUseServerRuntimeOptions,
} from './runtimeStoryService';
describe('runtimeStoryService', () => {
beforeEach(() => {
requestJsonMock.mockReset();
resetRuntimeStoryTransport();
vi.mocked(getStoredAccessToken).mockReturnValue('');
vi.mocked(getStoredSpacetimeToken).mockReturnValue('');
});
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',
@@ -78,30 +118,46 @@ describe('runtimeStoryService', () => {
}),
}),
'执行运行时动作失败',
expect.any(Object),
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
it('prefers spacetime token auth headers for runtime story compatibility bridge', async () => {
vi.mocked(getStoredAccessToken).mockReturnValue('legacy-http-token');
vi.mocked(getStoredSpacetimeToken).mockReturnValue('stdb-token');
requestJsonMock.mockResolvedValue(createMockRuntimeResponse());
await getRuntimeStoryState('runtime-main');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/state/runtime-main',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer stdb-token',
'x-genarrative-runtime-story-auth': 'spacetime-token',
}),
}),
'读取运行时故事状态失败',
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
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 +192,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

@@ -16,7 +16,12 @@ import type {
} from '../persistence/runtimeSnapshotTypes';
import type { GameState, StoryMoment, StoryOption } from '../types';
import { AnimationState } from '../types';
import { type ApiRetryOptions, requestJson } from './apiClient';
import {
getStoredAccessToken,
getStoredSpacetimeToken,
type ApiRetryOptions,
requestJson,
} from './apiClient';
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
const DEFAULT_SESSION_ID = 'runtime-main';
@@ -33,6 +38,7 @@ const TASK5_RUNTIME_FUNCTION_ID_SET = new Set<string>(
const SERVER_RUNTIME_FUNCTION_ID_SET = new Set<string>([
...SERVER_RUNTIME_FUNCTION_IDS,
]);
const RUNTIME_STORY_STDB_AUTH_HEADER = 'x-genarrative-runtime-story-auth';
export type RuntimeStoryServiceOptions = {
signal?: AbortSignal;
@@ -45,6 +51,48 @@ 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 withRuntimeStoryAuthHeaders(headers?: HeadersInit) {
const nextHeaders =
headers instanceof Headers
? Object.fromEntries(headers.entries())
: Array.isArray(headers)
? Object.fromEntries(headers)
: { ...(headers ?? {}) };
const httpAccessToken = getStoredAccessToken();
const spacetimeToken = getStoredSpacetimeToken();
const bearerToken = spacetimeToken || httpAccessToken;
if (bearerToken) {
nextHeaders.Authorization = `Bearer ${bearerToken}`;
}
if (spacetimeToken) {
nextHeaders[RUNTIME_STORY_STDB_AUTH_HEADER] = 'spacetime-token';
} else if (httpAccessToken) {
nextHeaders[RUNTIME_STORY_STDB_AUTH_HEADER] = 'http-access-token';
}
return nextHeaders;
}
function requestRuntimeStoryJson<T>(
path: string,
init: RequestInit,
@@ -56,12 +104,78 @@ function requestRuntimeStoryJson<T>(
{
...init,
signal: options.signal,
headers: withRuntimeStoryAuthHeaders(init.headers),
},
fallbackMessage,
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
{
retry: options.retry ?? RUNTIME_STORY_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}
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 +283,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) {

View File

@@ -1,6 +1,11 @@
import type { Identity } from 'spacetimedb';
import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
import {
AUTH_STATE_EVENT,
clearStoredSpacetimeToken,
getStoredSpacetimeToken,
setStoredSpacetimeToken,
} from '../services/apiClient';
import { DbConnection } from './generated';
const DEFAULT_SPACETIME_URI = 'wss://maincloud.spacetimedb.com';
@@ -172,7 +177,7 @@ export function disconnectSpacetimeConnection(options: { clearToken?: boolean }
resetReadyState();
currentConnection?.disconnect();
if (options.clearToken) {
clearStoredAccessToken({ emit: false });
clearStoredSpacetimeToken({ emit: false });
}
}
@@ -181,10 +186,10 @@ export function buildSpacetimeConnection() {
.withUri(resolveSpacetimeUri())
.withDatabaseName(resolveDatabaseName())
.withLightMode(true)
.withToken(getStoredAccessToken() || undefined)
.withToken(getStoredSpacetimeToken() || undefined)
.onConnect((nextConnection, _identity, token) => {
activeConnection = nextConnection;
setStoredAccessToken(token, { emit: false });
setStoredSpacetimeToken(token, { emit: false });
installConnectionCallbacks(nextConnection);
if (hasActiveSubscription) {
resolveReady?.(nextConnection);