diff --git a/docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md b/docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md index 870e6058..05ceef6a 100644 --- a/docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md +++ b/docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md @@ -6,7 +6,7 @@ 当前自定义世界创作工具已经有了比较强的生成骨架、锚点结构和结果编辑能力,但整体仍处在一个很明显的“半收口状态”: -**设计目标已经走到“创作者工作台”,数据结构已经支持“锚点化输入”,但实际体验仍然更像“大文本生成器 + 大型结果总表编辑器”。** +**设计目标已经走到“陶泥主工作台”,数据结构已经支持“锚点化输入”,但实际体验仍然更像“大文本生成器 + 大型结果总表编辑器”。** 如果用一句话概括当前问题,就是: @@ -61,7 +61,7 @@ - 标志性要素 - 禁止事项 -但实际入口 `src/components/SelectionCustomizationModals.tsx` 里,创作者弹窗仍然基本只有: +但实际入口 `src/components/SelectionCustomizationModals.tsx` 里,陶泥主弹窗仍然基本只有: - 生成模式 - 一块大 textarea @@ -82,7 +82,7 @@ --- -## 2.2 澄清机制已经存在,但没有真正服务创作者 +## 2.2 澄清机制已经存在,但没有真正服务陶泥主 `server-node/src/services/customWorldSessionStore.ts` 已经支持: @@ -101,7 +101,7 @@ 这意味着: -**系统表面上已经有“先澄清再生成”的能力,但实际体验里,创作者并没有真正参与这一步。** +**系统表面上已经有“先澄清再生成”的能力,但实际体验里,陶泥主并没有真正参与这一步。** 结果就是: @@ -113,7 +113,7 @@ - 把 session question 真正接到前端,作为生成前的二次确认步骤。 - 每次只问 `1~3` 个最关键问题,不要把它做成问卷。 -- 支持“一键使用系统建议”,但必须让创作者可见,而不是静默自动填充。 +- 支持“一键使用系统建议”,但必须让陶泥主可见,而不是静默自动填充。 - 把回答结果回写到 `creatorIntent`,而不是只作为一次性会话答案。 --- @@ -175,7 +175,7 @@ 这会带来三层问题: -1. 创作者负担过重 +1. 陶泥主负担过重 - 很多字段属于“系统编译层”,不属于“创作决策层”。 2. 移动端负担过重 @@ -240,7 +240,7 @@ --- -## 2.6 快速模式还不够“快”,生成页也还不够“创作者视角” +## 2.6 快速模式还不够“快”,生成页也还不够“陶泥主视角” 当前快速模式的主要区别,是把数量降成: @@ -269,7 +269,7 @@ - 计时 - 模型阶段 -而不是创作者真正关心的: +而不是陶泥主真正关心的: - 关键角色有没有成型 - 核心冲突有没有稳定 @@ -281,7 +281,7 @@ - 快速模式改成真正的“关键锚点预览模式”: - 先只生成关键角色、关键地点、核心冲突摘要 - 暂不补全所有长尾档案 -- 生成页改成“创作者视角进度”: +- 生成页改成“陶泥主视角进度”: - 世界灵魂已确定 - 关键角色已成型 - 关键地点已落地 @@ -391,16 +391,16 @@ ### P0:先修主链路闭环 - 补卡片化输入入口,至少把关键锚点输入真正开放出来。 -- 把澄清问题正式接入创作者流程,不再静默自动兜底。 +- 把澄清问题正式接入陶泥主流程,不再静默自动兜底。 - 修正“新建完成后直接回世界列表”的流程,生成后默认进入结果工作台。 -- 统一锁定与局部重生成规则,先让“创作者不怕重生成”成立。 +- 统一锁定与局部重生成规则,先让“陶泥主不怕重生成”成立。 ### P1:再降低工作台负担 - 结果页默认只展示高杠杆编辑。 - 低杠杆字段进入高级模式。 - 快速模式改成真正的关键对象预览模式。 -- 生成页改成创作者视角进度,而不是模型批次视角。 +- 生成页改成陶泥主视角进度,而不是模型批次视角。 ### P2:最后做架构收口与去模板化 @@ -441,4 +441,4 @@ 当前自定义世界创作工具最需要的,不是再继续补更多字段或更多生成步骤,而是: -**把“创作者先决定灵魂锚点,系统再稳定展开世界”这条主逻辑真正落到 UI、流程和后端边界上。** +**把“陶泥主先决定灵魂锚点,系统再稳定展开世界”这条主逻辑真正落到 UI、流程和后端边界上。** diff --git a/docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md b/docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md index dfb04ed9..e46a679c 100644 --- a/docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md +++ b/docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md @@ -1,4 +1,4 @@ -# 自定义世界创作者输入与 AI 分工边界设计 +# 自定义世界陶泥主输入与 AI 分工边界设计 更新时间:`2026-04-06` @@ -6,9 +6,9 @@ 这份文档回答一个非常关键的问题: -**在“低创作门槛、高创作自由度”的前提下,自定义世界里哪些内容应该交给创作者直接定义,哪些内容应该交给 AI 和系统完成。** +**在“低创作门槛、高创作自由度”的前提下,自定义世界里哪些内容应该交给陶泥主直接定义,哪些内容应该交给 AI 和系统完成。** -这里默认我们的创作者: +这里默认我们的陶泥主: - 不需要有专业作家背景 - 不需要有专业游戏设计背景 @@ -16,33 +16,33 @@ 一句话目标: -**让创作者把精力放在“决定这个世界为什么值得被创作”,把 AI 用在“把这个世界展开、编译、铺开、校验、补足”。** +**让陶泥主把精力放在“决定这个世界为什么值得被创作”,把 AI 用在“把这个世界展开、编译、铺开、校验、补足”。** ## 1. 总体结论 自定义世界的分工边界应该遵守 3 条硬原则: -1. 灵魂归创作者,杂活归 AI。 - - 凡是决定作品气质、主题、冲突、人物关系、审美方向的内容,都应由创作者掌握。 +1. 灵魂归陶泥主,杂活归 AI。 + - 凡是决定作品气质、主题、冲突、人物关系、审美方向的内容,都应由陶泥主掌握。 -2. 重点对象归创作者,长尾铺量归 AI。 - - 创作者应重点塑造少量关键角色、关键地点、关键冲突、关键意象,而不是被迫手填几十个 NPC、几十个场景、几百条描述。 +2. 重点对象归陶泥主,长尾铺量归 AI。 + - 陶泥主应重点塑造少量关键角色、关键地点、关键冲突、关键意象,而不是被迫手填几十个 NPC、几十个场景、几百条描述。 -3. 决策归创作者,编译归 AI / 系统。 - - 创作者负责说“这个世界要成为什么样”,AI / 系统负责把它编译成可运行的数据、规则、文本、关系钩子和运行时结构。 +3. 决策归陶泥主,编译归 AI / 系统。 + - 陶泥主负责说“这个世界要成为什么样”,AI / 系统负责把它编译成可运行的数据、规则、文本、关系钩子和运行时结构。 这意味着: -- 创作者应该主要编辑“高杠杆创作锚点” +- 陶泥主应该主要编辑“高杠杆创作锚点” - AI 应该主要承担“批量展开 + 结构编译 + 一致性维护 + 专业执行” -## 2. 什么内容应该交给创作者 +## 2. 什么内容应该交给陶泥主 -真正应该交给创作者的,不是大量表格字段,而是下面这些会显著决定作品质量、且 AI 不擅长替代的内容。 +真正应该交给陶泥主的,不是大量表格字段,而是下面这些会显著决定作品质量、且 AI 不擅长替代的内容。 ## 2.1 世界核心命题 -创作者应该直接定义: +陶泥主应该直接定义: - 这个世界的一句话设定 - 这个世界最吸引人的核心幻想 @@ -56,7 +56,7 @@ ## 2.2 主题、气质与边界 -创作者应该直接定义: +陶泥主应该直接定义: - 主题关键词 - 情绪基调 @@ -71,7 +71,7 @@ ## 2.3 玩家身份与开局处境 -创作者应该直接定义: +陶泥主应该直接定义: - 玩家扮演的是什么人 - 玩家一开始最缺什么、最想要什么 @@ -85,7 +85,7 @@ ## 2.4 核心冲突与关键势力 -创作者应该直接定义少量高价值内容: +陶泥主应该直接定义少量高价值内容: - 世界当前最重要的 `2~4` 条明面冲突 - 世界背后最关键的 `1~3` 条暗面问题 @@ -96,13 +96,13 @@ - 冲突结构决定世界是否“有戏” - 势力关系是 AI 最容易写散、写平、写成百科介绍的部分 -- 这一层由创作者把握,才能真正提高作品的辨识度 +- 这一层由陶泥主把握,才能真正提高作品的辨识度 ## 2.5 关键角色与关系张力 -创作者应该直接定义少量关键角色,而不是所有 NPC。 +陶泥主应该直接定义少量关键角色,而不是所有 NPC。 -建议重点交给创作者的,是: +建议重点交给陶泥主的,是: - `3~8` 个关键角色 - 玩家与这些人的潜在关系 @@ -113,11 +113,11 @@ - 角色关系是最能显著提升作品质量的部分之一 - 这也是 AI 最容易写得“完整但无味”的部分 -- 创作者不需要写长篇背景,但应掌握这些角色真正的关系骨架 +- 陶泥主不需要写长篇背景,但应掌握这些角色真正的关系骨架 ## 2.6 关键地点与空间记忆点 -创作者应该直接定义: +陶泥主应该直接定义: - `4~12` 个关键地点 / 区域 / 地标 - 这些地方为什么重要 @@ -131,7 +131,7 @@ ## 2.7 标志性意象、物件、怪物、制度与规则 -创作者应该优先控制世界里最能代表它的东西: +陶泥主应该优先控制世界里最能代表它的东西: - 标志性物件 - 标志性怪物 / 生物 @@ -144,9 +144,9 @@ - 这些内容决定世界的“手感” - 它们不是普通细节,而是会反复影响命名、剧情、视觉、对话与玩法解释的母题 -## 2.8 创作者应直接控制的“禁止事项” +## 2.8 陶泥主应直接控制的“禁止事项” -创作者必须能明确锁定: +陶泥主必须能明确锁定: - 什么绝对不能改 - 什么不能被 AI 自动扩写到别的方向 @@ -156,7 +156,7 @@ 原因: - 高自由度不等于所有内容都开放漂移 -- 如果没有“锁定机制”,AI 会把创作者真正关心的内容稀释掉 +- 如果没有“锁定机制”,AI 会把陶泥主真正关心的内容稀释掉 ## 3. 什么内容应该交给 AI 和系统 @@ -176,7 +176,7 @@ 原因: - 这些内容数量大、重复度高 -- 它们需要“贴合世界”,但不需要都由创作者逐个手写 +- 它们需要“贴合世界”,但不需要都由陶泥主逐个手写 - AI 很适合做“围绕锚点的批量铺量” ## 3.2 从创作锚点到系统结构的编译 @@ -186,7 +186,7 @@ - 从自然语言世界设定中提取题材词汇 - 从关键冲突中编译出世界叙事图谱 - 从关键角色卡编译出角色叙事档案 -- 从创作者输入里自动生成标签、钩子、隐藏线索、章节摘要 +- 从陶泥主输入里自动生成标签、钩子、隐藏线索、章节摘要 - 从地点和关系中编译出场景连接、事件触发和叙事回响 对应当前仓库,下面这些结构更适合由 AI / 系统生成,而不是让玩家直接编辑: @@ -203,7 +203,7 @@ 原因: -- 这些是运行时结构,不是创作者真正想表达的作品内容 +- 这些是运行时结构,不是陶泥主真正想表达的作品内容 - 直接暴露给玩家,会把创作过程变成专业数据填表 ## 3.3 专业化、规则化的任务 @@ -223,7 +223,7 @@ 原因: - 这些工作要么重复、要么专业、要么容易做脏活累活 -- 让非专业创作者处理,会显著提高门槛,却不一定显著提高质量 +- 让非专业陶泥主处理,会显著提高门槛,却不一定显著提高质量 ## 3.4 一致性、纠错与查漏补缺 @@ -240,15 +240,15 @@ 原因: - 这是 AI 比人更适合做的“维护型工作” -- 它属于创作支持,不属于创作者必须亲手完成的创作 +- 它属于创作支持,不属于陶泥主必须亲手完成的创作 ## 4. 最合理的边界不是二分法,而是三层分工 自定义世界最合理的结构,不是“玩家写”与“AI 写”的简单二选一,而是三层。 -## 4.1 第一层:创作者必控层 +## 4.1 第一层:陶泥主必控层 -这一层必须给创作者高自由度,且能被锁定: +这一层必须给陶泥主高自由度,且能被锁定: - 世界核心命题 - 主题与气质 @@ -264,9 +264,9 @@ **少而重。** -## 4.2 第二层:创作者可选强化层 +## 4.2 第二层:陶泥主可选强化层 -这一层不应强制填写,但应该允许创作者继续深挖: +这一层不应强制填写,但应该允许陶泥主继续深挖: - 明线 / 暗线种子 - 角色之间的旧事 @@ -301,17 +301,17 @@ ## 5. 具体模块的建议归属 -| 模块 | 建议归属 | 创作者应控制什么 | AI / 系统应负责什么 | +| 模块 | 建议归属 | 陶泥主应控制什么 | AI / 系统应负责什么 | | --- | --- | --- | --- | -| 世界一句话设定、核心幻想、核心卖点 | 创作者直接控制 | 直接写、直接改、可锁定 | 给出备选表述和扩展方向 | -| 主题、基调、审美、禁忌 | 创作者直接控制 | 选择 / 改写 / 锁定 | 生成风格词、避雷词、提示词约束 | -| 玩家身份、开局处境、玩家目标 | 创作者直接控制 | 直接定义 | 补足开局钩子和初始叙事包装 | -| 关键势力与核心冲突 | 创作者主控,AI 辅助 | 定义核心关系和立场 | 扩展冲突支路、生成世界线程 | -| 关键角色 | 创作者主控,AI 辅助 | 定义角色骨架、关系张力、秘密方向 | 生成长背景、章节拆分、技能、物品、叙事档案 | -| 关键地点 | 创作者主控,AI 辅助 | 定义地点意义、气氛、秘密 | 扩展场景细节、连接关系、遭遇分布 | -| 标志性物件 / 怪物 / 制度 / 规则 | 创作者主控,AI 辅助 | 定义代表性要素与硬边界 | 扩展变体、命名、说明、运行时挂钩 | +| 世界一句话设定、核心幻想、核心卖点 | 陶泥主直接控制 | 直接写、直接改、可锁定 | 给出备选表述和扩展方向 | +| 主题、基调、审美、禁忌 | 陶泥主直接控制 | 选择 / 改写 / 锁定 | 生成风格词、避雷词、提示词约束 | +| 玩家身份、开局处境、玩家目标 | 陶泥主直接控制 | 直接定义 | 补足开局钩子和初始叙事包装 | +| 关键势力与核心冲突 | 陶泥主控,AI 辅助 | 定义核心关系和立场 | 扩展冲突支路、生成世界线程 | +| 关键角色 | 陶泥主控,AI 辅助 | 定义角色骨架、关系张力、秘密方向 | 生成长背景、章节拆分、技能、物品、叙事档案 | +| 关键地点 | 陶泥主控,AI 辅助 | 定义地点意义、气氛、秘密 | 扩展场景细节、连接关系、遭遇分布 | +| 标志性物件 / 怪物 / 制度 / 规则 | 陶泥主控,AI 辅助 | 定义代表性要素与硬边界 | 扩展变体、命名、说明、运行时挂钩 | | 普通 NPC / 路人 / 杂兵 / 次级地点 | 主要交给 AI | 仅在需要时抽查或替换 | 批量生成与风格保持 | -| 角色长背景、章节 teaser、context snippet | 主要交给 AI | 创作者只改关键角色即可 | 自动拆章、压缩、解锁节奏整理 | +| 角色长背景、章节 teaser、context snippet | 主要交给 AI | 陶泥主只改关键角色即可 | 自动拆章、压缩、解锁节奏整理 | | 技能、初始物品、标签、构筑倾向 | 主要交给 AI / 系统 | 提供偏好或少量 override | 按角色和世界规则自动编译 | | 世界图谱、知识事实、可见性、导演指令 | AI / 系统内部层 | 不应默认暴露给玩家 | 运行时编译与维护 | | 一致性检查、冲突检查、越权检查 | AI / 系统内部层 | 查看报告、决定是否采纳修改 | 自动扫描并提出修正建议 | @@ -328,7 +328,7 @@ - 精确数值型 build 倾向 - 复杂掉落预算 -更合理的做法是让创作者填写直觉表达,例如: +更合理的做法是让陶泥主填写直觉表达,例如: - `初见就戒备` - `容易合作` @@ -351,7 +351,7 @@ 原因: -- 这些字段属于系统运行结构,不属于创作者自然的创作语言 +- 这些字段属于系统运行结构,不属于陶泥主自然的创作语言 - 直接让玩家填,会把工具变成只有懂系统的人才能用 ## 6.3 不应该要求玩家逐个补完所有人物设定字段 @@ -378,7 +378,7 @@ ## 7. 推荐的创作输入形态 -要让非专业创作者也能高自由度创作,输入形态必须改成“自然语言创作卡”,而不是“系统字段表单”。 +要让非专业陶泥主也能高自由度创作,输入形态必须改成“自然语言创作卡”,而不是“系统字段表单”。 ## 7.1 世界层卡片 @@ -397,10 +397,10 @@ ## 7.2 每张卡片都允许 3 种输入方式 1. 一句话自由输入 - - 适合低门槛创作者 + - 适合低门槛陶泥主 2. 标签 / 选项 / 语气滑条 - - 适合不想写太多字的创作者 + - 适合不想写太多字的陶泥主 3. 高级补充 - 适合愿意继续深挖的人 @@ -414,7 +414,7 @@ 这是高创作自由度里非常关键的一点。 -创作者应当能: +陶泥主应当能: - 锁定一个角色 - 锁定一个地点 @@ -422,13 +422,13 @@ - 只重生成未锁定部分 - 围绕锁定内容重写其余世界 -否则创作者每次调用 AI,都会有“好不容易想好的东西被洗掉”的感受。 +否则陶泥主每次调用 AI,都会有“好不容易想好的东西被洗掉”的感受。 ## 8. 面向当前仓库的结构映射建议 为了便于后续落实现有系统,这份边界建议可以直接映射到当前结构: -## 8.1 创作者输入层 +## 8.1 陶泥主输入层 建议主要映射到: @@ -445,7 +445,7 @@ ## 8.2 AI 编译层 -由 AI / 系统从创作者输入自动补出: +由 AI / 系统从陶泥主输入自动补出: - `themePack` - `storyGraph` @@ -465,7 +465,7 @@ - `CarrierStoryFingerprint` - `StorySignal` -这些内容应该是“系统如何把世界跑起来”,不是“创作者必须亲手写完的创作内容”。 +这些内容应该是“系统如何把世界跑起来”,不是“陶泥主必须亲手写完的创作内容”。 ## 9. 产品层面的最终结论 @@ -480,12 +480,12 @@ 它应该做成这样: -1. 创作者决定世界的灵魂锚点。 -2. 创作者重点塑造少量关键人、关键地、关键冲突、关键物。 +1. 陶泥主决定世界的灵魂锚点。 +2. 陶泥主重点塑造少量关键人、关键地、关键冲突、关键物。 3. AI 围绕这些锚点批量展开长尾内容。 4. 系统把这些内容编译成可运行的图谱、可见性、任务、物件和关系结构。 -5. 创作者随时可以锁定核心创意,并局部重生成其余部分。 +5. 陶泥主随时可以锁定核心创意,并局部重生成其余部分。 一句话收束: -**创作者应该写“这个世界为什么动人”,AI 应该负责“让这个世界长出来并跑起来”。** +**陶泥主应该写“这个世界为什么动人”,AI 应该负责“让这个世界长出来并跑起来”。** diff --git a/docs/design/CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md b/docs/design/CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md index 4ba249a7..f4024bd5 100644 --- a/docs/design/CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md +++ b/docs/design/CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md @@ -6,17 +6,17 @@ 这份文档用于回答一个更具体的问题: -**参考 RPG 专业剧情策划全流程后,在自定义世界创作工具里,哪些设定必须要求创作者手动填写,哪些设定应该由 AI 先生成但允许创作者修改,哪些设定应完全交给系统托管,才能在“尽可能降低门槛”和“尽可能提高作品质量”之间取一个平衡。** +**参考 RPG 专业剧情策划全流程后,在自定义世界创作工具里,哪些设定必须要求陶泥主手动填写,哪些设定应该由 AI 先生成但允许陶泥主修改,哪些设定应完全交给系统托管,才能在“尽可能降低门槛”和“尽可能提高作品质量”之间取一个平衡。** -这份文档不再只回答“创作者与 AI 怎么分工”,而是进一步把创作工作台收束成一个更可执行的三层输入结构: +这份文档不再只回答“陶泥主与 AI 怎么分工”,而是进一步把创作工作台收束成一个更可执行的三层输入结构: -1. 创作者必须手填的高杠杆锚点 -2. AI 先生成、创作者可修改的内容草稿层 +1. 陶泥主必须手填的高杠杆锚点 +2. AI 先生成、陶泥主可修改的内容草稿层 3. 系统自动编译和运行的托管层 一句话结论: -**让创作者只负责决定作品的灵魂、视角、冲突和关系钩子,让 AI 负责把这些锚点展开成可编辑的剧情草稿,让系统负责把草稿编译成可运行的结构。** +**让陶泥主只负责决定作品的灵魂、视角、冲突和关系钩子,让 AI 负责把这些锚点展开成可编辑的剧情草稿,让系统负责把草稿编译成可运行的结构。** --- @@ -25,27 +25,27 @@ 这套平衡设计要同时满足 5 个目标: 1. 低门槛 - - 新创作者不需要写长篇设定,也不需要理解底层系统结构。 + - 新陶泥主不需要写长篇设定,也不需要理解底层系统结构。 2. 高辨识度 - - 创作者写出来的世界,不应该只是“像一个世界”,而应该保留明显的个人方向。 + - 陶泥主写出来的世界,不应该只是“像一个世界”,而应该保留明显的个人方向。 3. 高可编辑性 - - AI 不能一次生成后就不可控,创作者必须能改关键对象、关键关系和关键章节。 + - AI 不能一次生成后就不可控,陶泥主必须能改关键对象、关键关系和关键章节。 4. 高稳定性 - - 任务、章节、关系、物件和可见性等运行层结构不能依赖创作者手填专业字段。 + - 任务、章节、关系、物件和可见性等运行层结构不能依赖陶泥主手填专业字段。 5. 可扩展 - - 愿意深挖的创作者可以继续补充世界上限,不愿深挖的人也能快速产出质量不错的作品。 + - 愿意深挖的陶泥主可以继续补充世界上限,不愿深挖的人也能快速产出质量不错的作品。 --- ## 2. 核心原则 -## 2.1 创作者手填的必须是“高杠杆决策”,不是“高工作量字段” +## 2.1 陶泥主手填的必须是“高杠杆决策”,不是“高工作量字段” -应该要求创作者手填的内容,必须同时满足下面两个条件: +应该要求陶泥主手填的内容,必须同时满足下面两个条件: 1. 会显著决定作品气质和辨识度 2. AI 很难替代判断 @@ -67,9 +67,9 @@ - 章节拆分 - 运行时信号结构 -## 2.2 创作者可改层应该承接“专业策划初稿”,而不是“原始底层字段” +## 2.2 陶泥主可改层应该承接“专业策划初稿”,而不是“原始底层字段” -AI 生成后允许创作者修改的,不应该是一堆技术型字段,而应该是一批已经成形的内容卡片,例如: +AI 生成后允许陶泥主修改的,不应该是一堆技术型字段,而应该是一批已经成形的内容卡片,例如: - 关键角色卡 - 势力卡 @@ -81,11 +81,11 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 也就是说: -**AI 先给创作者一个像策划初稿的东西,而不是给一堆系统字段让创作者自己拼。** +**AI 先给陶泥主一个像策划初稿的东西,而不是给一堆系统字段让陶泥主自己拼。** ## 2.3 系统托管层必须彻底隐藏专业运行结构 -以下这类结构不应该默认要求创作者理解或编辑: +以下这类结构不应该默认要求陶泥主理解或编辑: - `ThemePack` - `WorldStoryGraph` @@ -98,7 +98,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 - 稀有度映射 - 掉落和 build 权重 -创作者应该编辑的是自然语言与内容卡,而不是运行时图结构。 +陶泥主应该编辑的是自然语言与内容卡,而不是运行时图结构。 ## 2.4 先少量必填,再逐层展开 @@ -107,9 +107,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 ```text 先填最小必填卡 -> AI 生成世界初稿 --> 创作者修改关键对象 +-> 陶泥主修改关键对象 -> 系统继续展开长尾 --> 创作者决定是否进入高级补充 +-> 陶泥主决定是否进入高级补充 ``` ## 2.5 默认清爽,深度能力后置 @@ -127,27 +127,27 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 ## 3. 最终建议:三层分工 -## 3.1 第一层:必须要求创作者手动填写 +## 3.1 第一层:必须要求陶泥主手动填写 这一层只保留最影响作品质量的高杠杆锚点,建议默认强制填写 6 张卡。 -## 3.2 第二层:AI 生成后支持创作者修改 +## 3.2 第二层:AI 生成后支持陶泥主修改 -这一层由 AI 根据第一层锚点自动展开成专业剧情策划初稿,创作者可以逐项修改、锁定、局部重生成。 +这一层由 AI 根据第一层锚点自动展开成专业剧情策划初稿,陶泥主可以逐项修改、锁定、局部重生成。 ## 3.3 第三层:其余都交给系统 -这一层是把前两层编译成可运行游戏结构所需的系统字段、数值和运行时指令,默认不要求创作者处理。 +这一层是把前两层编译成可运行游戏结构所需的系统字段、数值和运行时指令,默认不要求陶泥主处理。 --- ## 4. 最低门槛方案:只强制手填 6 张卡 -如果目标是尽可能降低门槛,同时又保留作品辨识度,建议只强制创作者填写以下 6 张卡。 +如果目标是尽可能降低门槛,同时又保留作品辨识度,建议只强制陶泥主填写以下 6 张卡。 ## 4.1 卡 1:世界一句话与核心幻想 -创作者必须手填: +陶泥主必须手填: - 世界一句话设定 - 玩家来到这个世界最想体验的感觉 @@ -165,7 +165,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 ## 4.2 卡 2:玩家身份与开局困境 -创作者必须手填: +陶泥主必须手填: - 玩家是谁 - 玩家开局最缺什么 @@ -179,7 +179,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 ## 4.3 卡 3:主题气质与禁忌边界 -创作者必须手填: +陶泥主必须手填: - 主题关键词 - 情绪基调 @@ -199,7 +199,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 ## 4.4 卡 4:核心冲突 -创作者必须手填: +陶泥主必须手填: - 当前世界最重要的 `1~3` 个明面冲突 - 至少 `1` 个隐藏问题或暗面危机 @@ -212,9 +212,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 ## 4.5 卡 5:关键关系钩子 -这里不强制创作者一开始填写完整角色档案,只要求填写更高杠杆的“关系骨架”。 +这里不强制陶泥主一开始填写完整角色档案,只要求填写更高杠杆的“关系骨架”。 -创作者必须手填: +陶泥主必须手填: - `2~4` 条关键关系钩子 - 每条钩子至少说明: @@ -229,7 +229,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 ## 4.6 卡 6:标志性要素与硬规则 -创作者必须手填: +陶泥主必须手填: - `2~5` 个标志性要素 - 物件 @@ -247,11 +247,11 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 --- -## 5. 不建议强制手填,但应该让 AI 生成后支持创作者修改的设定 +## 5. 不建议强制手填,但应该让 AI 生成后支持陶泥主修改的设定 这一层是平衡“低门槛”和“高质量”的关键。 -创作者不需要从零填写这些内容,但 AI 生成后必须能看、能改、能锁定、能局部重生成。 +陶泥主不需要从零填写这些内容,但 AI 生成后必须能看、能改、能锁定、能局部重生成。 ## 5.1 世界外观层 @@ -282,7 +282,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: - 势力很重要,但让新手一开始手写完整势力表太重 -- 更合理的做法是让 AI 基于核心冲突先出草稿,再由创作者修正 +- 更合理的做法是让 AI 基于核心冲突先出草稿,再由陶泥主修正 ## 5.3 关键角色层 @@ -302,8 +302,8 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: -- 创作者已经通过“关系钩子”给出最关键的人物骨架 -- AI 负责把钩子展开成可编辑角色卡,创作者再做精修 +- 陶泥主已经通过“关系钩子”给出最关键的人物骨架 +- AI 负责把钩子展开成可编辑角色卡,陶泥主再做精修 ## 5.4 关键地点层 @@ -319,7 +319,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: - 地点是世界感的重要来源 -- 但新创作者未必能一开始就写出完整地点网络 +- 但新陶泥主未必能一开始就写出完整地点网络 ## 5.5 世界线程层 @@ -335,7 +335,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: - 线程是专业剧情结构,适合 AI 先搭骨架 -- 但创作者必须有权修正哪条线更重要、哪条线该隐藏 +- 但陶泥主必须有权修正哪条线更重要、哪条线该隐藏 ## 5.6 主线章节层 @@ -350,9 +350,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: -- 创作者已经给出了世界目标、冲突和关系 +- 陶泥主已经给出了世界目标、冲突和关系 - AI 可以先把它们编成主线章节初稿 -- 创作者再选择保留、删减或重排 +- 陶泥主再选择保留、删减或重排 ## 5.7 支线、角色线、阵营线层 @@ -367,7 +367,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: - 这是最适合 AI 拉开内容宽度的部分 -- 也是最需要创作者局部精修的部分 +- 也是最需要陶泥主局部精修的部分 ## 5.8 场景章节层 @@ -384,7 +384,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: - 当前项目已经在走“场景 = 章节单元”的方向 -- 这层非常适合 AI 编排出第一版,再由创作者补强记忆点 +- 这层非常适合 AI 编排出第一版,再由陶泥主补强记忆点 ## 5.9 叙事载体层 @@ -397,7 +397,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 - 场景遗物 - 怪物命名及其故事指向 -创作者主要修改: +陶泥主主要修改: - 哪些载体最重要 - 哪些载体和哪条线程绑定 @@ -417,13 +417,13 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: - 这些内容适合 AI 批量铺量 -- 创作者只需要挑、改、锁定,不必从零起草 +- 陶泥主只需要挑、改、锁定,不必从零起草 --- ## 6. 其余设定应交给系统托管 -以下内容不建议默认暴露给创作者编辑,应由系统根据前两层自动编译和维护。 +以下内容不建议默认暴露给陶泥主编辑,应由系统根据前两层自动编译和维护。 ## 6.1 题材与术语编译层 @@ -450,7 +450,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: -- 创作者要的是“故事线能对”,不是维护图数据库 +- 陶泥主要的是“故事线能对”,不是维护图数据库 ## 6.3 可见性和 prompt 裁剪层 @@ -465,7 +465,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 原因: - 这层必须稳定、严格、自动化 -- 不适合依赖创作者手动维护 +- 不适合依赖陶泥主手动维护 ## 6.4 运行时导演层 @@ -494,7 +494,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 说明: -- 创作者可以编辑“任务卡”和“章节卡” +- 陶泥主可以编辑“任务卡”和“章节卡” - 但不应默认编辑底层 contract 结构 ## 6.6 数值与配置层 @@ -511,7 +511,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 说明: -- 创作者可以给“偏向” +- 陶泥主可以给“偏向” - 系统负责编译成具体数值 ## 6.7 QA 与一致性层 @@ -547,7 +547,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 | 主线 | 不强制首轮手写完整主线 | 幕结构、章节卡、高潮与 handoff | 章节状态编译 | | 支线/角色线 | 不强制首轮手写完整矩阵 | 支线种子、角色线事件、阵营线分歧 | 任务 contract 编译 | | 场景章节 | 不强制首轮手写全量章节 | 场景章节卡、阶段内容、章节载体 | signal 与导演层 | -| 运行时结构 | 不建议创作者接触 | 不建议默认编辑 | 可见性、导演、信号、编译、QA | +| 运行时结构 | 不建议陶泥主接触 | 不建议默认编辑 | 可见性、导演、信号、编译、QA | --- @@ -555,7 +555,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 ## 8.1 第一步:只填写最小必填集 -创作者只需要完成: +陶泥主只需要完成: 1. 世界一句话与核心幻想 2. 玩家身份与开局困境 @@ -584,9 +584,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 这里的重点不是一次补满全世界,而是先形成一个像样的内容骨架。 -## 8.3 第三步:创作者只精修高价值卡片 +## 8.3 第三步:陶泥主只精修高价值卡片 -建议默认优先让创作者编辑这 4 类卡片: +建议默认优先让陶泥主编辑这 4 类卡片: 1. 关键角色 2. 核心冲突与线程 @@ -606,7 +606,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 - 任务包装 - 文案变体 -## 8.5 第五步:创作者按需进入高级模式 +## 8.5 第五步:陶泥主按需进入高级模式 高级模式只对愿意深挖的人开放: @@ -665,7 +665,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 ## 10.2 每张卡只保留自然语言输入 -不要强迫创作者在首轮填写: +不要强迫陶泥主在首轮填写: - tags - ids @@ -676,20 +676,20 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而 更合理的做法是: -- 让创作者输入自然语言或选择直觉标签 +- 让陶泥主输入自然语言或选择直觉标签 - 再由系统编译成结构化字段 ## 10.3 首轮生成后默认先看“精修建议” -AI 初稿生成后,不应该把创作者直接扔进一个大编辑器。 +AI 初稿生成后,不应该把陶泥主直接扔进一个大编辑器。 更好的做法是先给出: 1. 哪些卡片最值得改 2. 哪些内容已经比较稳定 -3. 哪些内容仍然偏泛,需要创作者补个性 +3. 哪些内容仍然偏泛,需要陶泥主补个性 -这样能明显提高创作者的修改效率。 +这样能明显提高陶泥主的修改效率。 ## 10.4 移动端优先只保留高杠杆操作 @@ -707,15 +707,15 @@ AI 初稿生成后,不应该把创作者直接扔进一个大编辑器。 ## 11. 最后结论 -如果目标是在自定义世界创作中真正平衡“降低门槛”和“提高作品质量”,最好的做法不是让创作者填更多字段,也不是把一切都交给 AI。 +如果目标是在自定义世界创作中真正平衡“降低门槛”和“提高作品质量”,最好的做法不是让陶泥主填更多字段,也不是把一切都交给 AI。 更合理的平衡是: -1. 创作者必须手填最小但高杠杆的 6 张卡,掌握世界灵魂。 +1. 陶泥主必须手填最小但高杠杆的 6 张卡,掌握世界灵魂。 2. AI 根据这 6 张卡生成一套可编辑的专业剧情初稿,负责把骨架展开成角色、地点、线程、章节和载体。 -3. 创作者只精修最有价值的关键对象,锁定真正重要的内容。 +3. 陶泥主只精修最有价值的关键对象,锁定真正重要的内容。 4. 其余运行结构、数值、可见性、任务编译和 QA 检查都交给系统托管。 一句话收束: -**创作者负责决定“这个世界为什么值得被创作”,AI 负责把它整理成可修改的策划初稿,系统负责把它稳定地跑成一个游戏世界。** +**陶泥主负责决定“这个世界为什么值得被创作”,AI 负责把它整理成可修改的策划初稿,系统负责把它稳定地跑成一个游戏世界。** diff --git a/docs/design/CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md b/docs/design/CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md index 126d7639..3280b823 100644 --- a/docs/design/CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md +++ b/docs/design/CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md @@ -10,7 +10,7 @@ - 基于“最小必填锚点 + AI 初稿卡片 + 系统托管层”的结构化创作方案 2. 纯 Agent 式方向 - - 以前台对话为唯一主交互,创作者主要通过和 Agent 聊天来完成世界构建、角色塑造、剧情扩展和修改 + - 以前台对话为唯一主交互,陶泥主主要通过和 Agent 聊天来完成世界构建、角色塑造、剧情扩展和修改 文档需要回答 3 个问题: @@ -34,7 +34,7 @@ 当前方案的核心是: -1. 创作者手填最小高杠杆锚点 +1. 陶泥主手填最小高杠杆锚点 2. AI 生成一批可编辑的剧情策划初稿卡片 3. 系统把内容编译成运行时结构 @@ -42,7 +42,7 @@ **结构化工作台 + AI 协作生成。** -创作者的主要行为是: +陶泥主的主要行为是: 1. 填写关键卡片 2. 修改关键角色、地点、势力、章节等内容卡 @@ -53,9 +53,9 @@ 纯 Agent 式不是指“系统内部没有结构”,而是指: -**创作者前台几乎不需要面对表单和卡片编辑器,主要通过自然语言对话来完成创作。** +**陶泥主前台几乎不需要面对表单和卡片编辑器,主要通过自然语言对话来完成创作。** -创作者的主要行为变成: +陶泥主的主要行为变成: 1. 用自然语言描述世界想法 2. 回答 Agent 的追问 @@ -77,7 +77,7 @@ 1. 前台用户主要通过什么方式思考和输入? 2. 后台系统是否仍然有稳定的世界模型和编译层? -3. 创作者是否还能看见摘要、锁定内容和修改范围? +3. 陶泥主是否还能看见摘要、锁定内容和修改范围? 对当前项目来说,真正危险的不是“转成聊天”,而是: @@ -93,11 +93,11 @@ 它更擅长: -1. 帮不擅长表单和结构思考的创作者起步 -2. 在创作者思路模糊时做追问和陪创作 +1. 帮不擅长表单和结构思考的陶泥主起步 +2. 在陶泥主思路模糊时做追问和陪创作 3. 把“我要做一个世界”变成一次自然聊天 4. 动态决定追问深度,而不是一上来摆很多字段 -5. 让创作者感觉自己是在和一个懂 RPG 的剧情搭档共创 +5. 让陶泥主感觉自己是在和一个懂 RPG 的剧情搭档共创 ## 2.2 纯 Agent 式的主要问题 @@ -110,7 +110,7 @@ 1. 聊天很多,但世界状态越来越难总览 2. 角色、地点、势力和章节信息散落在多轮消息里 3. 锁定范围不清,重生成容易误伤已有内容 -4. Agent 很容易“替创作者决定太多” +4. Agent 很容易“替陶泥主决定太多” 5. 长会话越来越贵,越来越慢,也越来越容易漂移 ## 2.3 对当前项目的判断 @@ -197,7 +197,7 @@ 纯 Agent 式更弱的地方在于: -1. 世界模型隐藏得太深时,创作者会失去整体掌控感 +1. 世界模型隐藏得太深时,陶泥主会失去整体掌控感 2. 多轮对话后,已确定内容不容易被清晰回看 3. 局部重做和精确编辑边界会变模糊 4. Agent 容易过度代写、过度主导 @@ -223,7 +223,7 @@ 因为这些环节的关键问题不是“字段如何摆放”,而是: -**创作者有没有被真正引导出自己想做的世界。** +**陶泥主有没有被真正引导出自己想做的世界。** ## 4.2 不值得直接转成纯聊天黑箱的部分 @@ -261,8 +261,8 @@ 即使转成纯 Agent 式,也仍然要保留这三层: -1. 创作者必须确认的高杠杆锚点 -2. AI 生成但允许创作者修改的策划初稿层 +1. 陶泥主必须确认的高杠杆锚点 +2. AI 生成但允许陶泥主修改的策划初稿层 3. 系统托管的运行时编译层 变化的只是: @@ -339,7 +339,7 @@ 2. 会阶段性总结 3. 会把聊天结果沉淀成结构化世界状态 4. 会提醒风险和冲突 -5. 会在创作者要求时进行局部重写和定向扩展 +5. 会在陶泥主要求时进行局部重写和定向扩展 ## 6.2 正确理解 @@ -349,7 +349,7 @@ 也就是说: -1. 创作者看到的是对话 +1. 陶泥主看到的是对话 2. 系统内部维护的是世界模型、锁定状态、摘要和编译结果 --- @@ -389,7 +389,7 @@ Agent 首轮不应该直接铺满全世界,而应该给出一份简明底稿 2. 建议内容 3. 待确认内容 -## 7.3 阶段 C:创作者锁定锚点 +## 7.3 阶段 C:陶泥主锁定锚点 在纯 Agent 模式里,锁定行为必须被显式支持。 @@ -455,7 +455,7 @@ Agent 不应该每轮都继续扩全局,而应该支持“单对象工作模 | 结构 | 作用 | | --- | --- | -| `creatorIntentProfile` | 当前创作者最初和最新的创作意图 | +| `creatorIntentProfile` | 当前陶泥主最初和最新的创作意图 | | `lockedAnchors` | 已确认不可自动改写的内容 | | `worldDraftSnapshot` | 当前世界底稿快照 | | `editableDraftCards` | 角色、地点、势力、章节等可编辑初稿 | @@ -530,7 +530,7 @@ Agent 不能像问卷系统,也不能一次追问太多。 1. 一次最多追问 `1~3` 个问题 2. 问题必须是当前最缺的高杠杆信息 3. 每次追问都给默认建议方向 -4. 如果创作者不想细答,允许 Agent 先代补一个版本再确认 +4. 如果陶泥主不想细答,允许 Agent 先代补一个版本再确认 这样才能保持“像聊天”,而不是“像客服表单”。 @@ -614,14 +614,14 @@ Agent 应能识别这些常见修改类型: 3. 锁定内容固定展示 4. 提供“当前世界圣经”入口 -## 11.2 风险 2:Agent 过度代写,创作者失去作品归属感 +## 11.2 风险 2:Agent 过度代写,陶泥主失去作品归属感 防护方式: 1. 高杠杆锚点必须要求确认 2. 重要改动前先说“我准备改什么” 3. 默认优先给多个候选,而不是直接盖写 -4. 允许创作者随时回退到旧版本 +4. 允许陶泥主随时回退到旧版本 ## 11.3 风险 3:局部修改带出全局漂移 diff --git a/docs/design/CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md b/docs/design/CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md index becee1b0..3412c69d 100644 --- a/docs/design/CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md +++ b/docs/design/CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md @@ -37,8 +37,8 @@ - 不能先删旧字段,再补新结构。 - 必须先补新设定层,再逐步迁读,最后再让旧模板字段退化成兼容层。 -4. 不能增加创作者负担 - - 这次不是让创作者多填一堆底层 schema。 +4. 不能增加陶泥主负担 + - 这次不是让陶泥主多填一堆底层 schema。 - 这些设定仍然应由 AI / 系统编译出来,只是所有权从模板世界转移到自定义世界自己。 --- diff --git a/docs/design/CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md b/docs/design/CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md index 392299b2..da73d825 100644 --- a/docs/design/CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md +++ b/docs/design/CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md @@ -102,9 +102,9 @@ 这不是真正跨题材,只是换了名字。 -## 3.3 不能让创作者承担更多底层配置工作 +## 3.3 不能让陶泥主承担更多底层配置工作 -这次优化不是让创作者额外填写: +这次优化不是让陶泥主额外填写: - 怪物模板表 - 场景参考池 diff --git a/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md index c769fb56..6a430d0c 100644 --- a/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md +++ b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md @@ -349,7 +349,7 @@ export interface ChapterProgressionPlan { } ``` -建议作为后端运行时编译结果缓存,不作为创作者直接编辑字段。 +建议作为后端运行时编译结果缓存,不作为陶泥主直接编辑字段。 ## 3.7 章节经验记账 @@ -636,7 +636,7 @@ chapterXpBudget = 3. 非主角色友方 NPC - `support` 或 `ambient` -如需修正,再允许章节蓝图加可选 override,但不要求创作者每次手填。 +如需修正,再允许章节蓝图加可选 override,但不要求陶泥主每次手填。 ## 7.2 等级锚点 diff --git a/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md b/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md index 735e248d..b8fef21f 100644 --- a/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md +++ b/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md @@ -17,10 +17,13 @@ 3. 卡片高度控制在约 4rem 内,标题与状态信息并排组织,避免大留白。 4. 模块本体使用 `max-height: 33svh` 作为硬约束,内容超出时优先在模板入口行内横向滚动,不撑高页面。 5. 桌面端保持网格入口,但同步收紧内边距和卡片留白,避免移动端与桌面端表现割裂。 +6. 横向滚动模板行必须隐藏原生滚动条,保留滑动能力,避免底部出现过粗的视觉条。 ## 文案约束 - UI 不新增规则说明类文案。 - 原有“直接选择游戏创作模板,立刻进入对应的共创工作台。”说明在移动端隐藏,桌面端保留为辅助说明。 -- 锁定、可创建、正在开启等状态继续来自既有模板元数据或忙碌状态。 - +- 可创建的模板卡不展示“可创建”状态标签,只保留标题、短副标题和进入箭头。 +- 锁定的模板卡统一以“敬请期待”作为状态标注,不再显示“锁定”。 +- RPG 入口展示为“角色扮演 / 剧情演绎,冒险成长”,拼图入口展示为“拼图 / 创意礼物,生活分享”。 +- 忙碌状态仅保留在模块标题行的轻量状态中,避免占用每张可用卡片的首要视觉层级。 diff --git a/docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md b/docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md new file mode 100644 index 00000000..c6b5cda0 --- /dev/null +++ b/docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md @@ -0,0 +1,46 @@ +# 移动端创作页作品列表统一卡片设计 2026-04-29 + +## 背景 + +创作页的作品模块需要同时承载 RPG、拼图和大鱼吃小鱼等玩法。不同玩法卡片不能各自展示阶段、素材、主题等细节标签,否则作品列表会在移动端显得拥挤,并且草稿作品会暴露过多编辑态信息。 + +本次将作品列表卡片收口成统一信息结构:草稿只用于快速识别和继续创作,已发布作品才展示公开数据与分享入口。 + +## 落地范围 + +- 列表容器:`src/components/custom-world-home/CustomWorldCreationHub.tsx` +- 作品卡片:`src/components/custom-world-home/CustomWorldWorkCard.tsx` +- 不改动作品数据聚合、筛选、打开和体验逻辑。 +- 已发布作品右上角动作从删除改为分享;草稿仍保留删除入口。 + +## 卡片结构规则 + +1. 标题上方只显示两个标签:作品状态与游戏类型。 +2. 不再显示阶段、主题、素材完成度、作者、作品号等额外标签。 +3. 标签下方依次显示作品名称与作品描述。 +4. 草稿卡片到作品描述为止,不显示其他统计、作品号或体验按钮。 +5. 已发布卡片在描述下方显示三项公开指标:游玩数、改造数、点赞数。 +6. 已发布卡片右上角显示分享 icon,点击后复制作品分享文案,不触发卡片打开。 +7. 草稿卡片右上角继续显示删除 icon,点击删除不触发卡片打开。 + +## 公开指标重点展示补充 + +1. 已发布作品的三项公开指标不得继续使用标签样式展示,必须参考作品详情页的统计区,采用“小标签 + 大数字 + 单位”的重点信息结构。 +2. 指标文案统一为“游玩”“改造”“点赞”,不得在创作页卡片中展示 `Remix` 英文。 +3. 用户每次进入创作页时,前端读取上一次进入该页面缓存的公开指标快照;当已发布作品卡片滑动进入视口后,数字从缓存值增长到本次接口返回的最新值。 +4. 若最新值高于缓存值,动画完成后在对应指标右下角展示红色向上箭头和本次上涨的具体数值,字号低于主数字,避免抢占主信息层级。 +5. 若没有缓存值、缓存值不低于最新值或作品仍是草稿,则直接显示最新值,不展示上涨标记。 +6. 每张作品卡片继续使用作品封面作为整卡背景,封面需要有透明度和渐变遮罩,确保标题、描述和指标在亮色与暗色主题下都清晰可读。 + +## 移动端布局规则 + +1. 作品列表默认仍使用 2 列网格,保证草稿可以快速扫视。 +2. 已发布作品卡片在移动端固定 `col-span-2`,即占据一整行,避免公开指标和分享入口互相挤压。 +3. `sm` 及以上视口恢复普通网格跨度,由卡片自然进入多列布局。 +4. 小屏卡片降低高度、内边距、标题字号和徽标尺寸,避免长标题或中文描述撑破容器。 + +## 文案约束 + +- 不新增功能说明类文案。 +- 空态和错误态沿用现有文案。 +- 中文标题、描述和指标需要在卡片内截断或换行,不得因长文本破坏布局。 diff --git a/docs/design/PLATFORM_HOME_CATEGORY_AND_RANKING_TAB_REDESIGN_2026-04-29.md b/docs/design/PLATFORM_HOME_CATEGORY_AND_RANKING_TAB_REDESIGN_2026-04-29.md new file mode 100644 index 00000000..b3b83b23 --- /dev/null +++ b/docs/design/PLATFORM_HOME_CATEGORY_AND_RANKING_TAB_REDESIGN_2026-04-29.md @@ -0,0 +1,82 @@ +# 平台首页分类入口与排行 Tab 调整设计 + +更新时间:`2026-04-29` + +## 1. 本次目标 + +1. 首页移动端频道只保留“推荐、今日游戏、游戏分类”,删除“PC游戏、即点即玩”。 +2. 原底部“分类” Tab 改为“排行” Tab,不再单独承载分类页。 +3. 原分类 Tab 的标签筛选移动到首页移动端“游戏分类”频道中,作品展示从双列网格改为应用商店式纵向列表。 +4. 排行页参考榜单式纵向布局,提供热门榜、改造榜、新品榜、点赞榜四个榜单切换。 +5. 页面继续使用平台主题变量、现有字号层级与卡片组件,避免新增大段功能说明文案。 + +## 2. 数据口径 + +当前公开作品聚合列表已经透传后端读模型字段: + +- `playCount`:历史游玩次数。 +- `remixCount`:历史改造次数。 +- `likeCount`:历史点赞次数。 +- `recentPlayCount7d`:近 7 日新增游玩次数。 +- `publishedAt / updatedAt`:发布时间或更新时间。 + +本次新增 `public_work_play_daily_stat` 日桶读模型,所有公开玩法的正式游玩入口在累加历史 `playCount` 时同步写入该表。公开列表返回时按作品聚合最近 7 个 UTC 自然日的 `recentPlayCount7d`,前端只负责展示与排序。 + +1. 热门榜按 `playCount` 降序。 +2. 改造榜按 `remixCount` 降序。 +3. 点赞榜按 `likeCount` 降序。 +4. 新品榜按 `recentPlayCount7d` 降序。 + +## 3. 交互规则 + +### 3.1 首页移动端 + +- 顶部搜索框保持不变。 +- 频道横滑 Tab 顺序为:推荐、今日游戏、游戏分类。 +- 推荐展示精选与最新去重后的作品流。 +- 今日游戏只展示 `publishedAt` 落在玩家当前浏览器自然日内的新发布公开作品;跨日旧作品即使仍在最新列表前排,也不能进入该频道。 +- 游戏分类展示原分类页内容:筛选胶囊 + 横向标签 + 当前标签下纵向作品列表。 +- 游戏分类列表参考移动应用商店结构,不再使用双列卡片:左侧方形封面,中间为作品名、状态角标、评分/题材、摘要或热度短句,右侧为“启动/试玩”主按钮。 +- 分类频道的筛选区只保留短标签,不写功能说明文案;筛选按钮展示当前标签数量,横向标签展示可切换的分类入口。 + +### 3.2 底部导航 + +- 登录态:`首页 / 排行 / 创作 / 存档 / 我的`。 +- 未登录态:`首页 / 创作 / 排行`。 +- 底部排行入口仍复用原 `category` Tab 的路由值,减少导航状态迁移风险,但所有用户可见文案改为“排行”。 + +### 3.3 排行页 + +- 顶部为横向榜单 Tab:热门榜、改造榜、新品榜、点赞榜。 +- 下方为纵向榜单列表,每行展示排名、封面、作品名、榜单指标、玩法类别、两个标签与进入按钮。 +- 公开作品名称在列表与卡片中统一限制为最多 8 字;公开作品标签统一限制为最多 4 字。 +- 排行榜单条目正文固定为三行:第一行作品名,第二行榜单数据与玩法类别,第三行展示两个标签;不再显示发布时间、作者名等第四行信息。 +- 无数据或加载中沿用现有短空态文案。 + +## 4. 编码落点 + +- `src/components/rpg-entry/RpgEntryHomeView.tsx` + - 精简首页频道枚举。 + - 增加排行榜单构造、榜单切换状态与榜单行组件。 + - 将分类内容移动到移动端首页“游戏分类”频道。 + - 增加游戏分类纵向列表条目组件,替换移动端分类频道的双列作品网格。 + - 将底部/桌面侧边导航文案从“分类”改为“排行”。 +- `src/index.css` + - 增加榜单行、榜单切换按钮、游戏分类筛选栏和纵向列表条目的主题化样式。 +- `server-rs/crates/spacetime-module/src/runtime/profile.rs` + - 增加公开作品每日游玩统计表与 7 日聚合 helper。 +- `server-rs/crates/spacetime-module/src/migration.rs` + - migration 表清单对齐 `public_work_play_daily_stat`。 +- `server-rs/crates/shared-contracts/src/*_works.rs`、`packages/shared/src/contracts/*` + - 公开作品响应补齐 `recentPlayCount7d`。 + +## 5. 验收点 + +1. 移动端首页不再显示“PC游戏、即点即玩”。 +2. 点击首页“游戏分类”能看到原分类标签与作品列表。 + - 移动端分类作品必须为纵向列表,不能回退为两列网格。 + - 单条作品在 390px 宽度下必须保持封面、标题、按钮同一行可扫读,摘要截断且不挤压右侧按钮。 +3. 点击首页“今日游戏”只显示当天新发布作品;仅更新时间为今天但发布时间不在今天的作品不能进入今日频道。 +4. 底部导航显示“排行”,不再显示“分类”。 +5. 排行页可切换四个榜单,排序口径符合当前字段约束。 +6. 不修改 server-node,不新增 PostgreSQL 相关实现。 diff --git a/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md b/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md index 5b423dd4..ef4970ff 100644 --- a/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md +++ b/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md @@ -28,6 +28,24 @@ likeCount: number 3. 大鱼公开广场:`BigFishWorkSummary` 返回 `likeCount`,当前由 Rust facade 返回 `0`,`playCount` 继续仅表示游玩次数。 4. 前端聚合类型 `PlatformPublicGalleryCard` 透传 `likeCount`,`WorldCard` 不再依赖 `badge/metaLabel` 决定主要信息结构。 +### 2.3 首页读链路核对 + +首页公开作品流的读取链路固定为: + +```text +RpgEntryHomeView +→ platformPublicGalleryClient / puzzleGalleryClient / bigFishGalleryClient +→ Rust api-server +→ spacetime-client 生成绑定 +→ spacetime-module procedure +→ SpacetimeDB 表 +``` + +1. 公开读取必须匿名可用,前端 `GET` 列表与详情统一传 `skipAuth: true`、`skipRefresh: true`,避免未登录首页被刷新 token 链路阻断。 +2. 拼图公开广场走 `list_puzzle_gallery` / `get_puzzle_gallery_detail`,返回 `coverImageSrc`、`summary`、`themeTags`、`playCount`、`remixCount`、`likeCount`。 +3. 大鱼公开广场走 `list_big_fish_works(published_only=true)`;由于部分已部署模块会在公开列表分支前仍校验 `owner_user_id` 非空,客户端与模块内部公共列表输入都使用 `public-big-fish-gallery` 占位 owner。该字段在 `published_only` 分支不参与筛选,只用于兼容旧校验。 +4. 自定义世界公开广场走 `list_custom_world_gallery_entries`,当前主云数据为空时应返回成功空列表,而不是错误态。 + ## 3. 移动端布局 1. 移动端首页只在 `RpgEntryHomeView` 的 mobile content 内重排。 @@ -57,3 +75,7 @@ likeCount: number 2. 桌面端首页布局区块顺序不变,只替换公开作品卡内部结构。 3. RPG、拼图、大鱼三类公开作品卡都有 `likeCount` 字段,前端聚合后能统一展示。 4. 运行编码检查、前端定向测试和必要的 Rust 检查。 +5. HTTP 验收需覆盖: + - `GET /api/runtime/custom-world-gallery` 成功返回 `entries`。 + - `GET /api/runtime/puzzle/gallery` 成功返回 `items` 且包含 `likeCount`。 + - `GET /api/runtime/big-fish/gallery` 成功返回 `items`,旧部署模块不再因 `big_fish.owner_user_id 不能为空` 阻断首页。 diff --git a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md index 53c56790..59ddb9a9 100644 --- a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -96,7 +96,7 @@ - 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。 - 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。 - `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。 -- `密码登录` 页签只包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或叙世号。 +- `密码登录` 页签只包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或陶泥号。 - 密码登录只是手机号验证码登录的补充方式:只有已登录并设置过密码的手机号账号才能使用,不能在密码页签创建账号。 - `密码登录` 主按钮固定为 `登录`,不得使用 `注册/登录`。 - 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。 diff --git a/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md b/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md index e77883a2..a8064430 100644 --- a/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md +++ b/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md @@ -59,8 +59,8 @@ ### 3.2 排版 - 平台层正文、按钮、说明、功能标签统一使用非像素字体 -- 左上角 `叙世 / GENARRATIVE` 品牌字标允许单独做成像素化 logo -- `GENARRATIVE` 与 `叙世` 都优先直接使用游戏内同款 `Fusion Pixel` +- 左上角 `陶泥 / GENARRATIVE` 品牌字标允许单独做成像素化 logo +- `GENARRATIVE` 与 `陶泥` 都优先直接使用游戏内同款 `Fusion Pixel` - 品牌字标默认保持正常像素字观感,禁止再叠双层粗阴影或手动加粗到影响识别 - 品牌字标直接使用字体文件内原字形,不额外做运行时描字、轮廓拼字或伪粗体处理 - 主标题保留明显层级,但不再做像素描边效果 diff --git a/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md b/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md index 3f957856..4b6370c5 100644 --- a/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md +++ b/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md @@ -1,11 +1,11 @@ # 平台统一作品详情页与 Remix 数据链路设计 -更新时间:`2026-04-28` +更新时间:`2026-04-29` ## 1. 本次目标 1. 平台首页、公开广场、分类列表中的每个公开作品点击后,统一先进入作品详情页,不再直接启动玩法。 -2. 作品详情页结构参考 TapTap 详情页:顶部封面图、作品基础信息、右侧 Remix 按钮、四项统计、简介内容、底部启动按钮。 +2. 作品详情页结构参考 TapTap 详情页:顶部封面图、作品基础信息、右侧“作品改造”按钮、四项统计、简介内容、底部启动按钮。 3. 删除参考图顶部 Tab,不接入评价和论坛功能,不展示“开发者的话”模块。 4. 统计数据必须从数据库读模型贯穿到前端展示,禁止在前端用假字段、游玩数冒充点赞数或固定文案代替真实字段。 5. Remix 按钮必须由后端事务复制公开作品为当前用户草稿,并同步增加原作品改造次数,成功后前端进入新草稿详情/结果页。 @@ -15,18 +15,21 @@ 统一详情页只做作品展示与动作入口,不承担规则说明。 1. 顶部导航:返回按钮、标题“详情”、更多按钮占位;不展示“统计 / 详情 / 评价 / 论坛”Tab。 -2. 封面区:使用作品封面图作为主视觉,背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。 +2. 封面区:固定 `16:9` 比例,使用作品封面图 `cover` 填满整块主视觉;背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。 3. 基础信息区: - 左侧作品图标使用作品封面或首图。 - - 中间展示作品名、作者名、玩法类型。 - - 右侧原 TapTap 评分位置替换为 `Remix` 按钮。 + - 中间展示作品名、作者头像、作者名、玩法类型;作者头像读取公开用户资料 `avatarUrl`,缺失时使用作者昵称首字占位。 + - 右侧原 TapTap 评分位置替换为 `作品改造` 按钮。 4. 统计区固定四项: - - 改造次数:`remixCount` - - 游玩次数:`playCount` - - 点赞次数:`likeCount` - - 上线日期:`publishedAt` + - 改造:`remixCount`,显示为“数字 + 次”,单位放在数字后方。 + - 游玩:`playCount`,显示为“数字 + 次”,单位放在数字后方。 + - 点赞:`likeCount`,显示为“数字 + 赞”,单位放在数字后方。 + - 最近更新:优先展示 `updatedAt`,缺失时回退 `publishedAt`;前端只负责格式化显示,必须兼容后端当前 `seconds.microsZ` 与 ISO 字符串两种真实时间文本,显示为完整 `YYYY-MM-DD`,使用更小字号并保持单行不换行。 + - 四项统计需要使用浅色图标底强化识别,但不得追加规则说明类文案。 5. 简介区:展示玩法标签和作品简介;不追加说明类文案。 6. 底部动作:主按钮为“启动”,点击后进入对应玩法运行态并记录游玩次数。 +7. 页面配色必须跟随平台明暗主题变量;亮色主题使用平台浅色底、深色文字和主按钮渐变,暗色主题使用平台暗色底、亮色文字和对应主按钮渐变,不在详情页写死独立黑色皮肤。 +8. 字号规范跟随平台页面既有节奏:标题/主按钮使用 `1rem` 级别,作品名使用卡片标题同级 `1rem`,辅助信息与简介使用 `0.8125rem` / `0.875rem`,标签与统计标签使用 `0.75rem`,避免在详情页使用随视口放大的独立大字号。 ## 3. 数据真相源 @@ -55,7 +58,7 @@ ### 3.3 大鱼吃小鱼作品 1. `big_fish_creation_session` 现有 `play_count` 继续作为游玩统计,新增 `remix_count`、`like_count`、`published_at`。 -2. `publish_big_fish_game` 写入 `published_at`,公开列表和详情用它展示上线日期。 +2. `publish_big_fish_game` 写入 `published_at` 与 `updated_at`,公开列表和详情优先用 `updated_at` 展示最近更新。 3. `record_big_fish_play` 继续作为游玩次数递增入口。 4. `remix_big_fish_work` 在同一事务内: - 校验源 session 为已发布作品。 @@ -65,7 +68,8 @@ ## 4. API 与前端接入 -1. 三类公开作品摘要统一返回:`playCount`、`remixCount`、`likeCount`、`publishedAt`。 +1. 三类公开作品摘要统一返回:`playCount`、`remixCount`、`likeCount`、`publishedAt`、`updatedAt`。 + - 作者头像不固化到作品读模型;详情页按 `authorPublicUserCode` 或 `ownerUserId` 读取公开用户摘要中的 `avatarUrl`,确保头像跟随账号资料更新。 2. Remix API: - RPG:`POST /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix` - 拼图:`POST /api/runtime/puzzle/gallery/{profile_id}/remix` @@ -76,6 +80,8 @@ - RPG:进入复制出的草稿详情。 - 拼图:进入复制出的拼图结果页草稿。 - 大鱼:进入复制出的大鱼结果页草稿。 +6. 拼图作品详情页启动时复用当前详情页已经展示的公开作品读模型,直接调用 `POST /api/runtime/puzzle/runs` 记录游玩并进入运行态;不得在启动前额外依赖 `GET /api/runtime/puzzle/gallery/{profile_id}`,避免开发代理或详情读取短断点阻塞启动链路。 +7. 本地开发时 `localhost:3000` 是 Vite 前端端口,`/api/**` 默认代理到 Rust `api-server:3100`;若 3100 未监听,点击启动或作品改造会在浏览器显示 `/api/... 500`,此时真实断点是 Rust 后端未启动,不允许用前端假数据替代后端事务。 ## 5. 验收点 diff --git a/docs/design/README.md b/docs/design/README.md index ee18cac7..0c88e55f 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -4,12 +4,13 @@ ## 文档列表 -- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里创作者输入与 AI 分工边界设计。 +- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里陶泥主输入与 AI 分工边界设计。 - [CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md):自定义世界创作里“手填锚点 / AI 可改初稿 / 系统托管层”的平衡设计。 - [CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md):纯 Agent 式创作工具与结构化工作台方案的优缺点对比,以及转型设计。 - [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。 - [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。 - [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。 +- [MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md](./MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md):移动端创作页作品列表至少 2 列的紧凑布局设计。 - [PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md](./PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md):平台首页移动端参考图式信息流、双端公开作品卡 16:9 封面结构与点赞数读模型设计。 - [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。 - [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。 @@ -28,8 +29,8 @@ - 做物品、Build、锻造相关需求时,先看前两份。 - 做 RPG 全剧情规划、主支线矩阵、角色线、场景章节与剧情交付模板时,先看新增的全剧情策划流程。 -- 做自定义世界创作工作台、创作者输入边界、AI 分工设计时,先看第一份。 -- 做“哪些内容必须让创作者手填、哪些适合 AI 先生成再改、哪些必须系统托管”这类分层设计时,优先看新增的输入平衡设计稿。 +- 做自定义世界创作工作台、陶泥主输入边界、AI 分工设计时,先看第一份。 +- 做“哪些内容必须让陶泥主手填、哪些适合 AI 先生成再改、哪些必须系统托管”这类分层设计时,优先看新增的输入平衡设计稿。 - 做“是否应该转成纯 Agent 式创作工具、转了之后前后台各该怎么收口”这类产品方向评估时,优先看新增的纯 Agent 对比与转型设计稿。 - 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。 - 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。 diff --git a/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md b/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md index 5a15a1ab..ae902107 100644 --- a/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md +++ b/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md @@ -29,7 +29,7 @@ 结论: - 独立编辑器入口如果没有继续接入主流程,应及时物理删除,不要长期保留兼容壳 -- 页签命名要贴近创作者语言,而不是内部实现命名 +- 页签命名要贴近陶泥主语言,而不是内部实现命名 ### 2.2 NPC 视觉模块并入 NPC 编辑 @@ -144,7 +144,7 @@ 经验: -- 创作者并不关心 “function” 这个技术词,更关心“这个选项会发生什么” +- 陶泥主并不关心 “function” 这个技术词,更关心“这个选项会发生什么” - 同类编辑器如果只给字段表单而没有模板起稿能力,复用效率会很低 ### 2.8 选项行为预览升级到实机回放 @@ -217,7 +217,7 @@ - 预览面板要么都显示“实时状态” - 要么都显示“同一个阶段的快照” -- 混用实时值和预测值会让创作者误判 +- 混用实时值和预测值会让陶泥主误判 ## 4. 这类项目里沉淀下来的方法论 @@ -245,7 +245,7 @@ - 不是所有字段都应该在所有行为类型下开放 - 如果某类行为最终不会直接读取某个字段,就应该禁用或弱化它 -- 否则创作者会错误地以为改动无效是 bug +- 否则陶泥主会错误地以为改动无效是 bug ### 4.4 模板比空白表单更重要 diff --git a/docs/experience/RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md b/docs/experience/RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md index a4a68196..0a6e73e2 100644 --- a/docs/experience/RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md +++ b/docs/experience/RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md @@ -25,7 +25,7 @@ 3. 历史已发布作品必须能自动补齐 gallery 投影。 - 公开列表读取 `list_custom_world_gallery_entries` 前,会扫描 `custom_world_profile` 中已发布且未删除的 profile。 - - 若已发布 profile 缺少 `custom_world_gallery_entry`,或缺少公开作品码 / 作者叙世号,会先补齐公开字段并同步 gallery 投影。 + - 若已发布 profile 缺少 `custom_world_gallery_entry`,或缺少公开作品码 / 作者陶泥号,会先补齐公开字段并同步 gallery 投影。 - 这样旧版本发布成功但未落入广场读模型的作品,在下一次首页 / 分类页读取公开列表时会自动出现。 ## 经验 diff --git a/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md index 029c812f..bc96b985 100644 --- a/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md +++ b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md @@ -394,7 +394,7 @@ MVP 阶段不需要单独设置密码。 落地规则: -- 入参只允许 `phone` 和 `password`,不支持邮箱、用户名或叙世号。 +- 入参只允许 `phone` 和 `password`,不支持邮箱、用户名或陶泥号。 - 手机号不存在时,不创建账号,返回统一的登录失败。 - 手机号存在但账号未设置过密码时,不允许密码登录。 - 首次设置密码只能在已登录账号中心内完成;用户必须先通过手机号验证码或已绑定手机号的微信账号进入已登录态。 @@ -734,7 +734,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含: 约束: -- 不支持邮箱、用户名或叙世号。 +- 不支持邮箱、用户名或陶泥号。 - 不承担注册能力。 - 只有已存在、已验证手机号、且 `passwordLoginEnabled=true` 的账号可以登录。 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md index eae559f4..f90a7cc3 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md @@ -31,7 +31,7 @@ 大鱼吃小鱼玩法是一个 `Agent-First` 的轻量实时成长玩法创作链: -**创作者先与 Agent 共创“进化母题、等级阶梯、生态风格与场地气质”,系统再自动编译出一个竖屏全屏、摇杆移动、吞噬收编、三合一进化、持续刷怪、升到最高级即通关的可运行玩法作品。** +**陶泥主先与 Agent 共创“进化母题、等级阶梯、生态风格与场地气质”,系统再自动编译出一个竖屏全屏、摇杆移动、吞噬收编、三合一进化、持续刷怪、升到最高级即通关的可运行玩法作品。** --- @@ -115,26 +115,26 @@ `Agent-First 大鱼吃小鱼玩法创作工具` -玩法运行态对外展示名可由创作者自定义,不强绑平台内部域名。 +玩法运行态对外展示名可由陶泥主自定义,不强绑平台内部域名。 ## 5.2 目标用户 目标用户主要是 3 类: -1. 轻创作者 +1. 轻陶泥主 - 想快速做一个可玩的成长吞噬小游戏,但不懂完整关卡编辑器 -2. 视觉驱动型创作者 +2. 视觉驱动型陶泥主 - 更关心“每级长什么样、动作怎么样、背景氛围如何” -3. 玩法原型创作者 +3. 玩法原型陶泥主 - 想快速验证一套吞噬成长节奏、等级曲线和场地压迫感 ## 5.3 成功标准 本期上线后,至少要满足下面这些结果: -1. 创作者可在 `5~12` 分钟内通过 Agent 聊天形成一版可用锚点草稿。 +1. 陶泥主可在 `5~12` 分钟内通过 Agent 聊天形成一版可用锚点草稿。 2. 系统默认能编译出 `8` 级实体阶梯的初版玩法草稿。 3. 每一级实体都能在结果页单独生成和重生成主图。 4. 每一级实体都能在结果页单独生成和重生成动作。 @@ -179,9 +179,9 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: -1. 帮创作者明确高杠杆锚点 -2. 帮创作者把模糊灵感总结成可编译结构 -3. 帮创作者收束出第一版等级阶梯与视觉方向 +1. 帮陶泥主明确高杠杆锚点 +2. 帮陶泥主把模糊灵感总结成可编译结构 +3. 帮陶泥主收束出第一版等级阶梯与视觉方向 ## 7.2 前台交互原则 @@ -222,7 +222,7 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: 3. `成长阶梯` - 这一玩法一共大致有几级,以及每一级如何逐步升级、变大、变强、变异 - 最高级终局形态也并入这一锚点统一确定 - - 若创作者没有明确总层数,本期默认按 `8` 级编译,可配置范围固定为 `6~12` 级 + - 若陶泥主没有明确总层数,本期默认按 `8` 级编译,可配置范围固定为 `6~12` 级 4. `风险节奏` - 玩家周围应该更偏压迫、平衡还是偏爽快 @@ -235,7 +235,7 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: 2. `等级总层数` 并入 `成长阶梯` 3. `升级轮廓` 并入 `成长阶梯` 4. `终局形态` 并入 `成长阶梯` -5. `开局成长方式` 改为系统固定规则,不再作为创作者锚点 +5. `开局成长方式` 改为系统固定规则,不再作为陶泥主锚点 后续 Agent 追问时,不再把这些内容拆成独立必答题。 @@ -302,7 +302,7 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: ## 9.1 默认草稿规模 -当创作者没有特别指定时,第一版玩法草稿必须默认编译为: +当陶泥主没有特别指定时,第一版玩法草稿必须默认编译为: 1. `8` 级实体阶梯 2. `1` 张活动区域背景图 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md index 52c13fd7..12e2a706 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md @@ -680,7 +680,7 @@ assistant 回复应包含: 1. 对 seedText / 用户消息的简要复述 2. 当前仍缺哪些世界锚点 -3. 建议创作者下一步回答什么 +3. 建议陶泥主下一步回答什么 #### 用户后续消息 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md index 9f3ae993..3a26244c 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md @@ -24,7 +24,7 @@ 那么第二阶段的目标就是: -**让 Agent 会话真正开始理解创作者输入,并把自然语言聊天沉淀成结构化创作锚点。** +**让 Agent 会话真正开始理解陶泥主输入,并把自然语言聊天沉淀成结构化创作锚点。** 一句话定义: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md index 823446b3..8c799b66 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md @@ -26,7 +26,7 @@ 那么第四阶段的目标就是: -**让创作者直接修改这版草稿设定,并且继续用 AI 为这版草稿扩出新的角色和场景。** +**让陶泥主直接修改这版草稿设定,并且继续用 AI 为这版草稿扩出新的角色和场景。** 一句话定义: @@ -100,7 +100,7 @@ 一句话目标: -**让第四阶段结束时,创作者第一次能像在真正做作品一样修改草稿、继续长出新对象。** +**让第四阶段结束时,陶泥主第一次能像在真正做作品一样修改草稿、继续长出新对象。** --- diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md index 0e0cd627..f00906d6 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md @@ -200,7 +200,7 @@ 1. 主线关键角色 2. 可扮演角色 -3. 创作者重点想看的角色 +3. 陶泥主重点想看的角色 ## 7.2 入口位置 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md index 4f6a457a..8efe1665 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md @@ -42,21 +42,21 @@ ## 1.2 一句话定义 -让创作者通过与一个懂 RPG 剧情策划方法的 Agent 对话,逐步完成世界锚点收集、关键对象塑造、剧情骨架搭建和长尾内容展开;同时由 Express 后端持续维护结构化世界状态、锁定边界、局部重生成和质量检查。 +让陶泥主通过与一个懂 RPG 剧情策划方法的 Agent 对话,逐步完成世界锚点收集、关键对象塑造、剧情骨架搭建和长尾内容展开;同时由 Express 后端持续维护结构化世界状态、锁定边界、局部重生成和质量检查。 ## 1.3 目标用户 目标用户分三类: -1. 轻创作者 +1. 轻陶泥主 - 有世界灵感,但不擅长结构化填表 -2. 中度创作者 +2. 中度陶泥主 - 愿意精修角色、地点、主线第一幕,但不想维护大量底层字段 -3. 重度创作者 +3. 重度陶泥主 - 需要局部重生成、锁定、版本化和导出世界圣经 ## 1.4 产品成功标准 @@ -76,7 +76,7 @@ 1. 不把整套系统做成纯聊天黑箱。 2. 不让前端继续承担锁定合并、重生成裁决、结构编译等核心逻辑。 -3. 不要求创作者直接编辑 `ThemePack / WorldStoryGraph / VisibilitySlice / ThreadContract` 等运行时结构。 +3. 不要求陶泥主直接编辑 `ThemePack / WorldStoryGraph / VisibilitySlice / ThreadContract` 等运行时结构。 4. 不把长项目世界管理完全交给一条无限增长的聊天记录。 5. 不再保留“生成完直接回世界列表并自动保存”的旧流程。 6. 不允许角色主图、角色动作、场景背景图继续停留在临时候选状态后直接发布世界。 @@ -151,7 +151,7 @@ 1. `src/services/customWorldCreatorIntent.ts` - - 已有创作者意图、锚点包、锁定状态的基础结构 + - 已有陶泥主意图、锚点包、锁定状态的基础结构 2. `src/types/customWorld.ts` @@ -228,7 +228,7 @@ 后台必须持续维护: -1. 创作者意图 +1. 陶泥主意图 2. 锁定状态 3. 世界底稿快照 4. 可编辑草稿对象列表 @@ -271,11 +271,11 @@ -> 打开 Agent 创作入口 -> Agent 收集最小锚点 -> Agent 输出首轮世界底稿 --> 创作者锁定/修改关键内容 +-> 陶泥主锁定/修改关键内容 -> Agent 局部生成关键角色/地点/主线第一幕 -> 进入角色与场景资产工坊,生成主形象 / 动作 / 背景图 -> Agent 扩展长尾内容 --> 创作者发布世界 +-> 陶泥主发布世界 -> 保存到世界库并进入世界 ``` @@ -2077,4 +2077,4 @@ Agent 会话每次 operation 完成后自动保存 session snapshot。 这次新创作工具的正确方向,不是把现有工作台换成一个更大的聊天框,而是: -**让 Agent 成为创作者的主交互入口,让 Express 后端成为真正的世界状态管理者,让锁定、局部重生成、摘要、质量护栏和发布链把整个创作过程牢牢收住。** +**让 Agent 成为陶泥主的主交互入口,让 Express 后端成为真正的世界状态管理者,让锁定、局部重生成、摘要、质量护栏和发布链把整个创作过程牢牢收住。** diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md b/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md index 1ce142bc..29326809 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md @@ -37,15 +37,15 @@ ## 1.3 目标用户 -目标用户仍然是当前自定义世界创作工具的三类创作者,但本流程更偏向解决其中两类人的起步问题: +目标用户仍然是当前自定义世界创作工具的三类陶泥主,但本流程更偏向解决其中两类人的起步问题: -1. 轻创作者 +1. 轻陶泥主 - 有模糊灵感,但不知道先想什么 -2. 中度创作者 +2. 中度陶泥主 - 有一些设定点子,但缺少把设定收束成可运行剧情骨架的方法 -重度创作者也可使用本流程,但他们更关心的是: +重度陶泥主也可使用本流程,但他们更关心的是: - Agent 是否会少问废话 - 摘要是否准确 @@ -1190,7 +1190,7 @@ Agent 不应回复成八问表: ## 13.2 后续可编辑范围 -进入世界底稿阶段后,创作者默认优先精修: +进入世界底稿阶段后,陶泥主默认优先精修: 1. 关键角色 2. 核心冲突与线程 diff --git a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md index 51182327..df76cb1a 100644 --- a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md +++ b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md @@ -8,11 +8,11 @@ 目标不是推翻当前已经存在的多阶段生成链,而是解决下面这个核心错位: -**当前仓库已经开始把世界生成拆成 `framework -> themePack -> storyGraph -> role outline -> dossier -> narrativeProfile` 的分阶段 AI 编译流程,但创作者入口仍然是“一段大文本”,结果页又把大量低杠杆字段重新扔回给创作者人工兜底。** +**当前仓库已经开始把世界生成拆成 `framework -> themePack -> storyGraph -> role outline -> dossier -> narrativeProfile` 的分阶段 AI 编译流程,但陶泥主入口仍然是“一段大文本”,结果页又把大量低杠杆字段重新扔回给陶泥主人工兜底。** 一句话定义本次优化: -**让创作者先定义世界灵魂锚点,再让 AI / 系统围绕锚点分层生成、分层展开、分层可控地完成长尾内容。** +**让陶泥主先定义世界灵魂锚点,再让 AI / 系统围绕锚点分层生成、分层展开、分层可控地完成长尾内容。** ## 1. 当前流程现状 @@ -64,7 +64,7 @@ ## 1.3 当前流程的核心问题 -## 1.3.1 创作者入口过于粗糙 +## 1.3.1 陶泥主入口过于粗糙 当前创建入口只有一块大文本输入框。 @@ -72,23 +72,23 @@ 1. 不会写长描述的用户很难开局。 2. 愿意精细创作的用户没有结构化落点。 -3. 系统无法明确分辨“哪些是创作者真正想锁定的锚点,哪些只是随口补充的描述”。 +3. 系统无法明确分辨“哪些是陶泥主真正想锁定的锚点,哪些只是随口补充的描述”。 结果就是: -**输入端自由,但信息信号不稳定;AI 虽然能生成很多内容,却不一定生成的是创作者真正关心的内容。** +**输入端自由,但信息信号不稳定;AI 虽然能生成很多内容,却不一定生成的是陶泥主真正关心的内容。** -## 1.3.2 创作者与 AI 的职责发生倒置 +## 1.3.2 陶泥主与 AI 的职责发生倒置 当前流程实际上是: -- 创作者先写一段泛化设定 +- 陶泥主先写一段泛化设定 - AI 再把整个世界铺满 -- 创作者最后回到结果页,人工修改大量角色、章节、技能、初始物品、场景连接等细节 +- 陶泥主最后回到结果页,人工修改大量角色、章节、技能、初始物品、场景连接等细节 这与“低创作门槛、高创作自由度”的目标相反。 -因为真正应该由创作者控制的,是: +因为真正应该由陶泥主控制的,是: - 世界核心命题 - 主题与气质 @@ -98,7 +98,7 @@ - 关键地点 - 标志性物件 / 怪物 / 禁忌 -而不是让创作者在结果页里逐个补: +而不是让陶泥主在结果页里逐个补: - `backstoryReveal.chapters` - `skills` @@ -117,13 +117,13 @@ 问题不在数量本身,而在于系统并没有明确区分: -1. 哪些是创作者应重点塑造的关键对象 +1. 哪些是陶泥主应重点塑造的关键对象 2. 哪些只是 AI 应自动展开的长尾铺量 这会导致两个问题: 1. AI 在早期就花大量成本生成长尾内容,等待时间长。 -2. 创作者在结果页里面对的是一整套“全部都生成了”的世界,而不是“先抓关键锚点,再决定是否继续铺开”。 +2. 陶泥主在结果页里面对的是一整套“全部都生成了”的世界,而不是“先抓关键锚点,再决定是否继续铺开”。 ## 1.3.4 当前结果页暴露了过多低杠杆字段 @@ -134,7 +134,7 @@ - 场景 NPC 分配 - 场景连接网络 -这对“专业创作者”当然有帮助,但对目标用户来说,容易把工具变成: +这对“专业陶泥主”当然有帮助,但对目标用户来说,容易把工具变成: **看起来自由度很高,实际上需要承担很多系统编辑工作。** @@ -144,11 +144,11 @@ 这意味着: -1. 创作者一旦修改过内容,就会担心被覆盖。 +1. 陶泥主一旦修改过内容,就会担心被覆盖。 2. 没有“锁定关键内容,只重生成长尾部分”的机制。 3. AI 无法真正成为创作搭档,只像一次性大批量生成器。 -## 1.3.6 当前生成阶段是“模型视角”,不是“创作者视角” +## 1.3.6 当前生成阶段是“模型视角”,不是“陶泥主视角” 当前生成页展示的是系统批次和阶段进度,这很好,但它主要回答的是: @@ -156,7 +156,7 @@ 没有回答的是: -- 创作者最关心的关键角色是否已经成型 +- 陶泥主最关心的关键角色是否已经成型 - 世界冲突是否已经稳定 - 当前这轮已经锁定了哪些核心创意 - 接下来生成的是关键锚点,还是长尾内容 @@ -170,19 +170,19 @@ 这次优化要同时满足 6 个目标: 1. 降低输入门槛 - - 不要求创作者一上来写长文,不要求理解系统字段。 + - 不要求陶泥主一上来写长文,不要求理解系统字段。 2. 提高高杠杆创作自由度 - - 让创作者直接控制世界灵魂锚点,而不是低价值细节。 + - 让陶泥主直接控制世界灵魂锚点,而不是低价值细节。 -3. 明确创作者与 AI 的职责边界 - - 创作者负责“决定什么值得创作”,AI 负责“把它展开并跑起来”。 +3. 明确陶泥主与 AI 的职责边界 + - 陶泥主负责“决定什么值得创作”,AI 负责“把它展开并跑起来”。 4. 保留现有分阶段生成骨架 - 不推翻 `framework -> themePack -> storyGraph -> role/landmark` 的已有结构。 5. 引入锁定与局部重生成 - - 让创作者能保住自己在乎的内容,只重做其余部分。 + - 让陶泥主能保住自己在乎的内容,只重做其余部分。 6. 把结果页从“数据总表”升级成“创作工作台” - 让编辑界面按创作价值组织,而不是按底层对象堆字段。 @@ -192,11 +192,11 @@ 优化后的自定义世界流程应该改为: ```text -创作者输入世界锚点 --> AI 编译创作者意图摘要 --> 创作者确认 / 锁定关键锚点 +陶泥主输入世界锚点 +-> AI 编译陶泥主意图摘要 +-> 陶泥主确认 / 锁定关键锚点 -> AI 先生成关键角色与关键地点 --> 创作者可局部修改 / 局部重生成 +-> 陶泥主可局部修改 / 局部重生成 -> AI 再展开长尾 NPC、长尾场景与运行时编译结构 -> 结果页以“锚点 / 关键对象 / 扩展内容 / 运行时摘要”方式组织 -> 保存并进入世界 @@ -204,7 +204,7 @@ 一句话: -**先做创作决策,再做内容展开;先做关键对象,再做长尾铺量;先让创作者锁定灵魂,再让 AI 扩散世界。** +**先做创作决策,再做内容展开;先做关键对象,再做长尾铺量;先让陶泥主锁定灵魂,再让 AI 扩散世界。** ## 4. 输入层优化方案 @@ -251,7 +251,7 @@ 2. 卡片模式 - 用户直接按结构化方式输入世界锚点 -两种模式最终都编译成统一的创作者意图对象。 +两种模式最终都编译成统一的陶泥主意图对象。 ## 4.3 必填与选填要分开 @@ -272,7 +272,7 @@ - 标志性要素 - 禁止事项 -这样既能保证世界最小成型,又不会把创作者门槛抬高。 +这样既能保证世界最小成型,又不会把陶泥主门槛抬高。 ## 4.3.1 抽象统一“聊天补充设定”能力 @@ -307,11 +307,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都 1. AI 不得在重生成时覆盖该内容 2. 长尾内容只能围绕它展开 -3. 结果页里应明确显示其为“创作者锚点” +3. 结果页里应明确显示其为“陶泥主锚点” ## 5. 生成链路优化方案 -## 5.1 新增“创作者意图编译层” +## 5.1 新增“陶泥主意图编译层” 在真正开始世界生成前,先新增一个轻量阶段: @@ -324,19 +324,19 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都 输出: -- 创作者意图摘要 +- 陶泥主意图摘要 - 世界锚点摘要 - 系统识别出的关键角色 / 冲突 / 地点 / 禁忌 这一步的作用不是生成世界,而是先回答: 1. 系统理解到的世界核心是什么 -2. 哪些内容将被视为创作者强锚点 +2. 哪些内容将被视为陶泥主强锚点 3. 哪些内容将交给 AI 扩展 ## 5.2 把当前生成链改成“关键先行、长尾后补” -当前 `generateCustomWorldProfile(...)` 的分阶段结构可以保留,但生成顺序需要更创作者化。 +当前 `generateCustomWorldProfile(...)` 的分阶段结构可以保留,但生成顺序需要更陶泥主化。 建议改成 5 层: @@ -347,9 +347,9 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都 - 世界框架 - ThemePack - StoryGraph 的基础版 -- 创作者锚点摘要 +- 陶泥主锚点摘要 -这一层完成后,系统应能让创作者看到: +这一层完成后,系统应能让陶泥主看到: - 世界现在到底被理解成了什么 - 哪些冲突 / 势力 / 意象被识别出来了 @@ -362,11 +362,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都 - 关键场景角色 - 关键地点 -这一层优先围绕创作者明确输入的角色和地点,而不是先铺满全部数量。 +这一层优先围绕陶泥主明确输入的角色和地点,而不是先铺满全部数量。 -### 第三层:创作者校对层 +### 第三层:陶泥主校对层 -在继续展开长尾内容前,应允许创作者做一次轻量校对: +在继续展开长尾内容前,应允许陶泥主做一次轻量校对: - 确认关键角色是否对 - 确认关键地点是否对 @@ -408,7 +408,7 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都 这样做的价值很高: 1. 降低首次等待焦虑 -2. 让创作者更早介入关键对象校正 +2. 让陶泥主更早介入关键对象校正 3. 避免系统在创作方向还没稳定前,先铺满大量长尾内容 ## 5.4 角色与场景生成要改成“锚点优先 + 长尾补位” @@ -417,11 +417,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都 优化后应改为: -1. 先生成创作者明确指定的关键角色 / 地点 +1. 先生成陶泥主明确指定的关键角色 / 地点 2. 再根据世界冲突自动补位缺失的角色原型和场景功能位 3. 最后再铺长尾 -这样生成出来的世界会更像“围绕创作者意图长出来”,而不是“先生成了一个完整世界,再让创作者去认领” +这样生成出来的世界会更像“围绕陶泥主意图长出来”,而不是“先生成了一个完整世界,再让陶泥主去认领” ## 6. 结果页与编辑工作台优化方案 @@ -439,7 +439,7 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都 优化后建议改成 4 层工作台: 1. 创作锚点 - - 展示创作者输入和锁定内容 + - 展示陶泥主输入和锁定内容 2. 关键对象 - 关键角色、关键地点、关键冲突对象 @@ -448,11 +448,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都 - AI 自动展开的长尾角色、长尾地点、补位内容 4. 世界编译摘要 - - 展示世界线程、题材包、运行时摘要,但默认不要求创作者编辑 + - 展示世界线程、题材包、运行时摘要,但默认不要求陶泥主编辑 ## 6.2 编辑界面应遵守“高价值字段前置,低价值字段折叠” -对创作者默认暴露的应是: +对陶泥主默认暴露的应是: - 角色一句话定位 - 角色表面面貌 @@ -507,7 +507,7 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都 ## 7.1 新增 `CustomWorldCreatorIntent` -建议新增创作者输入的统一结构: +建议新增陶泥主输入的统一结构: ```ts interface CustomWorldCreatorIntent { @@ -529,7 +529,7 @@ interface CustomWorldCreatorIntent { 作用: -- 把“创作者真正输入了什么”从最终 `CustomWorldProfile` 中分离出来 +- 把“陶泥主真正输入了什么”从最终 `CustomWorldProfile` 中分离出来 ## 7.2 新增 `CustomWorldAnchorPack` @@ -583,7 +583,7 @@ interface CustomWorldGenerationDraft { 作用: -- 让“创作者输入、AI 编译、结果编辑”成为连续工作流,而不是只有最终成品对象 +- 让“陶泥主输入、AI 编译、结果编辑”成为连续工作流,而不是只有最终成品对象 ## 8. 与当前仓库的接入建议 @@ -597,7 +597,7 @@ interface CustomWorldGenerationDraft { 目标: - 把单 textarea 升级为“快速模式 + 卡片模式” -- 新增创作者意图状态 +- 新增陶泥主意图状态 - 新增锁定和局部重生成入口 ## 8.2 prompt 与生成服务层 @@ -623,7 +623,7 @@ interface CustomWorldGenerationDraft { 目标: -- 为 `CustomWorldProfile` 增加创作者意图与锚点相关扩展字段 +- 为 `CustomWorldProfile` 增加陶泥主意图与锚点相关扩展字段 - 保持旧档兼容 - 让现有 builder 能同时消费 `creatorIntent + anchorPack + profile seed` @@ -647,28 +647,28 @@ interface CustomWorldGenerationDraft { 本次优化不做以下事情: 1. 不推翻当前自定义世界最终输出仍是 `CustomWorldProfile` 的兼容目标 -2. 不把所有运行时结构都暴露给创作者直接编辑 -3. 不要求创作者理解 `themePack / storyGraph / knowledgeFacts / threadContracts` 等系统结构 -4. 不把复杂数值平衡、掉落预算、build 预算转移给创作者 +2. 不把所有运行时结构都暴露给陶泥主直接编辑 +3. 不要求陶泥主理解 `themePack / storyGraph / knowledgeFacts / threadContracts` 等系统结构 +4. 不把复杂数值平衡、掉落预算、build 预算转移给陶泥主 5. 不把“高自由度”理解成“所有字段都手工可改” ## 10. 验收标准 做到以下几点,才算这次优化真正成立: -1. 创作者可以不用写长文,只靠卡片输入也能完成自定义世界创建。 -2. 系统会明确区分“创作者锚点”和“AI 自动展开内容”。 -3. 创作者不再需要默认手改大量 `skills / initialItems / backstoryReveal / scene connections` 才能得到可用世界。 +1. 陶泥主可以不用写长文,只靠卡片输入也能完成自定义世界创建。 +2. 系统会明确区分“陶泥主锚点”和“AI 自动展开内容”。 +3. 陶泥主不再需要默认手改大量 `skills / initialItems / backstoryReveal / scene connections` 才能得到可用世界。 4. 结果页支持锁定关键角色、关键地点、关键冲突,并支持局部重生成。 5. 重新生成不再默认覆盖整个世界。 6. 当前 `framework -> themePack -> storyGraph -> role/landmark` 生成主链可以继续复用,而不是被废弃。 7. 结果页默认展示的是高创作价值对象,而不是系统级低层字段。 -8. 长尾内容生成明显后置于关键对象生成,创作者能更早看到并修正关键对象。 +8. 长尾内容生成明显后置于关键对象生成,陶泥主能更早看到并修正关键对象。 9. 旧的自由文本输入模式仍然可用,但不再是唯一入口。 ## 11. 推荐落地顺序 -## 阶段 A:先加创作者意图层 +## 阶段 A:先加陶泥主意图层 先做: @@ -678,7 +678,7 @@ interface CustomWorldGenerationDraft { 目标: -- 先把创作者输入从“单一大文本”升级成“可识别的创作锚点” +- 先把陶泥主输入从“单一大文本”升级成“可识别的创作锚点” ## 阶段 B:再加锚点包与锁定能力 @@ -721,4 +721,4 @@ interface CustomWorldGenerationDraft { 当前自定义世界流程最需要优化的,不是“让 AI 再多生成一点内容”,而是: -**把创作者从低价值字段编辑里解放出来,让创作者负责世界灵魂锚点,让 AI 负责围绕这些锚点分层生成、分层展开、分层可控地把世界长出来。** +**把陶泥主从低价值字段编辑里解放出来,让陶泥主负责世界灵魂锚点,让 AI 负责围绕这些锚点分层生成、分层展开、分层可控地把世界长出来。** diff --git a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md index ace7d6ed..dbf94a58 100644 --- a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md @@ -13,7 +13,7 @@ -> 选择“拼图玩法” -> Agent 聊天收束高杠杆锚点 -> 生成拼图结果页 --> 创作者生成并确认拼图图片 +-> 陶泥主生成并确认拼图图片 -> 发布到拼图广场 -> 玩家从广场进入第 1 关 -> 全屏拼图运行时 @@ -26,7 +26,7 @@ ## 1. 一句话定义 -让创作者通过 Agent 对话确定拼图作品的高杠杆视觉锚点,再由系统生成结果页、AI 生成拼图图片并发布到广场;玩家进入游戏后,在全屏拼图画布中通过交换、合并、拖动和拆分完成关卡,并沿着“相似题材优先、同作者次优先”的关卡链持续游玩。 +让陶泥主通过 Agent 对话确定拼图作品的高杠杆视觉锚点,再由系统生成结果页、AI 生成拼图图片并发布到广场;玩家进入游戏后,在全屏拼图画布中通过交换、合并、拖动和拆分完成关卡,并沿着“相似题材优先、同作者次优先”的关卡链持续游玩。 --- @@ -78,7 +78,7 @@ - 拼图关卡名 - AI 生成拼图图片的功能 - 图片题材标签 -4. 创作者发布后的拼图作品必须进入平台广场。 +4. 陶泥主发布后的拼图作品必须进入平台广场。 5. 玩家从广场进入某个作品时,第 1 关必须先显示当前作品本身。 6. 第 2 关及以后必须按照“标签相似度权重 `70%` + 同作者权重 `30%`”选择下一关。 7. 游戏运行时必须全屏展示拼图画布。 @@ -109,8 +109,8 @@ 1. 不做旋转拼块。 2. 不做异形拼块。 -3. 不做时间限制和失败倒计时。 -4. 不做提示系统、道具系统和体力系统。 +3. 初版不做时间限制和失败倒计时;`2026-04-29` 起运行时升级为限时关卡,详见 `docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md`。 +4. 初版不做提示系统、道具系统和体力系统;`2026-04-29` 起先落地提示、查看原图、冻结时间三种拼图道具。 5. 不做复杂剧情文本或玩法说明文本默认堆在 UI 中。 6. 不做独立于平台创作中心之外的新创作站点。 7. 不做前端本地计算下一关推荐结果。 @@ -129,7 +129,7 @@ 创建拼图作品 -> Agent 聊天收束 5 个视觉锚点 -> 生成结果页 --> 创作者确认关卡名、标签、图片 +-> 陶泥主确认关卡名、标签、图片 -> 发布到拼图广场 ``` @@ -137,12 +137,12 @@ ### 5.1.1 已发布作品二次编辑 -创作者在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。 +陶泥主在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。 落地规则: 1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。 -2. 恢复后的结果页沿用原草稿、当前正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成图片。 +2. 恢复后的结果页沿用原草稿、当前正式图、标题、摘要和标签;陶泥主可以继续改标题、摘要、标签,并重新生成图片。 3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId`。 4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。 5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。 @@ -210,9 +210,9 @@ 拼图 Agent 必须做到: -1. 优先接住创作者的画面灵感,而不是立刻列问卷。 +1. 优先接住陶泥主的画面灵感,而不是立刻列问卷。 2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。 -3. 当创作者已经说出足够信息时,优先总结,不重复追问。 +3. 当陶泥主已经说出足够信息时,优先总结,不重复追问。 4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。 - 该动作沿用 RPG 聊天链路,仍走发送消息接口,但请求体必须携带 `quickFillRequested: true`。 - 前端不补数据、不伪造锚点状态,只发送“请补充剩余关键字。”作为本轮用户消息。 @@ -255,7 +255,7 @@ interface PuzzleAnchorPack { ## 7.1 结果页定位 -拼图结果页是创作者从 Agent 共创转入正式发布前的最小工作台。 +拼图结果页是陶泥主从 Agent 共创转入正式发布前的最小工作台。 它至少承担 5 件事: @@ -303,7 +303,7 @@ interface PuzzleAnchorPack { 关卡名生成规则建议如下: 1. 默认由 Agent 根据锚点自动生成 `1` 个正式候选名。 -2. 创作者可直接手改。 +2. 陶泥主可直接手改。 3. 关卡名长度建议控制在 `4~12` 个中文字符。 4. 不允许空标题发布。 @@ -374,8 +374,8 @@ interface PuzzleAnchorPack { 拼图图片的正式资产要求: -1. 官方拼图原图统一使用 `1:1` 正方形比例。 -2. 建议第一版正式生成尺寸为 `1536 x 1536`。 +1. 官方拼图原图统一使用 `9:16` 竖屏比例。 +2. 建议第一版正式生成尺寸为 `720 x 1280`。 3. 图中不允许生成标题字、水印、边框、按钮或 UI。 4. 图像主体必须足够清晰,切成 `4*4` 后仍然有辨识信息。 @@ -502,7 +502,7 @@ tagSimilarityScore = 画面要求: 1. 拼图舞台占满可用全屏区域 -2. 真正可操作的拼图棋盘按“最大正方形”填满安全区域 +2. 真正可操作的拼图棋盘按 `9:16` 竖屏比例填满安全区域 3. 棋盘外延空间用同图模糊背景或纯净氛围底承接 4. 不默认堆玩法说明文字 @@ -641,6 +641,31 @@ V1 规则如下: 在正式实现中,建议以后端 `allTilesResolved = true` 作为唯一真相。 +## 9.13 限时与失败 + +`2026-04-29` 起,拼图运行时加入倒计时: + +1. `3x3` 关卡限时 `180` 秒。 +2. `4x4` 关卡限时 `300` 秒。 +3. 规定时间内未完成拼图,关卡状态变为 `failed`。 +4. 弹窗、查看原图覆盖、冻结时间生效期间不消耗倒计时。 +5. 通关成绩只统计有效消耗时间,不统计暂停与冻结时间。 + +## 9.14 底部道具 + +底部固定 `3` 个道具: + +1. `提示`:演示将一个最大块移动到正确位置,但不替玩家移动。 +2. `查看原图`:开关按钮,打开后把原图覆盖在拼图画布上,再次点击关闭。 +3. `冻结时间`:播放冻结特效并展示冻结剩余时长。 + +道具使用规则: + +1. 点击道具必须弹出独立确认窗口。 +2. 确认窗口期间暂停游戏时间。 +3. 正式后端运行态每次确认消耗 `1` 陶泥币。 +4. 本地调试 run 不伪造钱包扣费,只保持确认、暂停和表现一致。 + --- ## 10. 运行时状态结构建议 @@ -1059,7 +1084,7 @@ interface PuzzleRunSnapshot { 建议布局: 1. 顶部轻量 HUD -2. 中间最大正方形拼图棋盘 +2. 中间 `9:16` 竖屏拼图棋盘 3. 底部不常驻大段文案 如需操作提示,只允许短暂轻提示,不允许占据长期版面。 @@ -1116,7 +1141,7 @@ interface PuzzleRunSnapshot { 完成标准: -1. 创作者能从平台进入拼图 Agent 工作区 +1. 陶泥主能从平台进入拼图 Agent 工作区 2. 能通过聊天生成结果页草稿 ## 阶段 B:再做结果页与图片资产 @@ -1130,7 +1155,7 @@ interface PuzzleRunSnapshot { 完成标准: -1. 创作者能生成正式拼图图片并发布 +1. 陶泥主能生成正式拼图图片并发布 2. 作品能进入拼图广场 ## 阶段 C:再做拼图运行时核心循环 @@ -1187,4 +1212,4 @@ interface PuzzleRunSnapshot { 这次平台新增拼图玩法,正确的做法不是只补一个拼图画布,而是: -**把拼图作为平台内独立玩法类型接进现有 Agent-first 创作中心、结果页、发布链、广场分发链和运行时链,让创作者先收束高杠杆视觉锚点,让玩家在全屏交换-合并-拆分的拼图循环中持续游玩。** +**把拼图作为平台内独立玩法类型接进现有 Agent-first 创作中心、结果页、发布链、广场分发链和运行时链,让陶泥主先收束高杠杆视觉锚点,让玩家在全屏交换-合并-拆分的拼图循环中持续游玩。** diff --git a/docs/prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md b/docs/prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md index 55e13adc..0222f6a6 100644 --- a/docs/prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md +++ b/docs/prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md @@ -630,7 +630,7 @@ SSE 事件: 1. 增加背景音乐和环境音,但不改变四帧三段主链。 2. 为移动端生成 `9:16` 竖版裁切版本。 -3. 支持创作者手动上传某张关键帧,再生成相邻视频。 +3. 支持陶泥主手动上传某张关键帧,再生成相邻视频。 4. 支持发布后版本化替换开场动画。 5. 支持用第四幕直接生成开局场景动态背景。 6. 支持把开场动画拆出的关键帧回流为作品详情页轮播素材。 diff --git a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md index f4cf2a9a..4027645f 100644 --- a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md +++ b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md @@ -22,7 +22,7 @@ 接成一条新的稳定流程: -**每个场景由创作者在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。** +**每个场景由陶泥主在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。** 本次还追加一条必须和草稿生成阶段一起落地的约束: @@ -31,13 +31,13 @@ 补充口径修正: 1. `scene_chapter` 在本期继续保留为数据层 / 编译层 / 运行时层概念。 -2. `scene_chapter` 不作为创作者可见的独立 Tab、独立卡片或独立导航入口。 -3. 创作者配置多幕的唯一入口,是现有“场景”列表里的场景编辑弹层。 +2. `scene_chapter` 不作为陶泥主可见的独立 Tab、独立卡片或独立导航入口。 +3. 陶泥主配置多幕的唯一入口,是现有“场景”列表里的场景编辑弹层。 4. 每一幕的 NPC 配置区必须直接叠在当前幕背景预览上,以“对面角色站位”的方式呈现;三个站位既是预览,也是编辑入口。 5. 幕编辑站位中每个角色只显示角色形象与名称,不展示额外信息块、规则说明或说明性标签。 6. 幕内小预览的构图固定为左侧玩家、右侧当前幕角色;右侧三个站位采用一前两后。 前排主角色的 y 轴必须与玩家角色对齐;后排两个角色必须同一列、x 轴对齐,上下分布,且后排整体的 y 轴中点与前排主角色保持一致。 -7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待创作者补充。 +7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待陶泥主补充。 8. 角色名称显示在角色形象上方,角色渲染不附带方形 UI 底板。 9. 世界档案的场景详情页不再单独展示“场景图片”和“场景内 NPC”字段;相关兼容数据统一由多幕配置自动同步回场景对象。 @@ -55,7 +55,7 @@ 本次迭代必须同时满足以下目标: -1. 创作者可以在现有创作页面中为每个场景章节配置多幕内容。 +1. 陶泥主可以在现有创作页面中为每个场景章节配置多幕内容。 2. 每一幕都必须绑定一张正式背景图。 3. 每一幕都可以配置玩家会遇到哪些 NPC,并且保留顺序。 4. 每一幕配置的第一个 NPC 必须被系统认定为该幕主角色。 @@ -89,7 +89,7 @@ 1. 不新建独立的“场景编辑器”页面。 2. 不把幕推进逻辑放到前端本地计算。 -3. 不让创作者直接编辑底层运行时 `ChapterState` 或聊天状态对象。 +3. 不让陶泥主直接编辑底层运行时 `ChapterState` 或聊天状态对象。 4. 不做多 NPC 并行聊天。 5. 不做每一幕的复杂分支树可视化编辑器。 6. 不把“规则说明文案”默认堆到创作页或游戏 UI 面板里。 @@ -122,7 +122,7 @@ 1. 场景章节没有“幕”这一层结构化对象。 2. 背景图是场景级资产,不是幕级资产。 3. NPC 与场景的关系主要还是地点级归属,不是幕级相遇编排。 -4. 创作者无法在创作页里明确控制“这一幕谁先出场、谁是主角色”。 +4. 陶泥主无法在创作页里明确控制“这一幕谁先出场、谁是主角色”。 ## 4.2 游戏运行侧现状 @@ -185,7 +185,7 @@ 这意味着: -1. 创作者在工具里编辑的是“第几幕”。 +1. 陶泥主在工具里编辑的是“第几幕”。 2. 运行时仍然只认现有章节阶段枚举。 3. `chapterDirector` 可以继续复用,只是数据来源从“纯 quest 推导”升级成“quest + 幕蓝图联合推导”。 @@ -214,7 +214,7 @@ - `name` - `description` - `imageSrc` - - `sceneNpcIds`(仅作为兼容字段,由多幕配置自动派生,不再作为创作者可编辑字段) + - `sceneNpcIds`(仅作为兼容字段,由多幕配置自动派生,不再作为陶泥主可编辑字段) - `connections` - `sceneChapterBlueprints` 对应的多幕配置 2. 场景配置面板中,开局场景必须复用普通场景同级的配置 UI,而不是继续保留一套缩水版表单。 @@ -251,7 +251,7 @@ 原因: 1. 当前创作工作区已经进入“先收关键锚点、再逐步扩写”的阶段。 -2. 一次铺太多 playable、场景和长尾对象,会稀释创作者对第一版底稿的掌控感。 +2. 一次铺太多 playable、场景和长尾对象,会稀释陶泥主对第一版底稿的掌控感。 3. 本期还要把幕级背景图和角色主形象自动挂回草稿,如果对象规模不收束,等待时间和生成成本都会直接失控。 ### 5.5.2 幕级出演角色与背景必须由剧情引擎判定 @@ -309,7 +309,7 @@ - 角色主形象是否就绪 - 场景幕背景是否就绪 -这样创作者一进入草稿精修工作区,就能直接看到: +这样陶泥主一进入草稿精修工作区,就能直接看到: 1. 角色已经带主形象 2. 每个场景章节的每一幕已经带背景图 @@ -369,7 +369,7 @@ interface CustomWorldFoundationDraftSceneChapter { 1. `primaryNpcId` 必须等于 `encounterNpcIds[0]`,不允许单独填写成别的角色。 2. 每幕必须至少有 `1` 个 NPC。 3. 每幕必须有 `backgroundImageSrc` 或 `backgroundAssetId`。 -4. `advanceRule` 由系统按幕位置默认编译,第一版不要求创作者手改。 +4. `advanceRule` 由系统按幕位置默认编译,第一版不要求陶泥主手改。 ## 6.2 发布到运行时的蓝图结构 @@ -416,7 +416,7 @@ sceneChapterBlueprints?: SceneChapterBlueprint[] | null; 原因: 1. 现有 `landmarks` 只足够表达地点,不足够表达幕顺序。 -2. 现有 `ChapterState` 是运行时状态,不适合直接兼做创作者蓝图。 +2. 现有 `ChapterState` 是运行时状态,不适合直接兼做陶泥主蓝图。 3. 独立蓝图层更适合后端编译和发布校验。 ## 6.3 聊天状态扩展 @@ -483,9 +483,9 @@ type NpcChatTurnResult = { 新增规则: -1. 创作者从现有“场景”列表点击任一场景卡,进入对应场景编辑弹层。 +1. 陶泥主从现有“场景”列表点击任一场景卡,进入对应场景编辑弹层。 2. 多幕配置必须作为场景编辑弹层内的一个区块出现,归属于该场景。 -3. `scene_chapter` 仅作为保存层和运行时蓝图存在,不单独暴露在创作者导航里。 +3. `scene_chapter` 仅作为保存层和运行时蓝图存在,不单独暴露在陶泥主导航里。 4. 场景卡片可增加“幕数量”轻量摘要,但第一版不是阻塞项。 ## 7.2 场景编辑弹层展示要求 @@ -498,8 +498,8 @@ type NpcChatTurnResult = { 补充约束: -1. “场景图片”不再作为场景详情页里的独立字段展示,创作者只能通过每一幕的“配置背景”入口管理视觉。 -2. “场景内 NPC”不再作为场景详情页里的独立字段展示,创作者只能通过每一幕角色槽位配置相遇 NPC。 +1. “场景图片”不再作为场景详情页里的独立字段展示,陶泥主只能通过每一幕的“配置背景”入口管理视觉。 +2. “场景内 NPC”不再作为场景详情页里的独立字段展示,陶泥主只能通过每一幕角色槽位配置相遇 NPC。 3. 为兼容现有运行时与旧数据结构,场景对象上的 `imageSrc / sceneNpcIds` 仍然保留,但必须由多幕配置自动回填,前台不再暴露单独编辑控件,且不能再用 `sceneNpcIds` 限制每幕可选角色。 多幕区块至少展示: @@ -566,11 +566,11 @@ NPC 配置面板必须支持: 3. 不允许把不存在于当前世界角色池中的 id 写入幕配置。 4. 若主角色未与当前场景或线程建立任何关联,给出发布警告。 5. 存储时继续落到 `encounterNpcIds` 有序数组,槽位从左到右按顺序压缩写入。 -6. `sceneNpcIds` 不再作为创作者字段,也不再作为幕角色选择范围;保存时只从所有幕的 `encounterNpcIds` 自动派生兼容值。 +6. `sceneNpcIds` 不再作为陶泥主字段,也不再作为幕角色选择范围;保存时只从所有幕的 `encounterNpcIds` 自动派生兼容值。 ## 7.6 幕预览 -创作者在场景编辑弹层里点击“幕预览”后,必须直接进入当前幕的运行时预览。 +陶泥主在场景编辑弹层里点击“幕预览”后,必须直接进入当前幕的运行时预览。 要求如下: @@ -633,7 +633,7 @@ interface SceneActRuntimeState { ## 8.3 幕推进规则 -第一版不要求创作者手填推进条件,而是由系统按幕位置默认编译: +第一版不要求陶泥主手填推进条件,而是由系统按幕位置默认编译: 1. 第 `1` 幕默认 `after_primary_contact` - 玩家与主角色发生首次有效接触后可进入下一幕判定 @@ -871,7 +871,7 @@ Adventure 主面板在本次迭代中至少增加下面这些表现: 当下面这些结果都成立时,视为本次 PRD 已被正确落地: -1. 创作者可以在现有场景编辑弹层中配置每个场景的多幕。 +1. 陶泥主可以在现有场景编辑弹层中配置每个场景的多幕。 2. 每个场景章节都可以配置 `2~5` 幕。 3. 每一幕都可以绑定独立背景图。 4. 每一幕都可以配置有序 NPC 列表,第一位自动成为主角色。 diff --git a/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md b/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md index 74b1f621..3f5b4c51 100644 --- a/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md @@ -4,7 +4,7 @@ ## 0. 目标 -把“剩余叙世币 / 总游戏时长 / 玩过作品”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。 +把“剩余陶泥币 / 总游戏时长 / 玩过作品”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。 --- @@ -12,7 +12,7 @@ 当前三个数字来源并不统一: -1. 叙世币来自当前存档上下文,不等于账号总资产 +1. 陶泥币来自当前存档上下文,不等于账号总资产 2. 总游戏时长依赖当前快照,不代表全账号累计 3. 玩过作品当前几乎是硬编码推导,不是真实统计 @@ -39,11 +39,11 @@ ## 3. 指标定义 -## 3.1 剩余叙世币 +## 3.1 剩余陶泥币 定义: -- 当前账号可立即消费的叙世币余额 +- 当前账号可立即消费的陶泥币余额 不使用: @@ -80,7 +80,7 @@ 点击行为: -1. 叙世币卡 +1. 陶泥币卡 - 打开资产流水抽屉 2. 总游戏时长卡 - 打开游玩统计抽屉 @@ -92,8 +92,9 @@ ## 4.2 展示规则 1. 数字过大时做单位缩略展示 -2. 进入页面先展示骨架屏 -3. 数据请求失败时展示降级文案,不展示假数字 +2. “总游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天 +3. 进入页面先展示骨架屏 +4. 数据请求失败时展示降级文案,不展示假数字 --- @@ -123,7 +124,7 @@ 返回: -- 叙世币流水列表 +- 陶泥币流水列表 ### `GET /api/profile/play-stats` @@ -152,3 +153,4 @@ 2. 切换设备后看板数据一致 3. 没有存档时也能正常展示账号级数据 4. 数据加载失败时页面表现可控 +5. “总游戏时长”卡展示值始终带 `小时` 单位,例如 `0小时`、`1.5小时`、`36小时` diff --git a/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md b/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md index 630a0211..7605025c 100644 --- a/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md @@ -73,7 +73,7 @@ 首期奖励建议采用可控方案: -1. 邀请人获得叙世币 +1. 邀请人获得陶泥币 2. 被邀请人获得新手奖励 所有奖励必须走台账,不允许前端本地加值。 @@ -164,4 +164,4 @@ 1. 用户能看到自己的邀请码与邀请链接 2. 可以一键复制或分享 3. 邀请成功后能看到正确统计 -4. 奖励到账后叙世币余额同步变化 +4. 奖励到账后陶泥币余额同步变化 diff --git a/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md b/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md index b5633a81..a7a8066f 100644 --- a/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md @@ -51,11 +51,11 @@ 首期只保留两种状态: 1. `普通用户` -2. `叙世会员` +2. `陶泥会员` 会员权益首期建议控制在直接可编码的范围: -1. 每日额外叙世币领取额度 +1. 每日额外陶泥币领取额度 2. 高级世界模板或创作槽位 3. 更高的云存档上限 4. 会员专属标识 @@ -119,7 +119,7 @@ 支付成功后: 1. 刷新会员状态 -2. 刷新叙世币余额 +2. 刷新陶泥币余额 3. 刷新权益标签 --- diff --git a/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md b/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md index adb0d62d..ea6d9fa4 100644 --- a/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md @@ -8,7 +8,7 @@ 1. 头像编辑 2. 昵称编辑 -3. 叙世号展示与复制 +3. 陶泥号展示与复制 4. 登录方式与绑定状态展示 5. 进入资料编辑抽屉 @@ -22,7 +22,7 @@ - 头像占位 - 昵称 -- 叙世号 +- 陶泥号 - 登录方式 - 绑定状态 @@ -31,7 +31,7 @@ 1. 头像按钮和昵称编辑按钮都直接打开账号弹窗,信息架构混在一起 2. 头像当前只是视觉壳,没有真正的上传与裁剪能力 3. 昵称缺少明确的编辑规则与唯一性策略 -4. 叙世号只是前端拼接值,不适合长期作为正式公开识别码 +4. 陶泥号只是前端拼接值,不适合长期作为正式公开识别码 --- @@ -43,7 +43,7 @@ 2. 资料编辑抽屉 3. 头像上传、裁切、保存 4. 昵称编辑、校验、保存 -5. 叙世号固定生成与复制 +5. 陶泥号固定生成与复制 6. 登录方式与账号状态标签展示 ## 2.2 本期不做 @@ -63,7 +63,7 @@ - 用户头像 - 用户昵称 -- `叙世号` +- `陶泥号` - 登录方式标签 - 账号状态标签 - 资料编辑入口 @@ -85,7 +85,7 @@ - 打开“编辑资料”抽屉,并默认聚焦头像编辑区域 2. 点击昵称右侧编辑按钮 - 打开“编辑资料”抽屉,并默认聚焦昵称输入框 -3. 点击叙世号复制按钮 +3. 点击陶泥号复制按钮 - 直接复制,并给出轻提示 4. 点击登录方式/状态标签 - 不跳页,不弹复杂说明 @@ -125,9 +125,9 @@ 4. 不要求全站唯一,但要允许后端做敏感词审核 5. 审核失败时返回明确错误 -## 4.3 叙世号 +## 4.3 陶泥号 -叙世号规则: +陶泥号规则: 1. 作为公开可复制识别码 2. 用户创建后固定生成,不允许用户修改 @@ -207,6 +207,6 @@ 1. 用户可以上传并保存头像 2. 用户可以修改昵称并实时看到更新 -3. 叙世号由后端返回,复制后可正常使用 +3. 陶泥号由后端返回,复制后可正常使用 4. 未登录或待绑定状态下,不出现无效编辑入口 5. 页面不出现冗长规则说明文案 diff --git a/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md b/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md index fca50ce9..07667ea6 100644 --- a/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md +++ b/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md @@ -35,7 +35,7 @@ TXT 模式核心玩法是一个包含“创作编辑器 -> 测试体验 -> 正 1. 支持创建 TXT 模式作品。 2. 支持 TXT 模式作品的完整创作流程。 -3. 支持创作者测试体验。 +3. 支持陶泥主测试体验。 4. 支持玩家正式游玩。 5. 支持文本模式运行。 6. 支持双会话机制。 @@ -174,9 +174,9 @@ TXT 模式核心玩法必须完整保留双会话机制。 2. 正式继续体验 3. 正式游玩推进 -## 7.2 创作者测试/读档会话 +## 7.2 陶泥主测试/读档会话 -创作者测试/读档会话用于: +陶泥主测试/读档会话用于: 1. 编辑器内测试体验 2. 指定存档加载 diff --git a/docs/technical/API_SERVER_CHARACTER_VISUAL_EXTERNAL_GENERATION_RUNTIME_FIX_2026-04-23.md b/docs/technical/API_SERVER_CHARACTER_VISUAL_EXTERNAL_GENERATION_RUNTIME_FIX_2026-04-23.md index 31c5813f..0d0128a3 100644 --- a/docs/technical/API_SERVER_CHARACTER_VISUAL_EXTERNAL_GENERATION_RUNTIME_FIX_2026-04-23.md +++ b/docs/technical/API_SERVER_CHARACTER_VISUAL_EXTERNAL_GENERATION_RUNTIME_FIX_2026-04-23.md @@ -45,7 +45,7 @@ 修复: 1. 在 `map_password_entry_error(...)` 中补充 `InvalidPublicUserCode` -2. 返回中文错误文案 `叙世号格式不正确` +2. 返回中文错误文案 `陶泥号格式不正确` ### 3.3 `module-custom-world` 的 `Display` 分支未覆盖新字段错误 diff --git a/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md index c395fb31..56450f6b 100644 --- a/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md +++ b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md @@ -1,8 +1,8 @@ -# 资产操作叙世币消耗接入方案 +# 资产操作陶泥币消耗接入方案 ## 背景 -当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成和作品发布由 Axum API 调用外部模型或写入业务状态,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此计费需要拆成两层: +当前陶泥币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成和作品发布由 Axum API 调用外部模型或写入业务状态,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此计费需要拆成两层: - SpacetimeDB 负责钱包余额和流水的原子变更。 - Axum 资产操作服务负责在执行业务资产操作前扣费,并在生成、持久化或发布失败时补偿退款。 @@ -24,13 +24,13 @@ 暂不接入以下入口: - 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。 -- 手动上传封面:不调用外部生成模型,不消耗叙世币。 +- 手动上传封面:不调用外部生成模型,不消耗陶泥币。 - 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。 - 文本实体、NPC 生成:本次需求聚焦图片资产和发布资产操作,首期只覆盖可明确归属的入口。 ## 计费规则 -- 每次可计费资产操作消耗 `1` 枚叙世币。 +- 每次可计费资产操作消耗 `1` 枚陶泥币。 - 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行。 - 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作。 - 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款。 diff --git a/docs/technical/AUTH_SPACETIMEDB_TABLE_SPLIT_STAGE2_2026-04-24.md b/docs/technical/AUTH_SPACETIMEDB_TABLE_SPLIT_STAGE2_2026-04-24.md index 7a3a4cb4..01f5c977 100644 --- a/docs/technical/AUTH_SPACETIMEDB_TABLE_SPLIT_STAGE2_2026-04-24.md +++ b/docs/technical/AUTH_SPACETIMEDB_TABLE_SPLIT_STAGE2_2026-04-24.md @@ -34,7 +34,7 @@ Stage 1 已把 Rust 鉴权快照同步到 SpacetimeDB 的 `auth_store_snapshot` | 字段 | 类型 | 说明 | | --- | --- | --- | | `user_id` | `String` | 主键。 | -| `public_user_code` | `String` | 公开叙世号。 | +| `public_user_code` | `String` | 公开陶泥号。 | | `username` | `String` | 当前账号用户名。 | | `display_name` | `String` | 展示名。 | | `phone_number_masked` | `Option` | 脱敏手机号。 | diff --git a/docs/technical/CUSTOM_WORLD_DRAFT_SCENE_TASK_AND_ACT_EVENT_FIELDS_2026-04-25.md b/docs/technical/CUSTOM_WORLD_DRAFT_SCENE_TASK_AND_ACT_EVENT_FIELDS_2026-04-25.md index f341e108..4b1905d7 100644 --- a/docs/technical/CUSTOM_WORLD_DRAFT_SCENE_TASK_AND_ACT_EVENT_FIELDS_2026-04-25.md +++ b/docs/technical/CUSTOM_WORLD_DRAFT_SCENE_TASK_AND_ACT_EVENT_FIELDS_2026-04-25.md @@ -37,7 +37,7 @@ - 当前场景的核心任务描述。 - 文本会作为游戏中首次进入某个场景生成章节任务的关键上下文。 - 必须结合场景描述、场景入口钩子、出场角色与 3 幕事件,说明玩家首次进入该场景时要完成什么。 - - 世界档案的场景详情页必须直接展示该字段,便于创作者确认每个场景的默认章节任务。 + - 世界档案的场景详情页必须直接展示该字段,便于陶泥主确认每个场景的默认章节任务。 ### Landmark 生成源字段 diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index 7854fff2..d12637c0 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -6,33 +6,33 @@ 本轮在“我的”页面的“会员充值”入口落地账户充值弹窗,包含两个页签: -1. `叙世币充值` +1. `陶泥币充值` 2. `会员卡充值` 前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。当前没有真实支付网关,本轮采用服务端模拟支付成功:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。后续接入真实支付时,只替换订单支付状态推进,不改前端套餐与账户快照 contract。 ## 2. 产品规则 -### 2.1 叙世币充值套餐 +### 2.1 陶泥币充值套餐 -| productId | 叙世币 | 金额分 | 徽标 | 说明 | +| productId | 陶泥币 | 金额分 | 徽标 | 说明 | | --- | ---: | ---: | --- | --- | -| `points_60` | 60 | 600 | 首充双倍 | 首充送60叙世币 | -| `points_180` | 180 | 1800 | 首充双倍 | 首充送180叙世币 | -| `points_300` | 300 | 3000 | 首充双倍 | 首充送300叙世币 | -| `points_680` | 680 | 6800 | 首充双倍 | 首充送680叙世币 | -| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280叙世币 | -| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280叙世币 | +| `points_60` | 60 | 600 | 首充双倍 | 首充送60陶泥币 | +| `points_180` | 180 | 1800 | 首充双倍 | 首充送180陶泥币 | +| `points_300` | 300 | 3000 | 首充双倍 | 首充送300陶泥币 | +| `points_680` | 680 | 6800 | 首充双倍 | 首充送680陶泥币 | +| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280陶泥币 | +| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280陶泥币 | -叙世币充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账叙世币为基础叙世币与等额赠送叙世币之和;已有充值流水后只到账基础叙世币。实际到账叙世币写入交易流水,余额以 SpacetimeDB projection 为准。 +陶泥币充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `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% | +| `member_month` | 月卡 | 30 | 2800 | 免陶泥币回合数100,每日签到加成0% | +| `member_season` | 季卡 | 90 | 7800 | 免陶泥币回合数100,每日签到加成100% | +| `member_year` | 年卡 | 365 | 24800 | 免陶泥币回合数100,每日签到加成210% | 购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。 @@ -42,8 +42,8 @@ 需要 Bearer JWT。返回: -1. 当前叙世币余额、会员状态、到期时间 -2. 叙世币套餐与会员套餐 +1. 当前陶泥币余额、会员状态、到期时间 +2. 陶泥币套餐与会员套餐 3. 会员权益表 4. 最近订单摘要 @@ -64,7 +64,7 @@ 1. 校验 `productId` 2. 后端创建已支付订单 -3. 叙世币套餐写入钱包余额与流水 +3. 陶泥币套餐写入钱包余额与流水 4. 会员套餐写入会员状态 5. 返回最新账户中心快照与订单摘要 @@ -74,15 +74,15 @@ 1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。 2. 弹窗顶部标题为 `账户充值`,右上角关闭。 -3. 默认打开 `叙世币充值`,可切换到 `会员卡充值`。 +3. 默认打开 `陶泥币充值`,可切换到 `会员卡充值`。 4. 点击套餐后调用下单接口,按钮进入处理中状态,成功后刷新 `profileDashboard`。 -5. 弹窗内不写大段说明文案,只保留必要金额、叙世币、会员权益和状态反馈。 +5. 弹窗内不写大段说明文案,只保留必要金额、陶泥币、会员权益和状态反馈。 6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 ## 5. 验收 -1. 普通用户打开弹窗能看到叙世币与会员套餐。 -2. 叙世币购买后余额增加,流水来源为 `points_recharge`。 -3. 首充赠送只在首次叙世币充值时生效。 +1. 普通用户打开弹窗能看到陶泥币与会员套餐。 +2. 陶泥币购买后余额增加,流水来源为 `points_recharge`。 +3. 首充赠送只在首次陶泥币充值时生效。 4. 会员购买后会员状态与到期时间立即更新。 5. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。 diff --git a/docs/technical/MY_TAB_PROFILE_EDIT_AND_AVATAR_CROP_2026-04-29.md b/docs/technical/MY_TAB_PROFILE_EDIT_AND_AVATAR_CROP_2026-04-29.md new file mode 100644 index 00000000..54fe7c0a --- /dev/null +++ b/docs/technical/MY_TAB_PROFILE_EDIT_AND_AVATAR_CROP_2026-04-29.md @@ -0,0 +1,89 @@ +# “我的”资料卡昵称与头像编辑落地说明 + +日期:`2026-04-29` + +## 1. 背景 + +本次迭代基于 `docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md` 落地,但交互口径有两处收敛: + +1. 昵称编辑不进入账号安全弹窗,点击昵称后的编辑按钮直接打开独立轻弹窗。 +2. 头像编辑不进入通用资料抽屉,点击头像先选择本地图片,校验通过后进入头像裁剪弹窗。 + +资料卡仍保持清爽,不展示规则说明型长文案。 + +## 2. 前端交互 + +### 2.1 陶泥号复制 + +1. 点击“我的”页陶泥号后的复制按钮后,按钮文案临时切换为 `已复制`。 +2. 复制失败时临时切换为 `复制失败`。 +3. 状态自动恢复为 `复制`。 + +### 2.2 昵称修改 + +1. 点击昵称右侧编辑按钮打开独立弹窗。 +2. 弹窗内只提供昵称输入、取消、保存。 +3. 前端先做长度与字符校验: + - `2-20` 个字符。 + - 允许中文、英文、数字、下划线。 + - 不允许纯空白。 +4. 保存调用 `PATCH /api/profile/me`,成功后即时回写 `AuthUiContext.user`。 + +### 2.3 头像上传与裁剪 + +1. 点击头像触发文件选择。 +2. 前端先审核文件: + - MIME 类型仅允许 `image/jpeg`、`image/png`、`image/webp`。 + - 单文件不超过 `5MB`。 +3. 校验通过后读取为图片,打开裁剪弹窗。 +4. 裁剪工具使用正方形裁剪框,支持拖动裁剪区域与缩放图片。 +5. 保存时前端输出 `256x256` 的 PNG data URL,调用 `PATCH /api/profile/me` 保存为账号头像。 +6. 成功后资料卡头像立即展示新图。 + +## 3. 后端契约 + +### `PATCH /api/profile/me` + +请求: + +```json +{ + "displayName": "新昵称", + "avatarDataUrl": "data:image/png;base64,..." +} +``` + +两个字段均可选,但至少提供一个有效字段。 + +响应: + +```json +{ + "user": { + "id": "user_00000001", + "publicUserCode": "SY-00000001", + "username": "phone_xxx", + "displayName": "新昵称", + "avatarUrl": "data:image/png;base64,...", + "phoneNumberMasked": "138****8000", + "loginMethod": "phone", + "bindingStatus": "active", + "wechatBound": false + } +} +``` + +## 4. 存储边界 + +当前头像先作为裁剪后的 `256x256` data URL 写入认证快照,保证账号资料可立即持久化和恢复。后续若接入 OSS 头像对象,应保持前端裁剪输出不变,只把后端 `avatarUrl` 从 data URL 替换为私有读代理 URL。 + +SpacetimeDB 正式表 `user_account` 需要增加 `avatar_url: Option`,并在认证快照导入/导出、迁移导入兼容中对齐。 + +## 5. 验收 + +1. 创作页已发布作品分享按钮点击后显示 `已复制`。 +2. “我的”页陶泥号复制按钮点击后显示 `已复制`。 +3. “我的”页不展示 `手机号` 与 `正常` 标签。 +4. 昵称编辑成功后,资料卡与顶部账号入口同步新昵称。 +5. 非法头像文件不会进入裁剪流程。 +6. 裁剪保存成功后,资料卡头像展示裁剪后的图片。 diff --git a/docs/technical/MY_TAB_REFERRAL_AND_COMMUNITY_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_REFERRAL_AND_COMMUNITY_IMPLEMENTATION_2026-04-25.md index a8ff771c..68e9d675 100644 --- a/docs/technical/MY_TAB_REFERRAL_AND_COMMUNITY_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_REFERRAL_AND_COMMUNITY_IMPLEMENTATION_2026-04-25.md @@ -1,20 +1,20 @@ # 我的 Tab 邀请与玩家社区首期落地方案 -更新时间:`2026-04-25` +更新时间:`2026-04-29` ## 目标 在现有“我的”Tab 常用功能区落地三个轻量入口: 1. `邀请好友`:弹出面板展示当前账号绑定的邀请码。 -2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 叙世币。 -3. `玩家社区`:弹出面板展示微信群与 QQ 群二维码占位图,后续替换为正式图片。 +2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 陶泥币。 +3. `玩家社区`:弹出面板展示微信群与 QQ 群正式二维码图片。 ## 后端边界 - 邀请码、邀请关系与奖励发放全部存入 `server-rs/crates/spacetime-module`。 - Axum 只做鉴权、参数转发与响应映射,不在 API 层自行计算奖励。 -- 前端只读取后端状态与调用提交接口,不做本地加叙世币。 +- 前端只读取后端状态与调用提交接口,不做本地加陶泥币。 - 钱包余额继续复用 `profile_dashboard_state.wallet_balance`。 - 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型: - `invite_inviter_reward` @@ -43,7 +43,7 @@ - 每个用户拥有一个稳定邀请码,首次进入邀请中心时自动生成。 - 用户不能填写自己的邀请码。 - 用户最多填写一个邀请码,成功后不可修改。 -- 被邀请者绑定成功后获得 `30` 叙世币。 +- 被邀请者绑定成功后获得 `30` 陶泥币。 - 邀请者每天最多获得 `10` 次邀请奖励,超过后关系仍可绑定,被邀请者仍获得奖励,邀请者当次不再加分。 - 每次奖励都写入钱包流水,钱包余额以后端返回为准。 @@ -69,13 +69,13 @@ - `server-rs/crates/spacetime-module` 已新增邀请码与邀请关系表,邀请中心读取和填码绑定均通过 SpacetimeDB procedure 执行。 - `server-rs/crates/api-server` 已挂接 `/api/runtime/profile/referrals/*` 与 `/api/profile/referrals/*` 两组路由。 -- 前端“我的”Tab 三个快捷入口均打开独立弹窗,玩家社区先使用空白二维码占位。 -- 复制邀请会复制邀请码和邀请链接;填码成功后刷新个人看板叙世币。 +- 前端“我的”Tab 三个快捷入口均打开独立弹窗,玩家社区使用 `media/social-media-group/wechat.png` 与 `media/social-media-group/qq.png` 两张正式二维码图片。 +- 复制邀请会复制邀请码和邀请链接;填码成功后刷新个人看板陶泥币。 ## 前端交互 - 三个入口继续放在“我的”Tab 常用功能区,不新增页面。 - `邀请好友` 弹窗展示邀请码、复制按钮、邀请链接。 - `填邀请码` 弹窗在未绑定时展示输入框;已绑定时展示短状态。 -- `玩家社区` 弹窗展示两个紧凑二维码占位区。 +- `玩家社区` 弹窗展示两个紧凑二维码图片区,保留微信群与 QQ 群短标签。 - 弹窗文案只保留必要标签和短提示,不放长规则说明。 diff --git a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md index fdfb33ea..29c5eacc 100644 --- a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md +++ b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md @@ -1,6 +1,6 @@ # 密码登录入口历史落地设计 -> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。 +> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或陶泥号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。 > > 2026-04-28 更新:为开发期本地/测试服联调新增服务端环境变量 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED`,默认 `false`。仅当该变量显式为 `true` 时,`POST /api/auth/entry` 可对未知手机号用本次密码直接创建账号并登录;默认关闭时仍严格保持未知手机号返回 `401` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。 @@ -17,7 +17,7 @@ 1. `api-server` 对外只暴露 `phone + password` 的最小接口。 2. `module-auth` 只负责已存在手机号账号的密码校验。 -3. 密码入口不创建账号,不接收邮箱、用户名或叙世号。 +3. 密码入口不创建账号,不接收邮箱、用户名或陶泥号。 4. 登录成功后与 JWT、refresh cookie 的衔接方式。 ## 1.1 当前冻结结论 @@ -239,7 +239,7 @@ 1. 未知手机号密码登录返回 `401`,且不创建账号。 2. 已登录手机号账号设置密码后可用 `phone + password` 登录。 3. 同手机号错误密码返回 `401`。 -4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`。 +4. 邮箱、用户名或陶泥号作为密码登录标识返回 `400`。 5. 登录成功时返回 access token。 6. 登录成功时写回 refresh cookie。 7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。 diff --git a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md index 4a819b98..8ed2f1cb 100644 --- a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md +++ b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md @@ -19,7 +19,7 @@ 沿用现有 `POST /api/auth/entry`: 1. 请求字段固定为 `phone`、`password`,前端只提交手机号。 -2. 后端只按标准手机号归一化后查找账号,不兼容邮箱、用户名、叙世号或历史开发游客标识。 +2. 后端只按标准手机号归一化后查找账号,不兼容邮箱、用户名、陶泥号或历史开发游客标识。 3. 手机号不存在时返回 `401`,不创建账号。 4. 手机号存在但未设置密码时返回 `401`。 5. 校验成功后签发 access token,并写入 refresh cookie。 diff --git a/docs/technical/PRODUCT_NAMING_TAONI_RENAME_2026-04-29.md b/docs/technical/PRODUCT_NAMING_TAONI_RENAME_2026-04-29.md new file mode 100644 index 00000000..4ba5461b --- /dev/null +++ b/docs/technical/PRODUCT_NAMING_TAONI_RENAME_2026-04-29.md @@ -0,0 +1,23 @@ +# 陶泥产品命名替换落地说明 + +## 背景 + +本轮将产品中文展示名从“叙世”调整为“陶泥”,并同步调整平台内三类对外称谓: + +- `叙世币` 对外展示为 `陶泥币` +- `叙世号` 对外展示为 `陶泥号` +- `创作者` 对外展示为 `陶泥主` + +## 落地边界 + +1. 前端页面、弹窗、测试断言和后端返回给用户的中文错误文案统一使用新称谓。 +2. SpacetimeDB 表字段、Rust/TypeScript contract 字段、流水来源枚举、`points_*` 商品 ID、`public_user_code` 字段名继续保持不变,避免引入数据库迁移和历史数据兼容风险。 +3. 公开编号现有 `SY-XXXXXXXX` 格式本轮不迁移,只调整用户可见标签为“陶泥号”;编号格式如需改为新前缀,应另起迁移方案并同步老用户兼容策略。 +4. 历史日志、构建产物、第三方依赖和生成绑定不参与本轮文本替换。 + +## 验收点 + +1. 首页、登录绑定页、我的页和搜索结果不再展示旧产品名。 +2. 钱包、充值、邀请、兑换码、资产计费和拼图道具确认文案统一展示“陶泥币”。 +3. 账号公开标识相关错误和搜索空状态统一展示“陶泥号”。 +4. 创作相关可见默认称谓使用“陶泥主”。 diff --git a/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md index 02c593b3..fb11beb5 100644 --- a/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md +++ b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md @@ -2,9 +2,9 @@ ## 1. 目标 -本轮在现有“我的”资料与钱包 projection 上新增兑换码能力。用户兑换成功后直接增加叙世币余额,写入 `profile_wallet_ledger`,并同步刷新 `profile_dashboard_state.wallet_balance`。 +本轮在现有“我的”资料与钱包 projection 上新增兑换码能力。用户兑换成功后直接增加陶泥币余额,写入 `profile_wallet_ledger`,并同步刷新 `profile_dashboard_state.wallet_balance`。 -管理侧本轮只提供后端 API,不新增管理后台页面。私有兑换码创建时支持内部 `userId` 与公开叙世号两类输入,后端创建阶段统一解析成内部 `userId` 存储。 +管理侧本轮只提供后端 API,不新增管理后台页面。私有兑换码创建时支持内部 `userId` 与公开陶泥号两类输入,后端创建阶段统一解析成内部 `userId` 存储。 ## 2. 兑换码类型 @@ -26,7 +26,7 @@ | --- | --- | --- | | `code` | `String` | 主键,标准化后的兑换码。 | | `mode` | `RuntimeProfileRedeemCodeMode` | 兑换码模式。 | -| `reward_points` | `u64` | 单次到账叙世币。 | +| `reward_points` | `u64` | 单次到账陶泥币。 | | `max_uses` | `u32` | 公共码为单用户上限,唯一码/私有码为全局上限。 | | `global_used_count` | `u32` | 全局已使用次数。公共码也记录总使用次数,但不参与公共码上限判断。 | | `enabled` | `bool` | 是否启用。 | @@ -42,7 +42,7 @@ | `usage_id` | `String` | 主键,格式 `redeem:{code}:{user_id}:{micros}:{sequence}`。 | | `code` | `String` | 兑换码。 | | `user_id` | `String` | 兑换用户。 | -| `amount_granted` | `u64` | 到账叙世币。 | +| `amount_granted` | `u64` | 到账陶泥币。 | | `created_at` | `Timestamp` | 兑换时间。 | 索引:`code`、`user_id`、`(code, user_id)`。 @@ -121,7 +121,7 @@ “我的”页头像右侧入口由 `会员充值` 改为 `兑换码`。点击打开独立模态窗口,窗口内只保留输入框、兑换按钮和后端返回提示,不展示兑换规则说明。 -成功后展示 `已到账 X 叙世币`,并刷新 profile dashboard。失败后直接展示后端 `message`。 +成功后展示 `已到账 X 陶泥币`,并刷新 profile dashboard。失败后直接展示后端 `message`。 ## 8. 测试矩阵 diff --git a/docs/technical/PUBLIC_ID_USER_AND_GALLERY_SEARCH_DESIGN_2026-04-23.md b/docs/technical/PUBLIC_ID_USER_AND_GALLERY_SEARCH_DESIGN_2026-04-23.md index 2491e7fa..3020f32e 100644 --- a/docs/technical/PUBLIC_ID_USER_AND_GALLERY_SEARCH_DESIGN_2026-04-23.md +++ b/docs/technical/PUBLIC_ID_USER_AND_GALLERY_SEARCH_DESIGN_2026-04-23.md @@ -2,7 +2,7 @@ ## 1. 背景 -当前前端展示的“叙世号”由前端基于 `AuthUser.id` 临时拼装: +当前前端展示的“陶泥号”由前端基于 `AuthUser.id` 临时拼装: - 前缀固定为 `SY-` - 取 `user.id` 或 `username` 去除非字母数字字符后的末 8 位 @@ -174,7 +174,7 @@ 1. `id` 返回内部 ID 仅供当前工程内部跳转与资源读取使用,不在 UI 上直接暴露为文案 2. 不返回手机号、登录方式、绑定状态、tokenVersion 等敏感字段 3. 未命中返回 `404` -4. `by-id` 仅接受内部 `user_XXXXXXXX` 这类用户 ID,用于工程内跳转、运营排查或已有资源引用,不替代公开叙世号主搜索语义 +4. `by-id` 仅接受内部 `user_XXXXXXXX` 这类用户 ID,用于工程内跳转、运营排查或已有资源引用,不替代公开陶泥号主搜索语义 ## 5.2 广场作品公开编号搜索 @@ -251,7 +251,7 @@ ## 7.1 账号展示 -当前首页资料卡和桌面顶部都展示前端拼装叙世号,改为: +当前首页资料卡和桌面顶部都展示前端拼装陶泥号,改为: 1. 直接展示 `authUi.user.publicUserCode` 2. 复制按钮复制后端返回值 @@ -262,7 +262,7 @@ 广场作品卡和详情页增加: 1. 作品号 `CW-XXXXXXXX` -2. 作者叙世号 `SY-XXXXXXXX` +2. 作者陶泥号 `SY-XXXXXXXX` 展示要求: @@ -284,7 +284,7 @@ 用户搜索命中后的最小行为: 1. 打开独立用户搜索结果面板或对话框 -2. 展示头像字母、显示名、叙世号 +2. 展示头像字母、显示名、陶泥号 3. 提供“查看该作者作品”入口 作品搜索命中后的行为: @@ -325,7 +325,7 @@ ## 11. 当前落地说明 -1. 首页叙世号展示已优先读取后端 `publicUserCode`,原本基于 `AuthUser.id/username` 的前端拼装仅保留为兼容兜底,避免老会话未刷新时界面直接空白。 +1. 首页陶泥号展示已优先读取后端 `publicUserCode`,原本基于 `AuthUser.id/username` 的前端拼装仅保留为兼容兜底,避免老会话未刷新时界面直接空白。 2. 用户公开搜索与广场作品公开搜索均已改为调用后端匿名接口,前端只负责输入、展示与跳转,不再自行决定最终编号格式。 3. 自定义世界发布链路已改为从认证服务读取真实 `public_user_code` 写入作品真相与广场读模型,不再从内部 `user_id` 临时反推 `SY-XXXXXXXX`。 4. 当前作品号 `public_work_code` 仍采用基于 `profile_id` 的稳定 fallback 方案生成 `CW-XXXXXXXX`;若后续补独立计数表,需要在不改变读写接口的前提下替换生成来源。 diff --git a/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md b/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md index af516097..be49c233 100644 --- a/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md +++ b/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md @@ -201,7 +201,7 @@ Rust DTO 只承载对前端公开的 HTTP contract,不直接泄露 `module-puz 1. 每次生成 2 张候选图。 2. 候选图通过 `api-server` 写入 OSS,兼容展示路径统一为 `/generated-puzzle-assets/...`,禁止再落到仓库 `public/` 目录。 3. Axum 把候选图 URL、assetId、prompt snapshot 回写到 Spacetime session draft。 -4. 创作者在结果页选择其中 1 张作为正式图。 +4. 陶泥主在结果页选择其中 1 张作为正式图。 这样可以保证: @@ -211,7 +211,7 @@ Rust DTO 只承载对前端公开的 HTTP contract,不直接泄露 `module-puz ### 6.1 发布前编辑真相补充 -结果页允许创作者在发布前直接编辑: +结果页允许陶泥主在发布前直接编辑: 1. `关卡名` 2. `摘要` diff --git a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md new file mode 100644 index 00000000..f3a5aab1 --- /dev/null +++ b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md @@ -0,0 +1,58 @@ +# 拼图填表式创作流程改造 2026-04-29 + +## 背景 + +拼图创作入口不再使用 Agent 对话收集题材锚点。新流程只让玩家填写两个字段:拼图标题、画面描述。画面描述支持上传参考图。玩家确认后直接进入草稿生成进度页,后续草稿生成、首图生成、正式图选择、结果页编辑和发布沿用现有后端编排。 + +## 入口表单 + +1. 拼图标题为必填字段,保存到 `seedText`,同时作为 `levelName` 的优先来源。 +2. 画面描述为必填字段,保存到 `pictureDescription`,同时作为 `summary` 和首图生成 prompt 的优先来源;支持多行文本,后端解析不得截断首行之后的内容。 +3. 参考图为可选字段,保存到 `referenceImageSrc`。表单支持本地图片上传为 Data URL;草稿首图生成时直接传入现有拼图图生图接口。 +4. 表单确认后前端先创建拼图 session,再立即执行 `compile_puzzle_draft`,并传入 `promptText = pictureDescription`、`referenceImageSrc`。 +5. 表单提交 payload 需要在前端创作流程中暂存,生成进度页失败重试时必须继续携带同一份画面描述与参考图。 +6. 入口不再展示拼图 Agent 聊天气泡、快捷补齐或多锚点卡片;新建拼图时必须清空旧 session,只有从当前生成进度页返回表单时保留本轮内容。 + +## 锚点映射 + +拼图模式锚点收口为两个玩家输入源: + +| 新字段 | 落地字段 | 说明 | +| --- | --- | --- | +| 拼图标题 | `themePromise.value`、`levelName`、`creatorIntent.themePromise` | 作为题材承诺与关卡名称的真相源 | +| 画面描述 | `visualSubject.value`、`summary`、首图 `promptText` | 作为画面主体与生图 prompt 的真相源 | + +兼容旧结构时仍保留 `visualMood`、`compositionHooks`、`tagsAndForbidden` 字段,但它们不再由 Agent 问答收集: + +1. `visualMood` 固定标记为系统推断,值为“清晰、适合拼图切块”。 +2. `compositionHooks` 固定标记为系统推断,值为“主体轮廓、色块分区、局部细节”。 +3. `tagsAndForbidden` 根据拼图标题和画面描述生成 3 到 6 个题材标签;禁忌只保留通用图像约束,不写入 UI。 + +生成进度页的“当前拼图信息”只展示玩家输入锚点:拼图标题、画面描述。题材标签仅作为草稿结果页内容展示,不在进度页混入旧五锚点结构。 + +## 后端编译 + +1. `CreatePuzzleAgentSessionRequest` 新增 `pictureDescription` 与 `referenceImageSrc`,但不改 SpacetimeDB 表结构。 +2. api-server 创建 session 时把标题和画面描述合成 `seedText` 传入 SpacetimeDB;SpacetimeDB reducer 只做确定性锚点生成,不接触图片或外部服务。 +3. `compile_puzzle_draft_with_initial_cover` 新增首图 prompt 和参考图参数。若前端传入画面描述,则首图生成直接使用这段文本;若传入参考图,则走现有 DashScope 图生图链路。 +4. 图片生成仍在 api-server 内完成,遵守 SpacetimeDB reducer 不做网络 I/O 的约束。 + +## 结果页 + +拼图草稿结果页不再区分 Tab,合并为一个可滚动列表页,内容顺序固定为: + +1. 关卡名称。 +2. 画面预览。 +3. 画面描述。 +4. 重新生成画面按钮。 +5. 题材标签。 + +画面描述区域不再展示候选图实际 prompt 或“请生成一张适合……”之类内部提示词模块。参考图入口保留在画面描述编辑区域内,便于重新生成时继续带入。结果页编辑画面描述时必须同步更新 `summary`,确保自动保存、作品测试、发布和重新生成画面使用同一份描述。 + +## 验收 + +1. 从拼图创作入口只能看到标题、画面描述和参考图上传,不出现 Agent 聊天输入、补齐设定、锚点问答。 +2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择。 +3. 首图生成请求使用玩家画面描述作为 prompt;上传参考图时走图生图。 +4. 结果页为单列表,顺序符合上文要求,不展示 Tab 和内部实际 prompt。 +5. 发布、作品测试、自动保存标题、画面描述和标签仍可用。 diff --git a/docs/technical/PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md b/docs/technical/PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md new file mode 100644 index 00000000..66eff222 --- /dev/null +++ b/docs/technical/PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md @@ -0,0 +1,42 @@ +# 拼图图片生成与运行时 9:16 对齐 2026-04-29 + +## 背景 + +拼图生成图和运行时画面需要统一为竖屏游戏口径。此前链路里存在两类不一致: + +1. 旧方案按 `1:1` 正方形生成与承载。 +2. 上一轮误按 `16:9` 横版对齐,和本轮竖屏玩法目标相反。 + +本次统一为 `9:16` 竖屏尺寸,确保生成图、结果页预览、发布正式图、历史素材缩略和实际游戏棋盘使用同一画面比例。 + +## 落地结论 + +### 1. 图片生成 + +1. 拼图生成图固定使用 `720*1280`。 +2. 文生图和参考图生图共用同一个尺寸常量,禁止一条链路仍生成正方形或横版图。 +3. 拼图图片提示词明确写入 `9:16 竖屏画布`,并继续保留 `3x3 或 4x4 拼图切块`、主体清晰、层次明确、无文字水印等约束。 +4. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 只负责 session、draft、candidate、work profile 的确定性落库,不做网络 I/O。 + +### 2. 结果页与素材选择 + +1. 画面预览容器使用 `aspect-[9/16]`。 +2. 发布弹窗正式图使用 `aspect-[9/16]`。 +3. 历史拼图素材卡片缩略图使用 `aspect-[9/16]`。 +4. 图片显示继续使用 `object-cover`,兼容历史正方形或横版素材,但新生成素材的真相比例为 `9:16`。 + +### 3. 运行时棋盘 + +1. `PuzzleRuntimeShell` 继续作为唯一运行时承载组件,不新增页面。 +2. 棋盘根容器使用 `aspect-[9/16]`,并显式设置行列网格,3x3 / 4x4 都在竖屏舞台内切片。 +3. 棋盘最大宽度按可用视口高度反推,避免桌面端竖屏棋盘被宽容器撑出首屏。 +4. 单格不设置固定最小高度,避免移动端竖屏棋盘被单格高度撑破。 +5. 拼图片背景切片仍按 `board.cols * 100%` 与 `board.rows * 100%` 计算,比例由棋盘容器统一决定。 + +## 验收 + +1. 点击拼图草稿生成或重新生成画面时,后端请求 DashScope 的 `size` 为 `720*1280`。 +2. 结果页画面预览、发布弹窗正式图、历史素材缩略图均为 `9:16`。 +3. 进入拼图运行时后,棋盘整体为 `9:16` 竖屏,不再是正方形或横版。 +4. 移动端和桌面端运行时棋盘不被单格最小高度撑出首屏,顶部标题、底部状态与棋盘不重叠。 +5. 旧正方形或横版素材仍能被 `object-cover` 展示和游玩,不阻断历史作品。 diff --git a/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md b/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md index 0c1aadc9..50a7f916 100644 --- a/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md +++ b/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md @@ -4,7 +4,7 @@ 拼图结果页此前存在两个串联问题: -1. 创作者在结果页修改 `关卡名`、新增标签、删除标签,只会改前端本地 `editState`,不会立即写回拼图作品 profile。 +1. 陶泥主在结果页修改 `关卡名`、新增标签、删除标签,只会改前端本地 `editState`,不会立即写回拼图作品 profile。 2. 发布弹窗同时混用了旧 session 内的 `publishReady` 与前端本地编辑态,导致标签已经在界面里补够,但发布校验仍然盯着旧草稿里的标签数量,用户无法通过发布检验。 这会直接破坏拼图创作主链的可用性:用户明明已经在结果页补齐正式标签,却因为没有自动保存、也没有按当前编辑态重算门槛而卡在发布前。 diff --git a/docs/technical/PUZZLE_RUNTIME_RUN_JSON_COMPAT_FIX_2026-04-29.md b/docs/technical/PUZZLE_RUNTIME_RUN_JSON_COMPAT_FIX_2026-04-29.md new file mode 100644 index 00000000..a38bc6d9 --- /dev/null +++ b/docs/technical/PUZZLE_RUNTIME_RUN_JSON_COMPAT_FIX_2026-04-29.md @@ -0,0 +1,45 @@ +# 拼图运行态 `run_json` 计时字段兼容修复 2026-04-29 + +## 背景 + +作品详情页点击“启动”时,Rust API 通过 SpacetimeDB `start_puzzle_run` procedure 拿到字符串化的 `run_json`,再在 `spacetime-client` 映射层反序列化为拼图运行态快照。 + +本次线上报错为: + +```text +puzzle run run_json 非法: missing field `started_at_ms` +``` + +说明主云 procedure 已成功返回快照,但返回的 JSON 仍可能是旧字段集,没有带上后续限时与排行榜迭代新增的计时字段。 + +## 根因 + +`PuzzleRuntimeLevelSnapshot` 在早期 PRD 中只包含关卡基础信息、棋盘和状态。后续版本新增: + +1. `started_at_ms` +2. `cleared_at_ms` +3. `elapsed_ms` +4. `time_limit_ms` +5. `remaining_ms` +6. `paused_accumulated_ms` +7. `pause_started_at_ms` +8. `freeze_accumulated_ms` +9. `freeze_started_at_ms` +10. `freeze_until_ms` +11. `leaderboard_entries` + +其中部分字段已经有 `serde(default)`,但 `started_at_ms`、`cleared_at_ms`、`elapsed_ms`、`leaderboard_entries` 仍按必填字段解析。只要主云旧模块或历史快照缺少这些字段,API facade 就会在映射层失败,导致详情页启动中断。 + +## 修复口径 + +本次只做后端兼容,不改表结构,不改前端表现: + +1. `module-puzzle` 的运行态快照新增字段统一允许缺省。 +2. 旧 JSON 缺 `started_at_ms` 时,用当前毫秒时间作为兼容起点,保证前端倒计时不会从 `0` 时间戳开始。 +3. 旧棋盘缺 `all_tiles_resolved` 时按 `false` 处理。 +4. 旧 run / level 缺 `leaderboard_entries` 时按空榜单处理。 +5. `spacetime-client` 增加回归测试,确保 `run_json` 缺新增计时字段仍能启动。 + +## 经验结论 + +`procedure -> run_json/items_json -> client record` 这类链路只要返回字符串化聚合快照,新增字段就必须默认具备向后兼容能力。平台入口级操作不应因为单个新增字段缺失直接 500;能安全补默认值的字段,应在服务端契约层统一兜底。 diff --git a/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md b/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md new file mode 100644 index 00000000..5b8d06fc --- /dev/null +++ b/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md @@ -0,0 +1,108 @@ +# 拼图运行时限时与道具系统设计 2026-04-29 + +## 背景 + +拼图运行时从纯粹的无压解谜升级为限时关卡,需要同时补齐三类体验: + +1. 不同难度有明确倒计时,超时即失败。 +2. 底部固定 3 个轻量道具:提示、查看原图、冻结时间。 +3. 道具使用必须经过确认弹窗并消耗 `1` 陶泥币,确认弹窗期间暂停关卡计时。 + +本设计只处理拼图运行时,不改拼图创作链、发布链和广场推荐链。 + +## 运行态字段 + +`PuzzleRuntimeLevelSnapshot` 增加以下字段: + +1. `timeLimitMs`:当前关卡限时。 +2. `remainingMs`:后端或本地运行态计算出的剩余时间。 +3. `pausedAccumulatedMs`:已累计暂停时长。 +4. `pauseStartedAtMs`:当前是否处于暂停中;有值表示暂停开始时间。 +5. `freezeUntilMs`:冻结时间道具生效截止时间;冻结期间倒计时不减少。 + +`status` 增加 `failed`。当 `remainingMs <= 0` 且关卡尚未通关时,状态进入 `failed`,后续交换、拖动、排行榜提交都拒绝。 + +## 难度限时 + +第一版按网格规模定义限时: + +1. `3x3`:`180000ms`。 +2. `4x4`:`300000ms`。 + +后续若扩展更多难度,只能通过同一个难度解析函数扩展,不允许在 UI 里写死另一套时间。 + +## 计时规则 + +有效消耗时间计算: + +```text +effectiveElapsedMs = nowMs - startedAtMs - pausedAccumulatedMs - activeFreezeElapsedMs +remainingMs = max(0, timeLimitMs - effectiveElapsedMs) +``` + +其中: + +1. 弹窗打开、设置面板打开、查看原图覆盖打开时,运行态需要暂停。 +2. 冻结时间生效时,画面播放冻结特效,并展示冻结剩余时长。 +3. 通关时 `elapsedMs` 使用有效消耗时间,不把确认弹窗、查看原图和冻结时间计入成绩。 +4. 失败后保留棋盘,不弹通关结算。 +5. 正式后端 run 的前端倒计时归零时,需要主动刷新一次 `getPuzzleRun`,让 SpacetimeDB 侧把 `failed` 状态写回快照,避免只停留在本地视觉失败。 + +## 道具规则 + +### 提示 + +提示道具只演示,不替玩家移动。 + +演示对象选择: + +1. 优先选当前棋盘中拼块数量最多、且尚未完全处于正确位置的合并块。 +2. 若没有合并块,选择一个不在正确格子的单块。 +3. 演示从当前所在格移动到该块锚点的正确格,结束后回到原位。 + +### 查看原图 + +查看原图是开关按钮: + +1. 打开后把原图以半透明方式覆盖在拼图棋盘上。 +2. 覆盖期间暂停倒计时;确认弹窗关闭到覆盖层显示之间不得恢复计时,正式后端 run 也需要保持 `pauseStartedAtMs`。 +3. 再次点击关闭覆盖并恢复计时。 + +### 冻结时间 + +冻结时间确认后: + +1. 播放冻结视觉特效。 +2. 显示冻结剩余时长。 +3. 第一版冻结 `10000ms`。 + +## 计费规则 + +每次确认使用道具消耗 `1` 陶泥币。 + +正式后端运行态复用现有资产操作钱包预扣链路,新增道具 `asset_kind`: + +1. `puzzle_prop_hint` +2. `puzzle_prop_preview` +3. `puzzle_prop_freeze_time` + +本地调试 run 没有真实用户钱包,不伪造扣费,只保留同样的确认交互与运行态效果。 + +若扣费或道具过程失败,确认弹窗保持打开并继续暂停倒计时,在弹窗内展示失败原因;只有成功确认后才关闭弹窗并播放对应反馈。 + +## UI 规则 + +1. 底部只放 3 个道具按钮,不写规则说明文案。 +2. 点击道具弹出独立确认窗口,不在底栏下方展开。 +3. 确认窗口打开期间暂停计时。 +4. 按钮使用图标和短标签;不可用时降低透明度。 +5. 失败状态使用简洁弹窗展示,可返回或重新开始,不与通关结算混用。 + +## 画布表现修正 + +本轮同步修正合并块视觉: + +1. 合并块之间不再使用额外 `p-1` 缝隙,拼图块需要贴合。 +2. 单块和大块使用同一套边界描边宽度与颜色。 +3. 外轮廓和凹入转角都需要圆角化。 +4. 新合并产生时,在新大块中心播放一次简洁闪光,不显示文字提示。 diff --git a/docs/technical/README.md b/docs/technical/README.md index f1e92a4c..9aca4ee4 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,13 @@ ## 文档列表 +- [RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md](./RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md):记录首页 `custom-world-library` 首屏列表 SpacetimeDB procedure 超时的根因,冻结列表读模型轻量化与 procedure 等待窗口配置化的修复口径。 +- [PRODUCT_NAMING_TAONI_RENAME_2026-04-29.md](./PRODUCT_NAMING_TAONI_RENAME_2026-04-29.md):记录本轮产品中文名调整为“陶泥”,以及陶泥币、陶泥号、陶泥主三类对外称谓替换的落地边界。 +- [PUZZLE_FORM_CREATION_FLOW_2026-04-29.md](./PUZZLE_FORM_CREATION_FLOW_2026-04-29.md):记录拼图创作入口从 Agent 对话改为标题与画面描述填表、参考图直达首图生成,以及结果页合并为单列表的落地边界。 +- [PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md):记录拼图生成图片、结果页预览、历史素材缩略和运行时棋盘统一为 9:16 竖屏的落地边界。 +- [RPG_NPC_BATTLE_ENTRY_QUEST_AND_TARGET_FIX_2026-04-29.md](./RPG_NPC_BATTLE_ENTRY_QUEST_AND_TARGET_FIX_2026-04-29.md):记录 NPC 进入战斗时不再自动补章节任务、pending 委托不被误接取,以及战斗目标缺少 encounter 时仍可渲染的修复边界。 +- [RPG_RUNTIME_PANEL_CLOSE_BUTTON_FIX_2026-04-29.md](./RPG_RUNTIME_PANEL_CLOSE_BUTTON_FIX_2026-04-29.md):记录 RPG 运行态历史手写弹窗右上关闭按钮点击失效的统一修复边界,收口像素风关闭按钮的事件传播、层级和点击面积。 +- [RPG_RUNTIME_PARTY_INVENTORY_PANEL_UI_SIMPLIFICATION_2026-04-29.md](./RPG_RUNTIME_PARTY_INVENTORY_PANEL_UI_SIMPLIFICATION_2026-04-29.md):记录 RPG 运行态队伍面板删除成员列表上方任务信息、背包面板删除顶部旅程回顾的展示边界,保持辅助面板首屏聚焦成员与物品。 - [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client,角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs`。 - [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。 - [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs` 的 `/api/runtime/custom-world/profile` 生成世界底稿。 diff --git a/docs/technical/RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md b/docs/technical/RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md new file mode 100644 index 00000000..4408473b --- /dev/null +++ b/docs/technical/RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md @@ -0,0 +1,50 @@ +# RPG 首页自定义世界库超时修复 + +日期:`2026-04-29` + +## 1. 问题 + +首页进入时会并发读取: + +1. `GET /api/runtime/custom-world-library` +2. `GET /api/runtime/custom-world-gallery` +3. 个人看板、浏览历史、存档等私有数据 + +其中 `custom-world-library` 通过 `api-server -> spacetime-client -> list_custom_world_profiles procedure` 读取当前用户作品。旧实现把每个作品的完整 `profile_payload_json` 一并返回给首页列表,而首页卡片只需要标题、摘要、封面、状态和计数字段。用户作品较多或 Maincloud 连接抖动时,这个 procedure 容易超过 `spacetime-client` 固定 `10s` 等待窗口,最终由 Axum 映射成 `502 Bad Gateway`,前端控制台显示 `SpacetimeDB procedure 调用超时`。 + +## 2. 修复口径 + +本轮不改表结构,不新增前端展示规则,只收窄首屏读模型负载: + +1. `list_custom_world_profiles` 仍保持旧 procedure 名称和返回 envelope,避免本轮重新生成 bindings。 +2. 列表返回的 `profile_payload_json` 改为轻量摘要 JSON,只包含首页卡片和标签兜底需要的少量字段。 +3. 单条详情、发布、下架、编辑继续使用完整 profile snapshot,确保进入详情或结果页时仍有完整世界数据。 +4. `spacetime-client` 的 procedure 等待窗口从硬编码 `10s` 改为可配置,Maincloud 默认使用更宽的窗口吸收连接冷启动与短时抖动。 +5. Axum 的 `GET /api/runtime/custom-world-library` 首屏接口改走已有 `custom-world/works` 轻量读模型,并在用户点击详情/编辑时再调用 owner-only detail 接口取完整 profile,避免 Maincloud wasm 尚未发布轻量 profile procedure 时首页继续命中重 procedure。 + +## 3. 轻量 profile JSON 字段 + +列表轻量 profile 只保留: + +1. `id` +2. `name` +3. `subtitle` +4. `summary` +5. `themeMode` +6. `cover.imageSrc` +7. `majorFactions` +8. `coreConflicts` +9. `playableNpcs` +10. `storyNpcs` +11. `landmarks` + +这些字段足够支撑首页卡片的封面、标签、数量和基本文案。服务端列表兜底允许把 `majorFactions`、`coreConflicts`、`playableNpcs`、`storyNpcs`、`landmarks` 返回为空数组,并依赖 entry 顶层的计数字段、封面和主题兜底展示。需要完整 profile 的操作必须走 detail 或 mutation 回包,不能依赖列表接口搬大 JSON。 + +## 4. 验收 + +1. 首页进入不再因为 `custom-world-library` 首屏列表超时直接报 502。 +2. `cargo check -p spacetime-module` 通过。 +3. `cargo check -p spacetime-client` 通过。 +4. `cargo check -p api-server` 通过。 +5. `npm run check:encoding` 通过。 +6. 修改后按项目约束使用 `npm run api-server:maincloud` 重启后端。 diff --git a/docs/technical/RPG_NPC_BATTLE_ENTRY_QUEST_AND_TARGET_FIX_2026-04-29.md b/docs/technical/RPG_NPC_BATTLE_ENTRY_QUEST_AND_TARGET_FIX_2026-04-29.md new file mode 100644 index 00000000..fa9071d3 --- /dev/null +++ b/docs/technical/RPG_NPC_BATTLE_ENTRY_QUEST_AND_TARGET_FIX_2026-04-29.md @@ -0,0 +1,26 @@ +# RPG NPC 战斗入口任务与目标显示修复记录(2026-04-29) + +## 背景 + +运行态从 NPC 交互进入战斗后,出现两个连带问题: + +1. 玩家没有确认领取任务,但界面表现为突然多了一个任务。 +2. 对面的 NPC 进入战斗后从画布上消失。 + +## 根因 + +1. `project_story_engine_after_action` 会在动作后自动补齐当前场景章节任务。这个规则适合“进入/探索场景”的开章节点,但不适合 `npc_fight / npc_spar` 战斗入口;否则玩家点击战斗也会像被系统强行塞入任务。 +2. `resolve_npc_battle_entry_action` 进入战斗时会清空 `currentEncounter`,并改由 `sceneHostileNpcs` 承接敌方渲染。若进入战斗前已有 `sceneHostileNpcs` 但条目缺少 `encounter`,画布层会因为没有 NPC 形象上下文而跳过渲染。 + +## 落地边界 + +1. `npc_fight / npc_spar` 只负责进入战斗,不创建章节任务,不接取 NPC pending quest。 +2. 场景章节任务仍保留在真正的场景进入、观察、推进节点自动创建。 +3. 战斗入口必须保证每一个 `sceneHostileNpcs` 条目都带有可渲染的 `encounter`;若旧数据缺失,使用进入战斗前的当前 NPC encounter 兜底。 +4. 前端画布也要兜底渲染缺少 `encounter` 的战斗目标,避免服务端旧快照或迁移数据导致目标直接不可见。 + +## 验证点 + +1. `npc_fight` 带 pending quest story 时,不写入 `quests`,不增加 `runtimeStats.questsAccepted`。 +2. `npc_fight` 时若已有敌方列表缺少 `encounter`,服务端会给战斗目标补齐进入战斗前的 NPC encounter。 +3. 画布层在 `sceneCombatants[].encounter` 缺失时仍显示敌方名称和血条。 diff --git a/docs/technical/RPG_RUNTIME_PANEL_CLOSE_BUTTON_FIX_2026-04-29.md b/docs/technical/RPG_RUNTIME_PANEL_CLOSE_BUTTON_FIX_2026-04-29.md new file mode 100644 index 00000000..95cebd48 --- /dev/null +++ b/docs/technical/RPG_RUNTIME_PANEL_CLOSE_BUTTON_FIX_2026-04-29.md @@ -0,0 +1,26 @@ +# RPG 运行态面板右上关闭按钮修复(2026-04-29) + +## 背景 + +RPG 运行态里仍有一批历史手写弹窗,没有统一迁入 `UnifiedModal`。这些弹窗的右上关闭按钮分别散落在角色详情、队伍、背包、地图、NPC 交易、任务日志和奖励面板里,按钮尺寸、层级、点击事件传播和无障碍标识不一致。 + +用户反馈多个 RPG 模板游戏内面板右上角关闭按钮点击无效。排查后,本次先按最小风险方式修复关闭交互边界,不重构业务面板结构。 + +## 落地方案 + +1. 新增 `PixelCloseButton` 作为 RPG 像素风面板右上关闭按钮的统一组件。 +2. 组件内部统一处理: + - `event.preventDefault()`; + - `event.stopPropagation()`; + - 稳定 `z-index`; + - 固定移动端友好的点击面积; + - `aria-label` 与 `title`。 +3. RPG 游戏内旧弹窗的右上关闭按钮统一替换为 `PixelCloseButton`。 +4. 保留各面板原本的关闭回调和业务状态清理逻辑,不改变任务、奖励、交易、地图、角色详情等业务行为。 + +## 验收 + +1. 点击游戏内面板右上关闭按钮时,只触发该按钮的关闭回调,不被父层遮罩或面板点击处理吞掉。 +2. 队伍、背包、地图、角色详情、角色聊天、NPC 交易 / 赠礼 / 招募、任务日志、任务详情、奖励详情等面板的右上关闭按钮可稳定关闭。 +3. 关闭按钮具备可检索的无障碍名称,后续可用自动化测试直接定位。 +4. 编码检查、定向测试和类型检查通过。 diff --git a/docs/technical/RPG_RUNTIME_PARTY_INVENTORY_PANEL_UI_SIMPLIFICATION_2026-04-29.md b/docs/technical/RPG_RUNTIME_PARTY_INVENTORY_PANEL_UI_SIMPLIFICATION_2026-04-29.md new file mode 100644 index 00000000..e5560c77 --- /dev/null +++ b/docs/technical/RPG_RUNTIME_PARTY_INVENTORY_PANEL_UI_SIMPLIFICATION_2026-04-29.md @@ -0,0 +1,19 @@ +# RPG 运行态队伍 / 背包面板信息精简(2026-04-29) + +## 背景 + +运行态队伍与背包都属于冒险过程中的辅助弹出面板,移动端优先要求是快速查看成员状态、物品格子与工坊操作。当前队伍面板在成员列表上方额外展示活跃任务,背包面板在格子上方额外展示旅程回顾,会把首屏焦点从“队伍成员 / 背包物品”推开。 + +## 落地边界 + +1. 队伍面板删除成员列表上方的任务信息模块。 +2. 背包面板删除物品格子上方的旅程回顾模块。 +3. 不删除任务系统、旅程回顾数据或冒险页里的任务提示,只调整这两个辅助面板的展示入口。 +4. 父级不再向这两个面板传入已经不展示的字段,避免保留无效 UI 契约。 + +## 验收 + +1. 打开队伍面板后,顶部直接进入“队伍成员”列表。 +2. 打开背包面板后,顶部直接进入物品格子。 +3. 任务状态仍由冒险主面板和任务弹层承担。 +4. `continueGameDigest` 数据仍保留在运行态状态中,后续可在更合适的独立入口展示。 diff --git a/docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md b/docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md index d16f0ffd..cb8c0c50 100644 --- a/docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md +++ b/docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md @@ -2,7 +2,7 @@ ## 背景 -世界创作结果页已经提供“作品测试”入口,但测试运行时此前缺少与“幕预览”一致的显式退出按钮。创作者进入测试后只能依赖浏览器返回、刷新或其他间接链路离开,不符合独立运行时面板的交互语义。 +世界创作结果页已经提供“作品测试”入口,但测试运行时此前缺少与“幕预览”一致的显式退出按钮。陶泥主进入测试后只能依赖浏览器返回、刷新或其他间接链路离开,不符合独立运行时面板的交互语义。 ## 本次约束 diff --git a/docs/technical/RUNTIME_PREVIEW_TEST_SAVE_ARCHIVE_ISOLATION_2026-04-26.md b/docs/technical/RUNTIME_PREVIEW_TEST_SAVE_ARCHIVE_ISOLATION_2026-04-26.md index 146fded9..f7fe9e6c 100644 --- a/docs/technical/RUNTIME_PREVIEW_TEST_SAVE_ARCHIVE_ISOLATION_2026-04-26.md +++ b/docs/technical/RUNTIME_PREVIEW_TEST_SAVE_ARCHIVE_ISOLATION_2026-04-26.md @@ -2,7 +2,7 @@ ## 背景 -幕预览和测试作品用于创作者检查玩法表现,不能被当作玩家正式游玩记录。若这类运行时复用正式 RPG 壳、story action 或 snapshot 接口,必须在进入个人存档页、游玩统计、作品游玩历史前被过滤。 +幕预览和测试作品用于陶泥主检查玩法表现,不能被当作玩家正式游玩记录。若这类运行时复用正式 RPG 壳、story action 或 snapshot 接口,必须在进入个人存档页、游玩统计、作品游玩历史前被过滤。 ## 落地约束 diff --git a/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md index b6fd583a..5f731466 100644 --- a/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md +++ b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md @@ -10,7 +10,7 @@ 1. 草稿层可以承载 `scene chapter / scene act` 2. 后端可以把 `scene_chapter` 编译成正式蓝图 -3. 创作者可以在现有场景编辑弹层里看到并编辑多幕配置 +3. 陶泥主可以在现有场景编辑弹层里看到并编辑多幕配置 4. 编辑后的幕信息可以正确写回 `sceneChapterBlueprints` 5. 运行时共享层先具备读取幕背景、主角色、相遇 NPC 池的基础能力 6. 当前幕主角色的负好感 `5` 轮聊天限制先形成首个可运行闭环 @@ -60,7 +60,7 @@ 前端已完成第一批接入: -1. `scene_chapter` 不再作为独立 Tab / 独立卡片暴露给创作者 +1. `scene_chapter` 不再作为独立 Tab / 独立卡片暴露给陶泥主 2. 多幕配置已内嵌到 `CustomWorldEntityEditorModal.tsx` 的 `LandmarkEditor` 3. 单幕编辑已从文本表单切成“背景大图预览 + 3 个角色槽位”的轻量交互 4. “幕标题 / 幕摘要 / 幕目标 / 过渡钩子”已从场景手工编辑区移除,继续留在草稿生成与编译层 @@ -88,7 +88,7 @@ 7. 幕预览运行时已补 custom world NPC 的视觉兜底链路,优先使用 `visual / imageSrc` 渲染,避免角色形象或动画空白 8. 当前幕小预览已调整为左侧玩家、右侧敌对/相遇角色的构图,NPC 站位采用一前两后 前排主角色与玩家角色保持同一 y 轴;后排两个角色改为同一列、x 轴对齐并上下分布,且后排整体 y 轴中点与前排主角色一致 -9. 新增幕默认只带 1 个主角色,后续槽位由创作者按需补充 +9. 新增幕默认只带 1 个主角色,后续槽位由陶泥主按需补充 10. 小预览里的名字已移动到角色头顶,角色渲染不再带方形底板,避免遮挡场景背景 11. 幕预览复用真实游戏壳时隐藏左上角角色等级徽标,退出入口固定在上方画面区域底部居中,并使用“结束预览”作为操作文案 12. 创作侧场景列表封面、多幕配置卡片、配置背景弹层统一读取同一张场景显示图;在任一幕保存背景时同步回全部幕背景字段和场景兼容图,避免同一场景在不同层级出现不同预览图 diff --git a/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md b/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md index 88b1921b..af91f25a 100644 --- a/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md +++ b/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md @@ -409,7 +409,7 @@ Access-Control-Allow-Credentials: true 职责: -- 面向创作者、运营、内部编辑器 +- 面向陶泥主、运营、内部编辑器 - 必须鉴权 - 必须审计 - 不建议对公网完全开放 @@ -469,7 +469,7 @@ flowchart TD 当出现这些需求时,再进入下一阶段: - 多人同时在线 -- 多创作者协作 +- 多陶泥主协作 - 图片/视频生成任务变多 - 需要账号体系、存档、云同步 - 需要审计和版本回滚 diff --git a/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md index 8ad68e66..bc167094 100644 --- a/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md +++ b/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md @@ -206,7 +206,7 @@ 1. 密码登录仍由 `user_account.password_hash` 承担 2. 本轮不引入 `password` provider identity -3. 密码登录只接受已绑定手机号的账号,不支持邮箱、用户名或叙世号作为登录身份 +3. 密码登录只接受已绑定手机号的账号,不支持邮箱、用户名或陶泥号作为登录身份 4. 密码登录不创建账号,新账号只由手机号验证码登录创建 ### 9.2 `POST /api/auth/phone/login` diff --git a/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md b/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md index aa35ecdf..63c21173 100644 --- a/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md +++ b/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md @@ -13,6 +13,8 @@ SpacetimeDB reducer 必须保持确定性,不能访问文件系统和网络。 procedure 不再访问 HTTP 文件桥,也不接收部署机本地文件路径。这样可以避开 SpacetimeDB 对 private/special-purpose 地址的 HTTP 访问限制,并避免把 private 表内容通过临时 HTTP 服务转发。 +SpacetimeDB Wasm 运行环境不支持 `std::time::SystemTime::now()`,procedure 或 reducer 内需要当前时间时必须使用 `ctx.timestamp`。如果共享 crate 同时服务前端/本地纯逻辑与 SpacetimeDB 模块,应提供 `*_at(now_ms)` 或显式时间参数版本,SpacetimeDB 模块只调用注入时间的函数,避免发布后在 maincloud 触发 `time not implemented on this platform` panic。 + `spacetime login show --token` 输出的是 CLI 登录 token,不是 HTTP `/v1/database/.../call` 所需的数据库连接 token。导入脚本如果没有显式传 `--token`,会自动调用 `POST /v1/identity` 获取 Web API token;迁移时不要把 CLI token 传给 `--token`。 ## 接口 @@ -132,6 +134,10 @@ node scripts/spacetime-revoke-migration-operator.mjs \ 4. 导出成功后执行清库发布新 wasm。 5. 新 wasm 发布成功后,把第 3 步导出的 JSON 自动导入回灌。 +SpacetimeDB 2.1 对 schema 冲突的报错文案可能不再包含 `schema conflict`,而是直接提示 `manual migration`、`default value annotation` 或 `--delete-data`。发布脚本必须把这些文案同样识别为可迁移冲突,否则会停在原始失败而不进入导出回灌流程。 + +新增字段优先采用低风险热升级策略:旧字段顺序保持不变,新字段追加到表尾,并用 `#[default(...)]` 提供旧行默认值。只有仍无法通过发布器检查时,才执行清库发布与 JSON 回灌。 + 任一阶段失败都会中止流程,并保留已经导出的迁移 JSON。非 schema 冲突的发布失败不会进入迁移流程。 ```bash @@ -253,7 +259,9 @@ node scripts/spacetime-export-migration-json.mjs \ - 自定义世界:`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` - 资产索引:`asset_object`、`asset_entity_binding` - 拼图:`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` +- 大鱼:`big_fish_creation_session`、`big_fish_agent_message`、`big_fish_asset_slot` + +`big_fish_runtime_run` 当前运行态已由前端本地运行服务承接,不再加入迁移白名单;但 maincloud 旧库仍可能存在该表。为避免热升级被 “Removing the table big_fish_runtime_run requires a manual migration” 阻断,模块发布期可以保留兼容空壳表,后续确认旧数据可丢弃后再走正式删除表迁移。 后续新增 SpacetimeDB 表时,必须同步把表加入迁移白名单与本文档。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index edbb6008..8474f0a6 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -20,16 +20,16 @@ spacetime sql "SELECT * FROM custom_world_gallery_entry" ## 总览 -| 领域 | 表 | -| --- | --- | -| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | +| 领域 | 表 | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | | 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_redeem_code`, `profile_redeem_code_usage`, `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` | +| 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` | ## 认证表 @@ -46,8 +46,8 @@ 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`, `phone_number_e164: Option`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: Option`, `password_login_enabled: bool`, `token_version: u64`。 +- 作用:用户账号主表,保存用户名、公开陶泥号、手机号掩码、登录方式、密码登录开关和 token 版本。 +- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option`, `phone_number_masked: Option`, `phone_number_e164: Option`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: Option`, `password_login_enabled: bool`, `token_version: u64`。 - 索引:`username`, `public_user_code`。 ```sql @@ -135,7 +135,7 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '' ORDER BY created ### `profile_redeem_code` -- 作用:运营发放的叙世币兑换码,支持公共码、唯一码和私有码。 +- 作用:运营发放的陶泥币兑换码,支持公共码、唯一码和私有码。 - 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`。 - 索引:主键 `code`。 diff --git a/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md b/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md index 2f137b9d..c78369eb 100644 --- a/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md +++ b/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md @@ -39,7 +39,7 @@ - 发布 3. 完整复制外部 TXT 模式的运行机制: - 玩家游玩会话 - - 创作者测试/读档会话 + - 陶泥主测试/读档会话 - 流式动作执行 - 文本模式显示 - 历史记录 @@ -99,7 +99,7 @@ - 属性面板 5. 双会话机制: - 玩家游玩会话 - - 创作者测试/读档会话 + - 陶泥主测试/读档会话 6. 流式动作接口与事件协议: - `start` - `raw_text` @@ -551,7 +551,7 @@ TXT 模式后续必须完整落地双会话机制: 1. 玩家游玩会话 - 对应外部 `POST /api/optical/games/session/create` - 用于正式游玩 -2. 创作者测试/读档会话 +2. 陶泥主测试/读档会话 - 对应外部 `POST /api/visual/session/create` - 用于测试体验与加载指定存档 diff --git a/index.html b/index.html index bb6e888e..9a907502 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - 叙世 + 陶泥
diff --git a/media/social-media-group/qq.png b/media/social-media-group/qq.png new file mode 100644 index 00000000..99fd5b46 Binary files /dev/null and b/media/social-media-group/qq.png differ diff --git a/media/social-media-group/wechat.png b/media/social-media-group/wechat.png new file mode 100644 index 00000000..666de689 Binary files /dev/null and b/media/social-media-group/wechat.png differ diff --git a/metadata.json b/metadata.json index 089b6060..c161dfc5 100644 --- a/metadata.json +++ b/metadata.json @@ -1,5 +1,5 @@ { - "name": "叙世", + "name": "陶泥", "description": "一个 AI 原生的武侠/仙侠 RPG 游戏,具有动态剧情生成和横版卷轴视觉效果。", "requestFramePermissions": [] } diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 6d3996de..11f518c1 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -6,6 +6,7 @@ export type AuthUser = { publicUserCode: string; username: string; displayName: string; + avatarUrl: string | null; phoneNumberMasked: string | null; loginMethod: AuthLoginMethod; bindingStatus: AuthBindingStatus; @@ -16,6 +17,7 @@ export type PublicUserSummary = { id: string; publicUserCode: string; displayName: string; + avatarUrl: string | null; }; export type PublicUserSearchResponse = { @@ -41,6 +43,15 @@ export type AuthPasswordChangeResponse = { user: AuthUser; }; +export type AuthProfileUpdateRequest = { + displayName?: string; + avatarDataUrl?: string; +}; + +export type AuthProfileUpdateResponse = { + user: AuthUser; +}; + export type AuthPasswordResetRequest = { phone: string; code: string; diff --git a/packages/shared/src/contracts/bigFishWorkSummary.ts b/packages/shared/src/contracts/bigFishWorkSummary.ts index e785ff77..4a1cd5f2 100644 --- a/packages/shared/src/contracts/bigFishWorkSummary.ts +++ b/packages/shared/src/contracts/bigFishWorkSummary.ts @@ -19,6 +19,7 @@ export interface BigFishWorkSummary { playCount?: number; remixCount?: number; likeCount?: number; + recentPlayCount7d?: number; } export interface BigFishWorksResponse { diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index bfb65203..abbb41d4 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -39,7 +39,12 @@ export interface PuzzleAgentOperationRecord { } export type PuzzleAgentActionRequest = - | { action: 'compile_puzzle_draft' } + | { + action: 'compile_puzzle_draft'; + promptText?: string | null; + referenceImageSrc?: string | null; + candidateCount?: number; + } | { action: 'generate_puzzle_images'; promptText?: string | null; diff --git a/packages/shared/src/contracts/puzzleAgentDraft.ts b/packages/shared/src/contracts/puzzleAgentDraft.ts index b2ef92ff..2336e4cf 100644 --- a/packages/shared/src/contracts/puzzleAgentDraft.ts +++ b/packages/shared/src/contracts/puzzleAgentDraft.ts @@ -22,7 +22,7 @@ export interface PuzzleAnchorPack { } export interface PuzzleCreatorIntent { - sourceMode: 'agent_chat'; + sourceMode: 'agent_chat' | 'form'; rawMessagesSummary: string; themePromise: string; visualSubject: string; diff --git a/packages/shared/src/contracts/puzzleAgentSession.ts b/packages/shared/src/contracts/puzzleAgentSession.ts index bd0a1c91..ac4d0747 100644 --- a/packages/shared/src/contracts/puzzleAgentSession.ts +++ b/packages/shared/src/contracts/puzzleAgentSession.ts @@ -42,6 +42,8 @@ export interface PuzzleAgentSessionSnapshot { export interface CreatePuzzleAgentSessionRequest { seedText?: string; + pictureDescription?: string; + referenceImageSrc?: string | null; } export interface CreatePuzzleAgentSessionResponse { diff --git a/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index 1009f22e..30fb5592 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -27,6 +27,10 @@ export interface PuzzleLeaderboardEntry { isCurrentPlayer?: boolean; } +export type PuzzleRuntimeLevelStatus = 'playing' | 'cleared' | 'failed'; + +export type PuzzleRuntimePropKind = 'hint' | 'reference' | 'freezeTime'; + export interface PuzzleBoardSnapshot { rows: number; cols: number; @@ -46,10 +50,17 @@ export interface PuzzleRuntimeLevelSnapshot { themeTags: string[]; coverImageSrc: string | null; board: PuzzleBoardSnapshot; - status: 'playing' | 'cleared'; + status: PuzzleRuntimeLevelStatus; startedAtMs: number; clearedAtMs: number | null; elapsedMs: number | null; + timeLimitMs: number; + remainingMs: number; + pausedAccumulatedMs: number; + pauseStartedAtMs: number | null; + freezeAccumulatedMs: number; + freezeStartedAtMs: number | null; + freezeUntilMs: number | null; leaderboardEntries: PuzzleLeaderboardEntry[]; } @@ -96,3 +107,11 @@ export interface DragPuzzlePieceRequest { targetRow: number; targetCol: number; } + +export interface UsePuzzleRuntimePropRequest { + propKind: PuzzleRuntimePropKind; +} + +export interface UpdatePuzzleRuntimePauseRequest { + paused: boolean; +} diff --git a/packages/shared/src/contracts/puzzleWorkSummary.ts b/packages/shared/src/contracts/puzzleWorkSummary.ts index 1b1dfe6d..649d516f 100644 --- a/packages/shared/src/contracts/puzzleWorkSummary.ts +++ b/packages/shared/src/contracts/puzzleWorkSummary.ts @@ -20,6 +20,7 @@ export interface PuzzleWorkSummary { playCount?: number; remixCount?: number; likeCount?: number; + recentPlayCount7d?: number; publishReady: boolean; } diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e0a3a5f0..cc18ec9c 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -252,6 +252,7 @@ export type CustomWorldLibraryEntry = { playCount?: number; remixCount?: number; likeCount?: number; + recentPlayCount7d?: number; }; export type CustomWorldGalleryCard = Omit< diff --git a/scripts/spacetime-publish-maincloud.sh b/scripts/spacetime-publish-maincloud.sh index 8c5dc920..93acf35d 100644 --- a/scripts/spacetime-publish-maincloud.sh +++ b/scripts/spacetime-publish-maincloud.sh @@ -90,7 +90,11 @@ timestamp_slug() { is_publish_conflict_output() { local output="$1" - [[ "${output}" == *"conflict"* ]] || [[ "${output}" == *"schema"* && "${output}" == *"clear"* ]] + [[ "${output}" == *"conflict"* ]] \ + || [[ "${output}" == *"schema"* && "${output}" == *"clear"* ]] \ + || [[ "${output}" == *"manual migration"* ]] \ + || [[ "${output}" == *"default value annotation"* ]] \ + || [[ "${output}" == *"delete-data"* ]] } run_publish() { diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 55804979..65d11bce 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2655,6 +2655,7 @@ version = "0.1.0" dependencies = [ "module-ai", "module-assets", + "module-big-fish", "module-combat", "module-custom-world", "module-inventory", diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 373506c5..e52cfe32 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -80,6 +80,7 @@ use crate::{ password_entry::password_entry, password_management::{change_password, reset_password}, phone_auth::{phone_login, send_phone_code}, + profile_identity::update_profile_identity, puzzle::{ advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session, delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action, @@ -87,6 +88,7 @@ use crate::{ get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, + update_puzzle_run_pause, use_puzzle_runtime_prop, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -247,6 +249,12 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/me", + axum::routing::patch(update_profile_identity).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/auth/refresh", post(refresh_session).route_layer(middleware::from_fn_with_state( @@ -783,6 +791,20 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/puzzle/runs/{run_id}/pause", + post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/puzzle/runs/{run_id}/props", + post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/puzzle/runs/{run_id}/leaderboard", post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 13d9485a..4c0406fb 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -29,7 +29,7 @@ where } } -/// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。 +/// 资产操作统一预扣陶泥币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。 async fn consume_asset_operation_points( state: &AppState, owner_user_id: &str, @@ -79,7 +79,7 @@ async fn refund_asset_operation_points( asset_kind, asset_id, error = %error, - "资产操作失败后的叙世币退款失败" + "资产操作失败后的陶泥币退款失败" ); } } @@ -87,7 +87,7 @@ async fn refund_asset_operation_points( pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, - SpacetimeClientError::Procedure(message) if message.contains("叙世币余额不足") => { + SpacetimeClientError::Procedure(message) if message.contains("陶泥币余额不足") => { StatusCode::CONFLICT } _ => StatusCode::BAD_GATEWAY, diff --git a/server-rs/crates/api-server/src/auth_payload.rs b/server-rs/crates/api-server/src/auth_payload.rs index 0e55b2cb..8edcef9a 100644 --- a/server-rs/crates/api-server/src/auth_payload.rs +++ b/server-rs/crates/api-server/src/auth_payload.rs @@ -7,6 +7,7 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload { public_user_code: user.public_user_code, username: user.username, display_name: user.display_name, + avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked, login_method: user.login_method.as_str().to_string(), binding_status: user.binding_status.as_str().to_string(), @@ -19,5 +20,6 @@ pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPaylo id: user.id, public_user_code: user.public_user_code, display_name: user.display_name, + avatar_url: user.avatar_url, } } diff --git a/server-rs/crates/api-server/src/auth_public_user.rs b/server-rs/crates/api-server/src/auth_public_user.rs index c33c384d..d1a641ad 100644 --- a/server-rs/crates/api-server/src/auth_public_user.rs +++ b/server-rs/crates/api-server/src/auth_public_user.rs @@ -20,7 +20,7 @@ pub async fn get_public_user_by_code( .get_user_by_public_user_code(&code) .map_err(map_public_user_search_error)? .ok_or_else(|| { - AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应叙世号用户") + AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应陶泥号用户") })?; Ok(json_success_body( @@ -60,12 +60,15 @@ pub async fn get_public_user_by_id( fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError { match error { module_auth::PasswordEntryError::InvalidPublicUserCode => { - AppError::from_status(StatusCode::BAD_REQUEST).with_message("叙世号格式不正确") + AppError::from_status(StatusCode::BAD_REQUEST).with_message("陶泥号格式不正确") } module_auth::PasswordEntryError::Store(_) | module_auth::PasswordEntryError::PasswordHash(_) | module_auth::PasswordEntryError::InvalidPhoneNumber | module_auth::PasswordEntryError::InvalidPasswordLength + | module_auth::PasswordEntryError::InvalidDisplayName + | module_auth::PasswordEntryError::InvalidAvatarDataUrl + | module_auth::PasswordEntryError::EmptyProfileUpdate | module_auth::PasswordEntryError::InvalidCredentials | module_auth::PasswordEntryError::UserNotFound => { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index dbf5f79c..1528ecc2 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -936,7 +936,9 @@ fn map_big_fish_work_summary_response( cover_image_src: item.cover_image_src, status: item.status, updated_at: current_timestamp_micros_to_string(item.updated_at_micros), - published_at: item.published_at_micros.map(current_timestamp_micros_to_string), + published_at: item + .published_at_micros + .map(current_timestamp_micros_to_string), publish_ready: item.publish_ready, level_count: item.level_count, level_main_image_ready_count: item.level_main_image_ready_count, @@ -945,6 +947,7 @@ fn map_big_fish_work_summary_response( play_count: item.play_count, remix_count: item.remix_count, like_count: item.like_count, + recent_play_count_7d: item.recent_play_count_7d, } } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index deb69712..236059fe 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1,4 +1,4 @@ -use std::{env, fs, net::SocketAddr, path::PathBuf}; +use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; use platform_llm::{ DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, @@ -74,6 +74,7 @@ pub struct AppConfig { pub spacetime_database: String, pub spacetime_token: Option, pub spacetime_pool_size: u32, + pub spacetime_procedure_timeout: Duration, pub llm_provider: LlmProvider, pub llm_base_url: String, pub llm_api_key: Option, @@ -165,6 +166,7 @@ impl Default for AppConfig { spacetime_database: "genarrative-dev".to_string(), spacetime_token: None, spacetime_pool_size: 4, + spacetime_procedure_timeout: Duration::from_secs(30), llm_provider: LlmProvider::Ark, llm_base_url: DEFAULT_ARK_BASE_URL.to_string(), llm_api_key: None, @@ -436,6 +438,12 @@ impl AppConfig { { config.spacetime_pool_size = spacetime_pool_size; } + if let Some(spacetime_procedure_timeout_seconds) = + read_first_duration_seconds_env(&["GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"]) + { + config.spacetime_procedure_timeout = + Duration::from_secs(spacetime_procedure_timeout_seconds); + } if let Some(llm_provider) = read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"]) @@ -840,6 +848,26 @@ mod tests { } } + #[test] + fn from_env_reads_spacetime_procedure_timeout() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"); + std::env::set_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS", "45"); + } + + let config = AppConfig::from_env(); + assert_eq!(config.spacetime_procedure_timeout.as_secs(), 45); + + unsafe { + std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"); + } + } + #[test] fn from_env_reads_rpg_llm_web_search_switch() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index b1f789d3..7493fb00 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -414,9 +414,10 @@ pub async fn get_custom_world_library( Extension(authenticated): Extension, ) -> Result, Response> { let owner_user_id = authenticated.claims().user_id().to_string(); + let author_display_name = resolve_author_display_name(&state, &authenticated); let entries = state .spacetime_client() - .list_custom_world_profiles(owner_user_id) + .list_custom_world_works(owner_user_id.clone()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) @@ -427,7 +428,13 @@ pub async fn get_custom_world_library( CustomWorldLibraryResponse { entries: entries .into_iter() - .map(map_custom_world_library_entry_response) + .filter_map(|item| { + map_custom_world_library_entry_response_from_work_summary( + item, + &owner_user_id, + &author_display_name, + ) + }) .collect(), }, )) @@ -2712,9 +2719,89 @@ fn map_custom_world_library_entry_response( play_count: entry.play_count, remix_count: entry.remix_count, like_count: entry.like_count, + recent_play_count_7d: 0, } } +fn map_custom_world_library_entry_response_from_work_summary( + item: CustomWorldWorkSummaryRecord, + owner_user_id: &str, + author_display_name: &str, +) -> Option { + let profile_id = item.profile_id.as_ref()?.clone(); + let profile = build_custom_world_library_list_profile_payload(&item, &profile_id); + Some(CustomWorldLibraryEntryResponse { + owner_user_id: owner_user_id.to_string(), + public_work_code: (item.status == "published") + .then(|| build_public_work_code_from_profile_id(&profile_id)), + profile_id, + author_public_user_code: None, + profile, + visibility: item.status, + published_at: item.published_at, + updated_at: item.updated_at, + author_display_name: author_display_name.to_string(), + world_name: item.title, + subtitle: item.subtitle, + summary_text: item.summary, + cover_image_src: item.cover_image_src, + theme_mode: "mythic".to_string(), + playable_npc_count: item.playable_npc_count, + landmark_count: item.landmark_count, + play_count: 0, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 0, + }) +} + +fn build_public_work_code_from_profile_id(profile_id: &str) -> String { + let digits = profile_id + .chars() + .filter(|character| character.is_ascii_digit()) + .collect::(); + let normalized_digits = if digits.is_empty() { + let checksum = profile_id.bytes().fold(0u32, |accumulator, value| { + accumulator.wrapping_mul(131) + u32::from(value) + }); + format!("{:08}", checksum % 100_000_000) + } else { + format!("{:0>8}", &digits[digits.len().saturating_sub(8)..]) + }; + + format!("CW-{normalized_digits}") +} + +fn build_custom_world_library_list_profile_payload( + item: &CustomWorldWorkSummaryRecord, + profile_id: &str, +) -> Value { + json!({ + "id": profile_id, + "name": item.title, + "subtitle": item.subtitle, + "summary": item.summary, + "tone": "", + "playerGoal": "", + "settingText": "", + "themeMode": "mythic", + "templateWorldType": "WUXIA", + "compatibilityTemplateWorldType": Value::Null, + "cover": item.cover_image_src.as_ref().map(|image_src| json!({ + "sourceType": "generated", + "imageSrc": image_src, + })), + "majorFactions": [], + "coreConflicts": [], + "playableNpcs": [], + "storyNpcs": [], + "items": [], + "camp": Value::Null, + "landmarks": [], + "ownedSettingLayers": Value::Null, + }) +} + fn map_custom_world_gallery_card_response( entry: CustomWorldGalleryEntryRecord, ) -> CustomWorldGalleryCardResponse { @@ -2737,6 +2824,7 @@ fn map_custom_world_gallery_card_response( play_count: entry.play_count, remix_count: entry.remix_count, like_count: entry.like_count, + recent_play_count_7d: entry.recent_play_count_7d, } } @@ -3308,7 +3396,7 @@ fn resolve_author_public_user_code( request_context, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "custom-world-library", - "message": format!("作者叙世号读取失败:{error}"), + "message": format!("作者陶泥号读取失败:{error}"), })), ) })? @@ -3319,7 +3407,7 @@ fn resolve_author_public_user_code( request_context, AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({ "provider": "custom-world-library", - "message": "当前登录用户缺少叙世号", + "message": "当前登录用户缺少陶泥号", })), ) }) diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index d52a7748..7036decc 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -39,7 +39,7 @@ pub async fn generate_custom_world_foundation_draft( emit_foundation_draft_progress( &mut on_progress, "整理世界骨架", - "正在根据创作者锚点生成第一版世界框架。", + "正在根据陶泥主锚点生成第一版世界框架。", 12, ); let mut framework = request_foundation_json_stage( diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 4a3c767a..48b5ef65 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -42,6 +42,7 @@ mod logout_all; mod password_entry; mod password_management; mod phone_auth; +mod profile_identity; mod prompt; mod puzzle; mod puzzle_agent_turn; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 52bbd3a3..9141656c 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -80,10 +80,15 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError { "field": "password", })), PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("叙世号格式不正确") + .with_message("陶泥号格式不正确") .with_details(json!({ "field": "phone", })), + PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } PasswordEntryError::InvalidCredentials => { AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误") } diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index cc75fbc2..f2895b20 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -103,6 +103,11 @@ fn map_password_management_error(error: PasswordEntryError) -> AppError { PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) } + PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("密码长度需要在 6 到 128 位之间"), PasswordEntryError::InvalidCredentials => { diff --git a/server-rs/crates/api-server/src/profile_identity.rs b/server-rs/crates/api-server/src/profile_identity.rs new file mode 100644 index 00000000..fc5365f2 --- /dev/null +++ b/server-rs/crates/api-server/src/profile_identity.rs @@ -0,0 +1,105 @@ +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use image::GenericImageView; +use module_auth::{PasswordEntryError, UpdateProfileInput}; +use shared_contracts::auth::{ProfileUpdateRequest, ProfileUpdateResponse}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, + auth_payload::map_auth_user_payload, http_error::AppError, request_context::RequestContext, + state::AppState, +}; + +const MAX_AVATAR_BYTES: usize = 5 * 1024 * 1024; +const AVATAR_SIZE_PX: u32 = 256; + +pub async fn update_profile_identity( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + if let Some(avatar_data_url) = payload.avatar_data_url.as_deref() { + validate_avatar_data_url(avatar_data_url)?; + } + + let result = state + .password_entry_service() + .update_profile(UpdateProfileInput { + user_id: authenticated.claims().user_id().to_string(), + display_name: payload.display_name, + avatar_url: payload.avatar_data_url, + }) + .map_err(map_profile_update_error)?; + + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileUpdateResponse { + user: map_auth_user_payload(result.user), + }, + )) +} + +fn validate_avatar_data_url(value: &str) -> Result<(), AppError> { + let Some((header, payload)) = value.trim().split_once(',') else { + return Err(invalid_avatar_error("头像图片格式不正确")); + }; + if !matches!( + header, + "data:image/png;base64" | "data:image/jpeg;base64" | "data:image/webp;base64" + ) { + return Err(invalid_avatar_error("头像仅支持 jpg、png、webp")); + } + + let bytes = BASE64_STANDARD + .decode(payload) + .map_err(|_| invalid_avatar_error("头像图片格式不正确"))?; + if bytes.len() > MAX_AVATAR_BYTES { + return Err(invalid_avatar_error("头像图片不能超过 5MB")); + } + + let image = + image::load_from_memory(&bytes).map_err(|_| invalid_avatar_error("头像图片格式不正确"))?; + let (width, height) = image.dimensions(); + if width != AVATAR_SIZE_PX || height != AVATAR_SIZE_PX { + return Err(invalid_avatar_error("头像裁剪尺寸需要为 256x256")); + } + + Ok(()) +} + +fn invalid_avatar_error(message: &'static str) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) +} + +fn map_profile_update_error(error: PasswordEntryError) -> AppError { + match error { + PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + PasswordEntryError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录"), + PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + } + PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidPublicUserCode + | PasswordEntryError::InvalidCredentials => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + } +} diff --git a/server-rs/crates/api-server/src/prompt/big_fish.rs b/server-rs/crates/api-server/src/prompt/big_fish.rs index a1e28fe8..9377c9b9 100644 --- a/server-rs/crates/api-server/src/prompt/big_fish.rs +++ b/server-rs/crates/api-server/src/prompt/big_fish.rs @@ -10,7 +10,7 @@ use crate::creation_agent_anchor_templates::{ }; use crate::creation_agent_chat::render_quick_fill_extra_rules; -pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。 +pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。 你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。 diff --git a/server-rs/crates/api-server/src/prompt/puzzle_image.rs b/server-rs/crates/api-server/src/prompt/puzzle_image.rs index 89645b5c..03d244a3 100644 --- a/server-rs/crates/api-server/src/prompt/puzzle_image.rs +++ b/server-rs/crates/api-server/src/prompt/puzzle_image.rs @@ -5,14 +5,14 @@ pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = "低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污"; -/// 根据拼图关卡名和创作者输入构造最终发给图片模型的提示词。 +/// 根据拼图关卡名和陶泥主输入构造最终发给图片模型的提示词。 pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { format!( concat!( - "请生成一张适合正方形拼图关卡的高清插画。", + "请生成一张适合 9:16 竖屏拼图关卡的高清插画。", "关卡名:{level_name}。", "画面主体:{prompt}。", - "画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,", + "画面要求:9:16 竖屏画布,适配 3x3 或 4x4 拼图切块,", "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", "避免文字、水印、边框和 UI 元素。" ), @@ -31,7 +31,7 @@ mod tests { assert!(prompt.contains("雨夜神庙")); assert!(prompt.contains("猫咪在发光遗迹前寻找线索")); - assert!(prompt.contains("正方形拼图关卡")); + assert!(prompt.contains("9:16 竖屏拼图关卡")); assert!(prompt.contains("3x3 或 4x4")); assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 66e33d10..eb271525 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -17,7 +17,7 @@ use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; -use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate}; +use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, @@ -40,7 +40,7 @@ use shared_contracts::{ PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, - SwapPuzzlePiecesRequest, + SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest, }, puzzle_works::{ PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, @@ -57,9 +57,10 @@ use spacetime_client::{ PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, - PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError, + PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, + PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; @@ -85,6 +86,7 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery"; const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime"; const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash"; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; +const PUZZLE_GENERATED_IMAGE_SIZE: &str = "720*1280"; pub async fn create_puzzle_agent_session( State(state): State, @@ -103,7 +105,7 @@ pub async fn create_puzzle_agent_session( ) })?; - let seed_text = payload.seed_text.unwrap_or_default().trim().to_string(); + let seed_text = build_puzzle_form_seed_text(&payload); let session = state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { @@ -455,6 +457,8 @@ pub async fn execute_puzzle_agent_action( &state, session_id.clone(), owner_user_id.clone(), + payload.prompt_text.as_deref(), + payload.reference_image_src.as_deref(), now, ) .await @@ -1142,6 +1146,120 @@ pub async fn advance_puzzle_next_level( )) } +pub async fn update_puzzle_run_pause( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .update_puzzle_run_pause(PuzzleRunPauseRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + paused: payload.paused, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(run), + }, + )) +} + +pub async fn use_puzzle_runtime_prop( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.prop_kind, + "propKind", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let prop_kind = payload.prop_kind.trim().to_string(); + let billing_asset_kind = match prop_kind.as_str() { + "hint" => "puzzle_prop_hint", + "reference" => "puzzle_prop_preview", + "freezeTime" | "freeze_time" => "puzzle_prop_freeze_time", + _ => { + return Err(puzzle_bad_request( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + "unknown puzzle prop kind", + )); + } + }; + let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); + let reducer_owner_user_id = owner_user_id.clone(); + let run = execute_billable_asset_operation( + &state, + &owner_user_id, + billing_asset_kind, + billing_asset_id.as_str(), + async { + state + .spacetime_client() + .use_puzzle_runtime_prop(PuzzleRunPropRecordInput { + run_id, + owner_user_id: reducer_owner_user_id, + prop_kind, + used_at_micros: current_utc_micros(), + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await + .map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(run), + }, + )) +} + pub async fn advance_local_puzzle_next_level( State(state): State, Extension(request_context): Extension, @@ -1399,6 +1517,7 @@ fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWork play_count: item.play_count, remix_count: item.remix_count, like_count: item.like_count, + recent_play_count_7d: item.recent_play_count_7d, publish_ready: item.publish_ready, } } @@ -1465,6 +1584,13 @@ fn map_puzzle_level_request_record( started_at_ms: level.started_at_ms, cleared_at_ms: level.cleared_at_ms, elapsed_ms: level.elapsed_ms, + time_limit_ms: level.time_limit_ms, + remaining_ms: level.remaining_ms, + paused_accumulated_ms: level.paused_accumulated_ms, + pause_started_at_ms: level.pause_started_at_ms, + freeze_accumulated_ms: level.freeze_accumulated_ms, + freeze_started_at_ms: level.freeze_started_at_ms, + freeze_until_ms: level.freeze_until_ms, leaderboard_entries: level .leaderboard_entries .into_iter() @@ -1524,6 +1650,18 @@ fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> Puzzle fn map_puzzle_runtime_level_response( level: spacetime_client::PuzzleRuntimeLevelRecord, ) -> PuzzleRuntimeLevelSnapshotResponse { + let timer_defaults = build_puzzle_runtime_timer_response_defaults(level.grid_size); + let time_limit_ms = if level.time_limit_ms == 0 { + timer_defaults.time_limit_ms + } else { + level.time_limit_ms + }; + let remaining_ms = + if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() { + time_limit_ms + } else { + level.remaining_ms.min(time_limit_ms) + }; PuzzleRuntimeLevelSnapshotResponse { run_id: level.run_id, level_index: level.level_index, @@ -1538,6 +1676,13 @@ fn map_puzzle_runtime_level_response( started_at_ms: level.started_at_ms, cleared_at_ms: level.cleared_at_ms, elapsed_ms: level.elapsed_ms, + time_limit_ms, + remaining_ms, + paused_accumulated_ms: level.paused_accumulated_ms, + pause_started_at_ms: level.pause_started_at_ms, + freeze_accumulated_ms: level.freeze_accumulated_ms, + freeze_started_at_ms: level.freeze_started_at_ms, + freeze_until_ms: level.freeze_until_ms, leaderboard_entries: level .leaderboard_entries .into_iter() @@ -1546,6 +1691,17 @@ fn map_puzzle_runtime_level_response( } } +struct PuzzleRuntimeTimerResponseDefaults { + time_limit_ms: u64, +} + +fn build_puzzle_runtime_timer_response_defaults( + grid_size: u32, +) -> PuzzleRuntimeTimerResponseDefaults { + let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size); + PuzzleRuntimeTimerResponseDefaults { time_limit_ms } +} + fn map_puzzle_leaderboard_entry_response( entry: PuzzleLeaderboardEntryRecord, ) -> PuzzleLeaderboardEntryResponse { @@ -1612,10 +1768,28 @@ fn resolve_author_display_name( fn build_puzzle_welcome_text(seed_text: &str) -> String { if seed_text.trim().is_empty() { - return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。".to_string(); + return "拼图创作信息已准备好。".to_string(); } - "我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string() + "拼图创作信息已准备好。".to_string() +} + +fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { + let title = payload.seed_text.as_deref().unwrap_or_default().trim(); + let picture_description = payload + .picture_description + .as_deref() + .unwrap_or_default() + .trim(); + + if title.is_empty() && picture_description.is_empty() { + return String::new(); + } + if title.is_empty() || picture_description.is_empty() { + return format!("{title}{picture_description}"); + } + + format!("拼图标题:{title}\n画面描述:{picture_description}") } fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { @@ -1632,6 +1806,8 @@ async fn compile_puzzle_draft_with_initial_cover( state: &AppState, session_id: String, owner_user_id: String, + prompt_text: Option<&str>, + reference_image_src: Option<&str>, now: i64, ) -> Result { let compiled_session = state @@ -1648,8 +1824,11 @@ async fn compile_puzzle_draft_with_initial_cover( owner_user_id.as_str(), &compiled_session.session_id, &draft.level_name, - &draft.summary, - None, + prompt_text + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(draft.summary.as_str()), + reference_image_src, 1, draft.candidates.len(), ) @@ -1815,6 +1994,7 @@ async fn generate_puzzle_image_candidates( None => None, }; // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。 + // 中文注释:拼图作品资产统一按 9:16 竖屏生成,运行时棋盘也按同一比例切块承载。 let generated = match reference_image.as_deref() { Some(reference_image) => { create_puzzle_image_to_image_generation( @@ -1822,7 +2002,7 @@ async fn generate_puzzle_image_candidates( &settings, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, - "1024*1024", + PUZZLE_GENERATED_IMAGE_SIZE, count, reference_image, ) @@ -1834,7 +2014,7 @@ async fn generate_puzzle_image_candidates( &settings, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, - "1024*1024", + PUZZLE_GENERATED_IMAGE_SIZE, count, ) .await @@ -2079,6 +2259,7 @@ fn build_next_run_from_parts( ) -> PuzzleRunRecord { let next_level_index = run.current_level_index + 1; let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 }; + let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size); let mut played_profile_ids = run.played_profile_ids.clone(); if !played_profile_ids.contains(&profile_id) { played_profile_ids.push(profile_id.clone()); @@ -2106,6 +2287,13 @@ fn build_next_run_from_parts( started_at_ms: (current_utc_micros().max(0) as u64) / 1_000, cleared_at_ms: None, elapsed_ms: None, + time_limit_ms, + remaining_ms: time_limit_ms, + paused_accumulated_ms: 0, + pause_started_at_ms: None, + freeze_accumulated_ms: 0, + freeze_started_at_ms: None, + freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, @@ -2221,6 +2409,11 @@ mod tests { assert!(!has_original_neighbor_pair(&second)); assert!(!has_original_neighbor_pair(&third)); } + + #[test] + fn puzzle_generated_image_size_is_portrait_9_16() { + assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "720*1280"); + } } struct PuzzleDashScopeSettings { diff --git a/server-rs/crates/api-server/src/puzzle_agent_turn.rs b/server-rs/crates/api-server/src/puzzle_agent_turn.rs index a1f7803f..34af6f05 100644 --- a/server-rs/crates/api-server/src/puzzle_agent_turn.rs +++ b/server-rs/crates/api-server/src/puzzle_agent_turn.rs @@ -60,7 +60,7 @@ struct PuzzleAgentModelOutput { next_anchor_pack: PuzzleAnchorPack, } -const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创拼图画面的中文创意策划。 +const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创拼图画面的中文创意策划。 你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。 diff --git a/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs b/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs index d662054b..929f8ec3 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs @@ -168,7 +168,10 @@ fn resolve_npc_battle_formation( if !visible_formation.is_empty() { return visible_formation .into_iter() - .map(|monster| normalize_npc_battle_monster(monster, battle_mode)) + .enumerate() + .map(|(index, monster)| { + normalize_npc_battle_monster(monster, encounter, battle_mode, index) + }) .collect(); } @@ -185,7 +188,12 @@ fn resolve_npc_battle_formation( .unwrap_or_default() } -fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value { +fn normalize_npc_battle_monster( + mut monster: Value, + fallback_encounter: Option<&Value>, + battle_mode: &str, + index: usize, +) -> Value { let Some(monster_object) = monster.as_object_mut() else { return monster; }; @@ -211,6 +219,26 @@ fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value monster_object .entry("hp".to_string()) .or_insert_with(|| json!(max_hp)); + if !monster_object + .get("encounter") + .is_some_and(|value| value.is_object()) + && let Some(fallback_encounter) = fallback_encounter + { + // 中文注释:进入 NPC 战斗时画布已经改由 sceneHostileNpcs 渲染敌方; + // 旧快照里的敌方条目可能只有数值没有形象上下文,必须把当前 NPC encounter 补进去。 + let mut battle_encounter = fallback_encounter.clone(); + if let Some(entry) = battle_encounter.as_object_mut() { + entry.insert("hostile".to_string(), Value::Bool(true)); + if !entry.contains_key("xMeters") { + let x_meters = monster_object + .get("xMeters") + .and_then(Value::as_f64) + .unwrap_or(3.2 + index as f64 * 1.08); + entry.insert("xMeters".to_string(), json!(x_meters)); + } + } + monster_object.insert("encounter".to_string(), battle_encounter); + } monster } diff --git a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs index 2b7b60ae..a54e2645 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs @@ -2502,6 +2502,58 @@ fn runtime_story_npc_fight_resolves_battle_snapshot_without_frontend_bridge() { ); } +#[test] +fn runtime_story_npc_fight_does_not_accept_pending_quest_and_keeps_target_renderable() { + let request = RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: "npc_fight".to_string(), + target_id: None, + payload: Some(json!({ "optionText": "直接开战" })), + }, + snapshot: None, + }; + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + ensure_json_object(&mut game_state).insert( + "sceneHostileNpcs".to_string(), + json!([{ + "id": "npc_merchant_01", + "name": "沈七", + "hp": 30, + "maxHp": 30, + "xMeters": 3.2 + }]), + ); + let current_story = build_runtime_story_pending_quest_offer_fixture( + build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), + ); + + let resolution = resolve_runtime_story_choice_action( + &mut game_state, + Some(¤t_story), + &request, + "npc_fight", + ) + .expect("npc fight should resolve"); + + assert!(resolution.result_text.contains("战斗节奏")); + assert!(read_array_field(&game_state, "quests").is_empty()); + assert_eq!( + read_field(&game_state, "runtimeStats") + .and_then(|stats| read_i32_field(stats, "questsAccepted")), + Some(0) + ); + let formation = read_array_field(&game_state, "sceneHostileNpcs"); + assert_eq!(formation.len(), 1); + assert_eq!( + read_object_field(formation[0], "encounter") + .and_then(|encounter| read_optional_string_field(encounter, "id")), + Some("npc_merchant_01".to_string()) + ); +} + #[test] fn runtime_story_npc_spar_resolves_lightweight_battle_snapshot() { let request = RuntimeStoryActionRequest { diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 7e2b1b35..ba6d10d1 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -164,6 +164,7 @@ impl AppState { database: config.spacetime_database.clone(), token: config.spacetime_token.clone(), pool_size: config.spacetime_pool_size, + procedure_timeout: config.spacetime_procedure_timeout, }); let llm_client = build_llm_client(&config)?; @@ -242,6 +243,7 @@ impl AppState { database: config.spacetime_database.clone(), token: config.spacetime_token.clone(), pool_size: config.spacetime_pool_size, + procedure_timeout: config.spacetime_procedure_timeout, }); match spacetime_client .export_auth_store_snapshot_from_tables() diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 57005c54..850bf806 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -24,6 +24,9 @@ const SMS_CODE_LENGTH: usize = 6; const SMS_CODE_TTL_MINUTES: i64 = 5; const SMS_CODE_COOLDOWN_SECONDS: u64 = 60; const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5; +const DISPLAY_NAME_MIN_CHARS: usize = 2; +const DISPLAY_NAME_MAX_CHARS: usize = 20; +const AVATAR_DATA_URL_MAX_CHARS: usize = 400_000; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthLoginMethod { @@ -44,6 +47,7 @@ pub struct AuthUser { pub public_user_code: String, pub username: String, pub display_name: String, + pub avatar_url: Option, pub phone_number_masked: Option, pub login_method: AuthLoginMethod, pub binding_status: AuthBindingStatus, @@ -85,6 +89,18 @@ pub struct ChangePasswordResult { pub user: AuthUser, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateProfileInput { + pub user_id: String, + pub display_name: Option, + pub avatar_url: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateProfileResult { + pub user: AuthUser, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResetPasswordInput { pub phone_number: String, @@ -316,6 +332,9 @@ pub enum PasswordEntryError { InvalidPhoneNumber, InvalidPasswordLength, InvalidPublicUserCode, + InvalidDisplayName, + InvalidAvatarDataUrl, + EmptyProfileUpdate, InvalidCredentials, UserNotFound, Store(String), @@ -572,6 +591,25 @@ impl PasswordEntryService { Ok(ChangePasswordResult { user }) } + + pub fn update_profile( + &self, + input: UpdateProfileInput, + ) -> Result { + let display_name = input.display_name.map(validate_display_name).transpose()?; + let avatar_url = input.avatar_url.map(validate_avatar_data_url).transpose()?; + + if display_name.is_none() && avatar_url.is_none() { + return Err(PasswordEntryError::EmptyProfileUpdate); + } + + let user = self + .store + .update_user_profile(&input.user_id, display_name, avatar_url)? + .ok_or(PasswordEntryError::UserNotFound)?; + + Ok(UpdateProfileResult { user }) + } } impl RefreshSessionService { @@ -1345,6 +1383,7 @@ impl InMemoryAuthStore { public_user_code, username: username.clone(), display_name, + avatar_url: None, phone_number_masked: Some(phone_number.masked_national_number.clone()), login_method: AuthLoginMethod::Phone, binding_status: AuthBindingStatus::Active, @@ -1392,6 +1431,7 @@ impl InMemoryAuthStore { public_user_code, username: username.clone(), display_name, + avatar_url: None, phone_number_masked: Some(phone_number.masked_national_number.clone()), login_method: AuthLoginMethod::Password, binding_status: AuthBindingStatus::Active, @@ -1442,6 +1482,7 @@ impl InMemoryAuthStore { public_user_code, username: username.clone(), display_name, + avatar_url: normalize_optional_string(profile.avatar_url.clone()), phone_number_masked: None, login_method: AuthLoginMethod::Wechat, binding_status: AuthBindingStatus::PendingBindPhone, @@ -1544,7 +1585,7 @@ impl InMemoryAuthStore { // 否则下一次只能按 unionid 命中,随后刷新资料时会因为旧 openid 不存在而丢失 identity。 identity.provider_uid = next_provider_uid.clone(); identity.display_name = next_display_name.clone(); - identity.avatar_url = next_avatar_url; + identity.avatar_url = next_avatar_url.clone(); identity.provider_union_id = next_provider_union_id.clone(); state .wechat_identity_by_provider_uid @@ -1570,6 +1611,9 @@ impl InMemoryAuthStore { { stored_user.user.display_name = display_name.to_string(); } + if let Some(avatar_url) = next_avatar_url { + stored_user.user.avatar_url = Some(avatar_url); + } stored_user.user.clone() }; self.persist_wechat_state(&state)?; @@ -1604,6 +1648,37 @@ impl InMemoryAuthStore { Ok(()) } + fn update_user_profile( + &self, + user_id: &str, + display_name: Option, + avatar_url: Option, + ) -> Result, PasswordEntryError> { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + + let Some(stored_user) = state + .users_by_username + .values_mut() + .find(|stored_user| stored_user.user.id == user_id) + else { + return Ok(None); + }; + + if let Some(display_name) = display_name { + stored_user.user.display_name = display_name; + } + if let Some(avatar_url) = avatar_url { + stored_user.user.avatar_url = Some(avatar_url); + } + + let next_user = stored_user.user.clone(); + self.persist_password_state(&state)?; + Ok(Some(next_user)) + } + fn upsert_phone_code( &self, code: StoredPhoneCode, @@ -2144,7 +2219,12 @@ impl fmt::Display for PasswordEntryError { match self { Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), - Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"), + Self::InvalidPublicUserCode => f.write_str("陶泥号格式不正确"), + Self::InvalidDisplayName => { + f.write_str("昵称需要为 2 到 20 位中文、英文、数字或下划线") + } + Self::InvalidAvatarDataUrl => f.write_str("头像图片格式不正确"), + Self::EmptyProfileUpdate => f.write_str("昵称或头像至少修改一项"), Self::InvalidCredentials => f.write_str("手机号或密码错误"), Self::UserNotFound => f.write_str("用户不存在"), Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), @@ -2219,6 +2299,9 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError { PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode + | PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate | PasswordEntryError::InvalidCredentials | PasswordEntryError::UserNotFound | PasswordEntryError::PasswordHash(_) => { @@ -2234,6 +2317,9 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode + | PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate | PasswordEntryError::InvalidCredentials | PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()), } @@ -2245,6 +2331,9 @@ fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode + | PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate | PasswordEntryError::InvalidCredentials | PasswordEntryError::UserNotFound | PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()), @@ -2279,6 +2368,56 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> { Ok(()) } +fn validate_display_name(display_name: String) -> Result { + let normalized = + normalize_required_string(&display_name).ok_or(PasswordEntryError::InvalidDisplayName)?; + let char_count = normalized.chars().count(); + if !(DISPLAY_NAME_MIN_CHARS..=DISPLAY_NAME_MAX_CHARS).contains(&char_count) { + return Err(PasswordEntryError::InvalidDisplayName); + } + if !normalized.chars().all(is_allowed_display_name_char) { + return Err(PasswordEntryError::InvalidDisplayName); + } + + Ok(normalized) +} + +fn is_allowed_display_name_char(character: char) -> bool { + character.is_ascii_alphanumeric() + || character == '_' + || ('\u{4E00}'..='\u{9FFF}').contains(&character) +} + +fn validate_avatar_data_url(avatar_url: String) -> Result { + let normalized = + normalize_required_string(&avatar_url).ok_or(PasswordEntryError::InvalidAvatarDataUrl)?; + if normalized.len() > AVATAR_DATA_URL_MAX_CHARS { + return Err(PasswordEntryError::InvalidAvatarDataUrl); + } + + let Some((header, payload)) = normalized.split_once(',') else { + return Err(PasswordEntryError::InvalidAvatarDataUrl); + }; + if !matches!( + header, + "data:image/png;base64" | "data:image/jpeg;base64" | "data:image/webp;base64" + ) { + return Err(PasswordEntryError::InvalidAvatarDataUrl); + } + if payload.is_empty() + || !payload.chars().all(|character| { + character.is_ascii_alphanumeric() + || character == '+' + || character == '/' + || character == '=' + }) + { + return Err(PasswordEntryError::InvalidAvatarDataUrl); + } + + Ok(normalized) +} + async fn verify_stored_password_user( existing_user: StoredPasswordUser, password: &str, @@ -2360,7 +2499,7 @@ fn build_system_username(prefix: &str, sequence: u64) -> String { format!("{prefix}_{sequence:08}") } -// 公开叙世号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。 +// 公开陶泥号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。 fn build_public_user_code(sequence: u64) -> String { format!("SY-{sequence:08}") } @@ -2586,6 +2725,65 @@ mod tests { assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials); } + #[tokio::test] + async fn password_entry_update_profile_changes_display_name_and_avatar() { + let store = build_store(); + let service = build_password_service(store); + let created = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138010".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("dev registration should create user") + .user; + let avatar_data_url = "data:image/png;base64,aGVsbG8=".to_string(); + + let updated = service + .update_profile(UpdateProfileInput { + user_id: created.id.clone(), + display_name: Some("旅人甲_01".to_string()), + avatar_url: Some(avatar_data_url.clone()), + }) + .expect("profile should update") + .user; + + assert_eq!(updated.display_name, "旅人甲_01"); + assert_eq!(updated.avatar_url, Some(avatar_data_url)); + } + + #[tokio::test] + async fn password_entry_update_profile_rejects_empty_or_invalid_payload() { + let store = build_store(); + let service = build_password_service(store); + let created = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138011".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("dev registration should create user") + .user; + + let empty_error = service + .update_profile(UpdateProfileInput { + user_id: created.id.clone(), + display_name: None, + avatar_url: None, + }) + .expect_err("empty profile update should fail"); + let invalid_name_error = service + .update_profile(UpdateProfileInput { + user_id: created.id, + display_name: Some("旅人-甲".to_string()), + avatar_url: None, + }) + .expect_err("invalid display name should fail"); + + assert_eq!(empty_error, PasswordEntryError::EmptyProfileUpdate); + assert_eq!(invalid_name_error, PasswordEntryError::InvalidDisplayName); + } + #[tokio::test] async fn phone_user_can_set_password_then_login() { let store = build_store(); diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index 29ad42b9..ab155e36 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -9,6 +9,7 @@ pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-"; pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-"; pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-"; pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-"; +pub const PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID: &str = "public-big-fish-gallery"; pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8; pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6; pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12; @@ -228,6 +229,8 @@ pub struct BigFishWorkSummarySnapshot { pub play_count: u32, pub remix_count: u32, pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, pub published_at_micros: Option, } @@ -952,4 +955,13 @@ mod tests { ); assert!(coverage.blockers.iter().any(|item| item.contains("背景图"))); } + + #[test] + fn public_big_fish_gallery_owner_placeholder_is_non_empty() { + assert_eq!( + PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID, + "public-big-fish-gallery" + ); + assert!(!PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID.trim().is_empty()); + } } diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index 41cc8be9..d09af952 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -213,6 +213,7 @@ pub struct CustomWorldGalleryEntrySnapshot { pub play_count: u32, pub remix_count: u32, pub like_count: u32, + pub recent_play_count_7d: u32, pub published_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 264c8c78..5b0256fd 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -15,6 +15,7 @@ pub const PUZZLE_PROFILE_ID_PREFIX: &str = "puzzle-profile-"; pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-"; pub const PUZZLE_MIN_TAG_COUNT: usize = 3; pub const PUZZLE_MAX_TAG_COUNT: usize = 6; +pub const PUZZLE_FREEZE_TIME_DURATION_MS: u64 = 10_000; const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -65,6 +66,7 @@ pub enum PuzzlePublicationStatus { pub enum PuzzleRuntimeLevelStatus { Playing, Cleared, + Failed, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -214,6 +216,8 @@ pub struct PuzzleWorkProfile { pub remix_count: u32, #[serde(default)] pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, pub publish_ready: bool, pub anchor_pack: PuzzleAnchorPack, } @@ -260,7 +264,9 @@ pub struct PuzzleBoardSnapshot { pub cols: u32, pub pieces: Vec, pub merged_groups: Vec, + #[serde(default)] pub selected_piece_id: Option, + #[serde(default)] pub all_tiles_resolved: bool, } @@ -277,9 +283,27 @@ pub struct PuzzleRuntimeLevelSnapshot { pub cover_image_src: Option, pub board: PuzzleBoardSnapshot, pub status: PuzzleRuntimeLevelStatus, + #[serde(default)] pub started_at_ms: u64, + #[serde(default)] pub cleared_at_ms: Option, + #[serde(default)] pub elapsed_ms: Option, + #[serde(default)] + pub time_limit_ms: u64, + #[serde(default)] + pub remaining_ms: u64, + #[serde(default)] + pub paused_accumulated_ms: u64, + #[serde(default)] + pub pause_started_at_ms: Option, + #[serde(default)] + pub freeze_accumulated_ms: u64, + #[serde(default)] + pub freeze_started_at_ms: Option, + #[serde(default)] + pub freeze_until_ms: Option, + #[serde(default)] pub leaderboard_entries: Vec, } @@ -295,6 +319,7 @@ pub struct PuzzleRunSnapshot { pub previous_level_tags: Vec, pub current_level: Option, pub recommended_next_profile_id: Option, + #[serde(default)] pub leaderboard_entries: Vec, } @@ -470,6 +495,24 @@ pub struct PuzzleRunNextLevelInput { pub advanced_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleRunPauseInput { + pub run_id: String, + pub owner_user_id: String, + pub paused: bool, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleRunPropInput { + pub run_id: String, + pub owner_user_id: String, + pub prop_kind: String, + pub used_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleLeaderboardSubmitInput { @@ -605,6 +648,7 @@ impl PuzzleRuntimeLevelStatus { match self { Self::Playing => "playing", Self::Cleared => "cleared", + Self::Failed => "failed", } } } @@ -648,6 +692,10 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl let source = normalize_required_string(latest_message.unwrap_or(seed_text)) .or_else(|| normalize_required_string(seed_text)) .unwrap_or_else(|| "童话森林里的发光猫咪遗迹".to_string()); + if let Some((title, picture_description)) = parse_form_seed_text(&source) { + return build_form_anchor_pack(title.as_str(), picture_description.as_str()); + } + let mut pack = empty_anchor_pack(); pack.theme_promise.value = infer_theme_promise(&source); pack.theme_promise.status = PuzzleAnchorStatus::Inferred; @@ -662,12 +710,38 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl pack } +pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleAnchorPack { + let normalized_title = + normalize_required_string(title).unwrap_or_else(|| "奇景拼图".to_string()); + let normalized_description = + normalize_required_string(picture_description).unwrap_or_else(|| normalized_title.clone()); + let mut pack = empty_anchor_pack(); + + pack.theme_promise.value = normalized_title.clone(); + pack.theme_promise.status = PuzzleAnchorStatus::Locked; + pack.visual_subject.value = normalized_description.clone(); + pack.visual_subject.status = PuzzleAnchorStatus::Locked; + pack.visual_mood.value = "清晰、适合拼图切块".to_string(); + pack.visual_mood.status = PuzzleAnchorStatus::Inferred; + pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string(); + pack.composition_hooks.status = PuzzleAnchorStatus::Inferred; + pack.tags_and_forbidden.value = + build_form_tags_and_forbidden(normalized_title.as_str(), normalized_description.as_str()); + pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred; + + pack +} + pub fn build_creator_intent( anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot], ) -> PuzzleCreatorIntent { PuzzleCreatorIntent { - source_mode: "agent_chat".to_string(), + source_mode: if is_form_anchor_pack(anchor_pack) { + "form".to_string() + } else { + "agent_chat".to_string() + }, raw_messages_summary: messages .iter() .rev() @@ -698,12 +772,7 @@ pub fn compile_result_draft( let level_name = build_level_name(anchor_pack, &normalized_tags); PuzzleResultDraft { level_name, - summary: format!( - "{},主体是{},氛围偏{}。", - fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"), - fallback_text(&anchor_pack.visual_subject.value, "画面主体"), - fallback_text(&anchor_pack.visual_mood.value, "温暖") - ), + summary: build_result_summary(anchor_pack), theme_tags: normalized_tags, forbidden_directives: creator_intent.forbidden_directives.clone(), creator_intent: Some(creator_intent), @@ -866,6 +935,7 @@ pub fn create_work_profile( play_count: 0, remix_count: 0, like_count: 0, + recent_play_count_7d: 0, publish_ready: preview.publish_ready, anchor_pack: draft.anchor_pack.clone(), }) @@ -930,6 +1000,159 @@ pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 { if cleared_level_count >= 3 { 4 } else { 3 } } +pub fn resolve_puzzle_level_time_limit_ms(grid_size: u32) -> u64 { + match grid_size { + 4 => 300_000, + _ => 180_000, + } +} + +pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { + let time_limit_ms = if level.time_limit_ms == 0 { + resolve_puzzle_level_time_limit_ms(level.grid_size) + } else { + level.time_limit_ms + }; + time_limit_ms.saturating_sub(resolve_effective_elapsed_ms(level, now_ms)) +} + +fn normalize_timer_fields(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { + if level.started_at_ms == 0 { + level.started_at_ms = now_ms; + } + if level.time_limit_ms == 0 { + level.time_limit_ms = resolve_puzzle_level_time_limit_ms(level.grid_size); + } + if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing { + level.remaining_ms = level.time_limit_ms; + } +} + +fn resolve_active_freeze_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { + match (level.freeze_started_at_ms, level.freeze_until_ms) { + (Some(started_at), Some(until_ms)) => now_ms.min(until_ms).saturating_sub(started_at), + _ => 0, + } +} + +fn resolve_effective_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { + let pause_elapsed_ms = level + .pause_started_at_ms + .map(|started_at| now_ms.saturating_sub(started_at)) + .unwrap_or(0); + now_ms + .saturating_sub(level.started_at_ms) + .saturating_sub(level.paused_accumulated_ms) + .saturating_sub(pause_elapsed_ms) + .saturating_sub(level.freeze_accumulated_ms) + .saturating_sub(resolve_active_freeze_elapsed_ms(level, now_ms)) +} + +fn settle_expired_freeze(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { + let (Some(started_at), Some(until_ms)) = (level.freeze_started_at_ms, level.freeze_until_ms) + else { + return; + }; + if now_ms < until_ms { + return; + } + level.freeze_accumulated_ms = level + .freeze_accumulated_ms + .saturating_add(until_ms.saturating_sub(started_at)); + level.freeze_started_at_ms = None; + level.freeze_until_ms = None; +} + +fn close_level_pause(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { + if let Some(pause_started_at_ms) = level.pause_started_at_ms.take() { + level.paused_accumulated_ms = level + .paused_accumulated_ms + .saturating_add(now_ms.saturating_sub(pause_started_at_ms)); + } +} + +pub fn resolve_puzzle_run_timer_at(mut run: PuzzleRunSnapshot, now_ms: u64) -> PuzzleRunSnapshot { + let Some(current_level) = run.current_level.as_mut() else { + return run; + }; + normalize_timer_fields(current_level, now_ms); + if current_level.status != PuzzleRuntimeLevelStatus::Playing { + return run; + } + + settle_expired_freeze(current_level, now_ms); + let effective_elapsed_ms = resolve_effective_elapsed_ms(current_level, now_ms); + current_level.remaining_ms = current_level + .time_limit_ms + .saturating_sub(effective_elapsed_ms); + if current_level.remaining_ms == 0 { + current_level.status = PuzzleRuntimeLevelStatus::Failed; + current_level.elapsed_ms = Some(current_level.time_limit_ms); + current_level.pause_started_at_ms = None; + current_level.freeze_started_at_ms = None; + current_level.freeze_until_ms = None; + } + run +} + +pub fn resolve_puzzle_run_timer(run: PuzzleRunSnapshot) -> PuzzleRunSnapshot { + resolve_puzzle_run_timer_at(run, current_unix_ms()) +} + +pub fn set_puzzle_run_paused_at( + run: &PuzzleRunSnapshot, + paused: bool, + now_ms: u64, +) -> Result { + let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = next_run + .current_level + .as_mut() + .ok_or(PuzzleFieldError::InvalidOperation)?; + if current_level.status != PuzzleRuntimeLevelStatus::Playing { + return Ok(next_run); + } + if paused { + if current_level.pause_started_at_ms.is_none() { + current_level.pause_started_at_ms = Some(now_ms); + } + return Ok(next_run); + } + close_level_pause(current_level, now_ms); + Ok(resolve_puzzle_run_timer_at(next_run, now_ms)) +} + +pub fn set_puzzle_run_paused( + run: &PuzzleRunSnapshot, + paused: bool, +) -> Result { + set_puzzle_run_paused_at(run, paused, current_unix_ms()) +} + +pub fn apply_puzzle_freeze_time_at( + run: &PuzzleRunSnapshot, + now_ms: u64, +) -> Result { + let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = next_run + .current_level + .as_mut() + .ok_or(PuzzleFieldError::InvalidOperation)?; + if current_level.status != PuzzleRuntimeLevelStatus::Playing { + return Err(PuzzleFieldError::InvalidOperation); + } + close_level_pause(current_level, now_ms); + current_level.freeze_started_at_ms = Some(now_ms); + current_level.freeze_until_ms = Some(now_ms.saturating_add(PUZZLE_FREEZE_TIME_DURATION_MS)); + Ok(next_run) +} + +pub fn apply_puzzle_freeze_time( + run: &PuzzleRunSnapshot, +) -> Result { + apply_puzzle_freeze_time_at(run, current_unix_ms()) +} + pub fn build_initial_board(grid_size: u32) -> Result { build_initial_board_with_seed(grid_size, 0) } @@ -951,6 +1174,20 @@ pub fn start_run( run_id: String, entry_profile: &PuzzleWorkProfile, cleared_level_count: u32, +) -> Result { + start_run_at( + run_id, + entry_profile, + cleared_level_count, + current_unix_ms(), + ) +} + +pub fn start_run_at( + run_id: String, + entry_profile: &PuzzleWorkProfile, + cleared_level_count: u32, + started_at_ms: u64, ) -> Result { let grid_size = resolve_puzzle_grid_size(cleared_level_count); let shuffle_seed = puzzle_shuffle_seed( @@ -959,7 +1196,13 @@ pub fn start_run( cleared_level_count + 1, grid_size, ); - start_run_with_shuffle_seed(run_id, entry_profile, cleared_level_count, shuffle_seed) + start_run_with_shuffle_seed_at( + run_id, + entry_profile, + cleared_level_count, + shuffle_seed, + started_at_ms, + ) } pub fn start_run_with_shuffle_seed( @@ -967,10 +1210,25 @@ pub fn start_run_with_shuffle_seed( entry_profile: &PuzzleWorkProfile, cleared_level_count: u32, shuffle_seed: u64, +) -> Result { + start_run_with_shuffle_seed_at( + run_id, + entry_profile, + cleared_level_count, + shuffle_seed, + current_unix_ms(), + ) +} + +pub fn start_run_with_shuffle_seed_at( + run_id: String, + entry_profile: &PuzzleWorkProfile, + cleared_level_count: u32, + shuffle_seed: u64, + started_at_ms: u64, ) -> Result { let grid_size = resolve_puzzle_grid_size(cleared_level_count); let board = build_initial_board_with_seed(grid_size, shuffle_seed)?; - let started_at_ms = current_unix_ms(); Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), @@ -993,6 +1251,13 @@ pub fn start_run_with_shuffle_seed( started_at_ms, cleared_at_ms: None, elapsed_ms: None, + time_limit_ms: resolve_puzzle_level_time_limit_ms(grid_size), + remaining_ms: resolve_puzzle_level_time_limit_ms(grid_size), + paused_accumulated_ms: 0, + pause_started_at_ms: None, + freeze_accumulated_ms: 0, + freeze_started_at_ms: None, + freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, @@ -1004,16 +1269,26 @@ pub fn swap_pieces( run: &PuzzleRunSnapshot, first_piece_id: &str, second_piece_id: &str, +) -> Result { + swap_pieces_at(run, first_piece_id, second_piece_id, current_unix_ms()) +} + +pub fn swap_pieces_at( + run: &PuzzleRunSnapshot, + first_piece_id: &str, + second_piece_id: &str, + now_ms: u64, ) -> Result { let first_piece_id = normalize_required_string(first_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; let second_piece_id = normalize_required_string(second_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; - let current_level = run + let timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = timed_run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; - if current_level.status == PuzzleRuntimeLevelStatus::Cleared { + if current_level.status != PuzzleRuntimeLevelStatus::Playing { return Err(PuzzleFieldError::InvalidOperation); } let mut pieces = current_level.board.pieces.clone(); @@ -1056,7 +1331,7 @@ pub fn swap_pieces( affected_cells, None, ); - Ok(with_next_board(run, next_board)) + Ok(with_next_board_at(&timed_run, next_board, now_ms)) } pub fn drag_piece_or_group( @@ -1064,13 +1339,24 @@ pub fn drag_piece_or_group( piece_id: &str, target_row: u32, target_col: u32, +) -> Result { + drag_piece_or_group_at(run, piece_id, target_row, target_col, current_unix_ms()) +} + +pub fn drag_piece_or_group_at( + run: &PuzzleRunSnapshot, + piece_id: &str, + target_row: u32, + target_col: u32, + now_ms: u64, ) -> Result { let piece_id = normalize_required_string(piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; - let current_level = run + let timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = timed_run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; - if current_level.status == PuzzleRuntimeLevelStatus::Cleared { + if current_level.status != PuzzleRuntimeLevelStatus::Playing { return Err(PuzzleFieldError::InvalidOperation); } let grid_size = current_level.grid_size; @@ -1097,7 +1383,7 @@ pub fn drag_piece_or_group( operation_cells, None, ); - Ok(with_next_board(run, next_board)) + Ok(with_next_board_at(&timed_run, next_board, now_ms)) } pub fn rebuild_board_snapshot_for_affected_cells( @@ -1175,6 +1461,14 @@ pub fn rebuild_board_snapshot_for_affected_cells( pub fn advance_next_level( run: &PuzzleRunSnapshot, next_profile: &PuzzleWorkProfile, +) -> Result { + advance_next_level_at(run, next_profile, current_unix_ms()) +} + +pub fn advance_next_level_at( + run: &PuzzleRunSnapshot, + next_profile: &PuzzleWorkProfile, + started_at_ms: u64, ) -> Result { let current_level = run .current_level @@ -1215,9 +1509,16 @@ pub fn advance_next_level( cover_image_src: next_profile.cover_image_src.clone(), board: next_board, status: PuzzleRuntimeLevelStatus::Playing, - started_at_ms: current_unix_ms(), + started_at_ms, cleared_at_ms: None, elapsed_ms: None, + time_limit_ms: resolve_puzzle_level_time_limit_ms(next_grid_size), + remaining_ms: resolve_puzzle_level_time_limit_ms(next_grid_size), + paused_accumulated_ms: 0, + pause_started_at_ms: None, + freeze_accumulated_ms: 0, + freeze_started_at_ms: None, + freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, @@ -1382,6 +1683,100 @@ fn infer_tags_and_forbidden(source: &str) -> String { } } +fn parse_form_seed_text(source: &str) -> Option<(String, String)> { + let normalized_source = source.trim(); + let title_marker = "拼图标题:"; + let description_marker = "画面描述:"; + let title_start = normalized_source.find(title_marker)? + title_marker.len(); + let description_start = normalized_source.find(description_marker)?; + if description_start <= title_start { + return None; + } + + let title = normalize_required_string(&normalized_source[title_start..description_start]); + let picture_description = normalize_required_string( + &normalized_source[description_start + description_marker.len()..], + ); + + match (title, picture_description) { + (Some(title), Some(picture_description)) => Some((title, picture_description)), + _ => None, + } +} + +fn build_form_tags_and_forbidden(title: &str, picture_description: &str) -> String { + let mut tags = derive_form_theme_tags(title, picture_description); + if tags.len() < PUZZLE_MIN_TAG_COUNT { + for fallback in ["拼图", "插画", "清晰构图"] { + if !tags.iter().any(|tag| tag == fallback) { + tags.push(fallback.to_string()); + } + if tags.len() >= PUZZLE_MIN_TAG_COUNT { + break; + } + } + } + format!("{};禁止标题字", tags.join("、")) +} + +fn derive_form_theme_tags(title: &str, picture_description: &str) -> Vec { + let source = format!("{title} {picture_description}"); + let keyword_tags = [ + ("猫", "猫咪"), + ("狗", "小狗"), + ("神庙", "神庙遗迹"), + ("遗迹", "神庙遗迹"), + ("森林", "童话森林"), + ("雨", "雨夜"), + ("夜", "夜景"), + ("城市", "城市奇景"), + ("蒸汽", "蒸汽城市"), + ("机械", "机械幻想"), + ("海", "海岸"), + ("花", "花园"), + ("雪", "雪景"), + ("龙", "幻想生物"), + ("灯", "暖灯"), + ]; + let mut tags = keyword_tags + .into_iter() + .filter(|(keyword, _)| source.contains(keyword)) + .map(|(_, tag)| tag.to_string()) + .collect::>(); + + for value in title + .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | '、' | ',' | ';' | ';')) + .filter_map(normalize_required_string) + { + if value.chars().count() <= 8 { + tags.push(value); + } + } + + normalize_theme_tags(tags) +} + +fn is_form_anchor_pack(anchor_pack: &PuzzleAnchorPack) -> bool { + matches!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked) + && matches!( + anchor_pack.visual_subject.status, + PuzzleAnchorStatus::Locked + ) +} + +fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String { + if is_form_anchor_pack(anchor_pack) { + return fallback_text(&anchor_pack.visual_subject.value, "画面主体"); + } + + format!( + "{},主体是{},氛围偏{}。", + fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"), + fallback_text(&anchor_pack.visual_subject.value, "画面主体"), + fallback_text(&anchor_pack.visual_mood.value, "温暖") + ) +} + fn extract_forbidden_directive(source: &str) -> String { if let Some((_, tail)) = source.split_once(';') { return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string()); @@ -1390,6 +1785,12 @@ fn extract_forbidden_directive(source: &str) -> String { } fn build_level_name(anchor_pack: &PuzzleAnchorPack, normalized_tags: &[String]) -> String { + if is_form_anchor_pack(anchor_pack) + && let Some(title) = normalize_required_string(&anchor_pack.theme_promise.value) + { + return title; + } + if let Some(tag) = normalized_tags.first() { return format!("{tag}拼图"); } @@ -1970,7 +2371,11 @@ fn drag_group( Ok(affected_cells) } -fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> PuzzleRunSnapshot { +fn with_next_board_at( + run: &PuzzleRunSnapshot, + next_board: PuzzleBoardSnapshot, + now_ms: u64, +) -> PuzzleRunSnapshot { let mut next_run = run.clone(); let is_cleared = next_board.all_tiles_resolved; let next_level_status = if is_cleared { @@ -1982,13 +2387,10 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> if let Some(current_level) = next_run.current_level.as_mut() { current_level.board = next_board; if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared { - let cleared_at_ms = current_unix_ms(); - current_level.cleared_at_ms = Some(cleared_at_ms); - current_level.elapsed_ms = Some( - cleared_at_ms - .saturating_sub(current_level.started_at_ms) - .max(1_000), - ); + current_level.cleared_at_ms = Some(now_ms); + current_level.elapsed_ms = + Some(resolve_effective_elapsed_ms(current_level, now_ms).max(1_000)); + current_level.remaining_ms = 0; } current_level.status = next_level_status; } @@ -2011,6 +2413,10 @@ fn current_unix_ms() -> u64 { .unwrap_or(0) } +pub fn current_puzzle_unix_micros() -> i64 { + (current_unix_ms() as i64).saturating_mul(1_000) +} + #[cfg(test)] mod tests { use super::*; @@ -2079,6 +2485,53 @@ mod tests { assert!(!candidates[0].image_src.contains(&legacy_public_prefix)); } + #[test] + fn form_seed_locks_title_and_picture_description_as_primary_anchors() { + let anchor_pack = infer_anchor_pack( + "拼图标题:暖灯猫街\n画面描述:一只猫在雨夜灯牌下回头。", + None, + ); + let draft = compile_result_draft(&anchor_pack, &[]); + + assert_eq!(anchor_pack.theme_promise.value, "暖灯猫街"); + assert_eq!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked); + assert_eq!(anchor_pack.visual_subject.value, "一只猫在雨夜灯牌下回头。"); + assert_eq!( + anchor_pack.visual_subject.status, + PuzzleAnchorStatus::Locked + ); + assert_eq!(draft.level_name, "暖灯猫街"); + assert_eq!(draft.summary, "一只猫在雨夜灯牌下回头。"); + assert_eq!( + draft + .creator_intent + .as_ref() + .map(|intent| intent.source_mode.as_str()), + Some("form") + ); + assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT); + } + + #[test] + fn form_seed_keeps_multiline_picture_description() { + let anchor_pack = infer_anchor_pack( + "拼图标题:雨夜猫街\n画面描述:一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。", + None, + ); + let draft = compile_result_draft(&anchor_pack, &[]); + + assert_eq!( + anchor_pack.visual_subject.value, + "一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。" + ); + assert_eq!( + draft.summary, + "一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。" + ); + assert!(draft.theme_tags.iter().any(|tag| tag == "猫咪")); + assert!(draft.theme_tags.iter().any(|tag| tag == "雨夜")); + } + #[test] fn tag_similarity_score_uses_jaccard() { let score = tag_similarity_score( @@ -2396,6 +2849,59 @@ mod tests { assert!(board.all_tiles_resolved); } + #[test] + fn timer_marks_running_level_failed_after_limit() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let mut run = + start_run_with_shuffle_seed("run-timeout".to_string(), &profile, 0, 11).expect("run"); + let level = run.current_level.as_mut().expect("level"); + level.started_at_ms = current_unix_ms().saturating_sub(level.time_limit_ms + 1_000); + + let timed_run = resolve_puzzle_run_timer(run); + let timed_level = timed_run.current_level.as_ref().expect("level"); + + assert_eq!(timed_level.status, PuzzleRuntimeLevelStatus::Failed); + assert_eq!(timed_level.remaining_ms, 0); + assert_eq!(timed_level.elapsed_ms, Some(timed_level.time_limit_ms)); + } + + #[test] + fn pause_and_freeze_are_excluded_from_effective_timer() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let mut run = + start_run_with_shuffle_seed("run-freeze".to_string(), &profile, 0, 12).expect("run"); + let now_ms = current_unix_ms(); + let level = run.current_level.as_mut().expect("level"); + level.started_at_ms = now_ms.saturating_sub(30_000); + level.paused_accumulated_ms = 8_000; + level.pause_started_at_ms = Some(now_ms.saturating_sub(5_000)); + level.freeze_accumulated_ms = 4_000; + level.freeze_started_at_ms = Some(now_ms.saturating_sub(3_000)); + level.freeze_until_ms = Some(now_ms.saturating_add(7_000)); + + let remaining_ms = resolve_puzzle_runtime_remaining_ms(level, now_ms); + + assert_eq!(remaining_ms, level.time_limit_ms.saturating_sub(10_000)); + } + + #[test] + fn reference_preview_can_keep_run_paused_until_overlay_closes() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let run = + start_run_with_shuffle_seed("run-reference".to_string(), &profile, 0, 13).expect("run"); + + let paused_run = set_puzzle_run_paused(&run, true).expect("pause"); + let still_paused_run = set_puzzle_run_paused(&paused_run, true).expect("reference pause"); + + assert!( + still_paused_run + .current_level + .as_ref() + .and_then(|level| level.pause_started_at_ms) + .is_some() + ); + } + #[test] fn apply_publish_overrides_updates_draft_truth() { let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙")); diff --git a/server-rs/crates/module-runtime-story-compat/src/story_engine.rs b/server-rs/crates/module-runtime-story-compat/src/story_engine.rs index 7e0c0db0..32fbaa7e 100644 --- a/server-rs/crates/module-runtime-story-compat/src/story_engine.rs +++ b/server-rs/crates/module-runtime-story-compat/src/story_engine.rs @@ -37,7 +37,11 @@ pub fn project_story_engine_after_action( battle_outcome, ); apply_thread_signal_updates(game_state, &mut memory, &signals); - ensure_scene_chapter_state(game_state, &mut memory); + // 中文注释:NPC 战斗入口只是把当前 NPC 切入战斗结算, + // 不能顺手触发“首进场景章节任务”。否则玩家点战斗会误以为被系统自动接了任务。 + if should_update_scene_chapter_state(function_id) { + ensure_scene_chapter_state(game_state, &mut memory); + } let previous_chapter = read_object_field(game_state, "chapterState") .or_else(|| read_object_field(&memory, "currentChapter")) @@ -141,6 +145,10 @@ fn ensure_array_field(root: &mut Map, key: &str) { } } +fn should_update_scene_chapter_state(function_id: &str) -> bool { + !matches!(function_id, "npc_fight" | "npc_spar") +} + fn collect_story_signals( previous_state: &Value, next_state: &Value, @@ -1566,4 +1574,51 @@ mod tests { .all(|mutation| mutation["mutationType"] != json!("enemy_pressure")) ); } + + #[test] + fn story_engine_projector_does_not_create_chapter_quest_on_npc_battle_entry() { + let previous_state = json!({ + "worldType": "WUXIA", + "currentScene": "Story", + "storyHistory": [], + "quests": [], + "currentScenePreset": { + "id": "scene-bridge", + "name": "断桥口", + "description": "风从桥下吹上来。", + "npcs": [{ + "id": "npc-guide", + "name": "沈七", + "hostile": false + }] + }, + "currentEncounter": { + "kind": "npc", + "id": "npc-guide", + "npcName": "沈七" + }, + "storyEngineMemory": { + "activeThreadIds": ["thread-bridge"] + } + }); + let mut next_state = previous_state.clone(); + next_state["inBattle"] = Value::Bool(true); + next_state["currentEncounter"] = Value::Null; + + project_story_engine_after_action( + &previous_state, + &mut next_state, + "与沈七战斗", + "沈七已经进入战斗节奏。", + "npc_fight", + Some("ongoing"), + ); + + assert!( + next_state["quests"] + .as_array() + .is_some_and(|items| items.is_empty()) + ); + assert_eq!(next_state["chapterState"]["chapterQuestId"], Value::Null); + } } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 7104c4a8..aaa30767 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1764,57 +1764,57 @@ pub fn runtime_profile_recharge_point_products() -> Vec Vec, pub phone_number_masked: Option, pub login_method: String, pub binding_status: String, @@ -31,6 +32,7 @@ pub struct PublicUserSummaryPayload { pub id: String, pub public_user_code: String, pub display_name: String, + pub avatar_url: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -66,6 +68,19 @@ pub struct PasswordChangeResponse { pub user: AuthUserPayload, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileUpdateRequest { + pub display_name: Option, + pub avatar_data_url: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileUpdateResponse { + pub user: AuthUserPayload, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PasswordResetRequest { @@ -252,6 +267,23 @@ mod tests { ); } + #[test] + fn profile_update_request_uses_camel_case_fields() { + let payload = serde_json::to_value(ProfileUpdateRequest { + display_name: Some("旅人甲".to_string()), + avatar_data_url: Some("data:image/png;base64,AAAA".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "displayName": "旅人甲", + "avatarDataUrl": "data:image/png;base64,AAAA" + }) + ); + } + #[test] fn wechat_callback_query_keeps_provider_compatible_field_names() { let payload = serde_json::to_value(WechatCallbackQuery { diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs index 2b17ebd7..58c6a743 100644 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -26,6 +26,8 @@ pub struct BigFishWorkSummaryResponse { pub remix_count: u32, #[serde(default)] pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 4409984c..6e009956 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -5,6 +5,10 @@ use serde::{Deserialize, Serialize}; pub struct CreatePuzzleAgentSessionRequest { #[serde(default)] pub seed_text: Option, + #[serde(default)] + pub picture_description: Option, + #[serde(default)] + pub reference_image_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs index 0976a901..0b577158 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs @@ -29,6 +29,18 @@ pub struct DragPuzzlePieceRequest { pub target_col: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UsePuzzleRuntimePropRequest { + pub prop_kind: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePuzzleRuntimePauseRequest { + pub paused: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SubmitPuzzleLeaderboardRequest { @@ -108,6 +120,20 @@ pub struct PuzzleRuntimeLevelSnapshotResponse { #[serde(default)] pub elapsed_ms: Option, #[serde(default)] + pub time_limit_ms: u64, + #[serde(default)] + pub remaining_ms: u64, + #[serde(default)] + pub paused_accumulated_ms: u64, + #[serde(default)] + pub pause_started_at_ms: Option, + #[serde(default)] + pub freeze_accumulated_ms: u64, + #[serde(default)] + pub freeze_started_at_ms: Option, + #[serde(default)] + pub freeze_until_ms: Option, + #[serde(default)] pub leaderboard_entries: Vec, } diff --git a/server-rs/crates/shared-contracts/src/puzzle_works.rs b/server-rs/crates/shared-contracts/src/puzzle_works.rs index c5e75595..8ec7319b 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_works.rs @@ -39,6 +39,8 @@ pub struct PuzzleWorkSummaryResponse { pub remix_count: u32, #[serde(default)] pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, pub publish_ready: bool, } diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 1ae0753e..28f8510a 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -443,6 +443,8 @@ pub struct CustomWorldLibraryEntryResponse { pub remix_count: u32, #[serde(default)] pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -469,6 +471,8 @@ pub struct CustomWorldGalleryCardResponse { pub remix_count: u32, #[serde(default)] pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -955,14 +959,14 @@ mod tests { }, point_products: vec![ProfileRechargeProductResponse { product_id: "points_60".to_string(), - title: "60叙世币".to_string(), + title: "60陶泥币".to_string(), price_cents: 600, kind: "points".to_string(), points_amount: 60, bonus_points: 60, duration_days: 0, badge_label: "首充双倍".to_string(), - description: "首充送60叙世币".to_string(), + description: "首充送60陶泥币".to_string(), tier: "normal".to_string(), }], membership_products: vec![], @@ -978,11 +982,11 @@ mod tests { json!("2026-05-25T10:00:00Z") ); assert_eq!(payload["pointProducts"][0]["productId"], json!("points_60")); - assert_eq!(payload["pointProducts"][0]["title"], json!("60叙世币")); + assert_eq!(payload["pointProducts"][0]["title"], json!("60陶泥币")); assert_eq!(payload["pointProducts"][0]["priceCents"], json!(600)); assert_eq!( payload["pointProducts"][0]["description"], - json!("首充送60叙世币") + json!("首充送60陶泥币") ); assert_eq!(payload["hasPointsRecharged"], json!(false)); } diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index bf6c934e..b700beaf 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -6,9 +6,10 @@ license.workspace = true [dependencies] module-ai = { path = "../module-ai" } -module-custom-world = { path = "../module-custom-world" } module-assets = { path = "../module-assets" } +module-big-fish = { path = "../module-big-fish" } module-combat = { path = "../module-combat" } +module-custom-world = { path = "../module-custom-world" } module-inventory = { path = "../module-inventory" } module-npc = { path = "../module-npc" } module-puzzle = { path = "../module-puzzle" } diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 98151507..29e5e6ea 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -3,6 +3,7 @@ use crate::mapper::*; use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play; use crate::module_bindings::remix_big_fish_work_procedure::remix_big_fish_work; +use module_big_fish::PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID; impl SpacetimeClient { pub async fn create_big_fish_session( @@ -71,7 +72,8 @@ impl SpacetimeClient { &self, ) -> Result, SpacetimeClientError> { self.list_big_fish_works_with_input(BigFishWorksListInput { - owner_user_id: String::new(), + // 中文注释:公开广场读取只依赖 published_only,但旧部署模块会先校验 owner_user_id 非空。 + owner_user_id: PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID.to_string(), published_only: true, }) .await diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs index 35642923..d1c16ff5 100644 --- a/server-rs/crates/spacetime-client/src/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -225,14 +225,15 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .remix_custom_world_profile_then(procedure_input, move |_, result| { + connection.procedures().remix_custom_world_profile_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_custom_world_library_mutation_result); send_once(&sender, mapped); - }); + }, + ); }) .await } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 9ad8af8a..b7e6458a 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -11,18 +11,18 @@ pub use mapper::{ BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, - BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord, - CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput, - CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput, - CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, - CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, - CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, - CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, + BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkRemixRecordInput, + BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, + CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, + CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, + CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, + CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, + CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, - CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord, - CustomWorldPublishWorldRecordInput, + CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, + CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord, PuzzleAgentMessageFinalizeRecordInput, @@ -34,10 +34,11 @@ pub use mapper::{ PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, PuzzleWorkRemixRecordInput, - PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, - PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, - ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, BigFishWorkRemixRecordInput, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, + PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, + PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, + PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, + ResolveNpcBattleInteractionInput, }; pub mod ai; @@ -175,6 +176,7 @@ pub struct SpacetimeClientConfig { pub database: String, pub token: Option, pub pool_size: u32, + pub procedure_timeout: Duration, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -205,7 +207,7 @@ pub enum SpacetimeClientError { Timeout, } -const CONFIRM_ASSET_OBJECT_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_PROCEDURE_TIMEOUT: Duration = Duration::from_secs(30); type ProcedureResultSender = Arc>>>>; @@ -236,6 +238,14 @@ struct PooledConnectionLease { impl SpacetimeClient { pub fn new(config: SpacetimeClientConfig) -> Self { let pool_size = config.pool_size.max(1) as usize; + let config = SpacetimeClientConfig { + procedure_timeout: if config.procedure_timeout.is_zero() { + DEFAULT_PROCEDURE_TIMEOUT + } else { + config.procedure_timeout + }, + ..config + }; let slots = (0..pool_size) .map(|_| { tokio::sync::Mutex::new(PooledConnectionSlot { @@ -264,7 +274,7 @@ impl SpacetimeClient { let lease = self.acquire_connection().await?; let final_result = if let Some(connection) = lease.connection.as_ref() { call(&connection.connection, result_sender.clone()); - match timeout(CONFIRM_ASSET_OBJECT_TIMEOUT, receiver).await { + match timeout(self.config.procedure_timeout, receiver).await { Ok(inner) => match inner { Ok(value) => value, Err(_) => Err(SpacetimeClientError::ConnectDropped), @@ -290,7 +300,7 @@ impl SpacetimeClient { let lease = self.acquire_connection().await?; let final_result = if let Some(connection) = lease.connection.as_ref() { call(&connection.connection, result_sender.clone()); - match timeout(CONFIRM_ASSET_OBJECT_TIMEOUT, receiver).await { + match timeout(self.config.procedure_timeout, receiver).await { Ok(inner) => match inner { Ok(value) => value, Err(_) => Err(SpacetimeClientError::ConnectDropped), @@ -309,7 +319,7 @@ impl SpacetimeClient { async fn acquire_connection(&self) -> Result { let permit = timeout( - CONFIRM_ASSET_OBJECT_TIMEOUT, + self.config.procedure_timeout, self.pool.permits.clone().acquire_owned(), ) .await @@ -386,7 +396,7 @@ impl SpacetimeClient { .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))??; let runner = connection.run_threaded(); - timeout(CONFIRM_ASSET_OBJECT_TIMEOUT, receiver) + timeout(self.config.procedure_timeout, receiver) .await .map_err(|_| SpacetimeClientError::Timeout)? .map_err(|_| SpacetimeClientError::ConnectDropped)??; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index a7f7cd2e..c535ffbc 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1888,6 +1888,7 @@ pub(crate) fn map_custom_world_library_entry_from_profile_snapshot( play_count: snapshot.play_count, remix_count: snapshot.remix_count, like_count: snapshot.like_count, + recent_play_count_7d: 0, }) } @@ -1916,6 +1917,7 @@ pub(crate) fn map_custom_world_gallery_entry_snapshot( play_count: snapshot.play_count, remix_count: snapshot.remix_count, like_count: snapshot.like_count, + recent_play_count_7d: snapshot.recent_play_count_7d, }) } @@ -2395,6 +2397,7 @@ pub(crate) fn map_puzzle_work_profile( play_count: snapshot.play_count, remix_count: snapshot.remix_count, like_count: snapshot.like_count, + recent_play_count_7d: snapshot.recent_play_count_7d, publish_ready: snapshot.publish_ready, anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), } @@ -2438,6 +2441,13 @@ pub(crate) fn map_puzzle_runtime_level_snapshot( started_at_ms: snapshot.started_at_ms, cleared_at_ms: snapshot.cleared_at_ms, elapsed_ms: snapshot.elapsed_ms, + time_limit_ms: snapshot.time_limit_ms, + remaining_ms: snapshot.remaining_ms, + paused_accumulated_ms: snapshot.paused_accumulated_ms, + pause_started_at_ms: snapshot.pause_started_at_ms, + freeze_accumulated_ms: snapshot.freeze_accumulated_ms, + freeze_started_at_ms: snapshot.freeze_started_at_ms, + freeze_until_ms: snapshot.freeze_until_ms, leaderboard_entries: snapshot .leaderboard_entries .into_iter() @@ -3948,6 +3958,7 @@ pub struct CustomWorldLibraryEntryRecord { pub play_count: u32, pub remix_count: u32, pub like_count: u32, + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -3970,6 +3981,7 @@ pub struct CustomWorldGalleryEntryRecord { pub play_count: u32, pub remix_count: u32, pub like_count: u32, + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, PartialEq)] @@ -4411,6 +4423,22 @@ pub struct PuzzleRunNextLevelRecordInput { pub advanced_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunPauseRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub paused: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunPropRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub prop_kind: String, + pub used_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct BigFishPlayReportRecordInput { pub session_id: String, @@ -4556,6 +4584,7 @@ pub struct PuzzleWorkProfileRecord { pub play_count: u32, pub remix_count: u32, pub like_count: u32, + pub recent_play_count_7d: u32, pub publish_ready: bool, pub anchor_pack: PuzzleAnchorPackRecord, } @@ -4616,6 +4645,13 @@ pub struct PuzzleRuntimeLevelRecord { pub started_at_ms: u64, pub cleared_at_ms: Option, pub elapsed_ms: Option, + pub time_limit_ms: u64, + pub remaining_ms: u64, + pub paused_accumulated_ms: u64, + pub pause_started_at_ms: Option, + pub freeze_accumulated_ms: u64, + pub freeze_started_at_ms: Option, + pub freeze_until_ms: Option, pub leaderboard_entries: Vec, } @@ -4834,6 +4870,7 @@ pub struct BigFishWorkSummaryRecord { pub play_count: u32, pub remix_count: u32, pub like_count: u32, + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] @@ -4861,6 +4898,8 @@ struct CompatibleBigFishWorkSummaryRecord { remix_count: u32, #[serde(default)] like_count: u32, + #[serde(default)] + recent_play_count_7d: u32, } impl CompatibleBigFishWorkSummaryRecord { @@ -4889,6 +4928,7 @@ impl CompatibleBigFishWorkSummaryRecord { play_count: self.play_count, remix_count: self.remix_count, like_count: self.like_count, + recent_play_count_7d: self.recent_play_count_7d, } } } @@ -4964,6 +5004,62 @@ mod tests { assert_eq!(items[0].like_count, 0); } + #[test] + fn puzzle_run_mapper_backfills_missing_timer_fields() { + let result = PuzzleRunProcedureResult { + ok: true, + run_json: Some( + r#"{ + "run_id":"puzzle-run-1", + "entry_profile_id":"puzzle-profile-1", + "cleared_level_count":0, + "current_level_index":1, + "current_grid_size":3, + "played_profile_ids":["puzzle-profile-1"], + "previous_level_tags":["雨夜","猫咪","神庙"], + "current_level":{ + "run_id":"puzzle-run-1", + "level_index":1, + "grid_size":3, + "profile_id":"puzzle-profile-1", + "level_name":"雨夜拼图", + "author_display_name":"测试作者", + "theme_tags":["雨夜","猫咪","神庙"], + "cover_image_src":null, + "board":{ + "rows":3, + "cols":3, + "pieces":[{ + "piece_id":"piece-1", + "correct_row":0, + "correct_col":0, + "current_row":0, + "current_col":0, + "merged_group_id":null + }], + "merged_groups":[], + "selected_piece_id":null + }, + "status":"Playing" + }, + "recommended_next_profile_id":null + }"# + .to_string(), + ), + error_message: None, + }; + + let run = map_puzzle_run_procedure_result(result) + .expect("旧 puzzle run JSON 缺计时字段时应按默认值兼容"); + let level = run.current_level.expect("兼容后仍应保留当前关卡"); + + assert_eq!(run.run_id, "puzzle-run-1"); + assert!(level.started_at_ms > 0); + assert_eq!(level.time_limit_ms, 0); + assert_eq!(level.remaining_ms, 0); + assert!(level.leaderboard_entries.is_empty()); + } + #[test] fn big_fish_works_mapper_backfills_missing_owner_user_id_for_private_lists() { let result = BigFishWorksProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs index e3a654d5..73557176 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs @@ -24,6 +24,7 @@ pub struct CustomWorldGalleryEntrySnapshot { pub play_count: u32, pub remix_count: u32, pub like_count: u32, + pub recent_play_count_7d: u32, pub published_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_procedure_result_type.rs index a3869c5d..3efd7d4c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_procedure_result_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::database_migration_table_stat_type::DatabaseMigrationTableStat; +use super::database_migration_warning_type::DatabaseMigrationWarning; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] @@ -13,6 +14,7 @@ pub struct DatabaseMigrationProcedureResult { pub schema_version: u32, pub migration_json: Option, pub table_stats: Vec, + pub warnings: Vec, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_warning_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_warning_type.rs new file mode 100644 index 00000000..7569ae84 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_warning_type.rs @@ -0,0 +1,17 @@ +// 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 DatabaseMigrationWarning { + pub table_name: String, + pub warning_kind: String, + pub message: String, +} + +impl __sdk::InModule for DatabaseMigrationWarning { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 17a1ec47..3be6524b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -195,6 +195,7 @@ pub mod database_migration_operator_type; pub mod database_migration_procedure_result_type; pub mod database_migration_revoke_operator_input_type; pub mod database_migration_table_stat_type; +pub mod database_migration_warning_type; pub mod delete_big_fish_work_procedure; pub mod delete_custom_world_agent_session_procedure; pub mod delete_custom_world_profile_and_return_procedure; @@ -312,7 +313,9 @@ pub mod puzzle_publish_input_type; pub mod puzzle_run_drag_input_type; pub mod puzzle_run_get_input_type; pub mod puzzle_run_next_level_input_type; +pub mod puzzle_run_pause_input_type; pub mod puzzle_run_procedure_result_type; +pub mod puzzle_run_prop_input_type; pub mod puzzle_run_start_input_type; pub mod puzzle_run_swap_input_type; pub mod puzzle_runtime_run_row_type; @@ -483,6 +486,7 @@ pub mod turn_in_quest_reducer; pub mod unequip_inventory_item_input_type; pub mod unpublish_custom_world_profile_and_return_procedure; pub mod unpublish_custom_world_profile_reducer; +pub mod update_puzzle_run_pause_procedure; pub mod update_puzzle_work_procedure; pub mod upsert_auth_store_snapshot_procedure; pub mod upsert_chapter_progression_and_return_procedure; @@ -495,6 +499,7 @@ pub mod upsert_npc_state_reducer; pub mod upsert_platform_browse_history_and_return_procedure; pub mod upsert_runtime_setting_and_return_procedure; pub mod upsert_runtime_snapshot_and_return_procedure; +pub mod use_puzzle_runtime_prop_procedure; pub mod user_account_type; pub mod user_browse_history_type; @@ -687,6 +692,7 @@ pub use database_migration_operator_type::DatabaseMigrationOperator; pub use database_migration_procedure_result_type::DatabaseMigrationProcedureResult; pub use database_migration_revoke_operator_input_type::DatabaseMigrationRevokeOperatorInput; pub use database_migration_table_stat_type::DatabaseMigrationTableStat; +pub use database_migration_warning_type::DatabaseMigrationWarning; 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; @@ -804,7 +810,9 @@ pub use puzzle_publish_input_type::PuzzlePublishInput; pub use puzzle_run_drag_input_type::PuzzleRunDragInput; pub use puzzle_run_get_input_type::PuzzleRunGetInput; pub use puzzle_run_next_level_input_type::PuzzleRunNextLevelInput; +pub use puzzle_run_pause_input_type::PuzzleRunPauseInput; pub use puzzle_run_procedure_result_type::PuzzleRunProcedureResult; +pub use puzzle_run_prop_input_type::PuzzleRunPropInput; pub use puzzle_run_start_input_type::PuzzleRunStartInput; pub use puzzle_run_swap_input_type::PuzzleRunSwapInput; pub use puzzle_runtime_run_row_type::PuzzleRuntimeRunRow; @@ -975,6 +983,7 @@ pub use turn_in_quest_reducer::turn_in_quest; pub use unequip_inventory_item_input_type::UnequipInventoryItemInput; pub use unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return; pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile; +pub use update_puzzle_run_pause_procedure::update_puzzle_run_pause; pub use update_puzzle_work_procedure::update_puzzle_work; pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; @@ -987,6 +996,7 @@ pub use upsert_npc_state_reducer::upsert_npc_state; pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return; pub use upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return; pub use upsert_runtime_snapshot_and_return_procedure::upsert_runtime_snapshot_and_return; +pub use use_puzzle_runtime_prop_procedure::use_puzzle_runtime_prop; pub use user_account_type::UserAccount; pub use user_browse_history_type::UserBrowseHistory; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_pause_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_pause_input_type.rs new file mode 100644 index 00000000..33a34776 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_pause_input_type.rs @@ -0,0 +1,18 @@ +// 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 PuzzleRunPauseInput { + pub run_id: String, + pub owner_user_id: String, + pub paused: bool, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PuzzleRunPauseInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs new file mode 100644 index 00000000..07afe7a9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs @@ -0,0 +1,18 @@ +// 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 PuzzleRunPropInput { + pub run_id: String, + pub owner_user_id: String, + pub prop_kind: String, + pub used_at_micros: i64, +} + +impl __sdk::InModule for PuzzleRunPropInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_run_pause_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_run_pause_procedure.rs new file mode 100644 index 00000000..1679b5ec --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_run_pause_procedure.rs @@ -0,0 +1,59 @@ +// 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::puzzle_run_pause_input_type::PuzzleRunPauseInput; +use super::puzzle_run_procedure_result_type::PuzzleRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdatePuzzleRunPauseArgs { + pub input: PuzzleRunPauseInput, +} + +impl __sdk::InModule for UpdatePuzzleRunPauseArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_puzzle_run_pause`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_puzzle_run_pause { + fn update_puzzle_run_pause(&self, input: PuzzleRunPauseInput) { + self.update_puzzle_run_pause_then(input, |_, _| {}); + } + + fn update_puzzle_run_pause_then( + &self, + input: PuzzleRunPauseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_puzzle_run_pause for super::RemoteProcedures { + fn update_puzzle_run_pause_then( + &self, + input: PuzzleRunPauseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>( + "update_puzzle_run_pause", + UpdatePuzzleRunPauseArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/use_puzzle_runtime_prop_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/use_puzzle_runtime_prop_procedure.rs new file mode 100644 index 00000000..45bd3a73 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/use_puzzle_runtime_prop_procedure.rs @@ -0,0 +1,59 @@ +// 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::puzzle_run_procedure_result_type::PuzzleRunProcedureResult; +use super::puzzle_run_prop_input_type::PuzzleRunPropInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UsePuzzleRuntimePropArgs { + pub input: PuzzleRunPropInput, +} + +impl __sdk::InModule for UsePuzzleRuntimePropArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `use_puzzle_runtime_prop`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait use_puzzle_runtime_prop { + fn use_puzzle_runtime_prop(&self, input: PuzzleRunPropInput) { + self.use_puzzle_runtime_prop_then(input, |_, _| {}); + } + + fn use_puzzle_runtime_prop_then( + &self, + input: PuzzleRunPropInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl use_puzzle_runtime_prop for super::RemoteProcedures { + fn use_puzzle_runtime_prop_then( + &self, + input: PuzzleRunPropInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>( + "use_puzzle_runtime_prop", + UsePuzzleRuntimePropArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs index 87b8e5f3..50dd5520 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs @@ -11,6 +11,7 @@ pub struct UserAccount { pub public_user_code: String, pub username: String, pub display_name: String, + pub avatar_url: Option, pub phone_number_masked: Option, pub phone_number_e_164: Option, pub login_method: String, @@ -33,6 +34,7 @@ pub struct UserAccountCols { pub public_user_code: __sdk::__query_builder::Col, pub username: __sdk::__query_builder::Col, pub display_name: __sdk::__query_builder::Col, + pub avatar_url: __sdk::__query_builder::Col>, pub phone_number_masked: __sdk::__query_builder::Col>, pub phone_number_e_164: __sdk::__query_builder::Col>, pub login_method: __sdk::__query_builder::Col, @@ -51,6 +53,7 @@ impl __sdk::__query_builder::HasCols for UserAccount { public_user_code: __sdk::__query_builder::Col::new(table_name, "public_user_code"), username: __sdk::__query_builder::Col::new(table_name, "username"), display_name: __sdk::__query_builder::Col::new(table_name, "display_name"), + avatar_url: __sdk::__query_builder::Col::new(table_name, "avatar_url"), phone_number_masked: __sdk::__query_builder::Col::new( table_name, "phone_number_masked", diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index c51b5083..d7e36907 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -492,6 +492,56 @@ impl SpacetimeClient { .await } + pub async fn update_puzzle_run_pause( + &self, + input: PuzzleRunPauseRecordInput, + ) -> Result { + let procedure_input = PuzzleRunPauseInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + paused: input.paused, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().update_puzzle_run_pause_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn use_puzzle_runtime_prop( + &self, + input: PuzzleRunPropRecordInput, + ) -> Result { + let procedure_input = PuzzleRunPropInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + prop_kind: input.prop_kind, + used_at_micros: input.used_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().use_puzzle_runtime_prop_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn submit_puzzle_leaderboard_entry( &self, input: PuzzleLeaderboardSubmitRecordInput, diff --git a/server-rs/crates/spacetime-module/src/auth.rs b/server-rs/crates/spacetime-module/src/auth.rs index cde789e7..a86a5cf7 100644 --- a/server-rs/crates/spacetime-module/src/auth.rs +++ b/server-rs/crates/spacetime-module/src/auth.rs @@ -56,6 +56,7 @@ pub struct UserAccount { pub(crate) public_user_code: String, pub(crate) username: String, pub(crate) display_name: String, + pub(crate) avatar_url: Option, pub(crate) phone_number_masked: Option, pub(crate) phone_number_e164: Option, pub(crate) login_method: String, @@ -256,6 +257,7 @@ fn import_auth_store_snapshot_tx( public_user_code: user.public_user_code, username: user.username, display_name: user.display_name, + avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked, phone_number_e164: stored_user.phone_number.clone(), login_method: user.login_method, @@ -387,6 +389,7 @@ fn export_auth_store_snapshot_from_tables_tx( public_user_code: user.public_user_code, username: user.username.clone(), display_name: user.display_name, + avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked, login_method: user.login_method, binding_status: user.binding_status, @@ -519,6 +522,8 @@ struct AuthUserSnapshot { public_user_code: String, username: String, display_name: String, + #[serde(default)] + avatar_url: Option, phone_number_masked: Option, login_method: String, binding_status: String, diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 44f980b0..42159924 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -1,6 +1,7 @@ use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session}; use crate::runtime::{ - ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work, + ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time, + count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work, }; use crate::*; @@ -288,6 +289,7 @@ pub(crate) fn list_big_fish_works_tx( input: BigFishWorksListInput, ) -> Result, String> { validate_works_list_input(&input).map_err(|error| error.to_string())?; + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let mut items = ctx .db @@ -300,7 +302,7 @@ pub(crate) fn list_big_fish_works_tx( row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row) }) - .map(|row| build_big_fish_work_summary(ctx, &row)) + .map(|row| build_big_fish_work_summary(ctx, &row, now_micros)) .collect::, _>>()?; items.sort_by(|left, right| { @@ -676,6 +678,15 @@ pub(crate) fn record_big_fish_play_tx( input.elapsed_ms, input.played_at_micros, )?; + record_public_work_play( + ctx, + PublicWorkPlayRecordInput { + source_type: "big-fish".to_string(), + owner_user_id: session.owner_user_id.clone(), + profile_id: session.session_id.clone(), + played_at_micros: input.played_at_micros, + }, + )?; let next_session = BigFishCreationSession { session_id: session.session_id.clone(), owner_user_id: session.owner_user_id.clone(), @@ -698,13 +709,7 @@ pub(crate) fn record_big_fish_play_tx( }; replace_big_fish_session(ctx, &session, next_session); - list_big_fish_works_tx( - ctx, - BigFishWorksListInput { - owner_user_id: String::new(), - published_only: true, - }, - ) + list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input()) } fn remix_big_fish_work_tx( @@ -876,6 +881,7 @@ pub(crate) fn build_big_fish_session_snapshot( pub(crate) fn build_big_fish_work_summary( ctx: &ReducerContext, row: &BigFishCreationSession, + now_micros: i64, ) -> Result { let draft = row .draft_json @@ -940,6 +946,12 @@ pub(crate) fn build_big_fish_work_summary( play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, + recent_play_count_7d: count_recent_public_work_plays( + ctx, + "big-fish", + &row.session_id, + now_micros, + ), published_at_micros: row .published_at .or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at)) @@ -947,6 +959,14 @@ pub(crate) fn build_big_fish_work_summary( }) } +fn build_public_big_fish_gallery_list_input() -> BigFishWorksListInput { + BigFishWorksListInput { + // 中文注释:published_only 分支不会按 owner 过滤;非空占位用于兼容旧部署模块的前置校验。 + owner_user_id: PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID.to_string(), + published_only: true, + } +} + pub(crate) fn replace_big_fish_session( ctx: &ReducerContext, current: &BigFishCreationSession, diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs index ec35a400..76bdfbf9 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -17,10 +17,40 @@ pub struct BigFishCreationSession { pub(crate) asset_coverage_json: String, pub(crate) last_assistant_reply: Option, pub(crate) publish_ready: bool, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, + #[default(0)] pub(crate) play_count: u32, + #[default(0)] pub(crate) remix_count: u32, + #[default(0)] pub(crate) like_count: u32, + #[default(None::)] pub(crate) published_at: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] +pub enum BigFishRuntimeSnapshot { + Running, + Won, + Failed, +} + +#[spacetimedb::table( + accessor = big_fish_runtime_run, + index(accessor = by_big_fish_run_session_id, btree(columns = [session_id])), + index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct BigFishRuntimeRun { + #[primary_key] + pub(crate) run_id: String, + pub(crate) session_id: String, + pub(crate) owner_user_id: String, + pub(crate) status: BigFishRuntimeSnapshot, + pub(crate) snapshot_json: String, + pub(crate) last_input_x: f32, + pub(crate) last_input_y: f32, + pub(crate) tick: u64, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, } diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index 32802668..38f42454 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -2344,7 +2344,7 @@ fn execute_publish_world_action( draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?, legacy_result_profile_json, setting_text, - author_display_name: "创作者".to_string(), + author_display_name: "陶泥主".to_string(), published_at_micros: input.submitted_at_micros, }, )?; diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index b93ce023..c7003157 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -325,7 +325,7 @@ pub struct CustomWorldProfile { owner_user_id: String, // 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。 public_work_code: Option, - // 作者公开叙世号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。 + // 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。 author_public_user_code: Option, source_agent_session_id: Option, publication_status: CustomWorldPublicationStatus, @@ -337,16 +337,19 @@ pub struct CustomWorldProfile { profile_payload_json: String, playable_npc_count: u32, landmark_count: u32, - // 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。 - play_count: u32, - remix_count: u32, - like_count: u32, author_display_name: String, published_at: Option, // 软删除后保留 profile 真相,供审计与幂等删除使用。 deleted_at: Option, created_at: Timestamp, updated_at: Timestamp, + // 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。 + #[default(0)] + play_count: u32, + #[default(0)] + remix_count: u32, + #[default(0)] + like_count: u32, } #[spacetimedb::table( @@ -488,12 +491,15 @@ pub struct CustomWorldGalleryEntry { theme_mode: CustomWorldThemeMode, playable_npc_count: u32, landmark_count: u32, - // 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。 - play_count: u32, - remix_count: u32, - like_count: u32, published_at: Timestamp, updated_at: Timestamp, + // 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。 + #[default(0)] + play_count: u32, + #[default(0)] + remix_count: u32, + #[default(0)] + like_count: u32, } // 成长状态默认按 user_id 单行持久化;若尚未存在记录则返回 Lv.1 / 0 XP 的兼容初始值。 @@ -2839,9 +2845,10 @@ fn list_custom_world_profile_snapshots( let mut entries = ctx .db .custom_world_profile() - .iter() - .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) - .map(|row| build_custom_world_profile_snapshot(&row)) + .by_custom_world_profile_owner_user_id() + .filter(&input.owner_user_id) + .filter(|row| row.deleted_at.is_none()) + .map(|row| build_custom_world_profile_list_snapshot(&row)) .collect::>(); entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); @@ -2849,6 +2856,86 @@ fn list_custom_world_profile_snapshots( Ok(entries) } +fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { + let mut snapshot = build_custom_world_profile_snapshot(row); + snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row); + snapshot +} + +fn build_custom_world_profile_list_payload_json(row: &CustomWorldProfile) -> String { + let source_profile = serde_json::from_str::(&row.profile_payload_json).ok(); + let source_object = source_profile.as_ref().and_then(JsonValue::as_object); + let empty_roles = JsonValue::Array(Vec::new()); + let empty_landmarks = JsonValue::Array(Vec::new()); + + // 中文注释:首屏作品列表只需要卡片摘要,不能继续把完整 profile 大 JSON 随列表搬回 Axum。 + let payload = json!({ + "id": row.profile_id, + "name": row.world_name, + "subtitle": row.subtitle, + "summary": row.summary_text, + "tone": source_object + .and_then(|object| object.get("tone")) + .and_then(JsonValue::as_str) + .unwrap_or_default(), + "playerGoal": source_object + .and_then(|object| object.get("playerGoal")) + .and_then(JsonValue::as_str) + .unwrap_or_default(), + "settingText": source_object + .and_then(|object| object.get("settingText")) + .and_then(JsonValue::as_str) + .unwrap_or_default(), + "themeMode": row.theme_mode.as_str(), + "templateWorldType": source_object + .and_then(|object| object.get("templateWorldType")) + .and_then(JsonValue::as_str) + .unwrap_or("WUXIA"), + "compatibilityTemplateWorldType": source_object + .and_then(|object| object.get("compatibilityTemplateWorldType")) + .cloned() + .unwrap_or(JsonValue::Null), + "cover": row.cover_image_src.as_ref().map(|image_src| json!({ + "sourceType": "generated", + "imageSrc": image_src, + })), + "majorFactions": source_object + .and_then(|object| object.get("majorFactions")) + .cloned() + .unwrap_or_else(|| JsonValue::Array(Vec::new())), + "coreConflicts": source_object + .and_then(|object| object.get("coreConflicts")) + .cloned() + .unwrap_or_else(|| JsonValue::Array(Vec::new())), + "playableNpcs": source_object + .and_then(|object| object.get("playableNpcs")) + .cloned() + .unwrap_or_else(|| empty_roles.clone()), + "storyNpcs": source_object + .and_then(|object| object.get("storyNpcs")) + .cloned() + .unwrap_or_else(|| JsonValue::Array(Vec::new())), + "items": source_object + .and_then(|object| object.get("items")) + .cloned() + .unwrap_or_else(|| JsonValue::Array(Vec::new())), + "camp": source_object + .and_then(|object| object.get("camp")) + .cloned() + .unwrap_or(JsonValue::Null), + "landmarks": source_object + .and_then(|object| object.get("landmarks")) + .cloned() + .unwrap_or_else(|| empty_landmarks.clone()), + "ownedSettingLayers": source_object + .and_then(|object| object.get("ownedSettingLayers")) + .cloned() + .unwrap_or(JsonValue::Null), + }); + + serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()) +} + fn list_custom_world_gallery_snapshots( ctx: &ReducerContext, ) -> Result, String> { @@ -2858,7 +2945,7 @@ fn list_custom_world_gallery_snapshots( .db .custom_world_gallery_entry() .iter() - .map(|row| build_custom_world_gallery_entry_snapshot(&row)) + .map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row)) .collect::>(); entries.sort_by(|left, right| { @@ -2905,7 +2992,7 @@ fn get_custom_world_library_detail_record( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() - .map(build_custom_world_gallery_entry_snapshot), + .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } @@ -2943,7 +3030,7 @@ fn get_custom_world_gallery_detail_record( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() - .map(build_custom_world_gallery_entry_snapshot), + .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } @@ -2985,7 +3072,7 @@ fn get_custom_world_gallery_detail_record_by_code( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() - .map(build_custom_world_gallery_entry_snapshot), + .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } @@ -3123,6 +3210,15 @@ fn record_custom_world_profile_play_record( }) .ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?; let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + record_public_work_play( + ctx, + crate::runtime::PublicWorkPlayRecordInput { + source_type: "custom-world".to_string(), + owner_user_id: existing.owner_user_id.clone(), + profile_id: existing.profile_id.clone(), + played_at_micros: input.played_at_micros, + }, + )?; // 游玩计数是公开广场消费数据,只增加计数并保持作品内容不变。 let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), @@ -3790,7 +3886,7 @@ fn execute_publish_world_action( 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()); + .unwrap_or_else(|| "陶泥主".to_string()); let publish_result = publish_custom_world_world_record( ctx, CustomWorldPublishWorldInput { @@ -5299,7 +5395,7 @@ fn sync_custom_world_gallery_entry_from_profile( let inserted = ctx.db.custom_world_gallery_entry().insert(row); - Ok(build_custom_world_gallery_entry_snapshot(&inserted)) + Ok(build_custom_world_gallery_entry_snapshot(ctx, &inserted)) } fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), String> { @@ -5570,6 +5666,7 @@ fn build_custom_world_draft_card_snapshot( } fn build_custom_world_gallery_entry_snapshot( + ctx: &ReducerContext, row: &CustomWorldGalleryEntry, ) -> CustomWorldGalleryEntrySnapshot { CustomWorldGalleryEntrySnapshot { @@ -5588,6 +5685,12 @@ fn build_custom_world_gallery_entry_snapshot( play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, + recent_play_count_7d: count_recent_public_work_plays( + ctx, + "custom-world", + &row.profile_id, + ctx.timestamp.to_micros_since_unix_epoch(), + ), published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 55198238..89b22121 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -122,6 +122,7 @@ macro_rules! migration_tables { profile_invite_code, profile_referral_relation, profile_played_world, + public_work_play_daily_stat, profile_membership, profile_recharge_order, profile_save_archive, @@ -742,6 +743,14 @@ where fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value { let mut next_value = value.clone(); + if table_name == "user_account" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:头像字段晚于认证拆表加入,旧迁移包按未设置头像兼容。 + object + .entry("avatar_url".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "big_fish_creation_session" { if let Some(object) = next_value.as_object_mut() { // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 77879e69..976a5bc4 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,5 +1,6 @@ use crate::runtime::{ - ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work, + ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time, + count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work, }; use module_puzzle::{ PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, @@ -8,13 +9,14 @@ use module_puzzle::{ PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, - PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, - PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, - PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, - PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, + PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, + PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, + PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, + PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags, - publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces, + publish_work_profile, resolve_puzzle_grid_size, select_next_profile, }; use serde_json::from_str as json_from_str; use serde_json::to_string as json_to_string; @@ -77,13 +79,15 @@ pub struct PuzzleWorkProfileRow { cover_asset_id: Option, publication_status: PuzzlePublicationStatus, play_count: u32, - remix_count: u32, - like_count: u32, anchor_pack_json: String, publish_ready: bool, created_at: Timestamp, updated_at: Timestamp, published_at: Option, + #[default(0)] + remix_count: u32, + #[default(0)] + like_count: u32, } /// 运行态 run 快照表。 @@ -503,6 +507,44 @@ pub fn advance_puzzle_next_level( } } +#[spacetimedb::procedure] +pub fn update_puzzle_run_pause( + ctx: &mut ProcedureContext, + input: PuzzleRunPauseInput, +) -> PuzzleRunProcedureResult { + match ctx.try_with_tx(|tx| update_puzzle_run_pause_tx(tx, input.clone())) { + Ok(run) => PuzzleRunProcedureResult { + ok: true, + run_json: Some(serialize_json(&run)), + error_message: None, + }, + Err(message) => PuzzleRunProcedureResult { + ok: false, + run_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn use_puzzle_runtime_prop( + ctx: &mut ProcedureContext, + input: PuzzleRunPropInput, +) -> PuzzleRunProcedureResult { + match ctx.try_with_tx(|tx| use_puzzle_runtime_prop_tx(tx, input.clone())) { + Ok(run) => PuzzleRunProcedureResult { + ok: true, + run_json: Some(serialize_json(&run)), + error_message: None, + }, + Err(message) => PuzzleRunProcedureResult { + ok: false, + run_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_puzzle_leaderboard_entry( ctx: &mut ProcedureContext, @@ -755,7 +797,7 @@ fn save_puzzle_generated_images_tx( } let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); - let next_stage = if build_result_preview(&draft, Some("创作者")).publish_ready { + let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready { PuzzleAgentStage::ReadyToPublish } else { PuzzleAgentStage::ImageRefining @@ -804,7 +846,7 @@ fn select_puzzle_cover_image_tx( let draft = apply_selected_candidate(draft, &input.candidate_id).map_err(|error| error.to_string())?; let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros); - let next_stage = if build_result_preview(&draft, Some("创作者")).publish_ready { + let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready { PuzzleAgentStage::ReadyToPublish } else { PuzzleAgentStage::ImageRefining @@ -1029,12 +1071,13 @@ fn delete_puzzle_work_tx( } fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result, String> { + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let mut items = ctx .db .puzzle_work_profile() .iter() .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) - .map(|row| build_puzzle_work_profile_from_row(&row)) + .map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros)) .collect::, _>>()?; items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(items) @@ -1053,7 +1096,11 @@ fn get_puzzle_gallery_detail_tx( if row.publication_status != PuzzlePublicationStatus::Published { return Err("拼图作品尚未发布".to_string()); } - build_puzzle_work_profile_from_row(&row) + build_puzzle_work_profile_from_row_with_recent_count( + ctx, + &row, + ctx.timestamp.to_micros_since_unix_epoch(), + ) } fn remix_puzzle_work_tx( @@ -1213,8 +1260,9 @@ fn start_puzzle_run_tx( return Err("入口拼图作品未发布".to_string()); } let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; - let mut run = - start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?; + let started_at_ms = micros_to_millis(input.started_at_micros); + let mut run = module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms) + .map_err(|error| error.to_string())?; let current_grid_size = run.current_grid_size; let current_profile_id = entry_profile.profile_id.clone(); hydrate_puzzle_leaderboard_entries( @@ -1231,6 +1279,15 @@ fn start_puzzle_run_tx( ) .map(|value| value.profile_id.clone()); + record_public_work_play( + ctx, + PublicWorkPlayRecordInput { + source_type: "puzzle".to_string(), + owner_user_id: entry_profile_row.owner_user_id.clone(), + profile_id: entry_profile_row.profile_id.clone(), + played_at_micros: input.started_at_micros, + }, + )?; increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros); upsert_puzzle_profile_played_work( ctx, @@ -1247,7 +1304,14 @@ fn get_puzzle_run_tx( input: PuzzleRunGetInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; - let mut run = deserialize_run(&row.snapshot_json)?; + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); + let mut run = module_puzzle::resolve_puzzle_run_timer_at( + deserialize_run(&row.snapshot_json)?, + micros_to_millis(now_micros), + ); + if serialize_json(&run) != row.snapshot_json { + replace_puzzle_runtime_run(ctx, &row, &run, now_micros); + } if let Some((profile_id, grid_size)) = run .current_level .as_ref() @@ -1270,9 +1334,27 @@ fn swap_puzzle_pieces_tx( ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; - let mut next_run = swap_pieces(¤t_run, &input.first_piece_id, &input.second_piece_id) - .map_err(|error| error.to_string())?; + let mut next_run = module_puzzle::swap_pieces_at( + ¤t_run, + &input.first_piece_id, + &input.second_piece_id, + micros_to_millis(input.swapped_at_micros), + ) + .map_err(|error| error.to_string())?; refresh_next_profile_recommendation(ctx, &mut next_run)?; + if let Some((profile_id, grid_size)) = next_run + .current_level + .as_ref() + .map(|level| (level.profile_id.clone(), level.grid_size)) + { + hydrate_puzzle_leaderboard_entries( + ctx, + &mut next_run, + &input.owner_user_id, + &profile_id, + grid_size, + ); + } replace_puzzle_runtime_run(ctx, &row, &next_run, input.swapped_at_micros); Ok(next_run) } @@ -1283,14 +1365,28 @@ fn drag_puzzle_piece_or_group_tx( ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; - let mut next_run = module_puzzle::drag_piece_or_group( + let mut next_run = module_puzzle::drag_piece_or_group_at( ¤t_run, &input.piece_id, input.target_row, input.target_col, + micros_to_millis(input.dragged_at_micros), ) .map_err(|error| error.to_string())?; refresh_next_profile_recommendation(ctx, &mut next_run)?; + if let Some((profile_id, grid_size)) = next_run + .current_level + .as_ref() + .map(|level| (level.profile_id.clone(), level.grid_size)) + { + hydrate_puzzle_leaderboard_entries( + ctx, + &mut next_run, + &input.owner_user_id, + &profile_id, + grid_size, + ); + } replace_puzzle_runtime_run(ctx, &row, &next_run, input.dragged_at_micros); Ok(next_run) } @@ -1323,8 +1419,12 @@ fn advance_puzzle_next_level_tx( ) .ok_or_else(|| "没有可用的下一关候选".to_string())? .clone(); - let mut next_run = module_puzzle::advance_next_level(¤t_run, &next_profile) - .map_err(|error| error.to_string())?; + let mut next_run = module_puzzle::advance_next_level_at( + ¤t_run, + &next_profile, + micros_to_millis(input.advanced_at_micros), + ) + .map_err(|error| error.to_string())?; let next_grid_size = next_run.current_grid_size; let next_profile_id = next_profile.profile_id.clone(); hydrate_puzzle_leaderboard_entries( @@ -1344,6 +1444,15 @@ fn advance_puzzle_next_level_tx( .profile_id() .find(&next_profile.profile_id) { + record_public_work_play( + ctx, + PublicWorkPlayRecordInput { + source_type: "puzzle".to_string(), + owner_user_id: next_profile_row.owner_user_id.clone(), + profile_id: next_profile_row.profile_id.clone(), + played_at_micros: input.advanced_at_micros, + }, + )?; increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros); upsert_puzzle_profile_played_work( ctx, @@ -1356,6 +1465,82 @@ fn advance_puzzle_next_level_tx( Ok(next_run) } +fn update_puzzle_run_pause_tx( + ctx: &TxContext, + input: PuzzleRunPauseInput, +) -> Result { + let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; + let current_run = deserialize_run(&row.snapshot_json)?; + let next_run = module_puzzle::set_puzzle_run_paused_at( + ¤t_run, + input.paused, + micros_to_millis(input.updated_at_micros), + ) + .map_err(|error| error.to_string())?; + replace_puzzle_runtime_run(ctx, &row, &next_run, input.updated_at_micros); + let mut hydrated_run = next_run; + if let Some((profile_id, grid_size)) = hydrated_run + .current_level + .as_ref() + .map(|level| (level.profile_id.clone(), level.grid_size)) + { + hydrate_puzzle_leaderboard_entries( + ctx, + &mut hydrated_run, + &input.owner_user_id, + &profile_id, + grid_size, + ); + } + Ok(hydrated_run) +} + +fn use_puzzle_runtime_prop_tx( + ctx: &TxContext, + input: PuzzleRunPropInput, +) -> Result { + let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; + let current_run = deserialize_run(&row.snapshot_json)?; + let next_run = match input.prop_kind.as_str() { + "freezeTime" | "freeze_time" => { + module_puzzle::apply_puzzle_freeze_time_at( + ¤t_run, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())? + } + "hint" => module_puzzle::set_puzzle_run_paused_at( + ¤t_run, + false, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, + "reference" => module_puzzle::set_puzzle_run_paused_at( + ¤t_run, + true, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, + _ => return Err("未知拼图道具".to_string()), + }; + replace_puzzle_runtime_run(ctx, &row, &next_run, input.used_at_micros); + let mut hydrated_run = next_run; + if let Some((profile_id, grid_size)) = hydrated_run + .current_level + .as_ref() + .map(|level| (level.profile_id.clone(), level.grid_size)) + { + hydrate_puzzle_leaderboard_entries( + ctx, + &mut hydrated_run, + &input.owner_user_id, + &profile_id, + grid_size, + ); + } + Ok(hydrated_run) +} + fn submit_puzzle_leaderboard_entry_tx( ctx: &TxContext, input: PuzzleLeaderboardSubmitInput, @@ -1424,7 +1609,7 @@ fn build_puzzle_agent_session_snapshot( let messages = list_session_messages(ctx, &row.session_id); let result_preview = draft .as_ref() - .map(|value| build_result_preview(value, Some("创作者"))); + .map(|value| build_result_preview(value, Some("陶泥主"))); Ok(PuzzleAgentSessionSnapshot { session_id: row.session_id.clone(), @@ -1447,6 +1632,23 @@ fn build_puzzle_agent_session_snapshot( fn build_puzzle_work_profile_from_row( row: &PuzzleWorkProfileRow, +) -> Result { + build_puzzle_work_profile_from_row_without_recent_count(row) +} + +fn build_puzzle_work_profile_from_row_with_recent_count( + ctx: &TxContext, + row: &PuzzleWorkProfileRow, + now_micros: i64, +) -> Result { + let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?; + profile.recent_play_count_7d = + count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros); + Ok(profile) +} + +fn build_puzzle_work_profile_from_row_without_recent_count( + row: &PuzzleWorkProfileRow, ) -> Result { Ok(PuzzleWorkProfile { work_id: row.work_id.clone(), @@ -1467,6 +1669,7 @@ fn build_puzzle_work_profile_from_row( play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, + recent_play_count_7d: 0, publish_ready: row.publish_ready, anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?, }) @@ -1482,6 +1685,13 @@ fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) { ) } +fn micros_to_millis(value: i64) -> u64 { + if value <= 0 { + return 0; + } + (value as u64).saturating_div(1_000) +} + fn upsert_puzzle_draft_work_profile( ctx: &TxContext, session_id: &str, @@ -1500,7 +1710,7 @@ fn upsert_puzzle_draft_work_profile( profile_id, owner_user_id.to_string(), Some(session_id.to_string()), - "创作者".to_string(), + "陶泥主".to_string(), draft, updated_at_micros, ) @@ -2095,6 +2305,9 @@ mod tests { updated_at_micros: 1, published_at_micros: Some(1), play_count: 0, + recent_play_count_7d: 0, + remix_count: 0, + like_count: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), }; @@ -2110,6 +2323,9 @@ mod tests { updated_at_micros: 2, published_at_micros: Some(2), play_count: 0, + recent_play_count_7d: 0, + remix_count: 0, + like_count: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), source_session_id: None, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 4c81d551..272e0b1b 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1,5 +1,8 @@ use crate::*; +const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; +const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -116,6 +119,26 @@ pub struct ProfilePlayedWorld { pub(crate) last_observed_play_time_ms: u64, } +#[spacetimedb::table( + accessor = public_work_play_daily_stat, + index( + accessor = by_public_work_play_daily_stat_work_day, + btree(columns = [source_type, profile_id, played_day]) + ) +)] +pub struct PublicWorkPlayDailyStat { + #[primary_key] + pub(crate) stat_id: String, + // 中文注释:source_type 区分 custom-world / puzzle / big-fish,避免不同玩法 profile_id 撞桶。 + pub(crate) source_type: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + // 中文注释:UTC 自 Unix 纪元起的自然日桶,用于快速聚合近 7 日新增游玩次数。 + pub(crate) played_day: i64, + pub(crate) play_count: u32, + pub(crate) updated_at: Timestamp, +} + pub(crate) struct ProfilePlayedWorkUpsertInput { pub(crate) user_id: String, pub(crate) world_key: String, @@ -127,6 +150,13 @@ pub(crate) struct ProfilePlayedWorkUpsertInput { pub(crate) played_at_micros: i64, } +pub(crate) struct PublicWorkPlayRecordInput { + pub(crate) source_type: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) played_at_micros: i64, +} + #[spacetimedb::table(accessor = profile_membership)] pub struct ProfileMembership { #[primary_key] @@ -420,7 +450,7 @@ pub fn get_profile_referral_invite_center( } } -// 填码绑定、每日邀请者奖励上限和双方叙世币发放都在同一事务内完成。 +// 填码绑定、每日邀请者奖励上限和双方陶泥币发放都在同一事务内完成。 #[spacetimedb::procedure] pub fn redeem_profile_referral_invite_code( ctx: &mut ProcedureContext, @@ -705,6 +735,88 @@ pub(crate) fn add_profile_observed_play_time( Ok(()) } +pub(crate) fn record_public_work_play( + ctx: &ReducerContext, + input: PublicWorkPlayRecordInput, +) -> Result<(), String> { + let source_type = input.source_type.trim(); + let owner_user_id = input.owner_user_id.trim(); + let profile_id = input.profile_id.trim(); + if source_type.is_empty() || owner_user_id.is_empty() || profile_id.is_empty() { + return Err("public_work_play_daily_stat 参数不能为空".to_string()); + } + + let played_day = public_work_play_day_from_micros(input.played_at_micros); + let stat_id = build_public_work_play_daily_stat_id(source_type, profile_id, played_day); + let updated_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + let next_count = ctx + .db + .public_work_play_daily_stat() + .stat_id() + .find(&stat_id) + .map(|existing| { + ctx.db + .public_work_play_daily_stat() + .stat_id() + .delete(&existing.stat_id); + existing.play_count.saturating_add(1) + }) + .unwrap_or(1); + + ctx.db + .public_work_play_daily_stat() + .insert(PublicWorkPlayDailyStat { + stat_id, + source_type: source_type.to_string(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + played_day, + play_count: next_count, + updated_at, + }); + + Ok(()) +} + +pub(crate) fn count_recent_public_work_plays( + ctx: &ReducerContext, + source_type: &str, + profile_id: &str, + now_micros: i64, +) -> u32 { + let source_type = source_type.trim(); + let profile_id = profile_id.trim(); + if source_type.is_empty() || profile_id.is_empty() { + return 0; + } + + let current_day = public_work_play_day_from_micros(now_micros); + let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); + + ctx.db + .public_work_play_daily_stat() + .iter() + .filter(|row| { + row.source_type == source_type + && row.profile_id == profile_id + && row.played_day >= first_day + && row.played_day <= current_day + }) + .fold(0u32, |total, row| total.saturating_add(row.play_count)) +} + +fn public_work_play_day_from_micros(value: i64) -> i64 { + value.div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS) +} + +fn build_public_work_play_daily_stat_id( + source_type: &str, + profile_id: &str, + played_day: i64, +) -> String { + format!("{source_type}:{profile_id}:{played_day}") +} + fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) { if ctx .db @@ -1954,7 +2066,7 @@ fn apply_profile_wallet_signed_delta( } else { previous_balance .checked_sub(amount_delta.unsigned_abs()) - .ok_or_else(|| "叙世币余额不足".to_string())? + .ok_or_else(|| "陶泥币余额不足".to_string())? }; let created_state_at = current .as_ref() diff --git a/src/PuzzlePlaygroundApp.tsx b/src/PuzzlePlaygroundApp.tsx index acf60ae6..544de368 100644 --- a/src/PuzzlePlaygroundApp.tsx +++ b/src/PuzzlePlaygroundApp.tsx @@ -7,8 +7,10 @@ import type { import type { PuzzleWorkSummary } from '../packages/shared/src/contracts/puzzleWorkSummary'; import { PuzzleRuntimeShell } from './components/puzzle-runtime/PuzzleRuntimeShell'; import { + applyLocalPuzzleFreezeTime, advanceLocalPuzzleLevel, dragLocalPuzzlePiece, + setLocalPuzzlePaused, startLocalPuzzleRun, swapLocalPuzzlePieces, } from './services/puzzle-runtime/puzzleLocalRuntime'; @@ -16,7 +18,7 @@ import { const PLACEHOLDER_PUZZLE_IMAGE = 'data:image/svg+xml;utf8,' + encodeURIComponent(` - + @@ -28,13 +30,13 @@ const PLACEHOLDER_PUZZLE_IMAGE = - - - - - - - + + + + + + + `); function buildPlaceholderPuzzleWork(): PuzzleWorkSummary { @@ -78,6 +80,20 @@ export default function PuzzlePlaygroundApp() { setRun((currentRun) => advanceLocalPuzzleLevel(currentRun)); }; + const handlePauseChange = async (paused: boolean) => { + setRun((currentRun) => setLocalPuzzlePaused(currentRun, paused)); + }; + + const handleUseProp = async ( + propKind: 'hint' | 'reference' | 'freezeTime', + ) => { + setRun((currentRun) => + propKind === 'freezeTime' + ? applyLocalPuzzleFreezeTime(currentRun) + : setLocalPuzzlePaused(currentRun, propKind === 'reference'), + ); + }; + return ( ); } diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index 81fb8c2a..cf275df8 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -1,4 +1,3 @@ -import { X } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; @@ -88,6 +87,7 @@ import { InventoryItemGrid, } from './InventoryItemViews'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; +import { PixelCloseButton } from './PixelCloseButton'; import { ResolvedAssetImage } from './ResolvedAssetImage'; import { SkillEffectPreview } from './SkillEffectPreview'; @@ -957,8 +957,8 @@ export function AdventureEntityModal({ style={getNineSliceStyle(UI_CHROME.modalPanel)} onClick={(event) => event.stopPropagation()} > -
-
+
+
详情
@@ -975,13 +975,7 @@ export function AdventureEntityModal({ /> ) : null}
- +
@@ -1319,13 +1313,10 @@ export function AdventureEntityModal({ {detailCharacter.name}
- + label="关闭标签效果" + />
@@ -1431,13 +1422,10 @@ export function AdventureEntityModal({ {selectedSkillOwnerName}
- + label="关闭技能详情" + />
diff --git a/src/components/CharacterChatModal.tsx b/src/components/CharacterChatModal.tsx index 5892f8c5..101fe663 100644 --- a/src/components/CharacterChatModal.tsx +++ b/src/components/CharacterChatModal.tsx @@ -2,8 +2,8 @@ import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useRef } from 'react'; import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; -import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; -import { PixelIcon } from './PixelIcon'; +import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { PixelCloseButton } from './PixelCloseButton'; interface CharacterChatModalProps { modal: CharacterChatModalState | null; @@ -56,13 +56,11 @@ export function CharacterChatModal({ {modal.target.character.title} / {modal.target.roleLabel}
- + label="关闭角色聊天" + placement="inline" + />
diff --git a/src/components/CharacterDetailModal.tsx b/src/components/CharacterDetailModal.tsx index 896eda50..5c07d7bc 100644 --- a/src/components/CharacterDetailModal.tsx +++ b/src/components/CharacterDetailModal.tsx @@ -23,7 +23,6 @@ import { type WorldType, } from '../types'; import { - CHROME_ICONS, getNineSliceStyle, type NineSliceTexture, UI_CHROME, @@ -38,7 +37,7 @@ import { CharacterSkillsList, } from './CharacterInfoShared'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; -import { PixelIcon } from './PixelIcon'; +import { PixelCloseButton } from './PixelCloseButton'; interface CharacterDetailModalProps { character: Character | null; @@ -194,14 +193,7 @@ export function CharacterDetailModal({ {subtitle}
- +
diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx index 20bc6383..919f9c27 100644 --- a/src/components/CharacterPanel.tsx +++ b/src/components/CharacterPanel.tsx @@ -1,7 +1,6 @@ import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useMemo, useState } from 'react'; -import { normalizePlayerProgressionState } from '../data/playerProgression'; import { resolveAttributeSchema, resolveCharacterAttributeProfile, @@ -27,6 +26,7 @@ import { getEquipmentSlotLabel, } from '../data/equipmentEffects'; import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals'; +import { normalizePlayerProgressionState } from '../data/playerProgression'; import type { CharacterChatTarget } from '../hooks/rpg-runtime-story'; import { getResourceLabelsForWorld } from '../services/customWorldPresentation'; import { @@ -38,12 +38,10 @@ import { CustomWorldProfile, EquipmentLoadout, GameState, - QuestLogEntry, TimedBuildBuff, WorldType, } from '../types'; import { - CHROME_ICONS, getEquipmentSlotIcon, getNineSliceStyle, UI_CHROME, @@ -66,6 +64,7 @@ import { } from './CharacterInfoShared'; import type { GameCanvasEntitySelection } from './GameCanvas'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; +import { PixelCloseButton } from './PixelCloseButton'; import { PixelIcon } from './PixelIcon'; import { ResolvedAssetImage } from './ResolvedAssetImage'; @@ -82,7 +81,6 @@ interface CharacterPanelProps { activeBuildBuffs?: TimedBuildBuff[]; companionRenderStates: CompanionRenderState[]; npcStates?: GameState['npcStates']; - quests: QuestLogEntry[]; onOpenCamp?: () => void; onOpenCharacterChat?: (target: CharacterChatTarget) => void; chatSummaries?: Record; @@ -155,7 +153,6 @@ export function CharacterPanel({ activeBuildBuffs = [], companionRenderStates, npcStates = {}, - quests, onInspectMember, companionArcStates = [], companionResolutions = [], @@ -215,11 +212,6 @@ export function CharacterPanel({ [partyMembers, selectedMemberId], ); - const activeQuests = useMemo( - () => quests.filter((quest) => quest.status !== 'turned_in'), - [quests], - ); - const buildBreakdownByMemberId = useMemo( () => Object.fromEntries( @@ -374,29 +366,6 @@ export function CharacterPanel({ paddingY: 12, })} > - {activeQuests.length > 0 && ( -
-
- 褰撳墠濮旀墭 -
-
- {activeQuests.map((quest) => ( -
-
- {quest.title} -
-
- {quest.summary} -
-
- ))} -
-
- )} -
队伍成员
{partyMembers.map((member) => ( @@ -497,13 +466,10 @@ export function CharacterPanel({ {selectedMember.character.name}
- + label="关闭标签效果" + />
@@ -619,13 +585,10 @@ export function CharacterPanel({
- + label="关闭角色详情" + />
diff --git a/src/components/CompanionCampModal.tsx b/src/components/CompanionCampModal.tsx index 1ebe3515..926eda7f 100644 --- a/src/components/CompanionCampModal.tsx +++ b/src/components/CompanionCampModal.tsx @@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react'; import { getCharacterById } from '../data/characterPresets'; import { MAX_COMPANIONS } from '../data/npcInteractions'; import { Character, CompanionState } from '../types'; -import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; -import { PixelIcon } from './PixelIcon'; +import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { PixelCloseButton } from './PixelCloseButton'; import { ResolvedAssetImage } from './ResolvedAssetImage'; interface CompanionCampModalProps { @@ -145,13 +145,7 @@ export function CompanionCampModal({ {playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
- +
diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 681948db..fb007438 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -1099,7 +1099,7 @@ export function CustomWorldEntityCatalog({
{lockedCharacterNames.has(role.name.trim()) ? ( - 创作者锁定 + 陶泥主锁定 ) : null} diff --git a/src/components/InventoryItemViews.tsx b/src/components/InventoryItemViews.tsx index 4ad39303..afecf70b 100644 --- a/src/components/InventoryItemViews.tsx +++ b/src/components/InventoryItemViews.tsx @@ -5,11 +5,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects'; import { buildInventoryItemDescription } from '../data/itemPresentation'; import type { Character, InventoryItem, WorldType } from '../types'; import { - CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME, } from '../uiAssets'; +import { PixelCloseButton } from './PixelCloseButton'; import { PixelIcon } from './PixelIcon'; function getInventoryRarityTheme(rarity: InventoryItem['rarity']) { @@ -185,13 +185,7 @@ export function InventoryItemDetailModal({ onClick={(event) => event.stopPropagation()} >
- +
Promise; onDismantleItem: (itemId: string) => Promise; onReforgeItem: (itemId: string) => Promise; - continueGameDigest?: string | null; narrativeCodex?: NarrativeCodexSection[]; narrativeQaReport?: NarrativeQaReport | null; } @@ -58,7 +57,6 @@ export function InventoryPanel({ onCraftRecipe, onDismantleItem: _onDismantleItem, onReforgeItem: _onReforgeItem, - continueGameDigest = null, narrativeCodex = [], narrativeQaReport = null, }: InventoryPanelProps) { @@ -92,14 +90,6 @@ export function InventoryPanel({ return (
- {continueGameDigest && ( -
-
- 旅程回顾 -
- {continueGameDigest} -
- )} 地图
- +
@@ -385,13 +380,10 @@ export function MapModal({
场景切换
{pendingScene.scene.name}
- + label="关闭场景切换" + />
diff --git a/src/components/NpcModals.tsx b/src/components/NpcModals.tsx index 0d209f93..3b991868 100644 --- a/src/components/NpcModals.tsx +++ b/src/components/NpcModals.tsx @@ -25,7 +25,8 @@ import { RuntimeNpcGiftItemView, RuntimeNpcTradeItemView, } from '../types'; -import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { PixelCloseButton } from './PixelCloseButton'; import { PixelIcon } from './PixelIcon'; interface NpcModalsProps { @@ -232,13 +233,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) { {npcInteraction?.npcName ?? tradeModal.encounter.npcName} / 你当前{currencyName}:{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
- + label="关闭交易" + placement="inline" + />
@@ -385,13 +384,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) { {tradeDetail.source === 'buy' ? '对方物品' : '背包物品'}
- + label="关闭物品详情" + placement="inline" + />
@@ -474,9 +471,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
赠送礼物
{npcUi.giftModal.encounter.npcName}
- +
@@ -550,9 +549,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
调整同行位置
队伍已满,请先选择一名当前同行角色离队,再邀请对方加入。
- +
diff --git a/src/components/PixelCloseButton.test.tsx b/src/components/PixelCloseButton.test.tsx new file mode 100644 index 00000000..9ab0ca12 --- /dev/null +++ b/src/components/PixelCloseButton.test.tsx @@ -0,0 +1,45 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; + +import { PixelCloseButton } from './PixelCloseButton'; + +test('pixel close button closes without bubbling to the overlay', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onOverlayClick = vi.fn(); + + render( +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: '关闭测试面板' })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onOverlayClick).not.toHaveBeenCalled(); +}); + +test('inline pixel close button keeps the same click boundary', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onHeaderClick = vi.fn(); + + render( +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: '关闭标题栏面板' })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onHeaderClick).not.toHaveBeenCalled(); +}); diff --git a/src/components/PixelCloseButton.tsx b/src/components/PixelCloseButton.tsx new file mode 100644 index 00000000..f9b6beb7 --- /dev/null +++ b/src/components/PixelCloseButton.tsx @@ -0,0 +1,45 @@ +import type { MouseEvent } from 'react'; + +import { CHROME_ICONS } from '../uiAssets'; +import { PixelIcon } from './PixelIcon'; + +type PixelCloseButtonProps = { + onClick: () => void; + label?: string; + placement?: 'absolute' | 'inline'; + className?: string; +}; + +/** + * RPG 像素风弹窗右上关闭按钮。 + * 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为。 + */ +export function PixelCloseButton({ + onClick, + label = '关闭面板', + placement = 'absolute', + className = '', +}: PixelCloseButtonProps) { + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onClick(); + }; + + const placementClassName = + placement === 'absolute' + ? 'absolute right-4 top-3 sm:right-5 sm:top-4' + : 'relative shrink-0'; + + return ( + + ); +} diff --git a/src/components/auth/AccountModal.test.tsx b/src/components/auth/AccountModal.test.tsx index e4d1b03c..223218b2 100644 --- a/src/components/auth/AccountModal.test.tsx +++ b/src/components/auth/AccountModal.test.tsx @@ -16,6 +16,7 @@ const baseUser: AuthUser = { id: 'user-1', username: 'tester', displayName: '138****8000', + avatarUrl: null, publicUserCode: 'user-tester', phoneNumberMasked: '138****8000', loginMethod: 'phone', diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index ddbcca76..52484249 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -78,6 +78,7 @@ const mockUser: AuthUser = { id: 'user-1', username: 'tester', displayName: '测试玩家', + avatarUrl: null, publicUserCode: 'user-tester', phoneNumberMasked: '138****8000', loginMethod: 'phone', diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 4263167b..cb37906c 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -410,6 +410,7 @@ export function AuthGate({ children }: AuthGateProps) { requireAuth, openSettingsModal, openAccountModal, + setCurrentUser: setUser, logout: logoutCurrentSession, musicVolume: settings.musicVolume, setMusicVolume: settings.setMusicVolume, diff --git a/src/components/auth/AuthUiContext.ts b/src/components/auth/AuthUiContext.ts index 34ffba99..2bf5ac79 100644 --- a/src/components/auth/AuthUiContext.ts +++ b/src/components/auth/AuthUiContext.ts @@ -17,6 +17,7 @@ type AuthUiContextValue = { requireAuth: (action: () => void) => void; openSettingsModal: (section?: PlatformSettingsSection) => void; openAccountModal: () => void; + setCurrentUser: (user: AuthUser) => void; logout: () => Promise; musicVolume: number; setMusicVolume: (value: number) => void; diff --git a/src/components/auth/BindPhoneScreen.tsx b/src/components/auth/BindPhoneScreen.tsx index 4f44c1e8..9d0fc972 100644 --- a/src/components/auth/BindPhoneScreen.tsx +++ b/src/components/auth/BindPhoneScreen.tsx @@ -62,7 +62,7 @@ export function BindPhoneScreen({
-
叙世
+
陶泥
视觉叙事 RPG

diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index b18fbddd..ec1d162a 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -11,12 +11,62 @@ const noopCreateType = () => {}; const originalClipboard = navigator.clipboard; afterEach(() => { + window.sessionStorage.clear(); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalClipboard, }); }); +test('creation hub shows published metric growth from cached page snapshot', async () => { + window.sessionStorage.setItem( + 'genarrative.creationHub.publishedMetrics.v1', + JSON.stringify({ + 'puzzle:puzzle:work-growth': { + 'play-count': 7, + 'remix-count': 1, + 'like-count': 2, + }, + }), + ); + + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={() => {}} + />, + ); + + expect(screen.getByLabelText('游玩 10次')).toBeTruthy(); + expect(screen.getByLabelText('改造 4次')).toBeTruthy(); + expect(await screen.findAllByText('↑')).toHaveLength(2); +}); + const baseDraftItem: CustomWorldWorkSummary = { workId: 'draft:session-1', sourceType: 'agent_session', @@ -52,10 +102,12 @@ test('creation hub reflects updated draft title summary and counts after rerende expect(screen.getByText('潮雾列岛')).toBeTruthy(); expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy(); - expect(screen.getByText('角色 3')).toBeTruthy(); - expect(screen.getByText('地点 4')).toBeTruthy(); - expect(screen.getByRole('button', { name: /角色扮演 RPG/u })).toBeTruthy(); - expect(screen.getByRole('button', { name: /拼图玩法/u })).toBeTruthy(); + expect(screen.queryByText('角色 3')).toBeNull(); + expect(screen.queryByText('地点 4')).toBeNull(); + expect( + screen.getByRole('button', { name: /角色扮演.*剧情演绎/u }), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: /拼图.*创意礼物/u })).toBeTruthy(); expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); rerender( @@ -83,8 +135,8 @@ test('creation hub reflects updated draft title summary and counts after rerende expect( screen.getByText('世界总卡和角色网已经继续长出了新的支线。'), ).toBeTruthy(); - expect(screen.getByText('角色 5')).toBeTruthy(); - expect(screen.getByText('地点 6')).toBeTruthy(); + expect(screen.queryByText('角色 5')).toBeNull(); + expect(screen.queryByText('地点 6')).toBeNull(); }); test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => { @@ -105,7 +157,8 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(), publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(), playCount: 8, - likeCount: 0, + remixCount: 2, + likeCount: 3, publishReady: true, }, ]} @@ -121,8 +174,14 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to expect(screen.getByText('潮雾列岛')).toBeTruthy(); expect(screen.getByText('沉钟拼图')).toBeTruthy(); - expect(screen.getByText('PZ-PROFILE1')).toBeTruthy(); expect(screen.getAllByText('拼图').length).toBeGreaterThan(0); + expect(screen.getByLabelText('游玩 8次')).toBeTruthy(); + expect(screen.getByLabelText('改造 2次')).toBeTruthy(); + expect(screen.getByLabelText('点赞 3赞')).toBeTruthy(); + expect(screen.queryByText('Remix')).toBeNull(); + expect(screen.queryByText('PZ-PROFILE1')).toBeNull(); + expect(screen.queryByText('潮雾')).toBeNull(); + expect(screen.queryByText('沉钟')).toBeNull(); expect(screen.queryByText('我的拼图作品')).toBeNull(); }); @@ -159,7 +218,9 @@ test('creation hub shows RPG public work code from published library entry', () themeMode: 'tide', playableNpcCount: 3, landmarkCount: 4, - likeCount: 0, + playCount: 12, + remixCount: 4, + likeCount: 5, }, ]} loading={false} @@ -172,7 +233,11 @@ test('creation hub shows RPG public work code from published library entry', () ); expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy(); - expect(screen.getByText('CW-00000001')).toBeTruthy(); + expect(screen.getByLabelText('游玩 12次')).toBeTruthy(); + expect(screen.getByLabelText('改造 4次')).toBeTruthy(); + expect(screen.getByLabelText('点赞 5赞')).toBeTruthy(); + expect(screen.queryByText('Remix')).toBeNull(); + expect(screen.queryByText('CW-00000001')).toBeNull(); }); test('creation hub shows delete action for persisted rpg drafts', () => { @@ -225,7 +290,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => { expect(openedItems).toEqual([persistedDraft]); }); -test('creation hub work code copy button copies without opening the card', async () => { +test('creation hub published share button copies share text without opening the card', async () => { const user = userEvent.setup(); const writeText = vi.fn(async () => undefined); const onOpenPuzzleDetail = vi.fn(); @@ -251,6 +316,7 @@ test('creation hub work code copy button copies without opening the card', async updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(), publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(), playCount: 8, + remixCount: 2, likeCount: 0, publishReady: true, }, @@ -265,11 +331,19 @@ test('creation hub work code copy button copies without opening the card', async />, ); - await user.click( - screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }), - ); + await user.click(screen.getByRole('button', { name: '分享' })); - expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1'); + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('邀请你来玩《沉钟拼图》'), + ); + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('作品号:PZ-PROFILE1'), + ); + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'), + ); expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); - expect(await screen.findByText('已复制')).toBeTruthy(); + expect( + await screen.findByRole('button', { name: '分享内容已复制' }), + ).toBeTruthy(); }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 09e5dea2..b4500833 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -42,12 +42,59 @@ test('creation hub draft card renders compiled work summary fields', () => { expect(html).toContain('一个被潮雾切开的列岛世界'); expect(html).toContain('玩家是失职返乡的守灯人'); expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); - expect(html).toContain('角色扮演 RPG'); - expect(html).toContain('拼图玩法'); + expect(html).toContain('角色扮演'); + expect(html).toContain('剧情演绎,冒险成长'); + expect(html).toContain('拼图'); + expect(html).toContain('创意礼物,生活分享'); expect(html).not.toContain('大鱼吃小鱼'); }); test('creation hub renders puzzle works in the same unified list with puzzle tag', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={() => {}} + />, + ); + + expect(html).toContain('潮雾拼图'); + expect(html).toContain('拼图'); + expect(html).toContain('aria-label="游玩 12次"'); + expect(html).toContain('aria-label="改造 3次"'); + expect(html).toContain('aria-label="点赞 4赞"'); + expect(html).not.toContain('作品号'); + expect(html).not.toContain('PZ-PROFILE1'); + expect(html).not.toContain('潮雾'); + expect(html).not.toContain('港口'); + expect(html).not.toContain('我的拼图作品'); +}); + +test('creation hub published work spans full mobile row', () => { const html = renderToStaticMarkup( , ); - expect(html).toContain('潮雾拼图'); - expect(html).toContain('拼图'); - expect(html).toContain('作品号'); - expect(html).toContain('PZ-PROFILE1'); - expect(html).not.toContain('我的拼图作品'); + expect(html).toContain('grid-cols-2'); + expect(html).toContain('col-span-2 sm:col-span-1'); + expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2'); }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index aa10a066..9d66627d 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -1,21 +1,32 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; 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'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { CustomWorldProfile } from '../../types'; +import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes'; +import { + buildCreationWorkShelfItems, + type CreationWorkShelfItem, + type CreationWorkShelfMetricId, +} from './creationWorkShelf'; import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard'; import { CustomWorldWorkCard } from './CustomWorldWorkCard'; import { type CustomWorldWorkFilter, CustomWorldWorkTabs, } from './CustomWorldWorkTabs'; -import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes'; -import { - buildCreationWorkShelfItems, - type CreationWorkShelfItem, -} from './creationWorkShelf'; + +// 中文注释:草稿在手机端保持双列,已发布卡片由卡片自身跨两列展示公开指标。 +const WORK_GRID_CLASS = + 'grid grid-cols-2 gap-2.5 sm:gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4'; +const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1'; + +type WorkMetricSnapshot = Record< + string, + Partial> +>; type CustomWorldCreationHubProps = { items: CustomWorldWorkSummary[]; @@ -29,15 +40,12 @@ type CustomWorldCreationHubProps = { onEnterPublished: (profileId: string) => void; onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null; deletingWorkId?: string | null; - onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null; rpgLibraryEntries?: CustomWorldLibraryEntry[]; bigFishItems?: BigFishWorkSummary[]; onOpenBigFishDetail?: (item: BigFishWorkSummary) => void; - onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null; onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null; puzzleItems?: PuzzleWorkSummary[]; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; - onExperiencePuzzle?: ((profileId: string) => void) | null; onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; }; @@ -51,6 +59,59 @@ function EmptyState({ title }: { title: string }) { ); } +function buildWorkMetricCacheItemKey(item: CreationWorkShelfItem) { + return `${item.kind}:${item.id}`; +} + +function readWorkMetricSnapshot(): WorkMetricSnapshot { + if (typeof window === 'undefined') { + return {}; + } + + try { + const rawSnapshot = window.sessionStorage.getItem(WORK_METRIC_CACHE_KEY); + if (!rawSnapshot) { + return {}; + } + + const parsed = JSON.parse(rawSnapshot) as WorkMetricSnapshot; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) { + if (typeof window === 'undefined') { + return; + } + + const snapshot: WorkMetricSnapshot = {}; + for (const item of items) { + if (item.status !== 'published' || item.metrics.length === 0) { + continue; + } + + snapshot[buildWorkMetricCacheItemKey(item)] = Object.fromEntries( + item.metrics.map((metric) => [metric.id, metric.value]), + ); + } + + // 中文注释:缓存只作为下一次进入创作页的数字动画起点,真实展示值仍以接口返回为准。 + if (Object.keys(snapshot).length === 0) { + return; + } + + try { + window.sessionStorage.setItem( + WORK_METRIC_CACHE_KEY, + JSON.stringify(snapshot), + ); + } catch { + // 中文注释:浏览器禁用 sessionStorage 时降级为无缓存动画,不影响作品列表使用。 + } +} + export function CustomWorldCreationHub({ items, loading, @@ -63,15 +124,12 @@ export function CustomWorldCreationHub({ onEnterPublished, onDeletePublished = null, deletingWorkId = null, - onExperienceRpg = null, rpgLibraryEntries = [], bigFishItems = [], onOpenBigFishDetail, - onExperienceBigFish = null, onDeleteBigFish = null, puzzleItems = [], onOpenPuzzleDetail, - onExperiencePuzzle = null, onDeletePuzzle = null, }: CustomWorldCreationHubProps) { const [activeFilter, setActiveFilter] = @@ -97,6 +155,12 @@ export function CustomWorldCreationHub({ rpgLibraryEntries, ], ); + const [metricSnapshot] = useState(() => + readWorkMetricSnapshot(), + ); + useEffect(() => { + writeWorkMetricSnapshot(shelfItems); + }, [shelfItems]); const draftCount = shelfItems.filter( (entry) => entry.status === 'draft', ).length; @@ -131,33 +195,6 @@ export function CustomWorldCreationHub({ } } - 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; @@ -215,31 +252,33 @@ export function CustomWorldCreationHub({ ) : null} {loading ? ( -

+
{Array.from({ length: 3 }).map((_, index) => (
-
-
+
+
-
-
-
+
+
+
))}
) : filteredItems.length > 0 ? ( -
+
{filteredItems.map((item) => ( handleOpenShelfItem(item)} - onExperience={buildExperienceAction(item)} onDelete={buildDeleteAction(item)} deleteBusy={deletingWorkId === item.id} /> diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx index 0601f53d..6add7234 100644 --- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx +++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx @@ -37,7 +37,7 @@ export function CustomWorldCreationStartCard({
-
+
{visibleCreationTypes.map((item) => { const disabled = item.locked || busy; @@ -49,22 +49,18 @@ export function CustomWorldCreationStartCard({ onClick={() => { onCreateType(item.id); }} - className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${ + className={`platform-interactive-card relative flex min-h-[4rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${ item.locked ? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70' : 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white' } ${busy && !item.locked ? 'opacity-70' : ''}`} > -
- - {item.locked ? item.badge : busy ? '正在开启' : item.badge} - +
+ {item.locked ? ( + + {item.badge} + + ) : null} {item.locked ? ( · ) : ( @@ -72,15 +68,17 @@ export function CustomWorldCreationStartCard({ )}
-
- {item.title} -
-
- {item.subtitle} +
+
+ {item.title} +
+
+ {item.subtitle} +
); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index f04fa6cc..41ecdf64 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -1,64 +1,240 @@ -import { Copy } from 'lucide-react'; -import { useState } from 'react'; +import { Share2, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { copyTextToClipboard } from '../../services/clipboard'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; -import type { CreationWorkShelfItem } from './creationWorkShelf'; - -function formatUpdatedAt(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return '最近更新'; - } - - return new Intl.DateTimeFormat('zh-CN', { - month: 'numeric', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(date); -} +import { + formatPlatformWorkDisplayName, + formatPlatformWorkDisplayTag, +} from '../rpg-entry/rpgEntryWorldPresentation'; +import { + type CreationWorkShelfBadgeTone, + type CreationWorkShelfItem, + type CreationWorkShelfMetric, + type CreationWorkShelfMetricId, + formatCreationMetricCount, +} from './creationWorkShelf'; type CustomWorldWorkCardProps = { item: CreationWorkShelfItem; + previousMetricValues?: Partial>; onOpen: () => void; - onExperience?: (() => void) | null; onDelete?: (() => void) | null; deleteBusy?: boolean; }; -const BADGE_TONE_CLASS: Record< - CreationWorkShelfItem['badges'][number]['tone'], - string -> = { +const BADGE_TONE_CLASS: Record = { warm: 'platform-pill--warm', success: 'platform-pill--success', neutral: 'platform-pill--neutral', }; -export function CustomWorldWorkCard({ - item, - onOpen, - onExperience = null, - onDelete = null, - deleteBusy = false, -}: CustomWorldWorkCardProps) { - const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( - 'idle', +const METRIC_ANIMATION_DURATION_MS = 820; +const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = []; + +function easeOutCubic(progress: number) { + return 1 - (1 - progress) ** 3; +} + +function resolveMetricStartValue( + metric: CreationWorkShelfMetric, + previousMetricValues?: Partial>, +) { + const previousValue = previousMetricValues?.[metric.id]; + if (previousValue === undefined || previousValue >= metric.value) { + return metric.value; + } + + return Math.max(0, Math.floor(previousValue)); +} + +function buildMetricValueMap( + metrics: CreationWorkShelfMetric[], + resolveValue: (metric: CreationWorkShelfMetric) => number, +) { + return Object.fromEntries( + metrics.map((metric) => [metric.id, resolveValue(metric)]), + ) as Record; +} + +function shouldAnimatePublishedMetrics() { + if (typeof window === 'undefined') { + return false; + } + + return !window.navigator.userAgent.toLowerCase().includes('jsdom'); +} + +function usePublishedMetricAnimation( + metrics: CreationWorkShelfMetric[], + previousMetricValues?: Partial>, +) { + const cardRef = useRef(null); + const [hasEnteredView, setHasEnteredView] = useState(false); + const startValues = useMemo( + () => + buildMetricValueMap(metrics, (metric) => + resolveMetricStartValue(metric, previousMetricValues), + ), + [metrics, previousMetricValues], ); - const copyPublicWorkCode = () => { - if (!item.publicWorkCode) { + const endValues = useMemo( + () => buildMetricValueMap(metrics, (metric) => metric.value), + [metrics], + ); + const deltas = useMemo( + () => + buildMetricValueMap(metrics, (metric) => + Math.max(0, metric.value - startValues[metric.id]), + ), + [metrics, startValues], + ); + const hasGrowth = useMemo( + () => Object.values(deltas).some((delta) => delta > 0), + [deltas], + ); + const [displayValues, setDisplayValues] = useState(endValues); + const [showGrowth, setShowGrowth] = useState(false); + + useEffect(() => { + setShowGrowth(false); + setHasEnteredView(false); + setDisplayValues(hasGrowth ? startValues : endValues); + }, [endValues, hasGrowth, startValues]); + + useEffect(() => { + const element = cardRef.current; + if (!element || !hasGrowth) { + setHasEnteredView(true); return; } - void copyTextToClipboard(item.publicWorkCode).then((copied) => { - setCopyState(copied ? 'copied' : 'failed'); - window.setTimeout(() => setCopyState('idle'), 1400); + if (typeof window === 'undefined' || !('IntersectionObserver' in window)) { + setHasEnteredView(true); + return; + } + + // 中文注释:指标增长只在卡片进入视口后启动,避免列表刷新时离屏卡片提前播放。 + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setHasEnteredView(true); + observer.disconnect(); + } + }, + { rootMargin: '0px 0px -10% 0px', threshold: 0.28 }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [hasGrowth]); + + useEffect(() => { + if (!hasEnteredView) { + return; + } + + if (!hasGrowth || !shouldAnimatePublishedMetrics()) { + setDisplayValues(endValues); + if (hasGrowth) { + setShowGrowth(true); + } + return; + } + + if (typeof window === 'undefined') { + setDisplayValues(endValues); + setShowGrowth(true); + return; + } + + let animationFrameId = 0; + const startTime = window.performance.now(); + const tick = (now: number) => { + const progress = Math.min( + 1, + (now - startTime) / METRIC_ANIMATION_DURATION_MS, + ); + const easedProgress = easeOutCubic(progress); + setDisplayValues( + buildMetricValueMap(metrics, (metric) => { + const startValue = startValues[metric.id]; + const endValue = endValues[metric.id]; + return Math.round( + startValue + (endValue - startValue) * easedProgress, + ); + }), + ); + + if (progress < 1) { + animationFrameId = window.requestAnimationFrame(tick); + return; + } + + setDisplayValues(endValues); + setShowGrowth(true); + }; + + animationFrameId = window.requestAnimationFrame(tick); + return () => { + window.cancelAnimationFrame(animationFrameId); + }; + }, [endValues, hasEnteredView, hasGrowth, metrics, startValues]); + + return { cardRef, deltas, displayValues, showGrowth }; +} + +export function CustomWorldWorkCard({ + item, + previousMetricValues, + onOpen, + onDelete = null, + deleteBusy = false, +}: CustomWorldWorkCardProps) { + const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const shareResetTimerRef = useRef(null); + const isPublished = item.status === 'published'; + const displayTitle = formatPlatformWorkDisplayName(item.title); + const { cardRef, deltas, displayValues, showGrowth } = + usePublishedMetricAnimation( + isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS, + previousMetricValues, + ); + const copyShareText = () => { + const publicWorkCode = item.publicWorkCode?.trim(); + const sharePath = item.sharePath?.trim(); + if (!publicWorkCode || !sharePath) { + return; + } + + const shareUrl = + typeof window === 'undefined' + ? sharePath + : new URL(sharePath, window.location.origin).href; + const shareText = `邀请你来玩《${item.title}》\n作品号:${publicWorkCode}\n${shareUrl}`; + void copyTextToClipboard(shareText).then((copied) => { + setShareState(copied ? 'copied' : 'failed'); + if (shareResetTimerRef.current !== null) { + window.clearTimeout(shareResetTimerRef.current); + } + shareResetTimerRef.current = window.setTimeout(() => { + shareResetTimerRef.current = null; + setShareState('idle'); + }, 1400); }); }; - + useEffect( + () => () => { + if (shareResetTimerRef.current !== null) { + window.clearTimeout(shareResetTimerRef.current); + } + }, + [], + ); return (
-
-
-
+
+
+ {!isPublished && onDelete ? ( + + ) : null} + {isPublished ? ( + + ) : null} + +
+
{item.badges.map((badge) => ( - {badge.label} + {formatPlatformWorkDisplayTag(badge.label)} ))}
-
- - {formatUpdatedAt(item.updatedAt)} - - {onDelete ? ( - - ) : null} -
-
-
- {item.title} +
+
+ {displayTitle}
-
- {item.subtitle} -
-
+
{item.summary}
-
-
- {item.publicWorkCode ? ( - - ) : null} -
- {item.metrics.map((metric) => ( - + {metric.label} - ))} -
+ + + {formatCreationMetricCount( + displayValues[metric.id] ?? metric.value, + )} + + + {metric.unit} + + + {showGrowth && deltas[metric.id] > 0 ? ( + + + {formatCreationMetricCount(deltas[metric.id])} + + ) : null} +
+ ))}
-
- {onExperience ? ( - - ) : null} -
-
+ ) : null}
); diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 1fa56e98..4a936ff2 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -2,7 +2,11 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; -import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode'; +import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; +import { + buildBigFishPublicWorkCode, + buildPuzzlePublicWorkCode, +} from '../../services/publicWorkCode'; import type { CustomWorldProfile } from '../../types'; export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle'; @@ -16,10 +20,19 @@ export type CreationWorkShelfBadge = { tone: CreationWorkShelfBadgeTone; }; +export type CreationWorkShelfMetricId = + | 'play-count' + | 'remix-count' + | 'like-count'; + +export type CreationWorkShelfMetricTone = 'play' | 'remix' | 'like'; + export type CreationWorkShelfMetric = { - id: string; + id: CreationWorkShelfMetricId; label: string; - tone?: CreationWorkShelfBadgeTone; + value: number; + unit: string; + tone: CreationWorkShelfMetricTone; }; export type CreationWorkShelfSource = @@ -41,17 +54,16 @@ export type CreationWorkShelfItem = { kind: CreationWorkShelfKind; status: CreationWorkShelfStatus; title: string; - subtitle: string; summary: string; updatedAt: string; coverImageSrc: string | null; coverRenderMode: 'image' | 'scene_with_roles'; coverCharacterImageSrcs: string[]; publicWorkCode: string | null; - typeLabel: string; + sharePath: string | null; openActionLabel: string; - canExperience: boolean; canDelete: boolean; + canShare: boolean; badges: CreationWorkShelfBadge[]; metrics: CreationWorkShelfMetric[]; source: CreationWorkShelfSource; @@ -101,67 +113,43 @@ function mapRpgWorkToShelfItem( const libraryEntry = item.profileId ? libraryEntries.find((entry) => entry.profileId === item.profileId) : null; + const publicWorkCode = + item.status === 'published' ? (libraryEntry?.publicWorkCode ?? null) : null; 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, - }); - } + const metrics = buildPublishedMetrics({ + playCount: libraryEntry?.playCount, + remixCount: libraryEntry?.remixCount, + likeCount: libraryEntry?.likeCount, + }); 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 ?? [], - publicWorkCode: - item.status === 'published' - ? (libraryEntry?.publicWorkCode ?? null) + publicWorkCode, + sharePath: + publicWorkCode && item.status === 'published' + ? buildPublicWorkStagePath('work-detail', publicWorkCode) : null, - typeLabel: 'RPG', openActionLabel: isDraft ? item.playableNpcCount > 0 || item.landmarkCount > 0 ? '继续完善' : '继续创作' : '查看详情', - canExperience: item.status === 'published' && item.canEnterWorld, canDelete, + canShare: item.status === 'published' && Boolean(publicWorkCode), badges, - metrics, + metrics: isDraft ? [] : metrics, source: { kind: 'rpg', item }, }; } @@ -170,47 +158,40 @@ function mapBigFishWorkToShelfItem( item: BigFishWorkSummary, canDelete: boolean, ): CreationWorkShelfItem { + const isPublished = item.status === 'published'; + const publicWorkCode = isPublished + ? buildBigFishPublicWorkCode(item.sourceSessionId) + : null; + 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: [], - publicWorkCode: null, - typeLabel: '大鱼', + publicWorkCode, + sharePath: + publicWorkCode && isPublished + ? buildPublicWorkStagePath('big-fish-runtime', publicWorkCode) + : null, openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情', - canExperience: item.status === 'published', canDelete, + canShare: isPublished && Boolean(publicWorkCode), 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}`, - }, - { id: 'play-count', label: `游玩 ${item.playCount ?? 0}` }, - ...(item.backgroundReady - ? [ - { - id: 'background-ready', - label: '背景已就绪', - tone: 'success' as const, - }, - ] - : []), - ], + metrics: isPublished + ? buildPublishedMetrics({ + playCount: item.playCount, + remixCount: item.remixCount, + likeCount: item.likeCount, + }) + : [], source: { kind: 'big-fish', item }, }; } @@ -220,42 +201,88 @@ function mapPuzzleWorkToShelfItem( canDelete: boolean, ): CreationWorkShelfItem { const status = item.publicationStatus; + const publicWorkCode = + status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null; 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: [], - publicWorkCode: - status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null, - typeLabel: '拼图', + publicWorkCode, + sharePath: + publicWorkCode && status === 'published' + ? buildPublicWorkStagePath('puzzle-gallery-detail', publicWorkCode) + : null, openActionLabel: status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作', - canExperience: status === 'published', canDelete, + canShare: status === 'published' && Boolean(publicWorkCode), 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}` }, ], + metrics: + status === 'published' + ? buildPublishedMetrics({ + playCount: item.playCount, + remixCount: item.remixCount, + likeCount: item.likeCount, + }) + : [], source: { kind: 'puzzle', item }, }; } +function buildPublishedMetrics(params: { + playCount?: number | null; + remixCount?: number | null; + likeCount?: number | null; +}): CreationWorkShelfMetric[] { + return [ + { + id: 'play-count', + label: '游玩', + value: normalizeMetricCount(params.playCount), + unit: '次', + tone: 'play', + }, + { + id: 'remix-count', + label: '改造', + value: normalizeMetricCount(params.remixCount), + unit: '次', + tone: 'remix', + }, + { + id: 'like-count', + label: '点赞', + value: normalizeMetricCount(params.likeCount), + unit: '赞', + tone: 'like', + }, + ]; +} + +export function normalizeMetricCount(value?: number | null) { + return Math.max(0, Math.floor(value ?? 0)); +} + +export function formatCreationMetricCount(value?: number | null) { + const normalized = Math.max(0, Math.floor(value ?? 0)); + if (normalized >= 10000) { + const wanValue = normalized / 10000; + return `${Number.isInteger(wanValue) ? wanValue.toFixed(0) : wanValue.toFixed(1)}万`; + } + + return `${normalized}`; +} + function buildStatusBadge( status: CreationWorkShelfStatus, ): CreationWorkShelfBadge { diff --git a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx index d73702fe..5eedf63b 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx @@ -234,6 +234,55 @@ describe('GameCanvasEntityLayer', () => { expect(html).toContain('aria-label="好感度变化 +3"'); }); + it('keeps battle opponent visible when compat payload misses encounter context', () => { + const hostileNpc = createHostileNpc({ + encounter: undefined, + name: '断桥匪首', + description: '刚进入战斗时的旧快照目标', + }); + const html = renderToStaticMarkup( + '70%'} + groundBottom="18%" + stageLiftPx={68} + encounter={null} + sideAnchor="15%" + cameraAnchorX={0} + monsterAnchorMeters={3.2} + playerX={0} + />, + ); + + expect(html).toContain('查看断桥匪首详情'); + expect(html).toContain('from-rose-500 to-red-400'); + }); + it('does not render affinity effect on a different npc', () => { const html = renderEntityLayer('npc-other'); diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx index aafe61bd..4e4e8aed 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx @@ -98,6 +98,18 @@ interface GameCanvasEntityLayerProps { const SCENE_ACT_BACK_ROW_ANCHOR_X_METERS = RESOLVED_ENTITY_X_METERS + 1.08; const SCENE_ACT_BACK_ROW_OFFSET_PX = [62, -46] as const; +function buildFallbackCombatEncounter(hostileNpc: SceneHostileNpc): Encounter { + return { + id: hostileNpc.id, + kind: 'npc', + npcName: hostileNpc.name, + npcDescription: hostileNpc.description, + npcAvatar: '', + context: hostileNpc.action, + hostile: true, + }; +} + function addCssPxOffset(value: string, offsetPx: number) { return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`; } @@ -440,8 +452,7 @@ export function GameCanvasEntityLayer({ {sceneCombatants.map((hostileNpc, index) => { - const npcEncounter = hostileNpc.encounter; - if (!npcEncounter) return null; + const npcEncounter = hostileNpc.encounter ?? buildFallbackCombatEncounter(hostileNpc); const hostileRenderKey = [ hostileNpc.id, npcEncounter.id ?? npcEncounter.npcName, diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 735a21c7..66dfe34b 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -26,37 +26,35 @@ function CreationTypeCard(props: { type="button" disabled={disabled} onClick={onSelect} - className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${ + className={`platform-interactive-card relative flex min-h-[8.25rem] flex-col overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${ item.locked ? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]' : 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white' } ${busy && !item.locked ? 'opacity-70' : ''}`} > -
- - {item.locked ? item.badge : busy ? '正在开启' : item.badge} - +
+ {item.locked ? ( + + {item.badge} + + ) : null} {item.locked ? ( · ) : ( )}
-
- {item.title} -
-
- {item.subtitle} +
+
+ {item.title} +
+
+ {item.subtitle} +
); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 92367a72..69e5ebce 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -25,6 +25,7 @@ import type { } from '../../../packages/shared/src/contracts/puzzleAgentActions'; import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { + CreatePuzzleAgentSessionRequest, PuzzleAgentSessionSnapshot, SendPuzzleAgentMessageRequest, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; @@ -98,12 +99,20 @@ import { } from '../../services/puzzle-gallery'; import { advanceLocalPuzzleNextLevel, + dragPuzzlePieceOrGroup, + getPuzzleRun, startPuzzleRun, submitPuzzleLeaderboard, + swapPuzzlePieces, + updatePuzzleRunPause, + usePuzzleRuntimeProp as consumePuzzleRuntimeProp, } from '../../services/puzzle-runtime'; import { + applyLocalPuzzleFreezeTime, dragLocalPuzzlePiece, isLocalPuzzleRun, + refreshLocalPuzzleTimer, + setLocalPuzzlePaused, startLocalPuzzleRun, submitLocalPuzzleLeaderboard, swapLocalPuzzlePieces, @@ -114,8 +123,8 @@ import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreati import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetailByCode, - remixRpgEntryWorldGallery, recordRpgEntryWorldGalleryPlay, + remixRpgEntryWorldGallery, } from '../../services/rpg-entry/rpgEntryLibraryClient'; import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; @@ -230,6 +239,33 @@ function mapBigFishWorkToPublicWorkDetail( return mapBigFishWorkToPlatformGalleryCard(item); } +function mapPublicWorkDetailToPuzzleWork( + entry: PlatformPublicGalleryCard, +): PuzzleWorkSummary | null { + if (!isPuzzleGalleryEntry(entry)) { + return null; + } + + return { + workId: entry.workId, + profileId: entry.profileId, + ownerUserId: entry.ownerUserId, + sourceSessionId: null, + authorDisplayName: entry.authorDisplayName, + levelName: entry.worldName, + summary: entry.summaryText, + themeTags: entry.themeTags, + coverImageSrc: entry.coverImageSrc, + publicationStatus: 'published', + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + playCount: entry.playCount ?? 0, + remixCount: entry.remixCount ?? 0, + likeCount: entry.likeCount ?? 0, + publishReady: true, + }; +} + function mapPublicWorkDetailToBigFishWork( entry: PlatformPublicGalleryCard, ): BigFishWorkSummary | null { @@ -265,6 +301,26 @@ function mapPublicWorkDetailToBigFishWork( }; } +async function resolvePublicWorkAuthorSummary( + entry: PlatformPublicGalleryCard, +): Promise { + if ('authorPublicUserCode' in entry && entry.authorPublicUserCode?.trim()) { + try { + return await getPublicAuthUserByCode(entry.authorPublicUserCode); + } catch { + if (!entry.ownerUserId.trim()) { + return null; + } + } + } + + if (entry.ownerUserId.trim()) { + return getPublicAuthUserById(entry.ownerUserId); + } + + return null; +} + function readProfileTextField( profile: CustomWorldProfile | null, paths: string[], @@ -400,6 +456,18 @@ function buildPuzzleResultProfileId(sessionId: string | null | undefined) { return `puzzle-profile-${stableSuffix}`; } +function buildPuzzleCompileActionFromFormPayload( + payload: CreatePuzzleAgentSessionRequest | null, +): PuzzleAgentActionRequest { + return { + action: 'compile_puzzle_draft', + promptText: + payload?.pictureDescription?.trim() || payload?.seedText?.trim(), + referenceImageSrc: payload?.referenceImageSrc || null, + candidateCount: 1, + }; +} + const CustomWorldGenerationView = lazy(async () => { const module = await import('../CustomWorldGenerationView'); return { @@ -505,6 +573,9 @@ export function PlatformEntryFlowShellImpl({ useState | null>(null); const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] = useState(null); + const [selectedPublicWorkAuthor, setSelectedPublicWorkAuthor] = + useState(null); + const publicWorkAuthorRequestKeyRef = useRef(0); const [publicWorkDetailError, setPublicWorkDetailError] = useState< string | null >(null); @@ -529,8 +600,9 @@ export function PlatformEntryFlowShellImpl({ const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false); const [bigFishGenerationState, setBigFishGenerationState] = useState(null); - const [puzzleOperation, setPuzzleOperation] = - useState(null); + const [, setPuzzleOperation] = useState( + null, + ); const [puzzleWorks, setPuzzleWorks] = useState([]); const [puzzleGalleryEntries, setPuzzleGalleryEntries] = useState< PuzzleWorkSummary[] @@ -544,9 +616,12 @@ export function PlatformEntryFlowShellImpl({ const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false); const submittedPuzzleLeaderboardKeysRef = useRef(new Set()); const [puzzleRun, setPuzzleRun] = useState(null); + const puzzleRunRef = useRef(null); const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false); const [puzzleGenerationState, setPuzzleGenerationState] = useState(null); + const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] = + useState(null); const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] = useState(false); const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false); @@ -984,7 +1059,7 @@ export function PlatformEntryFlowShellImpl({ const puzzleFlow = usePlatformCreationAgentFlowController< PuzzleAgentSessionSnapshot, - Record, + CreatePuzzleAgentSessionRequest, { session: PuzzleAgentSessionSnapshot }, SendPuzzleAgentMessageRequest, PuzzleAgentActionRequest, @@ -1097,7 +1172,6 @@ export function PlatformEntryFlowShellImpl({ const setPuzzleError = puzzleFlow.setError; const isPuzzleBusy = puzzleFlow.isBusy; const setIsPuzzleBusy = puzzleFlow.setIsBusy; - const streamingPuzzleReplyText = puzzleFlow.streamingReplyText; const isStreamingPuzzleReply = puzzleFlow.isStreamingReply; const resetRpgSessionViewState = sessionController.resetSessionViewState; const setRpgGeneratedCustomWorldProfile = @@ -1106,6 +1180,11 @@ export function PlatformEntryFlowShellImpl({ const persistRpgAgentUiState = sessionController.persistAgentUiState; const resetAutoSaveTrackingToIdle = autosaveCoordinator.resetAutoSaveTrackingToIdle; + + useEffect(() => { + puzzleRunRef.current = puzzleRun; + }, [puzzleRun]); + const openBigFishAgentWorkspace = useCallback(async () => { setBigFishRun(null); await bigFishFlow.openWorkspace(); @@ -1114,8 +1193,32 @@ export function PlatformEntryFlowShellImpl({ const openPuzzleAgentWorkspace = useCallback(async () => { setPuzzleRun(null); setPuzzleOperation(null); - await puzzleFlow.openWorkspace(); - }, [puzzleFlow]); + setPuzzleGenerationState(null); + setPuzzleFormDraftPayload(null); + puzzleFlow.setSession(null); + puzzleFlow.setError(null); + puzzleFlow.setStreamingReplyText(''); + puzzleFlow.setIsStreamingReply(false); + enterCreateTab(); + setShowCreationTypeModal(false); + setSelectionStage('puzzle-agent-workspace'); + }, [enterCreateTab, puzzleFlow, setSelectionStage]); + + const createPuzzleDraftFromForm = useCallback( + async (payload: CreatePuzzleAgentSessionRequest) => { + setPuzzleFormDraftPayload(payload); + const nextSession = await puzzleFlow.openWorkspace(payload); + if (!nextSession) { + return; + } + + await puzzleFlow.executeAction( + buildPuzzleCompileActionFromFormPayload(payload), + nextSession, + ); + }, + [puzzleFlow], + ); useEffect(() => { if (platformBootstrap.canReadProtectedData) { @@ -1325,6 +1428,8 @@ export function PlatformEntryFlowShellImpl({ async ( profileId: string, returnStage: PuzzleRuntimeReturnStage = 'work-detail', + detailItem?: PuzzleWorkSummary, + mirrorErrorToPublicDetail = false, ) => { if (isPuzzleBusy) { return; @@ -1334,7 +1439,8 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); try { - const { item } = await getPuzzleGalleryDetail(profileId); + const item = + detailItem ?? (await getPuzzleGalleryDetail(profileId)).item; const { run } = await startPuzzleRun({ profileId: item.profileId }); setSelectedPuzzleDetail(item); setPuzzleRun(run); @@ -1347,12 +1453,22 @@ export function PlatformEntryFlowShellImpl({ ), ); } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。')); + const message = resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'); + setPuzzleError(message); + if (mirrorErrorToPublicDetail) { + setPublicWorkDetailError(message); + } } finally { setIsPuzzleBusy(false); } }, - [isPuzzleBusy, resolvePuzzleErrorMessage, setSelectionStage], + [ + isPuzzleBusy, + resolvePuzzleErrorMessage, + setIsPuzzleBusy, + setPuzzleError, + setSelectionStage, + ], ); const buildPuzzleTestWork = useCallback( @@ -1449,9 +1565,20 @@ export function PlatformEntryFlowShellImpl({ } setPuzzleError(null); - setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload)); + if (isLocalPuzzleRun(puzzleRun)) { + setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload)); + return; + } + + void swapPuzzlePieces(puzzleRun.runId, payload) + .then(({ run }) => { + setPuzzleRun(run); + }) + .catch((error) => { + setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。')); + }); }, - [isPuzzleBusy, puzzleRun], + [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError], ); const dragPuzzlePiece = useCallback( @@ -1461,9 +1588,126 @@ export function PlatformEntryFlowShellImpl({ } setPuzzleError(null); - setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload)); + if (isLocalPuzzleRun(puzzleRun)) { + setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload)); + return; + } + + void dragPuzzlePieceOrGroup(puzzleRun.runId, payload) + .then(({ run }) => { + setPuzzleRun(run); + }) + .catch((error) => { + setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。')); + }); }, - [isPuzzleBusy, puzzleRun], + [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError], + ); + + useEffect(() => { + if (selectionStage !== 'puzzle-runtime' || !puzzleRun?.currentLevel) { + return; + } + if (puzzleRun.currentLevel.status !== 'playing') { + return; + } + + const timerId = window.setInterval(() => { + if (!isLocalPuzzleRun(puzzleRun)) { + return; + } + setPuzzleRun((currentRun) => + currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun, + ); + }, 250); + + return () => window.clearInterval(timerId); + }, [puzzleRun, selectionStage]); + + const setPuzzleRuntimePaused = useCallback( + async (paused: boolean) => { + if (!puzzleRun?.currentLevel) { + return; + } + + if (isLocalPuzzleRun(puzzleRun)) { + setPuzzleRun((currentRun) => + currentRun ? setLocalPuzzlePaused(currentRun, paused) : currentRun, + ); + return; + } + + try { + const { run } = await updatePuzzleRunPause(puzzleRun.runId, { + paused, + }); + setPuzzleRun(run); + void platformBootstrap.refreshProfileDashboard(); + } catch (error) { + setPuzzleError( + resolvePuzzleErrorMessage(error, '更新拼图计时状态失败。'), + ); + } + }, + [platformBootstrap, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError], + ); + + const syncPuzzleRuntimeTimeout = useCallback(async () => { + if ( + !puzzleRun?.currentLevel || + puzzleRun.currentLevel.status !== 'playing' + ) { + return; + } + + if (isLocalPuzzleRun(puzzleRun)) { + setPuzzleRun((currentRun) => + currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun, + ); + return; + } + + try { + const { run } = await getPuzzleRun(puzzleRun.runId); + setPuzzleRun(run); + } catch (error) { + setPuzzleError( + resolvePuzzleErrorMessage(error, '同步拼图失败状态失败。'), + ); + } + }, [puzzleRun, resolvePuzzleErrorMessage, setPuzzleError]); + + const usePuzzleProp = useCallback( + async (propKind: 'hint' | 'reference' | 'freezeTime') => { + if ( + !puzzleRun?.currentLevel || + puzzleRun.currentLevel.status !== 'playing' + ) { + return null; + } + + if (isLocalPuzzleRun(puzzleRun)) { + const currentRun = puzzleRunRef.current ?? puzzleRun; + if (!currentRun.currentLevel) { + return null; + } + const nextRun = + propKind === 'freezeTime' + ? applyLocalPuzzleFreezeTime(currentRun) + : setLocalPuzzlePaused(currentRun, propKind === 'reference'); + puzzleRunRef.current = nextRun; + setPuzzleRun(nextRun); + return nextRun; + } + + const { run } = await consumePuzzleRuntimeProp(puzzleRun.runId, { + propKind, + }); + setPuzzleRun(run); + void platformBootstrap.refreshProfileDashboard(); + return run; + }, + [platformBootstrap, puzzleRun], ); useEffect(() => { @@ -1622,34 +1866,6 @@ export function PlatformEntryFlowShellImpl({ }); }, [handleCustomWorldSelect, runProtectedAction, selectedDetailEntry]); - const handleExperienceRpgWork = useCallback( - (work: (typeof creationHubItems)[number]) => { - if (!work.profileId) { - return; - } - - runProtectedAction(() => { - const matchedEntry = platformBootstrap.savedCustomWorldEntries.find( - (entry) => entry.profileId === work.profileId, - ); - if (!matchedEntry) { - platformBootstrap.setPlatformError( - '未找到可体验的作品,请刷新后重试。', - ); - return; - } - - handleCustomWorldSelect(matchedEntry.profile); - }); - }, - [ - handleCustomWorldSelect, - platformBootstrap, - platformBootstrap.savedCustomWorldEntries, - runProtectedAction, - ], - ); - const handleDeleteLibraryEntry = useCallback( (entry: CustomWorldLibraryEntry) => { if (!entry.profileId || deletingCreationWorkId) { @@ -1815,6 +2031,32 @@ export function PlatformEntryFlowShellImpl({ ], ); + const clearSelectedPublicWorkAuthor = useCallback(() => { + publicWorkAuthorRequestKeyRef.current += 1; + setSelectedPublicWorkAuthor(null); + }, []); + + const loadSelectedPublicWorkAuthor = useCallback( + (entry: PlatformPublicGalleryCard) => { + const requestKey = publicWorkAuthorRequestKeyRef.current + 1; + publicWorkAuthorRequestKeyRef.current = requestKey; + setSelectedPublicWorkAuthor(null); + + void resolvePublicWorkAuthorSummary(entry) + .then((author) => { + if (publicWorkAuthorRequestKeyRef.current === requestKey) { + setSelectedPublicWorkAuthor(author); + } + }) + .catch(() => { + if (publicWorkAuthorRequestKeyRef.current === requestKey) { + setSelectedPublicWorkAuthor(null); + } + }); + }, + [], + ); + const openPublicWorkDetail = useCallback( (entry: PlatformPublicGalleryCard) => { setSelectedPublicWorkDetail(entry); @@ -1829,19 +2071,44 @@ export function PlatformEntryFlowShellImpl({ [setSelectionStage], ); + useEffect(() => { + const detailEntry = + selectionStage === 'work-detail' + ? selectedPublicWorkDetail + : selectionStage === 'detail' && + selectedDetailEntry && + selectedDetailEntry.visibility !== 'draft' + ? mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry) + : null; + + if (!detailEntry) { + clearSelectedPublicWorkAuthor(); + return; + } + + loadSelectedPublicWorkAuthor(detailEntry); + }, [ + clearSelectedPublicWorkAuthor, + loadSelectedPublicWorkAuthor, + selectedDetailEntry, + selectedPublicWorkDetail, + selectionStage, + ]); + const openRpgPublicWorkDetail = useCallback( async (entry: CustomWorldGalleryCard) => { setIsPublicWorkDetailBusy(true); setPublicWorkDetailError(null); + clearSelectedPublicWorkAuthor(); + setSelectedPublicWorkDetail(entry); setSelectionStage('work-detail'); try { const detailEntry = await detailNavigation.loadGalleryDetailEntry(entry); setSelectedDetailEntry(detailEntry); - setSelectedPublicWorkDetail( - mapRpgGalleryCardToPublicWorkDetail(detailEntry), - ); + const detailCard = mapRpgGalleryCardToPublicWorkDetail(detailEntry); + setSelectedPublicWorkDetail(detailCard); if (detailEntry.publicWorkCode?.trim()) { pushAppHistoryPath( buildPublicWorkStagePath('work-detail', detailEntry.publicWorkCode), @@ -1856,7 +2123,12 @@ export function PlatformEntryFlowShellImpl({ setIsPublicWorkDetailBusy(false); } }, - [detailNavigation, setSelectedDetailEntry, setSelectionStage], + [ + clearSelectedPublicWorkAuthor, + detailNavigation, + setSelectedDetailEntry, + setSelectionStage, + ], ); const openPuzzlePublicWorkDetail = useCallback( @@ -2004,7 +2276,13 @@ export function PlatformEntryFlowShellImpl({ } if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) { - void startPuzzleRunFromProfile(selectedPublicWorkDetail.profileId); + const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail); + if (!work) { + setPublicWorkDetailError('当前拼图作品信息不完整,暂时无法进入玩法。'); + return; + } + setPublicWorkDetailError(null); + void startPuzzleRunFromProfile(work.profileId, 'work-detail', work, true); return; } @@ -2106,7 +2384,7 @@ export function PlatformEntryFlowShellImpl({ (entry) => entry.profileId !== nextEntry.profileId, ), ]); - detailNavigation.openSavedCustomWorldEditor(nextEntry); + void detailNavigation.openSavedCustomWorldEditor(nextEntry); }) .catch((error) => { setPublicWorkDetailError( @@ -2272,7 +2550,7 @@ export function PlatformEntryFlowShellImpl({ setSearchedPublicUser(user); } catch (error) { setPublicSearchError( - resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'), + resolveRpgCreationErrorMessage(error, '未找到对应的陶泥号或作品号。'), ); } finally { setIsSearchingPublicCode(false); @@ -2539,9 +2817,6 @@ export function PlatformEntryFlowShellImpl({ handleDeletePublishedWork(item); }} deletingWorkId={deletingCreationWorkId} - onExperienceRpg={(item) => { - handleExperienceRpgWork(item); - }} rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} bigFishItems={isBigFishCreationVisible ? bigFishWorks : []} onOpenBigFishDetail={ @@ -2553,15 +2828,6 @@ export function PlatformEntryFlowShellImpl({ } : undefined } - onExperienceBigFish={ - isBigFishCreationVisible - ? (item) => { - runProtectedAction(() => { - void startBigFishRunFromWork(item, 'platform'); - }); - } - : null - } onDeleteBigFish={ isBigFishCreationVisible ? (item) => { @@ -2575,11 +2841,6 @@ export function PlatformEntryFlowShellImpl({ void openPuzzleDraft(item); }); }} - onExperiencePuzzle={(profileId) => { - runProtectedAction(() => { - void startPuzzleRunFromProfile(profileId, 'platform'); - }); - }} onDeletePuzzle={(item) => { handleDeletePuzzleWork(item); }} @@ -2648,7 +2909,7 @@ export function PlatformEntryFlowShellImpl({ }} onOpenLibraryDetail={(entry) => { runProtectedAction(() => { - detailNavigation.openLibraryDetail(entry); + void detailNavigation.openLibraryDetail(entry); }); }} onDeleteLibraryEntry={(entry) => { @@ -2691,10 +2952,12 @@ export function PlatformEntryFlowShellImpl({ > { setPublicWorkDetailError(null); + clearSelectedPublicWorkAuthor(); setSelectionStage('platform'); }} onStart={startSelectedPublicWork} @@ -2720,10 +2983,12 @@ export function PlatformEntryFlowShellImpl({ ) : selectedDetailEntry.visibility !== 'draft' ? ( { detailNavigation.setDetailError(null); + clearSelectedPublicWorkAuthor(); entryNavigation.backToPlatformHome(); }} onStart={handleStartSelectedWorld} @@ -2747,7 +3012,7 @@ export function PlatformEntryFlowShellImpl({ detailNavigation.isSelectedWorldOwned ? () => { runProtectedAction(() => { - detailNavigation.openSavedCustomWorldEditor( + void detailNavigation.openSavedCustomWorldEditor( selectedDetailEntry, ); }); @@ -2988,9 +3253,6 @@ export function PlatformEntryFlowShellImpl({ > { void executePuzzleAction(payload); }} + initialFormPayload={puzzleFormDraftPayload} + onCreateFromForm={(payload) => { + void createPuzzleDraftFromForm(payload); + }} /> @@ -3033,7 +3299,11 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('puzzle-agent-workspace'); }} onRetry={() => { - void executePuzzleAction({ action: 'compile_puzzle_draft' }); + void executePuzzleAction( + buildPuzzleCompileActionFromFormPayload( + puzzleFormDraftPayload, + ), + ); }} onInterrupt={undefined} backLabel="返回创作中心" @@ -3117,6 +3387,7 @@ export function PlatformEntryFlowShellImpl({ void startPuzzleRunFromProfile( selectedPuzzleDetail.profileId, 'puzzle-gallery-detail', + selectedPuzzleDetail, ); }} /> @@ -3155,6 +3426,9 @@ export function PlatformEntryFlowShellImpl({ onAdvanceNextLevel={() => { void advancePuzzleLevel(); }} + onPauseChange={setPuzzleRuntimePaused} + onUseProp={usePuzzleProp} + onTimeExpired={syncPuzzleRuntimeTimeout} /> {isPuzzleNextLevelGenerating ? ( @@ -3474,7 +3748,7 @@ export function PlatformEntryFlowShellImpl({ {searchedPublicUser.displayName}
- 叙世号 {searchedPublicUser.publicUserCode} + 陶泥号 {searchedPublicUser.publicUserCode}
) : null} diff --git a/src/components/platform-entry/PlatformWorkDetailView.test.tsx b/src/components/platform-entry/PlatformWorkDetailView.test.tsx new file mode 100644 index 00000000..c82bcb3f --- /dev/null +++ b/src/components/platform-entry/PlatformWorkDetailView.test.tsx @@ -0,0 +1,54 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; +import { PlatformWorkDetailView } from './PlatformWorkDetailView'; + +function createPuzzleEntry(): PlatformPublicGalleryCard { + return { + sourceType: 'puzzle', + workId: 'work-1', + profileId: 'profile-1', + publicWorkCode: 'PZ-001', + ownerUserId: 'user-1', + authorDisplayName: '137****6613', + worldName: '关键词:逍遥游拼图', + subtitle: '拼图关卡', + summaryText: '适合公开游玩的拼图作品。', + coverImageSrc: null, + themeTags: ['拼图'], + playCount: 12, + remixCount: 3, + likeCount: 4, + recentPlayCount7d: 0, + visibility: 'published', + publishedAt: '2026-04-20T10:00:00.000Z', + updatedAt: '2026-04-25T12:00:00.000Z', + }; +} + +test('PlatformWorkDetailView renders compact stats and recent update time', () => { + render( + , + ); + + expect(screen.getByText('改造')).toBeTruthy(); + expect(screen.getByText('游玩')).toBeTruthy(); + expect(screen.getByText('点赞')).toBeTruthy(); + expect(screen.getByText('最近更新')).toBeTruthy(); + expect(screen.queryByText('改造次数')).toBeNull(); + expect(screen.queryByText('游玩次数')).toBeNull(); + expect(screen.queryByText('上线日期')).toBeNull(); + expect(screen.getByText('2026-04-25')).toBeTruthy(); + expect(screen.getAllByText('次')).toHaveLength(2); + expect(screen.getByText('赞')).toBeTruthy(); +}); diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index a055764f..da850839 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -1,20 +1,32 @@ -import { ArrowLeft, Copy, GitFork, Play, Share2 } from 'lucide-react'; +import { + ArrowLeft, + Clock3, + Copy, + Gamepad2, + GitFork, + Heart, + Play, + Share2, +} from 'lucide-react'; import { useMemo, useState } from 'react'; import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; import { copyTextToClipboard } from '../../services/clipboard'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { - buildPlatformWorldTags, + buildPlatformWorldDisplayTags, + formatPlatformWorkDisplayName, + formatPlatformWorkDisplayTags, formatPlatformWorldTime, + type PlatformPublicGalleryCard, resolvePlatformPublicWorkCode, resolvePlatformWorldCoverImage, resolvePlatformWorldStats, - type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; export interface PlatformWorkDetailViewProps { entry: PlatformPublicGalleryCard; + authorAvatarUrl?: string | null; isBusy: boolean; error: string | null; onBack: () => void; @@ -40,8 +52,13 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) { return 'RPG'; } +function getAuthorAvatarLabel(authorDisplayName: string) { + return Array.from(authorDisplayName.trim() || '作')[0] ?? '作'; +} + export function PlatformWorkDetailView({ entry, + authorAvatarUrl, isBusy, error, onBack, @@ -50,30 +67,51 @@ export function PlatformWorkDetailView({ }: PlatformWorkDetailViewProps) { const coverImage = resolvePlatformWorldCoverImage(entry); const publicWorkCode = resolvePlatformPublicWorkCode(entry); + const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( 'idle', ); const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>( 'idle', ); + const displayName = formatPlatformWorkDisplayName(entry.worldName); const tags = useMemo( () => - [ - getSourceLabel(entry), - ...buildPlatformWorldTags(entry).map((tag) => tag.trim()), - ] - .filter(Boolean) - .slice(0, 4), + formatPlatformWorkDisplayTags( + [getSourceLabel(entry), ...buildPlatformWorldDisplayTags(entry, 3)], + 4, + ), [entry], ); const stats = resolvePlatformWorldStats(entry); const statItems = [ - { label: '改造次数', value: formatCompactCount(stats.remixCount) }, - { label: '游玩次数', value: formatCompactCount(stats.playCount) }, - { label: '点赞次数', value: formatCompactCount(stats.likeCount) }, { - label: '上线日期', - value: formatPlatformWorldTime(stats.publishedAt), + label: '改造', + value: formatCompactCount(stats.remixCount), + unit: '次', + icon: GitFork, + tone: 'remix', + }, + { + label: '游玩', + value: formatCompactCount(stats.playCount), + unit: '次', + icon: Gamepad2, + tone: 'play', + }, + { + label: '点赞', + value: formatCompactCount(stats.likeCount), + unit: '赞', + icon: Heart, + tone: 'like', + }, + { + label: '最近更新', + value: formatPlatformWorldTime(stats.updatedAt ?? stats.publishedAt), + icon: Clock3, + tone: 'time', + isTime: true, }, ]; @@ -162,10 +200,26 @@ export function PlatformWorkDetailView({
- {entry.worldName} + {displayName}
- {entry.authorDisplayName} + + {normalizedAuthorAvatarUrl ? ( + + + {entry.authorDisplayName} +
{statItems.map((item) => ( -
-
- {item.label} +
+
+ + + + + {item.label} +
-
- {item.value} +
+ + {item.value} + + {item.unit ? ( + + {item.unit} + + ) : null}
))} diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index 62a7575e..1e102493 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -36,8 +36,8 @@ export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) { export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [ { id: 'rpg', - title: '角色扮演 RPG', - subtitle: 'Agent 共创', + title: '角色扮演', + subtitle: '剧情演绎,冒险成长', badge: '可创建', locked: false, }, @@ -51,8 +51,8 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [ }, { id: 'puzzle', - title: '拼图玩法', - subtitle: '图像锚点共创', + title: '拼图', + subtitle: '创意礼物,生活分享', badge: '可创建', locked: false, }, @@ -60,14 +60,14 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [ id: 'airp', title: 'AIRP', subtitle: '敬请期待', - badge: '锁定', + badge: '敬请期待', locked: true, }, { id: 'visual-novel', title: '视觉小说', subtitle: '敬请期待', - badge: '锁定', + badge: '敬请期待', locked: true, }, ]; diff --git a/src/components/platform-entry/usePlatformCreationAgentFlowController.ts b/src/components/platform-entry/usePlatformCreationAgentFlowController.ts index e2915a40..71fe3052 100644 --- a/src/components/platform-entry/usePlatformCreationAgentFlowController.ts +++ b/src/components/platform-entry/usePlatformCreationAgentFlowController.ts @@ -131,9 +131,9 @@ export function usePlatformCreationAgentFlowController< const [streamingReplyText, setStreamingReplyText] = useState(''); const [isStreamingReply, setIsStreamingReply] = useState(false); - const openWorkspace = useCallback(async () => { + const openWorkspace = useCallback(async (createPayload?: TCreatePayload) => { if (isBusy) { - return; + return null; } setIsBusy(true); @@ -142,15 +142,20 @@ export function usePlatformCreationAgentFlowController< setIsStreamingReply(false); try { - const response = await options.client.createSession(options.createPayload); - setSession(options.client.selectSession(response)); + const response = await options.client.createSession( + createPayload ?? options.createPayload, + ); + const nextSession = options.client.selectSession(response); + setSession(nextSession); options.enterCreateTab(); options.onSessionOpened?.(); options.setSelectionStage(options.workspaceStage); + return nextSession; } catch (caughtError) { setError( options.resolveErrorMessage(caughtError, options.errorMessages.open), ); + return null; } finally { setIsBusy(false); } @@ -235,8 +240,9 @@ export function usePlatformCreationAgentFlowController< ); const executeAction = useCallback( - async (payload: TActionPayload) => { - if (!session || isBusy) { + async (payload: TActionPayload, sessionOverride?: TSession | null) => { + const targetSession = sessionOverride ?? session; + if (!targetSession || isBusy) { return; } @@ -244,15 +250,15 @@ export function usePlatformCreationAgentFlowController< setError(null); try { - options.beforeExecuteAction?.({ payload, session }); + options.beforeExecuteAction?.({ payload, session: targetSession }); const response = await options.client.executeAction( - session.sessionId, + targetSession.sessionId, payload, ); await options.onActionComplete?.({ payload, response, - session, + session: targetSession, setSession, }); if (options.isCompileAction(payload)) { diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index bd029559..b4c49c1e 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -1,7 +1,6 @@ -/* @vitest-environment jsdom */ +/* @vitest-environment jsdom */ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, expect, test, vi } from 'vitest'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; @@ -50,11 +49,11 @@ const baseSession: PuzzleAgentSessionSnapshot = { id: 'message-1', role: 'assistant', kind: 'chat', - text: '画面主体已经清楚,继续收束剩余关键词。', + text: '旧会话消息不再渲染为聊天入口。', createdAt: '2026-04-24T10:00:00.000Z', }, ], - lastAssistantReply: '画面主体已经清楚,继续收束剩余关键词。', + lastAssistantReply: '旧会话消息不再渲染为聊天入口。', publishedProfileId: null, suggestedActions: [], resultPreview: null, @@ -67,64 +66,54 @@ beforeEach(() => { } }); -test('puzzle workspace submits quick keyword fill request after two turns', async () => { - const user = userEvent.setup(); - const onSubmitMessage = vi.fn(); +test('puzzle workspace submits the two-field form instead of agent chat', () => { + const onCreateFromForm = vi.fn(); + + render( + {}} + onSubmitMessage={() => {}} + onExecuteAction={() => {}} + onCreateFromForm={onCreateFromForm} + />, + ); + + fireEvent.change(screen.getByLabelText('拼图标题'), { + target: { value: '暖灯猫街' }, + }); + fireEvent.change(screen.getByLabelText('画面描述'), { + target: { value: '一只猫在雨夜灯牌下回头。' }, + }); + fireEvent.click(screen.getByRole('button', { name: /生成草稿/u })); + + expect(onCreateFromForm).toHaveBeenCalledWith({ + seedText: '暖灯猫街', + pictureDescription: '一只猫在雨夜灯牌下回头。', + referenceImageSrc: null, + }); + expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull(); + expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull(); +}); + +test('puzzle workspace falls back to compile action for restored sessions', () => { + const onExecuteAction = vi.fn(); render( {}} - onSubmitMessage={onSubmitMessage} - onExecuteAction={() => {}} - />, - ); - - await user.click(screen.getByRole('button', { name: '补充剩余设定' })); - - expect(onSubmitMessage).toHaveBeenCalledWith( - expect.objectContaining({ - text: '请补充剩余设定。', - quickFillRequested: true, - }), - ); -}); - -test('puzzle workspace hides keyword fill before two turns', () => { - render( - {}} onSubmitMessage={() => {}} - onExecuteAction={() => {}} + onExecuteAction={onExecuteAction} />, ); - expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull(); -}); + fireEvent.click(screen.getByRole('button', { name: /生成草稿/u })); -test('puzzle workspace does not render progress action messages as chat bubbles', () => { - render( - {}} - onSubmitMessage={() => {}} - onExecuteAction={() => {}} - />, - ); - - expect(screen.getByText('画面主体已经清楚,继续收束剩余关键词。')).toBeTruthy(); - expect(screen.queryByText('拼图结果页草稿已生成。')).toBeNull(); + expect(onExecuteAction).toHaveBeenCalledWith({ + action: 'compile_puzzle_draft', + promptText: '潮雾中的灯塔与断桥', + referenceImageSrc: null, + candidateCount: 1, + }); }); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index e607be7b..6ee818e4 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -1,146 +1,299 @@ +import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react'; +import { type ChangeEvent, useEffect, useState } from 'react'; + +import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; import type { - PuzzleAgentActionRequest, - PuzzleAgentOperationRecord, -} from '../../../packages/shared/src/contracts/puzzleAgentActions'; -import type { + CreatePuzzleAgentSessionRequest, PuzzleAgentSessionSnapshot, SendPuzzleAgentMessageRequest, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; -import { - buildCreationAgentChatMessage, - createCreationAgentChatQuickActions, - createCreationAgentClientMessageId, - resolveCreationAgentQuickActionMessage, -} from '../../services/creation-agent'; -import { - type CreationAgentOperationView, - type CreationAgentSessionView, - type CreationAgentTheme, - CreationAgentWorkspace, -} from '../creation-agent'; type PuzzleAgentWorkspaceProps = { session: PuzzleAgentSessionSnapshot | null; - activeOperation?: PuzzleAgentOperationRecord | null; - streamingReplyText?: string; - isStreamingReply?: boolean; isBusy?: boolean; error?: string | null; onBack: () => void; onSubmitMessage: (payload: SendPuzzleAgentMessageRequest) => void; onExecuteAction: (payload: PuzzleAgentActionRequest) => void; + onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void; + initialFormPayload?: CreatePuzzleAgentSessionRequest | null; }; -const PUZZLE_AGENT_THEME: CreationAgentTheme = { - accentTextClass: 'text-amber-100/84', - accentBgClass: 'bg-amber-200', - accentButtonClass: 'bg-amber-200 shadow-amber-950/20', - userBubbleClass: 'bg-amber-600 text-white', - heroClass: - 'border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.96),rgba(20,24,35,0.96))]', - anchorGridClass: 'grid gap-2 sm:grid-cols-2 xl:grid-cols-5', +type PuzzleFormState = { + title: string; + pictureDescription: string; + referenceImageSrc: string; + referenceImageLabel: string; }; -function mapPuzzleSession( - session: PuzzleAgentSessionSnapshot, -): CreationAgentSessionView { - // 中文注释:生成进度与草稿写回记录不属于聊天历史,旧会话里的 action_result 也不再渲染为气泡。 - const chatMessages = session.messages.filter( - (message) => - message.kind === 'chat' || - message.kind === 'summary' || - message.kind === 'warning', - ); +const EMPTY_FORM_STATE: PuzzleFormState = { + title: '', + pictureDescription: '', + referenceImageSrc: '', + referenceImageLabel: '', +}; - return { - sessionId: session.sessionId, - // 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。 - title: null, - assistantSummary: null, - currentTurn: session.currentTurn, - progressPercent: session.progressPercent, - anchors: [ - session.anchorPack.themePromise, - session.anchorPack.visualSubject, - session.anchorPack.visualMood, - session.anchorPack.compositionHooks, - session.anchorPack.tagsAndForbidden, - ], - messages: chatMessages, - recommendedReplies: [], - }; +function readPuzzleReferenceImageAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('参考图读取失败,请重试。')); + reader.onload = () => resolve(String(reader.result || '')); + reader.readAsDataURL(file); + }); } -function mapPuzzleOperation( - operation: PuzzleAgentOperationRecord | null | undefined, -): CreationAgentOperationView | null { - if (!operation) { - return null; +function resolveInitialFormState( + session: PuzzleAgentSessionSnapshot | null, + initialFormPayload: CreatePuzzleAgentSessionRequest | null = null, +): PuzzleFormState { + if (initialFormPayload) { + return { + title: initialFormPayload.seedText ?? '', + pictureDescription: initialFormPayload.pictureDescription ?? '', + referenceImageSrc: initialFormPayload.referenceImageSrc ?? '', + referenceImageLabel: initialFormPayload.referenceImageSrc + ? '已选择参考图' + : '', + }; + } + + if (!session) { + return EMPTY_FORM_STATE; } return { - operationId: operation.operationId, - type: operation.type, - status: operation.status, - phaseLabel: operation.phaseLabel, - phaseDetail: operation.phaseDetail, - progress: operation.progress, - error: operation.error, + title: + session.draft?.levelName || + session.anchorPack.themePromise.value || + session.messages.find((message) => message.role === 'user')?.text || + '', + pictureDescription: + session.draft?.summary || session.anchorPack.visualSubject.value || '', + referenceImageSrc: '', + referenceImageLabel: '', }; } /** - * 拼图 Agent 共创工作区只保留品类适配,聊天 UI 与进度管理统一走 CreationAgentWorkspace。 + * 拼图创作入口已从 Agent 对话改为填表式。 + * 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。 */ export function PuzzleAgentWorkspace({ session, - activeOperation = null, - streamingReplyText = '', - isStreamingReply = false, isBusy = false, error = null, onBack, - onSubmitMessage, onExecuteAction, + onCreateFromForm, + initialFormPayload = null, }: PuzzleAgentWorkspaceProps) { + const [formState, setFormState] = useState(() => + resolveInitialFormState(session, initialFormPayload), + ); + const [referenceImageError, setReferenceImageError] = useState( + null, + ); + + useEffect(() => { + setFormState(resolveInitialFormState(session, initialFormPayload)); + setReferenceImageError(null); + }, [initialFormPayload, session]); + + const title = formState.title.trim(); + const pictureDescription = formState.pictureDescription.trim(); + const canSubmit = Boolean(title && pictureDescription) && !isBusy; + + const handleReferenceImageChange = async ( + event: ChangeEvent, + ) => { + const file = event.target.files?.[0]; + event.currentTarget.value = ''; + if (!file) { + return; + } + + try { + const dataUrl = await readPuzzleReferenceImageAsDataUrl(file); + setFormState((current) => ({ + ...current, + referenceImageSrc: dataUrl, + referenceImageLabel: file.name.trim() || '本地参考图', + })); + setReferenceImageError(null); + } catch (uploadError) { + setReferenceImageError( + uploadError instanceof Error + ? uploadError.message + : '参考图读取失败,请重试。', + ); + } + }; + + const submitForm = () => { + if (!canSubmit) { + return; + } + + const payload = { + seedText: title, + pictureDescription, + referenceImageSrc: formState.referenceImageSrc || null, + }; + + if (onCreateFromForm) { + onCreateFromForm(payload); + return; + } + + onExecuteAction({ + action: 'compile_puzzle_draft', + promptText: pictureDescription, + referenceImageSrc: formState.referenceImageSrc || null, + candidateCount: 1, + }); + }; + return ( - { - onSubmitMessage( - buildCreationAgentChatMessage({ - clientMessageId: createCreationAgentClientMessageId('puzzle'), - text, - }), - ); - }} - onPrimaryAction={() => { - onExecuteAction({ action: 'compile_puzzle_draft' }); - }} - onQuickAction={(action) => { - const quickActionMessage = resolveCreationAgentQuickActionMessage( - action.key, - '请总结一下当前已经成形的拼图设定。', - ); - onSubmitMessage( - buildCreationAgentChatMessage({ - clientMessageId: createCreationAgentClientMessageId('puzzle'), - ...quickActionMessage, - }), - ); - }} - /> +
+
+ +
+ +
+
+
+ + +