1
This commit is contained in:
@@ -30,9 +30,6 @@ GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3001"
|
||||
GENARRATIVE_SPACETIME_DATABASE="genarrative-dev"
|
||||
GENARRATIVE_SPACETIME_POOL_SIZE="4"
|
||||
|
||||
# Local Caddy upstream target used for dist-based testing.
|
||||
CADDY_API_UPSTREAM="http://127.0.0.1:3100"
|
||||
|
||||
# Editor and asset tool APIs. Defaults are enabled outside production and
|
||||
# disabled in production unless explicitly enabled.
|
||||
EDITOR_API_ENABLED="true"
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
## 1. 测试体系
|
||||
|
||||
- [x] 为 Axum handler 补接口测试(现阶段以既有 `api-server` handler 测试编译门禁 + M7 preflight 固化;新增接口测试继续按主链补齐)
|
||||
- [x] 为 Axum handler 补接口测试(现阶段以既有 `api-server` handler 测试编译门禁 + `server-rs/scripts/check.ps1` 固化;新增接口测试继续按主链补齐)
|
||||
- [x] 为 SpacetimeDB reducer 补规则测试(现阶段以 `cargo check -p spacetime-module` 作为 schema/reducer/procedure 最小门禁;真实数据库规则回归继续由本地 publish smoke 承接)
|
||||
- [x] 为 view / projection 补数据一致性测试(现阶段以 `shared-contracts` contract 回归与 SpacetimeDB schema check 固化投影字段门禁)
|
||||
- [x] 为 auth 主链补集成测试(现有 `shared-contracts` 与 `api-server` 鉴权 handler 测试已纳入 M7 preflight 入口)
|
||||
- [x] 为 runtime snapshot 主链补集成测试(现有 runtime contract 回归已纳入 M7 preflight 入口)
|
||||
- [x] 为 story action 主链补集成测试(现有 runtime story contract / handler 测试编译已纳入 M7 preflight 扩展验证)
|
||||
- [x] 为 custom world / agent 主链补集成测试(现阶段纳入 `api-server` 编译与 M7 preflight;真实 LLM/OSS 环境联调继续由 smoke 承接)
|
||||
- [x] 为 assets / OSS 主链补集成测试(现有 M6 OSS smoke 与 contract 测试保留,M7 preflight 固化基础门禁)
|
||||
- [x] 为兼容 contract 补回归测试(`cargo test -p shared-contracts` 已纳入 M7 preflight)
|
||||
- [x] 为 auth 主链补集成测试(现有 `shared-contracts` 与 `api-server` 鉴权 handler 测试已纳入 Rust 主线检查入口)
|
||||
- [x] 为 runtime snapshot 主链补集成测试(现有 runtime contract 回归已纳入 Rust 主线检查入口)
|
||||
- [x] 为 story action 主链补集成测试(现有 runtime story contract / handler 测试编译已纳入 Rust 主线检查入口)
|
||||
- [x] 为 custom world / agent 主链补集成测试(现阶段纳入 `api-server` 编译与 Rust 主线检查;真实 LLM/OSS 环境联调继续由 smoke 承接)
|
||||
- [x] 为 assets / OSS 主链补集成测试(现有 M6 OSS smoke 与 contract 测试保留,Rust 主线检查固化基础门禁)
|
||||
- [x] 为兼容 contract 补回归测试(`cargo test -p shared-contracts` 已纳入 Rust 主线检查)
|
||||
|
||||
## 2. 部署准备
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## 6. 阶段验收
|
||||
|
||||
- [x] 本地切流前预检通过(`server-rs/scripts/m7-preflight.ps1`)
|
||||
- [x] 本地切流前预检通过(M7 阶段性预检包装入口已归档,长期入口改为 `server-rs/scripts/check.ps1`)
|
||||
- [x] 主流程基础回归通过(`cargo check -p spacetime-module`、`cargo check -p api-server`、`cargo test -p shared-contracts`、`cargo test -p api-server --no-run`)
|
||||
- [ ] 全链路 smoke 通过
|
||||
- [ ] 主流程真实环境回归通过
|
||||
@@ -63,4 +63,4 @@
|
||||
|
||||
1. M7 已新增 [../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md),冻结本地预检、部署、灰度、双跑、回滚与结构收口口径。
|
||||
2. 本轮新增 [../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md),并落地 `scripts/dev-rust-stack.ps1`、`scripts/dev-rust-stack.sh`、`scripts/deploy-rust-remote.sh`;其中发布脚本当前语义为生成 Ubuntu release 包。
|
||||
3. 当前已通过本地 M7 preflight;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Node/Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。
|
||||
3. 当前 M7 阶段性 preflight 入口已归档;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
## 4. 工程防线
|
||||
|
||||
1. 第一批物理删除后,根目录 `package.json` 不再保留 `server-node:*`、`dev:node`、`check:server-node-freeze` 等旧入口。
|
||||
2. Vite、Caddy 与本地开发脚本默认只指向 Rust `api-server`,不再保留 Node/Rust 后端切换开关。
|
||||
2. Vite 与本地开发脚本默认只指向 Rust `api-server`,不再保留 Node/Rust 后端切换开关。
|
||||
3. 历史文档允许保留旧 `server-node` 字样,但新增工程入口、脚本、依赖、运行说明不得再指向旧 Node 后端。
|
||||
4. 若后续需要恢复旧能力,只能迁移到 `server-rs/` 对应 crate 或 Axum facade,不恢复 `server-node/` 工程目录。
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
1. 删除 `server-node/` 目录本体,旧实现只允许通过历史提交、迁移文档和已迁移到 `server-rs/` 的代码追溯。
|
||||
2. 删除旧 Node 后端专用入口:`scripts/dev-node.mjs`、`scripts/server-node-frozen.mjs`、`scripts/check-server-node-freeze.mjs`、`scripts/server-node-freeze-baseline.json`、`scripts/smoke-server-node.ts`、`scripts/smoke-same-origin-stack.ts`、`scripts/m7-api-compare.ts`、`scripts/deploy.sh`、`scripts/update.sh`、`view-llm-logs.ps1`。
|
||||
3. 根目录 `package.json` 删除 `server-node:*`、`dev:node`、`m7:api-compare` 与 `check:server-node-freeze` 等旧入口,并移除 `express`、`@types/express` 依赖。
|
||||
4. `npm run dev` 改为启动 Rust 本地栈;Vite 和 Caddy 默认只代理到 Rust `api-server`,不再保留 `GENARRATIVE_BACKEND_STACK` 的 Node/Rust 双栈切换口。
|
||||
4. `npm run dev` 改为启动 Rust 本地栈;Vite 默认只代理到 Rust `api-server`,不再保留 `GENARRATIVE_BACKEND_STACK` 的 Node/Rust 双栈切换口。
|
||||
5. 清理 `.gitignore` 中只服务 `server-node/` 的忽略规则,并同步 `README.md`、`.env.example`、`server-rs/README.md` 与 `scripts/dev-server/README.md`。
|
||||
|
||||
### 7.2 暂不处理范围
|
||||
@@ -93,3 +93,37 @@
|
||||
2. 保留审计、PRD、迁移基线中作为历史事实、旧实现来源、能力对照的 `server-node` 引用。
|
||||
3. 不大规模重写包含中文剧情、需求、审计结论的历史文档,避免把真实历史上下文抹平。
|
||||
4. 若发现某个历史文档仍指导新开发继续写 Node 后端,先把该文档改为“历史阶段口径”,再继续工程处理。
|
||||
|
||||
## 8. 开发命令与脚本复核(2026-04-26)
|
||||
|
||||
本轮按“`server-node/` 已完全移除”的状态复核当前开发入口、脚本和工程配置,确认不再保留旧 Node 后端或 Express 运行路径。
|
||||
|
||||
### 8.1 已复核范围
|
||||
|
||||
1. 根目录 `package.json` 与 `package-lock.json`。
|
||||
2. 根目录 `README.md`、`.env.example`、`.gitignore` 与 `vite.config.ts`。
|
||||
3. `scripts/`、`.github/`、`jenkins/` 与 `server-rs/` 下的已跟踪文本文件。
|
||||
|
||||
### 8.2 复核结论
|
||||
|
||||
1. `package.json` 中不存在 `server-node:*`、`dev:node`、`m7:api-compare`、`check:server-node-freeze` 等旧入口。
|
||||
2. `scripts/` 下不存在 `dev-node.mjs`、`smoke-server-node.ts`、`m7-api-compare.ts`、`smoke-same-origin-stack.ts` 等旧 Node 后端脚本。
|
||||
3. `package.json` 与 `package-lock.json` 中不存在 `express`、`@types/express`、`pg`、`postgres` 依赖。
|
||||
4. 当前开发入口继续固定为 `npm run dev`、`npm run dev:web`、`npm run api-server:maincloud` 与 Rust / SpacetimeDB 相关脚本,不恢复旧 Node 后端切换开关。
|
||||
|
||||
## 9. Caddy 本地服务入口移除(2026-04-26)
|
||||
|
||||
`serve:caddy` 仅服务旧的 dist 本地代理验证链路,不再属于当前 Rust / SpacetimeDB 主开发入口。本轮删除该入口和配套文件,避免开发命令继续暴露第二套本地服务方式。
|
||||
|
||||
### 9.1 删除范围
|
||||
|
||||
1. 根目录 `package.json` 删除 `serve:caddy`。
|
||||
2. 删除 `scripts/run-caddy-dev.mjs`。
|
||||
3. 删除 `tools/Caddyfile.dev`。
|
||||
4. `.env.example` 删除 `CADDY_API_UPSTREAM` 样例变量。
|
||||
|
||||
### 9.2 后续口径
|
||||
|
||||
1. 本地完整联调继续使用 `npm run dev`。
|
||||
2. 单独前端联调继续使用 `npm run dev:web` 并通过 Vite 代理到 Rust `api-server`。
|
||||
3. 生产包预览继续使用 Vite `preview`,不恢复 Caddy 专用开发入口。
|
||||
|
||||
@@ -240,6 +240,7 @@ function buildNpcFirstContactOptionCatalog(
|
||||
- 不能再用“某人看着你,像是在等你把话接下去”这类第三人称占位旁白充当可见对话历史首句,也不能在聊天 state 里本地硬编码一条替代台词。
|
||||
- 当玩家在场景中第一次真正撞上角色型 NPC 并进入聊天时,应直接触发一轮由 NPC 主动开口的模型回复;这一轮只生成 NPC 自己的首句与后续可选回应,不得代替玩家补写未说过的话。
|
||||
- 负好感或敌对关系不应跳过主动开口;如果玩家从 NPC 交互面板点击 `npc_chat`,且该角色尚未完成 `firstMeaningfulContactResolved`,仍要走同一条 NPC 主动开场链路。负好感只影响语气、敌对聊天指令与后续可选功能,不影响“由角色先发言”的首遇行为。
|
||||
- 好感度小于 `0` 的角色在聊天终止时不进入 `story_continue_adventure` 收束态。无论是玩家主动退出聊天,还是模型通过敌对聊天指令主动结束聊天,底部选项都固定收束为 `npc_fight` 与 `battle_escape_breakout`:按钮文案分别为“战斗”“逃跑”。点击“战斗”进入 NPC 战斗结算链路;点击“逃跑”执行现有 `battle_escape_breakout` function,完成脱离演出与后续状态更新。
|
||||
|
||||
4. 首遇状态下,不允许前两项直接变成:
|
||||
- 深背景追问
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Agent 空会话草稿可见性修正 2026-04-26
|
||||
|
||||
用户从创作中心点进 RPG 或大鱼吃小鱼工作台时,后端会立即创建 Agent session,并写入一条助手欢迎消息。但在用户尚未发送任何消息、也没有传入种子文本时,这个 session 只是临时工作区,不应进入“我的创作”草稿列表。
|
||||
|
||||
本次规则:
|
||||
|
||||
1. 只有存在用户消息、非空 seedText、真实草稿数据或已发布状态时,Agent session 才算作品草稿。
|
||||
2. 助手欢迎消息、默认 anchorPack、空 `{}` draftProfile 不算用户创作内容。
|
||||
3. 过滤必须落在后端 works 聚合层,前端创作中心只消费结果,不负责隐藏空草稿。
|
||||
4. RPG 仍保留已发布 profile 和孤立持久草稿 profile 的展示;未发布且仍有活跃 Agent session 的编译 profile 继续去重。
|
||||
|
||||
涉及入口:
|
||||
|
||||
- `server-rs/crates/spacetime-module/src/lib.rs`
|
||||
- `server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
||||
- `server-rs/crates/spacetime-module/src/big_fish/session.rs`
|
||||
|
||||
后续如果新增玩法创作 Agent,也必须复用同一判断:创建会话不等于创建草稿,作品列表只展示已经被用户实际开始编辑或已经生成结果的会话。
|
||||
@@ -29,3 +29,4 @@
|
||||
- [RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md](./RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md):记录 RPG 底稿阶段角色主形象与场景背景图并行生成约束。
|
||||
- [PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md](./PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md):记录首页 banner 背景图不能进入普通布局流的修复经验。
|
||||
- [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。
|
||||
- [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 前端首次加载慢修复记录
|
||||
|
||||
日期:`2026-04-26`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
网站启动后首次打开页面约需三分钟才出现可用界面。已确认 Vite dev server 本身可在数秒内 ready,浏览器 Network 面板中主要等待项集中在 `.tsx` 模块请求,因此本次不继续扩大 `api-server` 冷编译等待窗口,而是收口浏览器首屏 `.tsx` 冷转译与默认路由依赖图。
|
||||
|
||||
## 2. 现象与根因
|
||||
|
||||
本次排查发现三个会放大首屏等待的前端问题:
|
||||
|
||||
1. 默认路由进入 `AuthenticatedApp -> App -> RpgRuntimeShell -> PlatformEntryFlowShellImpl`,首屏虽然只显示平台首页,但入口文件静态导入了创作中心、拼图 Agent、拼图结果页、拼图运行态等非首屏阶段组件。Vite dev 首次访问时需要逐个请求并转译这些 `.tsx`,表现为浏览器长时间卡在加载 `.tsx`。
|
||||
2. `RouteImageReadyGate` 会先挂载真实业务页面但把整页 `visibility: hidden`,扫描路由 DOM 中所有 `<img>` 和 CSS 图片,等全部图片 settled 后才显示页面。图片不是本轮确认到的主等待项,但会放大 `.tsx` 冷转译后的可见延迟。
|
||||
3. Vite dev server 监听范围过宽,日志中可见 `docs/`、`scripts/`、`server-rs/` 和测试文件变更都会触发 `page reload`。后端编译、文档更新或测试文件保存会让浏览器反复全量重载,叠加 `.tsx` 冷转译后表现为“首次加载一直等”。
|
||||
|
||||
## 3. 修复口径
|
||||
|
||||
### 3.1 首屏 `.tsx` 冷转译
|
||||
|
||||
默认首页入口先做低风险依赖图收敛:
|
||||
|
||||
- `App`、运行时阶段路由、面板路由避免从 barrel 文件导入,改为直连具体实现文件或类型文件。
|
||||
- `PlatformEntryFlowShellImpl` 将拼图 Agent、拼图结果页、拼图详情页、拼图运行态、创作货架等非默认首屏组件改为 `lazy`。
|
||||
- 平台首页 Tab 保留已访问页面的挂载状态,但首访只挂载当前 Tab,避免隐藏的创作页提前触发创作中心等懒加载模块。
|
||||
- RPG 运行态画布和 overlay host 只在已经进入 RPG 世界后挂载,平台首页不再同步拉取运行态画布链路。
|
||||
- 默认 `App` 不再首屏调用 `useRpgRuntimeSession`。平台首页先挂载轻量 `PlatformEntryFlowShell`,用户选择世界、恢复存档或进入 RPG 运行态深链后,才懒加载完整 `RpgRuntimeApp` 和故事/战斗/NPC 交互 hooks。
|
||||
- 平台入口 props 移除未使用的 `gameState`,避免轻量首页为了兼容旧签名初始化完整 RPG `GameState`。
|
||||
- 平台首页资料服务直连 `rpgProfileClient`,避免经过 `services/rpg-entry/index.ts` 把同域其它 client 一并纳入冷转译链路。
|
||||
|
||||
### 3.2 首屏图片门控
|
||||
|
||||
图片门控从“等待所有图片加载完成”改为“短暂稳态等待后放行”:
|
||||
|
||||
- 页面仍先真实挂载,保留极短等待窗口,避免首帧布局剧烈闪动。
|
||||
- 达到最大阻塞时间后必须显示页面,慢图片由浏览器渐进加载,不再隐藏整页。
|
||||
- 页面已经显示后,不再因为新增图片或图片地址变化重新隐藏页面。
|
||||
- 图片预加载继续保留,用于提前触发浏览器缓存,但不得成为首屏可见的硬阻塞。
|
||||
|
||||
### 3.3 Vite 监听范围
|
||||
|
||||
Vite dev server 只对前端真实运行入口保持热更新敏感:
|
||||
|
||||
- 忽略 `docs/`、`server-rs/`、`scripts/`、`backend-rewrite-tasklist/`、`media/` 等非前端首屏运行目录。
|
||||
- 忽略 `*.test.ts(x)` / `*.spec.ts(x)`,避免测试文件保存触发页面 reload。
|
||||
- 保留 `src/` 与 `packages/shared/` 的正常变更反馈,因为它们仍是前端运行时依赖。
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
1. Vite ready 后,默认站点首屏不再一次性转译明显非首屏的拼图/玩法结果/运行态组件。
|
||||
2. 默认首页冷加载 `.tsx` 请求数量下降,创作、拼图、运行态等阶段在用户进入时再加载对应 chunk。
|
||||
3. 默认首页不再同步加载 RPG story / combat / NPC interaction 运行态 hooks;进入自定义世界或恢复存档后再加载完整运行态。
|
||||
4. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作。
|
||||
5. 修改 `docs/`、`server-rs/`、`scripts/` 或测试文件时,不再触发前端页面 reload。
|
||||
6. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。
|
||||
7. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。
|
||||
@@ -51,6 +51,19 @@
|
||||
1. `content-type`:优先使用 OSS 响应头。
|
||||
2. `cache-control`:`private, max-age=60`。
|
||||
3. `x-genarrative-asset-object-key`:回写解析后的 OSS object key,方便调试。
|
||||
4. `content-length`:成功读取 OSS 对象后按二进制体长度显式回写,避免开发代理或浏览器把已成功读取的图片误判为空响应。
|
||||
|
||||
## 3.1 成功对象响应稳定性补充
|
||||
|
||||
日期:`2026-04-26`
|
||||
|
||||
本次排查发现 `/generated-characters/storynpcs-0/visual/visual-storynpcs-0-aitask_65048e86d04f7/master.png` 在 OSS 签名 URL 下可正常返回 `200 image/png`,但 Rust 同源代理成功读取对象后曾对客户端返回空响应,Vite 开发代理进一步表现为 `500 Internal Server Error`。
|
||||
|
||||
修复口径:
|
||||
|
||||
1. 成功分支不再依赖隐式 `error_for_status()` 后继续用 builder 拼装响应,而是先判断上游状态,再用明确的 `(status, headers, bytes)` 二进制响应返回。
|
||||
2. 非 `2xx` 上游状态统一映射为 `404` 或 `502` JSON 错误,禁止把 OSS 上游异常表现成连接中断。
|
||||
3. 成功响应必须保留 `content-type`,并显式回写 `content-length`、`cache-control` 与 `x-genarrative-asset-object-key`。
|
||||
|
||||
## 4. 对象键约定
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
归档说明:截至 `2026-04-26`,Rust 迁移已完成,旧 `server-node/` 已删除,M7 阶段性预检包装入口已移除。后续长期检查统一使用 `server-rs/scripts/check.ps1`、`server-rs/scripts/smoke.ps1`、`server-rs/scripts/oss-smoke.ps1` 与 `npm run api-server:maincloud`。
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
这份文档把 `M7:联调、回归、部署与切流任务清单` 从高层勾选项细化为可直接执行的工程方案。
|
||||
@@ -34,7 +36,7 @@ M7 固定四层测试入口:
|
||||
推荐本地顺序:
|
||||
|
||||
```powershell
|
||||
.\server-rs\scripts\m7-preflight.ps1
|
||||
.\server-rs\scripts\check.ps1
|
||||
.\server-rs\scripts\smoke.ps1
|
||||
```
|
||||
|
||||
@@ -91,7 +93,7 @@ OSS / CDN / 域名方案:
|
||||
|
||||
第一批删除后不再保留 Node/Rust 对比脚本,M7 回归改为 Rust 主线 contract 验证:
|
||||
|
||||
1. `server-rs/scripts/m7-preflight.ps1` 覆盖 Rust 工作区构建、测试与关键脚本门禁。
|
||||
1. `server-rs/scripts/check.ps1` 覆盖 Rust 工作区格式、clippy、构建与测试门禁。
|
||||
2. `server-rs/scripts/smoke.ps1` 覆盖 `/healthz`、envelope 与 request id 基础 contract。
|
||||
3. `server-rs/scripts/oss-smoke.ps1` 覆盖真实 OSS 链路。
|
||||
4. 新增只读 contract 时优先补进 Rust 侧 smoke 或 handler 测试,不恢复 Node 对比脚本。
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# 拼图与大鱼吃小鱼初始创作进度归零修复 2026-04-26
|
||||
|
||||
## 背景
|
||||
|
||||
拼图与大鱼吃小鱼 Agent 新建会话时,后端会先写入欢迎消息和初始锚点草稿。此前这两个模板把欢迎消息和种子推断视为创作推进,导致新会话一进入工作区就显示非 `0%` 的创作进度。
|
||||
|
||||
这与创作工作区的统一约束冲突:新会话必须如实展示后端 session 的 `progressPercent`,初始值为 `0` 时前端数字与进度条都保持 `0%`,不能让用户误判已经完成了创作推进。
|
||||
|
||||
## 设计约束
|
||||
|
||||
1. 拼图与大鱼吃小鱼新建 Agent session 时,`progress_percent` 固定写入 `0`。
|
||||
2. 欢迎消息、种子文本和初始锚点推断只作为对话上下文与占位结构,不计入创作进度。
|
||||
3. 首次用户消息提交后,进度才允许由模型回包或后续 action 写回推进。
|
||||
4. 前端不为这两个模板额外兜底抬高初始进度,进度真相继续来自 `server-rs` 的 SpacetimeDB session。
|
||||
|
||||
## 落地点
|
||||
|
||||
1. `server-rs/crates/spacetime-module/src/big_fish/session.rs`
|
||||
- `create_big_fish_session_tx` 初始化 `progress_percent = 0`。
|
||||
2. `server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||
- `create_puzzle_agent_session_tx` 初始化 `progress_percent = 0`。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 新建大鱼吃小鱼创作工作区后,顶部创作进度显示 `0%`。
|
||||
2. 新建拼图创作工作区后,顶部创作进度显示 `0%`。
|
||||
3. 发送第一条用户消息后,进度按模型回包或后续操作正常推进。
|
||||
4. 生成草稿、生成资产、发布等后续阶段的进度值不受本次调整影响。
|
||||
@@ -7,6 +7,7 @@
|
||||
- [RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md](./RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md):记录 RPG 战斗血条安全锚点、服务端战斗回包前端短表现,以及 `battle_use_skill` 指定技能兜底结算的修复口径。
|
||||
- [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。
|
||||
- [RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md](./RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md):记录开局场景与普通场景复用同一场景展示解析服务,修复列表幕缩略图和详情幕背景预览图片不一致的问题。
|
||||
- [FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md](./FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md):记录网站启动后首次加载约三分钟的前端根因,收口 `RouteImageReadyGate` 首屏图片门控和 Vite dev server 无关文件监听范围。
|
||||
- [RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md](./RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md):记录 RPG 作品删除时报 `No such procedure` 的根因,补齐 `delete_custom_world_agent_session` 在有效 SpacetimeDB 模块入口中的导出,并要求发布后核验 Maincloud schema。
|
||||
- [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md):冻结当前后端唯一落地口径,明确新功能以 `server-rs + Axum + SpacetimeDB` 为准,旧 `server-node` / Express / PostgreSQL 与 Go 方向只允许作为迁移参考。
|
||||
- [RPG_DRAFT_GENERATION_CONTINUE_AND_ETA_FIX_2026-04-25.md](./RPG_DRAFT_GENERATION_CONTINUE_AND_ETA_FIX_2026-04-25.md):记录世界草稿生成失败/中断后进度不再误到 `100%`、主按钮改为“继续生成草稿”并复用已保存底稿续跑,以及按阶段耗时模型估算预计等待时间的修复口径。
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
"api-server:maincloud": "node scripts/api-server-maincloud.mjs",
|
||||
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
||||
"build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
||||
"serve:caddy": "node scripts/run-caddy-dev.mjs",
|
||||
"server-rs:m7:preflight": "powershell -ExecutionPolicy Bypass -File server-rs/scripts/m7-preflight.ps1",
|
||||
"build": "node scripts/build-gate.mjs",
|
||||
"build:raw": "node scripts/vite-cli.mjs build",
|
||||
"preview": "node scripts/vite-cli.mjs preview",
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
|
||||
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
|
||||
const caddyConfigPath = fileURLToPath(new URL('../tools/Caddyfile.dev', import.meta.url));
|
||||
const distRoot = fileURLToPath(new URL('../dist/', import.meta.url));
|
||||
const bundledCaddyExe = fileURLToPath(new URL('../tools/caddy.exe', import.meta.url));
|
||||
|
||||
function parseEnvContents(contents) {
|
||||
return contents
|
||||
.split(/\r?\n/u)
|
||||
.reduce((envMap, rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex < 0) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
let value = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envMap[key] = value;
|
||||
return envMap;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parseEnvContents(readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function normalizePathForCaddy(filePath) {
|
||||
return path.resolve(filePath).replace(/\\/gu, '/');
|
||||
}
|
||||
|
||||
function resolveApiUpstream(env) {
|
||||
return (
|
||||
env.CADDY_API_UPSTREAM ||
|
||||
env.GENARRATIVE_API_TARGET ||
|
||||
env.RUST_SERVER_TARGET ||
|
||||
'http://127.0.0.1:3100'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCaddyBinary() {
|
||||
if (process.platform === 'win32' && existsSync(bundledCaddyExe)) {
|
||||
return bundledCaddyExe;
|
||||
}
|
||||
|
||||
return process.platform === 'win32' ? 'caddy.exe' : 'caddy';
|
||||
}
|
||||
|
||||
const mergedEnv = {
|
||||
...readEnvFile(envExamplePath),
|
||||
...readEnvFile(envLocalPath),
|
||||
...process.env,
|
||||
};
|
||||
|
||||
if (!existsSync(path.join(distRoot, 'index.html'))) {
|
||||
console.error('[serve:caddy] dist/index.html 不存在,请先运行 npm run build:raw');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mergedEnv.CADDY_SITE_ROOT = mergedEnv.CADDY_SITE_ROOT || normalizePathForCaddy(distRoot);
|
||||
mergedEnv.CADDY_PUBLIC_ROOT = mergedEnv.CADDY_PUBLIC_ROOT || normalizePathForCaddy(path.join(repoRoot, 'public'));
|
||||
mergedEnv.CADDY_API_UPSTREAM = resolveApiUpstream(mergedEnv);
|
||||
|
||||
const caddyBinary = resolveCaddyBinary();
|
||||
|
||||
console.log('[serve:caddy] listen=:8080');
|
||||
console.log(`[serve:caddy] CADDY_SITE_ROOT=${mergedEnv.CADDY_SITE_ROOT}`);
|
||||
console.log(`[serve:caddy] CADDY_PUBLIC_ROOT=${mergedEnv.CADDY_PUBLIC_ROOT}`);
|
||||
console.log(`[serve:caddy] CADDY_API_UPSTREAM=${mergedEnv.CADDY_API_UPSTREAM}`);
|
||||
console.log(`[serve:caddy] config=${caddyConfigPath}`);
|
||||
|
||||
const caddyProcess = spawn(
|
||||
caddyBinary,
|
||||
['run', '--config', caddyConfigPath, '--adapter', 'caddyfile'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: mergedEnv,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' && !existsSync(bundledCaddyExe),
|
||||
},
|
||||
);
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
function requestShutdown(code = 0) {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
shuttingDown = true;
|
||||
|
||||
if (caddyProcess.exitCode === null) {
|
||||
caddyProcess.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (caddyProcess.exitCode === null) {
|
||||
caddyProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 2000).unref();
|
||||
}
|
||||
|
||||
if (caddyProcess.exitCode !== null) {
|
||||
process.exit(code);
|
||||
}
|
||||
}
|
||||
|
||||
caddyProcess.on('error', (error) => {
|
||||
console.error('[serve:caddy] 启动 Caddy 失败', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
caddyProcess.on('exit', (code, signal) => {
|
||||
if (!shuttingDown) {
|
||||
const resolvedExitCode = code ?? 1;
|
||||
const signalSuffix = signal ? ` (${signal})` : '';
|
||||
console.error(
|
||||
`[serve:caddy] Caddy exited with code ${resolvedExitCode}${signalSuffix}`,
|
||||
);
|
||||
process.exit(resolvedExitCode);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('[serve:caddy] received SIGINT, shutting down...');
|
||||
requestShutdown(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[serve:caddy] received SIGTERM, shutting down...');
|
||||
requestShutdown(0);
|
||||
});
|
||||
@@ -10,11 +10,11 @@
|
||||
2. `SpacetimeDB` 状态机模块
|
||||
3. `阿里云 OSS` 资产接入与应用层编排
|
||||
|
||||
该目录固定放在仓库根目录,与 `src/`、`docs/` 同级。旧 `server-node/` 已进入分批删除流程,后续只可通过历史提交或迁移文档追溯。
|
||||
该目录固定放在仓库根目录,与 `src/`、`docs/` 同级。旧 `server-node/` 已完成物理删除,后续只可通过历史提交或迁移文档追溯。
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前目录已经完成以下三十八项初始化:
|
||||
当前目录已经完成以下三十七项初始化:
|
||||
|
||||
1. 为新后端预留正式目录并把路径固定到仓库结构中。
|
||||
2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。
|
||||
@@ -52,8 +52,7 @@
|
||||
34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。
|
||||
35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。
|
||||
36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。
|
||||
37. 创建 `scripts/m7-preflight.ps1`,固定 M7 切流前 Rust 后端预检入口。
|
||||
38. 固定 Vite dev proxy 的 Rust `api-server` 默认目标与 `GENARRATIVE_RUNTIME_SERVER_TARGET` 覆盖开关。
|
||||
37. 固定 Vite dev proxy 的 Rust `api-server` 默认目标与 `GENARRATIVE_RUNTIME_SERVER_TARGET` 覆盖开关。
|
||||
|
||||
后续任务会继续在本目录内按顺序补齐:
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
||||
@@ -115,44 +114,47 @@ async fn read_legacy_generated_asset(
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.cloned();
|
||||
let bytes = upstream_response
|
||||
.error_for_status()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let mut response = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CACHE_CONTROL, CACHE_CONTROL_VALUE)
|
||||
.header(
|
||||
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
|
||||
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应头失败:{error}"),
|
||||
}))
|
||||
})?,
|
||||
);
|
||||
if let Some(content_type) = content_type {
|
||||
response = response.header(header::CONTENT_TYPE, content_type);
|
||||
if !status.is_success() {
|
||||
return Err(map_legacy_generated_upstream_status(status, object_key));
|
||||
}
|
||||
|
||||
response.body(Body::from(bytes)).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应失败:{error}"),
|
||||
let bytes = upstream_response.bytes().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
|
||||
}))
|
||||
})
|
||||
})?;
|
||||
|
||||
// 旧 generated 路径会被 <img> / <video> 直接消费,成功分支必须返回原始二进制体。
|
||||
// 这里显式组装 HeaderMap 并设置长度,避免代理层把已成功读取的 OSS 对象变成空响应。
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(CACHE_CONTROL_VALUE),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
|
||||
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应头失败:{error}"),
|
||||
}))
|
||||
})?,
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_LENGTH,
|
||||
HeaderValue::from_str(bytes.len().to_string().as_str()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源长度响应头失败:{error}"),
|
||||
}))
|
||||
})?,
|
||||
);
|
||||
if let Some(content_type) = content_type {
|
||||
headers.insert(header::CONTENT_TYPE, content_type);
|
||||
}
|
||||
|
||||
Ok((status, headers, bytes).into_response())
|
||||
}
|
||||
|
||||
fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result<String, AppError> {
|
||||
@@ -189,6 +191,25 @@ fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_legacy_generated_upstream_status(
|
||||
status: reqwest::StatusCode,
|
||||
object_key: String,
|
||||
) -> AppError {
|
||||
let mapped_status = match status {
|
||||
reqwest::StatusCode::NOT_FOUND => StatusCode::NOT_FOUND,
|
||||
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED => {
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(mapped_status).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"objectKey": object_key,
|
||||
"upstreamStatus": status.as_u16(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1971,15 +1971,25 @@ mod tests {
|
||||
|
||||
assert_eq!(point_products.len(), 6);
|
||||
assert_eq!(point_products[0].product_id, "points_60");
|
||||
assert_eq!(point_products[0].title, "60叙世币");
|
||||
assert_eq!(point_products[0].price_cents, 600);
|
||||
assert_eq!(point_products[0].bonus_points, 60);
|
||||
assert_eq!(point_products[0].description, "首充送60叙世币");
|
||||
assert_eq!(point_products[5].product_id, "points_3280");
|
||||
assert_eq!(point_products[5].price_cents, 32800);
|
||||
assert_eq!(point_products[5].bonus_points, 3280);
|
||||
assert_eq!(point_products[5].description, "首充送3280叙世币");
|
||||
assert_eq!(membership_products.len(), 3);
|
||||
assert_eq!(membership_products[0].title, "月卡");
|
||||
assert_eq!(membership_products[0].price_cents, 2800);
|
||||
assert_eq!(membership_products[2].duration_days, 365);
|
||||
|
||||
let benefits = runtime_profile_membership_benefits();
|
||||
assert!(
|
||||
benefits
|
||||
.iter()
|
||||
.any(|benefit| benefit.benefit_name == "免叙世币回合数")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -807,7 +807,12 @@ mod tests {
|
||||
json!("2026-05-25T10:00:00Z")
|
||||
);
|
||||
assert_eq!(payload["pointProducts"][0]["productId"], json!("points_60"));
|
||||
assert_eq!(payload["pointProducts"][0]["title"], json!("60叙世币"));
|
||||
assert_eq!(payload["pointProducts"][0]["priceCents"], json!(600));
|
||||
assert_eq!(
|
||||
payload["pointProducts"][0]["description"],
|
||||
json!("首充送60叙世币")
|
||||
);
|
||||
assert_eq!(payload["hasPointsRecharged"], json!(false));
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,9 @@ pub(crate) fn list_big_fish_works_tx(
|
||||
.db
|
||||
.big_fish_creation_session()
|
||||
.iter()
|
||||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||||
.filter(|row| {
|
||||
row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row)
|
||||
})
|
||||
.map(|row| build_big_fish_work_summary(ctx, &row))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
@@ -251,6 +253,24 @@ pub(crate) fn list_big_fish_works_tx(
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn should_include_big_fish_work(ctx: &ReducerContext, row: &BigFishCreationSession) -> bool {
|
||||
if big_fish_session_has_direct_work_content(row) {
|
||||
return true;
|
||||
}
|
||||
|
||||
ctx.db.big_fish_agent_message().iter().any(|message| {
|
||||
message.session_id == row.session_id
|
||||
&& matches!(message.role, BigFishAgentMessageRole::User)
|
||||
})
|
||||
}
|
||||
|
||||
fn big_fish_session_has_direct_work_content(row: &BigFishCreationSession) -> bool {
|
||||
// 助手欢迎语和默认 anchorPack 只是工作台初始状态,不应被当成草稿作品。
|
||||
!row.seed_text.trim().is_empty()
|
||||
|| row.draft_json.is_some()
|
||||
|| row.stage == BigFishCreationStage::Published
|
||||
}
|
||||
|
||||
pub(crate) fn delete_big_fish_work_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BigFishWorkDeleteInput,
|
||||
@@ -687,3 +707,53 @@ pub(crate) fn append_big_fish_system_message(
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_test_big_fish_session(
|
||||
seed_text: &str,
|
||||
draft_json: Option<&str>,
|
||||
stage: BigFishCreationStage,
|
||||
) -> BigFishCreationSession {
|
||||
BigFishCreationSession {
|
||||
session_id: "big-fish-session-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
seed_text: seed_text.to_string(),
|
||||
current_turn: 0,
|
||||
progress_percent: 20,
|
||||
stage,
|
||||
anchor_pack_json: "{}".to_string(),
|
||||
draft_json: draft_json.map(str::to_string),
|
||||
asset_coverage_json: "{}".to_string(),
|
||||
last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()),
|
||||
publish_ready: false,
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_fish_direct_work_content_ignores_empty_created_session() {
|
||||
let empty_session =
|
||||
build_test_big_fish_session("", None, BigFishCreationStage::CollectingAnchors);
|
||||
let seeded_session = build_test_big_fish_session(
|
||||
"想做深海吞噬成长",
|
||||
None,
|
||||
BigFishCreationStage::CollectingAnchors,
|
||||
);
|
||||
let drafted_session = build_test_big_fish_session(
|
||||
"",
|
||||
Some(r#"{"title":"深海吞噬"}"#),
|
||||
BigFishCreationStage::DraftReady,
|
||||
);
|
||||
let published_session =
|
||||
build_test_big_fish_session("", None, BigFishCreationStage::Published);
|
||||
|
||||
assert!(!big_fish_session_has_direct_work_content(&empty_session));
|
||||
assert!(big_fish_session_has_direct_work_content(&seeded_session));
|
||||
assert!(big_fish_session_has_direct_work_content(&drafted_session));
|
||||
assert!(big_fish_session_has_direct_work_content(&published_session));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = custom_world_profile,
|
||||
index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])),
|
||||
@@ -1457,10 +1459,14 @@ fn list_custom_world_work_snapshots(
|
||||
validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?;
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut active_agent_session_ids = HashSet::new();
|
||||
|
||||
for session in ctx.db.custom_world_agent_session().iter().filter(|row| {
|
||||
row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published
|
||||
row.owner_user_id == input.owner_user_id
|
||||
&& row.stage != RpgAgentStage::Published
|
||||
&& should_include_custom_world_agent_session_work(ctx, row)
|
||||
}) {
|
||||
active_agent_session_ids.insert(session.session_id.clone());
|
||||
let gate = build_custom_world_publish_gate_from_session(&session);
|
||||
let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref());
|
||||
let title = resolve_session_work_title(&session, draft_profile.as_ref());
|
||||
@@ -1504,6 +1510,7 @@ fn list_custom_world_work_snapshots(
|
||||
.custom_world_profile()
|
||||
.iter()
|
||||
.filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none())
|
||||
.filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids))
|
||||
{
|
||||
items.push(CustomWorldWorkSummarySnapshot {
|
||||
work_id: format!("published:{}", profile.profile_id),
|
||||
@@ -1558,6 +1565,63 @@ fn list_custom_world_work_snapshots(
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn should_include_custom_world_agent_session_work(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
) -> bool {
|
||||
if custom_world_agent_session_has_direct_work_content(session) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ctx.db.custom_world_agent_message().iter().any(|message| {
|
||||
message.session_id == session.session_id && matches!(message.role, RpgAgentMessageRole::User)
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.custom_world_draft_card()
|
||||
.iter()
|
||||
.any(|card| card.session_id == session.session_id)
|
||||
}
|
||||
|
||||
fn custom_world_agent_session_has_direct_work_content(
|
||||
session: &CustomWorldAgentSession,
|
||||
) -> bool {
|
||||
// 创建会话时写入的助手欢迎语和空 `{}` draftProfile 不算草稿内容;
|
||||
// 这里只承认用户显式输入的 seed 或已经生成出的真实草稿阶段。
|
||||
!session.seed_text.trim().is_empty()
|
||||
|| matches!(
|
||||
session.stage,
|
||||
RpgAgentStage::ObjectRefining
|
||||
| RpgAgentStage::VisualRefining
|
||||
| RpgAgentStage::LongTailReview
|
||||
| RpgAgentStage::ReadyToPublish
|
||||
| RpgAgentStage::Published
|
||||
)
|
||||
|| parse_optional_session_object(session.draft_profile_json.as_deref())
|
||||
.as_ref()
|
||||
.is_some_and(|profile| !profile.is_empty())
|
||||
}
|
||||
|
||||
fn should_include_custom_world_profile_work(
|
||||
row: &CustomWorldProfile,
|
||||
active_agent_session_ids: &HashSet<String>,
|
||||
) -> bool {
|
||||
// 已发布 profile 是正式作品;即使来源会话还存在,也必须保留独立入口。
|
||||
if row.publication_status == CustomWorldPublicationStatus::Published {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 未发布 profile 若来源于仍可继续聊天的 Agent 会话,只是同一草稿的编译产物,
|
||||
// works 里保留 agent_session 即可,避免草稿分组显示两份同名作品。
|
||||
row.source_agent_session_id
|
||||
.as_ref()
|
||||
.map_or(true, |session_id| {
|
||||
!active_agent_session_ids.contains(session_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_custom_world_agent_session_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: CustomWorldAgentSessionGetInput,
|
||||
@@ -3710,7 +3774,7 @@ fn parse_json_array_or_empty(raw: &str) -> Vec<JsonValue> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_first_payload_text(payload: &JsonMap<String, JsonValue>, array_key: &str, scalar_key: &str) -> Option<String> {
|
||||
fn read_first_payload_text(payload: &JsonMap<String, JsonValue>, array_key: &str, scalar_key: &str) -> Option<String> {
|
||||
payload.get(array_key).and_then(JsonValue::as_array).and_then(|values| values.first()).and_then(JsonValue::as_str)
|
||||
.or_else(|| payload.get(scalar_key).and_then(JsonValue::as_str))
|
||||
.map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned)
|
||||
|
||||
@@ -2930,7 +2930,9 @@ fn list_custom_world_work_snapshots(
|
||||
let mut active_agent_session_ids = HashSet::new();
|
||||
|
||||
for session in ctx.db.custom_world_agent_session().iter().filter(|row| {
|
||||
row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published
|
||||
row.owner_user_id == input.owner_user_id
|
||||
&& row.stage != RpgAgentStage::Published
|
||||
&& should_include_custom_world_agent_session_work(ctx, row)
|
||||
}) {
|
||||
active_agent_session_ids.insert(session.session_id.clone());
|
||||
let gate = build_custom_world_publish_gate_from_session(&session);
|
||||
@@ -3031,6 +3033,44 @@ fn list_custom_world_work_snapshots(
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn should_include_custom_world_agent_session_work(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
) -> bool {
|
||||
if custom_world_agent_session_has_direct_work_content(session) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ctx.db.custom_world_agent_message().iter().any(|message| {
|
||||
message.session_id == session.session_id
|
||||
&& matches!(message.role, RpgAgentMessageRole::User)
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.custom_world_draft_card()
|
||||
.iter()
|
||||
.any(|card| card.session_id == session.session_id)
|
||||
}
|
||||
|
||||
fn custom_world_agent_session_has_direct_work_content(session: &CustomWorldAgentSession) -> bool {
|
||||
// 创建会话时写入的助手欢迎语和空 `{}` draftProfile 不算草稿内容;
|
||||
// 这里只承认用户显式输入的 seed 或已经生成出的真实草稿阶段。
|
||||
!session.seed_text.trim().is_empty()
|
||||
|| matches!(
|
||||
session.stage,
|
||||
RpgAgentStage::ObjectRefining
|
||||
| RpgAgentStage::VisualRefining
|
||||
| RpgAgentStage::LongTailReview
|
||||
| RpgAgentStage::ReadyToPublish
|
||||
| RpgAgentStage::Published
|
||||
)
|
||||
|| parse_optional_session_object(session.draft_profile_json.as_deref())
|
||||
.as_ref()
|
||||
.is_some_and(|profile| !profile.is_empty())
|
||||
}
|
||||
|
||||
fn should_include_custom_world_profile_work(
|
||||
row: &CustomWorldProfile,
|
||||
active_agent_session_ids: &HashSet<String>,
|
||||
@@ -6250,25 +6290,25 @@ fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() {
|
||||
let session = CustomWorldAgentSession {
|
||||
fn build_test_custom_world_agent_session(
|
||||
seed_text: &str,
|
||||
stage: RpgAgentStage,
|
||||
draft_profile_json: Option<&str>,
|
||||
) -> CustomWorldAgentSession {
|
||||
CustomWorldAgentSession {
|
||||
session_id: "session-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
seed_text: "seed".to_string(),
|
||||
current_turn: 1,
|
||||
progress_percent: 100,
|
||||
stage: RpgAgentStage::ObjectRefining,
|
||||
seed_text: seed_text.to_string(),
|
||||
current_turn: 0,
|
||||
progress_percent: 0,
|
||||
stage,
|
||||
focus_card_id: None,
|
||||
anchor_content_json: "{}".to_string(),
|
||||
creator_intent_json: None,
|
||||
creator_intent_readiness_json: "{}".to_string(),
|
||||
anchor_pack_json: None,
|
||||
lock_state_json: None,
|
||||
draft_profile_json: Some(
|
||||
r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"#
|
||||
.to_string(),
|
||||
),
|
||||
draft_profile_json: draft_profile_json.map(str::to_string),
|
||||
last_assistant_reply: None,
|
||||
publish_gate_json: None,
|
||||
result_preview_json: None,
|
||||
@@ -6280,7 +6320,16 @@ mod tests {
|
||||
checkpoints_json: "[]".to_string(),
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() {
|
||||
let session = build_test_custom_world_agent_session(
|
||||
"seed",
|
||||
RpgAgentStage::ObjectRefining,
|
||||
Some(r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"#),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_stable_agent_draft_profile_id(&session),
|
||||
@@ -6288,6 +6337,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() {
|
||||
let empty_session =
|
||||
build_test_custom_world_agent_session("", RpgAgentStage::CollectingIntent, Some("{}"));
|
||||
let seeded_session = build_test_custom_world_agent_session(
|
||||
"想做一个海雾群岛",
|
||||
RpgAgentStage::CollectingIntent,
|
||||
Some("{}"),
|
||||
);
|
||||
let drafted_session =
|
||||
build_test_custom_world_agent_session("", RpgAgentStage::ObjectRefining, Some("{}"));
|
||||
let profile_session = build_test_custom_world_agent_session(
|
||||
"",
|
||||
RpgAgentStage::CollectingIntent,
|
||||
Some(r#"{"worldHook":"海雾会吞掉记错航线的人。"}"#),
|
||||
);
|
||||
|
||||
assert!(!custom_world_agent_session_has_direct_work_content(
|
||||
&empty_session,
|
||||
));
|
||||
assert!(custom_world_agent_session_has_direct_work_content(
|
||||
&seeded_session,
|
||||
));
|
||||
assert!(custom_world_agent_session_has_direct_work_content(
|
||||
&drafted_session,
|
||||
));
|
||||
assert!(custom_world_agent_session_has_direct_work_content(
|
||||
&profile_session,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_agent_draft_profile_candidate_requires_same_owner_active_draft_and_session() {
|
||||
let matching = CustomWorldProfile {
|
||||
|
||||
@@ -473,7 +473,8 @@ fn create_puzzle_agent_session_tx(
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
seed_text: input.seed_text.clone(),
|
||||
current_turn: 1,
|
||||
progress_percent: 18,
|
||||
// 中文注释:欢迎语和初始锚点推断不计入创作进度,新会话必须从 0% 开始。
|
||||
progress_percent: 0,
|
||||
stage: PuzzleAgentStage::CollectingAnchors,
|
||||
anchor_pack_json: serialize_json(&anchor_pack),
|
||||
draft_json: None,
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Alias("h")]
|
||||
[switch]$Help,
|
||||
[switch]$RunSmoke,
|
||||
[switch]$RunSpacetimeBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Usage {
|
||||
@(
|
||||
'Usage:',
|
||||
' ./server-rs/scripts/m7-preflight.ps1',
|
||||
' ./server-rs/scripts/m7-preflight.ps1 -RunSmoke',
|
||||
' ./server-rs/scripts/m7-preflight.ps1 -RunSpacetimeBuild',
|
||||
'',
|
||||
'Notes:',
|
||||
' 1. Run M7 cutover preflight checks for Rust backend',
|
||||
' 2. Default checks are non-destructive and do not publish or clear SpacetimeDB data',
|
||||
' 3. -RunSmoke starts a temporary api-server and verifies /healthz contract',
|
||||
' 4. -RunSpacetimeBuild requires spacetime CLI and only builds the module'
|
||||
) -join [Environment]::NewLine
|
||||
}
|
||||
|
||||
if ($Help) {
|
||||
Write-Usage
|
||||
exit 0
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$serverRsDir = Split-Path -Parent $scriptDir
|
||||
$repoRoot = Split-Path -Parent $serverRsDir
|
||||
$manifestPath = Join-Path $serverRsDir "Cargo.toml"
|
||||
$modulePath = Join-Path $serverRsDir "crates\spacetime-module"
|
||||
|
||||
if (-not (Test-Path $manifestPath)) {
|
||||
throw "Missing server-rs/Cargo.toml, cannot start M7 preflight."
|
||||
}
|
||||
|
||||
Write-Host "[m7:preflight] repo root: $repoRoot"
|
||||
Write-Host "[m7:preflight] server-rs: $serverRsDir"
|
||||
|
||||
Push-Location $serverRsDir
|
||||
try {
|
||||
Write-Host "[m7:preflight] step: cargo check -p spacetime-module"
|
||||
cargo check -p spacetime-module --manifest-path $manifestPath
|
||||
|
||||
Write-Host "[m7:preflight] step: cargo check -p api-server"
|
||||
cargo check -p api-server --manifest-path $manifestPath
|
||||
|
||||
Write-Host "[m7:preflight] step: cargo test -p shared-contracts"
|
||||
cargo test -p shared-contracts --manifest-path $manifestPath
|
||||
|
||||
if ($RunSpacetimeBuild) {
|
||||
$spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue
|
||||
if ($null -eq $spacetimeCommand) {
|
||||
throw "Missing spacetime CLI, cannot run spacetime build."
|
||||
}
|
||||
|
||||
Write-Host "[m7:preflight] step: spacetime build --debug"
|
||||
Push-Location $modulePath
|
||||
try {
|
||||
& $spacetimeCommand.Source build --debug
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($RunSmoke) {
|
||||
Write-Host "[m7:preflight] step: server-rs smoke"
|
||||
& (Join-Path $serverRsDir "scripts\smoke.ps1")
|
||||
}
|
||||
|
||||
Write-Host "[m7:preflight] all checks passed"
|
||||
125
src/App.tsx
125
src/App.tsx
@@ -1,8 +1,125 @@
|
||||
import { RpgRuntimeShell } from './components/rpg-runtime-shell';
|
||||
import { useRpgRuntimeSession } from './hooks/rpg-session';
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useAuthUi } from './components/auth/AuthUiContext';
|
||||
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
|
||||
import type { SelectionStage } from './components/platform-entry/platformEntryTypes';
|
||||
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
APP_RUNTIME_ROUTES,
|
||||
normalizeAppPath,
|
||||
pushAppHistoryPath,
|
||||
resolvePathForSelectionStage,
|
||||
resolveSelectionStageFromPath,
|
||||
} from './routing/appPageRoutes';
|
||||
import type { RpgRuntimeAppIntent } from './RpgRuntimeApp';
|
||||
import type { CustomWorldProfile } from './types';
|
||||
|
||||
const RpgRuntimeApp = lazy(async () => {
|
||||
const module = await import('./RpgRuntimeApp');
|
||||
return {
|
||||
default: module.RpgRuntimeApp,
|
||||
};
|
||||
});
|
||||
|
||||
function isRpgRuntimeRoute(pathname: string) {
|
||||
const normalizedPath = normalizeAppPath(pathname);
|
||||
return (
|
||||
normalizedPath === APP_RUNTIME_ROUTES['rpg-character-select'] ||
|
||||
normalizedPath === APP_RUNTIME_ROUTES['rpg-adventure']
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const gameShellProps = useRpgRuntimeSession();
|
||||
const authUi = useAuthUi();
|
||||
const runtimeIntentTokenRef = useRef(0);
|
||||
const [runtimeIntent, setRuntimeIntent] =
|
||||
useState<RpgRuntimeAppIntent | null>(null);
|
||||
const [isRuntimeActive, setIsRuntimeActive] = useState(() =>
|
||||
isRpgRuntimeRoute(window.location.pathname),
|
||||
);
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
|
||||
return <RpgRuntimeShell {...gameShellProps} />;
|
||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(resolvePathForSelectionStage(stage));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncStageFromHistory = () => {
|
||||
if (isRpgRuntimeRoute(window.location.pathname)) {
|
||||
setIsRuntimeActive(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRuntimeActive(false);
|
||||
setRawSelectionStage(
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', syncStageFromHistory);
|
||||
return () => window.removeEventListener('popstate', syncStageFromHistory);
|
||||
}, []);
|
||||
|
||||
const createRuntimeIntent = useCallback(
|
||||
(intent: Omit<RpgRuntimeAppIntent, 'token'>) => {
|
||||
runtimeIntentTokenRef.current += 1;
|
||||
setRuntimeIntent({
|
||||
...intent,
|
||||
token: runtimeIntentTokenRef.current,
|
||||
});
|
||||
setIsRuntimeActive(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContinueGame = useCallback(
|
||||
(snapshot?: HydratedSavedGameSnapshot | null) => {
|
||||
createRuntimeIntent({
|
||||
kind: 'snapshot',
|
||||
snapshot: snapshot ?? null,
|
||||
});
|
||||
},
|
||||
[createRuntimeIntent],
|
||||
);
|
||||
|
||||
const handleCustomWorldSelect = useCallback(
|
||||
(customWorldProfile: CustomWorldProfile) => {
|
||||
createRuntimeIntent({
|
||||
kind: 'custom-world',
|
||||
profile: customWorldProfile,
|
||||
});
|
||||
},
|
||||
[createRuntimeIntent],
|
||||
);
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
|
||||
if (isRuntimeActive) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<RpgRuntimeApp initialIntent={runtimeIntent} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-ui-shell platform-theme ${platformThemeClass} flex h-screen max-h-screen flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
|
||||
>
|
||||
<PlatformEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={() => {}}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
45
src/RpgRuntimeApp.tsx
Normal file
45
src/RpgRuntimeApp.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { RpgRuntimeShell } from './components/rpg-runtime-shell/RpgRuntimeShell';
|
||||
import { useRpgRuntimeSession } from './hooks/rpg-session/useRpgRuntimeSession';
|
||||
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from './types';
|
||||
|
||||
export type RpgRuntimeAppIntent =
|
||||
| {
|
||||
token: number;
|
||||
kind: 'custom-world';
|
||||
profile: CustomWorldProfile;
|
||||
}
|
||||
| {
|
||||
token: number;
|
||||
kind: 'snapshot';
|
||||
snapshot: HydratedSavedGameSnapshot | null;
|
||||
};
|
||||
|
||||
export function RpgRuntimeApp({
|
||||
initialIntent,
|
||||
}: {
|
||||
initialIntent: RpgRuntimeAppIntent | null;
|
||||
}) {
|
||||
const gameShellProps = useRpgRuntimeSession();
|
||||
const handledIntentTokenRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialIntent || handledIntentTokenRef.current === initialIntent.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledIntentTokenRef.current = initialIntent.token;
|
||||
if (initialIntent.kind === 'custom-world') {
|
||||
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile);
|
||||
return;
|
||||
}
|
||||
|
||||
gameShellProps.entry.handleContinueGame(initialIntent.snapshot);
|
||||
}, [gameShellProps.entry, initialIntent]);
|
||||
|
||||
return <RpgRuntimeShell {...gameShellProps} />;
|
||||
}
|
||||
|
||||
export default RpgRuntimeApp;
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
createMiniGameDraftGenerationState,
|
||||
type MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
executePuzzleAgentAction,
|
||||
@@ -81,15 +81,12 @@ import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
||||
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetailByCode,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
|
||||
import { PuzzleAgentWorkspace } from '../puzzle-agent/PuzzleAgentWorkspace';
|
||||
import { PuzzleGalleryDetailView } from '../puzzle-gallery/PuzzleGalleryDetailView';
|
||||
import { PuzzleResultView } from '../puzzle-result/PuzzleResultView';
|
||||
import { PuzzleRuntimeShell } from '../puzzle-runtime/PuzzleRuntimeShell';
|
||||
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
|
||||
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
|
||||
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
|
||||
@@ -334,6 +331,41 @@ const BigFishRuntimeShell = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const CustomWorldCreationHub = lazy(async () => {
|
||||
const module = await import('../custom-world-home/CustomWorldCreationHub');
|
||||
return {
|
||||
default: module.CustomWorldCreationHub,
|
||||
};
|
||||
});
|
||||
|
||||
const PuzzleAgentWorkspace = lazy(async () => {
|
||||
const module = await import('../puzzle-agent/PuzzleAgentWorkspace');
|
||||
return {
|
||||
default: module.PuzzleAgentWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
const PuzzleResultView = lazy(async () => {
|
||||
const module = await import('../puzzle-result/PuzzleResultView');
|
||||
return {
|
||||
default: module.PuzzleResultView,
|
||||
};
|
||||
});
|
||||
|
||||
const PuzzleGalleryDetailView = lazy(async () => {
|
||||
const module = await import('../puzzle-gallery/PuzzleGalleryDetailView');
|
||||
return {
|
||||
default: module.PuzzleGalleryDetailView,
|
||||
};
|
||||
});
|
||||
|
||||
const PuzzleRuntimeShell = lazy(async () => {
|
||||
const module = await import('../puzzle-runtime/PuzzleRuntimeShell');
|
||||
return {
|
||||
default: module.PuzzleRuntimeShell,
|
||||
};
|
||||
});
|
||||
|
||||
function LazyPanelFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
@@ -1649,97 +1681,99 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
const creationHubContent = (
|
||||
<CustomWorldCreationHub
|
||||
items={creationHubItems}
|
||||
loading={
|
||||
platformBootstrap.isLoadingPlatform ||
|
||||
isBigFishLoadingLibrary ||
|
||||
isPuzzleLoadingLibrary
|
||||
}
|
||||
error={
|
||||
platformBootstrap.isLoadingPlatform ||
|
||||
isBigFishLoadingLibrary ||
|
||||
isPuzzleLoadingLibrary
|
||||
? null
|
||||
: (platformBootstrap.platformError ??
|
||||
sessionController.agentWorkspaceRestoreError ??
|
||||
bigFishError ??
|
||||
puzzleError)
|
||||
}
|
||||
onRetry={() => {
|
||||
platformBootstrap.setPlatformError(null);
|
||||
setBigFishError(null);
|
||||
setPuzzleError(null);
|
||||
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
||||
platformBootstrap.setPlatformError(
|
||||
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
|
||||
);
|
||||
});
|
||||
void refreshBigFishShelf();
|
||||
void refreshPuzzleShelf();
|
||||
}}
|
||||
createError={
|
||||
sessionController.creationTypeError ?? bigFishError ?? puzzleError
|
||||
}
|
||||
createBusy={
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isPuzzleBusy
|
||||
}
|
||||
onCreateType={handleCreationHubCreateType}
|
||||
onOpenDraft={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleOpenCreationWork(item);
|
||||
});
|
||||
}}
|
||||
onEnterPublished={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
const matchedWork = creationHubItems.find(
|
||||
(entry) => entry.profileId === profileId,
|
||||
);
|
||||
if (!matchedWork) {
|
||||
return;
|
||||
}
|
||||
void detailNavigation.handleOpenCreationWork(matchedWork);
|
||||
});
|
||||
}}
|
||||
onDeletePublished={(item) => {
|
||||
handleDeletePublishedWork(item);
|
||||
}}
|
||||
deletingWorkId={deletingCreationWorkId}
|
||||
onExperienceRpg={(item) => {
|
||||
handleExperienceRpgWork(item);
|
||||
}}
|
||||
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
bigFishItems={bigFishWorks}
|
||||
onOpenBigFishDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void openBigFishDraft(item);
|
||||
});
|
||||
}}
|
||||
onExperienceBigFish={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void startBigFishRunFromWork(item);
|
||||
});
|
||||
}}
|
||||
onDeleteBigFish={(item) => {
|
||||
handleDeleteBigFishWork(item);
|
||||
}}
|
||||
puzzleItems={puzzleWorks}
|
||||
onOpenPuzzleDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleDraft(item);
|
||||
});
|
||||
}}
|
||||
onExperiencePuzzle={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
void startPuzzleRunFromProfile(profileId);
|
||||
});
|
||||
}}
|
||||
onDeletePuzzle={(item) => {
|
||||
handleDeletePuzzleWork(item);
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载创作中心..." />}>
|
||||
<CustomWorldCreationHub
|
||||
items={creationHubItems}
|
||||
loading={
|
||||
platformBootstrap.isLoadingPlatform ||
|
||||
isBigFishLoadingLibrary ||
|
||||
isPuzzleLoadingLibrary
|
||||
}
|
||||
error={
|
||||
platformBootstrap.isLoadingPlatform ||
|
||||
isBigFishLoadingLibrary ||
|
||||
isPuzzleLoadingLibrary
|
||||
? null
|
||||
: (platformBootstrap.platformError ??
|
||||
sessionController.agentWorkspaceRestoreError ??
|
||||
bigFishError ??
|
||||
puzzleError)
|
||||
}
|
||||
onRetry={() => {
|
||||
platformBootstrap.setPlatformError(null);
|
||||
setBigFishError(null);
|
||||
setPuzzleError(null);
|
||||
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
||||
platformBootstrap.setPlatformError(
|
||||
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
|
||||
);
|
||||
});
|
||||
void refreshBigFishShelf();
|
||||
void refreshPuzzleShelf();
|
||||
}}
|
||||
createError={
|
||||
sessionController.creationTypeError ?? bigFishError ?? puzzleError
|
||||
}
|
||||
createBusy={
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isPuzzleBusy
|
||||
}
|
||||
onCreateType={handleCreationHubCreateType}
|
||||
onOpenDraft={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleOpenCreationWork(item);
|
||||
});
|
||||
}}
|
||||
onEnterPublished={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
const matchedWork = creationHubItems.find(
|
||||
(entry) => entry.profileId === profileId,
|
||||
);
|
||||
if (!matchedWork) {
|
||||
return;
|
||||
}
|
||||
void detailNavigation.handleOpenCreationWork(matchedWork);
|
||||
});
|
||||
}}
|
||||
onDeletePublished={(item) => {
|
||||
handleDeletePublishedWork(item);
|
||||
}}
|
||||
deletingWorkId={deletingCreationWorkId}
|
||||
onExperienceRpg={(item) => {
|
||||
handleExperienceRpgWork(item);
|
||||
}}
|
||||
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
bigFishItems={bigFishWorks}
|
||||
onOpenBigFishDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void openBigFishDraft(item);
|
||||
});
|
||||
}}
|
||||
onExperienceBigFish={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void startBigFishRunFromWork(item);
|
||||
});
|
||||
}}
|
||||
onDeleteBigFish={(item) => {
|
||||
handleDeleteBigFishWork(item);
|
||||
}}
|
||||
puzzleItems={puzzleWorks}
|
||||
onOpenPuzzleDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleDraft(item);
|
||||
});
|
||||
}}
|
||||
onExperiencePuzzle={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
void startPuzzleRunFromProfile(profileId);
|
||||
});
|
||||
}}
|
||||
onDeletePuzzle={(item) => {
|
||||
handleDeletePuzzleWork(item);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -2076,21 +2110,23 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<PuzzleAgentWorkspace
|
||||
session={puzzleSession}
|
||||
activeOperation={puzzleOperation}
|
||||
streamingReplyText={streamingPuzzleReplyText}
|
||||
isStreamingReply={isStreamingPuzzleReply}
|
||||
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
|
||||
error={puzzleError}
|
||||
onBack={leavePuzzleFlow}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitPuzzleMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
|
||||
<PuzzleAgentWorkspace
|
||||
session={puzzleSession}
|
||||
activeOperation={puzzleOperation}
|
||||
streamingReplyText={streamingPuzzleReplyText}
|
||||
isStreamingReply={isStreamingPuzzleReply}
|
||||
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
|
||||
error={puzzleError}
|
||||
onBack={leavePuzzleFlow}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitPuzzleMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -2147,18 +2183,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<PuzzleResultView
|
||||
session={puzzleSession}
|
||||
author={authUi?.user ?? null}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图结果..." />}>
|
||||
<PuzzleResultView
|
||||
session={puzzleSession}
|
||||
author={authUi?.user ?? null}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -2170,31 +2208,33 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<PuzzleGalleryDetailView
|
||||
item={selectedPuzzleDetail}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
platformBootstrap.setPlatformTab(
|
||||
puzzleDetailReturnTarget?.tab ?? 'home',
|
||||
);
|
||||
setPuzzleDetailReturnTarget(null);
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onEdit={
|
||||
selectedPuzzleDetail.ownerUserId === authUi?.user?.id &&
|
||||
Boolean(selectedPuzzleDetail.sourceSessionId?.trim())
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleDraft(selectedPuzzleDetail);
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onStartGame={() => {
|
||||
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图详情..." />}>
|
||||
<PuzzleGalleryDetailView
|
||||
item={selectedPuzzleDetail}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
platformBootstrap.setPlatformTab(
|
||||
puzzleDetailReturnTarget?.tab ?? 'home',
|
||||
);
|
||||
setPuzzleDetailReturnTarget(null);
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onEdit={
|
||||
selectedPuzzleDetail.ownerUserId === authUi?.user?.id &&
|
||||
Boolean(selectedPuzzleDetail.sourceSessionId?.trim())
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleDraft(selectedPuzzleDetail);
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onStartGame={() => {
|
||||
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -2206,23 +2246,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100]"
|
||||
>
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage('puzzle-gallery-detail');
|
||||
}}
|
||||
onSwapPieces={(payload) => {
|
||||
void swapPuzzlePiecesInRun(payload);
|
||||
}}
|
||||
onDragPiece={(payload) => {
|
||||
void dragPuzzlePiece(payload);
|
||||
}}
|
||||
onAdvanceNextLevel={() => {
|
||||
void advancePuzzleLevel();
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}>
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage('puzzle-gallery-detail');
|
||||
}}
|
||||
onSwapPieces={(payload) => {
|
||||
void swapPuzzlePiecesInRun(payload);
|
||||
}}
|
||||
onDragPiece={(payload) => {
|
||||
void dragPuzzlePiece(payload);
|
||||
}}
|
||||
onAdvanceNextLevel={() => {
|
||||
void advancePuzzleLevel();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
{isPuzzleNextLevelGenerating ? (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm">
|
||||
<div className="flex max-w-[18rem] flex-col items-center gap-3 rounded-[1.5rem] border border-white/12 bg-slate-950/92 px-6 py-5 text-center text-white shadow-[0_28px_80px_rgba(0,0,0,0.35)]">
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile, GameState } from '../../types';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
@@ -34,7 +34,6 @@ export type SyncedAgentDraftResult = {
|
||||
export type PlatformEntryFlowShellProps = {
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
gameState: GameState;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
|
||||
@@ -28,8 +28,8 @@ import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClien
|
||||
import { fetchJson } from '../../editor/shared/jsonClient';
|
||||
import { useCombatFlow } from '../../hooks/useCombatFlow';
|
||||
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
|
||||
import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story';
|
||||
import { useRpgSessionBootstrap } from '../../hooks/rpg-session';
|
||||
import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story/useRpgRuntimeStory';
|
||||
import { useRpgSessionBootstrap } from '../../hooks/rpg-session/useRpgSessionBootstrap';
|
||||
import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts';
|
||||
import type { CustomWorldSceneImageResult } from '../../services/aiTypes';
|
||||
import { resolveCustomWorldCampScene } from '../../services/customWorldCamp';
|
||||
|
||||
@@ -21,7 +21,6 @@ import type { AuthUser } from '../../services/authService';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
getRpgProfileDashboard as getProfileDashboard,
|
||||
listRpgEntryWorldGallery,
|
||||
@@ -47,8 +46,10 @@ import {
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetailByCode,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
@@ -130,6 +131,7 @@ vi.mock('../../services/puzzle-gallery', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
|
||||
deleteRpgEntryWorldProfile: vi.fn(),
|
||||
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -495,7 +497,6 @@ function TestWrapper({
|
||||
<RpgEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={{} as GameState}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={onContinueGame ?? (() => {})}
|
||||
@@ -547,7 +548,7 @@ beforeEach(() => {
|
||||
savedAt: '2026-04-19T12:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {} as GameState,
|
||||
gameState: {},
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
@@ -1450,10 +1451,13 @@ test('published puzzle detail returns to the source platform tab', async () => {
|
||||
await waitFor(() => {
|
||||
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(
|
||||
within(categoryPanel).getAllByText('星桥机关').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(
|
||||
within(categoryPanel).getAllByText('星桥机关').length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await user.click(
|
||||
within(categoryPanel).getByRole('button', {
|
||||
@@ -2087,7 +2091,6 @@ test('agent draft result publishes to gallery from publish panel', async () => {
|
||||
<RpgEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={{} as GameState}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={() => {}}
|
||||
@@ -2162,7 +2165,6 @@ test('agent draft result test button enters current draft without publish gate',
|
||||
<RpgEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={{} as GameState}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={() => {}}
|
||||
@@ -2778,7 +2780,7 @@ test('save tab can resume a selected archive directly into the game', async () =
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
} as GameState,
|
||||
},
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlatformEntryFlowShell } from '../platform-entry';
|
||||
import { PlatformEntryFlowShell } from '../platform-entry/PlatformEntryFlowShell';
|
||||
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
|
||||
import type { SelectionStage } from './rpgEntryTypes';
|
||||
|
||||
|
||||
@@ -274,6 +274,7 @@ test('opens recharge modal and submits points product', async () => {
|
||||
await user.click(screen.getByText('会员充值'));
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(await screen.findByText('叙世币充值')).toBeTruthy();
|
||||
expect(await screen.findByText('60叙世币')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByText('首充送60叙世币'));
|
||||
|
||||
@@ -1306,6 +1306,9 @@ export function RpgEntryHomeView({
|
||||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
|
||||
() => new Set([activeTab]),
|
||||
);
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const featuredShelf = useMemo(
|
||||
@@ -1355,6 +1358,18 @@ export function RpgEntryHomeView({
|
||||
}
|
||||
}, [activeTab, onTabChange, visibleTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisitedTabs((currentTabs) => {
|
||||
if (currentTabs.has(activeTab)) {
|
||||
return currentTabs;
|
||||
}
|
||||
|
||||
const nextTabs = new Set(currentTabs);
|
||||
nextTabs.add(activeTab);
|
||||
return nextTabs;
|
||||
});
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryGroups.length === 0) {
|
||||
setSelectedCategoryTag(null);
|
||||
@@ -2213,11 +2228,15 @@ export function RpgEntryHomeView({
|
||||
} satisfies Record<PlatformHomeTab, ReactNode>;
|
||||
const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
|
||||
visibleTabs.includes(tab),
|
||||
).map((tab) => (
|
||||
<PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}>
|
||||
{tabContentById[tab]}
|
||||
</PlatformTabPanel>
|
||||
));
|
||||
).map((tab) => {
|
||||
const shouldMountPanel = tab === activeTab || visitedTabs.has(tab);
|
||||
|
||||
return (
|
||||
<PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}>
|
||||
{shouldMountPanel ? tabContentById[tab] : null}
|
||||
</PlatformTabPanel>
|
||||
);
|
||||
});
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
} from '../../services/rpg-entry';
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
|
||||
@@ -18,10 +18,8 @@ import type {
|
||||
import { getNineSliceStyle, TAB_ICONS, UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import {
|
||||
PanelLoadingFallback,
|
||||
type RpgAdventureStatistics,
|
||||
} from '../rpg-runtime-shell';
|
||||
import { PanelLoadingFallback } from '../rpg-runtime-shell/rpgRuntimeLoaders';
|
||||
import type { RpgAdventureStatistics } from '../rpg-runtime-shell/types';
|
||||
|
||||
const RpgAdventurePanel = lazy(async () => {
|
||||
const module = await import('./RpgAdventurePanel');
|
||||
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { RpgRuntimeCanvasStage } from './RpgRuntimeCanvasStage';
|
||||
import { RpgRuntimeStageRouter } from './RpgRuntimeStageRouter';
|
||||
import type { RpgRuntimeShellProps as RpgRuntimeShellComponentProps } from './types';
|
||||
import { useRpgRuntimeShellViewModel } from './useRpgRuntimeShellViewModel';
|
||||
|
||||
const RpgRuntimeCanvasStage = lazy(async () => {
|
||||
const module = await import('./RpgRuntimeCanvasStage');
|
||||
return {
|
||||
default: module.RpgRuntimeCanvasStage,
|
||||
};
|
||||
});
|
||||
|
||||
const RpgRuntimeOverlayHost = lazy(async () => {
|
||||
const module = await import('./RpgRuntimeOverlayHost');
|
||||
return {
|
||||
@@ -154,20 +160,22 @@ export function RpgRuntimeShell({
|
||||
backgroundRepeat: isPlatformShell ? undefined : 'repeat',
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<RpgRuntimeCanvasStage
|
||||
gameState={gameState}
|
||||
visibleGameState={visibleGameState}
|
||||
hideSelectionHero={hideSelectionHero}
|
||||
canvasCompanionRenderStates={canvasCompanionRenderStates}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
setSelectedSceneEntity={setSelectedSceneEntity}
|
||||
setIsMapOpen={setIsMapOpen}
|
||||
setSceneTransitionDurations={setSceneTransitionDurations}
|
||||
/>
|
||||
</Suspense>
|
||||
{gameState.worldType ? (
|
||||
<Suspense fallback={null}>
|
||||
<RpgRuntimeCanvasStage
|
||||
gameState={gameState}
|
||||
visibleGameState={visibleGameState}
|
||||
hideSelectionHero={hideSelectionHero}
|
||||
canvasCompanionRenderStates={canvasCompanionRenderStates}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
setSelectedSceneEntity={setSelectedSceneEntity}
|
||||
setIsMapOpen={setIsMapOpen}
|
||||
setSceneTransitionDurations={setSceneTransitionDurations}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{visibleGameState.playerCharacter && !chrome?.hidePlayerLevelBadge && (
|
||||
<div
|
||||
@@ -243,35 +251,37 @@ export function RpgRuntimeShell({
|
||||
handleSaveAndExit={handleSaveAndExit}
|
||||
/>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<RpgRuntimeOverlayHost
|
||||
gameState={gameState}
|
||||
isLoading={isLoading}
|
||||
isMapOpen={isMapOpen}
|
||||
setIsMapOpen={setIsMapOpen}
|
||||
npcUi={npcUi}
|
||||
characterChatUi={characterChatUi}
|
||||
inventoryUi={inventoryUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
overlayPanel={overlayPanel}
|
||||
closeOverlayPanel={closeOverlayPanel}
|
||||
openCampModal={openCampModal}
|
||||
openPartyMemberDetails={openPartyMemberDetails}
|
||||
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
|
||||
selectedSceneEntity={selectedSceneEntity}
|
||||
closeAdventureEntityModal={closeAdventureEntityModal}
|
||||
shouldMountCampModal={shouldMountCampModal}
|
||||
showTeamModal={showTeamModal}
|
||||
closeCampModal={closeCampModal}
|
||||
onBenchCompanion={onBenchCompanion}
|
||||
onActivateRosterCompanion={onActivateRosterCompanion}
|
||||
shouldMountMapModal={shouldMountMapModal}
|
||||
handleMapTravelToScene={handleMapTravelToScene}
|
||||
shouldMountCharacterChatModal={shouldMountCharacterChatModal}
|
||||
shouldMountNpcModals={shouldMountNpcModals}
|
||||
/>
|
||||
</Suspense>
|
||||
{gameState.worldType ? (
|
||||
<Suspense fallback={null}>
|
||||
<RpgRuntimeOverlayHost
|
||||
gameState={gameState}
|
||||
isLoading={isLoading}
|
||||
isMapOpen={isMapOpen}
|
||||
setIsMapOpen={setIsMapOpen}
|
||||
npcUi={npcUi}
|
||||
characterChatUi={characterChatUi}
|
||||
inventoryUi={inventoryUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
overlayPanel={overlayPanel}
|
||||
closeOverlayPanel={closeOverlayPanel}
|
||||
openCampModal={openCampModal}
|
||||
openPartyMemberDetails={openPartyMemberDetails}
|
||||
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
|
||||
selectedSceneEntity={selectedSceneEntity}
|
||||
closeAdventureEntityModal={closeAdventureEntityModal}
|
||||
shouldMountCampModal={shouldMountCampModal}
|
||||
showTeamModal={showTeamModal}
|
||||
closeCampModal={closeCampModal}
|
||||
onBenchCompanion={onBenchCompanion}
|
||||
onActivateRosterCompanion={onActivateRosterCompanion}
|
||||
shouldMountMapModal={shouldMountMapModal}
|
||||
handleMapTravelToScene={handleMapTravelToScene}
|
||||
shouldMountCharacterChatModal={shouldMountCharacterChatModal}
|
||||
shouldMountNpcModals={shouldMountNpcModals}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,25 +20,25 @@ import type {
|
||||
} from '../../types';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import type { SelectionStage } from '../platform-entry';
|
||||
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
|
||||
import type { RpgAdventureStatistics } from './types';
|
||||
|
||||
const RpgEntryCharacterSelectView = lazy(async () => {
|
||||
const module = await import('../rpg-entry');
|
||||
const module = await import('../rpg-entry/RpgEntryCharacterSelectView');
|
||||
return {
|
||||
default: module.RpgEntryCharacterSelectView,
|
||||
};
|
||||
});
|
||||
|
||||
const PlatformEntryFlowShell = lazy(async () => {
|
||||
const module = await import('../platform-entry');
|
||||
const module = await import('../platform-entry/PlatformEntryFlowShell');
|
||||
return {
|
||||
default: module.PlatformEntryFlowShell,
|
||||
};
|
||||
});
|
||||
|
||||
const RpgRuntimePanelRouter = lazy(async () => {
|
||||
const module = await import('../rpg-runtime-panels');
|
||||
const module = await import('../rpg-runtime-panels/RpgRuntimePanelRouter');
|
||||
return {
|
||||
default: module.RpgRuntimePanelRouter,
|
||||
};
|
||||
@@ -174,7 +174,6 @@ export function RpgRuntimeStageRouter({
|
||||
<PlatformEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={gameState}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
handleContinueGame={handleContinueGame}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '../../routing/appPageRoutes';
|
||||
import type { GameState } from '../../types';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import type { SelectionStage } from '../platform-entry';
|
||||
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
|
||||
|
||||
type OverlayPanel = 'character' | 'inventory' | null;
|
||||
|
||||
|
||||
@@ -1323,7 +1323,7 @@ describe('npcEncounterActions', () => {
|
||||
expect(lastStory.options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
actionText: '与他对战',
|
||||
actionText: '战斗',
|
||||
interaction: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
@@ -1413,7 +1413,7 @@ describe('npcEncounterActions', () => {
|
||||
expect(lastStory.options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
actionText: '与他对战',
|
||||
actionText: '战斗',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
@@ -1474,7 +1474,7 @@ describe('npcEncounterActions', () => {
|
||||
expect(lastStory.deferredOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prepares next scene act options when hostile chat ends before the final act', async () => {
|
||||
it('does not expose deferred scene act options when hostile chat ends before the final act', async () => {
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 0,
|
||||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||||
@@ -1517,20 +1517,16 @@ describe('npcEncounterActions', () => {
|
||||
).resolves.toBe(true);
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.deferredRuntimeState?.storyEngineMemory?.currentSceneActState).toMatchObject({
|
||||
currentActId: 'scene-bridge-act-2',
|
||||
currentActIndex: 1,
|
||||
completedActIds: ['scene-bridge-act-1'],
|
||||
visitedActIds: ['scene-bridge-act-1', 'scene-bridge-act-2'],
|
||||
});
|
||||
expect(lastStory.deferredOptions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
functionId: 'idle_observe_signs',
|
||||
actionText: '观察桥心的灯影',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(lastStory.options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.deferredRuntimeState).toBeUndefined();
|
||||
expect(lastStory.deferredOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reopens npc chat after battle victory with combat context and preserved negative affinity limit', () => {
|
||||
|
||||
@@ -1609,34 +1609,29 @@ export function createStoryNpcEncounterActions({
|
||||
const closingDialogue = [
|
||||
...nextDialogue,
|
||||
];
|
||||
const progressionResult = buildPostNpcChatProgressionOptions(
|
||||
encounter,
|
||||
playerCharacter,
|
||||
);
|
||||
const shouldUseHostileClosureOptions =
|
||||
shouldUseHostileNpcChatClosureOptions(
|
||||
resolvedChatDirective,
|
||||
Math.min(npcState.affinity, nextAffinity),
|
||||
);
|
||||
const progressionResult = shouldUseHostileClosureOptions
|
||||
? null
|
||||
: buildPostNpcChatProgressionOptions(encounter, playerCharacter);
|
||||
setCurrentStory({
|
||||
text: closingDialogue.map((turn) => turn.text).join('\n'),
|
||||
options: buildNpcChatClosureOptions(
|
||||
encounter,
|
||||
playerCharacter,
|
||||
resolvedChatDirective,
|
||||
nextAffinity,
|
||||
Math.min(npcState.affinity, nextAffinity),
|
||||
),
|
||||
displayMode: 'dialogue',
|
||||
dialogue: closingDialogue,
|
||||
streaming: false,
|
||||
npcAffinityEffect: latestAffinityEffect,
|
||||
deferredOptions: shouldUseHostileNpcChatClosureOptions(
|
||||
resolvedChatDirective,
|
||||
nextAffinity,
|
||||
)
|
||||
? undefined
|
||||
: progressionResult.options,
|
||||
deferredRuntimeState: shouldUseHostileNpcChatClosureOptions(
|
||||
resolvedChatDirective,
|
||||
nextAffinity,
|
||||
)
|
||||
? undefined
|
||||
: (progressionResult.deferredRuntimeState ?? undefined),
|
||||
deferredOptions: progressionResult?.options,
|
||||
deferredRuntimeState:
|
||||
progressionResult?.deferredRuntimeState ?? undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { useEffect } from 'react';
|
||||
|
||||
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
||||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell';
|
||||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
|
||||
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { useBackgroundMusic } from '../useBackgroundMusic';
|
||||
import { useCombatFlow } from '../useCombatFlow';
|
||||
import { useNpcInteractionFlow } from '../useNpcInteractionFlow';
|
||||
import { useRpgRuntimeStory } from '../rpg-runtime-story';
|
||||
import { useRpgRuntimeStory } from '../rpg-runtime-story/useRpgRuntimeStory';
|
||||
import { useRpgSessionBootstrap } from './useRpgSessionBootstrap';
|
||||
import { useRpgSessionPersistence } from './useRpgSessionPersistence';
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ export function useRpgSessionPersistence({
|
||||
|
||||
const continueSavedGame = useCallback(
|
||||
async (snapshotOverride?: HydratedSavedGameSnapshot | null) => {
|
||||
if (!authenticatedUserId) {
|
||||
if (!authenticatedUserId && !snapshotOverride) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,19 @@ test('maps running draft_foundation operation to refined generation progress ste
|
||||
expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true);
|
||||
});
|
||||
|
||||
test('calculates elapsed time from operation startedAt before local fallback', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
startedAt: '1970-01-01T00:00:01.000Z',
|
||||
},
|
||||
4_000,
|
||||
6_000,
|
||||
);
|
||||
|
||||
expect(progress?.elapsedMs).toBe(5_000);
|
||||
});
|
||||
|
||||
test('maps auto asset phases to refined generation progress steps', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
|
||||
@@ -273,6 +273,18 @@ function parseOperationUpdatedAtMs(
|
||||
return Number.isFinite(parsedMs) ? parsedMs : null;
|
||||
}
|
||||
|
||||
function parseOperationStartedAtMs(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
const rawStartedAt = operation.startedAt?.trim();
|
||||
if (!rawStartedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedMs = Date.parse(rawStartedAt);
|
||||
return Number.isFinite(parsedMs) ? parsedMs : null;
|
||||
}
|
||||
|
||||
function resolveAgentDraftFoundationStepIndex(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
@@ -410,7 +422,7 @@ export function isDraftFoundationOperationRunning(
|
||||
|
||||
export function buildAgentDraftFoundationGenerationProgress(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
startedAtMs: number | null,
|
||||
fallbackStartedAtMs: number | null,
|
||||
nowMs = Date.now(),
|
||||
): CustomWorldGenerationProgress | null {
|
||||
if (!isDraftFoundationOperation(operation)) {
|
||||
@@ -419,6 +431,8 @@ export function buildAgentDraftFoundationGenerationProgress(
|
||||
|
||||
const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation);
|
||||
const overallProgress = resolveFailedProgress(operation, activeStepIndex);
|
||||
// 中文注释:总耗时必须绑定服务端 operation 创建时间,避免刷新或前端重挂载后重新计时。
|
||||
const startedAtMs = parseOperationStartedAtMs(operation) ?? fallbackStartedAtMs;
|
||||
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
|
||||
const estimatedRemainingMs = resolveEstimatedRemainingMs(
|
||||
overallProgress,
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
/**
|
||||
* 平台入口服务通用封装。
|
||||
* 先复用既有资料看板读取逻辑,但对 `platform-entry` 暴露通用命名。
|
||||
*/
|
||||
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry';
|
||||
export { getPlatformProfileDashboard } from './platformProfileClient';
|
||||
|
||||
5
src/services/platform-entry/platformProfileClient.ts
Normal file
5
src/services/platform-entry/platformProfileClient.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台首页资料读取入口。
|
||||
* 直连 RPG profile client,避免默认首页首访经过服务桶入口触发额外模块转译。
|
||||
*/
|
||||
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry/rpgProfileClient';
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:8080 {
|
||||
root * {$CADDY_SITE_ROOT}
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy {$CADDY_API_UPSTREAM}
|
||||
}
|
||||
|
||||
@public_assets path /branding/* /character/* /generated-character-drafts/* /generated-characters/* /generated-custom-world-scenes/* /generated-qwen-sprites/* /Icons/* /Pixel* /scene_bg/* /UI/*
|
||||
handle @public_assets {
|
||||
root * {$CADDY_PUBLIC_ROOT}
|
||||
file_server
|
||||
}
|
||||
|
||||
handle /healthz {
|
||||
respond "ok" 200
|
||||
}
|
||||
|
||||
handle {
|
||||
encode gzip zstd
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,16 @@ export default defineConfig(({mode}) => {
|
||||
'**/public/generated-characters/**',
|
||||
'**/public/generated-custom-world-scenes/**',
|
||||
'**/public/generated-qwen-sprites/**',
|
||||
'**/backend-rewrite-tasklist/**',
|
||||
'**/docs/**',
|
||||
'**/jenkins/**',
|
||||
'**/media/**',
|
||||
'**/scripts/**',
|
||||
'**/server-rs/**',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
];
|
||||
const rustServerTarget =
|
||||
env.RUST_SERVER_TARGET ||
|
||||
|
||||
Reference in New Issue
Block a user