4 Commits

Author SHA1 Message Date
39f679d1ea 1 2026-04-26 17:53:31 +08:00
06d49c0ad2 1 2026-04-26 17:34:52 +08:00
705a2d3dd8 1 2026-04-26 16:50:53 +08:00
ea33413187 1 2026-04-26 14:27:48 +08:00
183 changed files with 11542 additions and 3224 deletions

View File

@@ -239,6 +239,8 @@ function buildNpcFirstContactOptionCatalog(
- 角色第一次真正对玩家开口时说什么,必须由 `npc_chat` 对应的 prompt 约束来生成,并要求首句是自然招呼或开场判断。
- 不能再用“某人看着你,像是在等你把话接下去”这类第三人称占位旁白充当可见对话历史首句,也不能在聊天 state 里本地硬编码一条替代台词。
- 当玩家在场景中第一次真正撞上角色型 NPC 并进入聊天时,应直接触发一轮由 NPC 主动开口的模型回复;这一轮只生成 NPC 自己的首句与后续可选回应,不得代替玩家补写未说过的话。
- 负好感或敌对关系不应跳过主动开口;如果玩家从 NPC 交互面板点击 `npc_chat`,且该角色尚未完成 `firstMeaningfulContactResolved`,仍要走同一条 NPC 主动开场链路。负好感只影响语气、敌对聊天指令与后续可选功能,不影响“由角色先发言”的首遇行为。
- 好感度小于 `0` 的角色在聊天终止时不进入 `story_continue_adventure` 收束态。无论是玩家主动退出聊天,还是模型通过敌对聊天指令主动结束聊天,底部选项都固定收束为 `npc_fight``battle_escape_breakout`:按钮文案分别为“战斗”“逃跑”。点击“战斗”进入 NPC 战斗结算链路;点击“逃跑”执行现有 `battle_escape_breakout` function完成脱离演出与后续状态更新。
4. 首遇状态下,不允许前两项直接变成:
- 深背景追问

View File

@@ -0,0 +1,80 @@
# RPG NPC 聊天敌对中止与聊天内 Function 选项设计2026-04-25
## 1. 目标
本次迭代调整运行时 NPC 聊天,让敌对角色聊天从固定五回合上限改为由模型按当前语境判定是否中止;好感度大于等于 0 的角色继续保持可持续聊天,不由模型强制结束。
同时,原本部分只在退出聊天后才出现的 NPC function 选项,需要进入聊天续写候选池。模型在生成聊天候选时要能看到可触发的 function 选项,并把它们改写成玩家可直接点击的动作文本。聊天中保留“换一换”能力,用于刷新下方候选。
## 2. 行为规则
1. 负好感或敌对 NPC 进入聊天后,不再设置固定 5 回合上限。
2. 负好感或敌对 NPC 每轮回复后,模型必须判断本轮是否结束聊天。
3. 敌对 NPC 判定时应偏向随时结束聊天并进入对峙但必须结合玩家刚说的话、NPC 性格、当前剧情压力和对话历史。
4. 好感度大于等于 0 且非敌对 NPC 不启用模型终止判定,玩家可一直聊天。
5. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。
6. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。
7. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。
8. 对负好感或敌对 NPC在聊天终止后的后续流程仍沿用原敌对出口继续推进后回到原有战斗或逃跑选择。
9. 聊天候选中允许混入当前 NPC 可执行 function例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。
10. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。
11. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。
## 3. 后端契约
`NpcChatTurnDirective` 增加:
1. `terminationMode``none | hostile_model`
2. `isHostileChat`:当前聊天是否按敌对中止规则处理
3. `functionOptions`:可进入聊天候选的 function 列表,包含 `functionId``actionText``detailText``action`
`NpcChatTurnCompletionDirective` 增加:
1. `forceExit`:本轮回复后是否关闭聊天输入
2. `closingMode`:保留 `free | foreshadow_close`
3. `terminationReason``hostile_breakoff | player_exit | null`
后端返回 `suggestions` 仍是字符串数组,前端按字符串生成 `npc_chat` 续写选项;新增 `functionSuggestions`,元素包含 `functionId` 与模型生成的 `actionText`,前端按对应 function 触发原 NPC action。
## 4. Prompt 规则
回复 prompt 需要明确:
1. 敌对聊天可随时中止NPC 更偏好结束谈判转入战斗或驱逐。
2. 终止不等于在回复正文里直接执行战斗,只需要用台词把对话收束到对峙、威胁、驱逐、最后通牒或行动前一刻。
3. 玩家主动退出聊天时NPC 回复要对这次收束作出回应,并留下自然的后续入口。
建议 prompt 需要明确:
1. 常规聊天候选继续生成玩家台词。
2. Function 候选要根据提供的 function 列表,改写成玩家可直接点击的动作文本。
3. 不输出规则说明,不把 functionId 暴露给玩家。
## 5. 前端流程
1. `enterNpcChat` 与每轮 `handleNpcChatTurn` 统一构造聊天可用 function 列表。
2. 聊天中的普通候选仍触发 `npc_chat`function 候选触发原 `handleNpcInteraction` 分流。
3. `exitNpcChat` 改为调用 `handleNpcChatTurn`,输入文本为结束聊天意图,并携带 `player_exit` 指令。
4. 收到终止结果后,当前 `StoryMoment` 保留 `dialogue`,移除 `npcChatState``options` 只保留 `buildContinueAdventureOption()`
5. 点击“继续”后沿用已有 deferred continue / story continue 逻辑进入下一阶段。
6. 聊天态“换一换”只轮换当前 `options`,若 function 候选不足则补普通聊天兜底候选。
## 6. 追加规则Function 标签与场景幕推进
1. 运行时选项按钮需要在动作文本前展示 function 短标签,例如 `npc_chat` 显示“聊天”,`npc_quest_accept` / `npc_quest_turn_in` 显示“任务”,`npc_gift` 显示“送礼”。
2. 标签只承担识别用途,不展示 functionId也不展示规则说明。
3. NPC 聊天终止后点击“继续冒险”,不再重新请求剧情推理;如果当前场景还有下一幕,直接进入下一幕并展示该幕可用的冒险选项。
4. 当当前场景已经到最后一幕再点击“继续冒险”应展示所有相邻场景入口选项文案按方向表达为“向东走前往xxxx”“向南走前往xxxx”等。
5. 相邻场景选项继续使用 `idle_travel_next_scene`,并在 `runtimePayload.targetSceneId` 中携带目标场景,后续点击沿用现有地图跳转结算。
6. 若没有场景幕数据,则继续使用当前可用选项作为兜底,不额外生成规则说明文案。
## 7. 验收
1. 负好感主 NPC 不再出现固定 `turnLimit: 5`
2. 敌对 NPC 每轮请求会向后端传 `terminationMode: hostile_model`
3. 模型返回 `forceExit: true` 后,聊天输入消失,只显示继续按钮。
4. 好感度大于等于 0 的 NPC 聊天不传敌对中止模式。
5. 点击退出聊天会新增玩家结束聊天气泡与 NPC 回复,而不是直接切走面板。
6. 聊天态可看到并点击 function 候选,且“换一换”可改变候选顺序。
7. 选项文字前出现中文 function 标签,且标签不改变原 actionText。
8. 聊天结束后的“继续冒险”直接进入下一幕;最后一幕则展示多个相邻场景方向入口。

View File

@@ -199,4 +199,12 @@
---
## 17. 2026-04-26 创作编辑器关闭确认弹窗亮色主题修正
- `RpgCreationEntityEditorShared.tsx` 的未保存关闭确认统一收口为 `CloseConfirmDialog`,弹窗只保留确认信息和两个动作,不新增说明文案。
- `CloseConfirmDialog` 通过 `platform-close-confirm-dialog` 语义类接入平台主题 token提示块使用 `--platform-warm-*`,确认按钮使用 `--platform-button-primary-*`,继续编辑按钮使用 `--platform-neutral-*`
- 后续新增关闭 / 退出确认面板时,不要继续复制 `text-amber-50``text-sky-50``bg-black/*` 这类深色 Tailwind 组合;优先复用语义类,避免亮色主题出现浅底白字和按钮文字不可读。
---
_文档目的交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。_

View File

@@ -97,6 +97,12 @@
- 当前仓库已进一步收口为:
不再提供右上角全局账号悬浮条,统一只保留页面内入口与独立账号面板。
### 4.6 冒险主场景双方角色必须按画面中线镜像
- 非滚动画面的交谈、预览、单体对峙态,主角和对面角色不能分别用左右边距、世界坐标和角色宽度重复推算。
- 正确做法是先定义“角色容器中心距离画面中线”的统一间距,再让主角中心落在中线左侧、对面角色中心落在中线右侧。
- 角色容器宽度、角色图片缩放和左右朝向必须各自独立处理,不能用额外 left inset 去修正角色图片,否则会破坏左右对称。
- 自定义图片 NPC、模板角色和组合式 NPC 都要进入同一 112px 场景容器,再按各自素材锚点做场景缩放,保证视觉大小不漂移。
## 5. 队伍面板经验
### 5.1 移动端成员列表不能太“卡片化”
@@ -202,3 +208,11 @@
- 可扮演角色的形象预览容器统一使用 1:1 方形,入口选择轮播、角色资产工坊和结果页角色卡片都不能用纵向长卡片去拉伸预览图。
- 预览图片本身使用 `object-contain`,保证 AI 生成主形象、模板像素角色和运行时动画都在方形容器内完整显示,不裁切角色主体。
- 卡片可以在方形预览下方放角色名、称号、状态等信息,但这些文本区不能反向影响预览区比例。
- 编辑角色弹窗也遵循同一规则:移动端不能用固定高度压扁预览区,预览容器应随宽度保持 `aspect-square`
### 10.2 运行画面怪物锚点按视觉底边校准
- 对战预览里主角和对手要沿画面中线成对出现,但纵向不能只共用一个 `bottom` 常量。
- 怪物精灵帧的空白、体型和脚底位置差异很大,运行画面应按帧高分档下沉,让怪物视觉底边落在主角同一条地面线上。
- 后续新增怪物资源时,先检查红圈标注的实际落点,再调整锚点分档或单怪物偏移,避免出现“悬在地面上方”的状态。
- 自定义世界里敌对角色已经先作为场景 NPC 存在,即使它同时携带 `characterId``monsterPresetId`,画布也不能直接沿用模板角色的 `groundOffsetY`;只要 encounter 自身有 `imageSrc``visual`,就按场景 NPC 自定义形象锚点处理。
- 幕预览运行时还会构造“无 `characterId`、但有 `visual` 的场景 NPC”这类和平相遇分支同样必须套用场景 NPC 自定义形象锚点,否则会停在画面中上部。

View File

@@ -0,0 +1,23 @@
# RPG 场景幕角色配置面板布局经验 2026-04-26
## 背景
场景多幕配置里的“配置角色”面板是高频编辑弹层,移动端和桌面端都需要快速完成选择并保存。
## 本次约束
1. 面板底部不再放“取消”按钮,关闭统一交给标题栏关闭按钮和遮罩。
2. “保存角色”必须位于面板底部操作区,角色列表较长时只滚动内容区,不把保存按钮滚出视口。
3. 已选角色时仍允许“移除角色”,但移动端纵向排列时保存按钮保持在最底部。
4. 不在面板内新增功能说明文本,维持清爽编辑体验。
5. 吸底操作区必须使用平台语义色 token不能写死深色 Tailwind 背景,避免亮色主题下出现突兀深色底栏。
6. 已选角色时,底部操作区保持同一行:左侧“移除角色”热区占 1/4右侧“保存角色”占 3/4未选角色时保存按钮占满整行。
## 落地位置
- `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`
- `SceneActNpcSlotPickerModal`
## 后续复用
以后新增类似独立选择弹层时,优先采用“标题栏 + 中间滚动内容 + 底部固定主动作”的结构;取消类动作不要默认占据底部按钮位,避免和主保存动作抢焦点。

View File

@@ -214,7 +214,7 @@
- `name`
- `description`
- `imageSrc`
- `sceneNpcIds`
- `sceneNpcIds`(仅作为兼容字段,由多幕配置自动派生,不再作为创作者可编辑字段)
- `connections`
- `sceneChapterBlueprints` 对应的多幕配置
2. 场景配置面板中,开局场景必须复用普通场景同级的配置 UI而不是继续保留一套缩水版表单。
@@ -224,7 +224,7 @@
4. 除“初始所在场景”语义之外,不允许再因为它是开局场景而裁掉 NPC、连接、多幕、危险度等配置能力。
5. 为兼容现有数据,当前 `camp` 字段可以继续保留,但其承载的结构必须与普通场景对齐,不能再是阉割版场景结构。
6. 运行时编译时,开局场景也必须按普通场景规则参与:
- 场景 NPC 编译
- 多幕相遇 NPC 编译
- 场景连接编译
- 多幕蓝图读取
- 场景图片 / 残痕 / 预览数据生成
@@ -500,7 +500,7 @@ type NpcChatTurnResult = {
1. “场景图片”不再作为场景详情页里的独立字段展示,创作者只能通过每一幕的“配置背景”入口管理视觉。
2. “场景内 NPC”不再作为场景详情页里的独立字段展示创作者只能通过每一幕角色槽位配置相遇 NPC。
3. 为兼容现有运行时与旧数据结构,场景对象上的 `imageSrc / sceneNpcIds` 仍然保留,但必须由多幕配置自动回填,前台不再暴露单独编辑控件。
3. 为兼容现有运行时与旧数据结构,场景对象上的 `imageSrc / sceneNpcIds` 仍然保留,但必须由多幕配置自动回填,前台不再暴露单独编辑控件,且不能再用 `sceneNpcIds` 限制每幕可选角色
多幕区块至少展示:
@@ -553,7 +553,7 @@ type NpcChatTurnResult = {
NPC 配置面板必须支持:
1. 从当前世界的 `playableNpcs + storyNpcs` 中选择角色
2. 只展示与当前场景相关的优先推荐角色
2. 当前场景相关角色只能作为排序或推荐依据,不能过滤掉其他世界角色
3.`3` 个固定槽位进行配置,而不是长列表表单
4. 第一槽位明确标记为“主角色”
5. 允许同一角色出现在多个不同幕
@@ -566,6 +566,7 @@ NPC 配置面板必须支持:
3. 不允许把不存在于当前世界角色池中的 id 写入幕配置。
4. 若主角色未与当前场景或线程建立任何关联,给出发布警告。
5. 存储时继续落到 `encounterNpcIds` 有序数组,槽位从左到右按顺序压缩写入。
6. `sceneNpcIds` 不再作为创作者字段,也不再作为幕角色选择范围;保存时只从所有幕的 `encounterNpcIds` 自动派生兼容值。
## 7.6 幕预览

View File

@@ -0,0 +1,45 @@
# 冒险实体详情 NPC 预览修复记录2026-04-26
## 背景
RPG 运行态点击画面中的对面 NPC 角色形象时,详情弹窗的立绘与画布上实际显示的 NPC 不一致,并伴随 React 报错:
`Encountered two children with the same key, ``.`
## 问题定位
1. 画布层 `GameCanvasEntityLayer` 渲染 NPC 时,会优先使用当前 `Encounter` 实例上的 `visual``imageSrc``monsterPresetId`,再回退到 `characterId` 对应的预设角色。
2. 详情弹窗 `AdventureEntityModal` 原本优先按 `characterId` 渲染预设角色,导致运行时遭遇已经携带独立形象时,点击后弹窗显示成另一个角色内容。
3. `AdventureEntityModal` 内部存在多个浮层共用同一个 `AnimatePresence`,直系子节点没有显式稳定 key同时 NPC 运行时背包物品如果传入空 `id`,会把空字符串直接交给物品格列表作为 React key。
## 落地约束
1. NPC 详情立绘必须与画布点击对象一致:
- `encounter.visual`
- `encounter.imageSrc`
- `encounter.monsterPresetId`
- `encounter.characterId`
- 通用 NPC 生成形象
2. 前端只做展示优先级和 key 稳定性处理,不新增剧情规则、不改写运行时 NPC 数据来源。
3. 所有列表和并列浮层都必须具备稳定、非空、可区分的渲染 key。
## 本次修改
1. `src/components/AdventureEntityModal.tsx`
- 新增 `NpcEncounterPortrait`,让弹窗立绘优先使用遭遇实例形象,与画布渲染策略对齐。
- 新增 `selectionRenderKey`,给实体详情、标签详情、技能详情浮层提供稳定 key。
- 新增 NPC 背包物品渲染 id 规范化,避免空 id 或重复 id 触发 React key 冲突,并避免点击物品时选中错误项。
- 技能附带状态标签 key 增加兜底字段,避免空 buff id 冲突。
2. `src/components/AdventureEntityModal.test.tsx`
- 覆盖“有 `characterId` 但遭遇实例提供 `imageSrc` 时,详情立绘必须显示遭遇图像”。
- 覆盖“NPC 背包物品空 id 不再触发重复 key 警告”。
## 验证
已执行:
```bash
npm run test -- AdventureEntityModal.test.tsx CharacterInfoShared.test.tsx
```
结果5 个测试全部通过。

View File

@@ -0,0 +1,32 @@
# 创作页场景世界地图面板修复设计2026-04-25
## 背景
创作结果页进入“场景”编辑面板后,底部“查看世界地图”弹出的面板存在两个问题:
1. 面板仍使用偏运行时的深色地图容器,放在浅色创作页主题下时配色割裂,节点文字与背景层次也不稳定。
2. 地图只按传入的地标列表渲染,普通场景编辑时容易漏掉开局场景,无法形成完整“世界地图”视角。
## 落地范围
- `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`
- `src/components/CustomWorldEntityEditorModal.test.tsx`
## 设计约束
1. 不新增说明类大段 UI 文案,只保留必要的节点名、方向标签和空状态。
2. 地图面板继续作为独立弹窗,不在当前场景连接面板下方展开。
3. 地图数据必须使用当前编辑中的草稿状态:
- 普通场景编辑:开局场景 + 已保存场景列表,并用当前 `draft` 替换正在编辑的场景。
- 新增普通场景:开局场景 + 已保存场景列表 + 当前 `draft`
- 开局场景编辑:当前 `draft` 开局场景 + 已保存场景列表。
4. 地图节点要标记当前编辑场景,连接线要展示方向短标签,避免用户只能看到无语义的线。
5. 配色使用 `platform-*` 主题变量,适配浅色与深色创作页主题。
## 验收点
1. 在普通场景编辑器点击“查看世界地图”后,弹窗中能同时看到开局场景和当前场景。
2. 未保存的场景连接关系会立刻体现在地图弹窗里。
3. 当前编辑场景节点有明确高亮。
4. 地图容器和节点不再固定为深色运行时风格。
5. 相关前端测试覆盖普通场景与开局场景两条入口。

View File

@@ -10,8 +10,10 @@
- 角色:`visualDescription`,用于打开角色形象图像生成面板时默认填入角色形象描述框。
- 角色:`actionDescription`,用于打开角色动作视频生成面板时默认填入各动作描述框;当前每个动作会从同一角色默认动作描述起步,用户切换动作后可分别编辑并缓存。
- 角色:`sceneVisualDescription`,用于描述角色常出现或关联的场景画面。三个角色默认描述字段必须在角色 outline 阶段同一次模型调用中产出;若模型遗漏,只允许后端本地兜底补字段,不再额外发起独立修复模型调用。
- 每一幕:`sceneChapterBlueprints[*].acts[*].backgroundPromptText`,用于打开该幕背景图像生成面板时默认填入场景描述框。
- 场景:`visualDescription` 只作为旧场景图或没有幕级描述时的兜底,不再从角色 AI 形象生成面板维护场景背景描述。
- 场景:`actNPCNames``connectedLandmarkNames``entryHook` 必须在关键场景生成阶段同一次模型调用中产出,并由原场景解析链路写入 `landmarks` 与幕级 `primaryNpcId / oppositeNpcId / encounterNpcIds`;不再使用独立的场景网络补全提示词。旧草稿中的 `sceneNpcNames` 仅作为兼容读取兜底,不作为新生成字段。
草稿生成契约位置:
@@ -41,6 +43,7 @@
- 角色主图:`server-rs/crates/api-server/src/custom_world_asset_prompts.rs`
- `build_character_visual_prompt`
- 内部使用 `build_master_prompt`
- 只拼入用户可见的 `promptText` / `visualPromptText`,不再拼入 `characterBriefText` 或角色摘要字段。
- 角色动作视频:`server-rs/crates/api-server/src/custom_world_asset_prompts.rs`
- `build_character_animation_prompt`
- 图生视频分支使用 `build_video_action_prompt`

View File

@@ -14,7 +14,6 @@ buildFoundationGenerationSeedText
-> generateFoundationRoleOutlineEntries(playable)
-> generateFoundationRoleOutlineEntries(story)
-> generateFoundationLandmarkSeedEntries
-> expandFoundationLandmarkNetworkEntries
-> expandFoundationRoleEntries(playable, narrative)
-> expandFoundationRoleEntries(playable, dossier)
-> expandFoundationRoleEntries(story, narrative)
@@ -30,15 +29,14 @@ buildFoundationGenerationSeedText
2. 使用旧 Node 的 framework prompt 生成世界核心骨架。
3. 分批生成可扮演角色 outline。
4. 分批生成场景角色 outline。
5. 分批生成关键场景 seed
6. 补全关键场景探索网络
7. 先补可扮演角色叙事档案,再补养成档案。
8. 先补场景角色叙事档案,再补养成档案
9. 将分阶段结果编译回 `draftProfile`,再交给 SpacetimeDB action 落库。
5. 分批生成关键场景;同一次模型调用必须同时产出 `actNPCNames``connectedLandmarkNames``entryHook``actBackgroundPromptTexts``actEventDescriptions`,其中 `actNPCNames` 表示三幕各自默认主场景角色。旧草稿的 `sceneNpcNames` 只允许作为读取兜底
6. 先补可扮演角色叙事档案,再补养成档案
7. 先补场景角色叙事档案,再补养成档案。
8. 将分阶段结果编译回 `draftProfile`,再交给 SpacetimeDB action 落库
## 约束
1. 未修改旧 Node 提示词原文的语义与阶段顺序
1. Rust 主链不再兼容旧 Node 的独立场景网络补全阶段;场景生成只允许通过 `build_custom_world_landmark_seed_batch_prompt` 一次完成
2. Rust 侧新增 prompt 构造只服务 `api-server` 外部 LLM 调用SpacetimeDB reducer 仍只负责校验与落库,不承担联网生成。
3. 当前仍保留 Rust 侧最小归一化,目的仅是保证 `publish gate / result preview` 需要的字段存在,不替代 Node 的 AI 工作流。
4. 后续如继续迁移,需要优先把 Node `buildFoundationDraftProfileFromFramework` 的结构编译细节进一步完整 Rust 化,而不是回退到单 prompt 直出。
@@ -63,8 +61,7 @@ cargo test -p api-server custom_world_foundation_draft --no-default-features
- `12`:整理世界骨架。
- `16-30`:生成可扮演角色。
- `30-44`:生成场景角色。
- `44-56`:生成关键场景。
- `56-66`:建立场景连接。
- `44-66`:生成关键场景,并同步产出幕 NPC 与场景连接
- `66-76`:补全可扮演角色叙事基础。
- `76-84`:补全可扮演角色档案细节。
- `84-92`:补全场景角色叙事基础。

View File

@@ -15,11 +15,13 @@
新增字段:
- `oppositeNpcId: string`
- 当前幕“对面的角色”,优先使用该场景 `sceneNpcNames` / `encounterNpcIds` 的第一个角色。
- 当前幕“对面的角色”,优先使用该场景 `actNPCNames[actIndex]` 对应的角色。
-`actNPCNames` 缺少当前幕条目,使用 `actNPCNames[0]`;旧草稿只存在 `sceneNpcNames` 时,仅作为兼容兜底读取,不再作为新生成字段。
- 若当前场景暂未绑定角色,使用空字符串,不在草稿合成阶段伪造角色 ID。
- `eventDescription: string`
- 描述当前幕正在发生的事件。
- 必须强绑定 `oppositeNpcId` / `primaryNpcId` 所指角色,写清该角色的行动、阻碍、试探、求助或冲突。
- 三幕默认遵循戏剧曲线:第一幕铺垫并露出异常,第二幕让阻碍或立场冲突升级,第三幕进入高潮、关键抉择或直接后果。
- 默认生成兜底规则:`第N幕中玩家在当前场景遭遇/处理与某角色直接相关的事件,并推动当前场景问题升级或转向。`
兼容字段:
@@ -35,6 +37,15 @@
- 当前场景的核心任务描述。
- 文本会作为游戏中首次进入某个场景生成章节任务的关键上下文。
- 必须结合场景描述、场景入口钩子、出场角色与 3 幕事件,说明玩家首次进入该场景时要完成什么。
- 世界档案的场景详情页必须直接展示该字段,便于创作者确认每个场景的默认章节任务。
### Landmark 生成源字段
- `actNPCNames: string[]`
- 关键场景生成阶段一次模型调用内产出,表示第 1/2/3 幕各自的主场景角色。
- 只能引用同一批角色生成链路中已有的场景角色名。
- 解析到幕蓝图时,每一幕默认写入 `primaryNpcId``oppositeNpcId`,并作为 `encounterNpcIds` 的首位。
- 新生成不再使用 `sceneNpcNames`;前端和后端可保留旧字段读取兜底,用于历史草稿不丢角色。
## 生成链路
@@ -42,13 +53,38 @@
2. LLM 提示词需要要求:
- `camp.sceneTaskDescription` 默认生成开局场景核心任务。
- `landmarks[*].sceneTaskDescription` 默认生成关键场景核心任务。
- `actNPCNames` 恰好 3 条,对应每一幕默认主场景角色;如果可用场景角色名单为空,输出空数组。
- `actEventDescriptions` 恰好 3 条,对应每一幕事件。
- `actEventDescriptions[0] / [1] / [2]` 必须分别承担铺垫、冲突、高潮,不允许三条只是同一事件的近义复述。
- `actBackgroundPromptTexts[n]` 必须基于同序号幕事件和相关角色写出画面主体、站位空间、冲突痕迹与氛围,不能只用场景名或幕标题拼接。
3. 后端合成 `sceneChapterBlueprints` 时把这些源字段落到:
- `sceneChapterBlueprints[*].sceneTaskDescription`
- `sceneChapterBlueprints[*].acts[*].oppositeNpcId`
- `sceneChapterBlueprints[*].acts[*].eventDescription`
- `sceneChapterBlueprints[*].acts[*].primaryNpcId`
- `sceneChapterBlueprints[*].acts[*].encounterNpcIds[0]`
4. 若 LLM 遗漏字段,归一化阶段用场景描述、入口钩子、角色名单生成中文默认值,保证草稿阶段字段非空。
5. 前端类型与归一化逻辑必须允许读取这些字段,旧草稿缺字段时仍自动补默认值。
6. 幕信息编辑界面必须直接展示 `eventDescription`,并在保存时保留 `sceneTaskDescription / oppositeNpcId / eventDescription / backgroundPromptText`,避免旧草稿经前端编辑后丢失后端生成字段。
7. 首次进入某场景时,现有章节任务生成流程必须优先读取对应 `sceneChapterBlueprints[*].sceneTaskDescription`,并把它作为 `buildChapterQuestForScene` 的章节任务覆盖上下文;同一场景只生成一次章节任务。
## 幕配置预览标识
1. 幕配置预览图里只保留简洁角色点位标识,不新增说明类文案。
2. 主角固定在画面左侧可站立区域,对面角色槽位固定在画面右侧可站立区域:
- 主角色槽位位于画面中右侧,作为当前幕的主要对峙对象。
- 第二、第三角色槽位位于右侧上、下两个辅助位置,形成清晰的纵向层次。
3. 每个槽位使用圆形短标识表达序号:`主 / 2 / 3`,旁边只展示角色名或“添加角色”。
4. 标识必须有高对比底色、描边和轻微阴影,避免在浅色天空、地面纹理或深色背景上丢失。
5. 空槽位仍然可点击,但只能显示 `+` 与短标签,不能显示大段规则说明。
## 对话选项差异要求
运行时 NPC 聊天每轮生成的 3 个对话选项必须导向不同氛围和好感结果:
1. 第一条为温和共情或愿意倾听,通常让气氛缓和并更容易带来好感上升。
2. 第二条为冷静追问或试探,通常保持中性但推进情报。
3. 第三条为施压、质疑或立场冲突,通常让气氛变紧,可能带来好感下降或代价。
## 非目标

View File

@@ -88,13 +88,13 @@ Rust Stage 2 不再使用“LLM 先摘要再拼 SVG”的链路。
新的生成 prompt 口径:
1. 以请求里的 `promptText + characterBriefText` 组装正式主图 prompt
1. 以请求里的 `promptText` 组装正式主图 prompt;主形象不再拼入 `characterBriefText`,避免角色名、身份摘要、参考模板等非形象描述干扰体型、视角和风格约束
2. 约束必须覆盖:
1. 单人
2. 右向斜侧身
3. 1:1 正方形画布
4. 纯绿色绿幕
5. 34 头身
5. 11.5 头身
6. 像素动作角色
7. 不要扩写复杂背景
3. 主目标是与旧 Node `buildNpcVisualPrompt` 生成出的正式约束保持同方向

View File

@@ -51,6 +51,19 @@
1. `content-type`:优先使用 OSS 响应头。
2. `cache-control``private, max-age=60`
3. `x-genarrative-asset-object-key`:回写解析后的 OSS object key方便调试。
4. `content-length`:成功读取 OSS 对象后按二进制体长度显式回写,避免开发代理或浏览器把已成功读取的图片误判为空响应。
## 3.1 成功对象响应稳定性补充
日期:`2026-04-26`
本次排查发现 `/generated-characters/storynpcs-0/visual/visual-storynpcs-0-aitask_65048e86d04f7/master.png` 在 OSS 签名 URL 下可正常返回 `200 image/png`,但 Rust 同源代理成功读取对象后曾对客户端返回空响应Vite 开发代理进一步表现为 `500 Internal Server Error`
修复口径:
1. 成功分支不再依赖隐式 `error_for_status()` 后继续用 builder 拼装响应,而是先判断上游状态,再用明确的 `(status, headers, bytes)` 二进制响应返回。
2.`2xx` 上游状态统一映射为 `404``502` JSON 错误,禁止把 OSS 上游异常表现成连接中断。
3. 成功响应必须保留 `content-type`,并显式回写 `content-length``cache-control``x-genarrative-asset-object-key`
## 4. 对象键约定

View File

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

View File

@@ -7,14 +7,14 @@
在现有“我的”Tab 常用功能区落地三个轻量入口:
1. `邀请好友`:弹出面板展示当前账号绑定的邀请码。
2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 积分
2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 叙世币
3. `玩家社区`:弹出面板展示微信群与 QQ 群二维码占位图,后续替换为正式图片。
## 后端边界
- 邀请码、邀请关系与奖励发放全部存入 `server-rs/crates/spacetime-module`
- Axum 只做鉴权、参数转发与响应映射,不在 API 层自行计算奖励。
- 前端只读取后端状态与调用提交接口,不做本地加积分
- 前端只读取后端状态与调用提交接口,不做本地加叙世币
- 钱包余额继续复用 `profile_dashboard_state.wallet_balance`
- 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型:
- `invite_inviter_reward`
@@ -42,7 +42,7 @@
- 每个用户拥有一个稳定邀请码,首次进入邀请中心时自动生成。
- 用户不能填写自己的邀请码。
- 用户最多填写一个邀请码,成功后不可修改。
- 被邀请者绑定成功后获得 `30` 积分
- 被邀请者绑定成功后获得 `30` 叙世币
- 邀请者每天最多获得 `10` 次邀请奖励,超过后关系仍可绑定,被邀请者仍获得奖励,邀请者当次不再加分。
- 每次奖励都写入钱包流水,钱包余额以后端返回为准。
@@ -64,6 +64,13 @@
返回绑定后的邀请中心状态与本次奖励发放结果。
## 落地状态
- `server-rs/crates/spacetime-module` 已新增邀请码与邀请关系表,邀请中心读取和填码绑定均通过 SpacetimeDB procedure 执行。
- `server-rs/crates/api-server` 已挂接 `/api/runtime/profile/referrals/*``/api/profile/referrals/*` 两组路由。
- 前端“我的”Tab 三个快捷入口均打开独立弹窗,玩家社区先使用空白二维码占位。
- 复制邀请会复制邀请码和邀请链接;填码成功后刷新个人看板叙世币。
## 前端交互
- 三个入口继续放在“我的”Tab 常用功能区,不新增页面。

View File

@@ -14,6 +14,7 @@
2. Rust API 必须至少提供契约兼容的后端 SSE 路由,避免回退到 server-node。
3. 任意好感度下,首次与一个 NPC 相遇都先进入 NPC 主动开场;后续再按敌对/普通分支处理。
4. 和平相遇态 NPC 固定使用已解析相遇锚点,与主角形成面对面的右侧对称表现,并强制朝向主角。
5. `npcInitiatesConversation=true` 表示 NPC 先手开口,不应要求玩家消息非空,也不能把内部占位文本写入 prompt 或好感结算。
## 代码设计
@@ -26,6 +27,8 @@
- `reply_delta`:增量文本。
- `complete``npcReply / affinityDelta / affinityText / suggestions / pendingQuestOffer / chatDirective`
3. 当前先提供后端确定性兜底回复,保证 Rust API 迁移期间链路可用;后续完整 LLM 编排应继续在 Rust API 内实现,不回接 server-node。
4. 主动开场请求允许 `playerMessage` 为空;这一轮不是玩家发言结算,`affinityDelta` 固定为 0。
5. 建议生成 prompt 在主动开场时明确“玩家尚未先开口”,避免把空消息或前端哨兵文本误描述为玩家台词。
### 前端交互
@@ -33,6 +36,7 @@
1. 首次相遇判断提前到敌对短路之前。
2. `firstMeaningfulContactResolved` 为 false 时,无论好感度或敌对状态如何,都调用 `startNpcInitiatedOpening(...)`
3. NPC 主动开场调用 `streamNpcChatTurn(...)` 时传空 `playerMessage`,只依赖 `npcInitiatesConversation` 表达“由 NPC 先发言”的语义。
调整 `src/components/game-canvas/GameCanvasEntityLayer.tsx`

View File

@@ -4,7 +4,9 @@
## 文档列表
- [RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md](./RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md):记录 RPG 战斗血条安全锚点、服务端战斗回包前端短表现,以及 `battle_use_skill` 指定技能兜底结算的修复口径。
- [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。
- [RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md](./RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md):记录开局场景与普通场景复用同一场景展示解析服务,修复列表幕缩略图和详情幕背景预览图片不一致的问题。
- [FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md](./FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md):记录网站启动后首次加载约三分钟的前端根因,收口 `RouteImageReadyGate` 首屏图片门控和 Vite dev server 无关文件监听范围。
- [RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md](./RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md):记录 RPG 作品删除时报 `No such procedure` 的根因,补齐 `delete_custom_world_agent_session` 在有效 SpacetimeDB 模块入口中的导出,并要求发布后核验 Maincloud schema。
- [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md):冻结当前后端唯一落地口径,明确新功能以 `server-rs + Axum + SpacetimeDB` 为准,旧 `server-node` / Express / PostgreSQL 与 Go 方向只允许作为迁移参考。

View File

@@ -0,0 +1,52 @@
# RPG Agent 单字段锚点结构重构方案
## 背景
当前 RPG 创作 Agent 的 `anchorContent` 把每个锚点继续拆成多个子字段,例如 `worldPromise.hook / differentiator / desiredExperience``playerFantasy.playerRole / corePursuit / fearOfLoss`。这些子字段在语义上会互相重叠,后续基础设定展示又会把子字段用分隔符连接成“标签”,导致同一内容在一个锚点内或跨锚点重复出现。
本次重构将 `anchorContent` 收束为“每个锚点一个字段”。子字段不再作为数据结构保存,只保留为 prompt 中的生成关注点。
## 新数据结构
`anchorContent` 保留原有 8 个键,便于上下游和旧存档兼容,但每个键的值统一为 `string | null`
```ts
type RpgCreationAnchorContent = {
worldPromise: string | null;
playerFantasy: string | null;
themeBoundary: string | null;
playerEntryPoint: string | null;
coreConflict: string | null;
keyRelationships: string | null;
hiddenLines: string | null;
iconicElements: string | null;
};
```
## 生成口径
1. Agent 每轮仍然输出完整 `nextAnchorContent`,并覆盖上一版。
2. 每个锚点只输出一段凝练中文,不再输出对象或数组。
3. 旧的子字段关注点进入 prompt 约束:
- `worldPromise` 关注世界钩子、差异点、玩家体验。
- `playerFantasy` 关注玩家身份、核心追求、失去风险。
- `themeBoundary` 关注主题气质、美术方向、禁用方向。
- `playerEntryPoint` 关注开局身份、开局问题、行动动机。
- `coreConflict` 关注表层冲突、隐藏危机、首次触发点。
- `keyRelationships` 关注关键人物关系、关系类型、代价或秘密。
- `hiddenLines` 关注隐藏真相、误导线索、揭示节奏。
- `iconicElements` 关注标志意象、组织/物件、硬规则。
## 兼容策略
1. 后端读取旧 `anchorContent` 时,允许把旧对象/数组压缩成字符串。
2. 后端新写回永远写单字段字符串结构。
3. 前端契约和展示只按字符串字段读取,不再把子字段连接成标签。
4. 草稿生成、`creatorIntent``anchorPack` 均从单字段锚点派生。
## 验收点
1. Agent 对话后 `anchorContent` 的 8 个锚点值均为字符串或 `null`
2. 基础设定面板不再因为子字段连接产生重复标签。
3. 旧存档中的对象结构仍可被读取并压缩展示。
4. `draft_foundation` 生成种子直接使用 8 个单字段锚点。

View File

@@ -0,0 +1,27 @@
# RPG 战斗血条与动作表现修复
更新时间:`2026-04-26`
## 背景
战斗运行态已经形成两条链路:
- `server-rs` / `module-runtime-story-compat` 负责 `battle_*``inventory_use` 的数值真相结算。
- 前端 `GameCanvasRuntime``GameCanvasEntityLayer``storyChoiceRuntime` 负责把结算结果表现为血条、动作和选项反馈。
本次修复只处理表现层与本地兜底一致性,不把战斗数值重新搬回前端。
## 落地规则
1. 战斗中双方血条必须使用角色安全区锚点,放在角色形象上方,不再贴着 112px 容器顶部渲染。
2. 自定义 NPC、模板角色、通用 NPC、怪物的脚底锚点和血条锚点分离维护避免为了落地位置牺牲血条可读性。
3. `battle_use_skill` 的本地兜底结算必须尊重 `runtimePayload.skillId`,不能重新随机挑技能。
4. `battle_recover_breath` 的本地兜底不能伪装成攻击动作;它只做恢复、冷却推进和后续敌方压力。
5. 服务端战斗回包如果带 `presentation.battle`,前端先播放一次短动作和血量变化,再落 `hydratedSnapshot.gameState`,避免选项点击后血量直接跳变。
## 验收点
- 玩家、同伴、NPC、怪物血条不遮挡头部或主体轮廓。
- 点击具体技能按钮时,播放与结算使用同一个 `skillId`
- 点击恢复时不会出现玩家同时释放攻击技能的错位表现。
- 走服务端 runtime action 的战斗选项仍以 server-rs 返回快照为最终状态。

View File

@@ -0,0 +1,24 @@
# RPG 开局场景幕预览图片同步修复2026-04-26
## 背景
世界档案场景 Tab 中,开局场景卡片和点进场景详情后的幕预览曾经存在取图口径不一致:
1. 列表侧会先解析场景主图,再把主图作为共享图传给所有幕预览。
2. 详情侧实际编辑的是 `sceneChapterBlueprints[].acts[].backgroundImageSrc`
3. 当开局场景 `camp.imageSrc` 与第二、第三幕背景不同步时,列表幕缩略图会被主图覆盖,点进详情后又显示幕自己的图。
## 落地规则
1. 开局场景和普通场景统一通过 `src/services/customWorldScenePresentation.ts` 解析展示模型。
2. 展示模型固定输出:
- `imageSrc`:场景主图,优先使用第一张幕背景,其次使用场景兼容图。
- `actPreviews`:幕预览列表,优先使用当前幕 `backgroundImageSrc`,只有该幕缺图时才回退 `imageSrc`
3. `camp.imageSrc``landmark.imageSrc` 只作为旧数据兼容字段,不反向覆盖已有幕背景。
4. 场景目录、开局场景详情、普通场景详情必须复用同一套展示解析服务;不得再为 `camp` 单独写一套幕预览取图逻辑。
## 验收点
1. 开局场景列表中的第 2 幕缩略图与详情页第 2 幕背景预览一致。
2. 普通场景仍沿用同一展示模型,列表幕缩略图不被场景主图覆盖。
3. 保存开局场景图片时,兼容字段 `camp.imageSrc` 和多幕背景仍保持已有同步规则。

View File

@@ -0,0 +1,20 @@
# 世界档案场景 Tab 与幕主角色隔离修复2026-04-26
## 背景
编辑世界档案时,开局场景已经复用普通场景的 `LandmarkEditor` 多幕配置面板,但幕角色归一化逻辑会把章节中任意一幕已选择的角色汇总成场景候选池。当第一幕刚配置主角色时,其他尚未配置角色的幕会被兜底补成同一个主角色,表现为“第一幕联动修改其他幕”。
## 落地规则
1. 世界档案实体 Tab 顺序调整为:世界、场景、可扮演角色、场景角色。
2. 开局场景与普通场景的多幕角色保存规则保持一致:
- 当前幕的主角色只由当前幕 `encounterNpcIds[0]` 决定。
- 已存在幕蓝图但当前幕未选角色时,保持空槽位,不从其他幕角色兜底。
- 只有旧草稿完全缺少幕蓝图时,才允许从场景已有角色列表生成默认幕槽位。
3. 保存场景时,`sceneNpcIds` 继续作为所有幕已选角色的汇总字段,用于列表检索、旧字段兼容和运行时场景候选,不反向覆盖未配置幕。
## 验收点
1. 在开局场景编辑器中只配置第一幕主角色,第二幕、第三幕仍保持未配置状态。
2. 保存后 `camp.sceneNpcIds` 包含第一幕角色,`sceneChapterBlueprints` 中只有第一幕写入该角色。
3. 普通场景的幕角色槽位、幕预览、场景任务和连接关系不发生行为漂移。

View File

@@ -0,0 +1,37 @@
# RPG 世界草稿属性六维生成 2026-04-26
## 背景
RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `CustomWorldProfile`。运行时已经支持 `attributeSchema`,但 foundation draft 当前没有稳定产出该字段,前端只能根据主题模式回退出固定模板,导致世界页面看到的六个维度更像预设,而不是本次世界草稿的一部分。
## 落地约束
- `draftProfile.attributeSchema` 是世界草稿真相源的一部分,必须随 foundation draft 一起生成并保存。
- 六维固定使用 `axis_a``axis_f` 六个槽位,但 `schemaName`、每个槽位 `name` 和说明必须贴合本次世界设定。
- 维度名不得沿用通用旧词:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神。
- 若模型遗漏或结构不合规,后端必须生成中文兜底属性体系,不能让前端只靠固定模板补齐。
- 世界页面的“世界”页签必须展示当前 `attributeSchema.slots` 的六个名称,作为玩家进入世界前可见的规则信号。
## 编码方案
1. `packages/shared/src/contracts/rpgAgentDraft.ts`
- 增加 `RpgAgentWorldAttributeSchema``RpgAgentWorldAttributeSlot` 合同。
- `RpgAgentFoundationDraftProfile` 增加 `attributeSchema` 字段。
2. `server-rs/crates/api-server/src/prompt/foundation_draft.rs`
- framework 阶段要求模型输出 `attributeSchema`
- 修复提示也必须保留 `attributeSchema`,避免 JSON repair 丢字段。
3. `server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
- `normalize_framework_shape()` 归一化 `attributeSchema`
- `build_foundation_draft_profile_from_framework()` 将归一化后的 `attributeSchema` 写入 `draftProfile`
- 新增兜底生成器,基于世界名、基调、目标、冲突和种子文本生成六个中文维度。
4. `src/components/CustomWorldEntityCatalog.tsx`
- 在世界页签增加“角色维度”区域,直接渲染 `profile.attributeSchema.slots` 的六个名称。
## 验收
- 新生成的 RPG 世界草稿 JSON 顶层包含 `attributeSchema.slots.length === 6`
- 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神。
- 缺失或非法模型输出会被后端兜底为合法中文六维。

View File

@@ -90,6 +90,9 @@
前排主角色与玩家角色保持同一 y 轴后排两个角色改为同一列、x 轴对齐并上下分布,且后排整体 y 轴中点与前排主角色一致
9. 新增幕默认只带 1 个主角色,后续槽位由创作者按需补充
10. 小预览里的名字已移动到角色头顶,角色渲染不再带方形底板,避免遮挡场景背景
11. 幕预览复用真实游戏壳时隐藏左上角角色等级徽标,退出入口固定在上方画面区域底部居中,并使用“结束预览”作为操作文案
12. 创作侧场景列表封面、多幕配置卡片、配置背景弹层统一读取同一张场景显示图;在任一幕保存背景时同步回全部幕背景字段和场景兼容图,避免同一场景在不同层级出现不同预览图
13. 场景角色预览图背景改用平台主题变量,亮色主题下不再保留深色预览底
## 2.6 负好感主角色有限聊天闭环

View File

@@ -4,13 +4,6 @@
*/
export type {
RpgCreationAnchorText as AnchorTextValue,
RpgCreationAnchorContent as EightAnchorContent,
RpgCreationCoreConflictValue as CoreConflictValue,
RpgCreationHiddenLineValue as HiddenLineValue,
RpgCreationIconicElementValue as IconicElementValue,
RpgCreationKeyRelationshipValue as KeyRelationshipValue,
RpgCreationPlayerEntryPointValue as PlayerEntryPointValue,
RpgCreationPlayerFantasyValue as PlayerFantasyValue,
RpgCreationThemeBoundaryValue as ThemeBoundaryValue,
RpgCreationWorldPromiseValue as WorldPromiseValue,
} from './rpgAgentAnchors';

View File

@@ -1,63 +1,17 @@
/**
* RPG 创作八锚点契约。
* 这一层只描述“创作意图采集态”的结构,不混入 session 或结果页字段
* 每个锚点只保留一段凝练文本;细分关注点由 Agent prompt 负责,不再进入存储结构
*/
export interface RpgCreationWorldPromiseValue {
hook: string;
differentiator: string;
desiredExperience: string;
}
export interface RpgCreationPlayerFantasyValue {
playerRole: string;
corePursuit: string;
fearOfLoss: string;
}
export interface RpgCreationThemeBoundaryValue {
toneKeywords: string[];
aestheticDirectives: string[];
forbiddenDirectives: string[];
}
export interface RpgCreationPlayerEntryPointValue {
openingIdentity: string;
openingProblem: string;
entryMotivation: string;
}
export interface RpgCreationCoreConflictValue {
surfaceConflicts: string[];
hiddenCrisis: string;
firstTouchedConflict: string;
}
export interface RpgCreationKeyRelationshipValue {
pairs: string;
relationshipType: string;
secretOrCost: string;
}
export interface RpgCreationHiddenLineValue {
hiddenTruths: string[];
misdirectionHints: string[];
revealPacing: string;
}
export interface RpgCreationIconicElementValue {
iconicMotifs: string[];
institutionsOrArtifacts: string[];
hardRules: string[];
}
export type RpgCreationAnchorText = string | null;
export interface RpgCreationAnchorContent {
worldPromise: RpgCreationWorldPromiseValue | null;
playerFantasy: RpgCreationPlayerFantasyValue | null;
themeBoundary: RpgCreationThemeBoundaryValue | null;
playerEntryPoint: RpgCreationPlayerEntryPointValue | null;
coreConflict: RpgCreationCoreConflictValue | null;
keyRelationships: RpgCreationKeyRelationshipValue[];
hiddenLines: RpgCreationHiddenLineValue | null;
iconicElements: RpgCreationIconicElementValue | null;
worldPromise: RpgCreationAnchorText;
playerFantasy: RpgCreationAnchorText;
themeBoundary: RpgCreationAnchorText;
playerEntryPoint: RpgCreationAnchorText;
coreConflict: RpgCreationAnchorText;
keyRelationships: RpgCreationAnchorText;
hiddenLines: RpgCreationAnchorText;
iconicElements: RpgCreationAnchorText;
}

View File

@@ -127,6 +127,32 @@ export interface RpgAgentFoundationDraftCamp {
summary: string;
}
export interface RpgAgentWorldAttributeSlot {
slotId: 'axis_a' | 'axis_b' | 'axis_c' | 'axis_d' | 'axis_e' | 'axis_f';
name: string;
definition: string;
positiveSignals: string[];
negativeSignals: string[];
combatUseText: string;
socialUseText: string;
explorationUseText: string;
}
export interface RpgAgentWorldAttributeSchema {
id: string;
worldId: string;
schemaVersion: number;
generatedFrom: {
worldType: 'CUSTOM' | 'WUXIA' | 'XIANXIA';
worldName: string;
settingSummary: string;
tone: string;
conflictCore: string;
};
schemaName?: string;
slots: RpgAgentWorldAttributeSlot[];
}
export type RpgAgentSceneActStage =
| 'opening'
| 'expansion'
@@ -148,6 +174,8 @@ export interface RpgAgentFoundationDraftSceneAct {
backgroundAssetId?: string | null;
encounterNpcIds: string[];
primaryNpcId: string;
oppositeNpcId: string;
eventDescription: string;
linkedThreadIds: string[];
actGoal: string;
transitionHook: string;
@@ -160,6 +188,7 @@ export interface RpgAgentFoundationDraftSceneChapter {
sceneName: string;
title: string;
summary: string;
sceneTaskDescription: string;
linkedThreadIds: string[];
linkedLandmarkIds: string[];
acts: RpgAgentFoundationDraftSceneAct[];
@@ -179,6 +208,7 @@ export interface RpgAgentFoundationDraftProfile {
camp?: RpgAgentFoundationDraftCamp | null;
themePack?: Record<string, unknown> | null;
storyGraph?: Record<string, unknown> | null;
attributeSchema: RpgAgentWorldAttributeSchema;
factions: RpgAgentFoundationDraftFaction[];
threads: RpgAgentFoundationDraftThread[];
chapters: RpgAgentFoundationDraftChapter[];

View File

@@ -32,7 +32,7 @@ describe('RPG 创作共享契约 fixture', () => {
const anchors = createRpgCreationAnchorContentFixture();
const draftProfile = createRpgAgentFoundationDraftProfileFixture();
expect(anchors.worldPromise?.hook).toContain('旧航路群岛');
expect(anchors.worldPromise).toContain('旧航路群岛');
expect(draftProfile.worldHook).toContain('旧航路群岛');
expect(draftProfile.playableNpcs).toHaveLength(1);
expect(draftProfile.storyNpcs).toHaveLength(1);

View File

@@ -32,48 +32,22 @@ function cloneFixture<T>(value: T): T {
*/
export function createRpgCreationAnchorContentFixture(): RpgCreationAnchorContent {
return cloneFixture({
worldPromise: {
hook: '被海雾吞没的旧航路群岛',
differentiator: '灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家回到群岛调查沉船真相。',
corePursuit: '找出失控航路背后的真相。',
fearOfLoss: '失去最后一个还能对上旧案的人。',
},
themeBoundary: {
toneKeywords: ['压抑', '潮湿', '悬疑'],
aestheticDirectives: ['旧灯塔', '潮雾', '断裂航路'],
forbiddenDirectives: ['不要出现现代枪械'],
},
playerEntryPoint: {
openingIdentity: '被迫返乡的失职守灯人',
openingProblem: '首夜就有陌生船只闯入禁航区。',
entryMotivation: '查清沉船夜里被谁改动了灯册。',
},
coreConflict: {
surfaceConflicts: ['守灯会与航运公会争夺旧航路控制权'],
hiddenCrisis: '沉船夜的航灯与灯册被人动过手脚。',
firstTouchedConflict: '玩家开局就会撞上新的封航命令。',
},
keyRelationships: [
{
pairs: '玩家 / 沈砺',
relationshipType: '旧友兼潜在背叛者',
secretOrCost: '沈砺暗地里在替沉船商盟引路。',
},
],
hiddenLines: {
hiddenTruths: ['沉船夜的真实失误并不是单纯天灾。'],
misdirectionHints: ['所有人都会先把问题推给潮雾本身。'],
revealPacing: '第一章露出痕迹,第二章才让玩家摸到灯册线。',
},
iconicElements: {
iconicMotifs: ['会移动的海雾'],
institutionsOrArtifacts: ['回潮旧灯塔', '封灯令', '旧潮图'],
hardRules: ['禁航信号一旦点亮,任何船都必须退航。'],
},
worldPromise:
'被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能活着穿过去,体验压抑、悬疑、潮湿。',
playerFantasy:
'玩家回到群岛调查沉船真相,核心追求是找出失控航路背后的真相,风险是失去最后一个还能对上旧案的人。',
themeBoundary:
'压抑、潮湿、悬疑;旧灯塔、潮雾、断裂航路;不要出现现代枪械。',
playerEntryPoint:
'玩家是被迫返乡的失职守灯人,首夜就有陌生船只闯入禁航区,动机是查清沉船夜里被谁改动了灯册。',
coreConflict:
'守灯会与航运公会争夺旧航路控制权,沉船夜的航灯与灯册被人动过手脚,玩家开局会撞上新的封航命令。',
keyRelationships:
'玩家与沈砺是旧友兼潜在背叛者,沈砺暗地里在替沉船商盟引路。',
hiddenLines:
'沉船夜的真实失误并不是单纯天灾;所有人都会先把问题推给潮雾本身;第一章露出痕迹,第二章才让玩家摸到灯册线。',
iconicElements:
'会移动的海雾、回潮旧灯塔、封灯令、旧潮图;禁航信号一旦点亮,任何船都必须退航。',
} satisfies RpgCreationAnchorContent);
}
@@ -204,6 +178,81 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio
},
],
},
attributeSchema: {
id: 'schema:rpg-agent:tide-fixture',
worldId: 'custom:潮雾列岛',
schemaVersion: 1,
schemaName: '潮雾六脉',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛',
settingSummary: '旧灯塔与失控航路',
tone: '压抑、潮湿、悬疑',
conflictCore: '沉船夜的航灯与灯册被人动过手脚。',
},
slots: [
{
slotId: 'axis_a',
name: '潮骨',
definition: '承受潮压、封航令与正面冲击的底子。',
positiveSignals: ['承压', '稳阵'],
negativeSignals: ['散乱', '畏压'],
combatUseText: '顶住正面压迫并守住行动空间。',
socialUseText: '在封锁与质问中保持可信姿态。',
explorationUseText: '穿过潮湿险境时维持身体与装备状态。',
},
{
slotId: 'axis_b',
name: '浪步',
definition: '顺潮借势、换线穿行与抢占位置的能力。',
positiveSignals: ['借势', '轻快'],
negativeSignals: ['迟滞', '失位'],
combatUseText: '借地形切线、拉开距离或抢先手。',
socialUseText: '顺着对方语气调整节奏。',
explorationUseText: '穿越港口、水路、雾区与复杂地形。',
},
{
slotId: 'axis_c',
name: '灯识',
definition: '看懂灯号、潮痕、档案错页与人心遮掩的能力。',
positiveSignals: ['辨伪', '识局'],
negativeSignals: ['误读', '迟钝'],
combatUseText: '识破破绽并判断局势变化。',
socialUseText: '听出隐瞒、试探与交换空间。',
explorationUseText: '辨认潮痕、灯册和沉船遗留线索。',
},
{
slotId: 'axis_d',
name: '雾魄',
definition: '在海雾、旧案与封锁压力中推进真相的胆气。',
positiveSignals: ['果断', '压前'],
negativeSignals: ['犹疑', '退缩'],
combatUseText: '顶着高压窗口推进突破口。',
socialUseText: '在谈判或对峙中定调。',
explorationUseText: '面对陌生雾区与异状仍敢继续前探。',
},
{
slotId: 'axis_e',
name: '旧约',
definition: '与旧友、信物、灯令和地方关系建立牵引的能力。',
positiveSignals: ['守诺', '通人情'],
negativeSignals: ['疏离', '失信'],
combatUseText: '借同伴协同与旧约牵制形成连锁。',
socialUseText: '安抚、结盟、交换与维系信任。',
explorationUseText: '从人情、传闻和旧物中打开线索。',
},
{
slotId: 'axis_f',
name: '回澜',
definition: '在长线消耗中回稳节奏并维持判断的能力。',
positiveSignals: ['回稳', '续航'],
negativeSignals: ['紊乱', '断流'],
combatUseText: '久战不乱,把节奏重新拉回手里。',
socialUseText: '情绪稳定,不轻易被带偏。',
explorationUseText: '在漫长远行与恶劣天气里保有余力。',
},
],
},
factions: [
{
id: 'faction-1',
@@ -249,6 +298,7 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio
sceneName: '回潮旧灯塔',
title: '灯塔初章',
summary: '围绕灯塔推进的首个场景章节。',
sceneTaskDescription: '首次进入回潮旧灯塔时,追查禁航灯册为何被人改写。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
@@ -262,6 +312,8 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio
backgroundAssetId: 'scene-asset-runtime',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
oppositeNpcId: 'story-1',
eventDescription: '顾潮音在旧灯塔门前拦住玩家,交出第一段灯册疑点。',
linkedThreadIds: ['thread-1'],
actGoal: '接住首幕入口',
transitionHook: '向第二幕推进。',
@@ -461,6 +513,7 @@ export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRe
sceneName: chapter.sceneName,
title: chapter.title,
summary: chapter.summary,
sceneTaskDescription: chapter.sceneTaskDescription,
acts: chapter.acts.map((act) => ({
id: act.id,
title: act.title,
@@ -469,6 +522,8 @@ export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRe
backgroundAssetId: act.backgroundAssetId,
encounterNpcIds: act.encounterNpcIds,
primaryNpcId: act.primaryNpcId,
oppositeNpcId: act.oppositeNpcId,
eventDescription: act.eventDescription,
actGoal: act.actGoal,
transitionHook: act.transitionHook,
})),

View File

@@ -8,6 +8,17 @@ export type NpcChatTurnLimitReason = 'negative_affinity';
export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close';
export type NpcChatTurnTerminationMode = 'none' | 'hostile_model';
export type NpcChatTurnTerminationReason = 'hostile_breakoff' | 'player_exit';
export type NpcChatFunctionOption = {
functionId: string;
actionText: string;
detailText?: string | null;
action?: string | null;
};
export type NpcChatTurnDirective = {
sceneActId?: string | null;
turnLimit?: number | null;
@@ -15,6 +26,10 @@ export type NpcChatTurnDirective = {
limitReason?: NpcChatTurnLimitReason | null;
closingMode?: NpcChatTurnClosingMode | null;
forceExitAfterTurn?: boolean;
terminationMode?: NpcChatTurnTerminationMode | null;
terminationReason?: NpcChatTurnTerminationReason | null;
isHostileChat?: boolean;
functionOptions?: NpcChatFunctionOption[];
};
export type NpcChatTurnCompletionDirective = {
@@ -22,6 +37,7 @@ export type NpcChatTurnCompletionDirective = {
remainingTurns?: number | null;
forceExit?: boolean;
closingMode?: NpcChatTurnClosingMode;
terminationReason?: NpcChatTurnTerminationReason | null;
};
export type CharacterChatReplyRequest<
@@ -133,11 +149,17 @@ export type NpcChatPendingQuestOffer<TQuest = unknown> = {
introText?: string;
};
export type NpcChatFunctionSuggestion = {
functionId: string;
actionText: string;
};
export type NpcChatTurnResult<TQuest = unknown> = {
npcReply: string;
affinityDelta: number;
affinityText: string;
suggestions: string[];
functionSuggestions?: NpcChatFunctionSuggestion[];
pendingQuestOffer?: NpcChatPendingQuestOffer<TQuest> | null;
chatDirective?: NpcChatTurnCompletionDirective | null;
};

View File

@@ -131,6 +131,32 @@ export type CreateProfileRechargeOrderResponse = {
center: ProfileRechargeCenterResponse;
};
export type ProfileReferralInviteCenterResponse = {
inviteCode: string;
inviteLinkPath: string;
invitedCount: number;
rewardedInviteCount: number;
todayInviterRewardCount: number;
todayInviterRewardRemaining: number;
rewardPoints: number;
hasRedeemedCode: boolean;
boundInviterUserId: string | null;
boundAt: string | null;
updatedAt: string;
};
export type RedeemProfileReferralInviteCodeRequest = {
inviteCode: string;
};
export type RedeemProfileReferralInviteCodeResponse = {
center: ProfileReferralInviteCenterResponse;
inviteeRewardGranted: boolean;
inviterRewardGranted: boolean;
inviteeBalanceAfter: number;
inviterBalanceAfter: number;
};
export type ProfilePlayedWorkSummary = {
worldKey: string;
ownerUserId: string | null;

View File

@@ -0,0 +1,28 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8111",
"phone_number_masked": "138****8111",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$qnArSgOrZvcQxap4KAMMnA$+K+gQgf7h0jQibJLuvAlOeHnNNYutTvLVDAyo1hqS/o",
"password_login_enabled": false,
"phone_number": "+8613800138111"
}
},
"phone_to_user_id": {
"+8613800138111": "user_00000001"
},
"sessions_by_id": {},
"session_id_by_refresh_token_hash": {},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -0,0 +1,28 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8112",
"phone_number_masked": "138****8112",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$0HR2g/fKOw9EFHz7BuYtGg$cpXb5KBwbEXPxPJHA4Bk1U7NtM97GhGTq7VK6jCJ+lA",
"password_login_enabled": false,
"phone_number": "+8613800138112"
}
},
"phone_to_user_id": {
"+8613800138112": "user_00000001"
},
"sessions_by_id": {},
"session_id_by_refresh_token_hash": {},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -0,0 +1,28 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8110",
"phone_number_masked": "138****8110",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$fEeSrVyialDeb8rarDSpdA$HFihZiuCOyaz8F5iNukmobeiHI/EpYWdeQzhbIYR4zk",
"password_login_enabled": false,
"phone_number": "+8613800138110"
}
},
"phone_to_user_id": {
"+8613800138110": "user_00000001"
},
"sessions_by_id": {},
"session_id_by_refresh_token_hash": {},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -0,0 +1,56 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8000",
"phone_number_masked": "138****8000",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$hoXmK/LzABj2QfWZSO3SNA$Qg71V2iZCPyLOsoQLffiCv3KPkWVNSAsP6IooTIXi/w",
"password_login_enabled": false,
"phone_number": "+8613800138000"
}
},
"phone_to_user_id": {
"+8613800138000": "user_00000001"
},
"sessions_by_id": {
"usess_52522126b58d40e3b9e503808dd11e2c": {
"session": {
"session_id": "usess_52522126b58d40e3b9e503808dd11e2c",
"user_id": "user_00000001",
"refresh_token_hash": "f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9",
"issued_by_provider": "Phone",
"client_info": {
"client_type": "web_browser",
"client_runtime": "unknown",
"client_platform": "unknown",
"client_instance_id": null,
"device_fingerprint": null,
"device_display_name": "未知设备 / 未知客户端",
"mini_program_app_id": null,
"mini_program_env": null,
"user_agent": null,
"ip": null
},
"expires_at": "2026-05-25T15:41:01.0856147Z",
"revoked_at": null,
"created_at": "2026-04-25T15:41:01.0856147Z",
"updated_at": "2026-04-25T15:41:01.0856147Z",
"last_seen_at": "2026-04-25T15:41:01.0856147Z"
}
}
},
"session_id_by_refresh_token_hash": {
"f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9": "usess_52522126b58d40e3b9e503808dd11e2c"
},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -95,7 +95,8 @@ use crate::{
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
get_profile_recharge_center, get_profile_wallet_ledger,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -820,6 +821,34 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(

View File

@@ -74,14 +74,9 @@ pub async fn generate_character_visual(
// 旧资产工坊接口没有显式 Bearer 头Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
let owner_user_id = "asset-tool".to_string();
let task_id = generate_ai_task_id(current_utc_micros());
let prompt = build_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let prompt = build_character_visual_prompt(payload.prompt_text.as_str());
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
@@ -296,27 +291,20 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
owner_user_id: &str,
character_id: &str,
prompt_text: &str,
character_brief_text: Option<&str>,
) -> Result<GeneratedCharacterPrimaryVisual, AppError> {
let payload = CharacterVisualGenerateRequest {
character_id: character_id.to_string(),
source_mode: shared_contracts::assets::CharacterVisualSourceMode::TextToImage,
prompt_text: prompt_text.to_string(),
character_brief_text: character_brief_text.map(ToOwned::to_owned),
reference_image_data_urls: Vec::new(),
candidate_count: 1,
image_model: CHARACTER_VISUAL_MODEL.to_string(),
size: "1024*1024".to_string(),
};
let task_id = generate_ai_task_id(current_utc_micros());
let prompt = build_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let prompt = build_character_visual_prompt(payload.prompt_text.as_str());
let fallback_prompt =
build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str());
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
@@ -2068,7 +2056,7 @@ mod tests {
#[test]
fn build_character_visual_prompt_keeps_generation_constraints() {
let prompt = build_character_visual_prompt("潮雾港向导", Some("旧港守望者"));
let prompt = build_character_visual_prompt("潮雾港向导");
assert!(prompt.contains("潮雾港向导"));
assert!(prompt.contains("右向斜侧身"));
@@ -2077,10 +2065,8 @@ mod tests {
#[test]
fn fallback_character_visual_prompt_removes_risky_specific_names() {
let prompt = build_fallback_moderation_safe_character_visual_prompt(
"艾瑞克,银发剑士,红色长披风",
Some("某知名设定参考"),
);
let prompt =
build_fallback_moderation_safe_character_visual_prompt("艾瑞克,银发剑士,红色长披风");
assert!(prompt.contains("原创"));
assert!(prompt.contains("不参考任何现有"));

View File

@@ -1613,7 +1613,6 @@ async fn generate_draft_foundation_role_visuals(
task_owner_user_id.as_str(),
role_ref.role_id.as_str(),
role_ref.prompt.as_str(),
Some(role_ref.name.as_str()),
)
.await
};
@@ -2429,8 +2428,8 @@ fn log_custom_world_publish_gate_diagnostics(
has_draft_profile = session.draft_profile.as_object().map(|value| !value.is_empty()).unwrap_or(false),
has_result_preview = session.result_preview.is_some(),
preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(serde_json::Value::as_str).unwrap_or(""),
has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise.hook", "settingText"]),
has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]),
has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise", "anchorContent.worldPromise.hook", "settingText"]),
has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]),
has_core_conflicts = has_custom_world_non_empty_text_array(profile, "coreConflicts"),
has_main_chapter = has_custom_world_array(profile, "chapters") || has_custom_world_array(profile, "sceneChapterBlueprints") || has_custom_world_array(profile, "sceneChapters"),
has_scene_act = has_custom_world_scene_act(profile),

View File

@@ -99,113 +99,25 @@ pub(crate) struct PromptDynamicStateInference {
judgement_summary: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorldPromiseValue {
#[serde(default)]
hook: String,
#[serde(default)]
differentiator: String,
#[serde(default)]
desired_experience: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PlayerFantasyValue {
#[serde(default)]
player_role: String,
#[serde(default)]
core_pursuit: String,
#[serde(default)]
fear_of_loss: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ThemeBoundaryValue {
#[serde(default)]
tone_keywords: Vec<String>,
#[serde(default)]
aesthetic_directives: Vec<String>,
#[serde(default)]
forbidden_directives: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PlayerEntryPointValue {
#[serde(default)]
opening_identity: String,
#[serde(default)]
opening_problem: String,
#[serde(default)]
entry_motivation: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoreConflictValue {
#[serde(default)]
surface_conflicts: Vec<String>,
#[serde(default)]
hidden_crisis: String,
#[serde(default)]
first_touched_conflict: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeyRelationshipValue {
#[serde(default)]
pairs: String,
#[serde(default)]
relationship_type: String,
#[serde(default)]
secret_or_cost: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HiddenLineValue {
#[serde(default)]
hidden_truths: Vec<String>,
#[serde(default)]
misdirection_hints: Vec<String>,
#[serde(default)]
reveal_pacing: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IconicElementValue {
#[serde(default)]
iconic_motifs: Vec<String>,
#[serde(default)]
institutions_or_artifacts: Vec<String>,
#[serde(default)]
hard_rules: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EightAnchorContent {
#[serde(default)]
world_promise: Option<WorldPromiseValue>,
world_promise: Option<String>,
#[serde(default)]
player_fantasy: Option<PlayerFantasyValue>,
player_fantasy: Option<String>,
#[serde(default)]
theme_boundary: Option<ThemeBoundaryValue>,
theme_boundary: Option<String>,
#[serde(default)]
player_entry_point: Option<PlayerEntryPointValue>,
player_entry_point: Option<String>,
#[serde(default)]
core_conflict: Option<CoreConflictValue>,
core_conflict: Option<String>,
#[serde(default)]
key_relationships: Vec<KeyRelationshipValue>,
key_relationships: Option<String>,
#[serde(default)]
hidden_lines: Option<HiddenLineValue>,
hidden_lines: Option<String>,
#[serde(default)]
iconic_elements: Option<IconicElementValue>,
iconic_elements: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -814,137 +726,127 @@ fn build_chat_history(messages: &[CustomWorldAgentMessageRecord]) -> Vec<JsonVal
}
fn normalize_eight_anchor_content(value: &JsonValue) -> EightAnchorContent {
serde_json::from_value::<EightAnchorContent>(value.clone()).unwrap_or_default()
// Agent session 的新结构要求每个锚点只保存一段文本;这里兼容旧对象/数组存档,
// 读取时压缩成单字段,写回时仍由 EightAnchorContent 序列化为新结构。
EightAnchorContent {
world_promise: normalize_anchor_text(value.get("worldPromise")),
player_fantasy: normalize_anchor_text(value.get("playerFantasy")),
theme_boundary: normalize_anchor_text(value.get("themeBoundary")),
player_entry_point: normalize_anchor_text(value.get("playerEntryPoint")),
core_conflict: normalize_anchor_text(value.get("coreConflict")),
key_relationships: normalize_anchor_text(value.get("keyRelationships")),
hidden_lines: normalize_anchor_text(value.get("hiddenLines")),
iconic_elements: normalize_anchor_text(value.get("iconicElements")),
}
}
fn normalize_anchor_text(value: Option<&JsonValue>) -> Option<String> {
let normalized = compact_json_anchor_text(value?)?;
Some(clamp_text(normalized.as_str(), 180))
}
fn compact_json_anchor_text(value: &JsonValue) -> Option<String> {
match value {
JsonValue::Null => None,
JsonValue::String(text) => {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
(!normalized.trim().is_empty()).then_some(normalized.trim().to_string())
}
JsonValue::Array(items) => {
let values = items
.iter()
.filter_map(compact_json_anchor_text)
.collect::<Vec<_>>();
let compacted = dedupe_string_list(values, 8).join("");
(!compacted.trim().is_empty()).then_some(compacted)
}
JsonValue::Object(object) => {
let values = object
.values()
.filter_map(compact_json_anchor_text)
.collect::<Vec<_>>();
let compacted = dedupe_string_list(values, 8).join("");
(!compacted.trim().is_empty()).then_some(compacted)
}
JsonValue::Bool(value) => Some(value.to_string()),
JsonValue::Number(value) => Some(value.to_string()),
}
}
fn split_anchor_phrases(value: Option<&str>) -> Vec<String> {
value
.unwrap_or_default()
.split(['', ';', '、', ',', '', '\n'])
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect()
}
fn build_creator_intent_from_eight_anchor_content(
anchor_content: &EightAnchorContent,
) -> CreatorIntentRecord {
let key_characters = anchor_content
let key_relationship_text = anchor_content
.key_relationships
.iter()
.enumerate()
.map(|(index, entry)| {
let (lead_name, relation_to_player) = split_relationship_pair(entry.pairs.as_str());
CreatorCharacterSeedRecord {
id: format!("creator-character-{}", index + 1),
name: if lead_name.is_empty() {
format!("关键人物{}", index + 1)
} else {
lead_name
},
role: entry.relationship_type.clone(),
public_mask: String::new(),
hidden_hook: entry.secret_or_cost.clone(),
relation_to_player,
notes: String::new(),
}
})
.collect::<Vec<_>>();
let core_conflicts = anchor_content
.core_conflict
.as_ref()
.map(|value| {
value
.surface_conflicts
.iter()
.cloned()
.chain(
(!value.hidden_crisis.trim().is_empty()).then_some(value.hidden_crisis.clone()),
)
.collect::<Vec<_>>()
})
.unwrap_or_default();
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
let key_characters = if key_relationship_text.is_empty() {
Vec::new()
} else {
let (lead_name, relation_to_player) =
split_relationship_pair(key_relationship_text.as_str());
vec![CreatorCharacterSeedRecord {
id: "creator-character-1".to_string(),
name: if lead_name.is_empty() {
"关键人物1".to_string()
} else {
lead_name
},
role: key_relationship_text.clone(),
public_mask: String::new(),
hidden_hook: key_relationship_text.clone(),
relation_to_player,
notes: String::new(),
}]
};
CreatorIntentRecord {
source_mode: "freeform".to_string(),
raw_setting_text: compact_lines([
anchor_content
.world_promise
.as_ref()
.map(|value| value.differentiator.as_str()),
anchor_content
.player_fantasy
.as_ref()
.map(|value| value.core_pursuit.as_str()),
anchor_content
.hidden_lines
.as_ref()
.and_then(|value| value.hidden_truths.first().map(String::as_str)),
anchor_content.world_promise.as_deref(),
anchor_content.player_fantasy.as_deref(),
anchor_content.hidden_lines.as_deref(),
]),
world_hook: compact_lines([
anchor_content
.world_promise
.as_ref()
.map(|value| value.hook.as_str()),
anchor_content
.world_promise
.as_ref()
.map(|value| value.differentiator.as_str()),
]),
theme_keywords: anchor_content
.theme_boundary
.as_ref()
.map(|value| value.tone_keywords.clone())
.unwrap_or_default(),
tone_directives: anchor_content
.theme_boundary
.as_ref()
.map(|value| value.aesthetic_directives.clone())
.unwrap_or_default(),
world_hook: anchor_content.world_promise.clone().unwrap_or_default(),
theme_keywords: split_anchor_phrases(anchor_content.theme_boundary.as_deref()),
tone_directives: split_anchor_phrases(anchor_content.theme_boundary.as_deref()),
player_premise: compact_lines([
anchor_content
.player_fantasy
.as_ref()
.map(|value| value.player_role.as_str()),
anchor_content
.player_entry_point
.as_ref()
.map(|value| value.opening_identity.as_str()),
anchor_content.player_fantasy.as_deref(),
anchor_content.player_entry_point.as_deref(),
]),
opening_situation: compact_lines([
anchor_content
.player_entry_point
.as_ref()
.map(|value| value.opening_problem.as_str()),
anchor_content
.player_entry_point
.as_ref()
.map(|value| value.entry_motivation.as_str()),
]),
core_conflicts: dedupe_string_list(core_conflicts, 6),
opening_situation: anchor_content
.player_entry_point
.clone()
.unwrap_or_default(),
core_conflicts: dedupe_string_list(
split_anchor_phrases(anchor_content.core_conflict.as_deref()),
6,
),
key_characters,
key_landmarks: Vec::new(),
iconic_elements: dedupe_string_list(
anchor_content
.iconic_elements
.as_ref()
.map(|value| {
value
.iconic_motifs
.iter()
.cloned()
.chain(value.institutions_or_artifacts.iter().cloned())
.collect::<Vec<_>>()
})
.unwrap_or_default(),
split_anchor_phrases(anchor_content.iconic_elements.as_deref()),
8,
),
forbidden_directives: dedupe_string_list(
anchor_content
.theme_boundary
.as_ref()
.map(|value| value.forbidden_directives.clone())
.unwrap_or_default()
split_anchor_phrases(anchor_content.theme_boundary.as_deref())
.into_iter()
.chain(
anchor_content
.iconic_elements
.as_ref()
.map(|value| value.hard_rules.clone())
.unwrap_or_default(),
)
.chain(split_anchor_phrases(
anchor_content.iconic_elements.as_deref(),
))
.filter(|value| contains_any(value, &["避免", "不要", "禁止", "不能", "硬规则"]))
.collect::<Vec<_>>(),
8,
),
@@ -1370,36 +1272,12 @@ fn detect_drift_risk(
let filled_count = [
anchor_content.world_promise.is_some(),
anchor_content.player_fantasy.is_some(),
anchor_content
.theme_boundary
.as_ref()
.map(|value| {
!value.tone_keywords.is_empty()
|| !value.aesthetic_directives.is_empty()
|| !value.forbidden_directives.is_empty()
})
.unwrap_or(false),
anchor_content.theme_boundary.is_some(),
anchor_content.player_entry_point.is_some(),
anchor_content.core_conflict.is_some(),
!anchor_content.key_relationships.is_empty(),
anchor_content
.hidden_lines
.as_ref()
.map(|value| {
!value.hidden_truths.is_empty()
|| !value.misdirection_hints.is_empty()
|| !value.reveal_pacing.trim().is_empty()
})
.unwrap_or(false),
anchor_content
.iconic_elements
.as_ref()
.map(|value| {
!value.iconic_motifs.is_empty()
|| !value.institutions_or_artifacts.is_empty()
|| !value.hard_rules.is_empty()
})
.unwrap_or(false),
anchor_content.key_relationships.is_some(),
anchor_content.hidden_lines.is_some(),
anchor_content.iconic_elements.is_some(),
]
.iter()
.filter(|value| **value)

View File

@@ -1,7 +1,6 @@
use axum::{
body::Body,
extract::{Path, State},
http::{HeaderName, HeaderValue, StatusCode, header},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
@@ -115,44 +114,47 @@ async fn read_legacy_generated_asset(
.headers()
.get(header::CONTENT_TYPE)
.cloned();
let bytes = upstream_response
.error_for_status()
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
}))
})?
.bytes()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
}))
})?;
let mut response = Response::builder()
.status(status)
.header(header::CACHE_CONTROL, CACHE_CONTROL_VALUE)
.header(
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源响应头失败:{error}"),
}))
})?,
);
if let Some(content_type) = content_type {
response = response.header(header::CONTENT_TYPE, content_type);
if !status.is_success() {
return Err(map_legacy_generated_upstream_status(status, object_key));
}
response.body(Body::from(bytes)).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源响应失败:{error}"),
let bytes = upstream_response.bytes().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
}))
})
})?;
// 旧 generated 路径会被 <img> / <video> 直接消费,成功分支必须返回原始二进制体。
// 这里显式组装 HeaderMap 并设置长度,避免代理层把已成功读取的 OSS 对象变成空响应。
let mut headers = HeaderMap::new();
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(CACHE_CONTROL_VALUE),
);
headers.insert(
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源响应头失败:{error}"),
}))
})?,
);
headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(bytes.len().to_string().as_str()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源长度响应头失败:{error}"),
}))
})?,
);
if let Some(content_type) = content_type {
headers.insert(header::CONTENT_TYPE, content_type);
}
Ok((status, headers, bytes).into_response())
}
fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result<String, AppError> {
@@ -189,6 +191,25 @@ fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
}))
}
fn map_legacy_generated_upstream_status(
status: reqwest::StatusCode,
object_key: String,
) -> AppError {
let mapped_status = match status {
reqwest::StatusCode::NOT_FOUND => StatusCode::NOT_FOUND,
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED => {
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(mapped_status).with_details(json!({
"provider": "aliyun-oss",
"objectKey": object_key,
"upstreamStatus": status.as_u16(),
}))
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -182,50 +182,28 @@ pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": {
"hook": "",
"differentiator": "",
"desiredExperience": ""
},
"playerFantasy": {
"playerRole": "",
"corePursuit": "",
"fearOfLoss": ""
},
"themeBoundary": {
"toneKeywords": [],
"aestheticDirectives": [],
"forbiddenDirectives": []
},
"playerEntryPoint": {
"openingIdentity": "",
"openingProblem": "",
"entryMotivation": ""
},
"coreConflict": {
"surfaceConflicts": [],
"hiddenCrisis": "",
"firstTouchedConflict": ""
},
"keyRelationships": [
{
"pairs": "",
"relationshipType": "",
"secretOrCost": ""
}
],
"hiddenLines": {
"hiddenTruths": [],
"misdirectionHints": [],
"revealPacing": ""
},
"iconicElements": {
"iconicMotifs": [],
"institutionsOrArtifacts": [],
"hardRules": []
}
"worldPromise": "",
"playerFantasy": "",
"themeBoundary": "",
"playerEntryPoint": "",
"coreConflict": "",
"keyRelationships": "",
"hiddenLines": "",
"iconicElements": ""
}
}"#;
}
nextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null不允许输出对象或数组。
请把每个锚点写成一段凝练中文:
- worldPromise 关注世界钩子、差异点、玩家体验。
- playerFantasy 关注玩家身份、核心追求、失去风险。
- themeBoundary 关注主题气质、美术方向、禁用方向。
- playerEntryPoint 关注开局身份、开局问题、行动动机。
- coreConflict 关注表层冲突、隐藏危机、首次触发点。
- keyRelationships 关注关键人物关系、关系类型、代价或秘密。
- hiddenLines 关注隐藏真相、误导线索、揭示节奏。
- iconicElements 关注标志意象、组织/物件、硬规则。
"#;
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
format!(

View File

@@ -1,27 +1,13 @@
/// 自定义世界角色主图提示词脚本。
pub(crate) fn build_character_visual_prompt(
prompt_text: &str,
character_brief_text: Option<&str>,
) -> String {
let character_brief = [character_brief_text.unwrap_or_default(), prompt_text]
.into_iter()
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n");
build_master_prompt(character_brief.as_str())
/// 自定义世界角色主图提示词脚本。
pub(crate) fn build_character_visual_prompt(prompt_text: &str) -> String {
build_master_prompt(prompt_text.trim())
}
/// 角色主图被供应商内容审核拦截时使用的安全兜底提示词。
///
/// 这里刻意不继续携带角色姓名、作品名和长设定文本,避免把可疑专名原样送回上游导致连续失败。
pub(crate) fn build_fallback_moderation_safe_character_visual_prompt(
prompt_text: &str,
character_brief_text: Option<&str>,
) -> String {
let source = [character_brief_text.unwrap_or_default(), prompt_text].join(" ");
let archetype = resolve_original_role_archetype(source.as_str());
pub(crate) fn build_fallback_moderation_safe_character_visual_prompt(prompt_text: &str) -> String {
let archetype = resolve_original_role_archetype(prompt_text);
build_master_prompt(
[
@@ -61,11 +47,11 @@ fn resolve_original_role_archetype(source: &str) -> &'static str {
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
fn build_master_prompt(character_brief: &str) -> String {
[
"单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,细节精致,设计感足,适合后续制作 sprite sheet 动画。".to_string(),
"单人2D像素角色形象,头身比必须控制在 1.5 到 2 头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素角色,头身比必须控制在 1 到 1.5 头身。使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点。".to_string(),
"风格要求:横版像素角色,细节精致,设计感足。使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点。".to_string(),
"如果角色形象设定没有明确要求非人身体结构,默认优先使用人类或类人动作角色骨架。\
默认将角色形象设定作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上。".to_string(),
"角色形象设定:".to_string(),
@@ -103,8 +89,6 @@ pub(crate) fn build_character_visual_negative_prompt() -> String {
"文字",
"水印",
"UI 元素",
"软萌 Q版大头贴",
"儿童绘本风",
"厚涂插画感",
"低对比柔边",
]

View File

@@ -20,6 +20,17 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
" \"attributeSchema\": {".to_string(),
" \"schemaName\": \"本世界六维名称\",".to_string(),
" \"slots\": [".to_string(),
" { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(),
" ]".to_string(),
" },".to_string(),
" \"camp\": {".to_string(),
" \"name\": \"开局归处名称\",".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
@@ -31,15 +42,18 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"".to_string(),
"要求:".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
"- 这一步只输出顶层 10 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 4090 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应第 1/2/3 幕背景图画面内容描述;每条必须基于同序号 actEventDescriptions 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 4090 个汉字内。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维属性体系slots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
"- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(),
"- 每个属性维度都要同时能服务战斗、社交、探索三种场景definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
"- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
@@ -50,9 +64,10 @@ pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &st
[
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
"请只输出修复后的 JSON 对象。",
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"attributeSchema 必须是对象,且包含 schemaName 与 slotsslots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f。",
"camp 必须是对象且包含name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。",
"原始文本:",
response_text.trim(),
@@ -135,47 +150,19 @@ pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt(
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_role_outline_asset_fields_repair_prompt(
role_type: &str,
role_entries: &[JsonValue],
missing_report: &str,
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
[
format!("下面这批{label}框架名单已经能解析为 JSON但有角色缺少资产默认描述字段。"),
"请只输出修复后的单个 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
"必须保留原有角色数量、顺序和 name不得新增、删除或改名。".to_string(),
"每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"visualDescription 必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内,不能复制 description。".to_string(),
"actionDescription 必须体现该角色默认动作节奏、武器或行动方式,控制在 18 到 48 个汉字内。".to_string(),
"sceneVisualDescription 必须描述该角色常出现或关联的场景画面,控制在 24 到 60 个汉字内。".to_string(),
"缺失报告:".to_string(),
missing_report.trim().to_string(),
"原始角色 JSON".to_string(),
compact_json_text(&JsonValue::Array(role_entries.to_vec())),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue,
batch_count: usize,
forbidden_names: &[String],
) -> String {
let story_npc_names = names_from_entries(&array_field(framework, "storyNpcs"));
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述逐幕背景描述。".to_string(),
"这一步必须一次性生成场景骨架、地点默认生图描述逐幕背景描述、幕 NPC 分配和相连场景信息".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("")) },
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
@@ -188,6 +175,9 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(),
" \"actNPCNames\": [\"第一幕主场景角色名\", \"第二幕主场景角色名\", \"第三幕主场景角色名\"],".to_string(),
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
@@ -196,12 +186,17 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(),
"- 每个地点只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook".to_string(),
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内".to_string(),
"- actNPCNames 只能引用上方可用场景角色名单中的名字,表示第 1/2/3 幕各自的主场景角色;如果名单为空,输出空数组".to_string(),
"- 可用场景角色名单非空时actNPCNames 必须恰好 3 个;可以重复使用同一角色,但每一项都必须服务对应幕事件。".to_string(),
"- actNPCNames[n] 会成为第 n+1 幕对面主角色;三幕事件和幕背景必须围绕对应角色的行动、阻碍、试探或求助展开。".to_string(),
"- connectedLandmarkNames 优先引用本批或已知关键场景名称,每个地点 1 到 3 个;只有 1 个地点时可以输出空数组。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围控制在 40 到 90 个汉字内。".to_string(),
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景玩家会在……”这类标题、摘要、规则句拼接格式必须像可直接交给生图模型的自然画面描述。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- description 控制在 12 到 24 个汉字内。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
@@ -219,70 +214,14 @@ pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt(
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("必须保留恰好 {expected_count} 个地点对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个地点只包含name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTextsactEventDescriptions 补空数组。".to_string(),
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
"每个地点只包含name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTextsactEventDescriptions、actNPCNames 和 connectedLandmarkNames 补空数组。".to_string(),
"不要输出 items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_landmark_network_batch_prompt(
framework: &JsonValue,
story_npcs: &[JsonValue],
landmark_batch: &[JsonValue],
) -> String {
[
"请补全下面这一批关键场景的探索网络信息。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 10),
"可用场景角色名单:".to_string(),
names_from_entries(story_npcs).join(""),
"本批场景:".to_string(),
compact_json_text(&JsonValue::Array(landmark_batch.to_vec())),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"landmarks\": [".to_string(),
" {".to_string(),
" \"name\": \"场景名称\",".to_string(),
" \"description\": \"场景描述\",".to_string(),
" \"sceneNpcNames\": [\"会在这里出现的角色名\"],".to_string(),
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 必须只补全本批场景name 必须与本批场景完全一致,不得增删改名。".to_string(),
"- sceneNpcNames 只能引用上方可用场景角色名单中的名字,每个地点 1 到 3 个。".to_string(),
"- connectedLandmarkNames 优先引用已知关键场景名称,每个地点 1 到 3 个。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_landmark_network_batch_json_repair_prompt(
response_text: &str,
expected_names: &[String],
) -> String {
[
"下面这段文本本应是自定义世界关键场景探索网络补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"请只输出修复后的 JSON 对象。".to_string(),
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("这个数组里只能保留这些地点名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个地点都必须包含name、description、sceneNpcNames、connectedLandmarkNames、entryHook。".to_string(),
"如果缺少字段:字符串补空字符串,数组补空数组。".to_string(),
"不要新增名单外的地点。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n")
}
pub(crate) fn build_custom_world_role_batch_prompt(
framework: &JsonValue,
role_type: &str,
@@ -498,7 +437,9 @@ fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec<String
array_field(framework, "landmarks")
.into_iter()
.filter_map(|landmark| {
let names = json_string_array(&landmark, "sceneNpcNames").unwrap_or_default();
let names = json_string_array(&landmark, "actNPCNames")
.or_else(|| json_string_array(&landmark, "sceneNpcNames"))
.unwrap_or_default();
if names.iter().any(|name| name == role_name) {
json_text(&landmark, "name")
} else {
@@ -559,7 +500,3 @@ fn json_string_array(value: &JsonValue, key: &str) -> Option<Vec<String>> {
.collect::<Vec<_>>();
if items.is_empty() { None } else { Some(items) }
}
fn compact_json_text(value: &JsonValue) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
}

View File

@@ -59,7 +59,7 @@ pub async fn stream_runtime_npc_chat_turn(
.or_else(|| read_string_field(&payload.encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
let player_message = payload.player_message.trim();
if player_message.is_empty() {
if player_message.is_empty() && !payload.npc_initiates_conversation {
return Err(runtime_chat_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -71,7 +71,7 @@ pub async fn stream_runtime_npc_chat_turn(
let llm_result =
generate_llm_npc_chat_turn(&state, &request_context, &payload, &npc_name).await;
let (mut body, npc_reply, suggestions) = match llm_result {
let (mut body, npc_reply, suggestions, function_suggestions, force_exit) = match llm_result {
Some(result) => result,
None => {
let npc_reply = build_deterministic_npc_reply(
@@ -79,11 +79,21 @@ pub async fn stream_runtime_npc_chat_turn(
player_message,
payload.npc_initiates_conversation,
);
let suggestions = if should_force_chat_exit(payload.chat_directive.as_ref()) {
let force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|| should_hostile_chat_breakoff_deterministically(
player_message,
payload.chat_directive.as_ref(),
);
let suggestions = if force_exit {
Vec::new()
} else {
build_deterministic_chat_suggestions(npc_name.as_str(), player_message)
};
let function_suggestions = if force_exit {
Vec::new()
} else {
build_fallback_function_suggestions(payload.chat_directive.as_ref())
};
let mut body = String::new();
append_sse_event(
&request_context,
@@ -91,20 +101,30 @@ pub async fn stream_runtime_npc_chat_turn(
"reply_delta",
&json!({ "text": npc_reply }),
)?;
(body, npc_reply, suggestions)
(
body,
npc_reply,
suggestions,
function_suggestions,
force_exit,
)
}
};
let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0);
let affinity_delta =
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count);
let affinity_delta = if payload.npc_initiates_conversation {
0
} else {
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count)
};
let complete_payload = json!({
"npcReply": npc_reply,
"affinityDelta": affinity_delta,
"affinityText": describe_affinity_shift(affinity_delta),
"suggestions": suggestions,
"functionSuggestions": function_suggestions,
"pendingQuestOffer": null,
"chatDirective": build_completion_directive(payload.chat_directive.as_ref()),
"chatDirective": build_completion_directive(payload.chat_directive.as_ref(), force_exit),
});
append_sse_event(&request_context, &mut body, "complete", &complete_payload)?;
@@ -117,7 +137,7 @@ async fn generate_llm_npc_chat_turn(
request_context: &RequestContext,
payload: &NpcChatTurnRequest,
npc_name: &str,
) -> Option<(String, String, Vec<String>)> {
) -> Option<(String, String, Vec<String>, Vec<Value>, bool)> {
let llm_client = state.llm_client()?;
let character = payload
.character
@@ -169,7 +189,7 @@ async fn generate_llm_npc_chat_turn(
});
if should_force_chat_exit(payload.chat_directive.as_ref()) {
return Some((body, npc_reply, Vec::new()));
return Some((body, npc_reply, Vec::new(), Vec::new(), true));
}
let suggestion_prompt =
@@ -180,15 +200,37 @@ async fn generate_llm_npc_chat_turn(
]);
suggestion_request.max_tokens = Some(200);
suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled;
let suggestions = llm_client
let suggestion_text = llm_client
.request_text(suggestion_request)
.await
.ok()
.map(|response| parse_line_list_content(response.content.as_str(), 3))
.filter(|items| items.len() == 3)
.unwrap_or_else(|| build_fallback_npc_chat_suggestions(payload.player_message.as_str()));
.map(|response| response.content)
.unwrap_or_default();
let (mut suggestions, mut function_suggestions, should_end_chat) =
parse_npc_chat_suggestion_resolution(
suggestion_text.as_str(),
payload.chat_directive.as_ref(),
);
let force_exit = should_end_chat
|| should_hostile_chat_breakoff_deterministically(
payload.player_message.as_str(),
payload.chat_directive.as_ref(),
);
Some((body, npc_reply, suggestions))
if force_exit {
suggestions.clear();
function_suggestions.clear();
} else if suggestions.is_empty() {
suggestions = build_fallback_npc_chat_suggestions(payload.player_message.as_str());
}
Some((
body,
npc_reply,
suggestions,
function_suggestions,
force_exit,
))
}
fn build_deterministic_npc_reply(
@@ -206,12 +248,12 @@ fn build_deterministic_npc_reply(
fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) -> Vec<String> {
// 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。
vec![
format!("继续询问{npc_name}的近况"),
"追问这里发生了什么".to_string(),
format!("{npc_name},我想先听你说"),
"这件事哪里不对劲".to_string(),
if player_message.contains('帮') || player_message.contains('忙') {
"请对方说清需要什么帮助".to_string()
"先别绕,说清代价".to_string()
} else {
"换个轻松的话题".to_string()
"你是不是还瞒着我".to_string()
},
]
}
@@ -225,33 +267,164 @@ fn build_fallback_npc_chat_suggestions(player_message: &str) -> Vec<String> {
};
vec![
"你刚才那句是什么意思".to_string(),
"我愿意先听你说完".to_string(),
format!("这事和{topic}有关吗"),
"愿意再说清楚点吗".to_string(),
"别再避重就轻".to_string(),
]
}
fn build_completion_directive(chat_directive: Option<&Value>) -> Value {
fn build_fallback_function_suggestions(chat_directive: Option<&Value>) -> Vec<Value> {
read_function_options(chat_directive)
.into_iter()
.filter(|option| {
read_string_field(option, "functionId")
.as_deref()
.is_some_and(|function_id| function_id != "npc_chat")
})
.take(2)
.filter_map(|option| {
let function_id = read_string_field(option, "functionId")?;
let action_text = read_string_field(option, "actionText")?;
Some(json!({
"functionId": function_id,
"actionText": action_text,
}))
})
.collect()
}
fn build_completion_directive(chat_directive: Option<&Value>, force_exit: bool) -> Value {
let Some(directive) = chat_directive else {
return Value::Null;
};
let closing_mode = read_string_field(directive, "closingMode")
.filter(|value| value == "foreshadow_close")
.unwrap_or_else(|| "free".to_string());
let force_exit = closing_mode == "foreshadow_close"
let force_exit = force_exit
|| closing_mode == "foreshadow_close"
|| directive
.get("forceExitAfterTurn")
.and_then(Value::as_bool)
.unwrap_or(false);
let termination_reason = if force_exit {
read_string_field(directive, "terminationReason")
.filter(|value| value == "player_exit" || value == "hostile_breakoff")
.or_else(|| {
if is_hostile_model_chat(chat_directive) {
Some("hostile_breakoff".to_string())
} else {
None
}
})
} else {
None
};
json!({
"turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null),
"remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null),
"forceExit": force_exit,
"closingMode": closing_mode,
"closingMode": if force_exit { "foreshadow_close" } else { closing_mode.as_str() },
"terminationReason": termination_reason,
})
}
fn parse_npc_chat_suggestion_resolution(
text: &str,
chat_directive: Option<&Value>,
) -> (Vec<String>, Vec<Value>, bool) {
let normalized = text.trim();
if normalized.is_empty() {
return (
Vec::new(),
build_fallback_function_suggestions(chat_directive),
false,
);
}
if let Ok(value) = serde_json::from_str::<Value>(normalized) {
let should_end_chat = value
.get("shouldEndChat")
.and_then(Value::as_bool)
.unwrap_or(false)
&& is_hostile_model_chat(chat_directive);
let suggestions = value
.get("suggestions")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToOwned::to_owned)
.take(3)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let function_suggestions =
parse_function_suggestions(value.get("functionSuggestions"), chat_directive);
return (suggestions, function_suggestions, should_end_chat);
}
(
parse_line_list_content(normalized, 3),
build_fallback_function_suggestions(chat_directive),
false,
)
}
fn parse_function_suggestions(value: Option<&Value>, chat_directive: Option<&Value>) -> Vec<Value> {
let allowed_options = read_function_options(chat_directive);
let allowed_ids = allowed_options
.iter()
.filter_map(|item| read_string_field(item, "functionId"))
.collect::<Vec<_>>();
let mut used_ids: Vec<String> = Vec::new();
value
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|item| {
let function_id = read_string_field(item, "functionId")?;
if function_id == "npc_chat" {
return None;
}
if !allowed_ids.is_empty() && !allowed_ids.contains(&function_id) {
return None;
}
if used_ids.contains(&function_id) {
return None;
}
let fallback_text = allowed_options
.iter()
.find(|option| {
read_string_field(option, "functionId").as_deref() == Some(function_id.as_str())
})
.and_then(|option| read_string_field(option, "actionText"));
let action_text = read_string_field(item, "actionText")
.or(fallback_text)
.filter(|text| !text.trim().is_empty())?;
used_ids.push(function_id.clone());
Some(json!({
"functionId": function_id,
"actionText": action_text,
}))
})
.take(3)
.collect()
}
fn read_function_options(chat_directive: Option<&Value>) -> Vec<&Value> {
chat_directive
.and_then(|directive| directive.get("functionOptions"))
.and_then(Value::as_array)
.map(|items| items.iter().collect::<Vec<_>>())
.unwrap_or_default()
}
fn read_string_field(value: &Value, field: &str) -> Option<String> {
value
.get(field)
@@ -268,18 +441,61 @@ fn read_number_field(value: &Value, field: &str) -> Option<f64> {
.filter(|number| number.is_finite())
}
fn read_bool_field(value: &Value, field: &str) -> Option<bool> {
value.get(field).and_then(Value::as_bool)
}
fn should_force_chat_exit(chat_directive: Option<&Value>) -> bool {
let Some(directive) = chat_directive else {
return false;
};
read_string_field(directive, "closingMode").as_deref() == Some("foreshadow_close")
|| read_string_field(directive, "terminationReason").as_deref() == Some("player_exit")
|| directive
.get("forceExitAfterTurn")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn is_hostile_model_chat(chat_directive: Option<&Value>) -> bool {
let Some(directive) = chat_directive else {
return false;
};
read_string_field(directive, "terminationMode").as_deref() == Some("hostile_model")
|| read_bool_field(directive, "isHostileChat").unwrap_or(false)
}
fn should_hostile_chat_breakoff_deterministically(
player_message: &str,
chat_directive: Option<&Value>,
) -> bool {
if !is_hostile_model_chat(chat_directive) {
return false;
}
let Some(directive) = chat_directive else {
return false;
};
if read_string_field(directive, "terminationReason").as_deref() == Some("player_exit") {
return true;
}
let hostile_break_words = [
"动手",
"开战",
"拔刀",
"",
"",
"闭嘴",
"少废话",
"别挡路",
];
count_keyword_matches(player_message, &hostile_break_words) > 0
}
fn normalize_required_text(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {
@@ -442,6 +658,21 @@ mod tests {
);
}
#[test]
fn npc_initiated_opening_keeps_neutral_affinity_delta() {
// 首遇主动开场不是玩家发言结算,不能因为空 playerMessage 或占位文本触发好感变化。
let npc_initiates_conversation = true;
let player_message = "";
let npc_reply = "你来了。先别急着走,我正有话想和你说。";
let affinity_delta = if npc_initiates_conversation {
0
} else {
compute_npc_chat_affinity_delta(player_message, npc_reply, 0.0)
};
assert_eq!(affinity_delta, 0);
}
#[test]
fn npc_chat_suggestion_parser_strips_list_markers() {
assert_eq!(

View File

@@ -6,10 +6,16 @@ pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#;
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为玩家生成下一轮可直接点击的 3 条聊天续写候选
只输出纯文本,共 3 行,每行 1 条
不要加编号、项目符号、Markdown、JSON 或额外说明。
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。"#;
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束
只输出 JSON不要输出 Markdown 或解释
JSON 结构:
{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]}
- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。
- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId不写规则说明。
- 非敌对聊天 shouldEndChat 必须为 false。
- 敌对聊天可以随时 shouldEndChat=true且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
#[derive(Debug)]
pub(crate) struct NpcChatTurnPromptInput<'a> {
@@ -71,6 +77,17 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<
let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode")));
let is_limited_negative_affinity_chat =
limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0;
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close")
|| chat_directive
.and_then(|record| read_bool(record.get("forceExitAfterTurn")))
@@ -142,6 +159,21 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<
} else {
None
},
if is_hostile_model_chat {
Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string())
} else {
None
},
if is_hostile_model_chat {
Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string())
} else {
None
},
if is_player_exit_turn {
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
} else {
None
},
if is_limited_negative_affinity_chat {
Some(format!(
"在你回复完这一轮之后,还剩 {} 轮可以继续聊。",
@@ -205,6 +237,22 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
payload.dialogue
};
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
let chat_directive = payload.chat_directive.and_then(as_record);
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let function_options_block = chat_directive
.and_then(|record| record.get("functionOptions"))
.map(describe_function_options)
.filter(|text| !text.trim().is_empty());
[
Some(build_npc_dialogue_prompt_base(payload)),
@@ -213,11 +261,26 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
encounter.npc_name.as_str(),
)),
combat_context_block,
Some(format!("玩家刚刚说:{}", payload.player_message)),
function_options_block,
if payload.npc_initiates_conversation {
Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message))
},
Some(format!("NPC 刚刚回复:{npc_reply}")),
Some("请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。".to_string()),
Some("每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议".to_string()),
Some("每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。".to_string()),
if is_hostile_model_chat {
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙".to_string())
} else {
Some("这是非敌对聊天shouldEndChat 必须为 false。".to_string())
},
if is_player_exit_turn {
Some("玩家已经选择结束聊天shouldEndChat 必须为 trueterminationReason 必须为 player_exit。".to_string())
} else {
None
},
Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()),
Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()),
Some("只输出 JSON{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()),
]
.into_iter()
.flatten()
@@ -226,6 +289,38 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
.join("\n\n")
}
fn describe_function_options(value: &Value) -> String {
let lines = value
.as_array()
.map(|items| {
items
.iter()
.take(8)
.filter_map(|item| {
let record = as_record(item)?;
let function_id = read_string(record.get("functionId"))?;
let action_text = read_string(record.get("actionText"))?;
let detail_text = read_string(record.get("detailText"));
let action = read_string(record.get("action"));
Some(format!(
"- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}",
action.unwrap_or_else(|| "unknown".to_string()),
detail_text.unwrap_or_else(|| "".to_string()),
))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if lines.is_empty() {
return String::new();
}
let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions".to_string()];
result.extend(lines);
result.join("\n")
}
fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);

View File

@@ -7,15 +7,18 @@ use axum::{
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord,
RuntimeProfileRechargeProductRecord, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -146,6 +149,54 @@ pub async fn create_profile_recharge_order(
))
}
pub async fn get_profile_referral_invite_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_referral_invite_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_referral_invite_center_response(record),
))
}
pub async fn redeem_profile_referral_invite_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_redeem_profile_referral_invite_code_response(record),
))
}
pub async fn get_profile_play_stats(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -284,6 +335,36 @@ fn build_profile_recharge_order_response(
}
}
fn build_profile_referral_invite_center_response(
record: RuntimeReferralInviteCenterRecord,
) -> ProfileReferralInviteCenterResponse {
ProfileReferralInviteCenterResponse {
invite_code: record.invite_code,
invite_link_path: record.invite_link_path,
invited_count: record.invited_count,
rewarded_invite_count: record.rewarded_invite_count,
today_inviter_reward_count: record.today_inviter_reward_count,
today_inviter_reward_remaining: record.today_inviter_reward_remaining,
reward_points: record.reward_points,
has_redeemed_code: record.has_redeemed_code,
bound_inviter_user_id: record.bound_inviter_user_id,
bound_at: record.bound_at,
updated_at: record.updated_at,
}
}
fn build_redeem_profile_referral_invite_code_response(
record: RuntimeReferralRedeemRecord,
) -> RedeemProfileReferralInviteCodeResponse {
RedeemProfileReferralInviteCodeResponse {
center: build_profile_referral_invite_center_response(record.center),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: record.invitee_balance_after,
inviter_balance_after: record.inviter_balance_after,
}
}
#[cfg(test)]
mod tests {
use axum::{
@@ -382,7 +463,44 @@ mod tests {
.method("POST")
.uri("/api/profile/recharge/orders")
.header("content-type", "application/json")
.body(Body::from(r#"{"productId":"points_10"}"#))
.body(Body::from(r#"{"productId":"points_60"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_invite_center_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/referrals/invite-center")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_redeem_code_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/referrals/redeem-code")
.header("content-type", "application/json")
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
.expect("request should build"),
)
.await

View File

@@ -1418,7 +1418,7 @@ pub fn build_custom_world_published_profile_compile_snapshot(
}
pub fn empty_agent_anchor_content_json() -> String {
r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":[],"hiddenLines":null,"iconicElements":null}"#.to_string()
r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string()
}
pub fn empty_agent_creator_intent_readiness_json() -> String {

View File

@@ -1508,59 +1508,59 @@ impl RuntimeProfileRechargeOrderStatus {
pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargeProductSnapshot> {
vec![
build_points_recharge_product(
"points_10",
"10积分",
100,
10,
19,
"首充送积分",
"首充送19积分",
),
build_points_recharge_product(
"points_60",
"60积分",
"60叙世币",
600,
60,
0,
"首充赠礼",
"首充",
60,
"首充双倍",
"首充送60叙世币",
),
build_points_recharge_product(
"points_240",
"240积分",
2400,
240,
240,
"points_180",
"180叙世币",
1800,
180,
180,
"首充双倍",
"首充送240积分",
"首充送180叙世币",
),
build_points_recharge_product(
"points_450",
"450积分",
4500,
450,
450,
"points_300",
"300叙世币",
3000,
300,
300,
"首充双倍",
"首充送450积分",
"首充送300叙世币",
),
build_points_recharge_product(
"points_950",
"950积分",
9500,
950,
950,
"points_680",
"680叙世币",
6800,
680,
680,
"首充双倍",
"首充送950积分",
"首充送680叙世币",
),
build_points_recharge_product(
"points_1980",
"1980积分",
19800,
1980,
1980,
"points_1280",
"1280叙世币",
12800,
1280,
1280,
"首充双倍",
"首充送1980积分",
"首充送1280叙世币",
),
build_points_recharge_product(
"points_3280",
"3280叙世币",
32800,
3280,
3280,
"首充双倍",
"首充送3280叙世币",
),
]
}
@@ -1609,7 +1609,7 @@ pub fn runtime_profile_membership_benefits() -> Vec<RuntimeProfileMembershipBene
year_value: "¥248".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "积分回合数".to_string(),
benefit_name: "叙世币回合数".to_string(),
normal_value: "30".to_string(),
month_value: "100".to_string(),
season_value: "100".to_string(),
@@ -1970,14 +1970,26 @@ mod tests {
let membership_products = runtime_profile_recharge_membership_products();
assert_eq!(point_products.len(), 6);
assert_eq!(point_products[0].product_id, "points_10");
assert_eq!(point_products[0].price_cents, 100);
assert_eq!(point_products[0].bonus_points, 19);
assert_eq!(point_products[5].points_amount, 1980);
assert_eq!(point_products[0].product_id, "points_60");
assert_eq!(point_products[0].title, "60叙世币");
assert_eq!(point_products[0].price_cents, 600);
assert_eq!(point_products[0].bonus_points, 60);
assert_eq!(point_products[0].description, "首充送60叙世币");
assert_eq!(point_products[5].product_id, "points_3280");
assert_eq!(point_products[5].price_cents, 32800);
assert_eq!(point_products[5].bonus_points, 3280);
assert_eq!(point_products[5].description, "首充送3280叙世币");
assert_eq!(membership_products.len(), 3);
assert_eq!(membership_products[0].title, "月卡");
assert_eq!(membership_products[0].price_cents, 2800);
assert_eq!(membership_products[2].duration_days, 365);
let benefits = runtime_profile_membership_benefits();
assert!(
benefits
.iter()
.any(|benefit| benefit.benefit_name == "免叙世币回合数")
);
}
#[test]

View File

@@ -99,8 +99,6 @@ pub struct CharacterVisualGenerateRequest {
pub source_mode: CharacterVisualSourceMode,
pub prompt_text: String,
#[serde(default)]
pub character_brief_text: Option<String>,
#[serde(default)]
pub reference_image_data_urls: Vec<String>,
pub candidate_count: u32,
pub image_model: String,

View File

@@ -5,6 +5,8 @@ pub const RUNTIME_PLATFORM_THEME_DARK: &str = "dark";
pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward";
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -220,6 +222,38 @@ pub struct CreateProfileRechargeOrderResponse {
pub center: ProfileRechargeCenterResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileReferralInviteCenterResponse {
pub invite_code: String,
pub invite_link_path: String,
pub invited_count: u32,
pub rewarded_invite_count: u32,
pub today_inviter_reward_count: u32,
pub today_inviter_reward_remaining: u32,
pub reward_points: u64,
pub has_redeemed_code: bool,
pub bound_inviter_user_id: Option<String>,
pub bound_at: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RedeemProfileReferralInviteCodeRequest {
pub invite_code: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RedeemProfileReferralInviteCodeResponse {
pub center: ProfileReferralInviteCenterResponse,
pub invitee_reward_granted: bool,
pub inviter_reward_granted: bool,
pub invitee_balance_after: u64,
pub inviter_balance_after: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfilePlayedWorkSummaryResponse {
@@ -749,15 +783,15 @@ mod tests {
updated_at: Some("2026-04-25T10:00:00Z".to_string()),
},
point_products: vec![ProfileRechargeProductResponse {
product_id: "points_10".to_string(),
title: "10积分".to_string(),
price_cents: 100,
product_id: "points_60".to_string(),
title: "60叙世币".to_string(),
price_cents: 600,
kind: "points".to_string(),
points_amount: 10,
bonus_points: 19,
points_amount: 60,
bonus_points: 60,
duration_days: 0,
badge_label: "首充送积分".to_string(),
description: "首充送19积分".to_string(),
badge_label: "首充双倍".to_string(),
description: "首充送60叙世币".to_string(),
tier: "normal".to_string(),
}],
membership_products: vec![],
@@ -772,8 +806,13 @@ mod tests {
payload["membership"]["expiresAt"],
json!("2026-05-25T10:00:00Z")
);
assert_eq!(payload["pointProducts"][0]["productId"], json!("points_10"));
assert_eq!(payload["pointProducts"][0]["priceCents"], json!(100));
assert_eq!(payload["pointProducts"][0]["productId"], json!("points_60"));
assert_eq!(payload["pointProducts"][0]["title"], json!("60叙世币"));
assert_eq!(payload["pointProducts"][0]["priceCents"], json!(600));
assert_eq!(
payload["pointProducts"][0]["description"],
json!("首充送60叙世币")
);
assert_eq!(payload["hasPointsRecharged"], json!(false));
}

View File

@@ -121,7 +121,8 @@ use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord, RuntimeSettingsRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
build_runtime_browse_history_list_input, build_runtime_browse_history_record,
build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input,
@@ -132,7 +133,9 @@ use module_runtime::{
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record,
build_runtime_profile_save_archive_resume_input,
build_runtime_profile_wallet_ledger_entry_record,
build_runtime_profile_wallet_ledger_list_input, build_runtime_setting_get_input,
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,
build_runtime_referral_redeem_record, build_runtime_setting_get_input,
build_runtime_setting_record, build_runtime_setting_upsert_input,
build_runtime_snapshot_delete_input, build_runtime_snapshot_get_input,
build_runtime_snapshot_record, build_runtime_snapshot_upsert_input,

View File

@@ -139,6 +139,26 @@ impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
}
}
impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
for RuntimeReferralInviteCenterGetInput
{
fn from(input: module_runtime::RuntimeReferralInviteCenterGetInput) -> Self {
Self {
user_id: input.user_id,
}
}
}
impl From<module_runtime::RuntimeReferralRedeemInput> for RuntimeReferralRedeemInput {
fn from(input: module_runtime::RuntimeReferralRedeemInput) -> Self {
Self {
user_id: input.user_id,
invite_code: input.invite_code,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfilePlayStatsGetInput> for RuntimeProfilePlayStatsGetInput {
fn from(input: module_runtime::RuntimeProfilePlayStatsGetInput) -> Self {
Self {
@@ -675,6 +695,50 @@ pub(crate) fn map_runtime_profile_recharge_order_procedure_result(
))
}
pub(crate) fn map_runtime_referral_invite_center_procedure_result(
result: RuntimeReferralInviteCenterProcedureResult,
) -> Result<RuntimeReferralInviteCenterRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 referral invite center 快照".to_string(),
)
})?;
Ok(build_runtime_referral_invite_center_record(
map_runtime_referral_invite_center_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_referral_redeem_procedure_result(
result: RuntimeReferralRedeemProcedureResult,
) -> Result<RuntimeReferralRedeemRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 referral redeem 快照".to_string(),
)
})?;
Ok(build_runtime_referral_redeem_record(
map_runtime_referral_redeem_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_play_stats_procedure_result(
result: RuntimeProfilePlayStatsProcedureResult,
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
@@ -1513,6 +1577,37 @@ pub(crate) fn map_runtime_profile_recharge_order_snapshot(
}
}
pub(crate) fn map_runtime_referral_invite_center_snapshot(
snapshot: RuntimeReferralInviteCenterSnapshot,
) -> module_runtime::RuntimeReferralInviteCenterSnapshot {
module_runtime::RuntimeReferralInviteCenterSnapshot {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
invite_link_path: snapshot.invite_link_path,
invited_count: snapshot.invited_count,
rewarded_invite_count: snapshot.rewarded_invite_count,
today_inviter_reward_count: snapshot.today_inviter_reward_count,
today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining,
reward_points: snapshot.reward_points,
has_redeemed_code: snapshot.has_redeemed_code,
bound_inviter_user_id: snapshot.bound_inviter_user_id,
bound_at_micros: snapshot.bound_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_referral_redeem_snapshot(
snapshot: RuntimeReferralRedeemSnapshot,
) -> module_runtime::RuntimeReferralRedeemSnapshot {
module_runtime::RuntimeReferralRedeemSnapshot {
center: map_runtime_referral_invite_center_snapshot(snapshot.center),
invitee_reward_granted: snapshot.invitee_reward_granted,
inviter_reward_granted: snapshot.inviter_reward_granted,
invitee_balance_after: snapshot.invitee_balance_after,
inviter_balance_after: snapshot.inviter_balance_after,
}
}
pub(crate) fn map_runtime_profile_played_world_snapshot(
snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {

View File

@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput;
use super::runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetProfileReferralInviteCenterArgs {
pub input: RuntimeReferralInviteCenterGetInput,
}
impl __sdk::InModule for GetProfileReferralInviteCenterArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_profile_referral_invite_center`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_profile_referral_invite_center {
fn get_profile_referral_invite_center(&self, input: RuntimeReferralInviteCenterGetInput,
) {
self.get_profile_referral_invite_center_then(input, |_, _| {});
}
fn get_profile_referral_invite_center_then(
&self,
input: RuntimeReferralInviteCenterGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeReferralInviteCenterProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl get_profile_referral_invite_center for super::RemoteProcedures {
fn get_profile_referral_invite_center_then(
&self,
input: RuntimeReferralInviteCenterGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeReferralInviteCenterProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, RuntimeReferralInviteCenterProcedureResult>(
"get_profile_referral_invite_center",
GetProfileReferralInviteCenterArgs { input, },
__callback,
);
}
}

View File

@@ -189,9 +189,11 @@ pub mod player_progression_grant_source_type;
pub mod player_progression_procedure_result_type;
pub mod player_progression_snapshot_type;
pub mod profile_dashboard_state_type;
pub mod profile_invite_code_type;
pub mod profile_membership_type;
pub mod profile_played_world_type;
pub mod profile_recharge_order_type;
pub mod profile_referral_relation_type;
pub mod profile_save_archive_type;
pub mod profile_wallet_ledger_type;
pub mod puzzle_agent_message_finalize_input_type;
@@ -305,6 +307,12 @@ pub mod runtime_profile_wallet_ledger_entry_snapshot_type;
pub mod runtime_profile_wallet_ledger_list_input_type;
pub mod runtime_profile_wallet_ledger_procedure_result_type;
pub mod runtime_profile_wallet_ledger_source_type_type;
pub mod runtime_referral_invite_center_get_input_type;
pub mod runtime_referral_invite_center_procedure_result_type;
pub mod runtime_referral_invite_center_snapshot_type;
pub mod runtime_referral_redeem_input_type;
pub mod runtime_referral_redeem_procedure_result_type;
pub mod runtime_referral_redeem_snapshot_type;
pub mod runtime_setting_type;
pub mod runtime_setting_get_input_type;
pub mod runtime_setting_procedure_result_type;
@@ -359,7 +367,52 @@ pub mod unpublish_custom_world_profile_reducer;
pub mod upsert_chapter_progression_reducer;
pub mod upsert_custom_world_profile_reducer;
pub mod upsert_npc_state_reducer;
pub mod ai_result_reference_table;
pub mod ai_task_table;
pub mod ai_task_stage_table;
pub mod ai_text_chunk_table;
pub mod asset_entity_binding_table;
pub mod asset_object_table;
pub mod auth_identity_table;
pub mod auth_store_snapshot_table;
pub mod battle_state_table;
pub mod big_fish_agent_message_table;
pub mod big_fish_asset_slot_table;
pub mod big_fish_creation_session_table;
pub mod big_fish_runtime_run_table;
pub mod chapter_progression_table;
pub mod custom_world_agent_message_table;
pub mod custom_world_agent_operation_table;
pub mod custom_world_agent_session_table;
pub mod custom_world_draft_card_table;
pub mod custom_world_gallery_entry_table;
pub mod custom_world_profile_table;
pub mod custom_world_session_table;
pub mod inventory_slot_table;
pub mod npc_state_table;
pub mod player_progression_table;
pub mod profile_dashboard_state_table;
pub mod profile_invite_code_table;
pub mod profile_membership_table;
pub mod profile_played_world_table;
pub mod profile_recharge_order_table;
pub mod profile_referral_relation_table;
pub mod profile_save_archive_table;
pub mod profile_wallet_ledger_table;
pub mod puzzle_agent_message_table;
pub mod puzzle_agent_session_table;
pub mod puzzle_runtime_run_table;
pub mod puzzle_work_profile_table;
pub mod quest_log_table;
pub mod quest_record_table;
pub mod refresh_session_table;
pub mod runtime_setting_table;
pub mod runtime_snapshot_table;
pub mod story_event_table;
pub mod story_session_table;
pub mod treasure_record_table;
pub mod user_account_table;
pub mod user_browse_history_table;
pub mod advance_puzzle_next_level_procedure;
pub mod append_ai_text_chunk_and_return_procedure;
pub mod apply_chapter_progression_ledger_entry_and_return_procedure;
@@ -409,6 +462,7 @@ pub mod get_player_progression_or_default_procedure;
pub mod get_profile_dashboard_procedure;
pub mod get_profile_play_stats_procedure;
pub mod get_profile_recharge_center_procedure;
pub mod get_profile_referral_invite_center_procedure;
pub mod get_puzzle_agent_session_procedure;
pub mod get_puzzle_gallery_detail_procedure;
pub mod get_puzzle_run_procedure;
@@ -432,6 +486,7 @@ pub mod publish_big_fish_game_procedure;
pub mod publish_custom_world_profile_and_return_procedure;
pub mod publish_custom_world_world_procedure;
pub mod publish_puzzle_work_procedure;
pub mod redeem_profile_referral_invite_code_procedure;
pub mod resolve_combat_action_and_return_procedure;
pub mod resolve_npc_battle_interaction_and_return_procedure;
pub mod resolve_npc_interaction_and_return_procedure;
@@ -636,9 +691,11 @@ pub use player_progression_grant_source_type::PlayerProgressionGrantSource;
pub use player_progression_procedure_result_type::PlayerProgressionProcedureResult;
pub use player_progression_snapshot_type::PlayerProgressionSnapshot;
pub use profile_dashboard_state_type::ProfileDashboardState;
pub use profile_invite_code_type::ProfileInviteCode;
pub use profile_membership_type::ProfileMembership;
pub use profile_played_world_type::ProfilePlayedWorld;
pub use profile_recharge_order_type::ProfileRechargeOrder;
pub use profile_referral_relation_type::ProfileReferralRelation;
pub use profile_save_archive_type::ProfileSaveArchive;
pub use profile_wallet_ledger_type::ProfileWalletLedger;
pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput;
@@ -752,6 +809,12 @@ pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletL
pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput;
pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult;
pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType;
pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput;
pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult;
pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot;
pub use runtime_referral_redeem_input_type::RuntimeReferralRedeemInput;
pub use runtime_referral_redeem_procedure_result_type::RuntimeReferralRedeemProcedureResult;
pub use runtime_referral_redeem_snapshot_type::RuntimeReferralRedeemSnapshot;
pub use runtime_setting_type::RuntimeSetting;
pub use runtime_setting_get_input_type::RuntimeSettingGetInput;
pub use runtime_setting_procedure_result_type::RuntimeSettingProcedureResult;
@@ -782,7 +845,52 @@ pub use treasure_resolve_input_type::TreasureResolveInput;
pub use unequip_inventory_item_input_type::UnequipInventoryItemInput;
pub use user_account_type::UserAccount;
pub use user_browse_history_type::UserBrowseHistory;
pub use ai_result_reference_table::*;
pub use ai_task_table::*;
pub use ai_task_stage_table::*;
pub use ai_text_chunk_table::*;
pub use asset_entity_binding_table::*;
pub use asset_object_table::*;
pub use auth_identity_table::*;
pub use auth_store_snapshot_table::*;
pub use battle_state_table::*;
pub use big_fish_agent_message_table::*;
pub use big_fish_asset_slot_table::*;
pub use big_fish_creation_session_table::*;
pub use big_fish_runtime_run_table::*;
pub use chapter_progression_table::*;
pub use custom_world_agent_message_table::*;
pub use custom_world_agent_operation_table::*;
pub use custom_world_agent_session_table::*;
pub use custom_world_draft_card_table::*;
pub use custom_world_gallery_entry_table::*;
pub use custom_world_profile_table::*;
pub use custom_world_session_table::*;
pub use inventory_slot_table::*;
pub use npc_state_table::*;
pub use player_progression_table::*;
pub use profile_dashboard_state_table::*;
pub use profile_invite_code_table::*;
pub use profile_membership_table::*;
pub use profile_played_world_table::*;
pub use profile_recharge_order_table::*;
pub use profile_referral_relation_table::*;
pub use profile_save_archive_table::*;
pub use profile_wallet_ledger_table::*;
pub use puzzle_agent_message_table::*;
pub use puzzle_agent_session_table::*;
pub use puzzle_runtime_run_table::*;
pub use puzzle_work_profile_table::*;
pub use quest_log_table::*;
pub use quest_record_table::*;
pub use refresh_session_table::*;
pub use runtime_setting_table::*;
pub use runtime_snapshot_table::*;
pub use story_event_table::*;
pub use story_session_table::*;
pub use treasure_record_table::*;
pub use user_account_table::*;
pub use user_browse_history_table::*;
pub use accept_quest_reducer::accept_quest;
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
pub use apply_chapter_progression_ledger_entry_reducer::apply_chapter_progression_ledger_entry;
@@ -856,6 +964,7 @@ pub use get_player_progression_or_default_procedure::get_player_progression_or_d
pub use get_profile_dashboard_procedure::get_profile_dashboard;
pub use get_profile_play_stats_procedure::get_profile_play_stats;
pub use get_profile_recharge_center_procedure::get_profile_recharge_center;
pub use get_profile_referral_invite_center_procedure::get_profile_referral_invite_center;
pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session;
pub use get_puzzle_gallery_detail_procedure::get_puzzle_gallery_detail;
pub use get_puzzle_run_procedure::get_puzzle_run;
@@ -879,6 +988,7 @@ pub use publish_big_fish_game_procedure::publish_big_fish_game;
pub use publish_custom_world_profile_and_return_procedure::publish_custom_world_profile_and_return;
pub use publish_custom_world_world_procedure::publish_custom_world_world;
pub use publish_puzzle_work_procedure::publish_puzzle_work;
pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code;
pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return;
pub use resolve_npc_battle_interaction_and_return_procedure::resolve_npc_battle_interaction_and_return;
pub use resolve_npc_interaction_and_return_procedure::resolve_npc_interaction_and_return;
@@ -1154,7 +1264,52 @@ fn args_bsatn(&self) -> Result<Vec<u8>, __sats::bsatn::EncodeError> {
#[allow(non_snake_case)]
#[doc(hidden)]
pub struct DbUpdate {
custom_world_gallery_entry: __sdk::TableUpdate<CustomWorldGalleryEntry>,
ai_result_reference: __sdk::TableUpdate<AiResultReference>,
ai_task: __sdk::TableUpdate<AiTask>,
ai_task_stage: __sdk::TableUpdate<AiTaskStage>,
ai_text_chunk: __sdk::TableUpdate<AiTextChunk>,
asset_entity_binding: __sdk::TableUpdate<AssetEntityBinding>,
asset_object: __sdk::TableUpdate<AssetObject>,
auth_identity: __sdk::TableUpdate<AuthIdentity>,
auth_store_snapshot: __sdk::TableUpdate<AuthStoreSnapshot>,
battle_state: __sdk::TableUpdate<BattleState>,
big_fish_agent_message: __sdk::TableUpdate<BigFishAgentMessage>,
big_fish_asset_slot: __sdk::TableUpdate<BigFishAssetSlot>,
big_fish_creation_session: __sdk::TableUpdate<BigFishCreationSession>,
big_fish_runtime_run: __sdk::TableUpdate<BigFishRuntimeRun>,
chapter_progression: __sdk::TableUpdate<ChapterProgression>,
custom_world_agent_message: __sdk::TableUpdate<CustomWorldAgentMessage>,
custom_world_agent_operation: __sdk::TableUpdate<CustomWorldAgentOperation>,
custom_world_agent_session: __sdk::TableUpdate<CustomWorldAgentSession>,
custom_world_draft_card: __sdk::TableUpdate<CustomWorldDraftCard>,
custom_world_gallery_entry: __sdk::TableUpdate<CustomWorldGalleryEntry>,
custom_world_profile: __sdk::TableUpdate<CustomWorldProfile>,
custom_world_session: __sdk::TableUpdate<CustomWorldSession>,
inventory_slot: __sdk::TableUpdate<InventorySlot>,
npc_state: __sdk::TableUpdate<NpcState>,
player_progression: __sdk::TableUpdate<PlayerProgression>,
profile_dashboard_state: __sdk::TableUpdate<ProfileDashboardState>,
profile_invite_code: __sdk::TableUpdate<ProfileInviteCode>,
profile_membership: __sdk::TableUpdate<ProfileMembership>,
profile_played_world: __sdk::TableUpdate<ProfilePlayedWorld>,
profile_recharge_order: __sdk::TableUpdate<ProfileRechargeOrder>,
profile_referral_relation: __sdk::TableUpdate<ProfileReferralRelation>,
profile_save_archive: __sdk::TableUpdate<ProfileSaveArchive>,
profile_wallet_ledger: __sdk::TableUpdate<ProfileWalletLedger>,
puzzle_agent_message: __sdk::TableUpdate<PuzzleAgentMessageRow>,
puzzle_agent_session: __sdk::TableUpdate<PuzzleAgentSessionRow>,
puzzle_runtime_run: __sdk::TableUpdate<PuzzleRuntimeRunRow>,
puzzle_work_profile: __sdk::TableUpdate<PuzzleWorkProfileRow>,
quest_log: __sdk::TableUpdate<QuestLog>,
quest_record: __sdk::TableUpdate<QuestRecord>,
refresh_session: __sdk::TableUpdate<RefreshSession>,
runtime_setting: __sdk::TableUpdate<RuntimeSetting>,
runtime_snapshot: __sdk::TableUpdate<RuntimeSnapshotRow>,
story_event: __sdk::TableUpdate<StoryEvent>,
story_session: __sdk::TableUpdate<StorySession>,
treasure_record: __sdk::TableUpdate<TreasureRecord>,
user_account: __sdk::TableUpdate<UserAccount>,
user_browse_history: __sdk::TableUpdate<UserBrowseHistory>,
}
@@ -1165,7 +1320,52 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
for table_update in __sdk::transaction_update_iter_table_updates(raw) {
match &table_update.table_name[..] {
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(custom_world_gallery_entry_table::parse_table_update(table_update)?),
"ai_result_reference" => db_update.ai_result_reference.append(ai_result_reference_table::parse_table_update(table_update)?),
"ai_task" => db_update.ai_task.append(ai_task_table::parse_table_update(table_update)?),
"ai_task_stage" => db_update.ai_task_stage.append(ai_task_stage_table::parse_table_update(table_update)?),
"ai_text_chunk" => db_update.ai_text_chunk.append(ai_text_chunk_table::parse_table_update(table_update)?),
"asset_entity_binding" => db_update.asset_entity_binding.append(asset_entity_binding_table::parse_table_update(table_update)?),
"asset_object" => db_update.asset_object.append(asset_object_table::parse_table_update(table_update)?),
"auth_identity" => db_update.auth_identity.append(auth_identity_table::parse_table_update(table_update)?),
"auth_store_snapshot" => db_update.auth_store_snapshot.append(auth_store_snapshot_table::parse_table_update(table_update)?),
"battle_state" => db_update.battle_state.append(battle_state_table::parse_table_update(table_update)?),
"big_fish_agent_message" => db_update.big_fish_agent_message.append(big_fish_agent_message_table::parse_table_update(table_update)?),
"big_fish_asset_slot" => db_update.big_fish_asset_slot.append(big_fish_asset_slot_table::parse_table_update(table_update)?),
"big_fish_creation_session" => db_update.big_fish_creation_session.append(big_fish_creation_session_table::parse_table_update(table_update)?),
"big_fish_runtime_run" => db_update.big_fish_runtime_run.append(big_fish_runtime_run_table::parse_table_update(table_update)?),
"chapter_progression" => db_update.chapter_progression.append(chapter_progression_table::parse_table_update(table_update)?),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(custom_world_agent_message_table::parse_table_update(table_update)?),
"custom_world_agent_operation" => db_update.custom_world_agent_operation.append(custom_world_agent_operation_table::parse_table_update(table_update)?),
"custom_world_agent_session" => db_update.custom_world_agent_session.append(custom_world_agent_session_table::parse_table_update(table_update)?),
"custom_world_draft_card" => db_update.custom_world_draft_card.append(custom_world_draft_card_table::parse_table_update(table_update)?),
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(custom_world_gallery_entry_table::parse_table_update(table_update)?),
"custom_world_profile" => db_update.custom_world_profile.append(custom_world_profile_table::parse_table_update(table_update)?),
"custom_world_session" => db_update.custom_world_session.append(custom_world_session_table::parse_table_update(table_update)?),
"inventory_slot" => db_update.inventory_slot.append(inventory_slot_table::parse_table_update(table_update)?),
"npc_state" => db_update.npc_state.append(npc_state_table::parse_table_update(table_update)?),
"player_progression" => db_update.player_progression.append(player_progression_table::parse_table_update(table_update)?),
"profile_dashboard_state" => db_update.profile_dashboard_state.append(profile_dashboard_state_table::parse_table_update(table_update)?),
"profile_invite_code" => db_update.profile_invite_code.append(profile_invite_code_table::parse_table_update(table_update)?),
"profile_membership" => db_update.profile_membership.append(profile_membership_table::parse_table_update(table_update)?),
"profile_played_world" => db_update.profile_played_world.append(profile_played_world_table::parse_table_update(table_update)?),
"profile_recharge_order" => db_update.profile_recharge_order.append(profile_recharge_order_table::parse_table_update(table_update)?),
"profile_referral_relation" => db_update.profile_referral_relation.append(profile_referral_relation_table::parse_table_update(table_update)?),
"profile_save_archive" => db_update.profile_save_archive.append(profile_save_archive_table::parse_table_update(table_update)?),
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(profile_wallet_ledger_table::parse_table_update(table_update)?),
"puzzle_agent_message" => db_update.puzzle_agent_message.append(puzzle_agent_message_table::parse_table_update(table_update)?),
"puzzle_agent_session" => db_update.puzzle_agent_session.append(puzzle_agent_session_table::parse_table_update(table_update)?),
"puzzle_runtime_run" => db_update.puzzle_runtime_run.append(puzzle_runtime_run_table::parse_table_update(table_update)?),
"puzzle_work_profile" => db_update.puzzle_work_profile.append(puzzle_work_profile_table::parse_table_update(table_update)?),
"quest_log" => db_update.quest_log.append(quest_log_table::parse_table_update(table_update)?),
"quest_record" => db_update.quest_record.append(quest_record_table::parse_table_update(table_update)?),
"refresh_session" => db_update.refresh_session.append(refresh_session_table::parse_table_update(table_update)?),
"runtime_setting" => db_update.runtime_setting.append(runtime_setting_table::parse_table_update(table_update)?),
"runtime_snapshot" => db_update.runtime_snapshot.append(runtime_snapshot_table::parse_table_update(table_update)?),
"story_event" => db_update.story_event.append(story_event_table::parse_table_update(table_update)?),
"story_session" => db_update.story_session.append(story_session_table::parse_table_update(table_update)?),
"treasure_record" => db_update.treasure_record.append(treasure_record_table::parse_table_update(table_update)?),
"user_account" => db_update.user_account.append(user_account_table::parse_table_update(table_update)?),
"user_browse_history" => db_update.user_browse_history.append(user_browse_history_table::parse_table_update(table_update)?),
unknown => {
return Err(__sdk::InternalError::unknown_name(
@@ -1188,7 +1388,52 @@ impl __sdk::DbUpdate for DbUpdate {
fn apply_to_client_cache(&self, cache: &mut __sdk::ClientCache<RemoteModule>) -> AppliedDiff<'_> {
let mut diff = AppliedDiff::default();
diff.custom_world_gallery_entry = cache.apply_diff_to_table::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry).with_updates_by_pk(|row| &row.profile_id);
diff.ai_result_reference = cache.apply_diff_to_table::<AiResultReference>("ai_result_reference", &self.ai_result_reference).with_updates_by_pk(|row| &row.result_reference_row_id);
diff.ai_task = cache.apply_diff_to_table::<AiTask>("ai_task", &self.ai_task).with_updates_by_pk(|row| &row.task_id);
diff.ai_task_stage = cache.apply_diff_to_table::<AiTaskStage>("ai_task_stage", &self.ai_task_stage).with_updates_by_pk(|row| &row.task_stage_id);
diff.ai_text_chunk = cache.apply_diff_to_table::<AiTextChunk>("ai_text_chunk", &self.ai_text_chunk).with_updates_by_pk(|row| &row.text_chunk_row_id);
diff.asset_entity_binding = cache.apply_diff_to_table::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding).with_updates_by_pk(|row| &row.binding_id);
diff.asset_object = cache.apply_diff_to_table::<AssetObject>("asset_object", &self.asset_object).with_updates_by_pk(|row| &row.asset_object_id);
diff.auth_identity = cache.apply_diff_to_table::<AuthIdentity>("auth_identity", &self.auth_identity).with_updates_by_pk(|row| &row.identity_id);
diff.auth_store_snapshot = cache.apply_diff_to_table::<AuthStoreSnapshot>("auth_store_snapshot", &self.auth_store_snapshot).with_updates_by_pk(|row| &row.snapshot_id);
diff.battle_state = cache.apply_diff_to_table::<BattleState>("battle_state", &self.battle_state).with_updates_by_pk(|row| &row.battle_state_id);
diff.big_fish_agent_message = cache.apply_diff_to_table::<BigFishAgentMessage>("big_fish_agent_message", &self.big_fish_agent_message).with_updates_by_pk(|row| &row.message_id);
diff.big_fish_asset_slot = cache.apply_diff_to_table::<BigFishAssetSlot>("big_fish_asset_slot", &self.big_fish_asset_slot).with_updates_by_pk(|row| &row.slot_id);
diff.big_fish_creation_session = cache.apply_diff_to_table::<BigFishCreationSession>("big_fish_creation_session", &self.big_fish_creation_session).with_updates_by_pk(|row| &row.session_id);
diff.big_fish_runtime_run = cache.apply_diff_to_table::<BigFishRuntimeRun>("big_fish_runtime_run", &self.big_fish_runtime_run).with_updates_by_pk(|row| &row.run_id);
diff.chapter_progression = cache.apply_diff_to_table::<ChapterProgression>("chapter_progression", &self.chapter_progression).with_updates_by_pk(|row| &row.chapter_progression_id);
diff.custom_world_agent_message = cache.apply_diff_to_table::<CustomWorldAgentMessage>("custom_world_agent_message", &self.custom_world_agent_message).with_updates_by_pk(|row| &row.message_id);
diff.custom_world_agent_operation = cache.apply_diff_to_table::<CustomWorldAgentOperation>("custom_world_agent_operation", &self.custom_world_agent_operation).with_updates_by_pk(|row| &row.operation_id);
diff.custom_world_agent_session = cache.apply_diff_to_table::<CustomWorldAgentSession>("custom_world_agent_session", &self.custom_world_agent_session).with_updates_by_pk(|row| &row.session_id);
diff.custom_world_draft_card = cache.apply_diff_to_table::<CustomWorldDraftCard>("custom_world_draft_card", &self.custom_world_draft_card).with_updates_by_pk(|row| &row.card_id);
diff.custom_world_gallery_entry = cache.apply_diff_to_table::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry).with_updates_by_pk(|row| &row.profile_id);
diff.custom_world_profile = cache.apply_diff_to_table::<CustomWorldProfile>("custom_world_profile", &self.custom_world_profile).with_updates_by_pk(|row| &row.profile_id);
diff.custom_world_session = cache.apply_diff_to_table::<CustomWorldSession>("custom_world_session", &self.custom_world_session).with_updates_by_pk(|row| &row.session_id);
diff.inventory_slot = cache.apply_diff_to_table::<InventorySlot>("inventory_slot", &self.inventory_slot).with_updates_by_pk(|row| &row.slot_id);
diff.npc_state = cache.apply_diff_to_table::<NpcState>("npc_state", &self.npc_state).with_updates_by_pk(|row| &row.npc_state_id);
diff.player_progression = cache.apply_diff_to_table::<PlayerProgression>("player_progression", &self.player_progression).with_updates_by_pk(|row| &row.user_id);
diff.profile_dashboard_state = cache.apply_diff_to_table::<ProfileDashboardState>("profile_dashboard_state", &self.profile_dashboard_state).with_updates_by_pk(|row| &row.user_id);
diff.profile_invite_code = cache.apply_diff_to_table::<ProfileInviteCode>("profile_invite_code", &self.profile_invite_code).with_updates_by_pk(|row| &row.user_id);
diff.profile_membership = cache.apply_diff_to_table::<ProfileMembership>("profile_membership", &self.profile_membership).with_updates_by_pk(|row| &row.user_id);
diff.profile_played_world = cache.apply_diff_to_table::<ProfilePlayedWorld>("profile_played_world", &self.profile_played_world).with_updates_by_pk(|row| &row.played_world_id);
diff.profile_recharge_order = cache.apply_diff_to_table::<ProfileRechargeOrder>("profile_recharge_order", &self.profile_recharge_order).with_updates_by_pk(|row| &row.order_id);
diff.profile_referral_relation = cache.apply_diff_to_table::<ProfileReferralRelation>("profile_referral_relation", &self.profile_referral_relation).with_updates_by_pk(|row| &row.invitee_user_id);
diff.profile_save_archive = cache.apply_diff_to_table::<ProfileSaveArchive>("profile_save_archive", &self.profile_save_archive).with_updates_by_pk(|row| &row.archive_id);
diff.profile_wallet_ledger = cache.apply_diff_to_table::<ProfileWalletLedger>("profile_wallet_ledger", &self.profile_wallet_ledger).with_updates_by_pk(|row| &row.wallet_ledger_id);
diff.puzzle_agent_message = cache.apply_diff_to_table::<PuzzleAgentMessageRow>("puzzle_agent_message", &self.puzzle_agent_message).with_updates_by_pk(|row| &row.message_id);
diff.puzzle_agent_session = cache.apply_diff_to_table::<PuzzleAgentSessionRow>("puzzle_agent_session", &self.puzzle_agent_session).with_updates_by_pk(|row| &row.session_id);
diff.puzzle_runtime_run = cache.apply_diff_to_table::<PuzzleRuntimeRunRow>("puzzle_runtime_run", &self.puzzle_runtime_run).with_updates_by_pk(|row| &row.run_id);
diff.puzzle_work_profile = cache.apply_diff_to_table::<PuzzleWorkProfileRow>("puzzle_work_profile", &self.puzzle_work_profile).with_updates_by_pk(|row| &row.profile_id);
diff.quest_log = cache.apply_diff_to_table::<QuestLog>("quest_log", &self.quest_log).with_updates_by_pk(|row| &row.log_id);
diff.quest_record = cache.apply_diff_to_table::<QuestRecord>("quest_record", &self.quest_record).with_updates_by_pk(|row| &row.quest_id);
diff.refresh_session = cache.apply_diff_to_table::<RefreshSession>("refresh_session", &self.refresh_session).with_updates_by_pk(|row| &row.session_id);
diff.runtime_setting = cache.apply_diff_to_table::<RuntimeSetting>("runtime_setting", &self.runtime_setting).with_updates_by_pk(|row| &row.user_id);
diff.runtime_snapshot = cache.apply_diff_to_table::<RuntimeSnapshotRow>("runtime_snapshot", &self.runtime_snapshot).with_updates_by_pk(|row| &row.user_id);
diff.story_event = cache.apply_diff_to_table::<StoryEvent>("story_event", &self.story_event).with_updates_by_pk(|row| &row.event_id);
diff.story_session = cache.apply_diff_to_table::<StorySession>("story_session", &self.story_session).with_updates_by_pk(|row| &row.story_session_id);
diff.treasure_record = cache.apply_diff_to_table::<TreasureRecord>("treasure_record", &self.treasure_record).with_updates_by_pk(|row| &row.treasure_record_id);
diff.user_account = cache.apply_diff_to_table::<UserAccount>("user_account", &self.user_account).with_updates_by_pk(|row| &row.user_id);
diff.user_browse_history = cache.apply_diff_to_table::<UserBrowseHistory>("user_browse_history", &self.user_browse_history).with_updates_by_pk(|row| &row.browse_history_id);
diff
}
@@ -1196,7 +1441,52 @@ fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
let mut db_update = DbUpdate::default();
for table_rows in raw.tables {
match &table_rows.table[..] {
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_result_reference" => db_update.ai_result_reference.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_task" => db_update.ai_task.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_task_stage" => db_update.ai_task_stage.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"auth_identity" => db_update.auth_identity.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"auth_store_snapshot" => db_update.auth_store_snapshot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_creation_session" => db_update.big_fish_creation_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_runtime_run" => db_update.big_fish_runtime_run.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"chapter_progression" => db_update.chapter_progression.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_operation" => db_update.custom_world_agent_operation.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_session" => db_update.custom_world_agent_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_draft_card" => db_update.custom_world_draft_card.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_profile" => db_update.custom_world_profile.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_session" => db_update.custom_world_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"inventory_slot" => db_update.inventory_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"npc_state" => db_update.npc_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"player_progression" => db_update.player_progression.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_dashboard_state" => db_update.profile_dashboard_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_invite_code" => db_update.profile_invite_code.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_membership" => db_update.profile_membership.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_played_world" => db_update.profile_played_world.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_recharge_order" => db_update.profile_recharge_order.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_referral_relation" => db_update.profile_referral_relation.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_save_archive" => db_update.profile_save_archive.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_agent_message" => db_update.puzzle_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_agent_session" => db_update.puzzle_agent_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_runtime_run" => db_update.puzzle_runtime_run.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_work_profile" => db_update.puzzle_work_profile.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"quest_log" => db_update.quest_log.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"quest_record" => db_update.quest_record.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"refresh_session" => db_update.refresh_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"runtime_setting" => db_update.runtime_setting.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"runtime_snapshot" => db_update.runtime_snapshot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"story_event" => db_update.story_event.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"story_session" => db_update.story_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"treasure_record" => db_update.treasure_record.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"user_account" => db_update.user_account.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"user_browse_history" => db_update.user_browse_history.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); }
}} Ok(db_update)
}
@@ -1204,7 +1494,52 @@ fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
let mut db_update = DbUpdate::default();
for table_rows in raw.tables {
match &table_rows.table[..] {
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_result_reference" => db_update.ai_result_reference.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_task" => db_update.ai_task.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_task_stage" => db_update.ai_task_stage.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"auth_identity" => db_update.auth_identity.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"auth_store_snapshot" => db_update.auth_store_snapshot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_creation_session" => db_update.big_fish_creation_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_runtime_run" => db_update.big_fish_runtime_run.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"chapter_progression" => db_update.chapter_progression.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_operation" => db_update.custom_world_agent_operation.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_session" => db_update.custom_world_agent_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_draft_card" => db_update.custom_world_draft_card.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_profile" => db_update.custom_world_profile.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_session" => db_update.custom_world_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"inventory_slot" => db_update.inventory_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"npc_state" => db_update.npc_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"player_progression" => db_update.player_progression.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_dashboard_state" => db_update.profile_dashboard_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_invite_code" => db_update.profile_invite_code.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_membership" => db_update.profile_membership.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_played_world" => db_update.profile_played_world.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_recharge_order" => db_update.profile_recharge_order.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_referral_relation" => db_update.profile_referral_relation.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_save_archive" => db_update.profile_save_archive.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_agent_message" => db_update.puzzle_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_agent_session" => db_update.puzzle_agent_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_runtime_run" => db_update.puzzle_runtime_run.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_work_profile" => db_update.puzzle_work_profile.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"quest_log" => db_update.quest_log.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"quest_record" => db_update.quest_record.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"refresh_session" => db_update.refresh_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"runtime_setting" => db_update.runtime_setting.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"runtime_snapshot" => db_update.runtime_snapshot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"story_event" => db_update.story_event.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"story_session" => db_update.story_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"treasure_record" => db_update.treasure_record.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"user_account" => db_update.user_account.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"user_browse_history" => db_update.user_browse_history.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); }
}} Ok(db_update)
}
@@ -1214,7 +1549,52 @@ for table_rows in raw.tables {
#[allow(non_snake_case)]
#[doc(hidden)]
pub struct AppliedDiff<'r> {
custom_world_gallery_entry: __sdk::TableAppliedDiff<'r, CustomWorldGalleryEntry>,
ai_result_reference: __sdk::TableAppliedDiff<'r, AiResultReference>,
ai_task: __sdk::TableAppliedDiff<'r, AiTask>,
ai_task_stage: __sdk::TableAppliedDiff<'r, AiTaskStage>,
ai_text_chunk: __sdk::TableAppliedDiff<'r, AiTextChunk>,
asset_entity_binding: __sdk::TableAppliedDiff<'r, AssetEntityBinding>,
asset_object: __sdk::TableAppliedDiff<'r, AssetObject>,
auth_identity: __sdk::TableAppliedDiff<'r, AuthIdentity>,
auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>,
battle_state: __sdk::TableAppliedDiff<'r, BattleState>,
big_fish_agent_message: __sdk::TableAppliedDiff<'r, BigFishAgentMessage>,
big_fish_asset_slot: __sdk::TableAppliedDiff<'r, BigFishAssetSlot>,
big_fish_creation_session: __sdk::TableAppliedDiff<'r, BigFishCreationSession>,
big_fish_runtime_run: __sdk::TableAppliedDiff<'r, BigFishRuntimeRun>,
chapter_progression: __sdk::TableAppliedDiff<'r, ChapterProgression>,
custom_world_agent_message: __sdk::TableAppliedDiff<'r, CustomWorldAgentMessage>,
custom_world_agent_operation: __sdk::TableAppliedDiff<'r, CustomWorldAgentOperation>,
custom_world_agent_session: __sdk::TableAppliedDiff<'r, CustomWorldAgentSession>,
custom_world_draft_card: __sdk::TableAppliedDiff<'r, CustomWorldDraftCard>,
custom_world_gallery_entry: __sdk::TableAppliedDiff<'r, CustomWorldGalleryEntry>,
custom_world_profile: __sdk::TableAppliedDiff<'r, CustomWorldProfile>,
custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>,
inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>,
npc_state: __sdk::TableAppliedDiff<'r, NpcState>,
player_progression: __sdk::TableAppliedDiff<'r, PlayerProgression>,
profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>,
profile_invite_code: __sdk::TableAppliedDiff<'r, ProfileInviteCode>,
profile_membership: __sdk::TableAppliedDiff<'r, ProfileMembership>,
profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>,
profile_recharge_order: __sdk::TableAppliedDiff<'r, ProfileRechargeOrder>,
profile_referral_relation: __sdk::TableAppliedDiff<'r, ProfileReferralRelation>,
profile_save_archive: __sdk::TableAppliedDiff<'r, ProfileSaveArchive>,
profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>,
puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>,
puzzle_agent_session: __sdk::TableAppliedDiff<'r, PuzzleAgentSessionRow>,
puzzle_runtime_run: __sdk::TableAppliedDiff<'r, PuzzleRuntimeRunRow>,
puzzle_work_profile: __sdk::TableAppliedDiff<'r, PuzzleWorkProfileRow>,
quest_log: __sdk::TableAppliedDiff<'r, QuestLog>,
quest_record: __sdk::TableAppliedDiff<'r, QuestRecord>,
refresh_session: __sdk::TableAppliedDiff<'r, RefreshSession>,
runtime_setting: __sdk::TableAppliedDiff<'r, RuntimeSetting>,
runtime_snapshot: __sdk::TableAppliedDiff<'r, RuntimeSnapshotRow>,
story_event: __sdk::TableAppliedDiff<'r, StoryEvent>,
story_session: __sdk::TableAppliedDiff<'r, StorySession>,
treasure_record: __sdk::TableAppliedDiff<'r, TreasureRecord>,
user_account: __sdk::TableAppliedDiff<'r, UserAccount>,
user_browse_history: __sdk::TableAppliedDiff<'r, UserBrowseHistory>,
__unused: std::marker::PhantomData<&'r ()>,
}
@@ -1225,7 +1605,52 @@ impl __sdk::InModule for AppliedDiff<'_> {
impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
fn invoke_row_callbacks(&self, event: &EventContext, callbacks: &mut __sdk::DbCallbacks<RemoteModule>) {
callbacks.invoke_table_row_callbacks::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry, event);
callbacks.invoke_table_row_callbacks::<AiResultReference>("ai_result_reference", &self.ai_result_reference, event);
callbacks.invoke_table_row_callbacks::<AiTask>("ai_task", &self.ai_task, event);
callbacks.invoke_table_row_callbacks::<AiTaskStage>("ai_task_stage", &self.ai_task_stage, event);
callbacks.invoke_table_row_callbacks::<AiTextChunk>("ai_text_chunk", &self.ai_text_chunk, event);
callbacks.invoke_table_row_callbacks::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding, event);
callbacks.invoke_table_row_callbacks::<AssetObject>("asset_object", &self.asset_object, event);
callbacks.invoke_table_row_callbacks::<AuthIdentity>("auth_identity", &self.auth_identity, event);
callbacks.invoke_table_row_callbacks::<AuthStoreSnapshot>("auth_store_snapshot", &self.auth_store_snapshot, event);
callbacks.invoke_table_row_callbacks::<BattleState>("battle_state", &self.battle_state, event);
callbacks.invoke_table_row_callbacks::<BigFishAgentMessage>("big_fish_agent_message", &self.big_fish_agent_message, event);
callbacks.invoke_table_row_callbacks::<BigFishAssetSlot>("big_fish_asset_slot", &self.big_fish_asset_slot, event);
callbacks.invoke_table_row_callbacks::<BigFishCreationSession>("big_fish_creation_session", &self.big_fish_creation_session, event);
callbacks.invoke_table_row_callbacks::<BigFishRuntimeRun>("big_fish_runtime_run", &self.big_fish_runtime_run, event);
callbacks.invoke_table_row_callbacks::<ChapterProgression>("chapter_progression", &self.chapter_progression, event);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentMessage>("custom_world_agent_message", &self.custom_world_agent_message, event);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentOperation>("custom_world_agent_operation", &self.custom_world_agent_operation, event);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentSession>("custom_world_agent_session", &self.custom_world_agent_session, event);
callbacks.invoke_table_row_callbacks::<CustomWorldDraftCard>("custom_world_draft_card", &self.custom_world_draft_card, event);
callbacks.invoke_table_row_callbacks::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry, event);
callbacks.invoke_table_row_callbacks::<CustomWorldProfile>("custom_world_profile", &self.custom_world_profile, event);
callbacks.invoke_table_row_callbacks::<CustomWorldSession>("custom_world_session", &self.custom_world_session, event);
callbacks.invoke_table_row_callbacks::<InventorySlot>("inventory_slot", &self.inventory_slot, event);
callbacks.invoke_table_row_callbacks::<NpcState>("npc_state", &self.npc_state, event);
callbacks.invoke_table_row_callbacks::<PlayerProgression>("player_progression", &self.player_progression, event);
callbacks.invoke_table_row_callbacks::<ProfileDashboardState>("profile_dashboard_state", &self.profile_dashboard_state, event);
callbacks.invoke_table_row_callbacks::<ProfileInviteCode>("profile_invite_code", &self.profile_invite_code, event);
callbacks.invoke_table_row_callbacks::<ProfileMembership>("profile_membership", &self.profile_membership, event);
callbacks.invoke_table_row_callbacks::<ProfilePlayedWorld>("profile_played_world", &self.profile_played_world, event);
callbacks.invoke_table_row_callbacks::<ProfileRechargeOrder>("profile_recharge_order", &self.profile_recharge_order, event);
callbacks.invoke_table_row_callbacks::<ProfileReferralRelation>("profile_referral_relation", &self.profile_referral_relation, event);
callbacks.invoke_table_row_callbacks::<ProfileSaveArchive>("profile_save_archive", &self.profile_save_archive, event);
callbacks.invoke_table_row_callbacks::<ProfileWalletLedger>("profile_wallet_ledger", &self.profile_wallet_ledger, event);
callbacks.invoke_table_row_callbacks::<PuzzleAgentMessageRow>("puzzle_agent_message", &self.puzzle_agent_message, event);
callbacks.invoke_table_row_callbacks::<PuzzleAgentSessionRow>("puzzle_agent_session", &self.puzzle_agent_session, event);
callbacks.invoke_table_row_callbacks::<PuzzleRuntimeRunRow>("puzzle_runtime_run", &self.puzzle_runtime_run, event);
callbacks.invoke_table_row_callbacks::<PuzzleWorkProfileRow>("puzzle_work_profile", &self.puzzle_work_profile, event);
callbacks.invoke_table_row_callbacks::<QuestLog>("quest_log", &self.quest_log, event);
callbacks.invoke_table_row_callbacks::<QuestRecord>("quest_record", &self.quest_record, event);
callbacks.invoke_table_row_callbacks::<RefreshSession>("refresh_session", &self.refresh_session, event);
callbacks.invoke_table_row_callbacks::<RuntimeSetting>("runtime_setting", &self.runtime_setting, event);
callbacks.invoke_table_row_callbacks::<RuntimeSnapshotRow>("runtime_snapshot", &self.runtime_snapshot, event);
callbacks.invoke_table_row_callbacks::<StoryEvent>("story_event", &self.story_event, event);
callbacks.invoke_table_row_callbacks::<StorySession>("story_session", &self.story_session, event);
callbacks.invoke_table_row_callbacks::<TreasureRecord>("treasure_record", &self.treasure_record, event);
callbacks.invoke_table_row_callbacks::<UserAccount>("user_account", &self.user_account, event);
callbacks.invoke_table_row_callbacks::<UserBrowseHistory>("user_browse_history", &self.user_browse_history, event);
}
}
@@ -1877,9 +2302,99 @@ impl __sdk::SpacetimeModule for RemoteModule {
type QueryBuilder = __sdk::QueryBuilder;
fn register_tables(client_cache: &mut __sdk::ClientCache<Self>) {
custom_world_gallery_entry_table::register_table(client_cache);
ai_result_reference_table::register_table(client_cache);
ai_task_table::register_table(client_cache);
ai_task_stage_table::register_table(client_cache);
ai_text_chunk_table::register_table(client_cache);
asset_entity_binding_table::register_table(client_cache);
asset_object_table::register_table(client_cache);
auth_identity_table::register_table(client_cache);
auth_store_snapshot_table::register_table(client_cache);
battle_state_table::register_table(client_cache);
big_fish_agent_message_table::register_table(client_cache);
big_fish_asset_slot_table::register_table(client_cache);
big_fish_creation_session_table::register_table(client_cache);
big_fish_runtime_run_table::register_table(client_cache);
chapter_progression_table::register_table(client_cache);
custom_world_agent_message_table::register_table(client_cache);
custom_world_agent_operation_table::register_table(client_cache);
custom_world_agent_session_table::register_table(client_cache);
custom_world_draft_card_table::register_table(client_cache);
custom_world_gallery_entry_table::register_table(client_cache);
custom_world_profile_table::register_table(client_cache);
custom_world_session_table::register_table(client_cache);
inventory_slot_table::register_table(client_cache);
npc_state_table::register_table(client_cache);
player_progression_table::register_table(client_cache);
profile_dashboard_state_table::register_table(client_cache);
profile_invite_code_table::register_table(client_cache);
profile_membership_table::register_table(client_cache);
profile_played_world_table::register_table(client_cache);
profile_recharge_order_table::register_table(client_cache);
profile_referral_relation_table::register_table(client_cache);
profile_save_archive_table::register_table(client_cache);
profile_wallet_ledger_table::register_table(client_cache);
puzzle_agent_message_table::register_table(client_cache);
puzzle_agent_session_table::register_table(client_cache);
puzzle_runtime_run_table::register_table(client_cache);
puzzle_work_profile_table::register_table(client_cache);
quest_log_table::register_table(client_cache);
quest_record_table::register_table(client_cache);
refresh_session_table::register_table(client_cache);
runtime_setting_table::register_table(client_cache);
runtime_snapshot_table::register_table(client_cache);
story_event_table::register_table(client_cache);
story_session_table::register_table(client_cache);
treasure_record_table::register_table(client_cache);
user_account_table::register_table(client_cache);
user_browse_history_table::register_table(client_cache);
}
const ALL_TABLE_NAMES: &'static [&'static str] = &[
"custom_world_gallery_entry",
"ai_result_reference",
"ai_task",
"ai_task_stage",
"ai_text_chunk",
"asset_entity_binding",
"asset_object",
"auth_identity",
"auth_store_snapshot",
"battle_state",
"big_fish_agent_message",
"big_fish_asset_slot",
"big_fish_creation_session",
"big_fish_runtime_run",
"chapter_progression",
"custom_world_agent_message",
"custom_world_agent_operation",
"custom_world_agent_session",
"custom_world_draft_card",
"custom_world_gallery_entry",
"custom_world_profile",
"custom_world_session",
"inventory_slot",
"npc_state",
"player_progression",
"profile_dashboard_state",
"profile_invite_code",
"profile_membership",
"profile_played_world",
"profile_recharge_order",
"profile_referral_relation",
"profile_save_archive",
"profile_wallet_ledger",
"puzzle_agent_message",
"puzzle_agent_session",
"puzzle_runtime_run",
"puzzle_work_profile",
"quest_log",
"quest_record",
"refresh_session",
"runtime_setting",
"runtime_snapshot",
"story_event",
"story_session",
"treasure_record",
"user_account",
"user_browse_history",
];
}

View File

@@ -0,0 +1,194 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::profile_invite_code_type::ProfileInviteCode;
/// Table handle for the table `profile_invite_code`.
///
/// Obtain a handle from the [`ProfileInviteCodeTableAccess::profile_invite_code`] method on [`super::RemoteTables`],
/// like `ctx.db.profile_invite_code()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_invite_code().on_insert(...)`.
pub struct ProfileInviteCodeTableHandle<'ctx> {
imp: __sdk::TableHandle<ProfileInviteCode>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `profile_invite_code`.
///
/// Implemented for [`super::RemoteTables`].
pub trait ProfileInviteCodeTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`ProfileInviteCodeTableHandle`], which mediates access to the table `profile_invite_code`.
fn profile_invite_code(&self) -> ProfileInviteCodeTableHandle<'_>;
}
impl ProfileInviteCodeTableAccess for super::RemoteTables {
fn profile_invite_code(&self) -> ProfileInviteCodeTableHandle<'_> {
ProfileInviteCodeTableHandle {
imp: self.imp.get_table::<ProfileInviteCode>("profile_invite_code"),
ctx: std::marker::PhantomData,
}
}
}
pub struct ProfileInviteCodeInsertCallbackId(__sdk::CallbackId);
pub struct ProfileInviteCodeDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for ProfileInviteCodeTableHandle<'ctx> {
type Row = ProfileInviteCode;
type EventContext = super::EventContext;
fn count(&self) -> u64 { self.imp.count() }
fn iter(&self) -> impl Iterator<Item = ProfileInviteCode> + '_ { self.imp.iter() }
type InsertCallbackId = ProfileInviteCodeInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileInviteCodeInsertCallbackId {
ProfileInviteCodeInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: ProfileInviteCodeInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = ProfileInviteCodeDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileInviteCodeDeleteCallbackId {
ProfileInviteCodeDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: ProfileInviteCodeDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct ProfileInviteCodeUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for ProfileInviteCodeTableHandle<'ctx> {
type UpdateCallbackId = ProfileInviteCodeUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> ProfileInviteCodeUpdateCallbackId {
ProfileInviteCodeUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: ProfileInviteCodeUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `user_id` unique index on the table `profile_invite_code`,
/// which allows point queries on the field of the same name
/// via the [`ProfileInviteCodeUserIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_invite_code().user_id().find(...)`.
pub struct ProfileInviteCodeUserIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<ProfileInviteCode, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> ProfileInviteCodeTableHandle<'ctx> {
/// Get a handle on the `user_id` unique index on the table `profile_invite_code`.
pub fn user_id(&self) -> ProfileInviteCodeUserIdUnique<'ctx> {
ProfileInviteCodeUserIdUnique {
imp: self.imp.get_unique_constraint::<String>("user_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> ProfileInviteCodeUserIdUnique<'ctx> {
/// Find the subscribed row whose `user_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<ProfileInviteCode> {
self.imp.find(col_val)
}
}
/// Access to the `invite_code` unique index on the table `profile_invite_code`,
/// which allows point queries on the field of the same name
/// via the [`ProfileInviteCodeInviteCodeUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_invite_code().invite_code().find(...)`.
pub struct ProfileInviteCodeInviteCodeUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<ProfileInviteCode, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> ProfileInviteCodeTableHandle<'ctx> {
/// Get a handle on the `invite_code` unique index on the table `profile_invite_code`.
pub fn invite_code(&self) -> ProfileInviteCodeInviteCodeUnique<'ctx> {
ProfileInviteCodeInviteCodeUnique {
imp: self.imp.get_unique_constraint::<String>("invite_code"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> ProfileInviteCodeInviteCodeUnique<'ctx> {
/// Find the subscribed row whose `invite_code` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<ProfileInviteCode> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<ProfileInviteCode>("profile_invite_code");
_table.add_unique_constraint::<String>("user_id", |row| &row.user_id);
_table.add_unique_constraint::<String>("invite_code", |row| &row.invite_code);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<ProfileInviteCode>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse(
"TableUpdate<ProfileInviteCode>",
"TableUpdate",
).with_cause(e).into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `ProfileInviteCode`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait profile_invite_codeQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `ProfileInviteCode`.
fn profile_invite_code(&self) -> __sdk::__query_builder::Table<ProfileInviteCode>;
}
impl profile_invite_codeQueryTableAccess for __sdk::QueryTableAccessor {
fn profile_invite_code(&self) -> __sdk::__query_builder::Table<ProfileInviteCode> {
__sdk::__query_builder::Table::new("profile_invite_code")
}
}

View File

@@ -0,0 +1,71 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileInviteCode {
pub user_id: String,
pub invite_code: String,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileInviteCode {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileInviteCode`.
///
/// Provides typed access to columns for query building.
pub struct ProfileInviteCodeCols {
pub user_id: __sdk::__query_builder::Col<ProfileInviteCode, String>,
pub invite_code: __sdk::__query_builder::Col<ProfileInviteCode, String>,
pub created_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileInviteCode {
type Cols = ProfileInviteCodeCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileInviteCodeCols {
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
invite_code: __sdk::__query_builder::Col::new(table_name, "invite_code"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileInviteCode`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileInviteCodeIxCols {
pub invite_code: __sdk::__query_builder::IxCol<ProfileInviteCode, String>,
pub user_id: __sdk::__query_builder::IxCol<ProfileInviteCode, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileInviteCode {
type IxCols = ProfileInviteCodeIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileInviteCodeIxCols {
invite_code: __sdk::__query_builder::IxCol::new(table_name, "invite_code"),
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileInviteCode {}

View File

@@ -0,0 +1,165 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::profile_membership_type::ProfileMembership;
use super::runtime_profile_membership_status_type::RuntimeProfileMembershipStatus;
use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier;
/// Table handle for the table `profile_membership`.
///
/// Obtain a handle from the [`ProfileMembershipTableAccess::profile_membership`] method on [`super::RemoteTables`],
/// like `ctx.db.profile_membership()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_membership().on_insert(...)`.
pub struct ProfileMembershipTableHandle<'ctx> {
imp: __sdk::TableHandle<ProfileMembership>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `profile_membership`.
///
/// Implemented for [`super::RemoteTables`].
pub trait ProfileMembershipTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`ProfileMembershipTableHandle`], which mediates access to the table `profile_membership`.
fn profile_membership(&self) -> ProfileMembershipTableHandle<'_>;
}
impl ProfileMembershipTableAccess for super::RemoteTables {
fn profile_membership(&self) -> ProfileMembershipTableHandle<'_> {
ProfileMembershipTableHandle {
imp: self.imp.get_table::<ProfileMembership>("profile_membership"),
ctx: std::marker::PhantomData,
}
}
}
pub struct ProfileMembershipInsertCallbackId(__sdk::CallbackId);
pub struct ProfileMembershipDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for ProfileMembershipTableHandle<'ctx> {
type Row = ProfileMembership;
type EventContext = super::EventContext;
fn count(&self) -> u64 { self.imp.count() }
fn iter(&self) -> impl Iterator<Item = ProfileMembership> + '_ { self.imp.iter() }
type InsertCallbackId = ProfileMembershipInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileMembershipInsertCallbackId {
ProfileMembershipInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: ProfileMembershipInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = ProfileMembershipDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileMembershipDeleteCallbackId {
ProfileMembershipDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: ProfileMembershipDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct ProfileMembershipUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for ProfileMembershipTableHandle<'ctx> {
type UpdateCallbackId = ProfileMembershipUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> ProfileMembershipUpdateCallbackId {
ProfileMembershipUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: ProfileMembershipUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `user_id` unique index on the table `profile_membership`,
/// which allows point queries on the field of the same name
/// via the [`ProfileMembershipUserIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_membership().user_id().find(...)`.
pub struct ProfileMembershipUserIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<ProfileMembership, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> ProfileMembershipTableHandle<'ctx> {
/// Get a handle on the `user_id` unique index on the table `profile_membership`.
pub fn user_id(&self) -> ProfileMembershipUserIdUnique<'ctx> {
ProfileMembershipUserIdUnique {
imp: self.imp.get_unique_constraint::<String>("user_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> ProfileMembershipUserIdUnique<'ctx> {
/// Find the subscribed row whose `user_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<ProfileMembership> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<ProfileMembership>("profile_membership");
_table.add_unique_constraint::<String>("user_id", |row| &row.user_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<ProfileMembership>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse(
"TableUpdate<ProfileMembership>",
"TableUpdate",
).with_cause(e).into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `ProfileMembership`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait profile_membershipQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `ProfileMembership`.
fn profile_membership(&self) -> __sdk::__query_builder::Table<ProfileMembership>;
}
impl profile_membershipQueryTableAccess for __sdk::QueryTableAccessor {
fn profile_membership(&self) -> __sdk::__query_builder::Table<ProfileMembership> {
__sdk::__query_builder::Table::new("profile_membership")
}
}

View File

@@ -0,0 +1,165 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::profile_recharge_order_type::ProfileRechargeOrder;
use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
use super::runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
/// Table handle for the table `profile_recharge_order`.
///
/// Obtain a handle from the [`ProfileRechargeOrderTableAccess::profile_recharge_order`] method on [`super::RemoteTables`],
/// like `ctx.db.profile_recharge_order()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_recharge_order().on_insert(...)`.
pub struct ProfileRechargeOrderTableHandle<'ctx> {
imp: __sdk::TableHandle<ProfileRechargeOrder>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `profile_recharge_order`.
///
/// Implemented for [`super::RemoteTables`].
pub trait ProfileRechargeOrderTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`ProfileRechargeOrderTableHandle`], which mediates access to the table `profile_recharge_order`.
fn profile_recharge_order(&self) -> ProfileRechargeOrderTableHandle<'_>;
}
impl ProfileRechargeOrderTableAccess for super::RemoteTables {
fn profile_recharge_order(&self) -> ProfileRechargeOrderTableHandle<'_> {
ProfileRechargeOrderTableHandle {
imp: self.imp.get_table::<ProfileRechargeOrder>("profile_recharge_order"),
ctx: std::marker::PhantomData,
}
}
}
pub struct ProfileRechargeOrderInsertCallbackId(__sdk::CallbackId);
pub struct ProfileRechargeOrderDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for ProfileRechargeOrderTableHandle<'ctx> {
type Row = ProfileRechargeOrder;
type EventContext = super::EventContext;
fn count(&self) -> u64 { self.imp.count() }
fn iter(&self) -> impl Iterator<Item = ProfileRechargeOrder> + '_ { self.imp.iter() }
type InsertCallbackId = ProfileRechargeOrderInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileRechargeOrderInsertCallbackId {
ProfileRechargeOrderInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: ProfileRechargeOrderInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = ProfileRechargeOrderDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileRechargeOrderDeleteCallbackId {
ProfileRechargeOrderDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: ProfileRechargeOrderDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct ProfileRechargeOrderUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for ProfileRechargeOrderTableHandle<'ctx> {
type UpdateCallbackId = ProfileRechargeOrderUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> ProfileRechargeOrderUpdateCallbackId {
ProfileRechargeOrderUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: ProfileRechargeOrderUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `order_id` unique index on the table `profile_recharge_order`,
/// which allows point queries on the field of the same name
/// via the [`ProfileRechargeOrderOrderIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_recharge_order().order_id().find(...)`.
pub struct ProfileRechargeOrderOrderIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<ProfileRechargeOrder, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> ProfileRechargeOrderTableHandle<'ctx> {
/// Get a handle on the `order_id` unique index on the table `profile_recharge_order`.
pub fn order_id(&self) -> ProfileRechargeOrderOrderIdUnique<'ctx> {
ProfileRechargeOrderOrderIdUnique {
imp: self.imp.get_unique_constraint::<String>("order_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> ProfileRechargeOrderOrderIdUnique<'ctx> {
/// Find the subscribed row whose `order_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<ProfileRechargeOrder> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<ProfileRechargeOrder>("profile_recharge_order");
_table.add_unique_constraint::<String>("order_id", |row| &row.order_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<ProfileRechargeOrder>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse(
"TableUpdate<ProfileRechargeOrder>",
"TableUpdate",
).with_cause(e).into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `ProfileRechargeOrder`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait profile_recharge_orderQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `ProfileRechargeOrder`.
fn profile_recharge_order(&self) -> __sdk::__query_builder::Table<ProfileRechargeOrder>;
}
impl profile_recharge_orderQueryTableAccess for __sdk::QueryTableAccessor {
fn profile_recharge_order(&self) -> __sdk::__query_builder::Table<ProfileRechargeOrder> {
__sdk::__query_builder::Table::new("profile_recharge_order")
}
}

View File

@@ -0,0 +1,163 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::profile_referral_relation_type::ProfileReferralRelation;
/// Table handle for the table `profile_referral_relation`.
///
/// Obtain a handle from the [`ProfileReferralRelationTableAccess::profile_referral_relation`] method on [`super::RemoteTables`],
/// like `ctx.db.profile_referral_relation()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_referral_relation().on_insert(...)`.
pub struct ProfileReferralRelationTableHandle<'ctx> {
imp: __sdk::TableHandle<ProfileReferralRelation>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `profile_referral_relation`.
///
/// Implemented for [`super::RemoteTables`].
pub trait ProfileReferralRelationTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`ProfileReferralRelationTableHandle`], which mediates access to the table `profile_referral_relation`.
fn profile_referral_relation(&self) -> ProfileReferralRelationTableHandle<'_>;
}
impl ProfileReferralRelationTableAccess for super::RemoteTables {
fn profile_referral_relation(&self) -> ProfileReferralRelationTableHandle<'_> {
ProfileReferralRelationTableHandle {
imp: self.imp.get_table::<ProfileReferralRelation>("profile_referral_relation"),
ctx: std::marker::PhantomData,
}
}
}
pub struct ProfileReferralRelationInsertCallbackId(__sdk::CallbackId);
pub struct ProfileReferralRelationDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for ProfileReferralRelationTableHandle<'ctx> {
type Row = ProfileReferralRelation;
type EventContext = super::EventContext;
fn count(&self) -> u64 { self.imp.count() }
fn iter(&self) -> impl Iterator<Item = ProfileReferralRelation> + '_ { self.imp.iter() }
type InsertCallbackId = ProfileReferralRelationInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileReferralRelationInsertCallbackId {
ProfileReferralRelationInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: ProfileReferralRelationInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = ProfileReferralRelationDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileReferralRelationDeleteCallbackId {
ProfileReferralRelationDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: ProfileReferralRelationDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct ProfileReferralRelationUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for ProfileReferralRelationTableHandle<'ctx> {
type UpdateCallbackId = ProfileReferralRelationUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> ProfileReferralRelationUpdateCallbackId {
ProfileReferralRelationUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: ProfileReferralRelationUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `invitee_user_id` unique index on the table `profile_referral_relation`,
/// which allows point queries on the field of the same name
/// via the [`ProfileReferralRelationInviteeUserIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_referral_relation().invitee_user_id().find(...)`.
pub struct ProfileReferralRelationInviteeUserIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<ProfileReferralRelation, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> ProfileReferralRelationTableHandle<'ctx> {
/// Get a handle on the `invitee_user_id` unique index on the table `profile_referral_relation`.
pub fn invitee_user_id(&self) -> ProfileReferralRelationInviteeUserIdUnique<'ctx> {
ProfileReferralRelationInviteeUserIdUnique {
imp: self.imp.get_unique_constraint::<String>("invitee_user_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> ProfileReferralRelationInviteeUserIdUnique<'ctx> {
/// Find the subscribed row whose `invitee_user_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<ProfileReferralRelation> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<ProfileReferralRelation>("profile_referral_relation");
_table.add_unique_constraint::<String>("invitee_user_id", |row| &row.invitee_user_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<ProfileReferralRelation>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse(
"TableUpdate<ProfileReferralRelation>",
"TableUpdate",
).with_cause(e).into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `ProfileReferralRelation`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait profile_referral_relationQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `ProfileReferralRelation`.
fn profile_referral_relation(&self) -> __sdk::__query_builder::Table<ProfileReferralRelation>;
}
impl profile_referral_relationQueryTableAccess for __sdk::QueryTableAccessor {
fn profile_referral_relation(&self) -> __sdk::__query_builder::Table<ProfileReferralRelation> {
__sdk::__query_builder::Table::new("profile_referral_relation")
}
}

View File

@@ -0,0 +1,77 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileReferralRelation {
pub invitee_user_id: String,
pub inviter_user_id: String,
pub invite_code: String,
pub inviter_reward_granted: bool,
pub invitee_reward_granted: bool,
pub bound_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileReferralRelation {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileReferralRelation`.
///
/// Provides typed access to columns for query building.
pub struct ProfileReferralRelationCols {
pub invitee_user_id: __sdk::__query_builder::Col<ProfileReferralRelation, String>,
pub inviter_user_id: __sdk::__query_builder::Col<ProfileReferralRelation, String>,
pub invite_code: __sdk::__query_builder::Col<ProfileReferralRelation, String>,
pub inviter_reward_granted: __sdk::__query_builder::Col<ProfileReferralRelation, bool>,
pub invitee_reward_granted: __sdk::__query_builder::Col<ProfileReferralRelation, bool>,
pub bound_at: __sdk::__query_builder::Col<ProfileReferralRelation, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileReferralRelation {
type Cols = ProfileReferralRelationCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileReferralRelationCols {
invitee_user_id: __sdk::__query_builder::Col::new(table_name, "invitee_user_id"),
inviter_user_id: __sdk::__query_builder::Col::new(table_name, "inviter_user_id"),
invite_code: __sdk::__query_builder::Col::new(table_name, "invite_code"),
inviter_reward_granted: __sdk::__query_builder::Col::new(table_name, "inviter_reward_granted"),
invitee_reward_granted: __sdk::__query_builder::Col::new(table_name, "invitee_reward_granted"),
bound_at: __sdk::__query_builder::Col::new(table_name, "bound_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileReferralRelation`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileReferralRelationIxCols {
pub invitee_user_id: __sdk::__query_builder::IxCol<ProfileReferralRelation, String>,
pub inviter_user_id: __sdk::__query_builder::IxCol<ProfileReferralRelation, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileReferralRelation {
type IxCols = ProfileReferralRelationIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileReferralRelationIxCols {
invitee_user_id: __sdk::__query_builder::IxCol::new(table_name, "invitee_user_id"),
inviter_user_id: __sdk::__query_builder::IxCol::new(table_name, "inviter_user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileReferralRelation {}

View File

@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_referral_redeem_input_type::RuntimeReferralRedeemInput;
use super::runtime_referral_redeem_procedure_result_type::RuntimeReferralRedeemProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct RedeemProfileReferralInviteCodeArgs {
pub input: RuntimeReferralRedeemInput,
}
impl __sdk::InModule for RedeemProfileReferralInviteCodeArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `redeem_profile_referral_invite_code`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait redeem_profile_referral_invite_code {
fn redeem_profile_referral_invite_code(&self, input: RuntimeReferralRedeemInput,
) {
self.redeem_profile_referral_invite_code_then(input, |_, _| {});
}
fn redeem_profile_referral_invite_code_then(
&self,
input: RuntimeReferralRedeemInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeReferralRedeemProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl redeem_profile_referral_invite_code for super::RemoteProcedures {
fn redeem_profile_referral_invite_code_then(
&self,
input: RuntimeReferralRedeemInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<RuntimeReferralRedeemProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, RuntimeReferralRedeemProcedureResult>(
"redeem_profile_referral_invite_code",
RedeemProfileReferralInviteCodeArgs { input, },
__callback,
);
}
}

View File

@@ -0,0 +1,23 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeReferralInviteCenterGetInput {
pub user_id: String,
}
impl __sdk::InModule for RuntimeReferralInviteCenterGetInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,26 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeReferralInviteCenterProcedureResult {
pub ok: bool,
pub record: Option::<RuntimeReferralInviteCenterSnapshot>,
pub error_message: Option::<String>,
}
impl __sdk::InModule for RuntimeReferralInviteCenterProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,34 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeReferralInviteCenterSnapshot {
pub user_id: String,
pub invite_code: String,
pub invite_link_path: String,
pub invited_count: u32,
pub rewarded_invite_count: u32,
pub today_inviter_reward_count: u32,
pub today_inviter_reward_remaining: u32,
pub reward_points: u64,
pub has_redeemed_code: bool,
pub bound_inviter_user_id: Option::<String>,
pub bound_at_micros: Option::<i64>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeReferralInviteCenterSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,25 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeReferralRedeemInput {
pub user_id: String,
pub invite_code: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeReferralRedeemInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,26 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_referral_redeem_snapshot_type::RuntimeReferralRedeemSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeReferralRedeemProcedureResult {
pub ok: bool,
pub record: Option::<RuntimeReferralRedeemSnapshot>,
pub error_message: Option::<String>,
}
impl __sdk::InModule for RuntimeReferralRedeemProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,28 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeReferralRedeemSnapshot {
pub center: RuntimeReferralInviteCenterSnapshot,
pub invitee_reward_granted: bool,
pub inviter_reward_granted: bool,
pub invitee_balance_after: u64,
pub inviter_balance_after: u64,
}
impl __sdk::InModule for RuntimeReferralRedeemSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -149,6 +149,51 @@ impl SpacetimeClient {
.await
}
pub async fn get_profile_referral_invite_center(
&self,
user_id: String,
) -> Result<RuntimeReferralInviteCenterRecord, SpacetimeClientError> {
let procedure_input = build_runtime_referral_invite_center_get_input(user_id)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.get_profile_referral_invite_center_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_referral_invite_center_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn redeem_profile_referral_invite_code(
&self,
user_id: String,
invite_code: String,
updated_at_micros: i64,
) -> Result<RuntimeReferralRedeemRecord, SpacetimeClientError> {
let procedure_input =
build_runtime_referral_redeem_input(user_id, invite_code, updated_at_micros)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.redeem_profile_referral_invite_code_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_referral_redeem_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_profile_play_stats(
&self,
user_id: String,

View File

@@ -2717,6 +2717,7 @@ fn summarize_publish_gate_from_json(
&[
"worldHook",
"creatorIntent.worldHook",
"anchorContent.worldPromise",
"anchorContent.worldPromise.hook",
"settingText",
],
@@ -2734,6 +2735,7 @@ fn summarize_publish_gate_from_json(
&[
"playerPremise",
"creatorIntent.playerPremise",
"anchorContent.playerEntryPoint",
"anchorContent.playerEntryPoint.openingIdentity",
"anchorContent.playerEntryPoint.openingProblem",
"anchorContent.playerEntryPoint.entryMotivation",

View File

@@ -3806,6 +3806,7 @@ fn summarize_publish_gate_from_json(
&[
"worldHook",
"creatorIntent.worldHook",
"anchorContent.worldPromise",
"anchorContent.worldPromise.hook",
"settingText",
],
@@ -3823,6 +3824,7 @@ fn summarize_publish_gate_from_json(
&[
"playerPremise",
"creatorIntent.playerPremise",
"anchorContent.playerEntryPoint",
"anchorContent.playerEntryPoint.openingIdentity",
"anchorContent.playerEntryPoint.openingProblem",
"anchorContent.playerEntryPoint.entryMotivation",

View File

@@ -28,6 +28,34 @@ pub struct ProfileWalletLedger {
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_invite_code)]
pub struct ProfileInviteCode {
#[primary_key]
pub(crate) user_id: String,
#[unique]
pub(crate) invite_code: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_referral_relation,
index(accessor = by_profile_referral_inviter_user_id, btree(columns = [inviter_user_id])),
index(
accessor = by_profile_referral_inviter_bound_at,
btree(columns = [inviter_user_id, bound_at])
)
)]
pub struct ProfileReferralRelation {
#[primary_key]
pub(crate) invitee_user_id: String,
pub(crate) inviter_user_id: String,
pub(crate) invite_code: String,
pub(crate) inviter_reward_granted: bool,
pub(crate) invitee_reward_granted: bool,
pub(crate) bound_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_played_world,
index(accessor = by_profile_played_world_user_id, btree(columns = [user_id])),
@@ -274,6 +302,46 @@ pub fn create_profile_recharge_order_and_return(
}
}
// 邀请中心会在首次打开时为账号创建稳定邀请码,前端只展示这里返回的后端状态。
#[spacetimedb::procedure]
pub fn get_profile_referral_invite_center(
ctx: &mut ProcedureContext,
input: RuntimeReferralInviteCenterGetInput,
) -> RuntimeReferralInviteCenterProcedureResult {
match ctx.try_with_tx(|tx| get_profile_referral_invite_center_snapshot(tx, input.clone())) {
Ok(record) => RuntimeReferralInviteCenterProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeReferralInviteCenterProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 填码绑定、每日邀请者奖励上限和双方叙世币发放都在同一事务内完成。
#[spacetimedb::procedure]
pub fn redeem_profile_referral_invite_code(
ctx: &mut ProcedureContext,
input: RuntimeReferralRedeemInput,
) -> RuntimeReferralRedeemProcedureResult {
match ctx.try_with_tx(|tx| redeem_profile_referral_invite_code_record(tx, input.clone())) {
Ok(record) => RuntimeReferralRedeemProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeReferralRedeemProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput,
@@ -948,6 +1016,215 @@ fn create_profile_recharge_order_record(
))
}
fn get_profile_referral_invite_center_snapshot(
ctx: &ReducerContext,
input: RuntimeReferralInviteCenterGetInput,
) -> Result<RuntimeReferralInviteCenterSnapshot, String> {
let validated_input = build_runtime_referral_invite_center_get_input(input.user_id)
.map_err(|error| error.to_string())?;
Ok(build_profile_referral_invite_center_snapshot(
ctx,
&validated_input.user_id,
))
}
fn redeem_profile_referral_invite_code_record(
ctx: &ReducerContext,
input: RuntimeReferralRedeemInput,
) -> Result<RuntimeReferralRedeemSnapshot, String> {
let validated_input = build_runtime_referral_redeem_input(
input.user_id,
input.invite_code,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let bound_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let invitee_user_id = validated_input.user_id;
let invite_code = validated_input.invite_code;
if ctx
.db
.profile_referral_relation()
.invitee_user_id()
.find(&invitee_user_id)
.is_some()
{
return Err("每个用户最多只能填写一个邀请码".to_string());
}
let inviter_code = ctx
.db
.profile_invite_code()
.invite_code()
.find(&invite_code)
.ok_or_else(|| "邀请码不存在".to_string())?;
if inviter_code.user_id == invitee_user_id {
return Err("不能填写自己的邀请码".to_string());
}
let invitee_balance_after = apply_profile_wallet_delta(
ctx,
&invitee_user_id,
PROFILE_REFERRAL_REWARD_POINTS,
RuntimeProfileWalletLedgerSourceType::InviteInviteeReward,
&format!(
"invitee:{}:{}",
invitee_user_id, validated_input.updated_at_micros
),
bound_at,
)?;
let today_inviter_reward_count =
count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at);
let inviter_reward_granted =
today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT;
let inviter_balance_after = if inviter_reward_granted {
apply_profile_wallet_delta(
ctx,
&inviter_code.user_id,
PROFILE_REFERRAL_REWARD_POINTS,
RuntimeProfileWalletLedgerSourceType::InviteInviterReward,
&format!(
"inviter:{}:{}",
inviter_code.user_id, validated_input.updated_at_micros
),
bound_at,
)?
} else {
profile_wallet_balance(ctx, &inviter_code.user_id)
};
ctx.db
.profile_referral_relation()
.insert(ProfileReferralRelation {
invitee_user_id: invitee_user_id.clone(),
inviter_user_id: inviter_code.user_id,
invite_code,
inviter_reward_granted,
invitee_reward_granted: true,
bound_at,
});
Ok(RuntimeReferralRedeemSnapshot {
center: build_profile_referral_invite_center_snapshot(ctx, &invitee_user_id),
invitee_reward_granted: true,
inviter_reward_granted,
invitee_balance_after,
inviter_balance_after,
})
}
fn build_profile_referral_invite_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
) -> RuntimeReferralInviteCenterSnapshot {
let code = ensure_profile_invite_code(ctx, user_id);
let today_inviter_reward_count =
count_today_profile_referral_inviter_rewards(ctx, user_id, ctx.timestamp);
let invited_count = ctx
.db
.profile_referral_relation()
.iter()
.filter(|row| row.inviter_user_id == user_id)
.count() as u32;
let rewarded_invite_count = ctx
.db
.profile_referral_relation()
.iter()
.filter(|row| row.inviter_user_id == user_id && row.inviter_reward_granted)
.count() as u32;
let bound_relation = ctx
.db
.profile_referral_relation()
.invitee_user_id()
.find(&user_id.to_string());
RuntimeReferralInviteCenterSnapshot {
user_id: user_id.to_string(),
invite_code: code.invite_code.clone(),
invite_link_path: format!("/?inviteCode={}", code.invite_code),
invited_count,
rewarded_invite_count,
today_inviter_reward_count,
today_inviter_reward_remaining: PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT
.saturating_sub(today_inviter_reward_count),
reward_points: PROFILE_REFERRAL_REWARD_POINTS,
has_redeemed_code: bound_relation.is_some(),
bound_inviter_user_id: bound_relation
.as_ref()
.map(|relation| relation.inviter_user_id.clone()),
bound_at_micros: bound_relation
.as_ref()
.map(|relation| relation.bound_at.to_micros_since_unix_epoch()),
updated_at_micros: code.updated_at.to_micros_since_unix_epoch(),
}
}
fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInviteCode {
if let Some(row) = ctx
.db
.profile_invite_code()
.user_id()
.find(&user_id.to_string())
{
return row;
}
let mut invite_code = build_profile_invite_code(user_id, 0);
let mut salt = 1;
while ctx
.db
.profile_invite_code()
.invite_code()
.find(&invite_code)
.is_some()
{
invite_code = build_profile_invite_code(user_id, salt);
salt += 1;
}
ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id: user_id.to_string(),
invite_code,
created_at: ctx.timestamp,
updated_at: ctx.timestamp,
})
}
fn build_profile_invite_code(user_id: &str, salt: u32) -> String {
let mut hash = 14_695_981_039_346_656_037u64;
for byte in user_id.as_bytes().iter().copied().chain(salt.to_le_bytes()) {
hash ^= byte as u64;
hash = hash.wrapping_mul(1_099_511_628_211);
}
format!("SY{:08X}", hash as u32)
}
fn count_today_profile_referral_inviter_rewards(
ctx: &ReducerContext,
user_id: &str,
now: Timestamp,
) -> u32 {
let day_start_micros = (now.to_micros_since_unix_epoch() / 86_400_000_000) * 86_400_000_000;
ctx.db
.profile_wallet_ledger()
.iter()
.filter(|row| {
row.user_id == user_id
&& row.source_type == RuntimeProfileWalletLedgerSourceType::InviteInviterReward
&& row.created_at.to_micros_since_unix_epoch() >= day_start_micros
})
.count() as u32
}
fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 {
ctx.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string())
.map(|row| row.wallet_balance)
.unwrap_or(0)
}
fn build_profile_recharge_center_snapshot(
ctx: &ReducerContext,
user_id: &str,

View File

@@ -0,0 +1,171 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import {
AnimationState,
type Encounter,
type GameState,
type EquipmentLoadout,
WorldType,
} from '../types';
import { AdventureEntityModal } from './AdventureEntityModal';
vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div data-testid="character-portrait" />,
}));
vi.mock('./MedievalNpcAnimator', () => ({
MedievalNpcAnimator: () => <div data-testid="medieval-npc-portrait" />,
}));
vi.mock('./HostileNpcAnimator', () => ({
HostileNpcAnimator: () => <div data-testid="hostile-npc-portrait" />,
}));
function createGameState(overrides: Partial<GameState> = {}): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'test-scene',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {} as EquipmentLoadout,
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'runtime-npc',
kind: 'npc',
npcName: '雾中来客',
npcDescription: '带着临时生成形象的相遇者',
npcAvatar: '/avatar.png',
context: '桥边试探',
...overrides,
};
}
afterEach(() => {
vi.restoreAllMocks();
});
test('NPC 详情立绘优先展示遭遇实例形象,而不是 characterId 对应预设', () => {
const encounter = createEncounter({
characterId: 'sword-princess',
imageSrc: '/runtime-npc-preview.png',
});
render(
<AdventureEntityModal
selection={{ kind: 'npc', encounter }}
gameState={createGameState()}
onClose={() => undefined}
/>,
);
const portrait = screen.getByAltText('雾中来客');
expect(portrait.getAttribute('src')).toBe('/runtime-npc-preview.png');
expect(screen.queryByTestId('character-portrait')).toBeNull();
});
test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const encounter = createEncounter();
render(
<AdventureEntityModal
selection={{ kind: 'npc', encounter }}
gameState={createGameState({
npcStates: {
'runtime-npc': {
affinity: 0,
relationState: { affinity: 0, stance: 'neutral' },
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [
{
id: '',
category: '材料',
name: '裂纹石片',
quantity: 1,
rarity: 'common',
tags: [],
},
{
id: '',
category: '材料',
name: '裂纹石片',
quantity: 2,
rarity: 'common',
tags: [],
},
],
recruited: false,
revealedFacts: [],
knownAttributeRumors: [],
firstMeaningfulContactResolved: false,
seenBackstoryChapterIds: [],
},
},
})}
onClose={() => undefined}
/>,
);
expect(screen.getAllByTitle(/裂纹石片 x/)).toHaveLength(2);
expect(
consoleErrorSpy.mock.calls.some((call) =>
call.some(
(arg) =>
typeof arg === 'string' &&
arg.includes('Encountered two children with the same key'),
),
),
).toBe(false);
});

View File

@@ -55,6 +55,7 @@ import {
type GameState,
type InventoryItem,
type NpcPersistentState,
type SceneHostileNpc,
} from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { AffinityStatusCard } from './AffinityStatusCard';
@@ -87,6 +88,7 @@ import {
InventoryItemGrid,
} from './InventoryItemViews';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { ResolvedAssetImage } from './ResolvedAssetImage';
import { SkillEffectPreview } from './SkillEffectPreview';
interface AdventureEntityModalProps {
@@ -201,6 +203,148 @@ function buildCharacterInventoryPreviewItems(
);
}
function buildSelectionRenderKey(selection: GameCanvasEntitySelection | null) {
if (!selection) {
return 'none';
}
if (selection.kind === 'player') {
return 'player';
}
if (selection.kind === 'companion') {
return `companion-${selection.companion.npcId}`;
}
const encounter = selection.encounter;
return `npc-${
encounter.id ||
selection.battleState?.id ||
encounter.characterId ||
encounter.monsterPresetId ||
encounter.npcName
}`;
}
function buildStableRenderKey(parts: Array<string | number | null | undefined>) {
return parts
.map((part, index) => {
const normalized = String(part ?? '').trim();
return normalized || `empty-${index}`;
})
.join(':');
}
function normalizeInventoryItemRenderIds(
items: InventoryItem[],
ownerKey: string,
) {
const seenIds = new Map<string, number>();
return items.map((item, index) => {
// 运行时 NPC 背包可能带空 id这里只修正展示层 key不改写原始状态。
const rawId = item.id.trim();
const baseId =
rawId ||
buildStableRenderKey([
'inventory',
ownerKey,
item.category,
item.name,
index,
]);
const repeatedCount = seenIds.get(baseId) ?? 0;
seenIds.set(baseId, repeatedCount + 1);
if (rawId && repeatedCount === 0) {
return item;
}
return {
...item,
id:
repeatedCount === 0
? baseId
: buildStableRenderKey([baseId, repeatedCount]),
};
});
}
function NpcEncounterPortrait({
encounter,
character,
hostileNpcPreset,
battleState,
}: {
encounter: Encounter;
character: Character | null;
hostileNpcPreset: ReturnType<typeof getHostileNpcPresetById> | null;
battleState: SceneHostileNpc | null;
}) {
// 详情立绘必须优先服从当前遭遇实例,否则会和画布上点击到的 NPC 形象错位。
if (encounter.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
encounter.visual,
)}
scale={2.08}
/>
);
}
if (encounter.imageSrc?.trim()) {
return (
<ResolvedAssetImage
src={encounter.imageSrc}
alt={encounter.npcName}
className="h-full w-full object-contain object-bottom"
style={{ imageRendering: 'pixelated' }}
/>
);
}
if (hostileNpcPreset) {
return (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
animation={battleState?.animation ?? 'idle'}
flip={(battleState?.facing ?? 'left') === 'right'}
/>
);
}
if (character?.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
character.visual,
)}
scale={2.08}
/>
);
}
if (character) {
return (
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
);
}
return (
<MedievalNpcAnimator
encounter={encounter}
scale={GENERIC_NPC_SCENE_SCALE / 3}
/>
);
}
function getNpcBadge(
encounter: Encounter,
affinity: number,
@@ -416,6 +560,7 @@ export function AdventureEntityModal({
: null;
const npcBattleState =
selection?.kind === 'npc' ? (selection.battleState ?? null) : null;
const selectionRenderKey = buildSelectionRenderKey(selection);
const archiveCharacter =
selection?.kind === 'companion'
? companionCharacter
@@ -602,21 +747,26 @@ export function AdventureEntityModal({
} satisfies CharacterChatTarget)
: null;
const inventory = useMemo(
() =>
selection?.kind === 'player'
? gameState.playerInventory
: selection?.kind === 'companion' && companionCharacter
? buildCharacterInventoryPreviewItems(
companionCharacter,
gameState.worldType,
)
: (npcState?.inventory ?? []),
() => {
const rawInventory =
selection?.kind === 'player'
? gameState.playerInventory
: selection?.kind === 'companion' && companionCharacter
? buildCharacterInventoryPreviewItems(
companionCharacter,
gameState.worldType,
)
: (npcState?.inventory ?? []);
return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey);
},
[
companionCharacter,
gameState.playerInventory,
gameState.worldType,
npcState?.inventory,
selection?.kind,
selectionRenderKey,
],
);
const attributeSchema = resolveAttributeSchema(
@@ -791,6 +941,7 @@ export function AdventureEntityModal({
<AnimatePresence>
{selection && (
<motion.div
key={`entity-modal-${selectionRenderKey}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -878,37 +1029,12 @@ export function AdventureEntityModal({
)}
/>
)
) : npcCharacter ? (
npcCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
npcCharacter.visual,
)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
npcCharacter,
)}
/>
)
) : hostileNpcPreset ? (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
animation={npcBattleState?.animation ?? 'idle'}
flip={
(npcBattleState?.facing ?? 'left') === 'right'
}
/>
) : npcEncounter ? (
<MedievalNpcAnimator
<NpcEncounterPortrait
encounter={npcEncounter}
scale={GENERIC_NPC_SCENE_SCALE / 3}
character={npcCharacter}
hostileNpcPreset={hostileNpcPreset}
battleState={npcBattleState}
/>
) : null}
</div>
@@ -1165,6 +1291,7 @@ export function AdventureEntityModal({
{selectedContributionRow && detailCharacter && (
<motion.div
key={`contribution-modal-${selectionRenderKey}-${selectedContributionRow.label}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -1276,6 +1403,10 @@ export function AdventureEntityModal({
{selectedSkill ? (
<motion.div
key={`skill-modal-${selectionRenderKey}-${buildCharacterSkillRenderId(
selectedSkill,
displayedSkills.indexOf(selectedSkill),
)}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -1401,9 +1532,15 @@ export function AdventureEntityModal({
</div>
<div className="mt-3 flex flex-wrap gap-2">
{selectedSkill.buildBuffs.map((buff) => (
{selectedSkill.buildBuffs.map((buff, index) => (
<span
key={buff.id}
key={buildStableRenderKey([
'skill-buff',
selectedSkill.id,
buff.id,
buff.name,
index,
])}
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
>
{buff.name} / {buff.tags.join('、')} /{' '}

View File

@@ -8,27 +8,23 @@
} from 'react';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import {
buildCustomWorldFoundationEntries,
parseFoundationTagText,
} from '../services/customWorldFoundationEntries';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import { buildCustomWorldScenePresentations } from '../services/customWorldScenePresentation';
import {
AnimationState,
Character,
CustomWorldProfile,
type Character,
type CustomWorldProfile,
type SceneActBlueprint,
type SceneChapterBlueprint,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
import { ResolvedAssetImage } from './ResolvedAssetImage';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
@@ -61,9 +57,9 @@ interface CustomWorldEntityCatalogProps {
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
{ id: 'world', label: '世界' },
{ id: 'landmarks', label: '场景' },
{ id: 'playable', label: '可扮演角色' },
{ id: 'story', label: '场景角色' },
{ id: 'landmarks', label: '场景' },
];
function Section({
@@ -242,45 +238,6 @@ function PendingEntityCard({
);
}
function resolveSceneEntrySceneChapters(params: {
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
sceneId: string;
sceneName: string;
}) {
const sceneChapters = params.sceneChapters ?? [];
const normalizedSceneId = params.sceneId.trim();
const normalizedSceneName = params.sceneName.trim();
const directMatches = sceneChapters.filter(
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
);
if (directMatches.length > 0) {
return directMatches;
}
const linkedMatches = sceneChapters.filter((chapter) =>
chapter.linkedLandmarkIds.some(
(landmarkId) => landmarkId.trim() === normalizedSceneId,
),
);
if (linkedMatches.length > 0) {
return linkedMatches;
}
return sceneChapters.filter((chapter) => {
const chapterTitle = chapter.title.trim();
return (
chapterTitle === normalizedSceneName ||
chapter.summary.includes(normalizedSceneName) ||
chapter.acts.some(
(act) =>
act.title.includes(normalizedSceneName) ||
act.summary.includes(normalizedSceneName),
)
);
});
}
function buildSceneActParticipantText(
act: SceneActBlueprint,
roleById: Map<
@@ -315,6 +272,7 @@ function buildSceneChapterSearchText(
.flatMap((chapter) => [
chapter.title,
chapter.summary,
chapter.sceneTaskDescription,
...chapter.acts.flatMap((act) => [
act.title,
act.summary,
@@ -327,48 +285,10 @@ function buildSceneChapterSearchText(
.join(' ');
}
function resolveSceneCardImage(params: {
sceneImageSrc?: string | null;
sceneChapters: SceneChapterBlueprint[];
}) {
const firstActImageSrc =
params.sceneChapters
.flatMap((chapter) => chapter.acts)
.map((act) => act.backgroundImageSrc?.trim() || '')
.find(Boolean) || '';
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) {
return sceneChapters.flatMap((chapter) =>
chapter.acts
.map((act, index) => ({
id: act.id.trim() || `${chapter.id}-act-${index}`,
title: act.title.trim() || `${index + 1}`,
imageSrc: act.backgroundImageSrc?.trim() || '',
}))
.filter((act) => act.imageSrc),
);
}
function buildFallbackSceneActImagePreviews(params: {
sceneChapters: SceneChapterBlueprint[];
sceneImageSrc?: string | null;
}) {
const actPreviews = collectSceneActImagePreviews(params.sceneChapters);
const sceneImageSrc = params.sceneImageSrc?.trim() || '';
if (actPreviews.length > 0 || !sceneImageSrc) {
return actPreviews;
}
// 中文注释:旧草稿可能只把开局场景图写在 camp.imageSrc尚未回填到每一幕目录侧先用场景图兜底避免开局场景看起来没有幕图片。
return [1, 2, 3].map((actNumber) => ({
id: `fallback-scene-act-${actNumber}`,
title: `${actNumber}`,
imageSrc: sceneImageSrc,
}));
function buildSceneTaskDescriptionText(sceneChapters: SceneChapterBlueprint[]) {
return compactTextList(
sceneChapters.map((chapter) => chapter.sceneTaskDescription),
)[0] ?? '';
}
function SceneActPreviewStrip({
@@ -559,7 +479,7 @@ function resolvePlayableRolePreviewImage(
function buildOpeningSceneSearchText(
profile: CustomWorldProfile,
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
campScene: { name: string; description: string },
) {
return [
campScene.name,
@@ -623,6 +543,16 @@ function buildLandmarkSearchText(
].join(' ');
}
function buildAttributeSlotSummary(
slot: CustomWorldProfile['attributeSchema']['slots'][number],
) {
return compactTextList([
slot.combatUseText,
slot.socialUseText,
slot.explorationUseText,
]).join(' / ');
}
export function CustomWorldEntityCatalog({
profile,
previewCharacters,
@@ -669,16 +599,8 @@ export function CustomWorldEntityCatalog({
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
[profile.landmarks],
);
const landmarkImageById = useMemo(
() => resolveCustomWorldLandmarkImageMap(profile),
[profile],
);
const resolvedCampScene = useMemo(
() => resolveCustomWorldCampScene(profile),
[profile],
);
const resolvedCampImageSrc = useMemo(
() => resolveCustomWorldCampSceneImage(profile),
const scenePresentations = useMemo(
() => buildCustomWorldScenePresentations(profile),
[profile],
);
const previewCharacterById = useMemo(
@@ -724,18 +646,6 @@ export function CustomWorldEntityCatalog({
[deferredSearch, profile.storyNpcs],
);
const filteredLandmarks = useMemo(
() =>
profile.landmarks.filter(
(landmark) =>
!deferredSearch ||
matchText(
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
deferredSearch,
),
),
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
);
const structuredFoundationEntries = useMemo(
() => buildCustomWorldFoundationEntries(profile),
[profile],
@@ -744,59 +654,38 @@ export function CustomWorldEntityCatalog({
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
[profile.creatorIntent],
);
const attributeSlots = Array.isArray(profile.attributeSchema?.slots)
? profile.attributeSchema.slots
: [];
const filteredSceneEntries = useMemo(() => {
const openingSceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: resolvedCampScene.id,
sceneName: resolvedCampScene.name,
});
const openingSceneImageSrc = resolveSceneCardImage({
sceneImageSrc: resolvedCampImageSrc,
sceneChapters: openingSceneChapters,
});
const openingSceneEntry = {
id: resolvedCampScene.id,
kind: 'camp' as const,
name: resolvedCampScene.name,
description: resolvedCampScene.description,
imageSrc: openingSceneImageSrc,
sceneChapters: openingSceneChapters,
actPreviews: buildFallbackSceneActImagePreviews({
sceneChapters: openingSceneChapters,
sceneImageSrc: openingSceneImageSrc,
}),
...scenePresentations.camp,
sceneTaskDescription: buildSceneTaskDescriptionText(
scenePresentations.camp.sceneChapters,
),
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
buildOpeningSceneSearchText(profile, scenePresentations.camp),
buildSceneChapterSearchText(
scenePresentations.camp.sceneChapters,
roleById,
),
]
.filter(Boolean)
.join(' '),
};
const landmarkEntries = profile.landmarks.map((landmark) => {
const sceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: landmark.id,
sceneName: landmark.name,
});
const sceneImageSrc = resolveSceneCardImage({
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
sceneChapters,
});
const landmarkEntries = scenePresentations.landmarks.map((scene) => {
const landmark = profile.landmarks.find((entry) => entry.id === scene.id);
return {
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: sceneImageSrc,
sceneChapters,
actPreviews: buildFallbackSceneActImagePreviews({
sceneChapters,
sceneImageSrc,
}),
...scene,
sceneTaskDescription: buildSceneTaskDescriptionText(
scene.sceneChapters,
),
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(sceneChapters, roleById),
landmark
? buildLandmarkSearchText(landmark, storyNpcById, landmarkById)
: '',
buildSceneChapterSearchText(scene.sceneChapters, roleById),
]
.filter(Boolean)
.join(' '),
@@ -820,12 +709,10 @@ export function CustomWorldEntityCatalog({
}, [
deferredSearch,
landmarkById,
landmarkImageById,
profile,
recentLandmarkIdSet,
resolvedCampImageSrc,
resolvedCampScene,
roleById,
scenePresentations,
storyNpcById,
]);
@@ -1035,6 +922,27 @@ export function CustomWorldEntityCatalog({
</div>
</Section>
<Section
title="角色维度"
subtitle={profile.attributeSchema?.schemaName}
>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{attributeSlots.map((slot) => (
<div
key={slot.slotId}
className="platform-subpanel rounded-xl px-3 py-3"
>
<div className="text-sm font-semibold text-white">
{slot.name}
</div>
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-zinc-400">
{buildAttributeSlotSummary(slot) || slot.definition}
</div>
</div>
))}
</div>
</Section>
<Section
title="世界概述"
actions={
@@ -1311,9 +1219,12 @@ export function CustomWorldEntityCatalog({
)}
title={scene.name}
description={
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description
compactTextList([
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description,
scene.sceneTaskDescription,
]).join(' / ')
}
badge={
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (

View File

@@ -1,22 +1,28 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
import type {
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
SceneActBlueprint,
SceneChapterBlueprint,
} from '../types';
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
import {
type RpgCreationEditorTarget,
RpgCreationEntityEditorModal,
} from './rpg-creation-editor/RpgCreationEntityEditorModal';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
afterEach(() => {
cleanup();
});
vi.mock('../data/characterPresets', async () => {
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
@@ -63,14 +69,25 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcVisualEditor: () => <div></div>,
}));
vi.mock('../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
resolvedUrl: source?.trim() ?? '',
isResolving: false,
shouldResolve: false,
}),
}));
vi.mock('./rpg-runtime-shell', () => ({
RpgRuntimeShell: ({
session,
chrome,
}: {
session: { gameState: { currentScenePreset?: { name?: string } | null } };
chrome?: { hidePlayerLevelBadge?: boolean };
}) => (
<div>
<div></div>
{chrome?.hidePlayerLevelBadge ? <div></div> : null}
<div>{session.gameState.currentScenePreset?.name ?? '未进入场景'}</div>
</div>
),
@@ -208,6 +225,7 @@ function createProfileWithLandmark(): CustomWorldProfile {
createStoryRole('story-1', '顾潮音'),
createStoryRole('story-2', '闻雪汀'),
createStoryRole('story-3', '谢孤灯'),
createStoryRole('story-4', '陆听潮'),
],
landmarks: [
{
@@ -222,6 +240,103 @@ function createProfileWithLandmark(): CustomWorldProfile {
} as unknown as CustomWorldProfile;
}
function createProfileWithTwoLandmarks(): CustomWorldProfile {
return {
...createProfileWithLandmark(),
landmarks: [
{
id: 'landmark-1',
name: '沉钟栈桥',
description: '旧钟与潮声常年相撞的码头栈桥。',
imageSrc: '/generated-custom-world-scenes/original-scene.png',
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
connections: [],
},
{
id: 'landmark-2',
name: '雾灯塔',
description: '雾中仍在闪烁的旧灯塔。',
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
connections: [],
},
],
} as unknown as CustomWorldProfile;
}
function createSceneAct(
sceneId: string,
index: number,
imageSrc: string,
): SceneActBlueprint {
return {
id: `${sceneId}-act-${index + 1}`,
sceneId,
title: `${index + 1}`,
summary: `${index + 1}幕摘要`,
stageCoverage: index === 0 ? ['opening'] : ['expansion'],
backgroundPromptText: '',
backgroundImageSrc: imageSrc,
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
oppositeNpcId: 'story-1',
eventDescription: `${index + 1}幕事件`,
linkedThreadIds: [],
advanceRule:
index === 0
? 'after_primary_contact'
: index >= 2
? 'after_chapter_resolution'
: 'after_active_step_complete',
actGoal: `${index + 1}幕目标`,
transitionHook: '',
};
}
function createSceneChapter(
sceneId: string,
sceneName: string,
imagePrefix: string,
): SceneChapterBlueprint {
return {
id: `${sceneId}-chapter`,
sceneId,
title: sceneName,
summary: `${sceneName}章节`,
sceneTaskDescription: `${sceneName}任务`,
linkedThreadIds: [],
linkedLandmarkIds: [sceneId],
acts: [0, 1, 2].map((index) =>
createSceneAct(sceneId, index, `${imagePrefix}-act-${index + 1}.png`),
),
};
}
function createProfileWithSceneChapters(): CustomWorldProfile {
return {
...createProfileWithLandmark(),
camp: {
id: 'custom-scene-camp',
name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。',
imageSrc: '/generated-custom-world-scenes/camp-main.png',
sceneNpcIds: ['story-1'],
connections: [],
},
sceneChapterBlueprints: [
createSceneChapter(
'custom-scene-camp',
'潮灯居',
'/generated-custom-world-scenes/camp',
),
createSceneChapter(
'landmark-1',
'沉钟栈桥',
'/generated-custom-world-scenes/landmark',
),
],
} as unknown as CustomWorldProfile;
}
function LandmarkEditorFlowHarness() {
const [profile, setProfile] = useState(createProfileWithLandmark());
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
@@ -255,6 +370,24 @@ function LandmarkEditorFlowHarness() {
);
}
function TwoLandmarkEditorFlowHarness() {
const [profile, setProfile] = useState(createProfileWithTwoLandmarks());
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
kind: 'landmark',
mode: 'edit',
id: 'landmark-1',
});
return (
<RpgCreationEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
onProfileChange={setProfile}
/>
);
}
function readLandmarkHarnessProfile() {
const content = screen.getByTestId('landmark-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
@@ -506,16 +639,13 @@ test('基本设定用分号拆分成标签展示', () => {
const profile = {
...createProfile(),
anchorContent: {
worldPromise: {
hook: '机械微生物吞并进化',
differentiator: '角色被迫寄生改造',
desiredExperience: '在失控系统里求生',
},
worldPromise:
'机械微生物吞并进化;角色被迫寄生改造;在失控系统里求生',
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},
@@ -688,6 +818,14 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
expect(savedProfile.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'landmark-1',
);
expect(
savedSceneChapter?.acts.every(
(act) => act.backgroundImageSrc === '/generated-custom-world-scenes/updated-scene.png',
),
).toBe(true);
});
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
@@ -758,6 +896,14 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
expect(savedProfile.camp?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'custom-scene-camp',
);
expect(
savedSceneChapter?.acts.every(
(act) => act.backgroundImageSrc === '/generated-custom-world-scenes/updated-camp.png',
),
).toBe(true);
});
test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => {
@@ -795,10 +941,7 @@ test('开局场景在场景配置面板中与普通场景使用同级参数并
(entry) => entry.sceneId === 'custom-scene-camp',
);
expect(savedProfile.camp?.sceneNpcIds).toHaveLength(3);
expect(savedProfile.camp?.sceneNpcIds).toEqual(
expect.arrayContaining(['story-1', 'story-2', 'story-3']),
);
expect(savedProfile.camp?.sceneNpcIds).toContain('story-2');
expect(savedProfile.camp?.connections).toEqual([
{
targetLandmarkId: 'landmark-1',
@@ -808,9 +951,93 @@ test('开局场景在场景配置面板中与普通场景使用同级参数并
]);
expect(openingSceneChapter).toBeTruthy();
expect(openingSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2');
expect(openingSceneChapter?.acts[1]?.encounterNpcIds[0]).not.toBe('story-2');
expect(openingSceneChapter?.acts[1]?.primaryNpcId).not.toBe('story-2');
expect(openingSceneChapter?.acts[2]?.encounterNpcIds[0]).not.toBe('story-2');
expect(openingSceneChapter?.acts[2]?.primaryNpcId).not.toBe('story-2');
expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp');
});
test('开局场景列表与详情幕预览复用同一套幕级图片', async () => {
const profile = createProfileWithSceneChapters();
const user = userEvent.setup();
render(
<>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
activeTab="landmarks"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={() => {}}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>
<RpgCreationEntityEditorModal
profile={profile}
target={{ kind: 'camp' }}
onClose={() => {}}
onProfileChange={() => {}}
/>
</>,
);
expect(screen.getByRole('img', { name: '潮灯居-第2幕' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/camp-act-2.png',
);
expect(screen.getByRole('img', { name: '沉钟栈桥-第2幕' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/landmark-act-2.png',
);
expect(screen.getByRole('img', { name: '第2幕幕背景' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/camp-act-2.png',
);
await user.click(within(getSceneActCard(1)).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第2幕')).toBeTruthy();
});
expect(screen.getByRole('img', { name: '第2幕背景预览' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/camp-act-2.png',
);
});
test('普通场景世界地图会包含开局场景并高亮当前场景', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
await user.click(screen.getByRole('button', { name: '查看世界地图' }));
await waitFor(() => {
expect(screen.getByText('世界地图')).toBeTruthy();
});
expect(screen.getAllByText('潮灯居').length).toBeGreaterThan(0);
expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0);
expect(screen.getByText('当前')).toBeTruthy();
});
test('世界地图会展示当前未保存的场景连接', async () => {
const user = userEvent.setup();
render(<TwoLandmarkEditorFlowHarness />);
await user.click(screen.getByText('北'));
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('北侧连接')).toBeNull();
});
await user.click(screen.getByRole('button', { name: '查看世界地图' }));
await waitFor(() => {
expect(screen.getByText('世界地图')).toBeTruthy();
});
expect(screen.getAllByText('雾灯塔').length).toBeGreaterThan(0);
expect(screen.getAllByText('北').length).toBeGreaterThan(0);
});
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
const user = userEvent.setup();
@@ -916,6 +1143,40 @@ test('场景多幕支持新增删除和调序', async () => {
expect(savedSceneChapter?.acts[2]?.primaryNpcId).toBe('story-3');
});
test('每幕角色槽位可以从当前世界所有 NPC 中选择', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
await user.click(within(getSceneActCard(0)).getAllByTestId('scene-act-slot-button')[0]!);
await waitFor(() => {
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
});
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => {
expect(screen.queryByText('配置角色第1幕 · 主角色槽位')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull();
});
const savedProfile = readLandmarkHarnessProfile();
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'landmark-1',
);
expect(savedSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-4');
expect(savedProfile.landmarks[0]?.sceneNpcIds).toContain('story-4');
});
test('场景幕预览会打开当前幕运行时面板', async () => {
const user = userEvent.setup();
@@ -929,7 +1190,9 @@ test('场景幕预览会打开当前幕运行时面板', async () => {
expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: '关闭预览' }));
expect(screen.getByText('隐藏等级徽标')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '结束预览' }));
await waitFor(() => {
expect(screen.queryByText('幕预览运行时')).toBeNull();

View File

@@ -328,8 +328,8 @@ export function CustomWorldNpcPortrait({
preferImageSrc && npc.imageSrc?.trim() ? npc.imageSrc.trim() : '';
return (
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
<div className={`platform-npc-portrait relative overflow-hidden rounded-2xl ${className}`}>
<div className="platform-npc-portrait__grid absolute inset-0" />
<div
className={`relative flex h-full items-center justify-center ${contentClassName}`}
>

View File

@@ -46,6 +46,14 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
),
}));
vi.mock('../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
resolvedUrl: source?.trim() ?? '',
isResolving: false,
shouldResolve: false,
}),
}));
vi.mock('./rpg-creation-editor/RpgCreationEntityEditorModal', () => ({
RpgCreationEntityEditorModal: () => null,
default: () => null,
@@ -184,48 +192,21 @@ const baseProfile = {
description: '玩家最初落脚的旧灯塔内院。',
},
anchorContent: {
worldPromise: {
hook: '被海雾反复改写航路的群岛世界。',
differentiator: '旧灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家是被迫返乡守灯人继承者。',
corePursuit: '查清沉钟异动与失控航路的真相。',
fearOfLoss: '失去家族留下的最后航路坐标。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['热血少年漫'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '首夜就撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守潮盟与沉钟会争夺航路解释权'],
hiddenCrisis: '有人借假航灯持续清洗整片群岛的旧证据',
firstTouchedConflict: '玩家回港当夜就被卷进禁航区封锁',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他掌握沉船夜的关键视角',
},
],
hiddenLines: {
hiddenTruths: ['沉钟异动和旧案灭口是同一条线'],
misdirectionHints: ['表面看像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
worldPromise:
'被海雾反复改写航路的群岛世界,旧灯塔与禁航令共同决定谁能活着穿过去,体验压抑、悬疑、潮湿。',
playerFantasy:
'玩家是被迫返乡的守灯人继承者,追查沉钟异动与失控航路的真相,风险是失去家族留下的最后航路坐标。',
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免热血少年漫。',
playerEntryPoint:
'玩家返乡守灯人继承者身份切入,首夜就撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict:
'守潮盟与沉钟会争夺航路解释权,有人借假航灯持续清洗整片群岛的旧证据,玩家回港当夜就被卷进禁航区封锁。',
keyRelationships:
'玩家与沈砺旧友互疑,沈砺掌握沉船夜的关键视角。',
hiddenLines:
'沉钟异动和旧案灭口是同一条线,表面看像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements:
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
},
landmarks: [
{
@@ -437,7 +418,7 @@ test('landmark tab previews every generated act image while keeping chapter deta
(screen.getByRole('img', {
name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-2.png');
).toBe('/generated-custom-world-scenes/scene-act-1.png');
});
test('readOnly result view hides edit and create actions for agent preview mode', async () => {

View File

@@ -65,7 +65,6 @@ export type CharacterVisualGenerationPayload = {
characterId: string;
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
promptText: string;
characterBriefText?: string;
referenceImageDataUrls: string[];
candidateCount: number;
imageModel: string;

View File

@@ -11,20 +11,14 @@ const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
currentTurn: 4,
anchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有通路都要向未知代价借路。',
desiredExperience: '压迫、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的旧航路继承人。',
corePursuit: '查清沉船夜背后的真相。',
fearOfLoss: '一旦失败,就会再次失去唯一还活着的旧友。',
},
worldPromise:
'一个被潮雾改写航线秩序的群岛世界,所有通路都要向未知代价借路,体验压迫、潮湿、悬疑。',
playerFantasy:
'玩家是被迫返乡的旧航路继承人,目标是查清沉船夜背后的真相,失败会再次失去唯一还活着的旧友。',
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},

View File

@@ -10,16 +10,13 @@ test('custom world agent workspace renders minimum loop chat layout', () => {
sessionId: 'custom-world-agent-session-1',
currentTurn: 3,
anchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要为每一次借路付出代价。',
desiredExperience: '压迫、悬疑、带一点海上传奇感',
},
worldPromise:
'一个被潮雾改写航线秩序的群岛世界,所有人都要为每一次借路付出代价,体验压迫、悬疑、带一点海上传奇感。',
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},
@@ -76,7 +73,7 @@ test('custom world agent workspace renders minimum loop chat layout', () => {
expect(html).toContain('42%');
expect(html).toContain('输入消息');
expect(html).toContain('总结当前设定');
expect(html).toContain('补剩余设定');
expect(html).toContain('补剩余设定');
expect(html).not.toContain('世界共创');
expect(html).not.toContain(
'先说一个你最想让玩家记住的世界方向,我会帮你收束成可生成草稿的锚点。',

View File

@@ -8,6 +8,17 @@ import {
type SceneHostileNpc,
} from '../../types';
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
import {
CHARACTER_COMBAT_HP_TOP_PX,
ENTITY_CONTAINER_REM,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMirroredStageEntityLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
MONSTER_COMBAT_HP_TOP_PX,
} from './GameCanvasShared';
function createCharacter(): Character {
return {
@@ -112,6 +123,55 @@ function renderEntityLayer(effectNpcId: string | null) {
}
describe('GameCanvasEntityLayer', () => {
it('uses mirrored stage anchors for player and opponent containers', () => {
expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%');
expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`);
});
it('lowers large monster sprites to the shared scene ground line', () => {
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 62})).toBe(-78);
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 46})).toBe(-68);
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 37})).toBe(-52);
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 23})).toBe(-28);
});
it('uses scene npc visual anchors instead of template character foot offsets', () => {
const sceneNpcEncounter = createEncounter({
characterId: 'hero',
monsterPresetId: 'monster-20',
imageSrc: '/generated-custom-world-npc/shark.png',
});
const character = createCharacter();
expect(getEncounterCharacterOpponentBottom('18%', 68, sceneNpcEncounter, character))
.toBe('calc(18% + 68px - 78px)');
expect(getEncounterCharacterBottomOffsetPx(68, sceneNpcEncounter, character))
.toBe(-10);
});
it('lowers scene npc custom visuals even without character ids', () => {
const sceneNpcEncounter = createEncounter({
visual: {
race: 'elf',
bodyColor: 'blue',
headIndex: 0,
hairColorIndex: 1,
hairStyleFrame: 2,
facialHairEnabled: false,
facialHairColorIndex: 0,
facialHairStyleFrame: 0,
},
});
expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-78);
});
it('keeps combat hp bars above character and monster silhouettes', () => {
expect(getNpcCombatHpTop('hero', null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
expect(getNpcCombatHpTop(null, 'monster-20')).toBe(MONSTER_COMBAT_HP_TOP_PX);
expect(getNpcCombatHpTop(null, null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
});
it('renders affinity effect on the matching hostile npc', () => {
const html = renderEntityLayer('npc-liu');

View File

@@ -21,13 +21,15 @@ import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
getCharacterBottomOffsetPx,
getCharacterOpponentBottom,
CHARACTER_COMBAT_HP_TOP_PX,
getCompanionSlotOffset,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
getSceneEntityZIndex,
HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX,
HpBar,
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
@@ -171,7 +173,10 @@ export function GameCanvasEntityLayer({
className="relative flex w-28 flex-col items-center"
>
{inBattle && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
>
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
</div>
)}
@@ -213,7 +218,10 @@ export function GameCanvasEntityLayer({
>
<div className="relative">
{inBattle && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
>
<HpBar hp={playerHp} maxHp={playerMaxHp} tone="emerald" />
</div>
)}
@@ -262,16 +270,18 @@ export function GameCanvasEntityLayer({
npcCharacter ? npcEncounter?.characterId : null,
npcCharacter ? null : npcEncounter?.monsterPresetId,
);
const hostileNpcBottomOffsetPx = npcMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
const hostileNpcBottomOffsetPx =
npcMonsterConfig
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
: getSceneNpcVisualBottomOffsetPx(npcEncounter);
const opponentBottom = npcCharacter
? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter)
? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter)
: `calc(${groundBottom} + ${stageLiftPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
const entityBottomOffsetPx = npcCharacter
? getCharacterBottomOffsetPx(
? getEncounterCharacterBottomOffsetPx(
stageLiftPx,
npcEncounter,
npcCharacter,
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
)
@@ -365,11 +375,12 @@ export function GameCanvasEntityLayer({
encounter.kind === 'npc' && encounter.monsterPresetId
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
: null;
const peacefulHostileBottomOffsetPx = peacefulMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
const peacefulHostileBottomOffsetPx =
peacefulMonsterConfig
? getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig)
: getSceneNpcVisualBottomOffsetPx(encounter);
const peacefulBottomOffsetPx = peacefulResolvedCharacter
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
: stageLiftPx + peacefulHostileBottomOffsetPx;
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
@@ -384,9 +395,10 @@ export function GameCanvasEntityLayer({
monsterAnchorMeters,
),
bottom: encounter.characterId
? getCharacterOpponentBottom(
? getEncounterCharacterOpponentBottom(
groundBottom,
stageLiftPx,
encounter,
getCharacterById(encounter.characterId),
)
: `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}px)`,

View File

@@ -11,6 +11,7 @@ import {GameCanvasSceneLayer} from './GameCanvasSceneLayer';
import {
type GameCanvasProps,
getCharacterBottomOffsetPx,
getMirroredStageEntityLeft,
getMonsterWorldLeft,
getPlayerWorldLeft,
HOSTILE_NPC_SCENE_INSET_PX,
@@ -77,6 +78,8 @@ export function GameCanvasRuntime({
const sideAnchor = '15%';
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
const playerStageLeft = getMirroredStageEntityLeft(sideAnchor, 'player');
const opponentStageLeft = getMirroredStageEntityLeft(sideAnchor, 'opponent');
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
@@ -84,9 +87,15 @@ export function GameCanvasRuntime({
const playerBottomOffsetPx = getCharacterBottomOffsetPx(stageLiftPx, playerCharacter, playerOffsetY);
const playerLeft = playerActionMode === 'melee' && !scrollWorld
? playerMeleeLeft
: playerWorldLeft;
: scrollWorld
? playerWorldLeft
: playerStageLeft;
const monsterAnchorMeters = 3.2;
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
if (!scrollWorld && hostileNpc.animation !== 'attack') {
return opponentStageLeft;
}
const baseLeft =
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft

View File

@@ -2,11 +2,11 @@ import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
buildMedievalNpcVisual,
buildMedievalNpcVisualFromCustomWorldVisual,
} from '../../data/medievalNpcVisuals';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
AnimationState,
Character,
@@ -17,8 +17,8 @@ import {
Encounter,
SceneHostileNpc,
ScenePresetInfo,
StoryNpcAffinityEffect,
StoryEngineMemoryState,
StoryNpcAffinityEffect,
WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
@@ -70,17 +70,21 @@ export const MONSTER_RENDER_OFFSETS: Record<string, {x: number; y: number}> = {
export const ENTITY_CONTAINER_REM = 7;
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
export const ROLE_CHARACTER_SCENE_IMAGE_SCALE = 1.32;
export const GENERIC_NPC_SCENE_SCALE = 1.72;
export const SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX = 78;
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
objectPosition: 'center bottom',
};
export const DEFAULT_COMBAT_HP_TOP_PX = -18;
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
export const CHARACTER_COMBAT_HP_TOP_PX = -48;
export const MONSTER_COMBAT_HP_TOP_PX = -44;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = -48;
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
export const HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX = -18;
export type HostileNpcSceneAnchorConfig = {
frameHeight: number;
};
export const CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
export const CHAT_BUBBLE_FRAME_HEIGHT = 22;
@@ -139,6 +143,15 @@ export function getPlayerWorldLeft(
return `calc(${sideAnchor} + ${(playerX - cameraAnchorX) * METERS_TO_PIXELS * 0.65}px)`;
}
export function getMirroredStageEntityLeft(
sideAnchor: string,
side: 'player' | 'opponent',
) {
return side === 'player'
? sideAnchor
: `calc(100% - ${sideAnchor} - ${ENTITY_CONTAINER_REM}rem)`;
}
export function getMonsterWorldLeft(
sideAnchor: string,
monsterX: number,
@@ -157,9 +170,64 @@ export function getCharacterOpponentBottom(
return `calc(${groundBottom} + ${stageLiftPx}px - ${groundOffset}px)`;
}
export function hasEncounterCustomSceneVisual(encounter: Encounter | null | undefined) {
return Boolean(
encounter?.visual
|| encounter?.imageSrc?.trim(),
);
}
export function getEncounterCharacterGroundOffset(
encounter: Encounter | null | undefined,
character: Character | null | undefined,
) {
if (hasEncounterCustomSceneVisual(encounter)) {
// 场景 NPC 的 AI 形象通常是方图或组合视觉,不能沿用模板角色脚底偏移。
return SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX;
}
return character?.groundOffsetY ?? 22;
}
export function getEncounterCharacterOpponentBottom(
groundBottom: string,
stageLiftPx: number,
encounter: Encounter | null | undefined,
character: Character | null | undefined,
) {
return `calc(${groundBottom} + ${stageLiftPx}px - ${getEncounterCharacterGroundOffset(encounter, character)}px)`;
}
export function getEncounterCharacterBottomOffsetPx(
stageLiftPx: number,
encounter: Encounter | null | undefined,
character: Character | null | undefined,
extraOffsetPx = 0,
) {
return stageLiftPx - getEncounterCharacterGroundOffset(encounter, character) + extraOffsetPx;
}
export function getSceneNpcVisualBottomOffsetPx(encounter: Encounter | null | undefined) {
return hasEncounterCustomSceneVisual(encounter)
? -SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX
: 0;
}
export function getHostileNpcSceneBottomOffsetPx(
monster: HostileNpcSceneAnchorConfig | null | undefined,
) {
if (!monster) return 0;
// 怪物动画帧和角色立绘不是同一套脚底锚点,大帧需要更明显地下沉到场景地面线。
if (monster.frameHeight >= 58) return -78;
if (monster.frameHeight >= 42) return -68;
if (monster.frameHeight >= 34) return -52;
return -28;
}
export function getNpcCombatHpTop(characterId?: string | null, monsterPresetId?: string | null) {
if (monsterPresetId) return DEFAULT_COMBAT_HP_TOP_PX;
return characterId ? CHARACTER_NPC_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
if (monsterPresetId) return MONSTER_COMBAT_HP_TOP_PX;
return characterId ? CHARACTER_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
}
export function getSceneEntityZIndex(bottomOffsetPx: number) {
@@ -208,9 +276,10 @@ export function getEntityEffectBottom({
}
if (targetHostileNpc.encounter?.characterId) {
return getCharacterOpponentBottom(
return getEncounterCharacterOpponentBottom(
groundBottom,
stageLiftPx + (targetHostileNpc.yOffset ?? 0) + anchorOffsetY,
targetHostileNpc.encounter,
getCharacterById(targetHostileNpc.encounter.characterId),
);
}
@@ -292,14 +361,16 @@ export function SceneEncounterNpcSprite({
}
if (displayEncounterImageSrc) {
const transform = `${facing === 'left' ? 'scaleX(-1) ' : ''}scale(${ROLE_CHARACTER_SCENE_IMAGE_SCALE})`;
return (
<img
src={displayEncounterImageSrc}
alt={encounter.npcName}
className={`h-full w-full object-contain ${className ?? ''}`.trim()}
className={`h-full w-full origin-bottom object-contain ${className ?? ''}`.trim()}
style={{
...DEFAULT_IMAGE_STYLE,
transform: facing === 'left' ? 'scaleX(-1)' : undefined,
transform,
transformOrigin: 'bottom center',
}}
/>

View File

@@ -232,6 +232,7 @@ function isAgentResultStructuralBlockerResolved(
readProfileTextField(profile, [
'worldHook',
'creatorIntent.worldHook',
'anchorContent.worldPromise',
'anchorContent.worldPromise.hook',
'settingText',
]),
@@ -242,6 +243,7 @@ function isAgentResultStructuralBlockerResolved(
readProfileTextField(profile, [
'playerPremise',
'creatorIntent.playerPremise',
'anchorContent.playerEntryPoint',
'anchorContent.playerEntryPoint.openingIdentity',
'anchorContent.playerEntryPoint.openingProblem',
'anchorContent.playerEntryPoint.entryMotivation',

View File

@@ -25,14 +25,14 @@ import { buildDefaultRolePromptBundle } from '../asset-studio/customWorldRolePro
import { buildProjectPixelStyleReferenceBoard } from '../asset-studio/projectPixelStyleReference';
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { RpgCreationRoleAnimationSection } from './RpgCreationRoleAnimationSection';
import { RpgCreationRoleAssetStudioFooter } from './RpgCreationRoleAssetStudioFooter';
import { RpgCreationRoleVisualSection } from './RpgCreationRoleVisualSection';
import {
CORE_ACTIONS,
type CustomWorldAiActionConfig,
type EditableCustomWorldRole,
} from './roleAssetStudioModel';
import { RpgCreationRoleAnimationSection } from './RpgCreationRoleAnimationSection';
import { RpgCreationRoleAssetStudioFooter } from './RpgCreationRoleAssetStudioFooter';
import { RpgCreationRoleVisualSection } from './RpgCreationRoleVisualSection';
import { useRoleAnimationWorkflow } from './useRoleAnimationWorkflow';
import { useRoleVisualCandidateWorkflow } from './useRoleVisualCandidateWorkflow';
@@ -867,7 +867,7 @@ export function RpgCreationRoleAssetStudioModal({
}
return window.confirm(
`${params.kindLabel}预计消耗 ${params.points} 积分\n${params.description}`,
`${params.kindLabel}预计消耗 ${params.points} 叙世币\n${params.description}`,
);
};
@@ -932,7 +932,6 @@ export function RpgCreationRoleAssetStudioModal({
try {
const result = await generateVisualCandidatesForRole({
characterBriefText,
promptText: visualPromptText,
referenceImageDataUrls: effectiveVisualReferenceImageDataUrls,
role: workingRole,

View File

@@ -6,14 +6,12 @@ import type { EditableCustomWorldRole } from './roleAssetStudioModel';
export function useRoleVisualCandidateWorkflow() {
const generateVisualCandidatesForRole = async (params: {
characterBriefText: string;
promptText: string;
referenceImageDataUrls: string[];
role: EditableCustomWorldRole;
sourceMode: 'text-to-image' | 'image-to-image';
}) => {
const {
characterBriefText,
promptText,
referenceImageDataUrls,
role,
@@ -24,7 +22,6 @@ export function useRoleVisualCandidateWorkflow() {
characterId: role.id,
sourceMode,
promptText,
characterBriefText,
referenceImageDataUrls,
candidateCount: 1,
imageModel: 'wan2.7-image-pro',

View File

@@ -214,48 +214,21 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
currentTurn: 0,
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛。',
differentiator: '灯塔与禁航令共同决定谁能穿过死潮。',
desiredExperience: '压抑、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡守灯人继承者。',
corePursuit: '查清沉船夜与假航灯的关系。',
fearOfLoss: '失去家族最后一条可信航线。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['轻喜冒险'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '回港首夜撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守灯会与航运公会争夺航路解释权'],
hiddenCrisis: '有人在借假航灯持续清洗旧案证据',
firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他知道沉船夜的另一半真相',
},
],
hiddenLines: {
hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'],
misdirectionHints: ['表面像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
worldPromise:
'被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能穿过死潮,体验压抑、潮湿、悬疑。',
playerFantasy:
'玩家是被迫返乡的守灯人继承者,追查沉船夜与假航灯的关系,风险是失去家族最后一条可信航线。',
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免轻喜冒险。',
playerEntryPoint:
'玩家返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict:
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
keyRelationships:
'玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
hiddenLines:
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements:
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
},
progressPercent: 0,
lastAssistantReply: '先告诉我你想做一个怎样的 RPG 世界。',
@@ -2272,42 +2245,20 @@ test('agent result view does not keep legacy publish blockers when preview uses
...compiledAgentDraftSession.resultPreview!.preview,
settingText: '被海雾吞没的旧航路群岛',
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛',
differentiator: '灯塔与禁航令共同决定谁能穿过死潮。',
desiredExperience: '压抑、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡守灯人继承者。',
corePursuit: '查清沉船夜与假航灯的关系。',
fearOfLoss: '失去家族最后一条可信航线。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['轻喜冒险'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '回港首夜撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守灯会与航运公会争夺航路解释权'],
hiddenCrisis: '有人在借假航灯持续清洗旧案证据',
firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突',
},
keyRelationships: [],
hiddenLines: {
hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'],
misdirectionHints: ['表面像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
worldPromise:
'被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能穿过死潮,体验压抑、潮湿、悬疑。',
playerFantasy:
'玩家是被迫返乡的守灯人继承者,追查沉船夜与假航灯的关系,风险是失去家族最后一条可信航线。',
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免轻喜冒险。',
playerEntryPoint:
'玩家返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict:
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
keyRelationships: null,
hiddenLines:
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements:
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
},
creatorIntent: {
sourceMode: 'card',

View File

@@ -20,15 +20,15 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
},
pointProducts: [
{
productId: 'points_10',
title: '10积分',
priceCents: 100,
productId: 'points_60',
title: '60叙世币',
priceCents: 600,
kind: 'points',
pointsAmount: 10,
bonusPoints: 19,
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充送积分',
description: '首充送19积分',
badgeLabel: '首充双倍',
description: '首充送60叙世币',
tier: 'normal',
},
],
@@ -48,7 +48,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
],
benefits: [
{
benefitName: '免积分回合数',
benefitName: '免叙世币回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
@@ -61,19 +61,19 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
createRpgProfileRechargeOrder: vi.fn(async () => ({
order: {
orderId: 'order-1',
productId: 'points_10',
productTitle: '10积分',
productId: 'points_60',
productTitle: '60叙世币',
kind: 'points',
amountCents: 100,
amountCents: 600,
status: 'paid',
paymentChannel: 'mock',
paidAt: '2026-04-25T10:00:00Z',
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 29,
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 29,
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
@@ -274,9 +274,10 @@ test('opens recharge modal and submits points product', async () => {
await user.click(screen.getByText('会员充值'));
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(await screen.findByText('10积分')).toBeTruthy();
expect(await screen.findByText('叙世币充值')).toBeTruthy();
expect(await screen.findByText('60叙世币')).toBeTruthy();
await user.click(screen.getByText('首充送19积分'));
await user.click(screen.getByText('首充送60叙世币'));
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
});

View File

@@ -36,13 +36,17 @@ import type {
ProfileDashboardSummary,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary,
RedeemProfileReferralInviteCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import {
createRpgProfileRechargeOrder,
getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter,
redeemRpgProfileReferralInviteCode,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -116,6 +120,7 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'saves',
'profile',
];
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
@@ -900,6 +905,14 @@ function formatRechargePrice(priceCents: number) {
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
}
function formatMembershipDuration(days: number) {
if (days >= 365) {
return '365天';
}
return `${days}`;
}
function AccountRechargeModal({
center,
activeTab,
@@ -925,36 +938,48 @@ function AccountRechargeModal({
: (center?.membershipProducts ?? []);
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[28rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[32rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_34%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]"
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
aria-label="关闭账户充值"
>
×
</button>
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-5 pb-5 pt-4">
<div className="text-center text-2xl font-black"></div>
<div className="mt-4 grid grid-cols-2 rounded-xl bg-zinc-100 p-1">
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
WALLET
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
<button
type="button"
onClick={() => onTabChange('points')}
className={`rounded-lg px-4 py-3 text-sm font-black transition ${
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
activeTab === 'points'
? 'bg-white text-[#ff4056] shadow'
? 'bg-white text-[#ff4056] shadow-sm'
: 'text-zinc-500'
}`}
>
</button>
<button
type="button"
onClick={() => onTabChange('membership')}
className={`rounded-lg px-4 py-3 text-sm font-black transition ${
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
activeTab === 'membership'
? 'bg-white text-[#ff4056] shadow'
? 'bg-white text-[#ff4056] shadow-sm'
: 'text-zinc-500'
}`}
>
@@ -987,20 +1012,14 @@ function AccountRechargeModal({
key={product.productId}
disabled={Boolean(isSubmitting)}
onClick={() => onSelectProduct(product)}
className="relative min-h-[8.45rem] overflow-hidden rounded-xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
className="relative min-h-[8.45rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
>
<div
className={`h-8 px-2 py-1.5 text-xs font-black text-white ${
product.productId === 'points_60'
? 'bg-zinc-500'
: 'bg-[#ff4056]'
}`}
>
<div className="h-8 bg-[#ff4056] px-2 py-1.5 text-xs font-black text-white">
{product.badgeLabel}
</div>
<div className="px-2 py-3">
<div className="text-xl font-black">
{product.pointsAmount}
{product.pointsAmount}
</div>
<div className="mt-1 text-xs text-zinc-500">
{formatRechargePrice(product.priceCents)}
@@ -1017,46 +1036,66 @@ function AccountRechargeModal({
</div>
) : (
<>
<div className="mt-5 grid grid-cols-3 gap-3">
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{visibleProducts.map((product) => (
<button
type="button"
key={product.productId}
disabled={Boolean(isSubmitting)}
onClick={() => onSelectProduct(product)}
className="min-h-[6rem] rounded-xl border border-zinc-200 bg-zinc-50 px-2 py-4 text-center transition hover:border-[#ff4056] disabled:opacity-70"
className="group relative min-h-[7.75rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-[#ff4056] hover:shadow-md disabled:opacity-70"
>
<div className="text-lg font-black">{product.title}</div>
<div className="mt-2 text-xl font-black text-[#ff4056]">
{formatRechargePrice(product.priceCents)}
<div className="absolute right-0 top-0 h-16 w-16 rounded-bl-[2rem] bg-[#ff4056]/10 transition group-hover:bg-[#ff4056]/16" />
<div className="relative">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-lg font-black">
{product.title}
</div>
<div className="mt-1 text-xs font-bold text-zinc-500">
{formatMembershipDuration(product.durationDays)}
</div>
</div>
<Crown className="h-5 w-5 shrink-0 text-[#ff4056]" />
</div>
<div className="mt-4 text-2xl font-black text-[#ff4056]">
{formatRechargePrice(product.priceCents)}
</div>
<div className="mt-2 text-xs font-semibold text-zinc-500">
{isSubmitting === product.productId
? '处理中'
: product.description}
</div>
</div>
</button>
))}
</div>
<div className="mt-5 overflow-hidden rounded-xl border border-zinc-200">
<div className="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
</div>
<div className="grid grid-cols-5 text-center text-sm">
{center?.benefits.map((benefit) => (
<div key={benefit.benefitName} className="contents">
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
{benefit.benefitName}
<div className="overflow-x-auto">
<div className="grid min-w-[30rem] grid-cols-5 text-center text-sm">
{center?.benefits.map((benefit) => (
<div key={benefit.benefitName} className="contents">
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
{benefit.benefitName}
</div>
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
{benefit.normalValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
{benefit.monthValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
{benefit.seasonValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
{benefit.yearValue}
</div>
</div>
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
{benefit.normalValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
{benefit.monthValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
{benefit.seasonValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
{benefit.yearValue}
</div>
</div>
))}
))}
</div>
</div>
</div>
</>
@@ -1067,6 +1106,154 @@ function AccountRechargeModal({
);
}
function ProfileReferralModal({
panel,
center,
inviteCodeInput,
isLoading,
isSubmitting,
error,
success,
onClose,
onInputChange,
onCopyInvite,
onSubmitRedeem,
}: {
panel: ProfilePopupPanel;
center: ProfileReferralInviteCenterResponse | null;
inviteCodeInput: string;
isLoading: boolean;
isSubmitting: boolean;
error: string | null;
success: string | null;
onClose: () => void;
onInputChange: (value: string) => void;
onCopyInvite: () => void;
onSubmitRedeem: () => void;
}) {
const title =
panel === 'invite'
? '邀请好友'
: panel === 'redeem'
? '填邀请码'
: '玩家社区';
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
<div className="relative w-full max-w-[24rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]"
aria-label={`关闭${title}`}
>
×
</button>
<div className="px-5 pb-5 pt-4">
<div className="text-center text-xl font-black">{title}</div>
{panel === 'community' ? (
<div className="mt-5 grid grid-cols-2 gap-3">
{['微信群', 'QQ群'].map((label) => (
<div
key={label}
className="rounded-xl border border-zinc-200 bg-zinc-50 p-3 text-center"
>
<div className="aspect-square rounded-lg border border-dashed border-zinc-300 bg-white" />
<div className="mt-2 text-sm font-bold text-zinc-700">
{label}
</div>
</div>
))}
</div>
) : isLoading ? (
<div className="mt-5 space-y-3">
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
</div>
) : panel === 'invite' ? (
<div className="mt-5 space-y-3">
<div className="rounded-xl bg-zinc-50 px-4 py-4 text-center">
<div className="text-[11px] font-bold text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
</div>
</div>
<button
type="button"
onClick={onCopyInvite}
disabled={!center?.inviteCode}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
>
<Copy className="h-4 w-4" />
</button>
<div className="grid grid-cols-3 gap-2 text-center text-xs text-zinc-500">
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-black text-zinc-900">
{center?.invitedCount ?? 0}
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-black text-zinc-900">
{center?.rewardedInviteCount ?? 0}
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-black text-zinc-900">
{center?.todayInviterRewardRemaining ?? 0}
</div>
</div>
</div>
</div>
) : (
<div className="mt-5 space-y-3">
{center?.hasRedeemedCode ? (
<div className="rounded-xl bg-emerald-50 px-4 py-4 text-center text-sm font-bold text-emerald-700">
</div>
) : (
<>
<input
value={inviteCodeInput}
onChange={(event) => onInputChange(event.target.value)}
placeholder="输入邀请码"
className="w-full rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-center text-base font-black tracking-[0.14em] outline-none focus:border-[#ff4056]"
/>
<button
type="button"
onClick={onSubmitRedeem}
disabled={isSubmitting || !inviteCodeInput.trim()}
className="w-full rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
>
{isSubmitting ? '提交中' : '确认填写'}
</button>
</>
)}
</div>
)}
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</div>
) : null}
{success ? (
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{success}
</div>
) : null}
</div>
</div>
</div>
);
}
export function RpgEntryHomeView({
activeTab,
onTabChange,
@@ -1107,6 +1294,15 @@ export function RpgEntryHomeView({
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
useState<string | null>(null);
const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
useState<ProfileReferralInviteCenterResponse | null>(null);
const [isLoadingReferral, setIsLoadingReferral] = useState(false);
const [isSubmittingReferral, setIsSubmittingReferral] = useState(false);
const [referralError, setReferralError] = useState<string | null>(null);
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
const [inviteCodeInput, setInviteCodeInput] = useState('');
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
@@ -1225,6 +1421,61 @@ export function RpgEntryHomeView({
})
.finally(() => setSubmittingRechargeProductId(null));
};
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
setProfilePopupPanel(panel);
setReferralError(null);
setReferralSuccess(null);
if (panel === 'community') {
return;
}
setIsLoadingReferral(true);
void getRpgProfileReferralInviteCenter()
.then(setReferralCenter)
.catch((error: unknown) => {
setReferralCenter(null);
setReferralError(
error instanceof Error ? error.message : '读取邀请码失败',
);
})
.finally(() => setIsLoadingReferral(false));
};
const copyInviteInfo = () => {
if (!referralCenter?.inviteCode) {
return;
}
const inviteUrl =
typeof window === 'undefined'
? referralCenter.inviteLinkPath
: new URL(referralCenter.inviteLinkPath, window.location.origin).href;
copyText(`${referralCenter.inviteCode} ${inviteUrl}`);
setReferralSuccess('已复制');
};
const submitReferralInviteCode = () => {
if (isSubmittingReferral || !inviteCodeInput.trim()) {
return;
}
setIsSubmittingReferral(true);
setReferralError(null);
setReferralSuccess(null);
void redeemRpgProfileReferralInviteCode(inviteCodeInput)
.then((response: RedeemProfileReferralInviteCodeResponse) => {
setReferralCenter(response.center);
setInviteCodeInput('');
setReferralSuccess(
response.inviteeRewardGranted ? '已获得30叙世币' : '填写成功',
);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setReferralError(
error instanceof Error ? error.message : '填写邀请码失败',
);
})
.finally(() => setIsSubmittingReferral(false));
};
const submitDesktopSearch = () => {
const keyword = desktopSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
@@ -1657,9 +1908,21 @@ export function RpgEntryHomeView({
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<SectionHeader title="常用功能" detail="快捷入口" />
<div className="grid grid-cols-3 gap-3">
<ProfileShortcutButton label="邀请好友" icon={UserPlus} />
<ProfileShortcutButton label="邀请码" icon={Ticket} />
<ProfileShortcutButton label="玩家社区" icon={MessageCircle} />
<ProfileShortcutButton
label="邀请好友"
icon={UserPlus}
onClick={() => openProfilePopupPanel('invite')}
/>
<ProfileShortcutButton
label="填邀请码"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
<ProfileShortcutButton
label="玩家社区"
icon={MessageCircle}
onClick={() => openProfilePopupPanel('community')}
/>
</div>
</section>
@@ -2030,6 +2293,21 @@ export function RpgEntryHomeView({
onSelectProduct={submitRechargeProduct}
/>
) : null}
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
</div>
);
}
@@ -2119,6 +2397,21 @@ export function RpgEntryHomeView({
onSelectProduct={submitRechargeProduct}
/>
) : null}
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
</div>
);
}

View File

@@ -79,7 +79,7 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},

View File

@@ -74,7 +74,7 @@ function buildSession(
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},

View File

@@ -107,7 +107,7 @@ test('adventure panel renders system turns without special relationship labels',
expect(html).not.toContain('关系变化');
});
test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => {
test('adventure panel shows current act label without fixed hostile chat turns', () => {
const currentStory: StoryMoment = {
text: '断桥客仍在压着最后那半句真相。',
displayMode: 'dialogue',
@@ -122,10 +122,12 @@ test('adventure panel shows current act label and remaining turns for limited ho
turnCount: 3,
customInputPlaceholder: '输入你想对 TA 说的话',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 2,
turnLimit: null,
remainingTurns: null,
limitReason: 'negative_affinity',
forceExitAfterTurn: false,
terminationMode: 'hostile_model',
isHostileChat: true,
},
};
@@ -201,6 +203,5 @@ test('adventure panel shows current act label and remaining turns for limited ho
expect(html).toContain('当前幕');
expect(html).toContain('断桥口 · 对峙幕');
expect(html).toContain('1/3');
expect(html).toContain('剩余交谈');
expect(html).toContain('2 轮');
expect(html).not.toContain('剩余交谈');
});

View File

@@ -147,6 +147,25 @@ test('adventure panel does not show deferred hint for non-continue options with
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
});
test('adventure panel renders compact function tags before option text', () => {
const chatOption = createOption('npc_chat', '继续追问桥上的旧账');
const questOption = createOption('npc_quest_accept', '接下断桥客的委托');
const giftOption = createOption('npc_gift', '把玉牌递给柳无声');
const currentStory: StoryMoment = {
text: '你看向眼前的人。',
options: [chatOption, questOption, giftOption],
};
const html = renderPanel(currentStory, [chatOption, questOption, giftOption]);
expect(html).toContain('聊天');
expect(html).toContain('继续追问桥上的旧账');
expect(html).toContain('任务');
expect(html).toContain('接下断桥客的委托');
expect(html).toContain('送礼');
expect(html).toContain('把玉牌递给柳无声');
});
test('adventure panel shows npc chat custom input and exit button in chat mode', () => {
const optionA = createOption('npc_chat', '先听对方把话说完');
const optionB = createOption('npc_chat', '顺着这个问题继续追问');
@@ -181,7 +200,7 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
expect(html).toContain('退出聊天');
expect(html).toContain('输入你想对 TA 说的话');
expect(html).toContain('发送');
expect(html).not.toContain('换一换');
expect(html).toContain('换一换');
expect(html).not.toContain('关系升温');
});

View File

@@ -18,11 +18,7 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { formatCurrency } from '../../data/economy';
import { getEquipmentSlotFromItem } from '../../data/equipmentEffects';
import {
getFunctionDocumentationById,
isContinueAdventureOption,
NPC_CHAT_FUNCTION,
} from '../../data/functionCatalog';
import { isContinueAdventureOption } from '../../data/functionCatalog';
import { getHostileNpcPresetById } from '../../data/hostileNpcPresets';
import { resolveInventoryItemUseEffect } from '../../data/inventoryEffects';
import { isQuestReadyToClaim } from '../../data/questFlow';
@@ -136,22 +132,6 @@ function AdventurePanelOverlayLoadingFallback() {
);
}
function getCompactOptionDetailText(option: StoryOption) {
if (option.functionId === NPC_CHAT_FUNCTION.id) {
return (
option.detailText ||
getFunctionDocumentationById(option.functionId)?.runtime
?.compactDetailText ||
'聊聊并试探口风'
);
}
return (
getFunctionDocumentationById(option.functionId)?.runtime
?.compactDetailText || option.detailText
);
}
function getOptionActionTextClass(option: StoryOption) {
if ((option.priority ?? 1) >= 3)
return 'text-fuchsia-200 group-hover:text-fuchsia-100';
@@ -160,6 +140,67 @@ function getOptionActionTextClass(option: StoryOption) {
return 'text-zinc-300 group-hover:text-white';
}
function getOptionFunctionTagText(option: StoryOption) {
const tagByFunctionId: Record<string, string> = {
battle_all_in_crush: '战斗',
battle_attack_basic: '战斗',
battle_escape_breakout: '逃跑',
battle_feint_step: '战斗',
battle_finisher_window: '战斗',
battle_guard_break: '战斗',
battle_probe_pressure: '战斗',
battle_recover_breath: '调息',
battle_use_skill: '技能',
camp_travel_home_scene: '场景',
idle_call_out: '试探',
idle_explore_forward: '探索',
idle_follow_clue: '线索',
idle_observe_signs: '观察',
idle_rest_focus: '调息',
idle_travel_next_scene: '场景',
npc_chat: '聊天',
npc_fight: '战斗',
npc_gift: '送礼',
npc_help: '求助',
npc_leave: '离开',
npc_preview_talk: '聊天',
npc_quest_accept: '任务',
npc_quest_turn_in: '任务',
npc_recruit: '招募',
npc_spar: '切磋',
npc_trade: '交易',
story_continue_adventure: '继续',
treasure_inspect: '探查',
treasure_leave: '离开',
treasure_secure: '收取',
};
if (option.functionId.startsWith('npc_chat_quest_offer_')) {
return '任务';
}
return tagByFunctionId[option.functionId] ?? null;
}
function RpgOptionActionLabel({ option }: { option: StoryOption }) {
const tagText = getOptionFunctionTagText(option);
return (
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
{tagText ? (
<span className="shrink-0 rounded border border-white/10 bg-white/10 px-1.5 py-0.5 text-[9px] leading-none text-zinc-300">
{tagText}
</span>
) : null}
<span
className={`min-w-0 break-words text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
</span>
);
}
function getDialogueTurnAlignmentClass(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
@@ -798,29 +839,45 @@ function RpgAdventureChoiceSection(props: {
</button>
</div>
{isNpcChatMode ? (
<button
type="button"
onClick={() => onExitNpcChat?.()}
aria-label="退出聊天"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
>
<span className="text-xs leading-none">退</span>
</button>
) : canRefreshOptions && !shouldHideChoiceUi ? (
<button
type="button"
onClick={onRefreshOptions}
aria-label="换一换选项"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-4 w-4"
/>
<span className="text-xs leading-none"></span>
</button>
) : null}
<div className="flex shrink-0 items-center gap-2">
{isNpcChatMode && canRefreshOptions && !shouldHideChoiceUi ? (
<button
type="button"
onClick={onRefreshOptions}
aria-label="换一换选项"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-4 w-4"
/>
<span className="text-xs leading-none"></span>
</button>
) : !isNpcChatMode && canRefreshOptions && !shouldHideChoiceUi ? (
<button
type="button"
onClick={onRefreshOptions}
aria-label="换一换选项"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-4 w-4"
/>
<span className="text-xs leading-none"></span>
</button>
) : null}
{isNpcChatMode ? (
<button
type="button"
onClick={() => onExitNpcChat?.()}
aria-label="退出聊天"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
>
<span className="text-xs leading-none">退</span>
</button>
) : null}
</div>
</div>
<div className="space-y-1.5">
@@ -867,12 +924,8 @@ function RpgAdventureChoiceSection(props: {
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<div className="flex items-center justify-between gap-3">
<RpgOptionActionLabel option={option} />
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
@@ -894,12 +947,8 @@ function RpgAdventureChoiceSection(props: {
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<div className="flex items-center justify-between gap-3">
<RpgOptionActionLabel option={option} />
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"

View File

@@ -1,6 +1,5 @@
import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/rpg-session/rpgSessionTypes';
import type {
BattleRewardUi,
CharacterChatUi,
@@ -9,6 +8,7 @@ import type {
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/rpg-runtime-story';
import type { BottomTab } from '../../hooks/rpg-session';
import type {
CompanionRenderState,
GameState,
@@ -56,6 +56,7 @@ export interface RpgRuntimePanelRouterProps {
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
refreshNpcChatOptions: () => boolean;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
@@ -91,6 +92,7 @@ export function RpgRuntimePanelRouter({
hideStoryOptions,
canRefreshOptions,
handleRefreshOptions,
refreshNpcChatOptions,
handleSceneTransitionChoice,
handleNpcChatInput,
exitNpcChat,
@@ -224,8 +226,18 @@ export function RpgRuntimePanelRouter({
isLoading={isLoading}
displayedOptions={displayedOptions}
hideOptions={hideStoryOptions}
canRefreshOptions={canRefreshOptions}
onRefreshOptions={handleRefreshOptions}
canRefreshOptions={
visibleCurrentStory.npcChatState
? visibleCurrentStory.options.length > 1
: canRefreshOptions
}
onRefreshOptions={() => {
if (visibleCurrentStory.npcChatState) {
refreshNpcChatOptions();
return;
}
handleRefreshOptions();
}}
onChoice={handleSceneTransitionChoice}
onSubmitNpcChatInput={handleNpcChatInput}
onExitNpcChat={exitNpcChat}

View File

@@ -36,6 +36,7 @@ export function RpgRuntimeShell({
entry,
companions,
audio,
chrome,
}: RpgRuntimeShellComponentProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
@@ -57,6 +58,7 @@ export function RpgRuntimeShell({
canRefreshOptions,
handleRefreshOptions,
handleNpcChatInput,
refreshNpcChatOptions,
exitNpcChat,
handleMapTravelToScene,
npcUi,
@@ -175,7 +177,7 @@ export function RpgRuntimeShell({
</Suspense>
) : null}
{visibleGameState.playerCharacter && (
{visibleGameState.playerCharacter && !chrome?.hidePlayerLevelBadge && (
<div
className="pointer-events-none fixed z-[26] w-[4.5rem] drop-shadow-[0_2px_8px_rgba(0,0,0,0.75)]"
style={{
@@ -227,6 +229,7 @@ export function RpgRuntimeShell({
hideStoryOptions={shouldHideStoryOptions}
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
refreshNpcChatOptions={refreshNpcChatOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
handleNpcChatInput={handleNpcChatInput}
exitNpcChat={exitNpcChat}

View File

@@ -1,7 +1,6 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/rpg-session/rpgSessionTypes';
import type {
BattleRewardUi,
CharacterChatUi,
@@ -10,6 +9,7 @@ import type {
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/rpg-runtime-story';
import type { BottomTab } from '../../hooks/rpg-session';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type {
CompanionRenderState,
@@ -76,6 +76,7 @@ export interface RpgRuntimeStageRouterProps {
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
refreshNpcChatOptions: () => boolean;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
@@ -123,6 +124,7 @@ export function RpgRuntimeStageRouter({
hideStoryOptions,
canRefreshOptions,
handleRefreshOptions,
refreshNpcChatOptions,
handleSceneTransitionChoice,
handleNpcChatInput,
exitNpcChat,
@@ -226,6 +228,7 @@ export function RpgRuntimeStageRouter({
hideStoryOptions={hideStoryOptions}
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
refreshNpcChatOptions={refreshNpcChatOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
handleNpcChatInput={handleNpcChatInput}
exitNpcChat={exitNpcChat}

View File

@@ -1,4 +1,3 @@
import type { BottomTab } from '../../hooks/rpg-session/rpgSessionTypes';
import type {
BattleRewardUi,
CharacterChatUi,
@@ -8,6 +7,7 @@ import type {
QuestFlowUi,
StoryGenerationNpcUi,
} from '../../hooks/rpg-runtime-story';
import type { BottomTab } from '../../hooks/rpg-session';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type {
Character,
@@ -35,6 +35,7 @@ export interface RpgRuntimeStoryProps {
handleRefreshOptions: () => void;
handleChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
refreshNpcChatOptions: () => boolean;
exitNpcChat: () => boolean;
handleMapTravelToScene: (sceneId: string) => boolean;
npcUi: StoryGenerationNpcUi;
@@ -69,6 +70,10 @@ export interface RpgRuntimeAudioProps {
onMusicVolumeChange: (value: number) => void;
}
export interface RpgRuntimeShellChromeOptions {
hidePlayerLevelBadge?: boolean;
}
export interface RpgRuntimeDialogueIndicator {
showPlayer: boolean;
showEncounter: boolean;
@@ -101,4 +106,5 @@ export interface RpgRuntimeShellProps {
entry: RpgEntrySessionProps;
companions: RpgRuntimeCompanionProps;
audio: RpgRuntimeAudioProps;
chrome?: RpgRuntimeShellChromeOptions;
}

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