Compare commits
11 Commits
46aafce59e
...
c3bfd86b53
| Author | SHA1 | Date | |
|---|---|---|---|
| c3bfd86b53 | |||
| cdda334f62 | |||
| 9d27284a64 | |||
| 00edcfe121 | |||
| e8beb0a988 | |||
| 06a8853167 | |||
| 9f225684b5 | |||
| 0efe887fa3 | |||
| d43d9f81d0 | |||
| 6e6bb073f3 | |||
| 84d6cb7784 |
@@ -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. 相关文档
|
||||
|
||||
如需继续细看已有沉淀,可结合以下文档一起阅读:
|
||||
|
||||
|
||||
@@ -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 时的布局和交互经验。
|
||||
|
||||
@@ -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 冻结版本、热点文件编辑规则与集成窗口清单。
|
||||
|
||||
@@ -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`
|
||||
@@ -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/*` 这层兼容桥
|
||||
@@ -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)
|
||||
@@ -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 的依赖
|
||||
@@ -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
|
||||
@@ -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. 文档明确固定“能力先迁、逻辑后搬”的顺序
|
||||
80
docs/technical/TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md
Normal file
80
docs/technical/TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md
Normal 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 改动时不混入当前提交”的边界行为
|
||||
@@ -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
57
scripts/commit-task.mjs
Normal 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
136
scripts/commit-task.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,10 @@ function createTestConfig(
|
||||
refreshCookieSameSite: 'Lax',
|
||||
refreshCookiePath: '/api/auth',
|
||||
},
|
||||
spacetime: {
|
||||
uri: 'ws://127.0.0.1:3000',
|
||||
databaseName: 'genarrative-test',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
248
server-node/src/modules/story/storySpacetimeBridge.test.ts
Normal file
248
server-node/src/modules/story/storySpacetimeBridge.test.ts
Normal 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();
|
||||
});
|
||||
207
server-node/src/modules/story/storySpacetimeBridge.ts
Normal file
207
server-node/src/modules/story/storySpacetimeBridge.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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()];
|
||||
},
|
||||
|
||||
@@ -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()];
|
||||
},
|
||||
|
||||
@@ -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()];
|
||||
},
|
||||
|
||||
@@ -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()];
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
) {
|
||||
|
||||
53
server-node/src/services/runtimeCapabilities.ts
Normal file
53
server-node/src/services/runtimeCapabilities.ts
Normal 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;
|
||||
@@ -39,6 +39,10 @@ function createAliyunSmsConfig(): AppConfig {
|
||||
blockPhoneDurationMinutes: 30,
|
||||
blockIpDurationMinutes: 30,
|
||||
},
|
||||
spacetime: {
|
||||
uri: 'ws://127.0.0.1:3000',
|
||||
databaseName: 'genarrative-test',
|
||||
},
|
||||
} as AppConfig;
|
||||
}
|
||||
|
||||
|
||||
1
server-node/src/types/express.d.ts
vendored
1
server-node/src/types/express.d.ts
vendored
@@ -4,6 +4,7 @@ declare global {
|
||||
requestId: string;
|
||||
requestStartedAt: number;
|
||||
userId?: string;
|
||||
runtimeStoryAuthMode?: 'jwt' | 'spacetime';
|
||||
auth?: {
|
||||
userId: string;
|
||||
tokenVersion: number;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '服务端返回的新故事',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user