1
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
4. 模块本体使用 `max-height: 33svh` 作为硬约束,内容超出时优先在模板入口行内横向滚动,不撑高页面。
|
||||
5. 桌面端保持网格入口,但同步收紧内边距和卡片留白,避免移动端与桌面端表现割裂。
|
||||
6. 横向滚动模板行必须隐藏原生滚动条,保留滑动能力,避免底部出现过粗的视觉条。
|
||||
7. 模板入口排序以可创作为第一优先级:可创建卡片保持原配置内相对顺序排在前面,锁定且展示“敬请期待”的卡片保持原配置内相对顺序排在后面。
|
||||
|
||||
## 文案约束
|
||||
|
||||
|
||||
@@ -29,5 +29,5 @@
|
||||
## 4. 验收点
|
||||
|
||||
- 平台“选择创作类型”弹层不再显示“大鱼吃小鱼”卡片。
|
||||
- RPG、拼图、“敬请期待”类卡片顺序与交互保持稳定。
|
||||
- 可创建卡片排在前面,展示“敬请期待”的锁定卡片排在后面,交互状态保持稳定。
|
||||
- 代码层不引入对 Big Fish 运行时或结果页的额外耦合修改。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 平台统一作品详情页与 Remix 数据链路设计
|
||||
|
||||
更新时间:`2026-04-29`
|
||||
更新时间:`2026-05-01`
|
||||
|
||||
## 1. 本次目标
|
||||
|
||||
@@ -15,21 +15,22 @@
|
||||
统一详情页只做作品展示与动作入口,不承担规则说明。
|
||||
|
||||
1. 顶部导航:返回按钮、标题“详情”、更多按钮占位;不展示“统计 / 详情 / 评价 / 论坛”Tab。
|
||||
2. 封面区:固定 `16:9` 比例,使用作品封面图 `cover` 填满整块主视觉;背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。
|
||||
3. 基础信息区:
|
||||
2. 封面区:固定 `16:9` 比例,默认使用作品封面图 `cover` 填满整块主视觉;背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。拼图作品详情页若详情数据包含多个关卡图,则顶部封面区优先按关卡正式图轮播展示,每张图对应一个关卡;无可用关卡图时再回退作品封面图。
|
||||
3. 移动端首页“推荐”和“今日游戏”列表中,只有最接近屏幕垂直中心的作品卡片进入封面轮播态;若该拼图作品有多张关卡封面,则按详情页同源封面序列自动轮换。用户滚动后,离开中心的旧卡片必须立即恢复首张封面,新中心卡片再开始轮播;“游戏分类”、排行、桌面端列表不启用该自动轮播。
|
||||
4. 基础信息区:
|
||||
- 左侧作品图标使用作品封面或首图。
|
||||
- 中间展示作品名、作者头像、作者名、玩法类型;作者头像读取公开用户资料 `avatarUrl`,缺失时使用作者昵称首字占位。
|
||||
- 右侧原 TapTap 评分位置替换为 `点赞` 按钮;点击后调用后端点赞接口,由后端记录当前登录用户对该公开作品的点赞关系并返回更新后的真实 `likeCount` 读模型,前端不伪造点赞增长。
|
||||
4. 统计区固定四项:
|
||||
5. 统计区固定四项:
|
||||
- 游玩:`playCount`,显示为“数字 + 次”,单位放在数字后方。
|
||||
- 改造:`remixCount`,显示为“数字 + 次”,单位放在数字后方。
|
||||
- 点赞:`likeCount`,显示为“数字 + 赞”,单位放在数字后方。
|
||||
- 日期:优先展示 `updatedAt`,缺失时回退 `publishedAt`;前端只负责格式化显示,必须兼容后端当前 `seconds.microsZ` 与 ISO 字符串两种真实时间文本,显示为完整 `YYYY-MM-DD`,使用更小字号并保持单行不换行。
|
||||
- 四项统计需要使用浅色图标底强化识别,但不得追加规则说明类文案。
|
||||
5. 简介区:展示玩法标签和作品简介;不追加说明类文案。
|
||||
6. 底部动作:左侧按钮为“作品改造”,右侧主按钮为“启动”;两个按钮必须位于同一行,点击“启动”后进入对应玩法运行态并记录游玩次数。
|
||||
7. 页面配色必须跟随平台明暗主题变量;亮色主题使用平台浅色底、深色文字和主按钮渐变,暗色主题使用平台暗色底、亮色文字和对应主按钮渐变,不在详情页写死独立黑色皮肤。
|
||||
8. 字号规范跟随平台页面既有节奏:标题/主按钮使用 `1rem` 级别,作品名使用卡片标题同级 `1rem`,辅助信息与简介使用 `0.8125rem` / `0.875rem`,标签与统计标签使用 `0.75rem`,避免在详情页使用随视口放大的独立大字号。
|
||||
6. 简介区:展示玩法标签和作品简介;不追加说明类文案。
|
||||
7. 底部动作:左侧按钮为“作品改造”,右侧主按钮为“启动”;两个按钮必须位于同一行,点击“启动”后进入对应玩法运行态并记录游玩次数。
|
||||
8. 页面配色必须跟随平台明暗主题变量;亮色主题使用平台浅色底、深色文字和主按钮渐变,暗色主题使用平台暗色底、亮色文字和对应主按钮渐变,不在详情页写死独立黑色皮肤。
|
||||
9. 字号规范跟随平台页面既有节奏:标题/主按钮使用 `1rem` 级别,作品名使用卡片标题同级 `1rem`,辅助信息与简介使用 `0.8125rem` / `0.875rem`,标签与统计标签使用 `0.75rem`,避免在详情页使用随视口放大的独立大字号。
|
||||
|
||||
## 3. 数据真相源
|
||||
|
||||
@@ -99,4 +100,5 @@
|
||||
4. Remix 后原作品改造次数增加,新草稿归当前用户所有,且不会继承源作品统计。
|
||||
5. 点赞公开作品会走对应后端记录入口,首次点赞后刷新仍能看到递增后的点赞次数,重复点赞不会继续增加。
|
||||
6. 启动公开作品会走对应后端记录入口,刷新后仍能看到递增后的游玩次数。
|
||||
7. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。
|
||||
7. 移动端首页“推荐”和“今日游戏”列表滚动时,仅中心卡片自动轮播多封面;旧中心卡离开后回到首张封面,新的中心卡接续轮播。
|
||||
8. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
|
||||
网格规模仍可作为运行时内部状态存在,但不默认写在 UI 顶栏中。
|
||||
|
||||
### 1.1 2026-04-30 顶栏与底部工具补充
|
||||
|
||||
1. 顶栏作者信息不再只显示一行作者名,必须展示为作者头像与昵称组合;当前运行态只提供昵称时,用昵称首字生成圆形占位头像。
|
||||
2. 倒计时组件提升为顶栏中的强信息,字号、内边距和图标尺寸都需要明显大于作者昵称与关卡序号。
|
||||
3. 底部只保留 `提示 / 原图 / 冻结` 三个功能按钮,并整体居中展示;三个按钮触控面积和图标字号都需要放大。
|
||||
4. 底部不再展示“等待下一关候选”这类状态占位。通关后在三个道具按钮上方固定展示“下一关”按钮,展示条件只依赖当前关卡已通关,不依赖 `recommendedNextProfileId` 是否已有值。
|
||||
5. 点击底部“下一关”按钮继续调用运行时壳层已有 `onAdvanceNextLevel` 事件;正式 run 由后端 `next-level` 选择候选,本地 run 由 `local-next-level` 生成或接续下一关,前端不在按钮层自行决定下一关来源。
|
||||
|
||||
### 2. 拼图块显示规则
|
||||
|
||||
运行时单块右下角编号全部移除。
|
||||
|
||||
+20
-13
@@ -11,14 +11,17 @@
|
||||
1. 负好感或敌对 NPC 进入聊天后,不再设置固定 5 回合上限。
|
||||
2. 负好感或敌对 NPC 每轮回复后,模型必须判断本轮是否结束聊天。
|
||||
3. 敌对 NPC 判定时应偏向随时结束聊天并进入对峙,但必须结合玩家刚说的话、NPC 性格、当前剧情压力和对话历史。
|
||||
4. 好感度大于等于 0 且非敌对 NPC 不启用模型终止判定,玩家可一直聊天。
|
||||
5. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。
|
||||
6. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。
|
||||
7. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。
|
||||
8. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用敌对出口:继续推进后展示一个“战斗”选项,以及按相邻场景和当前场景起点展开的多个逃跑选项。
|
||||
9. 聊天候选中允许混入当前 NPC 可执行 function,例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。
|
||||
10. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。
|
||||
11. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。
|
||||
4. 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 `shouldEndChat=true`。
|
||||
5. 敌对 NPC 已聊天轮次超过 4 轮后,应倾向立即 `shouldEndChat=true`,避免敌对关系被拖成长时间闲聊。
|
||||
6. 敌对 NPC 最后一轮回复必须像战斗、驱逐或正面对峙前的狠话:短促、带压迫感、明确把局势推向行动前一刻,但不在对白中直接结算战斗。
|
||||
7. 好感度大于等于 0 且非敌对 NPC 不启用模型终止判定,玩家可一直聊天。
|
||||
8. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。
|
||||
9. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。
|
||||
10. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。
|
||||
11. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用敌对出口:继续推进后展示一个“战斗”选项,以及按相邻场景和当前场景起点展开的多个逃跑选项。
|
||||
12. 聊天候选中允许混入当前 NPC 可执行 function,例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。
|
||||
13. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。
|
||||
14. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。
|
||||
|
||||
## 3. 后端契约
|
||||
|
||||
@@ -43,12 +46,14 @@
|
||||
1. 敌对聊天可随时中止,NPC 更偏好结束谈判转入战斗或驱逐。
|
||||
2. 终止不等于在回复正文里直接执行战斗,只需要用台词把对话收束到对峙、威胁、驱逐、最后通牒或行动前一刻。
|
||||
3. 玩家主动退出聊天时,NPC 回复要对这次收束作出回应,并留下自然的后续入口。
|
||||
4. 若玩家本轮发言明显负面,或敌对聊天已进入第 5 轮及之后,回复 prompt 要提示 NPC 直接给出战斗前、驱逐前或正面对峙前的狠话。
|
||||
|
||||
建议 prompt 需要明确:
|
||||
|
||||
1. 常规聊天候选继续生成玩家台词。
|
||||
2. Function 候选要根据提供的 function 列表,改写成玩家可直接点击的动作文本。
|
||||
3. 不输出规则说明,不把 functionId 暴露给玩家。
|
||||
4. 敌对聊天判断 `shouldEndChat` 时,负面发言和已聊天轮次超过 4 轮都应作为强收束信号;如果返回 `shouldEndChat=true`,`terminationReason` 使用 `hostile_breakoff`。
|
||||
|
||||
## 5. 前端流程
|
||||
|
||||
@@ -81,8 +86,10 @@
|
||||
1. 负好感主 NPC 不再出现固定 `turnLimit: 5`。
|
||||
2. 敌对 NPC 每轮请求会向后端传 `terminationMode: hostile_model`。
|
||||
3. 模型返回 `forceExit: true` 后,聊天输入消失,只显示继续按钮。
|
||||
4. 好感度大于等于 0 的 NPC 聊天不传敌对中止模式。
|
||||
5. 点击退出聊天会新增玩家结束聊天气泡与 NPC 回复,而不是直接切走面板。
|
||||
6. 聊天态可看到并点击 function 候选,且“换一换”可改变候选顺序。
|
||||
7. 选项文字前出现中文 function 标签,且标签不改变原 actionText。
|
||||
8. 聊天结束后的“继续冒险”直接进入下一幕;最后一幕则展示多个相邻场景方向入口。
|
||||
4. 玩家对敌对 NPC 说出挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界类发言时,模型应倾向返回 `shouldEndChat=true`,NPC 最后一轮回复带战斗前狠话。
|
||||
5. 敌对 NPC 已聊天轮次超过 4 轮后,模型应倾向返回 `shouldEndChat=true`,NPC 最后一轮回复带战斗前狠话。
|
||||
6. 好感度大于等于 0 的 NPC 聊天不传敌对中止模式。
|
||||
7. 点击退出聊天会新增玩家结束聊天气泡与 NPC 回复,而不是直接切走面板。
|
||||
8. 聊天态可看到并点击 function 候选,且“换一换”可改变候选顺序。
|
||||
9. 选项文字前出现中文 function 标签,且标签不改变原 actionText。
|
||||
10. 聊天结束后的“继续冒险”直接进入下一幕;最后一幕则展示多个相邻场景方向入口。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把“剩余陶泥币 / 总游戏时长 / 玩过作品”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。
|
||||
把“陶泥币 / 游戏时长 / 玩过”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。
|
||||
|
||||
---
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
当前三个数字来源并不统一:
|
||||
|
||||
1. 陶泥币来自当前存档上下文,不等于账号总资产
|
||||
2. 总游戏时长依赖当前快照,不代表全账号累计
|
||||
3. 玩过作品当前几乎是硬编码推导,不是真实统计
|
||||
2. 游戏时长依赖当前快照,不代表全账号累计
|
||||
3. 玩过当前几乎是硬编码推导,不是真实统计
|
||||
|
||||
这会导致“我的”页看到的数据不可信。
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
## 3. 指标定义
|
||||
|
||||
## 3.1 剩余陶泥币
|
||||
## 3.1 陶泥币
|
||||
|
||||
定义:
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
- 当前单个存档里的临时货币数值
|
||||
|
||||
## 3.2 总游戏时长
|
||||
## 3.2 游戏时长
|
||||
|
||||
定义:
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- 只累计进入有效游戏流程的时长
|
||||
- 后台挂机超阈值后停止累计
|
||||
|
||||
## 3.3 玩过作品
|
||||
## 3.3 玩过
|
||||
|
||||
定义:
|
||||
|
||||
@@ -82,17 +82,17 @@
|
||||
|
||||
1. 陶泥币卡
|
||||
- 打开资产流水抽屉
|
||||
2. 总游戏时长卡
|
||||
2. 游戏时长卡
|
||||
- 打开游玩统计抽屉
|
||||
3. 玩过作品卡
|
||||
- 打开玩过作品列表
|
||||
3. 玩过卡
|
||||
- 打开玩过列表
|
||||
|
||||
如果本期不做明细页,点击可先无动作,但必须预留可扩展事件位。
|
||||
|
||||
## 4.2 展示规则
|
||||
|
||||
1. 数字过大时做单位缩略展示
|
||||
2. “总游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天
|
||||
2. “游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天
|
||||
3. 进入页面先展示骨架屏
|
||||
4. 数据请求失败时展示降级文案,不展示假数字
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
返回:
|
||||
|
||||
- 游玩时长分布
|
||||
- 玩过作品列表摘要
|
||||
- 玩过列表摘要
|
||||
|
||||
---
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
|
||||
1. 钱包余额从后端钱包台账聚合
|
||||
2. 游戏时长从运行时会话日志或快照汇总
|
||||
3. 玩过作品数从有效游玩记录去重计算
|
||||
3. 玩过数从有效游玩记录去重计算
|
||||
|
||||
禁止继续采用:
|
||||
|
||||
@@ -153,4 +153,4 @@
|
||||
2. 切换设备后看板数据一致
|
||||
3. 没有存档时也能正常展示账号级数据
|
||||
4. 数据加载失败时页面表现可控
|
||||
5. “总游戏时长”卡展示值始终带 `小时` 单位,例如 `0小时`、`1.5小时`、`36小时`
|
||||
5. “游戏时长”卡展示值始终带 `小时` 单位,例如 `0小时`、`1.5小时`、`36小时`
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 网页端首页模块内容同步移动端首页 2026-04-30
|
||||
|
||||
## 背景
|
||||
|
||||
平台首页移动端已经收口为 `推荐 / 今日游戏 / 游戏分类` 三个频道。网页端首页保留宽屏布局,但模块文案和数据语义仍残留 `趋势关注`、`最新发布`、`作品广场` 等旧入口口径,导致双端首页内容不一致。
|
||||
|
||||
## 落地规则
|
||||
|
||||
1. 网页端首页只调整模块内容和文案,不改变现有宽屏栅格、面板数量与卡片布局。
|
||||
2. `推荐` 使用移动端推荐频道同源数据:精选作品优先,并与最新公开作品去重合并。
|
||||
3. `趋势关注` 改为 `今日游戏`,数据只取今天首次发布的公开作品,不把今天更新的旧作品计入今日游戏。
|
||||
4. `最新发布` 改为 `作品分类`,数据使用当前分类组内按综合指标排序后的作品。
|
||||
5. 首页首屏和快捷区域不再展示 `作品广场` 文案。
|
||||
6. 删除首页中的 `公开作品` 兜底模块;快捷区域只在存在最近作品或最近浏览时显示,不再用空模块占位。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 网页端首页仍保持原有 hero、右侧列表、中部双栏与底部网格布局。
|
||||
2. 网页端可见模块包含推荐、今日游戏、作品分类。
|
||||
3. 网页端首页不再出现 `趋势关注`、`最新发布`、`作品广场`。
|
||||
4. 无最近作品和最近浏览时,网页端首页不再展示 `公开作品` 快捷模块。
|
||||
5. 今日游戏与移动端 `今日游戏` 频道使用同一发布时间过滤规则。
|
||||
@@ -0,0 +1,95 @@
|
||||
# 拼图失败续时与存档投影设计 2026-05-01
|
||||
|
||||
## 背景
|
||||
|
||||
拼图运行时已经有倒计时失败态、道具确认扣费、下一关推荐和个人存档页,但失败后的玩家选择与拼图作品存档投影还没有闭环:
|
||||
|
||||
1. 倒计时结束后只能返回,不能重新开始或付费继续。
|
||||
2. 进入拼图作品后,存档页没有稳定出现一条可恢复的拼图游戏存档。
|
||||
3. 每通过一关后,存档应该更新到下一关入口,而不是停留在旧关卡。
|
||||
|
||||
本轮只补齐拼图运行态与存档投影,不迁移旧 `server-node`,不新增平行存档页。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 限定时间内未完成时弹出失败面板。
|
||||
2. 失败面板提供两个选择:
|
||||
- `重新开始`:重新开启当前拼图关卡,不扣陶泥币。
|
||||
- `继续1分钟`:先弹出确认窗口,确认后消耗 `1` 陶泥币,并把当前失败关卡恢复为 `playing`,剩余时间固定为 `60000ms`。
|
||||
3. 进入拼图作品后立即写入 `profile_save_archive`,存档页显示拼图存档。
|
||||
4. 每次进入下一关后更新同一条拼图存档,使存档恢复时指向最新可继续的关卡。
|
||||
|
||||
## 运行态规则
|
||||
|
||||
### 失败续时
|
||||
|
||||
`PuzzleRuntimePropKind` 增加 `extendTime`,沿用现有道具确认与扣费接口:
|
||||
|
||||
1. 前端只在 `runtimeStatus = failed` 时开放 `继续1分钟`。
|
||||
2. 点击后打开独立确认弹窗,文案只显示短标题和 `消耗 1 陶泥币`。
|
||||
3. 正式 run 继续走 `POST /api/runtime/puzzle/runs/:runId/props`。
|
||||
4. `api-server` 将 `extendTime` 映射为账单 `asset_kind = puzzle_prop_extend_time`。
|
||||
5. SpacetimeDB 侧只允许失败关卡续时;续时成功后:
|
||||
- `status = playing`
|
||||
- `remaining_ms = 60000`
|
||||
- `elapsed_ms = None`
|
||||
- `cleared_at_ms = None`
|
||||
- 清空暂停与冻结生效点
|
||||
- 调整 `paused_accumulated_ms`,保证从确认成功那一刻开始完整倒计时 `60` 秒
|
||||
|
||||
本地调试 run 没有真实钱包,沿用本地道具兜底:仍弹确认窗,但不扣真实陶泥币。
|
||||
|
||||
### 重新开始
|
||||
|
||||
重新开始不复用旧失败棋盘,而是重新创建当前关卡的 run:
|
||||
|
||||
1. 前端从当前 `currentLevel.profileId` 和 `currentLevel.levelId` 调用 `startPuzzleRun`。
|
||||
2. 新 run 的棋盘重新打乱、倒计时重置。
|
||||
3. 如果当前关卡来自作品内部第 N 关,必须携带 `levelId`,避免重开误回作品第 1 关。
|
||||
4. 旧失败 run 保留为历史运行记录,不在前端继续使用。
|
||||
|
||||
为支持第 3 点,`PuzzleRuntimeLevelSnapshot` 增加 `levelId: string | null`。
|
||||
|
||||
## 存档投影规则
|
||||
|
||||
复用现有 `profile_save_archive` 表,不新增拼图专属存档表。拼图存档固定规则:
|
||||
|
||||
1. `world_key = puzzle:{entry_profile_id}`。
|
||||
2. `world_type = PUZZLE`。
|
||||
3. `profile_id = entry_profile_id`,保证同一个作品链只覆盖一条存档。
|
||||
4. `world_name` 使用当前关卡名。
|
||||
5. `subtitle` 使用 `第 N 关`。
|
||||
6. `summary_text` 使用当前状态:
|
||||
- playing:`拼图进行中`
|
||||
- failed:`关卡失败`
|
||||
- cleared:`关卡已完成`
|
||||
7. `cover_image_src` 使用当前关卡正式图。
|
||||
8. `game_state_json` 保存最小拼图恢复载荷:
|
||||
- `runtimeKind = "puzzle"`
|
||||
- `runId`
|
||||
- `entryProfileId`
|
||||
- `currentProfileId`
|
||||
- `currentLevelIndex`
|
||||
- `currentLevelId`
|
||||
- `status`
|
||||
|
||||
## 写入时机
|
||||
|
||||
SpacetimeDB 拼图运行态每次持久化 run 时同步刷新存档:
|
||||
|
||||
1. `start_puzzle_run`:创建 run 后立即写入拼图存档。
|
||||
2. `advance_puzzle_next_level`:进入下一关后更新同一条存档。
|
||||
3. `use_puzzle_runtime_prop(extendTime)`:续时成功后更新状态。
|
||||
4. `get_puzzle_run` 导致失败态落库时,也同步更新为失败存档。
|
||||
|
||||
排行榜提交只负责成绩与通关态,不新增存档规则;如果它把 run 状态更新为通关,也跟随 run 持久化刷新存档。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 倒计时归零后失败弹窗有 `重新开始` 和 `继续1分钟`。
|
||||
2. 点击 `继续1分钟` 后先出现扣费确认,确认成功后失败弹窗关闭并恢复 `60` 秒倒计时。
|
||||
3. 陶泥币余额不足时确认弹窗保留,并展示错误。
|
||||
4. 点击 `重新开始` 后当前关卡重新打乱并重置倒计时。
|
||||
5. 进入拼图作品后,存档页出现 `worldType = PUZZLE` 的拼图存档。
|
||||
6. 通过一关进入下一关后,同一条存档更新到新关卡。
|
||||
7. 定向前端测试、Rust 拼图模块测试与编码检查通过。
|
||||
@@ -10,10 +10,13 @@
|
||||
|
||||
1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。
|
||||
2. 新 session 的 `seedText` 允许为空;SpacetimeDB 侧用空锚点和空表单草稿初始化,不得把默认题材文案写入玩家草稿字段。
|
||||
3. 初始表单输入自动保存到 session 的 `draft_json` 与 `puzzle_work_profile` 投影。保存字段只包含 `workTitle`、`workDescription`、`pictureDescription`、可推断标签和一个 `generationStatus = idle` 的默认关卡;参考图只保存在当前前端会话内,不落入 SpacetimeDB。
|
||||
3. 初始表单输入自动保存到 session 的 `draft_json` 与 `puzzle_work_profile` 投影。保存字段只包含 `workTitle`、`workDescription`、`pictureDescription`、可推断标签和一个 `generationStatus = idle` 的默认关卡;草稿设置阶段默认关卡名称必须为空,不得写入“第一关”“第1关”或作品名称作为默认值。参考图只保存在当前前端会话内,不落入 SpacetimeDB。
|
||||
4. 玩家在生成草稿前退出,再次从创作中心点击这条拼图草稿时,必须恢复到填表页,并回填之前自动保存的作品名称、作品描述和画面描述;只有执行 `compile_puzzle_draft` 且生成结果页草稿后,草稿入口才进入结果页。
|
||||
5. 表单自动保存走 `save_puzzle_form_draft` action,不消耗陶泥币,不生成图片,不改变 `stage = collecting_anchors`;生成草稿按钮仍单独触发 `compile_puzzle_draft` 并进入进度页。
|
||||
6. 点击拼图入口始终创建新草稿,不复用上一次未完成 session;恢复旧草稿只通过“我的创作”中的草稿卡进入。
|
||||
7. 若 Maincloud 仍运行旧 wasm,缺少 `save_puzzle_form_draft` procedure,前端提交生成或生成失败页重试时不得继续复用空 `seedText` 的表单 session,必须用当前表单 payload 新建带真实 seed 的 session 再执行 `compile_puzzle_draft`。
|
||||
8. api-server 也要兼容旧 wasm:`save_puzzle_form_draft` 缺失时,自动保存 action 降级返回当前 session;`compile_puzzle_draft` 前置保存缺失且当前 session 为空 seed 时,创建一条带表单 seed 的替代 session 后继续编译,避免再次暴露 `No such procedure`。
|
||||
9. 正式修复仍是发布最新 SpacetimeDB wasm。当前 Maincloud `xushi-p4wfr` 的迁移操作员表为空,但旧库引导密钥来自旧 wasm,本次临时生成的新引导密钥无法授权导出迁移,需使用已有迁移操作员 token 或数据库 owner 重新授权后发布;禁止为绕过冲突直接清库,除非明确接受数据丢失。
|
||||
|
||||
1. 作品名称为必填字段,保存到 `workTitle`,兼容写入旧 `seedText`,同时作为作品级 `workTitle` 的真相源。
|
||||
2. 作品描述为必填字段,保存到 `workDescription`,作为作品详情页、作品列表和发布资料中的 `summary` 真相源。
|
||||
@@ -51,7 +54,7 @@
|
||||
2. `PuzzleResultDraft.workDescription`:作品描述,旧 `summary` 只作为兼容字段同步为作品描述。
|
||||
3. `PuzzleResultDraft.themeTags`:作品标签,仍限制 3 到 6 个。
|
||||
4. `PuzzleResultDraft.levels[]`:关卡列表。每个关卡包含 `levelId`、`levelName`、`pictureDescription`、`candidates`、`selectedCandidateId`、`coverImageSrc`、`coverAssetId`、`generationStatus`。
|
||||
5. 首次草稿生成时必须创建一个默认关卡,`levelId = puzzle-level-1`,`pictureDescription = 表单画面描述`,首图生成后直接写入该关卡。
|
||||
5. 首次草稿生成时必须创建一个默认关卡,`levelId = puzzle-level-1`,`pictureDescription = 表单画面描述`,草稿设置阶段 `levelName` 为空;首图生成后可由后端根据画面描述和图片语义生成关卡名称并写入该关卡。
|
||||
6. 关卡名称由后端基于画面描述和图片语义输入生成;无可用语义时按题材标签与序号兜底,禁止继续直接使用作品名称作为关卡名称。
|
||||
7. 旧草稿或旧作品缺少 `levels` 时,读取层必须由旧 `levelName`、`summary`、`coverImageSrc`、`candidates` 补出一个兼容关卡,避免历史草稿无法打开。
|
||||
|
||||
@@ -69,14 +72,31 @@
|
||||
10. `ExecutePuzzleAgentActionRequest` 必须保留 `pictureDescription` 字段。表单直达生成时,`compile_puzzle_draft` 优先用 `pictureDescription` 作为首图 prompt,再回退到旧 `promptText`;避免生成页展示的是玩家画面描述,但后端实际用作品名称或旧摘要出图。
|
||||
11. `compile_puzzle_draft` 中的图片上游失败不得映射成 `400 BAD_REQUEST`。DashScope 返回 `InvalidParameter` 或任务失败时,api-server 统一按 `502 UPSTREAM_ERROR` 暴露,并在 `details.message` 中保留“拼图图片生成失败:...”的业务原因,避免生成页只显示“请求参数不合法”。
|
||||
12. `compile_puzzle_draft` 前置陶泥币预扣失败不得映射成 `400 BAD_REQUEST`。余额不足返回 `409 CONFLICT`,SpacetimeDB procedure 不可用、绑定不匹配、钱包服务异常等统一按 `502 UPSTREAM_ERROR` 暴露,并在 `details.message` 中保留真实钱包错误。
|
||||
13. 生成拼图作品草稿动作涉及的表单 seed prompt 与首图 prompt 来源选择统一收口在 `server-rs/crates/api-server/src/prompt/puzzle/draft.rs`;`puzzle.rs` 只负责调用 SpacetimeDB、计费、图片服务和持久化,不再直接拼草稿 prompt 文本。
|
||||
|
||||
## 结果页
|
||||
|
||||
拼图草稿结果页分为两个 Tab:
|
||||
|
||||
1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、重新生成画面,并支持单独体验该关卡。
|
||||
1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、生成或重新生成画面,并在已有正式图后支持关卡测试。
|
||||
2. 作品信息:展示并编辑作品名称、作品描述、作品标签。
|
||||
|
||||
### 2026-04-30 关卡列表卡片交互补充
|
||||
|
||||
1. 关卡列表卡片的删除按钮与关卡名称放在同一信息行,按钮固定在卡片右下角;不得再单独占用一整条底部分隔栏。
|
||||
2. 关卡图片、序号与名称区域仍作为打开关卡详情的主点击区;删除按钮只触发删除,不进入详情。
|
||||
|
||||
### 2026-04-30 关卡详情面板交互补充
|
||||
|
||||
1. 关卡详情面板内容区按移动端优先的单列顺序展示:`关卡名称 -> 画面图 -> 画面描述`。其中画面图只在该关卡已有正式图时出现;新建关卡或画面为空的关卡不展示空图占位模块。
|
||||
2. 画面生成主按钮固定吸底,始终位于关卡详情面板底部操作区。若当前关卡还没有正式图,按钮文案为“生成画面”;已有正式图后,按钮文案为“重新生成画面”。
|
||||
3. 关卡已有正式图后,底部操作区在生成按钮上方新增单独的关卡测试入口,原“体验该关”文案收口为“关卡测试”。无正式图时不展示该入口。
|
||||
4. 底部吸底操作区只承载动作按钮,不默认写玩法说明或规则解释,避免压缩移动端编辑空间。
|
||||
5. 关卡详情面板内触发生成画面时,前端必须把当前编辑态完整 `levelsJson` 随 `generate_puzzle_images` action 一起提交。这样新建关卡在自动保存完成前立即生成,也能由后端写回目标关卡。
|
||||
6. api-server 处理 `generate_puzzle_images` 时,若 action 带有 `levelsJson`,必须用这份关卡快照覆盖本次生成的草稿关卡视图后再定位 `levelId`。若请求明确传入 `levelId` 但关卡列表中不存在该关卡,必须返回错误,不得静默回退第一关。
|
||||
7. 历史拼图素材入口只在已有正式图的 `画面图` 区域右下角展示,不再放在 `画面描述` 输入区;本地上传参考图入口仍保留在画面描述输入区右下角。
|
||||
8. 历史拼图素材列表必须由服务端按当前登录账号过滤,只返回 `asset_kind = puzzle_cover_image` 且 `owner_user_id = 当前账号` 的资产;不得依赖前端过滤,也不得展示其他账号素材。
|
||||
|
||||
画面描述区域不再展示候选图实际 prompt 或“请生成一张适合……”之类内部提示词模块。参考图入口保留在画面描述编辑区域内,便于重新生成时继续带入。结果页编辑关卡画面描述时只同步该关卡 `pictureDescription`;作品描述只在作品信息 Tab 编辑,作品详情页不得再回退使用画面描述。
|
||||
|
||||
## 验收
|
||||
@@ -85,5 +105,5 @@
|
||||
2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择。
|
||||
3. 首图生成请求使用玩家画面描述作为 prompt;上传参考图时走图生图;作品详情页展示玩家作品描述。
|
||||
4. 结果页包含“拼图关卡”和“作品信息”两个 Tab;关卡列表默认至少一关,支持新增、删除和进入关卡详情。
|
||||
5. 关卡详情页支持重新生成画面和单独体验该关卡。
|
||||
5. 关卡详情页支持生成或重新生成画面;已有正式图后显示吸底“关卡测试”入口。
|
||||
6. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# 拼图下一关与相似作品接续设计 2026-04-30
|
||||
|
||||
## 背景
|
||||
|
||||
拼图通关结算弹窗已有“下一关”按钮,但当前按钮依赖 `recommendedNextProfileId`。这会带来两个问题:
|
||||
|
||||
1. 当前作品还有未玩的内部关卡时,按钮可能因为没有跨作品推荐而被禁用。
|
||||
2. 当前作品全部关卡玩完后,只返回单个推荐作品,无法满足“三个相似作品由用户选择”的交互。
|
||||
|
||||
本轮只修复拼图运行态接续链路,不迁移旧 `server-node`,不在前端计算正式相似度。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 通关后默认点击“下一关”,优先加载当前拼图作品的下一关。
|
||||
2. 当前作品没有下一关时,后端按标签语义相似度选出相似度最高的三个已发布作品。
|
||||
3. 用户在通关弹窗里点击候选作品后,进入该作品并从第 1 关重新开始。
|
||||
4. 移动端优先,候选卡片要紧凑,不写玩法说明类文案。
|
||||
|
||||
## 数据契约
|
||||
|
||||
`PuzzleRunSnapshot` 增加:
|
||||
|
||||
1. `nextLevelMode: "sameWork" | "similarWorks" | "none"`。
|
||||
2. `nextLevelProfileId: string | null`:同作品下一关或跨作品推荐的默认目标。
|
||||
3. `nextLevelId: string | null`:同作品下一关的 `levelId`;跨作品时为 `null`。
|
||||
4. `recommendedNextWorks: PuzzleRecommendedNextWork[]`:跨作品候选,最多 3 个。
|
||||
|
||||
`PuzzleRecommendedNextWork` 字段:
|
||||
|
||||
1. `profileId`
|
||||
2. `levelName`
|
||||
3. `authorDisplayName`
|
||||
4. `themeTags`
|
||||
5. `coverImageSrc`
|
||||
6. `similarityScore`
|
||||
|
||||
保留 `recommendedNextProfileId` 作为旧字段兼容,但前端新逻辑不再只依赖它。
|
||||
|
||||
## 后端规则
|
||||
|
||||
1. SpacetimeDB 侧在 `start / get / swap / drag / leaderboard / advance` 后刷新下一关状态。
|
||||
2. 当前作品存在未玩的下一张关卡图时:
|
||||
- `nextLevelMode = "sameWork"`
|
||||
- `nextLevelProfileId = 当前作品 profileId`
|
||||
- `nextLevelId = 下一关 levelId`
|
||||
- `recommendedNextWorks = []`
|
||||
3. 当前作品没有内部下一关时:
|
||||
- 使用拼图现有 `recommendation_score = tagSimilarity * 0.7 + sameAuthor * 0.3`
|
||||
- `tagSimilarity` 优先复用 RPG/build 标签语义亲和度模型;两侧标签未命中该语义模型时,回退到规范化标签 Jaccard
|
||||
- 排除当前 run 已玩过的作品;若池子为空,允许回收但不连续重复上一关作品
|
||||
- 返回最高的 3 个候选
|
||||
4. `advance_puzzle_next_level`:
|
||||
- `nextLevelMode = sameWork` 时加载当前作品的下一关,并继续当前 run。
|
||||
- `nextLevelMode = similarWorks` 时默认加载候选第一项,并从该作品第 1 关重新开始。
|
||||
5. `local-next-level` 兼容接口同样优先找同作品下一关;没有时才返回相似作品候选或旧草稿兜底。
|
||||
|
||||
## 前端规则
|
||||
|
||||
1. 结算弹窗:
|
||||
- `sameWork`:主按钮显示“下一关”,直接触发默认推进。
|
||||
- `similarWorks`:展示最多 3 个作品候选卡;用户点击卡片进入候选作品。
|
||||
- `none`:禁用下一关入口。
|
||||
2. 底部通关后入口:
|
||||
- `sameWork` 保留“下一关”。
|
||||
- `similarWorks` 显示“换个作品”,点击后打开结算弹窗供选择。
|
||||
3. 所有正式相似度计算只信任后端返回,不在 UI 里重新算。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 当前作品有下一关时,点击“下一关”进入当前作品下一关。
|
||||
2. 当前作品没有下一关时,通关弹窗显示最多 3 个相似作品。
|
||||
3. 点击相似作品后进入该作品第 1 关。
|
||||
4. 旧 `recommendedNextProfileId` 为空时,只要 `nextLevelMode = sameWork`,按钮仍可用。
|
||||
5. 拼图 runtime 单测、Rust 拼图模块测试和编码检查通过。
|
||||
@@ -41,6 +41,15 @@
|
||||
2. 单块交换、拖到合并块后拆分、合并块整体重排,继续沿用当前本地运行态规则。
|
||||
3. 不新增前端本地裁决,不把玩法真相从既有运行态实现中分叉出去。
|
||||
|
||||
### 3.4 点击触觉反馈
|
||||
|
||||
移动端用户每次按下可交互拼图片时,需要触发一次短促手机震动:
|
||||
|
||||
1. 震动触发点放在 `pointerdown`,让点击选中、按住准备拖动与拖起都有一致手感。
|
||||
2. 同一次按下会话只触发一次震动,后续连续移动不重复震动。
|
||||
3. 使用浏览器标准 `navigator.vibrate([12])`,不支持震动能力的设备静默跳过。
|
||||
4. 该反馈只属于前端表现层,不影响拖拽落点、交换、合并、拆分与通关判定。
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
1. 单块拖动时拼块视觉位置应紧跟手指或鼠标,不再出现明显缓动拖尾。
|
||||
@@ -48,3 +57,4 @@
|
||||
3. 点击选中与拖动阈值判定仍保持原语义,不因为优化误触发交换。
|
||||
4. 运行时现有结算弹窗、排行榜和下一关入口不受影响。
|
||||
5. 定向测试覆盖拖动提交坐标的行为,并运行编码检查确保中文文档未被写坏。
|
||||
6. 移动端点击拼图片时立即触发一次短震,同一次按下后的连续移动不重复触发。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
## 文档列表
|
||||
|
||||
- [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。
|
||||
- [SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md](./SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md):记录本地 standalone 启动时报 `mismatched database identity` 的 root-dir/replica 数据残留根因、备份重建步骤和脚本诊断口径。
|
||||
- [LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md](./LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md):冻结 RPG 运行时剧情推理使用 `doubao-seed-character-251128` 的 `/chat/completions`,以及所有模板创作大模型推理使用 `deepseek-v3-2-251201` 的 `/responses`。
|
||||
- [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。
|
||||
- [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。
|
||||
@@ -19,6 +20,8 @@
|
||||
- [PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md):记录拼图生成图片回到 1:1,运行时拖动、交换、合并与拆分由前端即时裁决,以及移动端棋盘贴近屏幕边缘的落地边界。
|
||||
- [PUZZLE_FORM_CREATION_FLOW_2026-04-29.md](./PUZZLE_FORM_CREATION_FLOW_2026-04-29.md):冻结拼图填表式创作入口、初始表单自动保存草稿、生成前退出后的表单恢复,以及草稿编译/首图生成的前后端边界。
|
||||
- [PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md](./PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md):记录拼图第二关排行榜提交以前端当前关卡为准、不被 SpacetimeDB 旧 run 快照误杀,以及 RPG 创作入口改为敬请期待的落地边界。
|
||||
- [PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md](./PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md):记录拼图通关后优先同作品下一关、无下一关时按 RPG/build 标签语义相似度返回三个候选作品并从第 1 关接续的落地规则。
|
||||
- [PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md](./PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。
|
||||
- [RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md](./RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md):记录编辑器幕预览卡在“正在载入这一幕”时的启动态根因,收口预览本地运行态装配与禁持久化首段 story 注入。
|
||||
- [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。
|
||||
- [WORK_AUTHOR_ID_RESOLUTION_2026-04-30.md](./WORK_AUTHOR_ID_RESOLUTION_2026-04-30.md):记录作品作者以 `owner_user_id` 为真相源,API 按用户 ID 解析最新昵称与公开用户码,历史 `author_display_name` 仅作为兼容回退。
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
## 体验规则
|
||||
|
||||
- 等待态继续复用 `RouteLoadingScreen`,只显示简短加载文案,不在 UI 中追加规则说明。
|
||||
- `RouteLoadingScreen` 必须读取 `tavernrealms.settings.v1` 中的 `platformTheme`,并复用 `platform-theme--light / platform-theme--dark` 与 `platform-body-fill / platform-text-*` token,不能硬编码独立深色背景。
|
||||
- 页面主体隐藏时使用 `visibility: hidden`,不能用 `display: none`,否则浏览器可能不触发布局与图片加载。
|
||||
- 图片加载失败不直接改写业务 UI;后续仍由原页面的兜底图、占位图或错误态处理。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `src/routing/RouteImageReadyGate.tsx`
|
||||
- `src/routing/RouteLoadingScreen.tsx`
|
||||
- `src/routing/RouteImageReadyGate.test.ts`
|
||||
- `src/main.tsx`
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
|
||||
1. 拼图提示词参考 RPG 的目录组织,统一迁入 `server-rs/crates/api-server/src/prompt/puzzle/`。
|
||||
2. `prompt/puzzle/agent_chat.rs` 承接拼图共创 Agent 的 system prompt、单轮 JSON 输出契约、用户提示词与 anchor pack / 聊天记录提示词组装。
|
||||
3. `prompt/puzzle/image.rs` 承接拼图图片生成正式提示词与默认反向提示词。
|
||||
4. `puzzle_agent_turn.rs` 只保留 LLM 调用、结果解析、阶段判断和 SpacetimeDB 写回输入构造,不再内联拼图聊天提示词正文。
|
||||
5. `puzzle.rs` 只保留拼图路由、计费、DashScope、OSS、候选图持久化和运行态编排,不再内联拼图图片提示词正文。
|
||||
6. 后续调整拼图共创问法、输出契约、图片画面约束或反向提示词时,优先修改 `prompt/puzzle/`,不要在 `puzzle.rs` 或 `puzzle_agent_turn.rs` 中新增提示词正文。
|
||||
3. `prompt/puzzle/draft.rs` 承接生成拼图作品草稿动作里的表单 seed prompt、草稿首图 prompt 来源选择、单关图片再生成 prompt 来源选择。
|
||||
4. `prompt/puzzle/image.rs` 承接拼图图片生成正式提示词与默认反向提示词。
|
||||
5. `puzzle_agent_turn.rs` 只保留 LLM 调用、结果解析、阶段判断和 SpacetimeDB 写回输入构造,不再内联拼图聊天提示词正文。
|
||||
6. `puzzle.rs` 只保留拼图路由、计费、DashScope、OSS、候选图持久化和运行态编排,不再内联拼图草稿或图片提示词正文。
|
||||
7. 后续调整拼图共创问法、输出契约、生成草稿 prompt 来源、图片画面约束或反向提示词时,优先修改 `prompt/puzzle/`,不要在 `puzzle.rs` 或 `puzzle_agent_turn.rs` 中新增提示词正文。
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# SpacetimeDB 本地 replica identity 不一致处理方案
|
||||
|
||||
日期:`2026-04-30`
|
||||
|
||||
## 1. 问题
|
||||
|
||||
本地启动 SpacetimeDB standalone 时出现:
|
||||
|
||||
```text
|
||||
error starting database: failed to init replica 1 for <new-database-identity>: mismatched database identity: <old-database-identity> != <new-database-identity>
|
||||
```
|
||||
|
||||
本次现场日志中,`server-rs/.spacetimedb/local/data/logs/spacetime-standalone.log` 显示:
|
||||
|
||||
1. `2026-04-30T12:17:26Z` 开始按 `c2006f3d846a8259512006a556b1bc3f751a9aef6608fc0ee75788deea6d9331` 启动数据库。
|
||||
2. `replica 1` 的持久化数据仍带有旧库 `c20037fcfaac4e5c4b1f492f026a4f6119a98f56319b77f21ef021ededf8b7ae`。
|
||||
3. SpacetimeDB 因同一个副本目录中 identity 不一致而拒绝继续启动。
|
||||
|
||||
这不是 Rust 编译错误,也不是 `api-server:maincloud` 的 token 错误。只要错误来自 `server-rs/.spacetimedb/local/.../spacetime-standalone.log`,优先按本地 root-dir 数据目录污染处理。
|
||||
|
||||
## 2. 根因
|
||||
|
||||
`spacetime start --edition standalone` 会在同一个 `--root-dir` 下保存控制库、程序字节、WAL 与 replica 数据。当前仓库默认本地 root-dir 是:
|
||||
|
||||
```text
|
||||
server-rs/.spacetimedb/local
|
||||
```
|
||||
|
||||
当这个目录曾经启动并发布过旧 database identity,之后又用同一个 root-dir 初始化或发布到另一个 database identity 时,可能出现:
|
||||
|
||||
1. `control-db` 记录的是新库。
|
||||
2. `data/replicas/1` 里仍残留旧库 WAL 或快照。
|
||||
3. 启动时 SpacetimeDB 尝试把旧 replica 当作新库加载,触发 `mismatched database identity`。
|
||||
|
||||
## 3. 处理原则
|
||||
|
||||
1. 不在脚本里默认删除 `.spacetimedb` 数据,避免误删本地开发数据。
|
||||
2. 如果只是本地开发库且数据可丢弃,优先备份后重建 `data` 目录。
|
||||
3. 如果数据必须保留,不要清理目录;应改回创建旧库时使用的 database/root-dir,或先导出迁移数据。
|
||||
4. Maincloud 发布与本地 standalone root-dir 是两条链路;不要通过切回 `server-node` 或 PostgreSQL 绕过。
|
||||
|
||||
## 4. 本地可丢弃数据时的修复
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$root = "C:\Genarrative\server-rs\.spacetimedb\local"
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.Name -match "spacetime" -and $_.CommandLine -and $_.CommandLine.Replace("/", "\") -like "*$($root.Replace("/", "\"))*" } |
|
||||
Select-Object ProcessId, Name, CommandLine
|
||||
```
|
||||
|
||||
确认占用进程后停止:
|
||||
|
||||
```powershell
|
||||
Stop-Process -Id <pid> -Force
|
||||
```
|
||||
|
||||
备份运行态数据目录:
|
||||
|
||||
```powershell
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
Move-Item -LiteralPath "C:\Genarrative\server-rs\.spacetimedb\local\data" -Destination "C:\Genarrative\server-rs\.spacetimedb\local\data.identity-mismatch-backup.$stamp"
|
||||
```
|
||||
|
||||
重新启动本地链路:
|
||||
|
||||
```powershell
|
||||
npm run dev:rust
|
||||
```
|
||||
|
||||
`npm run dev:rust` 会重新启动 standalone、发布 `spacetime-module`,并生成新的本地数据库运行态。
|
||||
|
||||
## 5. 需要保留数据时的处理
|
||||
|
||||
不要移动或删除 `server-rs/.spacetimedb/local/data`。先确认旧库 identity 对应的数据库名、root-dir 与发布命令,然后选择:
|
||||
|
||||
1. 用旧库对应的 database/root-dir 重新启动。
|
||||
2. 使用迁移导出脚本导出旧数据,再清理本地 root-dir 并导入到新库。
|
||||
3. 如目标其实是 Maincloud,改用 `npm run api-server:maincloud` 连接云端,避免误启动本地 standalone。
|
||||
|
||||
## 6. 脚本诊断
|
||||
|
||||
`scripts/dev-rust-stack.sh` 已补充本地启动失败诊断:
|
||||
|
||||
1. SpacetimeDB 进程在就绪前退出时,会打印 `spacetime-standalone.log` 尾部。
|
||||
2. 若日志包含 `mismatched database identity`,会提示本地 `data/replicas/1` 与当前 control-db identity 不一致。
|
||||
3. 诊断只输出建议,不自动清理数据。
|
||||
@@ -62,6 +62,7 @@ export type PuzzleAgentActionRequest =
|
||||
promptText?: string | null;
|
||||
referenceImageSrc?: string | null;
|
||||
candidateCount?: number;
|
||||
levelsJson?: string;
|
||||
}
|
||||
| {
|
||||
action: 'select_puzzle_image';
|
||||
|
||||
@@ -29,7 +29,11 @@ export interface PuzzleLeaderboardEntry {
|
||||
|
||||
export type PuzzleRuntimeLevelStatus = 'playing' | 'cleared' | 'failed';
|
||||
|
||||
export type PuzzleRuntimePropKind = 'hint' | 'reference' | 'freezeTime';
|
||||
export type PuzzleRuntimePropKind =
|
||||
| 'hint'
|
||||
| 'reference'
|
||||
| 'freezeTime'
|
||||
| 'extendTime';
|
||||
|
||||
export interface PuzzleBoardSnapshot {
|
||||
rows: number;
|
||||
@@ -43,6 +47,7 @@ export interface PuzzleBoardSnapshot {
|
||||
export interface PuzzleRuntimeLevelSnapshot {
|
||||
runId: string;
|
||||
levelIndex: number;
|
||||
levelId: string | null;
|
||||
gridSize: PuzzleGridSize;
|
||||
profileId: string;
|
||||
levelName: string;
|
||||
@@ -64,6 +69,17 @@ export interface PuzzleRuntimeLevelSnapshot {
|
||||
leaderboardEntries: PuzzleLeaderboardEntry[];
|
||||
}
|
||||
|
||||
export type PuzzleNextLevelMode = 'sameWork' | 'similarWorks' | 'none';
|
||||
|
||||
export interface PuzzleRecommendedNextWork {
|
||||
profileId: string;
|
||||
levelName: string;
|
||||
authorDisplayName: string;
|
||||
themeTags: string[];
|
||||
coverImageSrc: string | null;
|
||||
similarityScore: number;
|
||||
}
|
||||
|
||||
export interface PuzzleRunSnapshot {
|
||||
runId: string;
|
||||
entryProfileId: string;
|
||||
@@ -74,6 +90,10 @@ export interface PuzzleRunSnapshot {
|
||||
previousLevelTags: string[];
|
||||
currentLevel: PuzzleRuntimeLevelSnapshot | null;
|
||||
recommendedNextProfileId: string | null;
|
||||
nextLevelMode?: PuzzleNextLevelMode;
|
||||
nextLevelProfileId?: string | null;
|
||||
nextLevelId?: string | null;
|
||||
recommendedNextWorks?: PuzzleRecommendedNextWork[];
|
||||
leaderboardEntries: PuzzleLeaderboardEntry[];
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ export interface PuzzleWorkSummary {
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
publishReady: boolean;
|
||||
levels?: PuzzleDraftLevel[];
|
||||
}
|
||||
|
||||
export interface PuzzleWorkProfile extends PuzzleWorkSummary {
|
||||
anchorPack: PuzzleAnchorPack;
|
||||
levels?: PuzzleDraftLevel[];
|
||||
metadata?: JsonObject | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ wait_for_spacetime() {
|
||||
while ((SECONDS < deadline)); do
|
||||
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
|
||||
echo "[dev:rust] SpacetimeDB 进程在就绪前退出。" >&2
|
||||
print_spacetime_start_failure_diagnostics "${root_dir}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -88,6 +89,7 @@ wait_for_spacetime() {
|
||||
done
|
||||
|
||||
echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2
|
||||
print_spacetime_start_failure_diagnostics "${root_dir}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -115,6 +117,28 @@ request.on("error", () => process.exit(1));
|
||||
' "${server}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
print_spacetime_start_failure_diagnostics() {
|
||||
local root_dir="$1"
|
||||
local log_file="${root_dir}/data/logs/spacetime-standalone.log"
|
||||
|
||||
echo "[dev:rust] SpacetimeDB root-dir: ${root_dir}" >&2
|
||||
|
||||
if [[ ! -f "${log_file}" ]]; then
|
||||
echo "[dev:rust] 未找到 SpacetimeDB standalone 日志: ${log_file}" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[dev:rust] 最近 SpacetimeDB standalone 日志: ${log_file}" >&2
|
||||
tail -n 80 "${log_file}" >&2 || true
|
||||
|
||||
if grep -q "mismatched database identity" "${log_file}" 2>/dev/null; then
|
||||
echo "[dev:rust] 检测到本地 replica 与当前数据库 identity 不一致。" >&2
|
||||
echo "[dev:rust] 常见原因是同一个 root-dir 保留了旧库 data/replicas/1,但 control-db 已指向新库。" >&2
|
||||
echo "[dev:rust] 若这是可丢弃的本地开发库,请先停止 SpacetimeDB,再备份或移走 ${root_dir}/data 后重新启动。" >&2
|
||||
echo "[dev:rust] 若需要保留数据,不要清理目录;请改回创建旧库的 database/root-dir,或先走迁移导出。" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
describe_spacetime_root_owner() {
|
||||
local root_dir="$1"
|
||||
local windows_root_dir="${root_dir}"
|
||||
|
||||
@@ -23,8 +23,8 @@ use shared_contracts::assets::{
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
|
||||
@@ -119,6 +119,7 @@ pub async fn get_asset_read_url(
|
||||
pub async fn get_asset_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Query(query): Query<AssetHistoryQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let asset_kind = query.kind.trim().to_string();
|
||||
@@ -133,18 +134,23 @@ pub async fn get_asset_history(
|
||||
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.list_asset_history(module_assets::AssetHistoryListInput {
|
||||
asset_kind,
|
||||
limit: query.limit.unwrap_or(120).clamp(1, 120),
|
||||
})
|
||||
.list_asset_history(build_asset_history_list_input(asset_kind, query.limit))
|
||||
.await
|
||||
.map_err(map_confirm_asset_object_error)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AssetHistoryListResponse {
|
||||
assets: entries
|
||||
.into_iter()
|
||||
// 中文注释:Maincloud 旧 wasm 的历史素材 procedure 仍按类型返回,HTTP 门面必须兜底做账号隔离。
|
||||
.filter(|entry| {
|
||||
is_asset_history_owned_by(
|
||||
entry.owner_user_id.as_deref(),
|
||||
owner_user_id.as_str(),
|
||||
)
|
||||
})
|
||||
.map(|entry| AssetHistoryEntryPayload {
|
||||
owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()),
|
||||
asset_object_id: entry.asset_object_id,
|
||||
@@ -296,6 +302,25 @@ fn is_supported_asset_history_kind(asset_kind: &str) -> bool {
|
||||
SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind)
|
||||
}
|
||||
|
||||
fn is_asset_history_owned_by(entry_owner_user_id: Option<&str>, owner_user_id: &str) -> bool {
|
||||
let owner_user_id = owner_user_id.trim();
|
||||
!owner_user_id.is_empty()
|
||||
&& entry_owner_user_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
== Some(owner_user_id)
|
||||
}
|
||||
|
||||
fn build_asset_history_list_input(
|
||||
asset_kind: String,
|
||||
limit: Option<u32>,
|
||||
) -> module_assets::AssetHistoryListInput {
|
||||
module_assets::AssetHistoryListInput {
|
||||
asset_kind,
|
||||
limit: limit.unwrap_or(120).clamp(1, 120),
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_asset_history_kind_message() -> String {
|
||||
format!(
|
||||
"历史素材类型只支持 {}",
|
||||
@@ -490,6 +515,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_history_owner_filter_keeps_only_authenticated_owner_assets() {
|
||||
assert!(super::is_asset_history_owned_by(
|
||||
Some("user-current"),
|
||||
"user-current"
|
||||
));
|
||||
assert!(!super::is_asset_history_owned_by(
|
||||
Some("user-other"),
|
||||
"user-current"
|
||||
));
|
||||
assert!(!super::is_asset_history_owned_by(None, "user-current"));
|
||||
assert!(!super::is_asset_history_owned_by(Some("user-current"), ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_history_input_clamps_limit_for_spacetime_query() {
|
||||
let input =
|
||||
super::build_asset_history_list_input("puzzle_cover_image".to_string(), Some(240));
|
||||
|
||||
assert_eq!(input.asset_kind, "puzzle_cover_image");
|
||||
assert_eq!(input.limit, 120);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/// 拼图作品草稿生成动作的提示词主源。
|
||||
///
|
||||
/// 拼图结果页草稿本体仍由 SpacetimeDB reducer 按表单/锚点确定性编译;
|
||||
/// 这里收口 api-server 在生成草稿前后需要写入 reducer 的表单 seed 文本,
|
||||
/// 以及草稿首图生成时的 prompt 来源选择,避免业务路由直接拼提示词文本。
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct PuzzleFormSeedPromptParts<'a> {
|
||||
pub(crate) title: Option<&'a str>,
|
||||
pub(crate) work_description: Option<&'a str>,
|
||||
pub(crate) picture_description: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// 将填表式拼图输入编译成 SpacetimeDB 可恢复的表单 seed prompt。
|
||||
pub(crate) fn build_puzzle_form_seed_prompt(parts: PuzzleFormSeedPromptParts<'_>) -> String {
|
||||
[
|
||||
("作品名称", normalize_prompt_part(parts.title)),
|
||||
("作品描述", normalize_prompt_part(parts.work_description)),
|
||||
("画面描述", normalize_prompt_part(parts.picture_description)),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(label, value)| value.map(|value| format!("{label}:{value}")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// 生成作品草稿时,首图 prompt 优先使用玩家当前表单里的画面描述。
|
||||
pub(crate) fn resolve_puzzle_draft_cover_prompt(
|
||||
explicit_prompt: Option<&str>,
|
||||
level_picture_description: &str,
|
||||
draft_summary: &str,
|
||||
) -> String {
|
||||
normalize_prompt_part(explicit_prompt)
|
||||
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
|
||||
.or_else(|| normalize_prompt_part(Some(draft_summary)))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 结果页单关重新生成时,优先使用面板当前编辑态 prompt,再回退关卡画面描述。
|
||||
pub(crate) fn resolve_puzzle_level_image_prompt(
|
||||
explicit_prompt: Option<&str>,
|
||||
level_picture_description: &str,
|
||||
) -> String {
|
||||
normalize_prompt_part(explicit_prompt)
|
||||
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_prompt_part(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn form_seed_prompt_keeps_only_user_visible_fields() {
|
||||
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: Some(" 暖灯猫街 "),
|
||||
work_description: Some("雨夜礼物拼图"),
|
||||
picture_description: Some("猫咪在灯牌下回头"),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_cover_prompt_prefers_current_picture_description() {
|
||||
let prompt =
|
||||
resolve_puzzle_draft_cover_prompt(Some(" 当前表单画面 "), "旧关卡画面", "作品简介");
|
||||
|
||||
assert_eq!(prompt, "当前表单画面");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_image_prompt_falls_back_to_level_description() {
|
||||
let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述");
|
||||
|
||||
assert_eq!(prompt, "关卡画面描述");
|
||||
}
|
||||
}
|
||||
@@ -38,17 +38,15 @@ pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> Strin
|
||||
image_prompt
|
||||
}
|
||||
|
||||
fn build_puzzle_image_prompt_text(level_name: &str, prompt: &str) -> String {
|
||||
fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"请生成一张高清插画。",
|
||||
"关卡名:{level_name}。",
|
||||
"画面主体:{prompt}。",
|
||||
"画面要求:1:1 正方形拼图关卡,适配 3x3 或 4x4 拼图切块,",
|
||||
"画面要求:1:1",
|
||||
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
|
||||
"避免文字、水印、边框和 UI 元素。"
|
||||
),
|
||||
level_name = level_name,
|
||||
prompt = prompt,
|
||||
)
|
||||
}
|
||||
@@ -78,10 +76,9 @@ mod tests {
|
||||
fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() {
|
||||
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
|
||||
|
||||
assert!(prompt.contains("雨夜神庙"));
|
||||
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("1:1"));
|
||||
assert!(prompt.contains("主体要清晰集中"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
@@ -93,8 +90,8 @@ mod tests {
|
||||
let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str());
|
||||
|
||||
assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS);
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("1:1"));
|
||||
assert!(prompt.contains("主体要清晰集中"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod agent_chat;
|
||||
pub(crate) mod draft;
|
||||
pub(crate) mod image;
|
||||
|
||||
@@ -240,7 +240,10 @@ JSON 结构:
|
||||
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
|
||||
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。
|
||||
- 非敌对聊天 shouldEndChat 必须为 false。
|
||||
- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
|
||||
- 敌对聊天可以随时 shouldEndChat=true。
|
||||
- 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 shouldEndChat=true。
|
||||
- 敌对 NPC 已聊天轮次达到 4 轮或以上时,本轮结束后会超过 4 轮,应倾向立即 shouldEndChat=true。
|
||||
- shouldEndChat=true 时 terminationReason 使用 hostile_breakoff,suggestions 与 functionSuggestions 可以为空。"#;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct NpcChatTurnPromptInput<'a> {
|
||||
@@ -394,6 +397,19 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_hostile_model_chat {
|
||||
Some("如果玩家刚才的话被 NPC 感知为负面发言,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,本轮回复应倾向写成最后通牒、驱逐前警告或战斗前狠话。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_hostile_model_chat && chatted_count >= 4.0 {
|
||||
Some(format!(
|
||||
"敌对聊天已持续 {} 轮,本轮结束后会超过 4 轮;回复应明显倾向立即收束,像开战前最后一句狠话,而不是继续闲聊。",
|
||||
format_prompt_number(chatted_count)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
|
||||
} else {
|
||||
@@ -474,6 +490,9 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
|
||||
.and_then(|record| read_string(record.get("terminationReason")))
|
||||
.as_deref()
|
||||
== Some("player_exit");
|
||||
let chatted_count = as_record(payload.npc_state)
|
||||
.and_then(|record| read_number(record.get("chattedCount")))
|
||||
.unwrap_or(0.0);
|
||||
let function_options_block = chat_directive
|
||||
.and_then(|record| record.get("functionOptions"))
|
||||
.map(describe_function_options)
|
||||
@@ -498,6 +517,14 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
|
||||
} else {
|
||||
Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string())
|
||||
},
|
||||
if is_hostile_model_chat {
|
||||
Some(format!(
|
||||
"敌对聊天判定:已聊天轮次为 {}。若玩家刚才的话可被 NPC 感知为负面发言,或已聊天轮次达到 4 轮及以上,本轮应倾向 shouldEndChat=true,并使用 terminationReason=hostile_breakoff。",
|
||||
format_prompt_number(chatted_count)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string())
|
||||
} else {
|
||||
@@ -526,6 +553,20 @@ pub(crate) fn build_deterministic_npc_reply(
|
||||
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
|
||||
}
|
||||
|
||||
pub(crate) fn build_deterministic_hostile_breakoff_reply(
|
||||
npc_name: &str,
|
||||
player_message: &str,
|
||||
) -> String {
|
||||
// 中文注释:当模型不可用而敌对聊天必须中止时,兜底文案也保持“战斗前狠话”的语气。
|
||||
let player_signal = player_message.trim();
|
||||
if player_signal.is_empty() {
|
||||
return format!("{npc_name}冷声说道:“话已经够多了。再往前一步,就别指望还能全身而退。”");
|
||||
}
|
||||
format!(
|
||||
"{npc_name}冷声说道:“{player_signal}?话已经够多了。再往前一步,就别指望还能全身而退。”"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_reply_fallback(
|
||||
target_character: &Value,
|
||||
player_message: &str,
|
||||
@@ -1066,3 +1107,55 @@ fn format_prompt_number(value: f64) -> String {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hostile_prompt_input(npc_state: Value) -> NpcChatTurnPromptInput<'static> {
|
||||
NpcChatTurnPromptInput {
|
||||
world_type: "CUSTOM",
|
||||
character: Box::leak(Box::new(Value::Null)),
|
||||
encounter: Box::leak(Box::new(Value::Null)),
|
||||
monsters: &[],
|
||||
history: &[],
|
||||
context: Box::leak(Box::new(Value::Null)),
|
||||
conversation_history: &[],
|
||||
dialogue: &[],
|
||||
combat_context: None,
|
||||
player_message: "少废话,让开。",
|
||||
npc_state: Box::leak(Box::new(npc_state)),
|
||||
npc_initiates_conversation: false,
|
||||
chat_directive: Some(Box::leak(Box::new(json!({
|
||||
"terminationMode": "hostile_model",
|
||||
"isHostileChat": true,
|
||||
})))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_reply_prompt_mentions_final_threat_after_four_turns() {
|
||||
let input = hostile_prompt_input(json!({
|
||||
"affinity": -12,
|
||||
"chattedCount": 4,
|
||||
}));
|
||||
let prompt = build_npc_chat_turn_reply_prompt(&input);
|
||||
|
||||
assert!(prompt.contains("已聊天轮次:4"));
|
||||
assert!(prompt.contains("战斗前狠话"));
|
||||
assert!(prompt.contains("本轮结束后会超过 4 轮"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_suggestion_prompt_mentions_should_end_chat_signals() {
|
||||
let input = hostile_prompt_input(json!({
|
||||
"affinity": -12,
|
||||
"chattedCount": 4,
|
||||
}));
|
||||
let prompt = build_npc_chat_turn_suggestion_prompt(&input, "再往前一步,就别想回头。");
|
||||
|
||||
assert!(prompt.contains("shouldEndChat=true"));
|
||||
assert!(prompt.contains("terminationReason=hostile_breakoff"));
|
||||
assert!(prompt.contains("已聊天轮次为 4"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user