This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -6,7 +6,7 @@
当前自定义世界创作工具已经有了比较强的生成骨架、锚点结构和结果编辑能力,但整体仍处在一个很明显的“半收口状态”: 当前自定义世界创作工具已经有了比较强的生成骨架、锚点结构和结果编辑能力,但整体仍处在一个很明显的“半收口状态”:
**设计目标已经走到“创作者工作台”,数据结构已经支持“锚点化输入”,但实际体验仍然更像“大文本生成器 + 大型结果总表编辑器”。** **设计目标已经走到“陶泥主工作台”,数据结构已经支持“锚点化输入”,但实际体验仍然更像“大文本生成器 + 大型结果总表编辑器”。**
如果用一句话概括当前问题,就是: 如果用一句话概括当前问题,就是:
@@ -61,7 +61,7 @@
- 标志性要素 - 标志性要素
- 禁止事项 - 禁止事项
但实际入口 `src/components/SelectionCustomizationModals.tsx` 里,创作者弹窗仍然基本只有: 但实际入口 `src/components/SelectionCustomizationModals.tsx` 里,陶泥主弹窗仍然基本只有:
- 生成模式 - 生成模式
- 一块大 textarea - 一块大 textarea
@@ -82,7 +82,7 @@
--- ---
## 2.2 澄清机制已经存在,但没有真正服务创作者 ## 2.2 澄清机制已经存在,但没有真正服务陶泥主
`server-node/src/services/customWorldSessionStore.ts` 已经支持: `server-node/src/services/customWorldSessionStore.ts` 已经支持:
@@ -101,7 +101,7 @@
这意味着: 这意味着:
**系统表面上已经有“先澄清再生成”的能力,但实际体验里,创作者并没有真正参与这一步。** **系统表面上已经有“先澄清再生成”的能力,但实际体验里,陶泥主并没有真正参与这一步。**
结果就是: 结果就是:
@@ -113,7 +113,7 @@
- 把 session question 真正接到前端,作为生成前的二次确认步骤。 - 把 session question 真正接到前端,作为生成前的二次确认步骤。
- 每次只问 `1~3` 个最关键问题,不要把它做成问卷。 - 每次只问 `1~3` 个最关键问题,不要把它做成问卷。
- 支持“一键使用系统建议”,但必须让创作者可见,而不是静默自动填充。 - 支持“一键使用系统建议”,但必须让陶泥主可见,而不是静默自动填充。
- 把回答结果回写到 `creatorIntent`,而不是只作为一次性会话答案。 - 把回答结果回写到 `creatorIntent`,而不是只作为一次性会话答案。
--- ---
@@ -175,7 +175,7 @@
这会带来三层问题: 这会带来三层问题:
1. 创作者负担过重 1. 陶泥主负担过重
- 很多字段属于“系统编译层”,不属于“创作决策层”。 - 很多字段属于“系统编译层”,不属于“创作决策层”。
2. 移动端负担过重 2. 移动端负担过重
@@ -240,7 +240,7 @@
--- ---
## 2.6 快速模式还不够“快”,生成页也还不够“创作者视角” ## 2.6 快速模式还不够“快”,生成页也还不够“陶泥主视角”
当前快速模式的主要区别,是把数量降成: 当前快速模式的主要区别,是把数量降成:
@@ -269,7 +269,7 @@
- 计时 - 计时
- 模型阶段 - 模型阶段
而不是创作者真正关心的: 而不是陶泥主真正关心的:
- 关键角色有没有成型 - 关键角色有没有成型
- 核心冲突有没有稳定 - 核心冲突有没有稳定
@@ -281,7 +281,7 @@
- 快速模式改成真正的“关键锚点预览模式”: - 快速模式改成真正的“关键锚点预览模式”:
- 先只生成关键角色、关键地点、核心冲突摘要 - 先只生成关键角色、关键地点、核心冲突摘要
- 暂不补全所有长尾档案 - 暂不补全所有长尾档案
- 生成页改成“创作者视角进度”: - 生成页改成“陶泥主视角进度”:
- 世界灵魂已确定 - 世界灵魂已确定
- 关键角色已成型 - 关键角色已成型
- 关键地点已落地 - 关键地点已落地
@@ -391,16 +391,16 @@
### P0先修主链路闭环 ### P0先修主链路闭环
- 补卡片化输入入口,至少把关键锚点输入真正开放出来。 - 补卡片化输入入口,至少把关键锚点输入真正开放出来。
- 把澄清问题正式接入创作者流程,不再静默自动兜底。 - 把澄清问题正式接入陶泥主流程,不再静默自动兜底。
- 修正“新建完成后直接回世界列表”的流程,生成后默认进入结果工作台。 - 修正“新建完成后直接回世界列表”的流程,生成后默认进入结果工作台。
- 统一锁定与局部重生成规则,先让“创作者不怕重生成”成立。 - 统一锁定与局部重生成规则,先让“陶泥主不怕重生成”成立。
### P1再降低工作台负担 ### P1再降低工作台负担
- 结果页默认只展示高杠杆编辑。 - 结果页默认只展示高杠杆编辑。
- 低杠杆字段进入高级模式。 - 低杠杆字段进入高级模式。
- 快速模式改成真正的关键对象预览模式。 - 快速模式改成真正的关键对象预览模式。
- 生成页改成创作者视角进度,而不是模型批次视角。 - 生成页改成陶泥主视角进度,而不是模型批次视角。
### P2最后做架构收口与去模板化 ### P2最后做架构收口与去模板化
@@ -441,4 +441,4 @@
当前自定义世界创作工具最需要的,不是再继续补更多字段或更多生成步骤,而是: 当前自定义世界创作工具最需要的,不是再继续补更多字段或更多生成步骤,而是:
**把“创作者先决定灵魂锚点,系统再稳定展开世界”这条主逻辑真正落到 UI、流程和后端边界上。** **把“陶泥主先决定灵魂锚点,系统再稳定展开世界”这条主逻辑真正落到 UI、流程和后端边界上。**

View File

@@ -1,4 +1,4 @@
# 自定义世界创作者输入与 AI 分工边界设计 # 自定义世界陶泥主输入与 AI 分工边界设计
更新时间:`2026-04-06` 更新时间:`2026-04-06`
@@ -6,9 +6,9 @@
这份文档回答一个非常关键的问题: 这份文档回答一个非常关键的问题:
**在“低创作门槛、高创作自由度”的前提下,自定义世界里哪些内容应该交给创作者直接定义,哪些内容应该交给 AI 和系统完成。** **在“低创作门槛、高创作自由度”的前提下,自定义世界里哪些内容应该交给陶泥主直接定义,哪些内容应该交给 AI 和系统完成。**
这里默认我们的创作者 这里默认我们的陶泥主
- 不需要有专业作家背景 - 不需要有专业作家背景
- 不需要有专业游戏设计背景 - 不需要有专业游戏设计背景
@@ -16,33 +16,33 @@
一句话目标: 一句话目标:
**让创作者把精力放在“决定这个世界为什么值得被创作”,把 AI 用在“把这个世界展开、编译、铺开、校验、补足”。** **让陶泥主把精力放在“决定这个世界为什么值得被创作”,把 AI 用在“把这个世界展开、编译、铺开、校验、补足”。**
## 1. 总体结论 ## 1. 总体结论
自定义世界的分工边界应该遵守 3 条硬原则: 自定义世界的分工边界应该遵守 3 条硬原则:
1. 灵魂归创作者,杂活归 AI。 1. 灵魂归陶泥主,杂活归 AI。
- 凡是决定作品气质、主题、冲突、人物关系、审美方向的内容,都应由创作者掌握。 - 凡是决定作品气质、主题、冲突、人物关系、审美方向的内容,都应由陶泥主掌握。
2. 重点对象归创作者,长尾铺量归 AI。 2. 重点对象归陶泥主,长尾铺量归 AI。
- 创作者应重点塑造少量关键角色、关键地点、关键冲突、关键意象,而不是被迫手填几十个 NPC、几十个场景、几百条描述。 - 陶泥主应重点塑造少量关键角色、关键地点、关键冲突、关键意象,而不是被迫手填几十个 NPC、几十个场景、几百条描述。
3. 决策归创作者,编译归 AI / 系统。 3. 决策归陶泥主,编译归 AI / 系统。
- 创作者负责说“这个世界要成为什么样”AI / 系统负责把它编译成可运行的数据、规则、文本、关系钩子和运行时结构。 - 陶泥主负责说“这个世界要成为什么样”AI / 系统负责把它编译成可运行的数据、规则、文本、关系钩子和运行时结构。
这意味着: 这意味着:
- 创作者应该主要编辑“高杠杆创作锚点” - 陶泥主应该主要编辑“高杠杆创作锚点”
- AI 应该主要承担“批量展开 + 结构编译 + 一致性维护 + 专业执行” - AI 应该主要承担“批量展开 + 结构编译 + 一致性维护 + 专业执行”
## 2. 什么内容应该交给创作者 ## 2. 什么内容应该交给陶泥主
真正应该交给创作者的,不是大量表格字段,而是下面这些会显著决定作品质量、且 AI 不擅长替代的内容。 真正应该交给陶泥主的,不是大量表格字段,而是下面这些会显著决定作品质量、且 AI 不擅长替代的内容。
## 2.1 世界核心命题 ## 2.1 世界核心命题
创作者应该直接定义: 陶泥主应该直接定义:
- 这个世界的一句话设定 - 这个世界的一句话设定
- 这个世界最吸引人的核心幻想 - 这个世界最吸引人的核心幻想
@@ -56,7 +56,7 @@
## 2.2 主题、气质与边界 ## 2.2 主题、气质与边界
创作者应该直接定义: 陶泥主应该直接定义:
- 主题关键词 - 主题关键词
- 情绪基调 - 情绪基调
@@ -71,7 +71,7 @@
## 2.3 玩家身份与开局处境 ## 2.3 玩家身份与开局处境
创作者应该直接定义: 陶泥主应该直接定义:
- 玩家扮演的是什么人 - 玩家扮演的是什么人
- 玩家一开始最缺什么、最想要什么 - 玩家一开始最缺什么、最想要什么
@@ -85,7 +85,7 @@
## 2.4 核心冲突与关键势力 ## 2.4 核心冲突与关键势力
创作者应该直接定义少量高价值内容: 陶泥主应该直接定义少量高价值内容:
- 世界当前最重要的 `2~4` 条明面冲突 - 世界当前最重要的 `2~4` 条明面冲突
- 世界背后最关键的 `1~3` 条暗面问题 - 世界背后最关键的 `1~3` 条暗面问题
@@ -96,13 +96,13 @@
- 冲突结构决定世界是否“有戏” - 冲突结构决定世界是否“有戏”
- 势力关系是 AI 最容易写散、写平、写成百科介绍的部分 - 势力关系是 AI 最容易写散、写平、写成百科介绍的部分
- 这一层由创作者把握,才能真正提高作品的辨识度 - 这一层由陶泥主把握,才能真正提高作品的辨识度
## 2.5 关键角色与关系张力 ## 2.5 关键角色与关系张力
创作者应该直接定义少量关键角色,而不是所有 NPC。 陶泥主应该直接定义少量关键角色,而不是所有 NPC。
建议重点交给创作者的,是: 建议重点交给陶泥主的,是:
- `3~8` 个关键角色 - `3~8` 个关键角色
- 玩家与这些人的潜在关系 - 玩家与这些人的潜在关系
@@ -113,11 +113,11 @@
- 角色关系是最能显著提升作品质量的部分之一 - 角色关系是最能显著提升作品质量的部分之一
- 这也是 AI 最容易写得“完整但无味”的部分 - 这也是 AI 最容易写得“完整但无味”的部分
- 创作者不需要写长篇背景,但应掌握这些角色真正的关系骨架 - 陶泥主不需要写长篇背景,但应掌握这些角色真正的关系骨架
## 2.6 关键地点与空间记忆点 ## 2.6 关键地点与空间记忆点
创作者应该直接定义: 陶泥主应该直接定义:
- `4~12` 个关键地点 / 区域 / 地标 - `4~12` 个关键地点 / 区域 / 地标
- 这些地方为什么重要 - 这些地方为什么重要
@@ -131,7 +131,7 @@
## 2.7 标志性意象、物件、怪物、制度与规则 ## 2.7 标志性意象、物件、怪物、制度与规则
创作者应该优先控制世界里最能代表它的东西: 陶泥主应该优先控制世界里最能代表它的东西:
- 标志性物件 - 标志性物件
- 标志性怪物 / 生物 - 标志性怪物 / 生物
@@ -144,9 +144,9 @@
- 这些内容决定世界的“手感” - 这些内容决定世界的“手感”
- 它们不是普通细节,而是会反复影响命名、剧情、视觉、对话与玩法解释的母题 - 它们不是普通细节,而是会反复影响命名、剧情、视觉、对话与玩法解释的母题
## 2.8 创作者应直接控制的“禁止事项” ## 2.8 陶泥主应直接控制的“禁止事项”
创作者必须能明确锁定: 陶泥主必须能明确锁定:
- 什么绝对不能改 - 什么绝对不能改
- 什么不能被 AI 自动扩写到别的方向 - 什么不能被 AI 自动扩写到别的方向
@@ -156,7 +156,7 @@
原因: 原因:
- 高自由度不等于所有内容都开放漂移 - 高自由度不等于所有内容都开放漂移
- 如果没有“锁定机制”AI 会把创作者真正关心的内容稀释掉 - 如果没有“锁定机制”AI 会把陶泥主真正关心的内容稀释掉
## 3. 什么内容应该交给 AI 和系统 ## 3. 什么内容应该交给 AI 和系统
@@ -176,7 +176,7 @@
原因: 原因:
- 这些内容数量大、重复度高 - 这些内容数量大、重复度高
- 它们需要“贴合世界”,但不需要都由创作者逐个手写 - 它们需要“贴合世界”,但不需要都由陶泥主逐个手写
- AI 很适合做“围绕锚点的批量铺量” - AI 很适合做“围绕锚点的批量铺量”
## 3.2 从创作锚点到系统结构的编译 ## 3.2 从创作锚点到系统结构的编译
@@ -186,7 +186,7 @@
- 从自然语言世界设定中提取题材词汇 - 从自然语言世界设定中提取题材词汇
- 从关键冲突中编译出世界叙事图谱 - 从关键冲突中编译出世界叙事图谱
- 从关键角色卡编译出角色叙事档案 - 从关键角色卡编译出角色叙事档案
-创作者输入里自动生成标签、钩子、隐藏线索、章节摘要 -陶泥主输入里自动生成标签、钩子、隐藏线索、章节摘要
- 从地点和关系中编译出场景连接、事件触发和叙事回响 - 从地点和关系中编译出场景连接、事件触发和叙事回响
对应当前仓库,下面这些结构更适合由 AI / 系统生成,而不是让玩家直接编辑: 对应当前仓库,下面这些结构更适合由 AI / 系统生成,而不是让玩家直接编辑:
@@ -203,7 +203,7 @@
原因: 原因:
- 这些是运行时结构,不是创作者真正想表达的作品内容 - 这些是运行时结构,不是陶泥主真正想表达的作品内容
- 直接暴露给玩家,会把创作过程变成专业数据填表 - 直接暴露给玩家,会把创作过程变成专业数据填表
## 3.3 专业化、规则化的任务 ## 3.3 专业化、规则化的任务
@@ -223,7 +223,7 @@
原因: 原因:
- 这些工作要么重复、要么专业、要么容易做脏活累活 - 这些工作要么重复、要么专业、要么容易做脏活累活
- 让非专业创作者处理,会显著提高门槛,却不一定显著提高质量 - 让非专业陶泥主处理,会显著提高门槛,却不一定显著提高质量
## 3.4 一致性、纠错与查漏补缺 ## 3.4 一致性、纠错与查漏补缺
@@ -240,15 +240,15 @@
原因: 原因:
- 这是 AI 比人更适合做的“维护型工作” - 这是 AI 比人更适合做的“维护型工作”
- 它属于创作支持,不属于创作者必须亲手完成的创作 - 它属于创作支持,不属于陶泥主必须亲手完成的创作
## 4. 最合理的边界不是二分法,而是三层分工 ## 4. 最合理的边界不是二分法,而是三层分工
自定义世界最合理的结构不是“玩家写”与“AI 写”的简单二选一,而是三层。 自定义世界最合理的结构不是“玩家写”与“AI 写”的简单二选一,而是三层。
## 4.1 第一层:创作者必控层 ## 4.1 第一层:陶泥主必控层
这一层必须给创作者高自由度,且能被锁定: 这一层必须给陶泥主高自由度,且能被锁定:
- 世界核心命题 - 世界核心命题
- 主题与气质 - 主题与气质
@@ -264,9 +264,9 @@
**少而重。** **少而重。**
## 4.2 第二层:创作者可选强化层 ## 4.2 第二层:陶泥主可选强化层
这一层不应强制填写,但应该允许创作者继续深挖: 这一层不应强制填写,但应该允许陶泥主继续深挖:
- 明线 / 暗线种子 - 明线 / 暗线种子
- 角色之间的旧事 - 角色之间的旧事
@@ -301,17 +301,17 @@
## 5. 具体模块的建议归属 ## 5. 具体模块的建议归属
| 模块 | 建议归属 | 创作者应控制什么 | AI / 系统应负责什么 | | 模块 | 建议归属 | 陶泥主应控制什么 | AI / 系统应负责什么 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 世界一句话设定、核心幻想、核心卖点 | 创作者直接控制 | 直接写、直接改、可锁定 | 给出备选表述和扩展方向 | | 世界一句话设定、核心幻想、核心卖点 | 陶泥主直接控制 | 直接写、直接改、可锁定 | 给出备选表述和扩展方向 |
| 主题、基调、审美、禁忌 | 创作者直接控制 | 选择 / 改写 / 锁定 | 生成风格词、避雷词、提示词约束 | | 主题、基调、审美、禁忌 | 陶泥主直接控制 | 选择 / 改写 / 锁定 | 生成风格词、避雷词、提示词约束 |
| 玩家身份、开局处境、玩家目标 | 创作者直接控制 | 直接定义 | 补足开局钩子和初始叙事包装 | | 玩家身份、开局处境、玩家目标 | 陶泥主直接控制 | 直接定义 | 补足开局钩子和初始叙事包装 |
| 关键势力与核心冲突 | 创作者主控AI 辅助 | 定义核心关系和立场 | 扩展冲突支路、生成世界线程 | | 关键势力与核心冲突 | 陶泥主控AI 辅助 | 定义核心关系和立场 | 扩展冲突支路、生成世界线程 |
| 关键角色 | 创作者主控AI 辅助 | 定义角色骨架、关系张力、秘密方向 | 生成长背景、章节拆分、技能、物品、叙事档案 | | 关键角色 | 陶泥主控AI 辅助 | 定义角色骨架、关系张力、秘密方向 | 生成长背景、章节拆分、技能、物品、叙事档案 |
| 关键地点 | 创作者主控AI 辅助 | 定义地点意义、气氛、秘密 | 扩展场景细节、连接关系、遭遇分布 | | 关键地点 | 陶泥主控AI 辅助 | 定义地点意义、气氛、秘密 | 扩展场景细节、连接关系、遭遇分布 |
| 标志性物件 / 怪物 / 制度 / 规则 | 创作者主控AI 辅助 | 定义代表性要素与硬边界 | 扩展变体、命名、说明、运行时挂钩 | | 标志性物件 / 怪物 / 制度 / 规则 | 陶泥主控AI 辅助 | 定义代表性要素与硬边界 | 扩展变体、命名、说明、运行时挂钩 |
| 普通 NPC / 路人 / 杂兵 / 次级地点 | 主要交给 AI | 仅在需要时抽查或替换 | 批量生成与风格保持 | | 普通 NPC / 路人 / 杂兵 / 次级地点 | 主要交给 AI | 仅在需要时抽查或替换 | 批量生成与风格保持 |
| 角色长背景、章节 teaser、context snippet | 主要交给 AI | 创作者只改关键角色即可 | 自动拆章、压缩、解锁节奏整理 | | 角色长背景、章节 teaser、context snippet | 主要交给 AI | 陶泥主只改关键角色即可 | 自动拆章、压缩、解锁节奏整理 |
| 技能、初始物品、标签、构筑倾向 | 主要交给 AI / 系统 | 提供偏好或少量 override | 按角色和世界规则自动编译 | | 技能、初始物品、标签、构筑倾向 | 主要交给 AI / 系统 | 提供偏好或少量 override | 按角色和世界规则自动编译 |
| 世界图谱、知识事实、可见性、导演指令 | AI / 系统内部层 | 不应默认暴露给玩家 | 运行时编译与维护 | | 世界图谱、知识事实、可见性、导演指令 | AI / 系统内部层 | 不应默认暴露给玩家 | 运行时编译与维护 |
| 一致性检查、冲突检查、越权检查 | AI / 系统内部层 | 查看报告、决定是否采纳修改 | 自动扫描并提出修正建议 | | 一致性检查、冲突检查、越权检查 | AI / 系统内部层 | 查看报告、决定是否采纳修改 | 自动扫描并提出修正建议 |
@@ -328,7 +328,7 @@
- 精确数值型 build 倾向 - 精确数值型 build 倾向
- 复杂掉落预算 - 复杂掉落预算
更合理的做法是让创作者填写直觉表达,例如: 更合理的做法是让陶泥主填写直觉表达,例如:
- `初见就戒备` - `初见就戒备`
- `容易合作` - `容易合作`
@@ -351,7 +351,7 @@
原因: 原因:
- 这些字段属于系统运行结构,不属于创作者自然的创作语言 - 这些字段属于系统运行结构,不属于陶泥主自然的创作语言
- 直接让玩家填,会把工具变成只有懂系统的人才能用 - 直接让玩家填,会把工具变成只有懂系统的人才能用
## 6.3 不应该要求玩家逐个补完所有人物设定字段 ## 6.3 不应该要求玩家逐个补完所有人物设定字段
@@ -378,7 +378,7 @@
## 7. 推荐的创作输入形态 ## 7. 推荐的创作输入形态
要让非专业创作者也能高自由度创作,输入形态必须改成“自然语言创作卡”,而不是“系统字段表单”。 要让非专业陶泥主也能高自由度创作,输入形态必须改成“自然语言创作卡”,而不是“系统字段表单”。
## 7.1 世界层卡片 ## 7.1 世界层卡片
@@ -397,10 +397,10 @@
## 7.2 每张卡片都允许 3 种输入方式 ## 7.2 每张卡片都允许 3 种输入方式
1. 一句话自由输入 1. 一句话自由输入
- 适合低门槛创作者 - 适合低门槛陶泥主
2. 标签 / 选项 / 语气滑条 2. 标签 / 选项 / 语气滑条
- 适合不想写太多字的创作者 - 适合不想写太多字的陶泥主
3. 高级补充 3. 高级补充
- 适合愿意继续深挖的人 - 适合愿意继续深挖的人
@@ -414,7 +414,7 @@
这是高创作自由度里非常关键的一点。 这是高创作自由度里非常关键的一点。
创作者应当能: 陶泥主应当能:
- 锁定一个角色 - 锁定一个角色
- 锁定一个地点 - 锁定一个地点
@@ -422,13 +422,13 @@
- 只重生成未锁定部分 - 只重生成未锁定部分
- 围绕锁定内容重写其余世界 - 围绕锁定内容重写其余世界
否则创作者每次调用 AI都会有“好不容易想好的东西被洗掉”的感受。 否则陶泥主每次调用 AI都会有“好不容易想好的东西被洗掉”的感受。
## 8. 面向当前仓库的结构映射建议 ## 8. 面向当前仓库的结构映射建议
为了便于后续落实现有系统,这份边界建议可以直接映射到当前结构: 为了便于后续落实现有系统,这份边界建议可以直接映射到当前结构:
## 8.1 创作者输入层 ## 8.1 陶泥主输入层
建议主要映射到: 建议主要映射到:
@@ -445,7 +445,7 @@
## 8.2 AI 编译层 ## 8.2 AI 编译层
由 AI / 系统从创作者输入自动补出: 由 AI / 系统从陶泥主输入自动补出:
- `themePack` - `themePack`
- `storyGraph` - `storyGraph`
@@ -465,7 +465,7 @@
- `CarrierStoryFingerprint` - `CarrierStoryFingerprint`
- `StorySignal` - `StorySignal`
这些内容应该是“系统如何把世界跑起来”,不是“创作者必须亲手写完的创作内容”。 这些内容应该是“系统如何把世界跑起来”,不是“陶泥主必须亲手写完的创作内容”。
## 9. 产品层面的最终结论 ## 9. 产品层面的最终结论
@@ -480,12 +480,12 @@
它应该做成这样: 它应该做成这样:
1. 创作者决定世界的灵魂锚点。 1. 陶泥主决定世界的灵魂锚点。
2. 创作者重点塑造少量关键人、关键地、关键冲突、关键物。 2. 陶泥主重点塑造少量关键人、关键地、关键冲突、关键物。
3. AI 围绕这些锚点批量展开长尾内容。 3. AI 围绕这些锚点批量展开长尾内容。
4. 系统把这些内容编译成可运行的图谱、可见性、任务、物件和关系结构。 4. 系统把这些内容编译成可运行的图谱、可见性、任务、物件和关系结构。
5. 创作者随时可以锁定核心创意,并局部重生成其余部分。 5. 陶泥主随时可以锁定核心创意,并局部重生成其余部分。
一句话收束: 一句话收束:
**创作者应该写“这个世界为什么动人”AI 应该负责“让这个世界长出来并跑起来”。** **陶泥主应该写“这个世界为什么动人”AI 应该负责“让这个世界长出来并跑起来”。**

View File

@@ -6,17 +6,17 @@
这份文档用于回答一个更具体的问题: 这份文档用于回答一个更具体的问题:
**参考 RPG 专业剧情策划全流程后,在自定义世界创作工具里,哪些设定必须要求创作者手动填写,哪些设定应该由 AI 先生成但允许创作者修改,哪些设定应完全交给系统托管,才能在“尽可能降低门槛”和“尽可能提高作品质量”之间取一个平衡。** **参考 RPG 专业剧情策划全流程后,在自定义世界创作工具里,哪些设定必须要求陶泥主手动填写,哪些设定应该由 AI 先生成但允许陶泥主修改,哪些设定应完全交给系统托管,才能在“尽可能降低门槛”和“尽可能提高作品质量”之间取一个平衡。**
这份文档不再只回答“创作者与 AI 怎么分工”,而是进一步把创作工作台收束成一个更可执行的三层输入结构: 这份文档不再只回答“陶泥主与 AI 怎么分工”,而是进一步把创作工作台收束成一个更可执行的三层输入结构:
1. 创作者必须手填的高杠杆锚点 1. 陶泥主必须手填的高杠杆锚点
2. AI 先生成、创作者可修改的内容草稿层 2. AI 先生成、陶泥主可修改的内容草稿层
3. 系统自动编译和运行的托管层 3. 系统自动编译和运行的托管层
一句话结论: 一句话结论:
**让创作者只负责决定作品的灵魂、视角、冲突和关系钩子,让 AI 负责把这些锚点展开成可编辑的剧情草稿,让系统负责把草稿编译成可运行的结构。** **让陶泥主只负责决定作品的灵魂、视角、冲突和关系钩子,让 AI 负责把这些锚点展开成可编辑的剧情草稿,让系统负责把草稿编译成可运行的结构。**
--- ---
@@ -25,27 +25,27 @@
这套平衡设计要同时满足 5 个目标: 这套平衡设计要同时满足 5 个目标:
1. 低门槛 1. 低门槛
-创作者不需要写长篇设定,也不需要理解底层系统结构。 -陶泥主不需要写长篇设定,也不需要理解底层系统结构。
2. 高辨识度 2. 高辨识度
- 创作者写出来的世界,不应该只是“像一个世界”,而应该保留明显的个人方向。 - 陶泥主写出来的世界,不应该只是“像一个世界”,而应该保留明显的个人方向。
3. 高可编辑性 3. 高可编辑性
- AI 不能一次生成后就不可控,创作者必须能改关键对象、关键关系和关键章节。 - AI 不能一次生成后就不可控,陶泥主必须能改关键对象、关键关系和关键章节。
4. 高稳定性 4. 高稳定性
- 任务、章节、关系、物件和可见性等运行层结构不能依赖创作者手填专业字段。 - 任务、章节、关系、物件和可见性等运行层结构不能依赖陶泥主手填专业字段。
5. 可扩展 5. 可扩展
- 愿意深挖的创作者可以继续补充世界上限,不愿深挖的人也能快速产出质量不错的作品。 - 愿意深挖的陶泥主可以继续补充世界上限,不愿深挖的人也能快速产出质量不错的作品。
--- ---
## 2. 核心原则 ## 2. 核心原则
## 2.1 创作者手填的必须是“高杠杆决策”,不是“高工作量字段” ## 2.1 陶泥主手填的必须是“高杠杆决策”,不是“高工作量字段”
应该要求创作者手填的内容,必须同时满足下面两个条件: 应该要求陶泥主手填的内容,必须同时满足下面两个条件:
1. 会显著决定作品气质和辨识度 1. 会显著决定作品气质和辨识度
2. AI 很难替代判断 2. AI 很难替代判断
@@ -67,9 +67,9 @@
- 章节拆分 - 章节拆分
- 运行时信号结构 - 运行时信号结构
## 2.2 创作者可改层应该承接“专业策划初稿”,而不是“原始底层字段” ## 2.2 陶泥主可改层应该承接“专业策划初稿”,而不是“原始底层字段”
AI 生成后允许创作者修改的,不应该是一堆技术型字段,而应该是一批已经成形的内容卡片,例如: AI 生成后允许陶泥主修改的,不应该是一堆技术型字段,而应该是一批已经成形的内容卡片,例如:
- 关键角色卡 - 关键角色卡
- 势力卡 - 势力卡
@@ -81,11 +81,11 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
也就是说: 也就是说:
**AI 先给创作者一个像策划初稿的东西,而不是给一堆系统字段让创作者自己拼。** **AI 先给陶泥主一个像策划初稿的东西,而不是给一堆系统字段让陶泥主自己拼。**
## 2.3 系统托管层必须彻底隐藏专业运行结构 ## 2.3 系统托管层必须彻底隐藏专业运行结构
以下这类结构不应该默认要求创作者理解或编辑: 以下这类结构不应该默认要求陶泥主理解或编辑:
- `ThemePack` - `ThemePack`
- `WorldStoryGraph` - `WorldStoryGraph`
@@ -98,7 +98,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
- 稀有度映射 - 稀有度映射
- 掉落和 build 权重 - 掉落和 build 权重
创作者应该编辑的是自然语言与内容卡,而不是运行时图结构。 陶泥主应该编辑的是自然语言与内容卡,而不是运行时图结构。
## 2.4 先少量必填,再逐层展开 ## 2.4 先少量必填,再逐层展开
@@ -107,9 +107,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
```text ```text
先填最小必填卡 先填最小必填卡
-> AI 生成世界初稿 -> AI 生成世界初稿
-> 创作者修改关键对象 -> 陶泥主修改关键对象
-> 系统继续展开长尾 -> 系统继续展开长尾
-> 创作者决定是否进入高级补充 -> 陶泥主决定是否进入高级补充
``` ```
## 2.5 默认清爽,深度能力后置 ## 2.5 默认清爽,深度能力后置
@@ -127,27 +127,27 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 3. 最终建议:三层分工 ## 3. 最终建议:三层分工
## 3.1 第一层:必须要求创作者手动填写 ## 3.1 第一层:必须要求陶泥主手动填写
这一层只保留最影响作品质量的高杠杆锚点,建议默认强制填写 6 张卡。 这一层只保留最影响作品质量的高杠杆锚点,建议默认强制填写 6 张卡。
## 3.2 第二层AI 生成后支持创作者修改 ## 3.2 第二层AI 生成后支持陶泥主修改
这一层由 AI 根据第一层锚点自动展开成专业剧情策划初稿,创作者可以逐项修改、锁定、局部重生成。 这一层由 AI 根据第一层锚点自动展开成专业剧情策划初稿,陶泥主可以逐项修改、锁定、局部重生成。
## 3.3 第三层:其余都交给系统 ## 3.3 第三层:其余都交给系统
这一层是把前两层编译成可运行游戏结构所需的系统字段、数值和运行时指令,默认不要求创作者处理。 这一层是把前两层编译成可运行游戏结构所需的系统字段、数值和运行时指令,默认不要求陶泥主处理。
--- ---
## 4. 最低门槛方案:只强制手填 6 张卡 ## 4. 最低门槛方案:只强制手填 6 张卡
如果目标是尽可能降低门槛,同时又保留作品辨识度,建议只强制创作者填写以下 6 张卡。 如果目标是尽可能降低门槛,同时又保留作品辨识度,建议只强制陶泥主填写以下 6 张卡。
## 4.1 卡 1世界一句话与核心幻想 ## 4.1 卡 1世界一句话与核心幻想
创作者必须手填: 陶泥主必须手填:
- 世界一句话设定 - 世界一句话设定
- 玩家来到这个世界最想体验的感觉 - 玩家来到这个世界最想体验的感觉
@@ -165,7 +165,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.2 卡 2玩家身份与开局困境 ## 4.2 卡 2玩家身份与开局困境
创作者必须手填: 陶泥主必须手填:
- 玩家是谁 - 玩家是谁
- 玩家开局最缺什么 - 玩家开局最缺什么
@@ -179,7 +179,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.3 卡 3主题气质与禁忌边界 ## 4.3 卡 3主题气质与禁忌边界
创作者必须手填: 陶泥主必须手填:
- 主题关键词 - 主题关键词
- 情绪基调 - 情绪基调
@@ -199,7 +199,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.4 卡 4核心冲突 ## 4.4 卡 4核心冲突
创作者必须手填: 陶泥主必须手填:
- 当前世界最重要的 `1~3` 个明面冲突 - 当前世界最重要的 `1~3` 个明面冲突
- 至少 `1` 个隐藏问题或暗面危机 - 至少 `1` 个隐藏问题或暗面危机
@@ -212,9 +212,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.5 卡 5关键关系钩子 ## 4.5 卡 5关键关系钩子
这里不强制创作者一开始填写完整角色档案,只要求填写更高杠杆的“关系骨架”。 这里不强制陶泥主一开始填写完整角色档案,只要求填写更高杠杆的“关系骨架”。
创作者必须手填: 陶泥主必须手填:
- `2~4` 条关键关系钩子 - `2~4` 条关键关系钩子
- 每条钩子至少说明: - 每条钩子至少说明:
@@ -229,7 +229,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.6 卡 6标志性要素与硬规则 ## 4.6 卡 6标志性要素与硬规则
创作者必须手填: 陶泥主必须手填:
- `2~5` 个标志性要素 - `2~5` 个标志性要素
- 物件 - 物件
@@ -247,11 +247,11 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
--- ---
## 5. 不建议强制手填,但应该让 AI 生成后支持创作者修改的设定 ## 5. 不建议强制手填,但应该让 AI 生成后支持陶泥主修改的设定
这一层是平衡“低门槛”和“高质量”的关键。 这一层是平衡“低门槛”和“高质量”的关键。
创作者不需要从零填写这些内容,但 AI 生成后必须能看、能改、能锁定、能局部重生成。 陶泥主不需要从零填写这些内容,但 AI 生成后必须能看、能改、能锁定、能局部重生成。
## 5.1 世界外观层 ## 5.1 世界外观层
@@ -282,7 +282,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 势力很重要,但让新手一开始手写完整势力表太重 - 势力很重要,但让新手一开始手写完整势力表太重
- 更合理的做法是让 AI 基于核心冲突先出草稿,再由创作者修正 - 更合理的做法是让 AI 基于核心冲突先出草稿,再由陶泥主修正
## 5.3 关键角色层 ## 5.3 关键角色层
@@ -302,8 +302,8 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 创作者已经通过“关系钩子”给出最关键的人物骨架 - 陶泥主已经通过“关系钩子”给出最关键的人物骨架
- AI 负责把钩子展开成可编辑角色卡,创作者再做精修 - AI 负责把钩子展开成可编辑角色卡,陶泥主再做精修
## 5.4 关键地点层 ## 5.4 关键地点层
@@ -319,7 +319,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 地点是世界感的重要来源 - 地点是世界感的重要来源
- 但新创作者未必能一开始就写出完整地点网络 - 但新陶泥主未必能一开始就写出完整地点网络
## 5.5 世界线程层 ## 5.5 世界线程层
@@ -335,7 +335,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 线程是专业剧情结构,适合 AI 先搭骨架 - 线程是专业剧情结构,适合 AI 先搭骨架
-创作者必须有权修正哪条线更重要、哪条线该隐藏 -陶泥主必须有权修正哪条线更重要、哪条线该隐藏
## 5.6 主线章节层 ## 5.6 主线章节层
@@ -350,9 +350,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 创作者已经给出了世界目标、冲突和关系 - 陶泥主已经给出了世界目标、冲突和关系
- AI 可以先把它们编成主线章节初稿 - AI 可以先把它们编成主线章节初稿
- 创作者再选择保留、删减或重排 - 陶泥主再选择保留、删减或重排
## 5.7 支线、角色线、阵营线层 ## 5.7 支线、角色线、阵营线层
@@ -367,7 +367,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 这是最适合 AI 拉开内容宽度的部分 - 这是最适合 AI 拉开内容宽度的部分
- 也是最需要创作者局部精修的部分 - 也是最需要陶泥主局部精修的部分
## 5.8 场景章节层 ## 5.8 场景章节层
@@ -384,7 +384,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 当前项目已经在走“场景 = 章节单元”的方向 - 当前项目已经在走“场景 = 章节单元”的方向
- 这层非常适合 AI 编排出第一版,再由创作者补强记忆点 - 这层非常适合 AI 编排出第一版,再由陶泥主补强记忆点
## 5.9 叙事载体层 ## 5.9 叙事载体层
@@ -397,7 +397,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
- 场景遗物 - 场景遗物
- 怪物命名及其故事指向 - 怪物命名及其故事指向
创作者主要修改: 陶泥主主要修改:
- 哪些载体最重要 - 哪些载体最重要
- 哪些载体和哪条线程绑定 - 哪些载体和哪条线程绑定
@@ -417,13 +417,13 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 这些内容适合 AI 批量铺量 - 这些内容适合 AI 批量铺量
- 创作者只需要挑、改、锁定,不必从零起草 - 陶泥主只需要挑、改、锁定,不必从零起草
--- ---
## 6. 其余设定应交给系统托管 ## 6. 其余设定应交给系统托管
以下内容不建议默认暴露给创作者编辑,应由系统根据前两层自动编译和维护。 以下内容不建议默认暴露给陶泥主编辑,应由系统根据前两层自动编译和维护。
## 6.1 题材与术语编译层 ## 6.1 题材与术语编译层
@@ -450,7 +450,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 创作者要的是“故事线能对”,不是维护图数据库 - 陶泥主要的是“故事线能对”,不是维护图数据库
## 6.3 可见性和 prompt 裁剪层 ## 6.3 可见性和 prompt 裁剪层
@@ -465,7 +465,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因: 原因:
- 这层必须稳定、严格、自动化 - 这层必须稳定、严格、自动化
- 不适合依赖创作者手动维护 - 不适合依赖陶泥主手动维护
## 6.4 运行时导演层 ## 6.4 运行时导演层
@@ -494,7 +494,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
说明: 说明:
- 创作者可以编辑“任务卡”和“章节卡” - 陶泥主可以编辑“任务卡”和“章节卡”
- 但不应默认编辑底层 contract 结构 - 但不应默认编辑底层 contract 结构
## 6.6 数值与配置层 ## 6.6 数值与配置层
@@ -511,7 +511,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
说明: 说明:
- 创作者可以给“偏向” - 陶泥主可以给“偏向”
- 系统负责编译成具体数值 - 系统负责编译成具体数值
## 6.7 QA 与一致性层 ## 6.7 QA 与一致性层
@@ -547,7 +547,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
| 主线 | 不强制首轮手写完整主线 | 幕结构、章节卡、高潮与 handoff | 章节状态编译 | | 主线 | 不强制首轮手写完整主线 | 幕结构、章节卡、高潮与 handoff | 章节状态编译 |
| 支线/角色线 | 不强制首轮手写完整矩阵 | 支线种子、角色线事件、阵营线分歧 | 任务 contract 编译 | | 支线/角色线 | 不强制首轮手写完整矩阵 | 支线种子、角色线事件、阵营线分歧 | 任务 contract 编译 |
| 场景章节 | 不强制首轮手写全量章节 | 场景章节卡、阶段内容、章节载体 | signal 与导演层 | | 场景章节 | 不强制首轮手写全量章节 | 场景章节卡、阶段内容、章节载体 | signal 与导演层 |
| 运行时结构 | 不建议创作者接触 | 不建议默认编辑 | 可见性、导演、信号、编译、QA | | 运行时结构 | 不建议陶泥主接触 | 不建议默认编辑 | 可见性、导演、信号、编译、QA |
--- ---
@@ -555,7 +555,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 8.1 第一步:只填写最小必填集 ## 8.1 第一步:只填写最小必填集
创作者只需要完成: 陶泥主只需要完成:
1. 世界一句话与核心幻想 1. 世界一句话与核心幻想
2. 玩家身份与开局困境 2. 玩家身份与开局困境
@@ -584,9 +584,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
这里的重点不是一次补满全世界,而是先形成一个像样的内容骨架。 这里的重点不是一次补满全世界,而是先形成一个像样的内容骨架。
## 8.3 第三步:创作者只精修高价值卡片 ## 8.3 第三步:陶泥主只精修高价值卡片
建议默认优先让创作者编辑这 4 类卡片: 建议默认优先让陶泥主编辑这 4 类卡片:
1. 关键角色 1. 关键角色
2. 核心冲突与线程 2. 核心冲突与线程
@@ -606,7 +606,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
- 任务包装 - 任务包装
- 文案变体 - 文案变体
## 8.5 第五步:创作者按需进入高级模式 ## 8.5 第五步:陶泥主按需进入高级模式
高级模式只对愿意深挖的人开放: 高级模式只对愿意深挖的人开放:
@@ -665,7 +665,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 10.2 每张卡只保留自然语言输入 ## 10.2 每张卡只保留自然语言输入
不要强迫创作者在首轮填写: 不要强迫陶泥主在首轮填写:
- tags - tags
- ids - ids
@@ -676,20 +676,20 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
更合理的做法是: 更合理的做法是:
-创作者输入自然语言或选择直觉标签 -陶泥主输入自然语言或选择直觉标签
- 再由系统编译成结构化字段 - 再由系统编译成结构化字段
## 10.3 首轮生成后默认先看“精修建议” ## 10.3 首轮生成后默认先看“精修建议”
AI 初稿生成后,不应该把创作者直接扔进一个大编辑器。 AI 初稿生成后,不应该把陶泥主直接扔进一个大编辑器。
更好的做法是先给出: 更好的做法是先给出:
1. 哪些卡片最值得改 1. 哪些卡片最值得改
2. 哪些内容已经比较稳定 2. 哪些内容已经比较稳定
3. 哪些内容仍然偏泛,需要创作者补个性 3. 哪些内容仍然偏泛,需要陶泥主补个性
这样能明显提高创作者的修改效率。 这样能明显提高陶泥主的修改效率。
## 10.4 移动端优先只保留高杠杆操作 ## 10.4 移动端优先只保留高杠杆操作
@@ -707,15 +707,15 @@ AI 初稿生成后,不应该把创作者直接扔进一个大编辑器。
## 11. 最后结论 ## 11. 最后结论
如果目标是在自定义世界创作中真正平衡“降低门槛”和“提高作品质量”,最好的做法不是让创作者填更多字段,也不是把一切都交给 AI。 如果目标是在自定义世界创作中真正平衡“降低门槛”和“提高作品质量”,最好的做法不是让陶泥主填更多字段,也不是把一切都交给 AI。
更合理的平衡是: 更合理的平衡是:
1. 创作者必须手填最小但高杠杆的 6 张卡,掌握世界灵魂。 1. 陶泥主必须手填最小但高杠杆的 6 张卡,掌握世界灵魂。
2. AI 根据这 6 张卡生成一套可编辑的专业剧情初稿,负责把骨架展开成角色、地点、线程、章节和载体。 2. AI 根据这 6 张卡生成一套可编辑的专业剧情初稿,负责把骨架展开成角色、地点、线程、章节和载体。
3. 创作者只精修最有价值的关键对象,锁定真正重要的内容。 3. 陶泥主只精修最有价值的关键对象,锁定真正重要的内容。
4. 其余运行结构、数值、可见性、任务编译和 QA 检查都交给系统托管。 4. 其余运行结构、数值、可见性、任务编译和 QA 检查都交给系统托管。
一句话收束: 一句话收束:
**创作者负责决定“这个世界为什么值得被创作”AI 负责把它整理成可修改的策划初稿,系统负责把它稳定地跑成一个游戏世界。** **陶泥主负责决定“这个世界为什么值得被创作”AI 负责把它整理成可修改的策划初稿,系统负责把它稳定地跑成一个游戏世界。**

View File

@@ -10,7 +10,7 @@
- 基于“最小必填锚点 + AI 初稿卡片 + 系统托管层”的结构化创作方案 - 基于“最小必填锚点 + AI 初稿卡片 + 系统托管层”的结构化创作方案
2. 纯 Agent 式方向 2. 纯 Agent 式方向
- 以前台对话为唯一主交互,创作者主要通过和 Agent 聊天来完成世界构建、角色塑造、剧情扩展和修改 - 以前台对话为唯一主交互,陶泥主主要通过和 Agent 聊天来完成世界构建、角色塑造、剧情扩展和修改
文档需要回答 3 个问题: 文档需要回答 3 个问题:
@@ -34,7 +34,7 @@
当前方案的核心是: 当前方案的核心是:
1. 创作者手填最小高杠杆锚点 1. 陶泥主手填最小高杠杆锚点
2. AI 生成一批可编辑的剧情策划初稿卡片 2. AI 生成一批可编辑的剧情策划初稿卡片
3. 系统把内容编译成运行时结构 3. 系统把内容编译成运行时结构
@@ -42,7 +42,7 @@
**结构化工作台 + AI 协作生成。** **结构化工作台 + AI 协作生成。**
创作者的主要行为是: 陶泥主的主要行为是:
1. 填写关键卡片 1. 填写关键卡片
2. 修改关键角色、地点、势力、章节等内容卡 2. 修改关键角色、地点、势力、章节等内容卡
@@ -53,9 +53,9 @@
纯 Agent 式不是指“系统内部没有结构”,而是指: 纯 Agent 式不是指“系统内部没有结构”,而是指:
**创作者前台几乎不需要面对表单和卡片编辑器,主要通过自然语言对话来完成创作。** **陶泥主前台几乎不需要面对表单和卡片编辑器,主要通过自然语言对话来完成创作。**
创作者的主要行为变成: 陶泥主的主要行为变成:
1. 用自然语言描述世界想法 1. 用自然语言描述世界想法
2. 回答 Agent 的追问 2. 回答 Agent 的追问
@@ -77,7 +77,7 @@
1. 前台用户主要通过什么方式思考和输入? 1. 前台用户主要通过什么方式思考和输入?
2. 后台系统是否仍然有稳定的世界模型和编译层? 2. 后台系统是否仍然有稳定的世界模型和编译层?
3. 创作者是否还能看见摘要、锁定内容和修改范围? 3. 陶泥主是否还能看见摘要、锁定内容和修改范围?
对当前项目来说,真正危险的不是“转成聊天”,而是: 对当前项目来说,真正危险的不是“转成聊天”,而是:
@@ -93,11 +93,11 @@
它更擅长: 它更擅长:
1. 帮不擅长表单和结构思考的创作者起步 1. 帮不擅长表单和结构思考的陶泥主起步
2.创作者思路模糊时做追问和陪创作 2.陶泥主思路模糊时做追问和陪创作
3. 把“我要做一个世界”变成一次自然聊天 3. 把“我要做一个世界”变成一次自然聊天
4. 动态决定追问深度,而不是一上来摆很多字段 4. 动态决定追问深度,而不是一上来摆很多字段
5.创作者感觉自己是在和一个懂 RPG 的剧情搭档共创 5.陶泥主感觉自己是在和一个懂 RPG 的剧情搭档共创
## 2.2 纯 Agent 式的主要问题 ## 2.2 纯 Agent 式的主要问题
@@ -110,7 +110,7 @@
1. 聊天很多,但世界状态越来越难总览 1. 聊天很多,但世界状态越来越难总览
2. 角色、地点、势力和章节信息散落在多轮消息里 2. 角色、地点、势力和章节信息散落在多轮消息里
3. 锁定范围不清,重生成容易误伤已有内容 3. 锁定范围不清,重生成容易误伤已有内容
4. Agent 很容易“替创作者决定太多” 4. Agent 很容易“替陶泥主决定太多”
5. 长会话越来越贵,越来越慢,也越来越容易漂移 5. 长会话越来越贵,越来越慢,也越来越容易漂移
## 2.3 对当前项目的判断 ## 2.3 对当前项目的判断
@@ -197,7 +197,7 @@
纯 Agent 式更弱的地方在于: 纯 Agent 式更弱的地方在于:
1. 世界模型隐藏得太深时,创作者会失去整体掌控感 1. 世界模型隐藏得太深时,陶泥主会失去整体掌控感
2. 多轮对话后,已确定内容不容易被清晰回看 2. 多轮对话后,已确定内容不容易被清晰回看
3. 局部重做和精确编辑边界会变模糊 3. 局部重做和精确编辑边界会变模糊
4. Agent 容易过度代写、过度主导 4. Agent 容易过度代写、过度主导
@@ -223,7 +223,7 @@
因为这些环节的关键问题不是“字段如何摆放”,而是: 因为这些环节的关键问题不是“字段如何摆放”,而是:
**创作者有没有被真正引导出自己想做的世界。** **陶泥主有没有被真正引导出自己想做的世界。**
## 4.2 不值得直接转成纯聊天黑箱的部分 ## 4.2 不值得直接转成纯聊天黑箱的部分
@@ -261,8 +261,8 @@
即使转成纯 Agent 式,也仍然要保留这三层: 即使转成纯 Agent 式,也仍然要保留这三层:
1. 创作者必须确认的高杠杆锚点 1. 陶泥主必须确认的高杠杆锚点
2. AI 生成但允许创作者修改的策划初稿层 2. AI 生成但允许陶泥主修改的策划初稿层
3. 系统托管的运行时编译层 3. 系统托管的运行时编译层
变化的只是: 变化的只是:
@@ -339,7 +339,7 @@
2. 会阶段性总结 2. 会阶段性总结
3. 会把聊天结果沉淀成结构化世界状态 3. 会把聊天结果沉淀成结构化世界状态
4. 会提醒风险和冲突 4. 会提醒风险和冲突
5. 会在创作者要求时进行局部重写和定向扩展 5. 会在陶泥主要求时进行局部重写和定向扩展
## 6.2 正确理解 ## 6.2 正确理解
@@ -349,7 +349,7 @@
也就是说: 也就是说:
1. 创作者看到的是对话 1. 陶泥主看到的是对话
2. 系统内部维护的是世界模型、锁定状态、摘要和编译结果 2. 系统内部维护的是世界模型、锁定状态、摘要和编译结果
--- ---
@@ -389,7 +389,7 @@ Agent 首轮不应该直接铺满全世界,而应该给出一份简明底稿
2. 建议内容 2. 建议内容
3. 待确认内容 3. 待确认内容
## 7.3 阶段 C创作者锁定锚点 ## 7.3 阶段 C陶泥主锁定锚点
在纯 Agent 模式里,锁定行为必须被显式支持。 在纯 Agent 模式里,锁定行为必须被显式支持。
@@ -455,7 +455,7 @@ Agent 不应该每轮都继续扩全局,而应该支持“单对象工作模
| 结构 | 作用 | | 结构 | 作用 |
| --- | --- | | --- | --- |
| `creatorIntentProfile` | 当前创作者最初和最新的创作意图 | | `creatorIntentProfile` | 当前陶泥主最初和最新的创作意图 |
| `lockedAnchors` | 已确认不可自动改写的内容 | | `lockedAnchors` | 已确认不可自动改写的内容 |
| `worldDraftSnapshot` | 当前世界底稿快照 | | `worldDraftSnapshot` | 当前世界底稿快照 |
| `editableDraftCards` | 角色、地点、势力、章节等可编辑初稿 | | `editableDraftCards` | 角色、地点、势力、章节等可编辑初稿 |
@@ -530,7 +530,7 @@ Agent 不能像问卷系统,也不能一次追问太多。
1. 一次最多追问 `1~3` 个问题 1. 一次最多追问 `1~3` 个问题
2. 问题必须是当前最缺的高杠杆信息 2. 问题必须是当前最缺的高杠杆信息
3. 每次追问都给默认建议方向 3. 每次追问都给默认建议方向
4. 如果创作者不想细答,允许 Agent 先代补一个版本再确认 4. 如果陶泥主不想细答,允许 Agent 先代补一个版本再确认
这样才能保持“像聊天”,而不是“像客服表单”。 这样才能保持“像聊天”,而不是“像客服表单”。
@@ -614,14 +614,14 @@ Agent 应能识别这些常见修改类型:
3. 锁定内容固定展示 3. 锁定内容固定展示
4. 提供“当前世界圣经”入口 4. 提供“当前世界圣经”入口
## 11.2 风险 2Agent 过度代写,创作者失去作品归属感 ## 11.2 风险 2Agent 过度代写,陶泥主失去作品归属感
防护方式: 防护方式:
1. 高杠杆锚点必须要求确认 1. 高杠杆锚点必须要求确认
2. 重要改动前先说“我准备改什么” 2. 重要改动前先说“我准备改什么”
3. 默认优先给多个候选,而不是直接盖写 3. 默认优先给多个候选,而不是直接盖写
4. 允许创作者随时回退到旧版本 4. 允许陶泥主随时回退到旧版本
## 11.3 风险 3局部修改带出全局漂移 ## 11.3 风险 3局部修改带出全局漂移

View File

@@ -37,8 +37,8 @@
- 不能先删旧字段,再补新结构。 - 不能先删旧字段,再补新结构。
- 必须先补新设定层,再逐步迁读,最后再让旧模板字段退化成兼容层。 - 必须先补新设定层,再逐步迁读,最后再让旧模板字段退化成兼容层。
4. 不能增加创作者负担 4. 不能增加陶泥主负担
- 这次不是让创作者多填一堆底层 schema。 - 这次不是让陶泥主多填一堆底层 schema。
- 这些设定仍然应由 AI / 系统编译出来,只是所有权从模板世界转移到自定义世界自己。 - 这些设定仍然应由 AI / 系统编译出来,只是所有权从模板世界转移到自定义世界自己。
--- ---

View File

@@ -102,9 +102,9 @@
这不是真正跨题材,只是换了名字。 这不是真正跨题材,只是换了名字。
## 3.3 不能让创作者承担更多底层配置工作 ## 3.3 不能让陶泥主承担更多底层配置工作
这次优化不是让创作者额外填写: 这次优化不是让陶泥主额外填写:
- 怪物模板表 - 怪物模板表
- 场景参考池 - 场景参考池

View File

@@ -349,7 +349,7 @@ export interface ChapterProgressionPlan {
} }
``` ```
建议作为后端运行时编译结果缓存,不作为创作者直接编辑字段。 建议作为后端运行时编译结果缓存,不作为陶泥主直接编辑字段。
## 3.7 章节经验记账 ## 3.7 章节经验记账
@@ -636,7 +636,7 @@ chapterXpBudget =
3. 非主角色友方 NPC 3. 非主角色友方 NPC
- `support``ambient` - `support``ambient`
如需修正,再允许章节蓝图加可选 override但不要求创作者每次手填。 如需修正,再允许章节蓝图加可选 override但不要求陶泥主每次手填。
## 7.2 等级锚点 ## 7.2 等级锚点

View File

@@ -17,10 +17,13 @@
3. 卡片高度控制在约 4rem 内,标题与状态信息并排组织,避免大留白。 3. 卡片高度控制在约 4rem 内,标题与状态信息并排组织,避免大留白。
4. 模块本体使用 `max-height: 33svh` 作为硬约束,内容超出时优先在模板入口行内横向滚动,不撑高页面。 4. 模块本体使用 `max-height: 33svh` 作为硬约束,内容超出时优先在模板入口行内横向滚动,不撑高页面。
5. 桌面端保持网格入口,但同步收紧内边距和卡片留白,避免移动端与桌面端表现割裂。 5. 桌面端保持网格入口,但同步收紧内边距和卡片留白,避免移动端与桌面端表现割裂。
6. 横向滚动模板行必须隐藏原生滚动条,保留滑动能力,避免底部出现过粗的视觉条。
## 文案约束 ## 文案约束
- UI 不新增规则说明类文案。 - UI 不新增规则说明类文案。
- 原有“直接选择游戏创作模板,立刻进入对应的共创工作台。”说明在移动端隐藏,桌面端保留为辅助说明。 - 原有“直接选择游戏创作模板,立刻进入对应的共创工作台。”说明在移动端隐藏,桌面端保留为辅助说明。
- 锁定、可创建、正在开启等状态继续来自既有模板元数据或忙碌状态 - 可创建的模板卡不展示“可创建”状态标签,只保留标题、短副标题和进入箭头
- 锁定的模板卡统一以“敬请期待”作为状态标注,不再显示“锁定”。
- RPG 入口展示为“角色扮演 / 剧情演绎,冒险成长”,拼图入口展示为“拼图 / 创意礼物,生活分享”。
- 忙碌状态仅保留在模块标题行的轻量状态中,避免占用每张可用卡片的首要视觉层级。

View File

@@ -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. 小屏卡片降低高度、内边距、标题字号和徽标尺寸,避免长标题或中文描述撑破容器。
## 文案约束
- 不新增功能说明类文案。
- 空态和错误态沿用现有文案。
- 中文标题、描述和指标需要在卡片内截断或换行,不得因长文本破坏布局。

View File

@@ -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 相关实现。

View File

@@ -28,6 +28,24 @@ likeCount: number
3. 大鱼公开广场:`BigFishWorkSummary` 返回 `likeCount`,当前由 Rust facade 返回 `0``playCount` 继续仅表示游玩次数。 3. 大鱼公开广场:`BigFishWorkSummary` 返回 `likeCount`,当前由 Rust facade 返回 `0``playCount` 继续仅表示游玩次数。
4. 前端聚合类型 `PlatformPublicGalleryCard` 透传 `likeCount``WorldCard` 不再依赖 `badge/metaLabel` 决定主要信息结构。 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. 移动端布局 ## 3. 移动端布局
1. 移动端首页只在 `RpgEntryHomeView` 的 mobile content 内重排。 1. 移动端首页只在 `RpgEntryHomeView` 的 mobile content 内重排。
@@ -57,3 +75,7 @@ likeCount: number
2. 桌面端首页布局区块顺序不变,只替换公开作品卡内部结构。 2. 桌面端首页布局区块顺序不变,只替换公开作品卡内部结构。
3. RPG、拼图、大鱼三类公开作品卡都有 `likeCount` 字段,前端聚合后能统一展示。 3. RPG、拼图、大鱼三类公开作品卡都有 `likeCount` 字段,前端聚合后能统一展示。
4. 运行编码检查、前端定向测试和必要的 Rust 检查。 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 不能为空` 阻断首页。

View File

@@ -96,7 +96,7 @@
- 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。 - 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。
- 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。 - 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。
- `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。 - `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。
- `密码登录` 页签只包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或叙世号。 - `密码登录` 页签只包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或陶泥号。
- 密码登录只是手机号验证码登录的补充方式:只有已登录并设置过密码的手机号账号才能使用,不能在密码页签创建账号。 - 密码登录只是手机号验证码登录的补充方式:只有已登录并设置过密码的手机号账号才能使用,不能在密码页签创建账号。
- `密码登录` 主按钮固定为 `登录`,不得使用 `注册/登录` - `密码登录` 主按钮固定为 `登录`,不得使用 `注册/登录`
- 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。 - 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。

View File

@@ -59,8 +59,8 @@
### 3.2 排版 ### 3.2 排版
- 平台层正文、按钮、说明、功能标签统一使用非像素字体 - 平台层正文、按钮、说明、功能标签统一使用非像素字体
- 左上角 `叙世 / GENARRATIVE` 品牌字标允许单独做成像素化 logo - 左上角 `陶泥 / GENARRATIVE` 品牌字标允许单独做成像素化 logo
- `GENARRATIVE``叙世` 都优先直接使用游戏内同款 `Fusion Pixel` - `GENARRATIVE``陶泥` 都优先直接使用游戏内同款 `Fusion Pixel`
- 品牌字标默认保持正常像素字观感,禁止再叠双层粗阴影或手动加粗到影响识别 - 品牌字标默认保持正常像素字观感,禁止再叠双层粗阴影或手动加粗到影响识别
- 品牌字标直接使用字体文件内原字形,不额外做运行时描字、轮廓拼字或伪粗体处理 - 品牌字标直接使用字体文件内原字形,不额外做运行时描字、轮廓拼字或伪粗体处理
- 主标题保留明显层级,但不再做像素描边效果 - 主标题保留明显层级,但不再做像素描边效果

View File

@@ -1,11 +1,11 @@
# 平台统一作品详情页与 Remix 数据链路设计 # 平台统一作品详情页与 Remix 数据链路设计
更新时间:`2026-04-28` 更新时间:`2026-04-29`
## 1. 本次目标 ## 1. 本次目标
1. 平台首页、公开广场、分类列表中的每个公开作品点击后,统一先进入作品详情页,不再直接启动玩法。 1. 平台首页、公开广场、分类列表中的每个公开作品点击后,统一先进入作品详情页,不再直接启动玩法。
2. 作品详情页结构参考 TapTap 详情页:顶部封面图、作品基础信息、右侧 Remix 按钮、四项统计、简介内容、底部启动按钮。 2. 作品详情页结构参考 TapTap 详情页:顶部封面图、作品基础信息、右侧“作品改造”按钮、四项统计、简介内容、底部启动按钮。
3. 删除参考图顶部 Tab不接入评价和论坛功能不展示“开发者的话”模块。 3. 删除参考图顶部 Tab不接入评价和论坛功能不展示“开发者的话”模块。
4. 统计数据必须从数据库读模型贯穿到前端展示,禁止在前端用假字段、游玩数冒充点赞数或固定文案代替真实字段。 4. 统计数据必须从数据库读模型贯穿到前端展示,禁止在前端用假字段、游玩数冒充点赞数或固定文案代替真实字段。
5. Remix 按钮必须由后端事务复制公开作品为当前用户草稿,并同步增加原作品改造次数,成功后前端进入新草稿详情/结果页。 5. Remix 按钮必须由后端事务复制公开作品为当前用户草稿,并同步增加原作品改造次数,成功后前端进入新草稿详情/结果页。
@@ -15,18 +15,21 @@
统一详情页只做作品展示与动作入口,不承担规则说明。 统一详情页只做作品展示与动作入口,不承担规则说明。
1. 顶部导航:返回按钮、标题“详情”、更多按钮占位;不展示“统计 / 详情 / 评价 / 论坛”Tab。 1. 顶部导航:返回按钮、标题“详情”、更多按钮占位;不展示“统计 / 详情 / 评价 / 论坛”Tab。
2. 封面区:使用作品封面图作为主视觉背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。 2. 封面区:固定 `16:9` 比例,使用作品封面图 `cover` 填满整块主视觉背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。
3. 基础信息区: 3. 基础信息区:
- 左侧作品图标使用作品封面或首图。 - 左侧作品图标使用作品封面或首图。
- 中间展示作品名、作者名、玩法类型。 - 中间展示作品名、作者头像、作者名、玩法类型;作者头像读取公开用户资料 `avatarUrl`,缺失时使用作者昵称首字占位
- 右侧原 TapTap 评分位置替换为 `Remix` 按钮。 - 右侧原 TapTap 评分位置替换为 `作品改造` 按钮。
4. 统计区固定四项: 4. 统计区固定四项:
- 改造次数`remixCount` - 改造:`remixCount`,显示为“数字 + 次”,单位放在数字后方。
- 游玩次数`playCount` - 游玩:`playCount`,显示为“数字 + 次”,单位放在数字后方。
- 点赞次数`likeCount` - 点赞:`likeCount`,显示为“数字 + 赞”,单位放在数字后方。
- 上线日期:`publishedAt` - 最近更新:优先展示 `updatedAt`,缺失时回退 `publishedAt`;前端只负责格式化显示,必须兼容后端当前 `seconds.microsZ` 与 ISO 字符串两种真实时间文本,显示为完整 `YYYY-MM-DD`,使用更小字号并保持单行不换行。
- 四项统计需要使用浅色图标底强化识别,但不得追加规则说明类文案。
5. 简介区:展示玩法标签和作品简介;不追加说明类文案。 5. 简介区:展示玩法标签和作品简介;不追加说明类文案。
6. 底部动作:主按钮为“启动”,点击后进入对应玩法运行态并记录游玩次数。 6. 底部动作:主按钮为“启动”,点击后进入对应玩法运行态并记录游玩次数。
7. 页面配色必须跟随平台明暗主题变量;亮色主题使用平台浅色底、深色文字和主按钮渐变,暗色主题使用平台暗色底、亮色文字和对应主按钮渐变,不在详情页写死独立黑色皮肤。
8. 字号规范跟随平台页面既有节奏:标题/主按钮使用 `1rem` 级别,作品名使用卡片标题同级 `1rem`,辅助信息与简介使用 `0.8125rem` / `0.875rem`,标签与统计标签使用 `0.75rem`,避免在详情页使用随视口放大的独立大字号。
## 3. 数据真相源 ## 3. 数据真相源
@@ -55,7 +58,7 @@
### 3.3 大鱼吃小鱼作品 ### 3.3 大鱼吃小鱼作品
1. `big_fish_creation_session` 现有 `play_count` 继续作为游玩统计,新增 `remix_count``like_count``published_at` 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` 继续作为游玩次数递增入口。 3. `record_big_fish_play` 继续作为游玩次数递增入口。
4. `remix_big_fish_work` 在同一事务内: 4. `remix_big_fish_work` 在同一事务内:
- 校验源 session 为已发布作品。 - 校验源 session 为已发布作品。
@@ -65,7 +68,8 @@
## 4. API 与前端接入 ## 4. API 与前端接入
1. 三类公开作品摘要统一返回:`playCount``remixCount``likeCount``publishedAt` 1. 三类公开作品摘要统一返回:`playCount``remixCount``likeCount``publishedAt``updatedAt`
- 作者头像不固化到作品读模型;详情页按 `authorPublicUserCode``ownerUserId` 读取公开用户摘要中的 `avatarUrl`,确保头像跟随账号资料更新。
2. Remix API 2. Remix API
- RPG`POST /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix` - RPG`POST /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix`
- 拼图:`POST /api/runtime/puzzle/gallery/{profile_id}/remix` - 拼图:`POST /api/runtime/puzzle/gallery/{profile_id}/remix`
@@ -76,6 +80,8 @@
- RPG进入复制出的草稿详情。 - 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. 验收点 ## 5. 验收点

View File

@@ -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_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_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_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):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。 - [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_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_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_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):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。 - [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。
@@ -28,8 +29,8 @@
- 做物品、Build、锻造相关需求时先看前两份。 - 做物品、Build、锻造相关需求时先看前两份。
- 做 RPG 全剧情规划、主支线矩阵、角色线、场景章节与剧情交付模板时,先看新增的全剧情策划流程。 - 做 RPG 全剧情规划、主支线矩阵、角色线、场景章节与剧情交付模板时,先看新增的全剧情策划流程。
- 做自定义世界创作工作台、创作者输入边界、AI 分工设计时,先看第一份。 - 做自定义世界创作工作台、陶泥主输入边界、AI 分工设计时,先看第一份。
- 做“哪些内容必须让创作者手填、哪些适合 AI 先生成再改、哪些必须系统托管”这类分层设计时,优先看新增的输入平衡设计稿。 - 做“哪些内容必须让陶泥主手填、哪些适合 AI 先生成再改、哪些必须系统托管”这类分层设计时,优先看新增的输入平衡设计稿。
- 做“是否应该转成纯 Agent 式创作工具、转了之后前后台各该怎么收口”这类产品方向评估时,优先看新增的纯 Agent 对比与转型设计稿。 - 做“是否应该转成纯 Agent 式创作工具、转了之后前后台各该怎么收口”这类产品方向评估时,优先看新增的纯 Agent 对比与转型设计稿。
- 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。 - 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。
- 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。 - 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。

View File

@@ -29,7 +29,7 @@
结论: 结论:
- 独立编辑器入口如果没有继续接入主流程,应及时物理删除,不要长期保留兼容壳 - 独立编辑器入口如果没有继续接入主流程,应及时物理删除,不要长期保留兼容壳
- 页签命名要贴近创作者语言,而不是内部实现命名 - 页签命名要贴近陶泥主语言,而不是内部实现命名
### 2.2 NPC 视觉模块并入 NPC 编辑 ### 2.2 NPC 视觉模块并入 NPC 编辑
@@ -144,7 +144,7 @@
经验: 经验:
- 创作者并不关心 “function” 这个技术词,更关心“这个选项会发生什么” - 陶泥主并不关心 “function” 这个技术词,更关心“这个选项会发生什么”
- 同类编辑器如果只给字段表单而没有模板起稿能力,复用效率会很低 - 同类编辑器如果只给字段表单而没有模板起稿能力,复用效率会很低
### 2.8 选项行为预览升级到实机回放 ### 2.8 选项行为预览升级到实机回放
@@ -217,7 +217,7 @@
- 预览面板要么都显示“实时状态” - 预览面板要么都显示“实时状态”
- 要么都显示“同一个阶段的快照” - 要么都显示“同一个阶段的快照”
- 混用实时值和预测值会让创作者误判 - 混用实时值和预测值会让陶泥主误判
## 4. 这类项目里沉淀下来的方法论 ## 4. 这类项目里沉淀下来的方法论
@@ -245,7 +245,7 @@
- 不是所有字段都应该在所有行为类型下开放 - 不是所有字段都应该在所有行为类型下开放
- 如果某类行为最终不会直接读取某个字段,就应该禁用或弱化它 - 如果某类行为最终不会直接读取某个字段,就应该禁用或弱化它
- 否则创作者会错误地以为改动无效是 bug - 否则陶泥主会错误地以为改动无效是 bug
### 4.4 模板比空白表单更重要 ### 4.4 模板比空白表单更重要

View File

@@ -25,7 +25,7 @@
3. 历史已发布作品必须能自动补齐 gallery 投影。 3. 历史已发布作品必须能自动补齐 gallery 投影。
- 公开列表读取 `list_custom_world_gallery_entries` 前,会扫描 `custom_world_profile` 中已发布且未删除的 profile。 - 公开列表读取 `list_custom_world_gallery_entries` 前,会扫描 `custom_world_profile` 中已发布且未删除的 profile。
- 若已发布 profile 缺少 `custom_world_gallery_entry`,或缺少公开作品码 / 作者叙世号,会先补齐公开字段并同步 gallery 投影。 - 若已发布 profile 缺少 `custom_world_gallery_entry`,或缺少公开作品码 / 作者陶泥号,会先补齐公开字段并同步 gallery 投影。
- 这样旧版本发布成功但未落入广场读模型的作品,在下一次首页 / 分类页读取公开列表时会自动出现。 - 这样旧版本发布成功但未落入广场读模型的作品,在下一次首页 / 分类页读取公开列表时会自动出现。
## 经验 ## 经验

View File

@@ -394,7 +394,7 @@ MVP 阶段不需要单独设置密码。
落地规则: 落地规则:
- 入参只允许 `phone``password`,不支持邮箱、用户名或叙世号。 - 入参只允许 `phone``password`,不支持邮箱、用户名或陶泥号。
- 手机号不存在时,不创建账号,返回统一的登录失败。 - 手机号不存在时,不创建账号,返回统一的登录失败。
- 手机号存在但账号未设置过密码时,不允许密码登录。 - 手机号存在但账号未设置过密码时,不允许密码登录。
- 首次设置密码只能在已登录账号中心内完成;用户必须先通过手机号验证码或已绑定手机号的微信账号进入已登录态。 - 首次设置密码只能在已登录账号中心内完成;用户必须先通过手机号验证码或已绑定手机号的微信账号进入已登录态。
@@ -734,7 +734,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
约束: 约束:
- 不支持邮箱、用户名或叙世号。 - 不支持邮箱、用户名或陶泥号。
- 不承担注册能力。 - 不承担注册能力。
- 只有已存在、已验证手机号、且 `passwordLoginEnabled=true` 的账号可以登录。 - 只有已存在、已验证手机号、且 `passwordLoginEnabled=true` 的账号可以登录。

View File

@@ -31,7 +31,7 @@
大鱼吃小鱼玩法是一个 `Agent-First` 的轻量实时成长玩法创作链: 大鱼吃小鱼玩法是一个 `Agent-First` 的轻量实时成长玩法创作链:
**创作者先与 Agent 共创“进化母题、等级阶梯、生态风格与场地气质”,系统再自动编译出一个竖屏全屏、摇杆移动、吞噬收编、三合一进化、持续刷怪、升到最高级即通关的可运行玩法作品。** **陶泥主先与 Agent 共创“进化母题、等级阶梯、生态风格与场地气质”,系统再自动编译出一个竖屏全屏、摇杆移动、吞噬收编、三合一进化、持续刷怪、升到最高级即通关的可运行玩法作品。**
--- ---
@@ -115,26 +115,26 @@
`Agent-First 大鱼吃小鱼玩法创作工具` `Agent-First 大鱼吃小鱼玩法创作工具`
玩法运行态对外展示名可由创作者自定义,不强绑平台内部域名。 玩法运行态对外展示名可由陶泥主自定义,不强绑平台内部域名。
## 5.2 目标用户 ## 5.2 目标用户
目标用户主要是 3 类: 目标用户主要是 3 类:
1.创作者 1.陶泥主
- 想快速做一个可玩的成长吞噬小游戏,但不懂完整关卡编辑器 - 想快速做一个可玩的成长吞噬小游戏,但不懂完整关卡编辑器
2. 视觉驱动型创作者 2. 视觉驱动型陶泥主
- 更关心“每级长什么样、动作怎么样、背景氛围如何” - 更关心“每级长什么样、动作怎么样、背景氛围如何”
3. 玩法原型创作者 3. 玩法原型陶泥主
- 想快速验证一套吞噬成长节奏、等级曲线和场地压迫感 - 想快速验证一套吞噬成长节奏、等级曲线和场地压迫感
## 5.3 成功标准 ## 5.3 成功标准
本期上线后,至少要满足下面这些结果: 本期上线后,至少要满足下面这些结果:
1. 创作者可在 `5~12` 分钟内通过 Agent 聊天形成一版可用锚点草稿。 1. 陶泥主可在 `5~12` 分钟内通过 Agent 聊天形成一版可用锚点草稿。
2. 系统默认能编译出 `8` 级实体阶梯的初版玩法草稿。 2. 系统默认能编译出 `8` 级实体阶梯的初版玩法草稿。
3. 每一级实体都能在结果页单独生成和重生成主图。 3. 每一级实体都能在结果页单独生成和重生成主图。
4. 每一级实体都能在结果页单独生成和重生成动作。 4. 每一级实体都能在结果页单独生成和重生成动作。
@@ -179,9 +179,9 @@
Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
1.创作者明确高杠杆锚点 1.陶泥主明确高杠杆锚点
2.创作者把模糊灵感总结成可编译结构 2.陶泥主把模糊灵感总结成可编译结构
3.创作者收束出第一版等级阶梯与视觉方向 3.陶泥主收束出第一版等级阶梯与视觉方向
## 7.2 前台交互原则 ## 7.2 前台交互原则
@@ -222,7 +222,7 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
3. `成长阶梯` 3. `成长阶梯`
- 这一玩法一共大致有几级,以及每一级如何逐步升级、变大、变强、变异 - 这一玩法一共大致有几级,以及每一级如何逐步升级、变大、变强、变异
- 最高级终局形态也并入这一锚点统一确定 - 最高级终局形态也并入这一锚点统一确定
-创作者没有明确总层数,本期默认按 `8` 级编译,可配置范围固定为 `6~12` -陶泥主没有明确总层数,本期默认按 `8` 级编译,可配置范围固定为 `6~12`
4. `风险节奏` 4. `风险节奏`
- 玩家周围应该更偏压迫、平衡还是偏爽快 - 玩家周围应该更偏压迫、平衡还是偏爽快
@@ -235,7 +235,7 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
2. `等级总层数` 并入 `成长阶梯` 2. `等级总层数` 并入 `成长阶梯`
3. `升级轮廓` 并入 `成长阶梯` 3. `升级轮廓` 并入 `成长阶梯`
4. `终局形态` 并入 `成长阶梯` 4. `终局形态` 并入 `成长阶梯`
5. `开局成长方式` 改为系统固定规则,不再作为创作者锚点 5. `开局成长方式` 改为系统固定规则,不再作为陶泥主锚点
后续 Agent 追问时,不再把这些内容拆成独立必答题。 后续 Agent 追问时,不再把这些内容拆成独立必答题。
@@ -302,7 +302,7 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
## 9.1 默认草稿规模 ## 9.1 默认草稿规模
创作者没有特别指定时,第一版玩法草稿必须默认编译为: 陶泥主没有特别指定时,第一版玩法草稿必须默认编译为:
1. `8` 级实体阶梯 1. `8` 级实体阶梯
2. `1` 张活动区域背景图 2. `1` 张活动区域背景图

View File

@@ -680,7 +680,7 @@ assistant 回复应包含:
1. 对 seedText / 用户消息的简要复述 1. 对 seedText / 用户消息的简要复述
2. 当前仍缺哪些世界锚点 2. 当前仍缺哪些世界锚点
3. 建议创作者下一步回答什么 3. 建议陶泥主下一步回答什么
#### 用户后续消息 #### 用户后续消息

View File

@@ -24,7 +24,7 @@
那么第二阶段的目标就是: 那么第二阶段的目标就是:
**让 Agent 会话真正开始理解创作者输入,并把自然语言聊天沉淀成结构化创作锚点。** **让 Agent 会话真正开始理解陶泥主输入,并把自然语言聊天沉淀成结构化创作锚点。**
一句话定义: 一句话定义:

View File

@@ -26,7 +26,7 @@
那么第四阶段的目标就是: 那么第四阶段的目标就是:
**让创作者直接修改这版草稿设定,并且继续用 AI 为这版草稿扩出新的角色和场景。** **让陶泥主直接修改这版草稿设定,并且继续用 AI 为这版草稿扩出新的角色和场景。**
一句话定义: 一句话定义:
@@ -100,7 +100,7 @@
一句话目标: 一句话目标:
**让第四阶段结束时,创作者第一次能像在真正做作品一样修改草稿、继续长出新对象。** **让第四阶段结束时,陶泥主第一次能像在真正做作品一样修改草稿、继续长出新对象。**
--- ---

View File

@@ -200,7 +200,7 @@
1. 主线关键角色 1. 主线关键角色
2. 可扮演角色 2. 可扮演角色
3. 创作者重点想看的角色 3. 陶泥主重点想看的角色
## 7.2 入口位置 ## 7.2 入口位置

View File

@@ -42,21 +42,21 @@
## 1.2 一句话定义 ## 1.2 一句话定义
创作者通过与一个懂 RPG 剧情策划方法的 Agent 对话,逐步完成世界锚点收集、关键对象塑造、剧情骨架搭建和长尾内容展开;同时由 Express 后端持续维护结构化世界状态、锁定边界、局部重生成和质量检查。 陶泥主通过与一个懂 RPG 剧情策划方法的 Agent 对话,逐步完成世界锚点收集、关键对象塑造、剧情骨架搭建和长尾内容展开;同时由 Express 后端持续维护结构化世界状态、锁定边界、局部重生成和质量检查。
## 1.3 目标用户 ## 1.3 目标用户
目标用户分三类: 目标用户分三类:
1.创作者 1.陶泥主
- 有世界灵感,但不擅长结构化填表 - 有世界灵感,但不擅长结构化填表
2. 中度创作者 2. 中度陶泥主
- 愿意精修角色、地点、主线第一幕,但不想维护大量底层字段 - 愿意精修角色、地点、主线第一幕,但不想维护大量底层字段
3. 重度创作者 3. 重度陶泥主
- 需要局部重生成、锁定、版本化和导出世界圣经 - 需要局部重生成、锁定、版本化和导出世界圣经
## 1.4 产品成功标准 ## 1.4 产品成功标准
@@ -76,7 +76,7 @@
1. 不把整套系统做成纯聊天黑箱。 1. 不把整套系统做成纯聊天黑箱。
2. 不让前端继续承担锁定合并、重生成裁决、结构编译等核心逻辑。 2. 不让前端继续承担锁定合并、重生成裁决、结构编译等核心逻辑。
3. 不要求创作者直接编辑 `ThemePack / WorldStoryGraph / VisibilitySlice / ThreadContract` 等运行时结构。 3. 不要求陶泥主直接编辑 `ThemePack / WorldStoryGraph / VisibilitySlice / ThreadContract` 等运行时结构。
4. 不把长项目世界管理完全交给一条无限增长的聊天记录。 4. 不把长项目世界管理完全交给一条无限增长的聊天记录。
5. 不再保留“生成完直接回世界列表并自动保存”的旧流程。 5. 不再保留“生成完直接回世界列表并自动保存”的旧流程。
6. 不允许角色主图、角色动作、场景背景图继续停留在临时候选状态后直接发布世界。 6. 不允许角色主图、角色动作、场景背景图继续停留在临时候选状态后直接发布世界。
@@ -151,7 +151,7 @@
1. `src/services/customWorldCreatorIntent.ts` 1. `src/services/customWorldCreatorIntent.ts`
- 已有创作者意图、锚点包、锁定状态的基础结构 - 已有陶泥主意图、锚点包、锁定状态的基础结构
2. `src/types/customWorld.ts` 2. `src/types/customWorld.ts`
@@ -228,7 +228,7 @@
后台必须持续维护: 后台必须持续维护:
1. 创作者意图 1. 陶泥主意图
2. 锁定状态 2. 锁定状态
3. 世界底稿快照 3. 世界底稿快照
4. 可编辑草稿对象列表 4. 可编辑草稿对象列表
@@ -271,11 +271,11 @@
-> 打开 Agent 创作入口 -> 打开 Agent 创作入口
-> Agent 收集最小锚点 -> Agent 收集最小锚点
-> Agent 输出首轮世界底稿 -> Agent 输出首轮世界底稿
-> 创作者锁定/修改关键内容 -> 陶泥主锁定/修改关键内容
-> Agent 局部生成关键角色/地点/主线第一幕 -> Agent 局部生成关键角色/地点/主线第一幕
-> 进入角色与场景资产工坊,生成主形象 / 动作 / 背景图 -> 进入角色与场景资产工坊,生成主形象 / 动作 / 背景图
-> Agent 扩展长尾内容 -> Agent 扩展长尾内容
-> 创作者发布世界 -> 陶泥主发布世界
-> 保存到世界库并进入世界 -> 保存到世界库并进入世界
``` ```
@@ -2077,4 +2077,4 @@ Agent 会话每次 operation 完成后自动保存 session snapshot。
这次新创作工具的正确方向,不是把现有工作台换成一个更大的聊天框,而是: 这次新创作工具的正确方向,不是把现有工作台换成一个更大的聊天框,而是:
**让 Agent 成为创作者的主交互入口,让 Express 后端成为真正的世界状态管理者,让锁定、局部重生成、摘要、质量护栏和发布链把整个创作过程牢牢收住。** **让 Agent 成为陶泥主的主交互入口,让 Express 后端成为真正的世界状态管理者,让锁定、局部重生成、摘要、质量护栏和发布链把整个创作过程牢牢收住。**

View File

@@ -37,15 +37,15 @@
## 1.3 目标用户 ## 1.3 目标用户
目标用户仍然是当前自定义世界创作工具的三类创作者,但本流程更偏向解决其中两类人的起步问题: 目标用户仍然是当前自定义世界创作工具的三类陶泥主,但本流程更偏向解决其中两类人的起步问题:
1.创作者 1.陶泥主
- 有模糊灵感,但不知道先想什么 - 有模糊灵感,但不知道先想什么
2. 中度创作者 2. 中度陶泥主
- 有一些设定点子,但缺少把设定收束成可运行剧情骨架的方法 - 有一些设定点子,但缺少把设定收束成可运行剧情骨架的方法
重度创作者也可使用本流程,但他们更关心的是: 重度陶泥主也可使用本流程,但他们更关心的是:
- Agent 是否会少问废话 - Agent 是否会少问废话
- 摘要是否准确 - 摘要是否准确
@@ -1190,7 +1190,7 @@ Agent 不应回复成八问表:
## 13.2 后续可编辑范围 ## 13.2 后续可编辑范围
进入世界底稿阶段后,创作者默认优先精修: 进入世界底稿阶段后,陶泥主默认优先精修:
1. 关键角色 1. 关键角色
2. 核心冲突与线程 2. 核心冲突与线程

View File

@@ -8,11 +8,11 @@
目标不是推翻当前已经存在的多阶段生成链,而是解决下面这个核心错位: 目标不是推翻当前已经存在的多阶段生成链,而是解决下面这个核心错位:
**当前仓库已经开始把世界生成拆成 `framework -> themePack -> storyGraph -> role outline -> dossier -> narrativeProfile` 的分阶段 AI 编译流程,但创作者入口仍然是“一段大文本”,结果页又把大量低杠杆字段重新扔回给创作者人工兜底。** **当前仓库已经开始把世界生成拆成 `framework -> themePack -> storyGraph -> role outline -> dossier -> narrativeProfile` 的分阶段 AI 编译流程,但陶泥主入口仍然是“一段大文本”,结果页又把大量低杠杆字段重新扔回给陶泥主人工兜底。**
一句话定义本次优化: 一句话定义本次优化:
**让创作者先定义世界灵魂锚点,再让 AI / 系统围绕锚点分层生成、分层展开、分层可控地完成长尾内容。** **让陶泥主先定义世界灵魂锚点,再让 AI / 系统围绕锚点分层生成、分层展开、分层可控地完成长尾内容。**
## 1. 当前流程现状 ## 1. 当前流程现状
@@ -64,7 +64,7 @@
## 1.3 当前流程的核心问题 ## 1.3 当前流程的核心问题
## 1.3.1 创作者入口过于粗糙 ## 1.3.1 陶泥主入口过于粗糙
当前创建入口只有一块大文本输入框。 当前创建入口只有一块大文本输入框。
@@ -72,23 +72,23 @@
1. 不会写长描述的用户很难开局。 1. 不会写长描述的用户很难开局。
2. 愿意精细创作的用户没有结构化落点。 2. 愿意精细创作的用户没有结构化落点。
3. 系统无法明确分辨“哪些是创作者真正想锁定的锚点,哪些只是随口补充的描述”。 3. 系统无法明确分辨“哪些是陶泥主真正想锁定的锚点,哪些只是随口补充的描述”。
结果就是: 结果就是:
**输入端自由但信息信号不稳定AI 虽然能生成很多内容,却不一定生成的是创作者真正关心的内容。** **输入端自由但信息信号不稳定AI 虽然能生成很多内容,却不一定生成的是陶泥主真正关心的内容。**
## 1.3.2 创作者与 AI 的职责发生倒置 ## 1.3.2 陶泥主与 AI 的职责发生倒置
当前流程实际上是: 当前流程实际上是:
- 创作者先写一段泛化设定 - 陶泥主先写一段泛化设定
- AI 再把整个世界铺满 - AI 再把整个世界铺满
- 创作者最后回到结果页,人工修改大量角色、章节、技能、初始物品、场景连接等细节 - 陶泥主最后回到结果页,人工修改大量角色、章节、技能、初始物品、场景连接等细节
这与“低创作门槛、高创作自由度”的目标相反。 这与“低创作门槛、高创作自由度”的目标相反。
因为真正应该由创作者控制的,是: 因为真正应该由陶泥主控制的,是:
- 世界核心命题 - 世界核心命题
- 主题与气质 - 主题与气质
@@ -98,7 +98,7 @@
- 关键地点 - 关键地点
- 标志性物件 / 怪物 / 禁忌 - 标志性物件 / 怪物 / 禁忌
而不是让创作者在结果页里逐个补: 而不是让陶泥主在结果页里逐个补:
- `backstoryReveal.chapters` - `backstoryReveal.chapters`
- `skills` - `skills`
@@ -117,13 +117,13 @@
问题不在数量本身,而在于系统并没有明确区分: 问题不在数量本身,而在于系统并没有明确区分:
1. 哪些是创作者应重点塑造的关键对象 1. 哪些是陶泥主应重点塑造的关键对象
2. 哪些只是 AI 应自动展开的长尾铺量 2. 哪些只是 AI 应自动展开的长尾铺量
这会导致两个问题: 这会导致两个问题:
1. AI 在早期就花大量成本生成长尾内容,等待时间长。 1. AI 在早期就花大量成本生成长尾内容,等待时间长。
2. 创作者在结果页里面对的是一整套“全部都生成了”的世界,而不是“先抓关键锚点,再决定是否继续铺开”。 2. 陶泥主在结果页里面对的是一整套“全部都生成了”的世界,而不是“先抓关键锚点,再决定是否继续铺开”。
## 1.3.4 当前结果页暴露了过多低杠杆字段 ## 1.3.4 当前结果页暴露了过多低杠杆字段
@@ -134,7 +134,7 @@
- 场景 NPC 分配 - 场景 NPC 分配
- 场景连接网络 - 场景连接网络
这对“专业创作者”当然有帮助,但对目标用户来说,容易把工具变成: 这对“专业陶泥主”当然有帮助,但对目标用户来说,容易把工具变成:
**看起来自由度很高,实际上需要承担很多系统编辑工作。** **看起来自由度很高,实际上需要承担很多系统编辑工作。**
@@ -144,11 +144,11 @@
这意味着: 这意味着:
1. 创作者一旦修改过内容,就会担心被覆盖。 1. 陶泥主一旦修改过内容,就会担心被覆盖。
2. 没有“锁定关键内容,只重生成长尾部分”的机制。 2. 没有“锁定关键内容,只重生成长尾部分”的机制。
3. AI 无法真正成为创作搭档,只像一次性大批量生成器。 3. AI 无法真正成为创作搭档,只像一次性大批量生成器。
## 1.3.6 当前生成阶段是“模型视角”,不是“创作者视角” ## 1.3.6 当前生成阶段是“模型视角”,不是“陶泥主视角”
当前生成页展示的是系统批次和阶段进度,这很好,但它主要回答的是: 当前生成页展示的是系统批次和阶段进度,这很好,但它主要回答的是:
@@ -156,7 +156,7 @@
没有回答的是: 没有回答的是:
- 创作者最关心的关键角色是否已经成型 - 陶泥主最关心的关键角色是否已经成型
- 世界冲突是否已经稳定 - 世界冲突是否已经稳定
- 当前这轮已经锁定了哪些核心创意 - 当前这轮已经锁定了哪些核心创意
- 接下来生成的是关键锚点,还是长尾内容 - 接下来生成的是关键锚点,还是长尾内容
@@ -170,19 +170,19 @@
这次优化要同时满足 6 个目标: 这次优化要同时满足 6 个目标:
1. 降低输入门槛 1. 降低输入门槛
- 不要求创作者一上来写长文,不要求理解系统字段。 - 不要求陶泥主一上来写长文,不要求理解系统字段。
2. 提高高杠杆创作自由度 2. 提高高杠杆创作自由度
-创作者直接控制世界灵魂锚点,而不是低价值细节。 -陶泥主直接控制世界灵魂锚点,而不是低价值细节。
3. 明确创作者与 AI 的职责边界 3. 明确陶泥主与 AI 的职责边界
- 创作者负责“决定什么值得创作”AI 负责“把它展开并跑起来”。 - 陶泥主负责“决定什么值得创作”AI 负责“把它展开并跑起来”。
4. 保留现有分阶段生成骨架 4. 保留现有分阶段生成骨架
- 不推翻 `framework -> themePack -> storyGraph -> role/landmark` 的已有结构。 - 不推翻 `framework -> themePack -> storyGraph -> role/landmark` 的已有结构。
5. 引入锁定与局部重生成 5. 引入锁定与局部重生成
-创作者能保住自己在乎的内容,只重做其余部分。 -陶泥主能保住自己在乎的内容,只重做其余部分。
6. 把结果页从“数据总表”升级成“创作工作台” 6. 把结果页从“数据总表”升级成“创作工作台”
- 让编辑界面按创作价值组织,而不是按底层对象堆字段。 - 让编辑界面按创作价值组织,而不是按底层对象堆字段。
@@ -192,11 +192,11 @@
优化后的自定义世界流程应该改为: 优化后的自定义世界流程应该改为:
```text ```text
创作者输入世界锚点 陶泥主输入世界锚点
-> AI 编译创作者意图摘要 -> AI 编译陶泥主意图摘要
-> 创作者确认 / 锁定关键锚点 -> 陶泥主确认 / 锁定关键锚点
-> AI 先生成关键角色与关键地点 -> AI 先生成关键角色与关键地点
-> 创作者可局部修改 / 局部重生成 -> 陶泥主可局部修改 / 局部重生成
-> AI 再展开长尾 NPC、长尾场景与运行时编译结构 -> AI 再展开长尾 NPC、长尾场景与运行时编译结构
-> 结果页以“锚点 / 关键对象 / 扩展内容 / 运行时摘要”方式组织 -> 结果页以“锚点 / 关键对象 / 扩展内容 / 运行时摘要”方式组织
-> 保存并进入世界 -> 保存并进入世界
@@ -204,7 +204,7 @@
一句话: 一句话:
**先做创作决策,再做内容展开;先做关键对象,再做长尾铺量;先让创作者锁定灵魂,再让 AI 扩散世界。** **先做创作决策,再做内容展开;先做关键对象,再做长尾铺量;先让陶泥主锁定灵魂,再让 AI 扩散世界。**
## 4. 输入层优化方案 ## 4. 输入层优化方案
@@ -251,7 +251,7 @@
2. 卡片模式 2. 卡片模式
- 用户直接按结构化方式输入世界锚点 - 用户直接按结构化方式输入世界锚点
两种模式最终都编译成统一的创作者意图对象。 两种模式最终都编译成统一的陶泥主意图对象。
## 4.3 必填与选填要分开 ## 4.3 必填与选填要分开
@@ -272,7 +272,7 @@
- 标志性要素 - 标志性要素
- 禁止事项 - 禁止事项
这样既能保证世界最小成型,又不会把创作者门槛抬高。 这样既能保证世界最小成型,又不会把陶泥主门槛抬高。
## 4.3.1 抽象统一“聊天补充设定”能力 ## 4.3.1 抽象统一“聊天补充设定”能力
@@ -307,11 +307,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
1. AI 不得在重生成时覆盖该内容 1. AI 不得在重生成时覆盖该内容
2. 长尾内容只能围绕它展开 2. 长尾内容只能围绕它展开
3. 结果页里应明确显示其为“创作者锚点” 3. 结果页里应明确显示其为“陶泥主锚点”
## 5. 生成链路优化方案 ## 5. 生成链路优化方案
## 5.1 新增“创作者意图编译层” ## 5.1 新增“陶泥主意图编译层”
在真正开始世界生成前,先新增一个轻量阶段: 在真正开始世界生成前,先新增一个轻量阶段:
@@ -324,19 +324,19 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
输出: 输出:
- 创作者意图摘要 - 陶泥主意图摘要
- 世界锚点摘要 - 世界锚点摘要
- 系统识别出的关键角色 / 冲突 / 地点 / 禁忌 - 系统识别出的关键角色 / 冲突 / 地点 / 禁忌
这一步的作用不是生成世界,而是先回答: 这一步的作用不是生成世界,而是先回答:
1. 系统理解到的世界核心是什么 1. 系统理解到的世界核心是什么
2. 哪些内容将被视为创作者强锚点 2. 哪些内容将被视为陶泥主强锚点
3. 哪些内容将交给 AI 扩展 3. 哪些内容将交给 AI 扩展
## 5.2 把当前生成链改成“关键先行、长尾后补” ## 5.2 把当前生成链改成“关键先行、长尾后补”
当前 `generateCustomWorldProfile(...)` 的分阶段结构可以保留,但生成顺序需要更创作者化。 当前 `generateCustomWorldProfile(...)` 的分阶段结构可以保留,但生成顺序需要更陶泥主化。
建议改成 5 层: 建议改成 5 层:
@@ -347,9 +347,9 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
- 世界框架 - 世界框架
- ThemePack - ThemePack
- StoryGraph 的基础版 - StoryGraph 的基础版
- 创作者锚点摘要 - 陶泥主锚点摘要
这一层完成后,系统应能让创作者看到: 这一层完成后,系统应能让陶泥主看到:
- 世界现在到底被理解成了什么 - 世界现在到底被理解成了什么
- 哪些冲突 / 势力 / 意象被识别出来了 - 哪些冲突 / 势力 / 意象被识别出来了
@@ -362,11 +362,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
- 关键场景角色 - 关键场景角色
- 关键地点 - 关键地点
这一层优先围绕创作者明确输入的角色和地点,而不是先铺满全部数量。 这一层优先围绕陶泥主明确输入的角色和地点,而不是先铺满全部数量。
### 第三层:创作者校对层 ### 第三层:陶泥主校对层
在继续展开长尾内容前,应允许创作者做一次轻量校对: 在继续展开长尾内容前,应允许陶泥主做一次轻量校对:
- 确认关键角色是否对 - 确认关键角色是否对
- 确认关键地点是否对 - 确认关键地点是否对
@@ -408,7 +408,7 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
这样做的价值很高: 这样做的价值很高:
1. 降低首次等待焦虑 1. 降低首次等待焦虑
2.创作者更早介入关键对象校正 2.陶泥主更早介入关键对象校正
3. 避免系统在创作方向还没稳定前,先铺满大量长尾内容 3. 避免系统在创作方向还没稳定前,先铺满大量长尾内容
## 5.4 角色与场景生成要改成“锚点优先 + 长尾补位” ## 5.4 角色与场景生成要改成“锚点优先 + 长尾补位”
@@ -417,11 +417,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
优化后应改为: 优化后应改为:
1. 先生成创作者明确指定的关键角色 / 地点 1. 先生成陶泥主明确指定的关键角色 / 地点
2. 再根据世界冲突自动补位缺失的角色原型和场景功能位 2. 再根据世界冲突自动补位缺失的角色原型和场景功能位
3. 最后再铺长尾 3. 最后再铺长尾
这样生成出来的世界会更像“围绕创作者意图长出来”,而不是“先生成了一个完整世界,再让创作者去认领” 这样生成出来的世界会更像“围绕陶泥主意图长出来”,而不是“先生成了一个完整世界,再让陶泥主去认领”
## 6. 结果页与编辑工作台优化方案 ## 6. 结果页与编辑工作台优化方案
@@ -439,7 +439,7 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
优化后建议改成 4 层工作台: 优化后建议改成 4 层工作台:
1. 创作锚点 1. 创作锚点
- 展示创作者输入和锁定内容 - 展示陶泥主输入和锁定内容
2. 关键对象 2. 关键对象
- 关键角色、关键地点、关键冲突对象 - 关键角色、关键地点、关键冲突对象
@@ -448,11 +448,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
- AI 自动展开的长尾角色、长尾地点、补位内容 - AI 自动展开的长尾角色、长尾地点、补位内容
4. 世界编译摘要 4. 世界编译摘要
- 展示世界线程、题材包、运行时摘要,但默认不要求创作者编辑 - 展示世界线程、题材包、运行时摘要,但默认不要求陶泥主编辑
## 6.2 编辑界面应遵守“高价值字段前置,低价值字段折叠” ## 6.2 编辑界面应遵守“高价值字段前置,低价值字段折叠”
创作者默认暴露的应是: 陶泥主默认暴露的应是:
- 角色一句话定位 - 角色一句话定位
- 角色表面面貌 - 角色表面面貌
@@ -507,7 +507,7 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
## 7.1 新增 `CustomWorldCreatorIntent` ## 7.1 新增 `CustomWorldCreatorIntent`
建议新增创作者输入的统一结构: 建议新增陶泥主输入的统一结构:
```ts ```ts
interface CustomWorldCreatorIntent { interface CustomWorldCreatorIntent {
@@ -529,7 +529,7 @@ interface CustomWorldCreatorIntent {
作用: 作用:
- 把“创作者真正输入了什么”从最终 `CustomWorldProfile` 中分离出来 - 把“陶泥主真正输入了什么”从最终 `CustomWorldProfile` 中分离出来
## 7.2 新增 `CustomWorldAnchorPack` ## 7.2 新增 `CustomWorldAnchorPack`
@@ -583,7 +583,7 @@ interface CustomWorldGenerationDraft {
作用: 作用:
- 让“创作者输入、AI 编译、结果编辑”成为连续工作流,而不是只有最终成品对象 - 让“陶泥主输入、AI 编译、结果编辑”成为连续工作流,而不是只有最终成品对象
## 8. 与当前仓库的接入建议 ## 8. 与当前仓库的接入建议
@@ -597,7 +597,7 @@ interface CustomWorldGenerationDraft {
目标: 目标:
- 把单 textarea 升级为“快速模式 + 卡片模式” - 把单 textarea 升级为“快速模式 + 卡片模式”
- 新增创作者意图状态 - 新增陶泥主意图状态
- 新增锁定和局部重生成入口 - 新增锁定和局部重生成入口
## 8.2 prompt 与生成服务层 ## 8.2 prompt 与生成服务层
@@ -623,7 +623,7 @@ interface CustomWorldGenerationDraft {
目标: 目标:
-`CustomWorldProfile` 增加创作者意图与锚点相关扩展字段 -`CustomWorldProfile` 增加陶泥主意图与锚点相关扩展字段
- 保持旧档兼容 - 保持旧档兼容
- 让现有 builder 能同时消费 `creatorIntent + anchorPack + profile seed` - 让现有 builder 能同时消费 `creatorIntent + anchorPack + profile seed`
@@ -647,28 +647,28 @@ interface CustomWorldGenerationDraft {
本次优化不做以下事情: 本次优化不做以下事情:
1. 不推翻当前自定义世界最终输出仍是 `CustomWorldProfile` 的兼容目标 1. 不推翻当前自定义世界最终输出仍是 `CustomWorldProfile` 的兼容目标
2. 不把所有运行时结构都暴露给创作者直接编辑 2. 不把所有运行时结构都暴露给陶泥主直接编辑
3. 不要求创作者理解 `themePack / storyGraph / knowledgeFacts / threadContracts` 等系统结构 3. 不要求陶泥主理解 `themePack / storyGraph / knowledgeFacts / threadContracts` 等系统结构
4. 不把复杂数值平衡、掉落预算、build 预算转移给创作者 4. 不把复杂数值平衡、掉落预算、build 预算转移给陶泥主
5. 不把“高自由度”理解成“所有字段都手工可改” 5. 不把“高自由度”理解成“所有字段都手工可改”
## 10. 验收标准 ## 10. 验收标准
做到以下几点,才算这次优化真正成立: 做到以下几点,才算这次优化真正成立:
1. 创作者可以不用写长文,只靠卡片输入也能完成自定义世界创建。 1. 陶泥主可以不用写长文,只靠卡片输入也能完成自定义世界创建。
2. 系统会明确区分“创作者锚点”和“AI 自动展开内容”。 2. 系统会明确区分“陶泥主锚点”和“AI 自动展开内容”。
3. 创作者不再需要默认手改大量 `skills / initialItems / backstoryReveal / scene connections` 才能得到可用世界。 3. 陶泥主不再需要默认手改大量 `skills / initialItems / backstoryReveal / scene connections` 才能得到可用世界。
4. 结果页支持锁定关键角色、关键地点、关键冲突,并支持局部重生成。 4. 结果页支持锁定关键角色、关键地点、关键冲突,并支持局部重生成。
5. 重新生成不再默认覆盖整个世界。 5. 重新生成不再默认覆盖整个世界。
6. 当前 `framework -> themePack -> storyGraph -> role/landmark` 生成主链可以继续复用,而不是被废弃。 6. 当前 `framework -> themePack -> storyGraph -> role/landmark` 生成主链可以继续复用,而不是被废弃。
7. 结果页默认展示的是高创作价值对象,而不是系统级低层字段。 7. 结果页默认展示的是高创作价值对象,而不是系统级低层字段。
8. 长尾内容生成明显后置于关键对象生成,创作者能更早看到并修正关键对象。 8. 长尾内容生成明显后置于关键对象生成,陶泥主能更早看到并修正关键对象。
9. 旧的自由文本输入模式仍然可用,但不再是唯一入口。 9. 旧的自由文本输入模式仍然可用,但不再是唯一入口。
## 11. 推荐落地顺序 ## 11. 推荐落地顺序
## 阶段 A先加创作者意图层 ## 阶段 A先加陶泥主意图层
先做: 先做:
@@ -678,7 +678,7 @@ interface CustomWorldGenerationDraft {
目标: 目标:
- 先把创作者输入从“单一大文本”升级成“可识别的创作锚点” - 先把陶泥主输入从“单一大文本”升级成“可识别的创作锚点”
## 阶段 B再加锚点包与锁定能力 ## 阶段 B再加锚点包与锁定能力
@@ -721,4 +721,4 @@ interface CustomWorldGenerationDraft {
当前自定义世界流程最需要优化的,不是“让 AI 再多生成一点内容”,而是: 当前自定义世界流程最需要优化的,不是“让 AI 再多生成一点内容”,而是:
**把创作者从低价值字段编辑里解放出来,让创作者负责世界灵魂锚点,让 AI 负责围绕这些锚点分层生成、分层展开、分层可控地把世界长出来。** **把陶泥主从低价值字段编辑里解放出来,让陶泥主负责世界灵魂锚点,让 AI 负责围绕这些锚点分层生成、分层展开、分层可控地把世界长出来。**

View File

@@ -13,7 +13,7 @@
-> 选择“拼图玩法” -> 选择“拼图玩法”
-> Agent 聊天收束高杠杆锚点 -> Agent 聊天收束高杠杆锚点
-> 生成拼图结果页 -> 生成拼图结果页
-> 创作者生成并确认拼图图片 -> 陶泥主生成并确认拼图图片
-> 发布到拼图广场 -> 发布到拼图广场
-> 玩家从广场进入第 1 关 -> 玩家从广场进入第 1 关
-> 全屏拼图运行时 -> 全屏拼图运行时
@@ -26,7 +26,7 @@
## 1. 一句话定义 ## 1. 一句话定义
创作者通过 Agent 对话确定拼图作品的高杠杆视觉锚点再由系统生成结果页、AI 生成拼图图片并发布到广场;玩家进入游戏后,在全屏拼图画布中通过交换、合并、拖动和拆分完成关卡,并沿着“相似题材优先、同作者次优先”的关卡链持续游玩。 陶泥主通过 Agent 对话确定拼图作品的高杠杆视觉锚点再由系统生成结果页、AI 生成拼图图片并发布到广场;玩家进入游戏后,在全屏拼图画布中通过交换、合并、拖动和拆分完成关卡,并沿着“相似题材优先、同作者次优先”的关卡链持续游玩。
--- ---
@@ -78,7 +78,7 @@
- 拼图关卡名 - 拼图关卡名
- AI 生成拼图图片的功能 - AI 生成拼图图片的功能
- 图片题材标签 - 图片题材标签
4. 创作者发布后的拼图作品必须进入平台广场。 4. 陶泥主发布后的拼图作品必须进入平台广场。
5. 玩家从广场进入某个作品时,第 1 关必须先显示当前作品本身。 5. 玩家从广场进入某个作品时,第 1 关必须先显示当前作品本身。
6. 第 2 关及以后必须按照“标签相似度权重 `70%` + 同作者权重 `30%`”选择下一关。 6. 第 2 关及以后必须按照“标签相似度权重 `70%` + 同作者权重 `30%`”选择下一关。
7. 游戏运行时必须全屏展示拼图画布。 7. 游戏运行时必须全屏展示拼图画布。
@@ -109,8 +109,8 @@
1. 不做旋转拼块。 1. 不做旋转拼块。
2. 不做异形拼块。 2. 不做异形拼块。
3. 不做时间限制和失败倒计时。 3. 初版不做时间限制和失败倒计时`2026-04-29` 起运行时升级为限时关卡,详见 `docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md`
4. 不做提示系统、道具系统和体力系统。 4. 初版不做提示系统、道具系统和体力系统`2026-04-29` 起先落地提示、查看原图、冻结时间三种拼图道具
5. 不做复杂剧情文本或玩法说明文本默认堆在 UI 中。 5. 不做复杂剧情文本或玩法说明文本默认堆在 UI 中。
6. 不做独立于平台创作中心之外的新创作站点。 6. 不做独立于平台创作中心之外的新创作站点。
7. 不做前端本地计算下一关推荐结果。 7. 不做前端本地计算下一关推荐结果。
@@ -129,7 +129,7 @@
创建拼图作品 创建拼图作品
-> Agent 聊天收束 5 个视觉锚点 -> Agent 聊天收束 5 个视觉锚点
-> 生成结果页 -> 生成结果页
-> 创作者确认关卡名、标签、图片 -> 陶泥主确认关卡名、标签、图片
-> 发布到拼图广场 -> 发布到拼图广场
``` ```
@@ -137,12 +137,12 @@
### 5.1.1 已发布作品二次编辑 ### 5.1.1 已发布作品二次编辑
创作者在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。 陶泥主在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。
落地规则: 落地规则:
1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。 1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。
2. 恢复后的结果页沿用原草稿、当前正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成图片。 2. 恢复后的结果页沿用原草稿、当前正式图、标题、摘要和标签;陶泥主可以继续改标题、摘要、标签,并重新生成图片。
3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId` 3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId`
4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。 4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。
5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。 5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。
@@ -210,9 +210,9 @@
拼图 Agent 必须做到: 拼图 Agent 必须做到:
1. 优先接住创作者的画面灵感,而不是立刻列问卷。 1. 优先接住陶泥主的画面灵感,而不是立刻列问卷。
2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。 2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。
3.创作者已经说出足够信息时,优先总结,不重复追问。 3.陶泥主已经说出足够信息时,优先总结,不重复追问。
4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。 4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。
- 该动作沿用 RPG 聊天链路,仍走发送消息接口,但请求体必须携带 `quickFillRequested: true` - 该动作沿用 RPG 聊天链路,仍走发送消息接口,但请求体必须携带 `quickFillRequested: true`
- 前端不补数据、不伪造锚点状态,只发送“请补充剩余关键字。”作为本轮用户消息。 - 前端不补数据、不伪造锚点状态,只发送“请补充剩余关键字。”作为本轮用户消息。
@@ -255,7 +255,7 @@ interface PuzzleAnchorPack {
## 7.1 结果页定位 ## 7.1 结果页定位
拼图结果页是创作者从 Agent 共创转入正式发布前的最小工作台。 拼图结果页是陶泥主从 Agent 共创转入正式发布前的最小工作台。
它至少承担 5 件事: 它至少承担 5 件事:
@@ -303,7 +303,7 @@ interface PuzzleAnchorPack {
关卡名生成规则建议如下: 关卡名生成规则建议如下:
1. 默认由 Agent 根据锚点自动生成 `1` 个正式候选名。 1. 默认由 Agent 根据锚点自动生成 `1` 个正式候选名。
2. 创作者可直接手改。 2. 陶泥主可直接手改。
3. 关卡名长度建议控制在 `4~12` 个中文字符。 3. 关卡名长度建议控制在 `4~12` 个中文字符。
4. 不允许空标题发布。 4. 不允许空标题发布。
@@ -374,8 +374,8 @@ interface PuzzleAnchorPack {
拼图图片的正式资产要求: 拼图图片的正式资产要求:
1. 官方拼图原图统一使用 `1:1` 正方形比例。 1. 官方拼图原图统一使用 `9:16` 竖屏比例。
2. 建议第一版正式生成尺寸为 `1536 x 1536` 2. 建议第一版正式生成尺寸为 `720 x 1280`
3. 图中不允许生成标题字、水印、边框、按钮或 UI。 3. 图中不允许生成标题字、水印、边框、按钮或 UI。
4. 图像主体必须足够清晰,切成 `4*4` 后仍然有辨识信息。 4. 图像主体必须足够清晰,切成 `4*4` 后仍然有辨识信息。
@@ -502,7 +502,7 @@ tagSimilarityScore =
画面要求: 画面要求:
1. 拼图舞台占满可用全屏区域 1. 拼图舞台占满可用全屏区域
2. 真正可操作的拼图棋盘按“最大正方形”填满安全区域 2. 真正可操作的拼图棋盘按 `9:16` 竖屏比例填满安全区域
3. 棋盘外延空间用同图模糊背景或纯净氛围底承接 3. 棋盘外延空间用同图模糊背景或纯净氛围底承接
4. 不默认堆玩法说明文字 4. 不默认堆玩法说明文字
@@ -641,6 +641,31 @@ V1 规则如下:
在正式实现中,建议以后端 `allTilesResolved = true` 作为唯一真相。 在正式实现中,建议以后端 `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. 运行时状态结构建议 ## 10. 运行时状态结构建议
@@ -1059,7 +1084,7 @@ interface PuzzleRunSnapshot {
建议布局: 建议布局:
1. 顶部轻量 HUD 1. 顶部轻量 HUD
2. 中间最大正方形拼图棋盘 2. 中间 `9:16` 竖屏拼图棋盘
3. 底部不常驻大段文案 3. 底部不常驻大段文案
如需操作提示,只允许短暂轻提示,不允许占据长期版面。 如需操作提示,只允许短暂轻提示,不允许占据长期版面。
@@ -1116,7 +1141,7 @@ interface PuzzleRunSnapshot {
完成标准: 完成标准:
1. 创作者能从平台进入拼图 Agent 工作区 1. 陶泥主能从平台进入拼图 Agent 工作区
2. 能通过聊天生成结果页草稿 2. 能通过聊天生成结果页草稿
## 阶段 B再做结果页与图片资产 ## 阶段 B再做结果页与图片资产
@@ -1130,7 +1155,7 @@ interface PuzzleRunSnapshot {
完成标准: 完成标准:
1. 创作者能生成正式拼图图片并发布 1. 陶泥主能生成正式拼图图片并发布
2. 作品能进入拼图广场 2. 作品能进入拼图广场
## 阶段 C再做拼图运行时核心循环 ## 阶段 C再做拼图运行时核心循环
@@ -1187,4 +1212,4 @@ interface PuzzleRunSnapshot {
这次平台新增拼图玩法,正确的做法不是只补一个拼图画布,而是: 这次平台新增拼图玩法,正确的做法不是只补一个拼图画布,而是:
**把拼图作为平台内独立玩法类型接进现有 Agent-first 创作中心、结果页、发布链、广场分发链和运行时链,让创作者先收束高杠杆视觉锚点,让玩家在全屏交换-合并-拆分的拼图循环中持续游玩。** **把拼图作为平台内独立玩法类型接进现有 Agent-first 创作中心、结果页、发布链、广场分发链和运行时链,让陶泥主先收束高杠杆视觉锚点,让玩家在全屏交换-合并-拆分的拼图循环中持续游玩。**

View File

@@ -630,7 +630,7 @@ SSE 事件:
1. 增加背景音乐和环境音,但不改变四帧三段主链。 1. 增加背景音乐和环境音,但不改变四帧三段主链。
2. 为移动端生成 `9:16` 竖版裁切版本。 2. 为移动端生成 `9:16` 竖版裁切版本。
3. 支持创作者手动上传某张关键帧,再生成相邻视频。 3. 支持陶泥主手动上传某张关键帧,再生成相邻视频。
4. 支持发布后版本化替换开场动画。 4. 支持发布后版本化替换开场动画。
5. 支持用第四幕直接生成开局场景动态背景。 5. 支持用第四幕直接生成开局场景动态背景。
6. 支持把开场动画拆出的关键帧回流为作品详情页轮播素材。 6. 支持把开场动画拆出的关键帧回流为作品详情页轮播素材。

View File

@@ -22,7 +22,7 @@
接成一条新的稳定流程: 接成一条新的稳定流程:
**每个场景由创作者在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。** **每个场景由陶泥主在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。**
本次还追加一条必须和草稿生成阶段一起落地的约束: 本次还追加一条必须和草稿生成阶段一起落地的约束:
@@ -31,13 +31,13 @@
补充口径修正: 补充口径修正:
1. `scene_chapter` 在本期继续保留为数据层 / 编译层 / 运行时层概念。 1. `scene_chapter` 在本期继续保留为数据层 / 编译层 / 运行时层概念。
2. `scene_chapter` 不作为创作者可见的独立 Tab、独立卡片或独立导航入口。 2. `scene_chapter` 不作为陶泥主可见的独立 Tab、独立卡片或独立导航入口。
3. 创作者配置多幕的唯一入口,是现有“场景”列表里的场景编辑弹层。 3. 陶泥主配置多幕的唯一入口,是现有“场景”列表里的场景编辑弹层。
4. 每一幕的 NPC 配置区必须直接叠在当前幕背景预览上,以“对面角色站位”的方式呈现;三个站位既是预览,也是编辑入口。 4. 每一幕的 NPC 配置区必须直接叠在当前幕背景预览上,以“对面角色站位”的方式呈现;三个站位既是预览,也是编辑入口。
5. 幕编辑站位中每个角色只显示角色形象与名称,不展示额外信息块、规则说明或说明性标签。 5. 幕编辑站位中每个角色只显示角色形象与名称,不展示额外信息块、规则说明或说明性标签。
6. 幕内小预览的构图固定为左侧玩家、右侧当前幕角色;右侧三个站位采用一前两后。 6. 幕内小预览的构图固定为左侧玩家、右侧当前幕角色;右侧三个站位采用一前两后。
前排主角色的 y 轴必须与玩家角色对齐后排两个角色必须同一列、x 轴对齐,上下分布,且后排整体的 y 轴中点与前排主角色保持一致。 前排主角色的 y 轴必须与玩家角色对齐后排两个角色必须同一列、x 轴对齐,上下分布,且后排整体的 y 轴中点与前排主角色保持一致。
7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待创作者补充。 7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待陶泥主补充。
8. 角色名称显示在角色形象上方,角色渲染不附带方形 UI 底板。 8. 角色名称显示在角色形象上方,角色渲染不附带方形 UI 底板。
9. 世界档案的场景详情页不再单独展示“场景图片”和“场景内 NPC”字段相关兼容数据统一由多幕配置自动同步回场景对象。 9. 世界档案的场景详情页不再单独展示“场景图片”和“场景内 NPC”字段相关兼容数据统一由多幕配置自动同步回场景对象。
@@ -55,7 +55,7 @@
本次迭代必须同时满足以下目标: 本次迭代必须同时满足以下目标:
1. 创作者可以在现有创作页面中为每个场景章节配置多幕内容。 1. 陶泥主可以在现有创作页面中为每个场景章节配置多幕内容。
2. 每一幕都必须绑定一张正式背景图。 2. 每一幕都必须绑定一张正式背景图。
3. 每一幕都可以配置玩家会遇到哪些 NPC并且保留顺序。 3. 每一幕都可以配置玩家会遇到哪些 NPC并且保留顺序。
4. 每一幕配置的第一个 NPC 必须被系统认定为该幕主角色。 4. 每一幕配置的第一个 NPC 必须被系统认定为该幕主角色。
@@ -89,7 +89,7 @@
1. 不新建独立的“场景编辑器”页面。 1. 不新建独立的“场景编辑器”页面。
2. 不把幕推进逻辑放到前端本地计算。 2. 不把幕推进逻辑放到前端本地计算。
3. 不让创作者直接编辑底层运行时 `ChapterState` 或聊天状态对象。 3. 不让陶泥主直接编辑底层运行时 `ChapterState` 或聊天状态对象。
4. 不做多 NPC 并行聊天。 4. 不做多 NPC 并行聊天。
5. 不做每一幕的复杂分支树可视化编辑器。 5. 不做每一幕的复杂分支树可视化编辑器。
6. 不把“规则说明文案”默认堆到创作页或游戏 UI 面板里。 6. 不把“规则说明文案”默认堆到创作页或游戏 UI 面板里。
@@ -122,7 +122,7 @@
1. 场景章节没有“幕”这一层结构化对象。 1. 场景章节没有“幕”这一层结构化对象。
2. 背景图是场景级资产,不是幕级资产。 2. 背景图是场景级资产,不是幕级资产。
3. NPC 与场景的关系主要还是地点级归属,不是幕级相遇编排。 3. NPC 与场景的关系主要还是地点级归属,不是幕级相遇编排。
4. 创作者无法在创作页里明确控制“这一幕谁先出场、谁是主角色”。 4. 陶泥主无法在创作页里明确控制“这一幕谁先出场、谁是主角色”。
## 4.2 游戏运行侧现状 ## 4.2 游戏运行侧现状
@@ -185,7 +185,7 @@
这意味着: 这意味着:
1. 创作者在工具里编辑的是“第几幕”。 1. 陶泥主在工具里编辑的是“第几幕”。
2. 运行时仍然只认现有章节阶段枚举。 2. 运行时仍然只认现有章节阶段枚举。
3. `chapterDirector` 可以继续复用,只是数据来源从“纯 quest 推导”升级成“quest + 幕蓝图联合推导”。 3. `chapterDirector` 可以继续复用,只是数据来源从“纯 quest 推导”升级成“quest + 幕蓝图联合推导”。
@@ -214,7 +214,7 @@
- `name` - `name`
- `description` - `description`
- `imageSrc` - `imageSrc`
- `sceneNpcIds`(仅作为兼容字段,由多幕配置自动派生,不再作为创作者可编辑字段) - `sceneNpcIds`(仅作为兼容字段,由多幕配置自动派生,不再作为陶泥主可编辑字段)
- `connections` - `connections`
- `sceneChapterBlueprints` 对应的多幕配置 - `sceneChapterBlueprints` 对应的多幕配置
2. 场景配置面板中,开局场景必须复用普通场景同级的配置 UI而不是继续保留一套缩水版表单。 2. 场景配置面板中,开局场景必须复用普通场景同级的配置 UI而不是继续保留一套缩水版表单。
@@ -251,7 +251,7 @@
原因: 原因:
1. 当前创作工作区已经进入“先收关键锚点、再逐步扩写”的阶段。 1. 当前创作工作区已经进入“先收关键锚点、再逐步扩写”的阶段。
2. 一次铺太多 playable、场景和长尾对象会稀释创作者对第一版底稿的掌控感。 2. 一次铺太多 playable、场景和长尾对象会稀释陶泥主对第一版底稿的掌控感。
3. 本期还要把幕级背景图和角色主形象自动挂回草稿,如果对象规模不收束,等待时间和生成成本都会直接失控。 3. 本期还要把幕级背景图和角色主形象自动挂回草稿,如果对象规模不收束,等待时间和生成成本都会直接失控。
### 5.5.2 幕级出演角色与背景必须由剧情引擎判定 ### 5.5.2 幕级出演角色与背景必须由剧情引擎判定
@@ -309,7 +309,7 @@
- 角色主形象是否就绪 - 角色主形象是否就绪
- 场景幕背景是否就绪 - 场景幕背景是否就绪
这样创作者一进入草稿精修工作区,就能直接看到: 这样陶泥主一进入草稿精修工作区,就能直接看到:
1. 角色已经带主形象 1. 角色已经带主形象
2. 每个场景章节的每一幕已经带背景图 2. 每个场景章节的每一幕已经带背景图
@@ -369,7 +369,7 @@ interface CustomWorldFoundationDraftSceneChapter {
1. `primaryNpcId` 必须等于 `encounterNpcIds[0]`,不允许单独填写成别的角色。 1. `primaryNpcId` 必须等于 `encounterNpcIds[0]`,不允许单独填写成别的角色。
2. 每幕必须至少有 `1` 个 NPC。 2. 每幕必须至少有 `1` 个 NPC。
3. 每幕必须有 `backgroundImageSrc``backgroundAssetId` 3. 每幕必须有 `backgroundImageSrc``backgroundAssetId`
4. `advanceRule` 由系统按幕位置默认编译,第一版不要求创作者手改。 4. `advanceRule` 由系统按幕位置默认编译,第一版不要求陶泥主手改。
## 6.2 发布到运行时的蓝图结构 ## 6.2 发布到运行时的蓝图结构
@@ -416,7 +416,7 @@ sceneChapterBlueprints?: SceneChapterBlueprint[] | null;
原因: 原因:
1. 现有 `landmarks` 只足够表达地点,不足够表达幕顺序。 1. 现有 `landmarks` 只足够表达地点,不足够表达幕顺序。
2. 现有 `ChapterState` 是运行时状态,不适合直接兼做创作者蓝图。 2. 现有 `ChapterState` 是运行时状态,不适合直接兼做陶泥主蓝图。
3. 独立蓝图层更适合后端编译和发布校验。 3. 独立蓝图层更适合后端编译和发布校验。
## 6.3 聊天状态扩展 ## 6.3 聊天状态扩展
@@ -483,9 +483,9 @@ type NpcChatTurnResult = {
新增规则: 新增规则:
1. 创作者从现有“场景”列表点击任一场景卡,进入对应场景编辑弹层。 1. 陶泥主从现有“场景”列表点击任一场景卡,进入对应场景编辑弹层。
2. 多幕配置必须作为场景编辑弹层内的一个区块出现,归属于该场景。 2. 多幕配置必须作为场景编辑弹层内的一个区块出现,归属于该场景。
3. `scene_chapter` 仅作为保存层和运行时蓝图存在,不单独暴露在创作者导航里。 3. `scene_chapter` 仅作为保存层和运行时蓝图存在,不单独暴露在陶泥主导航里。
4. 场景卡片可增加“幕数量”轻量摘要,但第一版不是阻塞项。 4. 场景卡片可增加“幕数量”轻量摘要,但第一版不是阻塞项。
## 7.2 场景编辑弹层展示要求 ## 7.2 场景编辑弹层展示要求
@@ -498,8 +498,8 @@ type NpcChatTurnResult = {
补充约束: 补充约束:
1. “场景图片”不再作为场景详情页里的独立字段展示,创作者只能通过每一幕的“配置背景”入口管理视觉。 1. “场景图片”不再作为场景详情页里的独立字段展示,陶泥主只能通过每一幕的“配置背景”入口管理视觉。
2. “场景内 NPC”不再作为场景详情页里的独立字段展示创作者只能通过每一幕角色槽位配置相遇 NPC。 2. “场景内 NPC”不再作为场景详情页里的独立字段展示陶泥主只能通过每一幕角色槽位配置相遇 NPC。
3. 为兼容现有运行时与旧数据结构,场景对象上的 `imageSrc / sceneNpcIds` 仍然保留,但必须由多幕配置自动回填,前台不再暴露单独编辑控件,且不能再用 `sceneNpcIds` 限制每幕可选角色。 3. 为兼容现有运行时与旧数据结构,场景对象上的 `imageSrc / sceneNpcIds` 仍然保留,但必须由多幕配置自动回填,前台不再暴露单独编辑控件,且不能再用 `sceneNpcIds` 限制每幕可选角色。
多幕区块至少展示: 多幕区块至少展示:
@@ -566,11 +566,11 @@ NPC 配置面板必须支持:
3. 不允许把不存在于当前世界角色池中的 id 写入幕配置。 3. 不允许把不存在于当前世界角色池中的 id 写入幕配置。
4. 若主角色未与当前场景或线程建立任何关联,给出发布警告。 4. 若主角色未与当前场景或线程建立任何关联,给出发布警告。
5. 存储时继续落到 `encounterNpcIds` 有序数组,槽位从左到右按顺序压缩写入。 5. 存储时继续落到 `encounterNpcIds` 有序数组,槽位从左到右按顺序压缩写入。
6. `sceneNpcIds` 不再作为创作者字段,也不再作为幕角色选择范围;保存时只从所有幕的 `encounterNpcIds` 自动派生兼容值。 6. `sceneNpcIds` 不再作为陶泥主字段,也不再作为幕角色选择范围;保存时只从所有幕的 `encounterNpcIds` 自动派生兼容值。
## 7.6 幕预览 ## 7.6 幕预览
创作者在场景编辑弹层里点击“幕预览”后,必须直接进入当前幕的运行时预览。 陶泥主在场景编辑弹层里点击“幕预览”后,必须直接进入当前幕的运行时预览。
要求如下: 要求如下:
@@ -633,7 +633,7 @@ interface SceneActRuntimeState {
## 8.3 幕推进规则 ## 8.3 幕推进规则
第一版不要求创作者手填推进条件,而是由系统按幕位置默认编译: 第一版不要求陶泥主手填推进条件,而是由系统按幕位置默认编译:
1.`1` 幕默认 `after_primary_contact` 1.`1` 幕默认 `after_primary_contact`
- 玩家与主角色发生首次有效接触后可进入下一幕判定 - 玩家与主角色发生首次有效接触后可进入下一幕判定
@@ -871,7 +871,7 @@ Adventure 主面板在本次迭代中至少增加下面这些表现:
当下面这些结果都成立时,视为本次 PRD 已被正确落地: 当下面这些结果都成立时,视为本次 PRD 已被正确落地:
1. 创作者可以在现有场景编辑弹层中配置每个场景的多幕。 1. 陶泥主可以在现有场景编辑弹层中配置每个场景的多幕。
2. 每个场景章节都可以配置 `2~5` 幕。 2. 每个场景章节都可以配置 `2~5` 幕。
3. 每一幕都可以绑定独立背景图。 3. 每一幕都可以绑定独立背景图。
4. 每一幕都可以配置有序 NPC 列表,第一位自动成为主角色。 4. 每一幕都可以配置有序 NPC 列表,第一位自动成为主角色。

View File

@@ -4,7 +4,7 @@
## 0. 目标 ## 0. 目标
把“剩余叙世币 / 总游戏时长 / 玩过作品”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。 把“剩余陶泥币 / 总游戏时长 / 玩过作品”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。
--- ---
@@ -12,7 +12,7 @@
当前三个数字来源并不统一: 当前三个数字来源并不统一:
1. 叙世币来自当前存档上下文,不等于账号总资产 1. 陶泥币来自当前存档上下文,不等于账号总资产
2. 总游戏时长依赖当前快照,不代表全账号累计 2. 总游戏时长依赖当前快照,不代表全账号累计
3. 玩过作品当前几乎是硬编码推导,不是真实统计 3. 玩过作品当前几乎是硬编码推导,不是真实统计
@@ -39,11 +39,11 @@
## 3. 指标定义 ## 3. 指标定义
## 3.1 剩余叙世 ## 3.1 剩余陶泥
定义: 定义:
- 当前账号可立即消费的叙世币余额 - 当前账号可立即消费的陶泥币余额
不使用: 不使用:
@@ -80,7 +80,7 @@
点击行为: 点击行为:
1. 叙世币卡 1. 陶泥币卡
- 打开资产流水抽屉 - 打开资产流水抽屉
2. 总游戏时长卡 2. 总游戏时长卡
- 打开游玩统计抽屉 - 打开游玩统计抽屉
@@ -92,8 +92,9 @@
## 4.2 展示规则 ## 4.2 展示规则
1. 数字过大时做单位缩略展示 1. 数字过大时做单位缩略展示
2. 进入页面先展示骨架屏 2. “总游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天
3. 数据请求失败时展示降级文案,不展示假数字 3. 进入页面先展示骨架屏
4. 数据请求失败时展示降级文案,不展示假数字
--- ---
@@ -123,7 +124,7 @@
返回: 返回:
- 叙世币流水列表 - 陶泥币流水列表
### `GET /api/profile/play-stats` ### `GET /api/profile/play-stats`
@@ -152,3 +153,4 @@
2. 切换设备后看板数据一致 2. 切换设备后看板数据一致
3. 没有存档时也能正常展示账号级数据 3. 没有存档时也能正常展示账号级数据
4. 数据加载失败时页面表现可控 4. 数据加载失败时页面表现可控
5. “总游戏时长”卡展示值始终带 `小时` 单位,例如 `0小时``1.5小时``36小时`

View File

@@ -73,7 +73,7 @@
首期奖励建议采用可控方案: 首期奖励建议采用可控方案:
1. 邀请人获得叙世 1. 邀请人获得陶泥
2. 被邀请人获得新手奖励 2. 被邀请人获得新手奖励
所有奖励必须走台账,不允许前端本地加值。 所有奖励必须走台账,不允许前端本地加值。
@@ -164,4 +164,4 @@
1. 用户能看到自己的邀请码与邀请链接 1. 用户能看到自己的邀请码与邀请链接
2. 可以一键复制或分享 2. 可以一键复制或分享
3. 邀请成功后能看到正确统计 3. 邀请成功后能看到正确统计
4. 奖励到账后叙世币余额同步变化 4. 奖励到账后陶泥币余额同步变化

View File

@@ -51,11 +51,11 @@
首期只保留两种状态: 首期只保留两种状态:
1. `普通用户` 1. `普通用户`
2. `叙世会员` 2. `陶泥会员`
会员权益首期建议控制在直接可编码的范围: 会员权益首期建议控制在直接可编码的范围:
1. 每日额外叙世币领取额度 1. 每日额外陶泥币领取额度
2. 高级世界模板或创作槽位 2. 高级世界模板或创作槽位
3. 更高的云存档上限 3. 更高的云存档上限
4. 会员专属标识 4. 会员专属标识
@@ -119,7 +119,7 @@
支付成功后: 支付成功后:
1. 刷新会员状态 1. 刷新会员状态
2. 刷新叙世币余额 2. 刷新陶泥币余额
3. 刷新权益标签 3. 刷新权益标签
--- ---

View File

@@ -8,7 +8,7 @@
1. 头像编辑 1. 头像编辑
2. 昵称编辑 2. 昵称编辑
3. 叙世号展示与复制 3. 陶泥号展示与复制
4. 登录方式与绑定状态展示 4. 登录方式与绑定状态展示
5. 进入资料编辑抽屉 5. 进入资料编辑抽屉
@@ -22,7 +22,7 @@
- 头像占位 - 头像占位
- 昵称 - 昵称
- 叙世 - 陶泥
- 登录方式 - 登录方式
- 绑定状态 - 绑定状态
@@ -31,7 +31,7 @@
1. 头像按钮和昵称编辑按钮都直接打开账号弹窗,信息架构混在一起 1. 头像按钮和昵称编辑按钮都直接打开账号弹窗,信息架构混在一起
2. 头像当前只是视觉壳,没有真正的上传与裁剪能力 2. 头像当前只是视觉壳,没有真正的上传与裁剪能力
3. 昵称缺少明确的编辑规则与唯一性策略 3. 昵称缺少明确的编辑规则与唯一性策略
4. 叙世号只是前端拼接值,不适合长期作为正式公开识别码 4. 陶泥号只是前端拼接值,不适合长期作为正式公开识别码
--- ---
@@ -43,7 +43,7 @@
2. 资料编辑抽屉 2. 资料编辑抽屉
3. 头像上传、裁切、保存 3. 头像上传、裁切、保存
4. 昵称编辑、校验、保存 4. 昵称编辑、校验、保存
5. 叙世号固定生成与复制 5. 陶泥号固定生成与复制
6. 登录方式与账号状态标签展示 6. 登录方式与账号状态标签展示
## 2.2 本期不做 ## 2.2 本期不做
@@ -63,7 +63,7 @@
- 用户头像 - 用户头像
- 用户昵称 - 用户昵称
- `叙世号` - `陶泥号`
- 登录方式标签 - 登录方式标签
- 账号状态标签 - 账号状态标签
- 资料编辑入口 - 资料编辑入口
@@ -85,7 +85,7 @@
- 打开“编辑资料”抽屉,并默认聚焦头像编辑区域 - 打开“编辑资料”抽屉,并默认聚焦头像编辑区域
2. 点击昵称右侧编辑按钮 2. 点击昵称右侧编辑按钮
- 打开“编辑资料”抽屉,并默认聚焦昵称输入框 - 打开“编辑资料”抽屉,并默认聚焦昵称输入框
3. 点击叙世号复制按钮 3. 点击陶泥号复制按钮
- 直接复制,并给出轻提示 - 直接复制,并给出轻提示
4. 点击登录方式/状态标签 4. 点击登录方式/状态标签
- 不跳页,不弹复杂说明 - 不跳页,不弹复杂说明
@@ -125,9 +125,9 @@
4. 不要求全站唯一,但要允许后端做敏感词审核 4. 不要求全站唯一,但要允许后端做敏感词审核
5. 审核失败时返回明确错误 5. 审核失败时返回明确错误
## 4.3 叙世 ## 4.3 陶泥
叙世号规则: 陶泥号规则:
1. 作为公开可复制识别码 1. 作为公开可复制识别码
2. 用户创建后固定生成,不允许用户修改 2. 用户创建后固定生成,不允许用户修改
@@ -207,6 +207,6 @@
1. 用户可以上传并保存头像 1. 用户可以上传并保存头像
2. 用户可以修改昵称并实时看到更新 2. 用户可以修改昵称并实时看到更新
3. 叙世号由后端返回,复制后可正常使用 3. 陶泥号由后端返回,复制后可正常使用
4. 未登录或待绑定状态下,不出现无效编辑入口 4. 未登录或待绑定状态下,不出现无效编辑入口
5. 页面不出现冗长规则说明文案 5. 页面不出现冗长规则说明文案

View File

@@ -35,7 +35,7 @@ TXT 模式核心玩法是一个包含“创作编辑器 -> 测试体验 -> 正
1. 支持创建 TXT 模式作品。 1. 支持创建 TXT 模式作品。
2. 支持 TXT 模式作品的完整创作流程。 2. 支持 TXT 模式作品的完整创作流程。
3. 支持创作者测试体验。 3. 支持陶泥主测试体验。
4. 支持玩家正式游玩。 4. 支持玩家正式游玩。
5. 支持文本模式运行。 5. 支持文本模式运行。
6. 支持双会话机制。 6. 支持双会话机制。
@@ -174,9 +174,9 @@ TXT 模式核心玩法必须完整保留双会话机制。
2. 正式继续体验 2. 正式继续体验
3. 正式游玩推进 3. 正式游玩推进
## 7.2 创作者测试/读档会话 ## 7.2 陶泥主测试/读档会话
创作者测试/读档会话用于: 陶泥主测试/读档会话用于:
1. 编辑器内测试体验 1. 编辑器内测试体验
2. 指定存档加载 2. 指定存档加载

View File

@@ -45,7 +45,7 @@
修复: 修复:
1.`map_password_entry_error(...)` 中补充 `InvalidPublicUserCode` 1.`map_password_entry_error(...)` 中补充 `InvalidPublicUserCode`
2. 返回中文错误文案 `叙世号格式不正确` 2. 返回中文错误文案 `陶泥号格式不正确`
### 3.3 `module-custom-world` 的 `Display` 分支未覆盖新字段错误 ### 3.3 `module-custom-world` 的 `Display` 分支未覆盖新字段错误

View File

@@ -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 负责钱包余额和流水的原子变更。 - SpacetimeDB 负责钱包余额和流水的原子变更。
- Axum 资产操作服务负责在执行业务资产操作前扣费,并在生成、持久化或发布失败时补偿退款。 - Axum 资产操作服务负责在执行业务资产操作前扣费,并在生成、持久化或发布失败时补偿退款。
@@ -24,13 +24,13 @@
暂不接入以下入口: 暂不接入以下入口:
- 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。 - 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。
- 手动上传封面:不调用外部生成模型,不消耗叙世币。 - 手动上传封面:不调用外部生成模型,不消耗陶泥币。
- 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。 - 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。
- 文本实体、NPC 生成:本次需求聚焦图片资产和发布资产操作,首期只覆盖可明确归属的入口。 - 文本实体、NPC 生成:本次需求聚焦图片资产和发布资产操作,首期只覆盖可明确归属的入口。
## 计费规则 ## 计费规则
- 每次可计费资产操作消耗 `1`叙世币。 - 每次可计费资产操作消耗 `1`陶泥币。
- 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行。 - 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行。
- 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作。 - 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作。
- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款。 - 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款。

View File

@@ -34,7 +34,7 @@ Stage 1 已把 Rust 鉴权快照同步到 SpacetimeDB 的 `auth_store_snapshot`
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `user_id` | `String` | 主键。 | | `user_id` | `String` | 主键。 |
| `public_user_code` | `String` | 公开叙世号。 | | `public_user_code` | `String` | 公开陶泥号。 |
| `username` | `String` | 当前账号用户名。 | | `username` | `String` | 当前账号用户名。 |
| `display_name` | `String` | 展示名。 | | `display_name` | `String` | 展示名。 |
| `phone_number_masked` | `Option<String>` | 脱敏手机号。 | | `phone_number_masked` | `Option<String>` | 脱敏手机号。 |

View File

@@ -37,7 +37,7 @@
- 当前场景的核心任务描述。 - 当前场景的核心任务描述。
- 文本会作为游戏中首次进入某个场景生成章节任务的关键上下文。 - 文本会作为游戏中首次进入某个场景生成章节任务的关键上下文。
- 必须结合场景描述、场景入口钩子、出场角色与 3 幕事件,说明玩家首次进入该场景时要完成什么。 - 必须结合场景描述、场景入口钩子、出场角色与 3 幕事件,说明玩家首次进入该场景时要完成什么。
- 世界档案的场景详情页必须直接展示该字段,便于创作者确认每个场景的默认章节任务。 - 世界档案的场景详情页必须直接展示该字段,便于陶泥主确认每个场景的默认章节任务。
### Landmark 生成源字段 ### Landmark 生成源字段

View File

@@ -6,33 +6,33 @@
本轮在“我的”页面的“会员充值”入口落地账户充值弹窗,包含两个页签: 本轮在“我的”页面的“会员充值”入口落地账户充值弹窗,包含两个页签:
1. `叙世币充值` 1. `陶泥币充值`
2. `会员卡充值` 2. `会员卡充值`
前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。当前没有真实支付网关,本轮采用服务端模拟支付成功:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。后续接入真实支付时,只替换订单支付状态推进,不改前端套餐与账户快照 contract。 前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。当前没有真实支付网关,本轮采用服务端模拟支付成功:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。后续接入真实支付时,只替换订单支付状态推进,不改前端套餐与账户快照 contract。
## 2. 产品规则 ## 2. 产品规则
### 2.1 叙世币充值套餐 ### 2.1 陶泥币充值套餐
| productId | 叙世币 | 金额分 | 徽标 | 说明 | | productId | 陶泥币 | 金额分 | 徽标 | 说明 |
| --- | ---: | ---: | --- | --- | | --- | ---: | ---: | --- | --- |
| `points_60` | 60 | 600 | 首充双倍 | 首充送60叙世币 | | `points_60` | 60 | 600 | 首充双倍 | 首充送60陶泥币 |
| `points_180` | 180 | 1800 | 首充双倍 | 首充送180叙世币 | | `points_180` | 180 | 1800 | 首充双倍 | 首充送180陶泥币 |
| `points_300` | 300 | 3000 | 首充双倍 | 首充送300叙世币 | | `points_300` | 300 | 3000 | 首充双倍 | 首充送300陶泥币 |
| `points_680` | 680 | 6800 | 首充双倍 | 首充送680叙世币 | | `points_680` | 680 | 6800 | 首充双倍 | 首充送680陶泥币 |
| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280叙世币 | | `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280陶泥币 |
| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280叙世币 | | `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 会员卡套餐 ### 2.2 会员卡套餐
| productId | 类型 | 天数 | 金额分 | 权益 | | productId | 类型 | 天数 | 金额分 | 权益 |
| --- | --- | ---: | ---: | --- | | --- | --- | ---: | ---: | --- |
| `member_month` | 月卡 | 30 | 2800 | 免叙世币回合数100每日签到加成0% | | `member_month` | 月卡 | 30 | 2800 | 免陶泥币回合数100每日签到加成0% |
| `member_season` | 季卡 | 90 | 7800 | 免叙世币回合数100每日签到加成100% | | `member_season` | 季卡 | 90 | 7800 | 免陶泥币回合数100每日签到加成100% |
| `member_year` | 年卡 | 365 | 24800 | 免叙世币回合数100每日签到加成210% | | `member_year` | 年卡 | 365 | 24800 | 免陶泥币回合数100每日签到加成210% |
购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。 购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。
@@ -42,8 +42,8 @@
需要 Bearer JWT。返回 需要 Bearer JWT。返回
1. 当前叙世币余额、会员状态、到期时间 1. 当前陶泥币余额、会员状态、到期时间
2. 叙世币套餐与会员套餐 2. 陶泥币套餐与会员套餐
3. 会员权益表 3. 会员权益表
4. 最近订单摘要 4. 最近订单摘要
@@ -64,7 +64,7 @@
1. 校验 `productId` 1. 校验 `productId`
2. 后端创建已支付订单 2. 后端创建已支付订单
3. 叙世币套餐写入钱包余额与流水 3. 陶泥币套餐写入钱包余额与流水
4. 会员套餐写入会员状态 4. 会员套餐写入会员状态
5. 返回最新账户中心快照与订单摘要 5. 返回最新账户中心快照与订单摘要
@@ -74,15 +74,15 @@
1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。 1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。
2. 弹窗顶部标题为 `账户充值`,右上角关闭。 2. 弹窗顶部标题为 `账户充值`,右上角关闭。
3. 默认打开 `叙世币充值`,可切换到 `会员卡充值` 3. 默认打开 `陶泥币充值`,可切换到 `会员卡充值`
4. 点击套餐后调用下单接口,按钮进入处理中状态,成功后刷新 `profileDashboard` 4. 点击套餐后调用下单接口,按钮进入处理中状态,成功后刷新 `profileDashboard`
5. 弹窗内不写大段说明文案,只保留必要金额、叙世币、会员权益和状态反馈。 5. 弹窗内不写大段说明文案,只保留必要金额、陶泥币、会员权益和状态反馈。
6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
## 5. 验收 ## 5. 验收
1. 普通用户打开弹窗能看到叙世币与会员套餐。 1. 普通用户打开弹窗能看到陶泥币与会员套餐。
2. 叙世币购买后余额增加,流水来源为 `points_recharge` 2. 陶泥币购买后余额增加,流水来源为 `points_recharge`
3. 首充赠送只在首次叙世币充值时生效。 3. 首充赠送只在首次陶泥币充值时生效。
4. 会员购买后会员状态与到期时间立即更新。 4. 会员购买后会员状态与到期时间立即更新。
5. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。 5. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。

View File

@@ -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<String>`,并在认证快照导入/导出、迁移导入兼容中对齐。
## 5. 验收
1. 创作页已发布作品分享按钮点击后显示 `已复制`
2. “我的”页陶泥号复制按钮点击后显示 `已复制`
3. “我的”页不展示 `手机号``正常` 标签。
4. 昵称编辑成功后,资料卡与顶部账号入口同步新昵称。
5. 非法头像文件不会进入裁剪流程。
6. 裁剪保存成功后,资料卡头像展示裁剪后的图片。

View File

@@ -1,20 +1,20 @@
# 我的 Tab 邀请与玩家社区首期落地方案 # 我的 Tab 邀请与玩家社区首期落地方案
更新时间:`2026-04-25` 更新时间:`2026-04-29`
## 目标 ## 目标
在现有“我的”Tab 常用功能区落地三个轻量入口: 在现有“我的”Tab 常用功能区落地三个轻量入口:
1. `邀请好友`:弹出面板展示当前账号绑定的邀请码。 1. `邀请好友`:弹出面板展示当前账号绑定的邀请码。
2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 叙世币。 2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 陶泥币。
3. `玩家社区`:弹出面板展示微信群与 QQ 群二维码占位图,后续替换为正式图片。 3. `玩家社区`:弹出面板展示微信群与 QQ 群正式二维码图片。
## 后端边界 ## 后端边界
- 邀请码、邀请关系与奖励发放全部存入 `server-rs/crates/spacetime-module` - 邀请码、邀请关系与奖励发放全部存入 `server-rs/crates/spacetime-module`
- Axum 只做鉴权、参数转发与响应映射,不在 API 层自行计算奖励。 - Axum 只做鉴权、参数转发与响应映射,不在 API 层自行计算奖励。
- 前端只读取后端状态与调用提交接口,不做本地加叙世币。 - 前端只读取后端状态与调用提交接口,不做本地加陶泥币。
- 钱包余额继续复用 `profile_dashboard_state.wallet_balance` - 钱包余额继续复用 `profile_dashboard_state.wallet_balance`
- 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型: - 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型:
- `invite_inviter_reward` - `invite_inviter_reward`
@@ -43,7 +43,7 @@
- 每个用户拥有一个稳定邀请码,首次进入邀请中心时自动生成。 - 每个用户拥有一个稳定邀请码,首次进入邀请中心时自动生成。
- 用户不能填写自己的邀请码。 - 用户不能填写自己的邀请码。
- 用户最多填写一个邀请码,成功后不可修改。 - 用户最多填写一个邀请码,成功后不可修改。
- 被邀请者绑定成功后获得 `30` 叙世币。 - 被邀请者绑定成功后获得 `30` 陶泥币。
- 邀请者每天最多获得 `10` 次邀请奖励,超过后关系仍可绑定,被邀请者仍获得奖励,邀请者当次不再加分。 - 邀请者每天最多获得 `10` 次邀请奖励,超过后关系仍可绑定,被邀请者仍获得奖励,邀请者当次不再加分。
- 每次奖励都写入钱包流水,钱包余额以后端返回为准。 - 每次奖励都写入钱包流水,钱包余额以后端返回为准。
@@ -69,13 +69,13 @@
- `server-rs/crates/spacetime-module` 已新增邀请码与邀请关系表,邀请中心读取和填码绑定均通过 SpacetimeDB procedure 执行。 - `server-rs/crates/spacetime-module` 已新增邀请码与邀请关系表,邀请中心读取和填码绑定均通过 SpacetimeDB procedure 执行。
- `server-rs/crates/api-server` 已挂接 `/api/runtime/profile/referrals/*``/api/profile/referrals/*` 两组路由。 - `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 常用功能区,不新增页面。 - 三个入口继续放在“我的”Tab 常用功能区,不新增页面。
- `邀请好友` 弹窗展示邀请码、复制按钮、邀请链接。 - `邀请好友` 弹窗展示邀请码、复制按钮、邀请链接。
- `填邀请码` 弹窗在未绑定时展示输入框;已绑定时展示短状态。 - `填邀请码` 弹窗在未绑定时展示输入框;已绑定时展示短状态。
- `玩家社区` 弹窗展示两个紧凑二维码占位区 - `玩家社区` 弹窗展示两个紧凑二维码图片区,保留微信群与 QQ 群短标签
- 弹窗文案只保留必要标签和短提示,不放长规则说明。 - 弹窗文案只保留必要标签和短提示,不放长规则说明。

View File

@@ -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` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。 > 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` 的最小接口。 1. `api-server` 对外只暴露 `phone + password` 的最小接口。
2. `module-auth` 只负责已存在手机号账号的密码校验。 2. `module-auth` 只负责已存在手机号账号的密码校验。
3. 密码入口不创建账号,不接收邮箱、用户名或叙世号。 3. 密码入口不创建账号,不接收邮箱、用户名或陶泥号。
4. 登录成功后与 JWT、refresh cookie 的衔接方式。 4. 登录成功后与 JWT、refresh cookie 的衔接方式。
## 1.1 当前冻结结论 ## 1.1 当前冻结结论
@@ -239,7 +239,7 @@
1. 未知手机号密码登录返回 `401`,且不创建账号。 1. 未知手机号密码登录返回 `401`,且不创建账号。
2. 已登录手机号账号设置密码后可用 `phone + password` 登录。 2. 已登录手机号账号设置密码后可用 `phone + password` 登录。
3. 同手机号错误密码返回 `401` 3. 同手机号错误密码返回 `401`
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400` 4. 邮箱、用户名或陶泥号作为密码登录标识返回 `400`
5. 登录成功时返回 access token。 5. 登录成功时返回 access token。
6. 登录成功时写回 refresh cookie。 6. 登录成功时写回 refresh cookie。
7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。 7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。

View File

@@ -19,7 +19,7 @@
沿用现有 `POST /api/auth/entry` 沿用现有 `POST /api/auth/entry`
1. 请求字段固定为 `phone``password`,前端只提交手机号。 1. 请求字段固定为 `phone``password`,前端只提交手机号。
2. 后端只按标准手机号归一化后查找账号,不兼容邮箱、用户名、叙世号或历史开发游客标识。 2. 后端只按标准手机号归一化后查找账号,不兼容邮箱、用户名、陶泥号或历史开发游客标识。
3. 手机号不存在时返回 `401`,不创建账号。 3. 手机号不存在时返回 `401`,不创建账号。
4. 手机号存在但未设置密码时返回 `401` 4. 手机号存在但未设置密码时返回 `401`
5. 校验成功后签发 access token并写入 refresh cookie。 5. 校验成功后签发 access token并写入 refresh cookie。

View File

@@ -0,0 +1,23 @@
# 陶泥产品命名替换落地说明
## 背景
本轮将产品中文展示名从“叙世”调整为“陶泥”,并同步调整平台内三类对外称谓:
- `叙世币` 对外展示为 `陶泥币`
- `叙世号` 对外展示为 `陶泥号`
- `创作者` 对外展示为 `陶泥主`
## 落地边界
1. 前端页面、弹窗、测试断言和后端返回给用户的中文错误文案统一使用新称谓。
2. SpacetimeDB 表字段、Rust/TypeScript contract 字段、流水来源枚举、`points_*` 商品 ID、`public_user_code` 字段名继续保持不变,避免引入数据库迁移和历史数据兼容风险。
3. 公开编号现有 `SY-XXXXXXXX` 格式本轮不迁移,只调整用户可见标签为“陶泥号”;编号格式如需改为新前缀,应另起迁移方案并同步老用户兼容策略。
4. 历史日志、构建产物、第三方依赖和生成绑定不参与本轮文本替换。
## 验收点
1. 首页、登录绑定页、我的页和搜索结果不再展示旧产品名。
2. 钱包、充值、邀请、兑换码、资产计费和拼图道具确认文案统一展示“陶泥币”。
3. 账号公开标识相关错误和搜索空状态统一展示“陶泥号”。
4. 创作相关可见默认称谓使用“陶泥主”。

View File

@@ -2,9 +2,9 @@
## 1. 目标 ## 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. 兑换码类型 ## 2. 兑换码类型
@@ -26,7 +26,7 @@
| --- | --- | --- | | --- | --- | --- |
| `code` | `String` | 主键,标准化后的兑换码。 | | `code` | `String` | 主键,标准化后的兑换码。 |
| `mode` | `RuntimeProfileRedeemCodeMode` | 兑换码模式。 | | `mode` | `RuntimeProfileRedeemCodeMode` | 兑换码模式。 |
| `reward_points` | `u64` | 单次到账叙世币。 | | `reward_points` | `u64` | 单次到账陶泥币。 |
| `max_uses` | `u32` | 公共码为单用户上限,唯一码/私有码为全局上限。 | | `max_uses` | `u32` | 公共码为单用户上限,唯一码/私有码为全局上限。 |
| `global_used_count` | `u32` | 全局已使用次数。公共码也记录总使用次数,但不参与公共码上限判断。 | | `global_used_count` | `u32` | 全局已使用次数。公共码也记录总使用次数,但不参与公共码上限判断。 |
| `enabled` | `bool` | 是否启用。 | | `enabled` | `bool` | 是否启用。 |
@@ -42,7 +42,7 @@
| `usage_id` | `String` | 主键,格式 `redeem:{code}:{user_id}:{micros}:{sequence}`。 | | `usage_id` | `String` | 主键,格式 `redeem:{code}:{user_id}:{micros}:{sequence}`。 |
| `code` | `String` | 兑换码。 | | `code` | `String` | 兑换码。 |
| `user_id` | `String` | 兑换用户。 | | `user_id` | `String` | 兑换用户。 |
| `amount_granted` | `u64` | 到账叙世币。 | | `amount_granted` | `u64` | 到账陶泥币。 |
| `created_at` | `Timestamp` | 兑换时间。 | | `created_at` | `Timestamp` | 兑换时间。 |
索引:`code``user_id``(code, user_id)` 索引:`code``user_id``(code, user_id)`
@@ -121,7 +121,7 @@
“我的”页头像右侧入口由 `会员充值` 改为 `兑换码`。点击打开独立模态窗口,窗口内只保留输入框、兑换按钮和后端返回提示,不展示兑换规则说明。 “我的”页头像右侧入口由 `会员充值` 改为 `兑换码`。点击打开独立模态窗口,窗口内只保留输入框、兑换按钮和后端返回提示,不展示兑换规则说明。
成功后展示 `已到账 X 叙世币`,并刷新 profile dashboard。失败后直接展示后端 `message` 成功后展示 `已到账 X 陶泥币`,并刷新 profile dashboard。失败后直接展示后端 `message`
## 8. 测试矩阵 ## 8. 测试矩阵

View File

@@ -2,7 +2,7 @@
## 1. 背景 ## 1. 背景
当前前端展示的“叙世号”由前端基于 `AuthUser.id` 临时拼装: 当前前端展示的“陶泥号”由前端基于 `AuthUser.id` 临时拼装:
- 前缀固定为 `SY-` - 前缀固定为 `SY-`
-`user.id``username` 去除非字母数字字符后的末 8 位 -`user.id``username` 去除非字母数字字符后的末 8 位
@@ -174,7 +174,7 @@
1. `id` 返回内部 ID 仅供当前工程内部跳转与资源读取使用,不在 UI 上直接暴露为文案 1. `id` 返回内部 ID 仅供当前工程内部跳转与资源读取使用,不在 UI 上直接暴露为文案
2. 不返回手机号、登录方式、绑定状态、tokenVersion 等敏感字段 2. 不返回手机号、登录方式、绑定状态、tokenVersion 等敏感字段
3. 未命中返回 `404` 3. 未命中返回 `404`
4. `by-id` 仅接受内部 `user_XXXXXXXX` 这类用户 ID用于工程内跳转、运营排查或已有资源引用不替代公开叙世号主搜索语义 4. `by-id` 仅接受内部 `user_XXXXXXXX` 这类用户 ID用于工程内跳转、运营排查或已有资源引用不替代公开陶泥号主搜索语义
## 5.2 广场作品公开编号搜索 ## 5.2 广场作品公开编号搜索
@@ -251,7 +251,7 @@
## 7.1 账号展示 ## 7.1 账号展示
当前首页资料卡和桌面顶部都展示前端拼装叙世号,改为: 当前首页资料卡和桌面顶部都展示前端拼装陶泥号,改为:
1. 直接展示 `authUi.user.publicUserCode` 1. 直接展示 `authUi.user.publicUserCode`
2. 复制按钮复制后端返回值 2. 复制按钮复制后端返回值
@@ -262,7 +262,7 @@
广场作品卡和详情页增加: 广场作品卡和详情页增加:
1. 作品号 `CW-XXXXXXXX` 1. 作品号 `CW-XXXXXXXX`
2. 作者叙世`SY-XXXXXXXX` 2. 作者陶泥`SY-XXXXXXXX`
展示要求: 展示要求:
@@ -284,7 +284,7 @@
用户搜索命中后的最小行为: 用户搜索命中后的最小行为:
1. 打开独立用户搜索结果面板或对话框 1. 打开独立用户搜索结果面板或对话框
2. 展示头像字母、显示名、叙世 2. 展示头像字母、显示名、陶泥
3. 提供“查看该作者作品”入口 3. 提供“查看该作者作品”入口
作品搜索命中后的行为: 作品搜索命中后的行为:
@@ -325,7 +325,7 @@
## 11. 当前落地说明 ## 11. 当前落地说明
1. 首页叙世号展示已优先读取后端 `publicUserCode`,原本基于 `AuthUser.id/username` 的前端拼装仅保留为兼容兜底,避免老会话未刷新时界面直接空白。 1. 首页陶泥号展示已优先读取后端 `publicUserCode`,原本基于 `AuthUser.id/username` 的前端拼装仅保留为兼容兜底,避免老会话未刷新时界面直接空白。
2. 用户公开搜索与广场作品公开搜索均已改为调用后端匿名接口,前端只负责输入、展示与跳转,不再自行决定最终编号格式。 2. 用户公开搜索与广场作品公开搜索均已改为调用后端匿名接口,前端只负责输入、展示与跳转,不再自行决定最终编号格式。
3. 自定义世界发布链路已改为从认证服务读取真实 `public_user_code` 写入作品真相与广场读模型,不再从内部 `user_id` 临时反推 `SY-XXXXXXXX` 3. 自定义世界发布链路已改为从认证服务读取真实 `public_user_code` 写入作品真相与广场读模型,不再从内部 `user_id` 临时反推 `SY-XXXXXXXX`
4. 当前作品号 `public_work_code` 仍采用基于 `profile_id` 的稳定 fallback 方案生成 `CW-XXXXXXXX`;若后续补独立计数表,需要在不改变读写接口的前提下替换生成来源。 4. 当前作品号 `public_work_code` 仍采用基于 `profile_id` 的稳定 fallback 方案生成 `CW-XXXXXXXX`;若后续补独立计数表,需要在不改变读写接口的前提下替换生成来源。

View File

@@ -201,7 +201,7 @@ Rust DTO 只承载对前端公开的 HTTP contract不直接泄露 `module-puz
1. 每次生成 2 张候选图。 1. 每次生成 2 张候选图。
2. 候选图通过 `api-server` 写入 OSS兼容展示路径统一为 `/generated-puzzle-assets/...`,禁止再落到仓库 `public/` 目录。 2. 候选图通过 `api-server` 写入 OSS兼容展示路径统一为 `/generated-puzzle-assets/...`,禁止再落到仓库 `public/` 目录。
3. Axum 把候选图 URL、assetId、prompt snapshot 回写到 Spacetime session draft。 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 发布前编辑真相补充 ### 6.1 发布前编辑真相补充
结果页允许创作者在发布前直接编辑: 结果页允许陶泥主在发布前直接编辑:
1. `关卡名` 1. `关卡名`
2. `摘要` 2. `摘要`

View File

@@ -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` 传入 SpacetimeDBSpacetimeDB 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. 发布、作品测试、自动保存标题、画面描述和标签仍可用。

View File

@@ -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` 展示和游玩,不阻断历史作品。

View File

@@ -4,7 +4,7 @@
拼图结果页此前存在两个串联问题: 拼图结果页此前存在两个串联问题:
1. 创作者在结果页修改 `关卡名`、新增标签、删除标签,只会改前端本地 `editState`,不会立即写回拼图作品 profile。 1. 陶泥主在结果页修改 `关卡名`、新增标签、删除标签,只会改前端本地 `editState`,不会立即写回拼图作品 profile。
2. 发布弹窗同时混用了旧 session 内的 `publishReady` 与前端本地编辑态,导致标签已经在界面里补够,但发布校验仍然盯着旧草稿里的标签数量,用户无法通过发布检验。 2. 发布弹窗同时混用了旧 session 内的 `publishReady` 与前端本地编辑态,导致标签已经在界面里补够,但发布校验仍然盯着旧草稿里的标签数量,用户无法通过发布检验。
这会直接破坏拼图创作主链的可用性:用户明明已经在结果页补齐正式标签,却因为没有自动保存、也没有按当前编辑态重算门槛而卡在发布前。 这会直接破坏拼图创作主链的可用性:用户明明已经在结果页补齐正式标签,却因为没有自动保存、也没有按当前编辑态重算门槛而卡在发布前。

View File

@@ -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能安全补默认值的字段应在服务端契约层统一兜底。

View File

@@ -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. 新合并产生时,在新大块中心播放一次简洁闪光,不显示文字提示。

View File

@@ -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_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_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` 生成世界底稿。 - [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` 生成世界底稿。

View File

@@ -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` 重启后端。

View File

@@ -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` 缺失时仍显示敌方名称和血条。

View File

@@ -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. 编码检查、定向测试和类型检查通过。

View File

@@ -0,0 +1,19 @@
# RPG 运行态队伍 / 背包面板信息精简2026-04-29
## 背景
运行态队伍与背包都属于冒险过程中的辅助弹出面板,移动端优先要求是快速查看成员状态、物品格子与工坊操作。当前队伍面板在成员列表上方额外展示活跃任务,背包面板在格子上方额外展示旅程回顾,会把首屏焦点从“队伍成员 / 背包物品”推开。
## 落地边界
1. 队伍面板删除成员列表上方的任务信息模块。
2. 背包面板删除物品格子上方的旅程回顾模块。
3. 不删除任务系统、旅程回顾数据或冒险页里的任务提示,只调整这两个辅助面板的展示入口。
4. 父级不再向这两个面板传入已经不展示的字段,避免保留无效 UI 契约。
## 验收
1. 打开队伍面板后,顶部直接进入“队伍成员”列表。
2. 打开背包面板后,顶部直接进入物品格子。
3. 任务状态仍由冒险主面板和任务弹层承担。
4. `continueGameDigest` 数据仍保留在运行态状态中,后续可在更合适的独立入口展示。

View File

@@ -2,7 +2,7 @@
## 背景 ## 背景
世界创作结果页已经提供“作品测试”入口,但测试运行时此前缺少与“幕预览”一致的显式退出按钮。创作者进入测试后只能依赖浏览器返回、刷新或其他间接链路离开,不符合独立运行时面板的交互语义。 世界创作结果页已经提供“作品测试”入口,但测试运行时此前缺少与“幕预览”一致的显式退出按钮。陶泥主进入测试后只能依赖浏览器返回、刷新或其他间接链路离开,不符合独立运行时面板的交互语义。
## 本次约束 ## 本次约束

View File

@@ -2,7 +2,7 @@
## 背景 ## 背景
幕预览和测试作品用于创作者检查玩法表现,不能被当作玩家正式游玩记录。若这类运行时复用正式 RPG 壳、story action 或 snapshot 接口,必须在进入个人存档页、游玩统计、作品游玩历史前被过滤。 幕预览和测试作品用于陶泥主检查玩法表现,不能被当作玩家正式游玩记录。若这类运行时复用正式 RPG 壳、story action 或 snapshot 接口,必须在进入个人存档页、游玩统计、作品游玩历史前被过滤。
## 落地约束 ## 落地约束

View File

@@ -10,7 +10,7 @@
1. 草稿层可以承载 `scene chapter / scene act` 1. 草稿层可以承载 `scene chapter / scene act`
2. 后端可以把 `scene_chapter` 编译成正式蓝图 2. 后端可以把 `scene_chapter` 编译成正式蓝图
3. 创作者可以在现有场景编辑弹层里看到并编辑多幕配置 3. 陶泥主可以在现有场景编辑弹层里看到并编辑多幕配置
4. 编辑后的幕信息可以正确写回 `sceneChapterBlueprints` 4. 编辑后的幕信息可以正确写回 `sceneChapterBlueprints`
5. 运行时共享层先具备读取幕背景、主角色、相遇 NPC 池的基础能力 5. 运行时共享层先具备读取幕背景、主角色、相遇 NPC 池的基础能力
6. 当前幕主角色的负好感 `5` 轮聊天限制先形成首个可运行闭环 6. 当前幕主角色的负好感 `5` 轮聊天限制先形成首个可运行闭环
@@ -60,7 +60,7 @@
前端已完成第一批接入: 前端已完成第一批接入:
1. `scene_chapter` 不再作为独立 Tab / 独立卡片暴露给创作者 1. `scene_chapter` 不再作为独立 Tab / 独立卡片暴露给陶泥主
2. 多幕配置已内嵌到 `CustomWorldEntityEditorModal.tsx``LandmarkEditor` 2. 多幕配置已内嵌到 `CustomWorldEntityEditorModal.tsx``LandmarkEditor`
3. 单幕编辑已从文本表单切成“背景大图预览 + 3 个角色槽位”的轻量交互 3. 单幕编辑已从文本表单切成“背景大图预览 + 3 个角色槽位”的轻量交互
4. “幕标题 / 幕摘要 / 幕目标 / 过渡钩子”已从场景手工编辑区移除,继续留在草稿生成与编译层 4. “幕标题 / 幕摘要 / 幕目标 / 过渡钩子”已从场景手工编辑区移除,继续留在草稿生成与编译层
@@ -88,7 +88,7 @@
7. 幕预览运行时已补 custom world NPC 的视觉兜底链路,优先使用 `visual / imageSrc` 渲染,避免角色形象或动画空白 7. 幕预览运行时已补 custom world NPC 的视觉兜底链路,优先使用 `visual / imageSrc` 渲染,避免角色形象或动画空白
8. 当前幕小预览已调整为左侧玩家、右侧敌对/相遇角色的构图NPC 站位采用一前两后 8. 当前幕小预览已调整为左侧玩家、右侧敌对/相遇角色的构图NPC 站位采用一前两后
前排主角色与玩家角色保持同一 y 轴后排两个角色改为同一列、x 轴对齐并上下分布,且后排整体 y 轴中点与前排主角色一致 前排主角色与玩家角色保持同一 y 轴后排两个角色改为同一列、x 轴对齐并上下分布,且后排整体 y 轴中点与前排主角色一致
9. 新增幕默认只带 1 个主角色,后续槽位由创作者按需补充 9. 新增幕默认只带 1 个主角色,后续槽位由陶泥主按需补充
10. 小预览里的名字已移动到角色头顶,角色渲染不再带方形底板,避免遮挡场景背景 10. 小预览里的名字已移动到角色头顶,角色渲染不再带方形底板,避免遮挡场景背景
11. 幕预览复用真实游戏壳时隐藏左上角角色等级徽标,退出入口固定在上方画面区域底部居中,并使用“结束预览”作为操作文案 11. 幕预览复用真实游戏壳时隐藏左上角角色等级徽标,退出入口固定在上方画面区域底部居中,并使用“结束预览”作为操作文案
12. 创作侧场景列表封面、多幕配置卡片、配置背景弹层统一读取同一张场景显示图;在任一幕保存背景时同步回全部幕背景字段和场景兼容图,避免同一场景在不同层级出现不同预览图 12. 创作侧场景列表封面、多幕配置卡片、配置背景弹层统一读取同一张场景显示图;在任一幕保存背景时同步回全部幕背景字段和场景兼容图,避免同一场景在不同层级出现不同预览图

View File

@@ -409,7 +409,7 @@ Access-Control-Allow-Credentials: true
职责: 职责:
- 面向创作者、运营、内部编辑器 - 面向陶泥主、运营、内部编辑器
- 必须鉴权 - 必须鉴权
- 必须审计 - 必须审计
- 不建议对公网完全开放 - 不建议对公网完全开放
@@ -469,7 +469,7 @@ flowchart TD
当出现这些需求时,再进入下一阶段: 当出现这些需求时,再进入下一阶段:
- 多人同时在线 - 多人同时在线
-创作者协作 -陶泥主协作
- 图片/视频生成任务变多 - 图片/视频生成任务变多
- 需要账号体系、存档、云同步 - 需要账号体系、存档、云同步
- 需要审计和版本回滚 - 需要审计和版本回滚

View File

@@ -206,7 +206,7 @@
1. 密码登录仍由 `user_account.password_hash` 承担 1. 密码登录仍由 `user_account.password_hash` 承担
2. 本轮不引入 `password` provider identity 2. 本轮不引入 `password` provider identity
3. 密码登录只接受已绑定手机号的账号,不支持邮箱、用户名或叙世号作为登录身份 3. 密码登录只接受已绑定手机号的账号,不支持邮箱、用户名或陶泥号作为登录身份
4. 密码登录不创建账号,新账号只由手机号验证码登录创建 4. 密码登录不创建账号,新账号只由手机号验证码登录创建
### 9.2 `POST /api/auth/phone/login` ### 9.2 `POST /api/auth/phone/login`

View File

@@ -13,6 +13,8 @@ SpacetimeDB reducer 必须保持确定性,不能访问文件系统和网络。
procedure 不再访问 HTTP 文件桥,也不接收部署机本地文件路径。这样可以避开 SpacetimeDB 对 private/special-purpose 地址的 HTTP 访问限制,并避免把 private 表内容通过临时 HTTP 服务转发。 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` `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。 4. 导出成功后执行清库发布新 wasm。
5. 新 wasm 发布成功后,把第 3 步导出的 JSON 自动导入回灌。 5. 新 wasm 发布成功后,把第 3 步导出的 JSON 自动导入回灌。
SpacetimeDB 2.1 对 schema 冲突的报错文案可能不再包含 `schema conflict`,而是直接提示 `manual migration``default value annotation``--delete-data`。发布脚本必须把这些文案同样识别为可迁移冲突,否则会停在原始失败而不进入导出回灌流程。
新增字段优先采用低风险热升级策略:旧字段顺序保持不变,新字段追加到表尾,并用 `#[default(...)]` 提供旧行默认值。只有仍无法通过发布器检查时,才执行清库发布与 JSON 回灌。
任一阶段失败都会中止流程,并保留已经导出的迁移 JSON。非 schema 冲突的发布失败不会进入迁移流程。 任一阶段失败都会中止流程,并保留已经导出的迁移 JSON。非 schema 冲突的发布失败不会进入迁移流程。
```bash ```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` - 自定义世界:`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` - 资产索引:`asset_object``asset_entity_binding`
- 拼图:`puzzle_agent_session``puzzle_agent_message``puzzle_work_profile``puzzle_runtime_run` - 拼图:`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 表时,必须同步把表加入迁移白名单与本文档。 后续新增 SpacetimeDB 表时,必须同步把表加入迁移白名单与本文档。

View File

@@ -21,7 +21,7 @@ spacetime sql <db> "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` | | 运行时档案 | `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` | | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` |
@@ -46,8 +46,8 @@ SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default';
### `user_account` ### `user_account`
- 作用:用户账号主表,保存用户名、公开叙世号、手机号掩码、登录方式、密码登录开关和 token 版本。 - 作用:用户账号主表,保存用户名、公开陶泥号、手机号掩码、登录方式、密码登录开关和 token 版本。
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: Option<String>`, `password_login_enabled: bool`, `token_version: u64` - 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option<String>`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: Option<String>`, `password_login_enabled: bool`, `token_version: u64`
- 索引:`username`, `public_user_code` - 索引:`username`, `public_user_code`
```sql ```sql
@@ -135,7 +135,7 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created
### `profile_redeem_code` ### `profile_redeem_code`
- 作用:运营发放的叙世币兑换码,支持公共码、唯一码和私有码。 - 作用:运营发放的陶泥币兑换码,支持公共码、唯一码和私有码。
- 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec<String>`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp` - 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec<String>`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `code` - 索引:主键 `code`

View File

@@ -39,7 +39,7 @@
- 发布 - 发布
3. 完整复制外部 TXT 模式的运行机制: 3. 完整复制外部 TXT 模式的运行机制:
- 玩家游玩会话 - 玩家游玩会话
- 创作者测试/读档会话 - 陶泥主测试/读档会话
- 流式动作执行 - 流式动作执行
- 文本模式显示 - 文本模式显示
- 历史记录 - 历史记录
@@ -99,7 +99,7 @@
- 属性面板 - 属性面板
5. 双会话机制: 5. 双会话机制:
- 玩家游玩会话 - 玩家游玩会话
- 创作者测试/读档会话 - 陶泥主测试/读档会话
6. 流式动作接口与事件协议: 6. 流式动作接口与事件协议:
- `start` - `start`
- `raw_text` - `raw_text`
@@ -551,7 +551,7 @@ TXT 模式后续必须完整落地双会话机制:
1. 玩家游玩会话 1. 玩家游玩会话
- 对应外部 `POST /api/optical/games/session/create` - 对应外部 `POST /api/optical/games/session/create`
- 用于正式游玩 - 用于正式游玩
2. 创作者测试/读档会话 2. 陶泥主测试/读档会话
- 对应外部 `POST /api/visual/session/create` - 对应外部 `POST /api/visual/session/create`
- 用于测试体验与加载指定存档 - 用于测试体验与加载指定存档

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>叙世</title> <title>陶泥</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View File

@@ -1,5 +1,5 @@
{ {
"name": "叙世", "name": "陶泥",
"description": "一个 AI 原生的武侠/仙侠 RPG 游戏,具有动态剧情生成和横版卷轴视觉效果。", "description": "一个 AI 原生的武侠/仙侠 RPG 游戏,具有动态剧情生成和横版卷轴视觉效果。",
"requestFramePermissions": [] "requestFramePermissions": []
} }

View File

@@ -6,6 +6,7 @@ export type AuthUser = {
publicUserCode: string; publicUserCode: string;
username: string; username: string;
displayName: string; displayName: string;
avatarUrl: string | null;
phoneNumberMasked: string | null; phoneNumberMasked: string | null;
loginMethod: AuthLoginMethod; loginMethod: AuthLoginMethod;
bindingStatus: AuthBindingStatus; bindingStatus: AuthBindingStatus;
@@ -16,6 +17,7 @@ export type PublicUserSummary = {
id: string; id: string;
publicUserCode: string; publicUserCode: string;
displayName: string; displayName: string;
avatarUrl: string | null;
}; };
export type PublicUserSearchResponse = { export type PublicUserSearchResponse = {
@@ -41,6 +43,15 @@ export type AuthPasswordChangeResponse = {
user: AuthUser; user: AuthUser;
}; };
export type AuthProfileUpdateRequest = {
displayName?: string;
avatarDataUrl?: string;
};
export type AuthProfileUpdateResponse = {
user: AuthUser;
};
export type AuthPasswordResetRequest = { export type AuthPasswordResetRequest = {
phone: string; phone: string;
code: string; code: string;

View File

@@ -19,6 +19,7 @@ export interface BigFishWorkSummary {
playCount?: number; playCount?: number;
remixCount?: number; remixCount?: number;
likeCount?: number; likeCount?: number;
recentPlayCount7d?: number;
} }
export interface BigFishWorksResponse { export interface BigFishWorksResponse {

View File

@@ -39,7 +39,12 @@ export interface PuzzleAgentOperationRecord {
} }
export type PuzzleAgentActionRequest = export type PuzzleAgentActionRequest =
| { action: 'compile_puzzle_draft' } | {
action: 'compile_puzzle_draft';
promptText?: string | null;
referenceImageSrc?: string | null;
candidateCount?: number;
}
| { | {
action: 'generate_puzzle_images'; action: 'generate_puzzle_images';
promptText?: string | null; promptText?: string | null;

View File

@@ -22,7 +22,7 @@ export interface PuzzleAnchorPack {
} }
export interface PuzzleCreatorIntent { export interface PuzzleCreatorIntent {
sourceMode: 'agent_chat'; sourceMode: 'agent_chat' | 'form';
rawMessagesSummary: string; rawMessagesSummary: string;
themePromise: string; themePromise: string;
visualSubject: string; visualSubject: string;

View File

@@ -42,6 +42,8 @@ export interface PuzzleAgentSessionSnapshot {
export interface CreatePuzzleAgentSessionRequest { export interface CreatePuzzleAgentSessionRequest {
seedText?: string; seedText?: string;
pictureDescription?: string;
referenceImageSrc?: string | null;
} }
export interface CreatePuzzleAgentSessionResponse { export interface CreatePuzzleAgentSessionResponse {

View File

@@ -27,6 +27,10 @@ export interface PuzzleLeaderboardEntry {
isCurrentPlayer?: boolean; isCurrentPlayer?: boolean;
} }
export type PuzzleRuntimeLevelStatus = 'playing' | 'cleared' | 'failed';
export type PuzzleRuntimePropKind = 'hint' | 'reference' | 'freezeTime';
export interface PuzzleBoardSnapshot { export interface PuzzleBoardSnapshot {
rows: number; rows: number;
cols: number; cols: number;
@@ -46,10 +50,17 @@ export interface PuzzleRuntimeLevelSnapshot {
themeTags: string[]; themeTags: string[];
coverImageSrc: string | null; coverImageSrc: string | null;
board: PuzzleBoardSnapshot; board: PuzzleBoardSnapshot;
status: 'playing' | 'cleared'; status: PuzzleRuntimeLevelStatus;
startedAtMs: number; startedAtMs: number;
clearedAtMs: number | null; clearedAtMs: number | null;
elapsedMs: number | null; elapsedMs: number | null;
timeLimitMs: number;
remainingMs: number;
pausedAccumulatedMs: number;
pauseStartedAtMs: number | null;
freezeAccumulatedMs: number;
freezeStartedAtMs: number | null;
freezeUntilMs: number | null;
leaderboardEntries: PuzzleLeaderboardEntry[]; leaderboardEntries: PuzzleLeaderboardEntry[];
} }
@@ -96,3 +107,11 @@ export interface DragPuzzlePieceRequest {
targetRow: number; targetRow: number;
targetCol: number; targetCol: number;
} }
export interface UsePuzzleRuntimePropRequest {
propKind: PuzzleRuntimePropKind;
}
export interface UpdatePuzzleRuntimePauseRequest {
paused: boolean;
}

View File

@@ -20,6 +20,7 @@ export interface PuzzleWorkSummary {
playCount?: number; playCount?: number;
remixCount?: number; remixCount?: number;
likeCount?: number; likeCount?: number;
recentPlayCount7d?: number;
publishReady: boolean; publishReady: boolean;
} }

View File

@@ -252,6 +252,7 @@ export type CustomWorldLibraryEntry<TProfile = CustomWorldProfileRecord> = {
playCount?: number; playCount?: number;
remixCount?: number; remixCount?: number;
likeCount?: number; likeCount?: number;
recentPlayCount7d?: number;
}; };
export type CustomWorldGalleryCard = Omit< export type CustomWorldGalleryCard = Omit<

View File

@@ -90,7 +90,11 @@ timestamp_slug() {
is_publish_conflict_output() { is_publish_conflict_output() {
local output="$1" 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() { run_publish() {

1
server-rs/Cargo.lock generated
View File

@@ -2655,6 +2655,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"module-ai", "module-ai",
"module-assets", "module-assets",
"module-big-fish",
"module-combat", "module-combat",
"module-custom-world", "module-custom-world",
"module-inventory", "module-inventory",

View File

@@ -80,6 +80,7 @@ use crate::{
password_entry::password_entry, password_entry::password_entry,
password_management::{change_password, reset_password}, password_management::{change_password, reset_password},
phone_auth::{phone_login, send_phone_code}, phone_auth::{phone_login, send_phone_code},
profile_identity::update_profile_identity,
puzzle::{ puzzle::{
advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session, 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, 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, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message, remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message,
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
update_puzzle_run_pause, use_puzzle_runtime_prop,
}, },
refresh_session::refresh_session, refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id}, request_context::{attach_request_context, resolve_request_id},
@@ -247,6 +249,12 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, 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( .route(
"/api/auth/refresh", "/api/auth/refresh",
post(refresh_session).route_layer(middleware::from_fn_with_state( post(refresh_session).route_layer(middleware::from_fn_with_state(
@@ -783,6 +791,20 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, 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( .route(
"/api/runtime/puzzle/runs/{run_id}/leaderboard", "/api/runtime/puzzle/runs/{run_id}/leaderboard",
post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state( post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state(

View File

@@ -29,7 +29,7 @@ where
} }
} }
/// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。 /// 资产操作统一预扣陶泥币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
async fn consume_asset_operation_points( async fn consume_asset_operation_points(
state: &AppState, state: &AppState,
owner_user_id: &str, owner_user_id: &str,
@@ -79,7 +79,7 @@ async fn refund_asset_operation_points(
asset_kind, asset_kind,
asset_id, asset_id,
error = %error, error = %error,
"资产操作失败后的叙世币退款失败" "资产操作失败后的陶泥币退款失败"
); );
} }
} }
@@ -87,7 +87,7 @@ async fn refund_asset_operation_points(
pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError {
let status = match &error { let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message) if message.contains("叙世币余额不足") => { SpacetimeClientError::Procedure(message) if message.contains("陶泥币余额不足") => {
StatusCode::CONFLICT StatusCode::CONFLICT
} }
_ => StatusCode::BAD_GATEWAY, _ => StatusCode::BAD_GATEWAY,

View File

@@ -7,6 +7,7 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
public_user_code: user.public_user_code, public_user_code: user.public_user_code,
username: user.username, username: user.username,
display_name: user.display_name, display_name: user.display_name,
avatar_url: user.avatar_url,
phone_number_masked: user.phone_number_masked, phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(), login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.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, id: user.id,
public_user_code: user.public_user_code, public_user_code: user.public_user_code,
display_name: user.display_name, display_name: user.display_name,
avatar_url: user.avatar_url,
} }
} }

View File

@@ -20,7 +20,7 @@ pub async fn get_public_user_by_code(
.get_user_by_public_user_code(&code) .get_user_by_public_user_code(&code)
.map_err(map_public_user_search_error)? .map_err(map_public_user_search_error)?
.ok_or_else(|| { .ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应叙世号用户") AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应陶泥号用户")
})?; })?;
Ok(json_success_body( 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 { fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
match error { match error {
module_auth::PasswordEntryError::InvalidPublicUserCode => { 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::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_) | module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidPhoneNumber | module_auth::PasswordEntryError::InvalidPhoneNumber
| module_auth::PasswordEntryError::InvalidPasswordLength | module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidDisplayName
| module_auth::PasswordEntryError::InvalidAvatarDataUrl
| module_auth::PasswordEntryError::EmptyProfileUpdate
| module_auth::PasswordEntryError::InvalidCredentials | module_auth::PasswordEntryError::InvalidCredentials
| module_auth::PasswordEntryError::UserNotFound => { | module_auth::PasswordEntryError::UserNotFound => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())

View File

@@ -936,7 +936,9 @@ fn map_big_fish_work_summary_response(
cover_image_src: item.cover_image_src, cover_image_src: item.cover_image_src,
status: item.status, status: item.status,
updated_at: current_timestamp_micros_to_string(item.updated_at_micros), 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, publish_ready: item.publish_ready,
level_count: item.level_count, level_count: item.level_count,
level_main_image_ready_count: item.level_main_image_ready_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, play_count: item.play_count,
remix_count: item.remix_count, remix_count: item.remix_count,
like_count: item.like_count, like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
} }
} }

View File

@@ -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::{ use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
@@ -74,6 +74,7 @@ pub struct AppConfig {
pub spacetime_database: String, pub spacetime_database: String,
pub spacetime_token: Option<String>, pub spacetime_token: Option<String>,
pub spacetime_pool_size: u32, pub spacetime_pool_size: u32,
pub spacetime_procedure_timeout: Duration,
pub llm_provider: LlmProvider, pub llm_provider: LlmProvider,
pub llm_base_url: String, pub llm_base_url: String,
pub llm_api_key: Option<String>, pub llm_api_key: Option<String>,
@@ -165,6 +166,7 @@ impl Default for AppConfig {
spacetime_database: "genarrative-dev".to_string(), spacetime_database: "genarrative-dev".to_string(),
spacetime_token: None, spacetime_token: None,
spacetime_pool_size: 4, spacetime_pool_size: 4,
spacetime_procedure_timeout: Duration::from_secs(30),
llm_provider: LlmProvider::Ark, llm_provider: LlmProvider::Ark,
llm_base_url: DEFAULT_ARK_BASE_URL.to_string(), llm_base_url: DEFAULT_ARK_BASE_URL.to_string(),
llm_api_key: None, llm_api_key: None,
@@ -436,6 +438,12 @@ impl AppConfig {
{ {
config.spacetime_pool_size = spacetime_pool_size; 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) = if let Some(llm_provider) =
read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "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] #[test]
fn from_env_reads_rpg_llm_web_search_switch() { fn from_env_reads_rpg_llm_web_search_switch() {
let _guard = ENV_LOCK let _guard = ENV_LOCK

View File

@@ -414,9 +414,10 @@ pub async fn get_custom_world_library(
Extension(authenticated): Extension<AuthenticatedAccessToken>, Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> { ) -> Result<Json<Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string(); let owner_user_id = authenticated.claims().user_id().to_string();
let author_display_name = resolve_author_display_name(&state, &authenticated);
let entries = state let entries = state
.spacetime_client() .spacetime_client()
.list_custom_world_profiles(owner_user_id) .list_custom_world_works(owner_user_id.clone())
.await .await
.map_err(|error| { .map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(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 { CustomWorldLibraryResponse {
entries: entries entries: entries
.into_iter() .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(), .collect(),
}, },
)) ))
@@ -2712,9 +2719,89 @@ fn map_custom_world_library_entry_response(
play_count: entry.play_count, play_count: entry.play_count,
remix_count: entry.remix_count, remix_count: entry.remix_count,
like_count: entry.like_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<CustomWorldLibraryEntryResponse> {
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::<String>();
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( fn map_custom_world_gallery_card_response(
entry: CustomWorldGalleryEntryRecord, entry: CustomWorldGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse { ) -> CustomWorldGalleryCardResponse {
@@ -2737,6 +2824,7 @@ fn map_custom_world_gallery_card_response(
play_count: entry.play_count, play_count: entry.play_count,
remix_count: entry.remix_count, remix_count: entry.remix_count,
like_count: entry.like_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, request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-library", "provider": "custom-world-library",
"message": format!("作者叙世号读取失败:{error}"), "message": format!("作者陶泥号读取失败:{error}"),
})), })),
) )
})? })?
@@ -3319,7 +3407,7 @@ fn resolve_author_public_user_code(
request_context, request_context,
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({ AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "custom-world-library", "provider": "custom-world-library",
"message": "当前登录用户缺少叙世", "message": "当前登录用户缺少陶泥",
})), })),
) )
}) })

View File

@@ -39,7 +39,7 @@ pub async fn generate_custom_world_foundation_draft(
emit_foundation_draft_progress( emit_foundation_draft_progress(
&mut on_progress, &mut on_progress,
"整理世界骨架", "整理世界骨架",
"正在根据创作者锚点生成第一版世界框架。", "正在根据陶泥主锚点生成第一版世界框架。",
12, 12,
); );
let mut framework = request_foundation_json_stage( let mut framework = request_foundation_json_stage(

View File

@@ -42,6 +42,7 @@ mod logout_all;
mod password_entry; mod password_entry;
mod password_management; mod password_management;
mod phone_auth; mod phone_auth;
mod profile_identity;
mod prompt; mod prompt;
mod puzzle; mod puzzle;
mod puzzle_agent_turn; mod puzzle_agent_turn;

View File

@@ -80,10 +80,15 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
"field": "password", "field": "password",
})), })),
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST) PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("叙世号格式不正确") .with_message("陶泥号格式不正确")
.with_details(json!({ .with_details(json!({
"field": "phone", "field": "phone",
})), })),
PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PasswordEntryError::InvalidCredentials => { PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误") AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
} }

View File

@@ -103,6 +103,11 @@ fn map_password_management_error(error: PasswordEntryError) -> AppError {
PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => { PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) 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) PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("密码长度需要在 6 到 128 位之间"), .with_message("密码长度需要在 6 到 128 位之间"),
PasswordEntryError::InvalidCredentials => { PasswordEntryError::InvalidCredentials => {

View File

@@ -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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ProfileUpdateRequest>,
) -> Result<Json<serde_json::Value>, 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())
}
}
}

View File

@@ -10,7 +10,7 @@ use crate::creation_agent_anchor_templates::{
}; };
use crate::creation_agent_chat::render_quick_fill_extra_rules; 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#"你是一个负责和陶泥主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。 你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。

View File

@@ -5,14 +5,14 @@
pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污"; "低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
/// 根据拼图关卡名和创作者输入构造最终发给图片模型的提示词。 /// 根据拼图关卡名和陶泥主输入构造最终发给图片模型的提示词。
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
format!( format!(
concat!( concat!(
"请生成一张适合正方形拼图关卡的高清插画。", "请生成一张适合 9:16 竖屏拼图关卡的高清插画。",
"关卡名:{level_name}。", "关卡名:{level_name}。",
"画面主体:{prompt}。", "画面主体:{prompt}。",
"画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,", "画面要求:9:16 竖屏画布,适配 3x3 或 4x4 拼图切块,",
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
"避免文字、水印、边框和 UI 元素。" "避免文字、水印、边框和 UI 元素。"
), ),
@@ -31,7 +31,7 @@ mod tests {
assert!(prompt.contains("雨夜神庙")); assert!(prompt.contains("雨夜神庙"));
assert!(prompt.contains("猫咪在发光遗迹前寻找线索")); assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
assert!(prompt.contains("正方形拼图关卡")); assert!(prompt.contains("9:16 竖屏拼图关卡"));
assert!(prompt.contains("3x3 或 4x4")); assert!(prompt.contains("3x3 或 4x4"));
assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
} }

View File

@@ -17,7 +17,7 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, 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::{ use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest, OssSignedGetObjectUrlRequest,
@@ -40,7 +40,7 @@ use shared_contracts::{
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
SwapPuzzlePiecesRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
}, },
puzzle_works::{ puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
@@ -57,9 +57,10 @@ use spacetime_client::{
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
}; };
use std::convert::Infallible; use std::convert::Infallible;
use tokio::time::sleep; use tokio::time::sleep;
@@ -85,6 +86,7 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime"; const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash"; const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "720*1280";
pub async fn create_puzzle_agent_session( pub async fn create_puzzle_agent_session(
State(state): State<AppState>, State(state): State<AppState>,
@@ -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 let session = state
.spacetime_client() .spacetime_client()
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
@@ -455,6 +457,8 @@ pub async fn execute_puzzle_agent_action(
&state, &state,
session_id.clone(), session_id.clone(),
owner_user_id.clone(), owner_user_id.clone(),
payload.prompt_text.as_deref(),
payload.reference_image_src.as_deref(),
now, now,
) )
.await .await
@@ -1142,6 +1146,120 @@ pub async fn advance_puzzle_next_level(
)) ))
} }
pub async fn update_puzzle_run_pause(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<UpdatePuzzleRuntimePauseRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<UsePuzzleRuntimePropRequest>, JsonRejection>,
) -> Result<Json<Value>, 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( pub async fn advance_local_puzzle_next_level(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -1399,6 +1517,7 @@ fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWork
play_count: item.play_count, play_count: item.play_count,
remix_count: item.remix_count, remix_count: item.remix_count,
like_count: item.like_count, like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
publish_ready: item.publish_ready, publish_ready: item.publish_ready,
} }
} }
@@ -1465,6 +1584,13 @@ fn map_puzzle_level_request_record(
started_at_ms: level.started_at_ms, started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms, cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_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: level
.leaderboard_entries .leaderboard_entries
.into_iter() .into_iter()
@@ -1524,6 +1650,18 @@ fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> Puzzle
fn map_puzzle_runtime_level_response( fn map_puzzle_runtime_level_response(
level: spacetime_client::PuzzleRuntimeLevelRecord, level: spacetime_client::PuzzleRuntimeLevelRecord,
) -> PuzzleRuntimeLevelSnapshotResponse { ) -> 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 { PuzzleRuntimeLevelSnapshotResponse {
run_id: level.run_id, run_id: level.run_id,
level_index: level.level_index, level_index: level.level_index,
@@ -1538,6 +1676,13 @@ fn map_puzzle_runtime_level_response(
started_at_ms: level.started_at_ms, started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms, cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_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: level
.leaderboard_entries .leaderboard_entries
.into_iter() .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( fn map_puzzle_leaderboard_entry_response(
entry: PuzzleLeaderboardEntryRecord, entry: PuzzleLeaderboardEntryRecord,
) -> PuzzleLeaderboardEntryResponse { ) -> PuzzleLeaderboardEntryResponse {
@@ -1612,10 +1768,28 @@ fn resolve_author_display_name(
fn build_puzzle_welcome_text(seed_text: &str) -> String { fn build_puzzle_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() { 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) { 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, state: &AppState,
session_id: String, session_id: String,
owner_user_id: String, owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
now: i64, now: i64,
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> { ) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
let compiled_session = state let compiled_session = state
@@ -1648,8 +1824,11 @@ async fn compile_puzzle_draft_with_initial_cover(
owner_user_id.as_str(), owner_user_id.as_str(),
&compiled_session.session_id, &compiled_session.session_id,
&draft.level_name, &draft.level_name,
&draft.summary, prompt_text
None, .map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(draft.summary.as_str()),
reference_image_src,
1, 1,
draft.candidates.len(), draft.candidates.len(),
) )
@@ -1815,6 +1994,7 @@ async fn generate_puzzle_image_candidates(
None => None, None => None,
}; };
// 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与 DashScope 图生图都必须停留在 api-server。 // 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与 DashScope 图生图都必须停留在 api-server。
// 中文注释:拼图作品资产统一按 9:16 竖屏生成,运行时棋盘也按同一比例切块承载。
let generated = match reference_image.as_deref() { let generated = match reference_image.as_deref() {
Some(reference_image) => { Some(reference_image) => {
create_puzzle_image_to_image_generation( create_puzzle_image_to_image_generation(
@@ -1822,7 +2002,7 @@ async fn generate_puzzle_image_candidates(
&settings, &settings,
actual_prompt.as_str(), actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024", PUZZLE_GENERATED_IMAGE_SIZE,
count, count,
reference_image, reference_image,
) )
@@ -1834,7 +2014,7 @@ async fn generate_puzzle_image_candidates(
&settings, &settings,
actual_prompt.as_str(), actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024", PUZZLE_GENERATED_IMAGE_SIZE,
count, count,
) )
.await .await
@@ -2079,6 +2259,7 @@ fn build_next_run_from_parts(
) -> PuzzleRunRecord { ) -> PuzzleRunRecord {
let next_level_index = run.current_level_index + 1; let next_level_index = run.current_level_index + 1;
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 }; 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(); let mut played_profile_ids = run.played_profile_ids.clone();
if !played_profile_ids.contains(&profile_id) { if !played_profile_ids.contains(&profile_id) {
played_profile_ids.push(profile_id.clone()); 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, started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
cleared_at_ms: None, cleared_at_ms: None,
elapsed_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(), leaderboard_entries: Vec::new(),
}), }),
recommended_next_profile_id: None, recommended_next_profile_id: None,
@@ -2221,6 +2409,11 @@ mod tests {
assert!(!has_original_neighbor_pair(&second)); assert!(!has_original_neighbor_pair(&second));
assert!(!has_original_neighbor_pair(&third)); 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 { struct PuzzleDashScopeSettings {

View File

@@ -60,7 +60,7 @@ struct PuzzleAgentModelOutput {
next_anchor_pack: PuzzleAnchorPack, next_anchor_pack: PuzzleAnchorPack,
} }
const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创拼图画面的中文创意策划。 const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创拼图画面的中文创意策划。
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。 你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。

View File

@@ -168,7 +168,10 @@ fn resolve_npc_battle_formation(
if !visible_formation.is_empty() { if !visible_formation.is_empty() {
return visible_formation return visible_formation
.into_iter() .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(); .collect();
} }
@@ -185,7 +188,12 @@ fn resolve_npc_battle_formation(
.unwrap_or_default() .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 { let Some(monster_object) = monster.as_object_mut() else {
return monster; return monster;
}; };
@@ -211,6 +219,26 @@ fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value
monster_object monster_object
.entry("hp".to_string()) .entry("hp".to_string())
.or_insert_with(|| json!(max_hp)); .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 monster
} }

View File

@@ -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(&current_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] #[test]
fn runtime_story_npc_spar_resolves_lightweight_battle_snapshot() { fn runtime_story_npc_spar_resolves_lightweight_battle_snapshot() {
let request = RuntimeStoryActionRequest { let request = RuntimeStoryActionRequest {

View File

@@ -164,6 +164,7 @@ impl AppState {
database: config.spacetime_database.clone(), database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(), token: config.spacetime_token.clone(),
pool_size: config.spacetime_pool_size, pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout,
}); });
let llm_client = build_llm_client(&config)?; let llm_client = build_llm_client(&config)?;
@@ -242,6 +243,7 @@ impl AppState {
database: config.spacetime_database.clone(), database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(), token: config.spacetime_token.clone(),
pool_size: config.spacetime_pool_size, pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout,
}); });
match spacetime_client match spacetime_client
.export_auth_store_snapshot_from_tables() .export_auth_store_snapshot_from_tables()

View File

@@ -24,6 +24,9 @@ const SMS_CODE_LENGTH: usize = 6;
const SMS_CODE_TTL_MINUTES: i64 = 5; const SMS_CODE_TTL_MINUTES: i64 = 5;
const SMS_CODE_COOLDOWN_SECONDS: u64 = 60; const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5; 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthLoginMethod { pub enum AuthLoginMethod {
@@ -44,6 +47,7 @@ pub struct AuthUser {
pub public_user_code: String, pub public_user_code: String,
pub username: String, pub username: String,
pub display_name: String, pub display_name: String,
pub avatar_url: Option<String>,
pub phone_number_masked: Option<String>, pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod, pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus, pub binding_status: AuthBindingStatus,
@@ -85,6 +89,18 @@ pub struct ChangePasswordResult {
pub user: AuthUser, pub user: AuthUser,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateProfileInput {
pub user_id: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateProfileResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordInput { pub struct ResetPasswordInput {
pub phone_number: String, pub phone_number: String,
@@ -316,6 +332,9 @@ pub enum PasswordEntryError {
InvalidPhoneNumber, InvalidPhoneNumber,
InvalidPasswordLength, InvalidPasswordLength,
InvalidPublicUserCode, InvalidPublicUserCode,
InvalidDisplayName,
InvalidAvatarDataUrl,
EmptyProfileUpdate,
InvalidCredentials, InvalidCredentials,
UserNotFound, UserNotFound,
Store(String), Store(String),
@@ -572,6 +591,25 @@ impl PasswordEntryService {
Ok(ChangePasswordResult { user }) Ok(ChangePasswordResult { user })
} }
pub fn update_profile(
&self,
input: UpdateProfileInput,
) -> Result<UpdateProfileResult, PasswordEntryError> {
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 { impl RefreshSessionService {
@@ -1345,6 +1383,7 @@ impl InMemoryAuthStore {
public_user_code, public_user_code,
username: username.clone(), username: username.clone(),
display_name, display_name,
avatar_url: None,
phone_number_masked: Some(phone_number.masked_national_number.clone()), phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Phone, login_method: AuthLoginMethod::Phone,
binding_status: AuthBindingStatus::Active, binding_status: AuthBindingStatus::Active,
@@ -1392,6 +1431,7 @@ impl InMemoryAuthStore {
public_user_code, public_user_code,
username: username.clone(), username: username.clone(),
display_name, display_name,
avatar_url: None,
phone_number_masked: Some(phone_number.masked_national_number.clone()), phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Password, login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active, binding_status: AuthBindingStatus::Active,
@@ -1442,6 +1482,7 @@ impl InMemoryAuthStore {
public_user_code, public_user_code,
username: username.clone(), username: username.clone(),
display_name, display_name,
avatar_url: normalize_optional_string(profile.avatar_url.clone()),
phone_number_masked: None, phone_number_masked: None,
login_method: AuthLoginMethod::Wechat, login_method: AuthLoginMethod::Wechat,
binding_status: AuthBindingStatus::PendingBindPhone, binding_status: AuthBindingStatus::PendingBindPhone,
@@ -1544,7 +1585,7 @@ impl InMemoryAuthStore {
// 否则下一次只能按 unionid 命中,随后刷新资料时会因为旧 openid 不存在而丢失 identity。 // 否则下一次只能按 unionid 命中,随后刷新资料时会因为旧 openid 不存在而丢失 identity。
identity.provider_uid = next_provider_uid.clone(); identity.provider_uid = next_provider_uid.clone();
identity.display_name = next_display_name.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(); identity.provider_union_id = next_provider_union_id.clone();
state state
.wechat_identity_by_provider_uid .wechat_identity_by_provider_uid
@@ -1570,6 +1611,9 @@ impl InMemoryAuthStore {
{ {
stored_user.user.display_name = display_name.to_string(); 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() stored_user.user.clone()
}; };
self.persist_wechat_state(&state)?; self.persist_wechat_state(&state)?;
@@ -1604,6 +1648,37 @@ impl InMemoryAuthStore {
Ok(()) Ok(())
} }
fn update_user_profile(
&self,
user_id: &str,
display_name: Option<String>,
avatar_url: Option<String>,
) -> Result<Option<AuthUser>, 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( fn upsert_phone_code(
&self, &self,
code: StoredPhoneCode, code: StoredPhoneCode,
@@ -2144,7 +2219,12 @@ impl fmt::Display for PasswordEntryError {
match self { match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), 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::InvalidCredentials => f.write_str("手机号或密码错误"),
Self::UserNotFound => f.write_str("用户不存在"), Self::UserNotFound => f.write_str("用户不存在"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), 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::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidCredentials | PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound | PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => { | PasswordEntryError::PasswordHash(_) => {
@@ -2234,6 +2317,9 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
PasswordEntryError::InvalidPhoneNumber PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidCredentials | PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()), | PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()),
} }
@@ -2245,6 +2331,9 @@ fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError
PasswordEntryError::InvalidPhoneNumber PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidCredentials | PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound | PasswordEntryError::UserNotFound
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()), | PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
@@ -2279,6 +2368,56 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
Ok(()) Ok(())
} }
fn validate_display_name(display_name: String) -> Result<String, PasswordEntryError> {
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<String, PasswordEntryError> {
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( async fn verify_stored_password_user(
existing_user: StoredPasswordUser, existing_user: StoredPasswordUser,
password: &str, password: &str,
@@ -2360,7 +2499,7 @@ fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}") format!("{prefix}_{sequence:08}")
} }
// 公开叙世号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。 // 公开陶泥号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
fn build_public_user_code(sequence: u64) -> String { fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}") format!("SY-{sequence:08}")
} }
@@ -2586,6 +2725,65 @@ mod tests {
assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials); 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] #[tokio::test]
async fn phone_user_can_set_password_then_login() { async fn phone_user_can_set_password_then_login() {
let store = build_store(); let store = build_store();

View File

@@ -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_MESSAGE_ID_PREFIX: &str = "big-fish-message-";
pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-"; 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 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_DEFAULT_LEVEL_COUNT: u32 = 8;
pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6; pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6;
pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12; pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12;
@@ -228,6 +229,8 @@ pub struct BigFishWorkSummarySnapshot {
pub play_count: u32, pub play_count: u32,
pub remix_count: u32, pub remix_count: u32,
pub like_count: u32, pub like_count: u32,
#[serde(default)]
pub recent_play_count_7d: u32,
pub published_at_micros: Option<i64>, pub published_at_micros: Option<i64>,
} }
@@ -952,4 +955,13 @@ mod tests {
); );
assert!(coverage.blockers.iter().any(|item| item.contains("背景图"))); 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());
}
} }

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