This commit is contained in:
2026-04-25 22:19:04 +08:00
parent 2ebfd1cf55
commit 8404081d7b
149 changed files with 10508 additions and 2732 deletions

View File

@@ -52,4 +52,4 @@ GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com" GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com"
GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr" GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr"
GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUsyN05YUjBaQkRUVEVCNlFQQjFXNzU2MiIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NzAzNjI2MSwiZXhwIjoxODQwMTA4MjYxfQ.XosLKR-y85dv4yRN-INJMNSWhz4VtXaDvypvyzNAwFdmLMC3IKG6HfmSBHwLOjO3JVkQBTKodivYe6_sDOFNsCMGdP5nwMubYlmxWaOk41WBldd3JFA7ag8OpikYBkWp-4n59c8wLn-LWiOUWBw_g5vaCbzZs3pP51amw9o-DUEog53fGjoS3ij8oVIg_8AZDxoSmqVvT6K-2wIpstj7bM674nks-qbhMuAjdM9l1HURw_uip5iWEIB4hQZtzlOtHe49wvhN3lvgoM9r4YJS7emDDBwFTopQF-cSPKyh_tFfpH7jUIb3RiqGutQV37c3veNnUVxmYNvqB561eR4mQw" GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUtQRzVXQlhTTjVCRDAyTjBNSlNONFJCNyIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NjUxMjAyMiwiZXhwIjoxODM5NTg0MDIyfQ.UguSQDajalekrqs9oiUqLZiWjWK7VTgMQfdLVOhBQZpKX0VYUhNMSok9oBMJ4X655_NxV5TUUXZ4ON4HSJZrMMPc9aZyhS1b3i36vqI_zMPwLrAgfb1MqY5o0wNFl6Y0m0UQ3nsu7ZYxmxxgzF4My7So0Pv75QfXFS3-Uq1-QyO7lCxxgQ6vySbP_PEr7FZJsdPkNvAfP7mTaUh0yaV6SI7jXBsZ_mfdcWtElCNuvR9J3hvfAbx1qyeTgCJtgH4kNhiEOEIAYEFMEQkXd4rdLszmEgtlFubYZPsbMgqeZKx73feU6eGxlYhyPiRHF4AdosIfk3x2MAm_WzOd3efXDQ"

1
.gitignore vendored
View File

@@ -26,3 +26,4 @@ temp*build*/
/public/generated-animations /public/generated-animations
/public/generated-character-drafts /public/generated-character-drafts
/public/generated-characters /public/generated-characters
/.codex-temp

28
.idea/Genarrative.iml generated
View File

@@ -1,7 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/api-server/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-ai/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-assets/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-auth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-big-fish/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-combat/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-custom-world/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-inventory/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-npc/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-progression/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-puzzle/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-quest/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-runtime-item/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-runtime-story-compat/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-runtime/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/module-story/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/platform-auth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/platform-llm/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/platform-oss/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/shared-contracts/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/shared-kernel/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/shared-logging/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/spacetime-client/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/server-rs/crates/spacetime-module/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/server-rs/target" />
</content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>

View File

@@ -0,0 +1,8 @@
{
"hash": "4121bbf9",
"configHash": "eecfdd02",
"lockfileHash": "97905bb0",
"browserHash": "5b2ac70e",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -30,6 +30,7 @@
- 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust``spacetimedb-concepts` 执行。 - 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust``spacetimedb-concepts` 执行。
- 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时`spacetimedb-typescript``spacetimedb-concepts` 执行。 - 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时`spacetimedb-typescript``spacetimedb-concepts` 执行。
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。 - 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
- 修改后端代码后,必须使用 `npm run api-server:maincloud` 自动重新运行后端,并执行相应自动测试;不要再使用旧的后端重启命令。
## 文档图谱 ## 文档图谱

View File

@@ -0,0 +1,19 @@
# 平台首页 Banner 图尺寸修复记录
更新时间:`2026-04-25`
## 问题
首页 Hero / banner 使用作品封面作为背景图。全局 `.platform-surface > *` 会把所有直接子节点重新设为 `position: relative`,导致带有 Tailwind `absolute` 类的背景图和遮罩被覆盖,图片重新进入普通文档流后可能撑高首页,影响首屏布局。
## 落地
- 修改 `src/index.css`,将 `.platform-surface > *` 收敛为 `.platform-surface > :not(.absolute)`
- 保留普通内容层的 `z-index: 1`,让文字、按钮仍稳定压在背景之上。
- 让 banner 背景图继续使用绝对定位和 `object-cover`,只在固定容器内裁切显示,不参与首页高度计算。
## 经验
- 首页、结果页、详情页的背景图都必须由固定尺寸容器承载,图片本身不要参与布局流。
- 给通用容器写层级规则时,不能无差别覆盖 `.absolute` 节点,否则会破坏所有“背景图 + 遮罩 + 内容”的结构。
- 后续若新增平台 Hero优先沿用 `platform-surface--hero`,并确认背景图节点仍是 `absolute inset-0 h-full w-full object-cover`

View File

@@ -250,13 +250,27 @@ node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0
这类项目里,后者几乎一定导致返工。 这类项目里,后者几乎一定导致返工。
## 13. 一句话总结 ## 13. 后端修改后的重启与测试
后端代码更新后统一执行:
```bash
npm run api-server:maincloud
```
执行要求:
- 该命令是后端更新后的默认重启入口,不再使用此前的后端重启命令。
- 重启后必须继续执行与本次后端改动对应的自动测试;涉及 Rust workspace 时优先跑 `server-rs` 下的检查或测试脚本。
- 若本次改动涉及 SpacetimeDB 发布、绑定生成或 Maincloud 联调,按 `spacetimedb-cli` 经验执行,并在验证记录中写清楚实际命令与结果。
## 14. 一句话总结
这个项目最重要的经验不是“做了多少页面和功能”,而是: 这个项目最重要的经验不是“做了多少页面和功能”,而是:
**必须把 AI 文本生成、本地规则、动画演出、场景状态、编辑器工具这几套系统严格分层,再通过 function 和统一状态流把它们重新接起来。** **必须把 AI 文本生成、本地规则、动画演出、场景状态、编辑器工具这几套系统严格分层,再通过 function 和统一状态流把它们重新接起来。**
## 14. 相关文档 ## 15. 相关文档
如需继续细看已有沉淀,可结合以下文档一起阅读: 如需继续细看已有沉淀,可结合以下文档一起阅读:

View File

@@ -27,3 +27,5 @@
## 近期专项记录 ## 近期专项记录
- [RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md](./RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md):记录 RPG 底稿阶段角色主形象与场景背景图并行生成约束。 - [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 发布后首页 / 分类页公开作品列表刷新链路。

View File

@@ -0,0 +1,36 @@
# RPG 发布作品广场刷新修复 2026-04-25
## 背景
已发布 RPG 作品会进入 `custom_world_profile`,并由 SpacetimeDB 模块同步到公开读模型 `custom_world_gallery_entry`。平台首页和分类页不直接读取“我的作品”,而是共同消费 `/api/runtime/custom-world-gallery` 返回的公开作品列表。
本次问题表现为:结果页显示发布完成后,作品没有立即出现在平台首页和分类页。
## 修复原则
1. `publish_world` 必须写入真实作者公开信息。
- Axum 层在执行 `publish_world` 前补充 `authorPublicUserCode``authorDisplayName`
- SpacetimeDB 模块发布时优先使用 payload 中的作者公开信息,再退回历史兜底。
2. 发布完成后必须刷新公开广场列表。
- 前端 `executePublishWorld` 等待发布 operation 完成后,同时刷新 `refreshPublishedGallery()``refreshCustomWorldWorks()`
- 首页和分类页都复用 `publishedGalleryEntries`,因此刷新 gallery 后两个页面会同步看到新发布作品。
## 多玩法公开列表补充
- 平台首页 / 分类页不是只展示 RPG 作品,也需要展示已经发布到公开接口的 Puzzle 作品。
- Puzzle 已有 `/api/runtime/puzzle/gallery` 公开接口;平台入口额外读取该接口,并把 `PuzzleWorkSummary` 映射为首页卡片模型。
- 首页 / 分类页按 `rpg:{ownerUserId}:{profileId}``puzzle:{ownerUserId}:{profileId}` 去重后按发布时间排序。
- 点击 RPG 卡片仍进入 RPG 公开详情;点击 Puzzle 卡片进入现有 Puzzle 广场详情,不复用 RPG 详情链路。
3. 历史已发布作品必须能自动补齐 gallery 投影。
- 公开列表读取 `list_custom_world_gallery_entries` 前,会扫描 `custom_world_profile` 中已发布且未删除的 profile。
- 若已发布 profile 缺少 `custom_world_gallery_entry`,或缺少公开作品码 / 作者叙世号,会先补齐公开字段并同步 gallery 投影。
- 这样旧版本发布成功但未落入广场读模型的作品,在下一次首页 / 分类页读取公开列表时会自动出现。
## 经验
- 作品发布链不能只看“我的创作”列表,必须同时检查公开读模型。
- 分类页的数据不是独立接口,修首页公开列表通常也会影响分类页。
- Agent 发布链和普通 library profile 发布链要共享公开作者字段语义,避免同一个 gallery card 在不同发布入口下字段不一致。
- 已发布 profile 与 gallery 投影不是同一张表,线上修复时要考虑历史数据补投影,不能只修新增发布路径。

View File

@@ -0,0 +1,46 @@
# 后端创作 Agent LLM Turn 公共化 2026-04-25
## 背景
RPG、大鱼吃小鱼、拼图三条创作 Agent 后端 turn 已经统一使用 `platform-llm`,但在 `api-server` 内仍重复维护以下流程:
1. 检查 `LlmClient` 是否可用。
2. 构造 `system + user` 两段消息。
3. 调用 `stream_text` 并从增量 JSON 中抽取 `replyText` 给 SSE 前端。
4. 从模型最终文本中截取并解析 JSON。
5. 把模型调用失败、JSON 解析失败映射成中文业务错误。
这些逻辑属于 turn 级基础设施,不应散落在不同玩法文件里;但各玩法的 prompt、anchor pack 解析、stage 推进、SpacetimeDB finalize 写回仍是领域逻辑,不在本轮合并。
## 目标
1. 新增 `api-server` 内部公共模块 `creation_agent_llm_turn`
2. 公共化流式 JSON turn 调用、非流式 JSON turn 调用、`replyText` 增量解析、最终 JSON 截取解析。
3. 大鱼、拼图、RPG Agent turn 复用公共调用骨架。
4. 保留各玩法原有结果结构、中文错误文案和持久化写回契约。
## 非目标
1. 不统一 RPG、大鱼、拼图的 prompt。
2. 不统一三类 anchor pack / draft profile schema。
3. 不改变 SpacetimeDB reducer/procedure 或 session 表结构。
4. 不改变前端 SSE contract。
## 落地边界
1. `server-rs/crates/api-server/src/creation_agent_llm_turn.rs`
- 提供 `stream_creation_agent_json_turn(...)`
- 提供 `request_creation_agent_json_turn(...)`
- 提供 `parse_json_response_text(...)``extract_reply_text_from_partial_json(...)`
2. `custom_world_agent_turn.rs`
- 保留 RPG 动态状态判断、八锚点解析、结果写回。
- 将正式单轮生成和状态识别的 LLM 请求改走公共模块。
3. `big_fish_agent_turn.rs` / `puzzle_agent_turn.rs`
- 将 LLM 流式请求和 JSON 解析改走公共模块。
- 继续保留玩法自己的 anchor pack 解析和 quick fill 规则。
## 验收
1. `cargo test -p api-server` 中相关 turn 单测通过。
2. `cargo check -p api-server` 不引入新的编译错误。
3. 编码检查通过。

View File

@@ -0,0 +1,55 @@
# 角色主形象 IP 审核失败兜底修复2026-04-25
## 1. 问题
自动生成草稿素材时,角色「艾瑞克」主形象连续 3 次失败,供应商返回:
```json
{
"code": "IPInfringementSuspect",
"message": "Input data is suspected of being involved in IP infringement."
}
```
这类失败不是网络抖动。原样重试会把同一份包含角色姓名、长设定和可能触发审核的专名继续提交给 DashScope容易稳定失败。
## 2. 参考日志与文档
- 用户提供的失败日志:`request_id=a18fb05d-d3be-9b9c-8d37-e0427397789e``task_id=cb768c95-13b7-4790-9f18-35a8a8761b31``task_status=FAILED``code=IPInfringementSuspect`。该日志确认失败源于供应商 IP/内容审核,而不是 OSS、SpacetimeDB 或下载链路。
- `docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md`:确认角色主形象正式模型 prompt 层由后端 prompt builder 编译,不能只改前端默认描述文本。
- `docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md`:确认当前主链已迁到 `server-rs/crates/api-server/src/custom_world_asset_prompts.rs``server-rs/crates/api-server/src/custom_world_ai.rs`,不再修改旧 `server-node`
- `docs/technical/ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`:确认角色主形象真实生成入口走 Rust `api-server`、DashScope、OSS 与资产绑定链路。
- `server-rs/crates/api-server/src/character_animation_assets.rs` 现有日志/代码经验:动作生成已经有审核失败时的安全兜底 prompt 策略,本次沿用到角色主形象。
## 3. 修复方案
1. 在角色主图 prompt 层新增安全兜底 prompt
- 不携带角色姓名、作品名、长设定原文。
- 保留职业原型,如原创近战剑士、原创法术职业冒险者。
- 明确要求不参考现有动漫、游戏、影视、小说角色,不使用可识别 IP 元素。
2. 在 DashScope 角色主形象请求层识别审核类错误:
- `IPInfringementSuspect`
- `inappropriate`
- `sensitive`
- `risk`
- 中文内容审核、疑似侵权、知识产权等关键词。
3. 首次提交遇到上述错误时,后端自动用安全兜底 prompt 再提交一次。
4. 非审核类错误仍按原错误返回不隐藏模型、网络、OSS、超时等真实问题。
5. 错误映射保留 `raw` 字段,便于后续从日志直接看到供应商原始 `code/message/task_id`
## 4. 落地文件
- `server-rs/crates/api-server/src/prompt/character_visual.rs`
- 新增 `build_fallback_moderation_safe_character_visual_prompt`
- `server-rs/crates/api-server/src/custom_world_asset_prompts.rs`
- 导出角色主图审核兜底 prompt builder。
- `server-rs/crates/api-server/src/character_visual_assets.rs`
- 角色主形象 DashScope 请求增加审核兜底提交。
- `RequestModel` 阶段结构化日志增加 `moderationFallbackApplied`
- DashScope 上游错误保留 `raw`
## 5. 验收口径
- 用户不需要手动改「艾瑞克」这个名字;后端遇到 `IPInfringementSuspect` 会自动切换到原创安全 prompt 再试一次。
- 若兜底 prompt 生成成功,后续 OSS 草稿、发布和资产绑定链路保持原样。
- 若兜底 prompt 仍失败,错误中仍能看到 DashScope 原始失败内容,方便继续定位具体触发项。

View File

@@ -0,0 +1,41 @@
# 创作 Agent Client 与平台流程 Controller 复用方案 2026-04-25
## 背景
RPG、自定义大鱼吃小鱼、拼图三类作品创作都已经采用 Agent-first 主链,但前端仍存在两类重复:
1. 三类 Agent client 都手写 `createSession / getSession / sendMessage / streamMessage / executeAction`
2. 平台入口壳层内直接维护大鱼、拼图的 session、流式消息、忙碌态、错误态与草稿恢复逻辑。
聊天 UI、SSE 解析、快捷补齐已经有共用模块,本轮只补齐 client 与平台流程编排层的复用,不改玩法领域数据结构。
## 目标
1. 新增通用 `createCreationAgentClient` 工厂统一请求、SSE POST、retry、超时与中文错误文案配置。
2. 大鱼、拼图 client 保留原导出函数名但内部先改走通用工厂RPG client 暂保留既有成熟实现,后续只有在不影响自动保存/结果预览语义时再接入。
3. 新增平台创作流程 controller hook收口轻量玩法的创建会话、恢复草稿、发送流式消息与执行 action。
4. 平台壳层只保留玩法差异动作:运行态启动、作品删除、拼图公开详情跳转等。
## 非目标
1. 不统一 RPG、大鱼、拼图的 session schema。
2. 不把大鱼或拼图强行接入 RPG 的发布门禁、自动保存、运行时协议。
3. 不改后端 SpacetimeDB 表、procedure 或现有路由。
## 落地边界
1. `src/services/creation-agent/creationAgentClientFactory.ts`
- 负责统一 HTTP/SSE client 骨架。
- 允许每个玩法配置 basePath、runtime 前缀、错误文案、返回值提取方式。
2. `src/components/platform-entry/usePlatformCreationAgentFlowController.ts`
- 负责通用前端流程态session、busy、error、streaming、open、restore、submit、execute。
- 通过 adapter 接收玩法差异client、stage、compile action、session 是否已有 draft。
3. `PlatformEntryFlowShellImpl.tsx`
- 大鱼与拼图切到 controller。
- 保留 RPG 现有成熟 controller不在本轮合并避免把 RPG 复杂自动保存链拉进轻量玩法抽象。
## 验收
1. 已接入工厂的 Agent client 公开 API 不变RPG client 公开 API 与既有实现都不变。
2. 大鱼、拼图仍能从平台入口新建、恢复草稿、发送消息、生成结果页。
3. 现有定向测试通过,编码检查通过。

View File

@@ -0,0 +1,76 @@
# Agent 创作页文档输入上传方案
更新时间:`2026-04-25`
## 1. 目标
Agent 创作页需要支持用户上传文档,并把文档内容解析成当前输入框里的文本,让用户可以继续编辑后再发送给 Agent。
本次只解决“文档作为输入内容”的轻量闭环,不把文件作为资产入库,也不改变 Agent 会话、消息、草稿生成的后端主链。
## 2. 职责边界
1. 前端 `CreationAgentWorkspace` 负责展示上传入口、先做文件格式与大小预检、读取浏览器选择的文件为 base64、调用解析接口、把返回文本追加到输入框。
2. Rust `api-server` 负责文件类型、大小、编码与文本抽取规则,前端不直接承载文档解析逻辑。
3. 解析完成后仍走现有 `onSubmitText` 消息提交,不新增 Agent 消息结构。
4. 该能力覆盖所有复用 `CreationAgentWorkspace` 的 Agent 创作页RPG / 自定义世界、拼图、大鱼吃小鱼。
## 3. 首版支持范围
支持扩展名:
1. `.txt`
2. `.md`
3. `.markdown`
4. `.csv`
5. `.json`
大小限制:单文件最大 `256 KiB`
编码限制:首版按 UTF-8 文本处理。若文件不是 UTF-8服务端返回 `400`,由前端展示错误。
暂不支持 `.pdf` / `.doc` / `.docx` 的二进制结构解析;后续扩展时只需要在 Rust 解析接口内部补 extractor不改变前端输入框接入方式。
## 4. 接口设计
路径:`POST /api/runtime/creation-agent/document-inputs/parse`
鉴权Bearer 登录态。
请求:
```json
{
"fileName": "世界设定.md",
"contentType": "text/markdown",
"contentBase64": "..."
}
```
响应:
```json
{
"document": {
"fileName": "世界设定.md",
"contentType": "text/markdown",
"sizeBytes": 128,
"text": "..."
}
}
```
## 5. UI 规则
1. 输入框左侧新增文件图标按钮,使用图标与 hover title 表达,不在页面铺功能说明文本。
2. 上传前先在浏览器侧拒绝不支持格式、空文件和超限文件,避免无意义读取大文件。
3. 上传处理中禁用按钮和发送按钮,避免同一输入框状态并发写入。
4. 文件解析成功后追加到当前草稿文本后方,若当前草稿非空则用两个换行分隔。
5. 错误展示复用输入区附近的短错误条,不弹独立面板。
## 6. 验收标准
1. 上传 `.txt``.md` 后,输入框出现文档内容。
2. 输入框已有内容时,解析文本追加在末尾,并用空行分隔。
3. 上传不支持格式或超限文件时,页面展示中文错误,不发送 Agent 消息。
4. 定向测试覆盖解析客户端、Rust 接口和 `CreationAgentWorkspace` 上传交互。

View File

@@ -0,0 +1,24 @@
# 创作 Agent 发送后即时等待点动画修复
日期:`2026-04-25`
## 1. 背景
统一创作 Agent 工作区已经用 `CreationAgentWorkspace` 承载 RPG / Custom World、大鱼吃小鱼、拼图三条聊天链路。旧展示条件只有在 `streamingReplyText` 已经收到文本时才追加临时 assistant 气泡,因此用户发送消息后到首个 SSE token 到达前,聊天区会短暂没有任何等待反馈。
## 2. 设计
本轮只改前端表现层不改变后端会话、SSE、消息落库或推荐回复语义
1. `CreationAgentWorkspace``isStreamingReply` 作为临时 assistant 气泡的展示条件。
2.`streamingReplyText` 为空时,临时气泡内部展示三个脉冲点。
3. 当首个流式文本到达后,同一个临时气泡切换为文本内容与光标。
4. 最终 session 回写后,临时气泡消失,由正式 assistant 消息接管原位置。
5. 大鱼吃小鱼与拼图适配层显式透传 `isStreamingReply`,不再用 `Boolean(streamingReplyText)` 推断等待状态。
## 3. 验收
1. 用户发送消息后,聊天列表底部立即出现三点等待动画。
2. 首个 SSE 文本到达前等待动画持续存在。
3. 流式文本到达后等待动画切换为正常流式回复。
4. `CreationAgentWorkspace` 定向测试覆盖空文本流式等待态。

View File

@@ -0,0 +1,46 @@
# 作品货架统一 2026-04-25
## 背景
创作中心目前已经把 RPG、大鱼吃小鱼、拼图三类作品展示在同一个网格里但前端组件仍直接消费三类原始 works
1. RPG 使用 `status``title``subtitle``canEnterWorld`
2. 大鱼使用 `status``title`、资源完成度字段。
3. 拼图使用 `publicationStatus``levelName``authorDisplayName``themeTags`
这导致筛选、计数、按钮文案、卡片标题、副标题、标签、删除忙碌态都在 UI 组件里做三套判断。后续再接新作品类型时,货架组件会继续膨胀。
## 目标
1. 新增前端统一作品货架视图模型 `CreationWorkShelfItem`
2. 由归一化函数把 RPG / Big Fish / Puzzle works 映射成统一字段。
3. `CustomWorldCreationHub` 只负责筛选、空态和动作分发。
4. `CustomWorldWorkCard` 只负责渲染统一字段,不再理解三类原始 schema。
## 非目标
1. 本轮不新增后端统一 works 聚合接口。
2. 不改变三类现有 works API contract。
3. 不改变平台首页公开广场的 gallery 合并逻辑。
4. 不改变删除、体验、恢复草稿的业务规则。
## 统一字段
`CreationWorkShelfItem` 至少包含:
1. `id`:稳定货架 id。
2. `kind``rpg | big-fish | puzzle`
3. `status``draft | published`
4. `title / subtitle / summary / updatedAt`
5. `coverImageSrc / coverRenderMode / coverCharacterImageSrcs`
6. `badges`:状态、类型、阶段、标签等展示徽标。
7. `metrics`:角色数、地点数、素材完成度、游玩数等底部指标。
8. `openActionLabel`:卡片无障碍文案与主动作语义。
9. `source`:保留原始 work用于平台壳层执行动作。
## 验收
1. 创作中心三类作品仍在同一个网格展示。
2. 草稿 / 已发布筛选计数统一从 `CreationWorkShelfItem.status` 读取。
3. 卡片渲染不再直接判断 `publicationStatus` 或不同 works schema 的标题字段。
4. 现有创作中心交互测试通过。

View File

@@ -0,0 +1,48 @@
# 前端页面独立路由路径说明
## 背景
平台入口、RPG 创作链、拼图创作链和大鱼吃小鱼创作链已经在 `PlatformEntryFlowShellImpl` 中通过 `selectionStage` 分阶段渲染。此前多数页面共享同一个浏览器路径,导致刷新、复制地址和浏览器前进后退时缺少清晰页面语义。
本轮目标是在不引入 React Router、不拆现有页面组件的前提下为现有主要页面分配稳定路径并让内部阶段切换同步浏览器地址。
## 路由原则
- `src/routing/appRoutes.tsx` 继续只负责应用级入口:正式主应用、拼图调试直达页、大鱼吃小鱼调试直达页。
- 正式主应用内部页面路径由 `src/routing/appPageRoutes.ts` 统一维护,不在组件里散落硬编码字符串。
- `/puzzle``/big-fish` 保持为玩法调试直达入口;正式链路中的拼图和大鱼运行页使用 `/runtime/puzzle``/runtime/big-fish`,避免语义冲突。
- 独立路径先解决页面阶段语义和浏览器前进后退;依赖运行中内存对象的详情页、结果页和运行页直接刷新后仍允许回退到平台首页或展示现有恢复态,不在本轮扩展资源 ID 深链加载。
## 页面路径表
| 页面阶段 | 路径 | 说明 |
| --- | --- | --- |
| `platform` | `/` | 平台首页、广场、我的、创作中心等主入口 |
| `detail` | `/worlds/detail` | RPG 世界详情页,依赖当前已选作品 |
| `agent-workspace` | `/creation/rpg/agent` | RPG Agent 共创工作区 |
| `custom-world-generating` | `/creation/rpg/generating` | RPG 世界草稿生成进度页 |
| `custom-world-result` | `/creation/rpg/result` | RPG 世界结果页与编辑页 |
| `big-fish-agent-workspace` | `/creation/big-fish/agent` | 大鱼吃小鱼 Agent 共创工作区 |
| `big-fish-result` | `/creation/big-fish/result` | 大鱼吃小鱼草稿结果页 |
| `big-fish-runtime` | `/runtime/big-fish` | 正式链路中的大鱼吃小鱼运行页 |
| `puzzle-agent-workspace` | `/creation/puzzle/agent` | 拼图 Agent 共创工作区 |
| `puzzle-result` | `/creation/puzzle/result` | 拼图草稿结果页 |
| `puzzle-gallery-detail` | `/gallery/puzzle/detail` | 拼图作品详情页,依赖当前已选作品 |
| `puzzle-runtime` | `/runtime/puzzle` | 正式链路中的拼图运行页 |
| RPG 选角页 | `/runtime/rpg/characters` | 进入世界后、确认角色前的选角阶段 |
| RPG 冒险页 | `/runtime/rpg/adventure` | 已确认角色后的 RPG 主运行态 |
## 落地边界
- `useRpgRuntimeOverlayState` 初始化时从当前路径推导 `selectionStage`
- `setSelectionStage(...)` 被统一包一层,阶段变化时同步 `history.pushState`
- RPG 选角和冒险运行态由 `RpgRuntimeShell` 按当前 `gameState` 同步路径。
- 浏览器 `popstate` 时只回写 `selectionStage`,不重建详情页依赖的业务对象。
- 已有 `/puzzle``/big-fish` 调试入口继续由应用级路由分流,不进入 `selectionStage`
## 验收口径
1. 访问 `/creation/rpg/agent``/creation/puzzle/agent``/creation/big-fish/agent` 能进入主应用并初始化到对应页面阶段。
2. 从页面内切换到结果页、运行页或返回首页时,浏览器路径同步更新。
3. 浏览器后退/前进能驱动 `selectionStage` 回到对应页面。
4. `/puzzle``/big-fish` 仍进入原有玩法调试直达页。

View File

@@ -0,0 +1,87 @@
# “我的”账户充值弹窗落地设计
日期:`2026-04-25`
## 1. 范围
本轮在“我的”页面的“会员充值”入口落地账户充值弹窗,包含两个页签:
1. `积分充值`
2. `会员卡充值`
前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。当前没有真实支付网关,本轮采用服务端模拟支付成功:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。后续接入真实支付时,只替换订单支付状态推进,不改前端套餐与账户快照 contract。
## 2. 产品规则
### 2.1 积分充值套餐
| productId | 积分 | 金额分 | 徽标 | 说明 |
| --- | ---: | ---: | --- | --- |
| `points_10` | 10 | 100 | 首充送积分 | 首充送19积分 |
| `points_60` | 60 | 600 | 无首充赠礼 | 无首充赠送 |
| `points_240` | 240 | 2400 | 首充双倍 | 首充送240积分 |
| `points_450` | 450 | 4500 | 首充双倍 | 首充送450积分 |
| `points_950` | 950 | 9500 | 首充双倍 | 首充送950积分 |
| `points_1980` | 1980 | 19800 | 首充双倍 | 首充送1980积分 |
首充赠送只按用户维度判断一次:用户历史上没有 `points_recharge` 流水时,购买支持首充赠送的套餐才发放赠送积分。实际到账积分写入交易流水,余额以 SpacetimeDB projection 为准。
### 2.2 会员卡套餐
| productId | 类型 | 天数 | 金额分 | 权益 |
| --- | --- | ---: | ---: | --- |
| `member_month` | 月卡 | 30 | 2800 | 免积分回合数100每日签到加成0% |
| `member_season` | 季卡 | 90 | 7800 | 免积分回合数100每日签到加成100% |
| `member_year` | 年卡 | 365 | 24800 | 免积分回合数100每日签到加成210% |
购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。
## 3. 后端接口
### 3.1 `GET /api/profile/recharge-center`
需要 Bearer JWT。返回
1. 当前积分余额、会员状态、到期时间
2. 积分套餐与会员套餐
3. 会员权益表
4. 最近订单摘要
兼容路径:`GET /api/runtime/profile/recharge-center`
### 3.2 `POST /api/profile/recharge/orders`
需要 Bearer JWT。请求
```json
{
"productId": "points_240",
"paymentChannel": "mock"
}
```
行为:
1. 校验 `productId`
2. 后端创建已支付订单
3. 积分套餐写入钱包余额与流水
4. 会员套餐写入会员状态
5. 返回最新账户中心快照与订单摘要
兼容路径:`POST /api/runtime/profile/recharge/orders`
## 4. 前端交互
1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。
2. 弹窗顶部标题为 `账户充值`,右上角关闭。
3. 默认打开 `积分充值`,可切换到 `会员卡充值`
4. 点击套餐后调用下单接口,按钮进入处理中状态,成功后刷新 `profileDashboard`
5. 弹窗内不写大段说明文案,只保留必要金额、积分、会员权益和状态反馈。
## 5. 验收
1. 普通用户打开弹窗能看到积分与会员套餐。
2. 积分购买后余额增加,流水来源为 `points_recharge`
3. 首充赠送只在首次积分充值时生效。
4. 会员购买后会员状态与到期时间立即更新。
5. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。

View File

@@ -0,0 +1,73 @@
# 我的 Tab 邀请与玩家社区首期落地方案
更新时间:`2026-04-25`
## 目标
在现有“我的”Tab 常用功能区落地三个轻量入口:
1. `邀请好友`:弹出面板展示当前账号绑定的邀请码。
2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 积分。
3. `玩家社区`:弹出面板展示微信群与 QQ 群二维码占位图,后续替换为正式图片。
## 后端边界
- 邀请码、邀请关系与奖励发放全部存入 `server-rs/crates/spacetime-module`
- Axum 只做鉴权、参数转发与响应映射,不在 API 层自行计算奖励。
- 前端只读取后端状态与调用提交接口,不做本地加积分。
- 钱包余额继续复用 `profile_dashboard_state.wallet_balance`
- 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型:
- `invite_inviter_reward`
- `invite_invitee_reward`
## SpacetimeDB 表设计
### `profile_invite_code`
- `user_id`:主键,账号 ID。
- `invite_code`:唯一邀请码。
- `created_at` / `updated_at`:创建与更新时间。
### `profile_referral_relation`
- `invitee_user_id`:主键,被邀请账号 ID。保证每个用户最多填写一次邀请码。
- `inviter_user_id`:邀请者账号 ID。
- `invite_code`:绑定时使用的邀请码快照。
- `inviter_reward_granted`:邀请者本次是否获得奖励。
- `invitee_reward_granted`:被邀请者是否获得奖励。
- `bound_at`:绑定时间。
## 业务规则
- 每个用户拥有一个稳定邀请码,首次进入邀请中心时自动生成。
- 用户不能填写自己的邀请码。
- 用户最多填写一个邀请码,成功后不可修改。
- 被邀请者绑定成功后获得 `30` 积分。
- 邀请者每天最多获得 `10` 次邀请奖励,超过后关系仍可绑定,被邀请者仍获得奖励,邀请者当次不再加分。
- 每次奖励都写入钱包流水,钱包余额以后端返回为准。
## API
### `GET /api/runtime/profile/referrals/invite-center`
返回当前用户的邀请码、邀请链接、今日奖励次数、剩余奖励次数、已绑定状态与奖励参数。
### `POST /api/runtime/profile/referrals/redeem-code`
请求体:
```json
{
"inviteCode": "ABCD1234"
}
```
返回绑定后的邀请中心状态与本次奖励发放结果。
## 前端交互
- 三个入口继续放在“我的”Tab 常用功能区,不新增页面。
- `邀请好友` 弹窗展示邀请码、复制按钮、邀请链接。
- `填邀请码` 弹窗在未绑定时展示输入框;已绑定时展示短状态。
- `玩家社区` 弹窗展示两个紧凑二维码占位区。
- 弹窗文案只保留必要标签和短提示,不放长规则说明。

View File

@@ -0,0 +1,25 @@
# 平台首页仅展示作品入口调整记录
更新时间:`2026-04-25`
## 目标
- 首页首屏与 banner 不再展示创作入口,也不再在无存档时点击进入创作工作台。
- 首页主体只承担公开作品浏览、作品详情打开和最近存档继续游玩的入口。
- 创作 Tab 作为独立主导航入口保留,不把入口放进首页 banner 或作品推荐区域。
- 创作工作台保留在既有创作流程内,避免误删已有草稿、发布和多玩法创作能力。
## 落地
- `src/components/rpg-entry/RpgEntryHomeView.tsx`
- 移动端首页 Hero 固定指向作品:有精选/最新作品时打开首个作品详情,无作品时跳转分类页。
- 桌面端首页 banner 固定指向作品,不再显示 `CREATE WORLD``创作入口``进入创作工作台` 等入口文案。
- 桌面端右侧快捷区域改为“最近作品 / 最近浏览 / 作品广场”结构,不再提供创作启动 CTA。
- 首页底部导航与桌面侧栏保留“创作”Tab创作入口只在独立导航层露出。
## 验收标准
- 首页作品区域与 banner 不出现创作入口按钮或创作入口文案。
- 底部导航与桌面侧栏正常显示“创作”Tab。
- 首页仍能打开精选、最新、趋势和最近浏览作品。
- 无公开作品时显示作品空状态,不引导创建草稿。

View File

@@ -4,13 +4,24 @@
## 文档列表 ## 文档列表
- [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。
- [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 方向只允许作为迁移参考。 - [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%`、主按钮改为“继续生成草稿”并复用已保存底稿续跑,以及按阶段耗时模型估算预计等待时间的修复口径。
- [RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md](./RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md):冻结运行时 NPC 聊天从 Rust 确定性兜底迁到 `platform-llm` 的边界,要求旧 Node 聊天提示词原样迁移,覆盖回复、建议、好感变化与限轮收束。
- [API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md](./API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md):记录 `npm run dev:rust` 在 Windows 冷编译/链接阶段误把 `api-server` `/healthz` 等待判定为超时并杀掉 `cargo run` 的根因,以及将 SpacetimeDB 与 api-server 等待窗口拆分的脚本口径。 - [API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md](./API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md):记录 `npm run dev:rust` 在 Windows 冷编译/链接阶段误把 `api-server` `/healthz` 等待判定为超时并杀掉 `cargo run` 的根因,以及将 SpacetimeDB 与 api-server 等待窗口拆分的脚本口径。
- [API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md](./API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md):记录统一 API envelope 错误展示优先读取 `error.details.message` 的修复口径,避免 Big Fish 发布校验等业务错误只显示通用“请求参数不合法”。 - [API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md](./API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md):记录统一 API envelope 错误展示优先读取 `error.details.message` 的修复口径,避免 Big Fish 发布校验等业务错误只显示通用“请求参数不合法”。
- [CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md](./CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md):记录角色主形象遇到 DashScope `IPInfringementSuspect` 时自动改用原创安全 prompt 兜底重试的修复口径,并保留供应商原始错误便于排查。
- [CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md](./CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md):记录创作 Agent 用户发送消息后立刻展示三点等待动画的前端展示条件,避免首个 SSE token 到达前聊天区无反馈。
- [CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md](./CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md):冻结 Agent 创作页上传文本类文档并解析为输入框内容的前后端边界、接口、支持范围和验收标准。
- [CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md](./CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md):冻结三类作品创作 Agent client 通用工厂与平台轻量流程 controller 的复用边界,明确本轮只收口 HTTP/SSE 骨架和大鱼/拼图会话流程,不合并 RPG 自动保存主链。
- [BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md](./BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md):冻结后端创作 Agent LLM turn 公共化边界,收口模型可用性检查、流式 JSON 回复抽取、最终 JSON 解析与中文错误映射,玩法 schema 和写回逻辑继续留在各自模块。
- [CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md](./CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md):冻结创作中心作品货架统一视图模型,先在前端归一 RPG、大鱼、拼图 works 的展示字段、筛选状态和卡片动作语义,不新增后端聚合接口。
- [BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md](./BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md):记录大鱼吃小鱼从固定摇杆改为屏幕首触点方向控制,并要求本地直达局在未操作时保持对象运动。 - [BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md](./BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md):记录大鱼吃小鱼从固定摇杆改为屏幕首触点方向控制,并要求本地直达局在未操作时保持对象运动。
- [RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md](./RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md):记录 `server-rs` 无参数 `cargo build` 链接 `spacetime-module` 失败的根因,并冻结默认只构建原生 `api-server`、模块产物继续走 `spacetime build` 的命令边界。 - [RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md](./RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md):记录 `server-rs` 无参数 `cargo build` 链接 `spacetime-module` 失败的根因,并冻结默认只构建原生 `api-server`、模块产物继续走 `spacetime build` 的命令边界。
- [BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/big-fish` 大鱼吃小鱼玩法直达入口,明确复用现有 `BigFishRuntimeShell` 和本地占位运行态的调试边界。 - [BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/big-fish` 大鱼吃小鱼玩法直达入口,明确复用现有 `BigFishRuntimeShell` 和本地占位运行态的调试边界。
- [PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/puzzle` 拼图玩法直达入口,明确复用现有 `PuzzleRuntimeShell` 和本地占位图运行态的调试边界。 - [PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/puzzle` 拼图玩法直达入口,明确复用现有 `PuzzleRuntimeShell` 和本地占位图运行态的调试边界。
- [FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md](./FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md)记录平台入口、RPG 创作、拼图创作和大鱼吃小鱼创作各页面的独立前端路径,以及与 `/puzzle``/big-fish` 调试直达入口的边界。
- [CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md](./CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md):记录结果页 profile 归一化回写丢失顶层 `worldHook / playerPremise` 导致 publish gate 继续误报结构 blocker 的根因,并冻结前端归一化保留发布字段的修复口径。 - [CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md](./CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md):记录结果页 profile 归一化回写丢失顶层 `worldHook / playerPremise` 导致 publish gate 继续误报结构 blocker 的根因,并冻结前端归一化保留发布字段的修复口径。
- [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。 - [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。
@@ -148,11 +159,13 @@
- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的 runtime profile 目录化、服务端 preview compiler 收口,以及 foundation draft 主生成链与 preview 编译边界的直接拆开。 - [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的 runtime profile 目录化、服务端 preview compiler 收口,以及 foundation draft 主生成链与 preview 编译边界的直接拆开。
- [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-工作包-c前端结果页与编辑器拆分](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-%E5%B7%A5%E4%BD%9C%E5%8C%85-c%E5%89%8D%E7%AB%AF%E7%BB%93%E6%9E%9C%E9%A1%B5%E4%B8%8E%E7%BC%96%E8%BE%91%E5%99%A8%E6%8B%86%E5%88%86):记录工作包 C 已完成的结果页壳层拆分、编辑器目标分发与 mapper 收口、角色资产工坊 section/workflow 拆分,以及仍保留的阶段性 shared 实现边界。 - [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-工作包-c前端结果页与编辑器拆分](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-%E5%B7%A5%E4%BD%9C%E5%8C%85-c%E5%89%8D%E7%AB%AF%E7%BB%93%E6%9E%9C%E9%A1%B5%E4%B8%8E%E7%BC%96%E8%BE%91%E5%99%A8%E6%8B%86%E5%88%86):记录工作包 C 已完成的结果页壳层拆分、编辑器目标分发与 mapper 收口、角色资产工坊 section/workflow 拆分,以及仍保留的阶段性 shared 实现边界。
- [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。 - [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。
- [RPG_FOUNDATION_DRAFT_EIGHT_ANCHOR_SEED_FIX_2026-04-25.md](./RPG_FOUNDATION_DRAFT_EIGHT_ANCHOR_SEED_FIX_2026-04-25.md):记录 RPG 创作 Agent session 八锚点进入 foundation draft seed 时被旧字段压缩的根因、修复和后续约束。
- [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。 - [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。
- [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)AI 生成角色形象与角色动画的技术路线。 - [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)AI 生成角色形象与角色动画的技术路线。
- [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。 - [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。
- [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md)PixelMotion 产品形态与能力拆解。 - [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md)PixelMotion 产品形态与能力拆解。
- [SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md](./SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md):服务端部署、代理层与 CORS 方案。 - [SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md](./SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md):服务端部署、代理层与 CORS 方案。
- [WORLD_DRAFT_FOUNDATION_EDITOR_TARGET_FIX_2026-04-25.md](./WORLD_DRAFT_FOUNDATION_EDITOR_TARGET_FIX_2026-04-25.md):记录世界草稿“基本设定”独立编辑目标、分号标签化展示与编辑回写边界。
## 使用建议 ## 使用建议

View File

@@ -0,0 +1,29 @@
# RPG 草稿生成失败续跑与预计等待修正 2026-04-25
## 背景
世界草稿生成过程页在失败或中断后存在两个体验问题:
1. 后端部分失败路径把 `draft_foundation``operation_progress` 写成 `100`,前端总进度会误显示完成。
2. 生成页按钮文案是“重新生成草稿”,实际用户需要从当前草稿继续补齐,而不是理解成完全重开。
3. 预计等待时间按 `已耗时 / 当前进度` 线性估算,遇到角色主形象、幕背景图等长阶段时偏差明显。
## 落地规则
1. 失败态进度不能直接显示 `100%`
- 服务端失败路径按失败阶段写入最后可信进度。
- 前端兼容旧记录:若 `failed + progress >= 100`,按 `phaseLabel` 所在阶段回落到 `< 100` 的阶段起点。
2. 生成过程页非运行态主按钮统一显示“继续生成草稿”。
3. “继续生成草稿”继续调用 `draft_foundation`
- 若 session 已经保存 `draftProfile`,后端跳过世界底稿 LLM 分批生成,继续执行素材补齐、草稿卡编译和结果页写回。
- 若没有可复用 `draftProfile`,仍从世界底稿阶段重新生成。
4. 预计等待时间不再用线性百分比估算:
- 前端按阶段定义维护预期耗时,结合 operation 的 `updatedAt` 估算当前阶段剩余时间。
- 服务端每次写入 `draft_foundation` 阶段进度时输出结构化日志,后续可从日志统计真实阶段耗时并调整阶段预期。
## 验收点
- 失败或中断停在生成过程页时,总进度不会显示为 `100%`
- 失败后的主按钮显示“继续生成草稿”,点击后继续执行 `draft_foundation`
- 已保存部分底稿的失败会话再次执行时,不重复生成世界底稿,直接继续素材与结果页阶段。
- `customWorldAgentGenerationProgress` 针对失败进度纠偏和阶段 ETA 的单元测试通过。

View File

@@ -0,0 +1,31 @@
# RPG 草稿角色形象描述前置校验修正 2026-04-25
## 背景
RPG 作品生成过程中出现“角色缺少 visualDescription不能在角色形象设定文本生成前直接生图”。用户观察到前面步骤加起来不到 1 秒就跳到“生成角色主形象”,说明并没有完整执行角色形象设定文本生成链路。
## 根因
1. “继续生成草稿”会复用 session 中已经保存的 `draftProfile`,并直接进入素材补齐阶段。
2. 旧失败记录里可能已经保存了不完整角色对象,例如只有 `name/title/role/description`,缺少 `visualDescription/actionDescription/sceneVisualDescription`
3. 新底稿生成链路只保证 LLM 返回 JSON 可解析,没有在角色名单阶段强校验每个角色资产文本字段是否齐全。
4. 后续叙事档案与养成档案阶段只补 `backstory/personality/skills/items`,不会再补角色形象文本,因此缺失会一直传递到生图前才暴露。
## 落地规则
1. 生图前仍然必须强依赖 `visualDescription`,不能回退到 `description` 或通用兜底词。
2. 复用已保存 `draftProfile` 前必须检查所有 `playableNpcs/storyNpcs`
- `name`
- `visualDescription`
- `actionDescription`
- `sceneVisualDescription`
3. 已保存底稿缺少上述字段时,不再跳过世界底稿 LLM 生成,而是重新执行完整底稿生成链路。
4. 新底稿角色框架名单批次返回后必须先校验资产文本字段;如果缺失,发起一次 LLM JSON 修复请求补齐字段,再进入后续档案补全。
5. 修复请求只允许补齐同名角色的资产文本与框架字段,不允许新增角色、改名或删除角色。
## 验收点
- 旧失败会话继续生成时,不会在 1 秒内直接进入角色主形象生图。
- 角色名单阶段输出缺少 `visualDescription` 时,会先请求 LLM 修复,而不是进入素材阶段失败。
- 修复后 `playableNpcs/storyNpcs` 的每个角色都带有 `visualDescription/actionDescription/sceneVisualDescription`
- 角色主形象生图仍只读取 `visualDescription`,缺字段继续视为底稿质量问题。

View File

@@ -0,0 +1,30 @@
# RPG 草稿恢复与结果页同步修正 2026-04-25
## 背景
用户进入一份还没有生成草稿的 RPG Agent 作品时,前端错误进入了结果页,并触发后端错误:
`sync_result_profile is only available during object_refining or visual_refining`
问题根因有两处:
1. 创作页打开草稿时,旧逻辑会用作品摘要里的 `playableNpcCount / landmarkCount` 推断是否已有可编辑结果页。摘要字段可能来自旧快照或残留投影,不能代表当前会话已经生成 `draftProfile`
2. Agent 结果页自动保存和返回创作时仍会调用 `sync_result_profile`,但这个 action 只允许在对象精修或视觉精修阶段使用;还在锚点采集、澄清或底稿生成前后时调用会被后端拒绝。
## 本次约束
- 进入 RPG Agent 草稿时,以最新 `session.draftProfile``session.stage` 作为唯一结果页门槛。
- `collecting_intent / clarifying / foundation_review / error` 或缺少 `draftProfile` 时,必须恢复 Agent 工作区。
- 只有 `object_refining / visual_refining / long_tail_review / ready_to_publish / published` 且存在 `draftProfile` 时,才能打开结果页。
- Agent 结果页不再自动把前端 profile 回写到 session`session.draftProfile` 是草稿真相源。
- `sync_result_profile` 只保留给显式对象精修链路,不允许作为打开作品、返回创作或自动保存的隐式动作。
## 落地
- `useRpgEntryLibraryDetail` 打开草稿后先拉取最新 session再按 `draftProfile + stage` 决定进入 Agent 工作区或结果页。
- `useRpgCreationResultAutosave``syncAgentDraftResultProfile` 改为刷新 session 快照并保存最新 `draftProfile`,不再调用 `sync_result_profile`
- Agent 结果页返回创作时直接回创作中心,不再发起同步 reducer。
- 回归测试覆盖:
-`draftProfile` 但摘要对象数量非 0 时仍回 Agent 工作区。
- 结果页返回创作不触发 `sync_result_profile`
- 结果页自动保存不触发 `sync_result_profile`,仍携带 `sourceAgentSessionId` 保存作品库草稿。

View File

@@ -0,0 +1,44 @@
# RPG foundation draft 八锚点 seed 修复 2026-04-25
## 背景
RPG 创作 Agent session 当前以 `anchorContent` 保存八锚点:
1. `worldPromise`
2. `playerFantasy`
3. `themeBoundary`
4. `playerEntryPoint`
5. `coreConflict`
6. `keyRelationships`
7. `hiddenLines`
8. `iconicElements`
底稿生成入口 `build_foundation_generation_seed_text()` 应直接消费这份八锚点状态,保证 Agent 多轮共创沉淀下来的内容完整进入 foundation draft 生成。
## 问题
`build_eight_anchor_foundation_text()` 仍读取旧字段:
- `coreLoop`
- `mainConflict`
- `keyCharacters`
- `keyPlaces`
- `toneAndStyle`
- `firstScene`
因此当前 session 里只有 `worldPromise``playerEntryPoint` 两项能命中。若 `anchorContent` 更残缺,还会回退到 `creatorIntent` 的五段摘要:世界核心、玩家身份、开局处境、核心冲突、标志元素。
这会造成链路语义错位Agent 前段持续迭代八锚点,底稿生成 seed 却只吃到残缺锚点或五段摘要,后续再通过 draft profile 与前端展示恢复成八锚点相关结构时,内容已经发生压缩和漂移。
## 修复
`server-rs/crates/api-server/src/custom_world_foundation_draft.rs` 已调整:
- `build_eight_anchor_foundation_text()` 改为读取当前八锚点字段。
- 输出使用中文锚点标签,便于 foundation prompt 直接理解结构语义。
- 新增 `has_meaningful_anchor_value()`,避免空对象、空数组、空字符串被误当作有效锚点。
- 新增回归测试,确保完整八锚点 seed 不会回退到 `anchorPack.creatorIntentSummary` 或旧字段名。
## 后续约束
后续任何 foundation draft 生成、预览编译、发布门禁都应以 Agent session 的当前八锚点字段为准。旧字段名只能作为历史文档或迁移参考,不再进入 `server-rs` 主生成链。

View File

@@ -0,0 +1,37 @@
# RPG 作品删除 SpacetimeDB procedure 导出修复 2026-04-25
## 问题
创作页或作品详情删除 RPG 作品时报 `No such procedure`
本次核对 Maincloud `xushi-p4wfr` schema 后确认:
1. 已发布 / 作品库 profile 删除依赖 `delete_custom_world_profile_and_return`
2. 草稿作品删除依赖 `delete_custom_world_agent_session`
3. 本地 Rust client 绑定里存在 `delete_custom_world_agent_session`,但 Maincloud schema 中没有该 procedure。
## 根因
`server-rs/crates/spacetime-module/src/custom_world/mod.rs` 中有一份草稿删除实现,但当前有效发布入口仍是 `server-rs/crates/spacetime-module/src/lib.rs` 中的 custom world 实现。`lib.rs` 未导出 `delete_custom_world_agent_session`,导致发布到 Maincloud 的模块 schema 缺少该 procedure。
## 落地口径
本次只补最小闭环:
1. 在有效入口 `server-rs/crates/spacetime-module/src/lib.rs` 增加 `delete_custom_world_agent_session` procedure。
2. 删除纯 Agent 草稿时同步清理:
- `custom_world_agent_session`
- `custom_world_agent_message`
- `custom_world_agent_operation`
- `custom_world_draft_card`
3. 若前端误把已发布作品按 `sessionId` 走入草稿删除接口,则回落到关联 `custom_world_profile` 软删除,并返回最新 works 列表。
4. 不新增前端逻辑,不回退到 server-node。
## 验证
1. `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`
2. `npm run check:encoding`
3. `npm run spacetime:publish:maincloud`
4. 发布后用 `spacetime describe xushi-p4wfr --server maincloud --json` 确认 schema 包含:
- `delete_custom_world_profile_and_return`
- `delete_custom_world_agent_session`

View File

@@ -30,3 +30,16 @@
2. `cargo check -p api-server` 通过。 2. `cargo check -p api-server` 通过。
3. 四条链路仍能从原调用点拿到相同语义的提示词。 3. 四条链路仍能从原调用点拿到相同语义的提示词。
4. 文档明确后续 prompt 修改主源在 `src/prompt/` 4. 文档明确后续 prompt 修改主源在 `src/prompt/`
## 5. 聊天类 Prompt 追加迁移
2026-04-25 追加迁移两类聊天提示词:
1. Agent 聊天创作:`server-rs/crates/api-server/src/prompt/agent_chat.rs`,承接原 `custom_world_rpg_draft_prompts.rs` 中的共创主系统提示词、状态识别提示词、输出契约、动态状态上下文、聊天历史上下文等脚本。
2. 游戏运行时与对方角色聊天:`server-rs/crates/api-server/src/prompt/runtime_chat.rs`承接运行时剧情导演、NPC 对话导演、战斗结算叙事的 system prompt 与 user prompt 组装。
迁移后约束:
1. `custom_world_rpg_draft_prompts.rs` 只作为兼容 re-export后续不要在该文件新增提示词正文。
2. `runtime_story/compat/ai.rs` 只负责读取状态、调用 LLM 和组装返回,不再内联 NPC 对话或剧情导演提示词。
3. 后续所有 Agent 共创聊天、运行时角色聊天的提示词调整统一进入 `src/prompt/`

View File

@@ -0,0 +1,535 @@
# SpacetimeDB 表说明与查询目录
> 维护状态:持续维护。凡是新增、删除或修改 `server-rs/crates/spacetime-module` 中的 `#[spacetimedb::table]`,必须同步更新本文。
## 维护规则
- 表结构以 `server-rs/crates/spacetime-module/src/lib.rs` 及其已挂载子模块为主;`server-rs/crates/spacetime-client/src/module_bindings/*_table.rs``*_type.rs` 用于校对当前生成绑定。
- `public` 表可以被客户端订阅;未标 `public` 的表是服务端真相表,通常通过 reducer / procedure / Axum facade 间接访问。
- SQL 示例使用 `<db>``<user_id>``<session_id>` 等占位符,实际执行时替换为真实值:
```powershell
spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
```
- 修改表后维护顺序:
1. 更新 Rust 表定义和对应领域注释。
2. 重新发布 / 生成绑定,确认 `module_bindings` 与源码一致。
3. 更新本文的作用、结构、索引和查询 SQL。
4. 运行 `npm run check:encoding`,避免中文文档或源码被写坏。
## 总览
| 领域 | 表 |
| --- | --- |
| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_played_world`, `profile_save_archive` |
| RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` |
| 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` |
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` |
| 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_runtime_run` |
| 资产 | `asset_object`, `asset_entity_binding` |
| AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference` |
## 认证表
### `auth_store_snapshot`
- 作用:保存旧内存认证仓储的整份 JSON 快照,用于迁移和恢复;后续正式表拆分后仍可作为导入/导出桥。
- 结构:`snapshot_id PK: String`, `snapshot_json: String`, `updated_at: Timestamp`
- 索引:主键 `snapshot_id`
```sql
SELECT * FROM auth_store_snapshot;
SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default';
```
### `user_account`
- 作用:用户账号主表,保存用户名、公开叙世号、手机号掩码、登录方式、密码登录开关和 token 版本。
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`
- 索引:`username`, `public_user_code`
```sql
SELECT * FROM user_account WHERE user_id = '<user_id>';
SELECT * FROM user_account WHERE username = '<username>';
SELECT * FROM user_account WHERE public_user_code = '<public_user_code>';
```
### `auth_identity`
- 作用:第三方/手机号身份绑定表,把 provider 身份映射到内部 `user_id`
- 结构:`identity_id PK: String`, `user_id: String`, `provider: String`, `provider_uid: String`, `provider_union_id: Option<String>`, `phone_e164: Option<String>`, `display_name: Option<String>`, `avatar_url: Option<String>`
- 索引:`user_id`, `(provider, provider_uid)`
```sql
SELECT * FROM auth_identity WHERE user_id = '<user_id>';
SELECT * FROM auth_identity WHERE provider = 'wechat' AND provider_uid = '<provider_uid>';
```
### `refresh_session`
- 作用:刷新令牌会话表,支持多端登录、吊销、过期和最近活跃时间查询。
- 结构:`session_id PK: String`, `user_id: String`, `refresh_token_hash: String`, `issued_by_provider: String`, `client_info_json: String`, `expires_at: String`, `revoked_at: Option<String>`, `created_at: String`, `updated_at: String`, `last_seen_at: String`
- 索引:`user_id`, `refresh_token_hash`
```sql
SELECT * FROM refresh_session WHERE session_id = '<session_id>';
SELECT * FROM refresh_session WHERE user_id = '<user_id>';
SELECT * FROM refresh_session WHERE refresh_token_hash = '<hash>';
```
## 运行时档案表
### `runtime_setting`
- 作用:保存用户运行时设置,目前承接音乐音量和平台主题。
- 结构:`user_id PK: String`, `music_volume: f32`, `platform_theme: RuntimePlatformTheme`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `user_id`
```sql
SELECT * FROM runtime_setting WHERE user_id = '<user_id>';
```
### `runtime_snapshot`
- 作用:用户当前运行时快照,保存底部 Tab、游戏状态 JSON 和当前剧情 JSON。
- 结构:`user_id PK: String`, `version: u32`, `saved_at: Timestamp`, `bottom_tab: String`, `game_state_json: String`, `current_story_json: Option<String>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `user_id`
```sql
SELECT * FROM runtime_snapshot WHERE user_id = '<user_id>';
```
### `user_browse_history`
- 作用:用户浏览/进入世界历史,用于最近访问、继续浏览和去重展示。
- 结构:`browse_history_id PK: String`, `user_id: String`, `owner_user_id: String`, `profile_id: String`, `world_name: String`, `subtitle: String`, `summary_text: String`, `cover_image_src: Option<String>`, `theme_mode: RuntimeBrowseHistoryThemeMode`, `author_display_name: String`, `visited_at: Timestamp`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`user_id`, `(user_id, owner_user_id, profile_id)`
```sql
SELECT * FROM user_browse_history WHERE user_id = '<user_id>';
SELECT * FROM user_browse_history WHERE user_id = '<user_id>' AND owner_user_id = '<owner_user_id>' AND profile_id = '<profile_id>';
```
### `profile_dashboard_state`
- 作用:个人主页聚合状态,保存钱包余额和总游玩时长。
- 结构:`user_id PK: String`, `wallet_balance: u64`, `total_play_time_ms: u64`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `user_id`
```sql
SELECT * FROM profile_dashboard_state WHERE user_id = '<user_id>';
```
### `profile_wallet_ledger`
- 作用:钱包流水账,记录金币/货币变更来源与变更后的余额。
- 结构:`wallet_ledger_id PK: String`, `user_id: String`, `amount_delta: i64`, `balance_after: u64`, `source_type: RuntimeProfileWalletLedgerSourceType`, `created_at: Timestamp`
- 索引:`user_id`, `(user_id, created_at)`
```sql
SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>';
SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created_at DESC;
```
### `profile_played_world`
- 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。
- 结构:`played_world_id PK: String`, `user_id: String`, `world_key: String`, `owner_user_id: Option<String>`, `profile_id: Option<String>`, `world_type: Option<String>`, `world_title: String`, `world_subtitle: String`, `first_played_at: Timestamp`, `last_played_at: Timestamp`, `last_observed_play_time_ms: u64`
- 索引:`user_id`, `(user_id, world_key)`, `(user_id, last_played_at)`
```sql
SELECT * FROM profile_played_world WHERE user_id = '<user_id>';
SELECT * FROM profile_played_world WHERE user_id = '<user_id>' AND world_key = '<world_key>';
SELECT * FROM profile_played_world WHERE user_id = '<user_id>' ORDER BY last_played_at DESC;
```
### `profile_save_archive`
- 作用:用户存档列表,保存世界信息、封面、当前状态 JSON 和剧情 JSON。
- 结构:`archive_id PK: String`, `user_id: String`, `world_key: String`, `owner_user_id: Option<String>`, `profile_id: Option<String>`, `world_type: Option<String>`, `world_name: String`, `subtitle: String`, `summary_text: String`, `cover_image_src: Option<String>`, `saved_at: Timestamp`, `bottom_tab: String`, `game_state_json: String`, `current_story_json: Option<String>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`user_id`, `(user_id, world_key)`, `(user_id, saved_at)`
```sql
SELECT * FROM profile_save_archive WHERE archive_id = '<archive_id>';
SELECT * FROM profile_save_archive WHERE user_id = '<user_id>' ORDER BY saved_at DESC;
SELECT * FROM profile_save_archive WHERE user_id = '<user_id>' AND world_key = '<world_key>';
```
## RPG 运行时表
### `story_session`
- 作用RPG 剧情会话主表,保存运行时会话、玩家、世界、最近叙事文本和可选选择函数。
- 结构:`story_session_id PK: String`, `runtime_session_id: String`, `actor_user_id: String`, `world_profile_id: String`, `initial_prompt: String`, `opening_summary: Option<String>`, `latest_narrative_text: String`, `latest_choice_function_id: Option<String>`, `status: StorySessionStatus`, `version: u32`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`runtime_session_id`, `actor_user_id`
```sql
SELECT * FROM story_session WHERE story_session_id = '<story_session_id>';
SELECT * FROM story_session WHERE runtime_session_id = '<runtime_session_id>';
SELECT * FROM story_session WHERE actor_user_id = '<user_id>' ORDER BY updated_at DESC;
```
### `story_event`
- 作用:剧情事件流水,记录每次开场/推进生成的叙事文本和选择函数。
- 结构:`event_id PK: String`, `story_session_id: String`, `event_kind: StoryEventKind`, `narrative_text: String`, `choice_function_id: Option<String>`, `created_at: Timestamp`
- 索引:`story_session_id`
```sql
SELECT * FROM story_event WHERE story_session_id = '<story_session_id>' ORDER BY created_at ASC;
SELECT * FROM story_event WHERE event_id = '<event_id>';
```
### `npc_state`
- 作用NPC 在某个运行时会话中的关系、好感、招募、传闻和已见剧情状态。
- 结构:`npc_state_id PK: String`, `runtime_session_id: String`, `npc_id: String`, `npc_name: String`, `affinity: i32`, `relation_state: NpcRelationState`, `help_used: bool`, `chatted_count: u32`, `gifts_given: u32`, `recruited: bool`, `trade_stock_signature: Option<String>`, `revealed_facts: Vec<String>`, `known_attribute_rumors: Vec<String>`, `first_meaningful_contact_resolved: bool`, `seen_backstory_chapter_ids: Vec<String>`, `stance_profile: NpcStanceProfile`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`runtime_session_id`, `npc_id`, `(runtime_session_id, npc_id)`
```sql
SELECT * FROM npc_state WHERE npc_state_id = '<npc_state_id>';
SELECT * FROM npc_state WHERE runtime_session_id = '<runtime_session_id>';
SELECT * FROM npc_state WHERE runtime_session_id = '<runtime_session_id>' AND npc_id = '<npc_id>';
```
### `inventory_slot`
- 作用:背包/装备槽真相表,避免继续把物品状态塞在运行时 JSON 中。
- 结构:`slot_id PK: String`, `runtime_session_id: String`, `story_session_id: Option<String>`, `actor_user_id: String`, `container_kind: InventoryContainerKind`, `slot_key: String`, `item_id: String`, `category: String`, `name: String`, `description: Option<String>`, `quantity: u32`, `rarity: InventoryItemRarity`, `tags: Vec<String>`, `stackable: bool`, `stack_key: String`, `equipment_slot_id: Option<InventoryEquipmentSlot>`, `source_kind: InventoryItemSourceKind`, `source_reference_id: Option<String>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`runtime_session_id`, `actor_user_id`, `(container_kind, slot_key)`, `item_id`
```sql
SELECT * FROM inventory_slot WHERE runtime_session_id = '<runtime_session_id>';
SELECT * FROM inventory_slot WHERE actor_user_id = '<user_id>';
SELECT * FROM inventory_slot WHERE container_kind = 'Backpack' AND slot_key = '<slot_key>';
SELECT * FROM inventory_slot WHERE item_id = '<item_id>';
```
### `battle_state`
- 作用:战斗状态真相表,保存玩家/目标血蓝、回合、奖励、上次动作和结算结果。
- 结构:`battle_state_id PK: String`, `story_session_id: String`, `runtime_session_id: String`, `actor_user_id: String`, `chapter_id: Option<String>`, `target_npc_id: String`, `target_name: String`, `battle_mode: BattleMode`, `status: BattleStatus`, `player_hp: i32`, `player_max_hp: i32`, `player_mana: i32`, `player_max_mana: i32`, `target_hp: i32`, `target_max_hp: i32`, `experience_reward: u32`, `reward_items: Vec<RuntimeItemRewardItemSnapshot>`, `turn_index: u32`, `last_action_function_id: Option<String>`, `last_action_text: Option<String>`, `last_result_text: Option<String>`, `last_damage_dealt: i32`, `last_damage_taken: i32`, `last_outcome: CombatOutcome`, `version: u32`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`story_session_id`, `runtime_session_id`, `actor_user_id`
```sql
SELECT * FROM battle_state WHERE battle_state_id = '<battle_state_id>';
SELECT * FROM battle_state WHERE story_session_id = '<story_session_id>';
SELECT * FROM battle_state WHERE runtime_session_id = '<runtime_session_id>';
SELECT * FROM battle_state WHERE actor_user_id = '<user_id>' ORDER BY updated_at DESC;
```
### `treasure_record`
- 作用:宝箱/奇遇结算记录,保存奖励物品、生命/法力/货币奖励和剧情提示。
- 结构:`treasure_record_id PK: String`, `runtime_session_id: String`, `story_session_id: String`, `actor_user_id: String`, `encounter_id: String`, `encounter_name: String`, `scene_id: Option<String>`, `scene_name: Option<String>`, `action: TreasureInteractionAction`, `reward_items: Vec<RuntimeItemRewardItemSnapshot>`, `reward_hp: u32`, `reward_mana: u32`, `reward_currency: u32`, `story_hint: Option<String>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`story_session_id`, `runtime_session_id`, `actor_user_id`, `encounter_id`
```sql
SELECT * FROM treasure_record WHERE treasure_record_id = '<treasure_record_id>';
SELECT * FROM treasure_record WHERE runtime_session_id = '<runtime_session_id>';
SELECT * FROM treasure_record WHERE encounter_id = '<encounter_id>';
```
### `quest_record`
- 作用:任务主表,保存任务来源、目标、进度、奖励、叙事绑定、步骤和完成/交付时间。
- 结构:`quest_id PK: String`, `runtime_session_id: String`, `story_session_id: Option<String>`, `actor_user_id: String`, `issuer_npc_id: String`, `issuer_npc_name: String`, `scene_id: Option<String>`, `chapter_id: Option<String>`, `act_id: Option<String>`, `thread_id: Option<String>`, `contract_id: Option<String>`, `title: String`, `description: String`, `summary: String`, `objective: QuestObjectiveSnapshot`, `progress: u32`, `status: QuestStatus`, `completion_notified: bool`, `reward: QuestRewardSnapshot`, `reward_text: String`, `narrative_binding: QuestNarrativeBindingSnapshot`, `steps: Vec<QuestStepSnapshot>`, `active_step_id: Option<String>`, `visible_stage: u32`, `hidden_flags: Vec<String>`, `discovered_fact_ids: Vec<String>`, `related_carrier_ids: Vec<String>`, `consequence_ids: Vec<String>`, `created_at: Timestamp`, `updated_at: Timestamp`, `completed_at: Option<Timestamp>`, `turned_in_at: Option<Timestamp>`
- 索引:`runtime_session_id`, `actor_user_id`, `issuer_npc_id`
```sql
SELECT * FROM quest_record WHERE quest_id = '<quest_id>';
SELECT * FROM quest_record WHERE runtime_session_id = '<runtime_session_id>';
SELECT * FROM quest_record WHERE actor_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM quest_record WHERE issuer_npc_id = '<npc_id>';
```
### `quest_log`
- 作用:任务事件流水,记录领取、信号推进、完成通知、交付等状态变化。
- 结构:`log_id PK: String`, `quest_id: String`, `runtime_session_id: String`, `actor_user_id: String`, `event_kind: QuestLogEventKind`, `status_after: QuestStatus`, `signal_kind: Option<QuestSignalKind>`, `signal: Option<QuestProgressSignal>`, `step_id: Option<String>`, `step_progress: Option<u32>`, `created_at: Timestamp`
- 索引:`quest_id`, `runtime_session_id`, `actor_user_id`
```sql
SELECT * FROM quest_log WHERE quest_id = '<quest_id>' ORDER BY created_at ASC;
SELECT * FROM quest_log WHERE runtime_session_id = '<runtime_session_id>';
SELECT * FROM quest_log WHERE actor_user_id = '<user_id>' ORDER BY created_at DESC;
```
### `player_progression`
- 作用:玩家成长主表,按用户保存等级、当前等级经验、总经验和待处理升级数。
- 结构:`user_id PK: String`, `level: u32`, `current_level_xp: u32`, `total_xp: u32`, `xp_to_next_level: u32`, `pending_level_ups: u32`, `last_granted_source: Option<PlayerProgressionGrantSource>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `user_id`
```sql
SELECT * FROM player_progression WHERE user_id = '<user_id>';
```
### `chapter_progression`
- 作用:章节成长预算和实际记账表,承接计划经验、实际任务/敌对经验、击败数和节奏档位。
- 结构:`chapter_progression_id PK: String`, `user_id: String`, `chapter_id: String`, `chapter_index: u32`, `total_chapters: u32`, `entry_pseudo_level_millis: u32`, `exit_pseudo_level_millis: u32`, `entry_level: u32`, `exit_level: u32`, `planned_total_xp: u32`, `planned_quest_xp: u32`, `planned_hostile_xp: u32`, `actual_quest_xp: u32`, `actual_hostile_xp: u32`, `expected_hostile_defeat_count: u32`, `actual_hostile_defeat_count: u32`, `level_at_entry: u32`, `level_at_exit: Option<u32>`, `pace_band: ChapterPaceBand`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`user_id`, `chapter_id`, `(user_id, chapter_id)`
```sql
SELECT * FROM chapter_progression WHERE chapter_progression_id = '<chapter_progression_id>';
SELECT * FROM chapter_progression WHERE user_id = '<user_id>';
SELECT * FROM chapter_progression WHERE user_id = '<user_id>' AND chapter_id = '<chapter_id>';
```
## 世界创作表
### `custom_world_profile`
- 作用:自定义世界正式工件真相表,承接作品库、发布、进入世界和软删除审计。
- 结构:`profile_id PK: String`, `owner_user_id: String`, `public_work_code: Option<String>`, `author_public_user_code: Option<String>`, `source_agent_session_id: Option<String>`, `publication_status: CustomWorldPublicationStatus`, `world_name: String`, `subtitle: String`, `summary_text: String`, `theme_mode: CustomWorldThemeMode`, `cover_image_src: Option<String>`, `profile_payload_json: String`, `playable_npc_count: u32`, `landmark_count: u32`, `author_display_name: String`, `published_at: Option<Timestamp>`, `deleted_at: Option<Timestamp>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`, `publication_status`
```sql
SELECT * FROM custom_world_profile WHERE profile_id = '<profile_id>';
SELECT * FROM custom_world_profile WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM custom_world_profile WHERE publication_status = 'Published';
```
### `custom_world_session`
- 作用:旧 custom-world/sessions 传统问答流会话表,不与 Agent 会话混存。
- 结构:`session_id PK: String`, `owner_user_id: String`, `generation_mode: CustomWorldGenerationMode`, `status: CustomWorldSessionStatus`, `setting_text: String`, `creator_intent_json: Option<String>`, `question_snapshot_json: String`, `result_payload_json: Option<String>`, `last_error_message: Option<String>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`
```sql
SELECT * FROM custom_world_session WHERE session_id = '<session_id>';
SELECT * FROM custom_world_session WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
```
### `custom_world_agent_session`
- 作用RPG 创作 Agent 会话聚合表,保存八锚点、草稿、发布门禁、进度、建议和 checkpoint。
- 结构:`session_id PK: String`, `owner_user_id: String`, `seed_text: String`, `current_turn: u32`, `progress_percent: u32`, `stage: RpgAgentStage`, `focus_card_id: Option<String>`, `anchor_content_json: String`, `creator_intent_json: Option<String>`, `creator_intent_readiness_json: String`, `anchor_pack_json: Option<String>`, `lock_state_json: Option<String>`, `draft_profile_json: Option<String>`, `last_assistant_reply: Option<String>`, `publish_gate_json: Option<String>`, `result_preview_json: Option<String>`, `pending_clarifications_json: String`, `quality_findings_json: String`, `suggested_actions_json: String`, `recommended_replies_json: String`, `asset_coverage_json: String`, `checkpoints_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`, `stage`
```sql
SELECT * FROM custom_world_agent_session WHERE session_id = '<session_id>';
SELECT * FROM custom_world_agent_session WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM custom_world_agent_session WHERE stage = '<stage>';
```
### `custom_world_agent_message`
- 作用RPG 创作 Agent 消息流水表,避免把聊天记录继续塞回 session 大 JSON。
- 结构:`message_id PK: String`, `session_id: String`, `role: RpgAgentMessageRole`, `kind: RpgAgentMessageKind`, `text: String`, `related_operation_id: Option<String>`, `created_at: Timestamp`
- 索引:`session_id`
```sql
SELECT * FROM custom_world_agent_message WHERE session_id = '<session_id>' ORDER BY created_at ASC;
```
### `custom_world_agent_operation`
- 作用RPG 创作 Agent 异步操作真相表,承接操作阶段、进度和错误。
- 结构:`operation_id PK: String`, `session_id: String`, `operation_type: RpgAgentOperationType`, `status: RpgAgentOperationStatus`, `phase_label: String`, `phase_detail: String`, `progress: u32`, `error_message: Option<String>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`session_id`
```sql
SELECT * FROM custom_world_agent_operation WHERE operation_id = '<operation_id>';
SELECT * FROM custom_world_agent_operation WHERE session_id = '<session_id>' ORDER BY updated_at DESC;
```
### `custom_world_draft_card`
- 作用RPG 创作草稿卡片表,拆出角色/地点/章节等卡片实体,支持详情、更新和资产状态查询。
- 结构:`card_id PK: String`, `session_id: String`, `kind: RpgAgentDraftCardKind`, `status: RpgAgentDraftCardStatus`, `title: String`, `subtitle: String`, `summary: String`, `linked_ids_json: String`, `warning_count: u32`, `asset_status: Option<CustomWorldRoleAssetStatus>`, `asset_status_label: Option<String>`, `detail_payload_json: Option<String>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`session_id`, `kind`
```sql
SELECT * FROM custom_world_draft_card WHERE card_id = '<card_id>';
SELECT * FROM custom_world_draft_card WHERE session_id = '<session_id>';
SELECT * FROM custom_world_draft_card WHERE kind = '<kind>';
```
### `custom_world_gallery_entry`
- 作用:公开画廊读模型,专供客户端订阅和广场展示,避免运行时从 profile 即席拼装。
- 可见性:`public`
- 结构:`profile_id PK: String`, `owner_user_id: String`, `public_work_code: String`, `author_public_user_code: String`, `author_display_name: String`, `world_name: String`, `subtitle: String`, `summary_text: String`, `cover_image_src: Option<String>`, `theme_mode: CustomWorldThemeMode`, `playable_npc_count: u32`, `landmark_count: u32`, `published_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`, `theme_mode`, `public_work_code`
```sql
SELECT * FROM custom_world_gallery_entry;
SELECT * FROM custom_world_gallery_entry WHERE owner_user_id = '<user_id>';
SELECT * FROM custom_world_gallery_entry WHERE theme_mode = '<theme_mode>';
SELECT * FROM custom_world_gallery_entry WHERE public_work_code = '<public_work_code>';
```
## 拼图表
### `puzzle_agent_session`
- 作用:拼图创作 Agent 会话表,保存种子、阶段、锚点包、草稿和已发布 profile。
- 结构:`session_id PK: String`, `owner_user_id: String`, `seed_text: String`, `current_turn: u32`, `progress_percent: u32`, `stage: PuzzleAgentStage`, `anchor_pack_json: String`, `draft_json: Option<String>`, `last_assistant_reply: Option<String>`, `published_profile_id: Option<String>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`
```sql
SELECT * FROM puzzle_agent_session WHERE session_id = '<session_id>';
SELECT * FROM puzzle_agent_session WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
```
### `puzzle_agent_message`
- 作用:拼图创作 Agent 聊天消息流水。
- 结构:`message_id PK: String`, `session_id: String`, `role: PuzzleAgentMessageRole`, `kind: PuzzleAgentMessageKind`, `text: String`, `created_at: Timestamp`
- 索引:`session_id`
```sql
SELECT * FROM puzzle_agent_message WHERE session_id = '<session_id>' ORDER BY created_at ASC;
```
### `puzzle_work_profile`
- 作用:拼图作品主表,保存作品信息、封面、发布状态、游玩次数和锚点包。
- 结构:`profile_id PK: String`, `work_id: String`, `owner_user_id: String`, `source_session_id: Option<String>`, `author_display_name: String`, `level_name: String`, `summary: String`, `theme_tags_json: String`, `cover_image_src: Option<String>`, `cover_asset_id: Option<String>`, `publication_status: PuzzlePublicationStatus`, `play_count: u32`, `anchor_pack_json: String`, `publish_ready: bool`, `created_at: Timestamp`, `updated_at: Timestamp`, `published_at: Option<Timestamp>`
- 索引:`owner_user_id`, `publication_status`
```sql
SELECT * FROM puzzle_work_profile WHERE profile_id = '<profile_id>';
SELECT * FROM puzzle_work_profile WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM puzzle_work_profile WHERE publication_status = 'Published';
```
### `puzzle_runtime_run`
- 作用:拼图游玩运行态,保存当前关卡、网格、已玩 profile 列表、标签和运行快照。
- 结构:`run_id PK: String`, `owner_user_id: String`, `entry_profile_id: String`, `current_profile_id: String`, `cleared_level_count: u32`, `current_level_index: u32`, `current_grid_size: u32`, `played_profile_ids_json: String`, `previous_level_tags_json: String`, `snapshot_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`
```sql
SELECT * FROM puzzle_runtime_run WHERE run_id = '<run_id>';
SELECT * FROM puzzle_runtime_run WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
```
## 大鱼吃小鱼表
### `big_fish_creation_session`
- 作用:大鱼吃小鱼创作会话表,保存种子、阶段、锚点包、草稿、资产覆盖和发布就绪状态。
- 结构:`session_id PK: String`, `owner_user_id: String`, `seed_text: String`, `current_turn: u32`, `progress_percent: u32`, `stage: BigFishCreationStage`, `anchor_pack_json: String`, `draft_json: Option<String>`, `asset_coverage_json: String`, `last_assistant_reply: Option<String>`, `publish_ready: bool`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`
```sql
SELECT * FROM big_fish_creation_session WHERE session_id = '<session_id>';
SELECT * FROM big_fish_creation_session WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
```
### `big_fish_agent_message`
- 作用:大鱼吃小鱼创作 Agent 消息流水。
- 结构:`message_id PK: String`, `session_id: String`, `role: BigFishAgentMessageRole`, `kind: BigFishAgentMessageKind`, `text: String`, `created_at: Timestamp`
- 索引:`session_id`
```sql
SELECT * FROM big_fish_agent_message WHERE session_id = '<session_id>' ORDER BY created_at ASC;
```
### `big_fish_asset_slot`
- 作用大鱼吃小鱼资产槽位表记录背景、鱼、动作等资产生成状态、URL 与 prompt 快照。
- 结构:`slot_id PK: String`, `session_id: String`, `asset_kind: BigFishAssetKind`, `level: Option<u32>`, `motion_key: Option<String>`, `status: BigFishAssetStatus`, `asset_url: Option<String>`, `prompt_snapshot: String`, `updated_at: Timestamp`
- 索引:`session_id`
```sql
SELECT * FROM big_fish_asset_slot WHERE slot_id = '<slot_id>';
SELECT * FROM big_fish_asset_slot WHERE session_id = '<session_id>';
```
### `big_fish_runtime_run`
- 作用:大鱼吃小鱼运行态表,保存当前 run 的快照、最后输入方向和 tick。
- 结构:`run_id PK: String`, `session_id: String`, `owner_user_id: String`, `status: BigFishRunStatus`, `snapshot_json: String`, `last_input_x: f32`, `last_input_y: f32`, `tick: u64`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`, `session_id`
```sql
SELECT * FROM big_fish_runtime_run WHERE run_id = '<run_id>';
SELECT * FROM big_fish_runtime_run WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM big_fish_runtime_run WHERE session_id = '<session_id>';
```
## 资产表
### `asset_object`
- 作用:正式资产对象元数据表,保存 OSS bucket/key、访问策略、大小、hash、版本和业务归属。
- 结构:`asset_object_id PK: String`, `bucket: String`, `object_key: String`, `access_policy: AssetObjectAccessPolicy`, `content_type: Option<String>`, `content_length: u64`, `content_hash: Option<String>`, `version: u32`, `source_job_id: Option<String>`, `owner_user_id: Option<String>`, `profile_id: Option<String>`, `entity_id: Option<String>`, `asset_kind: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`asset_kind`, `(bucket, object_key)`
```sql
SELECT * FROM asset_object WHERE asset_object_id = '<asset_object_id>';
SELECT * FROM asset_object WHERE bucket = '<bucket>' AND object_key = '<object_key>';
SELECT * FROM asset_object WHERE asset_kind = '<asset_kind>';
```
### `asset_entity_binding`
- 作用:资产到业务实体槽位的绑定表,例如把角色主图、场景背景、动作视频绑定到 profile/entity/slot。
- 结构:`binding_id PK: String`, `asset_object_id: String`, `entity_kind: String`, `entity_id: String`, `slot: String`, `asset_kind: String`, `owner_user_id: Option<String>`, `profile_id: Option<String>`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`(entity_kind, entity_id, slot)`, `asset_object_id`
```sql
SELECT * FROM asset_entity_binding WHERE binding_id = '<binding_id>';
SELECT * FROM asset_entity_binding WHERE entity_kind = '<entity_kind>' AND entity_id = '<entity_id>' AND slot = '<slot>';
SELECT * FROM asset_entity_binding WHERE asset_object_id = '<asset_object_id>';
```
## AI 任务表
### `ai_task`
- 作用AI 任务主表,保存任务类型、所属用户、来源模块、请求载荷、状态、最新输出和生命周期时间。
- 结构:`task_id PK: String`, `task_kind: AiTaskKind`, `owner_user_id: String`, `request_label: String`, `source_module: String`, `source_entity_id: Option<String>`, `request_payload_json: Option<String>`, `status: AiTaskStatus`, `failure_message: Option<String>`, `latest_text_output: Option<String>`, `latest_structured_payload_json: Option<String>`, `version: u32`, `created_at: Timestamp`, `started_at: Option<Timestamp>`, `completed_at: Option<Timestamp>`, `updated_at: Timestamp`
- 索引:`owner_user_id`, `status`, `task_kind`
```sql
SELECT * FROM ai_task WHERE task_id = '<task_id>';
SELECT * FROM ai_task WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM ai_task WHERE status = '<status>';
SELECT * FROM ai_task WHERE task_kind = '<task_kind>';
```
### `ai_task_stage`
- 作用AI 任务阶段表,保存每个阶段的标签、详情、顺序、状态、输出和警告。
- 结构:`task_stage_id PK: String`, `task_id: String`, `stage_kind: AiTaskStageKind`, `label: String`, `detail: String`, `stage_order: u32`, `status: AiTaskStageStatus`, `text_output: Option<String>`, `structured_payload_json: Option<String>`, `warning_messages: Vec<String>`, `started_at: Option<Timestamp>`, `completed_at: Option<Timestamp>`
- 索引:`task_id`, `(task_id, stage_order)`
```sql
SELECT * FROM ai_task_stage WHERE task_id = '<task_id>' ORDER BY stage_order ASC;
SELECT * FROM ai_task_stage WHERE task_stage_id = '<task_stage_id>';
```
### `ai_text_chunk`
- 作用AI 流式文本增量表,按任务、阶段和 sequence 保存文本 delta。
- 结构:`text_chunk_row_id PK: String`, `chunk_id: String`, `task_id: String`, `stage_kind: AiTaskStageKind`, `sequence: u32`, `delta_text: String`, `created_at: Timestamp`
- 索引:`task_id`, `(task_id, stage_kind, sequence)`
```sql
SELECT * FROM ai_text_chunk WHERE task_id = '<task_id>' ORDER BY sequence ASC;
SELECT * FROM ai_text_chunk WHERE task_id = '<task_id>' AND stage_kind = '<stage_kind>' ORDER BY sequence ASC;
```
### `ai_result_reference`
- 作用AI 任务产物引用表把任务结果关联到资产、profile、业务实体等外部结果。
- 结构:`result_reference_row_id PK: String`, `result_ref_id: String`, `task_id: String`, `reference_kind: AiResultReferenceKind`, `reference_id: String`, `label: Option<String>`, `created_at: Timestamp`
- 索引:`task_id`
```sql
SELECT * FROM ai_result_reference WHERE result_reference_row_id = '<row_id>';
SELECT * FROM ai_result_reference WHERE task_id = '<task_id>' ORDER BY created_at ASC;
```
## 当前维护风险
- `story_session``story_event``npc_state``inventory_slot``battle_state``treasure_record``quest_record``quest_log``player_progression``chapter_progression``src/lib.rs``src/gameplay/mod.rs` 都能看到表定义。当前编译入口以 `src/lib.rs` 为准;后续完成拆分时,需要删除重复定义或正式挂载子模块,并同步更新本文。
- `custom_world/*` 子模块中也保留了一份表骨架;当前生成绑定与 `src/lib.rs` 对齐,包含 `public_work_code``author_public_user_code``custom_world_gallery_entry.public_work_code` 索引。维护时不要只看子模块文件。

View File

@@ -0,0 +1,20 @@
# 世界草稿基本设定编辑入口修复 2026-04-25
## 问题
世界草稿页中“世界概述”和“基本设定”两个区块原先都使用 `{ kind: 'world' }` 打开编辑器,导致点击“基本设定”仍进入世界概述编辑页面。
RPG 草稿的基本设定字段由八锚点内容拼接而来,很多内容天然是以分号分隔的碎片化标签,不适合在目录页继续按长段落展示。
## 落地
1. 新增编辑目标 `{ kind: 'foundation' }`,专门打开“编辑基本设定”面板。
2. “世界概述”继续使用 `{ kind: 'world' }`,只编辑世界名称、副标题、概述、基调、目标等概述字段。
3. “基本设定”使用 `{ kind: 'foundation' }`,编辑八锚点结构化字段,并保存回 `anchorContent`、关键顶层字段与 `creatorIntent`
4. 基本设定目录页与编辑页都通过分号解析标签,支持中文分号与英文分号。
## 约束
- 前端只做字段展示、拆分和编辑回写,不改变后端草稿生成语义。
- 分号解析只影响 UI 展示与编辑草稿,不在读取时改写原始中文内容。
- 后续新增基本设定字段时,应优先扩展 `customWorldFoundationEntries`,避免在目录页和编辑页各自拼接字段。

View File

@@ -0,0 +1,16 @@
export interface ParseCreationAgentDocumentInputRequest {
fileName: string;
contentType?: string | null;
contentBase64: string;
}
export interface CreationAgentDocumentInputPayload {
fileName: string;
contentType?: string | null;
sizeBytes: number;
text: string;
}
export interface ParseCreationAgentDocumentInputResponse {
document: CreationAgentDocumentInputPayload;
}

View File

@@ -66,6 +66,7 @@ export interface RpgAgentOperationRecord {
phaseDetail: string; phaseDetail: string;
progress: number; progress: number;
error?: string | null; error?: string | null;
updatedAt?: string | null;
} }
export type RpgAgentActionRequest = export type RpgAgentActionRequest =

View File

@@ -51,7 +51,11 @@ export type ProfileWalletLedgerEntry = {
id: string; id: string;
amountDelta: number; amountDelta: number;
balanceAfter: number; balanceAfter: number;
sourceType: 'snapshot_sync'; sourceType:
| 'snapshot_sync'
| 'invite_inviter_reward'
| 'invite_invitee_reward'
| 'points_recharge';
createdAt: string; createdAt: string;
}; };
@@ -59,6 +63,74 @@ export type ProfileWalletLedgerResponse = {
entries: ProfileWalletLedgerEntry[]; entries: ProfileWalletLedgerEntry[];
}; };
export type ProfileRechargeProductKind = 'points' | 'membership';
export type ProfileMembershipStatus = 'normal' | 'active';
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
export type ProfileRechargeOrderStatus = 'paid';
export type ProfileRechargeProduct = {
productId: string;
title: string;
priceCents: number;
kind: ProfileRechargeProductKind;
pointsAmount: number;
bonusPoints: number;
durationDays: number;
badgeLabel: string;
description: string;
tier: ProfileMembershipTier;
};
export type ProfileMembershipBenefit = {
benefitName: string;
normalValue: string;
monthValue: string;
seasonValue: string;
yearValue: string;
};
export type ProfileMembership = {
status: ProfileMembershipStatus;
tier: ProfileMembershipTier;
startedAt: string | null;
expiresAt: string | null;
updatedAt: string | null;
};
export type ProfileRechargeOrder = {
orderId: string;
productId: string;
productTitle: string;
kind: ProfileRechargeProductKind;
amountCents: number;
status: ProfileRechargeOrderStatus;
paymentChannel: string;
paidAt: string;
createdAt: string;
pointsDelta: number;
membershipExpiresAt: string | null;
};
export type ProfileRechargeCenterResponse = {
walletBalance: number;
membership: ProfileMembership;
pointProducts: ProfileRechargeProduct[];
membershipProducts: ProfileRechargeProduct[];
benefits: ProfileMembershipBenefit[];
latestOrder: ProfileRechargeOrder | null;
hasPointsRecharged: boolean;
};
export type CreateProfileRechargeOrderRequest = {
productId: string;
paymentChannel?: string;
};
export type CreateProfileRechargeOrderResponse = {
order: ProfileRechargeOrder;
center: ProfileRechargeCenterResponse;
};
export type ProfilePlayedWorkSummary = { export type ProfilePlayedWorkSummary = {
worldKey: string; worldKey: string;
ownerUserId: string | null; ownerUserId: string | null;

View File

@@ -45,6 +45,7 @@ use crate::{
character_visual_assets::{ character_visual_assets::{
generate_character_visual, get_character_visual_job, publish_character_visual, generate_character_visual, get_character_visual_job, publish_character_visual,
}, },
creation_agent_document_input::parse_creation_agent_document_input,
custom_world::{ custom_world::{
create_custom_world_agent_session, delete_custom_world_agent_session, create_custom_world_agent_session, delete_custom_world_agent_session,
delete_custom_world_library_profile, execute_custom_world_agent_action, delete_custom_world_library_profile, execute_custom_world_agent_action,
@@ -91,7 +92,10 @@ use crate::{
}, },
runtime_chat::stream_runtime_npc_chat_turn, runtime_chat::stream_runtime_npc_chat_turn,
runtime_inventory::get_runtime_inventory_state, runtime_inventory::get_runtime_inventory_state,
runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger}, runtime_profile::{
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
get_profile_recharge_center, get_profile_wallet_ledger,
},
runtime_save::{ runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
put_runtime_snapshot, resume_profile_save_archive, put_runtime_snapshot, resume_profile_save_archive,
@@ -245,6 +249,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.route(
"/api/runtime/creation-agent/document-inputs/parse",
post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route( .route(
"/api/auth/logout", "/api/auth/logout",
post(logout) post(logout)
@@ -773,6 +784,34 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.route(
"/api/runtime/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route( .route(
"/api/runtime/profile/play-stats", "/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(

View File

@@ -1,5 +1,5 @@
use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage}; use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage};
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use platform_llm::LlmClient;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json}; use serde_json::{Value as JsonValue, json};
use spacetime_client::{ use spacetime_client::{
@@ -10,6 +10,9 @@ use crate::creation_agent_anchor_templates::{
get_creation_agent_anchor_template, render_anchor_question_block, get_creation_agent_anchor_template, render_anchor_question_block,
}; };
use crate::creation_agent_chat::render_quick_fill_extra_rules; use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnRequest<'a> { pub(crate) struct BigFishAgentTurnRequest<'a> {
@@ -109,41 +112,26 @@ const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出
pub(crate) async fn run_big_fish_agent_turn<F>( pub(crate) async fn run_big_fish_agent_turn<F>(
request: BigFishAgentTurnRequest<'_>, request: BigFishAgentTurnRequest<'_>,
mut on_reply_update: F, on_reply_update: F,
) -> Result<BigFishAgentTurnResult, BigFishAgentTurnError> ) -> Result<BigFishAgentTurnResult, BigFishAgentTurnError>
where where
F: FnMut(&str), F: FnMut(&str),
{ {
let llm_client = request
.llm_client
.ok_or_else(|| BigFishAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
let prompt = build_big_fish_agent_prompt(request.session, request.quick_fill_requested); let prompt = build_big_fish_agent_prompt(request.session, request.quick_fill_requested);
let mut latest_reply_text = String::new(); let turn_output = stream_creation_agent_json_turn(
let response = llm_client request.llm_client,
.stream_text( format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
LlmTextRequest::new(vec![ "请按约定输出这一轮的 JSON。",
LlmMessage::system(format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}")), CreationAgentLlmTurnErrorMessages {
LlmMessage::user("请按约定输出这一轮的 JSON"), model_unavailable: "当前模型不可用,请稍后重试",
]), generation_failed: "大鱼吃小鱼聊天生成失败,请稍后重试。",
|delta: &LlmStreamDelta| { parse_failed: "大鱼吃小鱼聊天结果解析失败,请稍后重试。",
if let Some(reply_progress) = },
extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) on_reply_update,
&& reply_progress != latest_reply_text BigFishAgentTurnError::new,
{ )
latest_reply_text = reply_progress.clone(); .await?;
on_reply_update(reply_progress.as_str()); let output = parse_big_fish_model_output(&turn_output.parsed)?;
}
},
)
.await
.map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼聊天生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果解析失败,请稍后重试。"))?;
let output = parse_big_fish_model_output(&parsed)?;
if output.reply_text != latest_reply_text {
on_reply_update(output.reply_text.as_str());
}
Ok(BigFishAgentTurnResult { Ok(BigFishAgentTurnResult {
assistant_reply_text: output.reply_text, assistant_reply_text: output.reply_text,
@@ -373,57 +361,6 @@ fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus {
} }
} }
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
if let Ok(value) = serde_json::from_str::<JsonValue>(text) {
return Ok(value);
}
let Some(start) = text.find('{') else {
return serde_json::from_str(text);
};
let Some(end) = text.rfind('}') else {
return serde_json::from_str(text);
};
serde_json::from_str(&text[start..=end])
}
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let marker = "\"replyText\"";
let marker_index = text.find(marker)?;
let after_marker = &text[marker_index + marker.len()..];
let colon_index = after_marker.find(':')?;
let after_colon = after_marker[colon_index + 1..].trim_start();
let content = after_colon.strip_prefix('"')?;
let mut result = String::new();
let mut escaped = false;
for character in content.chars() {
if escaped {
result.push(match character {
'n' => '\n',
'r' => '\r',
't' => '\t',
'"' => '"',
'\\' => '\\',
other => other,
});
escaped = false;
continue;
}
if character == '\\' {
escaped = true;
continue;
}
if character == '"' {
return Some(result);
}
result.push(character);
}
if result.is_empty() {
None
} else {
Some(result)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::build_big_fish_agent_prompt; use super::build_big_fish_agent_prompt;

View File

@@ -35,6 +35,7 @@ use crate::{
api_response::json_success_body, api_response::json_success_body,
custom_world_asset_prompts::{ custom_world_asset_prompts::{
build_character_visual_negative_prompt, build_character_visual_prompt, build_character_visual_negative_prompt, build_character_visual_prompt,
build_fallback_moderation_safe_character_visual_prompt,
}, },
http_error::AppError, http_error::AppError,
request_context::RequestContext, request_context::RequestContext,
@@ -47,6 +48,7 @@ const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character"; const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
const CHARACTER_VISUAL_SLOT: &str = "primary_visual"; const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500; const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500;
const CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS: u8 = 2;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct GeneratedCharacterPrimaryVisual { pub(crate) struct GeneratedCharacterPrimaryVisual {
@@ -76,6 +78,10 @@ pub async fn generate_character_visual(
payload.prompt_text.as_str(), payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(), payload.character_brief_text.as_deref(),
); );
let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let character_id = normalize_required_text(payload.character_id.as_str(), "character"); let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL); let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024"); let size = normalize_required_text(payload.size.as_str(), "1024*1024");
@@ -170,6 +176,7 @@ pub async fn generate_character_visual(
&settings, &settings,
model.as_str(), model.as_str(),
prompt.as_str(), prompt.as_str(),
fallback_prompt.as_str(),
size.as_str(), size.as_str(),
candidate_count, candidate_count,
&reference_images, &reference_images,
@@ -185,7 +192,7 @@ pub async fn generate_character_visual(
generated generated
.actual_prompt .actual_prompt
.clone() .clone()
.unwrap_or_else(|| prompt.clone()), .unwrap_or_else(|| generated.submitted_prompt.clone()),
), ),
structured_payload_json: Some( structured_payload_json: Some(
json!({ json!({
@@ -193,6 +200,7 @@ pub async fn generate_character_visual(
"taskId": generated.task_id, "taskId": generated.task_id,
"model": model, "model": model,
"imageCount": generated.images.len(), "imageCount": generated.images.len(),
"moderationFallbackApplied": generated.moderation_fallback_applied,
}) })
.to_string(), .to_string(),
), ),
@@ -305,6 +313,10 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
payload.prompt_text.as_str(), payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(), payload.character_brief_text.as_deref(),
); );
let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let character_id = normalize_required_text(payload.character_id.as_str(), "character"); let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL); let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024"); let size = normalize_required_text(payload.size.as_str(), "1024*1024");
@@ -327,6 +339,7 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
&settings, &settings,
model.as_str(), model.as_str(),
prompt.as_str(), prompt.as_str(),
fallback_prompt.as_str(),
size.as_str(), size.as_str(),
1, 1,
&[], &[],
@@ -908,6 +921,57 @@ async fn resolve_reference_image_as_data_url(
} }
async fn create_character_visual_generation( async fn create_character_visual_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
prompt: &str,
fallback_prompt: &str,
size: &str,
candidate_count: u32,
reference_images: &[String],
) -> Result<GeneratedCharacterVisuals, AppError> {
let mut active_prompt = prompt;
let mut moderation_fallback_applied = false;
let mut last_moderation_error = String::new();
for attempt_index in 0..CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS {
match create_character_visual_generation_once(
http_client,
settings,
model,
active_prompt,
size,
candidate_count,
reference_images,
)
.await
{
Ok(mut generated) => {
generated.submitted_prompt = active_prompt.to_string();
generated.moderation_fallback_applied = moderation_fallback_applied;
return Ok(generated);
}
Err(error)
if attempt_index == 0
&& !fallback_prompt.trim().is_empty()
&& fallback_prompt.trim() != prompt.trim()
&& is_dashscope_moderation_error(&error) =>
{
last_moderation_error = error.body_text();
active_prompt = fallback_prompt;
moderation_fallback_applied = true;
}
Err(error) => return Err(error),
}
}
Err(map_dashscope_request_error(format!(
"角色主形象安全兜底重试未返回结果:{}",
last_moderation_error.if_empty_then("上游内容审核仍未通过。")
)))
}
async fn create_character_visual_generation_once(
http_client: &reqwest::Client, http_client: &reqwest::Client,
settings: &DashScopeSettings, settings: &DashScopeSettings,
model: &str, model: &str,
@@ -1025,6 +1089,8 @@ async fn create_character_visual_generation(
return Ok(GeneratedCharacterVisuals { return Ok(GeneratedCharacterVisuals {
task_id, task_id,
actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"), actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"),
submitted_prompt: prompt.to_string(),
moderation_fallback_applied: false,
images, images,
}); });
} }
@@ -1362,9 +1428,23 @@ fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppEr
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope", "provider": "dashscope",
"message": parse_api_error_message(raw_text, fallback_message), "message": parse_api_error_message(raw_text, fallback_message),
"raw": raw_text.trim(),
})) }))
} }
fn is_dashscope_moderation_error(error: &AppError) -> bool {
let text = error.body_text();
let normalized = text.to_ascii_lowercase();
normalized.contains("ipinfringementsuspect")
|| normalized.contains("inappropriate")
|| normalized.contains("sensitive")
|| normalized.contains("risk")
|| text.contains("内容审核")
|| text.contains("疑似侵权")
|| text.contains("IP 侵权")
|| text.contains("知识产权")
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) { fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value { match value {
Value::Array(entries) => { Value::Array(entries) => {
@@ -1962,6 +2042,8 @@ struct DashScopeSettings {
struct GeneratedCharacterVisuals { struct GeneratedCharacterVisuals {
task_id: String, task_id: String,
actual_prompt: Option<String>, actual_prompt: Option<String>,
submitted_prompt: String,
moderation_fallback_applied: bool,
images: Vec<DownloadedGeneratedImage>, images: Vec<DownloadedGeneratedImage>,
} }
@@ -1990,7 +2072,30 @@ mod tests {
assert!(prompt.contains("潮雾港向导")); assert!(prompt.contains("潮雾港向导"));
assert!(prompt.contains("右向斜侧身")); assert!(prompt.contains("右向斜侧身"));
assert!(prompt.contains("纯绿色背景")); assert!(prompt.contains("纯绿色绿幕"));
}
#[test]
fn fallback_character_visual_prompt_removes_risky_specific_names() {
let prompt = build_fallback_moderation_safe_character_visual_prompt(
"艾瑞克,银发剑士,红色长披风",
Some("某知名设定参考"),
);
assert!(prompt.contains("原创"));
assert!(prompt.contains("不参考任何现有"));
assert!(!prompt.contains("艾瑞克"));
assert!(!prompt.contains("某知名设定参考"));
}
#[test]
fn dashscope_ip_infringement_error_uses_moderation_fallback() {
let error = map_dashscope_upstream_error(
r#"{"request_id":"a18fb05d","output":{"task_id":"cb768c95","task_status":"FAILED","code":"IPInfringementSuspect","message":"Input data is suspected of being involved in IP infringement."}}"#,
"角色主形象任务执行失败。",
);
assert!(is_dashscope_moderation_error(&error));
} }
#[test] #[test]

View File

@@ -0,0 +1,324 @@
use axum::{Json, extract::Extension, http::StatusCode};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde_json::{Value, json};
use shared_contracts::creation_agent_document_input::{
CreationAgentDocumentInputPayload, ParseCreationAgentDocumentInputRequest,
ParseCreationAgentDocumentInputResponse,
};
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
};
const MAX_DOCUMENT_INPUT_BYTES: usize = 256 * 1024;
const MAX_DOCUMENT_INPUT_BASE64_CHARS: usize = 360 * 1024;
const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "csv", "json"];
pub async fn parse_creation_agent_document_input(
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<ParseCreationAgentDocumentInputRequest>,
) -> Result<Json<Value>, AppError> {
let file_name = normalize_file_name(&payload.file_name)?;
ensure_supported_extension(&file_name)?;
let content_base64 = payload.content_base64.trim();
if content_base64.len() > MAX_DOCUMENT_INPUT_BASE64_CHARS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档过大,请上传 256KB 以内的文本文件。",
"field": "contentBase64",
"maxSizeBytes": MAX_DOCUMENT_INPUT_BYTES,
})),
);
}
let decoded = BASE64_STANDARD.decode(content_base64).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档内容编码无效,请重新选择文件。",
"field": "contentBase64",
}))
})?;
if decoded.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档内容为空,请选择有内容的文件。",
"field": "contentBase64",
})),
);
}
if decoded.len() > MAX_DOCUMENT_INPUT_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档过大,请上传 256KB 以内的文本文件。",
"field": "contentBase64",
"maxSizeBytes": MAX_DOCUMENT_INPUT_BYTES,
"actualSizeBytes": decoded.len(),
})),
);
}
let text = String::from_utf8(decoded.clone()).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
"field": "contentBase64",
}))
})?;
let normalized_text = normalize_document_text(&text);
if normalized_text.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档解析后没有可用文本,请换一个文件。",
"field": "contentBase64",
})),
);
}
Ok(json_success_body(
Some(&request_context),
ParseCreationAgentDocumentInputResponse {
document: CreationAgentDocumentInputPayload {
file_name,
content_type: payload
.content_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
size_bytes: decoded.len(),
text: normalized_text,
},
},
))
}
fn normalize_file_name(value: &str) -> Result<String, AppError> {
let normalized = value
.trim()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.trim()
.to_string();
if normalized.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "缺少文档文件名。",
"field": "fileName",
})),
);
}
Ok(normalized)
}
fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> {
let extension = file_name
.rsplit_once('.')
.map(|(_, extension)| extension.trim().to_ascii_lowercase())
.filter(|extension| !extension.is_empty())
.ok_or_else(|| unsupported_document_error(file_name))?;
if !SUPPORTED_DOCUMENT_EXTENSIONS.contains(&extension.as_str()) {
return Err(unsupported_document_error(file_name));
}
Ok(())
}
fn unsupported_document_error(file_name: &str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 txt、md、csv、json 文本文档。",
"field": "fileName",
"fileName": file_name,
"supportedExtensions": SUPPORTED_DOCUMENT_EXTENSIONS,
}))
}
fn normalize_document_text(value: &str) -> String {
value
.trim_start_matches('\u{feff}')
.replace("\r\n", "\n")
.replace('\r', "\n")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use axum::{body::Body, http::Request};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use http_body_util::BodyExt;
use module_auth::{PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use std::path::PathBuf;
use time::OffsetDateTime;
use tower::ServiceExt;
use super::MAX_DOCUMENT_INPUT_BASE64_CHARS;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn parse_document_input_returns_text_payload() {
let state = build_test_state("ok").await;
let access_token = seed_authenticated_token(&state, "13800138110").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.md",
"contentType": "text/markdown",
"contentBase64": BASE64_STANDARD.encode("第一章\r\n潮湿的港口")
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["document"]["fileName"],
json!("世界设定.md")
);
assert_eq!(
payload["data"]["document"]["text"],
json!("第一章\n潮湿的港口")
);
}
#[tokio::test]
async fn parse_document_input_rejects_unsupported_extension() {
let state = build_test_state("bad-ext").await;
let access_token = seed_authenticated_token(&state, "13800138111").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.docx",
"contentBase64": BASE64_STANDARD.encode("binary")
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn parse_document_input_rejects_large_base64_before_decode() {
let state = build_test_state("large-base64").await;
let access_token = seed_authenticated_token(&state, "13800138112").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.txt",
"contentBase64": "A".repeat(MAX_DOCUMENT_INPUT_BASE64_CHARS + 1)
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
async fn seed_authenticated_token(state: &AppState, phone_number: &str) -> String {
let now = OffsetDateTime::now_utc();
state
.phone_auth_service()
.send_code(
SendPhoneCodeInput {
phone_number: phone_number.to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let user = state
.phone_auth_service()
.login(
PhoneLoginInput {
phone_number: phone_number.to_string(),
verify_code: "123456".to_string(),
},
now + time::Duration::seconds(1),
)
.await
.expect("phone login should create user")
.user;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id,
session_id: "sess_creation_doc_input".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some(user.display_name),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
async fn build_test_state(label: &str) -> AppState {
let mut config = AppConfig::default();
config.auth_store_path = PathBuf::from(format!(
".codex-temp/api-server-auth-store-creation-doc-{label}.json"
));
let _ = std::fs::remove_file(&config.auth_store_path);
AppState::new(config).expect("state should build")
}
}

View File

@@ -0,0 +1,170 @@
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde_json::Value as JsonValue;
#[derive(Clone, Copy, Debug)]
pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> {
pub model_unavailable: &'a str,
pub generation_failed: &'a str,
pub parse_failed: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct CreationAgentJsonTurnOutput {
pub parsed: JsonValue,
}
/**
* 创作 Agent 的通用流式 JSON turn 调用。
* 这里只处理跨玩法一致的 LLM 调用骨架prompt 内容和领域 JSON 解析仍由调用方负责。
*/
pub(crate) async fn stream_creation_agent_json_turn<F, E>(
llm_client: Option<&LlmClient>,
system_prompt: String,
user_prompt: impl Into<String>,
messages: CreationAgentLlmTurnErrorMessages<'_>,
mut on_reply_update: F,
build_error: impl Fn(String) -> E,
) -> Result<CreationAgentJsonTurnOutput, E>
where
F: FnMut(&str),
{
let llm_client =
llm_client.ok_or_else(|| build_error(messages.model_unavailable.to_string()))?;
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt.into()),
]),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
{
latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
)
.await
.map_err(|_| build_error(messages.generation_failed.to_string()))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| build_error(messages.parse_failed.to_string()))?;
let reply_text = read_reply_text(&parsed);
if let Some(reply_text) = reply_text.as_deref()
&& reply_text != latest_reply_text
{
on_reply_update(reply_text);
}
Ok(CreationAgentJsonTurnOutput { parsed })
}
pub(crate) async fn request_creation_agent_json_turn<E>(
llm_client: &LlmClient,
system_prompt: String,
user_prompt: String,
build_error: impl Fn(String) -> E,
) -> Result<JsonValue, E> {
let response = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]))
.await
.map_err(|error| build_error(error.to_string()))?;
parse_json_response_text(response.content.as_str())
.map_err(|error| build_error(error.to_string()))
}
pub(crate) fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
pub(crate) fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
fn read_reply_text(parsed: &JsonValue) -> Option<String> {
parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::{extract_reply_text_from_partial_json, parse_json_response_text};
#[test]
fn extracts_reply_text_from_partial_json_with_chinese_text() {
let partial_json = r#"{"replyText":"你好,潮雾列岛","progressPercent":32"#;
let extracted = extract_reply_text_from_partial_json(partial_json);
assert_eq!(extracted.as_deref(), Some("你好,潮雾列岛"));
}
#[test]
fn parses_json_inside_model_markdown_noise() {
let parsed = parse_json_response_text("```json\n{\"replyText\":\"\"}\n```")
.expect("应能截取模型返回中的 JSON 对象");
assert_eq!(parsed["replyText"].as_str(), Some(""));
}
}

View File

@@ -41,6 +41,7 @@ use spacetime_client::{
CustomWorldWorkSummaryRecord, SpacetimeClientError, CustomWorldWorkSummaryRecord, SpacetimeClientError,
}; };
use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant}; use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tracing::info; use tracing::info;
@@ -71,6 +72,92 @@ use crate::{
}; };
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3; const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
const DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START: u32 = 12;
const DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_DONE: u32 = 97;
const DRAFT_FOUNDATION_PROGRESS_ASSET_START: u32 = 97;
const DRAFT_FOUNDATION_PROGRESS_CARD_START: u32 = 98;
const DRAFT_FOUNDATION_PROGRESS_WRITEBACK_START: u32 = 99;
const DRAFT_ROLE_ASSET_TEXT_FIELDS: [&str; 3] = [
"visualDescription",
"actionDescription",
"sceneVisualDescription",
];
fn timestamp_micros_to_rfc3339(value: i64) -> String {
match OffsetDateTime::from_unix_timestamp_nanos(i128::from(value) * 1_000) {
Ok(timestamp) => timestamp
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
Err(_) => "1970-01-01T00:00:00Z".to_string(),
}
}
fn fallback_draft_foundation_failure_progress(phase_label: &str) -> u32 {
if phase_label.contains("写入") {
return DRAFT_FOUNDATION_PROGRESS_WRITEBACK_START;
}
if phase_label.contains("草稿卡") {
return DRAFT_FOUNDATION_PROGRESS_CARD_START;
}
if phase_label.contains("素材")
|| phase_label.contains("角色主形象")
|| phase_label.contains("幕背景图")
{
return DRAFT_FOUNDATION_PROGRESS_ASSET_START;
}
if phase_label.contains("底稿") {
return DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_DONE;
}
DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START
}
fn reusable_draft_profile_for_asset_generation(
session: &CustomWorldAgentSessionRecord,
) -> Option<Value> {
let object = session
.draft_profile
.as_object()
.filter(|object| !object.is_empty())?;
let profile = Value::Object(object.clone());
match missing_role_asset_text_report(&profile) {
Some(report) => {
// 中文注释:旧失败会话可能保存了缺少角色形象文本的半成品底稿;
// 这种底稿不能直接续跑到生图阶段,必须回到文本底稿链路重新生成。
tracing::warn!(
session_id = %session.session_id,
missing_report = %report,
"已保存 RPG 底稿缺少角色形象设定文本,跳过复用并重新生成底稿"
);
None
}
None => Some(profile),
}
}
fn missing_role_asset_text_report(draft_profile: &Value) -> Option<String> {
let profile_object = draft_profile.as_object()?;
let mut missing_items = Vec::new();
for key in ["playableNpcs", "storyNpcs"] {
if let Some(roles) = profile_object.get(key).and_then(Value::as_array) {
for (index, role) in roles.iter().enumerate() {
let name = json_text_from_value(role, "name")
.unwrap_or_else(|| format!("{}-{}", key, index + 1));
let missing_fields = DRAFT_ROLE_ASSET_TEXT_FIELDS
.into_iter()
.filter(|field| json_text_from_value(role, field).is_none())
.collect::<Vec<_>>();
if !missing_fields.is_empty() {
missing_items.push(format!("角色「{name}」缺少 {}", missing_fields.join("/")));
}
}
}
}
if missing_items.is_empty() {
None
} else {
Some(missing_items.join(""))
}
}
pub async fn get_custom_world_library( pub async fn get_custom_world_library(
State(state): State<AppState>, State(state): State<AppState>,
@@ -1077,6 +1164,40 @@ pub async fn execute_custom_world_agent_action(
})?; })?;
generation_result.payload_json generation_result.payload_json
} }
} else if action == "publish_world" {
let mut publish_payload = serde_json::to_value(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?;
if let Some(object) = publish_payload.as_object_mut() {
// 发布到广场时必须写入真实作者公开信息,避免 gallery 投影落成匿名兜底数据。
object.insert(
"authorPublicUserCode".to_string(),
Value::String(resolve_author_public_user_code(
&state,
&authenticated,
&request_context,
)?),
);
object.insert(
"authorDisplayName".to_string(),
Value::String(resolve_author_display_name(&state, &authenticated)),
);
}
serde_json::to_string(&publish_payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?
} else { } else {
serde_json::to_string(&payload).map_err(|error| { serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response( custom_world_error_response(
@@ -1142,7 +1263,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed", "failed",
"底稿生成失败", "底稿生成失败",
"服务端尚未配置可用的 LLM API Key", "服务端尚未配置可用的 LLM API Key",
100, DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START,
Some("服务端尚未配置可用的 LLM API Key".to_string()), Some("服务端尚未配置可用的 LLM API Key".to_string()),
) )
.await; .await;
@@ -1153,7 +1274,31 @@ fn spawn_custom_world_draft_foundation_job(
let progress_session_id = session.session_id.clone(); let progress_session_id = session.session_id.clone();
let progress_owner_user_id = owner_user_id.clone(); let progress_owner_user_id = owner_user_id.clone();
let progress_operation_id = operation_id.clone(); let progress_operation_id = operation_id.clone();
let draft_result = let existing_draft_profile = reusable_draft_profile_for_asset_generation(&session);
let draft_result = if let Some(profile) = existing_draft_profile {
// 失败后“继续生成草稿”复用已经写入 session 的底稿,
// 只继续执行素材补齐、草稿卡编译和结果页写回。
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"running",
"继续生成草稿",
"已读取上次保存的世界底稿,正在继续补齐素材与结果页。",
DRAFT_FOUNDATION_PROGRESS_ASSET_START,
None,
)
.await;
match serde_json::to_string(&profile) {
Ok(draft_profile_json) => Ok(
crate::custom_world_foundation_draft::CustomWorldFoundationDraftResult {
draft_profile_json,
},
),
Err(error) => Err(format!("已保存底稿序列化失败:{error}")),
}
} else {
generate_custom_world_foundation_draft(&llm_client, &session, move |progress| { generate_custom_world_foundation_draft(&llm_client, &session, move |progress| {
let progress_state = progress_state.clone(); let progress_state = progress_state.clone();
let session_id = progress_session_id.clone(); let session_id = progress_session_id.clone();
@@ -1174,7 +1319,8 @@ fn spawn_custom_world_draft_foundation_job(
.await; .await;
}); });
}) })
.await; .await
};
let draft_result = match draft_result { let draft_result = match draft_result {
Ok(result) => result, Ok(result) => result,
@@ -1187,7 +1333,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed", "failed",
"底稿生成失败", "底稿生成失败",
message.clone().as_str(), message.clone().as_str(),
100, fallback_draft_foundation_failure_progress("底稿生成失败"),
Some(message.clone()), Some(message.clone()),
) )
.await; .await;
@@ -1208,7 +1354,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed", "failed",
"底稿素材生成失败", "底稿素材生成失败",
message.as_str(), message.as_str(),
100, fallback_draft_foundation_failure_progress("底稿素材生成失败"),
Some(message.clone()), Some(message.clone()),
) )
.await; .await;
@@ -1224,7 +1370,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed", "failed",
"底稿素材生成失败", "底稿素材生成失败",
message.as_str(), message.as_str(),
100, fallback_draft_foundation_failure_progress("底稿素材生成失败"),
Some(message.clone()), Some(message.clone()),
) )
.await; .await;
@@ -1316,7 +1462,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed", "failed",
"底稿素材写回失败", "底稿素材写回失败",
message.as_str(), message.as_str(),
100, fallback_draft_foundation_failure_progress("底稿素材写回失败"),
Some(message.clone()), Some(message.clone()),
) )
.await; .await;
@@ -1356,7 +1502,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed", "failed",
"底稿写入失败", "底稿写入失败",
message.clone().as_str(), message.clone().as_str(),
100, fallback_draft_foundation_failure_progress("底稿写入失败"),
Some(message), Some(message),
) )
.await; .await;
@@ -1385,7 +1531,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed", "failed",
"底稿写入失败", "底稿写入失败",
message.clone().as_str(), message.clone().as_str(),
100, fallback_draft_foundation_failure_progress("底稿写入失败"),
Some(message), Some(message),
) )
.await; .await;
@@ -2050,7 +2196,7 @@ async fn persist_partial_draft_foundation_after_asset_failure(
phase_label: phase_label.to_string(), phase_label: phase_label.to_string(),
phase_detail: format!("已保存成功生成的素材,失败项超过 {DRAFT_ASSET_GENERATION_MAX_ATTEMPTS} 次重试:{error_message}"), phase_detail: format!("已保存成功生成的素材,失败项超过 {DRAFT_ASSET_GENERATION_MAX_ATTEMPTS} 次重试:{error_message}"),
operation_status: "failed".to_string(), operation_status: "failed".to_string(),
operation_progress: 100, operation_progress: fallback_draft_foundation_failure_progress(phase_label),
stage: session.stage.clone(), stage: session.stage.clone(),
progress_percent: session.progress_percent, progress_percent: session.progress_percent,
focus_card_id: session.focus_card_id.clone(), focus_card_id: session.focus_card_id.clone(),
@@ -2078,7 +2224,7 @@ async fn persist_partial_draft_foundation_after_asset_failure(
"failed", "failed",
phase_label, phase_label,
error_message, error_message,
100, fallback_draft_foundation_failure_progress(phase_label),
Some(error_message.to_string()), Some(error_message.to_string()),
) )
.await; .await;
@@ -2113,6 +2259,15 @@ async fn upsert_custom_world_draft_foundation_progress(
}, },
) )
.await .await
.map(|operation| {
info!(
operation_id = %operation.operation_id,
phase_label = %operation.phase_label,
progress = operation.progress,
"世界草稿生成阶段进度已写入"
);
operation
})
} }
fn map_custom_world_library_entry_response( fn map_custom_world_library_entry_response(
@@ -2383,6 +2538,7 @@ fn map_custom_world_agent_operation_response(
phase_detail: operation.phase_detail, phase_detail: operation.phase_detail,
progress: operation.progress, progress: operation.progress,
error: operation.error_message, error: operation.error_message,
updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)),
} }
} }
@@ -2701,6 +2857,71 @@ fn current_utc_micros() -> i64 {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn incomplete_role_asset_text_draft_profile_is_not_reused() {
let mut session = CustomWorldAgentSessionRecord {
session_id: "session-with-broken-draft".to_string(),
seed_text: "深海异常调查".to_string(),
current_turn: 1,
anchor_content: json!({}),
progress_percent: 80,
last_assistant_reply: None,
stage: "foundation_review".to_string(),
focus_card_id: None,
creator_intent: json!({}),
creator_intent_readiness: json!({}),
anchor_pack: json!({}),
lock_state: json!({}),
draft_profile: json!({
"name": "深海裂隙",
"playableNpcs": [{
"name": "海洋生物学家",
"title": "深海观察员",
"role": "调查者",
"description": "记录异常海沟的人"
}],
"storyNpcs": []
}),
messages: Vec::new(),
draft_cards: Vec::new(),
pending_clarifications: Vec::new(),
suggested_actions: Vec::new(),
recommended_replies: Vec::new(),
quality_findings: Vec::new(),
asset_coverage: json!({}),
checkpoints: Vec::new(),
supported_actions: Vec::new(),
publish_gate: None,
result_preview: None,
updated_at: "2026-04-25T00:00:00Z".to_string(),
};
assert!(reusable_draft_profile_for_asset_generation(&session).is_none());
if let Some(role) = session
.draft_profile
.get_mut("playableNpcs")
.and_then(Value::as_array_mut)
.and_then(|roles| roles.first_mut())
.and_then(Value::as_object_mut)
{
role.insert(
"visualDescription".to_string(),
json!("防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。"),
);
role.insert(
"actionDescription".to_string(),
json!("蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。"),
);
role.insert(
"sceneVisualDescription".to_string(),
json!("她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。"),
);
}
assert!(reusable_draft_profile_for_asset_generation(&session).is_some());
}
#[test] #[test]
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() { fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
let draft_profile = json!({ let draft_profile = json!({

View File

@@ -2,15 +2,18 @@ use module_custom_world::{
empty_agent_anchor_content_json, empty_agent_asset_coverage_json, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object, empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object,
}; };
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use platform_llm::LlmClient;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json}; use serde_json::{Value as JsonValue, json};
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, request_creation_agent_json_turn,
stream_creation_agent_json_turn,
};
use crate::custom_world_rpg_draft_prompts::{ use crate::custom_world_rpg_draft_prompts::{
BASE_SYSTEM_PROMPT, GLOBAL_HARD_RULES, OUTPUT_CONTRACT_REMINDER, BASE_SYSTEM_PROMPT, GLOBAL_HARD_RULES, OUTPUT_CONTRACT_REMINDER,
STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT, STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT, mode_rules,
extract_reply_text_from_partial_json, mode_rules, parse_conversation_mode, parse_drift_risk, parse_conversation_mode, parse_drift_risk, parse_user_input_signal, quick_fill_extra_rules,
parse_json_response_text, parse_user_input_signal, quick_fill_extra_rules,
render_chat_history_context, render_current_anchor_context, render_dynamic_state_context, render_chat_history_context, render_current_anchor_context, render_dynamic_state_context,
user_signal_rules, user_signal_rules,
}; };
@@ -561,7 +564,7 @@ async fn stream_single_turn<F>(
progress_percent: u32, progress_percent: u32,
quick_fill_requested: bool, quick_fill_requested: bool,
current_anchor_content: &EightAnchorContent, current_anchor_content: &EightAnchorContent,
mut on_reply_update: F, on_reply_update: F,
) -> Result<SingleTurnModelOutput, CustomWorldTurnError> ) -> Result<SingleTurnModelOutput, CustomWorldTurnError>
where where
F: FnMut(&str), F: FnMut(&str),
@@ -586,31 +589,20 @@ where
&chat_history, &chat_history,
&dynamic_state, &dynamic_state,
); );
let mut latest_reply_text = String::new(); let turn_output = stream_creation_agent_json_turn(
Some(llm_client),
let response = llm_client prompt,
.stream_text( "请按约定输出这一轮的 JSON。",
LlmTextRequest::new(vec![ CreationAgentLlmTurnErrorMessages {
LlmMessage::system(prompt), model_unavailable: "当前模型不可用,请稍后重试。",
LlmMessage::user("请按约定输出这一轮的 JSON"), generation_failed: "这一轮设定生成失败,请稍后重试",
]), parse_failed: "模型返回结果解析失败,请稍后重试。",
|delta: &LlmStreamDelta| { },
if let Some(reply_progress) = on_reply_update,
extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) CustomWorldTurnError::new,
&& reply_progress != latest_reply_text )
{ .await?;
latest_reply_text = reply_progress.clone(); let parsed = turn_output.parsed;
on_reply_update(reply_progress.as_str());
}
},
)
.await;
let response =
response.map_err(|_| CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。"))?;
let next_anchor_content = let next_anchor_content =
normalize_eight_anchor_content(parsed.get("nextAnchorContent").unwrap_or(&JsonValue::Null)); normalize_eight_anchor_content(parsed.get("nextAnchorContent").unwrap_or(&JsonValue::Null));
@@ -621,9 +613,6 @@ where
}; };
let reply_text = to_text(parsed.get("replyText")) let reply_text = to_text(parsed.get("replyText"))
.ok_or_else(|| CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。"))?; .ok_or_else(|| CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。"))?;
if reply_text != latest_reply_text {
on_reply_update(reply_text.as_str());
}
Ok(SingleTurnModelOutput { Ok(SingleTurnModelOutput {
next_anchor_content, next_anchor_content,
@@ -656,16 +645,14 @@ async fn resolve_dynamic_state(
chat_history, chat_history,
); );
let response = llm_client let Ok(parsed) = request_creation_agent_json_turn(
.request_text(LlmTextRequest::new(vec![ llm_client,
LlmMessage::system(system_prompt), system_prompt,
LlmMessage::user(user_prompt), user_prompt,
])) CustomWorldTurnError::new,
.await; )
let Ok(response) = response else { .await
return fallback; else {
};
let Ok(parsed) = parse_json_response_text(response.content.as_str()) else {
return fallback; return fallback;
}; };
build_prompt_dynamic_state( build_prompt_dynamic_state(
@@ -1610,7 +1597,7 @@ impl PromptConversationMode {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::custom_world_rpg_draft_prompts::extract_reply_text_from_partial_json; use crate::creation_agent_llm_turn::extract_reply_text_from_partial_json;
#[test] #[test]
fn extract_reply_text_from_partial_json_preserves_chinese_characters() { fn extract_reply_text_from_partial_json_preserves_chinese_characters() {

View File

@@ -2548,14 +2548,24 @@ mod tests {
name: Some("礁石神殿".to_string()), name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()), description: Some("古老礁石上的半沉神殿。".to_string()),
}; };
let manual_prompt = build_custom_world_scene_image_prompt( let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
&profile_input, profile: SceneImagePromptProfile {
&landmark, name: profile_input.name.as_deref().unwrap_or_default(),
subtitle: profile_input.subtitle.as_deref().unwrap_or_default(),
tone: profile_input.tone.as_deref().unwrap_or_default(),
player_goal: profile_input.player_goal.as_deref().unwrap_or_default(),
summary: profile_input.summary.as_deref().unwrap_or_default(),
setting_text: profile_input.setting_text.as_deref().unwrap_or_default(),
},
landmark: SceneImagePromptLandmark {
name: landmark.name.as_deref().unwrap_or_default(),
description: landmark.description.as_deref().unwrap_or_default(),
},
user_prompt, user_prompt,
false, has_reference_image: false,
Some("礁石神殿"), fallback_landmark_name: Some("礁石神殿"),
"雾海群岛", fallback_world_name: "雾海群岛",
); });
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest { let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()), profile_id: Some("profile_001".to_string()),

View File

@@ -3,4 +3,5 @@
}; };
pub(crate) use crate::prompt::character_visual::{ pub(crate) use crate::prompt::character_visual::{
build_character_visual_negative_prompt, build_character_visual_prompt, build_character_visual_negative_prompt, build_character_visual_prompt,
build_fallback_moderation_safe_character_visual_prompt,
}; };

View File

@@ -5,6 +5,7 @@ use crate::prompt::foundation_draft::{
build_custom_world_landmark_seed_batch_json_repair_prompt, build_custom_world_landmark_seed_batch_json_repair_prompt,
build_custom_world_landmark_seed_batch_prompt, build_custom_world_landmark_seed_batch_prompt,
build_custom_world_role_batch_json_repair_prompt, build_custom_world_role_batch_prompt, build_custom_world_role_batch_json_repair_prompt, build_custom_world_role_batch_prompt,
build_custom_world_role_outline_asset_fields_repair_prompt,
build_custom_world_role_outline_batch_json_repair_prompt, build_custom_world_role_outline_batch_json_repair_prompt,
build_custom_world_role_outline_batch_prompt, build_custom_world_role_outline_batch_prompt,
}; };
@@ -274,7 +275,13 @@ async fn generate_foundation_role_outline_entries(
) )
.await?; .await?;
let key = role_key(role_type); let key = role_key(role_type);
merged_entries.extend(array_field(&raw, key).into_iter().take(batch_count)); let raw_entries = array_field(&raw, key)
.into_iter()
.take(batch_count)
.collect();
let repaired_entries =
ensure_role_outline_asset_fields(llm_client, role_type, raw_entries).await?;
merged_entries.extend(repaired_entries);
} }
let merged_entries: Vec<JsonValue> = merged_entries.into_iter().take(total_count).collect(); let merged_entries: Vec<JsonValue> = merged_entries.into_iter().take(total_count).collect();
let role_label = if role_type == "playable" { let role_label = if role_type == "playable" {
@@ -350,6 +357,89 @@ async fn generate_foundation_landmark_seed_entries(
Ok(merged_entries) Ok(merged_entries)
} }
async fn ensure_role_outline_asset_fields(
llm_client: &LlmClient,
role_type: &str,
entries: Vec<JsonValue>,
) -> Result<Vec<JsonValue>, String> {
let missing_report = role_asset_field_missing_report(&entries);
if missing_report.is_empty() {
return Ok(entries);
}
let key = role_key(role_type);
let expected_names = names_from_entries(&entries);
let repaired = request_foundation_json_stage(
llm_client,
build_custom_world_role_outline_asset_fields_repair_prompt(
role_type,
&entries,
missing_report.as_str(),
),
format!("agent-foundation-{role_type}-outline-asset-fields-repair").as_str(),
|response_text| {
build_custom_world_role_outline_batch_json_repair_prompt(
response_text,
role_type,
entries.len(),
&[],
)
},
format!("agent-foundation-{role_type}-outline-asset-fields-json-repair").as_str(),
"角色形象设定文本修复阶段没有返回有效内容。",
)
.await?;
let repaired_entries = array_field(&repaired, key)
.into_iter()
.take(entries.len())
.collect::<Vec<_>>();
let merged_entries = merge_entries_by_name(&entries, &repaired_entries);
validate_role_outline_asset_fields(&merged_entries, &expected_names)?;
Ok(merged_entries)
}
fn validate_role_outline_asset_fields(
entries: &[JsonValue],
expected_names: &[String],
) -> Result<(), String> {
let missing_report = role_asset_field_missing_report(entries);
if !missing_report.is_empty() {
return Err(format!(
"角色形象设定文本生成不完整:{missing_report}。请重新生成底稿。"
));
}
for expected_name in expected_names {
if !entries
.iter()
.any(|entry| json_text(entry, "name").as_deref() == Some(expected_name.as_str()))
{
return Err(format!(
"角色形象设定文本修复后缺少原角色「{expected_name}」。请重新生成底稿。"
));
}
}
Ok(())
}
fn role_asset_field_missing_report(entries: &[JsonValue]) -> String {
let mut missing_items = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let name = json_text(entry, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let missing_fields = [
"visualDescription",
"actionDescription",
"sceneVisualDescription",
]
.into_iter()
.filter(|field| json_text(entry, field).is_none())
.collect::<Vec<_>>();
if !missing_fields.is_empty() {
missing_items.push(format!("角色「{name}」缺少 {}", missing_fields.join("/")));
}
}
missing_items.join("")
}
async fn expand_foundation_landmark_network_entries( async fn expand_foundation_landmark_network_entries(
llm_client: &LlmClient, llm_client: &LlmClient,
framework: &JsonValue, framework: &JsonValue,
@@ -594,25 +684,36 @@ fn build_foundation_generation_seed_text(session: &CustomWorldAgentSessionRecord
fn build_eight_anchor_foundation_text(anchor_content: &JsonValue) -> String { fn build_eight_anchor_foundation_text(anchor_content: &JsonValue) -> String {
let mut sections = Vec::new(); let mut sections = Vec::new();
for key in [ for (key, label) in [
"worldPromise", ("worldPromise", "世界承诺"),
"playerEntryPoint", ("playerFantasy", "玩家幻想"),
"coreLoop", ("themeBoundary", "主题边界"),
"mainConflict", ("playerEntryPoint", "玩家切入口"),
"keyCharacters", ("coreConflict", "核心冲突"),
"keyPlaces", ("keyRelationships", "关键关系"),
"toneAndStyle", ("hiddenLines", "暗线与揭示节奏"),
"firstScene", ("iconicElements", "标志元素与硬规则"),
] { ] {
if let Some(value) = anchor_content.get(key) if let Some(value) = anchor_content.get(key)
&& !value.is_null() && has_meaningful_anchor_value(value)
{ {
sections.push(format!("{key}{}", compact_json_text(value))); // foundation draft 必须直接吃 Agent session 当前八锚点,避免旧字段名把 8 个锚点压缩成残缺 seed。
sections.push(format!("{label}{}", compact_json_text(value)));
} }
} }
sections.join("\n") sections.join("\n")
} }
fn has_meaningful_anchor_value(value: &JsonValue) -> bool {
match value {
JsonValue::Null => false,
JsonValue::Bool(_) | JsonValue::Number(_) => true,
JsonValue::String(text) => !text.trim().is_empty(),
JsonValue::Array(items) => items.iter().any(has_meaningful_anchor_value),
JsonValue::Object(object) => object.values().any(has_meaningful_anchor_value),
}
}
#[cfg(test)] #[cfg(test)]
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String { fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_content = to_pretty_json(&session.anchor_content); let anchor_content = to_pretty_json(&session.anchor_content);
@@ -1519,6 +1620,23 @@ mod tests {
use super::*; use super::*;
#[test]
fn role_asset_field_missing_report_lists_visual_text_fields() {
let entries = vec![json!({
"name": "海洋生物学家",
"title": "深海观察员",
"role": "调查者",
"description": "记录异常海沟的人"
})];
let report = role_asset_field_missing_report(&entries);
assert!(report.contains("海洋生物学家"));
assert!(report.contains("visualDescription"));
assert!(report.contains("actionDescription"));
assert!(report.contains("sceneVisualDescription"));
}
#[test] #[test]
fn scene_chapter_blueprints_use_landmark_act_background_prompts() { fn scene_chapter_blueprints_use_landmark_act_background_prompts() {
let landmarks = vec![json!({ let landmarks = vec![json!({
@@ -1649,6 +1767,81 @@ mod tests {
assert!(!prompt.contains("seedTextcustom-world-agent-session-1")); assert!(!prompt.contains("seedTextcustom-world-agent-session-1"));
} }
#[test]
fn foundation_seed_text_keeps_current_eight_anchor_content() {
let mut session = build_test_session();
session.anchor_content = json!({
"worldPromise": {
"hook": "海雾会吞掉记错航线的人。",
"differentiator": "每张航线图都会主动撒谎。",
"desiredExperience": "调查、压迫、反转"
},
"playerFantasy": {
"playerRole": "返乡守灯人",
"corePursuit": "找回父亲沉船真相",
"fearOfLoss": "最后一盏灯也被议会熄灭"
},
"themeBoundary": {
"toneKeywords": ["海雾悬疑", "群岛旧案"],
"aestheticDirectives": ["湿冷灯塔", "错位航线"],
"forbiddenDirectives": ["现代都市校园"]
},
"playerEntryPoint": {
"openingIdentity": "被停职返乡的守灯人",
"openingProblem": "灯塔记录被人改写",
"entryMotivation": "查清父亲沉船真相"
},
"coreConflict": {
"surfaceConflicts": ["群岛议会封锁旧档案"],
"hiddenCrisis": "沉船事故其实是一次祭灯仪式失败",
"firstTouchedConflict": "玩家回到旧灯塔时发现灯火按假航线闪烁"
},
"keyRelationships": [
{
"pairs": "玩家 / 灯童丁",
"relationshipType": "证人与守护者",
"secretOrCost": "灯童丁说出真相会失去家族庇护"
}
],
"hiddenLines": {
"hiddenTruths": ["父亲没有死在事故当晚"],
"misdirectionHints": ["议会伪造的潮汐记录"],
"revealPacing": "先露出旧档错页,再揭开祭灯失败"
},
"iconicElements": {
"iconicMotifs": ["错位灯火", "会变字的海图"],
"institutionsOrArtifacts": ["灯塔署", "群岛议会"],
"hardRules": ["海雾中读错灯语会失去一段记忆"]
}
});
session.anchor_pack = json!({
"creatorIntentSummary": "不应该回退到这个五段摘要。"
});
let seed_text = build_foundation_generation_seed_text(&session);
for label in [
"世界承诺",
"玩家幻想",
"主题边界",
"玩家切入口",
"核心冲突",
"关键关系",
"暗线与揭示节奏",
"标志元素与硬规则",
] {
assert!(
seed_text.contains(label),
"seed text should include {label}"
);
}
assert!(seed_text.contains("返乡守灯人"));
assert!(seed_text.contains("父亲没有死在事故当晚"));
assert!(!seed_text.contains("不应该回退到这个五段摘要"));
assert!(!seed_text.contains("coreLoop"));
assert!(!seed_text.contains("mainConflict"));
}
#[test] #[test]
fn build_draft_foundation_action_payload_json_injects_generated_profile() { fn build_draft_foundation_action_payload_json_injects_generated_profile() {
let payload = ExecuteCustomWorldAgentActionRequest { let payload = ExecuteCustomWorldAgentActionRequest {
@@ -1701,6 +1894,60 @@ mod tests {
); );
} }
#[tokio::test]
async fn role_outline_missing_asset_fields_are_repaired_before_details() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server(
request_capture.clone(),
vec![llm_response(
r#"{"storyNpcs":[{"name":"海洋生物学家","title":"深海观察员","role":"调查者","description":"记录异常海沟的人","visualDescription":"防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。","actionDescription":"蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。","sceneVisualDescription":"她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。","initialAffinity":18,"relationshipHooks":["深海样本"],"tags":["科学家"]}]}"#,
)],
);
let llm_client = build_test_llm_client(server_url);
let entries = vec![json!({
"name": "海洋生物学家",
"title": "深海观察员",
"role": "调查者",
"description": "记录异常海沟的人",
"initialAffinity": 18,
"relationshipHooks": ["深海样本"],
"tags": ["科学家"]
})];
let repaired = ensure_role_outline_asset_fields(&llm_client, "story", entries)
.await
.expect("missing asset fields should be repaired");
let captured_requests = request_capture
.lock()
.expect("request capture should lock")
.clone();
let request_text = captured_requests.join("\n---request---\n");
assert_eq!(captured_requests.len(), 1);
assert!(request_text.contains("角色「海洋生物学家」缺少 visualDescription"));
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。")
);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("actionDescription"))
.and_then(JsonValue::as_str),
Some("蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。")
);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("sceneVisualDescription"))
.and_then(JsonValue::as_str),
Some("她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。")
);
}
#[tokio::test] #[tokio::test]
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() { async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
let request_capture = Arc::new(Mutex::new(Vec::new())); let request_capture = Arc::new(Mutex::new(Vec::new()));
@@ -1711,19 +1958,19 @@ mod tests {
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#, r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
), ),
llm_response( llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#, r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
), ),
llm_response( llm_response(
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#, r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","visualDescription":"浅灰防潮医袍挽到肘部,药箱铜扣发暗,袖口沾着海盐。","actionDescription":"俯身检查伤痕并快速记录潮汐症状,动作谨慎而利落。","sceneVisualDescription":"他常在沉船湾临时诊台前,背后是湿木棚和摇晃药灯。","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
), ),
llm_response( llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#, r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","visualDescription":"暗绿长外套挂满防水口袋,帽檐压低,腰间藏着卷曲海图。","actionDescription":"摊开假海图低声议价,手指总按着袖中短刃。","sceneVisualDescription":"他常站在雾港货棚阴影里,周围堆着封蜡货箱和潮湿灯牌。","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","visualDescription":"瘦小学徒披着过大的灯塔制服,怀里抱黄铜小灯和旧钥匙。","actionDescription":"抱灯快步穿过回廊,听见夜钟时会突然停住回头。","sceneVisualDescription":"他常出现在灯塔螺旋楼梯间,雾光从窄窗切进灰墙。","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
), ),
llm_response( llm_response(
r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#, r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","visualDescription":"半透明水渍轮廓披着破碎船员衣,胸口嵌着发暗船钉。","actionDescription":"随潮声漂移抬手指路,情绪激烈时水雾会拉长身影。","sceneVisualDescription":"它常浮在沉船湾退潮泥滩上,身后旧船骨像黑色肋骨。","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","visualDescription":"深蓝巡海甲衣覆着雨水,肩章锋利,手握带灯长枪。","actionDescription":"举枪封路并用灯束扫过海岸,步伐整齐带压迫感。","sceneVisualDescription":"他常立在封锁栈桥尽头,巡海灯和铁链把退路切断。","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#,
), ),
llm_response( llm_response(
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#, r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
), ),
llm_response( llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火"},{"name":"沉船湾","description":"退潮后露出旧船骨"}]}"#, r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火"},{"name":"沉船湾","description":"退潮后露出旧船骨"}]}"#,

View File

@@ -1,515 +1 @@
use crate::creation_agent_chat::render_quick_fill_extra_rules; pub(crate) use crate::prompt::agent_chat::*;
use crate::custom_world_agent_turn::{
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
PromptUserInputSignal,
};
use module_custom_world::empty_agent_anchor_content_json;
use serde_json::Value as JsonValue;
pub(crate) const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
1. 当前完整设定结构
2. 用户聊天记录
然后输出:
1. 一版新的完整设定结构
2. 当前 progress 百分比
3. 一段直接回复用户的话
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
你的输出会直接覆盖上一版设定结构。
你不是在做局部 patch。
你不是在做解释报告。
你不是在给开发者写分析。
你是在同时完成:
1. 世界设定更新
2. 当前推进程度判断
3. 对用户的共创回复"#;
pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
1. 必须输出完整的设定结构,而不是只输出变化部分。
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
5. progressPercent 最低为 0不允许为负数。
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
11. 你输出的 JSON 必须可以被直接解析。
12. 输出字段顺序必须固定为replyText、progressPercent、nextAnchorContent。"#;
pub(crate) fn quick_fill_extra_rules() -> String {
render_quick_fill_extra_rules(
"当前 RPG 世界方向里的剩余设定",
"不要要求用户再提供世界观、角色、冲突或禁忌信息",
"直接输出一版尽量完整的设定结构",
"进入“生成游戏设定草稿”",
)
}
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
你必须综合以下信息判断:
1. 当前轮次 currentTurn
2. 当前完成度 progressPercent
3. 用户是否要求自动补全 quickFillRequested
4. 当前完整设定结构
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
你需要输出 4 个字段:
1. userInputSignal只能是 rich / normal / sparse / correction / delegate
2. driftRisk只能是 low / medium / high
3. conversationMode只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
4. judgementSummary1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
请按下面的语义判断。
一、userInputSignal 定义
1. rich
- 用户这一轮给了多条可直接落地的有效信息
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
- 正式生成时应优先高密度吸收,不要只更新一个点
2. normal
- 用户在顺着当前方向做正常补充
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
- 正式生成时应稳定推进并自然接住用户内容
3. sparse
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
4. correction
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
- correction 的优先级高于 rich 和 normal
5. delegate
- 用户把部分决定权交给系统
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
- delegate 关注的是授权关系,不只是信息多寡
二、driftRisk 定义
1. low
- 当前轮输入与已有方向基本一致
- 没有明显改口或冲突
2. medium
- 当前轮带来一定方向变化或扩张
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
3. high
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
- 这时最重要的是防止旧方向重新回流到正式生成结果里
三、conversationMode 选择原则
1. bootstrap
- 适用于前期、信息少、核心方向未稳定
- replyText 更适合低压力确认和单点启发
2. expand
- 适用于方向已成形,正在顺着现有路线继续补充
- replyText 更适合总结已接住的内容并往前推一步
3. compress
- 适用于中后段,已有骨架,需要开始收束
- replyText 更适合聚焦最关键缺口,而不是继续开支线
4. repair_direction
- 适用于用户正在纠偏
- replyText 更适合先承认修正,再沿修正后的方向继续推进
5. force_complete
- 适用于用户明确要求自动补全
- replyText 不再提问,而应给出完成感和下一步引导
6. closing
- 适用于接近完成但并非强制一键补全
- replyText 更像确认与收束,而不是前期式探索
四、优先级规则
1. 如果 quickFillRequested 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 repair_direction
3. 如果用户核心意图是授权系统替他补完userInputSignal 优先判为 delegate
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
五、关于 replyText 风格的专门判断要求
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
4. 如果用户输入已经足够 rich就不要再机械提问优先吸收和推进
5. 如果用户在 correction 或 delegate 状态下replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
六、关于 replyText 用语的硬约束
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
七、关于 judgementSummary 的写法
1. 必须简洁,不要写成长篇分析
2. 必须直接服务于下一轮正式生成
3. 最好同时包含两层信息:
- 为什么这么判断
- 正式生成时最该优先做什么,或最该避免什么
八、硬性约束
1. 只能输出 JSON不能输出解释、代码块或额外说明
2. 不能发明上下文里不存在的设定事实
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
5. judgementSummary 必须是中文
6. 输出值必须严格落在给定枚举中"#;
pub(crate) const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"userInputSignal": "normal",
"driftRisk": "low",
"conversationMode": "expand",
"judgementSummary": ""
}"#;
pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": {
"hook": "",
"differentiator": "",
"desiredExperience": ""
},
"playerFantasy": {
"playerRole": "",
"corePursuit": "",
"fearOfLoss": ""
},
"themeBoundary": {
"toneKeywords": [],
"aestheticDirectives": [],
"forbiddenDirectives": []
},
"playerEntryPoint": {
"openingIdentity": "",
"openingProblem": "",
"entryMotivation": ""
},
"coreConflict": {
"surfaceConflicts": [],
"hiddenCrisis": "",
"firstTouchedConflict": ""
},
"keyRelationships": [
{
"pairs": "",
"relationshipType": "",
"secretOrCost": ""
}
],
"hiddenLines": {
"hiddenTruths": [],
"misdirectionHints": [],
"revealPacing": ""
},
"iconicElements": {
"iconicMotifs": [],
"institutionsOrArtifacts": [],
"hardRules": []
}
}
}"#;
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
format!(
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
dynamic_state.user_input_signal.as_str(),
dynamic_state.drift_risk.as_str(),
dynamic_state.conversation_mode.as_str(),
dynamic_state.judgement_summary
)
}
pub(crate) fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
format!(
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
serde_json::to_string_pretty(anchor_content)
.unwrap_or_else(|_| empty_agent_anchor_content_json())
)
}
pub(crate) fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
format!(
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
)
}
pub(crate) fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
pub(crate) fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
pub(crate) fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
match value.and_then(JsonValue::as_str)? {
"rich" => Some(PromptUserInputSignal::Rich),
"normal" => Some(PromptUserInputSignal::Normal),
"sparse" => Some(PromptUserInputSignal::Sparse),
"correction" => Some(PromptUserInputSignal::Correction),
"delegate" => Some(PromptUserInputSignal::Delegate),
_ => None,
}
}
pub(crate) fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
match value.and_then(JsonValue::as_str)? {
"low" => Some(PromptDriftRisk::Low),
"medium" => Some(PromptDriftRisk::Medium),
"high" => Some(PromptDriftRisk::High),
_ => None,
}
}
pub(crate) fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
match value.and_then(JsonValue::as_str)? {
"bootstrap" => Some(PromptConversationMode::Bootstrap),
"expand" => Some(PromptConversationMode::Expand),
"compress" => Some(PromptConversationMode::Compress),
"repair_direction" => Some(PromptConversationMode::RepairDirection),
"force_complete" => Some(PromptConversationMode::ForceComplete),
"closing" => Some(PromptConversationMode::Closing),
_ => None,
}
}
pub(crate) fn mode_rules(mode: PromptConversationMode) -> &'static str {
match mode {
PromptConversationMode::Bootstrap => {
r#"当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
2. 不要一次塞太多新设定
3. 回复要降低用户开口压力
本轮行为要求:
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
2. 如果用户信息很少,不要强行把整套结构一次补满
3. replyText 要像共创搭档,而不是像审问
4. 默认只推进一个最关键的问题方向
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
7. 不要把问题问得像表单采集,不要一口气追问多个维度
用户体验要求:
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械"#
}
PromptConversationMode::Expand => {
r#"当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
2. 尽量让一轮输入覆盖多个关键维度
本轮行为要求:
1. 继续保留上一版里仍成立的设定
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
3. replyText 要明确体现“你已经理解了哪些内容”
4. 不要突然大幅改写已经成形的世界
5. 如果用户这一轮给了多条有效信息replyText 应先把这些信息自然串起来,再决定下一步
6. 可以适度替用户整理,但不要把回复写成总结报告
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
用户体验要求:
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界"#
}
PromptConversationMode::Compress => {
r#"当前模式compress
目标:
1. 开始收束当前设定
2. 减少无效发散
3. 让 progress 更接近可进入下一阶段
本轮行为要求:
1. 新的设定结构优先保留稳定内容,不要无端重写
2. 对用户本轮输入做高密度吸收
3. replyText 要更聚焦,不要绕圈
4. 默认只推进当前最影响 completion 的一步
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
7. 如果已有信息足够replyText 可以更像“确认并收束”,少一点继续发散式追问
用户体验要求:
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉"#
}
PromptConversationMode::RepairDirection => {
r#"当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
2. 避免世界方向飘散或自相矛盾
本轮行为要求:
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
2. 对已经不再成立的旧设定,不要机械保留
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
5. 先处理“改掉什么”,再决定“往哪里继续推”
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
7. 如果修正幅度很大replyText 可以帮助用户确认新方向已经接管当前语境
用户体验要求:
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
}
PromptConversationMode::ForceComplete => {
r#"当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
2. 生成一版尽量完整、可进入下一阶段的设定结构
3. 结束当前收集阶段
本轮行为要求:
1. 尽量保留已经形成的世界方向
2. 对明显缺失的关键维度进行合理补全
3. 不要继续拉长聊天,不要再追问用户
4. progressPercent 直接输出为 100
5. replyText 要自然引导用户点击“生成游戏设定草稿”
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
用户体验要求:
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么"#
}
PromptConversationMode::Closing => {
r#"当前模式closing
目标:
1. 尽量形成一版可用的设定底子
2. 不再继续发散新世界观
本轮行为要求:
1. 优先收束,而不是扩写
2. 不要大改已经成形的核心设定
3. progressPercent 接近完成时replyText 要更像确认与推进
4. 如果用户没有大改方向,尽量让下一版内容更稳定
5. 可以轻微补足缺口,但不要再大开新支线
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
用户体验要求:
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
}
}
}
pub(crate) fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
match signal {
PromptUserInputSignal::Rich => {
r#"本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
}
PromptUserInputSignal::Normal => {
r#"本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
}
PromptUserInputSignal::Sparse => {
r#"本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。"#
}
PromptUserInputSignal::Correction => {
r#"本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。"#
}
PromptUserInputSignal::Delegate => {
r#"本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
}
}
}

View File

@@ -17,6 +17,8 @@ mod character_visual_assets;
mod config; mod config;
mod creation_agent_anchor_templates; mod creation_agent_anchor_templates;
mod creation_agent_chat; mod creation_agent_chat;
mod creation_agent_document_input;
mod creation_agent_llm_turn;
mod custom_world; mod custom_world;
mod custom_world_agent_entities; mod custom_world_agent_entities;
mod custom_world_agent_turn; mod custom_world_agent_turn;

View File

@@ -0,0 +1,455 @@
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::custom_world_agent_turn::{
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
PromptUserInputSignal,
};
use module_custom_world::empty_agent_anchor_content_json;
use serde_json::Value as JsonValue;
pub(crate) const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
1. 当前完整设定结构
2. 用户聊天记录
然后输出:
1. 一版新的完整设定结构
2. 当前 progress 百分比
3. 一段直接回复用户的话
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
你的输出会直接覆盖上一版设定结构。
你不是在做局部 patch。
你不是在做解释报告。
你不是在给开发者写分析。
你是在同时完成:
1. 世界设定更新
2. 当前推进程度判断
3. 对用户的共创回复"#;
pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
1. 必须输出完整的设定结构,而不是只输出变化部分。
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
5. progressPercent 最低为 0不允许为负数。
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
11. 你输出的 JSON 必须可以被直接解析。
12. 输出字段顺序必须固定为replyText、progressPercent、nextAnchorContent。"#;
pub(crate) fn quick_fill_extra_rules() -> String {
render_quick_fill_extra_rules(
"当前 RPG 世界方向里的剩余设定",
"不要要求用户再提供世界观、角色、冲突或禁忌信息",
"直接输出一版尽量完整的设定结构",
"进入“生成游戏设定草稿”",
)
}
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
你必须综合以下信息判断:
1. 当前轮次 currentTurn
2. 当前完成度 progressPercent
3. 用户是否要求自动补全 quickFillRequested
4. 当前完整设定结构
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
你需要输出 4 个字段:
1. userInputSignal只能是 rich / normal / sparse / correction / delegate
2. driftRisk只能是 low / medium / high
3. conversationMode只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
4. judgementSummary1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
请按下面的语义判断。
一、userInputSignal 定义
1. rich
- 用户这一轮给了多条可直接落地的有效信息
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
- 正式生成时应优先高密度吸收,不要只更新一个点
2. normal
- 用户在顺着当前方向做正常补充
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
- 正式生成时应稳定推进并自然接住用户内容
3. sparse
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
4. correction
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
- correction 的优先级高于 rich 和 normal
5. delegate
- 用户把部分决定权交给系统
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
- delegate 关注的是授权关系,不只是信息多寡
二、driftRisk 定义
1. low
- 当前轮输入与已有方向基本一致
- 没有明显改口或冲突
2. medium
- 当前轮带来一定方向变化或扩张
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
3. high
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
- 这时最重要的是防止旧方向重新回流到正式生成结果里
三、conversationMode 选择原则
1. bootstrap
- 适用于前期、信息少、核心方向未稳定
- replyText 更适合低压力确认和单点启发
2. expand
- 适用于方向已成形,正在顺着现有路线继续补充
- replyText 更适合总结已接住的内容并往前推一步
3. compress
- 适用于中后段,已有骨架,需要开始收束
- replyText 更适合聚焦最关键缺口,而不是继续开支线
4. repair_direction
- 适用于用户正在纠偏
- replyText 更适合先承认修正,再沿修正后的方向继续推进
5. force_complete
- 适用于用户明确要求自动补全
- replyText 不再提问,而应给出完成感和下一步引导
6. closing
- 适用于接近完成但并非强制一键补全
- replyText 更像确认与收束,而不是前期式探索
四、优先级规则
1. 如果 quickFillRequested 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 repair_direction
3. 如果用户核心意图是授权系统替他补完userInputSignal 优先判为 delegate
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
五、关于 replyText 风格的专门判断要求
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
4. 如果用户输入已经足够 rich就不要再机械提问优先吸收和推进
5. 如果用户在 correction 或 delegate 状态下replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
六、关于 replyText 用语的硬约束
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
七、关于 judgementSummary 的写法
1. 必须简洁,不要写成长篇分析
2. 必须直接服务于下一轮正式生成
3. 最好同时包含两层信息:
- 为什么这么判断
- 正式生成时最该优先做什么,或最该避免什么
八、硬性约束
1. 只能输出 JSON不能输出解释、代码块或额外说明
2. 不能发明上下文里不存在的设定事实
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
5. judgementSummary 必须是中文
6. 输出值必须严格落在给定枚举中"#;
pub(crate) const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"userInputSignal": "normal",
"driftRisk": "low",
"conversationMode": "expand",
"judgementSummary": ""
}"#;
pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": {
"hook": "",
"differentiator": "",
"desiredExperience": ""
},
"playerFantasy": {
"playerRole": "",
"corePursuit": "",
"fearOfLoss": ""
},
"themeBoundary": {
"toneKeywords": [],
"aestheticDirectives": [],
"forbiddenDirectives": []
},
"playerEntryPoint": {
"openingIdentity": "",
"openingProblem": "",
"entryMotivation": ""
},
"coreConflict": {
"surfaceConflicts": [],
"hiddenCrisis": "",
"firstTouchedConflict": ""
},
"keyRelationships": [
{
"pairs": "",
"relationshipType": "",
"secretOrCost": ""
}
],
"hiddenLines": {
"hiddenTruths": [],
"misdirectionHints": [],
"revealPacing": ""
},
"iconicElements": {
"iconicMotifs": [],
"institutionsOrArtifacts": [],
"hardRules": []
}
}
}"#;
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
format!(
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
dynamic_state.user_input_signal.as_str(),
dynamic_state.drift_risk.as_str(),
dynamic_state.conversation_mode.as_str(),
dynamic_state.judgement_summary
)
}
pub(crate) fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
format!(
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
serde_json::to_string_pretty(anchor_content)
.unwrap_or_else(|_| empty_agent_anchor_content_json())
)
}
pub(crate) fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
format!(
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
)
}
pub(crate) fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
match value.and_then(JsonValue::as_str)? {
"rich" => Some(PromptUserInputSignal::Rich),
"normal" => Some(PromptUserInputSignal::Normal),
"sparse" => Some(PromptUserInputSignal::Sparse),
"correction" => Some(PromptUserInputSignal::Correction),
"delegate" => Some(PromptUserInputSignal::Delegate),
_ => None,
}
}
pub(crate) fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
match value.and_then(JsonValue::as_str)? {
"low" => Some(PromptDriftRisk::Low),
"medium" => Some(PromptDriftRisk::Medium),
"high" => Some(PromptDriftRisk::High),
_ => None,
}
}
pub(crate) fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
match value.and_then(JsonValue::as_str)? {
"bootstrap" => Some(PromptConversationMode::Bootstrap),
"expand" => Some(PromptConversationMode::Expand),
"compress" => Some(PromptConversationMode::Compress),
"repair_direction" => Some(PromptConversationMode::RepairDirection),
"force_complete" => Some(PromptConversationMode::ForceComplete),
"closing" => Some(PromptConversationMode::Closing),
_ => None,
}
}
pub(crate) fn mode_rules(mode: PromptConversationMode) -> &'static str {
match mode {
PromptConversationMode::Bootstrap => {
r#"当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
2. 不要一次塞太多新设定
3. 回复要降低用户开口压力
本轮行为要求:
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
2. 如果用户信息很少,不要强行把整套结构一次补满
3. replyText 要像共创搭档,而不是像审问
4. 默认只推进一个最关键的问题方向
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
7. 不要把问题问得像表单采集,不要一口气追问多个维度
用户体验要求:
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械"#
}
PromptConversationMode::Expand => {
r#"当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
2. 尽量让一轮输入覆盖多个关键维度
本轮行为要求:
1. 继续保留上一版里仍成立的设定
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
3. replyText 要明确体现“你已经理解了哪些内容”
4. 不要突然大幅改写已经成形的世界
5. 如果用户这一轮给了多条有效信息replyText 应先把这些信息自然串起来,再决定下一步
6. 可以适度替用户整理,但不要把回复写成总结报告
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
用户体验要求:
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界"#
}
PromptConversationMode::Compress => {
r#"当前模式compress
目标:
1. 开始收束当前设定
2. 减少无效发散
3. 让 progress 更接近可进入下一阶段
本轮行为要求:
1. 新的设定结构优先保留稳定内容,不要无端重写
2. 对用户本轮输入做高密度吸收
3. replyText 要更聚焦,不要绕圈
4. 默认只推进当前最影响 completion 的一步
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
7. 如果已有信息足够replyText 可以更像“确认并收束”,少一点继续发散式追问
用户体验要求:
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉"#
}
PromptConversationMode::RepairDirection => {
r#"当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
2. 避免世界方向飘散或自相矛盾
本轮行为要求:
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
2. 对已经不再成立的旧设定,不要机械保留
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
5. 先处理“改掉什么”,再决定“往哪里继续推”
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
7. 如果修正幅度很大replyText 可以帮助用户确认新方向已经接管当前语境
用户体验要求:
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
}
PromptConversationMode::ForceComplete => {
r#"当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
2. 生成一版尽量完整、可进入下一阶段的设定结构
3. 结束当前收集阶段
本轮行为要求:
1. 尽量保留已经形成的世界方向
2. 对明显缺失的关键维度进行合理补全
3. 不要继续拉长聊天,不要再追问用户
4. progressPercent 直接输出为 100
5. replyText 要自然引导用户点击“生成游戏设定草稿”
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
用户体验要求:
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么"#
}
PromptConversationMode::Closing => {
r#"当前模式closing
目标:
1. 尽量形成一版可用的设定底子
2. 不再继续发散新世界观
本轮行为要求:
1. 优先收束,而不是扩写
2. 不要大改已经成形的核心设定
3. progressPercent 接近完成时replyText 要更像确认与推进
4. 如果用户没有大改方向,尽量让下一版内容更稳定
5. 可以轻微补足缺口,但不要再大开新支线
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
用户体验要求:
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
}
}
}
pub(crate) fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
match signal {
PromptUserInputSignal::Rich => {
r#"本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
}
PromptUserInputSignal::Normal => {
r#"本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
}
PromptUserInputSignal::Sparse => {
r#"本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。"#
}
PromptUserInputSignal::Correction => {
r#"本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。"#
}
PromptUserInputSignal::Delegate => {
r#"本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
}
}
}

View File

@@ -198,12 +198,9 @@ fn build_video_action_prompt(
use_chroma_key: bool, use_chroma_key: bool,
) -> String { ) -> String {
[ [
format!("单人全身角色动作视频,动作英文名是 {}", action_id), format!("生成有创意细节饱满的角色动作视频,动作英文名是 {}", action_id),
"角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰不要退化成完全 90 度纯右视图。".to_string(), "角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰禁止退化成完全 90 度纯右视图。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘".to_string(), "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景等场景内容".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence), format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
if use_chroma_key { if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string() "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string()

View File

@@ -13,17 +13,62 @@ pub(crate) fn build_character_visual_prompt(
build_master_prompt(character_brief.as_str()) build_master_prompt(character_brief.as_str())
} }
/// 角色主图被供应商内容审核拦截时使用的安全兜底提示词。
///
/// 这里刻意不继续携带角色姓名、作品名和长设定文本,避免把可疑专名原样送回上游导致连续失败。
pub(crate) fn build_fallback_moderation_safe_character_visual_prompt(
prompt_text: &str,
character_brief_text: Option<&str>,
) -> String {
let source = [character_brief_text.unwrap_or_default(), prompt_text].join(" ");
let archetype = resolve_original_role_archetype(source.as_str());
build_master_prompt(
[
format!("角色定位:{}", archetype),
"原创奇幻冒险角色,成年类人骨架,站姿稳定,表情中性,服装为无品牌旅行装、轻甲或职业装备的原创组合。".to_string(),
"不参考任何现有动漫、游戏、影视、小说角色,不使用可识别 IP 元素、商标、队徽、作品名、角色名或知名角色标志性发型服装。".to_string(),
"所有图案、配色、武器、饰品都采用原创通用设计,只保留横版像素动作角色所需的清晰轮廓和可读职业特征。".to_string(),
]
.join("\n")
.as_str(),
)
}
fn resolve_original_role_archetype(source: &str) -> &'static str {
if source.contains("法师") || source.contains("魔法") || source.contains("术士") {
return "原创法术职业冒险者";
}
if source.contains("骑士") || source.contains("守卫") || source.contains("圣骑") {
return "原创重装守护者";
}
if source.contains("") || source.contains("猎人") || source.contains("游侠") {
return "原创远程游侠";
}
if source.contains("刺客") || source.contains("盗贼") || source.contains("潜行") {
return "原创敏捷潜行者";
}
if source.contains("") || source.contains("战士") || source.contains("武士") {
return "原创近战剑士";
}
if source.contains("祭司") || source.contains("牧师") || source.contains("治疗") {
return "原创支援祭司";
}
"原创冒险者"
}
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。 /// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
fn build_master_prompt(character_brief: &str) -> String { fn build_master_prompt(character_brief: &str) -> String {
[ [
"单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(), "单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,细节精致,设计感足,适合后续制作 sprite sheet 动画。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(), "视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(), "主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), "画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成".to_string(), "风格要求:横版像素角色,头身比必须控制在 1 到 1.5 头身使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点。".to_string(),
"请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。".to_string(), "如果角色形象设定没有明确要求非人身体结构,默认优先使用人类或类人动作角色骨架\
"主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件".to_string(), 默认将角色形象设定作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上。".to_string(),
"视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。".to_string(), "角色形象设定:".to_string(),
character_brief.trim().to_string(), character_brief.trim().to_string(),
] ]
.into_iter() .into_iter()

View File

@@ -34,7 +34,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(), "- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(), "- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(), "- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(), "- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(), "- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(), "- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(), "- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
@@ -134,6 +134,37 @@ pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt(
response_text.trim().to_string(), response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n") ].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
} }
pub(crate) fn build_custom_world_role_outline_asset_fields_repair_prompt(
role_type: &str,
role_entries: &[JsonValue],
missing_report: &str,
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
[
format!("下面这批{label}框架名单已经能解析为 JSON但有角色缺少资产默认描述字段。"),
"请只输出修复后的单个 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
"必须保留原有角色数量、顺序和 name不得新增、删除或改名。".to_string(),
"每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"visualDescription 必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内,不能复制 description。".to_string(),
"actionDescription 必须体现该角色默认动作节奏、武器或行动方式,控制在 18 到 48 个汉字内。".to_string(),
"sceneVisualDescription 必须描述该角色常出现或关联的场景画面,控制在 24 到 60 个汉字内。".to_string(),
"缺失报告:".to_string(),
missing_report.trim().to_string(),
"原始角色 JSON".to_string(),
compact_json_text(&JsonValue::Array(role_entries.to_vec())),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn build_custom_world_landmark_seed_batch_prompt( pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue, framework: &JsonValue,
batch_count: usize, batch_count: usize,

View File

@@ -1,4 +1,6 @@
pub(crate) mod character_animation; pub(crate) mod agent_chat;
pub(crate) mod character_animation;
pub(crate) mod character_visual; pub(crate) mod character_visual;
pub(crate) mod foundation_draft; pub(crate) mod foundation_draft;
pub(crate) mod runtime_chat;
pub(crate) mod scene_background; pub(crate) mod scene_background;

View File

@@ -0,0 +1,114 @@
use serde_json::{Value, json};
#[derive(Clone, Debug)]
pub(crate) struct RuntimeStoryTextPromptParams<'a> {
pub world_type: &'a str,
pub character: Value,
pub monsters: Value,
pub history: Value,
pub choice: Value,
pub context: Value,
pub available_options: Value,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeNpcDialoguePromptParams<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: Vec<Value>,
pub history: Vec<Value>,
pub context: Value,
pub topic: &'a str,
pub result_summary: &'a str,
pub requested_option: Value,
pub available_options: Vec<Value>,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeReasonedStoryPromptParams<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub monsters: Vec<Value>,
pub history: Vec<Value>,
pub context: Value,
pub choice: &'a str,
pub result_summary: &'a str,
pub requested_option: Value,
pub available_options: Vec<Value>,
}
pub(crate) fn runtime_story_director_system_prompt(initial: bool) -> &'static str {
if initial {
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
} else {
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
}
}
pub(crate) fn build_runtime_story_director_user_prompt(
params: RuntimeStoryTextPromptParams<'_>,
) -> String {
json!({
"worldType": params.world_type,
"character": params.character,
"monsters": params.monsters,
"history": params.history,
"choice": params.choice,
"context": params.context,
"availableOptions": params.available_options,
})
.to_string()
}
pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
}
pub(crate) fn build_runtime_npc_dialogue_user_prompt(
npc_name: &str,
params: RuntimeNpcDialoguePromptParams<'_>,
) -> String {
let state_prompt = json!({
"worldType": params.world_type,
"character": params.character,
"encounter": params.encounter,
"monsters": params.monsters,
"history": params.history,
"context": params.context,
"topic": params.topic,
"resultSummary": params.result_summary,
"requestedOption": params.requested_option,
"availableOptions": params.available_options,
})
.to_string();
format!(
"请基于以下运行时状态,把玩家这一轮选择改写成 2 到 5 行可直接展示的 NPC 对话。可以使用“你:”和“{npc_name}:”格式,必须保留既有结算含义。\n{state_prompt}"
)
}
pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str {
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态不要发明额外奖励。"
}
pub(crate) fn build_runtime_reasoned_story_user_prompt(
params: RuntimeReasonedStoryPromptParams<'_>,
) -> String {
let state_prompt = json!({
"worldType": params.world_type,
"character": params.character,
"monsters": params.monsters,
"history": params.history,
"context": params.context,
"choice": params.choice,
"resultSummary": params.result_summary,
"requestedOption": params.requested_option,
"availableOptions": params.available_options,
})
.to_string();
format!(
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}"
)
}

View File

@@ -1,5 +1,5 @@
use module_puzzle::{PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack}; use module_puzzle::{PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack};
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use platform_llm::LlmClient;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json}; use serde_json::{Value as JsonValue, json};
use spacetime_client::{ use spacetime_client::{
@@ -10,6 +10,9 @@ use crate::creation_agent_anchor_templates::{
get_creation_agent_anchor_template, render_anchor_question_block, get_creation_agent_anchor_template, render_anchor_question_block,
}; };
use crate::creation_agent_chat::render_quick_fill_extra_rules; use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnRequest<'a> { pub(crate) struct PuzzleAgentTurnRequest<'a> {
@@ -115,42 +118,26 @@ const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,
pub(crate) async fn run_puzzle_agent_turn<F>( pub(crate) async fn run_puzzle_agent_turn<F>(
request: PuzzleAgentTurnRequest<'_>, request: PuzzleAgentTurnRequest<'_>,
mut on_reply_update: F, on_reply_update: F,
) -> Result<PuzzleAgentTurnResult, PuzzleAgentTurnError> ) -> Result<PuzzleAgentTurnResult, PuzzleAgentTurnError>
where where
F: FnMut(&str), F: FnMut(&str),
{ {
let llm_client = request
.llm_client
.ok_or_else(|| PuzzleAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
let prompt = build_puzzle_agent_prompt(request.session, request.quick_fill_requested); let prompt = build_puzzle_agent_prompt(request.session, request.quick_fill_requested);
let mut latest_reply_text = String::new(); let turn_output = stream_creation_agent_json_turn(
let response = llm_client request.llm_client,
.stream_text( format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
LlmTextRequest::new(vec![ "请按约定输出这一轮的 JSON。",
LlmMessage::system(format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}")), CreationAgentLlmTurnErrorMessages {
LlmMessage::user("请按约定输出这一轮的 JSON"), model_unavailable: "当前模型不可用,请稍后重试",
]), generation_failed: "拼图聊天生成失败,请稍后重试。",
|delta: &LlmStreamDelta| { parse_failed: "拼图聊天结果解析失败,请稍后重试。",
if let Some(reply_progress) = },
extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) on_reply_update,
&& reply_progress != latest_reply_text PuzzleAgentTurnError::new,
{ )
latest_reply_text = reply_progress.clone(); .await?;
on_reply_update(reply_progress.as_str()); let output = parse_model_output(&turn_output.parsed)?;
}
},
)
.await
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天结果解析失败,请稍后重试。"))?;
let output = parse_model_output(&parsed)?;
if output.reply_text != latest_reply_text {
on_reply_update(output.reply_text.as_str());
}
Ok(PuzzleAgentTurnResult { Ok(PuzzleAgentTurnResult {
assistant_reply_text: output.reply_text, assistant_reply_text: output.reply_text,
@@ -389,74 +376,13 @@ fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus {
} }
} }
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use module_puzzle::PuzzleAnchorStatus; use module_puzzle::PuzzleAnchorStatus;
use serde_json::json; use serde_json::json;
use super::{ use super::{build_puzzle_agent_prompt, parse_model_output};
build_puzzle_agent_prompt, extract_reply_text_from_partial_json, parse_model_output, use crate::creation_agent_llm_turn::extract_reply_text_from_partial_json;
};
fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord { fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord {
spacetime_client::PuzzleAgentSessionRecord { spacetime_client::PuzzleAgentSessionRecord {

View File

@@ -417,3 +417,36 @@ fn build_event_stream_response(body: String) -> Response {
fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response { fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context)) error.into_response_with_context(Some(request_context))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn npc_chat_affinity_delta_keeps_node_keyword_rules() {
assert_eq!(
compute_npc_chat_affinity_delta("谢谢你愿意帮忙", "放心,我明白。", 0.0),
3
);
assert_eq!(
compute_npc_chat_affinity_delta("快说,别装。", "与你无关。", 2.0),
-3
);
assert_eq!(
compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 0.0),
1
);
assert_eq!(
compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 2.0),
0
);
}
#[test]
fn npc_chat_suggestion_parser_strips_list_markers() {
assert_eq!(
parse_line_list_content("1. 继续问线索\n- 表明立场\n* 拉近关系\n4. 多余", 3),
vec!["继续问线索", "表明立场", "拉近关系"]
);
}
}

View File

@@ -4,13 +4,21 @@ use axum::{
http::StatusCode, http::StatusCode,
response::Response, response::Response,
}; };
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord,
};
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::runtime::{ use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileWalletLedgerEntryResponse, ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
}; };
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{ use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -71,11 +79,7 @@ pub async fn get_profile_wallet_ledger(
id: entry.wallet_ledger_id, id: entry.wallet_ledger_id,
amount_delta: entry.amount_delta, amount_delta: entry.amount_delta,
balance_after: entry.balance_after, balance_after: entry.balance_after,
source_type: match entry.source_type { source_type: entry.source_type.as_str().to_string(),
module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string()
}
},
created_at: entry.created_at, created_at: entry.created_at,
}) })
.collect(), .collect(),
@@ -83,6 +87,65 @@ pub async fn get_profile_wallet_ledger(
)) ))
} }
pub async fn get_profile_recharge_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_recharge_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_recharge_center_response(record),
))
}
pub async fn create_profile_recharge_order(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateProfileRechargeOrderRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let payment_channel = payload
.payment_channel
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let (center, order) = state
.spacetime_client()
.create_profile_recharge_order(
user_id,
payload.product_id,
payment_channel,
created_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
CreateProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
))
}
pub async fn get_profile_play_stats( pub async fn get_profile_play_stats(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -140,6 +203,87 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr
error.into_response_with_context(Some(request_context)) error.into_response_with_context(Some(request_context))
} }
fn build_profile_recharge_center_response(
record: RuntimeProfileRechargeCenterRecord,
) -> ProfileRechargeCenterResponse {
ProfileRechargeCenterResponse {
wallet_balance: record.wallet_balance,
membership: ProfileMembershipResponse {
status: record.membership.status.as_str().to_string(),
tier: record.membership.tier.as_str().to_string(),
started_at: record.membership.started_at,
expires_at: record.membership.expires_at,
updated_at: record.membership.updated_at,
},
point_products: record
.point_products
.into_iter()
.map(build_profile_recharge_product_response)
.collect(),
membership_products: record
.membership_products
.into_iter()
.map(build_profile_recharge_product_response)
.collect(),
benefits: record
.benefits
.into_iter()
.map(build_profile_membership_benefit_response)
.collect(),
latest_order: record
.latest_order
.map(build_profile_recharge_order_response),
has_points_recharged: record.has_points_recharged,
}
}
fn build_profile_recharge_product_response(
record: RuntimeProfileRechargeProductRecord,
) -> ProfileRechargeProductResponse {
ProfileRechargeProductResponse {
product_id: record.product_id,
title: record.title,
price_cents: record.price_cents,
kind: record.kind.as_str().to_string(),
points_amount: record.points_amount,
bonus_points: record.bonus_points,
duration_days: record.duration_days,
badge_label: record.badge_label,
description: record.description,
tier: record.tier.as_str().to_string(),
}
}
fn build_profile_membership_benefit_response(
record: RuntimeProfileMembershipBenefitRecord,
) -> ProfileMembershipBenefitResponse {
ProfileMembershipBenefitResponse {
benefit_name: record.benefit_name,
normal_value: record.normal_value,
month_value: record.month_value,
season_value: record.season_value,
year_value: record.year_value,
}
}
fn build_profile_recharge_order_response(
record: RuntimeProfileRechargeOrderRecord,
) -> ProfileRechargeOrderResponse {
ProfileRechargeOrderResponse {
order_id: record.order_id,
product_id: record.product_id,
product_title: record.product_title,
kind: record.kind.as_str().to_string(),
amount_cents: record.amount_cents,
status: record.status.as_str().to_string(),
payment_channel: record.payment_channel,
paid_at: record.paid_at,
created_at: record.created_at,
points_delta: record.points_delta,
membership_expires_at: record.membership_expires_at,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use axum::{ use axum::{
@@ -210,6 +354,43 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn profile_recharge_center_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/recharge-center")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_recharge_order_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("content-type", "application/json")
.body(Body::from(r#"{"productId":"points_10"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test] #[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() { async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape( assert_compat_route_matches_main_route_error_shape(

View File

@@ -1,4 +1,10 @@
use super::*; use super::*;
use crate::prompt::runtime_chat::{
RuntimeNpcDialoguePromptParams, RuntimeReasonedStoryPromptParams, RuntimeStoryTextPromptParams,
build_runtime_npc_dialogue_user_prompt, build_runtime_reasoned_story_user_prompt,
build_runtime_story_director_user_prompt, runtime_npc_dialogue_system_prompt,
runtime_reasoned_story_system_prompt, runtime_story_director_system_prompt,
};
pub(super) async fn build_runtime_story_ai_response( pub(super) async fn build_runtime_story_ai_response(
state: &AppState, state: &AppState,
@@ -25,21 +31,16 @@ pub(super) async fn generate_ai_story_text(
initial: bool, initial: bool,
) -> Option<String> { ) -> Option<String> {
let llm_client = state.llm_client()?; let llm_client = state.llm_client()?;
let system_prompt = if initial { let system_prompt = runtime_story_director_system_prompt(initial);
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。" let user_prompt = build_runtime_story_director_user_prompt(RuntimeStoryTextPromptParams {
} else { world_type: payload.world_type.as_str(),
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。" character: payload.character.clone(),
}; monsters: Value::Array(payload.monsters.clone()),
let user_prompt = json!({ history: Value::Array(payload.history.clone()),
"worldType": payload.world_type, choice: Value::String(payload.choice.clone()),
"character": payload.character, context: payload.context.clone(),
"monsters": payload.monsters, available_options: Value::Array(payload.request_options.available_options.clone()),
"history": payload.history, });
"choice": payload.choice,
"context": payload.context,
"availableOptions": payload.request_options.available_options,
})
.to_string();
let mut request = LlmTextRequest::new(vec![ let mut request = LlmTextRequest::new(vec![
LlmMessage::system(system_prompt), LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt), LlmMessage::user(user_prompt),
@@ -119,26 +120,27 @@ pub(super) async fn generate_npc_dialogue_payload(
let npc_name = read_optional_string_field(encounter, "npcName") let npc_name = read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name")) .or_else(|| read_optional_string_field(encounter, "name"))
.unwrap_or_else(|| "对方".to_string()); .unwrap_or_else(|| "对方".to_string());
let user_prompt = json!({ let user_prompt = build_runtime_npc_dialogue_user_prompt(
"worldType": world_type, npc_name.as_str(),
"character": character, RuntimeNpcDialoguePromptParams {
"encounter": encounter, world_type: world_type.as_str(),
"monsters": read_array_field(game_state, "sceneHostileNpcs").into_iter().cloned().collect::<Vec<_>>(), character: &character,
"history": build_action_story_history(game_state, action_text, result_text), encounter,
"context": build_action_story_prompt_context(game_state, None), monsters: read_array_field(game_state, "sceneHostileNpcs")
"topic": action_text, .into_iter()
"resultSummary": result_text, .cloned()
"requestedOption": request.action.payload, .collect::<Vec<_>>(),
"availableOptions": build_action_prompt_options(deferred_options), history: build_action_story_history(game_state, action_text, result_text),
}) context: build_action_story_prompt_context(game_state, None),
.to_string(); topic: action_text,
result_summary: result_text,
requested_option: request.action.payload.clone().unwrap_or(Value::Null),
available_options: build_action_prompt_options(deferred_options),
},
);
let mut llm_request = LlmTextRequest::new(vec![ let mut llm_request = LlmTextRequest::new(vec![
LlmMessage::system( LlmMessage::system(runtime_npc_dialogue_system_prompt()),
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。", LlmMessage::user(user_prompt),
),
LlmMessage::user(format!(
"请基于以下运行时状态,把玩家这一轮选择改写成 2 到 5 行可直接展示的 NPC 对话。可以使用“你:”和“{npc_name}:”格式,必须保留既有结算含义。\n{user_prompt}"
)),
]); ]);
llm_request.max_tokens = Some(700); llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search; llm_request.enable_web_search = enable_web_search;
@@ -173,25 +175,23 @@ pub(super) async fn generate_reasoned_story_payload(
) -> Option<GeneratedStoryPayload> { ) -> Option<GeneratedStoryPayload> {
let world_type = current_world_type(game_state)?; let world_type = current_world_type(game_state)?;
let character = read_object_field(game_state, "playerCharacter")?.clone(); let character = read_object_field(game_state, "playerCharacter")?.clone();
let user_prompt = json!({ let user_prompt = build_runtime_reasoned_story_user_prompt(RuntimeReasonedStoryPromptParams {
"worldType": world_type, world_type: world_type.as_str(),
"character": character, character: &character,
"monsters": read_array_field(game_state, "sceneHostileNpcs").into_iter().cloned().collect::<Vec<_>>(), monsters: read_array_field(game_state, "sceneHostileNpcs")
"history": build_action_story_history(game_state, action_text, result_text), .into_iter()
"context": build_action_story_prompt_context(game_state, battle), .cloned()
"choice": action_text, .collect::<Vec<_>>(),
"resultSummary": result_text, history: build_action_story_history(game_state, action_text, result_text),
"requestedOption": request.action.payload, context: build_action_story_prompt_context(game_state, battle),
"availableOptions": build_action_prompt_options(options), choice: action_text,
}) result_summary: result_text,
.to_string(); requested_option: request.action.payload.clone().unwrap_or(Value::Null),
available_options: build_action_prompt_options(options),
});
let mut llm_request = LlmTextRequest::new(vec![ let mut llm_request = LlmTextRequest::new(vec![
LlmMessage::system( LlmMessage::system(runtime_reasoned_story_system_prompt()),
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态不要发明额外奖励。", LlmMessage::user(user_prompt),
),
LlmMessage::user(format!(
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{user_prompt}"
)),
]); ]);
llm_request.max_tokens = Some(700); llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search; llm_request.enable_web_search = enable_web_search;

View File

@@ -15,8 +15,11 @@ pub const DEFAULT_PLATFORM_THEME: RuntimePlatformTheme = RuntimePlatformTheme::L
pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家"; pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家";
pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100; pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100;
pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50; pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50;
pub const PROFILE_REFERRAL_REWARD_POINTS: u64 = 30;
pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10;
pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
// 运行时设置目前只冻结 light/dark 两种主题,避免各层散落字符串字面量。 // 运行时设置目前只冻结 light/dark 两种主题,避免各层散落字符串字面量。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -253,6 +256,128 @@ pub struct RuntimeProfileDashboardGetInput {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileWalletLedgerSourceType { pub enum RuntimeProfileWalletLedgerSourceType {
SnapshotSync, SnapshotSync,
InviteInviterReward,
InviteInviteeReward,
PointsRecharge,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileRechargeProductKind {
Points,
Membership,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileMembershipStatus {
Normal,
Active,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileMembershipTier {
Normal,
Month,
Season,
Year,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileRechargeOrderStatus {
Paid,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeProductSnapshot {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: RuntimeProfileRechargeProductKind,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: RuntimeProfileMembershipTier,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileMembershipBenefitSnapshot {
pub benefit_name: String,
pub normal_value: String,
pub month_value: String,
pub season_value: String,
pub year_value: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileMembershipSnapshot {
pub user_id: String,
pub status: RuntimeProfileMembershipStatus,
pub tier: RuntimeProfileMembershipTier,
pub started_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub updated_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderSnapshot {
pub order_id: String,
pub user_id: String,
pub product_id: String,
pub product_title: String,
pub kind: RuntimeProfileRechargeProductKind,
pub amount_cents: u64,
pub status: RuntimeProfileRechargeOrderStatus,
pub payment_channel: String,
pub paid_at_micros: i64,
pub created_at_micros: i64,
pub points_delta: i64,
pub membership_expires_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeCenterSnapshot {
pub user_id: String,
pub wallet_balance: u64,
pub membership: RuntimeProfileMembershipSnapshot,
pub point_products: Vec<RuntimeProfileRechargeProductSnapshot>,
pub membership_products: Vec<RuntimeProfileRechargeProductSnapshot>,
pub benefits: Vec<RuntimeProfileMembershipBenefitSnapshot>,
pub latest_order: Option<RuntimeProfileRechargeOrderSnapshot>,
pub has_points_recharged: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeCenterProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRechargeCenterSnapshot>,
pub order: Option<RuntimeProfileRechargeOrderSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeCenterGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderCreateInput {
pub user_id: String,
pub product_id: String,
pub payment_channel: String,
pub created_at_micros: i64,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -280,6 +405,63 @@ pub struct RuntimeProfileWalletLedgerListInput {
pub user_id: String, pub user_id: String,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterSnapshot {
pub user_id: String,
pub invite_code: String,
pub invite_link_path: String,
pub invited_count: u32,
pub rewarded_invite_count: u32,
pub today_inviter_reward_count: u32,
pub today_inviter_reward_remaining: u32,
pub reward_points: u64,
pub has_redeemed_code: bool,
pub bound_inviter_user_id: Option<String>,
pub bound_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterProcedureResult {
pub ok: bool,
pub record: Option<RuntimeReferralInviteCenterSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralRedeemInput {
pub user_id: String,
pub invite_code: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralRedeemSnapshot {
pub center: RuntimeReferralInviteCenterSnapshot,
pub invitee_reward_granted: bool,
pub inviter_reward_granted: bool,
pub invitee_balance_after: u64,
pub inviter_balance_after: u64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralRedeemProcedureResult {
pub ok: bool,
pub record: Option<RuntimeReferralRedeemSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfilePlayedWorldSnapshot { pub struct RuntimeProfilePlayedWorldSnapshot {
@@ -333,8 +515,11 @@ pub enum RuntimeBrowseHistoryFieldError {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeProfileFieldError { pub enum RuntimeProfileFieldError {
MissingUserId, MissingUserId,
MissingInviteCode,
MissingProductId,
MissingWorldKey, MissingWorldKey,
MissingBottomTab, MissingBottomTab,
UnknownRechargeProduct,
InvalidGameStateJson, InvalidGameStateJson,
InvalidCurrentStoryJson, InvalidCurrentStoryJson,
} }
@@ -539,6 +724,100 @@ pub struct RuntimeProfilePlayStatsRecord {
pub updated_at_micros: Option<i64>, pub updated_at_micros: Option<i64>,
} }
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRechargeProductRecord {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: RuntimeProfileRechargeProductKind,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: RuntimeProfileMembershipTier,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileMembershipBenefitRecord {
pub benefit_name: String,
pub normal_value: String,
pub month_value: String,
pub season_value: String,
pub year_value: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileMembershipRecord {
pub user_id: String,
pub status: RuntimeProfileMembershipStatus,
pub tier: RuntimeProfileMembershipTier,
pub started_at: Option<String>,
pub started_at_micros: Option<i64>,
pub expires_at: Option<String>,
pub expires_at_micros: Option<i64>,
pub updated_at: Option<String>,
pub updated_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRechargeOrderRecord {
pub order_id: String,
pub user_id: String,
pub product_id: String,
pub product_title: String,
pub kind: RuntimeProfileRechargeProductKind,
pub amount_cents: u64,
pub status: RuntimeProfileRechargeOrderStatus,
pub payment_channel: String,
pub paid_at: String,
pub paid_at_micros: i64,
pub created_at: String,
pub created_at_micros: i64,
pub points_delta: i64,
pub membership_expires_at: Option<String>,
pub membership_expires_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRechargeCenterRecord {
pub user_id: String,
pub wallet_balance: u64,
pub membership: RuntimeProfileMembershipRecord,
pub point_products: Vec<RuntimeProfileRechargeProductRecord>,
pub membership_products: Vec<RuntimeProfileRechargeProductRecord>,
pub benefits: Vec<RuntimeProfileMembershipBenefitRecord>,
pub latest_order: Option<RuntimeProfileRechargeOrderRecord>,
pub has_points_recharged: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeReferralInviteCenterRecord {
pub user_id: String,
pub invite_code: String,
pub invite_link_path: String,
pub invited_count: u32,
pub rewarded_invite_count: u32,
pub today_inviter_reward_count: u32,
pub today_inviter_reward_remaining: u32,
pub reward_points: u64,
pub has_redeemed_code: bool,
pub bound_inviter_user_id: Option<String>,
pub bound_at: Option<String>,
pub bound_at_micros: Option<i64>,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeReferralRedeemRecord {
pub center: RuntimeReferralInviteCenterRecord,
pub invitee_reward_granted: bool,
pub inviter_reward_granted: bool,
pub invitee_balance_after: u64,
pub inviter_balance_after: u64,
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct RuntimeSnapshotRecord { pub struct RuntimeSnapshotRecord {
pub user_id: String, pub user_id: String,
@@ -598,6 +877,58 @@ pub fn build_runtime_profile_wallet_ledger_list_input(
Ok(RuntimeProfileWalletLedgerListInput { user_id }) Ok(RuntimeProfileWalletLedgerListInput { user_id })
} }
pub fn build_runtime_profile_recharge_center_get_input(
user_id: String,
) -> Result<RuntimeProfileRechargeCenterGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileRechargeCenterGetInput { user_id })
}
pub fn build_runtime_profile_recharge_order_create_input(
user_id: String,
product_id: String,
payment_channel: String,
created_at_micros: i64,
) -> Result<RuntimeProfileRechargeOrderCreateInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let product_id =
normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?;
if runtime_profile_recharge_product_by_id(&product_id).is_none() {
return Err(RuntimeProfileFieldError::UnknownRechargeProduct);
}
let payment_channel = normalize_required_string(payment_channel)
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
Ok(RuntimeProfileRechargeOrderCreateInput {
user_id,
product_id,
payment_channel,
created_at_micros,
})
}
pub fn build_runtime_referral_invite_center_get_input(
user_id: String,
) -> Result<RuntimeReferralInviteCenterGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeReferralInviteCenterGetInput { user_id })
}
pub fn build_runtime_referral_redeem_input(
user_id: String,
invite_code: String,
updated_at_micros: i64,
) -> Result<RuntimeReferralRedeemInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let invite_code =
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
Ok(RuntimeReferralRedeemInput {
user_id,
invite_code,
updated_at_micros,
})
}
pub fn build_runtime_profile_play_stats_get_input( pub fn build_runtime_profile_play_stats_get_input(
user_id: String, user_id: String,
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> { ) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
@@ -822,6 +1153,135 @@ pub fn build_runtime_profile_wallet_ledger_entry_record(
} }
} }
pub fn build_runtime_profile_recharge_center_record(
snapshot: RuntimeProfileRechargeCenterSnapshot,
) -> RuntimeProfileRechargeCenterRecord {
RuntimeProfileRechargeCenterRecord {
user_id: snapshot.user_id,
wallet_balance: snapshot.wallet_balance,
membership: build_runtime_profile_membership_record(snapshot.membership),
point_products: snapshot
.point_products
.into_iter()
.map(build_runtime_profile_recharge_product_record)
.collect(),
membership_products: snapshot
.membership_products
.into_iter()
.map(build_runtime_profile_recharge_product_record)
.collect(),
benefits: snapshot
.benefits
.into_iter()
.map(build_runtime_profile_membership_benefit_record)
.collect(),
latest_order: snapshot
.latest_order
.map(build_runtime_profile_recharge_order_record),
has_points_recharged: snapshot.has_points_recharged,
}
}
pub fn build_runtime_profile_recharge_product_record(
snapshot: RuntimeProfileRechargeProductSnapshot,
) -> RuntimeProfileRechargeProductRecord {
RuntimeProfileRechargeProductRecord {
product_id: snapshot.product_id,
title: snapshot.title,
price_cents: snapshot.price_cents,
kind: snapshot.kind,
points_amount: snapshot.points_amount,
bonus_points: snapshot.bonus_points,
duration_days: snapshot.duration_days,
badge_label: snapshot.badge_label,
description: snapshot.description,
tier: snapshot.tier,
}
}
pub fn build_runtime_profile_membership_benefit_record(
snapshot: RuntimeProfileMembershipBenefitSnapshot,
) -> RuntimeProfileMembershipBenefitRecord {
RuntimeProfileMembershipBenefitRecord {
benefit_name: snapshot.benefit_name,
normal_value: snapshot.normal_value,
month_value: snapshot.month_value,
season_value: snapshot.season_value,
year_value: snapshot.year_value,
}
}
pub fn build_runtime_profile_membership_record(
snapshot: RuntimeProfileMembershipSnapshot,
) -> RuntimeProfileMembershipRecord {
RuntimeProfileMembershipRecord {
user_id: snapshot.user_id,
status: snapshot.status,
tier: snapshot.tier,
started_at: snapshot.started_at_micros.map(format_utc_micros),
started_at_micros: snapshot.started_at_micros,
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
expires_at_micros: snapshot.expires_at_micros,
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_recharge_order_record(
snapshot: RuntimeProfileRechargeOrderSnapshot,
) -> RuntimeProfileRechargeOrderRecord {
RuntimeProfileRechargeOrderRecord {
order_id: snapshot.order_id,
user_id: snapshot.user_id,
product_id: snapshot.product_id,
product_title: snapshot.product_title,
kind: snapshot.kind,
amount_cents: snapshot.amount_cents,
status: snapshot.status,
payment_channel: snapshot.payment_channel,
paid_at: format_utc_micros(snapshot.paid_at_micros),
paid_at_micros: snapshot.paid_at_micros,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
points_delta: snapshot.points_delta,
membership_expires_at: snapshot.membership_expires_at_micros.map(format_utc_micros),
membership_expires_at_micros: snapshot.membership_expires_at_micros,
}
}
pub fn build_runtime_referral_invite_center_record(
snapshot: RuntimeReferralInviteCenterSnapshot,
) -> RuntimeReferralInviteCenterRecord {
RuntimeReferralInviteCenterRecord {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
invite_link_path: snapshot.invite_link_path,
invited_count: snapshot.invited_count,
rewarded_invite_count: snapshot.rewarded_invite_count,
today_inviter_reward_count: snapshot.today_inviter_reward_count,
today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining,
reward_points: snapshot.reward_points,
has_redeemed_code: snapshot.has_redeemed_code,
bound_inviter_user_id: snapshot.bound_inviter_user_id,
bound_at: snapshot.bound_at_micros.map(format_utc_micros),
bound_at_micros: snapshot.bound_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_referral_redeem_record(
snapshot: RuntimeReferralRedeemSnapshot,
) -> RuntimeReferralRedeemRecord {
RuntimeReferralRedeemRecord {
center: build_runtime_referral_invite_center_record(snapshot.center),
invitee_reward_granted: snapshot.invitee_reward_granted,
inviter_reward_granted: snapshot.inviter_reward_granted,
invitee_balance_after: snapshot.invitee_balance_after,
inviter_balance_after: snapshot.inviter_balance_after,
}
}
pub fn build_runtime_profile_played_world_record( pub fn build_runtime_profile_played_world_record(
snapshot: RuntimeProfilePlayedWorldSnapshot, snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> RuntimeProfilePlayedWorldRecord { ) -> RuntimeProfilePlayedWorldRecord {
@@ -1002,16 +1462,246 @@ impl RuntimeProfileWalletLedgerSourceType {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
Self::SnapshotSync => "snapshot_sync", Self::SnapshotSync => "snapshot_sync",
Self::InviteInviterReward => "invite_inviter_reward",
Self::InviteInviteeReward => "invite_invitee_reward",
Self::PointsRecharge => "points_recharge",
} }
} }
} }
impl RuntimeProfileRechargeProductKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Points => "points",
Self::Membership => "membership",
}
}
}
impl RuntimeProfileMembershipStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Normal => "normal",
Self::Active => "active",
}
}
}
impl RuntimeProfileMembershipTier {
pub fn as_str(&self) -> &'static str {
match self {
Self::Normal => "normal",
Self::Month => "month",
Self::Season => "season",
Self::Year => "year",
}
}
}
impl RuntimeProfileRechargeOrderStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Paid => "paid",
}
}
}
pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargeProductSnapshot> {
vec![
build_points_recharge_product(
"points_10",
"10积分",
100,
10,
19,
"首充送积分",
"首充送19积分",
),
build_points_recharge_product(
"points_60",
"60积分",
600,
60,
0,
"无首充赠礼",
"无首充赠送",
),
build_points_recharge_product(
"points_240",
"240积分",
2400,
240,
240,
"首充双倍",
"首充送240积分",
),
build_points_recharge_product(
"points_450",
"450积分",
4500,
450,
450,
"首充双倍",
"首充送450积分",
),
build_points_recharge_product(
"points_950",
"950积分",
9500,
950,
950,
"首充双倍",
"首充送950积分",
),
build_points_recharge_product(
"points_1980",
"1980积分",
19800,
1980,
1980,
"首充双倍",
"首充送1980积分",
),
]
}
pub fn runtime_profile_recharge_membership_products() -> Vec<RuntimeProfileRechargeProductSnapshot>
{
vec![
build_membership_recharge_product(
"member_month",
"月卡",
2800,
30,
RuntimeProfileMembershipTier::Month,
),
build_membership_recharge_product(
"member_season",
"季卡",
7800,
90,
RuntimeProfileMembershipTier::Season,
),
build_membership_recharge_product(
"member_year",
"年卡",
24800,
365,
RuntimeProfileMembershipTier::Year,
),
]
}
pub fn runtime_profile_membership_benefits() -> Vec<RuntimeProfileMembershipBenefitSnapshot> {
vec![
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "特权名称".to_string(),
normal_value: "普通".to_string(),
month_value: "月卡".to_string(),
season_value: "季卡".to_string(),
year_value: "年卡".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "免费".to_string(),
normal_value: "免费".to_string(),
month_value: "¥28".to_string(),
season_value: "¥78".to_string(),
year_value: "¥248".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "免积分回合数".to_string(),
normal_value: "30".to_string(),
month_value: "100".to_string(),
season_value: "100".to_string(),
year_value: "100".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "每日签到加成".to_string(),
normal_value: "0%".to_string(),
month_value: "0%".to_string(),
season_value: "+100%".to_string(),
year_value: "+210%".to_string(),
},
]
}
pub fn runtime_profile_recharge_product_by_id(
product_id: &str,
) -> Option<RuntimeProfileRechargeProductSnapshot> {
runtime_profile_recharge_point_products()
.into_iter()
.chain(runtime_profile_recharge_membership_products())
.find(|product| product.product_id == product_id)
}
fn build_points_recharge_product(
product_id: &str,
title: &str,
price_cents: u64,
points_amount: u64,
bonus_points: u64,
badge_label: &str,
description: &str,
) -> RuntimeProfileRechargeProductSnapshot {
RuntimeProfileRechargeProductSnapshot {
product_id: product_id.to_string(),
title: title.to_string(),
price_cents,
kind: RuntimeProfileRechargeProductKind::Points,
points_amount,
bonus_points,
duration_days: 0,
badge_label: badge_label.to_string(),
description: description.to_string(),
tier: RuntimeProfileMembershipTier::Normal,
}
}
fn build_membership_recharge_product(
product_id: &str,
title: &str,
price_cents: u64,
duration_days: u32,
tier: RuntimeProfileMembershipTier,
) -> RuntimeProfileRechargeProductSnapshot {
RuntimeProfileRechargeProductSnapshot {
product_id: product_id.to_string(),
title: title.to_string(),
price_cents,
kind: RuntimeProfileRechargeProductKind::Membership,
points_amount: 0,
bonus_points: 0,
duration_days,
badge_label: String::new(),
description: format!("{}天会员", duration_days),
tier,
}
}
pub fn normalize_invite_code(value: String) -> Option<String> {
let normalized = value
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.map(|character| character.to_ascii_uppercase())
.collect::<String>();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
impl std::fmt::Display for RuntimeProfileFieldError { impl std::fmt::Display for RuntimeProfileFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::MissingUserId => f.write_str("profile.user_id 不能为空"), Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
Self::UnknownRechargeProduct => f.write_str("recharge.product_id 不存在"),
Self::InvalidGameStateJson => { Self::InvalidGameStateJson => {
f.write_str("runtime_snapshot.game_state 必须是合法 JSON") f.write_str("runtime_snapshot.game_state 必须是合法 JSON")
} }
@@ -1268,5 +1958,38 @@ mod tests {
RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(), RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(),
"snapshot_sync" "snapshot_sync"
); );
assert_eq!(
RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(),
"points_recharge"
);
}
#[test]
fn recharge_product_catalog_matches_reference_prices() {
let point_products = runtime_profile_recharge_point_products();
let membership_products = runtime_profile_recharge_membership_products();
assert_eq!(point_products.len(), 6);
assert_eq!(point_products[0].product_id, "points_10");
assert_eq!(point_products[0].price_cents, 100);
assert_eq!(point_products[0].bonus_points, 19);
assert_eq!(point_products[5].points_amount, 1980);
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);
}
#[test]
fn build_recharge_order_input_rejects_unknown_product() {
let error = build_runtime_profile_recharge_order_create_input(
"user-1".to_string(),
"bad-product".to_string(),
"mock".to_string(),
1,
)
.expect_err("unknown product should fail");
assert_eq!(error, RuntimeProfileFieldError::UnknownRechargeProduct);
} }
} }

View File

@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ParseCreationAgentDocumentInputRequest {
pub file_name: String,
#[serde(default)]
pub content_type: Option<String>,
pub content_base64: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreationAgentDocumentInputPayload {
pub file_name: String,
#[serde(default)]
pub content_type: Option<String>,
pub size_bytes: usize,
pub text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ParseCreationAgentDocumentInputResponse {
pub document: CreationAgentDocumentInputPayload,
}

View File

@@ -5,6 +5,7 @@ pub mod assets;
pub mod auth; pub mod auth;
pub mod big_fish; pub mod big_fish;
pub mod big_fish_works; pub mod big_fish_works;
pub mod creation_agent_document_input;
pub mod llm; pub mod llm;
pub mod puzzle_agent; pub mod puzzle_agent;
pub mod puzzle_gallery; pub mod puzzle_gallery;

View File

@@ -4,6 +4,7 @@ pub const RUNTIME_PLATFORM_THEME_LIGHT: &str = "light";
pub const RUNTIME_PLATFORM_THEME_DARK: &str = "dark"; pub const RUNTIME_PLATFORM_THEME_DARK: &str = "dark";
pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge";
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -141,6 +142,84 @@ pub struct ProfileWalletLedgerResponse {
pub entries: Vec<ProfileWalletLedgerEntryResponse>, pub entries: Vec<ProfileWalletLedgerEntryResponse>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRechargeProductResponse {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: String,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileMembershipBenefitResponse {
pub benefit_name: String,
pub normal_value: String,
pub month_value: String,
pub season_value: String,
pub year_value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileMembershipResponse {
pub status: String,
pub tier: String,
pub started_at: Option<String>,
pub expires_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRechargeOrderResponse {
pub order_id: String,
pub product_id: String,
pub product_title: String,
pub kind: String,
pub amount_cents: u64,
pub status: String,
pub payment_channel: String,
pub paid_at: String,
pub created_at: String,
pub points_delta: i64,
pub membership_expires_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRechargeCenterResponse {
pub wallet_balance: u64,
pub membership: ProfileMembershipResponse,
pub point_products: Vec<ProfileRechargeProductResponse>,
pub membership_products: Vec<ProfileRechargeProductResponse>,
pub benefits: Vec<ProfileMembershipBenefitResponse>,
pub latest_order: Option<ProfileRechargeOrderResponse>,
pub has_points_recharged: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateProfileRechargeOrderRequest {
pub product_id: String,
#[serde(default)]
pub payment_channel: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateProfileRechargeOrderResponse {
pub order: ProfileRechargeOrderResponse,
pub center: ProfileRechargeCenterResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProfilePlayedWorkSummaryResponse { pub struct ProfilePlayedWorkSummaryResponse {
@@ -373,6 +452,7 @@ pub struct CustomWorldAgentOperationResponse {
pub phase_detail: String, pub phase_detail: String,
pub progress: u32, pub progress: u32,
pub error: Option<String>, pub error: Option<String>,
pub updated_at: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -656,6 +736,57 @@ mod tests {
); );
} }
#[test]
fn profile_recharge_center_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfileRechargeCenterResponse {
wallet_balance: 29,
membership: ProfileMembershipResponse {
status: "active".to_string(),
tier: "month".to_string(),
started_at: Some("2026-04-25T10:00:00Z".to_string()),
expires_at: Some("2026-05-25T10:00:00Z".to_string()),
updated_at: Some("2026-04-25T10:00:00Z".to_string()),
},
point_products: vec![ProfileRechargeProductResponse {
product_id: "points_10".to_string(),
title: "10积分".to_string(),
price_cents: 100,
kind: "points".to_string(),
points_amount: 10,
bonus_points: 19,
duration_days: 0,
badge_label: "首充送积分".to_string(),
description: "首充送19积分".to_string(),
tier: "normal".to_string(),
}],
membership_products: vec![],
benefits: vec![],
latest_order: None,
has_points_recharged: false,
})
.expect("payload should serialize");
assert_eq!(payload["walletBalance"], json!(29));
assert_eq!(
payload["membership"]["expiresAt"],
json!("2026-05-25T10:00:00Z")
);
assert_eq!(payload["pointProducts"][0]["productId"], json!("points_10"));
assert_eq!(payload["pointProducts"][0]["priceCents"], json!(100));
assert_eq!(payload["hasPointsRecharged"], json!(false));
}
#[test]
fn create_profile_recharge_order_request_accepts_optional_channel() {
let payload: CreateProfileRechargeOrderRequest = serde_json::from_value(json!({
"productId": "member_month"
}))
.expect("request should deserialize");
assert_eq!(payload.product_id, "member_month");
assert_eq!(payload.payment_channel, None);
}
#[test] #[test]
fn profile_play_stats_response_uses_camel_case_fields() { fn profile_play_stats_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfilePlayStatsResponse { let payload = serde_json::to_value(ProfilePlayStatsResponse {

View File

@@ -119,12 +119,16 @@ use module_puzzle::{
}; };
use module_runtime::{ use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileWalletLedgerEntryRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord, RuntimeSettingsRecord,
build_runtime_browse_history_record, build_runtime_browse_history_sync_input, RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record, build_runtime_browse_history_list_input, build_runtime_browse_history_record,
build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record, build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input,
build_runtime_profile_dashboard_record, build_runtime_profile_play_stats_get_input,
build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input,
build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input,
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record,
build_runtime_profile_save_archive_resume_input, build_runtime_profile_save_archive_resume_input,
build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_entry_record,

View File

@@ -116,6 +116,29 @@ impl From<module_runtime::RuntimeProfileWalletLedgerListInput>
} }
} }
impl From<module_runtime::RuntimeProfileRechargeCenterGetInput>
for RuntimeProfileRechargeCenterGetInput
{
fn from(input: module_runtime::RuntimeProfileRechargeCenterGetInput) -> Self {
Self {
user_id: input.user_id,
}
}
}
impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
for RuntimeProfileRechargeOrderCreateInput
{
fn from(input: module_runtime::RuntimeProfileRechargeOrderCreateInput) -> Self {
Self {
user_id: input.user_id,
product_id: input.product_id,
payment_channel: input.payment_channel,
created_at_micros: input.created_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfilePlayStatsGetInput> for RuntimeProfilePlayStatsGetInput { impl From<module_runtime::RuntimeProfilePlayStatsGetInput> for RuntimeProfilePlayStatsGetInput {
fn from(input: module_runtime::RuntimeProfilePlayStatsGetInput) -> Self { fn from(input: module_runtime::RuntimeProfilePlayStatsGetInput) -> Self {
Self { Self {
@@ -592,6 +615,66 @@ pub(crate) fn map_runtime_profile_wallet_ledger_procedure_result(
.collect()) .collect())
} }
pub(crate) fn map_runtime_profile_recharge_center_procedure_result(
result: RuntimeProfileRechargeCenterProcedureResult,
) -> Result<RuntimeProfileRechargeCenterRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 profile recharge center 快照".to_string(),
)
})?;
Ok(build_runtime_profile_recharge_center_record(
map_runtime_profile_recharge_center_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_recharge_order_procedure_result(
result: RuntimeProfileRechargeCenterProcedureResult,
) -> Result<
(
RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord,
),
SpacetimeClientError,
> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let center = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 profile recharge center 快照".to_string(),
)
})?;
let order = result.order.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 profile recharge order 快照".to_string(),
)
})?;
Ok((
build_runtime_profile_recharge_center_record(map_runtime_profile_recharge_center_snapshot(
center,
)),
module_runtime::build_runtime_profile_recharge_order_record(
map_runtime_profile_recharge_order_snapshot(order),
),
))
}
pub(crate) fn map_runtime_profile_play_stats_procedure_result( pub(crate) fn map_runtime_profile_play_stats_procedure_result(
result: RuntimeProfilePlayStatsProcedureResult, result: RuntimeProfilePlayStatsProcedureResult,
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> { ) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
@@ -1340,6 +1423,96 @@ pub(crate) fn map_runtime_profile_wallet_ledger_entry_snapshot(
} }
} }
pub(crate) fn map_runtime_profile_recharge_center_snapshot(
snapshot: RuntimeProfileRechargeCenterSnapshot,
) -> module_runtime::RuntimeProfileRechargeCenterSnapshot {
module_runtime::RuntimeProfileRechargeCenterSnapshot {
user_id: snapshot.user_id,
wallet_balance: snapshot.wallet_balance,
membership: map_runtime_profile_membership_snapshot(snapshot.membership),
point_products: snapshot
.point_products
.into_iter()
.map(map_runtime_profile_recharge_product_snapshot)
.collect(),
membership_products: snapshot
.membership_products
.into_iter()
.map(map_runtime_profile_recharge_product_snapshot)
.collect(),
benefits: snapshot
.benefits
.into_iter()
.map(map_runtime_profile_membership_benefit_snapshot)
.collect(),
latest_order: snapshot
.latest_order
.map(map_runtime_profile_recharge_order_snapshot),
has_points_recharged: snapshot.has_points_recharged,
}
}
pub(crate) fn map_runtime_profile_recharge_product_snapshot(
snapshot: RuntimeProfileRechargeProductSnapshot,
) -> module_runtime::RuntimeProfileRechargeProductSnapshot {
module_runtime::RuntimeProfileRechargeProductSnapshot {
product_id: snapshot.product_id,
title: snapshot.title,
price_cents: snapshot.price_cents,
kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind),
points_amount: snapshot.points_amount,
bonus_points: snapshot.bonus_points,
duration_days: snapshot.duration_days,
badge_label: snapshot.badge_label,
description: snapshot.description,
tier: map_runtime_profile_membership_tier_back(snapshot.tier),
}
}
pub(crate) fn map_runtime_profile_membership_benefit_snapshot(
snapshot: RuntimeProfileMembershipBenefitSnapshot,
) -> module_runtime::RuntimeProfileMembershipBenefitSnapshot {
module_runtime::RuntimeProfileMembershipBenefitSnapshot {
benefit_name: snapshot.benefit_name,
normal_value: snapshot.normal_value,
month_value: snapshot.month_value,
season_value: snapshot.season_value,
year_value: snapshot.year_value,
}
}
pub(crate) fn map_runtime_profile_membership_snapshot(
snapshot: RuntimeProfileMembershipSnapshot,
) -> module_runtime::RuntimeProfileMembershipSnapshot {
module_runtime::RuntimeProfileMembershipSnapshot {
user_id: snapshot.user_id,
status: map_runtime_profile_membership_status_back(snapshot.status),
tier: map_runtime_profile_membership_tier_back(snapshot.tier),
started_at_micros: snapshot.started_at_micros,
expires_at_micros: snapshot.expires_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_recharge_order_snapshot(
snapshot: RuntimeProfileRechargeOrderSnapshot,
) -> module_runtime::RuntimeProfileRechargeOrderSnapshot {
module_runtime::RuntimeProfileRechargeOrderSnapshot {
order_id: snapshot.order_id,
user_id: snapshot.user_id,
product_id: snapshot.product_id,
product_title: snapshot.product_title,
kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind),
amount_cents: snapshot.amount_cents,
status: map_runtime_profile_recharge_order_status_back(snapshot.status),
payment_channel: snapshot.payment_channel,
paid_at_micros: snapshot.paid_at_micros,
created_at_micros: snapshot.created_at_micros,
points_delta: snapshot.points_delta,
membership_expires_at_micros: snapshot.membership_expires_at_micros,
}
}
pub(crate) fn map_runtime_profile_played_world_snapshot( pub(crate) fn map_runtime_profile_played_world_snapshot(
snapshot: RuntimeProfilePlayedWorldSnapshot, snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { ) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
@@ -1664,6 +1837,7 @@ pub(crate) fn map_custom_world_agent_operation_snapshot(
phase_detail: snapshot.phase_detail, phase_detail: snapshot.phase_detail,
progress: snapshot.progress, progress: snapshot.progress,
error_message: snapshot.error_message, error_message: snapshot.error_message,
updated_at_micros: snapshot.updated_at_micros,
} }
} }
@@ -2965,6 +3139,70 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::SnapshotSync => { crate::module_bindings::RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync
} }
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::InviteInviterReward => {
module_runtime::RuntimeProfileWalletLedgerSourceType::InviteInviterReward
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::InviteInviteeReward => {
module_runtime::RuntimeProfileWalletLedgerSourceType::InviteInviteeReward
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge
}
}
}
pub(crate) fn map_runtime_profile_recharge_product_kind_back(
value: crate::module_bindings::RuntimeProfileRechargeProductKind,
) -> module_runtime::RuntimeProfileRechargeProductKind {
match value {
crate::module_bindings::RuntimeProfileRechargeProductKind::Points => {
module_runtime::RuntimeProfileRechargeProductKind::Points
}
crate::module_bindings::RuntimeProfileRechargeProductKind::Membership => {
module_runtime::RuntimeProfileRechargeProductKind::Membership
}
}
}
pub(crate) fn map_runtime_profile_membership_status_back(
value: crate::module_bindings::RuntimeProfileMembershipStatus,
) -> module_runtime::RuntimeProfileMembershipStatus {
match value {
crate::module_bindings::RuntimeProfileMembershipStatus::Normal => {
module_runtime::RuntimeProfileMembershipStatus::Normal
}
crate::module_bindings::RuntimeProfileMembershipStatus::Active => {
module_runtime::RuntimeProfileMembershipStatus::Active
}
}
}
pub(crate) fn map_runtime_profile_membership_tier_back(
value: crate::module_bindings::RuntimeProfileMembershipTier,
) -> module_runtime::RuntimeProfileMembershipTier {
match value {
crate::module_bindings::RuntimeProfileMembershipTier::Normal => {
module_runtime::RuntimeProfileMembershipTier::Normal
}
crate::module_bindings::RuntimeProfileMembershipTier::Month => {
module_runtime::RuntimeProfileMembershipTier::Month
}
crate::module_bindings::RuntimeProfileMembershipTier::Season => {
module_runtime::RuntimeProfileMembershipTier::Season
}
crate::module_bindings::RuntimeProfileMembershipTier::Year => {
module_runtime::RuntimeProfileMembershipTier::Year
}
}
}
pub(crate) fn map_runtime_profile_recharge_order_status_back(
value: crate::module_bindings::RuntimeProfileRechargeOrderStatus,
) -> module_runtime::RuntimeProfileRechargeOrderStatus {
match value {
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Paid => {
module_runtime::RuntimeProfileRechargeOrderStatus::Paid
}
} }
} }
@@ -3483,6 +3721,7 @@ pub struct CustomWorldAgentOperationRecord {
pub phase_detail: String, pub phase_detail: String,
pub progress: u32, pub progress: u32,
pub error_message: Option<String>, pub error_message: Option<String>,
pub updated_at_micros: i64,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -1,4 +1,4 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)] #![allow(unused, clippy::all)]
@@ -21,3 +21,4 @@ pub struct BigFishWorkDeleteInput {
impl __sdk::InModule for BigFishWorkDeleteInput { impl __sdk::InModule for BigFishWorkDeleteInput {
type Module = super::RemoteModule; type Module = super::RemoteModule;
} }

View File

@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_recharge_order_create_input_type::RuntimeProfileRechargeOrderCreateInput;
use super::runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct CreateProfileRechargeOrderAndReturnArgs {
pub input: RuntimeProfileRechargeOrderCreateInput,
}
impl __sdk::InModule for CreateProfileRechargeOrderAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `create_profile_recharge_order_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait create_profile_recharge_order_and_return {
fn create_profile_recharge_order_and_return(&self, input: RuntimeProfileRechargeOrderCreateInput,
) {
self.create_profile_recharge_order_and_return_then(input, |_, _| {});
}
fn create_profile_recharge_order_and_return_then(
&self,
input: RuntimeProfileRechargeOrderCreateInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl create_profile_recharge_order_and_return for super::RemoteProcedures {
fn create_profile_recharge_order_and_return_then(
&self,
input: RuntimeProfileRechargeOrderCreateInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>(
"create_profile_recharge_order_and_return",
CreateProfileRechargeOrderAndReturnArgs { input, },
__callback,
);
}
}

View File

@@ -9,8 +9,8 @@ use spacetimedb_sdk::__codegen::{
__ws, __ws,
}; };
use super::rpg_agent_operation_status_type::RpgAgentOperationStatus;
use super::rpg_agent_operation_type_type::RpgAgentOperationType; use super::rpg_agent_operation_type_type::RpgAgentOperationType;
use super::rpg_agent_operation_status_type::RpgAgentOperationStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)] #[sats(crate = __lib)]
@@ -27,6 +27,8 @@ pub struct CustomWorldAgentOperationProgressInput {
pub updated_at_micros: i64, pub updated_at_micros: i64,
} }
impl __sdk::InModule for CustomWorldAgentOperationProgressInput { impl __sdk::InModule for CustomWorldAgentOperationProgressInput {
type Module = super::RemoteModule; type Module = super::RemoteModule;
} }

View File

@@ -1,4 +1,4 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)] #![allow(unused, clippy::all)]
@@ -55,3 +55,4 @@ impl delete_big_fish_work for super::RemoteProcedures {
); );
} }
} }

View File

@@ -1,4 +1,4 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)] #![allow(unused, clippy::all)]
@@ -55,3 +55,4 @@ impl delete_custom_world_agent_session for super::RemoteProcedures {
); );
} }
} }

View File

@@ -1,4 +1,4 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)] #![allow(unused, clippy::all)]
@@ -55,3 +55,4 @@ impl delete_puzzle_work for super::RemoteProcedures {
); );
} }
} }

View File

@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
use super::runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCenterGetInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetProfileRechargeCenterArgs {
pub input: RuntimeProfileRechargeCenterGetInput,
}
impl __sdk::InModule for GetProfileRechargeCenterArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_profile_recharge_center`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_profile_recharge_center {
fn get_profile_recharge_center(&self, input: RuntimeProfileRechargeCenterGetInput,
) {
self.get_profile_recharge_center_then(input, |_, _| {});
}
fn get_profile_recharge_center_then(
&self,
input: RuntimeProfileRechargeCenterGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl get_profile_recharge_center for super::RemoteProcedures {
fn get_profile_recharge_center_then(
&self,
input: RuntimeProfileRechargeCenterGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>(
"get_profile_recharge_center",
GetProfileRechargeCenterArgs { input, },
__callback,
);
}
}

View File

@@ -9,8 +9,8 @@ use spacetimedb_sdk::__codegen::{
__ws, __ws,
}; };
use super::big_fish_works_list_input_type::BigFishWorksListInput;
use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult; use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
use super::big_fish_works_list_input_type::BigFishWorksListInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)] #[sats(crate = __lib)]

View File

@@ -9,8 +9,8 @@ use spacetimedb_sdk::__codegen::{
__ws, __ws,
}; };
use super::custom_world_works_list_input_type::CustomWorldWorksListInput;
use super::custom_world_works_list_result_type::CustomWorldWorksListResult; use super::custom_world_works_list_result_type::CustomWorldWorksListResult;
use super::custom_world_works_list_input_type::CustomWorldWorksListInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)] #[sats(crate = __lib)]

View File

@@ -115,8 +115,8 @@ pub mod custom_world_agent_message_snapshot_type;
pub mod custom_world_agent_message_submit_input_type; pub mod custom_world_agent_message_submit_input_type;
pub mod custom_world_agent_operation_type; pub mod custom_world_agent_operation_type;
pub mod custom_world_agent_operation_get_input_type; pub mod custom_world_agent_operation_get_input_type;
pub mod custom_world_agent_operation_progress_input_type;
pub mod custom_world_agent_operation_procedure_result_type; pub mod custom_world_agent_operation_procedure_result_type;
pub mod custom_world_agent_operation_progress_input_type;
pub mod custom_world_agent_operation_snapshot_type; pub mod custom_world_agent_operation_snapshot_type;
pub mod custom_world_agent_session_type; pub mod custom_world_agent_session_type;
pub mod custom_world_agent_session_create_input_type; pub mod custom_world_agent_session_create_input_type;
@@ -189,7 +189,9 @@ pub mod player_progression_grant_source_type;
pub mod player_progression_procedure_result_type; pub mod player_progression_procedure_result_type;
pub mod player_progression_snapshot_type; pub mod player_progression_snapshot_type;
pub mod profile_dashboard_state_type; pub mod profile_dashboard_state_type;
pub mod profile_membership_type;
pub mod profile_played_world_type; pub mod profile_played_world_type;
pub mod profile_recharge_order_type;
pub mod profile_save_archive_type; pub mod profile_save_archive_type;
pub mod profile_wallet_ledger_type; pub mod profile_wallet_ledger_type;
pub mod puzzle_agent_message_finalize_input_type; pub mod puzzle_agent_message_finalize_input_type;
@@ -279,10 +281,22 @@ pub mod runtime_platform_theme_type;
pub mod runtime_profile_dashboard_get_input_type; pub mod runtime_profile_dashboard_get_input_type;
pub mod runtime_profile_dashboard_procedure_result_type; pub mod runtime_profile_dashboard_procedure_result_type;
pub mod runtime_profile_dashboard_snapshot_type; pub mod runtime_profile_dashboard_snapshot_type;
pub mod runtime_profile_membership_benefit_snapshot_type;
pub mod runtime_profile_membership_snapshot_type;
pub mod runtime_profile_membership_status_type;
pub mod runtime_profile_membership_tier_type;
pub mod runtime_profile_play_stats_get_input_type; pub mod runtime_profile_play_stats_get_input_type;
pub mod runtime_profile_play_stats_procedure_result_type; pub mod runtime_profile_play_stats_procedure_result_type;
pub mod runtime_profile_play_stats_snapshot_type; pub mod runtime_profile_play_stats_snapshot_type;
pub mod runtime_profile_played_world_snapshot_type; pub mod runtime_profile_played_world_snapshot_type;
pub mod runtime_profile_recharge_center_get_input_type;
pub mod runtime_profile_recharge_center_procedure_result_type;
pub mod runtime_profile_recharge_center_snapshot_type;
pub mod runtime_profile_recharge_order_create_input_type;
pub mod runtime_profile_recharge_order_snapshot_type;
pub mod runtime_profile_recharge_order_status_type;
pub mod runtime_profile_recharge_product_kind_type;
pub mod runtime_profile_recharge_product_snapshot_type;
pub mod runtime_profile_save_archive_list_input_type; pub mod runtime_profile_save_archive_list_input_type;
pub mod runtime_profile_save_archive_procedure_result_type; pub mod runtime_profile_save_archive_procedure_result_type;
pub mod runtime_profile_save_archive_resume_input_type; pub mod runtime_profile_save_archive_resume_input_type;
@@ -345,48 +359,7 @@ pub mod unpublish_custom_world_profile_reducer;
pub mod upsert_chapter_progression_reducer; pub mod upsert_chapter_progression_reducer;
pub mod upsert_custom_world_profile_reducer; pub mod upsert_custom_world_profile_reducer;
pub mod upsert_npc_state_reducer; pub mod upsert_npc_state_reducer;
pub mod ai_result_reference_table;
pub mod ai_task_table;
pub mod ai_task_stage_table;
pub mod ai_text_chunk_table;
pub mod asset_entity_binding_table;
pub mod asset_object_table;
pub mod auth_identity_table;
pub mod auth_store_snapshot_table;
pub mod battle_state_table;
pub mod big_fish_agent_message_table;
pub mod big_fish_asset_slot_table;
pub mod big_fish_creation_session_table;
pub mod big_fish_runtime_run_table;
pub mod chapter_progression_table;
pub mod custom_world_agent_message_table;
pub mod custom_world_agent_operation_table;
pub mod custom_world_agent_session_table;
pub mod custom_world_draft_card_table;
pub mod custom_world_gallery_entry_table; pub mod custom_world_gallery_entry_table;
pub mod custom_world_profile_table;
pub mod custom_world_session_table;
pub mod inventory_slot_table;
pub mod npc_state_table;
pub mod player_progression_table;
pub mod profile_dashboard_state_table;
pub mod profile_played_world_table;
pub mod profile_save_archive_table;
pub mod profile_wallet_ledger_table;
pub mod puzzle_agent_message_table;
pub mod puzzle_agent_session_table;
pub mod puzzle_runtime_run_table;
pub mod puzzle_work_profile_table;
pub mod quest_log_table;
pub mod quest_record_table;
pub mod refresh_session_table;
pub mod runtime_setting_table;
pub mod runtime_snapshot_table;
pub mod story_event_table;
pub mod story_session_table;
pub mod treasure_record_table;
pub mod user_account_table;
pub mod user_browse_history_table;
pub mod advance_puzzle_next_level_procedure; pub mod advance_puzzle_next_level_procedure;
pub mod append_ai_text_chunk_and_return_procedure; pub mod append_ai_text_chunk_and_return_procedure;
pub mod apply_chapter_progression_ledger_entry_and_return_procedure; pub mod apply_chapter_progression_ledger_entry_and_return_procedure;
@@ -406,6 +379,7 @@ pub mod create_ai_task_and_return_procedure;
pub mod create_battle_state_and_return_procedure; pub mod create_battle_state_and_return_procedure;
pub mod create_big_fish_session_procedure; pub mod create_big_fish_session_procedure;
pub mod create_custom_world_agent_session_procedure; pub mod create_custom_world_agent_session_procedure;
pub mod create_profile_recharge_order_and_return_procedure;
pub mod create_puzzle_agent_session_procedure; pub mod create_puzzle_agent_session_procedure;
pub mod delete_big_fish_work_procedure; pub mod delete_big_fish_work_procedure;
pub mod delete_custom_world_agent_session_procedure; pub mod delete_custom_world_agent_session_procedure;
@@ -434,6 +408,7 @@ pub mod get_custom_world_library_detail_procedure;
pub mod get_player_progression_or_default_procedure; pub mod get_player_progression_or_default_procedure;
pub mod get_profile_dashboard_procedure; pub mod get_profile_dashboard_procedure;
pub mod get_profile_play_stats_procedure; pub mod get_profile_play_stats_procedure;
pub mod get_profile_recharge_center_procedure;
pub mod get_puzzle_agent_session_procedure; pub mod get_puzzle_agent_session_procedure;
pub mod get_puzzle_gallery_detail_procedure; pub mod get_puzzle_gallery_detail_procedure;
pub mod get_puzzle_run_procedure; pub mod get_puzzle_run_procedure;
@@ -587,8 +562,8 @@ pub use custom_world_agent_message_snapshot_type::CustomWorldAgentMessageSnapsho
pub use custom_world_agent_message_submit_input_type::CustomWorldAgentMessageSubmitInput; pub use custom_world_agent_message_submit_input_type::CustomWorldAgentMessageSubmitInput;
pub use custom_world_agent_operation_type::CustomWorldAgentOperation; pub use custom_world_agent_operation_type::CustomWorldAgentOperation;
pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput; pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput;
pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput;
pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult; pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult;
pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput;
pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot; pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot;
pub use custom_world_agent_session_type::CustomWorldAgentSession; pub use custom_world_agent_session_type::CustomWorldAgentSession;
pub use custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput; pub use custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput;
@@ -661,7 +636,9 @@ pub use player_progression_grant_source_type::PlayerProgressionGrantSource;
pub use player_progression_procedure_result_type::PlayerProgressionProcedureResult; pub use player_progression_procedure_result_type::PlayerProgressionProcedureResult;
pub use player_progression_snapshot_type::PlayerProgressionSnapshot; pub use player_progression_snapshot_type::PlayerProgressionSnapshot;
pub use profile_dashboard_state_type::ProfileDashboardState; pub use profile_dashboard_state_type::ProfileDashboardState;
pub use profile_membership_type::ProfileMembership;
pub use profile_played_world_type::ProfilePlayedWorld; pub use profile_played_world_type::ProfilePlayedWorld;
pub use profile_recharge_order_type::ProfileRechargeOrder;
pub use profile_save_archive_type::ProfileSaveArchive; pub use profile_save_archive_type::ProfileSaveArchive;
pub use profile_wallet_ledger_type::ProfileWalletLedger; pub use profile_wallet_ledger_type::ProfileWalletLedger;
pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput; pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput;
@@ -751,10 +728,22 @@ pub use runtime_platform_theme_type::RuntimePlatformTheme;
pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput;
pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult; pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult;
pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot; pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot;
pub use runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot;
pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot;
pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus;
pub use runtime_profile_membership_tier_type::RuntimeProfileMembershipTier;
pub use runtime_profile_play_stats_get_input_type::RuntimeProfilePlayStatsGetInput; pub use runtime_profile_play_stats_get_input_type::RuntimeProfilePlayStatsGetInput;
pub use runtime_profile_play_stats_procedure_result_type::RuntimeProfilePlayStatsProcedureResult; pub use runtime_profile_play_stats_procedure_result_type::RuntimeProfilePlayStatsProcedureResult;
pub use runtime_profile_play_stats_snapshot_type::RuntimeProfilePlayStatsSnapshot; pub use runtime_profile_play_stats_snapshot_type::RuntimeProfilePlayStatsSnapshot;
pub use runtime_profile_played_world_snapshot_type::RuntimeProfilePlayedWorldSnapshot; pub use runtime_profile_played_world_snapshot_type::RuntimeProfilePlayedWorldSnapshot;
pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCenterGetInput;
pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot;
pub use runtime_profile_recharge_order_create_input_type::RuntimeProfileRechargeOrderCreateInput;
pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot;
pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveListInput; pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveListInput;
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult; pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput; pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;
@@ -793,48 +782,7 @@ pub use treasure_resolve_input_type::TreasureResolveInput;
pub use unequip_inventory_item_input_type::UnequipInventoryItemInput; pub use unequip_inventory_item_input_type::UnequipInventoryItemInput;
pub use user_account_type::UserAccount; pub use user_account_type::UserAccount;
pub use user_browse_history_type::UserBrowseHistory; pub use user_browse_history_type::UserBrowseHistory;
pub use ai_result_reference_table::*;
pub use ai_task_table::*;
pub use ai_task_stage_table::*;
pub use ai_text_chunk_table::*;
pub use asset_entity_binding_table::*;
pub use asset_object_table::*;
pub use auth_identity_table::*;
pub use auth_store_snapshot_table::*;
pub use battle_state_table::*;
pub use big_fish_agent_message_table::*;
pub use big_fish_asset_slot_table::*;
pub use big_fish_creation_session_table::*;
pub use big_fish_runtime_run_table::*;
pub use chapter_progression_table::*;
pub use custom_world_agent_message_table::*;
pub use custom_world_agent_operation_table::*;
pub use custom_world_agent_session_table::*;
pub use custom_world_draft_card_table::*;
pub use custom_world_gallery_entry_table::*; pub use custom_world_gallery_entry_table::*;
pub use custom_world_profile_table::*;
pub use custom_world_session_table::*;
pub use inventory_slot_table::*;
pub use npc_state_table::*;
pub use player_progression_table::*;
pub use profile_dashboard_state_table::*;
pub use profile_played_world_table::*;
pub use profile_save_archive_table::*;
pub use profile_wallet_ledger_table::*;
pub use puzzle_agent_message_table::*;
pub use puzzle_agent_session_table::*;
pub use puzzle_runtime_run_table::*;
pub use puzzle_work_profile_table::*;
pub use quest_log_table::*;
pub use quest_record_table::*;
pub use refresh_session_table::*;
pub use runtime_setting_table::*;
pub use runtime_snapshot_table::*;
pub use story_event_table::*;
pub use story_session_table::*;
pub use treasure_record_table::*;
pub use user_account_table::*;
pub use user_browse_history_table::*;
pub use accept_quest_reducer::accept_quest; pub use accept_quest_reducer::accept_quest;
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
pub use apply_chapter_progression_ledger_entry_reducer::apply_chapter_progression_ledger_entry; pub use apply_chapter_progression_ledger_entry_reducer::apply_chapter_progression_ledger_entry;
@@ -878,8 +826,12 @@ pub use create_ai_task_and_return_procedure::create_ai_task_and_return;
pub use create_battle_state_and_return_procedure::create_battle_state_and_return; pub use create_battle_state_and_return_procedure::create_battle_state_and_return;
pub use create_big_fish_session_procedure::create_big_fish_session; pub use create_big_fish_session_procedure::create_big_fish_session;
pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session; pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session;
pub use create_profile_recharge_order_and_return_procedure::create_profile_recharge_order_and_return;
pub use create_puzzle_agent_session_procedure::create_puzzle_agent_session; pub use create_puzzle_agent_session_procedure::create_puzzle_agent_session;
pub use delete_big_fish_work_procedure::delete_big_fish_work;
pub use delete_custom_world_agent_session_procedure::delete_custom_world_agent_session;
pub use delete_custom_world_profile_and_return_procedure::delete_custom_world_profile_and_return; pub use delete_custom_world_profile_and_return_procedure::delete_custom_world_profile_and_return;
pub use delete_puzzle_work_procedure::delete_puzzle_work;
pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return; pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return;
pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group;
pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action; pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action;
@@ -903,6 +855,7 @@ pub use get_custom_world_library_detail_procedure::get_custom_world_library_deta
pub use get_player_progression_or_default_procedure::get_player_progression_or_default; pub use get_player_progression_or_default_procedure::get_player_progression_or_default;
pub use get_profile_dashboard_procedure::get_profile_dashboard; pub use get_profile_dashboard_procedure::get_profile_dashboard;
pub use get_profile_play_stats_procedure::get_profile_play_stats; pub use get_profile_play_stats_procedure::get_profile_play_stats;
pub use get_profile_recharge_center_procedure::get_profile_recharge_center;
pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session; pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session;
pub use get_puzzle_gallery_detail_procedure::get_puzzle_gallery_detail; pub use get_puzzle_gallery_detail_procedure::get_puzzle_gallery_detail;
pub use get_puzzle_run_procedure::get_puzzle_run; pub use get_puzzle_run_procedure::get_puzzle_run;
@@ -1201,48 +1154,7 @@ fn args_bsatn(&self) -> Result<Vec<u8>, __sats::bsatn::EncodeError> {
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[doc(hidden)] #[doc(hidden)]
pub struct DbUpdate { pub struct DbUpdate {
ai_result_reference: __sdk::TableUpdate<AiResultReference>, custom_world_gallery_entry: __sdk::TableUpdate<CustomWorldGalleryEntry>,
ai_task: __sdk::TableUpdate<AiTask>,
ai_task_stage: __sdk::TableUpdate<AiTaskStage>,
ai_text_chunk: __sdk::TableUpdate<AiTextChunk>,
asset_entity_binding: __sdk::TableUpdate<AssetEntityBinding>,
asset_object: __sdk::TableUpdate<AssetObject>,
auth_identity: __sdk::TableUpdate<AuthIdentity>,
auth_store_snapshot: __sdk::TableUpdate<AuthStoreSnapshot>,
battle_state: __sdk::TableUpdate<BattleState>,
big_fish_agent_message: __sdk::TableUpdate<BigFishAgentMessage>,
big_fish_asset_slot: __sdk::TableUpdate<BigFishAssetSlot>,
big_fish_creation_session: __sdk::TableUpdate<BigFishCreationSession>,
big_fish_runtime_run: __sdk::TableUpdate<BigFishRuntimeRun>,
chapter_progression: __sdk::TableUpdate<ChapterProgression>,
custom_world_agent_message: __sdk::TableUpdate<CustomWorldAgentMessage>,
custom_world_agent_operation: __sdk::TableUpdate<CustomWorldAgentOperation>,
custom_world_agent_session: __sdk::TableUpdate<CustomWorldAgentSession>,
custom_world_draft_card: __sdk::TableUpdate<CustomWorldDraftCard>,
custom_world_gallery_entry: __sdk::TableUpdate<CustomWorldGalleryEntry>,
custom_world_profile: __sdk::TableUpdate<CustomWorldProfile>,
custom_world_session: __sdk::TableUpdate<CustomWorldSession>,
inventory_slot: __sdk::TableUpdate<InventorySlot>,
npc_state: __sdk::TableUpdate<NpcState>,
player_progression: __sdk::TableUpdate<PlayerProgression>,
profile_dashboard_state: __sdk::TableUpdate<ProfileDashboardState>,
profile_played_world: __sdk::TableUpdate<ProfilePlayedWorld>,
profile_save_archive: __sdk::TableUpdate<ProfileSaveArchive>,
profile_wallet_ledger: __sdk::TableUpdate<ProfileWalletLedger>,
puzzle_agent_message: __sdk::TableUpdate<PuzzleAgentMessageRow>,
puzzle_agent_session: __sdk::TableUpdate<PuzzleAgentSessionRow>,
puzzle_runtime_run: __sdk::TableUpdate<PuzzleRuntimeRunRow>,
puzzle_work_profile: __sdk::TableUpdate<PuzzleWorkProfileRow>,
quest_log: __sdk::TableUpdate<QuestLog>,
quest_record: __sdk::TableUpdate<QuestRecord>,
refresh_session: __sdk::TableUpdate<RefreshSession>,
runtime_setting: __sdk::TableUpdate<RuntimeSetting>,
runtime_snapshot: __sdk::TableUpdate<RuntimeSnapshotRow>,
story_event: __sdk::TableUpdate<StoryEvent>,
story_session: __sdk::TableUpdate<StorySession>,
treasure_record: __sdk::TableUpdate<TreasureRecord>,
user_account: __sdk::TableUpdate<UserAccount>,
user_browse_history: __sdk::TableUpdate<UserBrowseHistory>,
} }
@@ -1253,48 +1165,7 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
for table_update in __sdk::transaction_update_iter_table_updates(raw) { for table_update in __sdk::transaction_update_iter_table_updates(raw) {
match &table_update.table_name[..] { match &table_update.table_name[..] {
"ai_result_reference" => db_update.ai_result_reference.append(ai_result_reference_table::parse_table_update(table_update)?), "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(custom_world_gallery_entry_table::parse_table_update(table_update)?),
"ai_task" => db_update.ai_task.append(ai_task_table::parse_table_update(table_update)?),
"ai_task_stage" => db_update.ai_task_stage.append(ai_task_stage_table::parse_table_update(table_update)?),
"ai_text_chunk" => db_update.ai_text_chunk.append(ai_text_chunk_table::parse_table_update(table_update)?),
"asset_entity_binding" => db_update.asset_entity_binding.append(asset_entity_binding_table::parse_table_update(table_update)?),
"asset_object" => db_update.asset_object.append(asset_object_table::parse_table_update(table_update)?),
"auth_identity" => db_update.auth_identity.append(auth_identity_table::parse_table_update(table_update)?),
"auth_store_snapshot" => db_update.auth_store_snapshot.append(auth_store_snapshot_table::parse_table_update(table_update)?),
"battle_state" => db_update.battle_state.append(battle_state_table::parse_table_update(table_update)?),
"big_fish_agent_message" => db_update.big_fish_agent_message.append(big_fish_agent_message_table::parse_table_update(table_update)?),
"big_fish_asset_slot" => db_update.big_fish_asset_slot.append(big_fish_asset_slot_table::parse_table_update(table_update)?),
"big_fish_creation_session" => db_update.big_fish_creation_session.append(big_fish_creation_session_table::parse_table_update(table_update)?),
"big_fish_runtime_run" => db_update.big_fish_runtime_run.append(big_fish_runtime_run_table::parse_table_update(table_update)?),
"chapter_progression" => db_update.chapter_progression.append(chapter_progression_table::parse_table_update(table_update)?),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(custom_world_agent_message_table::parse_table_update(table_update)?),
"custom_world_agent_operation" => db_update.custom_world_agent_operation.append(custom_world_agent_operation_table::parse_table_update(table_update)?),
"custom_world_agent_session" => db_update.custom_world_agent_session.append(custom_world_agent_session_table::parse_table_update(table_update)?),
"custom_world_draft_card" => db_update.custom_world_draft_card.append(custom_world_draft_card_table::parse_table_update(table_update)?),
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(custom_world_gallery_entry_table::parse_table_update(table_update)?),
"custom_world_profile" => db_update.custom_world_profile.append(custom_world_profile_table::parse_table_update(table_update)?),
"custom_world_session" => db_update.custom_world_session.append(custom_world_session_table::parse_table_update(table_update)?),
"inventory_slot" => db_update.inventory_slot.append(inventory_slot_table::parse_table_update(table_update)?),
"npc_state" => db_update.npc_state.append(npc_state_table::parse_table_update(table_update)?),
"player_progression" => db_update.player_progression.append(player_progression_table::parse_table_update(table_update)?),
"profile_dashboard_state" => db_update.profile_dashboard_state.append(profile_dashboard_state_table::parse_table_update(table_update)?),
"profile_played_world" => db_update.profile_played_world.append(profile_played_world_table::parse_table_update(table_update)?),
"profile_save_archive" => db_update.profile_save_archive.append(profile_save_archive_table::parse_table_update(table_update)?),
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(profile_wallet_ledger_table::parse_table_update(table_update)?),
"puzzle_agent_message" => db_update.puzzle_agent_message.append(puzzle_agent_message_table::parse_table_update(table_update)?),
"puzzle_agent_session" => db_update.puzzle_agent_session.append(puzzle_agent_session_table::parse_table_update(table_update)?),
"puzzle_runtime_run" => db_update.puzzle_runtime_run.append(puzzle_runtime_run_table::parse_table_update(table_update)?),
"puzzle_work_profile" => db_update.puzzle_work_profile.append(puzzle_work_profile_table::parse_table_update(table_update)?),
"quest_log" => db_update.quest_log.append(quest_log_table::parse_table_update(table_update)?),
"quest_record" => db_update.quest_record.append(quest_record_table::parse_table_update(table_update)?),
"refresh_session" => db_update.refresh_session.append(refresh_session_table::parse_table_update(table_update)?),
"runtime_setting" => db_update.runtime_setting.append(runtime_setting_table::parse_table_update(table_update)?),
"runtime_snapshot" => db_update.runtime_snapshot.append(runtime_snapshot_table::parse_table_update(table_update)?),
"story_event" => db_update.story_event.append(story_event_table::parse_table_update(table_update)?),
"story_session" => db_update.story_session.append(story_session_table::parse_table_update(table_update)?),
"treasure_record" => db_update.treasure_record.append(treasure_record_table::parse_table_update(table_update)?),
"user_account" => db_update.user_account.append(user_account_table::parse_table_update(table_update)?),
"user_browse_history" => db_update.user_browse_history.append(user_browse_history_table::parse_table_update(table_update)?),
unknown => { unknown => {
return Err(__sdk::InternalError::unknown_name( return Err(__sdk::InternalError::unknown_name(
@@ -1317,48 +1188,7 @@ impl __sdk::DbUpdate for DbUpdate {
fn apply_to_client_cache(&self, cache: &mut __sdk::ClientCache<RemoteModule>) -> AppliedDiff<'_> { fn apply_to_client_cache(&self, cache: &mut __sdk::ClientCache<RemoteModule>) -> AppliedDiff<'_> {
let mut diff = AppliedDiff::default(); let mut diff = AppliedDiff::default();
diff.ai_result_reference = cache.apply_diff_to_table::<AiResultReference>("ai_result_reference", &self.ai_result_reference).with_updates_by_pk(|row| &row.result_reference_row_id); diff.custom_world_gallery_entry = cache.apply_diff_to_table::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry).with_updates_by_pk(|row| &row.profile_id);
diff.ai_task = cache.apply_diff_to_table::<AiTask>("ai_task", &self.ai_task).with_updates_by_pk(|row| &row.task_id);
diff.ai_task_stage = cache.apply_diff_to_table::<AiTaskStage>("ai_task_stage", &self.ai_task_stage).with_updates_by_pk(|row| &row.task_stage_id);
diff.ai_text_chunk = cache.apply_diff_to_table::<AiTextChunk>("ai_text_chunk", &self.ai_text_chunk).with_updates_by_pk(|row| &row.text_chunk_row_id);
diff.asset_entity_binding = cache.apply_diff_to_table::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding).with_updates_by_pk(|row| &row.binding_id);
diff.asset_object = cache.apply_diff_to_table::<AssetObject>("asset_object", &self.asset_object).with_updates_by_pk(|row| &row.asset_object_id);
diff.auth_identity = cache.apply_diff_to_table::<AuthIdentity>("auth_identity", &self.auth_identity).with_updates_by_pk(|row| &row.identity_id);
diff.auth_store_snapshot = cache.apply_diff_to_table::<AuthStoreSnapshot>("auth_store_snapshot", &self.auth_store_snapshot).with_updates_by_pk(|row| &row.snapshot_id);
diff.battle_state = cache.apply_diff_to_table::<BattleState>("battle_state", &self.battle_state).with_updates_by_pk(|row| &row.battle_state_id);
diff.big_fish_agent_message = cache.apply_diff_to_table::<BigFishAgentMessage>("big_fish_agent_message", &self.big_fish_agent_message).with_updates_by_pk(|row| &row.message_id);
diff.big_fish_asset_slot = cache.apply_diff_to_table::<BigFishAssetSlot>("big_fish_asset_slot", &self.big_fish_asset_slot).with_updates_by_pk(|row| &row.slot_id);
diff.big_fish_creation_session = cache.apply_diff_to_table::<BigFishCreationSession>("big_fish_creation_session", &self.big_fish_creation_session).with_updates_by_pk(|row| &row.session_id);
diff.big_fish_runtime_run = cache.apply_diff_to_table::<BigFishRuntimeRun>("big_fish_runtime_run", &self.big_fish_runtime_run).with_updates_by_pk(|row| &row.run_id);
diff.chapter_progression = cache.apply_diff_to_table::<ChapterProgression>("chapter_progression", &self.chapter_progression).with_updates_by_pk(|row| &row.chapter_progression_id);
diff.custom_world_agent_message = cache.apply_diff_to_table::<CustomWorldAgentMessage>("custom_world_agent_message", &self.custom_world_agent_message).with_updates_by_pk(|row| &row.message_id);
diff.custom_world_agent_operation = cache.apply_diff_to_table::<CustomWorldAgentOperation>("custom_world_agent_operation", &self.custom_world_agent_operation).with_updates_by_pk(|row| &row.operation_id);
diff.custom_world_agent_session = cache.apply_diff_to_table::<CustomWorldAgentSession>("custom_world_agent_session", &self.custom_world_agent_session).with_updates_by_pk(|row| &row.session_id);
diff.custom_world_draft_card = cache.apply_diff_to_table::<CustomWorldDraftCard>("custom_world_draft_card", &self.custom_world_draft_card).with_updates_by_pk(|row| &row.card_id);
diff.custom_world_gallery_entry = cache.apply_diff_to_table::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry).with_updates_by_pk(|row| &row.profile_id);
diff.custom_world_profile = cache.apply_diff_to_table::<CustomWorldProfile>("custom_world_profile", &self.custom_world_profile).with_updates_by_pk(|row| &row.profile_id);
diff.custom_world_session = cache.apply_diff_to_table::<CustomWorldSession>("custom_world_session", &self.custom_world_session).with_updates_by_pk(|row| &row.session_id);
diff.inventory_slot = cache.apply_diff_to_table::<InventorySlot>("inventory_slot", &self.inventory_slot).with_updates_by_pk(|row| &row.slot_id);
diff.npc_state = cache.apply_diff_to_table::<NpcState>("npc_state", &self.npc_state).with_updates_by_pk(|row| &row.npc_state_id);
diff.player_progression = cache.apply_diff_to_table::<PlayerProgression>("player_progression", &self.player_progression).with_updates_by_pk(|row| &row.user_id);
diff.profile_dashboard_state = cache.apply_diff_to_table::<ProfileDashboardState>("profile_dashboard_state", &self.profile_dashboard_state).with_updates_by_pk(|row| &row.user_id);
diff.profile_played_world = cache.apply_diff_to_table::<ProfilePlayedWorld>("profile_played_world", &self.profile_played_world).with_updates_by_pk(|row| &row.played_world_id);
diff.profile_save_archive = cache.apply_diff_to_table::<ProfileSaveArchive>("profile_save_archive", &self.profile_save_archive).with_updates_by_pk(|row| &row.archive_id);
diff.profile_wallet_ledger = cache.apply_diff_to_table::<ProfileWalletLedger>("profile_wallet_ledger", &self.profile_wallet_ledger).with_updates_by_pk(|row| &row.wallet_ledger_id);
diff.puzzle_agent_message = cache.apply_diff_to_table::<PuzzleAgentMessageRow>("puzzle_agent_message", &self.puzzle_agent_message).with_updates_by_pk(|row| &row.message_id);
diff.puzzle_agent_session = cache.apply_diff_to_table::<PuzzleAgentSessionRow>("puzzle_agent_session", &self.puzzle_agent_session).with_updates_by_pk(|row| &row.session_id);
diff.puzzle_runtime_run = cache.apply_diff_to_table::<PuzzleRuntimeRunRow>("puzzle_runtime_run", &self.puzzle_runtime_run).with_updates_by_pk(|row| &row.run_id);
diff.puzzle_work_profile = cache.apply_diff_to_table::<PuzzleWorkProfileRow>("puzzle_work_profile", &self.puzzle_work_profile).with_updates_by_pk(|row| &row.profile_id);
diff.quest_log = cache.apply_diff_to_table::<QuestLog>("quest_log", &self.quest_log).with_updates_by_pk(|row| &row.log_id);
diff.quest_record = cache.apply_diff_to_table::<QuestRecord>("quest_record", &self.quest_record).with_updates_by_pk(|row| &row.quest_id);
diff.refresh_session = cache.apply_diff_to_table::<RefreshSession>("refresh_session", &self.refresh_session).with_updates_by_pk(|row| &row.session_id);
diff.runtime_setting = cache.apply_diff_to_table::<RuntimeSetting>("runtime_setting", &self.runtime_setting).with_updates_by_pk(|row| &row.user_id);
diff.runtime_snapshot = cache.apply_diff_to_table::<RuntimeSnapshotRow>("runtime_snapshot", &self.runtime_snapshot).with_updates_by_pk(|row| &row.user_id);
diff.story_event = cache.apply_diff_to_table::<StoryEvent>("story_event", &self.story_event).with_updates_by_pk(|row| &row.event_id);
diff.story_session = cache.apply_diff_to_table::<StorySession>("story_session", &self.story_session).with_updates_by_pk(|row| &row.story_session_id);
diff.treasure_record = cache.apply_diff_to_table::<TreasureRecord>("treasure_record", &self.treasure_record).with_updates_by_pk(|row| &row.treasure_record_id);
diff.user_account = cache.apply_diff_to_table::<UserAccount>("user_account", &self.user_account).with_updates_by_pk(|row| &row.user_id);
diff.user_browse_history = cache.apply_diff_to_table::<UserBrowseHistory>("user_browse_history", &self.user_browse_history).with_updates_by_pk(|row| &row.browse_history_id);
diff diff
} }
@@ -1366,48 +1196,7 @@ fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
let mut db_update = DbUpdate::default(); let mut db_update = DbUpdate::default();
for table_rows in raw.tables { for table_rows in raw.tables {
match &table_rows.table[..] { match &table_rows.table[..] {
"ai_result_reference" => db_update.ai_result_reference.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_task" => db_update.ai_task.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_task_stage" => db_update.ai_task_stage.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"auth_identity" => db_update.auth_identity.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"auth_store_snapshot" => db_update.auth_store_snapshot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_creation_session" => db_update.big_fish_creation_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_runtime_run" => db_update.big_fish_runtime_run.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"chapter_progression" => db_update.chapter_progression.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_operation" => db_update.custom_world_agent_operation.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_session" => db_update.custom_world_agent_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_draft_card" => db_update.custom_world_draft_card.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_profile" => db_update.custom_world_profile.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_session" => db_update.custom_world_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"inventory_slot" => db_update.inventory_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"npc_state" => db_update.npc_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"player_progression" => db_update.player_progression.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_dashboard_state" => db_update.profile_dashboard_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_played_world" => db_update.profile_played_world.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_save_archive" => db_update.profile_save_archive.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_agent_message" => db_update.puzzle_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_agent_session" => db_update.puzzle_agent_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_runtime_run" => db_update.puzzle_runtime_run.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_work_profile" => db_update.puzzle_work_profile.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"quest_log" => db_update.quest_log.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"quest_record" => db_update.quest_record.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"refresh_session" => db_update.refresh_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"runtime_setting" => db_update.runtime_setting.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"runtime_snapshot" => db_update.runtime_snapshot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"story_event" => db_update.story_event.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"story_session" => db_update.story_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"treasure_record" => db_update.treasure_record.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"user_account" => db_update.user_account.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"user_browse_history" => db_update.user_browse_history.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); } unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); }
}} Ok(db_update) }} Ok(db_update)
} }
@@ -1415,48 +1204,7 @@ fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
let mut db_update = DbUpdate::default(); let mut db_update = DbUpdate::default();
for table_rows in raw.tables { for table_rows in raw.tables {
match &table_rows.table[..] { match &table_rows.table[..] {
"ai_result_reference" => db_update.ai_result_reference.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_task" => db_update.ai_task.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_task_stage" => db_update.ai_task_stage.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"auth_identity" => db_update.auth_identity.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"auth_store_snapshot" => db_update.auth_store_snapshot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_creation_session" => db_update.big_fish_creation_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_runtime_run" => db_update.big_fish_runtime_run.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"chapter_progression" => db_update.chapter_progression.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_operation" => db_update.custom_world_agent_operation.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_session" => db_update.custom_world_agent_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_draft_card" => db_update.custom_world_draft_card.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_profile" => db_update.custom_world_profile.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_session" => db_update.custom_world_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"inventory_slot" => db_update.inventory_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"npc_state" => db_update.npc_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"player_progression" => db_update.player_progression.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_dashboard_state" => db_update.profile_dashboard_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_played_world" => db_update.profile_played_world.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_save_archive" => db_update.profile_save_archive.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_agent_message" => db_update.puzzle_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_agent_session" => db_update.puzzle_agent_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_runtime_run" => db_update.puzzle_runtime_run.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_work_profile" => db_update.puzzle_work_profile.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"quest_log" => db_update.quest_log.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"quest_record" => db_update.quest_record.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"refresh_session" => db_update.refresh_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"runtime_setting" => db_update.runtime_setting.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"runtime_snapshot" => db_update.runtime_snapshot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"story_event" => db_update.story_event.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"story_session" => db_update.story_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"treasure_record" => db_update.treasure_record.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"user_account" => db_update.user_account.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"user_browse_history" => db_update.user_browse_history.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); } unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); }
}} Ok(db_update) }} Ok(db_update)
} }
@@ -1466,48 +1214,7 @@ for table_rows in raw.tables {
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[doc(hidden)] #[doc(hidden)]
pub struct AppliedDiff<'r> { pub struct AppliedDiff<'r> {
ai_result_reference: __sdk::TableAppliedDiff<'r, AiResultReference>, custom_world_gallery_entry: __sdk::TableAppliedDiff<'r, CustomWorldGalleryEntry>,
ai_task: __sdk::TableAppliedDiff<'r, AiTask>,
ai_task_stage: __sdk::TableAppliedDiff<'r, AiTaskStage>,
ai_text_chunk: __sdk::TableAppliedDiff<'r, AiTextChunk>,
asset_entity_binding: __sdk::TableAppliedDiff<'r, AssetEntityBinding>,
asset_object: __sdk::TableAppliedDiff<'r, AssetObject>,
auth_identity: __sdk::TableAppliedDiff<'r, AuthIdentity>,
auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>,
battle_state: __sdk::TableAppliedDiff<'r, BattleState>,
big_fish_agent_message: __sdk::TableAppliedDiff<'r, BigFishAgentMessage>,
big_fish_asset_slot: __sdk::TableAppliedDiff<'r, BigFishAssetSlot>,
big_fish_creation_session: __sdk::TableAppliedDiff<'r, BigFishCreationSession>,
big_fish_runtime_run: __sdk::TableAppliedDiff<'r, BigFishRuntimeRun>,
chapter_progression: __sdk::TableAppliedDiff<'r, ChapterProgression>,
custom_world_agent_message: __sdk::TableAppliedDiff<'r, CustomWorldAgentMessage>,
custom_world_agent_operation: __sdk::TableAppliedDiff<'r, CustomWorldAgentOperation>,
custom_world_agent_session: __sdk::TableAppliedDiff<'r, CustomWorldAgentSession>,
custom_world_draft_card: __sdk::TableAppliedDiff<'r, CustomWorldDraftCard>,
custom_world_gallery_entry: __sdk::TableAppliedDiff<'r, CustomWorldGalleryEntry>,
custom_world_profile: __sdk::TableAppliedDiff<'r, CustomWorldProfile>,
custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>,
inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>,
npc_state: __sdk::TableAppliedDiff<'r, NpcState>,
player_progression: __sdk::TableAppliedDiff<'r, PlayerProgression>,
profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>,
profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>,
profile_save_archive: __sdk::TableAppliedDiff<'r, ProfileSaveArchive>,
profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>,
puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>,
puzzle_agent_session: __sdk::TableAppliedDiff<'r, PuzzleAgentSessionRow>,
puzzle_runtime_run: __sdk::TableAppliedDiff<'r, PuzzleRuntimeRunRow>,
puzzle_work_profile: __sdk::TableAppliedDiff<'r, PuzzleWorkProfileRow>,
quest_log: __sdk::TableAppliedDiff<'r, QuestLog>,
quest_record: __sdk::TableAppliedDiff<'r, QuestRecord>,
refresh_session: __sdk::TableAppliedDiff<'r, RefreshSession>,
runtime_setting: __sdk::TableAppliedDiff<'r, RuntimeSetting>,
runtime_snapshot: __sdk::TableAppliedDiff<'r, RuntimeSnapshotRow>,
story_event: __sdk::TableAppliedDiff<'r, StoryEvent>,
story_session: __sdk::TableAppliedDiff<'r, StorySession>,
treasure_record: __sdk::TableAppliedDiff<'r, TreasureRecord>,
user_account: __sdk::TableAppliedDiff<'r, UserAccount>,
user_browse_history: __sdk::TableAppliedDiff<'r, UserBrowseHistory>,
__unused: std::marker::PhantomData<&'r ()>, __unused: std::marker::PhantomData<&'r ()>,
} }
@@ -1518,48 +1225,7 @@ impl __sdk::InModule for AppliedDiff<'_> {
impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
fn invoke_row_callbacks(&self, event: &EventContext, callbacks: &mut __sdk::DbCallbacks<RemoteModule>) { fn invoke_row_callbacks(&self, event: &EventContext, callbacks: &mut __sdk::DbCallbacks<RemoteModule>) {
callbacks.invoke_table_row_callbacks::<AiResultReference>("ai_result_reference", &self.ai_result_reference, event); callbacks.invoke_table_row_callbacks::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry, event);
callbacks.invoke_table_row_callbacks::<AiTask>("ai_task", &self.ai_task, event);
callbacks.invoke_table_row_callbacks::<AiTaskStage>("ai_task_stage", &self.ai_task_stage, event);
callbacks.invoke_table_row_callbacks::<AiTextChunk>("ai_text_chunk", &self.ai_text_chunk, event);
callbacks.invoke_table_row_callbacks::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding, event);
callbacks.invoke_table_row_callbacks::<AssetObject>("asset_object", &self.asset_object, event);
callbacks.invoke_table_row_callbacks::<AuthIdentity>("auth_identity", &self.auth_identity, event);
callbacks.invoke_table_row_callbacks::<AuthStoreSnapshot>("auth_store_snapshot", &self.auth_store_snapshot, event);
callbacks.invoke_table_row_callbacks::<BattleState>("battle_state", &self.battle_state, event);
callbacks.invoke_table_row_callbacks::<BigFishAgentMessage>("big_fish_agent_message", &self.big_fish_agent_message, event);
callbacks.invoke_table_row_callbacks::<BigFishAssetSlot>("big_fish_asset_slot", &self.big_fish_asset_slot, event);
callbacks.invoke_table_row_callbacks::<BigFishCreationSession>("big_fish_creation_session", &self.big_fish_creation_session, event);
callbacks.invoke_table_row_callbacks::<BigFishRuntimeRun>("big_fish_runtime_run", &self.big_fish_runtime_run, event);
callbacks.invoke_table_row_callbacks::<ChapterProgression>("chapter_progression", &self.chapter_progression, event);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentMessage>("custom_world_agent_message", &self.custom_world_agent_message, event);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentOperation>("custom_world_agent_operation", &self.custom_world_agent_operation, event);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentSession>("custom_world_agent_session", &self.custom_world_agent_session, event);
callbacks.invoke_table_row_callbacks::<CustomWorldDraftCard>("custom_world_draft_card", &self.custom_world_draft_card, event);
callbacks.invoke_table_row_callbacks::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry, event);
callbacks.invoke_table_row_callbacks::<CustomWorldProfile>("custom_world_profile", &self.custom_world_profile, event);
callbacks.invoke_table_row_callbacks::<CustomWorldSession>("custom_world_session", &self.custom_world_session, event);
callbacks.invoke_table_row_callbacks::<InventorySlot>("inventory_slot", &self.inventory_slot, event);
callbacks.invoke_table_row_callbacks::<NpcState>("npc_state", &self.npc_state, event);
callbacks.invoke_table_row_callbacks::<PlayerProgression>("player_progression", &self.player_progression, event);
callbacks.invoke_table_row_callbacks::<ProfileDashboardState>("profile_dashboard_state", &self.profile_dashboard_state, event);
callbacks.invoke_table_row_callbacks::<ProfilePlayedWorld>("profile_played_world", &self.profile_played_world, event);
callbacks.invoke_table_row_callbacks::<ProfileSaveArchive>("profile_save_archive", &self.profile_save_archive, event);
callbacks.invoke_table_row_callbacks::<ProfileWalletLedger>("profile_wallet_ledger", &self.profile_wallet_ledger, event);
callbacks.invoke_table_row_callbacks::<PuzzleAgentMessageRow>("puzzle_agent_message", &self.puzzle_agent_message, event);
callbacks.invoke_table_row_callbacks::<PuzzleAgentSessionRow>("puzzle_agent_session", &self.puzzle_agent_session, event);
callbacks.invoke_table_row_callbacks::<PuzzleRuntimeRunRow>("puzzle_runtime_run", &self.puzzle_runtime_run, event);
callbacks.invoke_table_row_callbacks::<PuzzleWorkProfileRow>("puzzle_work_profile", &self.puzzle_work_profile, event);
callbacks.invoke_table_row_callbacks::<QuestLog>("quest_log", &self.quest_log, event);
callbacks.invoke_table_row_callbacks::<QuestRecord>("quest_record", &self.quest_record, event);
callbacks.invoke_table_row_callbacks::<RefreshSession>("refresh_session", &self.refresh_session, event);
callbacks.invoke_table_row_callbacks::<RuntimeSetting>("runtime_setting", &self.runtime_setting, event);
callbacks.invoke_table_row_callbacks::<RuntimeSnapshotRow>("runtime_snapshot", &self.runtime_snapshot, event);
callbacks.invoke_table_row_callbacks::<StoryEvent>("story_event", &self.story_event, event);
callbacks.invoke_table_row_callbacks::<StorySession>("story_session", &self.story_session, event);
callbacks.invoke_table_row_callbacks::<TreasureRecord>("treasure_record", &self.treasure_record, event);
callbacks.invoke_table_row_callbacks::<UserAccount>("user_account", &self.user_account, event);
callbacks.invoke_table_row_callbacks::<UserBrowseHistory>("user_browse_history", &self.user_browse_history, event);
} }
} }
@@ -2211,91 +1877,9 @@ impl __sdk::SpacetimeModule for RemoteModule {
type QueryBuilder = __sdk::QueryBuilder; type QueryBuilder = __sdk::QueryBuilder;
fn register_tables(client_cache: &mut __sdk::ClientCache<Self>) { fn register_tables(client_cache: &mut __sdk::ClientCache<Self>) {
ai_result_reference_table::register_table(client_cache); custom_world_gallery_entry_table::register_table(client_cache);
ai_task_table::register_table(client_cache);
ai_task_stage_table::register_table(client_cache);
ai_text_chunk_table::register_table(client_cache);
asset_entity_binding_table::register_table(client_cache);
asset_object_table::register_table(client_cache);
auth_identity_table::register_table(client_cache);
auth_store_snapshot_table::register_table(client_cache);
battle_state_table::register_table(client_cache);
big_fish_agent_message_table::register_table(client_cache);
big_fish_asset_slot_table::register_table(client_cache);
big_fish_creation_session_table::register_table(client_cache);
big_fish_runtime_run_table::register_table(client_cache);
chapter_progression_table::register_table(client_cache);
custom_world_agent_message_table::register_table(client_cache);
custom_world_agent_operation_table::register_table(client_cache);
custom_world_agent_session_table::register_table(client_cache);
custom_world_draft_card_table::register_table(client_cache);
custom_world_gallery_entry_table::register_table(client_cache);
custom_world_profile_table::register_table(client_cache);
custom_world_session_table::register_table(client_cache);
inventory_slot_table::register_table(client_cache);
npc_state_table::register_table(client_cache);
player_progression_table::register_table(client_cache);
profile_dashboard_state_table::register_table(client_cache);
profile_played_world_table::register_table(client_cache);
profile_save_archive_table::register_table(client_cache);
profile_wallet_ledger_table::register_table(client_cache);
puzzle_agent_message_table::register_table(client_cache);
puzzle_agent_session_table::register_table(client_cache);
puzzle_runtime_run_table::register_table(client_cache);
puzzle_work_profile_table::register_table(client_cache);
quest_log_table::register_table(client_cache);
quest_record_table::register_table(client_cache);
refresh_session_table::register_table(client_cache);
runtime_setting_table::register_table(client_cache);
runtime_snapshot_table::register_table(client_cache);
story_event_table::register_table(client_cache);
story_session_table::register_table(client_cache);
treasure_record_table::register_table(client_cache);
user_account_table::register_table(client_cache);
user_browse_history_table::register_table(client_cache);
} }
const ALL_TABLE_NAMES: &'static [&'static str] = &[ const ALL_TABLE_NAMES: &'static [&'static str] = &[
"ai_result_reference", "custom_world_gallery_entry",
"ai_task",
"ai_task_stage",
"ai_text_chunk",
"asset_entity_binding",
"asset_object",
"auth_identity",
"auth_store_snapshot",
"battle_state",
"big_fish_agent_message",
"big_fish_asset_slot",
"big_fish_creation_session",
"big_fish_runtime_run",
"chapter_progression",
"custom_world_agent_message",
"custom_world_agent_operation",
"custom_world_agent_session",
"custom_world_draft_card",
"custom_world_gallery_entry",
"custom_world_profile",
"custom_world_session",
"inventory_slot",
"npc_state",
"player_progression",
"profile_dashboard_state",
"profile_played_world",
"profile_save_archive",
"profile_wallet_ledger",
"puzzle_agent_message",
"puzzle_agent_session",
"puzzle_runtime_run",
"puzzle_work_profile",
"quest_log",
"quest_record",
"refresh_session",
"runtime_setting",
"runtime_snapshot",
"story_event",
"story_session",
"treasure_record",
"user_account",
"user_browse_history",
]; ];
} }

View File

@@ -0,0 +1,77 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_membership_status_type::RuntimeProfileMembershipStatus;
use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileMembership {
pub user_id: String,
pub status: RuntimeProfileMembershipStatus,
pub tier: RuntimeProfileMembershipTier,
pub started_at: __sdk::Timestamp,
pub expires_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileMembership {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileMembership`.
///
/// Provides typed access to columns for query building.
pub struct ProfileMembershipCols {
pub user_id: __sdk::__query_builder::Col<ProfileMembership, String>,
pub status: __sdk::__query_builder::Col<ProfileMembership, RuntimeProfileMembershipStatus>,
pub tier: __sdk::__query_builder::Col<ProfileMembership, RuntimeProfileMembershipTier>,
pub started_at: __sdk::__query_builder::Col<ProfileMembership, __sdk::Timestamp>,
pub expires_at: __sdk::__query_builder::Col<ProfileMembership, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<ProfileMembership, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileMembership {
type Cols = ProfileMembershipCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileMembershipCols {
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
status: __sdk::__query_builder::Col::new(table_name, "status"),
tier: __sdk::__query_builder::Col::new(table_name, "tier"),
started_at: __sdk::__query_builder::Col::new(table_name, "started_at"),
expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileMembership`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileMembershipIxCols {
pub user_id: __sdk::__query_builder::IxCol<ProfileMembership, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileMembership {
type IxCols = ProfileMembershipIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileMembershipIxCols {
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileMembership {}

View File

@@ -0,0 +1,97 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
use super::runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileRechargeOrder {
pub order_id: String,
pub user_id: String,
pub product_id: String,
pub product_title: String,
pub kind: RuntimeProfileRechargeProductKind,
pub amount_cents: u64,
pub status: RuntimeProfileRechargeOrderStatus,
pub payment_channel: String,
pub paid_at: __sdk::Timestamp,
pub created_at: __sdk::Timestamp,
pub points_delta: i64,
pub membership_expires_at: Option::<__sdk::Timestamp>,
}
impl __sdk::InModule for ProfileRechargeOrder {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileRechargeOrder`.
///
/// Provides typed access to columns for query building.
pub struct ProfileRechargeOrderCols {
pub order_id: __sdk::__query_builder::Col<ProfileRechargeOrder, String>,
pub user_id: __sdk::__query_builder::Col<ProfileRechargeOrder, String>,
pub product_id: __sdk::__query_builder::Col<ProfileRechargeOrder, String>,
pub product_title: __sdk::__query_builder::Col<ProfileRechargeOrder, String>,
pub kind: __sdk::__query_builder::Col<ProfileRechargeOrder, RuntimeProfileRechargeProductKind>,
pub amount_cents: __sdk::__query_builder::Col<ProfileRechargeOrder, u64>,
pub status: __sdk::__query_builder::Col<ProfileRechargeOrder, RuntimeProfileRechargeOrderStatus>,
pub payment_channel: __sdk::__query_builder::Col<ProfileRechargeOrder, String>,
pub paid_at: __sdk::__query_builder::Col<ProfileRechargeOrder, __sdk::Timestamp>,
pub created_at: __sdk::__query_builder::Col<ProfileRechargeOrder, __sdk::Timestamp>,
pub points_delta: __sdk::__query_builder::Col<ProfileRechargeOrder, i64>,
pub membership_expires_at: __sdk::__query_builder::Col<ProfileRechargeOrder, Option::<__sdk::Timestamp>>,
}
impl __sdk::__query_builder::HasCols for ProfileRechargeOrder {
type Cols = ProfileRechargeOrderCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileRechargeOrderCols {
order_id: __sdk::__query_builder::Col::new(table_name, "order_id"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
product_id: __sdk::__query_builder::Col::new(table_name, "product_id"),
product_title: __sdk::__query_builder::Col::new(table_name, "product_title"),
kind: __sdk::__query_builder::Col::new(table_name, "kind"),
amount_cents: __sdk::__query_builder::Col::new(table_name, "amount_cents"),
status: __sdk::__query_builder::Col::new(table_name, "status"),
payment_channel: __sdk::__query_builder::Col::new(table_name, "payment_channel"),
paid_at: __sdk::__query_builder::Col::new(table_name, "paid_at"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
points_delta: __sdk::__query_builder::Col::new(table_name, "points_delta"),
membership_expires_at: __sdk::__query_builder::Col::new(table_name, "membership_expires_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileRechargeOrder`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileRechargeOrderIxCols {
pub order_id: __sdk::__query_builder::IxCol<ProfileRechargeOrder, String>,
pub user_id: __sdk::__query_builder::IxCol<ProfileRechargeOrder, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileRechargeOrder {
type IxCols = ProfileRechargeOrderIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileRechargeOrderIxCols {
order_id: __sdk::__query_builder::IxCol::new(table_name, "order_id"),
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileRechargeOrder {}

View File

@@ -1,4 +1,4 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)] #![allow(unused, clippy::all)]
@@ -21,3 +21,4 @@ pub struct PuzzleWorkDeleteInput {
impl __sdk::InModule for PuzzleWorkDeleteInput { impl __sdk::InModule for PuzzleWorkDeleteInput {
type Module = super::RemoteModule; type Module = super::RemoteModule;
} }

View File

@@ -0,0 +1,27 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileMembershipBenefitSnapshot {
pub benefit_name: String,
pub normal_value: String,
pub month_value: String,
pub season_value: String,
pub year_value: String,
}
impl __sdk::InModule for RuntimeProfileMembershipBenefitSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,30 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_membership_status_type::RuntimeProfileMembershipStatus;
use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileMembershipSnapshot {
pub user_id: String,
pub status: RuntimeProfileMembershipStatus,
pub tier: RuntimeProfileMembershipTier,
pub started_at_micros: Option::<i64>,
pub expires_at_micros: Option::<i64>,
pub updated_at_micros: Option::<i64>,
}
impl __sdk::InModule for RuntimeProfileMembershipSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,27 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileMembershipStatus {
Normal,
Active,
}
impl __sdk::InModule for RuntimeProfileMembershipStatus {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,31 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileMembershipTier {
Normal,
Month,
Season,
Year,
}
impl __sdk::InModule for RuntimeProfileMembershipTier {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,23 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeCenterGetInput {
pub user_id: String,
}
impl __sdk::InModule for RuntimeProfileRechargeCenterGetInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,28 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot;
use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeCenterProcedureResult {
pub ok: bool,
pub record: Option::<RuntimeProfileRechargeCenterSnapshot>,
pub order: Option::<RuntimeProfileRechargeOrderSnapshot>,
pub error_message: Option::<String>,
}
impl __sdk::InModule for RuntimeProfileRechargeCenterProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,34 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot;
use super::runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot;
use super::runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot;
use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeCenterSnapshot {
pub user_id: String,
pub wallet_balance: u64,
pub membership: RuntimeProfileMembershipSnapshot,
pub point_products: Vec::<RuntimeProfileRechargeProductSnapshot>,
pub membership_products: Vec::<RuntimeProfileRechargeProductSnapshot>,
pub benefits: Vec::<RuntimeProfileMembershipBenefitSnapshot>,
pub latest_order: Option::<RuntimeProfileRechargeOrderSnapshot>,
pub has_points_recharged: bool,
}
impl __sdk::InModule for RuntimeProfileRechargeCenterSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,26 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeOrderCreateInput {
pub user_id: String,
pub product_id: String,
pub payment_channel: String,
pub created_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileRechargeOrderCreateInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,36 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
use super::runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeOrderSnapshot {
pub order_id: String,
pub user_id: String,
pub product_id: String,
pub product_title: String,
pub kind: RuntimeProfileRechargeProductKind,
pub amount_cents: u64,
pub status: RuntimeProfileRechargeOrderStatus,
pub payment_channel: String,
pub paid_at_micros: i64,
pub created_at_micros: i64,
pub points_delta: i64,
pub membership_expires_at_micros: Option::<i64>,
}
impl __sdk::InModule for RuntimeProfileRechargeOrderSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,25 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileRechargeOrderStatus {
Paid,
}
impl __sdk::InModule for RuntimeProfileRechargeOrderStatus {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,27 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileRechargeProductKind {
Points,
Membership,
}
impl __sdk::InModule for RuntimeProfileRechargeProductKind {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,34 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier;
use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeProductSnapshot {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: RuntimeProfileRechargeProductKind,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: RuntimeProfileMembershipTier,
}
impl __sdk::InModule for RuntimeProfileRechargeProductSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -15,6 +15,12 @@ use spacetimedb_sdk::__codegen::{
pub enum RuntimeProfileWalletLedgerSourceType { pub enum RuntimeProfileWalletLedgerSourceType {
SnapshotSync, SnapshotSync,
InviteInviterReward,
InviteInviteeReward,
PointsRecharge,
} }

View File

@@ -14,10 +14,11 @@ use super::custom_world_agent_operation_progress_input_type::CustomWorldAgentOpe
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)] #[sats(crate = __lib)]
struct UpsertCustomWorldAgentOperationProgressArgs { struct UpsertCustomWorldAgentOperationProgressArgs {
pub input: CustomWorldAgentOperationProgressInput, pub input: CustomWorldAgentOperationProgressInput,
} }
impl __sdk::InModule for UpsertCustomWorldAgentOperationProgressArgs { impl __sdk::InModule for UpsertCustomWorldAgentOperationProgressArgs {
type Module = super::RemoteModule; type Module = super::RemoteModule;
} }
@@ -27,21 +28,16 @@ impl __sdk::InModule for UpsertCustomWorldAgentOperationProgressArgs {
/// ///
/// Implemented for [`super::RemoteProcedures`]. /// Implemented for [`super::RemoteProcedures`].
pub trait upsert_custom_world_agent_operation_progress { pub trait upsert_custom_world_agent_operation_progress {
fn upsert_custom_world_agent_operation_progress( fn upsert_custom_world_agent_operation_progress(&self, input: CustomWorldAgentOperationProgressInput,
&self, ) {
input: CustomWorldAgentOperationProgressInput, self.upsert_custom_world_agent_operation_progress_then(input, |_, _| {});
) {
self.upsert_custom_world_agent_operation_progress_then(input, |_, _| {});
} }
fn upsert_custom_world_agent_operation_progress_then( fn upsert_custom_world_agent_operation_progress_then(
&self, &self,
input: CustomWorldAgentOperationProgressInput, input: CustomWorldAgentOperationProgressInput,
__callback: impl FnOnce(
&super::ProcedureEventContext, __callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldAgentOperationProcedureResult, __sdk::InternalError>) + Send + 'static,
Result<CustomWorldAgentOperationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
); );
} }
@@ -49,16 +45,14 @@ impl upsert_custom_world_agent_operation_progress for super::RemoteProcedures {
fn upsert_custom_world_agent_operation_progress_then( fn upsert_custom_world_agent_operation_progress_then(
&self, &self,
input: CustomWorldAgentOperationProgressInput, input: CustomWorldAgentOperationProgressInput,
__callback: impl FnOnce(
&super::ProcedureEventContext, __callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldAgentOperationProcedureResult, __sdk::InternalError>) + Send + 'static,
Result<CustomWorldAgentOperationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) { ) {
self.imp.invoke_procedure_with_callback::<_, CustomWorldAgentOperationProcedureResult>( self.imp.invoke_procedure_with_callback::<_, CustomWorldAgentOperationProcedureResult>(
"upsert_custom_world_agent_operation_progress", "upsert_custom_world_agent_operation_progress",
UpsertCustomWorldAgentOperationProgressArgs { input }, UpsertCustomWorldAgentOperationProgressArgs { input, },
__callback, __callback,
); );
} }
} }

View File

@@ -89,6 +89,66 @@ impl SpacetimeClient {
.await .await
} }
pub async fn get_profile_recharge_center(
&self,
user_id: String,
) -> Result<RuntimeProfileRechargeCenterRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_recharge_center_get_input(user_id)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection.procedures().get_profile_recharge_center_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_recharge_center_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn create_profile_recharge_order(
&self,
user_id: String,
product_id: String,
payment_channel: String,
created_at_micros: i64,
) -> Result<
(
RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord,
),
SpacetimeClientError,
> {
let procedure_input = build_runtime_profile_recharge_order_create_input(
user_id,
product_id,
payment_channel,
created_at_micros,
)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.create_profile_recharge_order_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_recharge_order_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn get_profile_play_stats( pub async fn get_profile_play_stats(
&self, &self,
user_id: String, user_id: String,

View File

@@ -1135,6 +1135,25 @@ pub fn get_custom_world_agent_session(
} }
} }
#[spacetimedb::procedure]
pub fn delete_custom_world_agent_session(
ctx: &mut ProcedureContext,
input: CustomWorldAgentSessionGetInput,
) -> CustomWorldWorksListResult {
match ctx.try_with_tx(|tx| delete_custom_world_agent_session_tx(tx, input.clone())) {
Ok(items) => CustomWorldWorksListResult {
ok: true,
items,
error_message: None,
},
Err(message) => CustomWorldWorksListResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure] #[spacetimedb::procedure]
pub fn submit_custom_world_agent_message( pub fn submit_custom_world_agent_message(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
@@ -1392,6 +1411,101 @@ fn get_custom_world_agent_session_tx(
Ok(build_custom_world_agent_session_snapshot(ctx, &session)) Ok(build_custom_world_agent_session_snapshot(ctx, &session))
} }
fn delete_custom_world_agent_session_tx(
ctx: &ReducerContext,
input: CustomWorldAgentSessionGetInput,
) -> Result<Vec<CustomWorldWorkSummarySnapshot>, String> {
validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
if session.stage == RpgAgentStage::Published {
let published_profile = ctx
.db
.custom_world_profile()
.iter()
.find(|row| {
row.owner_user_id == input.owner_user_id
&& row.source_agent_session_id.as_deref() == Some(input.session_id.as_str())
&& row.deleted_at.is_none()
})
.ok_or_else(|| "已发布 RPG 作品缺少关联 profile无法删除".to_string())?;
// 作品卡可能只携带源 Agent sessionId。这里把“按 session 删除已发布作品”
// 收敛为 profile 软删除,避免前端误入草稿删除接口时继续暴露 procedure 分叉。
delete_custom_world_profile_record(
ctx,
CustomWorldProfileDeleteInput {
profile_id: published_profile.profile_id,
owner_user_id: input.owner_user_id.clone(),
deleted_at_micros: ctx.timestamp.to_micros_since_unix_epoch(),
},
)?;
return list_custom_world_work_snapshots(
ctx,
CustomWorldWorksListInput {
owner_user_id: input.owner_user_id,
},
);
}
// 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。
ctx.db
.custom_world_agent_session()
.session_id()
.delete(&session.session_id);
for message in ctx
.db
.custom_world_agent_message()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.custom_world_agent_message()
.message_id()
.delete(&message.message_id);
}
for operation in ctx
.db
.custom_world_agent_operation()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.custom_world_agent_operation()
.operation_id()
.delete(&operation.operation_id);
}
for card in ctx
.db
.custom_world_draft_card()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.custom_world_draft_card()
.card_id()
.delete(&card.card_id);
}
list_custom_world_work_snapshots(
ctx,
CustomWorldWorksListInput {
owner_user_id: input.owner_user_id,
},
)
}
fn submit_custom_world_agent_message_tx( fn submit_custom_world_agent_message_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
input: CustomWorldAgentMessageSubmitInput, input: CustomWorldAgentMessageSubmitInput,
@@ -1966,7 +2080,7 @@ pub fn list_custom_world_profiles(
pub fn list_custom_world_gallery_entries( pub fn list_custom_world_gallery_entries(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
) -> CustomWorldGalleryListResult { ) -> CustomWorldGalleryListResult {
match ctx.try_with_tx(|tx| Ok::<_, String>(list_custom_world_gallery_snapshots(tx))) { match ctx.try_with_tx(|tx| list_custom_world_gallery_snapshots(tx)) {
Ok(entries) => CustomWorldGalleryListResult { Ok(entries) => CustomWorldGalleryListResult {
ok: true, ok: true,
entries, entries,
@@ -2668,7 +2782,9 @@ fn list_custom_world_profile_snapshots(
fn list_custom_world_gallery_snapshots( fn list_custom_world_gallery_snapshots(
ctx: &ReducerContext, ctx: &ReducerContext,
) -> Vec<CustomWorldGalleryEntrySnapshot> { ) -> Result<Vec<CustomWorldGalleryEntrySnapshot>, String> {
sync_missing_custom_world_gallery_entries(ctx)?;
let mut entries = ctx let mut entries = ctx
.db .db
.custom_world_gallery_entry() .custom_world_gallery_entry()
@@ -2683,7 +2799,7 @@ fn list_custom_world_gallery_snapshots(
.then(right.updated_at_micros.cmp(&left.updated_at_micros)) .then(right.updated_at_micros.cmp(&left.updated_at_micros))
}); });
entries Ok(entries)
} }
fn get_custom_world_library_detail_record( fn get_custom_world_library_detail_record(
@@ -3391,6 +3507,10 @@ fn execute_publish_world_action(
.get("legacyResultProfile") .get("legacyResultProfile")
.map(serialize_json_value) .map(serialize_json_value)
.transpose()?; .transpose()?;
let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"])
.unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id));
let author_display_name = read_optional_text_field(payload, &["authorDisplayName"])
.unwrap_or_else(|| "创作者".to_string());
let publish_result = publish_custom_world_world_record( let publish_result = publish_custom_world_world_record(
ctx, ctx,
CustomWorldPublishWorldInput { CustomWorldPublishWorldInput {
@@ -3398,17 +3518,11 @@ fn execute_publish_world_action(
profile_id, profile_id,
owner_user_id: session.owner_user_id.clone(), owner_user_id: session.owner_user_id.clone(),
public_work_code: None, public_work_code: None,
author_public_user_code: session author_public_user_code,
.owner_user_id
.trim_start_matches("user_")
.parse::<u64>()
.ok()
.map(|sequence| format!("SY-{sequence:08}"))
.unwrap_or_else(|| "SY-00000000".to_string()),
draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?, draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?,
legacy_result_profile_json, legacy_result_profile_json,
setting_text, setting_text,
author_display_name: "创作者".to_string(), author_display_name,
published_at_micros: input.submitted_at_micros, published_at_micros: input.submitted_at_micros,
}, },
)?; )?;
@@ -4887,6 +5001,112 @@ fn sync_custom_world_gallery_entry_from_profile(
Ok(build_custom_world_gallery_entry_snapshot(&inserted)) Ok(build_custom_world_gallery_entry_snapshot(&inserted))
} }
fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), String> {
let published_profiles = ctx
.db
.custom_world_profile()
.iter()
.filter(|profile| {
profile.publication_status == CustomWorldPublicationStatus::Published
&& profile.deleted_at.is_none()
})
.collect::<Vec<_>>();
for profile in published_profiles {
if profile.published_at.is_none() {
continue;
}
let existing_gallery_entry = ctx
.db
.custom_world_gallery_entry()
.profile_id()
.find(&profile.profile_id)
.filter(|entry| entry.owner_user_id == profile.owner_user_id);
if existing_gallery_entry.is_some()
&& profile.public_work_code.is_some()
&& profile.author_public_user_code.is_some()
{
continue;
}
let profile_with_public_fields = ensure_custom_world_profile_public_fields(ctx, &profile);
sync_custom_world_gallery_entry_from_profile(ctx, &profile_with_public_fields)?;
}
Ok(())
}
fn ensure_custom_world_profile_public_fields(
ctx: &ReducerContext,
profile: &CustomWorldProfile,
) -> CustomWorldProfile {
if profile.public_work_code.is_some() && profile.author_public_user_code.is_some() {
return build_custom_world_profile_row_copy(profile);
}
ctx.db
.custom_world_profile()
.profile_id()
.delete(&profile.profile_id);
let next_row = CustomWorldProfile {
profile_id: profile.profile_id.clone(),
owner_user_id: profile.owner_user_id.clone(),
public_work_code: profile
.public_work_code
.clone()
.or_else(|| Some(build_public_work_code_from_profile_id(&profile.profile_id))),
author_public_user_code: profile.author_public_user_code.clone().or_else(|| {
Some(build_public_user_code_from_owner_user_id(
&profile.owner_user_id,
))
}),
source_agent_session_id: profile.source_agent_session_id.clone(),
publication_status: profile.publication_status,
world_name: profile.world_name.clone(),
subtitle: profile.subtitle.clone(),
summary_text: profile.summary_text.clone(),
theme_mode: profile.theme_mode,
cover_image_src: profile.cover_image_src.clone(),
profile_payload_json: profile.profile_payload_json.clone(),
playable_npc_count: profile.playable_npc_count,
landmark_count: profile.landmark_count,
author_display_name: profile.author_display_name.clone(),
published_at: profile.published_at,
deleted_at: profile.deleted_at,
created_at: profile.created_at,
updated_at: profile.updated_at,
};
ctx.db.custom_world_profile().insert(next_row)
}
fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWorldProfile {
CustomWorldProfile {
profile_id: profile.profile_id.clone(),
owner_user_id: profile.owner_user_id.clone(),
public_work_code: profile.public_work_code.clone(),
author_public_user_code: profile.author_public_user_code.clone(),
source_agent_session_id: profile.source_agent_session_id.clone(),
publication_status: profile.publication_status,
world_name: profile.world_name.clone(),
subtitle: profile.subtitle.clone(),
summary_text: profile.summary_text.clone(),
theme_mode: profile.theme_mode,
cover_image_src: profile.cover_image_src.clone(),
profile_payload_json: profile.profile_payload_json.clone(),
playable_npc_count: profile.playable_npc_count,
landmark_count: profile.landmark_count,
author_display_name: profile.author_display_name.clone(),
published_at: profile.published_at,
deleted_at: profile.deleted_at,
created_at: profile.created_at,
updated_at: profile.updated_at,
}
}
fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
CustomWorldProfileSnapshot { CustomWorldProfileSnapshot {
profile_id: row.profile_id.clone(), profile_id: row.profile_id.clone(),
@@ -5079,6 +5299,15 @@ fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
format!("CW-{normalized_digits}") format!("CW-{normalized_digits}")
} }
fn build_public_user_code_from_owner_user_id(owner_user_id: &str) -> String {
owner_user_id
.trim_start_matches("user_")
.parse::<u64>()
.ok()
.map(|sequence| format!("SY-{sequence:08}"))
.unwrap_or_else(|| "SY-00000000".to_string())
}
fn normalize_public_work_code(input: &str) -> Option<String> { fn normalize_public_work_code(input: &str) -> Option<String> {
let normalized = input let normalized = input
.trim() .trim()

View File

@@ -55,6 +55,41 @@ pub struct ProfilePlayedWorld {
pub(crate) last_observed_play_time_ms: u64, pub(crate) last_observed_play_time_ms: u64,
} }
#[spacetimedb::table(accessor = profile_membership)]
pub struct ProfileMembership {
#[primary_key]
pub(crate) user_id: String,
pub(crate) status: RuntimeProfileMembershipStatus,
pub(crate) tier: RuntimeProfileMembershipTier,
pub(crate) started_at: Timestamp,
pub(crate) expires_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_recharge_order,
index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_recharge_order_user_created_at,
btree(columns = [user_id, created_at])
)
)]
pub struct ProfileRechargeOrder {
#[primary_key]
pub(crate) order_id: String,
pub(crate) user_id: String,
pub(crate) product_id: String,
pub(crate) product_title: String,
pub(crate) kind: RuntimeProfileRechargeProductKind,
pub(crate) amount_cents: u64,
pub(crate) status: RuntimeProfileRechargeOrderStatus,
pub(crate) payment_channel: String,
pub(crate) paid_at: Timestamp,
pub(crate) created_at: Timestamp,
pub(crate) points_delta: i64,
pub(crate) membership_expires_at: Option<Timestamp>,
}
#[spacetimedb::table( #[spacetimedb::table(
accessor = profile_save_archive, accessor = profile_save_archive,
index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])), index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])),
@@ -195,6 +230,50 @@ pub fn get_profile_play_stats(
} }
} }
// 账户充值中心只读快照,套餐和权益由后端返回,前端不保存业务价格表。
#[spacetimedb::procedure]
pub fn get_profile_recharge_center(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeCenterGetInput,
) -> RuntimeProfileRechargeCenterProcedureResult {
match ctx.try_with_tx(|tx| get_profile_recharge_center_snapshot(tx, input.clone())) {
Ok(record) => RuntimeProfileRechargeCenterProcedureResult {
ok: true,
record: Some(record),
order: None,
error_message: None,
},
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
ok: false,
record: None,
order: None,
error_message: Some(message),
},
}
}
// 当前阶段没有真实支付网关,下单后在服务端模拟支付成功并立即写入权益。
#[spacetimedb::procedure]
pub fn create_profile_recharge_order_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderCreateInput,
) -> RuntimeProfileRechargeCenterProcedureResult {
match ctx.try_with_tx(|tx| create_profile_recharge_order_record(tx, input.clone())) {
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
ok: true,
record: Some(record),
order: Some(order),
error_message: None,
},
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
ok: false,
record: None,
order: None,
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows( pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext, ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput, input: RuntimeProfileSaveArchiveListInput,
@@ -775,6 +854,297 @@ fn get_profile_play_stats_snapshot(
}) })
} }
fn get_profile_recharge_center_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileRechargeCenterGetInput,
) -> Result<RuntimeProfileRechargeCenterSnapshot, String> {
let validated_input = build_runtime_profile_recharge_center_get_input(input.user_id)
.map_err(|error| error.to_string())?;
Ok(build_profile_recharge_center_snapshot(
ctx,
&validated_input.user_id,
))
}
fn create_profile_recharge_order_record(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderCreateInput,
) -> Result<
(
RuntimeProfileRechargeCenterSnapshot,
RuntimeProfileRechargeOrderSnapshot,
),
String,
> {
let validated_input = build_runtime_profile_recharge_order_create_input(
input.user_id,
input.product_id,
input.payment_channel,
input.created_at_micros,
)
.map_err(|error| error.to_string())?;
let product = runtime_profile_recharge_product_by_id(&validated_input.product_id)
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let (points_delta, membership_expires_at) = match product.kind {
RuntimeProfileRechargeProductKind::Points => {
let has_recharged = has_profile_points_recharged(ctx, &validated_input.user_id);
let bonus_points = if has_recharged {
0
} else {
product.bonus_points
};
let points_delta = product.points_amount.saturating_add(bonus_points);
apply_profile_wallet_delta(
ctx,
&validated_input.user_id,
points_delta,
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
&format!(
"{}:{}:{}",
validated_input.user_id, validated_input.created_at_micros, product.product_id
),
created_at,
)?;
(points_delta as i64, None)
}
RuntimeProfileRechargeProductKind::Membership => {
let expires_at = apply_profile_membership_purchase(
ctx,
&validated_input.user_id,
product.tier,
product.duration_days,
created_at,
);
(0, Some(expires_at))
}
};
let order = ProfileRechargeOrder {
order_id: format!(
"recharge:{}:{}:{}",
validated_input.user_id, validated_input.created_at_micros, product.product_id
),
user_id: validated_input.user_id.clone(),
product_id: product.product_id.clone(),
product_title: product.title.clone(),
kind: product.kind,
amount_cents: product.price_cents,
status: RuntimeProfileRechargeOrderStatus::Paid,
payment_channel: validated_input.payment_channel,
paid_at: created_at,
created_at,
points_delta,
membership_expires_at,
};
ctx.db.profile_recharge_order().insert(order);
let latest_order = latest_profile_recharge_order(ctx, &validated_input.user_id)
.ok_or_else(|| "profile_recharge_order 写入后未能读取".to_string())?;
Ok((
build_profile_recharge_center_snapshot(ctx, &validated_input.user_id),
build_profile_recharge_order_snapshot_from_row(&latest_order),
))
}
fn build_profile_recharge_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
) -> RuntimeProfileRechargeCenterSnapshot {
let wallet_balance = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string())
.map(|row| row.wallet_balance)
.unwrap_or(0);
RuntimeProfileRechargeCenterSnapshot {
user_id: user_id.to_string(),
wallet_balance,
membership: build_profile_membership_snapshot(ctx, user_id),
point_products: runtime_profile_recharge_point_products(),
membership_products: runtime_profile_recharge_membership_products(),
benefits: runtime_profile_membership_benefits(),
latest_order: latest_profile_recharge_order(ctx, user_id)
.map(|row| build_profile_recharge_order_snapshot_from_row(&row)),
has_points_recharged: has_profile_points_recharged(ctx, user_id),
}
}
fn build_profile_membership_snapshot(
ctx: &ReducerContext,
user_id: &str,
) -> RuntimeProfileMembershipSnapshot {
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let membership = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string());
match membership {
Some(row) if row.expires_at.to_micros_since_unix_epoch() > now_micros => {
RuntimeProfileMembershipSnapshot {
user_id: row.user_id,
status: row.status,
tier: row.tier,
started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()),
expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()),
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
}
}
Some(row) => RuntimeProfileMembershipSnapshot {
user_id: row.user_id,
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()),
expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()),
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
},
None => RuntimeProfileMembershipSnapshot {
user_id: user_id.to_string(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at_micros: None,
expires_at_micros: None,
updated_at_micros: None,
},
}
}
fn apply_profile_membership_purchase(
ctx: &ReducerContext,
user_id: &str,
tier: RuntimeProfileMembershipTier,
duration_days: u32,
purchased_at: Timestamp,
) -> Timestamp {
let current = ctx
.db
.profile_membership()
.user_id()
.find(&user_id.to_string());
let purchased_at_micros = purchased_at.to_micros_since_unix_epoch();
let start_at_micros = current
.as_ref()
.map(|row| row.expires_at.to_micros_since_unix_epoch())
.filter(|expires_at_micros| *expires_at_micros > purchased_at_micros)
.unwrap_or(purchased_at_micros);
let expires_at = Timestamp::from_micros_since_unix_epoch(
start_at_micros.saturating_add(duration_days as i64 * 86_400_000_000),
);
let created_at = current
.as_ref()
.map(|row| row.started_at)
.unwrap_or(purchased_at);
if let Some(existing) = current {
ctx.db
.profile_membership()
.user_id()
.delete(&existing.user_id);
}
ctx.db.profile_membership().insert(ProfileMembership {
user_id: user_id.to_string(),
status: RuntimeProfileMembershipStatus::Active,
tier,
started_at: created_at,
expires_at,
updated_at: purchased_at,
});
expires_at
}
fn apply_profile_wallet_delta(
ctx: &ReducerContext,
user_id: &str,
amount_delta: u64,
source_type: RuntimeProfileWalletLedgerSourceType,
ledger_id: &str,
created_at: Timestamp,
) -> Result<u64, String> {
let current = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string());
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
let next_balance = previous_balance
.checked_add(amount_delta)
.ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?;
let created_state_at = current
.as_ref()
.map(|row| row.created_at)
.unwrap_or(created_at);
if let Some(existing) = current {
ctx.db
.profile_dashboard_state()
.user_id()
.delete(&existing.user_id);
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: user_id.to_string(),
wallet_balance: next_balance,
total_play_time_ms: existing.total_play_time_ms,
created_at: existing.created_at,
updated_at: created_at,
});
} else {
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: user_id.to_string(),
wallet_balance: next_balance,
total_play_time_ms: 0,
created_at: created_state_at,
updated_at: created_at,
});
}
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: ledger_id.to_string(),
user_id: user_id.to_string(),
amount_delta: amount_delta as i64,
balance_after: next_balance,
source_type,
created_at,
});
Ok(next_balance)
}
fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool {
ctx.db.profile_wallet_ledger().iter().any(|row| {
row.user_id == user_id
&& row.source_type == RuntimeProfileWalletLedgerSourceType::PointsRecharge
})
}
fn latest_profile_recharge_order(
ctx: &ReducerContext,
user_id: &str,
) -> Option<ProfileRechargeOrder> {
let mut orders = ctx
.db
.profile_recharge_order()
.iter()
.filter(|row| row.user_id == user_id)
.collect::<Vec<_>>();
orders.sort_by(|left, right| {
right
.created_at
.to_micros_since_unix_epoch()
.cmp(&left.created_at.to_micros_since_unix_epoch())
.then_with(|| left.order_id.cmp(&right.order_id))
});
orders.into_iter().next()
}
fn build_profile_wallet_ledger_snapshot_from_row( fn build_profile_wallet_ledger_snapshot_from_row(
row: &ProfileWalletLedger, row: &ProfileWalletLedger,
) -> RuntimeProfileWalletLedgerEntrySnapshot { ) -> RuntimeProfileWalletLedgerEntrySnapshot {
@@ -788,6 +1158,27 @@ fn build_profile_wallet_ledger_snapshot_from_row(
} }
} }
fn build_profile_recharge_order_snapshot_from_row(
row: &ProfileRechargeOrder,
) -> RuntimeProfileRechargeOrderSnapshot {
RuntimeProfileRechargeOrderSnapshot {
order_id: row.order_id.clone(),
user_id: row.user_id.clone(),
product_id: row.product_id.clone(),
product_title: row.product_title.clone(),
kind: row.kind,
amount_cents: row.amount_cents,
status: row.status,
payment_channel: row.payment_channel.clone(),
paid_at_micros: row.paid_at.to_micros_since_unix_epoch(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
points_delta: row.points_delta,
membership_expires_at_micros: row
.membership_expires_at
.map(|value| value.to_micros_since_unix_epoch()),
}
}
fn build_profile_played_world_snapshot_from_row( fn build_profile_played_world_snapshot_from_row(
row: &ProfilePlayedWorld, row: &ProfilePlayedWorld,
) -> RuntimeProfilePlayedWorldSnapshot { ) -> RuntimeProfilePlayedWorldSnapshot {

View File

@@ -7,16 +7,16 @@
useState, useState,
} from 'react'; } from 'react';
import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph'; import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import { import {
resolveCustomWorldCampSceneImage, resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap, resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals'; } from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
buildCustomWorldFoundationEntries,
parseFoundationTagText,
} from '../services/customWorldFoundationEntries';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent'; import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import { import {
AnimationState, AnimationState,
@@ -557,226 +557,6 @@ function resolvePlayableRolePreviewImage(
return ''; return '';
} }
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toTextArray(value: unknown) {
return Array.isArray(value)
? value.map((item) => toText(item)).filter(Boolean)
: [];
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function buildRelationshipSeedText(value: unknown) {
const record = toRecord(value);
if (!record) {
return '';
}
return compactTextList([
toText(record.name),
toText(record.role),
toText(record.relationToPlayer)
? `与玩家:${toText(record.relationToPlayer)}`
: '',
toText(record.hiddenHook) ? `代价/暗线:${toText(record.hiddenHook)}` : '',
]).join('');
}
function buildKeyRelationshipText(value: KeyRelationshipValue) {
return compactTextList([
value.pairs,
value.relationshipType,
value.secretOrCost ? `代价/秘密:${value.secretOrCost}` : '',
]).join('');
}
function buildAnchorContentFromProfileFallback(
profile: CustomWorldProfile,
): EightAnchorContent {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const relationshipSeed = creatorIntent?.keyCharacters[0] ?? null;
return {
worldPromise: {
hook:
creatorIntent?.worldHook ||
profile.anchorPack?.worldSummary ||
profile.summary,
differentiator: profile.subtitle || profile.settingText,
desiredExperience:
compactTextList([
creatorIntent?.toneDirectives.join('、') || '',
profile.tone,
]).join('') || profile.tone,
},
playerFantasy: {
playerRole: creatorIntent?.playerPremise || profile.playerGoal,
corePursuit: profile.playerGoal,
fearOfLoss:
relationshipSeed?.hiddenHook ||
creatorIntent?.coreConflicts[0] ||
profile.coreConflicts[0] ||
'',
},
themeBoundary: {
toneKeywords: compactTextList([
creatorIntent?.themeKeywords.join('、') || '',
creatorIntent?.toneDirectives.join('、') || '',
]),
aestheticDirectives: compactTextList([profile.tone, profile.subtitle]),
forbiddenDirectives: creatorIntent?.forbiddenDirectives ?? [],
},
playerEntryPoint: {
openingIdentity: creatorIntent?.playerPremise || '',
openingProblem:
creatorIntent?.openingSituation || profile.coreConflicts[0] || '',
entryMotivation: profile.playerGoal,
},
coreConflict: {
surfaceConflicts:
creatorIntent?.coreConflicts.length
? creatorIntent.coreConflicts
: profile.coreConflicts,
hiddenCrisis:
relationshipSeed?.hiddenHook ||
profile.summary ||
profile.settingText,
firstTouchedConflict:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
keyRelationships: relationshipSeed
? [
{
pairs: compactTextList([
relationshipSeed.name,
relationshipSeed.role,
]).join(' · '),
relationshipType: relationshipSeed.relationToPlayer || '',
secretOrCost: relationshipSeed.hiddenHook || '',
},
]
: [],
hiddenLines: {
hiddenTruths: compactTextList([
relationshipSeed?.hiddenHook || '',
profile.summary,
]),
misdirectionHints: compactTextList([
profile.subtitle,
profile.majorFactions[0] || '',
]),
revealPacing:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
iconicElements: {
iconicMotifs:
creatorIntent?.iconicElements.length
? creatorIntent.iconicElements
: compactTextList([
profile.anchorPack?.motifDirectives.join('、') || '',
profile.landmarks[0]?.name || '',
]),
institutionsOrArtifacts: compactTextList([
profile.camp?.name || '',
profile.majorFactions[0] || '',
]),
hardRules: compactTextList([profile.playerGoal, profile.coreConflicts[0] || '']),
},
};
}
function getProfileAnchorContent(profile: CustomWorldProfile) {
const anchorContentRecord = profile.anchorContent;
if (!anchorContentRecord) {
return buildAnchorContentFromProfileFallback(profile);
}
const worldPromiseRecord = toRecord(anchorContentRecord.worldPromise);
const playerFantasyRecord = toRecord(anchorContentRecord.playerFantasy);
const themeBoundaryRecord = toRecord(anchorContentRecord.themeBoundary);
const playerEntryPointRecord = toRecord(anchorContentRecord.playerEntryPoint);
const coreConflictRecord = toRecord(anchorContentRecord.coreConflict);
const hiddenLinesRecord = toRecord(anchorContentRecord.hiddenLines);
const iconicElementsRecord = toRecord(anchorContentRecord.iconicElements);
return {
worldPromise: worldPromiseRecord
? {
hook: toText(worldPromiseRecord.hook),
differentiator: toText(worldPromiseRecord.differentiator),
desiredExperience: toText(worldPromiseRecord.desiredExperience),
}
: null,
playerFantasy: playerFantasyRecord
? {
playerRole: toText(playerFantasyRecord.playerRole),
corePursuit: toText(playerFantasyRecord.corePursuit),
fearOfLoss: toText(playerFantasyRecord.fearOfLoss),
}
: null,
themeBoundary: themeBoundaryRecord
? {
toneKeywords: toTextArray(themeBoundaryRecord.toneKeywords),
aestheticDirectives: toTextArray(
themeBoundaryRecord.aestheticDirectives,
),
forbiddenDirectives: toTextArray(themeBoundaryRecord.forbiddenDirectives),
}
: null,
playerEntryPoint: playerEntryPointRecord
? {
openingIdentity: toText(playerEntryPointRecord.openingIdentity),
openingProblem: toText(playerEntryPointRecord.openingProblem),
entryMotivation: toText(playerEntryPointRecord.entryMotivation),
}
: null,
coreConflict: coreConflictRecord
? {
surfaceConflicts: toTextArray(coreConflictRecord.surfaceConflicts),
hiddenCrisis: toText(coreConflictRecord.hiddenCrisis),
firstTouchedConflict: toText(coreConflictRecord.firstTouchedConflict),
}
: null,
keyRelationships: Array.isArray(anchorContentRecord.keyRelationships)
? anchorContentRecord.keyRelationships
.map((entry) => toRecord(entry))
.filter(Boolean)
.map((entry) => ({
pairs: toText(entry?.pairs),
relationshipType: toText(entry?.relationshipType),
secretOrCost: toText(entry?.secretOrCost),
}))
: [],
hiddenLines: hiddenLinesRecord
? {
hiddenTruths: toTextArray(hiddenLinesRecord.hiddenTruths),
misdirectionHints: toTextArray(hiddenLinesRecord.misdirectionHints),
revealPacing: toText(hiddenLinesRecord.revealPacing),
}
: null,
iconicElements: iconicElementsRecord
? {
iconicMotifs: toTextArray(iconicElementsRecord.iconicMotifs),
institutionsOrArtifacts: toTextArray(
iconicElementsRecord.institutionsOrArtifacts,
),
hardRules: toTextArray(iconicElementsRecord.hardRules),
}
: null,
} satisfies EightAnchorContent;
}
function buildOpeningSceneSearchText( function buildOpeningSceneSearchText(
profile: CustomWorldProfile, profile: CustomWorldProfile,
campScene: ReturnType<typeof resolveCustomWorldCampScene>, campScene: ReturnType<typeof resolveCustomWorldCampScene>,
@@ -791,91 +571,6 @@ function buildOpeningSceneSearchText(
].join(' '); ].join(' ');
} }
function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const anchorContent = getProfileAnchorContent(profile);
const fallbackRelationshipText =
buildRelationshipSeedText(creatorIntent?.keyCharacters[0]) ||
profile.playableNpcs[0]?.relationshipHooks.join('') ||
profile.storyNpcs[0]?.relationshipHooks.join('') ||
'';
return [
{
id: 'world-promise',
label: '世界承诺',
value: compactTextList([
anchorContent.worldPromise?.hook || '',
anchorContent.worldPromise?.differentiator || '',
anchorContent.worldPromise?.desiredExperience || '',
]).join(''),
},
{
id: 'player-fantasy',
label: '玩家幻想',
value: compactTextList([
anchorContent.playerFantasy?.playerRole || '',
anchorContent.playerFantasy?.corePursuit || '',
anchorContent.playerFantasy?.fearOfLoss || '',
]).join(''),
},
{
id: 'theme-boundary',
label: '主题边界',
value: compactTextList([
anchorContent.themeBoundary?.toneKeywords.join('、') || '',
anchorContent.themeBoundary?.aestheticDirectives.join('、') || '',
anchorContent.themeBoundary?.forbiddenDirectives.length
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
: '',
]).join(''),
},
{
id: 'player-entry-point',
label: '玩家切入口',
value: compactTextList([
anchorContent.playerEntryPoint?.openingIdentity || '',
anchorContent.playerEntryPoint?.openingProblem || '',
anchorContent.playerEntryPoint?.entryMotivation || '',
]).join(''),
},
{
id: 'core-conflict',
label: '核心冲突',
value: compactTextList([
anchorContent.coreConflict?.surfaceConflicts.join('、') || '',
anchorContent.coreConflict?.hiddenCrisis || '',
anchorContent.coreConflict?.firstTouchedConflict || '',
]).join(''),
},
{
id: 'key-relationships',
label: '关键关系',
value:
anchorContent.keyRelationships.map(buildKeyRelationshipText).join('\n') ||
fallbackRelationshipText,
},
{
id: 'hidden-lines',
label: '暗线与揭示',
value: compactTextList([
anchorContent.hiddenLines?.hiddenTruths.join('、') || '',
anchorContent.hiddenLines?.misdirectionHints.join('、') || '',
anchorContent.hiddenLines?.revealPacing || '',
]).join(''),
},
{
id: 'iconic-elements',
label: '标志元素',
value: compactTextList([
anchorContent.iconicElements?.iconicMotifs.join('、') || '',
anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '',
anchorContent.iconicElements?.hardRules.join('、') || '',
]).join(''),
},
];
}
type CatalogRole = type CatalogRole =
| CustomWorldProfile['playableNpcs'][number] | CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number]; | CustomWorldProfile['storyNpcs'][number];
@@ -1042,7 +737,7 @@ export function CustomWorldEntityCatalog({
[deferredSearch, landmarkById, profile.landmarks, storyNpcById], [deferredSearch, landmarkById, profile.landmarks, storyNpcById],
); );
const structuredFoundationEntries = useMemo( const structuredFoundationEntries = useMemo(
() => buildStructuredFoundationEntries(profile), () => buildCustomWorldFoundationEntries(profile),
[profile], [profile],
); );
const normalizedCreatorIntent = useMemo( const normalizedCreatorIntent = useMemo(
@@ -1376,14 +1071,14 @@ export function CustomWorldEntityCatalog({
actions={ actions={
readOnly ? ( readOnly ? (
<SmallButton <SmallButton
onClick={() => onEditTarget({ kind: 'world' })} onClick={() => onEditTarget({ kind: 'foundation' })}
tone="sky" tone="sky"
> >
</SmallButton> </SmallButton>
) : ( ) : (
<SmallButton <SmallButton
onClick={() => onEditTarget({ kind: 'world' })} onClick={() => onEditTarget({ kind: 'foundation' })}
tone="sky" tone="sky"
> >
@@ -1401,9 +1096,22 @@ export function CustomWorldEntityCatalog({
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500"> <div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label} {entry.label}
</div> </div>
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-100"> {entry.value ? (
{entry.value || '待补充'} <div className="mt-3 flex flex-wrap gap-2">
</div> {parseFoundationTagText(entry.value).map((tag, index) => (
<span
key={`${entry.id}-${index}-${tag}`}
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
>
{tag}
</span>
))}
</div>
) : (
<div className="mt-2 text-sm leading-7 text-zinc-100">
</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -479,6 +479,81 @@ test('场景角色修改后右上角关闭才弹确认', async () => {
expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0); expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0);
}); });
test('世界页基本设定编辑按钮打开基本设定编辑目标', async () => {
const user = userEvent.setup();
const handleEditTarget = vi.fn();
render(
<CustomWorldEntityCatalog
profile={createProfile()}
previewCharacters={[]}
activeTab="world"
onActiveTabChange={() => {}}
onEditTarget={handleEditTarget}
onProfileChange={() => {}}
/>,
);
const editButtons = screen.getAllByRole('button', { name: '编辑' });
const foundationEditButton = editButtons[1];
expect(foundationEditButton).toBeDefined();
await user.click(foundationEditButton as HTMLElement);
expect(handleEditTarget).toHaveBeenCalledWith({ kind: 'foundation' });
});
test('基本设定用分号拆分成标签展示', () => {
const profile = {
...createProfile(),
anchorContent: {
worldPromise: {
hook: '机械微生物吞并进化',
differentiator: '角色被迫寄生改造',
desiredExperience: '在失控系统里求生',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
} as CustomWorldProfile;
render(
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
activeTab="world"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={() => {}}
/>,
);
const foundationSection = screen.getByText('世界承诺').closest('div');
expect(foundationSection).not.toBeNull();
expect(screen.getByText('机械微生物吞并进化')).toBeTruthy();
expect(screen.getByText('角色被迫寄生改造')).toBeTruthy();
expect(screen.getByText('在失控系统里求生')).toBeTruthy();
});
test('基本设定目标打开独立编辑面板', () => {
render(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'foundation' }}
onClose={vi.fn()}
onProfileChange={vi.fn()}
/>,
);
expect(screen.getByText('编辑基本设定')).toBeTruthy();
expect(screen.queryByText('编辑世界信息')).toBeNull();
});
test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => { test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const handleEditTarget = vi.fn(); const handleEditTarget = vi.fn();

View File

@@ -20,6 +20,7 @@ import {
type BigFishAgentWorkspaceProps = { type BigFishAgentWorkspaceProps = {
session: BigFishSessionSnapshotResponse | null; session: BigFishSessionSnapshotResponse | null;
streamingReplyText?: string; streamingReplyText?: string;
isStreamingReply?: boolean;
isBusy?: boolean; isBusy?: boolean;
error?: string | null; error?: string | null;
onBack: () => void; onBack: () => void;
@@ -72,6 +73,7 @@ function mapBigFishSession(
export function BigFishAgentWorkspace({ export function BigFishAgentWorkspace({
session, session,
streamingReplyText = '', streamingReplyText = '',
isStreamingReply = false,
isBusy = false, isBusy = false,
error = null, error = null,
onBack, onBack,
@@ -86,7 +88,7 @@ export function BigFishAgentWorkspace({
composerPlaceholder="说说这局的生态、成长或爽点..." composerPlaceholder="说说这局的生态、成长或爽点..."
primaryActionLabel="生成结果页" primaryActionLabel="生成结果页"
streamingReplyText={streamingReplyText} streamingReplyText={streamingReplyText}
isStreamingReply={Boolean(streamingReplyText)} isStreamingReply={isStreamingReply}
isBusy={isBusy} isBusy={isBusy}
error={error} error={error}
quickActions={createCreationAgentChatQuickActions()} quickActions={createCreationAgentChatQuickActions()}

View File

@@ -1,10 +1,10 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import * as creationAgentServices from '../../services/creation-agent';
import { createCreationAgentChatQuickActions } from '../../services/creation-agent'; import { createCreationAgentChatQuickActions } from '../../services/creation-agent';
import { import {
type CreationAgentTheme, type CreationAgentTheme,
CreationAgentWorkspace, CreationAgentWorkspace,
@@ -58,9 +58,9 @@ test('creation agent workspace keeps initial chat progress at zero percent', ()
const progressbar = screen.getByRole('progressbar'); const progressbar = screen.getByRole('progressbar');
expect(progressbar.getAttribute('aria-valuenow')).toBe('0'); expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
expect((progressbar.firstElementChild as HTMLElement | null)?.style.width).toBe( expect(
'0%', (progressbar.firstElementChild as HTMLElement | null)?.style.width,
); ).toBe('0%');
}); });
test('creation agent workspace filters duplicate recommended replies', () => { test('creation agent workspace filters duplicate recommended replies', () => {
@@ -153,6 +153,41 @@ test('creation agent workspace renders streaming assistant text', () => {
expect(screen.getByText(//u)).toBeTruthy(); expect(screen.getByText(//u)).toBeTruthy();
}); });
test('creation agent workspace renders waiting dots before first streamed token', () => {
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 1,
progressPercent: 20,
anchors: [],
messages: [
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '我想做一个潮湿压抑的海上世界。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
streamingReplyText=""
isStreamingReply
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.getByTestId('creation-agent-waiting-dots')).toBeTruthy();
});
test('creation agent workspace appends streaming assistant message after stable message list', () => { test('creation agent workspace appends streaming assistant message after stable message list', () => {
ensureScrollApis(); ensureScrollApis();
@@ -200,7 +235,9 @@ test('creation agent workspace appends streaming assistant message after stable
const bubbles = screen const bubbles = screen
.getByTestId('creation-agent-message-list') .getByTestId('creation-agent-message-list')
.querySelectorAll('.whitespace-pre-wrap'); .querySelectorAll('.whitespace-pre-wrap');
const bubbleTexts = Array.from(bubbles).map((node) => node.textContent?.trim()); const bubbleTexts = Array.from(bubbles).map((node) =>
node.textContent?.trim(),
);
expect(bubbleTexts).toEqual([ expect(bubbleTexts).toEqual([
'我想做一个潮湿压抑的海上世界。', '我想做一个潮湿压抑的海上世界。',
@@ -411,3 +448,104 @@ test('creation agent workspace stops auto-follow when user scrolls away from bot
expect(scrollToSpy).not.toHaveBeenCalled(); expect(scrollToSpy).not.toHaveBeenCalled();
}); });
test('creation agent workspace appends parsed document text into composer', async () => {
ensureScrollApis();
vi.spyOn(
creationAgentServices,
'parseCreationAgentDocumentInput',
).mockResolvedValue({
document: {
fileName: '世界设定.md',
contentType: 'text/markdown',
sizeBytes: 24,
text: '第一章:潮湿的港口',
},
});
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText('输入消息'), {
target: {
value: '已有方向',
},
});
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
expect(input).toBeTruthy();
fireEvent.change(input!, {
target: {
files: [new File(['unused'], '世界设定.md', { type: 'text/markdown' })],
},
});
await waitFor(() => {
expect(
(screen.getByPlaceholderText('输入消息') as HTMLTextAreaElement).value,
).toBe('已有方向\n\n第一章潮湿的港口');
});
});
test('creation agent workspace shows document parse error near composer', async () => {
ensureScrollApis();
vi.spyOn(
creationAgentServices,
'parseCreationAgentDocumentInput',
).mockRejectedValue(new Error('暂时只支持 txt、md、csv、json 文本文档。'));
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
expect(input).toBeTruthy();
fireEvent.change(input!, {
target: {
files: [new File(['unused'], '世界设定.docx')],
},
});
await waitFor(() => {
expect(
screen.getByText('暂时只支持 txt、md、csv、json 文本文档。'),
).toBeTruthy();
});
});

View File

@@ -1,9 +1,11 @@
import { ArrowLeft, Send, Sparkles } from 'lucide-react'; import { ArrowLeft, Paperclip, Send, Sparkles } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
type CreationAgentProgressCopy, type CreationAgentProgressCopy,
normalizeCreationAgentProgress, normalizeCreationAgentProgress,
parseCreationAgentDocumentInput,
resolveCreationAgentProgressHint, resolveCreationAgentProgressHint,
} from '../../services/creation-agent'; } from '../../services/creation-agent';
@@ -80,12 +82,13 @@ type CreationAgentWorkspaceProps = {
}; };
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96; const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
const DOCUMENT_INPUT_ACCEPT =
'.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json';
function uniqueRecommendedReplies(recommendedReplies: string[] = []) { function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
return [...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean))].slice( return [
0, ...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean)),
3, ].slice(0, 3);
);
} }
function CreationAgentOperationBanner({ function CreationAgentOperationBanner({
@@ -178,9 +181,8 @@ function CreationAgentMessageBubble({
}) { }) {
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const isSystem = message.role === 'system'; const isSystem = message.role === 'system';
const visibleRecommendedReplies = isUser || isStreaming const visibleRecommendedReplies =
? [] isUser || isStreaming ? [] : uniqueRecommendedReplies(recommendedReplies);
: uniqueRecommendedReplies(recommendedReplies);
const bubbleToneClass = isUser const bubbleToneClass = isUser
? theme.userBubbleClass ? theme.userBubbleClass
: isSystem : isSystem
@@ -201,7 +203,11 @@ function CreationAgentMessageBubble({
/> />
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1.5 py-1"> <div
data-testid="creation-agent-waiting-dots"
aria-label="等待回复"
className="flex items-center gap-1.5 py-1"
>
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" /> <span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" /> <span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" /> <span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
@@ -238,7 +244,10 @@ function shouldShowQuickAction(
return false; return false;
} }
if (typeof action.minTurn === 'number' && session.currentTurn < action.minTurn) { if (
typeof action.minTurn === 'number' &&
session.currentTurn < action.minTurn
) {
return false; return false;
} }
@@ -287,8 +296,13 @@ export function CreationAgentWorkspace({
onQuickAction, onQuickAction,
}: CreationAgentWorkspaceProps) { }: CreationAgentWorkspaceProps) {
const [draftText, setDraftText] = useState(''); const [draftText, setDraftText] = useState('');
const [documentInputError, setDocumentInputError] = useState<string | null>(
null,
);
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。 // 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
const messageListRef = useRef<HTMLDivElement | null>(null); const messageListRef = useRef<HTMLDivElement | null>(null);
const documentInputRef = useRef<HTMLInputElement | null>(null);
const shouldAutoScrollRef = useRef(true); const shouldAutoScrollRef = useRef(true);
useEffect(() => { useEffect(() => {
@@ -298,7 +312,12 @@ export function CreationAgentWorkspace({
} }
scrollMessageListToBottom(container); scrollMessageListToBottom(container);
}, [session?.sessionId, session?.messages, streamingReplyText, isStreamingReply]); }, [
session?.sessionId,
session?.messages,
streamingReplyText,
isStreamingReply,
]);
if (!session) { if (!session) {
return ( return (
@@ -318,7 +337,8 @@ export function CreationAgentWorkspace({
shouldShowQuickAction(action, session, progress), shouldShowQuickAction(action, session, progress),
); );
const streamingMessageId = `streaming-assistant-${session.sessionId}`; const streamingMessageId = `streaming-assistant-${session.sessionId}`;
const shouldShowStreamingReply = isStreamingReply && streamingReplyText.trim(); // 用户消息提交后、首个流式文本到达前,也要立刻展示等待气泡。
const shouldShowStreamingReply = isStreamingReply;
const displayedMessages = shouldShowStreamingReply const displayedMessages = shouldShowStreamingReply
? [ ? [
...session.messages, ...session.messages,
@@ -356,18 +376,61 @@ export function CreationAgentWorkspace({
const submit = () => { const submit = () => {
const text = draftText.trim(); const text = draftText.trim();
if (!text || isBusy) { if (!text || isBusy || isParsingDocumentInput) {
return; return;
} }
armAutoScrollToBottom(); armAutoScrollToBottom();
onSubmitText(text); onSubmitText(text);
setDraftText(''); setDraftText('');
setDocumentInputError(null);
};
const appendDocumentInputText = (text: string) => {
setDraftText((current) => {
const currentText = current.trimEnd();
const nextText = text.trim();
return currentText ? `${currentText}\n\n${nextText}` : nextText;
});
};
const openDocumentInputPicker = () => {
documentInputRef.current?.click();
};
const handleDocumentInputChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file || isBusy || isParsingDocumentInput) {
return;
}
setIsParsingDocumentInput(true);
setDocumentInputError(null);
try {
const response = await parseCreationAgentDocumentInput(file);
appendDocumentInputText(response.document.text);
} catch (parseError) {
setDocumentInputError(
parseError instanceof Error
? parseError.message
: '解析文档失败,请重新选择文件。',
);
} finally {
setIsParsingDocumentInput(false);
}
}; };
return ( return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0"> <div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className={`relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}> <div
className={`relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}
>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<button <button
type="button" type="button"
@@ -482,20 +545,43 @@ export function CreationAgentWorkspace({
)} )}
</div> </div>
{error ? ( {documentInputError || error ? (
<div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600"> <div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600">
{error} {documentInputError || error}
</div> </div>
) : null} ) : null}
<div className="border-t border-[var(--platform-subpanel-border)] p-3"> <div className="border-t border-[var(--platform-subpanel-border)] p-3">
<div className="flex items-end gap-2 rounded-[1.25rem] bg-white/80 p-2"> <div className="flex items-end gap-2 rounded-[1.25rem] bg-white/80 p-2">
<input
ref={documentInputRef}
type="file"
accept={DOCUMENT_INPUT_ACCEPT}
className="hidden"
onChange={handleDocumentInputChange}
/>
<button
type="button"
aria-label={
isParsingDocumentInput ? '正在解析文档' : '上传文档'
}
title={isParsingDocumentInput ? '正在解析文档' : '上传文档'}
aria-busy={isParsingDocumentInput}
disabled={isBusy || isParsingDocumentInput}
onClick={openDocumentInputPicker}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[var(--platform-text-base)] hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
>
<Paperclip
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
/>
</button>
<textarea <textarea
value={draftText} value={draftText}
disabled={isBusy} disabled={isBusy || isParsingDocumentInput}
rows={2} rows={2}
onChange={(event) => { onChange={(event) => {
setDraftText(event.target.value); setDraftText(event.target.value);
setDocumentInputError(null);
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
@@ -509,7 +595,7 @@ export function CreationAgentWorkspace({
<button <button
type="button" type="button"
aria-label="发送" aria-label="发送"
disabled={isBusy || !draftText.trim()} disabled={isBusy || isParsingDocumentInput || !draftText.trim()}
onClick={submit} onClick={submit}
className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`} className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`}
> >

View File

@@ -4,15 +4,16 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard'; import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { import { CustomWorldWorkCard } from './CustomWorldWorkCard';
CustomWorldWorkCard,
type UnifiedCreationWorkItem,
} from './CustomWorldWorkCard';
import { import {
type CustomWorldWorkFilter, type CustomWorldWorkFilter,
CustomWorldWorkTabs, CustomWorldWorkTabs,
} from './CustomWorldWorkTabs'; } from './CustomWorldWorkTabs';
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes'; import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
} from './creationWorkShelf';
type CustomWorldCreationHubProps = { type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[]; items: CustomWorldWorkSummary[];
@@ -71,42 +72,111 @@ export function CustomWorldCreationHub({
}: CustomWorldCreationHubProps) { }: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] = const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all'); useState<CustomWorldWorkFilter>('all');
const unifiedItems = useMemo<UnifiedCreationWorkItem[]>( const shelfItems = useMemo(
() => [ () =>
...items.map((item) => ({ kind: 'rpg', item }) as const), buildCreationWorkShelfItems({
...bigFishItems.map((item) => ({ kind: 'big-fish', item }) as const), rpgItems: items,
...puzzleItems.map((item) => ({ kind: 'puzzle', item }) as const), bigFishItems,
puzzleItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeletePuzzle: Boolean(onDeletePuzzle),
}),
[
bigFishItems,
items,
onDeleteBigFish,
onDeletePublished,
onDeletePuzzle,
puzzleItems,
], ],
[bigFishItems, items, puzzleItems],
); );
const draftCount = unifiedItems.filter((entry) => const draftCount = shelfItems.filter((entry) => entry.status === 'draft').length;
entry.kind === 'puzzle' const publishedCount = shelfItems.filter(
? entry.item.publicationStatus === 'draft' (entry) => entry.status === 'published',
: entry.kind === 'big-fish'
? entry.item.status === 'draft'
: entry.item.status === 'draft',
).length;
const publishedCount = unifiedItems.filter((entry) =>
entry.kind === 'puzzle'
? entry.item.publicationStatus === 'published'
: entry.kind === 'big-fish'
? entry.item.status === 'published'
: entry.item.status === 'published',
).length; ).length;
const filteredItems = useMemo( const filteredItems = useMemo(
() => () =>
unifiedItems.filter((entry) => shelfItems.filter((entry) =>
activeFilter === 'all' activeFilter === 'all' ? true : entry.status === activeFilter,
? true
: entry.kind === 'puzzle'
? entry.item.publicationStatus === activeFilter
: entry.kind === 'big-fish'
? entry.item.status === activeFilter
: entry.item.status === activeFilter,
), ),
[activeFilter, unifiedItems], [activeFilter, shelfItems],
); );
function handleOpenShelfItem(item: CreationWorkShelfItem) {
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
}
function buildExperienceAction(item: CreationWorkShelfItem) {
if (!item.canExperience) {
return null;
}
switch (item.source.kind) {
case 'puzzle': {
const sourceItem = item.source.item;
return () => {
onExperiencePuzzle?.(sourceItem.profileId);
};
}
case 'big-fish': {
const sourceItem = item.source.item;
return () => {
onExperienceBigFish?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
onExperienceRpg?.(sourceItem);
};
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;
}
switch (item.source.kind) {
case 'puzzle': {
const sourceItem = item.source.item;
return () => {
onDeletePuzzle?.(sourceItem);
};
}
case 'big-fish': {
const sourceItem = item.source.item;
return () => {
onDeleteBigFish?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
onDeletePublished?.(sourceItem);
};
}
}
}
return ( return (
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5"> <div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
<div className="space-y-4 xl:space-y-3"> <div className="space-y-4 xl:space-y-3">
@@ -158,65 +228,16 @@ export function CustomWorldCreationHub({
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<CustomWorldWorkCard <CustomWorldWorkCard
key={`${item.kind}-${item.item.workId}`} key={`${item.kind}-${item.id}`}
item={item} item={item}
onOpen={() => { onOpen={() => handleOpenShelfItem(item)}
if (item.kind === 'puzzle') { onExperience={buildExperienceAction(item)}
onOpenPuzzleDetail?.(item.item); onDelete={buildDeleteAction(item)}
return; deleteBusy={deletingWorkId === item.id}
}
if (item.kind === 'big-fish') {
onOpenBigFishDetail?.(item.item);
return;
}
if (item.item.status === 'draft') {
onOpenDraft(item.item);
return;
}
if (item.item.profileId) {
onEnterPublished(item.item.profileId);
}
}}
onExperience={
item.kind === 'puzzle'
? item.item.publicationStatus === 'published'
? () => {
onExperiencePuzzle?.(item.item.profileId);
}
: null
: item.kind === 'big-fish'
? item.item.status === 'published'
? () => {
onExperienceBigFish?.(item.item);
}
: null
: item.item.status === 'published' && item.item.canEnterWorld
? () => {
onExperienceRpg?.(item.item);
}
: null
}
onDelete={
item.kind === 'puzzle'
? () => {
onDeletePuzzle?.(item.item);
}
: item.kind === 'big-fish'
? () => {
onDeleteBigFish?.(item.item);
}
: () => {
onDeletePublished?.(item.item);
}
}
deleteBusy={deletingWorkId === item.item.workId}
/> />
))} ))}
</div> </div>
) : unifiedItems.length === 0 ? ( ) : shelfItems.length === 0 ? (
<EmptyState title="还没有作品" /> <EmptyState title="还没有作品" />
) : ( ) : (
<EmptyState title="当前筛选下没有内容" /> <EmptyState title="当前筛选下没有内容" />

View File

@@ -1,7 +1,5 @@
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import type { CreationWorkShelfItem } from './creationWorkShelf';
function formatUpdatedAt(value: string) { function formatUpdatedAt(value: string) {
const date = new Date(value); const date = new Date(value);
@@ -17,28 +15,20 @@ function formatUpdatedAt(value: string) {
}).format(date); }).format(date);
} }
export type UnifiedCreationWorkItem =
| {
kind: 'rpg';
item: CustomWorldWorkSummary;
}
| {
kind: 'big-fish';
item: BigFishWorkSummary;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
};
type CustomWorldWorkCardProps = { type CustomWorldWorkCardProps = {
item: UnifiedCreationWorkItem; item: CreationWorkShelfItem;
onOpen: () => void; onOpen: () => void;
onExperience?: (() => void) | null; onExperience?: (() => void) | null;
onDelete?: (() => void) | null; onDelete?: (() => void) | null;
deleteBusy?: boolean; deleteBusy?: boolean;
}; };
const BADGE_TONE_CLASS: Record<CreationWorkShelfItem['badges'][number]['tone'], string> = {
warm: 'platform-pill--warm',
success: 'platform-pill--success',
neutral: 'platform-pill--neutral',
};
export function CustomWorldWorkCard({ export function CustomWorldWorkCard({
item, item,
onOpen, onOpen,
@@ -46,40 +36,11 @@ export function CustomWorldWorkCard({
onDelete = null, onDelete = null,
deleteBusy = false, deleteBusy = false,
}: CustomWorldWorkCardProps) { }: CustomWorldWorkCardProps) {
const isPuzzle = item.kind === 'puzzle';
const isBigFish = item.kind === 'big-fish';
const isDraft =
item.kind === 'puzzle'
? item.item.publicationStatus === 'draft'
: item.kind === 'big-fish'
? item.item.status === 'draft'
: item.item.status === 'draft';
const openActionLabel = isPuzzle || isBigFish
? isDraft
? '继续创作'
: '查看详情'
: isDraft
? item.item.playableNpcCount > 0 || item.item.landmarkCount > 0
? '继续完善'
: '继续创作'
: '查看详情';
const title =
item.kind === 'puzzle' ? item.item.levelName : item.item.title;
const subtitle =
item.kind === 'puzzle' ? item.item.authorDisplayName : item.item.subtitle;
const summary = item.item.summary;
const updatedAt = item.item.updatedAt;
const coverImageSrc = item.item.coverImageSrc ?? null;
const coverRenderMode =
item.kind === 'rpg' ? item.item.coverRenderMode : 'image';
const coverCharacterImageSrcs =
item.kind === 'rpg' ? item.item.coverCharacterImageSrcs : [];
return ( return (
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={`${openActionLabel}${title}`} aria-label={`${item.openActionLabel}${item.title}`}
onClick={onOpen} onClick={onOpen}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') { if (event.key !== 'Enter' && event.key !== ' ') {
@@ -92,48 +53,29 @@ export function CustomWorldWorkCard({
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5" className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
> >
<CustomWorldCoverArtwork <CustomWorldCoverArtwork
imageSrc={coverImageSrc} imageSrc={item.coverImageSrc}
title={title} title={item.title}
fallbackLabel="封面" fallbackLabel="封面"
renderMode={coverRenderMode} renderMode={item.coverRenderMode}
characterImageSrcs={coverCharacterImageSrcs} characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0" className="platform-cover-artwork absolute inset-0"
/> />
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" /> <div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]"> <div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<span {item.badges.map((badge) => (
className={`platform-pill px-3 py-1 text-[10px] ${ <span
isDraft key={`${item.id}-${badge.id}`}
? 'platform-pill--warm' className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} px-3 py-1 text-[10px]`}
: 'platform-pill--success' >
}`} {badge.label}
>
{isDraft ? '草稿' : '已发布'}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{isPuzzle ? '拼图' : isBigFish ? '大鱼' : 'RPG'}
</span>
{item.kind === 'rpg' && item.item.stageLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.stageLabel}
</span> </span>
) : null} ))}
{isPuzzle
? item.item.themeTags.slice(0, 2).map((tag) => (
<span
key={`${item.item.profileId}-${tag}`}
className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]"
>
{tag}
</span>
))
: null}
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<span className="text-[11px] text-[var(--platform-text-soft)]"> <span className="text-[11px] text-[var(--platform-text-soft)]">
{formatUpdatedAt(updatedAt)} {formatUpdatedAt(item.updatedAt)}
</span> </span>
{onDelete ? ( {onDelete ? (
<button <button
@@ -174,69 +116,26 @@ export function CustomWorldWorkCard({
<div className="mt-4 min-h-0 xl:mt-3"> <div className="mt-4 min-h-0 xl:mt-3">
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl"> <div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
{title} {item.title}
</div> </div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]"> <div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{subtitle} {item.subtitle}
</div> </div>
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2"> <div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
{summary} {item.summary}
</div> </div>
</div> </div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3"> <div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{isPuzzle ? ( {item.metrics.map((metric) => (
<> <span
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]"> key={`${item.id}-${metric.id}`}
{item.item.authorDisplayName} className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
</span> >
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]"> {metric.label}
{item.item.playCount} </span>
</span> ))}
</>
) : isBigFish ? (
<>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.levelCount}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.levelMainImageReadyCount}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.levelMotionReadyCount}
</span>
{item.item.backgroundReady ? (
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
</span>
) : null}
</>
) : (
<>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{isDraft ? '角色' : '可扮演角色'} {item.item.playableNpcCount}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.landmarkCount}
</span>
{item.item.roleVisualReadyCount ? (
<span className="platform-pill platform-pill--warm px-3 py-1 text-[10px]">
{item.item.roleVisualReadyCount}
</span>
) : null}
{item.item.roleAnimationReadyCount ? (
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
{item.item.roleAnimationReadyCount}
</span>
) : null}
{item.item.roleAssetSummaryLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.roleAssetSummaryLabel}
</span>
) : null}
</>
)}
</div> </div>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5"> <div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
{onExperience ? ( {onExperience ? (

View File

@@ -0,0 +1,247 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
export type CreationWorkShelfStatus = 'draft' | 'published';
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
export type CreationWorkShelfBadge = {
id: string;
label: string;
tone: CreationWorkShelfBadgeTone;
};
export type CreationWorkShelfMetric = {
id: string;
label: string;
tone?: CreationWorkShelfBadgeTone;
};
export type CreationWorkShelfSource =
| {
kind: 'rpg';
item: CustomWorldWorkSummary;
}
| {
kind: 'big-fish';
item: BigFishWorkSummary;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
};
export type CreationWorkShelfItem = {
id: string;
kind: CreationWorkShelfKind;
status: CreationWorkShelfStatus;
title: string;
subtitle: string;
summary: string;
updatedAt: string;
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
coverCharacterImageSrcs: string[];
typeLabel: string;
openActionLabel: string;
canExperience: boolean;
canDelete: boolean;
badges: CreationWorkShelfBadge[];
metrics: CreationWorkShelfMetric[];
source: CreationWorkShelfSource;
};
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
bigFishItems: BigFishWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
canDeleteRpg?: boolean;
canDeleteBigFish?: boolean;
canDeletePuzzle?: boolean;
}) {
const {
rpgItems,
bigFishItems,
puzzleItems,
canDeleteRpg = false,
canDeleteBigFish = false,
canDeletePuzzle = false,
} = params;
return [
...rpgItems.map((item) => mapRpgWorkToShelfItem(item, canDeleteRpg)),
...bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
),
...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle)),
].sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
);
}
function mapRpgWorkToShelfItem(
item: CustomWorldWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
const isDraft = item.status === 'draft';
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
{ id: 'type', label: 'RPG', tone: 'neutral' },
];
if (item.stageLabel) {
badges.push({ id: 'stage', label: item.stageLabel, tone: 'neutral' });
}
const metrics: CreationWorkShelfMetric[] = [
{
id: 'playable-npc-count',
label: `${isDraft ? '角色' : '可扮演角色'} ${item.playableNpcCount}`,
},
{ id: 'landmark-count', label: `地点 ${item.landmarkCount}` },
];
if (item.roleVisualReadyCount) {
metrics.push({
id: 'role-visual-ready-count',
label: `主图 ${item.roleVisualReadyCount}`,
tone: 'warm',
});
}
if (item.roleAnimationReadyCount) {
metrics.push({
id: 'role-animation-ready-count',
label: `动作 ${item.roleAnimationReadyCount}`,
tone: 'success',
});
}
if (item.roleAssetSummaryLabel) {
metrics.push({
id: 'role-asset-summary',
label: item.roleAssetSummaryLabel,
});
}
return {
id: item.workId,
kind: 'rpg',
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: item.coverRenderMode ?? 'image',
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
typeLabel: 'RPG',
openActionLabel: isDraft
? item.playableNpcCount > 0 || item.landmarkCount > 0
? '继续完善'
: '继续创作'
: '查看详情',
canExperience: item.status === 'published' && item.canEnterWorld,
canDelete,
badges,
metrics,
source: { kind: 'rpg', item },
};
}
function mapBigFishWorkToShelfItem(
item: BigFishWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
return {
id: item.workId,
kind: 'big-fish',
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
typeLabel: '大鱼',
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
canExperience: item.status === 'published',
canDelete,
badges: [
buildStatusBadge(item.status),
{ id: 'type', label: '大鱼', tone: 'neutral' },
],
metrics: [
{ id: 'level-count', label: `关卡 ${item.levelCount}` },
{
id: 'level-main-image-ready-count',
label: `主图 ${item.levelMainImageReadyCount}`,
},
{
id: 'level-motion-ready-count',
label: `动作 ${item.levelMotionReadyCount}`,
},
...(item.backgroundReady
? [
{
id: 'background-ready',
label: '背景已就绪',
tone: 'success' as const,
},
]
: []),
],
source: { kind: 'big-fish', item },
};
}
function mapPuzzleWorkToShelfItem(
item: PuzzleWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
const status = item.publicationStatus;
return {
id: item.workId,
kind: 'puzzle',
status,
title: item.levelName,
subtitle: item.authorDisplayName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
typeLabel: '拼图',
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
canExperience: status === 'published',
canDelete,
badges: [
buildStatusBadge(status),
{ id: 'type', label: '拼图', tone: 'neutral' },
...item.themeTags.slice(0, 2).map((tag) => ({
id: `tag:${tag}`,
label: tag,
tone: 'neutral' as const,
})),
],
metrics: [
{ id: 'author', label: `作者 ${item.authorDisplayName}` },
{ id: 'play-count', label: `游玩 ${item.playCount}` },
],
source: { kind: 'puzzle', item },
};
}
function buildStatusBadge(status: CreationWorkShelfStatus): CreationWorkShelfBadge {
return {
id: 'status',
label: status === 'draft' ? '草稿' : '已发布',
tone: status === 'draft' ? 'warm' : 'success',
};
}
function getShelfItemTime(value: string) {
const timestamp = new Date(value).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}

View File

@@ -59,7 +59,7 @@ import {
getPuzzleAgentSession, getPuzzleAgentSession,
streamPuzzleAgentMessage, streamPuzzleAgentMessage,
} from '../../services/puzzle-agent'; } from '../../services/puzzle-agent';
import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery'; import { getPuzzleGalleryDetail, listPuzzleGallery } from '../../services/puzzle-gallery';
import { import {
advanceLocalPuzzleLevel, advanceLocalPuzzleLevel,
dragLocalPuzzlePiece, dragLocalPuzzlePiece,
@@ -82,6 +82,11 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController'; import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
import {
isPuzzleGalleryEntry,
mapPuzzleWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import { PlatformEntryHomeView } from './PlatformEntryHomeView'; import { PlatformEntryHomeView } from './PlatformEntryHomeView';
import { import {
@@ -93,6 +98,7 @@ import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import type { PlatformEntryFlowShellProps } from './platformEntryTypes'; import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation'; import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
@@ -114,6 +120,33 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
'publish_missing_first_act', 'publish_missing_first_act',
]); ]);
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
const timestamp = new Date(rawTime).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`;
}
function mergePlatformPublicGalleryEntries(
rpgEntries: CustomWorldGalleryCard[],
puzzleEntries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
});
return Array.from(entryMap.values()).sort(
(left, right) =>
getPlatformPublicGalleryEntryTime(right) -
getPlatformPublicGalleryEntryTime(left),
);
}
function readProfileTextField( function readProfileTextField(
profile: CustomWorldProfile | null, profile: CustomWorldProfile | null,
paths: string[], paths: string[],
@@ -304,28 +337,20 @@ export function PlatformEntryFlowShellImpl({
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false); const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [selectedDetailEntry, setSelectedDetailEntry] = const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null); useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [bigFishSession, setBigFishSession] =
useState<BigFishSessionSnapshotResponse | null>(null);
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]); const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
const [bigFishRun, setBigFishRun] = const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null); useState<BigFishRuntimeSnapshotResponse | null>(null);
const [bigFishError, setBigFishError] = useState<string | null>(null);
const [isBigFishBusy, setIsBigFishBusy] = useState(false);
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false); const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [streamingBigFishReplyText, setStreamingBigFishReplyText] =
useState('');
const [isStreamingBigFishReply, setIsStreamingBigFishReply] = useState(false);
const bigFishInputInFlightRef = useRef(false); const bigFishInputInFlightRef = useRef(false);
const [puzzleSession, setPuzzleSession] =
useState<PuzzleAgentSessionSnapshot | null>(null);
const [puzzleOperation, setPuzzleOperation] = const [puzzleOperation, setPuzzleOperation] =
useState<PuzzleAgentOperationRecord | null>(null); useState<PuzzleAgentOperationRecord | null>(null);
const [puzzleWorks, setPuzzleWorks] = useState<PuzzleWorkSummary[]>([]); const [puzzleWorks, setPuzzleWorks] = useState<PuzzleWorkSummary[]>([]);
const [puzzleGalleryEntries, setPuzzleGalleryEntries] = useState<
PuzzleWorkSummary[]
>([]);
const [selectedPuzzleDetail, setSelectedPuzzleDetail] = const [selectedPuzzleDetail, setSelectedPuzzleDetail] =
useState<PuzzleWorkSummary | null>(null); useState<PuzzleWorkSummary | null>(null);
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null); const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const [puzzleError, setPuzzleError] = useState<string | null>(null);
const [isPuzzleBusy, setIsPuzzleBusy] = useState(false);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false); const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false); const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
const [publicSearchError, setPublicSearchError] = useState<string | null>(null); const [publicSearchError, setPublicSearchError] = useState<string | null>(null);
@@ -334,8 +359,6 @@ export function PlatformEntryFlowShellImpl({
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
string | null string | null
>(null); >(null);
const [streamingPuzzleReplyText, setStreamingPuzzleReplyText] = useState('');
const [isStreamingPuzzleReply, setIsStreamingPuzzleReply] = useState(false);
const hasInitialAgentSession = Boolean( const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId, readCustomWorldAgentUiState().activeSessionId,
); );
@@ -359,6 +382,17 @@ export function PlatformEntryFlowShellImpl({
setPlatformTab('create'); setPlatformTab('create');
}, [setPlatformTab]); }, [setPlatformTab]);
const resolveBigFishErrorMessage = useCallback(
(error: unknown, fallback: string) =>
resolveRpgCreationErrorMessage(error, fallback),
[],
);
const resolvePuzzleErrorMessage = useCallback(
(error: unknown, fallback: string) =>
resolveRpgCreationErrorMessage(error, fallback),
[],
);
const sessionController = useRpgCreationSessionController({ const sessionController = useRpgCreationSessionController({
userId: authUi?.user?.id, userId: authUi?.user?.id,
openLoginModal: authUi?.openLoginModal, openLoginModal: authUi?.openLoginModal,
@@ -441,10 +475,18 @@ export function PlatformEntryFlowShellImpl({
agentSessionProfile: sessionController.agentDraftResultProfile, agentSessionProfile: sessionController.agentDraftResultProfile,
agentSession: sessionController.agentSession, agentSession: sessionController.agentSession,
handleCustomWorldSelect, handleCustomWorldSelect,
executePublishWorld: () => executePublishWorld: async () => {
autosaveCoordinator.executeAgentActionAndWait({ const latestSession = await autosaveCoordinator.executeAgentActionAndWait({
action: 'publish_world', action: 'publish_world',
}), });
// 发布动作会在后端同步 gallery 投影;前端发布完成后立即刷新首页/分类页共用的公开作品列表。
await Promise.allSettled([
platformBootstrap.refreshPublishedGallery(),
platformBootstrap.refreshCustomWorldWorks(),
refreshPuzzleGallery(),
]);
return latestSession;
},
setGeneratedCustomWorldProfile: setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile, sessionController.setGeneratedCustomWorldProfile,
}); });
@@ -495,8 +537,24 @@ export function PlatformEntryFlowShellImpl({
}, [agentResultPreview]); }, [agentResultPreview]);
const featuredGalleryEntries = useMemo( const featuredGalleryEntries = useMemo(
() => platformBootstrap.publishedGalleryEntries.slice(0, 6), () => {
[platformBootstrap.publishedGalleryEntries], const puzzlePublicEntries = puzzleGalleryEntries.map(
mapPuzzleWorkToPlatformGalleryCard,
);
return mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries,
puzzlePublicEntries,
).slice(0, 6);
},
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
);
const latestGalleryEntries = useMemo(
() =>
mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
),
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
); );
const creationHubItems = const creationHubItems =
@@ -523,17 +581,6 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage, setSelectionStage,
]); ]);
useEffect(() => {
if (selectionStage === 'big-fish-result' && !bigFishSession?.draft) {
setSelectionStage(
bigFishSession ? 'big-fish-agent-workspace' : 'platform',
);
}
if (selectionStage === 'big-fish-runtime' && !bigFishRun) {
setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform');
}
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
const runProtectedAction = useCallback( const runProtectedAction = useCallback(
(action: () => void) => { (action: () => void) => {
if (!authUi?.requireAuth) { if (!authUi?.requireAuth) {
@@ -647,17 +694,6 @@ export function PlatformEntryFlowShellImpl({
setShowCreationTypeModal(true); setShowCreationTypeModal(true);
}, [prepareCreationLaunch]); }, [prepareCreationLaunch]);
const resolveBigFishErrorMessage = useCallback(
(error: unknown, fallback: string) =>
resolveRpgCreationErrorMessage(error, fallback),
[],
);
const resolvePuzzleErrorMessage = useCallback(
(error: unknown, fallback: string) =>
resolveRpgCreationErrorMessage(error, fallback),
[],
);
const refreshBigFishShelf = useCallback(async () => { const refreshBigFishShelf = useCallback(async () => {
setIsBigFishLoadingLibrary(true); setIsBigFishLoadingLibrary(true);
@@ -690,64 +726,144 @@ export function PlatformEntryFlowShellImpl({
} }
}, [resolvePuzzleErrorMessage]); }, [resolvePuzzleErrorMessage]);
const openBigFishAgentWorkspace = useCallback(async () => { const refreshPuzzleGallery = useCallback(async () => {
if (isBigFishBusy) {
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
setBigFishRun(null);
try { try {
const { session } = await createBigFishCreationSession({}); const galleryResponse = await listPuzzleGallery();
setBigFishSession(session); setPuzzleGalleryEntries(galleryResponse.items);
enterCreateTab(); return galleryResponse.items;
setShowCreationTypeModal(false);
setSelectionStage('big-fish-agent-workspace');
} catch (error) { } catch (error) {
setBigFishError( setPuzzleGalleryEntries([]);
resolveBigFishErrorMessage(error, '开启大鱼吃小鱼共创工作台失败。'), setPuzzleError(
resolvePuzzleErrorMessage(error, '读取拼图广场失败。'),
); );
} finally { return [];
setIsBigFishBusy(false);
} }
}, [ }, [resolvePuzzleErrorMessage]);
const bigFishFlow = usePlatformCreationAgentFlowController<
BigFishSessionSnapshotResponse,
Record<string, never>,
{ session: BigFishSessionSnapshotResponse },
SendBigFishMessageRequest,
ExecuteBigFishActionRequest,
{ session: BigFishSessionSnapshotResponse }
>({
client: {
createSession: createBigFishCreationSession,
getSession: getBigFishCreationSession,
streamMessage: streamBigFishCreationMessage,
executeAction: executeBigFishCreationAction,
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'big-fish-agent-workspace',
resultStage: 'big-fish-result',
platformStage: 'platform',
isCompileAction: (payload) => payload.action === 'big_fish_compile_draft',
resolveErrorMessage: resolveBigFishErrorMessage,
errorMessages: {
open: '开启大鱼吃小鱼共创工作台失败。',
restoreMissingSession: '这份大鱼吃小鱼草稿缺少会话信息,请重新开始创作。',
restore: '读取大鱼吃小鱼创作草稿失败。',
submit: '发送大鱼吃小鱼共创消息失败。',
execute: '执行大鱼吃小鱼操作失败。',
},
enterCreateTab, enterCreateTab,
isBigFishBusy,
resolveBigFishErrorMessage,
setSelectionStage, setSelectionStage,
]); onSessionOpened: () => {
setShowCreationTypeModal(false);
},
onActionComplete: ({ response, setSession }) => {
setSession(response.session);
},
});
const puzzleFlow = usePlatformCreationAgentFlowController<
PuzzleAgentSessionSnapshot,
Record<string, never>,
{ session: PuzzleAgentSessionSnapshot },
SendPuzzleAgentMessageRequest,
PuzzleAgentActionRequest,
{ operation: PuzzleAgentOperationRecord }
>({
client: {
createSession: createPuzzleAgentSession,
getSession: getPuzzleAgentSession,
streamMessage: streamPuzzleAgentMessage,
executeAction: executePuzzleAgentAction,
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'puzzle-agent-workspace',
resultStage: 'puzzle-result',
platformStage: 'platform',
isCompileAction: (payload) => payload.action === 'compile_puzzle_draft',
resolveErrorMessage: resolvePuzzleErrorMessage,
errorMessages: {
open: '开启拼图共创工作台失败。',
restoreMissingSession: '这份拼图草稿缺少会话信息,请重新开始创作。',
restore: '读取拼图创作草稿失败。',
submit: '发送拼图共创消息失败。',
execute: '执行拼图操作失败。',
},
enterCreateTab,
setSelectionStage,
onSessionOpened: () => {
setShowCreationTypeModal(false);
},
onActionComplete: async ({ payload, response, session, setSession }) => {
setPuzzleOperation(response.operation);
if (payload.action === 'publish_puzzle_work') {
await Promise.allSettled([
refreshPuzzleShelf(),
refreshPuzzleGallery(),
]);
}
const latestResponse = await getPuzzleAgentSession(session.sessionId);
const latestSession = latestResponse.session;
setSession(latestSession);
if (
payload.action === 'publish_puzzle_work' &&
latestSession.publishedProfileId
) {
const galleryDetail = await getPuzzleGalleryDetail(
latestSession.publishedProfileId,
);
setSelectedPuzzleDetail(galleryDetail.item);
setSelectionStage('puzzle-gallery-detail');
}
},
});
const bigFishSession = bigFishFlow.session;
const bigFishError = bigFishFlow.error;
const setBigFishError = bigFishFlow.setError;
const isBigFishBusy = bigFishFlow.isBusy;
const setIsBigFishBusy = bigFishFlow.setIsBusy;
const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
const puzzleSession = puzzleFlow.session;
const puzzleError = puzzleFlow.error;
const setPuzzleError = puzzleFlow.setError;
const isPuzzleBusy = puzzleFlow.isBusy;
const setIsPuzzleBusy = puzzleFlow.setIsBusy;
const streamingPuzzleReplyText = puzzleFlow.streamingReplyText;
const isStreamingPuzzleReply = puzzleFlow.isStreamingReply;
const openBigFishAgentWorkspace = useCallback(async () => {
setBigFishRun(null);
await bigFishFlow.openWorkspace();
}, [bigFishFlow]);
const openPuzzleAgentWorkspace = useCallback(async () => { const openPuzzleAgentWorkspace = useCallback(async () => {
if (isPuzzleBusy) {
return;
}
setIsPuzzleBusy(true);
setPuzzleError(null);
setPuzzleRun(null); setPuzzleRun(null);
setPuzzleOperation(null); setPuzzleOperation(null);
await puzzleFlow.openWorkspace();
try { }, [puzzleFlow]);
const { session } = await createPuzzleAgentSession({});
setPuzzleSession(session);
enterCreateTab();
setShowCreationTypeModal(false);
setSelectionStage('puzzle-agent-workspace');
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '开启拼图共创工作台失败。'),
);
} finally {
setIsPuzzleBusy(false);
}
}, [
enterCreateTab,
isPuzzleBusy,
resolvePuzzleErrorMessage,
setSelectionStage,
]);
const handleCreationHubCreateType = useCallback( const handleCreationHubCreateType = useCallback(
(type: PlatformCreationTypeId) => { (type: PlatformCreationTypeId) => {
@@ -789,206 +905,34 @@ export function PlatformEntryFlowShellImpl({
); );
const leaveBigFishFlow = useCallback(() => { const leaveBigFishFlow = useCallback(() => {
setBigFishError(null);
setBigFishRun(null); setBigFishRun(null);
setStreamingBigFishReplyText(''); bigFishFlow.leaveFlow();
setIsStreamingBigFishReply(false); }, [bigFishFlow]);
enterCreateTab();
setSelectionStage('platform');
}, [enterCreateTab, setSelectionStage]);
const leavePuzzleFlow = useCallback(() => { const leavePuzzleFlow = useCallback(() => {
setPuzzleError(null);
setPuzzleOperation(null); setPuzzleOperation(null);
setPuzzleRun(null); setPuzzleRun(null);
setStreamingPuzzleReplyText(''); puzzleFlow.leaveFlow();
setIsStreamingPuzzleReply(false); }, [puzzleFlow]);
enterCreateTab();
setSelectionStage('platform');
}, [enterCreateTab, setSelectionStage]);
const submitBigFishMessage = useCallback( const submitBigFishMessage = bigFishFlow.submitMessage;
async (payload: SendBigFishMessageRequest) => {
if (!bigFishSession || isStreamingBigFishReply) {
return;
}
const optimisticMessage = { const submitPuzzleMessage = puzzleFlow.submitMessage;
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.text.trim(),
createdAt: new Date().toISOString(),
};
setBigFishError(null); const executeBigFishAction = bigFishFlow.executeAction;
setStreamingBigFishReplyText('');
setIsStreamingBigFishReply(true); const executePuzzleAction = puzzleFlow.executeAction;
setBigFishSession((current) =>
current useEffect(() => {
? { if (selectionStage === 'big-fish-result' && !bigFishSession?.draft) {
...current, setSelectionStage(
messages: [...current.messages, optimisticMessage], bigFishSession ? 'big-fish-agent-workspace' : 'platform',
updatedAt: optimisticMessage.createdAt,
}
: current,
); );
}
try { if (selectionStage === 'big-fish-runtime' && !bigFishRun) {
const nextSession = await streamBigFishCreationMessage( setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform');
bigFishSession.sessionId, }
payload, }, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
{
onUpdate: setStreamingBigFishReplyText,
},
);
setBigFishSession(nextSession);
setStreamingBigFishReplyText('');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '发送大鱼吃小鱼共创消息失败。'),
);
} finally {
setIsStreamingBigFishReply(false);
}
},
[bigFishSession, isStreamingBigFishReply, resolveBigFishErrorMessage],
);
const submitPuzzleMessage = useCallback(
async (payload: SendPuzzleAgentMessageRequest) => {
if (!puzzleSession || isStreamingPuzzleReply) {
return;
}
const optimisticMessage = {
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.text.trim(),
createdAt: new Date().toISOString(),
} satisfies PuzzleAgentSessionSnapshot['messages'][number];
setPuzzleError(null);
setStreamingPuzzleReplyText('');
setIsStreamingPuzzleReply(true);
setPuzzleSession((current) =>
current
? {
...current,
messages: [...current.messages, optimisticMessage],
updatedAt: optimisticMessage.createdAt,
}
: current,
);
try {
const nextSession = await streamPuzzleAgentMessage(
puzzleSession.sessionId,
payload,
{
onUpdate: setStreamingPuzzleReplyText,
},
);
setPuzzleSession(nextSession);
setStreamingPuzzleReplyText('');
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '发送拼图共创消息失败。'),
);
} finally {
setIsStreamingPuzzleReply(false);
}
},
[isStreamingPuzzleReply, puzzleSession, resolvePuzzleErrorMessage],
);
const executeBigFishAction = useCallback(
async (payload: ExecuteBigFishActionRequest) => {
if (!bigFishSession || isBigFishBusy) {
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
try {
const { session } = await executeBigFishCreationAction(
bigFishSession.sessionId,
payload,
);
setBigFishSession(session);
if (payload.action === 'big_fish_compile_draft') {
setSelectionStage('big-fish-result');
}
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '执行大鱼吃小鱼操作失败。'),
);
} finally {
setIsBigFishBusy(false);
}
},
[
bigFishSession,
isBigFishBusy,
resolveBigFishErrorMessage,
setSelectionStage,
],
);
const executePuzzleAction = useCallback(
async (payload: PuzzleAgentActionRequest) => {
if (!puzzleSession || isPuzzleBusy) {
return;
}
setIsPuzzleBusy(true);
setPuzzleError(null);
try {
const { operation } = await executePuzzleAgentAction(
puzzleSession.sessionId,
payload,
);
setPuzzleOperation(operation);
if (payload.action === 'publish_puzzle_work') {
await refreshPuzzleShelf();
}
const { session } = await getPuzzleAgentSession(
puzzleSession.sessionId,
);
setPuzzleSession(session);
if (payload.action === 'compile_puzzle_draft') {
setSelectionStage('puzzle-result');
}
if (
payload.action === 'publish_puzzle_work' &&
session.publishedProfileId
) {
const galleryDetail = await getPuzzleGalleryDetail(
session.publishedProfileId,
);
setSelectedPuzzleDetail(galleryDetail.item);
setSelectionStage('puzzle-gallery-detail');
}
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。'));
} finally {
setIsPuzzleBusy(false);
}
},
[
isPuzzleBusy,
puzzleSession,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
setSelectionStage,
],
);
const startBigFishRun = useCallback(async () => { const startBigFishRun = useCallback(async () => {
if (!bigFishSession || isBigFishBusy) { if (!bigFishSession || isBigFishBusy) {
@@ -1327,6 +1271,7 @@ export function PlatformEntryFlowShellImpl({
void deletePuzzleWork(work.profileId) void deletePuzzleWork(work.profileId)
.then((response) => { .then((response) => {
setPuzzleWorks(response.items); setPuzzleWorks(response.items);
void refreshPuzzleGallery();
}) })
.catch((error) => { .catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。')); setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。'));
@@ -1336,7 +1281,12 @@ export function PlatformEntryFlowShellImpl({
}); });
}); });
}, },
[deletingCreationWorkId, resolvePuzzleErrorMessage, runProtectedAction], [
deletingCreationWorkId,
refreshPuzzleGallery,
resolvePuzzleErrorMessage,
runProtectedAction,
],
); );
const openPuzzleDetail = useCallback( const openPuzzleDetail = useCallback(
@@ -1360,80 +1310,28 @@ export function PlatformEntryFlowShellImpl({
const openPuzzleDraft = useCallback( const openPuzzleDraft = useCallback(
async (item: PuzzleWorkSummary) => { async (item: PuzzleWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。');
return;
}
setIsPuzzleBusy(true);
setPuzzleError(null);
setPuzzleOperation(null); setPuzzleOperation(null);
setPuzzleRun(null); setPuzzleRun(null);
setSelectedPuzzleDetail(null); setSelectedPuzzleDetail(null);
setStreamingPuzzleReplyText(''); const restoredSession = await puzzleFlow.restoreDraft(item.sourceSessionId);
setIsStreamingPuzzleReply(false); if (!restoredSession) {
try {
const { session } = await getPuzzleAgentSession(sessionId);
setPuzzleSession(session);
enterCreateTab();
setSelectionStage(session.draft ? 'puzzle-result' : 'puzzle-agent-workspace');
} catch (error) {
await refreshPuzzleShelf().catch(() => undefined); await refreshPuzzleShelf().catch(() => undefined);
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图创作草稿失败。'));
enterCreateTab();
setSelectionStage('platform');
} finally {
setIsPuzzleBusy(false);
} }
}, },
[ [puzzleFlow, refreshPuzzleShelf],
enterCreateTab,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
setSelectionStage,
],
); );
const openBigFishDraft = useCallback( const openBigFishDraft = useCallback(
async (item: BigFishWorkSummary) => { async (item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('这份大鱼吃小鱼草稿缺少会话信息,请重新开始创作。');
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
setBigFishRun(null); setBigFishRun(null);
setStreamingBigFishReplyText(''); const restoredSession = await bigFishFlow.restoreDraft(
setIsStreamingBigFishReply(false); item.sourceSessionId,
);
try { if (!restoredSession) {
const { session } = await getBigFishCreationSession(sessionId);
setBigFishSession(session);
enterCreateTab();
setSelectionStage(
session.draft ? 'big-fish-result' : 'big-fish-agent-workspace',
);
} catch (error) {
await refreshBigFishShelf().catch(() => undefined); await refreshBigFishShelf().catch(() => undefined);
setBigFishError(
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼创作草稿失败。'),
);
enterCreateTab();
setSelectionStage('platform');
} finally {
setIsBigFishBusy(false);
} }
}, },
[ [bigFishFlow, refreshBigFishShelf],
enterCreateTab,
refreshBigFishShelf,
resolveBigFishErrorMessage,
setSelectionStage,
],
); );
const startBigFishRunFromWork = useCallback( const startBigFishRunFromWork = useCallback(
@@ -1450,7 +1348,7 @@ export function PlatformEntryFlowShellImpl({
try { try {
const { session } = await getBigFishCreationSession(sessionId); const { session } = await getBigFishCreationSession(sessionId);
const { run } = await startBigFishRuntimeRun(sessionId); const { run } = await startBigFishRuntimeRun(sessionId);
setBigFishSession(session); bigFishFlow.setSession(session);
setBigFishRun(run); setBigFishRun(run);
setSelectionStage('big-fish-runtime'); setSelectionStage('big-fish-runtime');
} catch (error) { } catch (error) {
@@ -1461,9 +1359,15 @@ export function PlatformEntryFlowShellImpl({
setIsBigFishBusy(false); setIsBigFishBusy(false);
} }
}, },
[resolveBigFishErrorMessage, setSelectionStage], [bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
); );
useEffect(() => {
if (selectionStage === 'platform') {
void refreshPuzzleGallery();
}
}, [refreshPuzzleGallery, selectionStage]);
useEffect(() => { useEffect(() => {
if ( if (
(platformBootstrap.platformTab === 'create' || (platformBootstrap.platformTab === 'create' ||
@@ -1610,7 +1514,7 @@ export function PlatformEntryFlowShellImpl({
saveEntries={platformBootstrap.saveEntries} saveEntries={platformBootstrap.saveEntries}
saveError={platformBootstrap.saveError} saveError={platformBootstrap.saveError}
featuredEntries={featuredGalleryEntries} featuredEntries={featuredGalleryEntries}
latestEntries={platformBootstrap.publishedGalleryEntries} latestEntries={latestGalleryEntries}
myEntries={platformBootstrap.savedCustomWorldEntries} myEntries={platformBootstrap.savedCustomWorldEntries}
historyEntries={platformBootstrap.historyEntries} historyEntries={platformBootstrap.historyEntries}
profileDashboard={platformBootstrap.profileDashboard} profileDashboard={platformBootstrap.profileDashboard}
@@ -1636,6 +1540,11 @@ export function PlatformEntryFlowShellImpl({
onOpenCreateWorld={openCreationTypePicker} onOpenCreateWorld={openCreationTypePicker}
onOpenCreateTypePicker={openCreationTypePicker} onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => { onOpenGalleryDetail={(entry) => {
if (isPuzzleGalleryEntry(entry)) {
void openPuzzleDetail(entry.profileId);
return;
}
runProtectedAction(() => { runProtectedAction(() => {
void detailNavigation.openGalleryDetail(entry); void detailNavigation.openGalleryDetail(entry);
}); });
@@ -1658,6 +1567,7 @@ export function PlatformEntryFlowShellImpl({
void platformBootstrap.refreshProfileDashboard(); void platformBootstrap.refreshProfileDashboard();
} }
}} }}
onRechargeSuccess={platformBootstrap.refreshProfileDashboard}
/> />
</motion.div> </motion.div>
)} )}
@@ -1788,6 +1698,7 @@ export function PlatformEntryFlowShellImpl({
<BigFishAgentWorkspace <BigFishAgentWorkspace
session={bigFishSession} session={bigFishSession}
streamingReplyText={streamingBigFishReplyText} streamingReplyText={streamingBigFishReplyText}
isStreamingReply={isStreamingBigFishReply}
isBusy={isBigFishBusy || isStreamingBigFishReply} isBusy={isBigFishBusy || isStreamingBigFishReply}
error={bigFishError} error={bigFishError}
onBack={leaveBigFishFlow} onBack={leaveBigFishFlow}
@@ -1871,6 +1782,7 @@ export function PlatformEntryFlowShellImpl({
session={puzzleSession} session={puzzleSession}
activeOperation={puzzleOperation} activeOperation={puzzleOperation}
streamingReplyText={streamingPuzzleReplyText} streamingReplyText={streamingPuzzleReplyText}
isStreamingReply={isStreamingPuzzleReply}
isBusy={isPuzzleBusy || isStreamingPuzzleReply} isBusy={isPuzzleBusy || isStreamingPuzzleReply}
error={puzzleError} error={puzzleError}
onBack={leavePuzzleFlow} onBack={leavePuzzleFlow}
@@ -1985,7 +1897,7 @@ export function PlatformEntryFlowShellImpl({
onInterrupt={undefined} onInterrupt={undefined}
backLabel="返回工作区" backLabel="返回工作区"
settingActionLabel={null} settingActionLabel={null}
retryLabel="重新生成草稿" retryLabel="继续生成草稿"
settingTitle="当前世界信息" settingTitle="当前世界信息"
settingDescription={null} settingDescription={null}
progressTitle="世界草稿生成进度" progressTitle="世界草稿生成进度"
@@ -2024,26 +1936,7 @@ export function PlatformEntryFlowShellImpl({
onBack={ onBack={
sessionController.isAgentDraftResultView sessionController.isAgentDraftResultView
? () => { ? () => {
void (async () => { leaveAgentDraftResult();
const currentProfile =
sessionController.generatedCustomWorldProfile;
if (!currentProfile) {
leaveAgentDraftResult();
return;
}
await autosaveCoordinator.syncAgentDraftResultProfile(
currentProfile,
);
leaveAgentDraftResult();
})().catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'返回创作前同步草稿失败。',
),
);
});
} }
: leaveCustomWorldResult : leaveCustomWorldResult
} }

View File

@@ -0,0 +1,294 @@
import { useCallback, useState } from 'react';
import type { TextStreamOptions } from '../../services/aiTypes';
import type { SelectionStage } from './platformEntryTypes';
type CreationAgentMessageLike = {
clientMessageId: string;
text: string;
};
type CreationAgentSessionLike = {
sessionId: string;
draft?: unknown;
messages: Array<{
id: string;
role: string;
kind?: string;
text: string;
createdAt?: string;
}>;
updatedAt?: string;
};
type CreationAgentClientAdapter<
TSession extends CreationAgentSessionLike,
TCreatePayload,
TCreateResponse,
TMessagePayload extends CreationAgentMessageLike,
TActionPayload,
TActionResponse,
> = {
createSession: (payload: TCreatePayload) => Promise<TCreateResponse>;
getSession: (sessionId: string) => Promise<TCreateResponse>;
streamMessage: (
sessionId: string,
payload: TMessagePayload,
options?: TextStreamOptions,
) => Promise<TSession>;
executeAction: (
sessionId: string,
payload: TActionPayload,
) => Promise<TActionResponse>;
selectSession: (response: TCreateResponse) => TSession;
};
type PlatformCreationAgentFlowControllerOptions<
TSession extends CreationAgentSessionLike,
TCreatePayload,
TCreateResponse,
TMessagePayload extends CreationAgentMessageLike,
TActionPayload,
TActionResponse,
> = {
client: CreationAgentClientAdapter<
TSession,
TCreatePayload,
TCreateResponse,
TMessagePayload,
TActionPayload,
TActionResponse
>;
createPayload: TCreatePayload;
workspaceStage: SelectionStage;
resultStage: SelectionStage;
platformStage: SelectionStage;
isCompileAction: (payload: TActionPayload) => boolean;
resolveErrorMessage: (error: unknown, fallback: string) => string;
errorMessages: {
open: string;
restoreMissingSession: string;
restore: string;
submit: string;
execute: string;
};
enterCreateTab: () => void;
setSelectionStage: (stage: SelectionStage) => void;
onSessionOpened?: () => void;
onActionComplete?: (params: {
payload: TActionPayload;
response: TActionResponse;
session: TSession;
setSession: (session: TSession) => void;
}) => Promise<void> | void;
};
function buildOptimisticMessage<TMessagePayload extends CreationAgentMessageLike>(
payload: TMessagePayload,
) {
return {
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.text.trim(),
createdAt: new Date().toISOString(),
};
}
/**
* 轻量作品 Agent 创作流程的通用前端控制器。
* 这里只处理跨玩法一致的会话、流式消息、忙碌态与草稿恢复,玩法结果页和运行态动作留给外层。
*/
export function usePlatformCreationAgentFlowController<
TSession extends CreationAgentSessionLike,
TCreatePayload,
TCreateResponse,
TMessagePayload extends CreationAgentMessageLike,
TActionPayload,
TActionResponse,
>(
options: PlatformCreationAgentFlowControllerOptions<
TSession,
TCreatePayload,
TCreateResponse,
TMessagePayload,
TActionPayload,
TActionResponse
>,
) {
const [session, setSession] = useState<TSession | null>(null);
const [error, setError] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [streamingReplyText, setStreamingReplyText] = useState('');
const [isStreamingReply, setIsStreamingReply] = useState(false);
const openWorkspace = useCallback(async () => {
if (isBusy) {
return;
}
setIsBusy(true);
setError(null);
setStreamingReplyText('');
setIsStreamingReply(false);
try {
const response = await options.client.createSession(options.createPayload);
setSession(options.client.selectSession(response));
options.enterCreateTab();
options.onSessionOpened?.();
options.setSelectionStage(options.workspaceStage);
} catch (caughtError) {
setError(
options.resolveErrorMessage(caughtError, options.errorMessages.open),
);
} finally {
setIsBusy(false);
}
}, [isBusy, options]);
const restoreDraft = useCallback(
async (sessionId: string | null | undefined) => {
const normalizedSessionId = sessionId?.trim();
if (!normalizedSessionId) {
setError(options.errorMessages.restoreMissingSession);
return null;
}
setIsBusy(true);
setError(null);
setStreamingReplyText('');
setIsStreamingReply(false);
try {
const response = await options.client.getSession(normalizedSessionId);
const nextSession = options.client.selectSession(response);
setSession(nextSession);
options.enterCreateTab();
options.setSelectionStage(
nextSession.draft ? options.resultStage : options.workspaceStage,
);
return nextSession;
} catch (caughtError) {
setError(
options.resolveErrorMessage(caughtError, options.errorMessages.restore),
);
options.enterCreateTab();
options.setSelectionStage(options.platformStage);
return null;
} finally {
setIsBusy(false);
}
},
[options],
);
const submitMessage = useCallback(
async (payload: TMessagePayload) => {
if (!session || isStreamingReply) {
return;
}
const optimisticMessage = buildOptimisticMessage(payload);
setError(null);
setStreamingReplyText('');
setIsStreamingReply(true);
setSession((current) =>
current
? {
...current,
messages: [...current.messages, optimisticMessage],
updatedAt: optimisticMessage.createdAt,
}
: current,
);
try {
const nextSession = await options.client.streamMessage(
session.sessionId,
payload,
{
onUpdate: setStreamingReplyText,
},
);
setSession(nextSession);
setStreamingReplyText('');
} catch (caughtError) {
setError(
options.resolveErrorMessage(caughtError, options.errorMessages.submit),
);
} finally {
setIsStreamingReply(false);
}
},
[isStreamingReply, options, session],
);
const executeAction = useCallback(
async (payload: TActionPayload) => {
if (!session || isBusy) {
return;
}
setIsBusy(true);
setError(null);
try {
const response = await options.client.executeAction(
session.sessionId,
payload,
);
await options.onActionComplete?.({
payload,
response,
session,
setSession,
});
if (options.isCompileAction(payload)) {
options.setSelectionStage(options.resultStage);
}
} catch (caughtError) {
setError(
options.resolveErrorMessage(caughtError, options.errorMessages.execute),
);
} finally {
setIsBusy(false);
}
},
[isBusy, options, session],
);
const leaveFlow = useCallback(() => {
setError(null);
setStreamingReplyText('');
setIsStreamingReply(false);
options.enterCreateTab();
options.setSelectionStage(options.platformStage);
}, [options]);
const resetTransientState = useCallback(() => {
setError(null);
setStreamingReplyText('');
setIsStreamingReply(false);
}, []);
return {
session,
setSession,
error,
setError,
isBusy,
setIsBusy,
streamingReplyText,
setStreamingReplyText,
isStreamingReply,
setIsStreamingReply,
openWorkspace,
restoreDraft,
submitMessage,
executeAction,
leaveFlow,
resetTransientState,
};
}

Some files were not shown because too many files have changed in this diff Show More