diff --git a/.idea/.name b/.idea/.name index 9c602fec..f03513b6 100644 --- a/.idea/.name +++ b/.idea/.name @@ -1 +1 @@ -db.test.ts \ No newline at end of file +PreGameSelectionFlow.tsx \ No newline at end of file diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md b/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md new file mode 100644 index 00000000..6160dd37 --- /dev/null +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md @@ -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 = { + value: T | null; + status: AnchorStatus; + confidence: number; + sourceMessageIds: string[]; + summary: string; + openQuestions: string[]; +}; + +type EightAnchorDraft = { + worldPromise: AnchorField; + playerFantasy: AnchorField; + themeBoundary: AnchorField; + playerEntryPoint: AnchorField; + coreConflict: AnchorField; + keyRelationships: AnchorField; + hiddenLines: AnchorField; + iconicElements: AnchorField; + 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 的共识沉淀成可运行的世界底子。** diff --git a/docs/prd/MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md b/docs/prd/MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md new file mode 100644 index 00000000..3dc426e3 --- /dev/null +++ b/docs/prd/MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md @@ -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. 清空后列表立即刷新 diff --git a/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md b/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md new file mode 100644 index 00000000..74b1f621 --- /dev/null +++ b/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md @@ -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. 数据加载失败时页面表现可控 diff --git a/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md b/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md new file mode 100644 index 00000000..31c947e1 --- /dev/null +++ b/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md @@ -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 为母文档,不再重新发散一套新的“个人中心总方案”。 diff --git a/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md b/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md new file mode 100644 index 00000000..2b2fd408 --- /dev/null +++ b/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md @@ -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. 奖励发放结果可追踪 diff --git a/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md b/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md new file mode 100644 index 00000000..630a0211 --- /dev/null +++ b/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md @@ -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. 奖励到账后叙世币余额同步变化 diff --git a/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md b/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md new file mode 100644 index 00000000..b5633a81 --- /dev/null +++ b/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md @@ -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. 没有空按钮和假入口 diff --git a/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md b/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md new file mode 100644 index 00000000..4b97a37e --- /dev/null +++ b/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md @@ -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. 不需要新增独立社交系统就能跑通首期体验 diff --git a/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md b/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md new file mode 100644 index 00000000..adb0d62d --- /dev/null +++ b/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md @@ -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. 页面不出现冗长规则说明文案 diff --git a/docs/prd/MY_TAB_RECENT_PLAY_PRD_2026-04-16.md b/docs/prd/MY_TAB_RECENT_PLAY_PRD_2026-04-16.md new file mode 100644 index 00000000..2a55df5d --- /dev/null +++ b/docs/prd/MY_TAB_RECENT_PLAY_PRD_2026-04-16.md @@ -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. 无效存档不会让前端直接报错白屏 diff --git a/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md new file mode 100644 index 00000000..68cfae4a --- /dev/null +++ b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md @@ -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. 退出登录和退出全部设备都能稳定生效 diff --git a/packages/shared/src/assets/qwenSprite.ts b/packages/shared/src/assets/qwenSprite.ts index 2dff962d..e0f884c3 100644 --- a/packages/shared/src/assets/qwenSprite.ts +++ b/packages/shared/src/assets/qwenSprite.ts @@ -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 ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index c0ea586f..284964e5 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -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 = { ownerUserId: string; profileId: string; profile: TProfile; @@ -71,9 +108,7 @@ export type CustomWorldGalleryCard = Omit< 'profile' >; -export type CustomWorldLibraryResponse< - TProfile = CustomWorldProfileRecord, -> = { +export type CustomWorldLibraryResponse = { entries: CustomWorldLibraryEntry[]; }; @@ -94,6 +129,33 @@ export type CustomWorldGalleryDetailResponse< entry: CustomWorldLibraryEntry; }; +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]; diff --git a/public/generated-character-drafts/_jobs/animation/e200f8f4-8573-41c5-a408-074b79aa00ea.json b/public/generated-character-drafts/_jobs/animation/e200f8f4-8573-41c5-a408-074b79aa00ea.json new file mode 100644 index 00000000..ce5f6567 --- /dev/null +++ b/public/generated-character-drafts/_jobs/animation/e200f8f4-8573-41c5-a408-074b79aa00ea.json @@ -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" + } +} diff --git a/public/generated-character-drafts/_jobs/visual/07f73062-8706-4e1b-9100-60e941e14a8a.json b/public/generated-character-drafts/_jobs/visual/07f73062-8706-4e1b-9100-60e941e14a8a.json new file mode 100644 index 00000000..0eb7d5e9 --- /dev/null +++ b/public/generated-character-drafts/_jobs/visual/07f73062-8706-4e1b-9100-60e941e14a8a.json @@ -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" + } +} diff --git a/public/generated-character-drafts/_jobs/visual/b00e1dc0-bfc0-4583-ac21-e99b157362f1.json b/public/generated-character-drafts/_jobs/visual/b00e1dc0-bfc0-4583-ac21-e99b157362f1.json new file mode 100644 index 00000000..cb325b52 --- /dev/null +++ b/public/generated-character-drafts/_jobs/visual/b00e1dc0-bfc0-4583-ac21-e99b157362f1.json @@ -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" + } +} diff --git a/public/generated-character-drafts/story-npc-1/animation/idle/animation-video-1776329042002/job.json b/public/generated-character-drafts/story-npc-1/animation/idle/animation-video-1776329042002/job.json new file mode 100644 index 00000000..e4237a50 --- /dev/null +++ b/public/generated-character-drafts/story-npc-1/animation/idle/animation-video-1776329042002/job.json @@ -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" +} diff --git a/public/generated-character-drafts/story-npc-1/animation/idle/animation-video-1776329042002/preview.mp4 b/public/generated-character-drafts/story-npc-1/animation/idle/animation-video-1776329042002/preview.mp4 new file mode 100644 index 00000000..717edad9 Binary files /dev/null and b/public/generated-character-drafts/story-npc-1/animation/idle/animation-video-1776329042002/preview.mp4 differ diff --git a/public/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/candidate-01.png b/public/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/candidate-01.png new file mode 100644 index 00000000..0f830e1f Binary files /dev/null and b/public/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/candidate-01.png differ diff --git a/public/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/candidate-02.png b/public/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/candidate-02.png new file mode 100644 index 00000000..fabb09f7 Binary files /dev/null and b/public/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/candidate-02.png differ diff --git a/public/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/job.json b/public/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/job.json new file mode 100644 index 00000000..5d4642fc --- /dev/null +++ b/public/generated-character-drafts/story-npc-1/visual/visual-draft-1776328873530/job.json @@ -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" + ] +} diff --git a/public/generated-character-drafts/story-npc-1/workflow-cache.json b/public/generated-character-drafts/story-npc-1/workflow-cache.json new file mode 100644 index 00000000..9f9d8a67 --- /dev/null +++ b/public/generated-character-drafts/story-npc-1/workflow-cache.json @@ -0,0 +1,10 @@ +{ + "characterId": "story-npc-艾莉丝-1", + "visualPromptText": "一位身着机械风格服饰的女性,侧身朝右,立于蒸汽灯塔顶端,脚下是工厂的金属平台,她手持机械魔杖,背后的机械羽翼展开,眼神专注而冷静,散发着旧秩序守护者的威严。", + "animationPromptText": "艾莉丝优雅地操控着机械装置和魔法,她的动作流畅而果断,时而挥动魔杖释放能量,时而借助机械羽翼在空中灵活移动,战斗中的她充满了冷静与自信,每一个动作都展现出她对局势的精准判断和对力量的掌控。", + "visualDrafts": [], + "selectedVisualDraftId": "", + "selectedAnimation": "idle", + "animationMap": null, + "updatedAt": "2026-04-16T12:19:15.547Z" +} diff --git a/public/generated-character-drafts/story-npc-2/workflow-cache.json b/public/generated-character-drafts/story-npc-2/workflow-cache.json new file mode 100644 index 00000000..9d65cd1a --- /dev/null +++ b/public/generated-character-drafts/story-npc-2/workflow-cache.json @@ -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" +} diff --git a/public/generated-character-drafts/story-npc-3/workflow-cache.json b/public/generated-character-drafts/story-npc-3/workflow-cache.json new file mode 100644 index 00000000..d9575991 --- /dev/null +++ b/public/generated-character-drafts/story-npc-3/workflow-cache.json @@ -0,0 +1,10 @@ +{ + "characterId": "story-npc-莉雅-3", + "visualPromptText": "身穿粉色魔法学徒袍,手持魔法杖,绿色眼眸,棕色长发扎成马尾,身体整体朝右但保留少量正面信息,站在纯绿色绿幕背景前的少女", + "animationPromptText": "莉雅挥舞魔法杖释放魔法攻击,魔法光芒闪烁,魔法护盾围绕身体,动作流畅自然,一气呵成", + "visualDrafts": [], + "selectedVisualDraftId": "", + "selectedAnimation": "idle", + "animationMap": null, + "updatedAt": "2026-04-16T12:19:41.599Z" +} diff --git a/public/generated-character-drafts/story-npc-5/workflow-cache.json b/public/generated-character-drafts/story-npc-5/workflow-cache.json new file mode 100644 index 00000000..2d2326a4 --- /dev/null +++ b/public/generated-character-drafts/story-npc-5/workflow-cache.json @@ -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" +} diff --git a/public/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-01.png b/public/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-01.png new file mode 100644 index 00000000..c84db1eb Binary files /dev/null and b/public/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-01.png differ diff --git a/public/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-02.png b/public/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-02.png new file mode 100644 index 00000000..b52afb55 Binary files /dev/null and b/public/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/candidate-02.png differ diff --git a/public/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/job.json b/public/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/job.json new file mode 100644 index 00000000..21254cfa --- /dev/null +++ b/public/generated-character-drafts/story-npc-mo1ek7si/visual/visual-draft-1776339352734/job.json @@ -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" + ] +} diff --git a/public/generated-character-drafts/story-npc-mo1ek7si/workflow-cache.json b/public/generated-character-drafts/story-npc-mo1ek7si/workflow-cache.json new file mode 100644 index 00000000..cf81f180 --- /dev/null +++ b/public/generated-character-drafts/story-npc-mo1ek7si/workflow-cache.json @@ -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" +} diff --git a/public/generated-characters/story-npc-1/visual/visual-1776328980091/master.png b/public/generated-characters/story-npc-1/visual/visual-1776328980091/master.png new file mode 100644 index 00000000..0f830e1f Binary files /dev/null and b/public/generated-characters/story-npc-1/visual/visual-1776328980091/master.png differ diff --git a/public/generated-characters/story-npc-1/visual/visual-1776328980091/preview-1.png b/public/generated-characters/story-npc-1/visual/visual-1776328980091/preview-1.png new file mode 100644 index 00000000..0f830e1f Binary files /dev/null and b/public/generated-characters/story-npc-1/visual/visual-1776328980091/preview-1.png differ diff --git a/public/generated-characters/story-npc-1/visual/visual-1776328980091/preview-2.png b/public/generated-characters/story-npc-1/visual/visual-1776328980091/preview-2.png new file mode 100644 index 00000000..fabb09f7 Binary files /dev/null and b/public/generated-characters/story-npc-1/visual/visual-1776328980091/preview-2.png differ diff --git a/public/generated-characters/story-npc-1/visual/visual-1776328980091/visual-manifest.json b/public/generated-characters/story-npc-1/visual/visual-1776328980091/visual-manifest.json new file mode 100644 index 00000000..6c59bdf1 --- /dev/null +++ b/public/generated-characters/story-npc-1/visual/visual-1776328980091/visual-manifest.json @@ -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 +} diff --git a/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/master.png b/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/master.png new file mode 100644 index 00000000..c84db1eb Binary files /dev/null and b/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/master.png differ diff --git a/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/preview-1.png b/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/preview-1.png new file mode 100644 index 00000000..c84db1eb Binary files /dev/null and b/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/preview-1.png differ diff --git a/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/preview-2.png b/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/preview-2.png new file mode 100644 index 00000000..b52afb55 Binary files /dev/null and b/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/preview-2.png differ diff --git a/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/visual-manifest.json b/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/visual-manifest.json new file mode 100644 index 00000000..a6e4ba49 --- /dev/null +++ b/public/generated-characters/story-npc-mo1ek7si/visual/visual-1776339356501/visual-manifest.json @@ -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 +} diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index a1ef8272..b4cea1cb 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -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; + }; + + 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; + }; + + 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; + }; + + assert.equal(clearedHistoryResponse.status, 200); + assert.deepEqual(clearedHistoryPayload.entries, []); + }); +}); diff --git a/server-node/src/app.ts b/server-node/src/app.ts index 7f0b3097..b0efbc43 100644 --- a/server-node/src/app.ts +++ b/server-node/src/app.ts @@ -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( diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts index a7fbc32b..5685065f 100644 --- a/server-node/src/db.test.ts +++ b/server-node/src/db.test.ts @@ -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, ); diff --git a/server-node/src/db/migrations.ts b/server-node/src/db/migrations.ts index 3031db43..a2a15d95 100644 --- a/server-node/src/db/migrations.ts +++ b/server-node/src/db/migrations.ts @@ -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)`, + ], + }, ]; diff --git a/server-node/src/modules/assets/characterAssetRoutes.test.ts b/server-node/src/modules/assets/characterAssetRoutes.test.ts index b059735e..137e4f18 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.test.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.test.ts @@ -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; + } | 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'); diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts index d37ac39c..8065deed 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -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 | null; + updatedAt: string; +}; + +function serializeWorkflowCacheComparableValue( + value: CharacterAssetWorkflowCacheRecord | Record, +) { + 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; + + 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; + 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; + 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, + ); + + 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) => diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index ea171299..0bbf48a6 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -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; putSnapshot( userId: string, payload: Omit, ): Promise; + getProfileDashboard(userId: string): Promise; + listProfileWalletLedger(userId: string): Promise; + getProfilePlayStats(userId: string): Promise; deleteSnapshot(userId: string): Promise; getSettings(userId: string): Promise; putSettings( @@ -87,6 +145,14 @@ export type RuntimeRepositoryPort = { listCustomWorldProfiles( userId: string, ): Promise[]>; + listPlatformBrowseHistory( + userId: string, + ): Promise; + upsertPlatformBrowseHistoryEntries( + userId: string, + entries: PlatformBrowseHistoryWriteEntry[], + ): Promise; + clearPlatformBrowseHistory(userId: string): Promise; upsertCustomWorldProfile( userId: string, profileId: string, @@ -100,9 +166,7 @@ export type RuntimeRepositoryPort = { userId: string, profileId: string, ): Promise[]>; - listCustomWorldSessions( - userId: string, - ): Promise; + listCustomWorldSessions(userId: string): Promise; 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 | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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( `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( + `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( + `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( `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( + `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( + `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( + `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( `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; } diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index 5c926d09..bcb24e19 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -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; + 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; + 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( + 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(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( + 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(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(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(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); }), ); diff --git a/server-node/src/services/customWorldEntityGenerationService.ts b/server-node/src/services/customWorldEntityGenerationService.ts new file mode 100644 index 00000000..4e0854d9 --- /dev/null +++ b/server-node/src/services/customWorldEntityGenerationService.ts @@ -0,0 +1,1050 @@ +import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; +import { badRequest } from '../errors.js'; +import type { UpstreamLlmClient } from './llmClient.js'; + +type CustomWorldEntityKind = 'playable' | 'story' | 'landmark'; + +type GenerateCustomWorldEntityInput = { + profile: Record; + kind: CustomWorldEntityKind; +}; + +type ParsedRole = { + id: string; + name: string; + title: string; + role: string; + description: string; + backstory: string; + personality: string; + motivation: string; + combatStyle: string; + initialAffinity: number; + relationshipHooks: string[]; + tags: string[]; +}; + +type ParsedLandmarkConnection = { + targetLandmarkId: string; + summary: string; + relativePosition: string; +}; + +type ParsedLandmark = { + id: string; + name: string; + description: string; + dangerLevel: string; + sceneNpcIds: string[]; + connections: ParsedLandmarkConnection[]; +}; + +type ParsedProfile = { + name: string; + settingText: string; + summary: string; + tone: string; + playerGoal: string; + playableNpcs: ParsedRole[]; + storyNpcs: ParsedRole[]; + landmarks: ParsedLandmark[]; +}; + +const BACKSTORY_CHAPTERS = [ + { id: 'surface', title: '表层来意', affinityRequired: 6 }, + { id: 'scar', title: '旧事裂痕', affinityRequired: 12 }, + { id: 'hidden', title: '隐藏执念', affinityRequired: 18 }, + { id: 'final', title: '最终底牌', affinityRequired: 24 }, +] as const; + +const ROLE_SURNAME_POOL = [ + '沈', + '顾', + '裴', + '闻', + '纪', + '苏', + '岑', + '陆', + '白', + '商', + '温', + '严', + '黎', + '季', +] as const; + +const ROLE_GIVEN_POOL = [ + '砺', + '岚', + '澄', + '栖', + '弦', + '朔', + '遥', + '霁', + '衡', + '铃', + '潮', + '燧', + '宁', + '鸢', +] as const; + +const PLAYABLE_ROLE_POOL = [ + '同行策士', + '前线斥候', + '旧誓护卫', + '异闻译者', + '禁制解读者', + '地脉向导', +] as const; + +const STORY_ROLE_POOL = [ + '守望者', + '情报掮客', + '巡夜人', + '渡口看守', + '旧案证人', + '异类来客', +] as const; + +const LANDMARK_PREFIX_POOL = [ + '潮碑', + '沉钟', + '雾湾', + '灰塔', + '回潮', + '旧航', + '断潮', + '盐火', +] as const; + +const LANDMARK_SUFFIX_POOL = [ + '前哨', + '档案楼', + '栈桥', + '工坊', + '集市', + '观测台', + '驿站', + '藏书阁', +] as const; + +function toRecord(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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 extractJsonPayload(content: string) { + const fencedMatch = content.match(/```(?:json)?\s*([\s\S]+?)\s*```/u); + if (fencedMatch?.[1]) { + return fencedMatch[1].trim(); + } + + const objectStart = content.indexOf('{'); + const objectEnd = content.lastIndexOf('}'); + if (objectStart >= 0 && objectEnd > objectStart) { + return content.slice(objectStart, objectEnd + 1); + } + + return content.trim(); +} + +function normalizeRole(value: unknown): ParsedRole | 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), + backstory: toText(record.backstory), + personality: toText(record.personality), + motivation: toText(record.motivation), + combatStyle: toText(record.combatStyle), + initialAffinity: + typeof record.initialAffinity === 'number' && + Number.isFinite(record.initialAffinity) + ? Math.round(record.initialAffinity) + : 0, + 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; + } + + const connections = Array.isArray(record.connections) + ? record.connections + .map((item) => { + const connection = toRecord(item); + if (!connection) { + return null; + } + + const targetLandmarkId = toText(connection.targetLandmarkId); + if (!targetLandmarkId) { + return null; + } + + return { + targetLandmarkId, + summary: toText(connection.summary), + relativePosition: toText(connection.relativePosition, 'forward'), + } satisfies ParsedLandmarkConnection; + }) + .filter( + (item): item is ParsedLandmarkConnection => item !== null, + ) + .slice(0, 8) + : []; + + return { + id, + name, + description: toText(record.description), + dangerLevel: toText(record.dangerLevel, 'medium'), + sceneNpcIds: toStringArray(record.sceneNpcIds, 12), + connections, + }; +} + +function normalizeProfile(value: unknown): ParsedProfile { + const record = toRecord(value); + if (!record) { + throw badRequest('profile is required'); + } + + return { + name: toText(record.name, '自定义世界'), + settingText: toText(record.settingText), + summary: toText(record.summary), + tone: toText(record.tone), + playerGoal: toText(record.playerGoal), + playableNpcs: Array.isArray(record.playableNpcs) + ? record.playableNpcs + .map(normalizeRole) + .filter((item): item is ParsedRole => item !== null) + : [], + storyNpcs: Array.isArray(record.storyNpcs) + ? record.storyNpcs + .map(normalizeRole) + .filter((item): item is ParsedRole => item !== null) + : [], + landmarks: Array.isArray(record.landmarks) + ? record.landmarks + .map(normalizeLandmark) + .filter((item): item is ParsedLandmark => item !== null) + : [], + }; +} + +function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) { + if (roles.length === 0) { + return emptyText; + } + + return roles + .slice(0, 12) + .map( + (role, index) => + `${index + 1}. ${role.name} / ${role.title || role.role} / 身份:${ + role.role || '未写' + } / 描述:${role.description || '未写'} / 背景:${ + role.backstory || '未写' + } / 性格:${role.personality || '未写'} / 动机:${ + role.motivation || '未写' + } / 标签:${role.tags.join('、') || '暂无'}`, + ) + .join('\n'); +} + +function buildLandmarkReferenceText(profile: ParsedProfile) { + if (profile.landmarks.length === 0) { + return '当前还没有场景设定。'; + } + + const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc])); + const landmarkById = new Map( + profile.landmarks.map((landmark) => [landmark.id, landmark]), + ); + + return profile.landmarks + .slice(0, 12) + .map((landmark, index) => { + const sceneNpcNames = landmark.sceneNpcIds + .map((npcId) => storyNpcById.get(npcId)?.name ?? '') + .filter(Boolean) + .join('、'); + const connectionNames = landmark.connections + .map((connection) => { + const targetName = + landmarkById.get(connection.targetLandmarkId)?.name || + connection.targetLandmarkId; + return `${targetName}(${connection.relativePosition} / ${ + connection.summary || '无说明' + })`; + }) + .join('、'); + + return `${index + 1}. ${landmark.name} / 危险度:${ + landmark.dangerLevel || 'medium' + } / 描述:${landmark.description || '未写'} / 场景角色:${ + sceneNpcNames || '暂无' + } / 连接:${connectionNames || '暂无'}`; + }) + .join('\n'); +} + +function buildUniqueRoleName(existingNames: Set, startIndex: number) { + for (let attempt = 0; attempt < 120; attempt += 1) { + const index = startIndex + attempt; + const surname = ROLE_SURNAME_POOL[index % ROLE_SURNAME_POOL.length]; + const firstName = + ROLE_GIVEN_POOL[ + Math.floor(index / ROLE_SURNAME_POOL.length) % ROLE_GIVEN_POOL.length + ]; + const secondName = ROLE_GIVEN_POOL[(index + 5) % ROLE_GIVEN_POOL.length]; + const candidate = `${surname}${firstName}${secondName}`; + + if (!existingNames.has(candidate)) { + existingNames.add(candidate); + return candidate; + } + } + + const fallback = `新角色${existingNames.size + 1}`; + existingNames.add(fallback); + return fallback; +} + +function buildUniqueLandmarkName(existingNames: Set, startIndex: number) { + for (let attempt = 0; attempt < 120; attempt += 1) { + const index = startIndex + attempt; + const candidate = `${LANDMARK_PREFIX_POOL[index % LANDMARK_PREFIX_POOL.length]}${ + LANDMARK_SUFFIX_POOL[ + Math.floor(index / LANDMARK_PREFIX_POOL.length) % + LANDMARK_SUFFIX_POOL.length + ] + }`; + + if (!existingNames.has(candidate)) { + existingNames.add(candidate); + return candidate; + } + } + + const fallback = `新场景${existingNames.size + 1}`; + existingNames.add(fallback); + return fallback; +} + +function buildFallbackRoleDraft( + profile: ParsedProfile, + kind: 'playable' | 'story', +) { + const existingNames = new Set( + [...profile.playableNpcs, ...profile.storyNpcs].map((role) => role.name), + ); + const name = buildUniqueRoleName(existingNames, existingNames.size + 1); + const roleTitlePool = + kind === 'playable' ? PLAYABLE_ROLE_POOL : STORY_ROLE_POOL; + const role = roleTitlePool[existingNames.size % roleTitlePool.length]; + const relationHook = + kind === 'playable' + ? `愿意与玩家共同推进“${profile.playerGoal || profile.summary || profile.name}”` + : `与“${profile.playerGoal || profile.summary || profile.name}”这条局势线直接相关`; + + return { + name, + title: role, + role, + description: clampText( + kind === 'playable' + ? `适合与玩家同行,能补足当前队伍短板的关键角色。` + : `长期活跃于当前世界暗面,能补足场景视角的关键角色。`, + 60, + ), + backstory: clampText( + `他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`, + 80, + ), + personality: kind === 'playable' ? '克制、敏锐,擅长合作推进。' : '谨慎、耐心,擅长观察局势。', + motivation: clampText( + kind === 'playable' + ? `希望借玩家的选择改变当前世界里已经失衡的局面。` + : `想借玩家的介入撬动一条已经僵住的关系链。`, + 72, + ), + combatStyle: + kind === 'playable' + ? '偏向协作压制与局势调度。' + : '偏向试探牵制与环境借势。', + initialAffinity: kind === 'playable' ? 22 : 6, + relationshipHooks: dedupeStrings( + [relationHook, profile.landmarks[0]?.name ? `常在${profile.landmarks[0].name}附近活动` : '', profile.playableNpcs[0]?.name ? `会先试探${profile.playableNpcs[0].name}与玩家的关系` : '会先试探玩家立场'], + 3, + ), + tags: dedupeStrings( + [profile.name, profile.tone, kind === 'playable' ? '可同行' : '场景线'], + 4, + ), + publicSummary: + kind === 'playable' + ? '一名能与玩家形成互补的新同行者。' + : '一名能补足当前场景关系网的新角色。', + chapterTeasers: [ + '他出现得并不偶然。', + '他与旧局势之间有一道未说透的裂痕。', + '他真正站队的理由比表面更复杂。', + '他留着最后一张不会轻易掀开的牌。', + ], + chapterContents: [ + `他在${profile.name}当前局势里早就留下了自己的位置。`, + '一段旧事让他无法再把自己完全抽离出去。', + '他真正想守住的并不是表面上说出口的东西。', + '一旦走到临界点,他会把最关键的底牌押上桌面。', + ], + skills: [ + { + name: kind === 'playable' ? '协作先手' : '观察起手', + summary: '先稳住局面,再把主动权拉回自己手里。', + style: '起手压制', + }, + { + name: kind === 'playable' ? '阵线补位' : '地形借势', + summary: '借助环境和站位撕开短暂缺口。', + style: '机动周旋', + }, + { + name: kind === 'playable' ? '压轴回合' : '暗线反制', + summary: '在关键回合揭出隐藏准备,改变节奏。', + style: '爆发终结', + }, + ], + initialItems: [ + { + name: kind === 'playable' ? '随身兵装' : '私藏器具', + category: '武器', + quantity: 1, + rarity: 'rare' as const, + description: '与其身份长期绑定的常备装备。', + tags: ['自定义'], + }, + { + name: kind === 'playable' ? '路书残页' : '情报残页', + category: '专属物品', + quantity: 1, + rarity: 'rare' as const, + description: '记录着只属于他自己的线索与判断。', + tags: ['线索'], + }, + { + name: '应急补给', + category: '消耗品', + quantity: 2, + rarity: 'uncommon' as const, + description: '面对突发局势时会先拿出来的保底物资。', + tags: ['备用'], + }, + ], + }; +} + +function buildFallbackLandmarkDraft(profile: ParsedProfile) { + const existingNames = new Set(profile.landmarks.map((landmark) => landmark.name)); + const name = buildUniqueLandmarkName(existingNames, profile.landmarks.length + 1); + const sceneNpcNames = profile.storyNpcs.slice(0, 3).map((npc) => npc.name); + const targetLandmarkNames = profile.landmarks.slice(0, 2).map((landmark) => landmark.name); + + return { + name, + description: clampText( + `承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`, + 72, + ), + dangerLevel: 'medium', + sceneNpcNames, + connections: targetLandmarkNames.map((targetLandmarkName, index) => ({ + targetLandmarkName, + relativePosition: index === 0 ? 'forward' : 'inside', + summary: index === 0 ? `沿主路可抵达${targetLandmarkName}` : `可从暗线进入${targetLandmarkName}`, + })), + }; +} + +function buildPlayablePrompt(profile: ParsedProfile) { + return [ + `世界名:${profile.name}`, + `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, + `世界摘要:${profile.summary || '未填写'}`, + `世界基调:${profile.tone || '未填写'}`, + `玩家主线目标:${profile.playerGoal || '未填写'}`, + `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, + `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, + `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, + '请基于上面全部上下文,生成 1 名新的“可扮演角色”。', + '要求:', + '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', + '- 必须保留明确的协作价值、成长空间和入队理由。', + '- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。', + '- 只返回 JSON,不要输出解释或 Markdown。', + 'JSON 结构:', + '{', + ' "playableNpc": {', + ' "name": "角色名",', + ' "title": "称号",', + ' "role": "身份",', + ' "description": "一句到两句定位描述",', + ' "backstory": "背景经历",', + ' "personality": "性格特点",', + ' "motivation": "当前动机",', + ' "combatStyle": "战斗风格",', + ' "initialAffinity": 22,', + ' "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": "rare", "description": "说明", "tags": ["标签"] },', + ' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }', + ' ]', + ' }', + '}', + ].join('\n'); +} + +function buildStoryPrompt(profile: ParsedProfile) { + return [ + `世界名:${profile.name}`, + `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, + `世界摘要:${profile.summary || '未填写'}`, + `世界基调:${profile.tone || '未填写'}`, + `玩家主线目标:${profile.playerGoal || '未填写'}`, + `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, + `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, + `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, + '请基于上面全部上下文,生成 1 名新的“场景角色”。', + '要求:', + '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', + '- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。', + '- 角色应与具体场景、关系链或局势变化发生绑定。', + '- 只返回 JSON,不要输出解释或 Markdown。', + 'JSON 结构:', + '{', + ' "storyNpc": {', + ' "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": "rare", "description": "说明", "tags": ["标签"] },', + ' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }', + ' ]', + ' }', + '}', + ].join('\n'); +} + +function buildLandmarkPrompt(profile: ParsedProfile) { + return [ + `世界名:${profile.name}`, + `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, + `世界摘要:${profile.summary || '未填写'}`, + `世界基调:${profile.tone || '未填写'}`, + `玩家主线目标:${profile.playerGoal || '未填写'}`, + `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, + `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, + `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, + '请基于上面全部上下文,生成 1 个新的“场景”。', + '要求:', + '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。', + '- 必须给出适合出现在这个新场景里的 sceneNpcNames,且只能从已有场景角色里选择至少 3 个名字。', + '- 必须给出 connections,且 targetLandmarkName 只能引用已有场景名,不要连向自己。', + '- 只返回 JSON,不要输出解释或 Markdown。', + 'JSON 结构:', + '{', + ' "landmark": {', + ' "name": "场景名",', + ' "description": "场景描述",', + ' "dangerLevel": "low|medium|high|extreme",', + ' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],', + ' "connections": [', + ' { "targetLandmarkName": "已有场景名", "relativePosition": "forward", "summary": "通路说明" },', + ' { "targetLandmarkName": "已有场景名", "relativePosition": "inside", "summary": "通路说明" }', + ' ]', + ' }', + '}', + ].join('\n'); +} + +function ensureUniqueName(name: string, existingNames: string[], fallbackName: string) { + const normalized = name.trim() || fallbackName; + if (!existingNames.includes(normalized)) { + return normalized; + } + + let index = 2; + let nextName = `${normalized}${index}`; + while (existingNames.includes(nextName)) { + index += 1; + nextName = `${normalized}${index}`; + } + return nextName; +} + +function sanitizeGeneratedRole( + rawValue: unknown, + profile: ParsedProfile, + kind: 'playable' | 'story', +) { + const record = toRecord(rawValue); + const fallbackDraft = buildFallbackRoleDraft(profile, kind); + const existingNames = [...profile.playableNpcs, ...profile.storyNpcs].map( + (role) => role.name, + ); + const seed = Date.now().toString(36); + const relationshipHooks = dedupeStrings( + toStringArray(record?.relationshipHooks, 6).concat( + fallbackDraft.relationshipHooks, + ), + 4, + ); + const tags = dedupeStrings( + toStringArray(record?.tags, 8).concat(fallbackDraft.tags), + 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 name = ensureUniqueName( + toText(record?.name, fallbackDraft.name), + existingNames, + fallbackDraft.name, + ); + + return { + id: createStableId( + kind === 'playable' ? 'playable-npc' : 'story-npc', + name, + seed, + ), + name, + title: clampText(toText(record?.title, fallbackDraft.title), 20), + role: clampText( + toText(record?.role, fallbackDraft.role || fallbackDraft.title), + 20, + ), + description: clampText( + toText(record?.description, fallbackDraft.description), + 120, + ), + backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260), + personality: clampText( + toText(record?.personality, fallbackDraft.personality), + 120, + ), + motivation: clampText( + toText(record?.motivation, fallbackDraft.motivation), + 120, + ), + combatStyle: clampText( + toText(record?.combatStyle, fallbackDraft.combatStyle), + 120, + ), + initialAffinity: + typeof record?.initialAffinity === 'number' && + Number.isFinite(record.initialAffinity) + ? Math.round( + Math.max( + kind === 'playable' ? 12 : -40, + Math.min(90, record.initialAffinity), + ), + ) + : fallbackDraft.initialAffinity, + relationshipHooks, + relations: [], + tags, + backstoryReveal: { + publicSummary: clampText( + toText(record?.publicSummary, fallbackDraft.publicSummary), + 120, + ), + chapters: BACKSTORY_CHAPTERS.map((chapter, index) => ({ + id: chapter.id, + title: chapter.title, + affinityRequired: chapter.affinityRequired, + teaser: + chapterTeasers[index] ?? + fallbackDraft.chapterTeasers[index] ?? + fallbackDraft.chapterTeasers[0], + content: + chapterContents[index] ?? + fallbackDraft.chapterContents[index] ?? + fallbackDraft.chapterContents[0], + contextSnippet: clampText( + chapterContents[index] ?? + fallbackDraft.chapterContents[index] ?? + fallbackDraft.chapterContents[0], + 36, + ), + })), + }, + skills: + skillRecords.length >= 3 + ? skillRecords.slice(0, 3).map((skill, index) => { + const skillRecord = toRecord(skill); + const fallbackSkill = + fallbackDraft.skills[index] ?? fallbackDraft.skills[0]; + return { + id: createStableId( + 'skill', + `${name}-${toText(skillRecord?.name, fallbackSkill.name)}`, + `${seed}-${index + 1}`, + ), + name: clampText( + toText(skillRecord?.name, fallbackSkill.name), + 20, + ), + summary: clampText( + toText(skillRecord?.summary, fallbackSkill.summary), + 60, + ), + style: clampText( + toText(skillRecord?.style, fallbackSkill.style), + 20, + ), + }; + }) + : fallbackDraft.skills.map((skill, index) => ({ + id: createStableId('skill', `${name}-${skill.name}`, `${seed}-${index + 1}`), + name: skill.name, + summary: skill.summary, + style: skill.style, + })), + 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); + return { + id: createStableId( + 'item', + `${name}-${toText(itemRecord?.name, fallbackItem.name)}`, + `${seed}-${index + 1}`, + ), + name: clampText(toText(itemRecord?.name, fallbackItem.name), 20), + category: clampText( + toText(itemRecord?.category, fallbackItem.category), + 16, + ), + quantity: + typeof itemRecord?.quantity === 'number' && + Number.isFinite(itemRecord.quantity) + ? Math.max(1, Math.min(9, Math.round(itemRecord.quantity))) + : fallbackItem.quantity, + rarity: + rarity === 'common' || + rarity === 'uncommon' || + rarity === 'rare' || + rarity === 'epic' || + rarity === 'legendary' + ? rarity + : fallbackItem.rarity, + description: clampText( + toText(itemRecord?.description, fallbackItem.description), + 80, + ), + tags: dedupeStrings( + toStringArray(itemRecord?.tags, 4).concat(fallbackItem.tags), + 4, + ), + }; + }) + : fallbackDraft.initialItems.map((item, index) => ({ + id: createStableId('item', `${name}-${item.name}`, `${seed}-${index + 1}`), + name: item.name, + category: item.category, + quantity: item.quantity, + rarity: item.rarity, + description: item.description, + tags: item.tags, + })), + }; +} + +function sanitizeGeneratedLandmark(rawValue: unknown, profile: ParsedProfile) { + const record = toRecord(rawValue); + const fallbackDraft = buildFallbackLandmarkDraft(profile); + const existingNames = profile.landmarks.map((landmark) => landmark.name); + const name = ensureUniqueName( + toText(record?.name, fallbackDraft.name), + existingNames, + fallbackDraft.name, + ); + const seed = Date.now().toString(36); + const storyNpcByName = new Map( + profile.storyNpcs.map((npc) => [npc.name.trim(), npc.id]), + ); + const landmarkByName = new Map( + profile.landmarks.map((landmark) => [landmark.name.trim(), landmark.id]), + ); + const rawSceneNpcNames = toStringArray(record?.sceneNpcNames, 12); + const rawConnections = Array.isArray(record?.connections) ? record.connections : []; + const resolvedSceneNpcIds = dedupeStrings( + rawSceneNpcNames + .map((npcName) => storyNpcByName.get(npcName.trim()) ?? '') + .concat( + fallbackDraft.sceneNpcNames + .map((npcName) => storyNpcByName.get(npcName.trim()) ?? '') + .filter(Boolean), + ), + 3, + ); + const fallbackSceneNpcIds = dedupeStrings( + profile.storyNpcs.slice(0, 3).map((npc) => npc.id), + 3, + ); + const sceneNpcIds = + resolvedSceneNpcIds.length >= 3 ? resolvedSceneNpcIds : fallbackSceneNpcIds; + + const connections = rawConnections + .map((item, index) => { + const connection = toRecord(item); + if (!connection) { + return null; + } + const targetLandmarkId = + landmarkByName.get(toText(connection.targetLandmarkName)) ?? + landmarkByName.get(toText(connection.targetLandmarkId)) ?? + ''; + if (!targetLandmarkId) { + return null; + } + + return { + targetLandmarkId, + relativePosition: toText( + connection.relativePosition, + index === 0 ? 'forward' : 'inside', + ), + summary: clampText( + toText( + connection.summary, + fallbackDraft.connections[index]?.summary || '可通往相邻区域', + ), + 24, + ), + }; + }) + .filter((item): item is ParsedLandmarkConnection => item !== null) + .filter((item) => item.targetLandmarkId); + + const fallbackConnections = fallbackDraft.connections + .map((connection) => { + const targetLandmarkId = + landmarkByName.get(connection.targetLandmarkName.trim()) ?? ''; + if (!targetLandmarkId) { + return null; + } + return { + targetLandmarkId, + relativePosition: connection.relativePosition, + summary: connection.summary, + } satisfies ParsedLandmarkConnection; + }) + .filter((item): item is ParsedLandmarkConnection => item !== null); + + return { + id: createStableId('landmark', name, seed), + name, + description: clampText( + toText(record?.description, fallbackDraft.description), + 140, + ), + dangerLevel: (() => { + const level = toText(record?.dangerLevel, fallbackDraft.dangerLevel); + return level === 'low' || + level === 'medium' || + level === 'high' || + level === 'extreme' + ? level + : 'medium'; + })(), + sceneNpcIds, + connections: (connections.length > 0 ? connections : fallbackConnections).slice(0, 3), + }; +} + +async function requestGeneratedEntity( + llmClient: UpstreamLlmClient, + kind: CustomWorldEntityKind, + profile: ParsedProfile, +) { + const userPrompt = + kind === 'playable' + ? buildPlayablePrompt(profile) + : kind === 'story' + ? buildStoryPrompt(profile) + : buildLandmarkPrompt(profile); + + const content = await llmClient.requestMessageContent({ + systemPrompt: + '你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。', + userPrompt, + timeoutMs: 45000, + debugLabel: `custom-world-generate-${kind}`, + }); + + return parseJsonResponseText(extractJsonPayload(content)); +} + +export async function generateCustomWorldEntity( + llmClient: UpstreamLlmClient, + input: GenerateCustomWorldEntityInput, +) { + const profile = normalizeProfile(input.profile); + + try { + const parsed = await requestGeneratedEntity(llmClient, input.kind, profile); + const record = toRecord(parsed); + + if (input.kind === 'playable') { + return { + kind: 'playable' as const, + entity: sanitizeGeneratedRole(record?.playableNpc ?? parsed, profile, 'playable'), + }; + } + + if (input.kind === 'story') { + return { + kind: 'story' as const, + entity: sanitizeGeneratedRole(record?.storyNpc ?? parsed, profile, 'story'), + }; + } + + return { + kind: 'landmark' as const, + entity: sanitizeGeneratedLandmark(record?.landmark ?? parsed, profile), + }; + } catch { + if (input.kind === 'playable') { + return { + kind: 'playable' as const, + entity: sanitizeGeneratedRole(null, profile, 'playable'), + }; + } + + if (input.kind === 'story') { + return { + kind: 'story' as const, + entity: sanitizeGeneratedRole(null, profile, 'story'), + }; + } + + return { + kind: 'landmark' as const, + entity: sanitizeGeneratedLandmark(null, profile), + }; + } +} diff --git a/server-node/src/services/customWorldSceneNpcGenerationService.ts b/server-node/src/services/customWorldSceneNpcGenerationService.ts new file mode 100644 index 00000000..8e271131 --- /dev/null +++ b/server-node/src/services/customWorldSceneNpcGenerationService.ts @@ -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; + 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) + : 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); + } +} diff --git a/server-node/src/services/sceneImageService.test.ts b/server-node/src/services/sceneImageService.test.ts index 29952c80..c679006f 100644 --- a/server-node/src/services/sceneImageService.test.ts +++ b/server-node/src/services/sceneImageService.test.ts @@ -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( } } -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, '废墟月台像素风场景'); + }, + ); +}); diff --git a/server-node/src/services/sceneImageService.ts b/server-node/src/services/sceneImageService.ts index b21972e3..98e6532c 100644 --- a/server-node/src/services/sceneImageService.ts +++ b/server-node/src/services/sceneImageService.ts @@ -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) { return [...new Set(urls)]; } -function ensurePayload( - payload: z.infer, - defaultModel: string, -) { - if (!payload.landmarkName && !payload.landmarkId) { - throw badRequest('landmarkName 或 landmarkId 至少要提供一个'); +async function createSceneImageTask(params: { + baseUrl: string; + apiKey: string; + payload: z.infer; +}) { + 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, }; } -export async function generateSceneImage( - context: AppContext, - input: z.infer, -) { - 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; + 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; - 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; - 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; + 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, + _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; + 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, +) { + 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; + 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, + }); +} diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 3d051735..a6b9330d 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -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({ ) : null} - {actions} +
+ {badge} + {actions} +
{children}
@@ -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({ @@ -161,23 +183,120 @@ function EmptyState({ title }: { title: string }) { ); } +function NewBadge() { + return ( + + 新 + + ); +} + +function PendingEntityCard({ + title, + phaseLabel, + progress, +}: { + title: string; + phaseLabel: string; + progress: number; +}) { + return ( +
+
+
+
{title}
+
+ {phaseLabel} +
+
+
+ {Math.round(progress)}% +
+
+
+
+
+
+ ); +} + 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 ? ( +
+ {isSelected ? '已选' : '选择'} +
+ ) : null; + + if (layout === 'compact') { + return ( + + ); + } + return ( + {open ? ( + + {info} + + ) : null} + + ); +} + function TextInput({ value, onChange, @@ -499,7 +725,16 @@ function ActionButton({ return ( + ); + })} +
+
+ + { + onApply(draftSelection); + onClose(); + }} + tone="sky" + /> +
+ + + ); +} + +function ConnectionDirectionSlot({ + direction, + targetName, + compact = false, + onClick, +}: { + direction: CardinalConnectionDirection; + targetName?: string; + compact?: boolean; + onClick?: (() => void) | null; +}) { + const content = ( +
+
+ {CARDINAL_CONNECTION_LABELS[direction]} +
+
+ {targetName || '空'} +
+
+ ); + + if (!onClick) { + return content; + } + + return ( + + ); +} + +function DirectionalSceneConnectionCompass({ + centerName, + directionTargets, + compact = false, + onDirectionClick, +}: { + centerName: string; + directionTargets: Partial>; + compact?: boolean; + onDirectionClick?: ((direction: CardinalConnectionDirection) => void) | null; +}) { + return ( +
+
+ onDirectionClick('north') : undefined + } + /> +
+ onDirectionClick('west') : undefined} + /> +
+
+ 当前场景 +
+
+ {centerName} +
+
+ onDirectionClick('east') : undefined} + /> +
+ onDirectionClick('south') : undefined + } + /> +
+
+ ); +} + +function SceneConnectionTargetPickerModal({ + direction, + landmarks, + currentTargetId, + onSelect, + onRemove, + onClose, +}: { + direction: CardinalConnectionDirection; + landmarks: CustomWorldLandmark[]; + currentTargetId?: string; + onSelect: (landmarkId: string) => void; + onRemove: () => void; + onClose: () => void; +}) { + return ( + +
+ {landmarks.map((landmark) => { + const isSelected = landmark.id === currentTargetId; + return ( + + ); + })} +
+ + {currentTargetId ? ( + { + onRemove(); + onClose(); + }} + /> + ) : null} +
+
+
+ ); +} + +type WorldMapNodeLayout = { + id: string; + name: string; + description: string; + left: number; + top: number; + centerX: number; + centerY: number; +}; + +type WorldMapEdgeLayout = { + fromId: string; + toId: string; +}; + +const WORLD_MAP_NODE_WIDTH = 152; +const WORLD_MAP_NODE_HEIGHT = 88; +const WORLD_MAP_GRID_WIDTH = 196; +const WORLD_MAP_GRID_HEIGHT = 132; +const WORLD_MAP_PADDING = 48; + +function getWorldMapDirectionOffset(direction: CardinalConnectionDirection) { + switch (direction) { + case 'north': + return { x: 0, y: -1 }; + case 'east': + return { x: 1, y: 0 }; + case 'south': + return { x: 0, y: 1 }; + case 'west': + return { x: -1, y: 0 }; + } +} + +function buildWorldMapLayout(landmarks: CustomWorldLandmark[]) { + const directionalConnectionMap = new Map( + landmarks.map((landmark) => [ + landmark.id, + buildDirectionalConnections(landmark.connections, landmarks), + ]), + ); + const landmarkById = new Map(landmarks.map((landmark) => [landmark.id, landmark])); + const positions = new Map(); + const occupied = new Map(); + const queue: string[] = []; + let clusterAnchorX = 0; + + const reservePosition = (landmarkId: string, x: number, y: number) => { + const key = `${x},${y}`; + const occupiedBy = occupied.get(key); + if (!occupiedBy || occupiedBy === landmarkId) { + occupied.set(key, landmarkId); + positions.set(landmarkId, { x, y }); + return { x, y }; + } + + for (let step = 1; step <= 6; step += 1) { + const candidates = [ + { x, y: y + step }, + { x, y: y - step }, + { x: x + step, y }, + { x: x - step, y }, + ]; + const available = candidates.find( + (candidate) => !occupied.has(`${candidate.x},${candidate.y}`), + ); + if (available) { + occupied.set(`${available.x},${available.y}`, landmarkId); + positions.set(landmarkId, available); + return available; + } + } + + occupied.set(key, landmarkId); + positions.set(landmarkId, { x, y }); + return { x, y }; + }; + + landmarks.forEach((landmark) => { + if (positions.has(landmark.id)) { + return; + } + + reservePosition(landmark.id, clusterAnchorX, 0); + queue.push(landmark.id); + let clusterMaxX = clusterAnchorX; + + while (queue.length > 0) { + const currentId = queue.shift(); + if (!currentId) { + continue; + } + + const currentPosition = positions.get(currentId); + if (!currentPosition) { + continue; + } + + const connections = directionalConnectionMap.get(currentId) ?? []; + connections.forEach((connection) => { + const target = landmarkById.get(connection.targetLandmarkId); + if (!target || positions.has(target.id)) { + return; + } + + const offset = getWorldMapDirectionOffset( + connection.relativePosition as CardinalConnectionDirection, + ); + const nextPosition = reservePosition( + target.id, + currentPosition.x + offset.x, + currentPosition.y + offset.y, + ); + clusterMaxX = Math.max(clusterMaxX, nextPosition.x); + queue.push(target.id); + }); + } + + clusterAnchorX = clusterMaxX + 3; + }); + + const coordinateEntries = landmarks.map((landmark) => { + const position = positions.get(landmark.id) ?? reservePosition(landmark.id, 0, 0); + return { landmark, x: position.x, y: position.y }; + }); + + const minX = Math.min(...coordinateEntries.map((entry) => entry.x), 0); + const maxX = Math.max(...coordinateEntries.map((entry) => entry.x), 0); + const minY = Math.min(...coordinateEntries.map((entry) => entry.y), 0); + const maxY = Math.max(...coordinateEntries.map((entry) => entry.y), 0); + + const nodes: WorldMapNodeLayout[] = coordinateEntries.map(({ landmark, x, y }) => { + const left = WORLD_MAP_PADDING + (x - minX) * WORLD_MAP_GRID_WIDTH; + const top = WORLD_MAP_PADDING + (y - minY) * WORLD_MAP_GRID_HEIGHT; + + return { + id: landmark.id, + name: landmark.name, + description: landmark.description, + left, + top, + centerX: left + WORLD_MAP_NODE_WIDTH / 2, + centerY: top + WORLD_MAP_NODE_HEIGHT / 2, + }; + }); + + const edgeMap = new Map(); + directionalConnectionMap.forEach((connections, sourceId) => { + connections.forEach((connection) => { + if (!landmarkById.has(connection.targetLandmarkId)) { + return; + } + + const pairKey = [sourceId, connection.targetLandmarkId].sort().join('::'); + if (!edgeMap.has(pairKey)) { + edgeMap.set(pairKey, { + fromId: sourceId, + toId: connection.targetLandmarkId, + }); + } + }); + }); + + return { + nodes, + edges: [...edgeMap.values()], + width: + WORLD_MAP_PADDING * 2 + + (maxX - minX) * WORLD_MAP_GRID_WIDTH + + WORLD_MAP_NODE_WIDTH, + height: + WORLD_MAP_PADDING * 2 + + (maxY - minY) * WORLD_MAP_GRID_HEIGHT + + WORLD_MAP_NODE_HEIGHT, + }; +} + +function WorldMapOverviewModal({ + landmarks, + onClose, +}: { + landmarks: CustomWorldLandmark[]; + onClose: () => void; +}) { + const { nodes, edges, width, height } = useMemo( + () => buildWorldMapLayout(landmarks), + [landmarks], + ); + const nodeById = useMemo( + () => new Map(nodes.map((node) => [node.id, node])), + [nodes], + ); + + return ( + +
+
+ + {edges.map((edge) => { + const fromNode = nodeById.get(edge.fromId); + const toNode = nodeById.get(edge.toId); + if (!fromNode || !toNode) { + return null; + } + + return ( + + ); + })} + + + {nodes.map((node) => ( +
+
{node.name}
+ {node.description ? ( +
+ {node.description} +
+ ) : null} +
+ ))} +
+
+
+ ); +} + const FIXED_SCENE_IMAGE_SIZE = '1280*720'; function SceneImageGenerationModal({ @@ -808,6 +1557,7 @@ function SceneImageGenerationModal({ ) : null}
+
@@ -895,10 +1645,12 @@ function SaveBar({ onClose, onSave, extraAction, + showClose = true, }: { onClose: () => void; onSave: () => void; extraAction?: ReactNode; + showClose?: boolean; }) { return (
@@ -913,13 +1665,15 @@ function SaveBar({
{extraAction}
) : null}
- + {showClose ? ( + + ) : null}