2
.idea/.name
generated
@@ -1 +1 @@
|
||||
db.test.ts
|
||||
PreGameSelectionFlow.tsx
|
||||
@@ -0,0 +1,844 @@
|
||||
# AI 原生 Agent-First 八锚点共创流程 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 文档目的
|
||||
|
||||
这份 PRD 用于把“八个剧情锚点”从一个抽象方法,落成当前自定义世界创作体系里可直接编码的 Agent 共创流程。
|
||||
|
||||
本文件重点回答的问题不是:
|
||||
|
||||
- 八个锚点分别叫什么
|
||||
- 玩家要不要一上来全部填完
|
||||
|
||||
而是:
|
||||
|
||||
**如何让玩家通过和 Agent 共创,在低压力、低表单感的体验里,被启发、被总结、被校准,逐步完成这八个锚点的稳定设定。**
|
||||
|
||||
这份 PRD 默认建立在现有 `Agent-First 自定义世界创作工具` 主链之上,不新建一套独立系统,不新开一条独立创作产品线,而是在现有创作工作区中,把“锚点收集阶段”升级成更强的共创体验。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品定义
|
||||
|
||||
## 1.1 一句话定义
|
||||
|
||||
让玩家通过与一个懂 RPG 剧情策划方法的 Agent 对话,在自然聊天中逐步明确作品方向、玩家视角、剧情发动机和世界统一母题;同时由 Express 后端把这些聊天沉淀成结构化八锚点状态,并支持确认、锁定、补缺和进入后续世界底稿生成。
|
||||
|
||||
## 1.2 产品定位
|
||||
|
||||
这不是“八题问卷生成器”。
|
||||
|
||||
这也不是“无限闲聊陪聊器”。
|
||||
|
||||
它应当是:
|
||||
|
||||
**一个会启发玩家表达、会主动总结当前理解、会识别缺口并只追问关键问题、最终把共创结果沉淀成结构化创作锚点的 Agent 共创流程。**
|
||||
|
||||
## 1.3 目标用户
|
||||
|
||||
目标用户仍然是当前自定义世界创作工具的三类创作者,但本流程更偏向解决其中两类人的起步问题:
|
||||
|
||||
1. 轻创作者
|
||||
- 有模糊灵感,但不知道先想什么
|
||||
|
||||
2. 中度创作者
|
||||
- 有一些设定点子,但缺少把设定收束成可运行剧情骨架的方法
|
||||
|
||||
重度创作者也可使用本流程,但他们更关心的是:
|
||||
|
||||
- Agent 是否会少问废话
|
||||
- 摘要是否准确
|
||||
- 锚点是否可编辑、可锁定、可回看
|
||||
|
||||
## 1.4 成功标准
|
||||
|
||||
这个流程上线后,必须同时满足:
|
||||
|
||||
1. 玩家不需要面对一整页字段,也能在 `5~12` 分钟内形成一版可用的八锚点底稿。
|
||||
2. Agent 不会机械地把八个锚点逐条盘问,而是根据玩家已有表达做提炼和补缺。
|
||||
3. 每个锚点都能被区分为 `已确认 / Agent 推断 / 待补充 / 已锁定` 四种状态。
|
||||
4. 玩家在任意时刻都能看懂“现在这个世界已经定了什么、还有什么没定、Agent 正在为什么追问”。
|
||||
5. 当前锚点状态能直接进入下一阶段,生成世界底稿、关键角色、关键地点和主线第一幕。
|
||||
6. 所有锚点状态更新、确认、锁定、冲突判断和完成度裁决都在 Express 后端完成,前端只负责表现和输入。
|
||||
|
||||
## 1.5 本次不做什么
|
||||
|
||||
本次 PRD 明确不做:
|
||||
|
||||
1. 不把八个锚点做成固定顺序的硬表单。
|
||||
2. 不让 Agent 一上来抛出 `8` 个问题要求用户逐条回答。
|
||||
3. 不让前端本地判断“锚点是否完整”或“是否可以进入下一阶段”。
|
||||
4. 不要求玩家在这一阶段直接填写全量角色卡、地点卡、阵营卡和章节卡。
|
||||
5. 不把长背景、世界编年史、底层运行结构暴露给玩家做首轮输入。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计背景
|
||||
|
||||
## 2.1 现有最小锚点方案的问题
|
||||
|
||||
现有文档已经证明,“最小锚点 + AI 初稿卡 + 系统托管层”方向是对的。
|
||||
|
||||
但如果直接把锚点做成显式卡片或显式问题列表,会出现 4 个体验问题:
|
||||
|
||||
1. 玩家会有表单焦虑
|
||||
- 明明只是有一个灵感,却像在填写策划需求单
|
||||
|
||||
2. 玩家会误以为每个字段都必须一次写对
|
||||
- 导致迟迟不敢开始
|
||||
|
||||
3. Agent 容易退化成“礼貌复读机”
|
||||
- 只是复述玩家说过的话,没有真实推进创作
|
||||
|
||||
4. 锚点层级混乱时,玩家会觉得问题很多但抓不住重点
|
||||
- 不知道先定体验,还是先定设定,还是先定剧情
|
||||
|
||||
## 2.2 新方案的核心判断
|
||||
|
||||
更合理的方式不是让玩家“填写八个锚点”,而是让 Agent 围绕八个锚点做 3 件事:
|
||||
|
||||
1. 启发
|
||||
- 帮玩家把模糊灵感说出来
|
||||
|
||||
2. 总结
|
||||
- 把玩家已经表达的内容收束成清晰锚点
|
||||
|
||||
3. 补缺
|
||||
- 只追问当前最影响后续生成质量的缺口
|
||||
|
||||
也就是说:
|
||||
|
||||
**八个锚点应该是后台结构,不应该原样等于前台交互。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 八锚点模型
|
||||
|
||||
## 3.1 八锚点定义
|
||||
|
||||
本流程采用以下八个锚点作为底层结构:
|
||||
|
||||
1. `世界承诺`
|
||||
- 这个世界最吸引玩家体验的核心承诺
|
||||
|
||||
2. `玩家幻想`
|
||||
- 玩家扮演谁、追求什么、害怕失去什么
|
||||
|
||||
3. `主题边界`
|
||||
- 作品气质、审美方向、禁忌边界
|
||||
|
||||
4. `玩家切入口`
|
||||
- 玩家从什么身份、困境和局面进入故事
|
||||
|
||||
5. `核心冲突`
|
||||
- 当前世界最主要的明面冲突与隐藏危机
|
||||
|
||||
6. `关键关系`
|
||||
- 最能制造情感张力和选择代价的关系骨架
|
||||
|
||||
7. `暗线与揭示节奏`
|
||||
- 哪些真相暂不揭露、后续如何层层展开
|
||||
|
||||
8. `标志元素与硬规则`
|
||||
- 反复出现的物件、制度、能力、仪式和不可擅改规则
|
||||
|
||||
## 3.2 八锚点不是同级提问列表,而是三层结构
|
||||
|
||||
为了让 Agent 的追问顺序更自然,八锚点在系统内部应分成三层:
|
||||
|
||||
### 第一层:方向盘层
|
||||
|
||||
1. 世界承诺
|
||||
2. 玩家幻想
|
||||
3. 主题边界
|
||||
|
||||
### 第二层:剧情发动机层
|
||||
|
||||
4. 玩家切入口
|
||||
5. 核心冲突
|
||||
6. 关键关系
|
||||
7. 暗线与揭示节奏
|
||||
|
||||
### 第三层:世界统一层
|
||||
|
||||
8. 标志元素与硬规则
|
||||
|
||||
这个分层的意义是:
|
||||
|
||||
1. Agent 先帮玩家说清“这部作品想让人爽什么”
|
||||
2. 再说清“故事靠什么往前推”
|
||||
3. 最后再把“这个世界为什么像同一个世界”收束起来
|
||||
|
||||
---
|
||||
|
||||
## 4. 产品目标
|
||||
|
||||
## 4.1 玩家体验目标
|
||||
|
||||
这套流程必须让玩家在主观体验上感受到:
|
||||
|
||||
1. 我不是在填表,我是在和一个懂行的搭档一起把脑子里的世界说清楚。
|
||||
2. Agent 不是在审问我,而是在帮我抓重点。
|
||||
3. 每聊一两轮,我都能明显看到这个世界变得更成形。
|
||||
4. 如果我一开始只说了一个模糊点子,Agent 也能把我带进状态。
|
||||
5. 如果我说得已经很多,Agent 不会浪费时间问明显问题。
|
||||
|
||||
## 4.2 业务目标
|
||||
|
||||
这套流程必须同时提升:
|
||||
|
||||
1. 起步转化率
|
||||
- 更多玩家愿意真正开始创作
|
||||
|
||||
2. 锚点完整率
|
||||
- 更多 session 能进入世界底稿生成阶段
|
||||
|
||||
3. 底稿质量稳定性
|
||||
- 后续生成的角色、地点、主线第一幕更聚焦,不发散
|
||||
|
||||
4. 可控性
|
||||
- 锚点状态清晰、可回看、可锁定、可修改
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心原则
|
||||
|
||||
## 5.1 先让玩家表达,再让 Agent 命名
|
||||
|
||||
玩家不需要先理解“世界承诺”“主题边界”“暗线节奏”这些术语。
|
||||
|
||||
前台交互里,Agent 应该优先用生活化说法启发玩家,例如:
|
||||
|
||||
- 你最想让玩家在这个世界里爽到什么
|
||||
- 你更想做悲壮、诡异、浪漫还是狠一点的味道
|
||||
- 玩家一开场最先卷进什么麻烦
|
||||
- 这个世界最容易让人记住的东西是什么
|
||||
|
||||
等玩家表达后,再由系统把内容归档到对应锚点。
|
||||
|
||||
## 5.2 每次只追问当前最高杠杆缺口
|
||||
|
||||
当多个锚点都不完整时,Agent 不应平均追问。
|
||||
|
||||
系统必须基于优先级只选择当前最影响后续生成质量的 `1~2` 个问题。
|
||||
|
||||
默认优先级如下:
|
||||
|
||||
1. 世界承诺
|
||||
2. 玩家幻想
|
||||
3. 玩家切入口
|
||||
4. 核心冲突
|
||||
5. 主题边界
|
||||
6. 关键关系
|
||||
7. 标志元素与硬规则
|
||||
8. 暗线与揭示节奏
|
||||
|
||||
原因不是暗线不重要,而是:
|
||||
|
||||
- 暗线通常应建立在前面几层已稳定的前提上
|
||||
- 太早问暗线,容易让新玩家卡住
|
||||
|
||||
## 5.3 Agent 必须周期性总结,而不是只追问
|
||||
|
||||
如果连续两轮都只是在问问题,玩家会感觉自己被采访。
|
||||
|
||||
因此 Agent 必须在关键节点做“短总结”,明确告诉玩家:
|
||||
|
||||
1. 我目前理解你这个世界是什么
|
||||
2. 哪些已经比较稳
|
||||
3. 接下来只差什么就能往下走
|
||||
|
||||
## 5.4 显式区分确认与推断
|
||||
|
||||
Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已确认事实。
|
||||
|
||||
系统和 UI 都必须把锚点拆成:
|
||||
|
||||
1. 玩家已明确确认
|
||||
2. Agent 根据上下文推断
|
||||
3. 还缺信息
|
||||
4. 已锁定不可自动改写
|
||||
|
||||
## 5.5 先收束,再展开
|
||||
|
||||
这一阶段的目标不是把世界写满,而是把方向盘和剧情发动机装好。
|
||||
|
||||
因此 Agent 在八锚点阶段不应主动输出:
|
||||
|
||||
- 大量全新角色
|
||||
- 大量地点
|
||||
- 多章主线
|
||||
- 长篇编年史
|
||||
|
||||
除非玩家主动要求。
|
||||
|
||||
---
|
||||
|
||||
## 6. 整体流程体验
|
||||
|
||||
## 6.1 总流程概览
|
||||
|
||||
八锚点共创阶段分成 6 个小阶段:
|
||||
|
||||
1. `灵感接住`
|
||||
2. `方向盘收束`
|
||||
3. `剧情发动机补齐`
|
||||
4. `世界统一母题收束`
|
||||
5. `共识确认`
|
||||
6. `进入世界底稿生成`
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段设计
|
||||
|
||||
## 7.1 阶段 A:灵感接住
|
||||
|
||||
### 目标
|
||||
|
||||
让玩家用最低压力开始,不要求结构化表达。
|
||||
|
||||
### 触发方式
|
||||
|
||||
玩家进入现有创作工作区后,Agent 第一条消息不展示八锚点清单,不展示术语定义,而是给出一个轻启动入口。
|
||||
|
||||
推荐首条引导语方向:
|
||||
|
||||
1. 你可以直接说一个灵感画面
|
||||
2. 也可以说一个你想做的题材混搭
|
||||
3. 也可以说你想让玩家体验到的感觉
|
||||
|
||||
### Agent 行为要求
|
||||
|
||||
1. 允许玩家只说一句不完整的话
|
||||
2. 优先识别其中已经透露出的锚点线索
|
||||
3. 不急着问全套问题
|
||||
4. 第一轮回复必须同时包含:
|
||||
- 对灵感的理解与接住
|
||||
- 一段不超过 `3` 点的当前提炼
|
||||
- 下一个最关键问题
|
||||
|
||||
### 体验要求
|
||||
|
||||
玩家发完第一句后,必须立刻感到:
|
||||
|
||||
- 被理解了
|
||||
- 世界已经开始成形了
|
||||
- 下一步很容易答
|
||||
|
||||
## 7.2 阶段 B:方向盘收束
|
||||
|
||||
### 目标
|
||||
|
||||
收束三件事:
|
||||
|
||||
1. 世界承诺
|
||||
2. 玩家幻想
|
||||
3. 主题边界
|
||||
|
||||
### Agent 行为要求
|
||||
|
||||
这一阶段的问题应该围绕“作品想让玩家爽什么、沉什么、怕什么”展开,而不是先问大量设定名词。
|
||||
|
||||
推荐提问策略:
|
||||
|
||||
1. 优先问体验差异
|
||||
- “你最想让这个世界在哪一点上和同类不一样?”
|
||||
|
||||
2. 再问玩家代入
|
||||
- “玩家在这里最想成为什么样的人?”
|
||||
|
||||
3. 最后问气质边界
|
||||
- “你更想要狠一点、暖一点、诡一点,还是史诗一点?”
|
||||
|
||||
### 阶段完成定义
|
||||
|
||||
当系统判断以下条件同时满足时,阶段 B 完成:
|
||||
|
||||
1. 世界承诺非空,且有至少 `1` 条体验差异
|
||||
2. 玩家幻想包含 `身份或追求` 与 `失去恐惧或代价`
|
||||
3. 主题边界至少包含 `1` 条风格方向与 `1` 条禁忌边界
|
||||
|
||||
## 7.3 阶段 C:剧情发动机补齐
|
||||
|
||||
### 目标
|
||||
|
||||
补齐:
|
||||
|
||||
1. 玩家切入口
|
||||
2. 核心冲突
|
||||
3. 关键关系
|
||||
4. 暗线与揭示节奏
|
||||
|
||||
### Agent 行为要求
|
||||
|
||||
这一阶段不能抽象空问,而要从“开场局面”入手。
|
||||
|
||||
推荐顺序:
|
||||
|
||||
1. 玩家一进来先遇到什么麻烦
|
||||
2. 这个麻烦背后连着什么更大的矛盾
|
||||
3. 谁和谁之间最有火药味或亏欠感
|
||||
4. 这世界有没有什么真相不想一开始就亮出来
|
||||
|
||||
### 特别要求
|
||||
|
||||
如果玩家已经自然说出了一部分角色、阵营、旧案,Agent 应先总结再追问,不得无视已有信息重复提问。
|
||||
|
||||
### 阶段完成定义
|
||||
|
||||
当系统判断以下条件同时满足时,阶段 C 完成:
|
||||
|
||||
1. 玩家切入口包含 `身份/局面/动机` 中至少 `2` 项
|
||||
2. 核心冲突包含 `1` 条明面冲突和 `1` 条隐藏危机
|
||||
3. 至少有 `2` 条关键关系骨架
|
||||
4. 暗线与揭示节奏至少明确 `1` 条隐藏真相和 `1` 条延后揭示意图
|
||||
|
||||
## 7.4 阶段 D:世界统一母题收束
|
||||
|
||||
### 目标
|
||||
|
||||
明确:
|
||||
|
||||
1. 标志元素
|
||||
2. 硬规则
|
||||
|
||||
### Agent 行为要求
|
||||
|
||||
不要用“请列举标志元素”这种空问法。
|
||||
|
||||
应从玩家已经说过的世界里抽取候选,再请玩家确认或替换,例如:
|
||||
|
||||
- “我感觉你这个世界的记忆点可能会落在失控神谕、债契纹印和会吃人的旧城制度上,这个方向对吗?”
|
||||
|
||||
### 阶段完成定义
|
||||
|
||||
1. 至少确认 `2~5` 个标志元素
|
||||
2. 至少确认 `1~3` 条硬规则
|
||||
|
||||
## 7.5 阶段 E:共识确认
|
||||
|
||||
### 目标
|
||||
|
||||
把前面多轮对话沉淀成一份可以被玩家直接确认的八锚点摘要。
|
||||
|
||||
### Agent 行为要求
|
||||
|
||||
Agent 必须输出一份结构化但仍然易读的摘要,分成三类:
|
||||
|
||||
1. 已确认
|
||||
2. 我的推断
|
||||
3. 还可补强
|
||||
|
||||
同时只问玩家一个问题:
|
||||
|
||||
**“这版底子你要先锁定,还是还想继续打磨某一块?”**
|
||||
|
||||
### 阶段完成定义
|
||||
|
||||
玩家执行以下任一动作即可:
|
||||
|
||||
1. 确认进入下一阶段
|
||||
2. 锁定部分锚点后进入下一阶段
|
||||
3. 指定某个锚点继续精修
|
||||
|
||||
## 7.6 阶段 F:进入世界底稿生成
|
||||
|
||||
### 目标
|
||||
|
||||
把八锚点状态提交给后端,生成首轮世界底稿包。
|
||||
|
||||
### 本阶段产物
|
||||
|
||||
系统至少生成:
|
||||
|
||||
1. 世界标题与摘要
|
||||
2. `3~5` 个关键角色卡
|
||||
3. `2~4` 个势力卡
|
||||
4. `4~6` 个关键地点卡
|
||||
5. `3~5` 条世界线程
|
||||
6. 主线第一幕简稿
|
||||
|
||||
---
|
||||
|
||||
## 8. Agent 回复策略
|
||||
|
||||
## 8.1 单轮回复结构
|
||||
|
||||
八锚点阶段中,Agent 的标准回复结构应尽量稳定为:
|
||||
|
||||
1. `接住`
|
||||
- 一句话表明理解到玩家当前表达的重点
|
||||
|
||||
2. `提炼`
|
||||
- 用 `2~4` 条短句总结已浮现的锚点
|
||||
|
||||
3. `补缺`
|
||||
- 只问 `1` 个主问题,必要时附 `1` 个轻量补充问法
|
||||
|
||||
## 8.2 禁止行为
|
||||
|
||||
这一阶段禁止 Agent 出现以下回复模式:
|
||||
|
||||
1. 连续大段夸赞,没有实质推进
|
||||
2. 把玩家原话换个说法重复一遍就结束
|
||||
3. 一次抛出 `5` 个以上问题
|
||||
4. 在锚点未稳定前自动生成成批设定
|
||||
5. 把推断写成已确认事实
|
||||
|
||||
## 8.3 提问模板原则
|
||||
|
||||
提问模板必须符合:
|
||||
|
||||
1. 生活化
|
||||
2. 可口语回答
|
||||
3. 不要求术语知识
|
||||
4. 一次只推进一个认知动作
|
||||
|
||||
例如:
|
||||
|
||||
- 好问题:
|
||||
- “你最想让玩家在这个世界里上瘾的是哪种感觉?”
|
||||
- “玩家一开场是被追、被困、被误认,还是自己主动撞进去?”
|
||||
|
||||
- 坏问题:
|
||||
- “请详细描述你的主题母题、叙事支柱、隐性线索分发策略与世界统一意象。”
|
||||
|
||||
---
|
||||
|
||||
## 9. 前台交互设计
|
||||
|
||||
## 9.1 总体原则
|
||||
|
||||
沿用现有创作工作区,不新开页面。
|
||||
|
||||
只在现有 Agent 工作区中新增更明确的锚点反馈区和阶段反馈区。
|
||||
|
||||
## 9.2 工作区组成
|
||||
|
||||
八锚点阶段的工作区默认包含三块:
|
||||
|
||||
1. `左侧或主区:聊天流`
|
||||
- 玩家输入
|
||||
- Agent 回复
|
||||
- 阶段性总结
|
||||
|
||||
2. `侧边摘要区:当前世界底子`
|
||||
- 以易读摘要展示八锚点当前状态
|
||||
|
||||
3. `底部操作区:下一步动作`
|
||||
- 继续聊
|
||||
- 确认这一版
|
||||
- 锁定当前理解
|
||||
- 指定重聊某个锚点
|
||||
|
||||
## 9.3 摘要区展示规则
|
||||
|
||||
摘要区不展示长解释,不堆技术词。
|
||||
|
||||
每个锚点只展示:
|
||||
|
||||
1. 标题
|
||||
2. 一行摘要
|
||||
3. 状态标签
|
||||
|
||||
状态标签仅允许:
|
||||
|
||||
1. `已确认`
|
||||
2. `推断中`
|
||||
3. `待补充`
|
||||
4. `已锁定`
|
||||
|
||||
## 9.4 阶段提示
|
||||
|
||||
工作区应始终用一句短提示明确当前阶段,例如:
|
||||
|
||||
- 正在帮你收束作品方向
|
||||
- 正在补齐剧情冲突和关系发动机
|
||||
- 正在确认这个世界最有记忆点的母题
|
||||
|
||||
禁止在 UI 上默认显示大段规则说明文字。
|
||||
|
||||
---
|
||||
|
||||
## 10. 后端结构设计
|
||||
|
||||
## 10.1 真实状态源
|
||||
|
||||
Express 后端必须作为唯一真实状态源,负责:
|
||||
|
||||
1. 解析聊天消息
|
||||
2. 更新八锚点结构
|
||||
3. 计算锚点状态
|
||||
4. 生成下一轮提问建议
|
||||
5. 生成阶段性摘要
|
||||
6. 判断是否进入下一阶段
|
||||
|
||||
## 10.2 结构化状态模型
|
||||
|
||||
建议新增或扩展 `creatorIntent` 为 `eightAnchorDraft` 结构,至少包含:
|
||||
|
||||
```ts
|
||||
type AnchorStatus = 'missing' | 'inferred' | 'confirmed' | 'locked';
|
||||
|
||||
type AnchorField<T> = {
|
||||
value: T | null;
|
||||
status: AnchorStatus;
|
||||
confidence: number;
|
||||
sourceMessageIds: string[];
|
||||
summary: string;
|
||||
openQuestions: string[];
|
||||
};
|
||||
|
||||
type EightAnchorDraft = {
|
||||
worldPromise: AnchorField<WorldPromiseValue>;
|
||||
playerFantasy: AnchorField<PlayerFantasyValue>;
|
||||
themeBoundary: AnchorField<ThemeBoundaryValue>;
|
||||
playerEntryPoint: AnchorField<PlayerEntryPointValue>;
|
||||
coreConflict: AnchorField<CoreConflictValue>;
|
||||
keyRelationships: AnchorField<KeyRelationshipValue[]>;
|
||||
hiddenLines: AnchorField<HiddenLineValue>;
|
||||
iconicElements: AnchorField<IconicElementValue>;
|
||||
phase: 'spark' | 'direction' | 'engine' | 'motif' | 'review' | 'ready';
|
||||
readyForFoundationDraft: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
## 10.3 每个锚点的最小字段
|
||||
|
||||
### 世界承诺
|
||||
|
||||
- `hook`
|
||||
- `differentiator`
|
||||
- `desiredExperience`
|
||||
|
||||
### 玩家幻想
|
||||
|
||||
- `playerRole`
|
||||
- `corePursuit`
|
||||
- `fearOfLoss`
|
||||
|
||||
### 主题边界
|
||||
|
||||
- `toneKeywords`
|
||||
- `aestheticDirectives`
|
||||
- `forbiddenDirectives`
|
||||
|
||||
### 玩家切入口
|
||||
|
||||
- `openingIdentity`
|
||||
- `openingProblem`
|
||||
- `entryMotivation`
|
||||
|
||||
### 核心冲突
|
||||
|
||||
- `surfaceConflicts`
|
||||
- `hiddenCrisis`
|
||||
- `firstTouchedConflict`
|
||||
|
||||
### 关键关系
|
||||
|
||||
- `pairs`
|
||||
- `relationshipType`
|
||||
- `secretOrCost`
|
||||
|
||||
### 暗线与揭示节奏
|
||||
|
||||
- `hiddenTruths`
|
||||
- `misdirectionHints`
|
||||
- `revealPacing`
|
||||
|
||||
### 标志元素与硬规则
|
||||
|
||||
- `iconicMotifs`
|
||||
- `institutionsOrArtifacts`
|
||||
- `hardRules`
|
||||
|
||||
## 10.4 更新策略
|
||||
|
||||
每轮聊天后,后端需要依次执行:
|
||||
|
||||
1. 从用户消息中提取可能涉及的锚点更新
|
||||
2. 判断新增内容是确认、补充还是冲突
|
||||
3. 生成新的锚点摘要
|
||||
4. 重新计算缺口优先级
|
||||
5. 产出下一轮 Agent 回复所需的:
|
||||
- 当前理解
|
||||
- 待补问题
|
||||
- 禁止重复问的问题
|
||||
- 推荐阶段标签
|
||||
|
||||
## 10.5 冲突处理
|
||||
|
||||
如果玩家后续表达与已确认锚点冲突,系统不得静默覆盖。
|
||||
|
||||
必须生成显式冲突状态,并让 Agent 用自然语言确认,例如:
|
||||
|
||||
- “你前面更像想做冷硬末日,现在这轮开始偏浪漫奇谭了,我先不自动改,想确认你是准备转方向,还是只想让其中一条支线更柔一点?”
|
||||
|
||||
---
|
||||
|
||||
## 11. 完成度判断
|
||||
|
||||
## 11.1 锚点完成度
|
||||
|
||||
每个锚点都需要有自己的完成度判断函数。
|
||||
|
||||
完成度不等于“字段不为空”,而是要判断其是否足以支持下一阶段生成。
|
||||
|
||||
例如:
|
||||
|
||||
- 世界承诺只有“修仙世界”不能算完成
|
||||
- 世界承诺如果是“一个靠借寿续命维持秩序的仙朝里,玩家要在飞升诱惑和众生寿债之间做选择”,则可判定为高完成度
|
||||
|
||||
## 11.2 阶段完成判定
|
||||
|
||||
系统不要求八个锚点都达到满分才允许进入下一阶段。
|
||||
|
||||
建议阈值:
|
||||
|
||||
1. 方向盘层全部至少为 `confirmed` 或高置信 `inferred`
|
||||
2. 核心冲突必须为 `confirmed`
|
||||
3. 玩家切入口必须至少高置信 `inferred`
|
||||
4. 关键关系至少有 `2` 条
|
||||
5. 标志元素至少有 `2` 个
|
||||
|
||||
满足以上条件即可进入 `ready`
|
||||
|
||||
---
|
||||
|
||||
## 12. 示例体验脚本
|
||||
|
||||
## 12.1 正常起步示例
|
||||
|
||||
玩家输入:
|
||||
|
||||
“我想做一个有点像修仙版黑帮世界的东西,大家靠供奉某种会回应愿望的古神器活着,但越许愿越会被改写。”
|
||||
|
||||
Agent 不应回复成八问表:
|
||||
|
||||
- 世界一句话是什么
|
||||
- 玩家身份是什么
|
||||
- 主题气质是什么
|
||||
|
||||
而应回复成:
|
||||
|
||||
1. 先接住这个灵感
|
||||
2. 提炼:
|
||||
- 世界承诺像是“高代价许愿修仙”
|
||||
- 标志元素已经浮现出“古神器”和“愿望改写”
|
||||
- 核心冲突隐约是“活下去必须许愿,但许愿会失去自我”
|
||||
3. 再只问一个高杠杆问题:
|
||||
- “你更想让玩家一开始就是这个秩序里的既得利益者,还是一个被它逼到角落的人?”
|
||||
|
||||
## 12.2 已说很多时的示例
|
||||
|
||||
如果玩家已经连续说了 `3` 大段设定,Agent 下一轮应优先总结,而不是继续散问。
|
||||
|
||||
推荐动作:
|
||||
|
||||
1. 给出一版八锚点缩略摘要
|
||||
2. 标明哪些是推断
|
||||
3. 只指出当前最影响后续生成质量的一个缺口
|
||||
|
||||
---
|
||||
|
||||
## 13. 与后续阶段的衔接
|
||||
|
||||
## 13.1 输入给世界底稿生成阶段的内容
|
||||
|
||||
八锚点阶段结束后,后续世界底稿生成必须直接吃结构化 `eightAnchorDraft`,而不是重新从聊天全文读一遍。
|
||||
|
||||
## 13.2 后续可编辑范围
|
||||
|
||||
进入世界底稿阶段后,创作者默认优先精修:
|
||||
|
||||
1. 关键角色
|
||||
2. 核心冲突与线程
|
||||
3. 关键地点
|
||||
4. 主线第一幕
|
||||
|
||||
八锚点继续作为全局约束存在。
|
||||
|
||||
---
|
||||
|
||||
## 14. 埋点与评估
|
||||
|
||||
## 14.1 关键埋点
|
||||
|
||||
至少记录:
|
||||
|
||||
1. 首轮消息后是否进入第二轮
|
||||
2. 每个锚点首次从 `missing` 变成 `inferred` 的时间
|
||||
3. 每个锚点首次被玩家确认的时间
|
||||
4. 会话进入 `ready` 所需轮数
|
||||
5. 玩家在共识确认阶段选择:
|
||||
- 继续打磨
|
||||
- 锁定并继续
|
||||
- 放弃
|
||||
|
||||
## 14.2 质量评估指标
|
||||
|
||||
上线后重点观察:
|
||||
|
||||
1. 平均完成锚点数
|
||||
2. 进入底稿生成阶段的比例
|
||||
3. 玩家主动修改摘要的比例
|
||||
4. 后续生成内容是否出现发散或跑偏
|
||||
5. 玩家是否频繁抱怨“Agent 老在问我已经说过的东西”
|
||||
|
||||
---
|
||||
|
||||
## 15. 实现拆分建议
|
||||
|
||||
## 15.1 第一阶段
|
||||
|
||||
先做最小闭环:
|
||||
|
||||
1. 八锚点结构化状态
|
||||
2. 锚点状态标签
|
||||
3. 单轮提炼 + 单问题追问
|
||||
4. 共识确认摘要
|
||||
5. 进入下一阶段的后端判定
|
||||
|
||||
## 15.2 第二阶段
|
||||
|
||||
再补:
|
||||
|
||||
1. 冲突检测
|
||||
2. 更细的完成度评分
|
||||
3. 阶段提示语
|
||||
4. 指定锚点重聊
|
||||
5. 锁定后禁止自动改写
|
||||
|
||||
## 15.3 第三阶段
|
||||
|
||||
继续补:
|
||||
|
||||
1. 更强的 Agent 提问策略
|
||||
2. 更丰富的摘要模板
|
||||
3. 基于锚点的底稿质量评估
|
||||
4. 对不同题材的提问风格适配
|
||||
|
||||
---
|
||||
|
||||
## 16. 验收标准
|
||||
|
||||
本 PRD 对应功能完成后,至少必须满足:
|
||||
|
||||
1. 玩家只输入一段模糊灵感时,Agent 能给出有效提炼和一个高杠杆追问。
|
||||
2. 玩家连续多轮输入后,八锚点摘要会持续更新,不只是聊天记录增长。
|
||||
3. 工作区能稳定显示每个锚点的当前状态。
|
||||
4. Agent 不会在同一锚点已高置信完成后继续反复追问。
|
||||
5. 玩家可明确确认当前理解、锁定部分锚点或指定某个锚点继续打磨。
|
||||
6. 八锚点状态能被后端判定为 `ready` 并进入世界底稿生成。
|
||||
7. 前端不承担锚点完成度判断、冲突裁决和下一步阶段判断。
|
||||
8. 相关测试、`check:encoding` 通过。
|
||||
|
||||
---
|
||||
|
||||
## 17. 一句话结论
|
||||
|
||||
八锚点真正应该做成的,不是一套问卷,也不是一堆字段,而是:
|
||||
|
||||
**一个由 Agent 主导的启发式共创流程:先接住灵感,再提炼方向,再补齐剧情发动机,最后把玩家和 Agent 的共识沉淀成可运行的世界底子。**
|
||||
147
docs/prd/MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# “我的”Tab 历史浏览 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把“历史浏览”从本地浏览记录升级成账号级内容回访能力,让玩家能找回最近看过的作品,并支持跨设备同步。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前现状与问题
|
||||
|
||||
当前仓库里 `platformBrowseHistory.ts` 采用 `localStorage` 方案,存在明显限制:
|
||||
|
||||
1. 仅本机可见
|
||||
2. 浏览记录上限固定且不可运营
|
||||
3. 删除缓存后全部丢失
|
||||
4. 无法用于账号级推荐和召回
|
||||
|
||||
---
|
||||
|
||||
## 2. 本期范围
|
||||
|
||||
## 2.1 本期要做
|
||||
|
||||
1. 账号级历史浏览记录
|
||||
2. 历史浏览列表接口
|
||||
3. 浏览记录去重与排序
|
||||
4. 清空历史入口
|
||||
|
||||
## 2.2 本期不做
|
||||
|
||||
1. 浏览历史搜索
|
||||
2. 收藏夹合并
|
||||
3. 基于历史的复杂推荐页
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
## 3.1 记录时机
|
||||
|
||||
用户进入公开作品详情页时写入浏览记录。
|
||||
|
||||
不写入的场景:
|
||||
|
||||
- 草稿世界
|
||||
- 未真正打开详情的列表曝光
|
||||
|
||||
## 3.2 列表规则
|
||||
|
||||
每条记录展示:
|
||||
|
||||
- 世界名
|
||||
- 作者名
|
||||
- 摘要
|
||||
- 封面
|
||||
- 最近浏览时间
|
||||
|
||||
排序:
|
||||
|
||||
- 按 `visitedAt` 倒序
|
||||
|
||||
去重:
|
||||
|
||||
- 同一用户对同一作品只保留最近一次
|
||||
|
||||
## 3.3 管理动作
|
||||
|
||||
支持:
|
||||
|
||||
1. 点击记录进入作品详情
|
||||
2. 清空全部浏览历史
|
||||
|
||||
首期不做单条删除,避免交互复杂化。
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端设计
|
||||
|
||||
## 4.1 数据模型
|
||||
|
||||
建议新增:
|
||||
|
||||
### `user_browse_history`
|
||||
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `owner_user_id`
|
||||
- `profile_id`
|
||||
- `world_name`
|
||||
- `subtitle`
|
||||
- `summary_text`
|
||||
- `cover_image_src`
|
||||
- `theme_mode`
|
||||
- `author_display_name`
|
||||
- `visited_at`
|
||||
|
||||
并对 `user_id + owner_user_id + profile_id` 做唯一约束或 upsert。
|
||||
|
||||
## 4.2 接口
|
||||
|
||||
### `POST /api/profile/browse-history`
|
||||
|
||||
用途:
|
||||
|
||||
- 进入作品详情时写入记录
|
||||
|
||||
### `GET /api/profile/browse-history`
|
||||
|
||||
返回:
|
||||
|
||||
- 浏览历史列表
|
||||
|
||||
### `DELETE /api/profile/browse-history`
|
||||
|
||||
用途:
|
||||
|
||||
- 清空当前账号浏览历史
|
||||
|
||||
---
|
||||
|
||||
## 5. 迁移策略
|
||||
|
||||
为了兼容当前本地历史:
|
||||
|
||||
1. 用户首次登录后可尝试把本地历史批量上报一次
|
||||
2. 服务端落库成功后,以服务端历史为主
|
||||
3. 本地历史保留为短期兜底缓存,不再作为主数据源
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现要求
|
||||
|
||||
1. “我的”页优先读服务端历史
|
||||
2. 清空历史前给出确认
|
||||
3. 空态保持轻量,不写规则说明
|
||||
4. 失败时保留当前列表,不做闪断
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 浏览详情后能在历史浏览中看到记录
|
||||
2. 同一作品重复浏览只保留最新一条
|
||||
3. 跨设备登录后可看到同一份历史
|
||||
4. 清空后列表立即刷新
|
||||
154
docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# “我的”Tab 我的数据看板 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把“剩余叙世币 / 总游戏时长 / 玩过作品”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前现状与问题
|
||||
|
||||
当前三个数字来源并不统一:
|
||||
|
||||
1. 叙世币来自当前存档上下文,不等于账号总资产
|
||||
2. 总游戏时长依赖当前快照,不代表全账号累计
|
||||
3. 玩过作品当前几乎是硬编码推导,不是真实统计
|
||||
|
||||
这会导致“我的”页看到的数据不可信。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本期范围
|
||||
|
||||
## 2.1 本期要做
|
||||
|
||||
1. 账号级数据聚合接口
|
||||
2. 三张核心数据卡
|
||||
3. 数据更新时间策略
|
||||
4. 点击卡片查看明细的扩展位
|
||||
|
||||
## 2.2 本期不做
|
||||
|
||||
1. 成就系统
|
||||
2. 排行榜
|
||||
3. 全量行为分析页
|
||||
|
||||
---
|
||||
|
||||
## 3. 指标定义
|
||||
|
||||
## 3.1 剩余叙世币
|
||||
|
||||
定义:
|
||||
|
||||
- 当前账号可立即消费的叙世币余额
|
||||
|
||||
不使用:
|
||||
|
||||
- 当前单个存档里的临时货币数值
|
||||
|
||||
## 3.2 总游戏时长
|
||||
|
||||
定义:
|
||||
|
||||
- 当前账号下所有正式游玩会话累计时长
|
||||
|
||||
规则:
|
||||
|
||||
- 只累计进入有效游戏流程的时长
|
||||
- 后台挂机超阈值后停止累计
|
||||
|
||||
## 3.3 玩过作品
|
||||
|
||||
定义:
|
||||
|
||||
- 当前账号实际进入过可游玩世界并产生有效游玩记录的去重作品数
|
||||
|
||||
去重键建议:
|
||||
|
||||
- `ownerUserId + profileId`
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
## 4.1 交互
|
||||
|
||||
三张卡片默认仅展示数字和标题。
|
||||
|
||||
点击行为:
|
||||
|
||||
1. 叙世币卡
|
||||
- 打开资产流水抽屉
|
||||
2. 总游戏时长卡
|
||||
- 打开游玩统计抽屉
|
||||
3. 玩过作品卡
|
||||
- 打开玩过作品列表
|
||||
|
||||
如果本期不做明细页,点击可先无动作,但必须预留可扩展事件位。
|
||||
|
||||
## 4.2 展示规则
|
||||
|
||||
1. 数字过大时做单位缩略展示
|
||||
2. 进入页面先展示骨架屏
|
||||
3. 数据请求失败时展示降级文案,不展示假数字
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端设计
|
||||
|
||||
## 5.1 聚合模型
|
||||
|
||||
建议新增账号聚合视图或服务:
|
||||
|
||||
- `wallet_balance`
|
||||
- `total_play_time_ms`
|
||||
- `played_world_count`
|
||||
- `updated_at`
|
||||
|
||||
## 5.2 接口
|
||||
|
||||
### `GET /api/profile/dashboard`
|
||||
|
||||
返回:
|
||||
|
||||
- `walletBalance`
|
||||
- `totalPlayTimeMs`
|
||||
- `playedWorldCount`
|
||||
- `updatedAt`
|
||||
|
||||
### `GET /api/profile/wallet-ledger`
|
||||
|
||||
返回:
|
||||
|
||||
- 叙世币流水列表
|
||||
|
||||
### `GET /api/profile/play-stats`
|
||||
|
||||
返回:
|
||||
|
||||
- 游玩时长分布
|
||||
- 玩过作品列表摘要
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据来源要求
|
||||
|
||||
1. 钱包余额从后端钱包台账聚合
|
||||
2. 游戏时长从运行时会话日志或快照汇总
|
||||
3. 玩过作品数从有效游玩记录去重计算
|
||||
|
||||
禁止继续采用:
|
||||
|
||||
- 仅从当前存档快照直接读取全部看板数据
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 三个核心指标都能按账号稳定返回
|
||||
2. 切换设备后看板数据一致
|
||||
3. 没有存档时也能正常展示账号级数据
|
||||
4. 数据加载失败时页面表现可控
|
||||
104
docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# “我的”Tab 功能 PRD 索引
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
基于当前仓库里 `src/components/game-shell/PlatformHomeView.tsx` 已经存在的 “我的” Tab 首屏结构,把页面内每个功能入口都拆成可独立开发、可独立排期、可直接进入编码的 PRD。
|
||||
|
||||
这次不是重新发明一个新的个人中心系统,而是遵守当前项目约束:
|
||||
|
||||
1. 尽量复用现有平台首页与账号体系
|
||||
2. 前端只负责表现,逻辑、校验、数据归属全部交给 Express 后端
|
||||
3. 移动端优先,桌面端兼容
|
||||
4. UI 保持清爽,不在界面默认堆规则说明文案
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前“我的”Tab 功能拆分
|
||||
|
||||
当前页面可拆成以下 `9` 个独立功能:
|
||||
|
||||
1. 账号资料与身份卡
|
||||
2. 会员中心与充值
|
||||
3. 我的数据看板
|
||||
4. 最近游玩
|
||||
5. 历史浏览
|
||||
6. 邀请好友
|
||||
7. 填邀请码
|
||||
8. 玩家社区
|
||||
9. 设置与账号安全
|
||||
|
||||
---
|
||||
|
||||
## 2. PRD 文件清单
|
||||
|
||||
1. [MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md)
|
||||
2. [MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md)
|
||||
3. [MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md)
|
||||
4. [MY_TAB_RECENT_PLAY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_RECENT_PLAY_PRD_2026-04-16.md)
|
||||
5. [MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md)
|
||||
6. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md)
|
||||
7. [MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md)
|
||||
8. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md)
|
||||
9. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md)
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐开发顺序
|
||||
|
||||
建议按下面顺序推进,避免后续返工:
|
||||
|
||||
1. 账号资料与身份卡
|
||||
2. 设置与账号安全
|
||||
3. 最近游玩
|
||||
4. 历史浏览
|
||||
5. 我的数据看板
|
||||
6. 会员中心与充值
|
||||
7. 邀请好友
|
||||
8. 填邀请码
|
||||
9. 玩家社区
|
||||
|
||||
原因:
|
||||
|
||||
- `1 + 2` 复用现有账号系统最多,最容易先落地
|
||||
- `3 + 4 + 5` 直接增强“我的”页内容密度,短期收益高
|
||||
- `6 + 7` 涉及商业化和关系绑定,依赖结算与奖励台账
|
||||
- `8` 最适合放在平台内容层能力稳定后再做
|
||||
|
||||
---
|
||||
|
||||
## 4. 模块边界约束
|
||||
|
||||
### 4.1 前端边界
|
||||
|
||||
- `PlatformHomeView` 继续作为“我的”Tab 首屏承载层
|
||||
- 优先采用现有面板、抽屉、弹窗,不新建独立大系统
|
||||
- 页面只展示后端返回的状态,不自行计算结论型业务状态
|
||||
|
||||
### 4.2 后端边界
|
||||
|
||||
- 用户资料、会员、资产、邀请、浏览历史、账号安全全部统一进 Express 后端
|
||||
- 不允许继续把历史浏览、邀请码状态、会员权益状态仅存本地
|
||||
- 用户相关聚合数据必须按账号隔离
|
||||
|
||||
### 4.3 数据边界
|
||||
|
||||
- 所有“我的”数据都必须与正式账号绑定
|
||||
- 微信待绑定手机号状态下,只展示最小必要的账户与安全入口
|
||||
- 涉及奖励、货币、权益的变更必须有流水
|
||||
|
||||
---
|
||||
|
||||
## 5. 结果要求
|
||||
|
||||
这组 PRD 交付后,开发层应能直接回答下面问题:
|
||||
|
||||
1. 这个功能入口点哪里打开
|
||||
2. 用户看见什么
|
||||
3. 前端调用什么接口
|
||||
4. 后端存什么数据
|
||||
5. 什么状态能操作,什么状态不能操作
|
||||
6. 怎么验收算完成
|
||||
|
||||
如果后续继续扩写实现计划,建议直接以这 9 份 PRD 为母文档,不再重新发散一套新的“个人中心总方案”。
|
||||
160
docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# “我的”Tab 填邀请码 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把“填邀请码”做成用户激活早期的一次性绑定动作,完成:
|
||||
|
||||
1. 输入邀请码
|
||||
2. 校验邀请码是否可用
|
||||
3. 绑定邀请关系
|
||||
4. 发放被邀请奖励
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前现状与问题
|
||||
|
||||
当前页面有“填邀请码”按钮,但没有成型规则。最容易踩坑的点是:
|
||||
|
||||
1. 什么时候还能填
|
||||
2. 一个账号能不能改绑
|
||||
3. 邀请人与被邀请人奖励何时发
|
||||
|
||||
如果不先写清楚,后续很容易出现刷奖励和投诉问题。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本期范围
|
||||
|
||||
## 2.1 本期要做
|
||||
|
||||
1. 邀请码填写弹窗
|
||||
2. 邀请关系校验
|
||||
3. 一次性绑定规则
|
||||
4. 绑定成功后的奖励发放
|
||||
|
||||
## 2.2 本期不做
|
||||
|
||||
1. 改绑邀请人
|
||||
2. 申诉人工修正流程
|
||||
3. 活动邀请码多类型扩展
|
||||
|
||||
---
|
||||
|
||||
## 3. 业务规则
|
||||
|
||||
## 3.1 填写时机
|
||||
|
||||
邀请码只允许在下面时间窗内填写:
|
||||
|
||||
1. 新账号注册后
|
||||
2. 且尚未绑定过任何邀请码
|
||||
3. 且未超过首个有效周期,例如 `7` 天
|
||||
|
||||
## 3.2 不允许情况
|
||||
|
||||
以下情况不可填写:
|
||||
|
||||
1. 已绑定过邀请码
|
||||
2. 用自己的邀请码填写
|
||||
3. 已超过填写时效
|
||||
4. 邀请码失效或不存在
|
||||
|
||||
## 3.3 绑定结果
|
||||
|
||||
绑定成功后:
|
||||
|
||||
1. 写入邀请关系
|
||||
2. 发放被邀请用户奖励
|
||||
3. 更新邀请人待结算或已结算状态
|
||||
|
||||
邀请码绑定后不可撤销、不可修改。
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
## 4.1 页面结构
|
||||
|
||||
弹窗内容仅保留:
|
||||
|
||||
1. 输入框
|
||||
2. 提交按钮
|
||||
3. 当前可否填写状态
|
||||
|
||||
不默认写长篇规则说明。
|
||||
|
||||
必要提示采用短句:
|
||||
|
||||
- 已绑定,无法修改
|
||||
- 该邀请码不可用
|
||||
- 绑定成功
|
||||
|
||||
## 4.2 交互
|
||||
|
||||
1. 输入邀请码
|
||||
2. 点击确认绑定
|
||||
3. 服务端校验
|
||||
4. 返回成功或失败状态
|
||||
|
||||
成功后:
|
||||
|
||||
- 按钮置灰
|
||||
- 展示绑定的邀请人昵称或摘要
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端设计
|
||||
|
||||
## 5.1 数据模型
|
||||
|
||||
复用:
|
||||
|
||||
- `user_invite_codes`
|
||||
- `user_referral_relations`
|
||||
- `user_reward_ledger`
|
||||
|
||||
并为被邀请方增加:
|
||||
|
||||
- `invited_by_user_id`
|
||||
- `invite_bound_at`
|
||||
|
||||
## 5.2 接口
|
||||
|
||||
### `GET /api/referrals/redeem-status`
|
||||
|
||||
返回:
|
||||
|
||||
- 是否还能填写
|
||||
- 已绑定邀请人摘要
|
||||
- 填写截止时间
|
||||
|
||||
### `POST /api/referrals/redeem-code`
|
||||
|
||||
入参:
|
||||
|
||||
- `inviteCode`
|
||||
|
||||
出参:
|
||||
|
||||
- `ok`
|
||||
- `inviterSummary`
|
||||
- `rewardSummary`
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现要求
|
||||
|
||||
1. 已绑定状态下直接展示结果,不再显示输入表单
|
||||
2. 提交中不能重复点击
|
||||
3. 服务端失败原因要原样映射成短提示
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 符合条件的新账号可以成功绑定邀请码
|
||||
2. 同一账号不能重复绑定
|
||||
3. 不能填写自己的邀请码
|
||||
4. 奖励发放结果可追踪
|
||||
167
docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# “我的”Tab 邀请好友 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把“邀请好友”做成正式的拉新入口,首期目标是:
|
||||
|
||||
1. 用户能拿到自己的专属邀请码或邀请链接
|
||||
2. 能方便地复制或分享
|
||||
3. 邀请成功后双方奖励可核算
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前现状与问题
|
||||
|
||||
当前“邀请好友”仅是快捷入口按钮,没有:
|
||||
|
||||
1. 专属邀请码
|
||||
2. 分享载体
|
||||
3. 邀请关系记录
|
||||
4. 奖励发放规则
|
||||
|
||||
因此无法真正产生拉新闭环。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本期范围
|
||||
|
||||
## 2.1 本期要做
|
||||
|
||||
1. 邀请好友弹窗
|
||||
2. 专属邀请码与邀请链接
|
||||
3. 复制、系统分享、二维码三种分享方式
|
||||
4. 邀请进度与奖励状态
|
||||
|
||||
## 2.2 本期不做
|
||||
|
||||
1. 多级分销
|
||||
2. 战队拉新活动
|
||||
3. 社交平台深度回流分析
|
||||
|
||||
---
|
||||
|
||||
## 3. 业务规则
|
||||
|
||||
## 3.1 邀请主体
|
||||
|
||||
只有正式激活账号可以邀请好友。
|
||||
|
||||
待绑定手机号账号不可邀请。
|
||||
|
||||
## 3.2 邀请标识
|
||||
|
||||
每个账号拥有:
|
||||
|
||||
1. 一个固定邀请码
|
||||
2. 一个可分享邀请链接
|
||||
|
||||
邀请码与账号一一绑定,不允许频繁重置。
|
||||
|
||||
## 3.3 邀请成功判定
|
||||
|
||||
被邀请用户满足以下条件才算成功:
|
||||
|
||||
1. 首次注册或首次完成正式激活
|
||||
2. 首次绑定邀请码成功
|
||||
3. 完成至少一次有效进入游戏或创建世界动作
|
||||
|
||||
这样可以过滤纯注册刷量。
|
||||
|
||||
## 3.4 奖励
|
||||
|
||||
首期奖励建议采用可控方案:
|
||||
|
||||
1. 邀请人获得叙世币
|
||||
2. 被邀请人获得新手奖励
|
||||
|
||||
所有奖励必须走台账,不允许前端本地加值。
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
## 4.1 页面内容
|
||||
|
||||
邀请弹窗展示:
|
||||
|
||||
1. 我的邀请码
|
||||
2. 复制按钮
|
||||
3. 系统分享按钮
|
||||
4. 二维码展示
|
||||
5. 已邀请人数
|
||||
6. 待达成奖励数量
|
||||
7. 已到账奖励
|
||||
|
||||
## 4.2 交互
|
||||
|
||||
1. 点击复制
|
||||
- 复制邀请码和邀请链接
|
||||
2. 点击分享
|
||||
- 触发浏览器分享或复制兜底
|
||||
3. 点击二维码
|
||||
- 放大查看
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端设计
|
||||
|
||||
## 5.1 数据模型
|
||||
|
||||
建议新增:
|
||||
|
||||
### `user_invite_codes`
|
||||
|
||||
- `user_id`
|
||||
- `invite_code`
|
||||
- `status`
|
||||
- `created_at`
|
||||
|
||||
### `user_referral_relations`
|
||||
|
||||
- `inviter_user_id`
|
||||
- `invitee_user_id`
|
||||
- `invite_code`
|
||||
- `bound_at`
|
||||
- `activated_at`
|
||||
- `reward_status`
|
||||
|
||||
### `user_reward_ledger`
|
||||
|
||||
- `user_id`
|
||||
- `reward_type`
|
||||
- `amount`
|
||||
- `source_type`
|
||||
- `source_id`
|
||||
- `created_at`
|
||||
|
||||
## 5.2 接口
|
||||
|
||||
### `GET /api/referrals/invite-center`
|
||||
|
||||
返回:
|
||||
|
||||
- 邀请码
|
||||
- 邀请链接
|
||||
- 分享二维码地址
|
||||
- 邀请统计
|
||||
- 奖励统计
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现要求
|
||||
|
||||
1. 邀请入口采用轻量弹窗,不跳新系统页
|
||||
2. 邀请码展示必须可直接复制
|
||||
3. 二维码图片由后端或统一服务生成
|
||||
4. 所有奖励数字以服务端返回为准
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 用户能看到自己的邀请码与邀请链接
|
||||
2. 可以一键复制或分享
|
||||
3. 邀请成功后能看到正确统计
|
||||
4. 奖励到账后叙世币余额同步变化
|
||||
209
docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# “我的”Tab 会员中心与充值 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把顶部“会员充值”按钮落成正式可运营的会员中心最小闭环,首期只解决三件事:
|
||||
|
||||
1. 看清当前会员状态
|
||||
2. 购买或续费会员
|
||||
3. 理解会员能得到的实际权益
|
||||
|
||||
会员中心不做复杂商城,不做满屏促销文案,保持轻量、清爽、可直接支付。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前现状与问题
|
||||
|
||||
当前页面已有“会员充值”按钮,但本质上还是视觉占位,缺少:
|
||||
|
||||
1. 会员等级定义
|
||||
2. 权益结构
|
||||
3. 订单与支付状态
|
||||
4. 到期时间与续费逻辑
|
||||
|
||||
这样会导致按钮可见但不可运营。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本期范围
|
||||
|
||||
## 2.1 本期要做
|
||||
|
||||
1. 会员中心弹窗/抽屉
|
||||
2. 当前会员状态展示
|
||||
3. 可购买套餐展示
|
||||
4. 下单与支付状态查询
|
||||
5. 充值成功后的权益生效
|
||||
|
||||
## 2.2 本期不做
|
||||
|
||||
1. 积分商城
|
||||
2. 限时活动页
|
||||
3. 礼包组合购
|
||||
4. 多级会员体系
|
||||
|
||||
---
|
||||
|
||||
## 3. 会员定义
|
||||
|
||||
首期只保留两种状态:
|
||||
|
||||
1. `普通用户`
|
||||
2. `叙世会员`
|
||||
|
||||
会员权益首期建议控制在直接可编码的范围:
|
||||
|
||||
1. 每日额外叙世币领取额度
|
||||
2. 高级世界模板或创作槽位
|
||||
3. 更高的云存档上限
|
||||
4. 会员专属标识
|
||||
|
||||
权益必须都是能被后端明确判定和下发的,不允许先写模糊营销描述。
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
## 4.1 入口与打开方式
|
||||
|
||||
点击“会员充值”按钮后:
|
||||
|
||||
1. 打开会员中心抽屉
|
||||
2. 顶部显示当前会员状态
|
||||
3. 中部显示权益卡片
|
||||
4. 底部显示套餐与购买按钮
|
||||
|
||||
## 4.2 页面内容
|
||||
|
||||
页面展示模块:
|
||||
|
||||
1. 当前状态
|
||||
2. 到期时间
|
||||
3. 可用权益
|
||||
4. 套餐列表
|
||||
5. 最近订单状态
|
||||
|
||||
不展示:
|
||||
|
||||
- 大段充值说明
|
||||
- 复杂规则 FAQ
|
||||
- 无法立即购买的灰色功能墙
|
||||
|
||||
## 4.3 套餐结构
|
||||
|
||||
首期套餐建议:
|
||||
|
||||
1. `月卡`
|
||||
2. `季卡`
|
||||
3. `年卡`
|
||||
|
||||
每个套餐展示:
|
||||
|
||||
- 套餐名
|
||||
- 价格
|
||||
- 到账权益
|
||||
- 生效周期
|
||||
|
||||
## 4.4 支付状态
|
||||
|
||||
订单状态至少支持:
|
||||
|
||||
- `pending`
|
||||
- `paid`
|
||||
- `failed`
|
||||
- `closed`
|
||||
- `refunded`
|
||||
|
||||
支付成功后:
|
||||
|
||||
1. 刷新会员状态
|
||||
2. 刷新叙世币余额
|
||||
3. 刷新权益标签
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端设计
|
||||
|
||||
## 5.1 数据模型
|
||||
|
||||
建议新增:
|
||||
|
||||
### `membership_products`
|
||||
|
||||
- `product_id`
|
||||
- `product_name`
|
||||
- `duration_days`
|
||||
- `price_cents`
|
||||
- `status`
|
||||
- `benefit_json`
|
||||
|
||||
### `user_memberships`
|
||||
|
||||
- `user_id`
|
||||
- `membership_type`
|
||||
- `started_at`
|
||||
- `expires_at`
|
||||
- `status`
|
||||
|
||||
### `membership_orders`
|
||||
|
||||
- `order_id`
|
||||
- `user_id`
|
||||
- `product_id`
|
||||
- `amount_cents`
|
||||
- `order_status`
|
||||
- `payment_channel`
|
||||
- `paid_at`
|
||||
- `created_at`
|
||||
|
||||
## 5.2 接口
|
||||
|
||||
### `GET /api/membership/center`
|
||||
|
||||
返回:
|
||||
|
||||
- 当前会员状态
|
||||
- 到期时间
|
||||
- 套餐列表
|
||||
- 权益列表
|
||||
- 最近订单摘要
|
||||
|
||||
### `POST /api/membership/orders`
|
||||
|
||||
入参:
|
||||
|
||||
- `productId`
|
||||
- `paymentChannel`
|
||||
|
||||
出参:
|
||||
|
||||
- `orderId`
|
||||
- `payParams`
|
||||
- `orderStatus`
|
||||
|
||||
### `GET /api/membership/orders/:orderId`
|
||||
|
||||
用途:
|
||||
|
||||
- 查询支付结果
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现要求
|
||||
|
||||
1. 会员中心复用现有模态层,不新增独立系统页
|
||||
2. 移动端默认单列套餐卡片
|
||||
3. 支付中状态不能重复点单
|
||||
4. 支付成功后从后端重新拉取状态,不靠前端假更新
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 普通用户能看到可买套餐
|
||||
2. 已开通会员能看到当前到期时间
|
||||
3. 支付成功后权益立即生效
|
||||
4. 续费不会覆盖错误时间
|
||||
5. 没有空按钮和假入口
|
||||
159
docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# “我的”Tab 玩家社区 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把“玩家社区”做成轻量社区入口,但不额外新造一个庞杂社交系统。首期目标是复用当前平台内容能力,提供:
|
||||
|
||||
1. 官方动态
|
||||
2. 玩家讨论入口
|
||||
3. 热门作品讨论聚合
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
这个社区功能必须遵守两个前提:
|
||||
|
||||
1. 优先复用已有平台作品与账号体系
|
||||
2. 不在首期直接做完整好友社交网
|
||||
|
||||
所以首期社区不是“朋友圈”,而是“内容讨论与官方动态聚合层”。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本期范围
|
||||
|
||||
## 2.1 本期要做
|
||||
|
||||
1. 社区入口页
|
||||
2. 官方公告流
|
||||
3. 玩家讨论话题流
|
||||
4. 作品详情页下的讨论聚合跳转
|
||||
|
||||
## 2.2 本期不做
|
||||
|
||||
1. 好友私聊
|
||||
2. 社区发帖富文本编辑器
|
||||
3. 点赞排行榜
|
||||
4. 群组系统
|
||||
|
||||
---
|
||||
|
||||
## 3. 信息架构
|
||||
|
||||
首期社区入口页建议拆成三个轻量分区:
|
||||
|
||||
1. 官方
|
||||
2. 热门讨论
|
||||
3. 最近作品讨论
|
||||
|
||||
点击“玩家社区”后,不跳全新站外页面,优先打开站内社区抽屉或二级视图。
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
## 4.1 官方区
|
||||
|
||||
展示:
|
||||
|
||||
- 官方公告
|
||||
- 版本更新摘要
|
||||
- 活动预告
|
||||
|
||||
每条内容只显示:
|
||||
|
||||
- 标题
|
||||
- 摘要
|
||||
- 时间
|
||||
|
||||
## 4.2 热门讨论区
|
||||
|
||||
展示:
|
||||
|
||||
- 讨论标题
|
||||
- 关联作品
|
||||
- 回复数
|
||||
- 最近活跃时间
|
||||
|
||||
## 4.3 作品讨论区
|
||||
|
||||
当前平台已有作品广场与作品详情,因此社区首期优先绑定作品:
|
||||
|
||||
1. 每个公开作品可有讨论串
|
||||
2. 社区页聚合热门作品讨论
|
||||
3. 作品详情页可跳到该讨论串
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端设计
|
||||
|
||||
## 5.1 数据模型
|
||||
|
||||
建议新增:
|
||||
|
||||
### `community_posts`
|
||||
|
||||
- `id`
|
||||
- `author_user_id`
|
||||
- `category`
|
||||
- `related_profile_id`
|
||||
- `title`
|
||||
- `content_text`
|
||||
- `status`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
### `community_replies`
|
||||
|
||||
- `id`
|
||||
- `post_id`
|
||||
- `author_user_id`
|
||||
- `content_text`
|
||||
- `status`
|
||||
- `created_at`
|
||||
|
||||
### `community_announcements`
|
||||
|
||||
- `id`
|
||||
- `title`
|
||||
- `summary`
|
||||
- `content_text`
|
||||
- `published_at`
|
||||
- `status`
|
||||
|
||||
## 5.2 接口
|
||||
|
||||
### `GET /api/community/home`
|
||||
|
||||
返回:
|
||||
|
||||
- 官方动态列表
|
||||
- 热门讨论列表
|
||||
- 最近作品讨论列表
|
||||
|
||||
### `GET /api/community/posts/:postId`
|
||||
|
||||
用途:
|
||||
|
||||
- 读取帖子与回复
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现要求
|
||||
|
||||
1. 社区页保持内容导向,不做复杂社交关系页
|
||||
2. 移动端优先采用流式卡片
|
||||
3. 非登录用户只可浏览,发言必须登录
|
||||
4. 敏感内容审核状态全部由后端控制
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 用户可以从“我的”页进入社区入口
|
||||
2. 可以看到官方动态和热门讨论
|
||||
3. 作品讨论与作品详情存在双向跳转
|
||||
4. 不需要新增独立社交系统就能跑通首期体验
|
||||
212
docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# “我的”Tab 账号资料与身份卡 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把“我的”页顶部资料卡从一个静态展示块升级成正式的用户身份入口,承载:
|
||||
|
||||
1. 头像编辑
|
||||
2. 昵称编辑
|
||||
3. 叙世号展示与复制
|
||||
4. 登录方式与绑定状态展示
|
||||
5. 进入资料编辑抽屉
|
||||
|
||||
这个模块的作用不是做安全设置总入口,而是把“我是谁”展示清楚,并提供最轻量的资料编辑。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前现状与问题
|
||||
|
||||
当前页面已经展示:
|
||||
|
||||
- 头像占位
|
||||
- 昵称
|
||||
- 叙世号
|
||||
- 登录方式
|
||||
- 绑定状态
|
||||
|
||||
但存在几个问题:
|
||||
|
||||
1. 头像按钮和昵称编辑按钮都直接打开账号弹窗,信息架构混在一起
|
||||
2. 头像当前只是视觉壳,没有真正的上传与裁剪能力
|
||||
3. 昵称缺少明确的编辑规则与唯一性策略
|
||||
4. 叙世号只是前端拼接值,不适合长期作为正式公开识别码
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品范围
|
||||
|
||||
## 2.1 本期要做
|
||||
|
||||
1. 用户身份卡展示
|
||||
2. 资料编辑抽屉
|
||||
3. 头像上传、裁切、保存
|
||||
4. 昵称编辑、校验、保存
|
||||
5. 叙世号固定生成与复制
|
||||
6. 登录方式与账号状态标签展示
|
||||
|
||||
## 2.2 本期不做
|
||||
|
||||
1. 个性签名
|
||||
2. 主页装扮
|
||||
3. 自定义头像框
|
||||
4. 社交主页公开页
|
||||
|
||||
---
|
||||
|
||||
## 3. 信息架构
|
||||
|
||||
## 3.1 首屏卡片内容
|
||||
|
||||
资料卡固定展示:
|
||||
|
||||
- 用户头像
|
||||
- 用户昵称
|
||||
- `叙世号`
|
||||
- 登录方式标签
|
||||
- 账号状态标签
|
||||
- 资料编辑入口
|
||||
|
||||
资料卡不展示:
|
||||
|
||||
- 大段规则说明
|
||||
- 安全告警明细
|
||||
- 设备管理
|
||||
- 审计日志
|
||||
|
||||
这些内容统一放到“设置与账号安全”。
|
||||
|
||||
## 3.2 交互结构
|
||||
|
||||
点击区域行为如下:
|
||||
|
||||
1. 点击头像
|
||||
- 打开“编辑资料”抽屉,并默认聚焦头像编辑区域
|
||||
2. 点击昵称右侧编辑按钮
|
||||
- 打开“编辑资料”抽屉,并默认聚焦昵称输入框
|
||||
3. 点击叙世号复制按钮
|
||||
- 直接复制,并给出轻提示
|
||||
4. 点击登录方式/状态标签
|
||||
- 不跳页,不弹复杂说明
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
## 4.1 头像
|
||||
|
||||
头像规则:
|
||||
|
||||
1. 默认使用系统首字头像
|
||||
2. 用户上传后替换为正式头像
|
||||
3. 上传后进入正方形裁切
|
||||
4. 服务端生成 `256x256` 主图和 `96x96` 缩略图
|
||||
5. 超过大小或格式限制时直接拦截
|
||||
|
||||
支持格式:
|
||||
|
||||
- `jpg`
|
||||
- `png`
|
||||
- `webp`
|
||||
|
||||
限制:
|
||||
|
||||
- 单文件最大 `5MB`
|
||||
- 裁切结果统一为正方形
|
||||
|
||||
## 4.2 昵称
|
||||
|
||||
昵称规则:
|
||||
|
||||
1. 长度 `2-20` 个字符
|
||||
2. 允许中文、英文、数字、下划线
|
||||
3. 不允许空白昵称
|
||||
4. 不要求全站唯一,但要允许后端做敏感词审核
|
||||
5. 审核失败时返回明确错误
|
||||
|
||||
## 4.3 叙世号
|
||||
|
||||
叙世号规则:
|
||||
|
||||
1. 作为公开可复制识别码
|
||||
2. 用户创建后固定生成,不允许用户修改
|
||||
3. 格式统一,例如:`SY-8F29A13C`
|
||||
4. 后端生成并返回,不再由前端临时拼接
|
||||
|
||||
## 4.4 账号状态标签
|
||||
|
||||
状态只显示短标签:
|
||||
|
||||
- `手机号登录`
|
||||
- `微信登录`
|
||||
- `待绑定手机号`
|
||||
- `已激活`
|
||||
|
||||
不在资料卡里展开规则说明。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端设计
|
||||
|
||||
## 5.1 数据模型
|
||||
|
||||
建议扩展 `users` 或新增 `user_profiles`:
|
||||
|
||||
- `user_id`
|
||||
- `public_user_code`
|
||||
- `display_name`
|
||||
- `avatar_asset_id`
|
||||
- `avatar_url`
|
||||
- `avatar_thumb_url`
|
||||
- `updated_at`
|
||||
|
||||
## 5.2 接口
|
||||
|
||||
### `GET /api/profile/me`
|
||||
|
||||
返回:
|
||||
|
||||
- `displayName`
|
||||
- `avatarUrl`
|
||||
- `avatarThumbUrl`
|
||||
- `publicUserCode`
|
||||
- `loginMethod`
|
||||
- `bindingStatus`
|
||||
|
||||
### `PATCH /api/profile/me`
|
||||
|
||||
入参:
|
||||
|
||||
- `displayName`
|
||||
- `avatarAssetId`
|
||||
|
||||
出参:
|
||||
|
||||
- 更新后的资料对象
|
||||
|
||||
### `POST /api/profile/avatar/upload-token`
|
||||
|
||||
用途:
|
||||
|
||||
- 获取头像上传凭证或上传地址
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现要求
|
||||
|
||||
1. 资料编辑优先做成底部抽屉或轻量弹窗,不新开独立页面
|
||||
2. 移动端头像、昵称、复制按钮必须单手可操作
|
||||
3. 保存按钮固定在抽屉底部
|
||||
4. 保存中展示明确 loading 态
|
||||
5. 成功后即时回写顶部资料卡
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 用户可以上传并保存头像
|
||||
2. 用户可以修改昵称并实时看到更新
|
||||
3. 叙世号由后端返回,复制后可正常使用
|
||||
4. 未登录或待绑定状态下,不出现无效编辑入口
|
||||
5. 页面不出现冗长规则说明文案
|
||||
126
docs/prd/MY_TAB_RECENT_PLAY_PRD_2026-04-16.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# “我的”Tab 最近游玩 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把“最近游玩”从单一继续游戏卡片扩成账号级最近游玩模块,让玩家可以快速回到最近推进过的作品和存档节点。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前现状与问题
|
||||
|
||||
当前“最近游玩”仅基于一个本地快照推导:
|
||||
|
||||
1. 只支持一个最近记录
|
||||
2. 只要本地没有快照就没有内容
|
||||
3. 无法跨设备同步
|
||||
4. 无法区分多个世界和多个角色
|
||||
|
||||
这不符合平台化后的用户预期。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本期范围
|
||||
|
||||
## 2.1 本期要做
|
||||
|
||||
1. 最近游玩列表
|
||||
2. 继续游玩主动作
|
||||
3. 进入作品详情或继续冒险
|
||||
4. 跨设备同步最近记录
|
||||
|
||||
## 2.2 本期不做
|
||||
|
||||
1. 多存档槽管理全量页面
|
||||
2. 手动置顶
|
||||
3. 存档备注与重命名
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
## 3.1 展示结构
|
||||
|
||||
首屏展示 `1-5` 条最近游玩卡片。
|
||||
|
||||
每张卡片展示:
|
||||
|
||||
- 世界名
|
||||
- 当前角色名
|
||||
- 最近摘要
|
||||
- 最近游玩时间
|
||||
- 继续按钮
|
||||
|
||||
移动端优先横向滑动卡片,桌面端可显示为横向列表或双列卡片。
|
||||
|
||||
## 3.2 点击行为
|
||||
|
||||
1. 点击卡片主体
|
||||
- 进入作品详情页,展示继续入口和存档摘要
|
||||
2. 点击继续按钮
|
||||
- 直接恢复最近游玩存档
|
||||
|
||||
如果该存档已失效:
|
||||
|
||||
- 给出“存档不可恢复”的明确提示
|
||||
- 引导回到作品详情或重新开始
|
||||
|
||||
## 3.3 排序规则
|
||||
|
||||
按 `lastPlayedAt` 倒序。
|
||||
|
||||
若同一作品下存在多个存档:
|
||||
|
||||
- 只展示最近一次有效记录
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端设计
|
||||
|
||||
## 4.1 数据模型
|
||||
|
||||
建议在存档或游玩记录层增加聚合:
|
||||
|
||||
- `user_id`
|
||||
- `world_owner_user_id`
|
||||
- `profile_id`
|
||||
- `save_id`
|
||||
- `world_name`
|
||||
- `character_name`
|
||||
- `continue_digest`
|
||||
- `cover_image_src`
|
||||
- `last_played_at`
|
||||
- `is_resume_available`
|
||||
|
||||
## 4.2 接口
|
||||
|
||||
### `GET /api/profile/recent-plays`
|
||||
|
||||
返回:
|
||||
|
||||
- 最近游玩列表
|
||||
|
||||
### `POST /api/runtime/saves/:saveId/resume`
|
||||
|
||||
用途:
|
||||
|
||||
- 校验并恢复指定存档
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端实现要求
|
||||
|
||||
1. 继续游玩动作必须走后端校验
|
||||
2. 不允许前端自行拼装恢复上下文
|
||||
3. 列表为空时展示轻量空态
|
||||
4. 卡片摘要最多显示三行,保持“我的”页清爽
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. 最近游玩可以展示多个最近记录
|
||||
2. 不同设备登录同一账号时列表一致
|
||||
3. 点击继续后能恢复到正确存档
|
||||
4. 无效存档不会让前端直接报错白屏
|
||||
202
docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# “我的”Tab 设置与账号安全 PRD
|
||||
|
||||
更新时间:`2026-04-16`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
把“设置”入口正式定义为账号安全中心,统一承载:
|
||||
|
||||
1. 账号基础信息查看
|
||||
2. 当前安全状态
|
||||
3. 登录设备管理
|
||||
4. 更换手机号
|
||||
5. 最近账号操作记录
|
||||
6. 退出登录与退出全部设备
|
||||
|
||||
这个模块直接建立在当前仓库已有的账号接口之上,是“我的”页最优先落地的正式功能。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前现状
|
||||
|
||||
当前仓库已具备以下基础能力:
|
||||
|
||||
1. `GET /api/auth/risk-blocks`
|
||||
2. `GET /api/auth/sessions`
|
||||
3. `GET /api/auth/audit-logs`
|
||||
4. `POST /api/auth/phone/change`
|
||||
5. `POST /api/auth/logout`
|
||||
6. `POST /api/auth/logout-all`
|
||||
7. `POST /api/auth/sessions/:sessionId/revoke`
|
||||
|
||||
说明当前账号安全不是从零开始,而是缺少稳定的信息架构和最终产品定义。
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品范围
|
||||
|
||||
## 2.1 本期要做
|
||||
|
||||
1. 设置与账号安全弹窗
|
||||
2. 安全状态展示
|
||||
3. 登录设备管理
|
||||
4. 更换手机号
|
||||
5. 操作记录查看
|
||||
6. 退出当前设备
|
||||
7. 退出全部设备
|
||||
|
||||
## 2.2 本期不做
|
||||
|
||||
1. 修改密码正式入口
|
||||
2. 实名认证
|
||||
3. 邮箱绑定
|
||||
4. 多因子认证
|
||||
|
||||
---
|
||||
|
||||
## 3. 信息架构
|
||||
|
||||
设置中心建议固定为五段:
|
||||
|
||||
1. 账号概况
|
||||
2. 当前安全状态
|
||||
3. 登录设备
|
||||
4. 更换手机号
|
||||
5. 账号操作记录
|
||||
|
||||
底部保留两个危险操作按钮:
|
||||
|
||||
1. 退出登录
|
||||
2. 退出全部设备
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
## 4.1 账号概况
|
||||
|
||||
展示:
|
||||
|
||||
- 登录方式
|
||||
- 手机号脱敏值
|
||||
- 微信绑定状态
|
||||
- 账号状态
|
||||
|
||||
这里只看信息,不做大编辑动作。
|
||||
|
||||
## 4.2 当前安全状态
|
||||
|
||||
展示当前账号命中的风控保护:
|
||||
|
||||
- 手机号保护
|
||||
- 当前网络保护
|
||||
|
||||
每条状态可执行:
|
||||
|
||||
- 解除保护
|
||||
|
||||
解除动作必须经过后端校验。
|
||||
|
||||
## 4.3 登录设备
|
||||
|
||||
每条设备展示:
|
||||
|
||||
- 设备类型
|
||||
- 最近活跃时间
|
||||
- 到期时间
|
||||
- IP 脱敏信息
|
||||
- 是否当前设备
|
||||
|
||||
非当前设备支持:
|
||||
|
||||
- 踢下线
|
||||
|
||||
## 4.4 更换手机号
|
||||
|
||||
流程:
|
||||
|
||||
1. 输入新手机号
|
||||
2. 获取验证码
|
||||
3. 如有需要,完成人机校验
|
||||
4. 输入验证码
|
||||
5. 提交修改
|
||||
|
||||
规则:
|
||||
|
||||
1. 新号码不能等于当前号码
|
||||
2. 已被其他账号绑定的号码不可用
|
||||
3. 风控命中时必须遵循现有保护逻辑
|
||||
|
||||
## 4.5 操作记录
|
||||
|
||||
展示最近账号行为:
|
||||
|
||||
- 登录
|
||||
- 绑定手机号
|
||||
- 更换手机号
|
||||
- 退出登录
|
||||
- 踢设备
|
||||
- 触发图形验证码
|
||||
- 风险保护
|
||||
|
||||
---
|
||||
|
||||
## 5. 前后端职责边界
|
||||
|
||||
## 5.1 前端职责
|
||||
|
||||
1. 展示状态
|
||||
2. 收集输入
|
||||
3. 呈现 loading / success / error
|
||||
|
||||
## 5.2 后端职责
|
||||
|
||||
1. 风控判断
|
||||
2. 图形验证码触发
|
||||
3. 会话列表返回
|
||||
4. 设备撤销
|
||||
5. 日志审计
|
||||
6. 手机号换绑
|
||||
|
||||
前端不允许自己决定:
|
||||
|
||||
- 是否需要验证码
|
||||
- 是否允许解除风控
|
||||
- 是否允许换绑
|
||||
|
||||
---
|
||||
|
||||
## 6. 接口对齐
|
||||
|
||||
首期优先复用现有接口:
|
||||
|
||||
1. `GET /api/auth/me`
|
||||
2. `GET /api/auth/risk-blocks`
|
||||
3. `POST /api/auth/risk-blocks/:scopeType/lift`
|
||||
4. `GET /api/auth/sessions`
|
||||
5. `POST /api/auth/sessions/:sessionId/revoke`
|
||||
6. `GET /api/auth/audit-logs`
|
||||
7. `POST /api/auth/phone/change`
|
||||
8. `POST /api/auth/logout`
|
||||
9. `POST /api/auth/logout-all`
|
||||
|
||||
如需补充,只增加展示层所缺的聚合字段,不重建接口体系。
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端实现要求
|
||||
|
||||
1. 设置继续采用当前账号弹窗基础形态即可
|
||||
2. 移动端优先底部弹层,桌面端可居中弹窗
|
||||
3. 更换手机号区域默认折叠
|
||||
4. 危险操作按钮与普通按钮必须明显区分
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
1. 用户能看到当前账号安全信息
|
||||
2. 能查看并管理登录设备
|
||||
3. 能按规则更换手机号
|
||||
4. 能查看最近账号操作记录
|
||||
5. 退出登录和退出全部设备都能稳定生效
|
||||
@@ -95,19 +95,25 @@ export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
|
||||
];
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
const SIDE_FACING_RIGHT_TEXT =
|
||||
'角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。';
|
||||
const SUBJECT_ONLY_TEXT =
|
||||
'画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。';
|
||||
const CLEAN_BACKGROUND_TEXT =
|
||||
'背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。';
|
||||
const STYLE_REFERENCE_SCOPE_TEXT =
|
||||
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
|
||||
const CONCEPT_INTERPRETATION_TEXT =
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
|
||||
const HUMANLIKE_PRIORITY_TEXT =
|
||||
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。';
|
||||
const JELLYFISH_THEME_EXAMPLE_TEXT =
|
||||
'示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。';
|
||||
const CONCEPT_HIERARCHY_TEXT =
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
|
||||
const THEME_APPLICATION_BOUNDARY_TEXT =
|
||||
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。';
|
||||
const CHIBI_CHARACTER_TEXT =
|
||||
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。';
|
||||
|
||||
@@ -120,13 +126,15 @@ export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'单人,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
'画面要求:1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
|
||||
'单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
`视角要求:${SIDE_FACING_RIGHT_TEXT}`,
|
||||
`主体要求:${SUBJECT_ONLY_TEXT}`,
|
||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。${CLEAN_BACKGROUND_TEXT}`,
|
||||
`风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
JELLYFISH_THEME_EXAMPLE_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -141,11 +149,11 @@ export function buildVideoActionPrompt(options: {
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`,
|
||||
`角色固定为图1同一角色,始终侧身朝右,镜头稳定,轮廓清晰。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
`角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
JELLYFISH_THEME_EXAMPLE_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`,
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
||||
|
||||
@@ -34,6 +34,45 @@ export type BasicOkResult = {
|
||||
ok: true;
|
||||
};
|
||||
|
||||
export type ProfileDashboardCardKey = 'wallet' | 'playTime' | 'playedWorks';
|
||||
|
||||
export type ProfileDashboardSummary = {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number;
|
||||
playedWorldCount: number;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type ProfileWalletLedgerEntry = {
|
||||
id: string;
|
||||
amountDelta: number;
|
||||
balanceAfter: number;
|
||||
sourceType: 'snapshot_sync';
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ProfileWalletLedgerResponse = {
|
||||
entries: ProfileWalletLedgerEntry[];
|
||||
};
|
||||
|
||||
export type ProfilePlayedWorkSummary = {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldTitle: string;
|
||||
worldSubtitle: string;
|
||||
firstPlayedAt: string;
|
||||
lastPlayedAt: string;
|
||||
lastObservedPlayTimeMs: number;
|
||||
};
|
||||
|
||||
export type ProfilePlayStatsResponse = {
|
||||
totalPlayTimeMs: number;
|
||||
playedWorks: ProfilePlayedWorkSummary[];
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type CustomWorldPublicationStatus = 'draft' | 'published';
|
||||
export type CustomWorldThemeMode =
|
||||
| 'martial'
|
||||
@@ -47,9 +86,7 @@ export type CustomWorldProfileRecord = JsonObject & {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type CustomWorldLibraryEntry<
|
||||
TProfile = CustomWorldProfileRecord,
|
||||
> = {
|
||||
export type CustomWorldLibraryEntry<TProfile = CustomWorldProfileRecord> = {
|
||||
ownerUserId: string;
|
||||
profileId: string;
|
||||
profile: TProfile;
|
||||
@@ -71,9 +108,7 @@ export type CustomWorldGalleryCard = Omit<
|
||||
'profile'
|
||||
>;
|
||||
|
||||
export type CustomWorldLibraryResponse<
|
||||
TProfile = CustomWorldProfileRecord,
|
||||
> = {
|
||||
export type CustomWorldLibraryResponse<TProfile = CustomWorldProfileRecord> = {
|
||||
entries: CustomWorldLibraryEntry<TProfile>[];
|
||||
};
|
||||
|
||||
@@ -94,6 +129,33 @@ export type CustomWorldGalleryDetailResponse<
|
||||
entry: CustomWorldLibraryEntry<TProfile>;
|
||||
};
|
||||
|
||||
export type PlatformBrowseHistoryEntry = {
|
||||
ownerUserId: string;
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeMode: CustomWorldThemeMode;
|
||||
authorDisplayName: string;
|
||||
visitedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformBrowseHistoryWriteEntry = Omit<
|
||||
PlatformBrowseHistoryEntry,
|
||||
'visitedAt'
|
||||
> & {
|
||||
visitedAt?: string;
|
||||
};
|
||||
|
||||
export type PlatformBrowseHistoryResponse = {
|
||||
entries: PlatformBrowseHistoryEntry[];
|
||||
};
|
||||
|
||||
export type PlatformBrowseHistoryBatchSyncRequest = {
|
||||
entries: PlatformBrowseHistoryWriteEntry[];
|
||||
};
|
||||
|
||||
export const CUSTOM_WORLD_GENERATION_MODES = ['fast', 'full'] as const;
|
||||
export type CustomWorldGenerationMode =
|
||||
(typeof CUSTOM_WORLD_GENERATION_MODES)[number];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"taskId": "e200f8f4-8573-41c5-a408-074b79aa00ea",
|
||||
"kind": "animation",
|
||||
"status": "completed",
|
||||
"characterId": "story-npc-艾莉丝-1",
|
||||
"animation": "idle",
|
||||
"strategy": "image-to-video",
|
||||
"model": "wan2.7-i2v",
|
||||
"prompt": "单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一角色,始终侧身朝右,镜头稳定,轮廓清晰。Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构:1-4 帧:稳定站姿,轻微呼吸起伏;5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化;9-12 帧:呼气回落,重心恢复;13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:艾莉丝核心动作试片。 保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯自然。 动作气质参考:擅长利用机械装置和魔法,以远程攻击和控制为主。 角色状态补充:冷静严谨,处事果断,面对危机时能迅速分析局势。 起手清楚,发力明确,收招干净,避免漂移、乱摆和形体变形。 角色设定:角色名称:艾莉丝\n角色头衔:旧秩序守护者\n世界身份:机械师\n角色描述:掌握关键技术的返乡者\n角色背景:旧秩序守护者,返乡后掌握关键技术,栖身于蒸汽灯塔和工厂\n角色性格:冷静严谨,处事果断,面对危机时能迅速分析局势\n角色动机:保护世界,阻止新威胁,探索失落信标真相\n战斗风格:擅长利用机械装置和魔法,以远程攻击和控制为主\n角色标签:旧秩序、机械 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
|
||||
"createdAt": "2026-04-16T08:43:14.143Z",
|
||||
"updatedAt": "2026-04-16T08:44:02.020Z",
|
||||
"result": {
|
||||
"previewVideoPath": "/generated-character-drafts/story-npc-1/animation/idle/animation-video-1776329042002/preview.mp4",
|
||||
"draftRelativeDir": "generated-character-drafts/story-npc-1/animation/idle/animation-video-1776329042002"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"taskId": "07f73062-8706-4e1b-9100-60e941e14a8a",
|
||||
"kind": "visual",
|
||||
"status": "completed",
|
||||
"characterId": "story-npc-凯瑟琳-mo1ek7si",
|
||||
"model": "wan2.7-image-pro",
|
||||
"prompt": "单人,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求:1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求:Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。\n视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。\n示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。\n角色名称:凯瑟琳\n角色头衔:蒸汽学者\n世界身份:研究员\n角色描述:痴迷于蒸汽能量研究的学者,在蒸汽朋克工厂进行秘密实验\n角色背景:凯瑟琳曾是一名普通学者,因对蒸汽能量的独特见解而被旧秩序招募,一直在蒸汽朋克工厂深入研究,试图解开蒸汽能量的更多秘密。\n角色性格:痴迷专注,对蒸汽能量的研究近乎疯狂,性格有些孤僻,不太善于与人交流,但在自己的领域内极具权威性。\n角色动机:渴望揭示蒸汽能量的真相,为世界带来新的变革,同时也希望通过研究找到化解核心危机的方法。\n战斗风格:擅长利用蒸汽能量制造各种陷阱和干扰效果,以保护自己和研究成果。\n角色标签:蒸汽、学者、研究、蒸汽悬谜大陆、神秘、紧张、充满未知、场景线\n蒸汽朋克风格,身穿复古蒸汽动力机械服装的女性学者凯瑟琳,侧身朝右,全身展示,脚踩在金属地板上,身后是复杂的蒸汽管道和机器,手中拿着一本记录着蒸汽研究数据的笔记本,眼神专注而痴迷。",
|
||||
"createdAt": "2026-04-16T11:35:42.385Z",
|
||||
"updatedAt": "2026-04-16T11:35:56.481Z",
|
||||
"result": {
|
||||
"drafts": [
|
||||
{
|
||||
"id": "candidate-1",
|
||||
"label": "候选 1",
|
||||
"imageSrc": "/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-01.png",
|
||||
"width": 1024,
|
||||
"height": 1536
|
||||
},
|
||||
{
|
||||
"id": "candidate-2",
|
||||
"label": "候选 2",
|
||||
"imageSrc": "/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-02.png",
|
||||
"width": 1024,
|
||||
"height": 1536
|
||||
}
|
||||
],
|
||||
"draftRelativeDir": "generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"taskId": "b00e1dc0-bfc0-4583-ac21-e99b157362f1",
|
||||
"kind": "visual",
|
||||
"status": "completed",
|
||||
"characterId": "story-npc-艾莉丝-1",
|
||||
"model": "wan2.7-image-pro",
|
||||
"prompt": "单人,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求:1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求:Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。\n视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。\n示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。\n角色名称:艾莉丝\n角色头衔:旧秩序守护者\n世界身份:机械师\n角色描述:掌握关键技术的返乡者\n角色背景:旧秩序守护者,返乡后掌握关键技术,栖身于蒸汽灯塔和工厂\n角色性格:冷静严谨,处事果断,面对危机时能迅速分析局势\n角色动机:保护世界,阻止新威胁,探索失落信标真相\n战斗风格:擅长利用机械装置和魔法,以远程攻击和控制为主\n角色标签:旧秩序、机械\n艾莉丝,旧秩序守护者 / 机械师。 单人全身,2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。 外观气质围绕:掌握关键技术的返乡者。 动作识别点参考:擅长利用机械装置和魔法,以远程攻击和控制为主。 保留 旧秩序、机械 的角色识别点。 构图干净,主体明确,不做正面立绘,不做夸张透视。",
|
||||
"createdAt": "2026-04-16T08:41:03.189Z",
|
||||
"updatedAt": "2026-04-16T08:41:17.095Z",
|
||||
"result": {
|
||||
"drafts": [
|
||||
{
|
||||
"id": "candidate-1",
|
||||
"label": "候选 1",
|
||||
"imageSrc": "/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/candidate-01.png",
|
||||
"width": 1024,
|
||||
"height": 1536
|
||||
},
|
||||
{
|
||||
"id": "candidate-2",
|
||||
"label": "候选 2",
|
||||
"imageSrc": "/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/candidate-02.png",
|
||||
"width": 1024,
|
||||
"height": 1536
|
||||
}
|
||||
],
|
||||
"draftRelativeDir": "generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"taskId": "e200f8f4-8573-41c5-a408-074b79aa00ea",
|
||||
"model": "wan2.7-i2v",
|
||||
"strategy": "image-to-video",
|
||||
"animation": "idle",
|
||||
"prompt": "单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一角色,始终侧身朝右,镜头稳定,轮廓清晰。Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构:1-4 帧:稳定站姿,轻微呼吸起伏;5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化;9-12 帧:呼气回落,重心恢复;13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:艾莉丝核心动作试片。 保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯自然。 动作气质参考:擅长利用机械装置和魔法,以远程攻击和控制为主。 角色状态补充:冷静严谨,处事果断,面对危机时能迅速分析局势。 起手清楚,发力明确,收招干净,避免漂移、乱摆和形体变形。 角色设定:角色名称:艾莉丝\n角色头衔:旧秩序守护者\n世界身份:机械师\n角色描述:掌握关键技术的返乡者\n角色背景:旧秩序守护者,返乡后掌握关键技术,栖身于蒸汽灯塔和工厂\n角色性格:冷静严谨,处事果断,面对危机时能迅速分析局势\n角色动机:保护世界,阻止新威胁,探索失落信标真相\n战斗风格:擅长利用机械装置和魔法,以远程攻击和控制为主\n角色标签:旧秩序、机械 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
|
||||
"createdAt": "2026-04-16T08:44:02.018Z",
|
||||
"videoUrl": "https://dashscope-a717.oss-accelerate.aliyuncs.com/1d/90/20260416/2b319361/28506302-metadata_user_8d68a963122d6a1b.mp4?Expires=1776415425&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=1Cw8RU0xfskBzCYtFixbiF1dmcs%3D"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"taskId": "b00e1dc0-bfc0-4583-ac21-e99b157362f1",
|
||||
"model": "wan2.7-image-pro",
|
||||
"prompt": "单人,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求:1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求:Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。\n视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。\n示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。\n角色名称:艾莉丝\n角色头衔:旧秩序守护者\n世界身份:机械师\n角色描述:掌握关键技术的返乡者\n角色背景:旧秩序守护者,返乡后掌握关键技术,栖身于蒸汽灯塔和工厂\n角色性格:冷静严谨,处事果断,面对危机时能迅速分析局势\n角色动机:保护世界,阻止新威胁,探索失落信标真相\n战斗风格:擅长利用机械装置和魔法,以远程攻击和控制为主\n角色标签:旧秩序、机械\n艾莉丝,旧秩序守护者 / 机械师。 单人全身,2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。 外观气质围绕:掌握关键技术的返乡者。 动作识别点参考:擅长利用机械装置和魔法,以远程攻击和控制为主。 保留 旧秩序、机械 的角色识别点。 构图干净,主体明确,不做正面立绘,不做夸张透视。",
|
||||
"sourceMode": "text-to-image",
|
||||
"createdAt": "2026-04-16T08:41:17.094Z",
|
||||
"imageUrls": [
|
||||
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/11/20260416/218dd9c9/dd363a11-75b1-4357-9c63-f2f6e587854c_0.png?Expires=1776415273&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=dDbFHWKyo6HP6I5GPUXTl%2FZ2pMo%3D",
|
||||
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/23/20260416/70729cf4/209cff9e-03b2-4d76-a6be-d0869c8a9882_0.png?Expires=1776415273&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=77ewqpGXbjpePJgmdGxfpgBeP5Q%3D"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"characterId": "story-npc-艾莉丝-1",
|
||||
"visualPromptText": "一位身着机械风格服饰的女性,侧身朝右,立于蒸汽灯塔顶端,脚下是工厂的金属平台,她手持机械魔杖,背后的机械羽翼展开,眼神专注而冷静,散发着旧秩序守护者的威严。",
|
||||
"animationPromptText": "艾莉丝优雅地操控着机械装置和魔法,她的动作流畅而果断,时而挥动魔杖释放能量,时而借助机械羽翼在空中灵活移动,战斗中的她充满了冷静与自信,每一个动作都展现出她对局势的精准判断和对力量的掌控。",
|
||||
"visualDrafts": [],
|
||||
"selectedVisualDraftId": "",
|
||||
"selectedAnimation": "idle",
|
||||
"animationMap": null,
|
||||
"updatedAt": "2026-04-16T12:19:15.547Z"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"characterId": "story-npc-暗影行者-2",
|
||||
"visualPromptText": "冷酷无情的黑暗刺客暗影行者,身体整体朝右但保留少量正面信息,全身着黑色紧身衣,衣服上有银色暗纹,头戴黑色兜帽,遮住大半张脸,只露出冷酷的双眼,腰间挂着一排暗器,右手拿着一把短匕首,匕首上闪烁着诡异的绿光,站在纯绿色绿幕背景前,脚底完整可见,右向斜侧身站姿,1:1画幅",
|
||||
"animationPromptText": "暗影行者以极快的速度在黑暗中穿梭,身形鬼魅,动作流畅且果断,每一次出手都带着致命的一击,使用暗器和毒药时手法娴熟,攻击时身体发力迅猛,连贯性强,保持同一角色的冷酷无情气质",
|
||||
"visualDrafts": [],
|
||||
"selectedVisualDraftId": "",
|
||||
"selectedAnimation": "idle",
|
||||
"animationMap": null,
|
||||
"updatedAt": "2026-04-16T12:19:28.473Z"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"characterId": "story-npc-莉雅-3",
|
||||
"visualPromptText": "身穿粉色魔法学徒袍,手持魔法杖,绿色眼眸,棕色长发扎成马尾,身体整体朝右但保留少量正面信息,站在纯绿色绿幕背景前的少女",
|
||||
"animationPromptText": "莉雅挥舞魔法杖释放魔法攻击,魔法光芒闪烁,魔法护盾围绕身体,动作流畅自然,一气呵成",
|
||||
"visualDrafts": [],
|
||||
"selectedVisualDraftId": "",
|
||||
"selectedAnimation": "idle",
|
||||
"animationMap": null,
|
||||
"updatedAt": "2026-04-16T12:19:41.599Z"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"characterId": "story-npc-露娜-5",
|
||||
"visualPromptText": "露娜,魔法少女 / 旧秩序联盟成员。 单人全身,2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。 外观气质围绕:魔法与科技的调和者。 动作识别点参考:以魔法为基础,结合科技道具,战斗时优雅灵动,擅长远程攻击和辅助。。 保留 魔法、友善 的角色识别点。 构图干净,主体明确,不做正面立绘,不做夸张透视。",
|
||||
"animationPromptText": "露娜核心动作试片。 保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯自然。 动作气质参考:以魔法为基础,结合科技道具,战斗时优雅灵动,擅长远程攻击和辅助。。 角色状态补充:温柔善良,机智灵活,善于协调,面对冲突能冷静应对。。 起手清楚,发力明确,收招干净,避免漂移、乱摆和形体变形。",
|
||||
"visualDrafts": [],
|
||||
"selectedVisualDraftId": "",
|
||||
"selectedAnimation": "idle",
|
||||
"animationMap": null,
|
||||
"updatedAt": "2026-04-16T12:20:00.778Z"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"taskId": "07f73062-8706-4e1b-9100-60e941e14a8a",
|
||||
"model": "wan2.7-image-pro",
|
||||
"prompt": "单人,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求:1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求:Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。\n视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。\n示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。\n角色名称:凯瑟琳\n角色头衔:蒸汽学者\n世界身份:研究员\n角色描述:痴迷于蒸汽能量研究的学者,在蒸汽朋克工厂进行秘密实验\n角色背景:凯瑟琳曾是一名普通学者,因对蒸汽能量的独特见解而被旧秩序招募,一直在蒸汽朋克工厂深入研究,试图解开蒸汽能量的更多秘密。\n角色性格:痴迷专注,对蒸汽能量的研究近乎疯狂,性格有些孤僻,不太善于与人交流,但在自己的领域内极具权威性。\n角色动机:渴望揭示蒸汽能量的真相,为世界带来新的变革,同时也希望通过研究找到化解核心危机的方法。\n战斗风格:擅长利用蒸汽能量制造各种陷阱和干扰效果,以保护自己和研究成果。\n角色标签:蒸汽、学者、研究、蒸汽悬谜大陆、神秘、紧张、充满未知、场景线\n蒸汽朋克风格,身穿复古蒸汽动力机械服装的女性学者凯瑟琳,侧身朝右,全身展示,脚踩在金属地板上,身后是复杂的蒸汽管道和机器,手中拿着一本记录着蒸汽研究数据的笔记本,眼神专注而痴迷。",
|
||||
"sourceMode": "text-to-image",
|
||||
"createdAt": "2026-04-16T11:35:56.475Z",
|
||||
"imageUrls": [
|
||||
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/6f/20260416/70729cf4/4c21609a-d1ee-45b4-a537-7c7a52e73000_0.png?Expires=1776425752&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=jzch3VdJz%2Bbickhx5Ex7838CmI8%3D",
|
||||
"https://dashscope-7c2c.oss-accelerate.aliyuncs.com/1d/03/20260416/70729cf4/6b8c3d90-b7fa-4247-a381-13f53a517868_0.png?Expires=1776425752&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=k%2BkL1IbsluUydxVS8oEJXXfWKLY%3D"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"characterId": "story-npc-凯瑟琳-mo1ek7si",
|
||||
"visualPromptText": "蒸汽朋克风格,身穿复古蒸汽动力机械服装的女性学者凯瑟琳,侧身朝右,全身展示,脚踩在金属地板上,身后是复杂的蒸汽管道和机器,手中拿着一本记录着蒸汽研究数据的笔记本,眼神专注而痴迷。",
|
||||
"animationPromptText": "凯瑟琳优雅地抬手释放蒸汽陷阱,动作流畅且充满自信,气质神秘而专注,每个动作之间衔接自然,始终保持着学者的沉稳与对蒸汽能量的掌控力。",
|
||||
"visualDrafts": [
|
||||
{
|
||||
"id": "candidate-1",
|
||||
"label": "候选 1",
|
||||
"imageSrc": "/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-01.png",
|
||||
"width": 1024,
|
||||
"height": 1536
|
||||
},
|
||||
{
|
||||
"id": "candidate-2",
|
||||
"label": "候选 2",
|
||||
"imageSrc": "/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-02.png",
|
||||
"width": 1024,
|
||||
"height": 1536
|
||||
}
|
||||
],
|
||||
"selectedVisualDraftId": "candidate-1",
|
||||
"selectedAnimation": "idle",
|
||||
"imageSrc": "/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/master.png",
|
||||
"generatedVisualAssetId": "visual-1776339356501",
|
||||
"animationMap": null,
|
||||
"updatedAt": "2026-04-16T11:35:56.889Z"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "visual-1776328980091",
|
||||
"characterId": "story-npc-艾莉丝-1",
|
||||
"sourceMode": "text-to-image",
|
||||
"promptText": "艾莉丝,旧秩序守护者 / 机械师。 单人全身,2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。 外观气质围绕:掌握关键技术的返乡者。 动作识别点参考:擅长利用机械装置和魔法,以远程攻击和控制为主。 保留 旧秩序、机械 的角色识别点。 构图干净,主体明确,不做正面立绘,不做夸张透视。",
|
||||
"masterImagePath": "/generated-characters/story-npc-1/visual/visual-1776328980091/master.png",
|
||||
"previewImagePaths": [
|
||||
"/generated-characters/story-npc-1/visual/visual-1776328980091/preview-1.png",
|
||||
"/generated-characters/story-npc-1/visual/visual-1776328980091/preview-2.png"
|
||||
],
|
||||
"width": 1024,
|
||||
"height": 1536,
|
||||
"facing": "right",
|
||||
"locked": true
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "visual-1776339356501",
|
||||
"characterId": "story-npc-凯瑟琳-mo1ek7si",
|
||||
"sourceMode": "text-to-image",
|
||||
"promptText": "蒸汽朋克风格,身穿复古蒸汽动力机械服装的女性学者凯瑟琳,侧身朝右,全身展示,脚踩在金属地板上,身后是复杂的蒸汽管道和机器,手中拿着一本记录着蒸汽研究数据的笔记本,眼神专注而痴迷。",
|
||||
"masterImagePath": "/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/master.png",
|
||||
"previewImagePaths": [
|
||||
"/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/preview-1.png",
|
||||
"/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/preview-2.png"
|
||||
],
|
||||
"width": 1024,
|
||||
"height": 1536,
|
||||
"facing": "right",
|
||||
"locked": true
|
||||
}
|
||||
@@ -277,7 +277,9 @@ async function waitForCustomWorldAgentOperation(params: {
|
||||
assert.equal(operationResponse.status, 200);
|
||||
operationText = await operationResponse.text();
|
||||
|
||||
if (new RegExp(`"status":"${params.expectedStatus}"`, 'u').test(operationText)) {
|
||||
if (
|
||||
new RegExp(`"status":"${params.expectedStatus}"`, 'u').test(operationText)
|
||||
) {
|
||||
return operationText;
|
||||
}
|
||||
|
||||
@@ -1827,6 +1829,164 @@ test('runtime persistence is isolated by user', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('profile dashboard aggregates wallet, play time and played works at the account level', async () => {
|
||||
await withTestServer('profile-dashboard', async ({ baseUrl }) => {
|
||||
const user = await authEntry(baseUrl, 'dashboard_user', 'secret123');
|
||||
|
||||
const firstSaveResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(user.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
savedAt: '2026-04-16T08:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
playerCurrency: 120,
|
||||
runtimeStats: {
|
||||
playTimeMs: 5400000,
|
||||
},
|
||||
customWorldProfile: {
|
||||
id: 'world-aurora',
|
||||
name: '裂潮边城',
|
||||
summary: '潮声与城线之间的冷铁边疆。',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(firstSaveResponse.status, 200);
|
||||
|
||||
const secondSaveResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(user.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
savedAt: '2026-04-16T09:30:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
playerCurrency: 86,
|
||||
runtimeStats: {
|
||||
playTimeMs: 7200000,
|
||||
},
|
||||
customWorldProfile: {
|
||||
id: 'world-aurora',
|
||||
name: '裂潮边城',
|
||||
summary: '潮声与城线之间的冷铁边疆。',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(secondSaveResponse.status, 200);
|
||||
|
||||
const thirdSaveResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(user.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
savedAt: '2026-04-16T10:15:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'WUXIA',
|
||||
playerCurrency: 86,
|
||||
runtimeStats: {
|
||||
playTimeMs: 900000,
|
||||
},
|
||||
currentScenePreset: {
|
||||
name: '江湖新章',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(thirdSaveResponse.status, 200);
|
||||
|
||||
const dashboardResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/dashboard`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const dashboardPayload = (await dashboardResponse.json()) as {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number;
|
||||
playedWorldCount: number;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
assert.equal(dashboardResponse.status, 200);
|
||||
assert.equal(dashboardPayload.walletBalance, 86);
|
||||
assert.equal(dashboardPayload.totalPlayTimeMs, 8100000);
|
||||
assert.equal(dashboardPayload.playedWorldCount, 2);
|
||||
assert.equal(dashboardPayload.updatedAt, '2026-04-16T10:15:00.000Z');
|
||||
|
||||
const legacyDashboardResponse = await httpRequest(
|
||||
`${baseUrl}/api/profile/dashboard`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const legacyDashboardPayload = (await legacyDashboardResponse.json()) as {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number;
|
||||
playedWorldCount: number;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
assert.equal(legacyDashboardResponse.status, 200);
|
||||
assert.deepEqual(legacyDashboardPayload, dashboardPayload);
|
||||
|
||||
const walletLedgerResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/wallet-ledger`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const walletLedgerPayload = (await walletLedgerResponse.json()) as {
|
||||
entries: Array<{
|
||||
amountDelta: number;
|
||||
balanceAfter: number;
|
||||
sourceType: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(walletLedgerResponse.status, 200);
|
||||
assert.equal(walletLedgerPayload.entries.length, 2);
|
||||
assert.equal(walletLedgerPayload.entries[0]?.amountDelta, -34);
|
||||
assert.equal(walletLedgerPayload.entries[0]?.balanceAfter, 86);
|
||||
assert.equal(walletLedgerPayload.entries[0]?.sourceType, 'snapshot_sync');
|
||||
assert.equal(walletLedgerPayload.entries[1]?.amountDelta, 120);
|
||||
|
||||
const playStatsResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/play-stats`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const playStatsPayload = (await playStatsResponse.json()) as {
|
||||
totalPlayTimeMs: number;
|
||||
playedWorks: Array<{
|
||||
worldKey: string;
|
||||
worldTitle: string;
|
||||
lastObservedPlayTimeMs: number;
|
||||
}>;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
assert.equal(playStatsResponse.status, 200);
|
||||
assert.equal(playStatsPayload.totalPlayTimeMs, 8100000);
|
||||
assert.equal(playStatsPayload.updatedAt, '2026-04-16T10:15:00.000Z');
|
||||
assert.equal(playStatsPayload.playedWorks.length, 2);
|
||||
assert.equal(playStatsPayload.playedWorks[0]?.worldKey, 'builtin:WUXIA');
|
||||
assert.equal(playStatsPayload.playedWorks[0]?.worldTitle, '江湖新章');
|
||||
assert.equal(
|
||||
playStatsPayload.playedWorks[1]?.worldKey,
|
||||
'custom:world-aurora',
|
||||
);
|
||||
assert.equal(
|
||||
playStatsPayload.playedWorks[1]?.lastObservedPlayTimeMs,
|
||||
7200000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('custom worlds stay private until published and then appear in the public gallery', async () => {
|
||||
await withTestServer('custom-world-gallery', async ({ baseUrl }) => {
|
||||
const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123');
|
||||
@@ -1929,7 +2089,10 @@ test('custom worlds stay private until published and then appear in the public g
|
||||
assert.equal(galleryAfterPublish.status, 200);
|
||||
assert.equal(galleryAfterPayload.entries.length, 1);
|
||||
assert.equal(galleryAfterPayload.entries[0]?.worldName, '裂桥前线');
|
||||
assert.equal(galleryAfterPayload.entries[0]?.authorDisplayName, 'gallery_owner');
|
||||
assert.equal(
|
||||
galleryAfterPayload.entries[0]?.authorDisplayName,
|
||||
'gallery_owner',
|
||||
);
|
||||
|
||||
const galleryDetail = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`,
|
||||
@@ -1975,9 +2138,10 @@ test('custom worlds stay private until published and then appear in the public g
|
||||
},
|
||||
},
|
||||
);
|
||||
const galleryAfterUnpublishPayload = (await galleryAfterUnpublish.json()) as {
|
||||
entries: unknown[];
|
||||
};
|
||||
const galleryAfterUnpublishPayload =
|
||||
(await galleryAfterUnpublish.json()) as {
|
||||
entries: unknown[];
|
||||
};
|
||||
assert.deepEqual(galleryAfterUnpublishPayload.entries, []);
|
||||
});
|
||||
});
|
||||
@@ -2413,12 +2577,18 @@ test('custom world agent update_draft_card action updates draft profile and card
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase4-update-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
const entry = await authEntry(baseUrl, 'cw_agent_phase4_update', 'secret123');
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'cw_agent_phase4_update',
|
||||
'secret123',
|
||||
);
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
});
|
||||
const worldCard = session.draftCards.find((card) => card.kind === 'world');
|
||||
const worldCard = session.draftCards.find(
|
||||
(card) => card.kind === 'world',
|
||||
);
|
||||
|
||||
assert.ok(worldCard);
|
||||
|
||||
@@ -2526,7 +2696,8 @@ test('custom world agent update_draft_card action updates draft profile and card
|
||||
assert.equal(cardDetailResponse.status, 200);
|
||||
assert.ok(
|
||||
cardDetailPayload.card.sections.some(
|
||||
(section) => section.label === '标题' && section.value === '潮雾列岛·回潮版',
|
||||
(section) =>
|
||||
section.label === '标题' && section.value === '潮雾列岛·回潮版',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2547,11 +2718,7 @@ test('custom world agent generate_characters action appends character cards over
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase4-generate-characters-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'cw_agent_p4_ch',
|
||||
'secret123',
|
||||
);
|
||||
const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123');
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
@@ -2621,7 +2788,8 @@ test('custom world agent generate_characters action appends character cards over
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
assert.ok((sessionPayload.draftProfile?.storyNpcs?.length ?? 0) >= 2);
|
||||
assert.ok(
|
||||
sessionPayload.draftCards.filter((card) => card.kind === 'character').length >=
|
||||
sessionPayload.draftCards.filter((card) => card.kind === 'character')
|
||||
.length >=
|
||||
baselineCharacterCount + 2,
|
||||
);
|
||||
assert.ok(sessionPayload.focusCardId);
|
||||
@@ -2649,11 +2817,7 @@ test('custom world agent generate_landmarks action appends landmark cards over h
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase4-generate-landmarks-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'cw_agent_p4_lm',
|
||||
'secret123',
|
||||
);
|
||||
const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123');
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
@@ -2723,7 +2887,8 @@ test('custom world agent generate_landmarks action appends landmark cards over h
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6);
|
||||
assert.ok(
|
||||
sessionPayload.draftCards.filter((card) => card.kind === 'landmark').length >=
|
||||
sessionPayload.draftCards.filter((card) => card.kind === 'landmark')
|
||||
.length >=
|
||||
baselineLandmarkCount + 2,
|
||||
);
|
||||
assert.ok(sessionPayload.focusCardId);
|
||||
@@ -3053,3 +3218,175 @@ test('runtime snapshot persistence returns hydrated snapshots for frontend resto
|
||||
assert.equal(loadPayload.gameState.playerMaxHp, 170);
|
||||
});
|
||||
});
|
||||
|
||||
test('profile browse history supports batch sync, dedupe ordering, isolation and clear', async () => {
|
||||
await withTestServer('profile-browse-history', async ({ baseUrl }) => {
|
||||
const viewer = await authEntry(baseUrl, 'browse_viewer', 'secret123');
|
||||
const author = await authEntry(baseUrl, 'browse_author', 'secret123');
|
||||
const browseHistoryUrl = `${baseUrl}/api/runtime/profile/browse-history`;
|
||||
|
||||
const createResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
withBearer(viewer.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ownerUserId: author.user.id,
|
||||
profileId: 'world-1',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '第一次浏览记录',
|
||||
coverImageSrc: '/covers/world-1.png',
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T10:00:00.000Z',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const createPayload = (await createResponse.json()) as {
|
||||
entries: Array<{
|
||||
profileId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(createResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
createPayload.entries.map((entry) => entry.profileId),
|
||||
['world-1'],
|
||||
);
|
||||
|
||||
const batchResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
withBearer(viewer.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
ownerUserId: author.user.id,
|
||||
profileId: 'world-2',
|
||||
worldName: '灰潮港',
|
||||
subtitle: '海雾中的残灯码头',
|
||||
summaryText: '第二条浏览记录',
|
||||
coverImageSrc: '/covers/world-2.png',
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T11:00:00.000Z',
|
||||
},
|
||||
{
|
||||
ownerUserId: author.user.id,
|
||||
profileId: 'world-1',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '第二次浏览后更新',
|
||||
coverImageSrc: '/covers/world-1-updated.png',
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const batchPayload = (await batchResponse.json()) as {
|
||||
entries: Array<{
|
||||
profileId: string;
|
||||
summaryText: string;
|
||||
visitedAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(batchResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
batchPayload.entries.map((entry) => entry.profileId),
|
||||
['world-1', 'world-2'],
|
||||
);
|
||||
assert.equal(batchPayload.entries[0]?.summaryText, '第二次浏览后更新');
|
||||
assert.equal(
|
||||
batchPayload.entries[0]?.visitedAt,
|
||||
'2026-04-16T12:00:00.000Z',
|
||||
);
|
||||
|
||||
const viewerHistoryResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const viewerHistoryPayload = (await viewerHistoryResponse.json()) as {
|
||||
entries: Array<{
|
||||
profileId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(viewerHistoryResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
viewerHistoryPayload.entries.map((entry) => entry.profileId),
|
||||
['world-1', 'world-2'],
|
||||
);
|
||||
|
||||
const legacyViewerHistoryResponse = await httpRequest(
|
||||
`${baseUrl}/api/profile/browse-history`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const legacyViewerHistoryPayload =
|
||||
(await legacyViewerHistoryResponse.json()) as {
|
||||
entries: Array<{
|
||||
profileId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(legacyViewerHistoryResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
legacyViewerHistoryPayload.entries.map((entry) => entry.profileId),
|
||||
['world-1', 'world-2'],
|
||||
);
|
||||
|
||||
const authorHistoryResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${author.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const authorHistoryPayload = (await authorHistoryResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
assert.equal(authorHistoryResponse.status, 200);
|
||||
assert.deepEqual(authorHistoryPayload.entries, []);
|
||||
|
||||
const clearResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
withBearer(viewer.token, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
);
|
||||
const clearPayload = (await clearResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
assert.equal(clearResponse.status, 200);
|
||||
assert.deepEqual(clearPayload.entries, []);
|
||||
|
||||
const clearedHistoryResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const clearedHistoryPayload = (await clearedHistoryResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
assert.equal(clearedHistoryResponse.status, 200);
|
||||
assert.deepEqual(clearedHistoryPayload.entries, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import pinoHttp from 'pino-http';
|
||||
|
||||
import type { AppContext } from './context.js';
|
||||
import { notFound } from './errors.js';
|
||||
import { buildApiLogContext, withRouteMeta } from './http.js';
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { requestIdMiddleware } from './middleware/requestId.js';
|
||||
@@ -12,7 +13,6 @@ import { createEditorRoutes } from './modules/editor/editorRoutes.js';
|
||||
import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js';
|
||||
import { createAuthRoutes } from './routes/authRoutes.js';
|
||||
import { createRuntimeRoutes } from './routes/runtimeRoutes.js';
|
||||
import { notFound } from './errors.js';
|
||||
|
||||
function matchesRoutePrefix(
|
||||
request: express.Request,
|
||||
@@ -114,7 +114,10 @@ export function createApp(context: AppContext) {
|
||||
),
|
||||
);
|
||||
app.use(
|
||||
scopeToPrefixes(['/api/assets'], createCharacterAssetRoutes(context.config)),
|
||||
scopeToPrefixes(
|
||||
['/api/assets'],
|
||||
createCharacterAssetRoutes(context.config, context.llmClient),
|
||||
),
|
||||
);
|
||||
app.use(
|
||||
scopeToPrefixes(
|
||||
|
||||
@@ -110,6 +110,8 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
||||
'20260409_008_auth_risk_blocks',
|
||||
'20260413_009_custom_world_sessions',
|
||||
'20260414_010_custom_world_gallery_metadata',
|
||||
'20260416_011_profile_dashboard_tables',
|
||||
'20260416_012_user_browse_history',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -126,8 +128,12 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
||||
'sms_auth_events',
|
||||
'user_sessions',
|
||||
'custom_world_sessions',
|
||||
'profile_dashboard_state',
|
||||
'profile_played_worlds',
|
||||
'profile_wallet_ledger',
|
||||
'save_snapshots',
|
||||
'runtime_settings',
|
||||
'user_browse_history',
|
||||
'custom_world_profiles'
|
||||
)
|
||||
ORDER BY table_name`,
|
||||
@@ -141,10 +147,14 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
||||
'auth_risk_blocks',
|
||||
'custom_world_profiles',
|
||||
'custom_world_sessions',
|
||||
'profile_dashboard_state',
|
||||
'profile_played_worlds',
|
||||
'profile_wallet_ledger',
|
||||
'runtime_settings',
|
||||
'save_snapshots',
|
||||
'schema_migrations',
|
||||
'sms_auth_events',
|
||||
'user_browse_history',
|
||||
'user_sessions',
|
||||
'users',
|
||||
],
|
||||
@@ -158,9 +168,7 @@ test('createDatabase rejects non-postgresql database urls', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
createDatabase(
|
||||
createTestConfig(
|
||||
'mysql://root:root@127.0.0.1:3306/genarrative',
|
||||
),
|
||||
createTestConfig('mysql://root:root@127.0.0.1:3306/genarrative'),
|
||||
),
|
||||
/DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接/u,
|
||||
);
|
||||
|
||||
@@ -234,4 +234,69 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
||||
ON custom_world_profiles (visibility, published_at DESC, updated_at DESC)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '20260416_011_profile_dashboard_tables',
|
||||
name: 'profile dashboard tables',
|
||||
statements: [
|
||||
`CREATE TABLE IF NOT EXISTS profile_dashboard_state (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
wallet_balance INTEGER NOT NULL DEFAULT 0,
|
||||
total_play_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS profile_wallet_ledger (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
amount_delta INTEGER NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS profile_wallet_ledger_user_source_key_idx
|
||||
ON profile_wallet_ledger (user_id, source_key)`,
|
||||
`CREATE INDEX IF NOT EXISTS profile_wallet_ledger_user_created_idx
|
||||
ON profile_wallet_ledger (user_id, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS profile_played_worlds (
|
||||
user_id TEXT NOT NULL,
|
||||
world_key TEXT NOT NULL,
|
||||
owner_user_id TEXT,
|
||||
profile_id TEXT,
|
||||
world_type TEXT,
|
||||
world_title TEXT NOT NULL DEFAULT '',
|
||||
world_subtitle TEXT NOT NULL DEFAULT '',
|
||||
first_played_at TEXT NOT NULL,
|
||||
last_played_at TEXT NOT NULL,
|
||||
last_observed_play_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, world_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS profile_played_worlds_user_last_played_idx
|
||||
ON profile_played_worlds (user_id, last_played_at DESC)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '20260416_012_user_browse_history',
|
||||
name: 'user browse history',
|
||||
statements: [
|
||||
`CREATE TABLE IF NOT EXISTS user_browse_history (
|
||||
user_id TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
profile_id TEXT NOT NULL,
|
||||
world_name TEXT NOT NULL DEFAULT '',
|
||||
subtitle TEXT NOT NULL DEFAULT '',
|
||||
summary_text TEXT NOT NULL DEFAULT '',
|
||||
cover_image_src TEXT,
|
||||
theme_mode TEXT NOT NULL DEFAULT 'mythic',
|
||||
author_display_name TEXT NOT NULL DEFAULT '玩家',
|
||||
visited_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, owner_user_id, profile_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS user_browse_history_user_visited_idx
|
||||
ON user_browse_history (user_id, visited_at DESC)`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -209,6 +209,12 @@ test('character visual generation converts public reference images into data url
|
||||
};
|
||||
};
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.match(content[0]?.text ?? '', /右向斜侧身/u);
|
||||
assert.match(content[0]?.text ?? '', /纯绿色绿幕/u);
|
||||
assert.match(content[0]?.text ?? '', /1:1 正方形画幅|1:1 正方形画布/u);
|
||||
assert.match(content[0]?.text ?? '', /2 到 3 头身/u);
|
||||
assert.match(content[0]?.text ?? '', /不要把主题词自动扩写成背景建筑/u);
|
||||
assert.doesNotMatch(content[0]?.text ?? '', /水母国王/u);
|
||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
|
||||
const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1));
|
||||
@@ -218,6 +224,186 @@ test('character visual generation converts public reference images into data url
|
||||
);
|
||||
});
|
||||
|
||||
test('character prompt bundle generation falls back to local defaults when llm client is unavailable', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-'));
|
||||
|
||||
await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => {
|
||||
const response = await fetch(`${assetBaseUrl}/api/assets/character-prompts/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roleKind: 'story',
|
||||
characterName: '港口向导',
|
||||
roleTitle: '潮灯守望者',
|
||||
roleLabel: '旧港引路人',
|
||||
description: '熟悉黑潮与暗礁,身上带着潮雾气息。',
|
||||
backstory: '常年守在废弃灯塔附近,为误入者指路。',
|
||||
personality: '冷静克制,但会在关键时刻出手。',
|
||||
motivation: '想守住最后一段仍能靠岸的航道。',
|
||||
combatStyle: '短刀与信号灯配合,动作利落。',
|
||||
tags: ['潮雾', '守望', '引路'],
|
||||
characterBriefText: '角色名称:港口向导\n角色头衔:潮灯守望者\n世界身份:旧港引路人',
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
source: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
assert.equal(payload.source, 'fallback');
|
||||
assert.match(payload.visualPromptText, /港口向导/u);
|
||||
assert.match(payload.visualPromptText, /右向斜侧身/u);
|
||||
assert.match(payload.visualPromptText, /纯绿色绿幕/u);
|
||||
assert.match(payload.visualPromptText, /2 到 3 头身/u);
|
||||
assert.match(payload.animationPromptText, /动作/u);
|
||||
assert.match(payload.scenePromptText, /场景/u);
|
||||
});
|
||||
});
|
||||
|
||||
test('character workflow cache persists unsaved studio state', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'));
|
||||
|
||||
await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => {
|
||||
const saveResponse = await fetch(`${assetBaseUrl}/api/assets/character-workflow-cache`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
visualPromptText: '潮雾港守望者',
|
||||
animationPromptText: '短刀起手,收招利落',
|
||||
visualDrafts: [
|
||||
{
|
||||
id: 'draft-1',
|
||||
label: '候选 1',
|
||||
imageSrc: '/generated-character-drafts/harbor-guide/draft-1.png',
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
},
|
||||
],
|
||||
selectedVisualDraftId: 'draft-1',
|
||||
selectedAnimation: 'idle',
|
||||
imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png',
|
||||
generatedVisualAssetId: 'visual-1',
|
||||
generatedAnimationSetId: 'animation-set-1',
|
||||
animationMap: {
|
||||
idle: {
|
||||
basePath: '/generated-animations/harbor-guide/animation-set-1/idle',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(saveResponse.status, 200);
|
||||
|
||||
const readResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache/harbor-guide`,
|
||||
);
|
||||
assert.equal(readResponse.status, 200);
|
||||
|
||||
const payload = (await readResponse.json()) as {
|
||||
cache: {
|
||||
characterId: string;
|
||||
selectedVisualDraftId: string;
|
||||
generatedVisualAssetId?: string;
|
||||
animationMap?: Record<string, { basePath?: string }>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
assert.equal(payload.cache?.characterId, 'harbor-guide');
|
||||
assert.equal(payload.cache?.selectedVisualDraftId, 'draft-1');
|
||||
assert.equal(payload.cache?.generatedVisualAssetId, 'visual-1');
|
||||
assert.equal(
|
||||
payload.cache?.animationMap?.idle?.basePath,
|
||||
'/generated-animations/harbor-guide/animation-set-1/idle',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('character workflow cache skips rewriting unchanged payloads', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-character-workflow-cache-stable-'),
|
||||
);
|
||||
|
||||
await withAssetRouteServer(
|
||||
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
|
||||
async (assetBaseUrl) => {
|
||||
const payload = {
|
||||
characterId: 'harbor-guide',
|
||||
visualPromptText: '潮雾港守望者',
|
||||
animationPromptText: '短刀起手,收招利落',
|
||||
visualDrafts: [
|
||||
{
|
||||
id: 'draft-1',
|
||||
label: '候选 1',
|
||||
imageSrc: '/generated-character-drafts/harbor-guide/draft-1.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
selectedVisualDraftId: 'draft-1',
|
||||
selectedAnimation: 'idle',
|
||||
imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png',
|
||||
generatedVisualAssetId: 'visual-1',
|
||||
generatedAnimationSetId: 'animation-set-1',
|
||||
animationMap: {
|
||||
idle: {
|
||||
basePath: '/generated-animations/harbor-guide/animation-set-1/idle',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const firstSaveResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
assert.equal(firstSaveResponse.status, 200);
|
||||
const firstSavePayload = (await firstSaveResponse.json()) as {
|
||||
cache: {
|
||||
updatedAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
const secondSaveResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
assert.equal(secondSaveResponse.status, 200);
|
||||
const secondSavePayload = (await secondSaveResponse.json()) as {
|
||||
saveMessage: string;
|
||||
cache: {
|
||||
updatedAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(secondSavePayload.saveMessage, '角色形象生成缓存无变化。');
|
||||
assert.equal(
|
||||
secondSavePayload.cache.updatedAt,
|
||||
firstSavePayload.cache.updatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation image-to-video flow uploads a public visual source and submits the resolved oss url', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
|
||||
@@ -7,7 +7,7 @@ import http, {
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Router, type NextFunction, type Request, type Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response,Router } from 'express';
|
||||
|
||||
import {
|
||||
buildMasterPrompt,
|
||||
@@ -15,8 +15,12 @@ import {
|
||||
getActionTemplateById,
|
||||
} from '../../../../packages/shared/src/assets/qwenSprite.js';
|
||||
import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js';
|
||||
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
|
||||
import type { AppConfig } from '../../config.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
|
||||
const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH = '/api/assets/character-prompts/generate';
|
||||
const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache';
|
||||
const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate';
|
||||
const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish';
|
||||
const CHARACTER_VISUAL_JOBS_PATH = '/api/assets/character-visual/jobs/';
|
||||
@@ -34,6 +38,24 @@ const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
|
||||
const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000;
|
||||
const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000;
|
||||
const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000;
|
||||
const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。
|
||||
你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。
|
||||
你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。
|
||||
输出格式必须严格为:
|
||||
{
|
||||
"visualPromptText": "角色主图提示词",
|
||||
"animationPromptText": "角色动作提示词",
|
||||
"scenePromptText": "角色关联场景提示词"
|
||||
}
|
||||
|
||||
硬性约束:
|
||||
- 所有字段都必须是自然中文。
|
||||
- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。
|
||||
- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。
|
||||
- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。
|
||||
- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。
|
||||
- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。
|
||||
- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`;
|
||||
|
||||
const BUILT_IN_MOTION_TEMPLATES = [
|
||||
{
|
||||
@@ -85,6 +107,83 @@ type DecodedMediaPayload = {
|
||||
extension: string;
|
||||
};
|
||||
|
||||
type CharacterPromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
source: 'llm' | 'fallback';
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
type CharacterAssetWorkflowCacheRecord = {
|
||||
characterId: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
visualDrafts: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
selectedVisualDraftId: string;
|
||||
selectedAnimation: string;
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown> | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function serializeWorkflowCacheComparableValue(
|
||||
value: CharacterAssetWorkflowCacheRecord | Record<string, unknown>,
|
||||
) {
|
||||
const visualDrafts = Array.isArray(value.visualDrafts)
|
||||
? value.visualDrafts
|
||||
.map((item) => {
|
||||
if (!isRecordValue(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' ? item.id : '',
|
||||
label: typeof item.label === 'string' ? item.label : '',
|
||||
imageSrc: typeof item.imageSrc === 'string' ? item.imageSrc : '',
|
||||
width: typeof item.width === 'number' ? item.width : 0,
|
||||
height: typeof item.height === 'number' ? item.height : 0,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return JSON.stringify({
|
||||
characterId: typeof value.characterId === 'string' ? value.characterId : '',
|
||||
visualPromptText:
|
||||
typeof value.visualPromptText === 'string' ? value.visualPromptText : '',
|
||||
animationPromptText:
|
||||
typeof value.animationPromptText === 'string'
|
||||
? value.animationPromptText
|
||||
: '',
|
||||
visualDrafts,
|
||||
selectedVisualDraftId:
|
||||
typeof value.selectedVisualDraftId === 'string'
|
||||
? value.selectedVisualDraftId
|
||||
: '',
|
||||
selectedAnimation:
|
||||
typeof value.selectedAnimation === 'string' ? value.selectedAnimation : '',
|
||||
imageSrc: typeof value.imageSrc === 'string' ? value.imageSrc : '',
|
||||
generatedVisualAssetId:
|
||||
typeof value.generatedVisualAssetId === 'string'
|
||||
? value.generatedVisualAssetId
|
||||
: '',
|
||||
generatedAnimationSetId:
|
||||
typeof value.generatedAnimationSetId === 'string'
|
||||
? value.generatedAnimationSetId
|
||||
: '',
|
||||
animationMap: isRecordValue(value.animationMap) ? value.animationMap : null,
|
||||
});
|
||||
}
|
||||
|
||||
function readJsonBody(req: IncomingMessage & { body?: unknown }) {
|
||||
const parsedBody = req.body;
|
||||
if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) {
|
||||
@@ -154,6 +253,144 @@ function sanitizePathSegment(value: string) {
|
||||
return normalized || 'asset';
|
||||
}
|
||||
|
||||
function clampPromptSeedText(value: unknown, maxLength: number) {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildFallbackCharacterPromptBundle(params: {
|
||||
characterName: string;
|
||||
roleKind: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
const roleAnchor =
|
||||
[params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') ||
|
||||
(params.roleKind === 'playable' ? '可扮演角色' : '场景角色');
|
||||
const characterAnchor = params.characterName || '该角色';
|
||||
const descriptionAnchor =
|
||||
params.description || params.backstory || params.personality || '气质鲜明';
|
||||
const combatAnchor = params.combatStyle || params.motivation || '动作发力清晰';
|
||||
const tagAnchor =
|
||||
params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : '';
|
||||
|
||||
return {
|
||||
visualPromptText: [
|
||||
`${characterAnchor},${roleAnchor}。`,
|
||||
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 3 头身,右向斜侧身站立,身体整体朝右但保留少量正面信息,脚底完整可见,服装、发型、武器和轮廓稳定清楚。',
|
||||
`外观气质围绕:${descriptionAnchor}。`,
|
||||
combatAnchor ? `战斗识别点:${combatAnchor}。` : '',
|
||||
tagAnchor,
|
||||
'背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
animationPromptText: [
|
||||
`${characterAnchor}的核心动作试片。`,
|
||||
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯。',
|
||||
combatAnchor ? `动作气质参考:${combatAnchor}。` : '',
|
||||
params.personality ? `角色气质补充:${params.personality}。` : '',
|
||||
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
scenePromptText: [
|
||||
`${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`,
|
||||
'16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。',
|
||||
`场景叙事气质围绕:${descriptionAnchor}。`,
|
||||
params.backstory ? `背景线索可参考:${params.backstory}。` : '',
|
||||
params.motivation ? `环境中可埋入与当前目标相关的暗示:${params.motivation}。` : '',
|
||||
'整体风格克制统一,适合剧情探索与战斗底图。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
source: 'fallback' as const,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePromptBundleValue(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
maxLength: number,
|
||||
) {
|
||||
const normalized = clampPromptSeedText(value, maxLength);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function sanitizeCharacterPromptBundle(
|
||||
value: unknown,
|
||||
fallback: CharacterPromptBundle,
|
||||
model: string,
|
||||
) {
|
||||
const record = isRecordValue(value) ? value : {};
|
||||
|
||||
return {
|
||||
visualPromptText: sanitizePromptBundleValue(
|
||||
record.visualPromptText,
|
||||
fallback.visualPromptText,
|
||||
280,
|
||||
),
|
||||
animationPromptText: sanitizePromptBundleValue(
|
||||
record.animationPromptText,
|
||||
fallback.animationPromptText,
|
||||
280,
|
||||
),
|
||||
scenePromptText: sanitizePromptBundleValue(
|
||||
record.scenePromptText,
|
||||
fallback.scenePromptText,
|
||||
320,
|
||||
),
|
||||
source: 'llm' as const,
|
||||
model: model.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCharacterPromptBundleUserPrompt(params: {
|
||||
roleKind: string;
|
||||
characterBriefText: string;
|
||||
characterName: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
return [
|
||||
'请根据下面的角色卡摘要,编译一组默认资产提示词。',
|
||||
'提示词用于当前项目的角色主图、动作试片和角色关联场景背景。',
|
||||
'请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。',
|
||||
'',
|
||||
`角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`,
|
||||
params.characterName ? `角色名称:${params.characterName}` : '',
|
||||
params.roleTitle ? `角色头衔:${params.roleTitle}` : '',
|
||||
params.roleLabel ? `世界身份:${params.roleLabel}` : '',
|
||||
params.description ? `角色描述:${params.description}` : '',
|
||||
params.backstory ? `角色背景:${params.backstory}` : '',
|
||||
params.personality ? `角色性格:${params.personality}` : '',
|
||||
params.motivation ? `角色动机:${params.motivation}` : '',
|
||||
params.combatStyle ? `战斗风格:${params.combatStyle}` : '',
|
||||
params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '',
|
||||
'',
|
||||
'角色卡全文:',
|
||||
params.characterBriefText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function createTimestampId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
@@ -173,6 +410,16 @@ function getJobRecordPath(
|
||||
);
|
||||
}
|
||||
|
||||
function getCharacterWorkflowCachePath(rootDir: string, characterId: string) {
|
||||
return path.resolve(
|
||||
rootDir,
|
||||
'public',
|
||||
'generated-character-drafts',
|
||||
sanitizePathSegment(characterId),
|
||||
'workflow-cache.json',
|
||||
);
|
||||
}
|
||||
|
||||
async function writeJobRecord(
|
||||
rootDir: string,
|
||||
kind: 'visual' | 'animation',
|
||||
@@ -766,6 +1013,103 @@ function buildNpcAnimationPrompt(options: {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
async function handleGenerateCharacterPromptBundle(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
llmClient?: UpstreamLlmClient | null,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readJsonBody(req);
|
||||
const roleKind =
|
||||
typeof body.roleKind === 'string' && body.roleKind.trim()
|
||||
? body.roleKind.trim()
|
||||
: 'story';
|
||||
const characterBriefText = clampPromptSeedText(body.characterBriefText, 2400);
|
||||
const characterName = clampPromptSeedText(body.characterName, 40);
|
||||
const roleTitle = clampPromptSeedText(body.roleTitle, 60);
|
||||
const roleLabel = clampPromptSeedText(body.roleLabel, 60);
|
||||
const description = clampPromptSeedText(body.description, 240);
|
||||
const backstory = clampPromptSeedText(body.backstory, 320);
|
||||
const personality = clampPromptSeedText(body.personality, 180);
|
||||
const motivation = clampPromptSeedText(body.motivation, 180);
|
||||
const combatStyle = clampPromptSeedText(body.combatStyle, 180);
|
||||
const tags = isStringArray(body.tags)
|
||||
? body.tags.map((item) => clampPromptSeedText(item, 24)).filter(Boolean).slice(0, 8)
|
||||
: [];
|
||||
|
||||
if (!characterBriefText) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: '生成默认提示词前需要提供角色设定摘要。' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackBundle = buildFallbackCharacterPromptBundle({
|
||||
characterName,
|
||||
roleKind,
|
||||
roleTitle,
|
||||
roleLabel,
|
||||
description,
|
||||
backstory,
|
||||
personality,
|
||||
motivation,
|
||||
combatStyle,
|
||||
tags,
|
||||
});
|
||||
const llmApiKey =
|
||||
typeof config.llm?.apiKey === 'string' ? config.llm.apiKey.trim() : '';
|
||||
const llmModel =
|
||||
typeof config.llm?.model === 'string' ? config.llm.model : '';
|
||||
|
||||
if (!llmClient || !llmApiKey) {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...fallbackBundle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const responseText = await llmClient.requestMessageContent({
|
||||
systemPrompt: CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
|
||||
userPrompt: buildCharacterPromptBundleUserPrompt({
|
||||
roleKind,
|
||||
characterBriefText,
|
||||
characterName,
|
||||
roleTitle,
|
||||
roleLabel,
|
||||
description,
|
||||
backstory,
|
||||
personality,
|
||||
motivation,
|
||||
combatStyle,
|
||||
tags,
|
||||
}),
|
||||
debugLabel: 'character-prompt-bundle',
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...sanitizeCharacterPromptBundle(
|
||||
parseJsonResponseText(responseText),
|
||||
fallbackBundle,
|
||||
llmModel,
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...fallbackBundle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDraftBinaryFile(
|
||||
rootDir: string,
|
||||
relativePath: string,
|
||||
@@ -847,7 +1191,7 @@ async function handleGenerateCharacterVisuals(
|
||||
const size =
|
||||
typeof body.size === 'string' && body.size.trim()
|
||||
? body.size.trim()
|
||||
: '1024*1536';
|
||||
: '1024*1024';
|
||||
|
||||
if (sourceMode === 'image-to-image' && referenceImageDataUrls.length === 0) {
|
||||
sendJson(res, 400, {
|
||||
@@ -982,7 +1326,7 @@ async function handleGenerateCharacterVisuals(
|
||||
label: `候选 ${index + 1}`,
|
||||
imageSrc,
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
height: 1024,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -2081,6 +2425,198 @@ function handleListAnimationTemplates(
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetCharacterWorkflowCache(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { originalUrl?: string },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawUrl = req.originalUrl ?? req.url ?? '';
|
||||
const characterId = decodeURIComponent(
|
||||
rawUrl.slice(rawUrl.lastIndexOf('/') + 1),
|
||||
).trim();
|
||||
|
||||
if (!characterId) {
|
||||
sendJson(res, 400, { error: { message: 'characterId is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cache = (await readJsonObjectFile(
|
||||
getCharacterWorkflowCachePath(config.projectRoot, characterId),
|
||||
)) as CharacterAssetWorkflowCacheRecord | Record<string, never>;
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
cache:
|
||||
isRecordValue(cache) && typeof cache.characterId === 'string'
|
||||
? cache
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message:
|
||||
error instanceof Error ? error.message : '读取角色形象生成缓存失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCharacterWorkflowCache(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const characterId =
|
||||
typeof body.characterId === 'string' ? body.characterId.trim() : '';
|
||||
if (!characterId) {
|
||||
sendJson(res, 400, { error: { message: 'characterId is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const visualDrafts = Array.isArray(body.visualDrafts)
|
||||
? body.visualDrafts
|
||||
.map((item, index) => {
|
||||
if (!isRecordValue(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageSrc =
|
||||
typeof item.imageSrc === 'string' ? item.imageSrc.trim() : '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof item.id === 'string' && item.id.trim()
|
||||
? item.id.trim()
|
||||
: `${characterId}-draft-${index + 1}`;
|
||||
const label =
|
||||
typeof item.label === 'string' && item.label.trim()
|
||||
? item.label.trim()
|
||||
: `候选 ${index + 1}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
imageSrc,
|
||||
width:
|
||||
typeof item.width === 'number' && Number.isFinite(item.width)
|
||||
? item.width
|
||||
: 1024,
|
||||
height:
|
||||
typeof item.height === 'number' && Number.isFinite(item.height)
|
||||
? item.height
|
||||
: 1536,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is CharacterAssetWorkflowCacheRecord['visualDrafts'][number] =>
|
||||
Boolean(item),
|
||||
)
|
||||
: [];
|
||||
|
||||
const cacheFilePath = getCharacterWorkflowCachePath(
|
||||
config.projectRoot,
|
||||
characterId,
|
||||
);
|
||||
const payloadBase = {
|
||||
characterId,
|
||||
visualPromptText: clampPromptSeedText(body.visualPromptText, 280),
|
||||
animationPromptText: clampPromptSeedText(body.animationPromptText, 280),
|
||||
visualDrafts,
|
||||
selectedVisualDraftId:
|
||||
typeof body.selectedVisualDraftId === 'string'
|
||||
? body.selectedVisualDraftId.trim()
|
||||
: '',
|
||||
selectedAnimation:
|
||||
typeof body.selectedAnimation === 'string'
|
||||
? body.selectedAnimation.trim()
|
||||
: 'idle',
|
||||
imageSrc:
|
||||
typeof body.imageSrc === 'string' && body.imageSrc.trim()
|
||||
? body.imageSrc.trim()
|
||||
: undefined,
|
||||
generatedVisualAssetId:
|
||||
typeof body.generatedVisualAssetId === 'string' &&
|
||||
body.generatedVisualAssetId.trim()
|
||||
? body.generatedVisualAssetId.trim()
|
||||
: undefined,
|
||||
generatedAnimationSetId:
|
||||
typeof body.generatedAnimationSetId === 'string' &&
|
||||
body.generatedAnimationSetId.trim()
|
||||
? body.generatedAnimationSetId.trim()
|
||||
: undefined,
|
||||
animationMap: isRecordValue(body.animationMap) ? body.animationMap : null,
|
||||
};
|
||||
|
||||
try {
|
||||
const existingCache = (await readJsonObjectFile(cacheFilePath)) as
|
||||
| CharacterAssetWorkflowCacheRecord
|
||||
| Record<string, never>;
|
||||
const comparablePayload = serializeWorkflowCacheComparableValue(payloadBase);
|
||||
const comparableExisting = serializeWorkflowCacheComparableValue(
|
||||
existingCache,
|
||||
);
|
||||
|
||||
if (
|
||||
isRecordValue(existingCache) &&
|
||||
typeof existingCache.characterId === 'string' &&
|
||||
comparableExisting === comparablePayload
|
||||
) {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
cache: existingCache,
|
||||
saveMessage: '角色形象生成缓存无变化。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CharacterAssetWorkflowCacheRecord = {
|
||||
...payloadBase,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeJsonObjectFile(
|
||||
cacheFilePath,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
cache: payload,
|
||||
saveMessage: '角色形象生成缓存已更新。',
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message:
|
||||
error instanceof Error ? error.message : '保存角色形象生成缓存失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishCharacterVisual(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
@@ -2126,7 +2662,7 @@ async function handlePublishCharacterVisual(
|
||||
const height =
|
||||
typeof body.height === 'number' && Number.isFinite(body.height)
|
||||
? body.height
|
||||
: 1536;
|
||||
: 1024;
|
||||
const updateCharacterOverride = body.updateCharacterOverride !== false;
|
||||
|
||||
if (!characterId) {
|
||||
@@ -2443,7 +2979,10 @@ function toExpressHandler(
|
||||
};
|
||||
}
|
||||
|
||||
export function createCharacterAssetRoutes(config: AppConfig) {
|
||||
export function createCharacterAssetRoutes(
|
||||
config: AppConfig,
|
||||
llmClient?: UpstreamLlmClient | null,
|
||||
) {
|
||||
const router = Router();
|
||||
|
||||
router.use((request, response, next) => {
|
||||
@@ -2466,6 +3005,21 @@ export function createCharacterAssetRoutes(config: AppConfig) {
|
||||
next();
|
||||
});
|
||||
|
||||
router.use(
|
||||
CHARACTER_WORKFLOW_CACHE_PATH,
|
||||
toExpressHandler((request, response) => {
|
||||
if (request.method === 'GET') {
|
||||
return handleGetCharacterWorkflowCache(config, request, response);
|
||||
}
|
||||
return handleSaveCharacterWorkflowCache(config, request, response);
|
||||
}),
|
||||
);
|
||||
router.use(
|
||||
CHARACTER_PROMPT_BUNDLE_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleGenerateCharacterPromptBundle(config, request, response, llmClient),
|
||||
),
|
||||
);
|
||||
router.use(
|
||||
CHARACTER_VISUAL_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import type {
|
||||
CustomWorldProfileRecord,
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerEntry,
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import {
|
||||
type CustomWorldSessionRecord,
|
||||
type CustomWorldGalleryCard,
|
||||
type CustomWorldLibraryEntry,
|
||||
type CustomWorldPublicationStatus,
|
||||
type CustomWorldSessionRecord,
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
SAVE_SNAPSHOT_VERSION,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
@@ -72,12 +80,62 @@ type CustomWorldCardRow = QueryResultRow & {
|
||||
landmarkCount: number;
|
||||
};
|
||||
|
||||
type PlatformBrowseHistoryRow = QueryResultRow & {
|
||||
ownerUserId: string;
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeMode: PlatformBrowseHistoryEntry['themeMode'];
|
||||
authorDisplayName: string;
|
||||
visitedAt: string;
|
||||
};
|
||||
|
||||
type ProfileDashboardStateRow = QueryResultRow & {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number | string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ProfileWalletLedgerRow = QueryResultRow & {
|
||||
id: string;
|
||||
amountDelta: number;
|
||||
balanceAfter: number;
|
||||
sourceType: ProfileWalletLedgerEntry['sourceType'];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ProfilePlayedWorldRow = QueryResultRow & {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldTitle: string;
|
||||
worldSubtitle: string;
|
||||
firstPlayedAt: string;
|
||||
lastPlayedAt: string;
|
||||
lastObservedPlayTimeMs: number | string;
|
||||
};
|
||||
|
||||
type ProfileWorldSnapshotMeta = {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldTitle: string;
|
||||
worldSubtitle: string;
|
||||
};
|
||||
|
||||
export type RuntimeRepositoryPort = {
|
||||
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
|
||||
putSnapshot(
|
||||
userId: string,
|
||||
payload: Omit<SavedSnapshot, 'version'>,
|
||||
): Promise<SavedSnapshot>;
|
||||
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary>;
|
||||
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]>;
|
||||
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse>;
|
||||
deleteSnapshot(userId: string): Promise<void>;
|
||||
getSettings(userId: string): Promise<RuntimeSettings>;
|
||||
putSettings(
|
||||
@@ -87,6 +145,14 @@ export type RuntimeRepositoryPort = {
|
||||
listCustomWorldProfiles(
|
||||
userId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
listPlatformBrowseHistory(
|
||||
userId: string,
|
||||
): Promise<PlatformBrowseHistoryEntry[]>;
|
||||
upsertPlatformBrowseHistoryEntries(
|
||||
userId: string,
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
): Promise<PlatformBrowseHistoryEntry[]>;
|
||||
clearPlatformBrowseHistory(userId: string): Promise<void>;
|
||||
upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
@@ -100,9 +166,7 @@ export type RuntimeRepositoryPort = {
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
listCustomWorldSessions(
|
||||
userId: string,
|
||||
): Promise<CustomWorldSessionRecord[]>;
|
||||
listCustomWorldSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
|
||||
getCustomWorldSession(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
@@ -168,7 +232,9 @@ function toCustomWorldLibraryEntry(
|
||||
? row.playableNpcCount
|
||||
: fallbackMetadata.playableNpcCount,
|
||||
landmarkCount:
|
||||
row.landmarkCount > 0 ? row.landmarkCount : fallbackMetadata.landmarkCount,
|
||||
row.landmarkCount > 0
|
||||
? row.landmarkCount
|
||||
: fallbackMetadata.landmarkCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,13 +258,159 @@ function toCustomWorldGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
function toPlatformBrowseHistoryEntry(
|
||||
row: PlatformBrowseHistoryRow,
|
||||
): PlatformBrowseHistoryEntry {
|
||||
return {
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldName: row.worldName || '未命名世界',
|
||||
subtitle: row.subtitle || '',
|
||||
summaryText: row.summaryText || '',
|
||||
coverImageSrc: row.coverImageSrc || null,
|
||||
themeMode: row.themeMode || 'mythic',
|
||||
authorDisplayName: row.authorDisplayName || '玩家',
|
||||
visitedAt: row.visitedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizePlatformBrowseHistoryWriteEntry(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
): PlatformBrowseHistoryEntry | null {
|
||||
const ownerUserId = readString(entry.ownerUserId);
|
||||
const profileId = readString(entry.profileId);
|
||||
const worldName = readString(entry.worldName);
|
||||
|
||||
if (!ownerUserId || !profileId || !worldName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visitedAt = readString(entry.visitedAt) || new Date().toISOString();
|
||||
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
worldName,
|
||||
subtitle: readString(entry.subtitle),
|
||||
summaryText: readString(entry.summaryText),
|
||||
coverImageSrc: readString(entry.coverImageSrc) || null,
|
||||
themeMode:
|
||||
(readString(
|
||||
entry.themeMode,
|
||||
) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic',
|
||||
authorDisplayName: readString(entry.authorDisplayName) || '玩家',
|
||||
visitedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeDashboardNumber(value: unknown) {
|
||||
return Math.max(0, Math.round(readFiniteNumber(value)));
|
||||
}
|
||||
|
||||
function buildBuiltinWorldTitle(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '武侠世界';
|
||||
case 'XIANXIA':
|
||||
return '仙侠世界';
|
||||
default:
|
||||
return '叙事世界';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProfileWorldSnapshotMeta(
|
||||
snapshot: SavedSnapshot,
|
||||
): ProfileWorldSnapshotMeta | null {
|
||||
const gameState = asRecord(snapshot.gameState);
|
||||
if (!gameState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customWorldProfile = asRecord(gameState.customWorldProfile);
|
||||
if (customWorldProfile) {
|
||||
const profileId = readString(customWorldProfile.id);
|
||||
const worldTitle =
|
||||
readString(customWorldProfile.name) ||
|
||||
readString(customWorldProfile.title);
|
||||
if (profileId || worldTitle) {
|
||||
return {
|
||||
worldKey: profileId ? `custom:${profileId}` : `custom:${worldTitle}`,
|
||||
ownerUserId: null,
|
||||
profileId: profileId || null,
|
||||
worldType: 'CUSTOM',
|
||||
worldTitle: worldTitle || '自定义世界',
|
||||
worldSubtitle:
|
||||
readString(customWorldProfile.summary) ||
|
||||
readString(customWorldProfile.settingText),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const worldType = readString(gameState.worldType);
|
||||
if (!worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentScenePreset = asRecord(gameState.currentScenePreset);
|
||||
const worldTitle =
|
||||
readString(currentScenePreset?.name) || buildBuiltinWorldTitle(worldType);
|
||||
|
||||
return {
|
||||
worldKey: `builtin:${worldType}`,
|
||||
ownerUserId: null,
|
||||
profileId: null,
|
||||
worldType,
|
||||
worldTitle,
|
||||
worldSubtitle:
|
||||
readString(currentScenePreset?.summary) ||
|
||||
readString(currentScenePreset?.description),
|
||||
};
|
||||
}
|
||||
|
||||
function toProfilePlayedWorkSummary(
|
||||
row: ProfilePlayedWorldRow,
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: row.worldKey,
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldType: row.worldType,
|
||||
worldTitle: row.worldTitle,
|
||||
worldSubtitle: row.worldSubtitle,
|
||||
firstPlayedAt: row.firstPlayedAt,
|
||||
lastPlayedAt: row.lastPlayedAt,
|
||||
lastObservedPlayTimeMs: normalizeDashboardNumber(
|
||||
row.lastObservedPlayTimeMs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
private async findCustomWorldProfileEntry(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
) {
|
||||
private async findCustomWorldProfileEntry(userId: string, profileId: string) {
|
||||
const result = await this.db.query<CustomWorldEntryRow>(
|
||||
`SELECT user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
@@ -224,6 +436,164 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
return row ? toCustomWorldLibraryEntry(row) : null;
|
||||
}
|
||||
|
||||
private async getProfileDashboardState(userId: string) {
|
||||
const result = await this.db.query<ProfileDashboardStateRow>(
|
||||
`SELECT wallet_balance AS "walletBalance",
|
||||
total_play_time_ms AS "totalPlayTimeMs",
|
||||
updated_at AS "updatedAt"
|
||||
FROM profile_dashboard_state
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findProfilePlayedWorld(userId: string, worldKey: string) {
|
||||
const result = await this.db.query<ProfilePlayedWorldRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_title AS "worldTitle",
|
||||
world_subtitle AS "worldSubtitle",
|
||||
first_played_at AS "firstPlayedAt",
|
||||
last_played_at AS "lastPlayedAt",
|
||||
last_observed_play_time_ms AS "lastObservedPlayTimeMs"
|
||||
FROM profile_played_worlds
|
||||
WHERE user_id = $1
|
||||
AND world_key = $2`,
|
||||
[userId, worldKey],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async upsertProfileDashboardState(
|
||||
userId: string,
|
||||
state: {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number;
|
||||
updatedAt: string;
|
||||
},
|
||||
) {
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_dashboard_state (
|
||||
user_id,
|
||||
wallet_balance,
|
||||
total_play_time_ms,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
wallet_balance = EXCLUDED.wallet_balance,
|
||||
total_play_time_ms = EXCLUDED.total_play_time_ms,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[userId, state.walletBalance, state.totalPlayTimeMs, state.updatedAt],
|
||||
);
|
||||
}
|
||||
|
||||
private async syncProfileDashboardFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
) {
|
||||
const state = (await this.getProfileDashboardState(userId)) ?? {
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
updatedAt: snapshot.savedAt,
|
||||
};
|
||||
const syncedAt = snapshot.savedAt || new Date().toISOString();
|
||||
const gameState = asRecord(snapshot.gameState);
|
||||
const nextWalletBalance = normalizeDashboardNumber(
|
||||
gameState?.playerCurrency,
|
||||
);
|
||||
let nextTotalPlayTimeMs = normalizeDashboardNumber(state.totalPlayTimeMs);
|
||||
|
||||
if (nextWalletBalance !== state.walletBalance) {
|
||||
const amountDelta = nextWalletBalance - state.walletBalance;
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_wallet_ledger (
|
||||
id,
|
||||
user_id,
|
||||
amount_delta,
|
||||
balance_after,
|
||||
source_type,
|
||||
source_key,
|
||||
created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id, source_key) DO NOTHING`,
|
||||
[
|
||||
randomUUID(),
|
||||
userId,
|
||||
amountDelta,
|
||||
nextWalletBalance,
|
||||
'snapshot_sync',
|
||||
`snapshot:${syncedAt}:wallet:${nextWalletBalance}`,
|
||||
syncedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const worldMeta = resolveProfileWorldSnapshotMeta(snapshot);
|
||||
if (worldMeta) {
|
||||
const currentPlayTimeMs = normalizeDashboardNumber(
|
||||
asRecord(gameState?.runtimeStats)?.playTimeMs,
|
||||
);
|
||||
const currentWorld = await this.findProfilePlayedWorld(
|
||||
userId,
|
||||
worldMeta.worldKey,
|
||||
);
|
||||
const incrementalPlayTimeMs = Math.max(
|
||||
0,
|
||||
currentPlayTimeMs -
|
||||
normalizeDashboardNumber(currentWorld?.lastObservedPlayTimeMs ?? 0),
|
||||
);
|
||||
|
||||
nextTotalPlayTimeMs += incrementalPlayTimeMs;
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_played_worlds (
|
||||
user_id,
|
||||
world_key,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_type,
|
||||
world_title,
|
||||
world_subtitle,
|
||||
first_played_at,
|
||||
last_played_at,
|
||||
last_observed_play_time_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $9)
|
||||
ON CONFLICT (user_id, world_key) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
world_type = EXCLUDED.world_type,
|
||||
world_title = EXCLUDED.world_title,
|
||||
world_subtitle = EXCLUDED.world_subtitle,
|
||||
last_played_at = EXCLUDED.last_played_at,
|
||||
last_observed_play_time_ms = GREATEST(
|
||||
profile_played_worlds.last_observed_play_time_ms,
|
||||
EXCLUDED.last_observed_play_time_ms
|
||||
)`,
|
||||
[
|
||||
userId,
|
||||
worldMeta.worldKey,
|
||||
worldMeta.ownerUserId,
|
||||
worldMeta.profileId,
|
||||
worldMeta.worldType,
|
||||
worldMeta.worldTitle,
|
||||
worldMeta.worldSubtitle,
|
||||
syncedAt,
|
||||
currentPlayTimeMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await this.upsertProfileDashboardState(userId, {
|
||||
walletBalance: nextWalletBalance,
|
||||
totalPlayTimeMs: nextTotalPlayTimeMs,
|
||||
updatedAt: syncedAt,
|
||||
});
|
||||
}
|
||||
|
||||
async getSnapshot(userId: string) {
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`SELECT version,
|
||||
@@ -288,18 +658,89 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
return {
|
||||
const persistedSnapshot = {
|
||||
version: row.version,
|
||||
savedAt: row.savedAt,
|
||||
gameState: row.gameState,
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
|
||||
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
|
||||
|
||||
return persistedSnapshot;
|
||||
}
|
||||
|
||||
async getProfileDashboard(userId: string) {
|
||||
const state = await this.getProfileDashboardState(userId);
|
||||
const playedWorldsResult = await this.db.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM profile_played_worlds
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return {
|
||||
walletBalance: normalizeDashboardNumber(state?.walletBalance ?? 0),
|
||||
totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0),
|
||||
playedWorldCount:
|
||||
Number.parseInt(playedWorldsResult.rows[0]?.count ?? '0', 10) || 0,
|
||||
updatedAt: state?.updatedAt ?? null,
|
||||
} satisfies ProfileDashboardSummary;
|
||||
}
|
||||
|
||||
async listProfileWalletLedger(userId: string) {
|
||||
const result = await this.db.query<ProfileWalletLedgerRow>(
|
||||
`SELECT id,
|
||||
amount_delta AS "amountDelta",
|
||||
balance_after AS "balanceAfter",
|
||||
source_type AS "sourceType",
|
||||
created_at AS "createdAt"
|
||||
FROM profile_wallet_ledger
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
amountDelta: row.amountDelta,
|
||||
balanceAfter: row.balanceAfter,
|
||||
sourceType: row.sourceType,
|
||||
createdAt: row.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getProfilePlayStats(userId: string) {
|
||||
const state = await this.getProfileDashboardState(userId);
|
||||
const result = await this.db.query<ProfilePlayedWorldRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_title AS "worldTitle",
|
||||
world_subtitle AS "worldSubtitle",
|
||||
first_played_at AS "firstPlayedAt",
|
||||
last_played_at AS "lastPlayedAt",
|
||||
last_observed_play_time_ms AS "lastObservedPlayTimeMs"
|
||||
FROM profile_played_worlds
|
||||
WHERE user_id = $1
|
||||
ORDER BY last_played_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return {
|
||||
totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0),
|
||||
playedWorks: result.rows.map((row) => toProfilePlayedWorkSummary(row)),
|
||||
updatedAt: state?.updatedAt ?? null,
|
||||
} satisfies ProfilePlayStatsResponse;
|
||||
}
|
||||
|
||||
async deleteSnapshot(userId: string) {
|
||||
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [userId]);
|
||||
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
async getSettings(userId: string) {
|
||||
@@ -339,6 +780,95 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
async listPlatformBrowseHistory(userId: string) {
|
||||
const result = await this.db.query<PlatformBrowseHistoryRow>(
|
||||
`SELECT owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_name AS "worldName",
|
||||
subtitle,
|
||||
summary_text AS "summaryText",
|
||||
cover_image_src AS "coverImageSrc",
|
||||
theme_mode AS "themeMode",
|
||||
author_display_name AS "authorDisplayName",
|
||||
visited_at AS "visitedAt"
|
||||
FROM user_browse_history
|
||||
WHERE user_id = $1
|
||||
ORDER BY visited_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => toPlatformBrowseHistoryEntry(row));
|
||||
}
|
||||
|
||||
async upsertPlatformBrowseHistoryEntries(
|
||||
userId: string,
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
) {
|
||||
const dedupedEntries = [
|
||||
...new Map(
|
||||
entries
|
||||
.map((entry) => normalizePlatformBrowseHistoryWriteEntry(entry))
|
||||
.filter((entry): entry is PlatformBrowseHistoryEntry =>
|
||||
Boolean(entry),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.visitedAt).getTime() -
|
||||
new Date(left.visitedAt).getTime(),
|
||||
)
|
||||
.map(
|
||||
(entry) =>
|
||||
[`${entry.ownerUserId}:${entry.profileId}`, entry] as const,
|
||||
),
|
||||
).values(),
|
||||
];
|
||||
|
||||
for (const entry of dedupedEntries) {
|
||||
await this.db.query(
|
||||
`INSERT INTO user_browse_history (
|
||||
user_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_name,
|
||||
subtitle,
|
||||
summary_text,
|
||||
cover_image_src,
|
||||
theme_mode,
|
||||
author_display_name,
|
||||
visited_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (user_id, owner_user_id, profile_id) DO UPDATE SET
|
||||
world_name = EXCLUDED.world_name,
|
||||
subtitle = EXCLUDED.subtitle,
|
||||
summary_text = EXCLUDED.summary_text,
|
||||
cover_image_src = EXCLUDED.cover_image_src,
|
||||
theme_mode = EXCLUDED.theme_mode,
|
||||
author_display_name = EXCLUDED.author_display_name,
|
||||
visited_at = EXCLUDED.visited_at`,
|
||||
[
|
||||
userId,
|
||||
entry.ownerUserId,
|
||||
entry.profileId,
|
||||
entry.worldName,
|
||||
entry.subtitle,
|
||||
entry.summaryText,
|
||||
entry.coverImageSrc,
|
||||
entry.themeMode,
|
||||
entry.authorDisplayName,
|
||||
entry.visitedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return this.listPlatformBrowseHistory(userId);
|
||||
}
|
||||
|
||||
async clearPlatformBrowseHistory(userId: string) {
|
||||
await this.db.query(`DELETE FROM user_browse_history WHERE user_id = $1`, [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
async listCustomWorldProfiles(userId: string) {
|
||||
const result = await this.db.query<CustomWorldEntryRow>(
|
||||
`SELECT user_id AS "ownerUserId",
|
||||
@@ -514,7 +1044,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId);
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(
|
||||
userId,
|
||||
profileId,
|
||||
);
|
||||
if (!existingEntry) {
|
||||
return null;
|
||||
}
|
||||
@@ -569,7 +1102,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId);
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(
|
||||
userId,
|
||||
profileId,
|
||||
);
|
||||
if (!existingEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ import type {
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshotInput,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
@@ -52,10 +58,10 @@ import {
|
||||
npcChatDialogueRequestSchema,
|
||||
npcRecruitDialogueRequestSchema,
|
||||
} from '../services/chatService.js';
|
||||
import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js';
|
||||
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
|
||||
import {
|
||||
listCustomWorldWorkSummaries,
|
||||
} from '../services/customWorldWorkSummaryService.js';
|
||||
import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGenerationService.js';
|
||||
import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js';
|
||||
import { generateQuestForNpcEncounter } from '../services/questService.js';
|
||||
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
|
||||
import {
|
||||
@@ -82,10 +88,36 @@ const settingsSchema = z.object({
|
||||
musicVolume: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
const platformBrowseHistoryEntrySchema = z.object({
|
||||
ownerUserId: z.string().trim().min(1),
|
||||
profileId: z.string().trim().min(1),
|
||||
worldName: z.string().trim().min(1),
|
||||
subtitle: z.string().trim().optional().default(''),
|
||||
summaryText: z.string().trim().optional().default(''),
|
||||
coverImageSrc: z.string().trim().nullable().optional().default(null),
|
||||
themeMode: z.string().trim().optional().default('mythic'),
|
||||
authorDisplayName: z.string().trim().optional().default('玩家'),
|
||||
visitedAt: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const platformBrowseHistoryBatchSchema = z.object({
|
||||
entries: z.array(platformBrowseHistoryEntrySchema).max(100),
|
||||
});
|
||||
|
||||
const customWorldProfileSchema = z.object({
|
||||
profile: jsonObjectSchema,
|
||||
});
|
||||
|
||||
const customWorldSceneNpcSchema = z.object({
|
||||
profile: jsonObjectSchema,
|
||||
landmarkId: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const customWorldEntitySchema = z.object({
|
||||
profile: jsonObjectSchema,
|
||||
kind: z.enum(['playable', 'story', 'landmark']),
|
||||
});
|
||||
|
||||
const customWorldSessionSchema = z.object({
|
||||
settingText: z.string().trim().min(1),
|
||||
creatorIntent: jsonObjectSchema.nullable().optional().default(null),
|
||||
@@ -125,6 +157,29 @@ async function resolveAuthDisplayName(context: AppContext, userId: string) {
|
||||
export function createRuntimeRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
const routeCompatPaths = (path: string) => [
|
||||
path,
|
||||
`/runtime${path}`,
|
||||
] as const;
|
||||
const handleCustomWorldEntityGeneration = asyncHandler(async (request, response) => {
|
||||
const payload = customWorldEntitySchema.parse(request.body) as {
|
||||
profile: Record<string, unknown>;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
};
|
||||
sendApiResponse(
|
||||
response,
|
||||
await generateCustomWorldEntity(context.llmClient, payload),
|
||||
);
|
||||
});
|
||||
const handleCustomWorldSceneNpcGeneration = asyncHandler(async (request, response) => {
|
||||
const payload = customWorldSceneNpcSchema.parse(request.body) as {
|
||||
profile: Record<string, unknown>;
|
||||
landmarkId: string;
|
||||
};
|
||||
sendApiResponse(response, {
|
||||
npc: await generateSceneNpcForLandmark(context.llmClient, payload),
|
||||
});
|
||||
});
|
||||
|
||||
router.use(requireAuth);
|
||||
router.use(
|
||||
@@ -132,6 +187,129 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
createCustomWorldAgentRoutes(context),
|
||||
);
|
||||
|
||||
routeCompatPaths('/profile/dashboard').forEach((path, index) => {
|
||||
router.get(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.dashboard.get'
|
||||
: 'profile.dashboard.get.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfileDashboardSummary>(
|
||||
response,
|
||||
await context.runtimeRepository.getProfileDashboard(request.userId!),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/profile/wallet-ledger').forEach((path, index) => {
|
||||
router.get(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.walletLedger.list'
|
||||
: 'profile.walletLedger.list.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfileWalletLedgerResponse>(response, {
|
||||
entries: await context.runtimeRepository.listProfileWalletLedger(
|
||||
request.userId!,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/profile/play-stats').forEach((path, index) => {
|
||||
router.get(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.playStats.get'
|
||||
: 'profile.playStats.get.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfilePlayStatsResponse>(
|
||||
response,
|
||||
await context.runtimeRepository.getProfilePlayStats(request.userId!),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/profile/browse-history').forEach((path, index) => {
|
||||
router.get(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.browseHistory.list'
|
||||
: 'profile.browseHistory.list.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
|
||||
entries: await context.runtimeRepository.listPlatformBrowseHistory(
|
||||
request.userId!,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.browseHistory.upsert'
|
||||
: 'profile.browseHistory.upsert.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
const rawBody =
|
||||
request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const payload = (
|
||||
'entries' in rawBody
|
||||
? platformBrowseHistoryBatchSchema.parse(rawBody)
|
||||
: platformBrowseHistoryEntrySchema.parse(rawBody)
|
||||
) as
|
||||
| PlatformBrowseHistoryBatchSyncRequest
|
||||
| PlatformBrowseHistoryWriteEntry;
|
||||
|
||||
const entries = 'entries' in payload ? payload.entries : [payload];
|
||||
|
||||
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
|
||||
entries:
|
||||
await context.runtimeRepository.upsertPlatformBrowseHistoryEntries(
|
||||
request.userId!,
|
||||
entries,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.browseHistory.clear'
|
||||
: 'profile.browseHistory.clear.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
await context.runtimeRepository.clearPlatformBrowseHistory(
|
||||
request.userId!,
|
||||
);
|
||||
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
|
||||
entries: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/llm/chat/completions',
|
||||
routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }),
|
||||
@@ -150,6 +328,30 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/entity',
|
||||
routeMeta({ operation: 'runtime.customWorld.entity' }),
|
||||
handleCustomWorldEntityGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/entity',
|
||||
routeMeta({ operation: 'runtime.customWorld.entity.compat' }),
|
||||
handleCustomWorldEntityGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/scene-npc',
|
||||
routeMeta({ operation: 'runtime.customWorld.sceneNpc' }),
|
||||
handleCustomWorldSceneNpcGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/scene-npc',
|
||||
routeMeta({ operation: 'runtime.customWorld.sceneNpc.compat' }),
|
||||
handleCustomWorldSceneNpcGeneration,
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/save/snapshot',
|
||||
routeMeta({ operation: 'runtime.snapshot.get' }),
|
||||
@@ -237,14 +439,11 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
'/runtime/custom-world-library',
|
||||
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse(
|
||||
response,
|
||||
{
|
||||
entries: await context.runtimeRepository.listCustomWorldProfiles(
|
||||
request.userId!,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse,
|
||||
);
|
||||
sendApiResponse(response, {
|
||||
entries: await context.runtimeRepository.listCustomWorldProfiles(
|
||||
request.userId!,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -252,12 +451,10 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
'/runtime/custom-world-gallery',
|
||||
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
|
||||
asyncHandler(async (_request, response) => {
|
||||
sendApiResponse(
|
||||
response,
|
||||
{
|
||||
entries: await context.runtimeRepository.listPublishedCustomWorldGallery(),
|
||||
} satisfies CustomWorldGalleryResponse,
|
||||
);
|
||||
sendApiResponse(response, {
|
||||
entries:
|
||||
await context.runtimeRepository.listPublishedCustomWorldGallery(),
|
||||
} satisfies CustomWorldGalleryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -280,12 +477,9 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
throw notFound('public custom world not found');
|
||||
}
|
||||
|
||||
sendApiResponse(
|
||||
response,
|
||||
{
|
||||
entry,
|
||||
} satisfies CustomWorldGalleryDetailResponse,
|
||||
);
|
||||
sendApiResponse(response, {
|
||||
entry,
|
||||
} satisfies CustomWorldGalleryDetailResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -322,15 +516,12 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
sendApiResponse(
|
||||
response,
|
||||
{
|
||||
entries: await context.runtimeRepository.deleteCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse,
|
||||
);
|
||||
sendApiResponse(response, {
|
||||
entries: await context.runtimeRepository.deleteCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
1050
server-node/src/services/customWorldEntityGenerationService.ts
Normal file
586
server-node/src/services/customWorldSceneNpcGenerationService.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
type SceneNpcGenerationInput = {
|
||||
profile: Record<string, unknown>;
|
||||
landmarkId: string;
|
||||
};
|
||||
|
||||
type ParsedStoryNpc = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type ParsedLandmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
sceneNpcIds: string[];
|
||||
};
|
||||
|
||||
type ParsedProfile = {
|
||||
name: string;
|
||||
settingText: string;
|
||||
storyNpcs: ParsedStoryNpc[];
|
||||
landmarks: ParsedLandmark[];
|
||||
};
|
||||
|
||||
type GeneratedNpcDraft = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
publicSummary: string;
|
||||
chapterTeasers: string[];
|
||||
chapterContents: string[];
|
||||
skills: Array<{
|
||||
name: string;
|
||||
summary: string;
|
||||
style: string;
|
||||
}>;
|
||||
initialItems: Array<{
|
||||
name: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
description: string;
|
||||
tags: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function toText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, maxCount = 12) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, maxCount);
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.replace(/\s+/gu, ' ').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '');
|
||||
|
||||
return normalized || 'entry';
|
||||
}
|
||||
|
||||
function createStableId(prefix: string, label: string, seed: string) {
|
||||
return `${prefix}-${slugify(label || prefix)}-${seed}`;
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[], maxCount = 8) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].slice(
|
||||
0,
|
||||
maxCount,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStoryNpc(value: unknown): ParsedStoryNpc | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = toText(record.id);
|
||||
const name = toText(record.name);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = toText(record.title);
|
||||
const role = toText(record.role, title || '场景角色');
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: title || role || '场景角色',
|
||||
role,
|
||||
description: toText(record.description),
|
||||
personality: toText(record.personality),
|
||||
motivation: toText(record.motivation),
|
||||
relationshipHooks: toStringArray(record.relationshipHooks, 6),
|
||||
tags: toStringArray(record.tags, 8),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLandmark(value: unknown): ParsedLandmark | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = toText(record.id);
|
||||
const name = toText(record.name);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: toText(record.description),
|
||||
dangerLevel: toText(record.dangerLevel, '中'),
|
||||
sceneNpcIds: toStringArray(record.sceneNpcIds, 12),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown): ParsedProfile {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
throw badRequest('profile is required');
|
||||
}
|
||||
|
||||
const storyNpcs = Array.isArray(record.storyNpcs)
|
||||
? record.storyNpcs.map(normalizeStoryNpc).filter((item): item is ParsedStoryNpc => item !== null)
|
||||
: [];
|
||||
const landmarks = Array.isArray(record.landmarks)
|
||||
? record.landmarks.map(normalizeLandmark).filter((item): item is ParsedLandmark => item !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: toText(record.name, '自定义世界'),
|
||||
settingText: toText(record.settingText),
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureUniqueName(name: string, existingNames: string[]) {
|
||||
const normalizedName = name.trim() || '新场景角色';
|
||||
if (!existingNames.includes(normalizedName)) {
|
||||
return normalizedName;
|
||||
}
|
||||
|
||||
let index = 2;
|
||||
let nextName = `${normalizedName}${index}`;
|
||||
while (existingNames.includes(nextName)) {
|
||||
index += 1;
|
||||
nextName = `${normalizedName}${index}`;
|
||||
}
|
||||
return nextName;
|
||||
}
|
||||
|
||||
function buildFallbackDraft(
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
sceneNpcs: ParsedStoryNpc[],
|
||||
): GeneratedNpcDraft {
|
||||
const tags = dedupeStrings([
|
||||
landmark.name,
|
||||
landmark.dangerLevel,
|
||||
...sceneNpcs.flatMap((npc) => npc.tags),
|
||||
], 4);
|
||||
|
||||
return {
|
||||
name: `${landmark.name}来客`,
|
||||
title: `${landmark.name}的观察者`,
|
||||
role: `${landmark.name}的观察者`,
|
||||
description: `长期活动于${landmark.name},熟悉这里的局势与暗线,能为玩家提供新的观察角度。`,
|
||||
backstory: `他在${landmark.name}扎根已久,对这片区域的危险节奏、人物流动与隐藏冲突有自己的判断。`,
|
||||
personality: '谨慎、敏锐,先观察再表态。',
|
||||
motivation: `希望借玩家之手改变${landmark.name}当前逐渐失衡的局面。`,
|
||||
combatStyle: '偏向控场与试探,不轻易暴露底牌。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: dedupeStrings([
|
||||
`与${landmark.name}局势深度绑定`,
|
||||
sceneNpcs[0] ? `对${sceneNpcs[0].name}保持长期观察` : '对玩家保持试探',
|
||||
'愿意交换情报,但保留关键秘密',
|
||||
], 3),
|
||||
tags,
|
||||
publicSummary: `一名活跃于${landmark.name}的关键观察者。`,
|
||||
chapterTeasers: [
|
||||
'他知道这片区域最近正在发生什么。',
|
||||
'他与此地某个旧事件有直接牵连。',
|
||||
'他真正想推动的局面并不只是自保。',
|
||||
'他手里握有改变关系网的最后筹码。',
|
||||
],
|
||||
chapterContents: [
|
||||
`他常年在${landmark.name}周边活动,对人和事的变化极为敏感。`,
|
||||
`多年前的一次变故把他和${landmark.name}牢牢绑在了一起。`,
|
||||
`他表面克制,实际上一直在寻找扭转局面的机会。`,
|
||||
'他保留着一张只会在局势逼近临界点时才动用的底牌。',
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
name: '试探起手',
|
||||
summary: '以低风险方式摸清对手意图。',
|
||||
style: '试探压制',
|
||||
},
|
||||
{
|
||||
name: '地形借势',
|
||||
summary: `借助${landmark.name}环境制造主动权。`,
|
||||
style: '环境协同',
|
||||
},
|
||||
{
|
||||
name: '暗线反制',
|
||||
summary: '在关键回合揭示隐藏准备,打乱对方节奏。',
|
||||
style: '后手翻盘',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
name: '随身兵装',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '常备的近身防护装备。',
|
||||
tags: ['自定义', landmark.name],
|
||||
},
|
||||
{
|
||||
name: '区域通行物',
|
||||
category: '道具',
|
||||
quantity: 1,
|
||||
rarity: 'uncommon',
|
||||
description: `能在${landmark.name}一带快速周转的私人物件。`,
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
name: '情报残页',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '记录着部分隐藏线索与往事片段。',
|
||||
tags: ['线索'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompt(
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
sceneNpcs: ParsedStoryNpc[],
|
||||
otherNpcs: ParsedStoryNpc[],
|
||||
) {
|
||||
const sceneNpcSummary = sceneNpcs.length
|
||||
? sceneNpcs
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '当前场景还没有已加入 NPC。';
|
||||
|
||||
const reserveNpcSummary = otherNpcs.length
|
||||
? otherNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '暂无其他场景角色参考。';
|
||||
|
||||
const landmarkSummary = profile.landmarks
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(entry, index) =>
|
||||
`${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`世界设定:${profile.settingText || '未提供额外设定文本。'}`,
|
||||
`当前目标场景:${landmark.name}`,
|
||||
`场景描述:${landmark.description || '未填写'}`,
|
||||
`危险度:${landmark.dangerLevel || '中'}`,
|
||||
`当前场景已加入 NPC:\n${sceneNpcSummary}`,
|
||||
`其他可参考 NPC:\n${reserveNpcSummary}`,
|
||||
`世界内其他场景概览:\n${landmarkSummary}`,
|
||||
'请生成 1 名适合加入当前场景的新 NPC。',
|
||||
'要求:',
|
||||
'- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。',
|
||||
'- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。',
|
||||
'- 关系钩子、技能、初始物品都要可直接进入编辑器。',
|
||||
'- 返回 JSON,不要额外解释。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "npc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "头衔",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句角色描述",',
|
||||
' "backstory": "背景",',
|
||||
' "personality": "性格",',
|
||||
' "motivation": "动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 6,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sanitizeGeneratedNpc(
|
||||
rawValue: unknown,
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
fallbackDraft: GeneratedNpcDraft,
|
||||
) {
|
||||
const record = toRecord(rawValue);
|
||||
const existingNames = profile.storyNpcs.map((npc) => npc.name);
|
||||
const seed = Date.now().toString(36);
|
||||
const chapterTitles = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌'];
|
||||
const chapterThresholds = [6, 12, 18, 24];
|
||||
const relationshipHooks = dedupeStrings(
|
||||
toStringArray(record?.relationshipHooks, 6).concat(
|
||||
fallbackDraft.relationshipHooks,
|
||||
),
|
||||
4,
|
||||
);
|
||||
const tags = dedupeStrings(
|
||||
toStringArray(record?.tags, 8).concat(fallbackDraft.tags, landmark.name),
|
||||
6,
|
||||
);
|
||||
const chapterTeasers = toStringArray(record?.chapterTeasers, 4);
|
||||
const chapterContents = toStringArray(record?.chapterContents, 4);
|
||||
const skillRecords = Array.isArray(record?.skills) ? record?.skills : [];
|
||||
const itemRecords = Array.isArray(record?.initialItems) ? record?.initialItems : [];
|
||||
|
||||
const draft: GeneratedNpcDraft = {
|
||||
name: ensureUniqueName(
|
||||
toText(record?.name, fallbackDraft.name),
|
||||
existingNames,
|
||||
),
|
||||
title: toText(record?.title, fallbackDraft.title),
|
||||
role: toText(record?.role, toText(record?.title, fallbackDraft.role)),
|
||||
description: clampText(
|
||||
toText(record?.description, fallbackDraft.description),
|
||||
120,
|
||||
),
|
||||
backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260),
|
||||
personality: clampText(
|
||||
toText(record?.personality, fallbackDraft.personality),
|
||||
100,
|
||||
),
|
||||
motivation: clampText(
|
||||
toText(record?.motivation, fallbackDraft.motivation),
|
||||
120,
|
||||
),
|
||||
combatStyle: clampText(
|
||||
toText(record?.combatStyle, fallbackDraft.combatStyle),
|
||||
100,
|
||||
),
|
||||
initialAffinity:
|
||||
typeof record?.initialAffinity === 'number' &&
|
||||
Number.isFinite(record.initialAffinity)
|
||||
? Math.min(12, Math.max(1, Math.round(record.initialAffinity)))
|
||||
: fallbackDraft.initialAffinity,
|
||||
relationshipHooks,
|
||||
tags,
|
||||
publicSummary: clampText(
|
||||
toText(record?.publicSummary, fallbackDraft.publicSummary),
|
||||
120,
|
||||
),
|
||||
chapterTeasers:
|
||||
chapterTeasers.length === 4
|
||||
? chapterTeasers
|
||||
: fallbackDraft.chapterTeasers.slice(0, 4),
|
||||
chapterContents:
|
||||
chapterContents.length === 4
|
||||
? chapterContents
|
||||
: fallbackDraft.chapterContents.slice(0, 4),
|
||||
skills:
|
||||
skillRecords.length >= 3
|
||||
? skillRecords.slice(0, 3).map((skill, index) => {
|
||||
const skillRecord = toRecord(skill);
|
||||
const fallbackSkill =
|
||||
fallbackDraft.skills[index] ?? fallbackDraft.skills[0];
|
||||
return {
|
||||
name: clampText(
|
||||
toText(skillRecord?.name, fallbackSkill?.name || `技能${index + 1}`),
|
||||
20,
|
||||
),
|
||||
summary: clampText(
|
||||
toText(skillRecord?.summary, fallbackSkill?.summary || ''),
|
||||
60,
|
||||
),
|
||||
style: clampText(
|
||||
toText(skillRecord?.style, fallbackSkill?.style || ''),
|
||||
20,
|
||||
),
|
||||
};
|
||||
})
|
||||
: fallbackDraft.skills,
|
||||
initialItems:
|
||||
itemRecords.length >= 3
|
||||
? itemRecords.slice(0, 3).map((item, index) => {
|
||||
const itemRecord = toRecord(item);
|
||||
const fallbackItem =
|
||||
fallbackDraft.initialItems[index] ?? fallbackDraft.initialItems[0];
|
||||
const rarity = toText(itemRecord?.rarity, fallbackItem?.rarity || 'rare');
|
||||
return {
|
||||
name: clampText(
|
||||
toText(itemRecord?.name, fallbackItem?.name || `物品${index + 1}`),
|
||||
20,
|
||||
),
|
||||
category: clampText(
|
||||
toText(itemRecord?.category, fallbackItem?.category || '道具'),
|
||||
16,
|
||||
),
|
||||
quantity:
|
||||
typeof itemRecord?.quantity === 'number' &&
|
||||
Number.isFinite(itemRecord.quantity)
|
||||
? Math.min(9, Math.max(1, Math.round(itemRecord.quantity)))
|
||||
: fallbackItem?.quantity || 1,
|
||||
rarity:
|
||||
rarity === 'common' ||
|
||||
rarity === 'uncommon' ||
|
||||
rarity === 'rare' ||
|
||||
rarity === 'epic' ||
|
||||
rarity === 'legendary'
|
||||
? rarity
|
||||
: fallbackItem?.rarity || 'rare',
|
||||
description: clampText(
|
||||
toText(itemRecord?.description, fallbackItem?.description || ''),
|
||||
80,
|
||||
),
|
||||
tags: dedupeStrings(
|
||||
toStringArray(itemRecord?.tags, 4).concat(
|
||||
fallbackItem?.tags ?? [],
|
||||
),
|
||||
4,
|
||||
),
|
||||
};
|
||||
})
|
||||
: fallbackDraft.initialItems,
|
||||
};
|
||||
|
||||
return {
|
||||
id: createStableId('story-npc', draft.name, seed),
|
||||
name: draft.name,
|
||||
title: draft.title || draft.role,
|
||||
role: draft.role || draft.title,
|
||||
description: draft.description,
|
||||
backstory: draft.backstory,
|
||||
personality: draft.personality,
|
||||
motivation: draft.motivation,
|
||||
combatStyle: draft.combatStyle,
|
||||
initialAffinity: draft.initialAffinity,
|
||||
relationshipHooks: draft.relationshipHooks,
|
||||
relations: [],
|
||||
tags: draft.tags,
|
||||
backstoryReveal: {
|
||||
publicSummary: draft.publicSummary,
|
||||
chapters: chapterTitles.map((title, index) => ({
|
||||
id: ['surface', 'scar', 'hidden', 'final'][index],
|
||||
title,
|
||||
affinityRequired: chapterThresholds[index],
|
||||
teaser:
|
||||
draft.chapterTeasers[index] ?? fallbackDraft.chapterTeasers[index] ?? '',
|
||||
content:
|
||||
draft.chapterContents[index] ??
|
||||
fallbackDraft.chapterContents[index] ??
|
||||
'',
|
||||
contextSnippet: '',
|
||||
})),
|
||||
},
|
||||
skills: draft.skills.map((skill, index) => ({
|
||||
id: createStableId('skill', `${draft.name}-${skill.name}`, `${seed}-${index + 1}`),
|
||||
name: skill.name,
|
||||
summary: skill.summary,
|
||||
style: skill.style,
|
||||
})),
|
||||
initialItems: draft.initialItems.map((item, index) => ({
|
||||
id: createStableId('item', `${draft.name}-${item.name}`, `${seed}-${index + 1}`),
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
quantity: item.quantity,
|
||||
rarity: item.rarity,
|
||||
description: item.description,
|
||||
tags: item.tags,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneNpcForLandmark(
|
||||
llmClient: UpstreamLlmClient,
|
||||
input: SceneNpcGenerationInput,
|
||||
) {
|
||||
const profile = normalizeProfile(input.profile);
|
||||
const landmark = profile.landmarks.find((entry) => entry.id === input.landmarkId);
|
||||
if (!landmark) {
|
||||
throw badRequest('landmark not found');
|
||||
}
|
||||
|
||||
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
|
||||
const sceneNpcs = landmark.sceneNpcIds
|
||||
.map((npcId) => storyNpcById.get(npcId))
|
||||
.filter((npc): npc is ParsedStoryNpc => Boolean(npc));
|
||||
const otherNpcs = profile.storyNpcs.filter(
|
||||
(npc) => !landmark.sceneNpcIds.includes(npc.id),
|
||||
);
|
||||
const fallbackDraft = buildFallbackDraft(profile, landmark, sceneNpcs);
|
||||
|
||||
try {
|
||||
const content = await llmClient.requestMessageContent({
|
||||
systemPrompt:
|
||||
'你是游戏世界编辑器的角色生成器。你必须只返回可解析 JSON,不要输出解释、前言或 markdown 代码块之外的额外内容。',
|
||||
userPrompt: buildPrompt(profile, landmark, sceneNpcs, otherNpcs),
|
||||
debugLabel: 'custom-world-scene-npc',
|
||||
});
|
||||
const parsed = parseJsonResponseText(content);
|
||||
const parsedRecord = toRecord(parsed);
|
||||
const npcRecord = parsedRecord?.npc ?? parsed;
|
||||
return sanitizeGeneratedNpc(npcRecord, profile, landmark, fallbackDraft);
|
||||
} catch {
|
||||
return sanitizeGeneratedNpc(fallbackDraft, profile, landmark, fallbackDraft);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AppContext } from '../context.js';
|
||||
import { type AppConfig } from '../config.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import { generateSceneImage } from './sceneImageService.js';
|
||||
|
||||
const PNG_BUFFER = Buffer.from(
|
||||
@@ -24,7 +24,7 @@ function createTestConfig(
|
||||
dashScope: {
|
||||
baseUrl: dashScopeBaseUrl,
|
||||
apiKey: 'test-dashscope-key',
|
||||
imageModel: 'wan2.7-image',
|
||||
imageModel: 'wan2.2-t2i-flash',
|
||||
requestTimeoutMs: 5_000,
|
||||
},
|
||||
} as AppConfig;
|
||||
@@ -92,11 +92,8 @@ async function withHttpServer<T>(
|
||||
}
|
||||
}
|
||||
|
||||
test('generateSceneImage uploads a public reference image as a data url and saves the generated scene', async () => {
|
||||
test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves the generated scene', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
@@ -164,7 +161,6 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
profileId: 'world-1',
|
||||
landmarkName: '旧港灯塔',
|
||||
landmarkId: 'landmark-1',
|
||||
referenceImageSrc: '/scene_bg/reference-layout.png',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
@@ -177,6 +173,7 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
model: string;
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
@@ -188,8 +185,9 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
};
|
||||
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
|
||||
assert.equal(content[0]?.text, '海雾港口像素风场景');
|
||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(content.length, 1);
|
||||
assert.equal(createPayload.parameters.negative_prompt, '模糊');
|
||||
|
||||
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||
@@ -197,3 +195,105 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
bodyText?: string;
|
||||
}> = [];
|
||||
|
||||
await withHttpServer(
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
const bodyText =
|
||||
req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined;
|
||||
capturedRequests.push({
|
||||
pathname: url.pathname,
|
||||
bodyText,
|
||||
});
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/multimodal-generation/generation'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
image: `${baseUrl}/downloads/reference-scene.png`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/reference-scene.png') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(PNG_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const context = {
|
||||
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
|
||||
} as AppContext;
|
||||
|
||||
const result = await generateSceneImage(context, {
|
||||
prompt: '废墟月台像素风场景',
|
||||
negativePrompt: '模糊',
|
||||
size: '1280*720',
|
||||
worldName: '碎轨边境',
|
||||
profileId: 'world-2',
|
||||
landmarkName: '裂轨月台',
|
||||
landmarkId: 'landmark-2',
|
||||
referenceImageSrc: '/scene_bg/reference-layout.png',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.model, 'qwen-image-2.0');
|
||||
assert.match(result.taskId, /^scene-edit-/u);
|
||||
assert.equal(
|
||||
capturedRequests.some(
|
||||
(entry) => entry.pathname === '/api/v1/tasks/scene-task-1',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) =>
|
||||
entry.pathname === '/api/v1/services/aigc/multimodal-generation/generation',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
model: string;
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.equal(createPayload.model, 'qwen-image-2.0');
|
||||
assert.match(content[0]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(content[1]?.text, '废墟月台像素风场景');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ export const sceneImageSchema = z.object({
|
||||
landmarkId: z.string().trim().optional().default(''),
|
||||
referenceImageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
const TEXT_TO_IMAGE_SCENE_MODEL = 'wan2.2-t2i-flash';
|
||||
const REFERENCE_IMAGE_SCENE_MODEL = 'qwen-image-2.0';
|
||||
|
||||
function parseImageDataUrl(source: string) {
|
||||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||||
@@ -122,40 +124,72 @@ function extractImageUrls(payload: Record<string, unknown>) {
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
defaultModel: string,
|
||||
) {
|
||||
if (!payload.landmarkName && !payload.landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
async function createSceneImageTask(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
}) {
|
||||
const { baseUrl, apiKey, payload } = params;
|
||||
const response = await fetch(`${baseUrl}/services/aigc/image-generation/generation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ text: payload.prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: payload.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
...(payload.negativePrompt
|
||||
? { negative_prompt: payload.negativePrompt }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: extractApiErrorMessage(
|
||||
responseText,
|
||||
'创建场景图片生成任务失败',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
model: payload.model || defaultModel,
|
||||
ok: true as const,
|
||||
payload: JSON.parse(responseText) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof sceneImageSchema>,
|
||||
) {
|
||||
const payload = ensurePayload(input, context.config.dashScope.imageModel);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
context.config.projectRoot,
|
||||
payload.referenceImageSrc,
|
||||
)
|
||||
: '';
|
||||
const createResponse = await fetch(
|
||||
`${baseUrl}/services/aigc/image-generation/generation`,
|
||||
async function createSceneImageFromReference(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
referenceImage: string;
|
||||
}) {
|
||||
const { baseUrl, apiKey, payload, referenceImage } = params;
|
||||
const response = await fetch(
|
||||
`${baseUrl}/services/aigc/multimodal-generation/generation`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
@@ -163,10 +197,7 @@ export async function generateSceneImage(
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ text: payload.prompt },
|
||||
...(referenceImage ? [{ image: referenceImage }] : []),
|
||||
],
|
||||
content: [{ image: referenceImage }, { text: payload.prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -182,56 +213,65 @@ export async function generateSceneImage(
|
||||
}),
|
||||
},
|
||||
);
|
||||
const createText = await createResponse.text();
|
||||
if (!createResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(createText, '创建场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const createPayload = JSON.parse(createText) as Record<string, unknown>;
|
||||
const taskId = extractTaskId(createPayload);
|
||||
if (!taskId) {
|
||||
throw badRequest('场景图片生成任务未返回 task_id');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
|
||||
let imageUrl = '';
|
||||
let actualPrompt = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
break;
|
||||
}
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: extractApiErrorMessage(
|
||||
responseText,
|
||||
'创建参考图场景编辑任务失败',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const responsePayload = JSON.parse(responseText) as Record<string, unknown>;
|
||||
const imageUrl = extractImageUrls(responsePayload)[0] ?? '';
|
||||
if (!imageUrl) {
|
||||
throw badRequest('场景图片生成超时或未返回图片地址');
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: '参考图场景编辑未返回图片地址',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
imageUrl,
|
||||
actualPrompt: findFirstStringByKey(responsePayload, 'actual_prompt').trim(),
|
||||
taskId: `scene-edit-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
_defaultModel: string,
|
||||
) {
|
||||
if (!payload.landmarkName && !payload.landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
}
|
||||
|
||||
const referenceImageSrc =
|
||||
typeof payload.referenceImageSrc === 'string'
|
||||
? payload.referenceImageSrc.trim()
|
||||
: '';
|
||||
|
||||
return {
|
||||
...payload,
|
||||
referenceImageSrc,
|
||||
model: referenceImageSrc
|
||||
? REFERENCE_IMAGE_SCENE_MODEL
|
||||
: TEXT_TO_IMAGE_SCENE_MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSceneImageAsset(params: {
|
||||
context: AppContext;
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
imageUrl: string;
|
||||
taskId: string;
|
||||
actualPrompt: string;
|
||||
}) {
|
||||
const { context, payload, imageUrl, taskId, actualPrompt } = params;
|
||||
const imageResponse = await fetch(imageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw badRequest('下载生成图片失败');
|
||||
@@ -295,3 +335,99 @@ export async function generateSceneImage(
|
||||
actualPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof sceneImageSchema>,
|
||||
) {
|
||||
const payload = ensurePayload(input, context.config.dashScope.imageModel);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc.trim()
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
context.config.projectRoot,
|
||||
payload.referenceImageSrc,
|
||||
)
|
||||
: '';
|
||||
|
||||
if (referenceImage) {
|
||||
const referenceResult = await createSceneImageFromReference({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
payload,
|
||||
referenceImage,
|
||||
});
|
||||
|
||||
if (!referenceResult.ok) {
|
||||
throw badRequest(referenceResult.errorMessage);
|
||||
}
|
||||
|
||||
return saveSceneImageAsset({
|
||||
context,
|
||||
payload,
|
||||
imageUrl: referenceResult.imageUrl,
|
||||
taskId: referenceResult.taskId,
|
||||
actualPrompt: referenceResult.actualPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
const createTaskResult = await createSceneImageTask({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!createTaskResult.ok) {
|
||||
throw badRequest(createTaskResult.errorMessage);
|
||||
}
|
||||
|
||||
const createPayload = createTaskResult.payload;
|
||||
const taskId = extractTaskId(createPayload);
|
||||
if (!taskId) {
|
||||
throw badRequest('场景图片生成任务未返回 task_id');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
|
||||
let imageUrl = '';
|
||||
let actualPrompt = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
break;
|
||||
}
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
throw badRequest('场景图片生成超时或未返回图片地址');
|
||||
}
|
||||
|
||||
return saveSceneImageAsset({
|
||||
context,
|
||||
payload,
|
||||
imageUrl,
|
||||
taskId,
|
||||
actualPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
} from '../data/customWorldVisuals';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
@@ -24,6 +25,16 @@ import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
|
||||
type PendingGeneratedEntity = {
|
||||
id: string;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
title: string;
|
||||
progress: number;
|
||||
phaseLabel: string;
|
||||
};
|
||||
|
||||
type RecentGeneratedIds = Record<'playable' | 'story' | 'landmark', string[]>;
|
||||
|
||||
interface CustomWorldEntityCatalogProps {
|
||||
profile: CustomWorldProfile;
|
||||
previewCharacters: Character[];
|
||||
@@ -35,6 +46,9 @@ interface CustomWorldEntityCatalogProps {
|
||||
onDeleteLandmarks?: (ids: string[]) => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
createActionDisabled?: boolean;
|
||||
pendingGeneratedEntity?: PendingGeneratedEntity | null;
|
||||
recentGeneratedIds?: RecentGeneratedIds;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -48,11 +62,13 @@ const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||||
function Section({
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
@@ -72,7 +88,10 @@ function Section({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions}
|
||||
<div className="flex items-center gap-2">
|
||||
{badge}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">{children}</div>
|
||||
</div>
|
||||
@@ -83,10 +102,12 @@ function SmallButton({
|
||||
onClick,
|
||||
children,
|
||||
tone = 'default',
|
||||
disabled = false,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
tone?: 'default' | 'sky' | 'rose';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const toneClassName =
|
||||
tone === 'sky'
|
||||
@@ -99,7 +120,8 @@ function SmallButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName}`}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -161,23 +183,120 @@ function EmptyState({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NewBadge() {
|
||||
return (
|
||||
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
|
||||
新
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingEntityCard({
|
||||
title,
|
||||
phaseLabel,
|
||||
progress,
|
||||
}: {
|
||||
title: string;
|
||||
phaseLabel: string;
|
||||
progress: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-sky-300/18 bg-sky-500/10 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
<div className="mt-1 text-xs leading-6 text-sky-50/90">
|
||||
{phaseLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-sky-300/20 bg-black/20 px-2.5 py-1 text-[10px] text-sky-100">
|
||||
{Math.round(progress)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
|
||||
<div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_100%)] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogCard({
|
||||
title,
|
||||
description,
|
||||
media,
|
||||
badge,
|
||||
isSelectionMode,
|
||||
isSelected,
|
||||
onClick,
|
||||
layout = 'stacked',
|
||||
mediaClassName,
|
||||
disabled = false,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
media: ReactNode;
|
||||
badge?: ReactNode;
|
||||
isSelectionMode: boolean;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
layout?: 'stacked' | 'compact';
|
||||
mediaClassName?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const selectionBadge = isSelectionMode ? (
|
||||
<div
|
||||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||||
isSelected
|
||||
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
|
||||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '已选' : '选择'}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (layout === 'compact') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: disabled
|
||||
? 'border-white/10 bg-black/20'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`shrink-0 overflow-hidden rounded-[1rem] border border-white/8 bg-black/25 ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{badge}
|
||||
{selectionBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm leading-5 text-zinc-300">
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -192,24 +311,19 @@ function CatalogCard({
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25">
|
||||
<div
|
||||
className={`overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25 ${mediaClassName ?? ''}`}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-base font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
{isSelectionMode ? (
|
||||
<div
|
||||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||||
isSelected
|
||||
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
|
||||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '已选' : '选择'}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
{badge}
|
||||
{selectionBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-zinc-300">
|
||||
{description || '暂无描述'}
|
||||
@@ -276,7 +390,7 @@ function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
|
||||
return [
|
||||
{
|
||||
id: 'world-hook',
|
||||
label: '世界核心',
|
||||
label: '世界一句话',
|
||||
value:
|
||||
creatorIntent?.worldHook ||
|
||||
profile.anchorPack?.worldSummary ||
|
||||
@@ -384,8 +498,16 @@ export function CustomWorldEntityCatalog({
|
||||
onDeleteLandmarks,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
createActionDisabled = false,
|
||||
pendingGeneratedEntity = null,
|
||||
recentGeneratedIds = {
|
||||
playable: [],
|
||||
story: [],
|
||||
landmark: [],
|
||||
},
|
||||
readOnly = false,
|
||||
}: CustomWorldEntityCatalogProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(
|
||||
null,
|
||||
@@ -423,6 +545,18 @@ export function CustomWorldEntityCatalog({
|
||||
),
|
||||
[previewCharacters, profile.playableNpcs],
|
||||
);
|
||||
const recentPlayableIdSet = useMemo(
|
||||
() => new Set(recentGeneratedIds.playable),
|
||||
[recentGeneratedIds.playable],
|
||||
);
|
||||
const recentStoryIdSet = useMemo(
|
||||
() => new Set(recentGeneratedIds.story),
|
||||
[recentGeneratedIds.story],
|
||||
);
|
||||
const recentLandmarkIdSet = useMemo(
|
||||
() => new Set(recentGeneratedIds.landmark),
|
||||
[recentGeneratedIds.landmark],
|
||||
);
|
||||
|
||||
const filteredPlayable = useMemo(
|
||||
() =>
|
||||
@@ -460,6 +594,16 @@ export function CustomWorldEntityCatalog({
|
||||
() => buildStructuredFoundationEntries(profile),
|
||||
[profile],
|
||||
);
|
||||
const structuredFoundationSourceText = useMemo(
|
||||
() =>
|
||||
buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() ||
|
||||
profile.settingText.trim(),
|
||||
[profile.creatorIntent, profile.settingText],
|
||||
);
|
||||
const normalizedCreatorIntent = useMemo(
|
||||
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const filteredSceneEntries = useMemo(() => {
|
||||
const openingSceneEntry = {
|
||||
id: 'custom-world-opening-scene',
|
||||
@@ -477,7 +621,13 @@ export function CustomWorldEntityCatalog({
|
||||
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
}));
|
||||
const allEntries = [openingSceneEntry, ...landmarkEntries];
|
||||
const recentEntries = landmarkEntries.filter((entry) =>
|
||||
recentLandmarkIdSet.has(entry.id),
|
||||
);
|
||||
const restEntries = landmarkEntries.filter(
|
||||
(entry) => !recentLandmarkIdSet.has(entry.id),
|
||||
);
|
||||
const allEntries = [...recentEntries, openingSceneEntry, ...restEntries];
|
||||
|
||||
if (!deferredSearch) {
|
||||
return allEntries;
|
||||
@@ -492,32 +642,35 @@ export function CustomWorldEntityCatalog({
|
||||
landmarkById,
|
||||
landmarkImageById,
|
||||
profile,
|
||||
recentLandmarkIdSet,
|
||||
resolvedCampImageSrc,
|
||||
resolvedCampScene,
|
||||
storyNpcById,
|
||||
]);
|
||||
|
||||
const creatorIntentSummary = useMemo(
|
||||
() =>
|
||||
buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const lockedCharacterNames = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
profile.creatorIntent?.keyCharacters
|
||||
normalizedCreatorIntent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean) ?? [],
|
||||
),
|
||||
[profile.creatorIntent],
|
||||
[normalizedCreatorIntent],
|
||||
);
|
||||
|
||||
const counts = {
|
||||
world: 1,
|
||||
playable: profile.playableNpcs.length,
|
||||
story: profile.storyNpcs.length,
|
||||
landmarks: profile.landmarks.length + 1,
|
||||
playable:
|
||||
profile.playableNpcs.length +
|
||||
(pendingGeneratedEntity?.kind === 'playable' ? 1 : 0),
|
||||
story:
|
||||
profile.storyNpcs.length +
|
||||
(pendingGeneratedEntity?.kind === 'story' ? 1 : 0),
|
||||
landmarks:
|
||||
profile.landmarks.length +
|
||||
1 +
|
||||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||||
} satisfies Record<ResultTab, number>;
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
@@ -532,6 +685,16 @@ export function CustomWorldEntityCatalog({
|
||||
}
|
||||
}, [activeTab, bulkDeleteMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
if (typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({ top: 0, behavior: 'auto' });
|
||||
return;
|
||||
}
|
||||
container.scrollTop = 0;
|
||||
}, [activeTab]);
|
||||
|
||||
const removePlayable = (id: string, name: string) => {
|
||||
if (profile.playableNpcs.length <= 1) {
|
||||
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。');
|
||||
@@ -584,7 +747,10 @@ export function CustomWorldEntityCatalog({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide"
|
||||
>
|
||||
<div className="px-1 pb-1 text-center">
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||||
世界档案
|
||||
@@ -638,7 +804,11 @@ export function CustomWorldEntityCatalog({
|
||||
) : (
|
||||
<>
|
||||
{!readOnly && createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">
|
||||
<SmallButton
|
||||
onClick={onCreateAction}
|
||||
tone="sky"
|
||||
disabled={createActionDisabled}
|
||||
>
|
||||
{createActionLabel}
|
||||
</SmallButton>
|
||||
) : null}
|
||||
@@ -662,6 +832,29 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'world' ? (
|
||||
<>
|
||||
<Section title="档案规模">
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.playableNpcs.length}
|
||||
</div>
|
||||
<div>可扮演角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.storyNpcs.length}
|
||||
</div>
|
||||
<div>场景角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.landmarks.length + 1}
|
||||
</div>
|
||||
<div>场景</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
@@ -694,10 +887,29 @@ export function CustomWorldEntityCatalog({
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="原始设定"
|
||||
subtitle="把开局最关键的 6 个原始锚点拆开看,后续精修会更顺。"
|
||||
title="基本设定"
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
) : (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
解析字段
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{structuredFoundationEntries.map((entry) => (
|
||||
<div
|
||||
@@ -713,45 +925,16 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{profile.settingText ? (
|
||||
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-7 text-zinc-200">
|
||||
{profile.settingText}
|
||||
{structuredFoundationSourceText ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
锚点原文
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-200">
|
||||
{structuredFoundationSourceText}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{creatorIntentSummary && creatorIntentSummary !== profile.settingText ? (
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="档案规模"
|
||||
subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.playableNpcs.length}
|
||||
</div>
|
||||
<div>可扮演角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.storyNpcs.length}
|
||||
</div>
|
||||
<div>场景角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.landmarks.length + 1}
|
||||
</div>
|
||||
<div>场景</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-3 text-sm leading-6 text-sky-50/90">
|
||||
自定义世界不再预生成物品档案。进入世界后的交易、掉落和初始装备会按当前世界主题即时生成。
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
@@ -759,6 +942,13 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'playable' ? (
|
||||
<div className="space-y-3">
|
||||
{pendingGeneratedEntity?.kind === 'playable' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
phaseLabel={pendingGeneratedEntity.phaseLabel}
|
||||
progress={pendingGeneratedEntity.progress}
|
||||
/>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{readOnly
|
||||
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
|
||||
@@ -776,6 +966,7 @@ export function CustomWorldEntityCatalog({
|
||||
<Section
|
||||
title={role.name}
|
||||
subtitle={role.title}
|
||||
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
@@ -927,6 +1118,13 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'story' ? (
|
||||
<div className="space-y-3">
|
||||
{pendingGeneratedEntity?.kind === 'story' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
phaseLabel={pendingGeneratedEntity.phaseLabel}
|
||||
progress={pendingGeneratedEntity.progress}
|
||||
/>
|
||||
) : null}
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
) : (
|
||||
@@ -935,8 +1133,11 @@ export function CustomWorldEntityCatalog({
|
||||
<CatalogCard
|
||||
title={npc.name}
|
||||
description={npc.description}
|
||||
badge={recentStoryIdSet.has(npc.id) ? <NewBadge /> : null}
|
||||
isSelectionMode={isBulkDeleteMode}
|
||||
isSelected={selectedBulkIds.includes(npc.id)}
|
||||
layout="compact"
|
||||
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
|
||||
onClick={() =>
|
||||
isBulkDeleteMode
|
||||
? toggleBulkSelected(npc.id)
|
||||
@@ -957,8 +1158,9 @@ export function CustomWorldEntityCatalog({
|
||||
npc={npc}
|
||||
profile={profile}
|
||||
visual={npc.visual}
|
||||
className="aspect-square"
|
||||
scale={2.18}
|
||||
className="h-full w-full"
|
||||
contentClassName="min-h-0 p-2"
|
||||
scale={1.82}
|
||||
preferImageSrc
|
||||
/>
|
||||
}
|
||||
@@ -971,6 +1173,13 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'landmarks' ? (
|
||||
<div className="space-y-3">
|
||||
{pendingGeneratedEntity?.kind === 'landmark' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
phaseLabel={pendingGeneratedEntity.phaseLabel}
|
||||
progress={pendingGeneratedEntity.progress}
|
||||
/>
|
||||
) : null}
|
||||
{filteredSceneEntries.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
@@ -983,6 +1192,11 @@ export function CustomWorldEntityCatalog({
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description
|
||||
}
|
||||
badge={
|
||||
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
|
||||
<NewBadge />
|
||||
) : null
|
||||
}
|
||||
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
|
||||
isSelected={
|
||||
scene.kind === 'landmark' &&
|
||||
@@ -990,7 +1204,7 @@ export function CustomWorldEntityCatalog({
|
||||
}
|
||||
onClick={() =>
|
||||
scene.kind === 'camp'
|
||||
? onEditTarget({ kind: 'world' })
|
||||
? onEditTarget({ kind: 'camp' })
|
||||
: isBulkDeleteMode
|
||||
? toggleBulkSelected(scene.id)
|
||||
: onEditTarget({
|
||||
|
||||
181
src/components/CustomWorldEntityEditorModal.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
|
||||
|
||||
vi.mock('./CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div>角色预览</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
|
||||
<div>{npc.name}</div>
|
||||
),
|
||||
CustomWorldNpcVisualEditor: () => <div>预设形象编辑器</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
|
||||
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
|
||||
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
|
||||
visualPromptText: '自动生成的形象提示词',
|
||||
animationPromptText: '自动生成的动作提示词',
|
||||
}),
|
||||
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
|
||||
generateCharacterVisualCandidates: vi.fn(),
|
||||
publishCharacterVisualAsset: vi.fn(),
|
||||
generateCharacterAnimationDraft: vi.fn(),
|
||||
publishCharacterAnimationAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
function createBackstoryReveal() {
|
||||
return {
|
||||
publicSummary: '公开背景',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: 6,
|
||||
teaser: '表层来意',
|
||||
content: '表层来意内容',
|
||||
contextSnippet: '表层来意摘要',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 12,
|
||||
teaser: '旧事裂痕',
|
||||
content: '旧事裂痕内容',
|
||||
contextSnippet: '旧事裂痕摘要',
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 18,
|
||||
teaser: '隐藏执念',
|
||||
content: '隐藏执念内容',
|
||||
contextSnippet: '隐藏执念摘要',
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: 24,
|
||||
teaser: '最终底牌',
|
||||
content: '最终底牌内容',
|
||||
contextSnippet: '最终底牌摘要',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: '同行者',
|
||||
role: '协作战力',
|
||||
description: `${name}的定位描述`,
|
||||
backstory: `${name}的背景`,
|
||||
personality: `${name}的性格`,
|
||||
motivation: `${name}的动机`,
|
||||
combatStyle: `${name}的战斗风格`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['关系钩子'],
|
||||
relations: [],
|
||||
tags: ['测试'],
|
||||
backstoryReveal: createBackstoryReveal(),
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
templateCharacterId: 'knight-female-1',
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryRole(id: string, name: string): CustomWorldNpc {
|
||||
return {
|
||||
...createPlayableRole(id, name),
|
||||
initialAffinity: 6,
|
||||
visual: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createProfile(): CustomWorldProfile {
|
||||
return {
|
||||
id: 'world-1',
|
||||
settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '旧航道与沉钟回响',
|
||||
summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。',
|
||||
tone: '压抑、潮湿、带着未解旧伤。',
|
||||
playerGoal: '找到能让群岛重新稳定的关键节点。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守潮盟', '沉钟会'],
|
||||
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
|
||||
attributeSchema: {},
|
||||
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
|
||||
storyNpcs: [createStoryRole('story-1', '顾潮音')],
|
||||
items: [],
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
},
|
||||
landmarks: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
ownedSettingLayers: null,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
test('playable角色打开AI工坊后不会自动关闭', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
|
||||
onClose={handleClose}
|
||||
onProfileChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AI角色形象生成')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(handleClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('场景角色打开AI工坊后不会自动关闭', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
|
||||
onClose={handleClose}
|
||||
onProfileChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AI角色形象生成')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(handleClose).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -284,6 +284,12 @@ function ActionButton({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
tone === 'sky'
|
||||
@@ -301,6 +307,7 @@ export function CustomWorldNpcPortrait({
|
||||
profile,
|
||||
visual,
|
||||
className = '',
|
||||
contentClassName = 'min-h-[7rem] p-3',
|
||||
scale = 2.05,
|
||||
preferImageSrc = false,
|
||||
}: {
|
||||
@@ -308,6 +315,7 @@ export function CustomWorldNpcPortrait({
|
||||
profile?: CustomWorldProfile | null;
|
||||
visual?: CustomWorldNpcVisual;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
scale?: number;
|
||||
preferImageSrc?: boolean;
|
||||
}) {
|
||||
@@ -321,7 +329,9 @@ export function CustomWorldNpcPortrait({
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
|
||||
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
|
||||
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
|
||||
<div
|
||||
className={`relative flex h-full items-center justify-center ${contentClassName}`}
|
||||
>
|
||||
{preferredImageSrc ? (
|
||||
<img
|
||||
src={preferredImageSrc}
|
||||
|
||||
244
src/components/CustomWorldResultView.test.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
|
||||
import { CustomWorldResultView } from './CustomWorldResultView';
|
||||
|
||||
vi.mock('../services/aiService', () => ({
|
||||
generateCustomWorldPlayableNpc: vi.fn(),
|
||||
generateCustomWorldStoryNpc: vi.fn(),
|
||||
generateCustomWorldLandmark: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div>角色预览</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
|
||||
<div>{npc.name}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldEntityEditorModal', () => ({
|
||||
CustomWorldEntityEditorModal: () => null,
|
||||
}));
|
||||
|
||||
async function loadAiService() {
|
||||
return import('../services/aiService');
|
||||
}
|
||||
|
||||
function createBackstoryReveal() {
|
||||
return {
|
||||
publicSummary: '公开背景',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: 6,
|
||||
teaser: '表层来意',
|
||||
content: '表层来意内容',
|
||||
contextSnippet: '表层来意摘要',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 12,
|
||||
teaser: '旧事裂痕',
|
||||
content: '旧事裂痕内容',
|
||||
contextSnippet: '旧事裂痕摘要',
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 18,
|
||||
teaser: '隐藏执念',
|
||||
content: '隐藏执念内容',
|
||||
contextSnippet: '隐藏执念摘要',
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: 24,
|
||||
teaser: '最终底牌',
|
||||
content: '最终底牌内容',
|
||||
contextSnippet: '最终底牌摘要',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: '同行者',
|
||||
role: '协作战力',
|
||||
description: `${name}的定位描述`,
|
||||
backstory: `${name}的背景`,
|
||||
personality: `${name}的性格`,
|
||||
motivation: `${name}的动机`,
|
||||
combatStyle: `${name}的战斗风格`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['关系钩子'],
|
||||
relations: [],
|
||||
tags: ['测试'],
|
||||
backstoryReveal: createBackstoryReveal(),
|
||||
skills: [
|
||||
{
|
||||
id: `${id}-skill-1`,
|
||||
name: '技能一',
|
||||
summary: '技能说明一',
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: `${id}-skill-2`,
|
||||
name: '技能二',
|
||||
summary: '技能说明二',
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: `${id}-skill-3`,
|
||||
name: '技能三',
|
||||
summary: '技能说明三',
|
||||
style: '爆发终结',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: `${id}-item-1`,
|
||||
name: '物品一',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明一',
|
||||
tags: ['测试'],
|
||||
},
|
||||
{
|
||||
id: `${id}-item-2`,
|
||||
name: '物品二',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明二',
|
||||
tags: ['测试'],
|
||||
},
|
||||
{
|
||||
id: `${id}-item-3`,
|
||||
name: '物品三',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '物品说明三',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const baseProfile = {
|
||||
id: 'world-1',
|
||||
settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '旧航道与沉钟回响',
|
||||
summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。',
|
||||
tone: '压抑、潮湿、带着未解旧伤。',
|
||||
playerGoal: '找到能让群岛重新稳定的关键节点。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守潮盟', '沉钟会'],
|
||||
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
|
||||
attributeSchema: {},
|
||||
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
|
||||
storyNpcs: [
|
||||
{
|
||||
...createPlayableRole('story-1', '顾潮音'),
|
||||
initialAffinity: 6,
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '沉钟栈桥',
|
||||
description: '旧钟与潮声常年相撞的码头栈桥。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
ownedSettingLayers: null,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} as unknown as CustomWorldProfile;
|
||||
|
||||
function ResultViewHarness() {
|
||||
const [profile, setProfile] = useState(baseProfile);
|
||||
|
||||
return (
|
||||
<CustomWorldResultView
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={setProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
|
||||
const aiService = await loadAiService();
|
||||
const user = userEvent.setup();
|
||||
|
||||
let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null;
|
||||
vi.mocked(aiService.generateCustomWorldPlayableNpc).mockImplementation(
|
||||
() =>
|
||||
new Promise<CustomWorldPlayableNpc>((resolve) => {
|
||||
resolveGeneration = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /可扮演角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: '新增可扮演角色' }));
|
||||
|
||||
expect(screen.getByText('新可扮演角色')).toBeTruthy();
|
||||
expect(screen.getByText('正在整理世界上下文')).toBeTruthy();
|
||||
|
||||
const createButton = screen.getByRole('button', { name: '新增可扮演角色' });
|
||||
expect((createButton as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const finishGeneration = resolveGeneration;
|
||||
if (!finishGeneration) {
|
||||
throw new Error('expected pending playable generation resolver');
|
||||
}
|
||||
|
||||
(finishGeneration as (value: CustomWorldPlayableNpc) => void)(
|
||||
createPlayableRole('playable-2', '云止'),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('云止')).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('新可扮演角色')).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { Character, CustomWorldProfile } from '../types';
|
||||
import {
|
||||
generateCustomWorldLandmark,
|
||||
generateCustomWorldPlayableNpc,
|
||||
generateCustomWorldStoryNpc,
|
||||
} from '../services/aiService';
|
||||
import {
|
||||
Character,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
CustomWorldEntityCatalog,
|
||||
@@ -23,15 +34,28 @@ interface CustomWorldResultViewProps {
|
||||
onEditSetting?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
onContinueExpand?: () => void;
|
||||
onSave?: () => void;
|
||||
onEnterWorld?: () => void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
readOnly?: boolean;
|
||||
backLabel?: string;
|
||||
editActionLabel?: string;
|
||||
regenerateActionLabel?: string;
|
||||
saveActionLabel?: string;
|
||||
enterWorldActionLabel?: string;
|
||||
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
|
||||
}
|
||||
|
||||
type EntityGenerationKind = 'playable' | 'story' | 'landmark';
|
||||
|
||||
type PendingGeneratedEntity = {
|
||||
id: string;
|
||||
kind: EntityGenerationKind;
|
||||
title: string;
|
||||
progress: number;
|
||||
phaseLabel: string;
|
||||
};
|
||||
|
||||
type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
|
||||
|
||||
function SmallButton({
|
||||
onClick,
|
||||
children,
|
||||
@@ -75,6 +99,66 @@ function getCreateLabelByTab(activeTab: ResultTab) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function createPendingGeneratedEntity(
|
||||
kind: EntityGenerationKind,
|
||||
): PendingGeneratedEntity {
|
||||
return {
|
||||
id: `pending-${kind}-${Date.now()}`,
|
||||
kind,
|
||||
title:
|
||||
kind === 'playable'
|
||||
? '新可扮演角色'
|
||||
: kind === 'story'
|
||||
? '新场景角色'
|
||||
: '新场景',
|
||||
progress: 8,
|
||||
phaseLabel: '正在整理世界上下文',
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePendingPhaseLabel(
|
||||
kind: EntityGenerationKind,
|
||||
progress: number,
|
||||
) {
|
||||
if (progress < 28) {
|
||||
return '正在整理世界上下文';
|
||||
}
|
||||
if (progress < 72) {
|
||||
return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构';
|
||||
}
|
||||
return '正在回写结果';
|
||||
}
|
||||
|
||||
function prependPlayableNpc(
|
||||
profile: CustomWorldProfile,
|
||||
npc: CustomWorldPlayableNpc,
|
||||
) {
|
||||
return {
|
||||
...profile,
|
||||
playableNpcs: [npc, ...profile.playableNpcs],
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) {
|
||||
return {
|
||||
...profile,
|
||||
storyNpcs: [npc, ...profile.storyNpcs],
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function prependLandmark(
|
||||
profile: CustomWorldProfile,
|
||||
landmark: CustomWorldLandmark,
|
||||
) {
|
||||
return {
|
||||
...profile,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: [landmark, ...profile.landmarks],
|
||||
storyNpcs: profile.storyNpcs,
|
||||
}),
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function removeStoryNpcsFromProfile(
|
||||
profile: CustomWorldProfile,
|
||||
ids: string[],
|
||||
@@ -129,17 +213,31 @@ export function CustomWorldResultView({
|
||||
onEditSetting,
|
||||
onRegenerate: triggerRegenerate,
|
||||
onContinueExpand,
|
||||
onSave,
|
||||
onEnterWorld,
|
||||
onProfileChange,
|
||||
readOnly = false,
|
||||
backLabel = '返回',
|
||||
editActionLabel = '修改设定',
|
||||
regenerateActionLabel = '重新生成',
|
||||
saveActionLabel = '保存到我的作品',
|
||||
enterWorldActionLabel = '进入世界',
|
||||
autoSaveState = 'idle',
|
||||
}: CustomWorldResultViewProps) {
|
||||
const [editorTarget, setEditorTarget] =
|
||||
useState<CustomWorldEditorTarget | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
||||
const [pendingGeneratedEntity, setPendingGeneratedEntity] =
|
||||
useState<PendingGeneratedEntity | null>(null);
|
||||
const [recentGeneratedIds, setRecentGeneratedIds] = useState<RecentGeneratedIds>(
|
||||
{
|
||||
playable: [],
|
||||
story: [],
|
||||
landmark: [],
|
||||
},
|
||||
);
|
||||
const [localGenerationError, setLocalGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const pendingProgressTimerRef = useRef<number | null>(null);
|
||||
|
||||
const createTarget = useMemo(
|
||||
() => getCreateTargetByTab(activeTab),
|
||||
@@ -149,6 +247,89 @@ export function CustomWorldResultView({
|
||||
() => getCreateLabelByTab(activeTab),
|
||||
[activeTab],
|
||||
);
|
||||
const stopPendingProgressTimer = () => {
|
||||
if (pendingProgressTimerRef.current !== null) {
|
||||
window.clearInterval(pendingProgressTimerRef.current);
|
||||
pendingProgressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => () => stopPendingProgressTimer(), []);
|
||||
|
||||
const startPendingProgress = (kind: EntityGenerationKind) => {
|
||||
stopPendingProgressTimer();
|
||||
setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
|
||||
pendingProgressTimerRef.current = window.setInterval(() => {
|
||||
setPendingGeneratedEntity((current) => {
|
||||
if (!current || current.kind !== kind) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextProgress = Math.min(
|
||||
current.progress + (current.progress < 56 ? 11 : 5),
|
||||
88,
|
||||
);
|
||||
|
||||
return {
|
||||
...current,
|
||||
progress: nextProgress,
|
||||
phaseLabel: resolvePendingPhaseLabel(kind, nextProgress),
|
||||
};
|
||||
});
|
||||
}, 520);
|
||||
};
|
||||
|
||||
const finishPendingProgress = () => {
|
||||
stopPendingProgressTimer();
|
||||
setPendingGeneratedEntity(null);
|
||||
};
|
||||
|
||||
const markGeneratedAsRecent = (
|
||||
kind: EntityGenerationKind,
|
||||
generatedId: string,
|
||||
) => {
|
||||
setRecentGeneratedIds((current) => ({
|
||||
...current,
|
||||
[kind]: [generatedId, ...current[kind].filter((id) => id !== generatedId)].slice(
|
||||
0,
|
||||
6,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleGenerateEntity = async (kind: EntityGenerationKind) => {
|
||||
if (readOnly || isGenerating || pendingGeneratedEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalGenerationError(null);
|
||||
startPendingProgress(kind);
|
||||
|
||||
try {
|
||||
if (kind === 'playable') {
|
||||
const nextNpc = await generateCustomWorldPlayableNpc({ profile });
|
||||
onProfileChange(prependPlayableNpc(profile, nextNpc));
|
||||
markGeneratedAsRecent('playable', nextNpc.id);
|
||||
} else if (kind === 'story') {
|
||||
const nextNpc = await generateCustomWorldStoryNpc({ profile });
|
||||
onProfileChange(prependStoryNpc(profile, nextNpc));
|
||||
markGeneratedAsRecent('story', nextNpc.id);
|
||||
} else {
|
||||
const nextLandmark = await generateCustomWorldLandmark({ profile });
|
||||
onProfileChange(prependLandmark(profile, nextLandmark));
|
||||
markGeneratedAsRecent('landmark', nextLandmark.id);
|
||||
}
|
||||
} catch (generationError) {
|
||||
setLocalGenerationError(
|
||||
generationError instanceof Error
|
||||
? generationError.message
|
||||
: '生成失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
finishPendingProgress();
|
||||
}
|
||||
};
|
||||
|
||||
const onRegenerate = () => {
|
||||
if (isGenerating || !triggerRegenerate) return;
|
||||
|
||||
@@ -169,10 +350,24 @@ export function CustomWorldResultView({
|
||||
if (ids.length === 0) return;
|
||||
onProfileChange(removeLandmarksFromProfile(profile, ids));
|
||||
};
|
||||
const autoSaveBadge =
|
||||
autoSaveState === 'saved' ? (
|
||||
<div className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
已自动保存
|
||||
</div>
|
||||
) : autoSaveState === 'saving' ? (
|
||||
<div className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
保存中
|
||||
</div>
|
||||
) : autoSaveState === 'error' ? (
|
||||
<div className="rounded-full border border-rose-300/20 bg-rose-500/10 px-3 py-1 text-[11px] text-rose-100">
|
||||
保存失败
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4 flex justify-start">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -181,6 +376,7 @@ export function CustomWorldResultView({
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
{autoSaveBadge}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
@@ -197,8 +393,27 @@ export function CustomWorldResultView({
|
||||
onCreateAction={
|
||||
readOnly || !createTarget
|
||||
? undefined
|
||||
: () => setEditorTarget(createTarget)
|
||||
: () => {
|
||||
if (activeTab === 'playable') {
|
||||
void handleGenerateEntity('playable');
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'story') {
|
||||
void handleGenerateEntity('story');
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'landmarks') {
|
||||
void handleGenerateEntity('landmark');
|
||||
return;
|
||||
}
|
||||
setEditorTarget(createTarget);
|
||||
}
|
||||
}
|
||||
createActionDisabled={Boolean(
|
||||
isGenerating || pendingGeneratedEntity,
|
||||
)}
|
||||
pendingGeneratedEntity={pendingGeneratedEntity}
|
||||
recentGeneratedIds={recentGeneratedIds}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
@@ -225,6 +440,11 @@ export function CustomWorldResultView({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && localGenerationError ? (
|
||||
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{localGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{profile.generationStatus === 'key_only' ? (
|
||||
@@ -250,10 +470,10 @@ export function CustomWorldResultView({
|
||||
继续补全世界
|
||||
</SmallButton>
|
||||
) : null}
|
||||
{onSave ? (
|
||||
{onEnterWorld ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
onClick={onEnterWorld}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
@@ -263,7 +483,7 @@ export function CustomWorldResultView({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{saveActionLabel}
|
||||
{enterWorldActionLabel}
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '../../types';
|
||||
|
||||
export const MASTER_VISUAL_WIDTH = 1024;
|
||||
export const MASTER_VISUAL_HEIGHT = 1536;
|
||||
export const MASTER_VISUAL_HEIGHT = 1024;
|
||||
export const GENERATED_FRAME_WIDTH = 192;
|
||||
export const GENERATED_FRAME_HEIGHT = 256;
|
||||
|
||||
@@ -769,6 +769,34 @@ async function normalizeFrameSourceToDataUrl(
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function normalizeMasterVisualSourceToDataUrl(
|
||||
source: string,
|
||||
options: {
|
||||
applyChromaKey?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const image = await loadImageFromSource(source);
|
||||
const { canvas, context } = createCanvas(
|
||||
MASTER_VISUAL_WIDTH,
|
||||
MASTER_VISUAL_HEIGHT,
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey !== false) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function seekVideo(video: HTMLVideoElement, targetTime: number) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (Math.abs(video.currentTime - targetTime) < 0.001) {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { fetchJson } from '../../editor/shared/jsonClient';
|
||||
|
||||
export const CHARACTER_VISUAL_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterVisualGenerate;
|
||||
export const CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterPromptBundleGenerate;
|
||||
export const CHARACTER_WORKFLOW_CACHE_API_PATH =
|
||||
ASSET_API_PATHS.characterWorkflowCache;
|
||||
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
|
||||
ASSET_API_PATHS.characterVisualPublish;
|
||||
export const CHARACTER_VISUAL_JOB_API_PATH = ASSET_API_PATHS.characterVisualJobs;
|
||||
@@ -43,6 +47,43 @@ export type CharacterVisualDraft = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type CharacterPromptBundlePayload = {
|
||||
roleKind: 'playable' | 'story';
|
||||
characterName: string;
|
||||
roleTitle?: string;
|
||||
roleLabel?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
characterBriefText: string;
|
||||
};
|
||||
|
||||
export type CharacterPromptBundleResult = {
|
||||
ok: true;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
source: 'llm' | 'fallback';
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
export type CharacterAssetWorkflowCache = {
|
||||
characterId: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
visualDrafts: CharacterVisualDraft[];
|
||||
selectedVisualDraftId: string;
|
||||
selectedAnimation: string;
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown> | null;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type CharacterVisualGenerationPayload = {
|
||||
characterId: string;
|
||||
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
|
||||
@@ -129,7 +170,41 @@ export async function generateCharacterVisualCandidates(
|
||||
model: string;
|
||||
prompt: string;
|
||||
drafts: CharacterVisualDraft[];
|
||||
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象候选失败');
|
||||
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
|
||||
}
|
||||
|
||||
export async function generateCharacterPromptBundle(
|
||||
payload: CharacterPromptBundlePayload,
|
||||
) {
|
||||
return postApiJson<CharacterPromptBundleResult>(
|
||||
CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH,
|
||||
payload,
|
||||
'生成默认提示词失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCharacterWorkflowCache(characterId: string) {
|
||||
return fetchJson<{
|
||||
ok: true;
|
||||
cache: CharacterAssetWorkflowCache | null;
|
||||
}>(
|
||||
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}`,
|
||||
'读取角色形象生成缓存失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveCharacterWorkflowCache(
|
||||
payload: CharacterAssetWorkflowCache,
|
||||
) {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
cache: CharacterAssetWorkflowCache;
|
||||
saveMessage: string;
|
||||
}>(
|
||||
CHARACTER_WORKFLOW_CACHE_API_PATH,
|
||||
payload,
|
||||
'保存角色形象生成缓存失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCharacterVisualJobStatus(taskId: string) {
|
||||
|
||||
76
src/components/asset-studio/customWorldRolePromptDefaults.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export type PromptDefaultRole = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type CustomWorldRolePromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
const characterName = cleanSeedText(role.name, 40) || '该角色';
|
||||
const roleAnchor =
|
||||
[cleanSeedText(role.title, 60), cleanSeedText(role.role, 60)]
|
||||
.filter(Boolean)
|
||||
.join(' / ') || '关键角色';
|
||||
const descriptionAnchor =
|
||||
cleanSeedText(role.description, 220) ||
|
||||
cleanSeedText(role.backstory, 260) ||
|
||||
cleanSeedText(role.personality, 160) ||
|
||||
'识别度鲜明';
|
||||
const combatAnchor =
|
||||
cleanSeedText(role.combatStyle, 180) ||
|
||||
cleanSeedText(role.motivation, 180) ||
|
||||
'动作重心稳定';
|
||||
const tagAnchor =
|
||||
role.tags && role.tags.length > 0
|
||||
? `保留 ${role.tags.slice(0, 8).join('、')} 的角色识别点。`
|
||||
: '';
|
||||
|
||||
return {
|
||||
visualPromptText: [
|
||||
`${characterName},${roleAnchor}。`,
|
||||
'单人全身,2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。',
|
||||
`外观气质围绕:${descriptionAnchor}。`,
|
||||
`动作识别点参考:${combatAnchor}。`,
|
||||
tagAnchor,
|
||||
'构图干净,主体明确,不做正面立绘,不做夸张透视。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
animationPromptText: [
|
||||
`${characterName}核心动作试片。`,
|
||||
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯自然。',
|
||||
`动作气质参考:${combatAnchor}。`,
|
||||
role.personality ? `角色状态补充:${cleanSeedText(role.personality, 160)}。` : '',
|
||||
'起手清楚,发力明确,收招干净,避免漂移、乱摆和形体变形。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
scenePromptText: [
|
||||
`${characterName}关联主场景,适合作为首次登场区域或常驻活动空间。`,
|
||||
'16:9 横版 RPG 场景背景,上半部分突出中远景氛围,下半部分是清晰可站立地面。',
|
||||
`场景叙事气质围绕:${descriptionAnchor}。`,
|
||||
role.backstory ? `环境背景可埋入:${cleanSeedText(role.backstory, 260)}。` : '',
|
||||
role.motivation ? `场景目标暗示可参考:${cleanSeedText(role.motivation, 160)}。` : '',
|
||||
'整体风格统一克制,适合作为剧情探索与战斗底图。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import { type ComponentType, useMemo } from 'react';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
@@ -72,7 +74,11 @@ function WorldCard({
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const tags = [
|
||||
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
|
||||
...new Set(
|
||||
buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 3);
|
||||
|
||||
return (
|
||||
@@ -224,19 +230,53 @@ function describeBindingStatus(bindingStatus: AuthUser['bindingStatus']) {
|
||||
return bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '正常';
|
||||
}
|
||||
|
||||
function formatPlayTime(playTimeMs: number) {
|
||||
const totalSeconds = Math.max(0, Math.floor(playTimeMs / 1000));
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
function formatCompactPlayTime(playTimeMs: number) {
|
||||
const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000));
|
||||
const days = totalMinutes / 1440;
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时`;
|
||||
if (days >= 10) {
|
||||
return `${Math.floor(days)}天`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分`;
|
||||
if (days >= 1) {
|
||||
return `${days.toFixed(days >= 3 ? 0 : 1)}天`;
|
||||
}
|
||||
return `${minutes}分`;
|
||||
|
||||
const hours = totalMinutes / 60;
|
||||
if (hours >= 1) {
|
||||
return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`;
|
||||
}
|
||||
|
||||
return `${Math.max(0, totalMinutes)}分`;
|
||||
}
|
||||
|
||||
function formatDashboardCount(value: number) {
|
||||
const normalizedValue = Math.max(0, Math.round(value));
|
||||
if (normalizedValue >= 100000000) {
|
||||
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
|
||||
}
|
||||
if (normalizedValue >= 10000) {
|
||||
return `${(normalizedValue / 10000).toFixed(1)}万`;
|
||||
}
|
||||
|
||||
return normalizedValue.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatDashboardUpdatedAt(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '暂无更新记录';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
@@ -249,7 +289,9 @@ function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
}
|
||||
|
||||
function getUserAvatarLabel(user: AuthUser | null | undefined) {
|
||||
return (user?.displayName || user?.username || '叙').slice(0, 1).toUpperCase();
|
||||
return (user?.displayName || user?.username || '叙')
|
||||
.slice(0, 1)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function copyText(value: string) {
|
||||
@@ -261,23 +303,40 @@ function copyText(value: string) {
|
||||
}
|
||||
|
||||
function ProfileStatCard({
|
||||
cardKey,
|
||||
label,
|
||||
value,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
cardKey: ProfileDashboardCardKey;
|
||||
label: string;
|
||||
value: string;
|
||||
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||
className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3 text-left transition hover:border-white/20 hover:bg-white/6"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-[11px] tracking-[0.16em]">{label}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-black text-white">{value}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileStatCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
|
||||
<div className="h-4 w-20 animate-pulse rounded-full bg-white/10" />
|
||||
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-white/12" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -316,13 +375,20 @@ export function PlatformHomeView({
|
||||
latestEntries,
|
||||
myEntries,
|
||||
historyEntries,
|
||||
historyError,
|
||||
profileDashboard,
|
||||
isLoadingPlatform,
|
||||
isLoadingDashboard,
|
||||
isClearingHistory,
|
||||
platformError,
|
||||
dashboardError,
|
||||
onContinueGame,
|
||||
onClearHistory,
|
||||
onOpenCreateWorld,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
onOpenProfileDashboardCard,
|
||||
}: {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
@@ -332,15 +398,22 @@ export function PlatformHomeView({
|
||||
latestEntries: CustomWorldGalleryCard[];
|
||||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
historyEntries: PlatformBrowseHistoryEntry[];
|
||||
historyError: string | null;
|
||||
profileDashboard: ProfileDashboardSummary | null;
|
||||
isLoadingPlatform: boolean;
|
||||
isLoadingDashboard: boolean;
|
||||
isClearingHistory: boolean;
|
||||
platformError: string | null;
|
||||
dashboardError: string | null;
|
||||
onContinueGame: () => void;
|
||||
onClearHistory: () => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const featuredShelf = useMemo(
|
||||
@@ -362,11 +435,11 @@ export function PlatformHomeView({
|
||||
'上一次冒险已经保存,可以从这里继续推进故事。';
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||||
const remainingNarrativeCoins = savedSnapshot?.gameState.playerCurrency ?? 0;
|
||||
const totalPlayTime = formatPlayTime(
|
||||
savedSnapshot?.gameState.runtimeStats.playTimeMs ?? 0,
|
||||
const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
|
||||
const totalPlayTime = formatCompactPlayTime(
|
||||
profileDashboard?.totalPlayTimeMs ?? 0,
|
||||
);
|
||||
const playedWorkCount = hasSavedGame ? 1 : 0;
|
||||
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
|
||||
const tabIcons = {
|
||||
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
|
||||
create: '/Icons/01_Scroll.png',
|
||||
@@ -647,21 +720,66 @@ export function PlatformHomeView({
|
||||
})}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProfileStatCard
|
||||
label="剩余叙世币"
|
||||
value={`${remainingNarrativeCoins}`}
|
||||
icon={Coins}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
label="总游戏时长"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
label="玩过作品"
|
||||
value={`${playedWorkCount}`}
|
||||
icon={BookOpen}
|
||||
/>
|
||||
{isLoadingDashboard ? (
|
||||
<>
|
||||
<ProfileStatCardSkeleton />
|
||||
<ProfileStatCardSkeleton />
|
||||
<ProfileStatCardSkeleton />
|
||||
</>
|
||||
) : dashboardError ? (
|
||||
<>
|
||||
<ProfileStatCard
|
||||
cardKey="wallet"
|
||||
label="剩余叙世币"
|
||||
value="暂不可用"
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="总游戏时长"
|
||||
value="暂不可用"
|
||||
icon={Clock3}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="玩过作品"
|
||||
value="暂不可用"
|
||||
icon={BookOpen}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProfileStatCard
|
||||
cardKey="wallet"
|
||||
label="剩余叙世币"
|
||||
value={formatDashboardCount(remainingNarrativeCoins)}
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="总游戏时长"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="玩过作品"
|
||||
value={formatDashboardCount(playedWorkCount)}
|
||||
icon={BookOpen}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-zinc-500">
|
||||
{dashboardError
|
||||
? dashboardError
|
||||
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -719,8 +837,27 @@ export function PlatformHomeView({
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<SectionHeader title="历史浏览" detail="最近看过的作品" />
|
||||
{historyEntries.length > 0 ? (
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<SectionHeader title="历史浏览" detail="最近看过的作品" />
|
||||
{historyEntries.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearHistory}
|
||||
disabled={isClearingHistory}
|
||||
className="shrink-0 rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{isClearingHistory ? '清空中' : '清空'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{historyError ? (
|
||||
<div className="mb-3 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{historyError}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoadingPlatform && historyEntries.length === 0 ? (
|
||||
<EmptyShelf text="正在读取浏览历史..." />
|
||||
) : historyEntries.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{historyEntries.map((entry) => (
|
||||
<button
|
||||
@@ -771,7 +908,9 @@ export function PlatformHomeView({
|
||||
作者:{entry.authorDisplayName}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
|
||||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||||
{entry.summaryText ||
|
||||
entry.subtitle ||
|
||||
'等待补充世界摘要。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -815,7 +954,9 @@ export function PlatformHomeView({
|
||||
<Settings className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">设置</div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
设置
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">账号与安全</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,18 @@ import {
|
||||
getCustomWorldAgentOperation,
|
||||
getCustomWorldAgentSession,
|
||||
} from '../../services/aiService';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
getProfileDashboard,
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
listProfileBrowseHistory,
|
||||
upsertCustomWorldProfile,
|
||||
upsertProfileBrowseHistory,
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
PreGameSelectionFlow,
|
||||
type SelectionStage,
|
||||
@@ -33,11 +39,16 @@ vi.mock('../../services/aiService', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/storageService', () => ({
|
||||
clearProfileBrowseHistory: vi.fn(),
|
||||
getCustomWorldGalleryDetail: vi.fn(),
|
||||
getProfileDashboard: vi.fn(),
|
||||
listCustomWorldGallery: vi.fn(),
|
||||
listCustomWorldLibrary: vi.fn(),
|
||||
listProfileBrowseHistory: vi.fn(),
|
||||
publishCustomWorldProfile: vi.fn(),
|
||||
syncProfileBrowseHistory: vi.fn(),
|
||||
unpublishCustomWorldProfile: vi.fn(),
|
||||
upsertProfileBrowseHistory: vi.fn(),
|
||||
upsertCustomWorldProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -108,11 +119,21 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
};
|
||||
|
||||
function TestWrapper() {
|
||||
const mockAuthUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
|
||||
const [selectionStage, setSelectionStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
@@ -124,14 +145,41 @@ function TestWrapper() {
|
||||
handleCustomWorldSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!withAuth) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: mockAuthUser,
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
});
|
||||
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
|
||||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
@@ -350,26 +398,56 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /保存到我的作品|自动保存中|已保存到我的作品/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('已自动保存')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /进入世界/u })).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull();
|
||||
expect(screen.getByText(/原始设定/u)).toBeTruthy();
|
||||
expect(screen.getByText(/基本设定/u)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: /顾潮音/u }));
|
||||
|
||||
expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /AI生成形象与动作/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy();
|
||||
expect(screen.getByText('技能')).toBeTruthy();
|
||||
|
||||
});
|
||||
|
||||
test('profile tab loads server browse history and can clear it after confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([
|
||||
{
|
||||
ownerUserId: 'author-1',
|
||||
profileId: 'world-1',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '最近浏览过的公开作品。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '我的' }));
|
||||
|
||||
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '清空' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clearProfileBrowseHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('你最近还没有浏览过作品详情,去首页或发现逛一逛吧。'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleInitialItem,
|
||||
CustomWorldRoleRelation,
|
||||
CustomWorldRoleSkill,
|
||||
EquipmentSlotId,
|
||||
ItemAttributeResonance,
|
||||
@@ -39,8 +40,8 @@ import {
|
||||
SceneNarrativeResidue,
|
||||
ThemePack,
|
||||
ThreadContract,
|
||||
WorldType,
|
||||
WorldStoryGraph,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||||
@@ -330,6 +331,35 @@ function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
|
||||
] satisfies CustomWorldRoleSkill[];
|
||||
}
|
||||
|
||||
function normalizeRoleRelations(value: unknown, fallbackHooks: string[]) {
|
||||
const normalized = Array.isArray(value)
|
||||
? value
|
||||
.filter(isRecord)
|
||||
.map(
|
||||
(entry, index) =>
|
||||
({
|
||||
id: toText(entry.id, `saved-role-relation-${index + 1}`),
|
||||
targetRoleId: toText(entry.targetRoleId),
|
||||
summary: toText(entry.summary),
|
||||
}) satisfies CustomWorldRoleRelation,
|
||||
)
|
||||
.filter((entry) => entry.summary)
|
||||
.slice(0, 8)
|
||||
: [];
|
||||
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return fallbackHooks
|
||||
.map((summary, index) => ({
|
||||
id: `saved-role-relation-${index + 1}`,
|
||||
targetRoleId: '',
|
||||
summary,
|
||||
}))
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function normalizeRoleSkills(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
@@ -344,6 +374,10 @@ function normalizeRoleSkills(
|
||||
name: toText(entry.name),
|
||||
summary: toText(entry.summary, toText(entry.description)),
|
||||
style: toText(entry.style, toText(entry.category, '常用')),
|
||||
actionPromptText: toText(entry.actionPromptText) || undefined,
|
||||
actionPreviewConfig:
|
||||
normalizeCharacterAnimationConfig(entry.actionPreviewConfig) ??
|
||||
undefined,
|
||||
}) satisfies CustomWorldRoleSkill,
|
||||
)
|
||||
.filter((entry) => entry.name)
|
||||
@@ -424,6 +458,7 @@ function normalizeRoleInitialItems(
|
||||
: 'rare',
|
||||
description: toText(entry.description),
|
||||
tags: toStringArray(entry.tags),
|
||||
iconSrc: toText(entry.iconSrc) || undefined,
|
||||
}) satisfies CustomWorldRoleInitialItem,
|
||||
)
|
||||
.filter((entry) => entry.name)
|
||||
@@ -587,6 +622,11 @@ function normalizePlayableNpc(
|
||||
const title = toText(value.title, toText(value.role, '未命名角色'));
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const relations = normalizeRoleRelations(value.relations, relationshipHooks);
|
||||
const relationSummaries = relations
|
||||
.map((entry) => entry.summary)
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
const tags = toStringArray(value.tags);
|
||||
const fallbackSource = {
|
||||
name,
|
||||
@@ -616,7 +656,11 @@ function normalizePlayableNpc(
|
||||
value.initialAffinity,
|
||||
DEFAULT_PLAYABLE_INITIAL_AFFINITY,
|
||||
),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
relationshipHooks:
|
||||
relationSummaries.length > 0
|
||||
? relationSummaries
|
||||
: fallbackSource.relationshipHooks,
|
||||
relations,
|
||||
tags: fallbackSource.tags,
|
||||
backstoryReveal: normalizeBackstoryReveal(
|
||||
value.backstoryReveal,
|
||||
@@ -650,6 +694,11 @@ function normalizeStoryNpc(
|
||||
const title = toText(value.title, toText(value.role, '未命名场景角色'));
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const relations = normalizeRoleRelations(value.relations, relationshipHooks);
|
||||
const relationSummaries = relations
|
||||
.map((entry) => entry.summary)
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
const tags = toStringArray(value.tags);
|
||||
const fallbackSource = {
|
||||
name,
|
||||
@@ -679,7 +728,11 @@ function normalizeStoryNpc(
|
||||
value.initialAffinity,
|
||||
DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
relationshipHooks:
|
||||
relationSummaries.length > 0
|
||||
? relationSummaries
|
||||
: fallbackSource.relationshipHooks,
|
||||
relations,
|
||||
tags: fallbackSource.tags,
|
||||
backstoryReveal: normalizeBackstoryReveal(
|
||||
value.backstoryReveal,
|
||||
|
||||
@@ -18,6 +18,8 @@ export type EditorJsonResourceId =
|
||||
(typeof EDITOR_JSON_RESOURCE_IDS)[keyof typeof EDITOR_JSON_RESOURCE_IDS];
|
||||
|
||||
export const ASSET_API_PATHS = {
|
||||
characterPromptBundleGenerate: `${ASSETS_API_BASE_PATH}/character-prompts/generate`,
|
||||
characterWorkflowCache: `${ASSETS_API_BASE_PATH}/character-workflow-cache`,
|
||||
characterVisualGenerate: `${ASSETS_API_BASE_PATH}/character-visual/generate`,
|
||||
characterVisualPublish: `${ASSETS_API_BASE_PATH}/character-visual/publish`,
|
||||
characterVisualJobs: `${ASSETS_API_BASE_PATH}/character-visual/jobs`,
|
||||
|
||||
@@ -899,13 +899,21 @@ describe('ai orchestration fallbacks', () => {
|
||||
ok: true,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||||
assetId: 'custom-scene-1',
|
||||
model: 'wan2.7-image',
|
||||
size: '1280*720',
|
||||
taskId: 'task-123',
|
||||
prompt: '系统整理后的提示词',
|
||||
actualPrompt: '扩写后的提示词',
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||||
assetId: 'custom-scene-1',
|
||||
model: 'wan2.7-image',
|
||||
size: '1280*720',
|
||||
taskId: 'task-123',
|
||||
prompt: '系统整理后的提示词',
|
||||
actualPrompt: '扩写后的提示词',
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type {
|
||||
CustomWorldGenerationStep,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import { unwrapApiResponse } from '../../packages/shared/src/http';
|
||||
import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs';
|
||||
import {
|
||||
buildEncounterFromSceneNpc,
|
||||
@@ -26,12 +32,6 @@ import {
|
||||
WorldStoryGraph,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {
|
||||
CustomWorldGenerationStep,
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback,
|
||||
buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback,
|
||||
@@ -136,7 +136,6 @@ export type {
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
|
||||
export type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
@@ -2018,8 +2017,8 @@ export async function generateCustomWorldSceneImage({
|
||||
);
|
||||
}
|
||||
|
||||
const data = JSON.parse(
|
||||
responseText,
|
||||
const data = unwrapApiResponse(
|
||||
JSON.parse(responseText) as Partial<CustomWorldSceneImageResult>,
|
||||
) as Partial<CustomWorldSceneImageResult>;
|
||||
if (
|
||||
!data.imageSrc ||
|
||||
|
||||
@@ -30,6 +30,9 @@ import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
@@ -49,6 +52,7 @@ import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const CUSTOM_WORLD_API_BASE = '/api';
|
||||
|
||||
type LegacyAiModule = typeof import('./ai');
|
||||
|
||||
@@ -490,6 +494,74 @@ export async function generateCustomWorldSceneImage(
|
||||
return aiClient.generateCustomWorldSceneImage(...args);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
landmarkId: string;
|
||||
}) {
|
||||
const response = await requestPostJson<{ npc: CustomWorldNpc }>(
|
||||
`${CUSTOM_WORLD_API_BASE}/custom-world/scene-npc`,
|
||||
payload,
|
||||
'生成场景 NPC 失败',
|
||||
);
|
||||
|
||||
return response.npc;
|
||||
}
|
||||
|
||||
async function requestCustomWorldEntity<T>(
|
||||
payload: {
|
||||
profile: CustomWorldProfile;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
},
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestPostJson<{
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
entity: T;
|
||||
}>(`${CUSTOM_WORLD_API_BASE}/custom-world/entity`, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldPlayableNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestCustomWorldEntity<CustomWorldPlayableNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'playable',
|
||||
},
|
||||
'生成可扮演角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldStoryNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestCustomWorldEntity<CustomWorldNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'story',
|
||||
},
|
||||
'生成场景角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldLandmark(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestCustomWorldEntity<CustomWorldLandmark>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'landmark',
|
||||
},
|
||||
'生成场景失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function createCustomWorldSession(payload: {
|
||||
settingText: string;
|
||||
creatorIntent?: Record<string, unknown> | null;
|
||||
|
||||
@@ -2354,7 +2354,7 @@ export function buildCustomWorldSceneImagePrompt(
|
||||
'下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。',
|
||||
'下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。',
|
||||
options.hasReferenceImage
|
||||
? '已提供一张自定义参考图,可适度参考其构图、镜头或氛围,但仍以本次场景需求为准,不要生硬照搬。'
|
||||
? '已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。'
|
||||
: '',
|
||||
`世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`,
|
||||
worldSetting ? `玩家设定:${worldSetting}。` : '',
|
||||
|
||||
@@ -116,9 +116,9 @@ test('marks all legacy progress steps complete when draft foundation finishes',
|
||||
test('builds readable draft setting text from creator intent first', () => {
|
||||
const settingText = buildAgentDraftFoundationSettingText(baseSession);
|
||||
|
||||
expect(settingText).toContain('世界核心命题');
|
||||
expect(settingText).toContain('玩家身份');
|
||||
expect(settingText).toContain('标志性要素');
|
||||
expect(settingText).toContain('世界一句话');
|
||||
expect(settingText).toContain('玩家开局');
|
||||
expect(settingText).toContain('标志元素');
|
||||
});
|
||||
|
||||
test('falls back to latest user message when creator intent is unavailable', () => {
|
||||
|
||||
@@ -7,8 +7,7 @@ import type {
|
||||
CustomWorldGenerationStep,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from './customWorldCreatorIntent';
|
||||
|
||||
@@ -177,17 +176,11 @@ export function buildAgentDraftFoundationSettingText(
|
||||
);
|
||||
|
||||
if (creatorIntent) {
|
||||
const generationText =
|
||||
buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim();
|
||||
const displayText =
|
||||
buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
|
||||
const foundationText =
|
||||
buildCustomWorldCreatorIntentFoundationText(creatorIntent).trim();
|
||||
|
||||
if (generationText) {
|
||||
return generationText;
|
||||
}
|
||||
|
||||
if (displayText) {
|
||||
return displayText;
|
||||
if (foundationText) {
|
||||
return foundationText;
|
||||
}
|
||||
|
||||
if (creatorIntent.rawSettingText.trim()) {
|
||||
|
||||
@@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
buildPendingClarifications,
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
buildPendingClarifications,
|
||||
createEmptyCustomWorldCreatorIntent,
|
||||
evaluateCustomWorldCreatorIntentReadiness,
|
||||
mergeCustomWorldCreatorIntent,
|
||||
@@ -42,6 +43,41 @@ describe('customWorldCreatorIntent', () => {
|
||||
expect(summary).toContain('关键角色:沈砺 / 灰炬向导');
|
||||
});
|
||||
|
||||
it('builds six-anchor foundation text from structured creator intent', () => {
|
||||
const intent = {
|
||||
...createEmptyCustomWorldCreatorIntent('card'),
|
||||
worldHook: '一个会被灵潮反复改写地形的边境世界。',
|
||||
themeKeywords: ['边境', '灵潮'],
|
||||
toneDirectives: ['紧张', '潮湿'],
|
||||
playerPremise: '玩家是带着旧名单回来的前巡夜人。',
|
||||
openingSituation: '返乡第一夜,封锁线外出现了本不该存在的灯火。',
|
||||
coreConflicts: ['旧案名单再次出现'],
|
||||
keyCharacters: [
|
||||
{
|
||||
id: 'character-1',
|
||||
name: '沈砺',
|
||||
role: '灰炬向导',
|
||||
publicMask: '看起来只是熟路的带路人',
|
||||
hiddenHook: '他一直在追查撤离线失控真相',
|
||||
relationToPlayer: '会先怀疑玩家身份',
|
||||
notes: '',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
iconicElements: ['会逆向蔓延的潮雾'],
|
||||
};
|
||||
|
||||
const foundationText = buildCustomWorldCreatorIntentFoundationText(intent);
|
||||
|
||||
expect(foundationText).toContain(
|
||||
'世界一句话:一个会被灵潮反复改写地形的边境世界。',
|
||||
);
|
||||
expect(foundationText).toContain('玩家开局:玩家是带着旧名单回来的前巡夜人。');
|
||||
expect(foundationText).toContain('主题气质:边境、灵潮 / 紧张、潮湿');
|
||||
expect(foundationText).toContain('关键关系:沈砺 · 灰炬向导');
|
||||
expect(foundationText).toContain('标志元素:会逆向蔓延的潮雾');
|
||||
});
|
||||
|
||||
it('builds anchor pack from creator intent and keeps locked ids', () => {
|
||||
const intent = {
|
||||
...createEmptyCustomWorldCreatorIntent('card'),
|
||||
|
||||
@@ -710,6 +710,48 @@ function buildAnchorLine(label: string, content: string) {
|
||||
return content ? `${label}:${content}` : '';
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentFoundationText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const relationshipSeed = intent?.keyCharacters[0];
|
||||
const relationshipText = relationshipSeed
|
||||
? [
|
||||
relationshipSeed.name,
|
||||
relationshipSeed.role,
|
||||
relationshipSeed.relationToPlayer
|
||||
? `与玩家 ${relationshipSeed.relationToPlayer}`
|
||||
: '',
|
||||
relationshipSeed.hiddenHook ? `暗线 ${relationshipSeed.hiddenHook}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: '';
|
||||
const playerOpeningText = [intent?.playerPremise || '', intent?.openingSituation || '']
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
const themeToneText = [
|
||||
intent?.themeKeywords.join('、') || '',
|
||||
intent?.toneDirectives.join('、') || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
return [
|
||||
buildAnchorLine('世界一句话', intent?.worldHook || ''),
|
||||
buildAnchorLine('玩家开局', playerOpeningText),
|
||||
buildAnchorLine('主题气质', themeToneText),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join(';') || ''),
|
||||
buildAnchorLine('关键关系', relationshipText),
|
||||
buildAnchorLine('标志元素', intent?.iconicElements.join('、') || ''),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentDisplayText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import type { CustomWorldGalleryCard } from '../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthUser } from './authService';
|
||||
|
||||
export type PlatformBrowseHistoryEntry = {
|
||||
ownerUserId: string;
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeMode: CustomWorldGalleryCard['themeMode'];
|
||||
authorDisplayName: string;
|
||||
visitedAt: string;
|
||||
};
|
||||
export type { PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry };
|
||||
|
||||
const HISTORY_STORAGE_KEY_PREFIX = 'genarrative.platform.browse-history.v1';
|
||||
const HISTORY_SYNC_KEY_PREFIX = 'genarrative.platform.browse-history.synced.v1';
|
||||
const MAX_HISTORY_ENTRIES = 20;
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
return (
|
||||
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
function buildHistoryStorageKey(user: AuthUser | null | undefined) {
|
||||
@@ -25,6 +21,11 @@ function buildHistoryStorageKey(user: AuthUser | null | undefined) {
|
||||
return `${HISTORY_STORAGE_KEY_PREFIX}:${accountId}`;
|
||||
}
|
||||
|
||||
function buildHistorySyncKey(user: AuthUser | null | undefined) {
|
||||
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
|
||||
return `${HISTORY_SYNC_KEY_PREFIX}:${accountId}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -33,7 +34,9 @@ function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeHistoryEntry(value: unknown): PlatformBrowseHistoryEntry | null {
|
||||
function normalizeHistoryEntry(
|
||||
value: unknown,
|
||||
): PlatformBrowseHistoryEntry | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
@@ -42,12 +45,11 @@ function normalizeHistoryEntry(value: unknown): PlatformBrowseHistoryEntry | nul
|
||||
const profileId = readString(value.profileId);
|
||||
const worldName = readString(value.worldName);
|
||||
const visitedAt = readString(value.visitedAt);
|
||||
|
||||
if (!ownerUserId || !profileId || !worldName || !visitedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themeMode = readString(value.themeMode) as PlatformBrowseHistoryEntry['themeMode'];
|
||||
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
@@ -55,7 +57,10 @@ function normalizeHistoryEntry(value: unknown): PlatformBrowseHistoryEntry | nul
|
||||
subtitle: readString(value.subtitle),
|
||||
summaryText: readString(value.summaryText),
|
||||
coverImageSrc: readString(value.coverImageSrc) || null,
|
||||
themeMode: themeMode || 'mythic',
|
||||
themeMode:
|
||||
(readString(
|
||||
value.themeMode,
|
||||
) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic',
|
||||
authorDisplayName: readString(value.authorDisplayName) || '玩家',
|
||||
visitedAt,
|
||||
};
|
||||
@@ -97,19 +102,20 @@ export function readPlatformBrowseHistory(user: AuthUser | null | undefined) {
|
||||
|
||||
export function writePlatformBrowseHistory(
|
||||
user: AuthUser | null | undefined,
|
||||
entry: Omit<PlatformBrowseHistoryEntry, 'visitedAt'> & {
|
||||
visitedAt?: string;
|
||||
},
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return [] as PlatformBrowseHistoryEntry[];
|
||||
}
|
||||
|
||||
const nextEntry: PlatformBrowseHistoryEntry = {
|
||||
...entry,
|
||||
ownerUserId: entry.ownerUserId.trim(),
|
||||
profileId: entry.profileId.trim(),
|
||||
worldName: entry.worldName.trim(),
|
||||
subtitle: entry.subtitle?.trim() || '',
|
||||
summaryText: entry.summaryText?.trim() || '',
|
||||
coverImageSrc: entry.coverImageSrc?.trim() || null,
|
||||
themeMode: entry.themeMode || 'mythic',
|
||||
authorDisplayName: entry.authorDisplayName?.trim() || '玩家',
|
||||
visitedAt: entry.visitedAt?.trim() || new Date().toISOString(),
|
||||
};
|
||||
@@ -129,5 +135,38 @@ export function writePlatformBrowseHistory(
|
||||
buildHistoryStorageKey(user),
|
||||
JSON.stringify(nextEntries),
|
||||
);
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
export function clearPlatformBrowseHistory(user: AuthUser | null | undefined) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(buildHistoryStorageKey(user));
|
||||
window.localStorage.removeItem(buildHistorySyncKey(user));
|
||||
}
|
||||
|
||||
export function hasPendingPlatformBrowseHistoryMigration(
|
||||
user: AuthUser | null | undefined,
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
readPlatformBrowseHistory(user).length > 0 &&
|
||||
window.localStorage.getItem(buildHistorySyncKey(user)) !== '1'
|
||||
);
|
||||
}
|
||||
|
||||
export function markPlatformBrowseHistoryMigrated(
|
||||
user: AuthUser | null | undefined,
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(buildHistorySyncKey(user), '1');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type {
|
||||
ListCustomWorldWorksResponse,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { ListCustomWorldWorksResponse } from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
BasicOkResult,
|
||||
CustomWorldGalleryDetailResponse,
|
||||
@@ -8,15 +6,20 @@ import type {
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
SavedGameSnapshotInput,
|
||||
} from '../persistence/gameSaveStorage';
|
||||
import type { SavedGameSnapshotInput } from '../persistence/gameSaveStorage';
|
||||
import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import { type ApiRetryOptions,requestJson } from './apiClient';
|
||||
import { type ApiRetryOptions, requestJson } from './apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -58,6 +61,28 @@ function requestRuntimeJson<T>(
|
||||
);
|
||||
}
|
||||
|
||||
function requestProfileJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`/api/profile${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
@@ -105,6 +130,35 @@ export async function getSettings(options: RuntimeRequestOptions = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProfileDashboard(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfileDashboardSummary>(
|
||||
'/profile/dashboard',
|
||||
{ method: 'GET' },
|
||||
'读取个人看板失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProfileWalletLedger(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<ProfileWalletLedgerResponse>(
|
||||
'/profile/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
'读取资产流水失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
{ method: 'GET' },
|
||||
'读取游玩统计失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: RuntimeRequestOptions = {},
|
||||
@@ -121,8 +175,12 @@ export async function putSettings(
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
export async function listCustomWorldLibrary(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
@@ -132,7 +190,9 @@ export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function listCustomWorldWorks(options: RuntimeRequestOptions = {}) {
|
||||
export async function listCustomWorldWorks(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<ListCustomWorldWorksResponse>(
|
||||
'/custom-world/works',
|
||||
{ method: 'GET' },
|
||||
@@ -147,7 +207,9 @@ export async function upsertCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
@@ -170,7 +232,9 @@ export async function deleteCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
@@ -184,7 +248,9 @@ export async function publishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
@@ -201,7 +267,9 @@ export async function unpublishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
@@ -214,7 +282,9 @@ export async function unpublishCustomWorldProfile(
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCustomWorldGallery(options: RuntimeRequestOptions = {}) {
|
||||
export async function listCustomWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
@@ -230,7 +300,9 @@ export async function getCustomWorldGalleryDetail(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldGalleryDetailResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
@@ -240,12 +312,79 @@ export async function getCustomWorldGalleryDetail(
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function listProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
|
||||
'/browse-history',
|
||||
{ method: 'GET' },
|
||||
'读取浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertProfileBrowseHistory(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
|
||||
'/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
'写入浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function syncProfileBrowseHistory(
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
|
||||
'/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entries,
|
||||
} satisfies PlatformBrowseHistoryBatchSyncRequest),
|
||||
},
|
||||
'同步浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function clearProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
|
||||
'/browse-history',
|
||||
{ method: 'DELETE' },
|
||||
'清空浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export const runtimeStorageClient = {
|
||||
getSaveSnapshot,
|
||||
putSaveSnapshot,
|
||||
deleteSaveSnapshot,
|
||||
getSettings,
|
||||
putSettings,
|
||||
getProfileDashboard,
|
||||
getProfileWalletLedger,
|
||||
getProfilePlayStats,
|
||||
listCustomWorldLibrary,
|
||||
listCustomWorldWorks,
|
||||
upsertCustomWorldProfile,
|
||||
@@ -254,6 +393,11 @@ export const runtimeStorageClient = {
|
||||
unpublishCustomWorldProfile,
|
||||
listCustomWorldGallery,
|
||||
getCustomWorldGalleryDetail,
|
||||
listProfileBrowseHistory,
|
||||
upsertProfileBrowseHistory,
|
||||
syncProfileBrowseHistory,
|
||||
clearProfileBrowseHistory,
|
||||
};
|
||||
|
||||
export type { CustomWorldLibraryEntry };
|
||||
export type { PlatformBrowseHistoryEntry };
|
||||
|
||||
@@ -70,6 +70,8 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
expect(prompt).toContain('1:1 正方形画布');
|
||||
expect(prompt).toContain('大头身');
|
||||
expect(prompt).toContain('2 到 3 头身');
|
||||
expect(prompt).toContain('不是完全 90 度纯右视图');
|
||||
expect(prompt).toContain('背景固定为纯绿色绿幕');
|
||||
});
|
||||
|
||||
it('strengthens non-human species traits for siren-like characters', () => {
|
||||
@@ -81,16 +83,16 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
expect(prompt).toContain('如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色');
|
||||
expect(prompt).toContain('严格约束身体结构骨架');
|
||||
expect(prompt).toContain('沿用参考图的人形动作角色身体结构');
|
||||
expect(prompt).toContain('主题词默认只作用在角色自身');
|
||||
expect(negativePrompt).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
});
|
||||
|
||||
it('teaches the model how to interpret jellyfish king concepts', () => {
|
||||
const prompt = buildMasterPrompt('水母国王,半透明伞盖,荧光斑点,权杖。');
|
||||
it('keeps theme words on the character instead of leaking into the background', () => {
|
||||
const prompt = buildMasterPrompt('机械祭司,冷白金属外套,环形圣徽。');
|
||||
|
||||
expect(prompt).toContain('示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色');
|
||||
expect(prompt).toContain('水母主题的服装和配饰');
|
||||
expect(prompt).toContain('水母权杖');
|
||||
expect(prompt).toContain('而不是完整水母怪物本体');
|
||||
expect(prompt).toContain('主题词默认只作用在角色自身');
|
||||
expect(prompt).toContain('不要把主题词自动扩写成背景建筑');
|
||||
expect(prompt).not.toContain('水母国王');
|
||||
});
|
||||
|
||||
it('builds a repair prompt that keeps chibi ratio', () => {
|
||||
@@ -112,7 +114,7 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain('动作视频');
|
||||
expect(prompt).toContain('侧身朝右');
|
||||
expect(prompt).toContain('右向斜侧身动作视角');
|
||||
expect(prompt).toContain('像素风');
|
||||
expect(prompt).toContain('绿幕');
|
||||
expect(prompt).toContain('默认优先生成人形拟人化角色');
|
||||
@@ -122,6 +124,7 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
it('builds generic theme over-literalization negatives', () => {
|
||||
expect(buildSheetNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
expect(buildRepairNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
expect(buildMasterNegativePrompt('机械祭司')).toContain('不要把主题词自动扩写成角色以外的场景元素');
|
||||
});
|
||||
|
||||
it('contains built-in playable character style reference sources', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export type QwenSpriteActionTemplate = {
|
||||
};
|
||||
|
||||
export const DEFAULT_MASTER_NEGATIVE_PROMPT =
|
||||
'正面视角,左朝向,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
'正面视角,左朝向,完全 90 度纯右视图,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,建筑场景,道具堆叠,漂浮物,烟雾环境,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
|
||||
export const DEFAULT_SHEET_NEGATIVE_PROMPT =
|
||||
'多角色,左右朝向混乱,前视图,背视图,镜头切换,景别变化,特写,脚底裁切,头顶裁切,缺手,缺脚,额外肢体,武器消失,武器换手,服装变化,脸部变化,发型变化,动作不连续,重复帧过多,构图混乱,背景复杂,强透视,运动模糊,残影,文字,水印,UI,边框覆盖角色';
|
||||
@@ -26,19 +26,25 @@ export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
|
||||
'多角色,错误朝向,缺手,缺脚,额外肢体,武器消失,武器换手,脸部变化,发型变化,服装变化,模糊,运动模糊,复杂背景,文字,水印';
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
const SIDE_FACING_RIGHT_TEXT =
|
||||
'角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。';
|
||||
const SUBJECT_ONLY_TEXT =
|
||||
'画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。';
|
||||
const CLEAN_BACKGROUND_TEXT =
|
||||
'背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。';
|
||||
const STYLE_REFERENCE_SCOPE_TEXT =
|
||||
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
|
||||
const CONCEPT_INTERPRETATION_TEXT =
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
|
||||
const HUMANLIKE_PRIORITY_TEXT =
|
||||
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。';
|
||||
const JELLYFISH_THEME_EXAMPLE_TEXT =
|
||||
'示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。';
|
||||
const CONCEPT_HIERARCHY_TEXT =
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
|
||||
const THEME_APPLICATION_BOUNDARY_TEXT =
|
||||
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。';
|
||||
const CHIBI_CHARACTER_TEXT =
|
||||
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。';
|
||||
|
||||
@@ -417,13 +423,15 @@ export async function buildPlayableCharacterStyleReferenceBoard(
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'单人,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
'画面要求:1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
|
||||
'单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
`视角要求:${SIDE_FACING_RIGHT_TEXT}`,
|
||||
`主体要求:${SUBJECT_ONLY_TEXT}`,
|
||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。${CLEAN_BACKGROUND_TEXT}`,
|
||||
`风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
JELLYFISH_THEME_EXAMPLE_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -436,11 +444,11 @@ export function buildSheetPrompt(options: {
|
||||
extraDirection: string;
|
||||
}) {
|
||||
return [
|
||||
`使用图1作为风格参考。生成一张 4x4 的 sprite sheet,共 16 帧,展示同一个角色的连续动作。角色始终朝右,主体完整出现在每一个格子里,底部轮廓稳定,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
`使用图1作为风格参考。生成一张 4x4 的 sprite sheet,共 16 帧,展示同一个角色的连续动作。角色保持右向斜侧身动作视角,主体完整出现在每一个格子里,底部轮廓稳定,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转,也不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
JELLYFISH_THEME_EXAMPLE_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`动作名:${options.actionTemplate.label}`,
|
||||
`是否循环:${options.actionTemplate.loop ? '是' : '否'}`,
|
||||
`身体位移:${options.actionTemplate.bodyTravel}`,
|
||||
@@ -461,7 +469,7 @@ export function buildRepairPrompt(options: {
|
||||
}) {
|
||||
return [
|
||||
`使用图1作为风格参考,参考图2的动作连续性,修复图3这一个单帧。图2代表${options.useNeighborLabel}。`,
|
||||
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色始终朝右,主体完整,底部结构稳定,保持与图2连续,并且与图1是同一个角色。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${JELLYFISH_THEME_EXAMPLE_TEXT} 修复图3中的错误,使这一帧适合插回原来的 sprite sheet 中。`,
|
||||
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色保持右向斜侧身动作视角,主体完整,底部结构稳定,保持与图2连续,并且与图1是同一个角色,不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${THEME_APPLICATION_BOUNDARY_TEXT} 修复图3中的错误,使这一帧适合插回原来的 sprite sheet 中。`,
|
||||
'保持不变:发型、服装结构、主配色、武器类型、朝向。',
|
||||
`重点修复:${options.issueText.trim() || '修复手脚畸形、武器错误或朝向不一致问题。'}`,
|
||||
].join('\n');
|
||||
@@ -475,11 +483,11 @@ export function buildVideoActionPrompt(options: {
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`,
|
||||
`角色固定为图1同一角色,始终侧身朝右,镜头稳定,轮廓清晰。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
`角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
JELLYFISH_THEME_EXAMPLE_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`,
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
||||
@@ -525,7 +533,7 @@ export function restoreAllFrames(frameCount: number) {
|
||||
}
|
||||
|
||||
export function buildMasterNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,也不要把主题词自动扩写成角色以外的场景元素,除非文字设定明确要求`;
|
||||
}
|
||||
|
||||
export function buildSheetNegativePrompt(_characterBrief: string) {
|
||||
|
||||
@@ -192,6 +192,8 @@ export interface CustomWorldRoleSkill {
|
||||
name: string;
|
||||
summary: string;
|
||||
style: string;
|
||||
actionPromptText?: string;
|
||||
actionPreviewConfig?: CharacterAnimationConfig;
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleInitialItem {
|
||||
@@ -202,6 +204,13 @@ export interface CustomWorldRoleInitialItem {
|
||||
rarity: ItemRarity;
|
||||
description: string;
|
||||
tags: string[];
|
||||
iconSrc?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleRelation {
|
||||
id: string;
|
||||
targetRoleId: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleProfile {
|
||||
@@ -216,6 +225,7 @@ export interface CustomWorldRoleProfile {
|
||||
combatStyle: string;
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
relations?: CustomWorldRoleRelation[];
|
||||
tags: string[];
|
||||
backstoryReveal: CharacterBackstoryRevealConfig;
|
||||
skills: CustomWorldRoleSkill[];
|
||||
|
||||
@@ -12,6 +12,10 @@ export default defineConfig(({mode}) => {
|
||||
'**/dist_check_final/**',
|
||||
'**/dist_check_monster_position/**',
|
||||
'**/temp*build*/**',
|
||||
'**/public/generated-character-drafts/**',
|
||||
'**/public/generated-characters/**',
|
||||
'**/public/generated-custom-world-scenes/**',
|
||||
'**/public/generated-qwen-sprites/**',
|
||||
];
|
||||
const runtimeServerTarget =
|
||||
env.NODE_SERVER_TARGET ||
|
||||
@@ -65,7 +69,7 @@ export default defineConfig(({mode}) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/api/custom-world/scene-image': {
|
||||
'/api/custom-world': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
|
||||