Compare commits
2 Commits
2792df03a6
...
271db02e4a
| Author | SHA1 | Date | |
|---|---|---|---|
| 271db02e4a | |||
| b6c6640548 |
@@ -240,7 +240,7 @@ function buildNpcFirstContactOptionCatalog(
|
||||
- 不能再用“某人看着你,像是在等你把话接下去”这类第三人称占位旁白充当可见对话历史首句,也不能在聊天 state 里本地硬编码一条替代台词。
|
||||
- 当玩家在场景中第一次真正撞上角色型 NPC 并进入聊天时,应直接触发一轮由 NPC 主动开口的模型回复;这一轮只生成 NPC 自己的首句与后续可选回应,不得代替玩家补写未说过的话。
|
||||
- 负好感或敌对关系不应跳过主动开口;如果玩家从 NPC 交互面板点击 `npc_chat`,且该角色尚未完成 `firstMeaningfulContactResolved`,仍要走同一条 NPC 主动开场链路。负好感只影响语气、敌对聊天指令与后续可选功能,不影响“由角色先发言”的首遇行为。
|
||||
- 好感度小于 `0` 的角色在聊天终止时不进入 `story_continue_adventure` 收束态。无论是玩家主动退出聊天,还是模型通过敌对聊天指令主动结束聊天,底部选项都固定收束为 `npc_fight` 与 `battle_escape_breakout`:按钮文案分别为“战斗”“逃跑”。点击“战斗”进入 NPC 战斗结算链路;点击“逃跑”执行现有 `battle_escape_breakout` function,完成脱离演出与后续状态更新。
|
||||
- 好感度小于 `0` 的角色在聊天终止时不进入 `story_continue_adventure` 收束态。无论是玩家主动退出聊天,还是模型通过敌对聊天指令主动结束聊天,底部选项都必须收束为一个 `npc_fight` 与多个 `battle_escape_breakout`:`npc_fight` 的按钮文案保持“战斗”,点击后仍进入 NPC 战斗结算链路;逃跑类选项按当前场景相邻场景展开为“逃往{场景名}”,并额外提供“逃回当前场景起点”。逃跑选项需要在 `runtimePayload` 中携带目标场景信息,点击后复用现有主角向左转身跑出屏幕的逃离演出,再在目标场景从左侧入场并面向右侧。
|
||||
|
||||
4. 首遇状态下,不允许前两项直接变成:
|
||||
- 深背景追问
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 拼图运行时顶栏与通关演出调整设计
|
||||
|
||||
## 背景
|
||||
|
||||
现有拼图运行时存在 4 个体验问题:
|
||||
|
||||
1. 顶栏把 `PUZZLE`、`3*3 / 4*4` 这类系统标签直接暴露给玩家,主信息不够聚焦。
|
||||
2. 拼图块右下角数字会破坏原图识别,影响拼图沉浸感。
|
||||
3. 拼图运行时右上角缺少与 RPG 一致的设置入口,玩家不能在玩法内直接调整音量或处理中途退出。
|
||||
4. 通关后立即弹结算弹窗,会打断玩家先看到完整成图的奖励时刻。
|
||||
|
||||
## 本次落地结论
|
||||
|
||||
### 1. 顶栏信息层级
|
||||
|
||||
拼图运行时顶栏统一改成三段结构:
|
||||
|
||||
1. 左侧保留返回按钮。
|
||||
2. 中间居中展示关卡主信息:
|
||||
- 第一行:拼图关卡名
|
||||
- 第二行:作者昵称
|
||||
- 第三行:`第 N 关`
|
||||
3. 右侧新增设置按钮。
|
||||
|
||||
同时移除以下冗余标识:
|
||||
|
||||
1. `PUZZLE`
|
||||
2. `3*3 / 4*4`
|
||||
|
||||
网格规模仍可作为运行时内部状态存在,但不默认写在 UI 顶栏中。
|
||||
|
||||
### 2. 拼图块显示规则
|
||||
|
||||
运行时单块右下角编号全部移除。
|
||||
|
||||
原因:
|
||||
|
||||
1. 玩家需要优先依赖画面主体、构图和色块识别位置。
|
||||
2. 编号覆盖会削弱“完整图片被逐步复原”的视觉奖励。
|
||||
|
||||
### 3. 设置能力
|
||||
|
||||
拼图运行时右上角设置按钮对齐 RPG 玩法内设置入口,打开独立弹层,不在当前面板下方展开内容。
|
||||
|
||||
本次至少保留以下能力:
|
||||
|
||||
1. 音乐音量调节
|
||||
2. 本局进度查看
|
||||
3. 返回上一页
|
||||
|
||||
后续若 RPG 运行时设置继续扩展,拼图运行时应优先复用同类能力与交互层级。
|
||||
|
||||
### 4. 通关演出时序
|
||||
|
||||
拼图完成后不立即弹结算弹窗,统一按以下顺序执行:
|
||||
|
||||
1. 保持完整成图画面可见。
|
||||
2. 播放一段从一个角扫到另一个角的对角线闪光特效。
|
||||
3. 闪光特效结束后额外等待 `0.5s`。
|
||||
4. 再弹出结算弹窗。
|
||||
|
||||
这样可以先给玩家完整成图的视觉奖励,再进入排行榜与下一关决策。
|
||||
|
||||
## 工程约束
|
||||
|
||||
1. 继续复用现有 `PuzzleRuntimeShell` 作为运行时承载组件,不新增平行页面。
|
||||
2. 设置弹层沿用现有像素风弹窗资源,不单独引入新的弹窗体系。
|
||||
3. 通关演出只作为前端表现层时序,不改动通关判定与排行榜数据来源。
|
||||
@@ -15,7 +15,7 @@
|
||||
5. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。
|
||||
6. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。
|
||||
7. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。
|
||||
8. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用原敌对出口:继续推进后回到原有战斗或逃跑选择。
|
||||
8. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用敌对出口:继续推进后展示一个“战斗”选项,以及按相邻场景和当前场景起点展开的多个逃跑选项。
|
||||
9. 聊天候选中允许混入当前 NPC 可执行 function,例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。
|
||||
10. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。
|
||||
11. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。
|
||||
@@ -68,6 +68,14 @@
|
||||
5. 相邻场景选项继续使用 `idle_travel_next_scene`,并在 `runtimePayload.targetSceneId` 中携带目标场景,后续点击沿用现有地图跳转结算。
|
||||
6. 若没有场景幕数据,则继续使用当前可用选项作为兜底,不额外生成规则说明文案。
|
||||
|
||||
## 8. 补充规则:敌对聊天逃跑目标展开
|
||||
|
||||
1. 负好感或敌对 NPC 聊天终止后,`npc_fight` 只保留一个,按钮文案固定为“战斗”,原有 NPC 战斗交互与结算链路不变。
|
||||
2. 原单一“逃跑”按钮改为多个 `battle_escape_breakout` 选项:当前场景每个相邻场景生成“逃往{场景名}”,并额外生成“逃回当前场景起点”。
|
||||
3. 逃往相邻场景的选项在 `runtimePayload.targetSceneId` 中写入目标场景 id;逃回起点的选项在 `runtimePayload.escapeReturnToSceneStart` 中写入 `true`,并保留当前场景 id 作为目标。
|
||||
4. 点击任一逃跑类选项时,先复用现有主角向左转身跑出屏幕的逃离动画,再把运行态切到目标场景或当前场景起点,最后从左侧入场并面向右侧。
|
||||
5. 逃跑类选项只负责运行态目标和表现,不重新请求剧情推理,也不把规则说明显示到 UI。
|
||||
|
||||
## 7. 验收
|
||||
|
||||
1. 负好感主 NPC 不再出现固定 `turnLimit: 5`。
|
||||
|
||||
@@ -253,3 +253,40 @@ AI 可以解释世界,但不能私自改世界。
|
||||
这类 AI 冒险 RPG 的开发,最难的不是“把功能做出来”,而是:
|
||||
|
||||
**让 function 边界、世界状态、视觉演绎、移动端面板和大模型文本在同一套规则下稳定协作。**
|
||||
|
||||
## 7. 聊天输入区布局补充经验
|
||||
|
||||
### 7.1 聊天框变大时,要优先增加“消息展示区”而不是只放大输入框
|
||||
|
||||
- 玩家感知里的“聊天框高度”主要来自消息气泡和剧情滚动区,不是输入栏本身。
|
||||
- 如果只把输入框做高,实际会压缩选项区和底部按钮区,移动端反而更挤。
|
||||
- 更稳妥的做法是:
|
||||
先让剧情/聊天滚动区在剩余空间里拿到更高的伸缩优先级,再微调输入条高度和底部留白。
|
||||
- 移动端不要随意给聊天区写死过大的最小高度,否则很容易把选项按钮和自定义输入一起挤出首屏。
|
||||
|
||||
### 7.2 底部输入区要向安全区贴近,但不能直接贴死
|
||||
|
||||
- 自定义输入要更贴近屏幕底部,应该缩小底部控制区的额外 padding,而不是去掉安全区。
|
||||
- `env(safe-area-inset-bottom)` 仍然要保留,否则刘海屏、手势条机型会出现输入框被顶起或遮挡的问题。
|
||||
- 正确方向是:
|
||||
保留安全区补偿,只减少设计层自己额外加上的底部留白。
|
||||
|
||||
### 7.3 底部操作区下沉时,要同步增加和聊天区之间的呼吸感
|
||||
|
||||
- 当“队伍 / 背包 / 换一换 / 退出聊天 / 自定义输入”整体下移后,上下区块更容易挤在一起。
|
||||
- 这时要略微增加聊天区和操作区之间的垂直间距,保证视觉层级仍然清楚。
|
||||
- 目标不是做出更厚的面板,而是让用户一眼分清:
|
||||
上面是正在发生的对话,下面是马上可点的操作。
|
||||
|
||||
## 8. 战斗态底部面板布局经验
|
||||
|
||||
### 8.1 战斗态不应继续保留剧情框占位
|
||||
|
||||
- 战斗画面里玩家关注的是敌我状态与可执行动作,剧情文本框如果继续占据底部高度,会直接挤压操作按钮。
|
||||
- 战斗态应隐藏剧情框组件,只保留操作区;战斗结果叙事可放到结算或下一次非战斗剧情里展示。
|
||||
|
||||
### 8.2 战斗选项数量要由剩余高度决定
|
||||
|
||||
- 不要固定渲染全部战斗选项,否则移动端低高度屏幕会把按钮挤出可点击区域。
|
||||
- 更稳妥的做法是测量底部操作区可用高度,用单个按钮的最小高度和间距计算本帧可显示数量。
|
||||
- 至少保留 1 个操作,避免极端高度下玩家看不到任何战斗入口。
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# RPG 下一场景服务端桥接修复(2026-04-27)
|
||||
|
||||
## 问题现象
|
||||
|
||||
- 在 RPG 运行时点击“继续前往下一场景”后,玩家角色离场动画已经正常播放。
|
||||
- 但下一幕背景没有切过来,场景内 NPC / 敌对实体没有刷新出来。
|
||||
- 任务推进也没有跟着进入下一场景后的状态继续展示。
|
||||
|
||||
## 根因
|
||||
|
||||
- 这条链路当前走的是服务端 runtime action:`idle_travel_next_scene`。
|
||||
- 服务端 compat 返回的快照只表达“当前遭遇已经结束”,没有像前端本地旅行链路那样自动补齐:
|
||||
- `currentScenePreset` 切换后的真实目标场景
|
||||
- 新场景的 `currentEncounter / sceneHostileNpcs`
|
||||
- 新场景到达后触发的 quest / scene chapter 进度
|
||||
- 前端此前对服务端返回快照只做了 battle hydration,没有对“场景旅行”做二次桥接。
|
||||
|
||||
## 修复口径
|
||||
|
||||
- 在 [src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts](/C:/Genarrative/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts) 中新增服务端场景旅行桥接:
|
||||
- 当 functionId 为 `idle_travel_next_scene` 时,优先读取服务端返回的新场景 id。
|
||||
- 如果服务端仍未给出新场景 id,则回退到当前场景的 `forwardSceneId`。
|
||||
- 统一复用 `buildMapTravelResolution`,把前端现有的场景切换真相态补齐到服务端快照上。
|
||||
- 这样可以继续复用现有的:
|
||||
- 场景背景切换
|
||||
- NPC / 敌对实体预览与入场
|
||||
- quest 与 scene chapter 的场景到达推进
|
||||
|
||||
## 后续约束
|
||||
|
||||
- 后续所有服务端 runtime 的“旅行 / 切场景 / 回营地”动作,如果返回的是最小快照,前端必须明确判断是否需要桥接到现有真相态工具。
|
||||
- 不要只把“动画播放结束”当成场景切换完成;真正完成的标准必须是:
|
||||
- 新 `currentScenePreset` 已落地
|
||||
- 新场景 encounter / hostile NPC 已进入可渲染状态
|
||||
- 场景到达后的 quest / chapter 推进结果可见
|
||||
@@ -546,9 +546,9 @@ function resolvePuzzleGridSize(clearedLevelCount: number): 3 | 4 {
|
||||
|
||||
1. 初始局面不是已完成态
|
||||
2. 初始局面至少存在可推进空间
|
||||
3. 初始局面不能存在任何已经正确相邻的两块,避免玩家开局即看到自动合并块
|
||||
3. 初始局面不能存在任何在原图中相邻的两块互相贴边,避免玩家开局即看到接近完成的局部结构
|
||||
|
||||
初始化算法必须对候选打乱结果做正确相邻关系扫描:若任意两块在当前棋盘四向相邻,且它们在原图中的正确坐标也以相同方向相邻,则该候选布局无效,需要继续洗牌。多次随机尝试仍未得到合法布局时,使用确定性反序布局兜底;该布局等价于完整棋盘旋转 180 度,可保证原图相邻块不会以正确方向相邻。
|
||||
初始化算法必须对候选打乱结果做原图相邻关系扫描:若任意两块在当前棋盘四向相邻,且它们在原图中的正确坐标也四向相邻,则该候选布局无效,需要继续洗牌。多次随机尝试仍未得到合法布局时,使用确定性约束搜索兜底,逐格放置拼块并排除所有原图相邻块贴边的候选。
|
||||
|
||||
## 9.5 交互规则总览
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# 战斗直结算与表现修复记录(2026-04-27)
|
||||
|
||||
更新时间:`2026-04-27`
|
||||
|
||||
## 1. 问题背景
|
||||
|
||||
本次修复针对 RPG 运行态战斗里两类直接可感知的问题:
|
||||
|
||||
1. 战斗面板中的“普通攻击”“技能释放”等选项点击后,体验上像是又走了一次剧情/模型推演,而不是立刻播动作并结算伤害。
|
||||
2. 伤害飘字与敌方头顶血条在战斗里没有稳定可见,尤其是最后一击和脱战前后的几帧容易直接丢失。
|
||||
|
||||
## 2. 本次约束
|
||||
|
||||
为避免后续回归,本次明确补充以下工程约束:
|
||||
|
||||
1. 只要已经处于 `inBattle = true` 的战斗面板阶段,`battle_*` 与战斗内 `inventory_use` 都必须走前端现有的本地确定性战斗播放链。
|
||||
2. 这类动作只负责“播放动作 -> 扣血/扣蓝/冷却 -> 刷新下一轮 options / 脱战收尾”,不能再回到服务端剧情推演分流。
|
||||
3. 脱战前最后一拍的画布表现必须保留,不能因为 `inBattle` 提前变成 `false` 就把伤害飘字、敌方血条和受击反馈一起关掉。
|
||||
|
||||
## 3. 落地改动
|
||||
|
||||
### 3.1 战斗动作分流
|
||||
|
||||
文件:`src/hooks/rpg-runtime-story/choiceActions.ts`
|
||||
|
||||
调整:
|
||||
|
||||
1. 新增“战斗直结算动作”判定。
|
||||
2. 当运行态已经处于战斗中时,`battle_*` 和 `inventory_use` 不再命中 `runServerRuntimeChoiceAction()`。
|
||||
3. 这些动作统一回到 `runLocalStoryChoiceContinuation()`,沿用现有 `buildBattlePlan + playback` 本地确定性链路。
|
||||
|
||||
结果:
|
||||
|
||||
1. 普通攻击、技能释放、调息、逃跑、战斗内用物品都会直接播动作并结算。
|
||||
2. UI 不再把这类按钮误导成“剧情推演中”的体验。
|
||||
|
||||
### 3.2 战斗画布表现保留
|
||||
|
||||
文件:`src/components/game-canvas/GameCanvasEntityLayer.tsx`
|
||||
|
||||
调整:
|
||||
|
||||
1. 新增 `hasCombatAfterimage` 判定,用于识别“虽然 `inBattle` 已经开始收尾,但敌方仍处于受击/死亡帧或刚产生过伤害反馈”的阶段。
|
||||
2. 伤害反馈样本采集、飘字事件保留、敌我血条展示统一改为依赖 `shouldRenderCombatPresentation`,而不是只看 `inBattle`。
|
||||
|
||||
结果:
|
||||
|
||||
1. 伤害数值飘字不会在最后一击前被提前清空。
|
||||
2. 敌方头顶血条能跟着受击与死亡过渡完整显示出来。
|
||||
|
||||
### 3.3 面板文案修正
|
||||
|
||||
文件:`src/components/rpg-runtime-panels/RpgAdventurePanel.tsx`
|
||||
|
||||
调整:
|
||||
|
||||
1. 运行态仍需锁输入的短暂阶段,如果当前处于战斗中,加载提示改为“战斗结算中...”。
|
||||
2. 非战斗态保留“剧情推演中...”。
|
||||
|
||||
结果:
|
||||
|
||||
1. 战斗按钮点击后的反馈语义与真实执行链路一致。
|
||||
2. 用户不会再误判为“点击普通攻击/技能后又触发了一次模型推理”。
|
||||
|
||||
## 4. 回归关注点
|
||||
|
||||
后续若继续改 `server-rs` / Runtime Story 的战斗 option 下发,需要继续遵守:
|
||||
|
||||
1. 服务端可以负责下发合法战斗选项和最终快照结构。
|
||||
2. 但战斗内直接动作的逐帧播放与即时结算,默认仍以前端确定性链路为准。
|
||||
3. 若未来要把这条链路整体迁回 `server-rs`,必须先补齐“无推理、可逐帧播放、可保留最终受击帧”的等价表现方案,再替换当前实现。
|
||||
|
||||
## 5. 追加修复(2026-04-27 晚)
|
||||
|
||||
本日后续排查又发现一处更直接的战斗跳过根因:
|
||||
|
||||
1. `src/hooks/combat/battlePlan.ts` 旧实现会在一次点击里继续跑完整段 `turnOrder`,而不是只结算“玩家一次声明动作 + 最多一次敌方反击”。
|
||||
2. `battle_attack_basic` 还会复用技能挑选逻辑,导致“普通攻击”可能被本地随机映射成别的技能动作。
|
||||
|
||||
本轮已追加修复:
|
||||
|
||||
1. 本地战斗计划严格收束为单回合结算,不再一次点击连跑多轮。
|
||||
2. `battle_attack_basic` 固定走基础攻击,不再随机选技能。
|
||||
3. 战斗内 `inventory_use` 在本地计划中按单次动作消费物品、应用恢复/冷却收益,不再落回攻击型分支。
|
||||
|
||||
追加验收口径:
|
||||
|
||||
1. 战斗中点击一次普通攻击 / 技能 / 物品 / 调息,不会直接把整场战斗过程跳过。
|
||||
2. 若敌人未被这一击打死,当前次结算结束后仍停留在战斗态,并刷新下一轮战斗选项。
|
||||
3. “普通攻击”不会显示成其他技能的动作与耗蓝结果。
|
||||
|
||||
## 6. 再追加修复(2026-04-27 夜间二次排查)
|
||||
|
||||
用户复测后又暴露出一类更隐蔽的问题:
|
||||
|
||||
1. 面板展示层可能仍在显示战斗选项,但逻辑态 `gameState.inBattle` 已短暂回落为 `false`。
|
||||
2. 旧实现里,`src/hooks/rpg-runtime-story/choiceActions.ts` 只要看到 `inBattle === false`,就会把 `battle_*` / `inventory_use` 重新分流回服务端 runtime。
|
||||
3. 这会让用户在“视觉上仍处于战斗”的窗口点击选项时,直接命中服务端快照结果,体感仍然是“点一下就把战斗过程跳过”。
|
||||
|
||||
本轮追加修正:
|
||||
|
||||
1. 战斗直结算判定不再只依赖 `gameState.inBattle`。
|
||||
2. 只要当前选项本身属于 `battle_*` / `inventory_use`,并且任一战斗上下文仍然存在
|
||||
`currentBattleNpcId`、`currentNpcBattleMode`、存活敌人、当前故事仍在显示战斗选项
|
||||
就必须继续走本地逐帧战斗链。
|
||||
3. 为此补充了回归测试,覆盖“`inBattle` 已短暂变成 `false`,但战斗展示仍在”的残留窗口。
|
||||
|
||||
新增验收口径:
|
||||
|
||||
1. 即使 `inBattle` 出现一帧级别的提前回落,只要玩家看到的仍是战斗选项,点击后也不会跳回服务端直结算。
|
||||
2. 战斗面板残留显示期间点击 `battle_*` / `inventory_use`,仍应播放本地战斗过程,而不是直接跳到结果快照。
|
||||
@@ -397,8 +397,8 @@ finalScore = tagSimilarityScore * 0.7 + sameAuthorScore * 0.3;
|
||||
1. `build_initial_board_with_seed` 使用种子化洗牌生成初始棋盘,不再固定左移一格。
|
||||
2. 正式 run 的种子由 `runId + profileId + levelIndex + gridSize` 派生;由于每次进入都会创建新的 `runId`,同一作品多次进入也会得到不同打乱样式。
|
||||
3. 洗牌后若极端情况下仍为完成态,强制旋转一次,保证新关卡不是已完成局面。
|
||||
4. 初始棋盘不得存在任何已经正确相邻的两块;初始化会多次洗牌筛选,若极端情况下未命中,则使用反序排列兜底,避免开局自动出现合并块。
|
||||
5. `module-puzzle` 与本地 fallback 的测试都必须直接断言初始棋盘不存在正确相邻对,不能只检查 `mergedGroups = []`。
|
||||
4. 初始棋盘不得存在任何原图相邻块互相贴边;初始化会多次洗牌筛选,若极端情况下未命中,则使用确定性约束搜索兜底,避免开局出现局部连续结构。
|
||||
5. `module-puzzle` 与本地 fallback 的测试都必须直接断言初始棋盘不存在原图相邻贴边对,不能只检查 `mergedGroups = []`。
|
||||
|
||||
### 11.2 局部重算与合并
|
||||
|
||||
@@ -442,3 +442,15 @@ finalScore = tagSimilarityScore * 0.7 + sameAuthorScore * 0.3;
|
||||
1. `dragState` 持续记录 `currentX/currentY`,拖动中按指针偏移对单块或合并块做 `translate3d` 跟手反馈。
|
||||
2. 棋盘与合并块交互层增加 `touch-none select-none`,避免移动端滚动、选中文本等默认行为打断拖动。
|
||||
3. 松手后仍只提交 `pieceId + targetRow + targetCol`,最终交换、合并、拆分和通关继续以后端/本地运行态快照为准。
|
||||
|
||||
### 12.3 合并块外轮廓描边修正
|
||||
|
||||
用户反馈“合并的块的边界显示要描边自己的块的边界,不要搞一个正方形或者矩形的边界”后,移除合并块外接矩形 `ring` 层。运行态现在按合并组真实占据格逐格判断四向邻居:某一边没有同组合并格时才画该边描边,同组内部相邻边不画线。这样 L 形、长条或其他非矩形合并块只显示自身外轮廓,拖动热区仍只覆盖真实拼块格。
|
||||
|
||||
后续反馈要求合并块边界也要圆角后,外轮廓描边补充按四个角判断:只有相邻两条外露边同时存在的真实外轮廓角才应用圆角,内部拼接角保持直角且不显示分界线。
|
||||
|
||||
### 12.4 第二关后打乱规则旁路修正
|
||||
|
||||
用户反馈“从第二关开始打乱规则像是完全相同”后,检查发现 `api-server` 的本地下一关 fallback 仍使用旧版 `build_local_puzzle_board` 固定左移一格,没有复用 `module-puzzle` 的种子化初始化规则。该路径会在图库/正式推荐不可用、由 API 临时构造下一关时触发。
|
||||
|
||||
修正后 `api-server` 本地下一关构造改为调用 `module_puzzle::build_initial_board_with_seed`,种子由 `runId + profileId + levelIndex + gridSize` 派生;因此第二关、第三关以及后续 fallback 关卡也会得到不同布局,并继续满足“开局没有原图相邻块贴边”的约束。
|
||||
|
||||
105
docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md
Normal file
105
docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 拼图运行时真实排行榜落地说明
|
||||
|
||||
更新时间:`2026-04-27`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前拼图关卡结束弹窗里的排行榜数据并不是真实用户成绩。
|
||||
|
||||
问题根因有两层:
|
||||
|
||||
1. 前端本地运行态 `src/services/puzzle-runtime/puzzleLocalRuntime.ts` 在通关后会直接拼出几条演示昵称数据。
|
||||
2. `server-rs` 拼图运行时虽然已经预留了 `leaderboardEntries` 字段,但 `module-puzzle`、`spacetime-client`、`api-server` 还没有真实成绩表与聚合过程,因此接口层长期返回空数组。
|
||||
|
||||
这导致用户在结算弹窗里看到的是“看起来像真实排行榜,但实际上是本地假数据”的结果,和平台“真实用户数据”要求冲突。
|
||||
|
||||
## 2. 本次目标
|
||||
|
||||
本次改动只解决一个明确问题:
|
||||
|
||||
1. 拼图关卡结束后的排行榜必须使用真实用户成绩。
|
||||
2. 删除现有前端演示昵称、演示耗时等假数据。
|
||||
3. 不在 UI 中默认塞入任何说明型占位文案。
|
||||
|
||||
## 3. 本次落地边界
|
||||
|
||||
为了控制改动范围,本次不把整套拼图运行态全部迁回后端,而是在当前“本地棋盘运行态”基础上补一条真实成绩回写链路:
|
||||
|
||||
1. 拼图拖拽、交换、合并、拆分、通关判定,仍然沿用当前本地运行态。
|
||||
2. 玩家一旦通关,前端立即把当前关卡成绩提交到 `server-rs`。
|
||||
3. `server-rs` 将成绩写入 `SpacetimeDB` 成绩表,并返回该关卡的真实排行榜。
|
||||
4. 结算弹窗只展示后端返回的真实成绩榜单,不再混入本地演示数据。
|
||||
|
||||
这意味着:
|
||||
|
||||
1. 这次不是“完整后端裁决化”。
|
||||
2. 这次是“先把排行榜真相源收回后端”,满足真实成绩展示要求。
|
||||
|
||||
## 4. 成绩真相源设计
|
||||
|
||||
新增拼图成绩表,按“关卡作品 + 网格规格 + 用户”维护最佳成绩。
|
||||
|
||||
建议字段:
|
||||
|
||||
1. `entry_id`
|
||||
唯一主键。
|
||||
2. `profile_id`
|
||||
当前关卡作品 `profile_id`。
|
||||
3. `grid_size`
|
||||
当前成绩对应的拼图网格规格,至少区分 `3x3` 与 `4x4`。
|
||||
4. `user_id`
|
||||
成绩所属真实用户 ID。
|
||||
5. `nickname`
|
||||
成绩展示昵称。当前优先使用提交时的用户显示名快照。
|
||||
6. `best_elapsed_ms`
|
||||
用户在该关卡该规格下的最佳通关耗时。
|
||||
7. `last_run_id`
|
||||
最近一次刷新该最佳成绩的运行态 `run_id`。
|
||||
8. `updated_at`
|
||||
最后一次刷新时间。
|
||||
|
||||
## 5. 排行榜口径
|
||||
|
||||
排行榜必须遵守下面规则:
|
||||
|
||||
1. 只读真实成绩表。
|
||||
2. 同一用户在同一 `profile_id + grid_size` 下只保留 1 条最佳成绩。
|
||||
3. 排序按 `best_elapsed_ms` 从小到大。
|
||||
4. 同耗时按 `updated_at` 更早者优先,再按 `user_id` 稳定排序。
|
||||
5. 返回前 `N` 条,当前阶段固定 `10` 条即可。
|
||||
6. 当前用户如果在榜单内,需要标记 `isCurrentPlayer = true`。
|
||||
|
||||
## 6. 接口落地
|
||||
|
||||
新增拼图排行榜提交接口:
|
||||
|
||||
`POST /api/runtime/puzzle/runs/:runId/leaderboard`
|
||||
|
||||
请求体至少包含:
|
||||
|
||||
1. `profileId`
|
||||
2. `gridSize`
|
||||
3. `elapsedMs`
|
||||
4. `nickname`
|
||||
|
||||
返回体采用现有 `PuzzleRunResponse`,但要求:
|
||||
|
||||
1. `run.currentLevel.leaderboardEntries` 返回真实榜单。
|
||||
2. `run.leaderboardEntries` 同步返回当前关卡真实榜单,方便现有结算弹窗兼容读取。
|
||||
|
||||
## 7. 前端改动规则
|
||||
|
||||
1. 删除 `puzzleLocalRuntime.ts` 中本地演示榜单构造逻辑。
|
||||
2. 本地通关后,运行态只保留真实通关耗时,不再生成假昵称榜单。
|
||||
3. 结算弹窗显示时,如果真实榜单尚未回写完成,可以显示加载态;但不能回退到假数据。
|
||||
4. 下一关开始后,当前关卡榜单状态清空。
|
||||
|
||||
## 8. 测试要求
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. 通关后不会再生成本地假榜单。
|
||||
2. 同一用户重复通关同一关卡时,只保留更优成绩。
|
||||
3. 不同用户成绩会按耗时正确排序。
|
||||
4. `3x3` 与 `4x4` 不混榜。
|
||||
5. 下一关开启后上一关榜单不会污染新关卡。
|
||||
@@ -0,0 +1,97 @@
|
||||
# RPG 战斗回合顺序与同伴表现修复
|
||||
|
||||
更新时间:`2026-04-27`
|
||||
|
||||
## 1. 问题归因
|
||||
|
||||
本轮复测暴露出的战斗问题,不再是单一的“按钮分流错误”,而是同一条战斗链上有三处模型不一致:
|
||||
|
||||
1. `npc_fight / npc_spar` 的开战入口已经把运行态切进战斗,但后续本地回合计划仍停留在“玩家一下 + 敌人一下”的简化模型。
|
||||
2. 本地 `battlePlan` 虽然已经具备 `companion` 播放 step 类型,但实际上从未生成同伴出手步骤。
|
||||
3. 战斗入场后,同伴仍沿用和平探索态附近的锚点与层级,容易被主角遮住,体感上像“后面的角色消失了”。
|
||||
|
||||
因此本次修复必须同时覆盖开战后的回合规则与画布站位规则,而不是继续只补某个 `if` 分支。
|
||||
|
||||
## 2. 统一后的目标行为
|
||||
|
||||
### 2.1 开战入口
|
||||
|
||||
1. `npc_fight / npc_spar` 仍允许先走服务端 runtime action,把 `currentBattleNpcId / currentNpcBattleMode / inBattle` 等真实战斗态切好。
|
||||
2. 一旦战斗选项已经展示出来,后续 `battle_*` 与战斗内 `inventory_use` 必须继续走前端本地确定性回合链。
|
||||
3. 也就是说:
|
||||
- 服务端负责“进入什么战斗、当前战斗对象是谁”。
|
||||
- 前端本地链负责“这一轮谁先动、谁受伤、怎么播放、什么时候脱战”。
|
||||
|
||||
### 2.2 一次点击的结算边界
|
||||
|
||||
1. 每次点击战斗按钮,只结算当前这一整轮。
|
||||
2. 一整轮的定义是:当前仍存活、且本轮应出手的单位,各自按速度最多行动一次。
|
||||
3. 这一轮里如果有人先被击败,后续轮到他时直接跳过,不补行动。
|
||||
4. 一旦满足战斗结束条件,立刻停止后续轮内动作,不再补跑剩余单位。
|
||||
|
||||
### 2.3 正式战斗与切磋的区别
|
||||
|
||||
1. `fight`:
|
||||
- 玩家、存活同伴、敌方单位一起参与。
|
||||
- 胜利条件是敌方全部倒下。
|
||||
- 失败条件是主角生命降到 `0`。
|
||||
2. `spar`:
|
||||
- 只保留主角与当前切磋对象对打。
|
||||
- 同伴继续显示在场上,但不参与出手和承伤。
|
||||
- 切磋终止仍沿用现有“任一方被压到保底线即结束”的规则。
|
||||
|
||||
## 3. 回合顺序规则
|
||||
|
||||
### 3.1 速度来源
|
||||
|
||||
1. 玩家与同伴使用角色战斗属性解析后的 `turnSpeed`。
|
||||
2. 敌方单位使用当前 `SceneHostileNpc.speed`。
|
||||
3. 同速时使用稳定顺序打破平手:
|
||||
- 主角优先于同伴。
|
||||
- 同伴按队伍槽位顺序。
|
||||
- 敌方按当前战场数组顺序。
|
||||
|
||||
### 3.2 本轮排序
|
||||
|
||||
1. 开始结算当前点击时,先基于本轮起始快照收集所有可行动单位。
|
||||
2. 按速度从高到低生成本轮 `turnOrder`。
|
||||
3. 之后按这个顺序依次推进:
|
||||
- 若该单位轮到时已经死亡,则跳过。
|
||||
- 若战斗已结束,则直接停止本轮。
|
||||
|
||||
## 4. 玩家动作规则
|
||||
|
||||
1. `battle_attack_basic`:
|
||||
- 永远只执行基础攻击,不再随机映射到其他技能。
|
||||
2. `battle_use_skill`:
|
||||
- 使用 `runtimePayload.skillId` 指定的技能。
|
||||
3. `battle_recover_breath`:
|
||||
- 作为“消耗当前回合的一次非攻击动作”处理。
|
||||
- 恢复收益应发生在主角真正轮到自己的那个时点,而不是点击瞬间提前生效。
|
||||
4. `inventory_use`:
|
||||
- 同样作为“消耗当前回合的一次非攻击动作”处理。
|
||||
- 物品消耗、回血回蓝、冷却推进、build buff 增加,都发生在主角轮到时。
|
||||
|
||||
## 5. 同伴出手规则
|
||||
|
||||
1. 正式战斗中,所有存活同伴都应参与本轮排序。
|
||||
2. 每名同伴每轮最多出手一次。
|
||||
3. 同伴默认使用自己的可用技能;若当前没有可释放技能,则回退到基础攻击。
|
||||
4. 同伴的蓝量、技能冷却和受击血量都持续写回 `gameState.companions`。
|
||||
|
||||
## 6. 战斗画布中的同伴表现规则
|
||||
|
||||
1. 战斗态下,同伴不能继续和主角共用同一横向锚点。
|
||||
2. 战斗队形改为“主角前、同伴后排左侧展开”:
|
||||
- 仍保留上下两个槽位。
|
||||
- 横向偏移必须明显大于和平态,避免被主角主体遮挡。
|
||||
3. 同伴层级仍按场景脚底锚点参与排序,但战斗态需要给同伴少量前移权重,避免完全压在主角后面。
|
||||
4. 切磋模式同样保留同伴站位显示,只是不参与回合。
|
||||
|
||||
## 7. 验收口径
|
||||
|
||||
1. 点击一次战斗按钮后,不会再直接跳完整场,而是只播放当前一整轮。
|
||||
2. 若敌方速度高于主角,敌方可以在这一轮里先行动。
|
||||
3. 正式战斗里,同伴会参与轮番出手,并按速度插入行动顺序。
|
||||
4. 切磋里同伴不会出手,但也不会在开战后“消失”。
|
||||
5. 战斗入场后,主角后方同伴仍稳定可见,不会因为站位重叠被完全遮住。
|
||||
@@ -150,3 +150,86 @@ npm run typecheck -- --pretty false
|
||||
```
|
||||
|
||||
以上局部测试、局部 ESLint 与全量类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`。
|
||||
|
||||
## 2026-04-27 第四轮复查修正
|
||||
|
||||
用户复测后仍出现“进入作品测试没有显示幕配置角色”。本轮继续沿真实结果页入口复查,确认前几轮的第一幕解析已经覆盖 `oppositeNpcId -> primaryNpcId -> encounterNpcIds`,但作品测试入口仍可能在进入选角前拿到旧 profile。
|
||||
|
||||
定位结论:
|
||||
|
||||
1. 结果页展示和自动保存期望消费 `session.resultPreview.preview`,或者在缺少 resultPreview 时消费 `draftProfile.legacyResultProfile`。
|
||||
2. `rpgCreationPreviewAdapter.buildPreviewFromSession()` 原先优先规范化 `session.draftProfile`,会把基础草稿骨架当成运行态 profile。
|
||||
3. 当基础草稿骨架与结果页预览中的 `sceneChapterBlueprints`、角色、第一幕对面角色不一致时,作品测试即使后续严格读取第一幕,也会读取到旧世界数据。
|
||||
|
||||
本轮修正:
|
||||
|
||||
1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts`
|
||||
- 结果页 profile 解析顺序调整为:`session.resultPreview.preview -> draftProfile.legacyResultProfile -> draftProfile`。
|
||||
- 作品测试、结果页展示和自动保存使用同一份当前结果页 profile,避免选角后加载旧草稿骨架。
|
||||
2. `src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts`
|
||||
- 覆盖 `resultPreview.preview` 优先于 `draftProfile`。
|
||||
- 覆盖缺少 resultPreview 时回退到 `draftProfile.legacyResultProfile`。
|
||||
3. `src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx`
|
||||
- 将作品测试入口断言改为使用“结果页当前 profile”,保证入口语义与 UI 展示一致。
|
||||
|
||||
本轮语义补齐为:结果页点“作品测试”后,先用当前结果页 profile 进入角色选择;选完角色后再直接加载该 profile 的开局场景第一幕,并把第一幕 `oppositeNpcId` 作为对面 NPC 启动聊天。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
npm test -- --run src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/data/customWorldLibrary.test.ts
|
||||
npx eslint src/services/rpg-creation/rpgCreationPreviewAdapter.ts src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.ts src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/data/customWorldLibrary.ts src/data/customWorldLibrary.test.ts src/data/scenePresets.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-session/useRpgSessionBootstrap.ts src/services/customWorldRoleReferences.ts src/services/big-fish-gallery/bigFishGalleryClient.ts
|
||||
npm run typecheck -- --pretty false
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
以上相关测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本轮修改中触碰,因此未执行 `npm run api-server:maincloud`。
|
||||
|
||||
## 2026-04-27 第五轮误导链路闭口
|
||||
|
||||
第四轮修复后,继续清理会误导后续迭代的旧入口:
|
||||
|
||||
1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts`
|
||||
- 删除普通 `draftProfile -> CustomWorldProfile` 兜底。
|
||||
- Agent 结果页 profile 只允许来自 `session.resultPreview.preview`,或缺少 resultPreview 时来自明确的 `draftProfile.legacyResultProfile` 兼容快照。
|
||||
- 基础 `draftProfile` 不再能被静默当作运行态 profile,避免作品测试再次读到旧草稿骨架。
|
||||
2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
|
||||
- 自定义世界选角后的开局状态已经显式构造第一幕 encounter 时,直接返回开局状态。
|
||||
- 不再把自定义世界开局交给 `ensureSceneEncounterPreview()` 二次推断,避免旧的友好 NPC / 场景预览链路覆盖第一幕 `oppositeNpcId`。
|
||||
3. `src/components/rpg-entry/useRpgCreationEnterWorld.ts` 与 `src/components/rpg-entry/useRpgCreationResultAutosave.ts`
|
||||
- 移除“只读 session.draftProfile / draftProfile 是真相源”这类已经误导本次排查的注释。
|
||||
- 明确作品测试读取当前结果页 profile,不静默回退到基础 draftProfile。
|
||||
|
||||
闭口后的主链路:结果页 profile -> 作品测试选角 -> 第一章第一幕 -> `oppositeNpcId` encounter。普通场景预览只作为非自定义世界或非开局场景的兜底,不再参与作品测试开局第一幕的角色裁决。
|
||||
|
||||
补充闭口:
|
||||
|
||||
1. Agent 结果页作品测试与发布入口要求存在当前结果页 profile。
|
||||
2. 若当前结果页 profile 缺失,入口直接停止,不再使用 `generatedCustomWorldProfile` 旧内存态兜底。
|
||||
|
||||
## 2026-04-27 第六轮入口引用与测试态收口
|
||||
|
||||
用户复测后再次出现“进入后没有正确显示幕配置角色,且没有进入聊天状态”。本轮继续把问题压回作品测试真实入口,确认前几轮在标准 `oppositeNpcId` 写法下可以正确进入聊天,但真实生成数据可能把第一幕角色引用写成运行时 NPC 形态,例如 `character-npc-角色id`,或混用角色 id、名称、标题、角色职责文本。旧引用解析只覆盖了部分标准形态,导致第一幕 encounter 解析失败后,运行态会退回普通开局剧情或其他场景角色,自然也不会进入该幕 NPC 聊天。
|
||||
|
||||
本轮修正:
|
||||
|
||||
1. `src/services/customWorldRoleReferences.ts`
|
||||
- 角色引用归一化新增 `character-npc-*`、`npc-*`、`story-*`、`playable-*` 等运行时/草稿前缀剥离。
|
||||
- 角色别名新增“职责+姓名”“姓名+职责”等组合,兼容生成器把 `role` 文本写入幕槽位的情况。
|
||||
2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
|
||||
- 第一幕候选 NPC 解析在进入优先队列前先通过当前 profile 统一归一化。
|
||||
- 跳过当前玩家角色时,不再只比较 `character.id`,而是同时用角色引用解析比较 id/name,避免玩家本人被 `oppositeNpcId` 的别名误判为对面 NPC。
|
||||
- 自定义世界作品测试进入选择世界与选角后的运行态明确标记 `runtimeMode: 'test'`、`runtimePersistenceDisabled: true`,避免作品测试被普通游玩存档/自动保存链路污染。
|
||||
3. `src/hooks/useGameFlow.customWorld.test.tsx`
|
||||
- 增加复现测试:当第一幕 `oppositeNpcId` 写成 `character-npc-story-act-only` 时,选角后仍必须命中陆衡,并由陆衡主动进入聊天。
|
||||
- 增加作品测试态断言,确保测试入口不参与普通持久化。
|
||||
|
||||
补充验证命令:
|
||||
|
||||
```bash
|
||||
npm test -- --run src/hooks/useGameFlow.customWorld.test.tsx src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx
|
||||
npx eslint src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow.customWorld.test.tsx src/services/customWorldRoleReferences.ts
|
||||
npm run typecheck -- --pretty false
|
||||
```
|
||||
|
||||
以上测试、ESLint 与类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`。
|
||||
|
||||
28
docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md
Normal file
28
docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# RPG 作品测试结束按钮补齐(2026-04-27)
|
||||
|
||||
## 背景
|
||||
|
||||
世界创作结果页已经提供“作品测试”入口,但测试运行时此前缺少与“幕预览”一致的显式退出按钮。创作者进入测试后只能依赖浏览器返回、刷新或其他间接链路离开,不符合独立运行时面板的交互语义。
|
||||
|
||||
## 本次约束
|
||||
|
||||
1. “作品测试”进入的运行时必须显式标记为 `runtimeMode: "test"`。
|
||||
2. 测试态退出入口使用固定浮层按钮,文案为“结束测试”。
|
||||
3. “结束测试”不做保存,不写正式游玩存档。
|
||||
4. 从结果页进入作品测试后,结束测试必须返回当前结果页,而不是平台首页。
|
||||
5. 正式“进入世界 / 发布并进入世界”保持原有行为,不受本次退出按钮影响。
|
||||
|
||||
## 落地实现
|
||||
|
||||
1. `App.tsx` 为自定义世界运行时启动增加轻量 launch options,记录本次进入是 `play` 还是 `test`,以及测试结束后的返回 stage。
|
||||
2. `useRpgCreationEnterWorld.ts` 将结果页“作品测试”入口显式标记为 `mode: "test"`,并写入 `returnStage: "custom-world-result"`。
|
||||
3. `useRpgSessionBootstrap.ts` 支持自定义世界按启动模式写入 `runtimeMode` 与 `runtimePersistenceDisabled`:
|
||||
- `test`:禁存;
|
||||
- `play`:沿正式游玩链路运行。
|
||||
4. `RpgRuntimeShell.tsx` 在 `runtimeMode === "test"` 时叠加固定浮层按钮“结束测试”,点击后直接退出运行时并返回启动前页面。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 结果页点击“作品测试”后可见“结束测试”按钮。
|
||||
2. 点击“结束测试”后返回结果页,且“作品测试 / 发布”按钮仍可继续操作。
|
||||
3. `useRpgSessionPersistence` 继续跳过测试态存档,不新增正式游玩记录。
|
||||
@@ -79,6 +79,13 @@ export interface PuzzleRunResponse {
|
||||
run: PuzzleRunSnapshot;
|
||||
}
|
||||
|
||||
export interface SubmitPuzzleLeaderboardRequest {
|
||||
profileId: string;
|
||||
gridSize: PuzzleGridSize;
|
||||
elapsedMs: number;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export interface SwapPuzzlePiecesRequest {
|
||||
firstPieceId: string;
|
||||
secondPieceId: string;
|
||||
|
||||
@@ -83,6 +83,7 @@ use crate::{
|
||||
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
||||
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
||||
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
|
||||
submit_puzzle_leaderboard,
|
||||
swap_puzzle_pieces,
|
||||
},
|
||||
refresh_session::refresh_session,
|
||||
@@ -673,6 +674,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/runs/{run_id}/leaderboard",
|
||||
post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/custom-world/entity",
|
||||
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -28,11 +28,8 @@ use crate::{
|
||||
};
|
||||
|
||||
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
|
||||
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = [
|
||||
"character_visual",
|
||||
"scene_image",
|
||||
"puzzle_cover_image",
|
||||
];
|
||||
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] =
|
||||
["character_visual", "scene_image", "puzzle_cover_image"];
|
||||
|
||||
pub async fn create_direct_upload_ticket(
|
||||
State(state): State<AppState>,
|
||||
@@ -480,7 +477,9 @@ mod tests {
|
||||
assert!(super::is_supported_asset_history_kind("character_visual"));
|
||||
assert!(super::is_supported_asset_history_kind("scene_image"));
|
||||
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
|
||||
assert!(!super::is_supported_asset_history_kind("puzzle_preview_image"));
|
||||
assert!(!super::is_supported_asset_history_kind(
|
||||
"puzzle_preview_image"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"anchorQuestions": [
|
||||
{
|
||||
"key": "themePromise",
|
||||
"label": "题材承诺",
|
||||
"label": "题材",
|
||||
"question": "这张拼图给玩家的题材和完成期待是什么?",
|
||||
"requiredEffect": "明确拼图主题、辨识度和完成后的满足感。"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use module_puzzle::PuzzleGeneratedImageCandidate;
|
||||
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
@@ -37,9 +37,10 @@ use shared_contracts::{
|
||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||
puzzle_runtime::{
|
||||
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||||
PuzzleCellPositionResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
|
||||
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
|
||||
StartPuzzleRunRequest, SwapPuzzlePiecesRequest,
|
||||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse,
|
||||
PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse,
|
||||
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
|
||||
},
|
||||
puzzle_works::{
|
||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||
@@ -53,7 +54,8 @@ use spacetime_client::{
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
@@ -1104,6 +1106,54 @@ pub async fn advance_local_puzzle_next_level(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn submit_puzzle_leaderboard(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<SubmitPuzzleLeaderboardRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_RUNTIME_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
|
||||
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput {
|
||||
run_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
profile_id: payload.profile_id,
|
||||
grid_size: payload.grid_size,
|
||||
elapsed_ms: payload.elapsed_ms.max(1_000),
|
||||
nickname: payload.nickname.trim().to_string(),
|
||||
submitted_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_RUNTIME_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleRunResponse {
|
||||
run: map_puzzle_run_response(run),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_puzzle_agent_session_response(
|
||||
session: PuzzleAgentSessionRecord,
|
||||
) -> PuzzleAgentSessionSnapshotResponse {
|
||||
@@ -1303,7 +1353,11 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||
previous_level_tags: run.previous_level_tags,
|
||||
current_level: run.current_level.map(map_puzzle_runtime_level_response),
|
||||
recommended_next_profile_id: run.recommended_next_profile_id,
|
||||
leaderboard_entries: Vec::new(),
|
||||
leaderboard_entries: run
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_entry_response)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1318,6 +1372,11 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec
|
||||
previous_level_tags: run.previous_level_tags,
|
||||
current_level: run.current_level.map(map_puzzle_level_request_record),
|
||||
recommended_next_profile_id: run.recommended_next_profile_id,
|
||||
leaderboard_entries: run
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_request_record)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1335,6 +1394,25 @@ fn map_puzzle_level_request_record(
|
||||
cover_image_src: level.cover_image_src,
|
||||
board: map_puzzle_board_request_record(level.board),
|
||||
status: level.status,
|
||||
started_at_ms: level.started_at_ms,
|
||||
cleared_at_ms: level.cleared_at_ms,
|
||||
elapsed_ms: level.elapsed_ms,
|
||||
leaderboard_entries: level
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_request_record)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_leaderboard_request_record(
|
||||
entry: PuzzleLeaderboardEntryResponse,
|
||||
) -> PuzzleLeaderboardEntryRecord {
|
||||
PuzzleLeaderboardEntryRecord {
|
||||
rank: entry.rank,
|
||||
nickname: entry.nickname,
|
||||
elapsed_ms: entry.elapsed_ms,
|
||||
is_current_player: entry.is_current_player,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,10 +1467,25 @@ fn map_puzzle_runtime_level_response(
|
||||
cover_image_src: level.cover_image_src,
|
||||
board: map_puzzle_board_response(level.board),
|
||||
status: level.status,
|
||||
started_at_ms: 0,
|
||||
cleared_at_ms: None,
|
||||
elapsed_ms: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
started_at_ms: level.started_at_ms,
|
||||
cleared_at_ms: level.cleared_at_ms,
|
||||
elapsed_ms: level.elapsed_ms,
|
||||
leaderboard_entries: level
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_entry_response)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_leaderboard_entry_response(
|
||||
entry: PuzzleLeaderboardEntryRecord,
|
||||
) -> PuzzleLeaderboardEntryResponse {
|
||||
PuzzleLeaderboardEntryResponse {
|
||||
rank: entry.rank,
|
||||
nickname: entry.nickname,
|
||||
elapsed_ms: entry.elapsed_ms,
|
||||
is_current_player: entry.is_current_player,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1922,6 +2015,7 @@ fn build_next_run_from_parts(
|
||||
if !played_profile_ids.contains(&profile_id) {
|
||||
played_profile_ids.push(profile_id.clone());
|
||||
}
|
||||
let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index);
|
||||
PuzzleRunRecord {
|
||||
run_id: run.run_id.clone(),
|
||||
entry_profile_id: run.entry_profile_id,
|
||||
@@ -1939,52 +2033,125 @@ fn build_next_run_from_parts(
|
||||
author_display_name,
|
||||
theme_tags,
|
||||
cover_image_src,
|
||||
board: build_local_puzzle_board(grid_size),
|
||||
board,
|
||||
status: "playing".to_string(),
|
||||
started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
|
||||
cleared_at_ms: None,
|
||||
elapsed_ms: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}),
|
||||
recommended_next_profile_id: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
|
||||
let total = grid_size * grid_size;
|
||||
let mut positions = (0..total)
|
||||
.map(|index| PuzzleCellPositionRecord {
|
||||
row: index / grid_size,
|
||||
col: index % grid_size,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !positions.is_empty() {
|
||||
let first = positions.remove(0);
|
||||
positions.push(first);
|
||||
fn build_local_puzzle_board(
|
||||
grid_size: u32,
|
||||
run_id: &str,
|
||||
profile_id: &str,
|
||||
level_index: u32,
|
||||
) -> PuzzleBoardRecord {
|
||||
let board = module_puzzle::build_initial_board_with_seed(
|
||||
grid_size,
|
||||
build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
module_puzzle::build_initial_board_with_seed(3, 1)
|
||||
.expect("fallback puzzle board should use supported grid size")
|
||||
});
|
||||
map_puzzle_board_snapshot_record(board)
|
||||
}
|
||||
|
||||
fn build_local_puzzle_shuffle_seed(
|
||||
run_id: &str,
|
||||
profile_id: &str,
|
||||
level_index: u32,
|
||||
grid_size: u32,
|
||||
) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
|
||||
for byte in run_id
|
||||
.bytes()
|
||||
.chain(profile_id.bytes())
|
||||
.chain(level_index.to_le_bytes())
|
||||
.chain(grid_size.to_le_bytes())
|
||||
{
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
let pieces = (0..total)
|
||||
.map(|index| {
|
||||
let current =
|
||||
positions
|
||||
.get(index as usize)
|
||||
.cloned()
|
||||
.unwrap_or(PuzzleCellPositionRecord {
|
||||
row: index / grid_size,
|
||||
col: index % grid_size,
|
||||
});
|
||||
PuzzlePieceStateRecord {
|
||||
piece_id: format!("piece-{index}"),
|
||||
correct_row: index / grid_size,
|
||||
correct_col: index % grid_size,
|
||||
current_row: current.row,
|
||||
current_col: current.col,
|
||||
merged_group_id: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
hash
|
||||
}
|
||||
|
||||
fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord {
|
||||
PuzzleBoardRecord {
|
||||
rows: grid_size,
|
||||
cols: grid_size,
|
||||
pieces,
|
||||
merged_groups: Vec::new(),
|
||||
selected_piece_id: None,
|
||||
all_tiles_resolved: false,
|
||||
rows: board.rows,
|
||||
cols: board.cols,
|
||||
pieces: board
|
||||
.pieces
|
||||
.into_iter()
|
||||
.map(|piece| PuzzlePieceStateRecord {
|
||||
piece_id: piece.piece_id,
|
||||
correct_row: piece.correct_row,
|
||||
correct_col: piece.correct_col,
|
||||
current_row: piece.current_row,
|
||||
current_col: piece.current_col,
|
||||
merged_group_id: piece.merged_group_id,
|
||||
})
|
||||
.collect(),
|
||||
merged_groups: board
|
||||
.merged_groups
|
||||
.into_iter()
|
||||
.map(|group| PuzzleMergedGroupRecord {
|
||||
group_id: group.group_id,
|
||||
piece_ids: group.piece_ids,
|
||||
occupied_cells: group
|
||||
.occupied_cells
|
||||
.into_iter()
|
||||
.map(|cell| PuzzleCellPositionRecord {
|
||||
row: cell.row,
|
||||
col: cell.col,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
selected_piece_id: board.selected_piece_id,
|
||||
all_tiles_resolved: board.all_tiles_resolved,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn board_positions(board: &PuzzleBoardRecord) -> Vec<(u32, u32)> {
|
||||
board
|
||||
.pieces
|
||||
.iter()
|
||||
.map(|piece| (piece.current_row, piece.current_col))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_original_neighbor_pair(board: &PuzzleBoardRecord) -> bool {
|
||||
board.pieces.iter().any(|piece| {
|
||||
board.pieces.iter().any(|candidate| {
|
||||
piece.piece_id != candidate.piece_id
|
||||
&& piece.current_row.abs_diff(candidate.current_row)
|
||||
+ piece.current_col.abs_diff(candidate.current_col)
|
||||
== 1
|
||||
&& piece.correct_row.abs_diff(candidate.correct_row)
|
||||
+ piece.correct_col.abs_diff(candidate.correct_col)
|
||||
== 1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_next_level_board_shuffle_changes_by_level() {
|
||||
let second = build_local_puzzle_board(3, "run-a", "profile-level-2", 2);
|
||||
let third = build_local_puzzle_board(3, "run-a", "profile-level-3", 3);
|
||||
|
||||
assert_ne!(board_positions(&second), board_positions(&third));
|
||||
assert!(!has_original_neighbor_pair(&second));
|
||||
assert!(!has_original_neighbor_pair(&third));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -239,6 +239,15 @@ pub struct PuzzleMergedGroupState {
|
||||
pub occupied_cells: Vec<PuzzleCellPosition>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleLeaderboardEntry {
|
||||
pub rank: u32,
|
||||
pub nickname: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub is_current_player: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleBoardSnapshot {
|
||||
@@ -263,6 +272,10 @@ pub struct PuzzleRuntimeLevelSnapshot {
|
||||
pub cover_image_src: Option<String>,
|
||||
pub board: PuzzleBoardSnapshot,
|
||||
pub status: PuzzleRuntimeLevelStatus,
|
||||
pub started_at_ms: u64,
|
||||
pub cleared_at_ms: Option<u64>,
|
||||
pub elapsed_ms: Option<u64>,
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -277,6 +290,7 @@ pub struct PuzzleRunSnapshot {
|
||||
pub previous_level_tags: Vec<String>,
|
||||
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
|
||||
pub recommended_next_profile_id: Option<String>,
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -438,6 +452,18 @@ pub struct PuzzleRunNextLevelInput {
|
||||
pub advanced_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleLeaderboardSubmitInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub grid_size: u32,
|
||||
pub elapsed_ms: u64,
|
||||
pub nickname: String,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleAgentSessionProcedureResult {
|
||||
@@ -924,6 +950,7 @@ pub fn start_run_with_shuffle_seed(
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
|
||||
let started_at_ms = current_unix_ms();
|
||||
Ok(PuzzleRunSnapshot {
|
||||
run_id: run_id.clone(),
|
||||
entry_profile_id: entry_profile.profile_id.clone(),
|
||||
@@ -943,8 +970,13 @@ pub fn start_run_with_shuffle_seed(
|
||||
cover_image_src: entry_profile.cover_image_src.clone(),
|
||||
board,
|
||||
status: PuzzleRuntimeLevelStatus::Playing,
|
||||
started_at_ms,
|
||||
cleared_at_ms: None,
|
||||
elapsed_ms: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}),
|
||||
recommended_next_profile_id: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1163,8 +1195,13 @@ pub fn advance_next_level(
|
||||
cover_image_src: next_profile.cover_image_src.clone(),
|
||||
board: next_board,
|
||||
status: PuzzleRuntimeLevelStatus::Playing,
|
||||
started_at_ms: current_unix_ms(),
|
||||
cleared_at_ms: None,
|
||||
elapsed_ms: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}),
|
||||
recommended_next_profile_id: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1389,7 +1426,6 @@ fn build_initial_pieces_without_correct_neighbors(
|
||||
grid_size: u32,
|
||||
shuffle_seed: u64,
|
||||
) -> Vec<PuzzlePieceState> {
|
||||
let total = grid_size * grid_size;
|
||||
let base_positions = build_correct_positions(grid_size);
|
||||
for attempt in 0..PUZZLE_INITIAL_SHUFFLE_ATTEMPTS {
|
||||
let mut positions = base_positions.clone();
|
||||
@@ -1399,16 +1435,15 @@ fn build_initial_pieces_without_correct_neighbors(
|
||||
);
|
||||
ensure_board_is_not_solved(&mut positions, grid_size);
|
||||
let pieces = build_pieces_from_positions(grid_size, &positions);
|
||||
if !has_any_correct_neighbor_pair(&pieces) {
|
||||
if !has_any_original_neighbor_pair(&pieces) {
|
||||
return pieces;
|
||||
}
|
||||
}
|
||||
|
||||
// 反序布局等价于把完整棋盘旋转 180 度;任意原图相邻块在当前棋盘中的方向都会反向,
|
||||
// 因此可作为“开局没有正确相邻块”的确定性兜底。
|
||||
let fallback_pieces =
|
||||
build_pieces_from_positions(grid_size, &build_reverse_positions(total, grid_size));
|
||||
debug_assert!(!has_any_correct_neighbor_pair(&fallback_pieces));
|
||||
// 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。
|
||||
let fallback_pieces = build_original_neighbor_free_pieces(grid_size, shuffle_seed)
|
||||
.unwrap_or_else(|| build_pieces_from_positions(grid_size, &base_positions));
|
||||
debug_assert!(!has_any_original_neighbor_pair(&fallback_pieces));
|
||||
fallback_pieces
|
||||
}
|
||||
|
||||
@@ -1422,16 +1457,6 @@ fn build_correct_positions(grid_size: u32) -> Vec<PuzzleCellPosition> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_reverse_positions(total: u32, grid_size: u32) -> Vec<PuzzleCellPosition> {
|
||||
(0..total)
|
||||
.rev()
|
||||
.map(|index| PuzzleCellPosition {
|
||||
row: index / grid_size,
|
||||
col: index % grid_size,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_pieces_from_positions(
|
||||
grid_size: u32,
|
||||
positions: &[PuzzleCellPosition],
|
||||
@@ -1466,7 +1491,7 @@ fn ensure_board_is_not_solved(positions: &mut [PuzzleCellPosition], grid_size: u
|
||||
}
|
||||
}
|
||||
|
||||
fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
|
||||
fn has_any_original_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
|
||||
let pieces_by_cell = pieces
|
||||
.iter()
|
||||
.map(|piece| ((piece.current_row, piece.current_col), piece))
|
||||
@@ -1476,10 +1501,138 @@ fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
|
||||
neighbor_cells(piece.current_row, piece.current_col)
|
||||
.into_iter()
|
||||
.filter_map(|cell| pieces_by_cell.get(&cell))
|
||||
.any(|neighbor| are_correct_neighbors(piece, neighbor))
|
||||
.any(|neighbor| are_original_neighbors(piece, neighbor))
|
||||
})
|
||||
}
|
||||
|
||||
fn are_original_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> bool {
|
||||
left.correct_row.abs_diff(right.correct_row) + left.correct_col.abs_diff(right.correct_col) == 1
|
||||
}
|
||||
|
||||
fn build_original_neighbor_free_pieces(
|
||||
grid_size: u32,
|
||||
shuffle_seed: u64,
|
||||
) -> Option<Vec<PuzzlePieceState>> {
|
||||
let total = (grid_size * grid_size) as usize;
|
||||
let mut piece_order = (0..total as u32).collect::<Vec<_>>();
|
||||
sort_indices_by_seed(&mut piece_order, shuffle_seed ^ 0xa076_1d64_78bd_642f);
|
||||
let mut cell_order = build_correct_positions(grid_size);
|
||||
sort_cells_by_seed(&mut cell_order, shuffle_seed ^ 0xe703_7ed1_a0b4_28db);
|
||||
|
||||
let mut placements = vec![None; total];
|
||||
let mut used_cells = BTreeSet::new();
|
||||
if place_neighbor_free_piece(
|
||||
grid_size,
|
||||
&piece_order,
|
||||
&cell_order,
|
||||
0,
|
||||
&mut placements,
|
||||
&mut used_cells,
|
||||
) {
|
||||
Some(
|
||||
placements
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, current)| {
|
||||
current.map(|current| PuzzlePieceState {
|
||||
piece_id: format!("piece-{index}"),
|
||||
correct_row: index as u32 / grid_size,
|
||||
correct_col: index as u32 % grid_size,
|
||||
current_row: current.row,
|
||||
current_col: current.col,
|
||||
merged_group_id: None,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn place_neighbor_free_piece(
|
||||
grid_size: u32,
|
||||
piece_order: &[u32],
|
||||
cell_order: &[PuzzleCellPosition],
|
||||
depth: usize,
|
||||
placements: &mut [Option<PuzzleCellPosition>],
|
||||
used_cells: &mut BTreeSet<(u32, u32)>,
|
||||
) -> bool {
|
||||
let Some(piece_index) = piece_order.get(depth).copied() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
for cell in cell_order {
|
||||
if used_cells.contains(&(cell.row, cell.col)) {
|
||||
continue;
|
||||
}
|
||||
if cell.row == piece_index / grid_size && cell.col == piece_index % grid_size {
|
||||
continue;
|
||||
}
|
||||
if violates_original_neighbor_free_rule(grid_size, piece_index, cell.clone(), placements) {
|
||||
continue;
|
||||
}
|
||||
|
||||
placements[piece_index as usize] = Some(cell.clone());
|
||||
used_cells.insert((cell.row, cell.col));
|
||||
if place_neighbor_free_piece(
|
||||
grid_size,
|
||||
piece_order,
|
||||
cell_order,
|
||||
depth + 1,
|
||||
placements,
|
||||
used_cells,
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
used_cells.remove(&(cell.row, cell.col));
|
||||
placements[piece_index as usize] = None;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn violates_original_neighbor_free_rule(
|
||||
grid_size: u32,
|
||||
piece_index: u32,
|
||||
cell: PuzzleCellPosition,
|
||||
placements: &[Option<PuzzleCellPosition>],
|
||||
) -> bool {
|
||||
placements
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(placed_index, placed_cell)| {
|
||||
placed_cell
|
||||
.as_ref()
|
||||
.map(|placed_cell| (placed_index as u32, placed_cell))
|
||||
})
|
||||
.any(|(placed_index, placed_cell)| {
|
||||
let original_neighbors = (piece_index / grid_size).abs_diff(placed_index / grid_size)
|
||||
+ (piece_index % grid_size).abs_diff(placed_index % grid_size)
|
||||
== 1;
|
||||
let current_neighbors =
|
||||
cell.row.abs_diff(placed_cell.row) + cell.col.abs_diff(placed_cell.col) == 1;
|
||||
original_neighbors && current_neighbors
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_indices_by_seed(indices: &mut [u32], seed: u64) {
|
||||
indices.sort_by_key(|index| seeded_order_key(seed, u64::from(*index)));
|
||||
}
|
||||
|
||||
fn sort_cells_by_seed(cells: &mut [PuzzleCellPosition], seed: u64) {
|
||||
cells.sort_by_key(|cell| seeded_order_key(seed, u64::from(cell.row * 16 + cell.col)));
|
||||
}
|
||||
|
||||
fn seeded_order_key(seed: u64, value: u64) -> u64 {
|
||||
let mut state = seed ^ value.wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
state ^= state >> 30;
|
||||
state = state.wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
state ^= state >> 27;
|
||||
state = state.wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
state ^ (state >> 31)
|
||||
}
|
||||
|
||||
fn rebuild_board_snapshot(
|
||||
grid_size: u32,
|
||||
pieces: Vec<PuzzlePieceState>,
|
||||
@@ -1808,15 +1961,32 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) ->
|
||||
|
||||
if let Some(current_level) = next_run.current_level.as_mut() {
|
||||
current_level.board = next_board;
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
|
||||
let cleared_at_ms = current_unix_ms();
|
||||
current_level.cleared_at_ms = Some(cleared_at_ms);
|
||||
current_level.elapsed_ms =
|
||||
Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000));
|
||||
}
|
||||
current_level.status = next_level_status;
|
||||
}
|
||||
|
||||
if is_cleared {
|
||||
if is_cleared && run.current_level.as_ref().map(|level| level.status)
|
||||
!= Some(PuzzleRuntimeLevelStatus::Cleared)
|
||||
{
|
||||
next_run.cleared_level_count += 1;
|
||||
}
|
||||
next_run
|
||||
}
|
||||
|
||||
fn current_unix_ms() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|value| value.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1951,14 +2121,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_board_has_no_correct_neighbor_pairs() {
|
||||
fn initial_board_has_no_original_neighbor_pairs() {
|
||||
for grid_size in [3, 4] {
|
||||
for shuffle_seed in 0..128 {
|
||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board");
|
||||
|
||||
assert!(board.merged_groups.is_empty());
|
||||
assert!(
|
||||
!has_any_correct_neighbor_pair(&board.pieces),
|
||||
!has_any_original_neighbor_pair(&board.pieces),
|
||||
"grid_size={grid_size}, shuffle_seed={shuffle_seed}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@ pub struct DragPuzzlePieceRequest {
|
||||
pub target_col: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubmitPuzzleLeaderboardRequest {
|
||||
pub profile_id: String,
|
||||
pub grid_size: u32,
|
||||
pub elapsed_ms: u64,
|
||||
pub nickname: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PuzzleCellPositionResponse {
|
||||
|
||||
@@ -29,7 +29,8 @@ pub use mapper::{
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||
PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
|
||||
@@ -2252,6 +2252,11 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz
|
||||
.current_level
|
||||
.map(map_puzzle_runtime_level_snapshot),
|
||||
recommended_next_profile_id: snapshot.recommended_next_profile_id,
|
||||
leaderboard_entries: snapshot
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_entry)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2269,6 +2274,25 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
board: map_puzzle_board_snapshot(snapshot.board),
|
||||
status: snapshot.status.as_str().to_string(),
|
||||
started_at_ms: snapshot.started_at_ms,
|
||||
cleared_at_ms: snapshot.cleared_at_ms,
|
||||
elapsed_ms: snapshot.elapsed_ms,
|
||||
leaderboard_entries: snapshot
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_entry)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_puzzle_leaderboard_entry(
|
||||
snapshot: module_puzzle::PuzzleLeaderboardEntry,
|
||||
) -> PuzzleLeaderboardEntryRecord {
|
||||
PuzzleLeaderboardEntryRecord {
|
||||
rank: snapshot.rank,
|
||||
nickname: snapshot.nickname,
|
||||
elapsed_ms: snapshot.elapsed_ms,
|
||||
is_current_player: snapshot.is_current_player,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4299,6 +4323,14 @@ pub struct PuzzleMergedGroupRecord {
|
||||
pub occupied_cells: Vec<PuzzleCellPositionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleLeaderboardEntryRecord {
|
||||
pub rank: u32,
|
||||
pub nickname: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub is_current_player: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleBoardRecord {
|
||||
pub rows: u32,
|
||||
@@ -4321,6 +4353,10 @@ pub struct PuzzleRuntimeLevelRecord {
|
||||
pub cover_image_src: Option<String>,
|
||||
pub board: PuzzleBoardRecord,
|
||||
pub status: String,
|
||||
pub started_at_ms: u64,
|
||||
pub cleared_at_ms: Option<u64>,
|
||||
pub elapsed_ms: Option<u64>,
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -4334,6 +4370,18 @@ pub struct PuzzleRunRecord {
|
||||
pub previous_level_tags: Vec<String>,
|
||||
pub current_level: Option<PuzzleRuntimeLevelRecord>,
|
||||
pub recommended_next_profile_id: Option<String>,
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleLeaderboardSubmitRecordInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub grid_size: u32,
|
||||
pub elapsed_ms: u64,
|
||||
pub nickname: String,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -296,6 +296,8 @@ pub mod puzzle_agent_session_row_type;
|
||||
pub mod puzzle_agent_stage_type;
|
||||
pub mod puzzle_draft_compile_input_type;
|
||||
pub mod puzzle_generated_images_save_input_type;
|
||||
pub mod puzzle_leaderboard_entry_row_type;
|
||||
pub mod puzzle_leaderboard_submit_input_type;
|
||||
pub mod puzzle_publication_status_type;
|
||||
pub mod puzzle_publish_input_type;
|
||||
pub mod puzzle_run_drag_input_type;
|
||||
@@ -443,6 +445,7 @@ pub mod story_session_type;
|
||||
pub mod submit_big_fish_message_procedure;
|
||||
pub mod submit_custom_world_agent_message_procedure;
|
||||
pub mod submit_puzzle_agent_message_procedure;
|
||||
pub mod submit_puzzle_leaderboard_entry_procedure;
|
||||
pub mod swap_puzzle_pieces_procedure;
|
||||
pub mod treasure_interaction_action_type;
|
||||
pub mod treasure_record_procedure_result_type;
|
||||
@@ -758,6 +761,8 @@ pub use puzzle_agent_session_row_type::PuzzleAgentSessionRow;
|
||||
pub use puzzle_agent_stage_type::PuzzleAgentStage;
|
||||
pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput;
|
||||
pub use puzzle_generated_images_save_input_type::PuzzleGeneratedImagesSaveInput;
|
||||
pub use puzzle_leaderboard_entry_row_type::PuzzleLeaderboardEntryRow;
|
||||
pub use puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput;
|
||||
pub use puzzle_publication_status_type::PuzzlePublicationStatus;
|
||||
pub use puzzle_publish_input_type::PuzzlePublishInput;
|
||||
pub use puzzle_run_drag_input_type::PuzzleRunDragInput;
|
||||
@@ -905,6 +910,7 @@ pub use story_session_type::StorySession;
|
||||
pub use submit_big_fish_message_procedure::submit_big_fish_message;
|
||||
pub use submit_custom_world_agent_message_procedure::submit_custom_world_agent_message;
|
||||
pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message;
|
||||
pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry;
|
||||
pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces;
|
||||
pub use treasure_interaction_action_type::TreasureInteractionAction;
|
||||
pub use treasure_record_procedure_result_type::TreasureRecordProcedureResult;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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 PuzzleLeaderboardEntryRow {
|
||||
pub entry_id: String,
|
||||
pub profile_id: String,
|
||||
pub grid_size: u32,
|
||||
pub user_id: String,
|
||||
pub nickname: String,
|
||||
pub best_elapsed_ms: u64,
|
||||
pub last_run_id: String,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleLeaderboardEntryRow {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `PuzzleLeaderboardEntryRow`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct PuzzleLeaderboardEntryRowCols {
|
||||
pub entry_id: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
|
||||
pub profile_id: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
|
||||
pub grid_size: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, u32>,
|
||||
pub user_id: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
|
||||
pub nickname: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
|
||||
pub best_elapsed_ms: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, u64>,
|
||||
pub last_run_id: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
|
||||
pub updated_at: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for PuzzleLeaderboardEntryRow {
|
||||
type Cols = PuzzleLeaderboardEntryRowCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
PuzzleLeaderboardEntryRowCols {
|
||||
entry_id: __sdk::__query_builder::Col::new(table_name, "entry_id"),
|
||||
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
|
||||
grid_size: __sdk::__query_builder::Col::new(table_name, "grid_size"),
|
||||
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
|
||||
nickname: __sdk::__query_builder::Col::new(table_name, "nickname"),
|
||||
best_elapsed_ms: __sdk::__query_builder::Col::new(table_name, "best_elapsed_ms"),
|
||||
last_run_id: __sdk::__query_builder::Col::new(table_name, "last_run_id"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `PuzzleLeaderboardEntryRow`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct PuzzleLeaderboardEntryRowIxCols {
|
||||
pub entry_id: __sdk::__query_builder::IxCol<PuzzleLeaderboardEntryRow, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for PuzzleLeaderboardEntryRow {
|
||||
type IxCols = PuzzleLeaderboardEntryRowIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
PuzzleLeaderboardEntryRowIxCols {
|
||||
entry_id: __sdk::__query_builder::IxCol::new(table_name, "entry_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for PuzzleLeaderboardEntryRow {}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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 PuzzleLeaderboardSubmitInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub grid_size: u32,
|
||||
pub elapsed_ms: u64,
|
||||
pub nickname: String,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleLeaderboardSubmitInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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::puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput;
|
||||
use super::puzzle_run_procedure_result_type::PuzzleRunProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct SubmitPuzzleLeaderboardEntryArgs {
|
||||
pub input: PuzzleLeaderboardSubmitInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for SubmitPuzzleLeaderboardEntryArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `submit_puzzle_leaderboard_entry`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait submit_puzzle_leaderboard_entry {
|
||||
fn submit_puzzle_leaderboard_entry(&self, input: PuzzleLeaderboardSubmitInput) {
|
||||
self.submit_puzzle_leaderboard_entry_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn submit_puzzle_leaderboard_entry_then(
|
||||
&self,
|
||||
input: PuzzleLeaderboardSubmitInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl submit_puzzle_leaderboard_entry for super::RemoteProcedures {
|
||||
fn submit_puzzle_leaderboard_entry_then(
|
||||
&self,
|
||||
input: PuzzleLeaderboardSubmitInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>(
|
||||
"submit_puzzle_leaderboard_entry",
|
||||
SubmitPuzzleLeaderboardEntryArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -462,4 +462,32 @@ impl SpacetimeClient {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn submit_puzzle_leaderboard_entry(
|
||||
&self,
|
||||
input: PuzzleLeaderboardSubmitRecordInput,
|
||||
) -> Result<PuzzleRunRecord, SpacetimeClientError> {
|
||||
let procedure_input = PuzzleLeaderboardSubmitInput {
|
||||
run_id: input.run_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
grid_size: input.grid_size,
|
||||
elapsed_ms: input.elapsed_ms,
|
||||
nickname: input.nickname,
|
||||
submitted_at_micros: input.submitted_at_micros,
|
||||
};
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection.procedures().submit_puzzle_leaderboard_entry_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_puzzle_run_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ use module_puzzle::{
|
||||
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
|
||||
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft,
|
||||
PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult,
|
||||
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
|
||||
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
|
||||
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput,
|
||||
PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate,
|
||||
build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack,
|
||||
normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size, select_next_profile,
|
||||
start_run, swap_pieces,
|
||||
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput,
|
||||
PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput,
|
||||
PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
|
||||
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
|
||||
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
||||
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
|
||||
compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags,
|
||||
publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces,
|
||||
};
|
||||
use serde_json::from_str as json_from_str;
|
||||
use serde_json::to_string as json_to_string;
|
||||
@@ -102,6 +102,25 @@ pub struct PuzzleRuntimeRunRow {
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
/// 拼图关卡真实成绩表。
|
||||
/// 每个用户在同一作品同一网格规格下只保留一条最佳成绩,用于结算弹窗排行榜。
|
||||
#[spacetimedb::table(
|
||||
accessor = puzzle_leaderboard_entry,
|
||||
index(accessor = by_puzzle_leaderboard_profile_grid, btree(columns = [profile_id, grid_size])),
|
||||
index(accessor = by_puzzle_leaderboard_user_profile_grid, btree(columns = [user_id, profile_id, grid_size]))
|
||||
)]
|
||||
pub struct PuzzleLeaderboardEntryRow {
|
||||
#[primary_key]
|
||||
entry_id: String,
|
||||
profile_id: String,
|
||||
grid_size: u32,
|
||||
user_id: String,
|
||||
nickname: String,
|
||||
best_elapsed_ms: u64,
|
||||
last_run_id: String,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn create_puzzle_agent_session(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -460,6 +479,25 @@ pub fn advance_puzzle_next_level(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn submit_puzzle_leaderboard_entry(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: PuzzleLeaderboardSubmitInput,
|
||||
) -> PuzzleRunProcedureResult {
|
||||
match ctx.try_with_tx(|tx| submit_puzzle_leaderboard_entry_tx(tx, input.clone())) {
|
||||
Ok(run) => PuzzleRunProcedureResult {
|
||||
ok: true,
|
||||
run_json: Some(serialize_json(&run)),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => PuzzleRunProcedureResult {
|
||||
ok: false,
|
||||
run_json: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create_puzzle_agent_session_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleAgentSessionCreateInput,
|
||||
@@ -1017,6 +1055,15 @@ fn start_puzzle_run_tx(
|
||||
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
||||
let mut run =
|
||||
start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?;
|
||||
let current_grid_size = run.current_grid_size;
|
||||
let current_profile_id = entry_profile.profile_id.clone();
|
||||
hydrate_puzzle_leaderboard_entries(
|
||||
ctx,
|
||||
&mut run,
|
||||
&input.owner_user_id,
|
||||
current_profile_id.as_str(),
|
||||
current_grid_size,
|
||||
);
|
||||
run.recommended_next_profile_id = select_next_profile(
|
||||
&entry_profile,
|
||||
&run.played_profile_ids,
|
||||
@@ -1034,7 +1081,21 @@ fn get_puzzle_run_tx(
|
||||
input: PuzzleRunGetInput,
|
||||
) -> Result<PuzzleRunSnapshot, String> {
|
||||
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
|
||||
deserialize_run(&row.snapshot_json)
|
||||
let mut run = deserialize_run(&row.snapshot_json)?;
|
||||
if let Some((profile_id, grid_size)) = run
|
||||
.current_level
|
||||
.as_ref()
|
||||
.map(|level| (level.profile_id.clone(), level.grid_size))
|
||||
{
|
||||
hydrate_puzzle_leaderboard_entries(
|
||||
ctx,
|
||||
&mut run,
|
||||
&input.owner_user_id,
|
||||
&profile_id,
|
||||
grid_size,
|
||||
);
|
||||
}
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
fn swap_puzzle_pieces_tx(
|
||||
@@ -1098,6 +1159,15 @@ fn advance_puzzle_next_level_tx(
|
||||
.clone();
|
||||
let mut next_run = module_puzzle::advance_next_level(¤t_run, &next_profile)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let next_grid_size = next_run.current_grid_size;
|
||||
let next_profile_id = next_profile.profile_id.clone();
|
||||
hydrate_puzzle_leaderboard_entries(
|
||||
ctx,
|
||||
&mut next_run,
|
||||
&input.owner_user_id,
|
||||
&next_profile_id,
|
||||
next_grid_size,
|
||||
);
|
||||
next_run.recommended_next_profile_id =
|
||||
select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates)
|
||||
.map(|value| value.profile_id.clone());
|
||||
@@ -1114,6 +1184,58 @@ fn advance_puzzle_next_level_tx(
|
||||
Ok(next_run)
|
||||
}
|
||||
|
||||
fn submit_puzzle_leaderboard_entry_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleLeaderboardSubmitInput,
|
||||
) -> Result<PuzzleRunSnapshot, String> {
|
||||
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
|
||||
let mut run = deserialize_run(&row.snapshot_json)?;
|
||||
let current_level = run
|
||||
.current_level
|
||||
.as_ref()
|
||||
.ok_or_else(|| "拼图关卡不存在".to_string())?;
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
|
||||
return Err("当前关卡尚未通关".to_string());
|
||||
}
|
||||
if current_level.profile_id != input.profile_id {
|
||||
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
|
||||
}
|
||||
if current_level.grid_size != input.grid_size {
|
||||
return Err("提交成绩的网格规格与当前关卡不匹配".to_string());
|
||||
}
|
||||
|
||||
let nickname = input.nickname.trim();
|
||||
if nickname.is_empty() {
|
||||
return Err("排行榜昵称不能为空".to_string());
|
||||
}
|
||||
|
||||
upsert_puzzle_leaderboard_entry(
|
||||
ctx,
|
||||
&input.owner_user_id,
|
||||
&input.profile_id,
|
||||
input.grid_size,
|
||||
nickname,
|
||||
input.elapsed_ms.max(1_000),
|
||||
&input.run_id,
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
|
||||
let leaderboard_entries = list_puzzle_leaderboard_entries(
|
||||
ctx,
|
||||
&input.profile_id,
|
||||
input.grid_size,
|
||||
&input.owner_user_id,
|
||||
10,
|
||||
);
|
||||
if let Some(level) = run.current_level.as_mut() {
|
||||
level.elapsed_ms = Some(input.elapsed_ms.max(1_000));
|
||||
level.leaderboard_entries = leaderboard_entries.clone();
|
||||
}
|
||||
run.leaderboard_entries = leaderboard_entries;
|
||||
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
fn build_puzzle_agent_session_snapshot(
|
||||
ctx: &TxContext,
|
||||
row: &PuzzleAgentSessionRow,
|
||||
@@ -1536,6 +1658,116 @@ fn refresh_next_profile_recommendation(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hydrate_puzzle_leaderboard_entries(
|
||||
ctx: &TxContext,
|
||||
run: &mut PuzzleRunSnapshot,
|
||||
current_user_id: &str,
|
||||
profile_id: &str,
|
||||
grid_size: u32,
|
||||
) {
|
||||
let leaderboard_entries =
|
||||
list_puzzle_leaderboard_entries(ctx, profile_id, grid_size, current_user_id, 10);
|
||||
run.leaderboard_entries = leaderboard_entries.clone();
|
||||
if let Some(level) = run.current_level.as_mut() {
|
||||
level.leaderboard_entries = leaderboard_entries;
|
||||
}
|
||||
}
|
||||
|
||||
fn build_puzzle_leaderboard_entry_id(user_id: &str, profile_id: &str, grid_size: u32) -> String {
|
||||
format!("puzzle-leaderboard-{user_id}-{profile_id}-{grid_size}")
|
||||
}
|
||||
|
||||
fn upsert_puzzle_leaderboard_entry(
|
||||
ctx: &TxContext,
|
||||
user_id: &str,
|
||||
profile_id: &str,
|
||||
grid_size: u32,
|
||||
nickname: &str,
|
||||
elapsed_ms: u64,
|
||||
run_id: &str,
|
||||
updated_at_micros: i64,
|
||||
) {
|
||||
let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size);
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
|
||||
if let Some(existing) = ctx
|
||||
.db
|
||||
.puzzle_leaderboard_entry()
|
||||
.entry_id()
|
||||
.find(&entry_id)
|
||||
{
|
||||
let should_replace = elapsed_ms < existing.best_elapsed_ms
|
||||
|| (elapsed_ms == existing.best_elapsed_ms
|
||||
&& updated_at.to_micros_since_unix_epoch()
|
||||
< existing.updated_at.to_micros_since_unix_epoch());
|
||||
let next_row = PuzzleLeaderboardEntryRow {
|
||||
entry_id: existing.entry_id.clone(),
|
||||
profile_id: existing.profile_id.clone(),
|
||||
grid_size: existing.grid_size,
|
||||
user_id: existing.user_id.clone(),
|
||||
nickname: nickname.to_string(),
|
||||
best_elapsed_ms: if should_replace {
|
||||
elapsed_ms
|
||||
} else {
|
||||
existing.best_elapsed_ms
|
||||
},
|
||||
last_run_id: if should_replace {
|
||||
run_id.to_string()
|
||||
} else {
|
||||
existing.last_run_id.clone()
|
||||
},
|
||||
updated_at,
|
||||
};
|
||||
ctx.db
|
||||
.puzzle_leaderboard_entry()
|
||||
.entry_id()
|
||||
.delete(&existing.entry_id);
|
||||
ctx.db.puzzle_leaderboard_entry().insert(next_row);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow {
|
||||
entry_id,
|
||||
profile_id: profile_id.to_string(),
|
||||
grid_size,
|
||||
user_id: user_id.to_string(),
|
||||
nickname: nickname.to_string(),
|
||||
best_elapsed_ms: elapsed_ms,
|
||||
last_run_id: run_id.to_string(),
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
fn list_puzzle_leaderboard_entries(
|
||||
ctx: &TxContext,
|
||||
profile_id: &str,
|
||||
grid_size: u32,
|
||||
current_user_id: &str,
|
||||
limit: usize,
|
||||
) -> Vec<PuzzleLeaderboardEntry> {
|
||||
let mut rows = ctx
|
||||
.db
|
||||
.puzzle_leaderboard_entry()
|
||||
.iter()
|
||||
.filter(|row| row.profile_id == profile_id && row.grid_size == grid_size)
|
||||
.collect::<Vec<_>>();
|
||||
rows.sort_by(|left, right| {
|
||||
left.best_elapsed_ms
|
||||
.cmp(&right.best_elapsed_ms)
|
||||
.then_with(|| left.updated_at.cmp(&right.updated_at))
|
||||
.then_with(|| left.user_id.cmp(&right.user_id))
|
||||
});
|
||||
rows.into_iter()
|
||||
.take(limit)
|
||||
.enumerate()
|
||||
.map(|(index, row)| PuzzleLeaderboardEntry {
|
||||
rank: index as u32 + 1,
|
||||
nickname: row.nickname,
|
||||
elapsed_ms: row.best_elapsed_ms,
|
||||
is_current_player: row.user_id == current_user_id,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn serialize_json<T: ::serde::Serialize>(value: &T) -> String {
|
||||
json_to_string(value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
@@ -1568,6 +1800,7 @@ mod tests {
|
||||
use super::*;
|
||||
use module_puzzle::{
|
||||
build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score,
|
||||
PuzzleLeaderboardEntry,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -1582,6 +1815,7 @@ mod tests {
|
||||
previous_level_tags: vec!["蒸汽城市".to_string()],
|
||||
current_level: None,
|
||||
recommended_next_profile_id: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
};
|
||||
let serialized = serialize_json(&snapshot);
|
||||
let parsed = deserialize_run(&serialized).expect("run json should parse");
|
||||
@@ -1681,4 +1915,31 @@ mod tests {
|
||||
> tag_similarity_score(&left.theme_tags, &right.theme_tags)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_leaderboard_entries_sort_by_elapsed_time() {
|
||||
let mut entries = vec![
|
||||
PuzzleLeaderboardEntry {
|
||||
rank: 0,
|
||||
nickname: "玩家 B".to_string(),
|
||||
elapsed_ms: 5200,
|
||||
is_current_player: false,
|
||||
},
|
||||
PuzzleLeaderboardEntry {
|
||||
rank: 0,
|
||||
nickname: "玩家 A".to_string(),
|
||||
elapsed_ms: 3100,
|
||||
is_current_player: true,
|
||||
},
|
||||
];
|
||||
entries.sort_by(|left, right| left.elapsed_ms.cmp(&right.elapsed_ms));
|
||||
for (index, entry) in entries.iter_mut().enumerate() {
|
||||
entry.rank = index as u32 + 1;
|
||||
}
|
||||
|
||||
assert_eq!(entries[0].nickname, "玩家 A");
|
||||
assert_eq!(entries[0].rank, 1);
|
||||
assert_eq!(entries[1].nickname, "玩家 B");
|
||||
assert_eq!(entries[1].rank, 2);
|
||||
}
|
||||
}
|
||||
|
||||
24
src/App.tsx
24
src/App.tsx
@@ -2,7 +2,10 @@ import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useAuthUi } from './components/auth/AuthUiContext';
|
||||
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
|
||||
import type { SelectionStage } from './components/platform-entry/platformEntryTypes';
|
||||
import type {
|
||||
CustomWorldRuntimeLaunchOptions,
|
||||
SelectionStage,
|
||||
} from './components/platform-entry/platformEntryTypes';
|
||||
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
APP_RUNTIME_ROUTES,
|
||||
@@ -40,6 +43,8 @@ export default function App() {
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
const [runtimeReturnStage, setRuntimeReturnStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
|
||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||
setRawSelectionStage(stage);
|
||||
@@ -86,10 +91,17 @@ export default function App() {
|
||||
);
|
||||
|
||||
const handleCustomWorldSelect = useCallback(
|
||||
(customWorldProfile: CustomWorldProfile) => {
|
||||
(
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => {
|
||||
// 中文注释:作品测试需要在结束测试后精确返回启动它的结果页;
|
||||
// 正式进入世界仍保持既有平台首页返回语义。
|
||||
setRuntimeReturnStage(options?.returnStage ?? 'platform');
|
||||
createRuntimeIntent({
|
||||
kind: 'custom-world',
|
||||
profile: customWorldProfile,
|
||||
mode: options?.mode ?? 'play',
|
||||
});
|
||||
},
|
||||
[createRuntimeIntent],
|
||||
@@ -102,7 +114,13 @@ export default function App() {
|
||||
if (isRuntimeActive) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<RpgRuntimeApp initialIntent={runtimeIntent} />
|
||||
<RpgRuntimeApp
|
||||
initialIntent={runtimeIntent}
|
||||
onExitRuntime={() => {
|
||||
setIsRuntimeActive(false);
|
||||
setSelectionStage(runtimeReturnStage);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ export default function PuzzlePlaygroundApp() {
|
||||
return (
|
||||
<PuzzleRuntimeShell
|
||||
run={run}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={handleRestart}
|
||||
onSwapPieces={handleSwapPieces}
|
||||
onDragPiece={handleDragPiece}
|
||||
|
||||
@@ -10,6 +10,7 @@ export type RpgRuntimeAppIntent =
|
||||
token: number;
|
||||
kind: 'custom-world';
|
||||
profile: CustomWorldProfile;
|
||||
mode?: 'play' | 'test';
|
||||
}
|
||||
| {
|
||||
token: number;
|
||||
@@ -19,8 +20,10 @@ export type RpgRuntimeAppIntent =
|
||||
|
||||
export function RpgRuntimeApp({
|
||||
initialIntent,
|
||||
onExitRuntime,
|
||||
}: {
|
||||
initialIntent: RpgRuntimeAppIntent | null;
|
||||
onExitRuntime?: () => void;
|
||||
}) {
|
||||
const gameShellProps = useRpgRuntimeSession();
|
||||
const handledIntentTokenRef = useRef<number | null>(null);
|
||||
@@ -32,14 +35,16 @@ export function RpgRuntimeApp({
|
||||
|
||||
handledIntentTokenRef.current = initialIntent.token;
|
||||
if (initialIntent.kind === 'custom-world') {
|
||||
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile);
|
||||
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile, {
|
||||
mode: initialIntent.mode ?? 'play',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
gameShellProps.entry.handleContinueGame(initialIntent.snapshot);
|
||||
}, [gameShellProps.entry, initialIntent]);
|
||||
|
||||
return <RpgRuntimeShell {...gameShellProps} />;
|
||||
return <RpgRuntimeShell {...gameShellProps} onExitTestRuntime={onExitRuntime} />;
|
||||
}
|
||||
|
||||
export default RpgRuntimeApp;
|
||||
|
||||
@@ -187,6 +187,55 @@ describe('GameCanvasEntityLayer', () => {
|
||||
expect(html).not.toContain('好感度变化 +3');
|
||||
});
|
||||
|
||||
it('keeps hostile combat hp bar visible during post-hit afterimage frames', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
sceneActAmbientEncounters={[]}
|
||||
currentScenePreset={null}
|
||||
sceneTransitionToken={0}
|
||||
isSceneTransitionEntering={false}
|
||||
isSceneTransitionExiting={false}
|
||||
transitionSweepPx={320}
|
||||
sceneTransitionExitDurationS={0.2}
|
||||
sceneTransitionEntryDurationS={0.2}
|
||||
companionAnchorLeft="10%"
|
||||
companionAnchorBottom="20%"
|
||||
playerBottomOffsetPx={0}
|
||||
sceneTransitionPhase="idle"
|
||||
inBattle={false}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.IDLE}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={null}
|
||||
sceneCombatants={[
|
||||
createHostileNpc({
|
||||
hp: 4,
|
||||
maxHp: 10,
|
||||
animation: 'die',
|
||||
}),
|
||||
]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '70%'}
|
||||
groundBottom="18%"
|
||||
stageLiftPx={68}
|
||||
encounter={null}
|
||||
sideAnchor="15%"
|
||||
cameraAnchorX={0}
|
||||
monsterAnchorMeters={3.2}
|
||||
playerX={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('from-rose-500 to-red-400');
|
||||
});
|
||||
|
||||
it('renders scene act back-row encounters alongside the primary encounter', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DialogueBubbleIcon,
|
||||
type GameCanvasEntitySelection,
|
||||
GENERIC_NPC_SCENE_SCALE,
|
||||
getBattleCompanionSlotOffset,
|
||||
getCompanionSlotOffset,
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
@@ -222,11 +223,23 @@ export function GameCanvasEntityLayer({
|
||||
const [combatFeedbackEvents, setCombatFeedbackEvents] = useState<CombatFeedbackEvent[]>([]);
|
||||
const previousCombatSamplesRef = useRef<Map<string, CombatFeedbackHealthSample> | null>(null);
|
||||
const combatFeedbackSequenceRef = useRef(0);
|
||||
const hasCombatAfterimage = useMemo(
|
||||
() =>
|
||||
combatFeedbackEvents.length > 0 ||
|
||||
sceneCombatants.some(
|
||||
(hostileNpc) =>
|
||||
hostileNpc.hp < hostileNpc.maxHp ||
|
||||
hostileNpc.animation === 'attack' ||
|
||||
hostileNpc.animation === 'die',
|
||||
),
|
||||
[combatFeedbackEvents.length, sceneCombatants],
|
||||
);
|
||||
const shouldRenderCombatPresentation = inBattle || hasCombatAfterimage;
|
||||
const shouldRenderPeacefulEncounter =
|
||||
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
|
||||
const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
|
||||
() => {
|
||||
if (!inBattle) return [];
|
||||
if (!shouldRenderCombatPresentation) return [];
|
||||
|
||||
return [
|
||||
{key: 'player', kind: 'player', hp: playerHp},
|
||||
@@ -242,7 +255,7 @@ export function GameCanvasEntityLayer({
|
||||
})),
|
||||
];
|
||||
},
|
||||
[companions, inBattle, playerHp, sceneCombatants],
|
||||
[companions, playerHp, sceneCombatants, shouldRenderCombatPresentation],
|
||||
);
|
||||
const combatFeedbackByTarget = useMemo(() => {
|
||||
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
|
||||
@@ -259,7 +272,7 @@ export function GameCanvasEntityLayer({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!inBattle) {
|
||||
if (!shouldRenderCombatPresentation) {
|
||||
previousCombatSamplesRef.current = null;
|
||||
setCombatFeedbackEvents([]);
|
||||
return;
|
||||
@@ -283,12 +296,14 @@ export function GameCanvasEntityLayer({
|
||||
previousCombatSamplesRef.current = new Map(
|
||||
combatHealthSamples.map(sample => [sample.key, sample]),
|
||||
);
|
||||
}, [combatHealthSamples, inBattle]);
|
||||
}, [combatHealthSamples, shouldRenderCombatPresentation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{companions.map(companion => {
|
||||
const slotOffset = getCompanionSlotOffset(companion.slot);
|
||||
const slotOffset = inBattle
|
||||
? getBattleCompanionSlotOffset(companion.slot)
|
||||
: getCompanionSlotOffset(companion.slot);
|
||||
const feedbackTargetKey = `companion:${companion.npcId}`;
|
||||
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
|
||||
const companionFacing = companion.facing ?? 'right';
|
||||
@@ -314,7 +329,7 @@ export function GameCanvasEntityLayer({
|
||||
style={{
|
||||
left: companionAnchorLeft,
|
||||
bottom: companionAnchorBottom,
|
||||
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
|
||||
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom) + (inBattle ? 1 : 0),
|
||||
transition: 'left 260ms linear, bottom 180ms ease',
|
||||
}}
|
||||
>
|
||||
@@ -336,7 +351,7 @@ export function GameCanvasEntityLayer({
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
||||
{inBattle && (
|
||||
{shouldRenderCombatPresentation && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||
@@ -385,7 +400,7 @@ export function GameCanvasEntityLayer({
|
||||
events={combatFeedbackByTarget.get('player') ?? []}
|
||||
onDone={removeCombatFeedbackEvent}
|
||||
/>
|
||||
{inBattle && (
|
||||
{shouldRenderCombatPresentation && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||
@@ -484,7 +499,7 @@ export function GameCanvasEntityLayer({
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
||||
{inBattle && (
|
||||
{shouldRenderCombatPresentation && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${npcCombatHpTop}px`}}
|
||||
|
||||
@@ -108,6 +108,12 @@ export function getCompanionSlotOffset(slot: CompanionRenderState['slot']) {
|
||||
: {left: -34, bottom: 10};
|
||||
}
|
||||
|
||||
export function getBattleCompanionSlotOffset(slot: CompanionRenderState['slot']) {
|
||||
return slot === 'upper'
|
||||
? {left: -118, bottom: 86}
|
||||
: {left: -92, bottom: 26};
|
||||
}
|
||||
|
||||
export function mapHostileNpcAnimationToCharacterState(animation: SceneHostileNpc['animation']) {
|
||||
if (animation === 'move') return AnimationState.RUN;
|
||||
if (animation === 'attack') return AnimationState.ATTACK;
|
||||
|
||||
@@ -28,7 +28,10 @@ import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type {
|
||||
PuzzleRunSnapshot,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
@@ -80,7 +83,10 @@ import {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
|
||||
import {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
submitPuzzleLeaderboard,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
dragLocalPuzzlePiece,
|
||||
startLocalPuzzleRun,
|
||||
@@ -427,6 +433,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
useState<PuzzleDetailReturnTarget | null>(null);
|
||||
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
|
||||
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
|
||||
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
|
||||
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
|
||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||
const [puzzleGenerationState, setPuzzleGenerationState] =
|
||||
@@ -1236,6 +1244,50 @@ export function PlatformEntryFlowShellImpl({
|
||||
[isPuzzleBusy, puzzleRun],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentLevel = puzzleRun?.currentLevel ?? null;
|
||||
if (!puzzleRun || !currentLevel || currentLevel.status !== 'cleared') {
|
||||
return;
|
||||
}
|
||||
if (currentLevel.elapsedMs === null) {
|
||||
return;
|
||||
}
|
||||
if ((currentLevel.leaderboardEntries ?? []).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submitKey = `${puzzleRun.runId}:${currentLevel.profileId}:${currentLevel.gridSize}:${currentLevel.elapsedMs}`;
|
||||
if (submittedPuzzleLeaderboardKeysRef.current.has(submitKey)) {
|
||||
return;
|
||||
}
|
||||
submittedPuzzleLeaderboardKeysRef.current.add(submitKey);
|
||||
setIsPuzzleLeaderboardBusy(true);
|
||||
|
||||
const payload: SubmitPuzzleLeaderboardRequest = {
|
||||
profileId: currentLevel.profileId,
|
||||
gridSize: currentLevel.gridSize,
|
||||
elapsedMs: currentLevel.elapsedMs,
|
||||
nickname: authUi?.user?.displayName?.trim() || '玩家',
|
||||
};
|
||||
|
||||
void submitPuzzleLeaderboard(puzzleRun.runId, payload)
|
||||
.then(({ run }) => {
|
||||
setPuzzleRun(run);
|
||||
})
|
||||
.catch((error) => {
|
||||
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPuzzleLeaderboardBusy(false);
|
||||
});
|
||||
}, [
|
||||
authUi?.user?.displayName,
|
||||
puzzleRun,
|
||||
resolvePuzzleErrorMessage,
|
||||
setPuzzleError,
|
||||
]);
|
||||
|
||||
const advancePuzzleLevel = useCallback(async () => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
return;
|
||||
@@ -2407,13 +2459,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
|
||||
>
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
}}
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={
|
||||
isPuzzleBusy ||
|
||||
isPuzzleNextLevelGenerating ||
|
||||
isPuzzleLeaderboardBusy
|
||||
}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
}}
|
||||
onSwapPieces={(payload) => {
|
||||
void swapPuzzlePiecesInRun(payload);
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,13 @@ import type {
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type CustomWorldRuntimeLaunchMode = 'play' | 'test';
|
||||
|
||||
export type CustomWorldRuntimeLaunchOptions = {
|
||||
mode?: CustomWorldRuntimeLaunchMode;
|
||||
returnStage?: SelectionStage | null;
|
||||
};
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'detail'
|
||||
@@ -38,5 +45,8 @@ export type PlatformEntryFlowShellProps = {
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => void;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
@@ -18,6 +19,34 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
function createAuthValue() {
|
||||
return {
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action: () => void) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light' as const,
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPuzzleRuntime(
|
||||
ui: React.ReactElement,
|
||||
authValue = createAuthValue(),
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider value={authValue}>{ui}</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
const clearedRun: PuzzleRunSnapshot = {
|
||||
runId: 'run-1',
|
||||
entryProfileId: 'profile-1',
|
||||
@@ -85,9 +114,10 @@ const clearedRun: PuzzleRunSnapshot = {
|
||||
};
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
|
||||
render(
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
@@ -97,6 +127,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
expect(screen.getByTestId('puzzle-clear-flash')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
|
||||
expect(within(dialog).getByText('排行榜')).toBeTruthy();
|
||||
@@ -106,10 +143,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
render(
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
@@ -119,8 +159,91 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
const authValue = createAuthValue();
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
authValue,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
|
||||
const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' });
|
||||
fireEvent.change(slider, { target: { value: '77' } });
|
||||
|
||||
expect(within(dialog).getByText('第 1 关')).toBeTruthy();
|
||||
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
|
||||
});
|
||||
|
||||
test('合并块按实际拼块外轮廓描边', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [
|
||||
{
|
||||
groupId: 'group-l',
|
||||
pieceIds: ['piece-0', 'piece-1', 'piece-3'],
|
||||
occupiedCells: [
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
|
||||
['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
|
||||
? { ...piece, mergedGroupId: 'group-l' }
|
||||
: piece,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const outlinedPieces = container.querySelectorAll(
|
||||
'[data-merged-piece-outline="true"]',
|
||||
);
|
||||
|
||||
expect(outlinedPieces).toHaveLength(3);
|
||||
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
|
||||
expect(outlinedPieces[0]?.className).toContain('border-r-0');
|
||||
expect(outlinedPieces[0]?.className).toContain('border-b-0');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-tr-none');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-bl-none');
|
||||
expect(outlinedPieces[1]?.className).toContain('border-l-0');
|
||||
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
|
||||
expect(outlinedPieces[2]?.className).toContain('border-t-0');
|
||||
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy, X } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleRuntimeShellProps = {
|
||||
@@ -29,7 +32,6 @@ type PuzzleBoardPieceViewModel = {
|
||||
correctRow: number;
|
||||
correctCol: number;
|
||||
mergedGroupId: string | null;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type PuzzleMergedGroupViewModel = {
|
||||
@@ -59,9 +61,41 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPieceLabel(pieceId: string) {
|
||||
const fallback = pieceId.slice(-2).toUpperCase();
|
||||
return fallback || '块';
|
||||
function buildLocalCellKey(row: number, col: number) {
|
||||
return `${row}:${col}`;
|
||||
}
|
||||
|
||||
function resolveMergedPieceOutlineClass(
|
||||
group: PuzzleMergedGroupViewModel,
|
||||
piece: PuzzleMergedGroupViewModel['pieces'][number],
|
||||
) {
|
||||
const groupCellKeys = new Set(
|
||||
group.pieces.map((groupPiece) =>
|
||||
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
|
||||
),
|
||||
);
|
||||
const hasTopEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow - 1, piece.localCol),
|
||||
);
|
||||
const hasRightEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow, piece.localCol + 1),
|
||||
);
|
||||
const hasBottomEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow + 1, piece.localCol),
|
||||
);
|
||||
const hasLeftEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow, piece.localCol - 1),
|
||||
);
|
||||
return [
|
||||
hasTopEdge ? 'border-t-2' : 'border-t-0',
|
||||
hasRightEdge ? 'border-r-2' : 'border-r-0',
|
||||
hasBottomEdge ? 'border-b-2' : 'border-b-0',
|
||||
hasLeftEdge ? 'border-l-2' : 'border-l-0',
|
||||
hasTopEdge && hasLeftEdge ? 'rounded-tl-[0.85rem]' : 'rounded-tl-none',
|
||||
hasTopEdge && hasRightEdge ? 'rounded-tr-[0.85rem]' : 'rounded-tr-none',
|
||||
hasBottomEdge && hasRightEdge ? 'rounded-br-[0.85rem]' : 'rounded-br-none',
|
||||
hasBottomEdge && hasLeftEdge ? 'rounded-bl-[0.85rem]' : 'rounded-bl-none',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildMergedGroupViewModels(
|
||||
@@ -117,6 +151,10 @@ function formatElapsedMs(elapsedMs: number | null | undefined) {
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
|
||||
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
|
||||
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
|
||||
@@ -130,7 +168,9 @@ export function PuzzleRuntimeShell({
|
||||
onDragPiece,
|
||||
onAdvanceNextLevel,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
const dragSessionRef = useRef<{
|
||||
pieceId: string;
|
||||
pointerId: number;
|
||||
@@ -148,10 +188,21 @@ export function PuzzleRuntimeShell({
|
||||
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(null);
|
||||
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isClearFlashVisible, setIsClearFlashVisible] = useState(false);
|
||||
const [isClearResultReady, setIsClearResultReady] = useState(false);
|
||||
const clearPresentationKeyRef = useRef<string | null>(null);
|
||||
const clearPresentationTimeoutIdsRef = useRef<number[]>([]);
|
||||
const boardRef = useRef<HTMLDivElement | null>(null);
|
||||
const currentLevel = run?.currentLevel ?? null;
|
||||
const board = currentLevel?.board ?? null;
|
||||
const clearResultKey = currentLevel
|
||||
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
||||
: null;
|
||||
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
|
||||
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
|
||||
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.coverImageSrc ?? null,
|
||||
);
|
||||
@@ -167,7 +218,6 @@ export function PuzzleRuntimeShell({
|
||||
correctRow: piece.correctRow,
|
||||
correctCol: piece.correctCol,
|
||||
mergedGroupId: piece.mergedGroupId,
|
||||
label: buildPieceLabel(piece.pieceId),
|
||||
}));
|
||||
}, [board]);
|
||||
|
||||
@@ -206,7 +256,9 @@ export function PuzzleRuntimeShell({
|
||||
return;
|
||||
}
|
||||
|
||||
const pieceElement = pieceElementRefMap.current.get(dragVisualTarget.pieceId);
|
||||
const pieceElement = pieceElementRefMap.current.get(
|
||||
dragVisualTarget.pieceId,
|
||||
);
|
||||
if (pieceElement) {
|
||||
pieceElement.style.transform = '';
|
||||
pieceElement.style.willChange = '';
|
||||
@@ -215,7 +267,9 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
|
||||
if (dragVisualTarget.groupId) {
|
||||
const groupElement = groupElementRefMap.current.get(dragVisualTarget.groupId);
|
||||
const groupElement = groupElementRefMap.current.get(
|
||||
dragVisualTarget.groupId,
|
||||
);
|
||||
if (groupElement) {
|
||||
groupElement.style.transform = '';
|
||||
groupElement.style.willChange = '';
|
||||
@@ -304,10 +358,66 @@ export function PuzzleRuntimeShell({
|
||||
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
cancelDragVisualFrame();
|
||||
resetDragVisualTarget();
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelDragVisualFrame();
|
||||
resetDragVisualTarget();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearPresentationTimeouts = () => {
|
||||
for (const timeoutId of clearPresentationTimeoutIdsRef.current) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
clearPresentationTimeoutIdsRef.current = [];
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearPresentationTimeouts();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevel || !clearResultKey) {
|
||||
clearPresentationKeyRef.current = null;
|
||||
clearPresentationTimeouts();
|
||||
setIsClearFlashVisible(false);
|
||||
setIsClearResultReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentLevel.status !== 'cleared') {
|
||||
clearPresentationKeyRef.current = null;
|
||||
clearPresentationTimeouts();
|
||||
setIsClearFlashVisible(false);
|
||||
setIsClearResultReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
dismissedClearKey === clearResultKey ||
|
||||
clearPresentationKeyRef.current === clearResultKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。
|
||||
clearPresentationKeyRef.current = clearResultKey;
|
||||
clearPresentationTimeouts();
|
||||
setIsClearFlashVisible(true);
|
||||
setIsClearResultReady(false);
|
||||
clearPresentationTimeoutIdsRef.current = [
|
||||
window.setTimeout(() => {
|
||||
setIsClearFlashVisible(false);
|
||||
}, PUZZLE_CLEAR_FLASH_DURATION_MS),
|
||||
window.setTimeout(() => {
|
||||
setIsClearResultReady(true);
|
||||
}, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS),
|
||||
];
|
||||
}, [clearResultKey, currentLevel, dismissedClearKey]);
|
||||
|
||||
if (!run || !currentLevel || !board) {
|
||||
return (
|
||||
@@ -453,17 +563,18 @@ export function PuzzleRuntimeShell({
|
||||
scheduleDragVisual();
|
||||
};
|
||||
|
||||
const statusLabel =
|
||||
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
|
||||
const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中';
|
||||
const nextAvailable =
|
||||
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
||||
const clearResultKey = `${run.runId}:${currentLevel.profileId}:${currentLevel.levelIndex}`;
|
||||
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||||
const leaderboardEntries =
|
||||
(currentLevel.leaderboardEntries ?? []).length > 0
|
||||
? currentLevel.leaderboardEntries
|
||||
: (run.leaderboardEntries ?? []);
|
||||
const isClearResultOpen =
|
||||
currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey;
|
||||
currentLevel.status === 'cleared' &&
|
||||
dismissedClearKey !== clearResultKey &&
|
||||
isClearResultReady;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
@@ -478,26 +589,41 @@ export function PuzzleRuntimeShell({
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
|
||||
|
||||
<div className="absolute left-0 top-0 z-20 flex w-full items-start justify-between gap-3 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="absolute left-0 top-0 z-20 w-full px-4 py-4">
|
||||
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="返回上一页"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex max-w-[70vw] flex-col items-end gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-right backdrop-blur">
|
||||
<div className="text-[0.68rem] font-semibold tracking-[0.2em] text-white/70">
|
||||
PUZZLE
|
||||
</div>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
<div className="text-xs text-white/74">
|
||||
{currentLevel.authorDisplayName} · 第 {currentLevel.levelIndex} 关 ·{' '}
|
||||
{statusLabel}
|
||||
<div className="flex min-w-0 flex-col items-center gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-center backdrop-blur">
|
||||
<div className="line-clamp-1 text-sm font-bold text-white sm:text-base">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
<div className="line-clamp-1 text-xs text-white/78">
|
||||
{currentLevel.authorDisplayName}
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
|
||||
{levelLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSettingsPanelOpen(true)}
|
||||
aria-label="打开拼图设置"
|
||||
title="打开拼图设置"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/60"
|
||||
>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.settings}
|
||||
className="h-[1.4rem] w-[1.4rem] drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -517,10 +643,7 @@ export function PuzzleRuntimeShell({
|
||||
const isSelected = piece?.pieceId === selectedPieceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${cell.row}:${cell.col}`}
|
||||
className="relative p-1"
|
||||
>
|
||||
<div key={`${cell.row}:${cell.col}`} className="relative p-1">
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (!piece) {
|
||||
@@ -542,7 +665,9 @@ export function PuzzleRuntimeShell({
|
||||
: 'border-white/18 bg-white/12 text-white'
|
||||
: 'border-white/8 bg-black/18 text-white/20'
|
||||
} ${
|
||||
isMerged ? 'transition-colors' : 'transition-[background-color,border-color,box-shadow,opacity]'
|
||||
isMerged
|
||||
? 'transition-colors'
|
||||
: 'transition-[background-color,border-color,box-shadow,opacity]'
|
||||
}`}
|
||||
onPointerDown={(event) => {
|
||||
if (!piece || isMerged) {
|
||||
@@ -591,11 +716,6 @@ export function PuzzleRuntimeShell({
|
||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/10" />
|
||||
{!isMerged ? (
|
||||
<div className="absolute bottom-1 right-1 rounded-full bg-black/38 px-1.5 py-0.5 text-[10px] font-black text-white/86">
|
||||
{piece.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
@@ -632,7 +752,11 @@ export function PuzzleRuntimeShell({
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10"
|
||||
className={`pointer-events-auto relative touch-none overflow-hidden border-emerald-100/72 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
|
||||
group,
|
||||
piece,
|
||||
)}`}
|
||||
data-merged-piece-outline="true"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
gridRow: piece.localRow + 1,
|
||||
@@ -676,7 +800,6 @@ export function PuzzleRuntimeShell({
|
||||
<div className="absolute inset-0 bg-black/8" />
|
||||
</div>
|
||||
))}
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[1rem] ring-2 ring-emerald-100/58 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_14px_32px_rgba(6,78,59,0.24)]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -717,6 +840,142 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isClearFlashVisible ? (
|
||||
<div
|
||||
data-testid="puzzle-clear-flash"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-30 overflow-hidden"
|
||||
>
|
||||
<div className="puzzle-clear-flash-overlay absolute inset-0" />
|
||||
<div className="puzzle-clear-flash-beam" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSettingsPanelOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-settings-title"
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<h2
|
||||
id="puzzle-settings-title"
|
||||
className="text-sm font-semibold text-white"
|
||||
>
|
||||
拼图设置
|
||||
</h2>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
调整音乐音量,查看本局进度,或返回上一页。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭拼图设置"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_65%),rgba(0,0,0,0.24)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.24em] text-sky-200/80">
|
||||
音频
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
音乐音量
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/28 px-2 py-1 text-xs text-white/80">
|
||||
{Math.round(musicVolume * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
aria-label="拼图音乐音量"
|
||||
value={Math.round(musicVolume * 100)}
|
||||
onChange={(event) =>
|
||||
onMusicVolumeChange(
|
||||
Number(event.currentTarget.value) / 100,
|
||||
)
|
||||
}
|
||||
className="h-2 w-full cursor-pointer accent-sky-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
本局进度
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-white/82">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-white/56">关卡</span>
|
||||
<span className="font-semibold text-white">
|
||||
{levelLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-white/56">已完成关卡</span>
|
||||
<span className="font-semibold text-white">
|
||||
{run.clearedLevelCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-white/56">当前状态</span>
|
||||
<span className="font-semibold text-white">
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-white/56">当前用时</span>
|
||||
<span className="font-mono font-semibold text-white">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
className="rounded-full border border-white/12 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:text-white"
|
||||
>
|
||||
继续拼图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSettingsPanelOpen(false);
|
||||
onBack();
|
||||
}}
|
||||
className="rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100"
|
||||
>
|
||||
返回上一页
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isClearResultOpen ? (
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
|
||||
<section
|
||||
@@ -748,7 +1007,7 @@ export function PuzzleRuntimeShell({
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -768,7 +1027,9 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-sm font-bold text-white">排行榜</div>
|
||||
<div className="mb-2 text-sm font-bold text-white">
|
||||
排行榜
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-white/10">
|
||||
<div className="grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] bg-white/6 px-3 py-2 text-[11px] font-bold text-white/48">
|
||||
<span>名次</span>
|
||||
@@ -776,24 +1037,32 @@ export function PuzzleRuntimeShell({
|
||||
<span className="text-right">通关时间</span>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{leaderboardEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
|
||||
entry.isCurrentPlayer
|
||||
? 'bg-amber-200/14 text-amber-50'
|
||||
: 'border-t border-white/8 text-white/78'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-black">#{entry.rank}</span>
|
||||
<span className="truncate font-semibold">
|
||||
{entry.nickname}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-bold">
|
||||
{formatElapsedMs(entry.elapsedMs)}
|
||||
</span>
|
||||
{leaderboardEntries.length > 0 ? (
|
||||
leaderboardEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
|
||||
entry.isCurrentPlayer
|
||||
? 'bg-amber-200/14 text-amber-50'
|
||||
: 'border-t border-white/8 text-white/78'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-black">
|
||||
#{entry.rank}
|
||||
</span>
|
||||
<span className="truncate font-semibold">
|
||||
{entry.nickname}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-bold">
|
||||
{formatElapsedMs(entry.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
|
||||
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,8 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
@@ -2526,6 +2526,10 @@ test('agent draft result test button enters current draft without publish gate',
|
||||
await waitFor(() => {
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: '潮雾列岛' }),
|
||||
expect.objectContaining({
|
||||
mode: 'test',
|
||||
returnStage: 'custom-world-result',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { act, render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { WorldType, type CustomWorldProfile } from '../../types';
|
||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||||
|
||||
function buildProfile(params: {
|
||||
@@ -88,7 +88,11 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
stage: 'ready_to_publish',
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: { isReady: true, completedKeys: [], missingKeys: [] },
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: null,
|
||||
@@ -110,15 +114,15 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
}
|
||||
|
||||
describe('useRpgCreationEnterWorld', () => {
|
||||
it('Agent 草稿进入游戏时使用 session draft profile 的角色形象', async () => {
|
||||
it('Agent 草稿测试进入游戏时使用结果页当前 profile 的角色形象', async () => {
|
||||
const staleResultProfile = buildProfile({
|
||||
id: 'stale-result',
|
||||
name: '旧结果页快照',
|
||||
imageSrc: '/template/old-role.png',
|
||||
});
|
||||
const draftProfile = buildProfile({
|
||||
const resultProfile = buildProfile({
|
||||
id: 'draft-profile',
|
||||
name: '草稿真相源',
|
||||
name: '结果页真相源',
|
||||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||||
});
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
@@ -130,7 +134,7 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
isAgentDraftResultView: true,
|
||||
activeAgentSessionId: 'session-1',
|
||||
generatedCustomWorldProfile: staleResultProfile,
|
||||
agentSessionProfile: draftProfile,
|
||||
agentSessionProfile: resultProfile,
|
||||
agentSession: buildSession(),
|
||||
handleCustomWorldSelect,
|
||||
executePublishWorld,
|
||||
@@ -138,7 +142,10 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => void enterWorldForTestFromCurrentResult()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void enterWorldForTestFromCurrentResult()}
|
||||
>
|
||||
进入
|
||||
</button>
|
||||
);
|
||||
@@ -150,9 +157,12 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
});
|
||||
|
||||
expect(executePublishWorld).not.toHaveBeenCalled();
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(draftProfile);
|
||||
expect(handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/draft-role/portrait.png',
|
||||
);
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, {
|
||||
mode: 'test',
|
||||
returnStage: 'custom-world-result',
|
||||
});
|
||||
expect(
|
||||
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
|
||||
).toBe('/generated-characters/draft-role/portrait.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
@@ -10,14 +11,17 @@ type UseRpgCreationEnterWorldParams = {
|
||||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||||
agentSessionProfile: CustomWorldProfile | null;
|
||||
agentSession: CustomWorldAgentSessionSnapshot | null;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => void;
|
||||
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
|
||||
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一“进入世界”前的最终同步策略。
|
||||
* Agent 草稿结果进入游戏时只读 session.draftProfile,不再把结果页快照回写成新的运行时 profile。
|
||||
* Agent 草稿结果进入游戏时只读当前结果页 profile,不再静默回退到基础 draftProfile。
|
||||
*/
|
||||
export function useRpgCreationEnterWorld(
|
||||
params: UseRpgCreationEnterWorldParams,
|
||||
@@ -39,13 +43,22 @@ export function useRpgCreationEnterWorld(
|
||||
}
|
||||
|
||||
if (!isAgentDraftResultView || !activeAgentSessionId) {
|
||||
handleCustomWorldSelect(generatedCustomWorldProfile);
|
||||
handleCustomWorldSelect(generatedCustomWorldProfile, {
|
||||
mode: 'test',
|
||||
returnStage: 'custom-world-result',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
handleCustomWorldSelect(latestProfile);
|
||||
if (!agentSessionProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratedCustomWorldProfile(agentSessionProfile);
|
||||
handleCustomWorldSelect(agentSessionProfile, {
|
||||
mode: 'test',
|
||||
returnStage: 'custom-world-result',
|
||||
});
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
agentSessionProfile,
|
||||
@@ -64,8 +77,11 @@ export function useRpgCreationEnterWorld(
|
||||
return generatedCustomWorldProfile;
|
||||
}
|
||||
|
||||
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
if (!agentSessionProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setGeneratedCustomWorldProfile(agentSessionProfile);
|
||||
|
||||
const latestSession = agentSession;
|
||||
const canEnterPublishedWorld =
|
||||
@@ -73,13 +89,13 @@ export function useRpgCreationEnterWorld(
|
||||
latestSession.resultPreview?.canEnterWorld;
|
||||
|
||||
if (canEnterPublishedWorld) {
|
||||
return latestProfile;
|
||||
return agentSessionProfile;
|
||||
}
|
||||
|
||||
const publishedSession = await executePublishWorld();
|
||||
const publishedProfile =
|
||||
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
|
||||
latestProfile;
|
||||
agentSessionProfile;
|
||||
|
||||
setGeneratedCustomWorldProfile(publishedProfile);
|
||||
return publishedProfile;
|
||||
@@ -89,7 +105,6 @@ export function useRpgCreationEnterWorld(
|
||||
agentSessionProfile,
|
||||
executePublishWorld,
|
||||
generatedCustomWorldProfile,
|
||||
handleCustomWorldSelect,
|
||||
isAgentDraftResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
]);
|
||||
|
||||
@@ -219,7 +219,7 @@ export function useRpgCreationResultAutosave(
|
||||
}
|
||||
|
||||
// Agent 结果页不再把前端 profile 回写到 session。
|
||||
// session.draftProfile 是真相源;这里只刷新后端最新快照,避免在采集/生成早期误触 sync_result_profile。
|
||||
// 这里只刷新后端结果页快照,避免在采集/生成早期误触 sync_result_profile。
|
||||
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
|
||||
const latestProfile = normalizeAgentBackedProfile(
|
||||
buildDraftResultProfile(latestSession) ?? profile,
|
||||
|
||||
@@ -52,6 +52,7 @@ function renderPanel(
|
||||
isLoading?: boolean;
|
||||
onSubmitNpcChatInput?: (input: string) => boolean;
|
||||
onExitNpcChat?: () => boolean;
|
||||
inBattle?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
@@ -97,7 +98,7 @@ function renderPanel(
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
inBattle={overrides.inBattle ?? false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
@@ -283,3 +284,53 @@ test('adventure panel renders narrative story text without italics and hides opt
|
||||
expect(html).toContain('text-[15px]');
|
||||
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
|
||||
});
|
||||
|
||||
test('adventure panel hides narrative story section during battle', () => {
|
||||
const option = createOption('battle_attack_basic', '挥剑压上');
|
||||
const currentStory: StoryMoment = {
|
||||
text: '敌人的刀光逼到眼前,这段剧情框在战斗中不应该占位。',
|
||||
options: [option],
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [option], {
|
||||
inBattle: true,
|
||||
});
|
||||
|
||||
expect(html).not.toContain('敌人的刀光逼到眼前');
|
||||
expect(html).toContain('挥剑压上');
|
||||
});
|
||||
|
||||
test('adventure panel limits battle choices before viewport measurement', () => {
|
||||
const options = Array.from({ length: 6 }, (_, index) =>
|
||||
createOption('battle_attack_basic', `战斗动作${index + 1}`),
|
||||
);
|
||||
const currentStory: StoryMoment = {
|
||||
text: '战斗中剧情框不占底部空间。',
|
||||
options,
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, options, {
|
||||
inBattle: true,
|
||||
});
|
||||
|
||||
expect(html).toContain('战斗动作1');
|
||||
expect(html).toContain('战斗动作4');
|
||||
expect(html).not.toContain('战斗动作5');
|
||||
expect(html).not.toContain('战斗动作6');
|
||||
});
|
||||
|
||||
test('adventure panel uses combat settlement loading copy during battle', () => {
|
||||
const option = createOption('battle_attack_basic', '挥剑压上');
|
||||
const currentStory: StoryMoment = {
|
||||
text: '战斗中的加载态不该再提示剧情推演。',
|
||||
options: [option],
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [option], {
|
||||
inBattle: true,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
expect(html).toContain('战斗结算中...');
|
||||
expect(html).not.toContain('剧情推演中...');
|
||||
});
|
||||
|
||||
@@ -53,6 +53,10 @@ import {
|
||||
import { HostileNpcAnimator } from '../HostileNpcAnimator';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
|
||||
const BATTLE_OPTION_ROW_MIN_HEIGHT = 58;
|
||||
const BATTLE_OPTION_ROW_GAP = 6;
|
||||
const DEFAULT_BATTLE_VISIBLE_OPTION_COUNT = 4;
|
||||
|
||||
export interface RpgAdventurePanelProps {
|
||||
aiError: string | null;
|
||||
currentStory: StoryMoment;
|
||||
@@ -140,6 +144,57 @@ function getOptionActionTextClass(option: StoryOption) {
|
||||
return 'text-zinc-300 group-hover:text-white';
|
||||
}
|
||||
|
||||
function getBattleVisibleOptionCount(availableHeight: number, total: number) {
|
||||
if (total <= 0) return 0;
|
||||
|
||||
if (!Number.isFinite(availableHeight) || availableHeight <= 0) {
|
||||
return Math.min(total, DEFAULT_BATTLE_VISIBLE_OPTION_COUNT);
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
total,
|
||||
Math.floor(
|
||||
(availableHeight + BATTLE_OPTION_ROW_GAP) /
|
||||
(BATTLE_OPTION_ROW_MIN_HEIGHT + BATTLE_OPTION_ROW_GAP),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function useMeasuredElementHeight<T extends HTMLElement>(enabled: boolean) {
|
||||
const elementRef = useRef<T | null>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setHeight(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setHeight(element.getBoundingClientRect().height);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(updateHeight);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateHeight);
|
||||
return () => window.removeEventListener('resize', updateHeight);
|
||||
}, [enabled]);
|
||||
|
||||
return [elementRef, height] as const;
|
||||
}
|
||||
|
||||
function getOptionFunctionTagText(option: StoryOption) {
|
||||
const tagByFunctionId: Record<string, string> = {
|
||||
battle_all_in_crush: '战斗',
|
||||
@@ -692,11 +747,14 @@ function RpgAdventureStorySection(props: {
|
||||
isStoryStreaming,
|
||||
currentStory,
|
||||
} = props;
|
||||
const storyPanelClassName = isNpcChatMode
|
||||
? 'flex-[1.18] sm:min-h-[15rem]'
|
||||
: 'flex-1 sm:min-h-[14rem]';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={storyScrollContainerRef}
|
||||
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
|
||||
className={`pixel-nine-slice pixel-panel mb-3 min-h-0 overflow-y-auto pr-1 scrollbar-hide ${storyPanelClassName}`}
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel)}
|
||||
>
|
||||
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
|
||||
@@ -787,6 +845,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
setNpcChatDraft: (value: string) => void;
|
||||
npcChatPlaceholder: string;
|
||||
submitNpcChatDraft: () => void;
|
||||
inBattle: boolean;
|
||||
}) {
|
||||
const {
|
||||
isNpcChatMode,
|
||||
@@ -813,11 +872,29 @@ function RpgAdventureChoiceSection(props: {
|
||||
setNpcChatDraft,
|
||||
npcChatPlaceholder,
|
||||
submitNpcChatDraft,
|
||||
inBattle,
|
||||
} = props;
|
||||
const [battleChoiceViewportRef, battleChoiceViewportHeight] =
|
||||
useMeasuredElementHeight<HTMLDivElement>(
|
||||
inBattle && !isNpcChatMode && !shouldHideChoiceUi,
|
||||
);
|
||||
const visibleDisplayedOptions =
|
||||
inBattle && !isNpcChatMode && !shouldHideChoiceUi
|
||||
? displayedOptions.slice(
|
||||
0,
|
||||
getBattleVisibleOptionCount(
|
||||
battleChoiceViewportHeight,
|
||||
displayedOptions.length,
|
||||
),
|
||||
)
|
||||
: displayedOptions;
|
||||
|
||||
return (
|
||||
<div className="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
|
||||
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<div
|
||||
className={`mt-auto min-h-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.1rem)] pt-1.5 ${inBattle ? 'flex flex-1 flex-col' : 'shrink-0'}`}
|
||||
>
|
||||
{/* 让底部操作区整体更贴近屏幕底部,同时把上方聊天区腾出更稳定的展示高度。 */}
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -880,12 +957,15 @@ function RpgAdventureChoiceSection(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
ref={battleChoiceViewportRef}
|
||||
className={`space-y-1.5 ${inBattle ? 'min-h-0 flex-1 overflow-hidden' : ''}`}
|
||||
>
|
||||
{isLoading && !isStoryStreaming ? (
|
||||
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-xs uppercase tracking-widest">
|
||||
剧情推演中...
|
||||
{inBattle ? '战斗结算中...' : '剧情推演中...'}
|
||||
</span>
|
||||
</div>
|
||||
) : isStoryStreaming ? (
|
||||
@@ -896,7 +976,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
<div className="p-4" aria-hidden="true" />
|
||||
) : (
|
||||
<>
|
||||
{displayedOptions.map((option, index) => {
|
||||
{visibleDisplayedOptions.map((option, index) => {
|
||||
const optionImpactSummary = getOptionImpactSummary(
|
||||
option,
|
||||
playerCharacter,
|
||||
@@ -970,7 +1050,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
);
|
||||
})}
|
||||
{isNpcChatMode && !isNpcQuestOfferMode ? (
|
||||
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
|
||||
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 px-1.5 pb-1.5 pt-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
value={npcChatDraft}
|
||||
@@ -985,7 +1065,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
}
|
||||
}}
|
||||
placeholder={npcChatPlaceholder}
|
||||
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
|
||||
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
|
||||
maxLength={80}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -993,7 +1073,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
type="button"
|
||||
onClick={submitNpcChatDraft}
|
||||
disabled={isLoading || !npcChatDraft.trim()}
|
||||
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
|
||||
className="inline-flex h-10 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
@@ -1161,6 +1241,7 @@ export function RpgAdventurePanel({
|
||||
playerMana,
|
||||
playerMaxMana,
|
||||
playerSkillCooldowns,
|
||||
inBattle,
|
||||
currentNpcBattleMode,
|
||||
statistics,
|
||||
musicVolume,
|
||||
@@ -1550,18 +1631,20 @@ export function RpgAdventurePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RpgAdventureStorySection
|
||||
currentSceneActTitle={currentSceneActTitle}
|
||||
currentSceneActIndex={currentSceneActIndex}
|
||||
currentSceneActCount={currentSceneActCount}
|
||||
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
|
||||
storyScrollContainerRef={storyScrollContainerRef}
|
||||
isDialogueStory={isDialogueStory}
|
||||
dialogueTurns={dialogueTurns}
|
||||
isNpcChatMode={isNpcChatMode}
|
||||
isStoryStreaming={isStoryStreaming}
|
||||
currentStory={currentStory}
|
||||
/>
|
||||
{!inBattle ? (
|
||||
<RpgAdventureStorySection
|
||||
currentSceneActTitle={currentSceneActTitle}
|
||||
currentSceneActIndex={currentSceneActIndex}
|
||||
currentSceneActCount={currentSceneActCount}
|
||||
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
|
||||
storyScrollContainerRef={storyScrollContainerRef}
|
||||
isDialogueStory={isDialogueStory}
|
||||
dialogueTurns={dialogueTurns}
|
||||
isNpcChatMode={isNpcChatMode}
|
||||
isStoryStreaming={isStoryStreaming}
|
||||
currentStory={currentStory}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<RpgAdventureChoiceSection
|
||||
isNpcChatMode={isNpcChatMode}
|
||||
@@ -1590,6 +1673,7 @@ export function RpgAdventurePanel({
|
||||
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
|
||||
}
|
||||
submitNpcChatDraft={submitNpcChatDraft}
|
||||
inBattle={inBattle}
|
||||
/>
|
||||
|
||||
<RpgAdventureOverlaySection
|
||||
|
||||
277
src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx
Normal file
277
src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AnimationState, WorldType, type GameState } from '../../types';
|
||||
import { RpgRuntimeShell } from './RpgRuntimeShell';
|
||||
import type { RpgRuntimeShellProps } from './types';
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./useRpgRuntimeShellViewModel', () => ({
|
||||
useRpgRuntimeShellViewModel: () => ({
|
||||
selectionStage: 'platform',
|
||||
setSelectionStage: () => {},
|
||||
overlayPanel: null,
|
||||
openOverlayPanel: () => {},
|
||||
closeOverlayPanel: () => {},
|
||||
selectedSceneEntity: null,
|
||||
setSelectedSceneEntity: () => {},
|
||||
openPartyMemberDetails: () => {},
|
||||
closeAdventureEntityModal: () => {},
|
||||
showTeamModal: false,
|
||||
openCampModal: () => {},
|
||||
closeCampModal: () => {},
|
||||
resetForSaveAndExit: () => {},
|
||||
shouldMountAdventureEntityModal: false,
|
||||
shouldMountCampModal: false,
|
||||
shouldMountMapModal: false,
|
||||
shouldMountCharacterChatModal: false,
|
||||
shouldMountNpcModals: false,
|
||||
visibleGameState: mockVisibleGameState,
|
||||
visibleCurrentStory: {
|
||||
storyText: '测试故事',
|
||||
options: [],
|
||||
},
|
||||
sceneTransitionPhase: 'idle',
|
||||
sceneTransitionToken: 0,
|
||||
setSceneTransitionDurations: () => {},
|
||||
isCharacterSelectionStage: false,
|
||||
shouldHideStoryOptions: false,
|
||||
hideSelectionHero: false,
|
||||
dialogueIndicator: {
|
||||
showPlayer: false,
|
||||
showEncounter: false,
|
||||
activeSpeaker: null,
|
||||
},
|
||||
characterChatSummaries: {},
|
||||
canvasCompanionRenderStates: [],
|
||||
adventureStatistics: {
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '测试场景',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
},
|
||||
handleSceneTransitionChoice: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./RpgRuntimeCanvasStage', () => ({
|
||||
RpgRuntimeCanvasStage: () => <div>画布舞台</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./RpgRuntimeOverlayHost', () => ({
|
||||
RpgRuntimeOverlayHost: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./RpgRuntimeStageRouter', () => ({
|
||||
RpgRuntimeStageRouter: () => <div>运行时主面板</div>,
|
||||
}));
|
||||
|
||||
let mockVisibleGameState: GameState;
|
||||
|
||||
function createGameState(runtimeMode: GameState['runtimeMode']): GameState {
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: {
|
||||
id: 'player-1',
|
||||
name: '测试角色',
|
||||
title: '测试者',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
personality: '冷静',
|
||||
motivation: '完成测试',
|
||||
combatStyle: '均衡',
|
||||
role: '主角',
|
||||
avatar: '',
|
||||
portrait: '',
|
||||
imageSrc: '',
|
||||
initialAffinity: 0,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
backstoryReveal: {
|
||||
publicSummary: '测试',
|
||||
privateChatUnlockAffinity: 60,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
runtimeMode,
|
||||
runtimePersistenceDisabled: runtimeMode !== 'play',
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
playerProgression: {
|
||||
level: 1,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 0,
|
||||
xpToNextLevel: 100,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
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: 50,
|
||||
playerMaxMana: 50,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProps(runtimeMode: GameState['runtimeMode']): RpgRuntimeShellProps {
|
||||
const gameState = createGameState(runtimeMode);
|
||||
mockVisibleGameState = gameState;
|
||||
return {
|
||||
session: {
|
||||
gameState,
|
||||
currentStory: {
|
||||
storyText: '测试故事',
|
||||
options: [],
|
||||
},
|
||||
isLoading: false,
|
||||
aiError: null,
|
||||
bottomTab: 'adventure',
|
||||
setBottomTab: () => {},
|
||||
isMapOpen: false,
|
||||
setIsMapOpen: () => {},
|
||||
},
|
||||
story: {
|
||||
displayedOptions: [],
|
||||
canRefreshOptions: false,
|
||||
handleRefreshOptions: () => {},
|
||||
handleChoice: () => {},
|
||||
handleNpcChatInput: () => false,
|
||||
refreshNpcChatOptions: () => false,
|
||||
exitNpcChat: () => false,
|
||||
handleMapTravelToScene: () => false,
|
||||
npcUi: {
|
||||
isNpcModalOpen: false,
|
||||
currentNpcEncounter: null,
|
||||
selectedNpc: null,
|
||||
isGeneratingNpcResponse: false,
|
||||
npcResponseError: null,
|
||||
generatedNpcText: '',
|
||||
npcResponseOptions: [],
|
||||
selectedOptionId: null,
|
||||
},
|
||||
characterChatUi: {
|
||||
isCharacterChatModalOpen: false,
|
||||
activeCharacter: null,
|
||||
},
|
||||
inventoryUi: {
|
||||
isInventoryOpen: false,
|
||||
},
|
||||
battleRewardUi: {
|
||||
isRewardModalOpen: false,
|
||||
rewards: [],
|
||||
},
|
||||
questUi: {
|
||||
isQuestPanelOpen: false,
|
||||
},
|
||||
npcChatQuestOfferUi: {
|
||||
isOfferModalOpen: false,
|
||||
pendingQuest: null,
|
||||
},
|
||||
goalUi: {
|
||||
isGoalPanelOpen: false,
|
||||
entries: [],
|
||||
},
|
||||
},
|
||||
entry: {
|
||||
hasSavedGame: false,
|
||||
savedSnapshot: null,
|
||||
handleContinueGame: () => {},
|
||||
handleStartNewGame: () => {},
|
||||
handleSaveAndExit: () => {},
|
||||
handleCustomWorldSelect: () => {},
|
||||
handleBackToWorldSelect: () => {},
|
||||
handleCharacterSelect: () => {},
|
||||
},
|
||||
companions: {
|
||||
companionRenderStates: [],
|
||||
buildCompanionRenderStates: () => [],
|
||||
onBenchCompanion: () => {},
|
||||
onActivateRosterCompanion: () => {},
|
||||
},
|
||||
audio: {
|
||||
musicVolume: 0.5,
|
||||
onMusicVolumeChange: () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockVisibleGameState = createGameState('play');
|
||||
});
|
||||
|
||||
test('测试态显示结束测试按钮并触发退出回调', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExitTestRuntime = vi.fn();
|
||||
|
||||
render(
|
||||
<RpgRuntimeShell
|
||||
{...buildProps('test')}
|
||||
onExitTestRuntime={onExitTestRuntime}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '结束测试' }));
|
||||
|
||||
expect(onExitTestRuntime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('正式运行态不显示结束测试按钮', () => {
|
||||
render(<RpgRuntimeShell {...buildProps('play')} onExitTestRuntime={() => {}} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '结束测试' })).toBeNull();
|
||||
});
|
||||
@@ -37,6 +37,7 @@ export function RpgRuntimeShell({
|
||||
companions,
|
||||
audio,
|
||||
chrome,
|
||||
onExitTestRuntime,
|
||||
}: RpgRuntimeShellComponentProps) {
|
||||
const authUi = useAuthUi();
|
||||
const isPlatformShell = !session.gameState.worldType;
|
||||
@@ -132,6 +133,7 @@ export function RpgRuntimeShell({
|
||||
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
|
||||
),
|
||||
);
|
||||
const isTestRuntime = gameState.runtimeMode === 'test';
|
||||
|
||||
useEffect(() => {
|
||||
if (gameState.worldType && !gameState.playerCharacter) {
|
||||
@@ -207,6 +209,23 @@ export function RpgRuntimeShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState.worldType && isTestRuntime && onExitTestRuntime ? (
|
||||
<div
|
||||
className="fixed inset-x-0 z-[170] flex justify-center px-4"
|
||||
style={{
|
||||
top: 'calc(36vh - 3.25rem)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExitTestRuntime}
|
||||
className="inline-flex min-h-[2.75rem] items-center justify-center rounded-full border border-white/15 bg-black/65 px-5 text-sm font-semibold text-white shadow-[0_12px_30px_rgba(0,0,0,0.38)] backdrop-blur-sm transition hover:border-white/28 hover:bg-black/78"
|
||||
>
|
||||
结束测试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RpgRuntimeStageRouter
|
||||
gameState={gameState}
|
||||
visibleGameState={visibleGameState}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
|
||||
|
||||
export interface RpgRuntimeSessionProps {
|
||||
gameState: GameState;
|
||||
@@ -53,7 +54,10 @@ export interface RpgEntrySessionProps {
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: Character) => void;
|
||||
}
|
||||
@@ -107,4 +111,5 @@ export interface RpgRuntimeShellProps {
|
||||
companions: RpgRuntimeCompanionProps;
|
||||
audio: RpgRuntimeAudioProps;
|
||||
chrome?: RpgRuntimeShellChromeOptions;
|
||||
onExitTestRuntime?: () => void;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,51 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('把幕配置里的角色名归一到真实角色 id', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '雾港归航',
|
||||
settingText: '海雾旧案',
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-cendeng',
|
||||
name: '岑灯',
|
||||
title: '返乡守灯人',
|
||||
role: '主角代理',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-luheng',
|
||||
name: '陆衡',
|
||||
title: '航运公会审计员',
|
||||
role: '第一幕主NPC',
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '开局章节',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
title: '第一幕',
|
||||
summary: '陆衡先拦住玩家。',
|
||||
encounterNpcIds: ['陆衡'],
|
||||
primaryNpcId: '航运公会审计员',
|
||||
oppositeNpcId: '陆衡',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const act = profile?.sceneChapterBlueprints?.[0]?.acts[0];
|
||||
expect(act?.encounterNpcIds).toEqual(['story-luheng']);
|
||||
expect(act?.primaryNpcId).toBe('story-luheng');
|
||||
expect(act?.oppositeNpcId).toBe('story-luheng');
|
||||
});
|
||||
|
||||
it('直接读取 Rust 草稿角色字段和形象资源', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '雾港归航',
|
||||
@@ -121,4 +166,3 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
normalizeCustomWorldLockState,
|
||||
} from '../services/customWorldCreatorIntent';
|
||||
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
|
||||
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
|
||||
import {
|
||||
AnimationState,
|
||||
CharacterAnimationConfig,
|
||||
@@ -971,18 +972,30 @@ function normalizeSceneActBlueprint(
|
||||
value: unknown,
|
||||
index: number,
|
||||
sceneId: string,
|
||||
profileRoles?: {
|
||||
playableNpcs: CustomWorldPlayableNpc[];
|
||||
storyNpcs: CustomWorldNpc[];
|
||||
} | null,
|
||||
): SceneActBlueprint | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encounterNpcIds = toStringArray(value.encounterNpcIds);
|
||||
const encounterNpcIds = toStringArray(value.encounterNpcIds).map((npcId) =>
|
||||
resolveCustomWorldRoleIdReference(profileRoles, npcId),
|
||||
);
|
||||
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
|
||||
const advanceRule = toText(value.advanceRule);
|
||||
const title = toText(value.title);
|
||||
const summary = toText(value.summary);
|
||||
const primaryNpcId = toText(value.primaryNpcId, encounterNpcIds[0] ?? '');
|
||||
const oppositeNpcId = toText(value.oppositeNpcId, primaryNpcId);
|
||||
const primaryNpcId = resolveCustomWorldRoleIdReference(
|
||||
profileRoles,
|
||||
toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
|
||||
);
|
||||
const oppositeNpcId = resolveCustomWorldRoleIdReference(
|
||||
profileRoles,
|
||||
toText(value.oppositeNpcId, primaryNpcId),
|
||||
);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
return null;
|
||||
@@ -1020,7 +1033,13 @@ function normalizeSceneActBlueprint(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSceneChapterBlueprints(value: unknown) {
|
||||
function normalizeSceneChapterBlueprints(
|
||||
value: unknown,
|
||||
profileRoles?: {
|
||||
playableNpcs: CustomWorldPlayableNpc[];
|
||||
storyNpcs: CustomWorldNpc[];
|
||||
} | null,
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
@@ -1036,7 +1055,12 @@ function normalizeSceneChapterBlueprints(value: unknown) {
|
||||
const acts = Array.isArray(entry.acts)
|
||||
? entry.acts
|
||||
.map((act, actIndex) =>
|
||||
normalizeSceneActBlueprint(act, actIndex, sceneId),
|
||||
normalizeSceneActBlueprint(
|
||||
act,
|
||||
actIndex,
|
||||
sceneId,
|
||||
profileRoles,
|
||||
),
|
||||
)
|
||||
.filter((act): act is SceneActBlueprint => Boolean(act))
|
||||
: [];
|
||||
@@ -1126,6 +1150,11 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
|
||||
: [];
|
||||
|
||||
const playableNpcs = Array.isArray(value.playableNpcs)
|
||||
? value.playableNpcs
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [];
|
||||
const normalizedProfile = {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
settingText,
|
||||
@@ -1144,11 +1173,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
value.attributeSchema,
|
||||
generatedAttributeSchema,
|
||||
),
|
||||
playableNpcs: Array.isArray(value.playableNpcs)
|
||||
? value.playableNpcs
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [],
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
items: Array.isArray(value.items)
|
||||
? value.items
|
||||
@@ -1168,6 +1193,10 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
|
||||
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
|
||||
value.sceneChapterBlueprints,
|
||||
{
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
},
|
||||
),
|
||||
anchorContent: preserveStructuredRecord<EightAnchorContent>(
|
||||
value.anchorContent,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
|
||||
import {
|
||||
canUseLimitedPrimaryNpcChat,
|
||||
resolveActiveSceneActEncounterFocusNpcId,
|
||||
@@ -145,9 +146,16 @@ function getAvailableActiveSceneActNpcs(state: GameState) {
|
||||
|
||||
return (state.currentScenePreset?.npcs ?? [])
|
||||
.filter(candidate => {
|
||||
const candidateIds = [candidate.id, candidate.characterId].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
const candidateIds = [
|
||||
candidate.id,
|
||||
candidate.characterId,
|
||||
candidate.name,
|
||||
candidate.title,
|
||||
]
|
||||
.map((value) =>
|
||||
resolveCustomWorldRoleIdReference(state.customWorldProfile, value),
|
||||
)
|
||||
.filter(Boolean);
|
||||
return candidateIds.some(id => activeActNpcIdSet.has(id));
|
||||
})
|
||||
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
||||
@@ -180,8 +188,19 @@ function pickFriendlySceneNpcForActiveAct(state: GameState, npcs: SceneNpc[]) {
|
||||
return (
|
||||
npcs.find(
|
||||
(npc) =>
|
||||
npc.id === focusNpcId ||
|
||||
(npc.characterId ? npc.characterId === focusNpcId : false),
|
||||
resolveCustomWorldRoleIdReference(state.customWorldProfile, npc.id) === focusNpcId ||
|
||||
resolveCustomWorldRoleIdReference(
|
||||
state.customWorldProfile,
|
||||
npc.characterId,
|
||||
) === focusNpcId ||
|
||||
resolveCustomWorldRoleIdReference(
|
||||
state.customWorldProfile,
|
||||
npc.name,
|
||||
) === focusNpcId ||
|
||||
resolveCustomWorldRoleIdReference(
|
||||
state.customWorldProfile,
|
||||
npc.title,
|
||||
) === focusNpcId,
|
||||
) ?? pickRandomItem(npcs)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
|
||||
import { resolveCustomWorldRoleIdReferences } from '../services/customWorldRoleReferences';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
@@ -406,9 +407,11 @@ function collectSceneActNpcIdsForScene(
|
||||
}
|
||||
|
||||
chapter.acts.forEach((act) => {
|
||||
pushNpcId(act.primaryNpcId);
|
||||
pushNpcId(act.oppositeNpcId);
|
||||
act.encounterNpcIds.forEach(pushNpcId);
|
||||
resolveCustomWorldRoleIdReferences(profile, [
|
||||
act.primaryNpcId,
|
||||
act.oppositeNpcId,
|
||||
...act.encounterNpcIds,
|
||||
]).forEach(pushNpcId);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -229,7 +229,92 @@ describe('buildBattlePlan', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not turn recovery fallback into a random player attack', () => {
|
||||
it('keeps battle_attack_basic as a single basic attack instead of randomly selecting another skill', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerMana: 20,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: '山狼',
|
||||
action: '压低身体',
|
||||
description: '测试敌人',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 80,
|
||||
maxHp: 80,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: {
|
||||
...createBattleOption(),
|
||||
functionId: 'battle_attack_basic',
|
||||
},
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 900,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 1,
|
||||
});
|
||||
|
||||
const playerTurns = plan.turns.filter((turn) => turn.actor === 'player');
|
||||
|
||||
expect(playerTurns).toHaveLength(1);
|
||||
expect(playerTurns[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
selectedSkillId: 'battle-basic-attack',
|
||||
}),
|
||||
);
|
||||
expect(plan.finalState.playerMana).toBe(state.playerMana);
|
||||
});
|
||||
|
||||
it('resolves one full speed-ordered round when combat continues', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: '山狼',
|
||||
action: '压低身体',
|
||||
description: '测试敌人',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 120,
|
||||
maxHp: 120,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: {
|
||||
...createBattleOption(),
|
||||
functionId: 'battle_attack_basic',
|
||||
},
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 6000,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 6,
|
||||
});
|
||||
|
||||
expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']);
|
||||
expect(plan.finalState.inBattle).toBe(true);
|
||||
expect(plan.finalState.sceneHostileNpcs[0]?.hp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps recovery as a player turn without converting it into an attack', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerHp: 40,
|
||||
@@ -265,8 +350,77 @@ describe('buildBattlePlan', () => {
|
||||
minTurnCount: 1,
|
||||
});
|
||||
|
||||
expect(plan.turns.some((turn) => turn.actor === 'player')).toBe(false);
|
||||
expect(plan.preparedState.playerHp).toBeGreaterThan(state.playerHp);
|
||||
expect(plan.preparedState.playerMana).toBeGreaterThan(state.playerMana);
|
||||
const playerTurn = plan.turns.find((turn) => turn.actor === 'player');
|
||||
|
||||
expect(playerTurn).toEqual(
|
||||
expect.objectContaining({
|
||||
actor: 'player',
|
||||
actionKind: 'recover',
|
||||
selectedSkillId: null,
|
||||
damage: 0,
|
||||
}),
|
||||
);
|
||||
expect(plan.finalState.playerHp).toBeGreaterThan(state.playerHp);
|
||||
expect(plan.finalState.playerMana).toBeGreaterThan(state.playerMana);
|
||||
});
|
||||
|
||||
it('includes companion turns in fight mode and orders the round by speed', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
companions: [
|
||||
{
|
||||
npcId: 'companion-1',
|
||||
characterId: 'archer-hero',
|
||||
joinedAtAffinity: 10,
|
||||
hp: 60,
|
||||
maxHp: 60,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: '山狼',
|
||||
action: '压低身体',
|
||||
description: '测试敌人',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 0.5,
|
||||
hp: 120,
|
||||
maxHp: 120,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: {
|
||||
...createBattleOption(),
|
||||
functionId: 'battle_attack_basic',
|
||||
},
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 6000,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 6,
|
||||
});
|
||||
|
||||
expect(plan.turns.map((turn) => turn.actor)).toEqual([
|
||||
'companion',
|
||||
'player',
|
||||
'monster',
|
||||
]);
|
||||
expect(plan.turns[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
actor: 'companion',
|
||||
companionNpcId: 'companion-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -185,4 +185,62 @@ describe('escapeFlow', () => {
|
||||
expect(result.scrollWorld).toBe(false);
|
||||
expect(result.playerFacing).toBe('right');
|
||||
});
|
||||
|
||||
it('plays left exit and right-facing entry when escape targets a scene start', async () => {
|
||||
const state = {
|
||||
...createState(),
|
||||
currentScenePreset: {
|
||||
id: 'scene-bridge',
|
||||
name: 'Bridge',
|
||||
description: 'Bridge',
|
||||
imageSrc: '/bridge.png',
|
||||
worldType: WorldType.WUXIA,
|
||||
connectedSceneIds: [],
|
||||
connections: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
};
|
||||
const targetScene = {
|
||||
...state.currentScenePreset!,
|
||||
id: 'scene-east',
|
||||
name: 'East Street',
|
||||
};
|
||||
const option = {
|
||||
...createEscapeOption(),
|
||||
runtimePayload: {
|
||||
escapeTargetSceneId: targetScene.id,
|
||||
escapeEntry: 'from_left',
|
||||
},
|
||||
};
|
||||
const finalState = buildEscapeAfterSequence(state, option, targetScene);
|
||||
const committedStates: GameState[] = [];
|
||||
|
||||
const result = await playEscapeSequenceWithStorySync({
|
||||
setGameState: (nextState: GameState) => {
|
||||
committedStates.push(nextState);
|
||||
},
|
||||
state,
|
||||
option,
|
||||
finalState,
|
||||
sleepMs: async () => {
|
||||
await Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
expect(committedStates[0]).toEqual(expect.objectContaining({
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.RUN,
|
||||
scrollWorld: true,
|
||||
}));
|
||||
expect(committedStates.some((committedState) =>
|
||||
committedState.currentScenePreset?.id === 'scene-east' &&
|
||||
committedState.playerX < 0 &&
|
||||
committedState.playerFacing === 'right',
|
||||
)).toBe(true);
|
||||
expect(result.currentScenePreset?.id).toBe('scene-east');
|
||||
expect(result.playerX).toBe(0);
|
||||
expect(result.playerFacing).toBe('right');
|
||||
expect(result.scrollWorld).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../../data/encounterTransition';
|
||||
import {
|
||||
getFacingTowardPlayer,
|
||||
settleHostileNpcAnimations,
|
||||
} from '../../data/hostileNpcs';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getFunctionEffect } from '../../data/stateFunctions';
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -15,6 +25,9 @@ import {
|
||||
const ESCAPE_RUN_MS = 5000;
|
||||
const ESCAPE_TICK_MS = 250;
|
||||
const ESCAPE_TURN_PAUSE_MS = 180;
|
||||
const ESCAPE_ENTRY_MS = 900;
|
||||
const ESCAPE_ENTRY_TICK_MS = 90;
|
||||
const ESCAPE_PLAYER_ENTRY_X = -1.4;
|
||||
|
||||
export type EscapePlaybackSync = {
|
||||
waitForStoryResponse?: Promise<void>;
|
||||
@@ -22,7 +35,7 @@ export type EscapePlaybackSync = {
|
||||
|
||||
type SetGameStateFn = Dispatch<SetStateAction<GameState>> | ((state: GameState) => void);
|
||||
|
||||
function sleep(ms: number) {
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -35,6 +48,10 @@ function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
|
||||
}));
|
||||
}
|
||||
|
||||
function lerpMeters(start: number, end: number, progress: number) {
|
||||
return Number((start + ((end - start) * progress)).toFixed(2));
|
||||
}
|
||||
|
||||
export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
|
||||
const escapeDistance = getFunctionEffect(option.functionId).escapeDistance ?? 5;
|
||||
const settleOffset = Math.max(1, Math.min(1.4, escapeDistance * 0.24));
|
||||
@@ -44,18 +61,22 @@ export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
|
||||
export function buildEscapeAfterSequence(
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
nextScenePreset: GameState['currentScenePreset'] = state.currentScenePreset,
|
||||
) {
|
||||
const escapePlayerX = getEscapeSettlePlayerX(state, option);
|
||||
|
||||
return {
|
||||
const shouldResetToSceneStart =
|
||||
nextScenePreset?.id !== state.currentScenePreset?.id ||
|
||||
option.runtimePayload?.escapeReturnToSceneStart === true;
|
||||
const baseState = {
|
||||
...state,
|
||||
currentScenePreset: nextScenePreset ?? state.currentScenePreset,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: escapePlayerX,
|
||||
playerX: shouldResetToSceneStart ? 0 : escapePlayerX,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
@@ -66,6 +87,66 @@ export function buildEscapeAfterSequence(
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
} satisfies GameState;
|
||||
const previewState = shouldResetToSceneStart
|
||||
? ({
|
||||
...baseState,
|
||||
...createSceneEncounterPreview(baseState),
|
||||
} satisfies GameState)
|
||||
: baseState;
|
||||
|
||||
return hasEncounterEntity(previewState)
|
||||
? resolveSceneEncounterPreview(previewState)
|
||||
: baseState;
|
||||
}
|
||||
|
||||
async function playEscapeEntrySequence(params: {
|
||||
setGameState: SetGameStateFn;
|
||||
finalState: GameState;
|
||||
sleepMs: (ms: number) => Promise<void>;
|
||||
}) {
|
||||
const entryState = buildEncounterEntryState(
|
||||
{
|
||||
...params.finalState,
|
||||
playerX: ESCAPE_PLAYER_ENTRY_X,
|
||||
playerFacing: 'right',
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: true,
|
||||
},
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
);
|
||||
const runTicks = Math.max(1, Math.ceil(ESCAPE_ENTRY_MS / ESCAPE_ENTRY_TICK_MS));
|
||||
const tickDurationMs = Math.max(1, Math.round(ESCAPE_ENTRY_MS / runTicks));
|
||||
let currentState = entryState;
|
||||
|
||||
params.setGameState(currentState);
|
||||
|
||||
for (let tick = 1; tick <= runTicks; tick += 1) {
|
||||
const progress = tick / runTicks;
|
||||
const interpolatedState = interpolateEncounterTransitionState(
|
||||
entryState,
|
||||
params.finalState,
|
||||
progress,
|
||||
);
|
||||
currentState = {
|
||||
...interpolatedState,
|
||||
playerX: lerpMeters(
|
||||
ESCAPE_PLAYER_ENTRY_X,
|
||||
params.finalState.playerX,
|
||||
progress,
|
||||
),
|
||||
playerFacing: 'right',
|
||||
animationState:
|
||||
progress < 1 ? AnimationState.RUN : params.finalState.animationState,
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: progress < 1,
|
||||
};
|
||||
params.setGameState(currentState);
|
||||
await params.sleepMs(tickDurationMs);
|
||||
}
|
||||
|
||||
params.setGameState(params.finalState);
|
||||
return params.finalState;
|
||||
}
|
||||
|
||||
export async function playEscapeSequenceWithStorySync(params: {
|
||||
@@ -93,6 +174,9 @@ export async function playEscapeSequenceWithStorySync(params: {
|
||||
const settlePlayerX = finalState.playerX;
|
||||
let storyResponseReady = !sync?.waitForStoryResponse;
|
||||
let elapsedMs = 0;
|
||||
const shouldPlayEntry =
|
||||
finalState.currentScenePreset?.id !== state.currentScenePreset?.id ||
|
||||
option.runtimePayload?.escapeReturnToSceneStart === true;
|
||||
|
||||
void sync?.waitForStoryResponse?.then(() => {
|
||||
storyResponseReady = true;
|
||||
@@ -127,19 +211,39 @@ export async function playEscapeSequenceWithStorySync(params: {
|
||||
await sleepMs(ESCAPE_TICK_MS);
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...finalState,
|
||||
playerX: settlePlayerX,
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
|
||||
};
|
||||
const settledExitState: GameState = shouldPlayEntry
|
||||
? {
|
||||
...finalState,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, 0),
|
||||
}
|
||||
: {
|
||||
...finalState,
|
||||
playerX: settlePlayerX,
|
||||
playerFacing: 'left' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
|
||||
};
|
||||
currentState = settledExitState;
|
||||
setGameState(currentState);
|
||||
await sleepMs(ESCAPE_TURN_PAUSE_MS);
|
||||
|
||||
if (shouldPlayEntry) {
|
||||
return playEscapeEntrySequence({
|
||||
setGameState,
|
||||
finalState: settledExitState,
|
||||
sleepMs,
|
||||
});
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerFacing: 'right',
|
||||
|
||||
@@ -52,6 +52,20 @@ function sleep(ms: number) {
|
||||
}
|
||||
|
||||
function getSkillById(character: Character, skillId: string) {
|
||||
if (skillId === 'battle-basic-attack') {
|
||||
return {
|
||||
id: 'battle-basic-attack',
|
||||
name: '普通攻击',
|
||||
animation: AnimationState.ATTACK,
|
||||
damage: 0,
|
||||
manaCost: 0,
|
||||
cooldownTurns: 0,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
delivery: 'melee',
|
||||
} satisfies CharacterSkillDefinition;
|
||||
}
|
||||
|
||||
return character.skills.find(skill => skill.id === skillId) ?? null;
|
||||
}
|
||||
|
||||
@@ -192,6 +206,47 @@ async function playBattleSequence(params: CombatPlaybackParams & {
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.actionKind === 'recover') {
|
||||
currentState = {
|
||||
...currentState,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
playerHp: step.playerHpAfterAction,
|
||||
playerMana: step.playerManaAfterAction,
|
||||
playerSkillCooldowns: step.appliedCooldowns,
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.actionKind === 'inventory') {
|
||||
currentState = {
|
||||
...currentState,
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
playerActionMode: 'idle',
|
||||
playerHp: step.playerHpAfterAction,
|
||||
playerMana: step.playerManaAfterAction,
|
||||
playerSkillCooldowns: step.appliedCooldowns,
|
||||
playerInventory:
|
||||
step.playerInventoryAfterAction ?? currentState.playerInventory,
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(Math.max(180, resetStageMs));
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const skill = step.selectedSkillId ? getSkillById(character, step.selectedSkillId) : null;
|
||||
if (!skill) {
|
||||
await sleep(resetStageMs);
|
||||
@@ -353,7 +408,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.delivery === 'melee' && step.strikeOffsetX > 0) {
|
||||
if (step.delivery === 'melee' && Math.abs(step.strikeOffsetX) > 0.01) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: updateCompanionState(
|
||||
@@ -740,4 +795,3 @@ export function createCombatPlayback(params: CombatPlaybackParams) {
|
||||
playResolvedChoice,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,50 @@ describe('buildResolvedChoiceState', () => {
|
||||
expect(resolved.afterSequence.playerFacing).toBe('right');
|
||||
});
|
||||
|
||||
it('moves escape result to explicit target scene and resets player to scene start', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: scenes[0] as GameState['currentScenePreset'],
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: 'Wolf',
|
||||
action: 'growls',
|
||||
description: 'A wolf',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const option = {
|
||||
...createOption('battle_escape_breakout'),
|
||||
runtimePayload: {
|
||||
escapeTargetSceneId: 'scene-3',
|
||||
escapeEntry: 'from_left',
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = buildResolvedChoiceState({
|
||||
state,
|
||||
option,
|
||||
character: createTestCharacter(),
|
||||
buildBattlePlan: vi.fn(),
|
||||
});
|
||||
|
||||
expect(resolved.optionKind).toBe('escape');
|
||||
expect(resolved.afterSequence.currentScenePreset?.id).toBe('scene-3');
|
||||
expect(resolved.afterSequence.playerX).toBe(0);
|
||||
expect(resolved.afterSequence.playerFacing).toBe('right');
|
||||
expect(resolved.afterSequence.inBattle).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps idle follow-up generation separate from combat planning', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
|
||||
@@ -58,15 +58,17 @@ function getSceneTargetForFunction(
|
||||
): GameState['currentScenePreset'] {
|
||||
if (!worldType) return currentScenePreset;
|
||||
|
||||
if (option.functionId === 'idle_travel_next_scene') {
|
||||
const targetSceneId =
|
||||
typeof option.runtimePayload?.targetSceneId === 'string'
|
||||
? option.runtimePayload.targetSceneId
|
||||
const targetSceneId =
|
||||
typeof option.runtimePayload?.targetSceneId === 'string'
|
||||
? option.runtimePayload.targetSceneId
|
||||
: typeof option.runtimePayload?.escapeTargetSceneId === 'string'
|
||||
? option.runtimePayload.escapeTargetSceneId
|
||||
: null;
|
||||
if (targetSceneId) {
|
||||
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
|
||||
}
|
||||
if (targetSceneId) {
|
||||
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
|
||||
}
|
||||
|
||||
if (option.functionId === 'idle_travel_next_scene') {
|
||||
return getTravelScenePreset(worldType, currentScenePreset?.id) ?? currentScenePreset;
|
||||
}
|
||||
|
||||
@@ -114,7 +116,7 @@ export function buildResolvedChoiceState(params: {
|
||||
return {
|
||||
optionKind,
|
||||
battlePlan: null,
|
||||
afterSequence: buildEscapeAfterSequence(state, option),
|
||||
afterSequence: buildEscapeAfterSequence(state, option, nextScenePreset),
|
||||
} satisfies ResolvedChoiceState;
|
||||
}
|
||||
|
||||
|
||||
@@ -738,4 +738,204 @@ describe('createStoryChoiceActions', () => {
|
||||
expect.objectContaining({ hostileNpcsDefeated: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps battle attack and skill choices on the local combat path even if runtime server supports them', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf-1',
|
||||
name: '山狼',
|
||||
action: '低伏逼近',
|
||||
description: '一头山狼',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const option: StoryOption = {
|
||||
...createBattleOption('battle_use_skill'),
|
||||
runtimePayload: {
|
||||
skillId: 'skill-basic',
|
||||
},
|
||||
};
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const buildResolvedChoiceState = vi.fn(() => ({
|
||||
optionKind: 'battle' as const,
|
||||
battlePlan: null,
|
||||
afterSequence: state,
|
||||
}));
|
||||
const playResolvedChoice = vi.fn().mockResolvedValue(state);
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: true,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => [option]),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
|
||||
getResolvedSceneHostileNpcs: vi.fn(
|
||||
(inputState: GameState) => inputState.sceneHostileNpcs,
|
||||
),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
|
||||
state,
|
||||
option,
|
||||
state.playerCharacter!,
|
||||
);
|
||||
expect(playResolvedChoice).toHaveBeenCalled();
|
||||
expect(setGameState).toHaveBeenCalled();
|
||||
expect(setCurrentStory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps stale battle panel choices on the local combat path when combat presentation is still visible', async () => {
|
||||
const battleOption = createBattleOption('battle_attack_basic');
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf-1',
|
||||
name: '山狼',
|
||||
action: '低伏逼近',
|
||||
description: '一头山狼',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '山狼还在你面前压低身位,战斗并未真正结束。',
|
||||
options: [battleOption],
|
||||
};
|
||||
const buildResolvedChoiceState = vi.fn(() => ({
|
||||
optionKind: 'battle' as const,
|
||||
battlePlan: null,
|
||||
afterSequence: {
|
||||
...state,
|
||||
inBattle: true,
|
||||
},
|
||||
}));
|
||||
const playResolvedChoice = vi.fn().mockResolvedValue({
|
||||
...state,
|
||||
inBattle: true,
|
||||
});
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: true,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => [battleOption]),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
|
||||
getResolvedSceneHostileNpcs: vi.fn(
|
||||
(inputState: GameState) => inputState.sceneHostileNpcs,
|
||||
),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(battleOption);
|
||||
|
||||
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
|
||||
state,
|
||||
battleOption,
|
||||
state.playerCharacter!,
|
||||
);
|
||||
expect(playResolvedChoice).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,39 @@ type IncrementRuntimeStats = (
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function isImmediateCombatChoice(option: StoryOption) {
|
||||
return (
|
||||
option.functionId.startsWith('battle_') ||
|
||||
option.functionId === 'inventory_use'
|
||||
);
|
||||
}
|
||||
|
||||
function shouldResolveCombatChoiceLocally(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
option: StoryOption,
|
||||
) {
|
||||
if (!isImmediateCombatChoice(option)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (gameState.inBattle) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasBattleMarkers =
|
||||
Boolean(gameState.currentBattleNpcId || gameState.currentNpcBattleMode) ||
|
||||
gameState.sceneHostileNpcs.some((hostileNpc) => hostileNpc.hp > 0);
|
||||
const storyStillShowsBattleChoices = Boolean(
|
||||
currentStory?.options.some(isImmediateCombatChoice),
|
||||
);
|
||||
|
||||
// 中文注释:真实运行态里可能短暂出现“可见层仍在战斗,但逻辑态 inBattle
|
||||
// 已经被提前切回 false”的窗口。如果这时玩家点击了还在面板上的 battle_* /
|
||||
// inventory_use 选项,必须继续走本地逐帧战斗链,不能误分流到服务端直结算。
|
||||
return hasBattleMarkers || storyStillShowsBattleChoices;
|
||||
}
|
||||
|
||||
export function createStoryChoiceActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
@@ -219,7 +252,10 @@ export function createStoryChoiceActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRpgRuntimeServerFunctionId(option.functionId)) {
|
||||
if (
|
||||
isRpgRuntimeServerFunctionId(option.functionId) &&
|
||||
!shouldResolveCombatChoiceLocally(gameState, currentStory, option)
|
||||
) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
|
||||
@@ -1284,7 +1284,7 @@ describe('npcEncounterActions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('lets player exit hostile chat and offers fight or escape instead of continuing adventure', async () => {
|
||||
it('lets player exit hostile chat and offers fight plus scene escape routes instead of continuing adventure', async () => {
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 0,
|
||||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||||
@@ -1320,19 +1320,43 @@ describe('npcEncounterActions', () => {
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState).toBeUndefined();
|
||||
expect(lastStory.options).toEqual([
|
||||
expect(lastStory.options[0]).toEqual(expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
actionText: '战斗',
|
||||
interaction: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'fight',
|
||||
}),
|
||||
}));
|
||||
expect(lastStory.options.slice(1)).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
actionText: '战斗',
|
||||
interaction: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'fight',
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往东侧旧街还亮着灯。',
|
||||
runtimePayload: expect.objectContaining({
|
||||
targetSceneId: 'scene-east',
|
||||
escapeTargetSceneId: 'scene-east',
|
||||
escapeEntry: 'from_left',
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃跑',
|
||||
actionText: '逃往南侧河滩雾气更重。',
|
||||
runtimePayload: expect.objectContaining({
|
||||
targetSceneId: 'scene-south',
|
||||
escapeTargetSceneId: 'scene-south',
|
||||
escapeEntry: 'from_left',
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃回当前场景起点',
|
||||
runtimePayload: expect.objectContaining({
|
||||
targetSceneId: 'scene-bridge',
|
||||
escapeTargetSceneId: 'scene-bridge',
|
||||
escapeReturnToSceneStart: true,
|
||||
escapeEntry: 'from_left',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.options).not.toEqual(
|
||||
@@ -1417,7 +1441,15 @@ describe('npcEncounterActions', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃跑',
|
||||
actionText: '逃往东侧旧街还亮着灯。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往南侧河滩雾气更重。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃回当前场景起点',
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.deferredOptions).toBeUndefined();
|
||||
@@ -1469,6 +1501,15 @@ describe('npcEncounterActions', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往东侧旧街还亮着灯。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往南侧河滩雾气更重。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃回当前场景起点',
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.deferredOptions).toBeUndefined();
|
||||
@@ -1523,6 +1564,15 @@ describe('npcEncounterActions', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往东侧旧街还亮着灯。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往南侧河滩雾气更重。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃回当前场景起点',
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.deferredRuntimeState).toBeUndefined();
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import { getForwardScenePreset } from '../../data/scenePresets';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { buildMapTravelResolution } from './storyGenerationState';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
@@ -29,6 +31,95 @@ function buildRuntimeSnapshotRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServerTravelTargetSceneId(params: {
|
||||
previousState: GameState;
|
||||
snapshotState: GameState;
|
||||
}) {
|
||||
const { previousState, snapshotState } = params;
|
||||
const snapshotSceneId = snapshotState.currentScenePreset?.id ?? null;
|
||||
if (
|
||||
snapshotSceneId &&
|
||||
snapshotSceneId !== previousState.currentScenePreset?.id
|
||||
) {
|
||||
return snapshotSceneId;
|
||||
}
|
||||
|
||||
if (!previousState.worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
getForwardScenePreset(
|
||||
previousState.worldType,
|
||||
previousState.currentScenePreset?.id,
|
||||
)?.id ??
|
||||
previousState.currentScenePreset?.forwardSceneId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function bridgeServerSceneTravelSnapshot(params: {
|
||||
previousState: GameState;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
functionId: string;
|
||||
}) {
|
||||
const { previousState, hydratedSnapshot, functionId } = params;
|
||||
if (functionId !== 'idle_travel_next_scene' || !previousState.worldType) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const targetSceneId = resolveServerTravelTargetSceneId({
|
||||
previousState,
|
||||
snapshotState: hydratedSnapshot.gameState,
|
||||
});
|
||||
if (!targetSceneId) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const travelResolution = buildMapTravelResolution(previousState, targetSceneId);
|
||||
if (!travelResolution) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
return {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...hydratedSnapshot.gameState,
|
||||
// 中文注释:服务端 compat 当前只保证“本轮旅行动作已经结算完成”,
|
||||
// 前端这里复用既有地图旅行真相,补齐下一幕场景 preset、遭遇预览和任务推进结果。
|
||||
currentScenePreset: travelResolution.nextState.currentScenePreset,
|
||||
currentEncounter: travelResolution.nextState.currentEncounter,
|
||||
npcInteractionActive: travelResolution.nextState.npcInteractionActive,
|
||||
sceneHostileNpcs: travelResolution.nextState.sceneHostileNpcs,
|
||||
playerX: travelResolution.nextState.playerX,
|
||||
playerFacing: travelResolution.nextState.playerFacing,
|
||||
animationState: travelResolution.nextState.animationState,
|
||||
playerActionMode: travelResolution.nextState.playerActionMode,
|
||||
activeCombatEffects: travelResolution.nextState.activeCombatEffects,
|
||||
scrollWorld: travelResolution.nextState.scrollWorld,
|
||||
inBattle: travelResolution.nextState.inBattle,
|
||||
lastObserveSignsSceneId: travelResolution.nextState.lastObserveSignsSceneId,
|
||||
lastObserveSignsReport: travelResolution.nextState.lastObserveSignsReport,
|
||||
currentBattleNpcId: travelResolution.nextState.currentBattleNpcId,
|
||||
currentNpcBattleMode: travelResolution.nextState.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: travelResolution.nextState.currentNpcBattleOutcome,
|
||||
sparReturnEncounter: travelResolution.nextState.sparReturnEncounter,
|
||||
sparPlayerHpBefore: travelResolution.nextState.sparPlayerHpBefore,
|
||||
sparPlayerMaxHpBefore: travelResolution.nextState.sparPlayerMaxHpBefore,
|
||||
sparStoryHistoryBefore: travelResolution.nextState.sparStoryHistoryBefore,
|
||||
runtimeStats: {
|
||||
...hydratedSnapshot.gameState.runtimeStats,
|
||||
scenesTraveled:
|
||||
travelResolution.nextState.runtimeStats.scenesTraveled,
|
||||
},
|
||||
quests:
|
||||
hydratedSnapshot.gameState.quests.length > 0
|
||||
? hydratedSnapshot.gameState.quests
|
||||
: travelResolution.nextState.quests,
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
@@ -111,7 +202,11 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
payload: params.payload,
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
functionId: params.option.functionId,
|
||||
});
|
||||
|
||||
return {
|
||||
response,
|
||||
|
||||
@@ -56,6 +56,106 @@ function createGameState(): GameState {
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createTravelGameState(): GameState {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 7,
|
||||
worldType: WorldType.WUXIA,
|
||||
currentScene: 'Story',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '站在桥口的人。',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
personality: '谨慎',
|
||||
attributes: {
|
||||
strength: 8,
|
||||
agility: 8,
|
||||
intelligence: 6,
|
||||
spirit: 6,
|
||||
},
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as GameState['playerCharacter'],
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'encounter-current',
|
||||
npcName: '桥头行商',
|
||||
npcDescription: '正准备收摊离开的行商',
|
||||
context: '桥口',
|
||||
hostile: false,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风穿竹影,路面狭长。',
|
||||
imageSrc: '/scene-a.png',
|
||||
worldType: WorldType.WUXIA,
|
||||
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street'],
|
||||
connections: [
|
||||
{
|
||||
sceneId: 'wuxia-rain-street',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿石板路继续前行',
|
||||
},
|
||||
],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 38,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 12,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
activeBuildBuffs: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
customWorldProfile: null,
|
||||
} as unknown as GameState;
|
||||
}
|
||||
|
||||
function createRuntimeNpcBattleSnapshot(
|
||||
overrides: Partial<HydratedSavedGameSnapshot['gameState']> = {},
|
||||
) {
|
||||
@@ -553,6 +653,97 @@ describe('runtimeStoryCoordinator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => {
|
||||
const gameState = createTravelGameState();
|
||||
const currentStory = createStory('桥口这一段已经收束。');
|
||||
const option = {
|
||||
functionId: 'idle_travel_next_scene',
|
||||
actionText: '前往相邻场景',
|
||||
text: '前往相邻场景',
|
||||
visuals: {
|
||||
playerAnimation: 'run',
|
||||
playerMoveMeters: 1,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
const serverSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure' as const,
|
||||
currentStory: createStory('你顺着石板路继续前行。'),
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 8,
|
||||
currentScenePreset: {
|
||||
...gameState.currentScenePreset!,
|
||||
id: 'wuxia-rain-street',
|
||||
name: '夜雨长街',
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 38,
|
||||
maxHp: 40,
|
||||
mana: 12,
|
||||
maxMana: 16,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'idle_observe_signs',
|
||||
actionText: '观察周围迹象',
|
||||
scope: 'story',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '前往相邻场景',
|
||||
resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: serverSnapshot,
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(result.hydratedSnapshot.gameState.currentScenePreset?.id).toBe(
|
||||
'wuxia-rain-street',
|
||||
);
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.runtimeStats.scenesTraveled,
|
||||
).toBe(1);
|
||||
expect(
|
||||
Boolean(
|
||||
result.hydratedSnapshot.gameState.currentEncounter ||
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.length > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
|
||||
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
|
||||
runtimeActionVersion: 7,
|
||||
|
||||
@@ -451,4 +451,101 @@ describe('storyChoiceRuntime', () => {
|
||||
);
|
||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
||||
});
|
||||
|
||||
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
|
||||
const gameState = createState({
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风穿竹影,路面狭长。',
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: ['wuxia-rain-street'],
|
||||
connections: [
|
||||
{
|
||||
sceneId: 'wuxia-rain-street',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿石板路继续前行',
|
||||
},
|
||||
],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-bridge',
|
||||
npcName: '桥头行商',
|
||||
npcDescription: '正准备收摊离开的行商',
|
||||
npcAvatar: '桥',
|
||||
context: '桥口',
|
||||
hostile: false,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
});
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 3,
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-rain-street',
|
||||
name: '夜雨长街',
|
||||
description: '雨丝压低灯火,街面反着潮光。',
|
||||
imageSrc: '/scene-b.png',
|
||||
connectedSceneIds: ['wuxia-bamboo-road'],
|
||||
connections: [
|
||||
{
|
||||
sceneId: 'wuxia-bamboo-road',
|
||||
relativePosition: 'back',
|
||||
summary: '可以沿原路退回竹林古道',
|
||||
},
|
||||
],
|
||||
forwardSceneId: 'wuxia-ferry-bridge',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
runtimeStats: {
|
||||
...gameState.runtimeStats,
|
||||
scenesTraveled: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
nextStory: createStory('服务端故事'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: createStory('当前故事'),
|
||||
option: createOption('idle_travel_next_scene'),
|
||||
character: createCharacter(),
|
||||
setBattleReward: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: 'wuxia-rain-street',
|
||||
}),
|
||||
runtimeStats: expect.objectContaining({
|
||||
scenesTraveled: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '服务端故事',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1103,7 +1103,11 @@ export function createStoryNpcEncounterActions({
|
||||
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
|
||||
};
|
||||
|
||||
const buildHostileNpcEscapeOption = (character: Character): StoryOption => {
|
||||
const buildHostileNpcEscapeOption = (
|
||||
character: Character,
|
||||
actionText = '逃跑',
|
||||
runtimePayload?: StoryOption['runtimePayload'],
|
||||
): StoryOption => {
|
||||
const functionContext = gameState.worldType
|
||||
? {
|
||||
worldType: gameState.worldType,
|
||||
@@ -1125,16 +1129,20 @@ export function createStoryNpcEncounterActions({
|
||||
if (resolvedOption) {
|
||||
return {
|
||||
...resolvedOption,
|
||||
actionText: '逃跑',
|
||||
text: '逃跑',
|
||||
actionText,
|
||||
text: actionText,
|
||||
detailText: '',
|
||||
runtimePayload: {
|
||||
...(resolvedOption.runtimePayload ?? {}),
|
||||
...(runtimePayload ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃跑',
|
||||
text: '逃跑',
|
||||
actionText,
|
||||
text: actionText,
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
@@ -1144,9 +1152,61 @@ export function createStoryNpcEncounterActions({
|
||||
scrollWorld: true,
|
||||
monsterChanges: [],
|
||||
},
|
||||
runtimePayload,
|
||||
};
|
||||
};
|
||||
|
||||
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
|
||||
const currentScene = gameState.currentScenePreset;
|
||||
const worldType = gameState.worldType;
|
||||
const options: StoryOption[] = [];
|
||||
const seenSceneIds = new Set<string>();
|
||||
|
||||
if (worldType && currentScene) {
|
||||
for (const connection of currentScene.connections ?? []) {
|
||||
if (!connection.sceneId || seenSceneIds.has(connection.sceneId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenSceneIds.add(connection.sceneId);
|
||||
const targetScene = getScenePresetById(worldType, connection.sceneId);
|
||||
const targetSceneName =
|
||||
targetScene?.name ??
|
||||
connection.summary?.trim() ??
|
||||
connection.sceneId;
|
||||
|
||||
options.push(
|
||||
buildHostileNpcEscapeOption(
|
||||
character,
|
||||
`逃往${targetSceneName}`,
|
||||
{
|
||||
targetSceneId: connection.sceneId,
|
||||
escapeTargetSceneId: connection.sceneId,
|
||||
escapeEntry: 'from_left',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
options.push(
|
||||
buildHostileNpcEscapeOption(
|
||||
character,
|
||||
'逃回当前场景起点',
|
||||
{
|
||||
targetSceneId: currentScene.id,
|
||||
escapeTargetSceneId: currentScene.id,
|
||||
escapeReturnToSceneStart: true,
|
||||
escapeEntry: 'from_left',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return options.length > 0
|
||||
? options
|
||||
: [buildHostileNpcEscapeOption(character)];
|
||||
};
|
||||
|
||||
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
|
||||
functionId: NPC_FIGHT_FUNCTION.id,
|
||||
actionText: '与他对战',
|
||||
@@ -1177,8 +1237,8 @@ export function createStoryNpcEncounterActions({
|
||||
return {
|
||||
text: declarationText,
|
||||
options: [
|
||||
buildHostileNpcEscapeOption(character),
|
||||
buildHostileNpcFightOption(encounter),
|
||||
...buildHostileNpcEscapeOptions(character),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
@@ -1220,7 +1280,7 @@ export function createStoryNpcEncounterActions({
|
||||
actionText: '战斗',
|
||||
text: '战斗',
|
||||
},
|
||||
buildHostileNpcEscapeOption(character),
|
||||
...buildHostileNpcEscapeOptions(character),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
|
||||
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
||||
import type { CustomWorldRuntimeLaunchOptions } from '../../components/platform-entry/platformEntryTypes';
|
||||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
|
||||
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
||||
@@ -87,9 +88,10 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
|
||||
const handleCustomWorldSelect = (
|
||||
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile);
|
||||
selectCustomWorld(customWorldProfile, { mode: options?.mode });
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (
|
||||
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
getScenePresetById,
|
||||
getWorldCampScenePreset,
|
||||
} from '../../data/scenePresets';
|
||||
import {
|
||||
findCustomWorldRoleByReference,
|
||||
resolveCustomWorldRoleIdReference,
|
||||
resolveCustomWorldRoleIdReferences,
|
||||
} from '../../services/customWorldRoleReferences';
|
||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
@@ -39,6 +44,7 @@ import {
|
||||
Encounter,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
GameRuntimeMode,
|
||||
InventoryItem,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
@@ -313,23 +319,39 @@ function resolveCustomWorldScenePresetByConfiguredId(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOpeningActNpcIdPriority(openingAct: SceneActBlueprint) {
|
||||
return [
|
||||
function resolveOpeningActNpcIdPriority(
|
||||
profile: CustomWorldProfile,
|
||||
openingAct: SceneActBlueprint,
|
||||
) {
|
||||
return resolveCustomWorldRoleIdReferences(profile, [
|
||||
openingAct.oppositeNpcId,
|
||||
openingAct.primaryNpcId,
|
||||
...openingAct.encounterNpcIds,
|
||||
]
|
||||
.map((npcId) => npcId.trim())
|
||||
.filter((npcId, index, list) => npcId && list.indexOf(npcId) === index);
|
||||
]);
|
||||
}
|
||||
|
||||
function doRoleReferencesMatch(
|
||||
profile: CustomWorldProfile | null,
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
) {
|
||||
const normalizedLeft = resolveCustomWorldRoleIdReference(profile, left);
|
||||
const normalizedRight = resolveCustomWorldRoleIdReference(profile, right);
|
||||
return Boolean(normalizedLeft && normalizedLeft === normalizedRight);
|
||||
}
|
||||
|
||||
function findSceneNpcByRuntimeRoleId(
|
||||
scenePreset: GameState['currentScenePreset'],
|
||||
profile: CustomWorldProfile | null,
|
||||
roleId: string,
|
||||
) {
|
||||
return (
|
||||
scenePreset?.npcs?.find(
|
||||
(npc) => npc.id === roleId || npc.characterId === roleId,
|
||||
(npc) =>
|
||||
doRoleReferencesMatch(profile, npc.id, roleId) ||
|
||||
doRoleReferencesMatch(profile, npc.characterId, roleId) ||
|
||||
doRoleReferencesMatch(profile, npc.name, roleId) ||
|
||||
doRoleReferencesMatch(profile, npc.title, roleId),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -339,9 +361,7 @@ function buildOpeningEncounterFromCustomWorldRole(
|
||||
roleId: string,
|
||||
): Encounter | null {
|
||||
const role =
|
||||
profile.storyNpcs.find((npc) => npc.id === roleId) ??
|
||||
profile.playableNpcs.find((npc) => npc.id === roleId) ??
|
||||
null;
|
||||
findCustomWorldRoleByReference(profile, roleId);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
@@ -388,12 +408,27 @@ function resolveOpeningActEncounter(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const npcId of resolveOpeningActNpcIdPriority(opening.act)) {
|
||||
if (npcId === params.playerCharacter.id) {
|
||||
for (const npcId of resolveOpeningActNpcIdPriority(params.profile, opening.act)) {
|
||||
if (
|
||||
doRoleReferencesMatch(
|
||||
params.profile,
|
||||
npcId,
|
||||
params.playerCharacter.id,
|
||||
) ||
|
||||
doRoleReferencesMatch(
|
||||
params.profile,
|
||||
npcId,
|
||||
params.playerCharacter.name,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sceneNpc = findSceneNpcByRuntimeRoleId(params.scenePreset, npcId);
|
||||
const sceneNpc = findSceneNpcByRuntimeRoleId(
|
||||
params.scenePreset,
|
||||
params.profile,
|
||||
npcId,
|
||||
);
|
||||
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
|
||||
return {
|
||||
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
|
||||
@@ -456,8 +491,13 @@ export function useRpgSessionBootstrap() {
|
||||
setGameState(createInitialGameState());
|
||||
};
|
||||
|
||||
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
|
||||
const handleCustomWorldSelect = (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: { mode?: GameRuntimeMode },
|
||||
) => {
|
||||
const resolvedWorldType = WorldType.CUSTOM;
|
||||
const runtimeMode: GameRuntimeMode =
|
||||
options?.mode === 'play' ? 'play' : 'test';
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
buildCustomWorldRuntimeCharacters(customWorldProfile),
|
||||
@@ -469,6 +509,8 @@ export function useRpgSessionBootstrap() {
|
||||
...prev,
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
runtimeMode,
|
||||
runtimePersistenceDisabled: runtimeMode !== 'play',
|
||||
currentScenePreset: initialScenePreset,
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
@@ -552,82 +594,92 @@ export function useRpgSessionBootstrap() {
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
|
||||
return ensureSceneEncounterPreview(
|
||||
applyEquipmentLoadoutToState(
|
||||
{
|
||||
...prev,
|
||||
playerCharacter: character,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildOpeningStoryEngineMemory(
|
||||
resolvedCustomWorldProfile,
|
||||
initialScenePreset?.id,
|
||||
)
|
||||
: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId:
|
||||
prev.customWorldProfile?.scenarioPackId ?? null,
|
||||
activeCampaignPackId:
|
||||
prev.customWorldProfile?.campaignPackId ?? null,
|
||||
characterChats: {},
|
||||
currentEncounter: initialEncounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: initialScenePreset,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: playerMaxHp,
|
||||
playerMaxHp: playerMaxHp,
|
||||
playerMana: getCharacterMaxMana(character),
|
||||
playerMaxMana: getCharacterMaxMana(character),
|
||||
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: getInitialPlayerCurrency(
|
||||
const openingState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...prev,
|
||||
playerCharacter: character,
|
||||
runtimeMode:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? prev.runtimeMode === 'play'
|
||||
? 'play'
|
||||
: 'test'
|
||||
: (prev.runtimeMode ?? 'play'),
|
||||
runtimePersistenceDisabled:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? prev.runtimeMode !== 'play'
|
||||
: prev.runtimePersistenceDisabled,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildOpeningStoryEngineMemory(
|
||||
resolvedCustomWorldProfile,
|
||||
initialScenePreset?.id,
|
||||
)
|
||||
: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId: prev.customWorldProfile?.scenarioPackId ?? null,
|
||||
activeCampaignPackId: prev.customWorldProfile?.campaignPackId ?? null,
|
||||
characterChats: {},
|
||||
currentEncounter: initialEncounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: initialScenePreset,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: playerMaxHp,
|
||||
playerMaxHp: playerMaxHp,
|
||||
playerMana: getCharacterMaxMana(character),
|
||||
playerMaxMana: getCharacterMaxMana(character),
|
||||
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: getInitialPlayerCurrency(
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
playerInventory: mergeStarterInventoryItems<InventoryItem>(
|
||||
explicitStarterItems?.inventory ?? [],
|
||||
buildInitialPlayerInventory(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
playerInventory: mergeStarterInventoryItems<InventoryItem>(
|
||||
explicitStarterItems?.inventory ?? [],
|
||||
buildInitialPlayerInventory(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates:
|
||||
initialEncounter && initialNpcState
|
||||
? {
|
||||
[initialEncounter.id!]: initialNpcState,
|
||||
}
|
||||
: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
mergedStarterEquipment,
|
||||
),
|
||||
),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates:
|
||||
initialEncounter && initialNpcState
|
||||
? {
|
||||
[initialEncounter.id!]: initialNpcState,
|
||||
}
|
||||
: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
mergedStarterEquipment,
|
||||
);
|
||||
|
||||
return resolvedWorldType === WorldType.CUSTOM
|
||||
? openingState
|
||||
: ensureSceneEncounterPreview(openingState);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -72,7 +72,9 @@ function buildBackstoryReveal(label: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSavedProfile() {
|
||||
function buildSavedProfile(options: {
|
||||
openingOppositeNpcId?: string;
|
||||
} = {}) {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
id: 'saved-runtime-profile',
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
@@ -318,9 +320,9 @@ function buildSavedProfile() {
|
||||
title: '第一幕',
|
||||
summary: '陆衡先开口试探玩家。',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['story-primary-only', 'story-act-only'],
|
||||
primaryNpcId: 'story-primary-only',
|
||||
oppositeNpcId: 'story-act-only',
|
||||
encounterNpcIds: ['沈砺旧识', '陆衡'],
|
||||
primaryNpcId: '沈砺旧识',
|
||||
oppositeNpcId: options.openingOppositeNpcId ?? '陆衡',
|
||||
eventDescription: '陆衡拿着异常账本,在开盘前拦住玩家。',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
@@ -389,6 +391,8 @@ function readSnapshot() {
|
||||
isStoryLoading: boolean;
|
||||
firstLandmarkResidueTitle: string | null;
|
||||
playerCharacterName: string | null;
|
||||
runtimeMode: string | null;
|
||||
runtimePersistenceDisabled: boolean;
|
||||
playerInventoryNames: string[];
|
||||
playerEquipment: {
|
||||
weapon: string | null;
|
||||
@@ -398,8 +402,15 @@ function readSnapshot() {
|
||||
};
|
||||
}
|
||||
|
||||
function GameFlowHarness() {
|
||||
const profile = useMemo(() => buildSavedProfile(), []);
|
||||
function GameFlowHarness({
|
||||
openingOppositeNpcId,
|
||||
}: {
|
||||
openingOppositeNpcId?: string;
|
||||
} = {}) {
|
||||
const profile = useMemo(
|
||||
() => buildSavedProfile({ openingOppositeNpcId }),
|
||||
[openingOppositeNpcId],
|
||||
);
|
||||
const playableCharacters = useMemo(
|
||||
() => buildCustomWorldPlayableCharacters(profile),
|
||||
[profile],
|
||||
@@ -441,6 +452,8 @@ function GameFlowHarness() {
|
||||
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
|
||||
?.title ?? null,
|
||||
playerCharacterName: gameState.playerCharacter?.name ?? null,
|
||||
runtimeMode: gameState.runtimeMode ?? null,
|
||||
runtimePersistenceDisabled: gameState.runtimePersistenceDisabled === true,
|
||||
playerInventoryNames: gameState.playerInventory.map((item) => item.name),
|
||||
playerEquipment: {
|
||||
weapon: gameState.playerEquipment.weapon?.name ?? null,
|
||||
@@ -515,6 +528,8 @@ test('saved custom world result settings flow into game state after entering the
|
||||
});
|
||||
|
||||
expect(readSnapshot().playerCharacterName).toBe('沈砺');
|
||||
expect(readSnapshot().runtimeMode).toBe('test');
|
||||
expect(readSnapshot().runtimePersistenceDisabled).toBe(true);
|
||||
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
|
||||
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
|
||||
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
|
||||
@@ -547,3 +562,38 @@ test('saved custom world result settings flow into game state after entering the
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('custom world opening act accepts runtime npc id references and still starts configured npc chat', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<GameFlowHarness openingOppositeNpcId="character-npc-story-act-only" />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '选择世界' }));
|
||||
await waitFor(() => {
|
||||
expect(readSnapshot().worldType).toBe(WorldType.CUSTOM);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '确认角色' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readSnapshot().currentEncounterId).toBe('story-act-only');
|
||||
});
|
||||
expect(readSnapshot().currentEncounterName).toBe('陆衡');
|
||||
await waitFor(() => {
|
||||
expect(readSnapshot().currentStoryNpcName).toBe('陆衡');
|
||||
});
|
||||
expect(aiServiceMocks.streamNpcChatTurn).toHaveBeenCalledWith(
|
||||
WorldType.CUSTOM,
|
||||
expect.objectContaining({ name: '沈砺' }),
|
||||
expect.objectContaining({ id: 'story-act-only', npcName: '陆衡' }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
[],
|
||||
'',
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
npcInitiatesConversation: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -76,6 +76,66 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes puzzle-clear-flash-sweep {
|
||||
0% {
|
||||
transform: translate3d(-135%, -135%, 0) rotate(18deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
18% {
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
45% {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate3d(135%, 135%, 0) rotate(18deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.puzzle-clear-flash-overlay {
|
||||
background: radial-gradient(
|
||||
circle at 22% 24%,
|
||||
rgba(255, 250, 214, 0.22),
|
||||
transparent 28%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 76% 74%,
|
||||
rgba(255, 214, 150, 0.16),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 251, 235, 0.08),
|
||||
rgba(255, 251, 235, 0.02)
|
||||
);
|
||||
}
|
||||
|
||||
.puzzle-clear-flash-beam {
|
||||
position: absolute;
|
||||
inset: -48%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 248, 214, 0.18) 38%,
|
||||
rgba(255, 255, 255, 0.96) 50%,
|
||||
rgba(255, 235, 166, 0.28) 62%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 0 36px rgba(255, 250, 214, 0.28),
|
||||
0 0 110px rgba(255, 233, 163, 0.12);
|
||||
mix-blend-mode: screen;
|
||||
animation: puzzle-clear-flash-sweep 0.9s ease-out forwards;
|
||||
}
|
||||
|
||||
.fusion-pixel-app,
|
||||
.fusion-pixel-app * {
|
||||
font-family: 'Fusion Pixel', 'Inter', ui-sans-serif, system-ui, sans-serif !important;
|
||||
@@ -1493,8 +1553,10 @@ body {
|
||||
|
||||
.platform-npc-portrait__grid {
|
||||
opacity: 0.14;
|
||||
background-image:
|
||||
linear-gradient(var(--platform-line-soft) 1px, transparent 1px),
|
||||
background-image: linear-gradient(
|
||||
var(--platform-line-soft) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(90deg, var(--platform-line-soft) 1px, transparent 1px);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import { ApiClientError, type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_GALLERY_API_BASE = '/api/runtime/big-fish/gallery';
|
||||
const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -12,16 +12,25 @@ const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
|
||||
* 读取大鱼吃小鱼公开广场列表。
|
||||
*/
|
||||
export async function listBigFishGallery() {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
BIG_FISH_GALLERY_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼广场失败',
|
||||
{
|
||||
retry: BIG_FISH_GALLERY_READ_RETRY,
|
||||
},
|
||||
);
|
||||
try {
|
||||
return await requestJson<BigFishWorksResponse>(
|
||||
BIG_FISH_GALLERY_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼广场失败',
|
||||
{
|
||||
retry: BIG_FISH_GALLERY_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
return { items: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const bigFishGalleryClient = {
|
||||
|
||||
70
src/services/customWorldRoleReferences.ts
Normal file
70
src/services/customWorldRoleReferences.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
CustomWorldRoleProfile,
|
||||
} from '../types';
|
||||
|
||||
type CustomWorldRoleReferenceProfile = {
|
||||
playableNpcs: CustomWorldRoleProfile[];
|
||||
storyNpcs: CustomWorldRoleProfile[];
|
||||
};
|
||||
|
||||
function normalizeRoleReference(value: string | null | undefined) {
|
||||
return (value ?? '')
|
||||
.trim()
|
||||
.replace(/^character-npc[-:]/i, '')
|
||||
.replace(/^(playable|story|role|npc)[-_:]/i, '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[((].*?[))]/g, '');
|
||||
}
|
||||
|
||||
function getRoleReferenceAliases(role: CustomWorldRoleProfile) {
|
||||
return [
|
||||
role.id,
|
||||
role.name,
|
||||
role.title,
|
||||
`${role.name}${role.title}`,
|
||||
`${role.title}${role.name}`,
|
||||
`${role.role}${role.name}`,
|
||||
`${role.name}${role.role}`,
|
||||
]
|
||||
.map(normalizeRoleReference)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function findCustomWorldRoleByReference(
|
||||
profile: CustomWorldRoleReferenceProfile | null | undefined,
|
||||
reference: string | null | undefined,
|
||||
) {
|
||||
const normalizedReference = normalizeRoleReference(reference);
|
||||
if (!profile || !normalizedReference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roles = [...profile.storyNpcs, ...profile.playableNpcs];
|
||||
return (
|
||||
roles.find((role) =>
|
||||
getRoleReferenceAliases(role).includes(normalizedReference),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCustomWorldRoleIdReference(
|
||||
profile: CustomWorldRoleReferenceProfile | null | undefined,
|
||||
reference: string | null | undefined,
|
||||
) {
|
||||
const role = findCustomWorldRoleByReference(profile, reference);
|
||||
return role?.id ?? reference?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function resolveCustomWorldRoleIdReferences(
|
||||
profile: CustomWorldRoleReferenceProfile | null | undefined,
|
||||
references: Array<string | null | undefined>,
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
references
|
||||
.map((reference) => resolveCustomWorldRoleIdReference(profile, reference))
|
||||
.map((reference) => reference.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SceneConnectionInfo,
|
||||
StoryEngineMemoryState,
|
||||
} from '../types';
|
||||
import { resolveCustomWorldRoleIdReferences } from './customWorldRoleReferences';
|
||||
|
||||
function toSet(values: string[]) {
|
||||
return new Set(values.map((value) => value.trim()).filter(Boolean));
|
||||
@@ -227,17 +228,11 @@ export function resolveActiveSceneActEncounterNpcIds(params: {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
[
|
||||
activeAct.primaryNpcId,
|
||||
activeAct.oppositeNpcId,
|
||||
...activeAct.encounterNpcIds,
|
||||
]
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
activeAct.primaryNpcId,
|
||||
activeAct.oppositeNpcId,
|
||||
...activeAct.encounterNpcIds,
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||
@@ -245,7 +240,9 @@ export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
|
||||
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
resolveActiveSceneActBlueprint(params)?.primaryNpcId,
|
||||
])[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActOppositeNpcId(params: {
|
||||
@@ -253,7 +250,9 @@ export function resolveActiveSceneActOppositeNpcId(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return resolveActiveSceneActBlueprint(params)?.oppositeNpcId?.trim() || null;
|
||||
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
resolveActiveSceneActBlueprint(params)?.oppositeNpcId,
|
||||
])[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
||||
@@ -262,12 +261,11 @@ export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
const activeAct = resolveActiveSceneActBlueprint(params);
|
||||
return (
|
||||
activeAct?.oppositeNpcId?.trim() ||
|
||||
activeAct?.primaryNpcId?.trim() ||
|
||||
activeAct?.encounterNpcIds[0]?.trim() ||
|
||||
null
|
||||
);
|
||||
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
activeAct?.oppositeNpcId,
|
||||
activeAct?.primaryNpcId,
|
||||
activeAct?.encounterNpcIds[0],
|
||||
])[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActBackgroundImage(params: {
|
||||
@@ -295,13 +293,18 @@ export function canUseLimitedPrimaryNpcChat(params: {
|
||||
storyEngineMemory: params.storyEngineMemory,
|
||||
});
|
||||
|
||||
const limitedChatNpcIds = toSet([
|
||||
activeAct?.primaryNpcId ?? '',
|
||||
activeAct?.oppositeNpcId ?? '',
|
||||
]);
|
||||
const limitedChatNpcIds = toSet(
|
||||
resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
activeAct?.primaryNpcId,
|
||||
activeAct?.oppositeNpcId,
|
||||
]),
|
||||
);
|
||||
const normalizedNpcId =
|
||||
resolveCustomWorldRoleIdReferences(params.profile, [params.npcId])[0] ??
|
||||
params.npcId;
|
||||
|
||||
// 中文注释:第一幕对面角色即使是负好感,也必须先进入剧情对话;普通敌人仍按战斗处理。
|
||||
if (limitedChatNpcIds.has(params.npcId)) {
|
||||
if (limitedChatNpcIds.has(normalizedNpcId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -310,7 +313,7 @@ export function canUseLimitedPrimaryNpcChat(params: {
|
||||
profile: params.profile,
|
||||
sceneId: params.sceneId,
|
||||
storyEngineMemory: params.storyEngineMemory,
|
||||
}) === params.npcId
|
||||
}) === normalizedNpcId
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@ export {
|
||||
getPuzzleRun,
|
||||
puzzleRuntimeClient,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
@@ -27,7 +27,7 @@ const baseWork: PuzzleWorkSummary = {
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
return pieces.some((piece) =>
|
||||
pieces.some((candidate) => {
|
||||
if (piece.pieceId === candidate.pieceId) {
|
||||
@@ -39,13 +39,18 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const correctColDelta = candidate.correctCol - piece.correctCol;
|
||||
return (
|
||||
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
|
||||
currentRowDelta === correctRowDelta &&
|
||||
currentColDelta === correctColDelta
|
||||
Math.abs(correctRowDelta) + Math.abs(correctColDelta) === 1
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function boardPositionSignature(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
return run.currentLevel?.board.pieces
|
||||
.map((piece) => `${piece.currentRow}:${piece.currentCol}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
let nextRun = run;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
@@ -89,13 +94,13 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(firstPositions).not.toEqual(secondPositions);
|
||||
});
|
||||
|
||||
test('初始棋盘没有任何自动合并块', () => {
|
||||
test('初始棋盘没有任何原图相邻块贴边', () => {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const board = run.currentLevel?.board;
|
||||
|
||||
expect(board?.mergedGroups).toEqual([]);
|
||||
expect(hasAnyCorrectNeighborPair(board?.pieces ?? [])).toBe(false);
|
||||
expect(hasAnyOriginalNeighborPair(board?.pieces ?? [])).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -283,12 +288,8 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
||||
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
||||
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
clearedRun.currentLevel?.leaderboardEntries.some(
|
||||
(entry) => entry.isCurrentPlayer && entry.nickname === '测试作者',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(clearedRun.leaderboardEntries).toEqual([]);
|
||||
|
||||
const nextRun = advanceLocalPuzzleLevel(clearedRun);
|
||||
|
||||
@@ -300,4 +301,17 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(nextRun.recommendedNextProfileId).toBeNull();
|
||||
});
|
||||
|
||||
test('连续推进下一关会重新打乱棋盘', () => {
|
||||
const firstClearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
const secondRun = advanceLocalPuzzleLevel(firstClearedRun);
|
||||
const secondClearedRun = solveCurrentLevel(secondRun);
|
||||
const thirdRun = advanceLocalPuzzleLevel(secondClearedRun);
|
||||
|
||||
expect(secondRun.currentLevelIndex).toBe(2);
|
||||
expect(thirdRun.currentLevelIndex).toBe(3);
|
||||
expect(boardPositionSignature(secondRun)).not.toBe(boardPositionSignature(thirdRun));
|
||||
expect(hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
expect(hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleGridSize,
|
||||
PuzzleLeaderboardEntry,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzlePieceState,
|
||||
PuzzleRunSnapshot,
|
||||
@@ -75,11 +74,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
|
||||
);
|
||||
ensureBoardIsNotSolved(shuffled, gridSize);
|
||||
const pieces = buildPiecesFromPositions(gridSize, shuffled);
|
||||
if (!hasAnyCorrectNeighborPair(pieces)) {
|
||||
if (!hasAnyOriginalNeighborPair(pieces)) {
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
return positions.slice().reverse();
|
||||
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
|
||||
}
|
||||
|
||||
function boardCellKey(row: number, col: number) {
|
||||
@@ -90,48 +89,6 @@ function clampElapsedMs(value: number) {
|
||||
return Math.max(1_000, Math.round(value));
|
||||
}
|
||||
|
||||
function rankLeaderboardEntries(
|
||||
entries: Omit<PuzzleLeaderboardEntry, 'rank'>[],
|
||||
): PuzzleLeaderboardEntry[] {
|
||||
return entries
|
||||
.map((entry) => ({ ...entry }))
|
||||
.sort((left, right) => left.elapsedMs - right.elapsedMs)
|
||||
.map((entry, index) => ({
|
||||
...entry,
|
||||
rank: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// V1 本地榜单只用于单次游玩闭环展示;正式榜单后续迁移到 SpacetimeDB 表或 view。
|
||||
function buildLocalLeaderboardEntries(
|
||||
elapsedMs: number,
|
||||
playerNickname: string,
|
||||
levelIndex: number,
|
||||
gridSize: PuzzleGridSize,
|
||||
): PuzzleLeaderboardEntry[] {
|
||||
const normalizedElapsedMs = clampElapsedMs(elapsedMs);
|
||||
const baseOffsetMs = gridSize === 3 ? 4_000 : 8_000;
|
||||
return rankLeaderboardEntries([
|
||||
{
|
||||
nickname: playerNickname.trim() || '玩家',
|
||||
elapsedMs: normalizedElapsedMs,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
{
|
||||
nickname: '星桥旅人',
|
||||
elapsedMs: normalizedElapsedMs + baseOffsetMs + levelIndex * 700,
|
||||
},
|
||||
{
|
||||
nickname: '月港拼图手',
|
||||
elapsedMs: Math.max(1_000, normalizedElapsedMs - baseOffsetMs / 2),
|
||||
},
|
||||
{
|
||||
nickname: '雾灯收藏家',
|
||||
elapsedMs: normalizedElapsedMs + baseOffsetMs * 2 + levelIndex * 900,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function neighborCells(row: number, col: number): PuzzleCellPosition[] {
|
||||
return [
|
||||
row > 0 ? { row: row - 1, col } : null,
|
||||
@@ -179,6 +136,119 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
|
||||
return (
|
||||
Math.abs(right.correctRow - left.correctRow) +
|
||||
Math.abs(right.correctCol - left.correctCol) ===
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
);
|
||||
return pieces.some((piece) =>
|
||||
neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
return Boolean(neighborPiece && areOriginalNeighbors(piece, neighborPiece));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function seededOrderKey(seed: number, value: number) {
|
||||
let state = (seed ^ Math.imul(value, 2654435761)) >>> 0;
|
||||
state ^= state >>> 16;
|
||||
state = Math.imul(state, 2246822507) >>> 0;
|
||||
state ^= state >>> 13;
|
||||
state = Math.imul(state, 3266489909) >>> 0;
|
||||
return (state ^ (state >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
function buildOriginalNeighborFreePositions(
|
||||
gridSize: PuzzleGridSize,
|
||||
seed: number,
|
||||
) {
|
||||
const total = gridSize * gridSize;
|
||||
const pieceOrder = Array.from({ length: total }, (_, index) => index).sort(
|
||||
(left, right) =>
|
||||
seededOrderKey(seed ^ 0xa0761d64, left) -
|
||||
seededOrderKey(seed ^ 0xa0761d64, right),
|
||||
);
|
||||
const cellOrder = Array.from({ length: total }, (_, index) => ({
|
||||
row: Math.floor(index / gridSize),
|
||||
col: index % gridSize,
|
||||
})).sort(
|
||||
(left, right) =>
|
||||
seededOrderKey(seed ^ 0xe7037ed1, left.row * 16 + left.col) -
|
||||
seededOrderKey(seed ^ 0xe7037ed1, right.row * 16 + right.col),
|
||||
);
|
||||
const placements: Array<PuzzleCellPosition | null> = Array.from(
|
||||
{ length: total },
|
||||
() => null,
|
||||
);
|
||||
const usedCells = new Set<string>();
|
||||
|
||||
const placePiece = (depth: number): boolean => {
|
||||
const pieceIndex = pieceOrder[depth];
|
||||
if (pieceIndex === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const cell of cellOrder) {
|
||||
const cellKey = boardCellKey(cell.row, cell.col);
|
||||
if (usedCells.has(cellKey)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
cell.row === Math.floor(pieceIndex / gridSize) &&
|
||||
cell.col === pieceIndex % gridSize
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
violatesOriginalNeighborFreeRule(gridSize, pieceIndex, cell, placements)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
placements[pieceIndex] = cell;
|
||||
usedCells.add(cellKey);
|
||||
if (placePiece(depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
usedCells.delete(cellKey);
|
||||
placements[pieceIndex] = null;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return placePiece(0) && placements.every(Boolean)
|
||||
? (placements as PuzzleCellPosition[])
|
||||
: null;
|
||||
}
|
||||
|
||||
function violatesOriginalNeighborFreeRule(
|
||||
gridSize: PuzzleGridSize,
|
||||
pieceIndex: number,
|
||||
cell: PuzzleCellPosition,
|
||||
placements: Array<PuzzleCellPosition | null>,
|
||||
) {
|
||||
return placements.some((placedCell, placedIndex) => {
|
||||
if (!placedCell) {
|
||||
return false;
|
||||
}
|
||||
const originalNeighbors =
|
||||
Math.abs(Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize)) +
|
||||
Math.abs((pieceIndex % gridSize) - (placedIndex % gridSize)) ===
|
||||
1;
|
||||
const currentNeighbors =
|
||||
Math.abs(cell.row - placedCell.row) + Math.abs(cell.col - placedCell.col) ===
|
||||
1;
|
||||
return originalNeighbors && currentNeighbors;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMergedGroups(
|
||||
pieces: PuzzlePieceState[],
|
||||
): PuzzleMergedGroupState[] {
|
||||
@@ -306,15 +376,6 @@ function applyNextBoard(
|
||||
const elapsedMs = justCleared
|
||||
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
|
||||
: (run.currentLevel.elapsedMs ?? null);
|
||||
const leaderboardEntries =
|
||||
justCleared && elapsedMs
|
||||
? buildLocalLeaderboardEntries(
|
||||
elapsedMs,
|
||||
run.currentLevel.authorDisplayName,
|
||||
run.currentLevel.levelIndex,
|
||||
run.currentLevel.gridSize,
|
||||
)
|
||||
: run.currentLevel.leaderboardEntries;
|
||||
return {
|
||||
...run,
|
||||
clearedLevelCount: nextClearedLevelCount,
|
||||
@@ -324,9 +385,9 @@ function applyNextBoard(
|
||||
status,
|
||||
clearedAtMs,
|
||||
elapsedMs,
|
||||
leaderboardEntries,
|
||||
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
|
||||
},
|
||||
leaderboardEntries,
|
||||
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
|
||||
recommendedNextProfileId:
|
||||
status === 'cleared'
|
||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
@@ -112,6 +113,27 @@ export async function advancePuzzleNextLevel(runId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交通关成绩并读取真实排行榜。
|
||||
*/
|
||||
export async function submitPuzzleLeaderboard(
|
||||
runId: string,
|
||||
payload: SubmitPuzzleLeaderboardRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交拼图排行榜失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单机运行态进入下一关,图片来源选择全部由后端裁决。
|
||||
*/
|
||||
@@ -137,6 +159,7 @@ export const puzzleRuntimeClient = {
|
||||
advanceNextLevel: advancePuzzleNextLevel,
|
||||
drag: dragPuzzlePieceOrGroup,
|
||||
getRun: getPuzzleRun,
|
||||
submitLeaderboard: submitPuzzleLeaderboard,
|
||||
startRun: startPuzzleRun,
|
||||
swap: swapPuzzlePieces,
|
||||
};
|
||||
|
||||
@@ -202,31 +202,42 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop
|
||||
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession prefers agent draft profile', () => {
|
||||
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
||||
|
||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
||||
expect(profile?.summary).toBe('fallback');
|
||||
expect(profile?.id).toBe('draft-profile-1');
|
||||
expect(profile?.playableNpcs[0]?.id).toBe('draft-playable-1');
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
|
||||
expect(profile?.id).toBe('preview-profile-1');
|
||||
expect(profile?.playableNpcs).toEqual([]);
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
|
||||
test('buildRpgCreationPreviewFromSession falls back to draft legacy result profile', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession({
|
||||
...sessionWithPreview,
|
||||
resultPreview: null,
|
||||
draftProfile: {
|
||||
...sessionWithPreview.draftProfile,
|
||||
legacyResultProfile: {
|
||||
...sessionWithPreview.resultPreview!.preview,
|
||||
id: 'legacy-result-profile-1',
|
||||
name: '草稿内嵌结果页',
|
||||
summary: 'resultPreview 缺失时继续使用 draft 内嵌的结果页快照。',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile?.name).toBe('草稿内嵌结果页');
|
||||
expect(profile?.summary).toBe(
|
||||
'resultPreview 缺失时继续使用 draft 内嵌的结果页快照。',
|
||||
);
|
||||
expect(profile?.id).toBe('legacy-result-profile-1');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession does not treat draftProfile as runtime profile', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession({
|
||||
...sessionWithPreview,
|
||||
resultPreview: null,
|
||||
});
|
||||
|
||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/draft-playable-1/portrait.png',
|
||||
);
|
||||
expect(profile?.attributeSchema.slots.map((slot) => slot.name)).toEqual([
|
||||
'稿骨',
|
||||
'稿步',
|
||||
'稿识',
|
||||
'稿魄',
|
||||
'稿契',
|
||||
'稿澜',
|
||||
]);
|
||||
expect(profile).toBeNull();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,19 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/s
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
function buildCustomWorldProfileFromDraftLegacyResult(
|
||||
draftProfile: CustomWorldAgentSessionSnapshot['draftProfile'],
|
||||
): CustomWorldProfile | null {
|
||||
if (!draftProfile || typeof draftProfile !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeCustomWorldProfileRecord(
|
||||
(draftProfile as { legacyResultProfile?: unknown }).legacyResultProfile ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCustomWorldProfileFromResultPreview(
|
||||
resultPreview:
|
||||
| CustomWorldAgentSessionSnapshot['resultPreview']
|
||||
@@ -15,14 +28,14 @@ export function buildCustomWorldProfileFromAgentSession(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return (
|
||||
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null) ??
|
||||
buildCustomWorldProfileFromResultPreview(session?.resultPreview)
|
||||
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
|
||||
buildCustomWorldProfileFromDraftLegacyResult(session?.draftProfile ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这是工作包 A 提供的新命名兼容层。
|
||||
* 主入口保持命名稳定,优先消费 Agent 草稿真相源,缺失时才回退到 resultPreview。
|
||||
* 主入口保持命名稳定,只消费结果页运行态快照,避免作品测试读到旧草稿骨架。
|
||||
*/
|
||||
export const rpgCreationPreviewAdapter = {
|
||||
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
||||
@@ -30,6 +43,6 @@ export const rpgCreationPreviewAdapter = {
|
||||
};
|
||||
|
||||
export {
|
||||
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
||||
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
|
||||
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user