Compare commits
2 Commits
2792df03a6
...
271db02e4a
| Author | SHA1 | Date | |
|---|---|---|---|
| 271db02e4a | |||
| b6c6640548 |
@@ -240,7 +240,7 @@ function buildNpcFirstContactOptionCatalog(
|
|||||||
- 不能再用“某人看着你,像是在等你把话接下去”这类第三人称占位旁白充当可见对话历史首句,也不能在聊天 state 里本地硬编码一条替代台词。
|
- 不能再用“某人看着你,像是在等你把话接下去”这类第三人称占位旁白充当可见对话历史首句,也不能在聊天 state 里本地硬编码一条替代台词。
|
||||||
- 当玩家在场景中第一次真正撞上角色型 NPC 并进入聊天时,应直接触发一轮由 NPC 主动开口的模型回复;这一轮只生成 NPC 自己的首句与后续可选回应,不得代替玩家补写未说过的话。
|
- 当玩家在场景中第一次真正撞上角色型 NPC 并进入聊天时,应直接触发一轮由 NPC 主动开口的模型回复;这一轮只生成 NPC 自己的首句与后续可选回应,不得代替玩家补写未说过的话。
|
||||||
- 负好感或敌对关系不应跳过主动开口;如果玩家从 NPC 交互面板点击 `npc_chat`,且该角色尚未完成 `firstMeaningfulContactResolved`,仍要走同一条 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. 首遇状态下,不允许前两项直接变成:
|
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. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。
|
5. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。
|
||||||
6. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。
|
6. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。
|
||||||
7. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。
|
7. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。
|
||||||
8. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用原敌对出口:继续推进后回到原有战斗或逃跑选择。
|
8. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用敌对出口:继续推进后展示一个“战斗”选项,以及按相邻场景和当前场景起点展开的多个逃跑选项。
|
||||||
9. 聊天候选中允许混入当前 NPC 可执行 function,例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。
|
9. 聊天候选中允许混入当前 NPC 可执行 function,例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。
|
||||||
10. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。
|
10. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。
|
||||||
11. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。
|
11. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。
|
||||||
@@ -68,6 +68,14 @@
|
|||||||
5. 相邻场景选项继续使用 `idle_travel_next_scene`,并在 `runtimePayload.targetSceneId` 中携带目标场景,后续点击沿用现有地图跳转结算。
|
5. 相邻场景选项继续使用 `idle_travel_next_scene`,并在 `runtimePayload.targetSceneId` 中携带目标场景,后续点击沿用现有地图跳转结算。
|
||||||
6. 若没有场景幕数据,则继续使用当前可用选项作为兜底,不额外生成规则说明文案。
|
6. 若没有场景幕数据,则继续使用当前可用选项作为兜底,不额外生成规则说明文案。
|
||||||
|
|
||||||
|
## 8. 补充规则:敌对聊天逃跑目标展开
|
||||||
|
|
||||||
|
1. 负好感或敌对 NPC 聊天终止后,`npc_fight` 只保留一个,按钮文案固定为“战斗”,原有 NPC 战斗交互与结算链路不变。
|
||||||
|
2. 原单一“逃跑”按钮改为多个 `battle_escape_breakout` 选项:当前场景每个相邻场景生成“逃往{场景名}”,并额外生成“逃回当前场景起点”。
|
||||||
|
3. 逃往相邻场景的选项在 `runtimePayload.targetSceneId` 中写入目标场景 id;逃回起点的选项在 `runtimePayload.escapeReturnToSceneStart` 中写入 `true`,并保留当前场景 id 作为目标。
|
||||||
|
4. 点击任一逃跑类选项时,先复用现有主角向左转身跑出屏幕的逃离动画,再把运行态切到目标场景或当前场景起点,最后从左侧入场并面向右侧。
|
||||||
|
5. 逃跑类选项只负责运行态目标和表现,不重新请求剧情推理,也不把规则说明显示到 UI。
|
||||||
|
|
||||||
## 7. 验收
|
## 7. 验收
|
||||||
|
|
||||||
1. 负好感主 NPC 不再出现固定 `turnLimit: 5`。
|
1. 负好感主 NPC 不再出现固定 `turnLimit: 5`。
|
||||||
|
|||||||
@@ -253,3 +253,40 @@ AI 可以解释世界,但不能私自改世界。
|
|||||||
这类 AI 冒险 RPG 的开发,最难的不是“把功能做出来”,而是:
|
这类 AI 冒险 RPG 的开发,最难的不是“把功能做出来”,而是:
|
||||||
|
|
||||||
**让 function 边界、世界状态、视觉演绎、移动端面板和大模型文本在同一套规则下稳定协作。**
|
**让 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. 初始局面不是已完成态
|
1. 初始局面不是已完成态
|
||||||
2. 初始局面至少存在可推进空间
|
2. 初始局面至少存在可推进空间
|
||||||
3. 初始局面不能存在任何已经正确相邻的两块,避免玩家开局即看到自动合并块
|
3. 初始局面不能存在任何在原图中相邻的两块互相贴边,避免玩家开局即看到接近完成的局部结构
|
||||||
|
|
||||||
初始化算法必须对候选打乱结果做正确相邻关系扫描:若任意两块在当前棋盘四向相邻,且它们在原图中的正确坐标也以相同方向相邻,则该候选布局无效,需要继续洗牌。多次随机尝试仍未得到合法布局时,使用确定性反序布局兜底;该布局等价于完整棋盘旋转 180 度,可保证原图相邻块不会以正确方向相邻。
|
初始化算法必须对候选打乱结果做原图相邻关系扫描:若任意两块在当前棋盘四向相邻,且它们在原图中的正确坐标也四向相邻,则该候选布局无效,需要继续洗牌。多次随机尝试仍未得到合法布局时,使用确定性约束搜索兜底,逐格放置拼块并排除所有原图相邻块贴边的候选。
|
||||||
|
|
||||||
## 9.5 交互规则总览
|
## 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` 使用种子化洗牌生成初始棋盘,不再固定左移一格。
|
1. `build_initial_board_with_seed` 使用种子化洗牌生成初始棋盘,不再固定左移一格。
|
||||||
2. 正式 run 的种子由 `runId + profileId + levelIndex + gridSize` 派生;由于每次进入都会创建新的 `runId`,同一作品多次进入也会得到不同打乱样式。
|
2. 正式 run 的种子由 `runId + profileId + levelIndex + gridSize` 派生;由于每次进入都会创建新的 `runId`,同一作品多次进入也会得到不同打乱样式。
|
||||||
3. 洗牌后若极端情况下仍为完成态,强制旋转一次,保证新关卡不是已完成局面。
|
3. 洗牌后若极端情况下仍为完成态,强制旋转一次,保证新关卡不是已完成局面。
|
||||||
4. 初始棋盘不得存在任何已经正确相邻的两块;初始化会多次洗牌筛选,若极端情况下未命中,则使用反序排列兜底,避免开局自动出现合并块。
|
4. 初始棋盘不得存在任何原图相邻块互相贴边;初始化会多次洗牌筛选,若极端情况下未命中,则使用确定性约束搜索兜底,避免开局出现局部连续结构。
|
||||||
5. `module-puzzle` 与本地 fallback 的测试都必须直接断言初始棋盘不存在正确相邻对,不能只检查 `mergedGroups = []`。
|
5. `module-puzzle` 与本地 fallback 的测试都必须直接断言初始棋盘不存在原图相邻贴边对,不能只检查 `mergedGroups = []`。
|
||||||
|
|
||||||
### 11.2 局部重算与合并
|
### 11.2 局部重算与合并
|
||||||
|
|
||||||
@@ -442,3 +442,15 @@ finalScore = tagSimilarityScore * 0.7 + sameAuthorScore * 0.3;
|
|||||||
1. `dragState` 持续记录 `currentX/currentY`,拖动中按指针偏移对单块或合并块做 `translate3d` 跟手反馈。
|
1. `dragState` 持续记录 `currentX/currentY`,拖动中按指针偏移对单块或合并块做 `translate3d` 跟手反馈。
|
||||||
2. 棋盘与合并块交互层增加 `touch-none select-none`,避免移动端滚动、选中文本等默认行为打断拖动。
|
2. 棋盘与合并块交互层增加 `touch-none select-none`,避免移动端滚动、选中文本等默认行为打断拖动。
|
||||||
3. 松手后仍只提交 `pieceId + targetRow + targetCol`,最终交换、合并、拆分和通关继续以后端/本地运行态快照为准。
|
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`。
|
以上局部测试、局部 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;
|
run: PuzzleRunSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmitPuzzleLeaderboardRequest {
|
||||||
|
profileId: string;
|
||||||
|
gridSize: PuzzleGridSize;
|
||||||
|
elapsedMs: number;
|
||||||
|
nickname: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SwapPuzzlePiecesRequest {
|
export interface SwapPuzzlePiecesRequest {
|
||||||
firstPieceId: string;
|
firstPieceId: string;
|
||||||
secondPieceId: string;
|
secondPieceId: string;
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ use crate::{
|
|||||||
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
||||||
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
||||||
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
|
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
|
||||||
|
submit_puzzle_leaderboard,
|
||||||
swap_puzzle_pieces,
|
swap_puzzle_pieces,
|
||||||
},
|
},
|
||||||
refresh_session::refresh_session,
|
refresh_session::refresh_session,
|
||||||
@@ -673,6 +674,13 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
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(
|
.route(
|
||||||
"/api/custom-world/entity",
|
"/api/custom-world/entity",
|
||||||
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
|
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -28,11 +28,8 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
|
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
|
||||||
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = [
|
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] =
|
||||||
"character_visual",
|
["character_visual", "scene_image", "puzzle_cover_image"];
|
||||||
"scene_image",
|
|
||||||
"puzzle_cover_image",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub async fn create_direct_upload_ticket(
|
pub async fn create_direct_upload_ticket(
|
||||||
State(state): State<AppState>,
|
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("character_visual"));
|
||||||
assert!(super::is_supported_asset_history_kind("scene_image"));
|
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_cover_image"));
|
||||||
assert!(!super::is_supported_asset_history_kind("puzzle_preview_image"));
|
assert!(!super::is_supported_asset_history_kind(
|
||||||
|
"puzzle_preview_image"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
"anchorQuestions": [
|
"anchorQuestions": [
|
||||||
{
|
{
|
||||||
"key": "themePromise",
|
"key": "themePromise",
|
||||||
"label": "题材承诺",
|
"label": "题材",
|
||||||
"question": "这张拼图给玩家的题材和完成期待是什么?",
|
"question": "这张拼图给玩家的题材和完成期待是什么?",
|
||||||
"requiredEffect": "明确拼图主题、辨识度和完成后的满足感。"
|
"requiredEffect": "明确拼图主题、辨识度和完成后的满足感。"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use module_assets::{
|
|||||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
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::{
|
use platform_oss::{
|
||||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||||
OssSignedGetObjectUrlRequest,
|
OssSignedGetObjectUrlRequest,
|
||||||
@@ -37,9 +37,10 @@ use shared_contracts::{
|
|||||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||||
puzzle_runtime::{
|
puzzle_runtime::{
|
||||||
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||||||
PuzzleCellPositionResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
|
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse,
|
||||||
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
|
PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse,
|
||||||
StartPuzzleRunRequest, SwapPuzzlePiecesRequest,
|
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
||||||
|
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
|
||||||
},
|
},
|
||||||
puzzle_works::{
|
puzzle_works::{
|
||||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||||
@@ -53,7 +54,8 @@ use spacetime_client::{
|
|||||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||||
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
||||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||||
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||||
|
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
||||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord,
|
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord,
|
||||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
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(
|
fn map_puzzle_agent_session_response(
|
||||||
session: PuzzleAgentSessionRecord,
|
session: PuzzleAgentSessionRecord,
|
||||||
) -> PuzzleAgentSessionSnapshotResponse {
|
) -> PuzzleAgentSessionSnapshotResponse {
|
||||||
@@ -1303,7 +1353,11 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
|||||||
previous_level_tags: run.previous_level_tags,
|
previous_level_tags: run.previous_level_tags,
|
||||||
current_level: run.current_level.map(map_puzzle_runtime_level_response),
|
current_level: run.current_level.map(map_puzzle_runtime_level_response),
|
||||||
recommended_next_profile_id: run.recommended_next_profile_id,
|
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,
|
previous_level_tags: run.previous_level_tags,
|
||||||
current_level: run.current_level.map(map_puzzle_level_request_record),
|
current_level: run.current_level.map(map_puzzle_level_request_record),
|
||||||
recommended_next_profile_id: run.recommended_next_profile_id,
|
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,
|
cover_image_src: level.cover_image_src,
|
||||||
board: map_puzzle_board_request_record(level.board),
|
board: map_puzzle_board_request_record(level.board),
|
||||||
status: level.status,
|
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,
|
cover_image_src: level.cover_image_src,
|
||||||
board: map_puzzle_board_response(level.board),
|
board: map_puzzle_board_response(level.board),
|
||||||
status: level.status,
|
status: level.status,
|
||||||
started_at_ms: 0,
|
started_at_ms: level.started_at_ms,
|
||||||
cleared_at_ms: None,
|
cleared_at_ms: level.cleared_at_ms,
|
||||||
elapsed_ms: None,
|
elapsed_ms: level.elapsed_ms,
|
||||||
leaderboard_entries: Vec::new(),
|
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) {
|
if !played_profile_ids.contains(&profile_id) {
|
||||||
played_profile_ids.push(profile_id.clone());
|
played_profile_ids.push(profile_id.clone());
|
||||||
}
|
}
|
||||||
|
let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index);
|
||||||
PuzzleRunRecord {
|
PuzzleRunRecord {
|
||||||
run_id: run.run_id.clone(),
|
run_id: run.run_id.clone(),
|
||||||
entry_profile_id: run.entry_profile_id,
|
entry_profile_id: run.entry_profile_id,
|
||||||
@@ -1939,52 +2033,125 @@ fn build_next_run_from_parts(
|
|||||||
author_display_name,
|
author_display_name,
|
||||||
theme_tags,
|
theme_tags,
|
||||||
cover_image_src,
|
cover_image_src,
|
||||||
board: build_local_puzzle_board(grid_size),
|
board,
|
||||||
status: "playing".to_string(),
|
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,
|
recommended_next_profile_id: None,
|
||||||
|
leaderboard_entries: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
|
fn build_local_puzzle_board(
|
||||||
let total = grid_size * grid_size;
|
grid_size: u32,
|
||||||
let mut positions = (0..total)
|
run_id: &str,
|
||||||
.map(|index| PuzzleCellPositionRecord {
|
profile_id: &str,
|
||||||
row: index / grid_size,
|
level_index: u32,
|
||||||
col: index % grid_size,
|
) -> PuzzleBoardRecord {
|
||||||
})
|
let board = module_puzzle::build_initial_board_with_seed(
|
||||||
.collect::<Vec<_>>();
|
grid_size,
|
||||||
if !positions.is_empty() {
|
build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size),
|
||||||
let first = positions.remove(0);
|
)
|
||||||
positions.push(first);
|
.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)
|
hash
|
||||||
.map(|index| {
|
}
|
||||||
let current =
|
|
||||||
positions
|
fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord {
|
||||||
.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();
|
|
||||||
PuzzleBoardRecord {
|
PuzzleBoardRecord {
|
||||||
rows: grid_size,
|
rows: board.rows,
|
||||||
cols: grid_size,
|
cols: board.cols,
|
||||||
pieces,
|
pieces: board
|
||||||
merged_groups: Vec::new(),
|
.pieces
|
||||||
selected_piece_id: None,
|
.into_iter()
|
||||||
all_tiles_resolved: false,
|
.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>,
|
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))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PuzzleBoardSnapshot {
|
pub struct PuzzleBoardSnapshot {
|
||||||
@@ -263,6 +272,10 @@ pub struct PuzzleRuntimeLevelSnapshot {
|
|||||||
pub cover_image_src: Option<String>,
|
pub cover_image_src: Option<String>,
|
||||||
pub board: PuzzleBoardSnapshot,
|
pub board: PuzzleBoardSnapshot,
|
||||||
pub status: PuzzleRuntimeLevelStatus,
|
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))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
@@ -277,6 +290,7 @@ pub struct PuzzleRunSnapshot {
|
|||||||
pub previous_level_tags: Vec<String>,
|
pub previous_level_tags: Vec<String>,
|
||||||
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
|
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
|
||||||
pub recommended_next_profile_id: Option<String>,
|
pub recommended_next_profile_id: Option<String>,
|
||||||
|
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
@@ -438,6 +452,18 @@ pub struct PuzzleRunNextLevelInput {
|
|||||||
pub advanced_at_micros: i64,
|
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))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PuzzleAgentSessionProcedureResult {
|
pub struct PuzzleAgentSessionProcedureResult {
|
||||||
@@ -924,6 +950,7 @@ pub fn start_run_with_shuffle_seed(
|
|||||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||||
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
||||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
|
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
|
||||||
|
let started_at_ms = current_unix_ms();
|
||||||
Ok(PuzzleRunSnapshot {
|
Ok(PuzzleRunSnapshot {
|
||||||
run_id: run_id.clone(),
|
run_id: run_id.clone(),
|
||||||
entry_profile_id: entry_profile.profile_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(),
|
cover_image_src: entry_profile.cover_image_src.clone(),
|
||||||
board,
|
board,
|
||||||
status: PuzzleRuntimeLevelStatus::Playing,
|
status: PuzzleRuntimeLevelStatus::Playing,
|
||||||
|
started_at_ms,
|
||||||
|
cleared_at_ms: None,
|
||||||
|
elapsed_ms: None,
|
||||||
|
leaderboard_entries: Vec::new(),
|
||||||
}),
|
}),
|
||||||
recommended_next_profile_id: None,
|
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(),
|
cover_image_src: next_profile.cover_image_src.clone(),
|
||||||
board: next_board,
|
board: next_board,
|
||||||
status: PuzzleRuntimeLevelStatus::Playing,
|
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,
|
recommended_next_profile_id: None,
|
||||||
|
leaderboard_entries: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1389,7 +1426,6 @@ fn build_initial_pieces_without_correct_neighbors(
|
|||||||
grid_size: u32,
|
grid_size: u32,
|
||||||
shuffle_seed: u64,
|
shuffle_seed: u64,
|
||||||
) -> Vec<PuzzlePieceState> {
|
) -> Vec<PuzzlePieceState> {
|
||||||
let total = grid_size * grid_size;
|
|
||||||
let base_positions = build_correct_positions(grid_size);
|
let base_positions = build_correct_positions(grid_size);
|
||||||
for attempt in 0..PUZZLE_INITIAL_SHUFFLE_ATTEMPTS {
|
for attempt in 0..PUZZLE_INITIAL_SHUFFLE_ATTEMPTS {
|
||||||
let mut positions = base_positions.clone();
|
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);
|
ensure_board_is_not_solved(&mut positions, grid_size);
|
||||||
let pieces = build_pieces_from_positions(grid_size, &positions);
|
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;
|
return pieces;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 反序布局等价于把完整棋盘旋转 180 度;任意原图相邻块在当前棋盘中的方向都会反向,
|
// 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。
|
||||||
// 因此可作为“开局没有正确相邻块”的确定性兜底。
|
let fallback_pieces = build_original_neighbor_free_pieces(grid_size, shuffle_seed)
|
||||||
let fallback_pieces =
|
.unwrap_or_else(|| build_pieces_from_positions(grid_size, &base_positions));
|
||||||
build_pieces_from_positions(grid_size, &build_reverse_positions(total, grid_size));
|
debug_assert!(!has_any_original_neighbor_pair(&fallback_pieces));
|
||||||
debug_assert!(!has_any_correct_neighbor_pair(&fallback_pieces));
|
|
||||||
fallback_pieces
|
fallback_pieces
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1422,16 +1457,6 @@ fn build_correct_positions(grid_size: u32) -> Vec<PuzzleCellPosition> {
|
|||||||
.collect()
|
.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(
|
fn build_pieces_from_positions(
|
||||||
grid_size: u32,
|
grid_size: u32,
|
||||||
positions: &[PuzzleCellPosition],
|
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
|
let pieces_by_cell = pieces
|
||||||
.iter()
|
.iter()
|
||||||
.map(|piece| ((piece.current_row, piece.current_col), piece))
|
.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)
|
neighbor_cells(piece.current_row, piece.current_col)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|cell| pieces_by_cell.get(&cell))
|
.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(
|
fn rebuild_board_snapshot(
|
||||||
grid_size: u32,
|
grid_size: u32,
|
||||||
pieces: Vec<PuzzlePieceState>,
|
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() {
|
if let Some(current_level) = next_run.current_level.as_mut() {
|
||||||
current_level.board = next_board;
|
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;
|
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.cleared_level_count += 1;
|
||||||
}
|
}
|
||||||
next_run
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1951,14 +2121,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initial_board_has_no_correct_neighbor_pairs() {
|
fn initial_board_has_no_original_neighbor_pairs() {
|
||||||
for grid_size in [3, 4] {
|
for grid_size in [3, 4] {
|
||||||
for shuffle_seed in 0..128 {
|
for shuffle_seed in 0..128 {
|
||||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board");
|
let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board");
|
||||||
|
|
||||||
assert!(board.merged_groups.is_empty());
|
assert!(board.merged_groups.is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
!has_any_correct_neighbor_pair(&board.pieces),
|
!has_any_original_neighbor_pair(&board.pieces),
|
||||||
"grid_size={grid_size}, shuffle_seed={shuffle_seed}"
|
"grid_size={grid_size}, shuffle_seed={shuffle_seed}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ pub struct DragPuzzlePieceRequest {
|
|||||||
pub target_col: u32,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PuzzleCellPositionResponse {
|
pub struct PuzzleCellPositionResponse {
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ pub use mapper::{
|
|||||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||||
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
||||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||||
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||||
|
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
|
||||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||||
PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||||
|
|||||||
@@ -2252,6 +2252,11 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz
|
|||||||
.current_level
|
.current_level
|
||||||
.map(map_puzzle_runtime_level_snapshot),
|
.map(map_puzzle_runtime_level_snapshot),
|
||||||
recommended_next_profile_id: snapshot.recommended_next_profile_id,
|
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,
|
cover_image_src: snapshot.cover_image_src,
|
||||||
board: map_puzzle_board_snapshot(snapshot.board),
|
board: map_puzzle_board_snapshot(snapshot.board),
|
||||||
status: snapshot.status.as_str().to_string(),
|
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>,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct PuzzleBoardRecord {
|
pub struct PuzzleBoardRecord {
|
||||||
pub rows: u32,
|
pub rows: u32,
|
||||||
@@ -4321,6 +4353,10 @@ pub struct PuzzleRuntimeLevelRecord {
|
|||||||
pub cover_image_src: Option<String>,
|
pub cover_image_src: Option<String>,
|
||||||
pub board: PuzzleBoardRecord,
|
pub board: PuzzleBoardRecord,
|
||||||
pub status: String,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -4334,6 +4370,18 @@ pub struct PuzzleRunRecord {
|
|||||||
pub previous_level_tags: Vec<String>,
|
pub previous_level_tags: Vec<String>,
|
||||||
pub current_level: Option<PuzzleRuntimeLevelRecord>,
|
pub current_level: Option<PuzzleRuntimeLevelRecord>,
|
||||||
pub recommended_next_profile_id: Option<String>,
|
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)]
|
#[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_agent_stage_type;
|
||||||
pub mod puzzle_draft_compile_input_type;
|
pub mod puzzle_draft_compile_input_type;
|
||||||
pub mod puzzle_generated_images_save_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_publication_status_type;
|
||||||
pub mod puzzle_publish_input_type;
|
pub mod puzzle_publish_input_type;
|
||||||
pub mod puzzle_run_drag_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_big_fish_message_procedure;
|
||||||
pub mod submit_custom_world_agent_message_procedure;
|
pub mod submit_custom_world_agent_message_procedure;
|
||||||
pub mod submit_puzzle_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 swap_puzzle_pieces_procedure;
|
||||||
pub mod treasure_interaction_action_type;
|
pub mod treasure_interaction_action_type;
|
||||||
pub mod treasure_record_procedure_result_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_agent_stage_type::PuzzleAgentStage;
|
||||||
pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput;
|
pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput;
|
||||||
pub use puzzle_generated_images_save_input_type::PuzzleGeneratedImagesSaveInput;
|
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_publication_status_type::PuzzlePublicationStatus;
|
||||||
pub use puzzle_publish_input_type::PuzzlePublishInput;
|
pub use puzzle_publish_input_type::PuzzlePublishInput;
|
||||||
pub use puzzle_run_drag_input_type::PuzzleRunDragInput;
|
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_big_fish_message_procedure::submit_big_fish_message;
|
||||||
pub use submit_custom_world_agent_message_procedure::submit_custom_world_agent_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_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 swap_puzzle_pieces_procedure::swap_puzzle_pieces;
|
||||||
pub use treasure_interaction_action_type::TreasureInteractionAction;
|
pub use treasure_interaction_action_type::TreasureInteractionAction;
|
||||||
pub use treasure_record_procedure_result_type::TreasureRecordProcedureResult;
|
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
|
.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,
|
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
|
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
|
||||||
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft,
|
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft,
|
||||||
PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult,
|
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput,
|
||||||
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
|
PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput,
|
||||||
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
|
PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
|
||||||
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput,
|
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
|
||||||
PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate,
|
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
||||||
build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack,
|
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
|
||||||
normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size, select_next_profile,
|
compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags,
|
||||||
start_run, swap_pieces,
|
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::from_str as json_from_str;
|
||||||
use serde_json::to_string as json_to_string;
|
use serde_json::to_string as json_to_string;
|
||||||
@@ -102,6 +102,25 @@ pub struct PuzzleRuntimeRunRow {
|
|||||||
updated_at: Timestamp,
|
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]
|
#[spacetimedb::procedure]
|
||||||
pub fn create_puzzle_agent_session(
|
pub fn create_puzzle_agent_session(
|
||||||
ctx: &mut ProcedureContext,
|
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(
|
fn create_puzzle_agent_session_tx(
|
||||||
ctx: &TxContext,
|
ctx: &TxContext,
|
||||||
input: PuzzleAgentSessionCreateInput,
|
input: PuzzleAgentSessionCreateInput,
|
||||||
@@ -1017,6 +1055,15 @@ fn start_puzzle_run_tx(
|
|||||||
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
||||||
let mut run =
|
let mut run =
|
||||||
start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?;
|
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(
|
run.recommended_next_profile_id = select_next_profile(
|
||||||
&entry_profile,
|
&entry_profile,
|
||||||
&run.played_profile_ids,
|
&run.played_profile_ids,
|
||||||
@@ -1034,7 +1081,21 @@ fn get_puzzle_run_tx(
|
|||||||
input: PuzzleRunGetInput,
|
input: PuzzleRunGetInput,
|
||||||
) -> Result<PuzzleRunSnapshot, String> {
|
) -> Result<PuzzleRunSnapshot, String> {
|
||||||
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
|
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(
|
fn swap_puzzle_pieces_tx(
|
||||||
@@ -1098,6 +1159,15 @@ fn advance_puzzle_next_level_tx(
|
|||||||
.clone();
|
.clone();
|
||||||
let mut next_run = module_puzzle::advance_next_level(¤t_run, &next_profile)
|
let mut next_run = module_puzzle::advance_next_level(¤t_run, &next_profile)
|
||||||
.map_err(|error| error.to_string())?;
|
.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 =
|
next_run.recommended_next_profile_id =
|
||||||
select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates)
|
select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates)
|
||||||
.map(|value| value.profile_id.clone());
|
.map(|value| value.profile_id.clone());
|
||||||
@@ -1114,6 +1184,58 @@ fn advance_puzzle_next_level_tx(
|
|||||||
Ok(next_run)
|
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(
|
fn build_puzzle_agent_session_snapshot(
|
||||||
ctx: &TxContext,
|
ctx: &TxContext,
|
||||||
row: &PuzzleAgentSessionRow,
|
row: &PuzzleAgentSessionRow,
|
||||||
@@ -1536,6 +1658,116 @@ fn refresh_next_profile_recommendation(
|
|||||||
Ok(())
|
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 {
|
fn serialize_json<T: ::serde::Serialize>(value: &T) -> String {
|
||||||
json_to_string(value).unwrap_or_else(|_| "{}".to_string())
|
json_to_string(value).unwrap_or_else(|_| "{}".to_string())
|
||||||
}
|
}
|
||||||
@@ -1568,6 +1800,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use module_puzzle::{
|
use module_puzzle::{
|
||||||
build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score,
|
build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score,
|
||||||
|
PuzzleLeaderboardEntry,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1582,6 +1815,7 @@ mod tests {
|
|||||||
previous_level_tags: vec!["蒸汽城市".to_string()],
|
previous_level_tags: vec!["蒸汽城市".to_string()],
|
||||||
current_level: None,
|
current_level: None,
|
||||||
recommended_next_profile_id: None,
|
recommended_next_profile_id: None,
|
||||||
|
leaderboard_entries: Vec::new(),
|
||||||
};
|
};
|
||||||
let serialized = serialize_json(&snapshot);
|
let serialized = serialize_json(&snapshot);
|
||||||
let parsed = deserialize_run(&serialized).expect("run json should parse");
|
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)
|
> 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 { useAuthUi } from './components/auth/AuthUiContext';
|
||||||
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
|
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 type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
|
||||||
import {
|
import {
|
||||||
APP_RUNTIME_ROUTES,
|
APP_RUNTIME_ROUTES,
|
||||||
@@ -40,6 +43,8 @@ export default function App() {
|
|||||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||||
resolveSelectionStageFromPath(window.location.pathname),
|
resolveSelectionStageFromPath(window.location.pathname),
|
||||||
);
|
);
|
||||||
|
const [runtimeReturnStage, setRuntimeReturnStage] =
|
||||||
|
useState<SelectionStage>('platform');
|
||||||
|
|
||||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||||
setRawSelectionStage(stage);
|
setRawSelectionStage(stage);
|
||||||
@@ -86,10 +91,17 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCustomWorldSelect = useCallback(
|
const handleCustomWorldSelect = useCallback(
|
||||||
(customWorldProfile: CustomWorldProfile) => {
|
(
|
||||||
|
customWorldProfile: CustomWorldProfile,
|
||||||
|
options?: CustomWorldRuntimeLaunchOptions,
|
||||||
|
) => {
|
||||||
|
// 中文注释:作品测试需要在结束测试后精确返回启动它的结果页;
|
||||||
|
// 正式进入世界仍保持既有平台首页返回语义。
|
||||||
|
setRuntimeReturnStage(options?.returnStage ?? 'platform');
|
||||||
createRuntimeIntent({
|
createRuntimeIntent({
|
||||||
kind: 'custom-world',
|
kind: 'custom-world',
|
||||||
profile: customWorldProfile,
|
profile: customWorldProfile,
|
||||||
|
mode: options?.mode ?? 'play',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[createRuntimeIntent],
|
[createRuntimeIntent],
|
||||||
@@ -102,7 +114,13 @@ export default function App() {
|
|||||||
if (isRuntimeActive) {
|
if (isRuntimeActive) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<RpgRuntimeApp initialIntent={runtimeIntent} />
|
<RpgRuntimeApp
|
||||||
|
initialIntent={runtimeIntent}
|
||||||
|
onExitRuntime={() => {
|
||||||
|
setIsRuntimeActive(false);
|
||||||
|
setSelectionStage(runtimeReturnStage);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export default function PuzzlePlaygroundApp() {
|
|||||||
return (
|
return (
|
||||||
<PuzzleRuntimeShell
|
<PuzzleRuntimeShell
|
||||||
run={run}
|
run={run}
|
||||||
|
isBusy={false}
|
||||||
|
error={null}
|
||||||
onBack={handleRestart}
|
onBack={handleRestart}
|
||||||
onSwapPieces={handleSwapPieces}
|
onSwapPieces={handleSwapPieces}
|
||||||
onDragPiece={handleDragPiece}
|
onDragPiece={handleDragPiece}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type RpgRuntimeAppIntent =
|
|||||||
token: number;
|
token: number;
|
||||||
kind: 'custom-world';
|
kind: 'custom-world';
|
||||||
profile: CustomWorldProfile;
|
profile: CustomWorldProfile;
|
||||||
|
mode?: 'play' | 'test';
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
token: number;
|
token: number;
|
||||||
@@ -19,8 +20,10 @@ export type RpgRuntimeAppIntent =
|
|||||||
|
|
||||||
export function RpgRuntimeApp({
|
export function RpgRuntimeApp({
|
||||||
initialIntent,
|
initialIntent,
|
||||||
|
onExitRuntime,
|
||||||
}: {
|
}: {
|
||||||
initialIntent: RpgRuntimeAppIntent | null;
|
initialIntent: RpgRuntimeAppIntent | null;
|
||||||
|
onExitRuntime?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const gameShellProps = useRpgRuntimeSession();
|
const gameShellProps = useRpgRuntimeSession();
|
||||||
const handledIntentTokenRef = useRef<number | null>(null);
|
const handledIntentTokenRef = useRef<number | null>(null);
|
||||||
@@ -32,14 +35,16 @@ export function RpgRuntimeApp({
|
|||||||
|
|
||||||
handledIntentTokenRef.current = initialIntent.token;
|
handledIntentTokenRef.current = initialIntent.token;
|
||||||
if (initialIntent.kind === 'custom-world') {
|
if (initialIntent.kind === 'custom-world') {
|
||||||
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile);
|
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile, {
|
||||||
|
mode: initialIntent.mode ?? 'play',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
gameShellProps.entry.handleContinueGame(initialIntent.snapshot);
|
gameShellProps.entry.handleContinueGame(initialIntent.snapshot);
|
||||||
}, [gameShellProps.entry, initialIntent]);
|
}, [gameShellProps.entry, initialIntent]);
|
||||||
|
|
||||||
return <RpgRuntimeShell {...gameShellProps} />;
|
return <RpgRuntimeShell {...gameShellProps} onExitTestRuntime={onExitRuntime} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RpgRuntimeApp;
|
export default RpgRuntimeApp;
|
||||||
|
|||||||
@@ -187,6 +187,55 @@ describe('GameCanvasEntityLayer', () => {
|
|||||||
expect(html).not.toContain('好感度变化 +3');
|
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', () => {
|
it('renders scene act back-row encounters alongside the primary encounter', () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<GameCanvasEntityLayer
|
<GameCanvasEntityLayer
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
DialogueBubbleIcon,
|
DialogueBubbleIcon,
|
||||||
type GameCanvasEntitySelection,
|
type GameCanvasEntitySelection,
|
||||||
GENERIC_NPC_SCENE_SCALE,
|
GENERIC_NPC_SCENE_SCALE,
|
||||||
|
getBattleCompanionSlotOffset,
|
||||||
getCompanionSlotOffset,
|
getCompanionSlotOffset,
|
||||||
getEncounterCharacterBottomOffsetPx,
|
getEncounterCharacterBottomOffsetPx,
|
||||||
getEncounterCharacterOpponentBottom,
|
getEncounterCharacterOpponentBottom,
|
||||||
@@ -222,11 +223,23 @@ export function GameCanvasEntityLayer({
|
|||||||
const [combatFeedbackEvents, setCombatFeedbackEvents] = useState<CombatFeedbackEvent[]>([]);
|
const [combatFeedbackEvents, setCombatFeedbackEvents] = useState<CombatFeedbackEvent[]>([]);
|
||||||
const previousCombatSamplesRef = useRef<Map<string, CombatFeedbackHealthSample> | null>(null);
|
const previousCombatSamplesRef = useRef<Map<string, CombatFeedbackHealthSample> | null>(null);
|
||||||
const combatFeedbackSequenceRef = useRef(0);
|
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 =
|
const shouldRenderPeacefulEncounter =
|
||||||
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
|
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
|
||||||
const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
|
const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
|
||||||
() => {
|
() => {
|
||||||
if (!inBattle) return [];
|
if (!shouldRenderCombatPresentation) return [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{key: 'player', kind: 'player', hp: playerHp},
|
{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 combatFeedbackByTarget = useMemo(() => {
|
||||||
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
|
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
|
||||||
@@ -259,7 +272,7 @@ export function GameCanvasEntityLayer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inBattle) {
|
if (!shouldRenderCombatPresentation) {
|
||||||
previousCombatSamplesRef.current = null;
|
previousCombatSamplesRef.current = null;
|
||||||
setCombatFeedbackEvents([]);
|
setCombatFeedbackEvents([]);
|
||||||
return;
|
return;
|
||||||
@@ -283,12 +296,14 @@ export function GameCanvasEntityLayer({
|
|||||||
previousCombatSamplesRef.current = new Map(
|
previousCombatSamplesRef.current = new Map(
|
||||||
combatHealthSamples.map(sample => [sample.key, sample]),
|
combatHealthSamples.map(sample => [sample.key, sample]),
|
||||||
);
|
);
|
||||||
}, [combatHealthSamples, inBattle]);
|
}, [combatHealthSamples, shouldRenderCombatPresentation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{companions.map(companion => {
|
{companions.map(companion => {
|
||||||
const slotOffset = getCompanionSlotOffset(companion.slot);
|
const slotOffset = inBattle
|
||||||
|
? getBattleCompanionSlotOffset(companion.slot)
|
||||||
|
: getCompanionSlotOffset(companion.slot);
|
||||||
const feedbackTargetKey = `companion:${companion.npcId}`;
|
const feedbackTargetKey = `companion:${companion.npcId}`;
|
||||||
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
|
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
|
||||||
const companionFacing = companion.facing ?? 'right';
|
const companionFacing = companion.facing ?? 'right';
|
||||||
@@ -314,7 +329,7 @@ export function GameCanvasEntityLayer({
|
|||||||
style={{
|
style={{
|
||||||
left: companionAnchorLeft,
|
left: companionAnchorLeft,
|
||||||
bottom: companionAnchorBottom,
|
bottom: companionAnchorBottom,
|
||||||
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
|
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom) + (inBattle ? 1 : 0),
|
||||||
transition: 'left 260ms linear, bottom 180ms ease',
|
transition: 'left 260ms linear, bottom 180ms ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -336,7 +351,7 @@ export function GameCanvasEntityLayer({
|
|||||||
className="relative flex w-28 flex-col items-center"
|
className="relative flex w-28 flex-col items-center"
|
||||||
>
|
>
|
||||||
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
||||||
{inBattle && (
|
{shouldRenderCombatPresentation && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-1/2 -translate-x-1/2"
|
className="absolute left-1/2 -translate-x-1/2"
|
||||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||||
@@ -385,7 +400,7 @@ export function GameCanvasEntityLayer({
|
|||||||
events={combatFeedbackByTarget.get('player') ?? []}
|
events={combatFeedbackByTarget.get('player') ?? []}
|
||||||
onDone={removeCombatFeedbackEvent}
|
onDone={removeCombatFeedbackEvent}
|
||||||
/>
|
/>
|
||||||
{inBattle && (
|
{shouldRenderCombatPresentation && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-1/2 -translate-x-1/2"
|
className="absolute left-1/2 -translate-x-1/2"
|
||||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||||
@@ -484,7 +499,7 @@ export function GameCanvasEntityLayer({
|
|||||||
className="relative flex w-28 flex-col items-center"
|
className="relative flex w-28 flex-col items-center"
|
||||||
>
|
>
|
||||||
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
||||||
{inBattle && (
|
{shouldRenderCombatPresentation && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-1/2 -translate-x-1/2"
|
className="absolute left-1/2 -translate-x-1/2"
|
||||||
style={{top: `${npcCombatHpTop}px`}}
|
style={{top: `${npcCombatHpTop}px`}}
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ export function getCompanionSlotOffset(slot: CompanionRenderState['slot']) {
|
|||||||
: {left: -34, bottom: 10};
|
: {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']) {
|
export function mapHostileNpcAnimationToCharacterState(animation: SceneHostileNpc['animation']) {
|
||||||
if (animation === 'move') return AnimationState.RUN;
|
if (animation === 'move') return AnimationState.RUN;
|
||||||
if (animation === 'attack') return AnimationState.ATTACK;
|
if (animation === 'attack') return AnimationState.ATTACK;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import type {
|
|||||||
PuzzleAgentSessionSnapshot,
|
PuzzleAgentSessionSnapshot,
|
||||||
SendPuzzleAgentMessageRequest,
|
SendPuzzleAgentMessageRequest,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
} 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 { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
import type {
|
import type {
|
||||||
CustomWorldGalleryCard,
|
CustomWorldGalleryCard,
|
||||||
@@ -80,7 +83,10 @@ import {
|
|||||||
getPuzzleGalleryDetail,
|
getPuzzleGalleryDetail,
|
||||||
listPuzzleGallery,
|
listPuzzleGallery,
|
||||||
} from '../../services/puzzle-gallery';
|
} from '../../services/puzzle-gallery';
|
||||||
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
|
import {
|
||||||
|
advanceLocalPuzzleNextLevel,
|
||||||
|
submitPuzzleLeaderboard,
|
||||||
|
} from '../../services/puzzle-runtime';
|
||||||
import {
|
import {
|
||||||
dragLocalPuzzlePiece,
|
dragLocalPuzzlePiece,
|
||||||
startLocalPuzzleRun,
|
startLocalPuzzleRun,
|
||||||
@@ -427,6 +433,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
useState<PuzzleDetailReturnTarget | null>(null);
|
useState<PuzzleDetailReturnTarget | null>(null);
|
||||||
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
|
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
|
||||||
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
|
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
|
||||||
|
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
|
||||||
|
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
|
||||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||||
const [puzzleGenerationState, setPuzzleGenerationState] =
|
const [puzzleGenerationState, setPuzzleGenerationState] =
|
||||||
@@ -1236,6 +1244,50 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
[isPuzzleBusy, puzzleRun],
|
[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 () => {
|
const advancePuzzleLevel = useCallback(async () => {
|
||||||
if (!puzzleRun || isPuzzleBusy) {
|
if (!puzzleRun || isPuzzleBusy) {
|
||||||
return;
|
return;
|
||||||
@@ -2407,13 +2459,17 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
<Suspense
|
<Suspense
|
||||||
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
|
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
|
||||||
>
|
>
|
||||||
<PuzzleRuntimeShell
|
<PuzzleRuntimeShell
|
||||||
run={puzzleRun}
|
run={puzzleRun}
|
||||||
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
|
isBusy={
|
||||||
error={puzzleError}
|
isPuzzleBusy ||
|
||||||
onBack={() => {
|
isPuzzleNextLevelGenerating ||
|
||||||
setSelectionStage(puzzleRuntimeReturnStage);
|
isPuzzleLeaderboardBusy
|
||||||
}}
|
}
|
||||||
|
error={puzzleError}
|
||||||
|
onBack={() => {
|
||||||
|
setSelectionStage(puzzleRuntimeReturnStage);
|
||||||
|
}}
|
||||||
onSwapPieces={(payload) => {
|
onSwapPieces={(payload) => {
|
||||||
void swapPuzzlePiecesInRun(payload);
|
void swapPuzzlePiecesInRun(payload);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import type {
|
|||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
|
|
||||||
|
export type CustomWorldRuntimeLaunchMode = 'play' | 'test';
|
||||||
|
|
||||||
|
export type CustomWorldRuntimeLaunchOptions = {
|
||||||
|
mode?: CustomWorldRuntimeLaunchMode;
|
||||||
|
returnStage?: SelectionStage | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type SelectionStage =
|
export type SelectionStage =
|
||||||
| 'platform'
|
| 'platform'
|
||||||
| 'detail'
|
| 'detail'
|
||||||
@@ -38,5 +45,8 @@ export type PlatformEntryFlowShellProps = {
|
|||||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||||
handleStartNewGame: () => void;
|
handleStartNewGame: () => void;
|
||||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
handleCustomWorldSelect: (
|
||||||
|
customWorldProfile: CustomWorldProfile,
|
||||||
|
options?: CustomWorldRuntimeLaunchOptions,
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @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 { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||||
|
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||||
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
|
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
|
||||||
|
|
||||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||||
@@ -18,6 +19,34 @@ vi.mock('../ResolvedAssetImage', () => ({
|
|||||||
ResolvedAssetImage: () => null,
|
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 = {
|
const clearedRun: PuzzleRunSnapshot = {
|
||||||
runId: 'run-1',
|
runId: 'run-1',
|
||||||
entryProfileId: 'profile-1',
|
entryProfileId: 'profile-1',
|
||||||
@@ -85,9 +114,10 @@ const clearedRun: PuzzleRunSnapshot = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
const onAdvanceNextLevel = vi.fn();
|
const onAdvanceNextLevel = vi.fn();
|
||||||
|
|
||||||
render(
|
renderPuzzleRuntime(
|
||||||
<PuzzleRuntimeShell
|
<PuzzleRuntimeShell
|
||||||
run={clearedRun}
|
run={clearedRun}
|
||||||
onBack={vi.fn()}
|
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: '通关完成' });
|
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||||
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
|
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
|
||||||
expect(within(dialog).getByText('排行榜')).toBeTruthy();
|
expect(within(dialog).getByText('排行榜')).toBeTruthy();
|
||||||
@@ -106,10 +143,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
|||||||
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
|
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||||
|
|
||||||
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
|
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('关闭通关弹窗后保留底部下一关入口', () => {
|
test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||||
render(
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
renderPuzzleRuntime(
|
||||||
<PuzzleRuntimeShell
|
<PuzzleRuntimeShell
|
||||||
run={clearedRun}
|
run={clearedRun}
|
||||||
onBack={vi.fn()}
|
onBack={vi.fn()}
|
||||||
@@ -119,8 +159,91 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1_400);
|
||||||
|
});
|
||||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||||
expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy();
|
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -9,7 +9,10 @@ import type {
|
|||||||
PuzzleRunSnapshot,
|
PuzzleRunSnapshot,
|
||||||
SwapPuzzlePiecesRequest,
|
SwapPuzzlePiecesRequest,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||||
|
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||||
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
|
import { PixelIcon } from '../PixelIcon';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
|
||||||
type PuzzleRuntimeShellProps = {
|
type PuzzleRuntimeShellProps = {
|
||||||
@@ -29,7 +32,6 @@ type PuzzleBoardPieceViewModel = {
|
|||||||
correctRow: number;
|
correctRow: number;
|
||||||
correctCol: number;
|
correctCol: number;
|
||||||
mergedGroupId: string | null;
|
mergedGroupId: string | null;
|
||||||
label: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PuzzleMergedGroupViewModel = {
|
type PuzzleMergedGroupViewModel = {
|
||||||
@@ -59,9 +61,41 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPieceLabel(pieceId: string) {
|
function buildLocalCellKey(row: number, col: number) {
|
||||||
const fallback = pieceId.slice(-2).toUpperCase();
|
return `${row}:${col}`;
|
||||||
return fallback || '块';
|
}
|
||||||
|
|
||||||
|
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(
|
function buildMergedGroupViewModels(
|
||||||
@@ -117,6 +151,10 @@ function formatElapsedMs(elapsedMs: number | null | undefined) {
|
|||||||
.padStart(2, '0')}`;
|
.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,
|
onDragPiece,
|
||||||
onAdvanceNextLevel,
|
onAdvanceNextLevel,
|
||||||
}: PuzzleRuntimeShellProps) {
|
}: PuzzleRuntimeShellProps) {
|
||||||
|
const authUi = useAuthUi();
|
||||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||||
|
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||||
const dragSessionRef = useRef<{
|
const dragSessionRef = useRef<{
|
||||||
pieceId: string;
|
pieceId: string;
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
@@ -148,10 +188,21 @@ export function PuzzleRuntimeShell({
|
|||||||
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
|
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||||
const groupElementRefMap = 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 boardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const currentLevel = run?.currentLevel ?? null;
|
const currentLevel = run?.currentLevel ?? null;
|
||||||
const board = currentLevel?.board ?? 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(
|
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
||||||
currentLevel?.coverImageSrc ?? null,
|
currentLevel?.coverImageSrc ?? null,
|
||||||
);
|
);
|
||||||
@@ -167,7 +218,6 @@ export function PuzzleRuntimeShell({
|
|||||||
correctRow: piece.correctRow,
|
correctRow: piece.correctRow,
|
||||||
correctCol: piece.correctCol,
|
correctCol: piece.correctCol,
|
||||||
mergedGroupId: piece.mergedGroupId,
|
mergedGroupId: piece.mergedGroupId,
|
||||||
label: buildPieceLabel(piece.pieceId),
|
|
||||||
}));
|
}));
|
||||||
}, [board]);
|
}, [board]);
|
||||||
|
|
||||||
@@ -206,7 +256,9 @@ export function PuzzleRuntimeShell({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieceElement = pieceElementRefMap.current.get(dragVisualTarget.pieceId);
|
const pieceElement = pieceElementRefMap.current.get(
|
||||||
|
dragVisualTarget.pieceId,
|
||||||
|
);
|
||||||
if (pieceElement) {
|
if (pieceElement) {
|
||||||
pieceElement.style.transform = '';
|
pieceElement.style.transform = '';
|
||||||
pieceElement.style.willChange = '';
|
pieceElement.style.willChange = '';
|
||||||
@@ -215,7 +267,9 @@ export function PuzzleRuntimeShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dragVisualTarget.groupId) {
|
if (dragVisualTarget.groupId) {
|
||||||
const groupElement = groupElementRefMap.current.get(dragVisualTarget.groupId);
|
const groupElement = groupElementRefMap.current.get(
|
||||||
|
dragVisualTarget.groupId,
|
||||||
|
);
|
||||||
if (groupElement) {
|
if (groupElement) {
|
||||||
groupElement.style.transform = '';
|
groupElement.style.transform = '';
|
||||||
groupElement.style.willChange = '';
|
groupElement.style.willChange = '';
|
||||||
@@ -304,10 +358,66 @@ export function PuzzleRuntimeShell({
|
|||||||
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(
|
||||||
cancelDragVisualFrame();
|
() => () => {
|
||||||
resetDragVisualTarget();
|
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) {
|
if (!run || !currentLevel || !board) {
|
||||||
return (
|
return (
|
||||||
@@ -453,17 +563,18 @@ export function PuzzleRuntimeShell({
|
|||||||
scheduleDragVisual();
|
scheduleDragVisual();
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel =
|
const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中';
|
||||||
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
|
|
||||||
const nextAvailable =
|
const nextAvailable =
|
||||||
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
||||||
const clearResultKey = `${run.runId}:${currentLevel.profileId}:${currentLevel.levelIndex}`;
|
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||||||
const leaderboardEntries =
|
const leaderboardEntries =
|
||||||
(currentLevel.leaderboardEntries ?? []).length > 0
|
(currentLevel.leaderboardEntries ?? []).length > 0
|
||||||
? currentLevel.leaderboardEntries
|
? currentLevel.leaderboardEntries
|
||||||
: (run.leaderboardEntries ?? []);
|
: (run.leaderboardEntries ?? []);
|
||||||
const isClearResultOpen =
|
const isClearResultOpen =
|
||||||
currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey;
|
currentLevel.status === 'cleared' &&
|
||||||
|
dismissedClearKey !== clearResultKey &&
|
||||||
|
isClearResultReady;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||||
@@ -478,26 +589,41 @@ export function PuzzleRuntimeShell({
|
|||||||
) : null}
|
) : 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 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">
|
<div className="absolute left-0 top-0 z-20 w-full px-4 py-4">
|
||||||
<button
|
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-3">
|
||||||
type="button"
|
<button
|
||||||
onClick={onBack}
|
type="button"
|
||||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
onClick={onBack}
|
||||||
>
|
aria-label="返回上一页"
|
||||||
<ArrowLeft className="h-4 w-4" />
|
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
||||||
</button>
|
>
|
||||||
|
<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="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="text-[0.68rem] font-semibold tracking-[0.2em] text-white/70">
|
<div className="line-clamp-1 text-sm font-bold text-white sm:text-base">
|
||||||
PUZZLE
|
{currentLevel.levelName}
|
||||||
</div>
|
</div>
|
||||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
<div className="line-clamp-1 text-xs text-white/78">
|
||||||
{currentLevel.levelName}
|
{currentLevel.authorDisplayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-white/74">
|
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
|
||||||
{currentLevel.authorDisplayName} · 第 {currentLevel.levelIndex} 关 ·{' '}
|
{levelLabel}
|
||||||
{statusLabel}
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -517,10 +643,7 @@ export function PuzzleRuntimeShell({
|
|||||||
const isSelected = piece?.pieceId === selectedPieceId;
|
const isSelected = piece?.pieceId === selectedPieceId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={`${cell.row}:${cell.col}`} className="relative p-1">
|
||||||
key={`${cell.row}:${cell.col}`}
|
|
||||||
className="relative p-1"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
if (!piece) {
|
if (!piece) {
|
||||||
@@ -542,7 +665,9 @@ export function PuzzleRuntimeShell({
|
|||||||
: 'border-white/18 bg-white/12 text-white'
|
: 'border-white/18 bg-white/12 text-white'
|
||||||
: 'border-white/8 bg-black/18 text-white/20'
|
: '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) => {
|
onPointerDown={(event) => {
|
||||||
if (!piece || isMerged) {
|
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-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-black/10" />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
@@ -632,7 +752,11 @@ export function PuzzleRuntimeShell({
|
|||||||
{group.pieces.map((piece) => (
|
{group.pieces.map((piece) => (
|
||||||
<div
|
<div
|
||||||
key={piece.pieceId}
|
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={{
|
style={{
|
||||||
gridColumn: piece.localCol + 1,
|
gridColumn: piece.localCol + 1,
|
||||||
gridRow: piece.localRow + 1,
|
gridRow: piece.localRow + 1,
|
||||||
@@ -676,7 +800,6 @@ export function PuzzleRuntimeShell({
|
|||||||
<div className="absolute inset-0 bg-black/8" />
|
<div className="absolute inset-0 bg-black/8" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -717,6 +840,142 @@ export function PuzzleRuntimeShell({
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{isClearResultOpen ? (
|
||||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
|
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
|
||||||
<section
|
<section
|
||||||
@@ -748,7 +1007,7 @@ export function PuzzleRuntimeShell({
|
|||||||
setDismissedClearKey(clearResultKey);
|
setDismissedClearKey(clearResultKey);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -768,7 +1027,9 @@ export function PuzzleRuntimeShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<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="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">
|
<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>
|
<span>名次</span>
|
||||||
@@ -776,24 +1037,32 @@ export function PuzzleRuntimeShell({
|
|||||||
<span className="text-right">通关时间</span>
|
<span className="text-right">通关时间</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-56 overflow-y-auto">
|
<div className="max-h-56 overflow-y-auto">
|
||||||
{leaderboardEntries.map((entry) => (
|
{leaderboardEntries.length > 0 ? (
|
||||||
<div
|
leaderboardEntries.map((entry) => (
|
||||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
<div
|
||||||
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
|
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||||
entry.isCurrentPlayer
|
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
|
||||||
? 'bg-amber-200/14 text-amber-50'
|
entry.isCurrentPlayer
|
||||||
: 'border-t border-white/8 text-white/78'
|
? '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">
|
<span className="font-mono font-black">
|
||||||
{entry.nickname}
|
#{entry.rank}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right font-mono text-xs font-bold">
|
<span className="truncate font-semibold">
|
||||||
{formatElapsedMs(entry.elapsedMs)}
|
{entry.nickname}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { beforeEach, expect, test, vi } from 'vitest';
|
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 { 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 { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import { ApiClientError } from '../../services/apiClient';
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
@@ -2526,6 +2526,10 @@ test('agent draft result test button enters current draft without publish gate',
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ name: '潮雾列岛' }),
|
expect.objectContaining({ name: '潮雾列岛' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
mode: 'test',
|
||||||
|
returnStage: 'custom-world-result',
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { act, render } from '@testing-library/react';
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
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';
|
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||||||
|
|
||||||
function buildProfile(params: {
|
function buildProfile(params: {
|
||||||
@@ -88,7 +88,11 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
|||||||
stage: 'ready_to_publish',
|
stage: 'ready_to_publish',
|
||||||
focusCardId: null,
|
focusCardId: null,
|
||||||
creatorIntent: null,
|
creatorIntent: null,
|
||||||
creatorIntentReadiness: { isReady: true, completedKeys: [], missingKeys: [] },
|
creatorIntentReadiness: {
|
||||||
|
isReady: true,
|
||||||
|
completedKeys: [],
|
||||||
|
missingKeys: [],
|
||||||
|
},
|
||||||
anchorPack: null,
|
anchorPack: null,
|
||||||
lockState: null,
|
lockState: null,
|
||||||
draftProfile: null,
|
draftProfile: null,
|
||||||
@@ -110,15 +114,15 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('useRpgCreationEnterWorld', () => {
|
describe('useRpgCreationEnterWorld', () => {
|
||||||
it('Agent 草稿进入游戏时使用 session draft profile 的角色形象', async () => {
|
it('Agent 草稿测试进入游戏时使用结果页当前 profile 的角色形象', async () => {
|
||||||
const staleResultProfile = buildProfile({
|
const staleResultProfile = buildProfile({
|
||||||
id: 'stale-result',
|
id: 'stale-result',
|
||||||
name: '旧结果页快照',
|
name: '旧结果页快照',
|
||||||
imageSrc: '/template/old-role.png',
|
imageSrc: '/template/old-role.png',
|
||||||
});
|
});
|
||||||
const draftProfile = buildProfile({
|
const resultProfile = buildProfile({
|
||||||
id: 'draft-profile',
|
id: 'draft-profile',
|
||||||
name: '草稿真相源',
|
name: '结果页真相源',
|
||||||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||||||
});
|
});
|
||||||
const handleCustomWorldSelect = vi.fn();
|
const handleCustomWorldSelect = vi.fn();
|
||||||
@@ -130,7 +134,7 @@ describe('useRpgCreationEnterWorld', () => {
|
|||||||
isAgentDraftResultView: true,
|
isAgentDraftResultView: true,
|
||||||
activeAgentSessionId: 'session-1',
|
activeAgentSessionId: 'session-1',
|
||||||
generatedCustomWorldProfile: staleResultProfile,
|
generatedCustomWorldProfile: staleResultProfile,
|
||||||
agentSessionProfile: draftProfile,
|
agentSessionProfile: resultProfile,
|
||||||
agentSession: buildSession(),
|
agentSession: buildSession(),
|
||||||
handleCustomWorldSelect,
|
handleCustomWorldSelect,
|
||||||
executePublishWorld,
|
executePublishWorld,
|
||||||
@@ -138,7 +142,10 @@ describe('useRpgCreationEnterWorld', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={() => void enterWorldForTestFromCurrentResult()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void enterWorldForTestFromCurrentResult()}
|
||||||
|
>
|
||||||
进入
|
进入
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -150,9 +157,12 @@ describe('useRpgCreationEnterWorld', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(executePublishWorld).not.toHaveBeenCalled();
|
expect(executePublishWorld).not.toHaveBeenCalled();
|
||||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(draftProfile);
|
expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, {
|
||||||
expect(handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc).toBe(
|
mode: 'test',
|
||||||
'/generated-characters/draft-role/portrait.png',
|
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 { useCallback } from 'react';
|
||||||
|
|
||||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
|
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
|
||||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
|
|
||||||
@@ -10,14 +11,17 @@ type UseRpgCreationEnterWorldParams = {
|
|||||||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||||||
agentSessionProfile: CustomWorldProfile | null;
|
agentSessionProfile: CustomWorldProfile | null;
|
||||||
agentSession: CustomWorldAgentSessionSnapshot | null;
|
agentSession: CustomWorldAgentSessionSnapshot | null;
|
||||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
handleCustomWorldSelect: (
|
||||||
|
customWorldProfile: CustomWorldProfile,
|
||||||
|
options?: CustomWorldRuntimeLaunchOptions,
|
||||||
|
) => void;
|
||||||
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
|
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
|
||||||
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
|
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一“进入世界”前的最终同步策略。
|
* 统一“进入世界”前的最终同步策略。
|
||||||
* Agent 草稿结果进入游戏时只读 session.draftProfile,不再把结果页快照回写成新的运行时 profile。
|
* Agent 草稿结果进入游戏时只读当前结果页 profile,不再静默回退到基础 draftProfile。
|
||||||
*/
|
*/
|
||||||
export function useRpgCreationEnterWorld(
|
export function useRpgCreationEnterWorld(
|
||||||
params: UseRpgCreationEnterWorldParams,
|
params: UseRpgCreationEnterWorldParams,
|
||||||
@@ -39,13 +43,22 @@ export function useRpgCreationEnterWorld(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAgentDraftResultView || !activeAgentSessionId) {
|
if (!isAgentDraftResultView || !activeAgentSessionId) {
|
||||||
handleCustomWorldSelect(generatedCustomWorldProfile);
|
handleCustomWorldSelect(generatedCustomWorldProfile, {
|
||||||
|
mode: 'test',
|
||||||
|
returnStage: 'custom-world-result',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
|
if (!agentSessionProfile) {
|
||||||
setGeneratedCustomWorldProfile(latestProfile);
|
return;
|
||||||
handleCustomWorldSelect(latestProfile);
|
}
|
||||||
|
|
||||||
|
setGeneratedCustomWorldProfile(agentSessionProfile);
|
||||||
|
handleCustomWorldSelect(agentSessionProfile, {
|
||||||
|
mode: 'test',
|
||||||
|
returnStage: 'custom-world-result',
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
activeAgentSessionId,
|
activeAgentSessionId,
|
||||||
agentSessionProfile,
|
agentSessionProfile,
|
||||||
@@ -64,8 +77,11 @@ export function useRpgCreationEnterWorld(
|
|||||||
return generatedCustomWorldProfile;
|
return generatedCustomWorldProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
|
if (!agentSessionProfile) {
|
||||||
setGeneratedCustomWorldProfile(latestProfile);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeneratedCustomWorldProfile(agentSessionProfile);
|
||||||
|
|
||||||
const latestSession = agentSession;
|
const latestSession = agentSession;
|
||||||
const canEnterPublishedWorld =
|
const canEnterPublishedWorld =
|
||||||
@@ -73,13 +89,13 @@ export function useRpgCreationEnterWorld(
|
|||||||
latestSession.resultPreview?.canEnterWorld;
|
latestSession.resultPreview?.canEnterWorld;
|
||||||
|
|
||||||
if (canEnterPublishedWorld) {
|
if (canEnterPublishedWorld) {
|
||||||
return latestProfile;
|
return agentSessionProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishedSession = await executePublishWorld();
|
const publishedSession = await executePublishWorld();
|
||||||
const publishedProfile =
|
const publishedProfile =
|
||||||
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
|
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
|
||||||
latestProfile;
|
agentSessionProfile;
|
||||||
|
|
||||||
setGeneratedCustomWorldProfile(publishedProfile);
|
setGeneratedCustomWorldProfile(publishedProfile);
|
||||||
return publishedProfile;
|
return publishedProfile;
|
||||||
@@ -89,7 +105,6 @@ export function useRpgCreationEnterWorld(
|
|||||||
agentSessionProfile,
|
agentSessionProfile,
|
||||||
executePublishWorld,
|
executePublishWorld,
|
||||||
generatedCustomWorldProfile,
|
generatedCustomWorldProfile,
|
||||||
handleCustomWorldSelect,
|
|
||||||
isAgentDraftResultView,
|
isAgentDraftResultView,
|
||||||
setGeneratedCustomWorldProfile,
|
setGeneratedCustomWorldProfile,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export function useRpgCreationResultAutosave(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Agent 结果页不再把前端 profile 回写到 session。
|
// Agent 结果页不再把前端 profile 回写到 session。
|
||||||
// session.draftProfile 是真相源;这里只刷新后端最新快照,避免在采集/生成早期误触 sync_result_profile。
|
// 这里只刷新后端结果页快照,避免在采集/生成早期误触 sync_result_profile。
|
||||||
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
|
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
|
||||||
const latestProfile = normalizeAgentBackedProfile(
|
const latestProfile = normalizeAgentBackedProfile(
|
||||||
buildDraftResultProfile(latestSession) ?? profile,
|
buildDraftResultProfile(latestSession) ?? profile,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function renderPanel(
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onSubmitNpcChatInput?: (input: string) => boolean;
|
onSubmitNpcChatInput?: (input: string) => boolean;
|
||||||
onExitNpcChat?: () => boolean;
|
onExitNpcChat?: () => boolean;
|
||||||
|
inBattle?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
return renderToStaticMarkup(
|
return renderToStaticMarkup(
|
||||||
@@ -97,7 +98,7 @@ function renderPanel(
|
|||||||
playerMana={20}
|
playerMana={20}
|
||||||
playerMaxMana={20}
|
playerMaxMana={20}
|
||||||
playerSkillCooldowns={{}}
|
playerSkillCooldowns={{}}
|
||||||
inBattle={false}
|
inBattle={overrides.inBattle ?? false}
|
||||||
currentNpcBattleMode={null}
|
currentNpcBattleMode={null}
|
||||||
statistics={{
|
statistics={{
|
||||||
playTimeMs: 0,
|
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).toContain('text-[15px]');
|
||||||
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
|
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 { HostileNpcAnimator } from '../HostileNpcAnimator';
|
||||||
import { PixelIcon } from '../PixelIcon';
|
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 {
|
export interface RpgAdventurePanelProps {
|
||||||
aiError: string | null;
|
aiError: string | null;
|
||||||
currentStory: StoryMoment;
|
currentStory: StoryMoment;
|
||||||
@@ -140,6 +144,57 @@ function getOptionActionTextClass(option: StoryOption) {
|
|||||||
return 'text-zinc-300 group-hover:text-white';
|
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) {
|
function getOptionFunctionTagText(option: StoryOption) {
|
||||||
const tagByFunctionId: Record<string, string> = {
|
const tagByFunctionId: Record<string, string> = {
|
||||||
battle_all_in_crush: '战斗',
|
battle_all_in_crush: '战斗',
|
||||||
@@ -692,11 +747,14 @@ function RpgAdventureStorySection(props: {
|
|||||||
isStoryStreaming,
|
isStoryStreaming,
|
||||||
currentStory,
|
currentStory,
|
||||||
} = props;
|
} = props;
|
||||||
|
const storyPanelClassName = isNpcChatMode
|
||||||
|
? 'flex-[1.18] sm:min-h-[15rem]'
|
||||||
|
: 'flex-1 sm:min-h-[14rem]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={storyScrollContainerRef}
|
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)}
|
style={getNineSliceStyle(UI_CHROME.storyPanel)}
|
||||||
>
|
>
|
||||||
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
|
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
|
||||||
@@ -787,6 +845,7 @@ function RpgAdventureChoiceSection(props: {
|
|||||||
setNpcChatDraft: (value: string) => void;
|
setNpcChatDraft: (value: string) => void;
|
||||||
npcChatPlaceholder: string;
|
npcChatPlaceholder: string;
|
||||||
submitNpcChatDraft: () => void;
|
submitNpcChatDraft: () => void;
|
||||||
|
inBattle: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
isNpcChatMode,
|
isNpcChatMode,
|
||||||
@@ -813,11 +872,29 @@ function RpgAdventureChoiceSection(props: {
|
|||||||
setNpcChatDraft,
|
setNpcChatDraft,
|
||||||
npcChatPlaceholder,
|
npcChatPlaceholder,
|
||||||
submitNpcChatDraft,
|
submitNpcChatDraft,
|
||||||
|
inBattle,
|
||||||
} = props;
|
} = props;
|
||||||
|
const [battleChoiceViewportRef, battleChoiceViewportHeight] =
|
||||||
|
useMeasuredElementHeight<HTMLDivElement>(
|
||||||
|
inBattle && !isNpcChatMode && !shouldHideChoiceUi,
|
||||||
|
);
|
||||||
|
const visibleDisplayedOptions =
|
||||||
|
inBattle && !isNpcChatMode && !shouldHideChoiceUi
|
||||||
|
? displayedOptions.slice(
|
||||||
|
0,
|
||||||
|
getBattleVisibleOptionCount(
|
||||||
|
battleChoiceViewportHeight,
|
||||||
|
displayedOptions.length,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: displayedOptions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
|
<div
|
||||||
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
|
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">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -880,12 +957,15 @@ function RpgAdventureChoiceSection(props: {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{isLoading && !isStoryStreaming ? (
|
||||||
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
|
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<span className="text-xs uppercase tracking-widest">
|
<span className="text-xs uppercase tracking-widest">
|
||||||
剧情推演中...
|
{inBattle ? '战斗结算中...' : '剧情推演中...'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : isStoryStreaming ? (
|
) : isStoryStreaming ? (
|
||||||
@@ -896,7 +976,7 @@ function RpgAdventureChoiceSection(props: {
|
|||||||
<div className="p-4" aria-hidden="true" />
|
<div className="p-4" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{displayedOptions.map((option, index) => {
|
{visibleDisplayedOptions.map((option, index) => {
|
||||||
const optionImpactSummary = getOptionImpactSummary(
|
const optionImpactSummary = getOptionImpactSummary(
|
||||||
option,
|
option,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
@@ -970,7 +1050,7 @@ function RpgAdventureChoiceSection(props: {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{isNpcChatMode && !isNpcQuestOfferMode ? (
|
{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">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<input
|
<input
|
||||||
value={npcChatDraft}
|
value={npcChatDraft}
|
||||||
@@ -985,7 +1065,7 @@ function RpgAdventureChoiceSection(props: {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={npcChatPlaceholder}
|
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}
|
maxLength={80}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -993,7 +1073,7 @@ function RpgAdventureChoiceSection(props: {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={submitNpcChatDraft}
|
onClick={submitNpcChatDraft}
|
||||||
disabled={isLoading || !npcChatDraft.trim()}
|
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>
|
</button>
|
||||||
@@ -1161,6 +1241,7 @@ export function RpgAdventurePanel({
|
|||||||
playerMana,
|
playerMana,
|
||||||
playerMaxMana,
|
playerMaxMana,
|
||||||
playerSkillCooldowns,
|
playerSkillCooldowns,
|
||||||
|
inBattle,
|
||||||
currentNpcBattleMode,
|
currentNpcBattleMode,
|
||||||
statistics,
|
statistics,
|
||||||
musicVolume,
|
musicVolume,
|
||||||
@@ -1550,18 +1631,20 @@ export function RpgAdventurePanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RpgAdventureStorySection
|
{!inBattle ? (
|
||||||
currentSceneActTitle={currentSceneActTitle}
|
<RpgAdventureStorySection
|
||||||
currentSceneActIndex={currentSceneActIndex}
|
currentSceneActTitle={currentSceneActTitle}
|
||||||
currentSceneActCount={currentSceneActCount}
|
currentSceneActIndex={currentSceneActIndex}
|
||||||
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
|
currentSceneActCount={currentSceneActCount}
|
||||||
storyScrollContainerRef={storyScrollContainerRef}
|
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
|
||||||
isDialogueStory={isDialogueStory}
|
storyScrollContainerRef={storyScrollContainerRef}
|
||||||
dialogueTurns={dialogueTurns}
|
isDialogueStory={isDialogueStory}
|
||||||
isNpcChatMode={isNpcChatMode}
|
dialogueTurns={dialogueTurns}
|
||||||
isStoryStreaming={isStoryStreaming}
|
isNpcChatMode={isNpcChatMode}
|
||||||
currentStory={currentStory}
|
isStoryStreaming={isStoryStreaming}
|
||||||
/>
|
currentStory={currentStory}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<RpgAdventureChoiceSection
|
<RpgAdventureChoiceSection
|
||||||
isNpcChatMode={isNpcChatMode}
|
isNpcChatMode={isNpcChatMode}
|
||||||
@@ -1590,6 +1673,7 @@ export function RpgAdventurePanel({
|
|||||||
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
|
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
|
||||||
}
|
}
|
||||||
submitNpcChatDraft={submitNpcChatDraft}
|
submitNpcChatDraft={submitNpcChatDraft}
|
||||||
|
inBattle={inBattle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RpgAdventureOverlaySection
|
<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,
|
companions,
|
||||||
audio,
|
audio,
|
||||||
chrome,
|
chrome,
|
||||||
|
onExitTestRuntime,
|
||||||
}: RpgRuntimeShellComponentProps) {
|
}: RpgRuntimeShellComponentProps) {
|
||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
const isPlatformShell = !session.gameState.worldType;
|
const isPlatformShell = !session.gameState.worldType;
|
||||||
@@ -132,6 +133,7 @@ export function RpgRuntimeShell({
|
|||||||
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
|
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const isTestRuntime = gameState.runtimeMode === 'test';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameState.worldType && !gameState.playerCharacter) {
|
if (gameState.worldType && !gameState.playerCharacter) {
|
||||||
@@ -207,6 +209,23 @@ export function RpgRuntimeShell({
|
|||||||
</div>
|
</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
|
<RpgRuntimeStageRouter
|
||||||
gameState={gameState}
|
gameState={gameState}
|
||||||
visibleGameState={visibleGameState}
|
visibleGameState={visibleGameState}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
StoryMoment,
|
StoryMoment,
|
||||||
StoryOption,
|
StoryOption,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
|
||||||
|
|
||||||
export interface RpgRuntimeSessionProps {
|
export interface RpgRuntimeSessionProps {
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
@@ -53,7 +54,10 @@ export interface RpgEntrySessionProps {
|
|||||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||||
handleStartNewGame: () => void;
|
handleStartNewGame: () => void;
|
||||||
handleSaveAndExit: () => void;
|
handleSaveAndExit: () => void;
|
||||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
handleCustomWorldSelect: (
|
||||||
|
customWorldProfile: CustomWorldProfile,
|
||||||
|
options?: CustomWorldRuntimeLaunchOptions,
|
||||||
|
) => void;
|
||||||
handleBackToWorldSelect: () => void;
|
handleBackToWorldSelect: () => void;
|
||||||
handleCharacterSelect: (character: Character) => void;
|
handleCharacterSelect: (character: Character) => void;
|
||||||
}
|
}
|
||||||
@@ -107,4 +111,5 @@ export interface RpgRuntimeShellProps {
|
|||||||
companions: RpgRuntimeCompanionProps;
|
companions: RpgRuntimeCompanionProps;
|
||||||
audio: RpgRuntimeAudioProps;
|
audio: RpgRuntimeAudioProps;
|
||||||
chrome?: RpgRuntimeShellChromeOptions;
|
chrome?: RpgRuntimeShellChromeOptions;
|
||||||
|
onExitTestRuntime?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,51 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
|||||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
|
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 草稿角色字段和形象资源', () => {
|
it('直接读取 Rust 草稿角色字段和形象资源', () => {
|
||||||
const profile = normalizeCustomWorldProfileRecord({
|
const profile = normalizeCustomWorldProfileRecord({
|
||||||
name: '雾港归航',
|
name: '雾港归航',
|
||||||
@@ -121,4 +166,3 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
normalizeCustomWorldLockState,
|
normalizeCustomWorldLockState,
|
||||||
} from '../services/customWorldCreatorIntent';
|
} from '../services/customWorldCreatorIntent';
|
||||||
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
|
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
|
||||||
|
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
|
||||||
import {
|
import {
|
||||||
AnimationState,
|
AnimationState,
|
||||||
CharacterAnimationConfig,
|
CharacterAnimationConfig,
|
||||||
@@ -971,18 +972,30 @@ function normalizeSceneActBlueprint(
|
|||||||
value: unknown,
|
value: unknown,
|
||||||
index: number,
|
index: number,
|
||||||
sceneId: string,
|
sceneId: string,
|
||||||
|
profileRoles?: {
|
||||||
|
playableNpcs: CustomWorldPlayableNpc[];
|
||||||
|
storyNpcs: CustomWorldNpc[];
|
||||||
|
} | null,
|
||||||
): SceneActBlueprint | null {
|
): SceneActBlueprint | null {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const encounterNpcIds = toStringArray(value.encounterNpcIds);
|
const encounterNpcIds = toStringArray(value.encounterNpcIds).map((npcId) =>
|
||||||
|
resolveCustomWorldRoleIdReference(profileRoles, npcId),
|
||||||
|
);
|
||||||
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
|
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
|
||||||
const advanceRule = toText(value.advanceRule);
|
const advanceRule = toText(value.advanceRule);
|
||||||
const title = toText(value.title);
|
const title = toText(value.title);
|
||||||
const summary = toText(value.summary);
|
const summary = toText(value.summary);
|
||||||
const primaryNpcId = toText(value.primaryNpcId, encounterNpcIds[0] ?? '');
|
const primaryNpcId = resolveCustomWorldRoleIdReference(
|
||||||
const oppositeNpcId = toText(value.oppositeNpcId, primaryNpcId);
|
profileRoles,
|
||||||
|
toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
|
||||||
|
);
|
||||||
|
const oppositeNpcId = resolveCustomWorldRoleIdReference(
|
||||||
|
profileRoles,
|
||||||
|
toText(value.oppositeNpcId, primaryNpcId),
|
||||||
|
);
|
||||||
|
|
||||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||||
return null;
|
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)) {
|
if (!Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1036,7 +1055,12 @@ function normalizeSceneChapterBlueprints(value: unknown) {
|
|||||||
const acts = Array.isArray(entry.acts)
|
const acts = Array.isArray(entry.acts)
|
||||||
? entry.acts
|
? entry.acts
|
||||||
.map((act, actIndex) =>
|
.map((act, actIndex) =>
|
||||||
normalizeSceneActBlueprint(act, actIndex, sceneId),
|
normalizeSceneActBlueprint(
|
||||||
|
act,
|
||||||
|
actIndex,
|
||||||
|
sceneId,
|
||||||
|
profileRoles,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.filter((act): act is SceneActBlueprint => Boolean(act))
|
.filter((act): act is SceneActBlueprint => Boolean(act))
|
||||||
: [];
|
: [];
|
||||||
@@ -1126,6 +1150,11 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
|||||||
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
|
.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 = {
|
const normalizedProfile = {
|
||||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||||
settingText,
|
settingText,
|
||||||
@@ -1144,11 +1173,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
|||||||
value.attributeSchema,
|
value.attributeSchema,
|
||||||
generatedAttributeSchema,
|
generatedAttributeSchema,
|
||||||
),
|
),
|
||||||
playableNpcs: Array.isArray(value.playableNpcs)
|
playableNpcs,
|
||||||
? value.playableNpcs
|
|
||||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
|
||||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
|
||||||
: [],
|
|
||||||
storyNpcs,
|
storyNpcs,
|
||||||
items: Array.isArray(value.items)
|
items: Array.isArray(value.items)
|
||||||
? value.items
|
? value.items
|
||||||
@@ -1168,6 +1193,10 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
|||||||
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
|
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
|
||||||
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
|
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
|
||||||
value.sceneChapterBlueprints,
|
value.sceneChapterBlueprints,
|
||||||
|
{
|
||||||
|
playableNpcs,
|
||||||
|
storyNpcs,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
anchorContent: preserveStructuredRecord<EightAnchorContent>(
|
anchorContent: preserveStructuredRecord<EightAnchorContent>(
|
||||||
value.anchorContent,
|
value.anchorContent,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
|
||||||
import {
|
import {
|
||||||
canUseLimitedPrimaryNpcChat,
|
canUseLimitedPrimaryNpcChat,
|
||||||
resolveActiveSceneActEncounterFocusNpcId,
|
resolveActiveSceneActEncounterFocusNpcId,
|
||||||
@@ -145,9 +146,16 @@ function getAvailableActiveSceneActNpcs(state: GameState) {
|
|||||||
|
|
||||||
return (state.currentScenePreset?.npcs ?? [])
|
return (state.currentScenePreset?.npcs ?? [])
|
||||||
.filter(candidate => {
|
.filter(candidate => {
|
||||||
const candidateIds = [candidate.id, candidate.characterId].filter(
|
const candidateIds = [
|
||||||
(value): value is string => Boolean(value),
|
candidate.id,
|
||||||
);
|
candidate.characterId,
|
||||||
|
candidate.name,
|
||||||
|
candidate.title,
|
||||||
|
]
|
||||||
|
.map((value) =>
|
||||||
|
resolveCustomWorldRoleIdReference(state.customWorldProfile, value),
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
return candidateIds.some(id => activeActNpcIdSet.has(id));
|
return candidateIds.some(id => activeActNpcIdSet.has(id));
|
||||||
})
|
})
|
||||||
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
||||||
@@ -180,8 +188,19 @@ function pickFriendlySceneNpcForActiveAct(state: GameState, npcs: SceneNpc[]) {
|
|||||||
return (
|
return (
|
||||||
npcs.find(
|
npcs.find(
|
||||||
(npc) =>
|
(npc) =>
|
||||||
npc.id === focusNpcId ||
|
resolveCustomWorldRoleIdReference(state.customWorldProfile, npc.id) === focusNpcId ||
|
||||||
(npc.characterId ? npc.characterId === focusNpcId : false),
|
resolveCustomWorldRoleIdReference(
|
||||||
|
state.customWorldProfile,
|
||||||
|
npc.characterId,
|
||||||
|
) === focusNpcId ||
|
||||||
|
resolveCustomWorldRoleIdReference(
|
||||||
|
state.customWorldProfile,
|
||||||
|
npc.name,
|
||||||
|
) === focusNpcId ||
|
||||||
|
resolveCustomWorldRoleIdReference(
|
||||||
|
state.customWorldProfile,
|
||||||
|
npc.title,
|
||||||
|
) === focusNpcId,
|
||||||
) ?? pickRandomItem(npcs)
|
) ?? pickRandomItem(npcs)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||||
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
|
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
|
||||||
|
import { resolveCustomWorldRoleIdReferences } from '../services/customWorldRoleReferences';
|
||||||
import {
|
import {
|
||||||
buildFallbackActorNarrativeProfile,
|
buildFallbackActorNarrativeProfile,
|
||||||
normalizeActorNarrativeProfile,
|
normalizeActorNarrativeProfile,
|
||||||
@@ -406,9 +407,11 @@ function collectSceneActNpcIdsForScene(
|
|||||||
}
|
}
|
||||||
|
|
||||||
chapter.acts.forEach((act) => {
|
chapter.acts.forEach((act) => {
|
||||||
pushNpcId(act.primaryNpcId);
|
resolveCustomWorldRoleIdReferences(profile, [
|
||||||
pushNpcId(act.oppositeNpcId);
|
act.primaryNpcId,
|
||||||
act.encounterNpcIds.forEach(pushNpcId);
|
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 = {
|
const state = {
|
||||||
...createBaseState(),
|
...createBaseState(),
|
||||||
playerHp: 40,
|
playerHp: 40,
|
||||||
@@ -265,8 +350,77 @@ describe('buildBattlePlan', () => {
|
|||||||
minTurnCount: 1,
|
minTurnCount: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(plan.turns.some((turn) => turn.actor === 'player')).toBe(false);
|
const playerTurn = plan.turns.find((turn) => turn.actor === 'player');
|
||||||
expect(plan.preparedState.playerHp).toBeGreaterThan(state.playerHp);
|
|
||||||
expect(plan.preparedState.playerMana).toBeGreaterThan(state.playerMana);
|
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.scrollWorld).toBe(false);
|
||||||
expect(result.playerFacing).toBe('right');
|
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 type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildEncounterEntryState,
|
||||||
|
hasEncounterEntity,
|
||||||
|
interpolateEncounterTransitionState,
|
||||||
|
} from '../../data/encounterTransition';
|
||||||
import {
|
import {
|
||||||
getFacingTowardPlayer,
|
getFacingTowardPlayer,
|
||||||
settleHostileNpcAnimations,
|
settleHostileNpcAnimations,
|
||||||
} from '../../data/hostileNpcs';
|
} from '../../data/hostileNpcs';
|
||||||
|
import {
|
||||||
|
CALL_OUT_ENTRY_X_METERS,
|
||||||
|
createSceneEncounterPreview,
|
||||||
|
resolveSceneEncounterPreview,
|
||||||
|
} from '../../data/sceneEncounterPreviews';
|
||||||
import { getFunctionEffect } from '../../data/stateFunctions';
|
import { getFunctionEffect } from '../../data/stateFunctions';
|
||||||
import {
|
import {
|
||||||
AnimationState,
|
AnimationState,
|
||||||
@@ -15,6 +25,9 @@ import {
|
|||||||
const ESCAPE_RUN_MS = 5000;
|
const ESCAPE_RUN_MS = 5000;
|
||||||
const ESCAPE_TICK_MS = 250;
|
const ESCAPE_TICK_MS = 250;
|
||||||
const ESCAPE_TURN_PAUSE_MS = 180;
|
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 = {
|
export type EscapePlaybackSync = {
|
||||||
waitForStoryResponse?: Promise<void>;
|
waitForStoryResponse?: Promise<void>;
|
||||||
@@ -22,7 +35,7 @@ export type EscapePlaybackSync = {
|
|||||||
|
|
||||||
type SetGameStateFn = Dispatch<SetStateAction<GameState>> | ((state: GameState) => void);
|
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));
|
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) {
|
export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
|
||||||
const escapeDistance = getFunctionEffect(option.functionId).escapeDistance ?? 5;
|
const escapeDistance = getFunctionEffect(option.functionId).escapeDistance ?? 5;
|
||||||
const settleOffset = Math.max(1, Math.min(1.4, escapeDistance * 0.24));
|
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(
|
export function buildEscapeAfterSequence(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
option: StoryOption,
|
option: StoryOption,
|
||||||
|
nextScenePreset: GameState['currentScenePreset'] = state.currentScenePreset,
|
||||||
) {
|
) {
|
||||||
const escapePlayerX = getEscapeSettlePlayerX(state, option);
|
const escapePlayerX = getEscapeSettlePlayerX(state, option);
|
||||||
|
const shouldResetToSceneStart =
|
||||||
return {
|
nextScenePreset?.id !== state.currentScenePreset?.id ||
|
||||||
|
option.runtimePayload?.escapeReturnToSceneStart === true;
|
||||||
|
const baseState = {
|
||||||
...state,
|
...state,
|
||||||
|
currentScenePreset: nextScenePreset ?? state.currentScenePreset,
|
||||||
currentEncounter: null,
|
currentEncounter: null,
|
||||||
npcInteractionActive: false,
|
npcInteractionActive: false,
|
||||||
currentBattleNpcId: null,
|
currentBattleNpcId: null,
|
||||||
currentNpcBattleMode: null,
|
currentNpcBattleMode: null,
|
||||||
currentNpcBattleOutcome: null,
|
currentNpcBattleOutcome: null,
|
||||||
sceneHostileNpcs: [],
|
sceneHostileNpcs: [],
|
||||||
playerX: escapePlayerX,
|
playerX: shouldResetToSceneStart ? 0 : escapePlayerX,
|
||||||
playerFacing: 'right' as const,
|
playerFacing: 'right' as const,
|
||||||
animationState: AnimationState.IDLE,
|
animationState: AnimationState.IDLE,
|
||||||
playerActionMode: 'idle' as const,
|
playerActionMode: 'idle' as const,
|
||||||
@@ -66,6 +87,66 @@ export function buildEscapeAfterSequence(
|
|||||||
sparPlayerHpBefore: null,
|
sparPlayerHpBefore: null,
|
||||||
sparPlayerMaxHpBefore: null,
|
sparPlayerMaxHpBefore: null,
|
||||||
} satisfies GameState;
|
} 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: {
|
export async function playEscapeSequenceWithStorySync(params: {
|
||||||
@@ -93,6 +174,9 @@ export async function playEscapeSequenceWithStorySync(params: {
|
|||||||
const settlePlayerX = finalState.playerX;
|
const settlePlayerX = finalState.playerX;
|
||||||
let storyResponseReady = !sync?.waitForStoryResponse;
|
let storyResponseReady = !sync?.waitForStoryResponse;
|
||||||
let elapsedMs = 0;
|
let elapsedMs = 0;
|
||||||
|
const shouldPlayEntry =
|
||||||
|
finalState.currentScenePreset?.id !== state.currentScenePreset?.id ||
|
||||||
|
option.runtimePayload?.escapeReturnToSceneStart === true;
|
||||||
|
|
||||||
void sync?.waitForStoryResponse?.then(() => {
|
void sync?.waitForStoryResponse?.then(() => {
|
||||||
storyResponseReady = true;
|
storyResponseReady = true;
|
||||||
@@ -127,19 +211,39 @@ export async function playEscapeSequenceWithStorySync(params: {
|
|||||||
await sleepMs(ESCAPE_TICK_MS);
|
await sleepMs(ESCAPE_TICK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentState = {
|
const settledExitState: GameState = shouldPlayEntry
|
||||||
...finalState,
|
? {
|
||||||
playerX: settlePlayerX,
|
...finalState,
|
||||||
playerFacing: 'left',
|
playerX: 0,
|
||||||
animationState: AnimationState.IDLE,
|
playerFacing: 'right' as const,
|
||||||
playerActionMode: 'idle',
|
animationState: AnimationState.IDLE,
|
||||||
activeCombatEffects: [],
|
playerActionMode: 'idle' as const,
|
||||||
scrollWorld: false,
|
activeCombatEffects: [],
|
||||||
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
|
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);
|
setGameState(currentState);
|
||||||
await sleepMs(ESCAPE_TURN_PAUSE_MS);
|
await sleepMs(ESCAPE_TURN_PAUSE_MS);
|
||||||
|
|
||||||
|
if (shouldPlayEntry) {
|
||||||
|
return playEscapeEntrySequence({
|
||||||
|
setGameState,
|
||||||
|
finalState: settledExitState,
|
||||||
|
sleepMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
currentState = {
|
currentState = {
|
||||||
...currentState,
|
...currentState,
|
||||||
playerFacing: 'right',
|
playerFacing: 'right',
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ function sleep(ms: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSkillById(character: Character, skillId: string) {
|
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;
|
return character.skills.find(skill => skill.id === skillId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +206,47 @@ async function playBattleSequence(params: CombatPlaybackParams & {
|
|||||||
};
|
};
|
||||||
setGameState(currentState);
|
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;
|
const skill = step.selectedSkillId ? getSkillById(character, step.selectedSkillId) : null;
|
||||||
if (!skill) {
|
if (!skill) {
|
||||||
await sleep(resetStageMs);
|
await sleep(resetStageMs);
|
||||||
@@ -353,7 +408,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
|
|||||||
};
|
};
|
||||||
setGameState(currentState);
|
setGameState(currentState);
|
||||||
|
|
||||||
if (step.delivery === 'melee' && step.strikeOffsetX > 0) {
|
if (step.delivery === 'melee' && Math.abs(step.strikeOffsetX) > 0.01) {
|
||||||
currentState = {
|
currentState = {
|
||||||
...currentState,
|
...currentState,
|
||||||
companions: updateCompanionState(
|
companions: updateCompanionState(
|
||||||
@@ -740,4 +795,3 @@ export function createCombatPlayback(params: CombatPlaybackParams) {
|
|||||||
playResolvedChoice,
|
playResolvedChoice,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,50 @@ describe('buildResolvedChoiceState', () => {
|
|||||||
expect(resolved.afterSequence.playerFacing).toBe('right');
|
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', () => {
|
it('keeps idle follow-up generation separate from combat planning', () => {
|
||||||
const state = {
|
const state = {
|
||||||
...createBaseState(),
|
...createBaseState(),
|
||||||
|
|||||||
@@ -58,15 +58,17 @@ function getSceneTargetForFunction(
|
|||||||
): GameState['currentScenePreset'] {
|
): GameState['currentScenePreset'] {
|
||||||
if (!worldType) return currentScenePreset;
|
if (!worldType) return currentScenePreset;
|
||||||
|
|
||||||
if (option.functionId === 'idle_travel_next_scene') {
|
const targetSceneId =
|
||||||
const targetSceneId =
|
typeof option.runtimePayload?.targetSceneId === 'string'
|
||||||
typeof option.runtimePayload?.targetSceneId === 'string'
|
? option.runtimePayload.targetSceneId
|
||||||
? option.runtimePayload.targetSceneId
|
: typeof option.runtimePayload?.escapeTargetSceneId === 'string'
|
||||||
|
? option.runtimePayload.escapeTargetSceneId
|
||||||
: null;
|
: null;
|
||||||
if (targetSceneId) {
|
if (targetSceneId) {
|
||||||
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
|
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (option.functionId === 'idle_travel_next_scene') {
|
||||||
return getTravelScenePreset(worldType, currentScenePreset?.id) ?? currentScenePreset;
|
return getTravelScenePreset(worldType, currentScenePreset?.id) ?? currentScenePreset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ export function buildResolvedChoiceState(params: {
|
|||||||
return {
|
return {
|
||||||
optionKind,
|
optionKind,
|
||||||
battlePlan: null,
|
battlePlan: null,
|
||||||
afterSequence: buildEscapeAfterSequence(state, option),
|
afterSequence: buildEscapeAfterSequence(state, option, nextScenePreset),
|
||||||
} satisfies ResolvedChoiceState;
|
} satisfies ResolvedChoiceState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -738,4 +738,204 @@ describe('createStoryChoiceActions', () => {
|
|||||||
expect.objectContaining({ hostileNpcsDefeated: 0 }),
|
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,
|
increments: RuntimeStatsIncrements,
|
||||||
) => GameState;
|
) => 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({
|
export function createStoryChoiceActions({
|
||||||
gameState,
|
gameState,
|
||||||
currentStory,
|
currentStory,
|
||||||
@@ -219,7 +252,10 @@ export function createStoryChoiceActions({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRpgRuntimeServerFunctionId(option.functionId)) {
|
if (
|
||||||
|
isRpgRuntimeServerFunctionId(option.functionId) &&
|
||||||
|
!shouldResolveCombatChoiceLocally(gameState, currentStory, option)
|
||||||
|
) {
|
||||||
await runServerRuntimeChoiceAction({
|
await runServerRuntimeChoiceAction({
|
||||||
gameState,
|
gameState,
|
||||||
currentStory,
|
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({
|
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||||
affinityDelta: 0,
|
affinityDelta: 0,
|
||||||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||||||
@@ -1320,19 +1320,43 @@ describe('npcEncounterActions', () => {
|
|||||||
|
|
||||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||||
expect(lastStory.npcChatState).toBeUndefined();
|
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({
|
expect.objectContaining({
|
||||||
functionId: 'npc_fight',
|
functionId: 'battle_escape_breakout',
|
||||||
actionText: '战斗',
|
actionText: '逃往东侧旧街还亮着灯。',
|
||||||
interaction: expect.objectContaining({
|
runtimePayload: expect.objectContaining({
|
||||||
kind: 'npc',
|
targetSceneId: 'scene-east',
|
||||||
npcId: 'npc-rival',
|
escapeTargetSceneId: 'scene-east',
|
||||||
action: 'fight',
|
escapeEntry: 'from_left',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
functionId: 'battle_escape_breakout',
|
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(
|
expect(lastStory.options).not.toEqual(
|
||||||
@@ -1417,7 +1441,15 @@ describe('npcEncounterActions', () => {
|
|||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
functionId: 'battle_escape_breakout',
|
functionId: 'battle_escape_breakout',
|
||||||
actionText: '逃跑',
|
actionText: '逃往东侧旧街还亮着灯。',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
functionId: 'battle_escape_breakout',
|
||||||
|
actionText: '逃往南侧河滩雾气更重。',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
functionId: 'battle_escape_breakout',
|
||||||
|
actionText: '逃回当前场景起点',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(lastStory.deferredOptions).toBeUndefined();
|
expect(lastStory.deferredOptions).toBeUndefined();
|
||||||
@@ -1469,6 +1501,15 @@ describe('npcEncounterActions', () => {
|
|||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
functionId: 'battle_escape_breakout',
|
functionId: 'battle_escape_breakout',
|
||||||
|
actionText: '逃往东侧旧街还亮着灯。',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
functionId: 'battle_escape_breakout',
|
||||||
|
actionText: '逃往南侧河滩雾气更重。',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
functionId: 'battle_escape_breakout',
|
||||||
|
actionText: '逃回当前场景起点',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(lastStory.deferredOptions).toBeUndefined();
|
expect(lastStory.deferredOptions).toBeUndefined();
|
||||||
@@ -1523,6 +1564,15 @@ describe('npcEncounterActions', () => {
|
|||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
functionId: 'battle_escape_breakout',
|
functionId: 'battle_escape_breakout',
|
||||||
|
actionText: '逃往东侧旧街还亮着灯。',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
functionId: 'battle_escape_breakout',
|
||||||
|
actionText: '逃往南侧河滩雾气更重。',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
functionId: 'battle_escape_breakout',
|
||||||
|
actionText: '逃回当前场景起点',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(lastStory.deferredRuntimeState).toBeUndefined();
|
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 type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import {
|
import {
|
||||||
getRpgRuntimeClientVersion,
|
getRpgRuntimeClientVersion,
|
||||||
getRpgRuntimeSessionId,
|
getRpgRuntimeSessionId,
|
||||||
getRpgRuntimeStoryState,
|
getRpgRuntimeStoryState,
|
||||||
resolveRpgRuntimeStoryAction,
|
resolveRpgRuntimeStoryAction,
|
||||||
type RuntimeStorySnapshotRequest,
|
|
||||||
resolveRpgRuntimeStoryMoment,
|
resolveRpgRuntimeStoryMoment,
|
||||||
type RuntimeStoryChoicePayload,
|
type RuntimeStoryChoicePayload,
|
||||||
type RuntimeStoryResponse,
|
type RuntimeStoryResponse,
|
||||||
|
type RuntimeStorySnapshotRequest,
|
||||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||||
|
import { buildMapTravelResolution } from './storyGenerationState';
|
||||||
|
|
||||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||||
return response.viewModel.availableOptions.length > 0
|
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 的统一网关。
|
* 前端访问服务端 runtime story 的统一网关。
|
||||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||||
@@ -111,7 +202,11 @@ export async function resolveServerRuntimeChoice(params: {
|
|||||||
payload: params.payload,
|
payload: params.payload,
|
||||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
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 {
|
return {
|
||||||
response,
|
response,
|
||||||
|
|||||||
@@ -56,6 +56,106 @@ function createGameState(): GameState {
|
|||||||
} as 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(
|
function createRuntimeNpcBattleSnapshot(
|
||||||
overrides: Partial<HydratedSavedGameSnapshot['gameState']> = {},
|
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 () => {
|
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
|
||||||
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
|
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
|
||||||
runtimeActionVersion: 7,
|
runtimeActionVersion: 7,
|
||||||
|
|||||||
@@ -451,4 +451,101 @@ describe('storyChoiceRuntime', () => {
|
|||||||
);
|
);
|
||||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
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} 要么现在转身逃开,要么就拔刀。`;
|
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildHostileNpcEscapeOption = (character: Character): StoryOption => {
|
const buildHostileNpcEscapeOption = (
|
||||||
|
character: Character,
|
||||||
|
actionText = '逃跑',
|
||||||
|
runtimePayload?: StoryOption['runtimePayload'],
|
||||||
|
): StoryOption => {
|
||||||
const functionContext = gameState.worldType
|
const functionContext = gameState.worldType
|
||||||
? {
|
? {
|
||||||
worldType: gameState.worldType,
|
worldType: gameState.worldType,
|
||||||
@@ -1125,16 +1129,20 @@ export function createStoryNpcEncounterActions({
|
|||||||
if (resolvedOption) {
|
if (resolvedOption) {
|
||||||
return {
|
return {
|
||||||
...resolvedOption,
|
...resolvedOption,
|
||||||
actionText: '逃跑',
|
actionText,
|
||||||
text: '逃跑',
|
text: actionText,
|
||||||
detailText: '',
|
detailText: '',
|
||||||
|
runtimePayload: {
|
||||||
|
...(resolvedOption.runtimePayload ?? {}),
|
||||||
|
...(runtimePayload ?? {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
functionId: 'battle_escape_breakout',
|
functionId: 'battle_escape_breakout',
|
||||||
actionText: '逃跑',
|
actionText,
|
||||||
text: '逃跑',
|
text: actionText,
|
||||||
detailText: '',
|
detailText: '',
|
||||||
visuals: {
|
visuals: {
|
||||||
playerAnimation: AnimationState.RUN,
|
playerAnimation: AnimationState.RUN,
|
||||||
@@ -1144,9 +1152,61 @@ export function createStoryNpcEncounterActions({
|
|||||||
scrollWorld: true,
|
scrollWorld: true,
|
||||||
monsterChanges: [],
|
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 => ({
|
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
|
||||||
functionId: NPC_FIGHT_FUNCTION.id,
|
functionId: NPC_FIGHT_FUNCTION.id,
|
||||||
actionText: '与他对战',
|
actionText: '与他对战',
|
||||||
@@ -1177,8 +1237,8 @@ export function createStoryNpcEncounterActions({
|
|||||||
return {
|
return {
|
||||||
text: declarationText,
|
text: declarationText,
|
||||||
options: [
|
options: [
|
||||||
buildHostileNpcEscapeOption(character),
|
|
||||||
buildHostileNpcFightOption(encounter),
|
buildHostileNpcFightOption(encounter),
|
||||||
|
...buildHostileNpcEscapeOptions(character),
|
||||||
],
|
],
|
||||||
displayMode: 'dialogue',
|
displayMode: 'dialogue',
|
||||||
dialogue: [
|
dialogue: [
|
||||||
@@ -1220,7 +1280,7 @@ export function createStoryNpcEncounterActions({
|
|||||||
actionText: '战斗',
|
actionText: '战斗',
|
||||||
text: '战斗',
|
text: '战斗',
|
||||||
},
|
},
|
||||||
buildHostileNpcEscapeOption(character),
|
...buildHostileNpcEscapeOptions(character),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
|
|
||||||
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
|
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
||||||
|
import type { CustomWorldRuntimeLaunchOptions } from '../../components/platform-entry/platformEntryTypes';
|
||||||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
|
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
|
||||||
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
|
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
|
||||||
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
||||||
@@ -87,9 +88,10 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
|||||||
|
|
||||||
const handleCustomWorldSelect = (
|
const handleCustomWorldSelect = (
|
||||||
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
||||||
|
options?: CustomWorldRuntimeLaunchOptions,
|
||||||
) => {
|
) => {
|
||||||
storyFlow.resetStoryState();
|
storyFlow.resetStoryState();
|
||||||
selectCustomWorld(customWorldProfile);
|
selectCustomWorld(customWorldProfile, { mode: options?.mode });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCharacterSelect = (
|
const handleCharacterSelect = (
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import {
|
|||||||
getScenePresetById,
|
getScenePresetById,
|
||||||
getWorldCampScenePreset,
|
getWorldCampScenePreset,
|
||||||
} from '../../data/scenePresets';
|
} from '../../data/scenePresets';
|
||||||
|
import {
|
||||||
|
findCustomWorldRoleByReference,
|
||||||
|
resolveCustomWorldRoleIdReference,
|
||||||
|
resolveCustomWorldRoleIdReferences,
|
||||||
|
} from '../../services/customWorldRoleReferences';
|
||||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +44,7 @@ import {
|
|||||||
Encounter,
|
Encounter,
|
||||||
EquipmentLoadout,
|
EquipmentLoadout,
|
||||||
GameState,
|
GameState,
|
||||||
|
GameRuntimeMode,
|
||||||
InventoryItem,
|
InventoryItem,
|
||||||
SceneActBlueprint,
|
SceneActBlueprint,
|
||||||
SceneChapterBlueprint,
|
SceneChapterBlueprint,
|
||||||
@@ -313,23 +319,39 @@ function resolveCustomWorldScenePresetByConfiguredId(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOpeningActNpcIdPriority(openingAct: SceneActBlueprint) {
|
function resolveOpeningActNpcIdPriority(
|
||||||
return [
|
profile: CustomWorldProfile,
|
||||||
|
openingAct: SceneActBlueprint,
|
||||||
|
) {
|
||||||
|
return resolveCustomWorldRoleIdReferences(profile, [
|
||||||
openingAct.oppositeNpcId,
|
openingAct.oppositeNpcId,
|
||||||
openingAct.primaryNpcId,
|
openingAct.primaryNpcId,
|
||||||
...openingAct.encounterNpcIds,
|
...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(
|
function findSceneNpcByRuntimeRoleId(
|
||||||
scenePreset: GameState['currentScenePreset'],
|
scenePreset: GameState['currentScenePreset'],
|
||||||
|
profile: CustomWorldProfile | null,
|
||||||
roleId: string,
|
roleId: string,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
scenePreset?.npcs?.find(
|
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
|
) ?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -339,9 +361,7 @@ function buildOpeningEncounterFromCustomWorldRole(
|
|||||||
roleId: string,
|
roleId: string,
|
||||||
): Encounter | null {
|
): Encounter | null {
|
||||||
const role =
|
const role =
|
||||||
profile.storyNpcs.find((npc) => npc.id === roleId) ??
|
findCustomWorldRoleByReference(profile, roleId);
|
||||||
profile.playableNpcs.find((npc) => npc.id === roleId) ??
|
|
||||||
null;
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -388,12 +408,27 @@ function resolveOpeningActEncounter(params: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const npcId of resolveOpeningActNpcIdPriority(opening.act)) {
|
for (const npcId of resolveOpeningActNpcIdPriority(params.profile, opening.act)) {
|
||||||
if (npcId === params.playerCharacter.id) {
|
if (
|
||||||
|
doRoleReferencesMatch(
|
||||||
|
params.profile,
|
||||||
|
npcId,
|
||||||
|
params.playerCharacter.id,
|
||||||
|
) ||
|
||||||
|
doRoleReferencesMatch(
|
||||||
|
params.profile,
|
||||||
|
npcId,
|
||||||
|
params.playerCharacter.name,
|
||||||
|
)
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sceneNpc = findSceneNpcByRuntimeRoleId(params.scenePreset, npcId);
|
const sceneNpc = findSceneNpcByRuntimeRoleId(
|
||||||
|
params.scenePreset,
|
||||||
|
params.profile,
|
||||||
|
npcId,
|
||||||
|
);
|
||||||
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
|
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
|
||||||
return {
|
return {
|
||||||
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
|
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
|
||||||
@@ -456,8 +491,13 @@ export function useRpgSessionBootstrap() {
|
|||||||
setGameState(createInitialGameState());
|
setGameState(createInitialGameState());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
|
const handleCustomWorldSelect = (
|
||||||
|
customWorldProfile: CustomWorldProfile,
|
||||||
|
options?: { mode?: GameRuntimeMode },
|
||||||
|
) => {
|
||||||
const resolvedWorldType = WorldType.CUSTOM;
|
const resolvedWorldType = WorldType.CUSTOM;
|
||||||
|
const runtimeMode: GameRuntimeMode =
|
||||||
|
options?.mode === 'play' ? 'play' : 'test';
|
||||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||||
setRuntimeCharacterOverrides(
|
setRuntimeCharacterOverrides(
|
||||||
buildCustomWorldRuntimeCharacters(customWorldProfile),
|
buildCustomWorldRuntimeCharacters(customWorldProfile),
|
||||||
@@ -469,6 +509,8 @@ export function useRpgSessionBootstrap() {
|
|||||||
...prev,
|
...prev,
|
||||||
worldType: resolvedWorldType,
|
worldType: resolvedWorldType,
|
||||||
customWorldProfile,
|
customWorldProfile,
|
||||||
|
runtimeMode,
|
||||||
|
runtimePersistenceDisabled: runtimeMode !== 'play',
|
||||||
currentScenePreset: initialScenePreset,
|
currentScenePreset: initialScenePreset,
|
||||||
sceneHostileNpcs: [],
|
sceneHostileNpcs: [],
|
||||||
currentEncounter: null,
|
currentEncounter: null,
|
||||||
@@ -552,82 +594,92 @@ export function useRpgSessionBootstrap() {
|
|||||||
resolvedCustomWorldProfile,
|
resolvedCustomWorldProfile,
|
||||||
);
|
);
|
||||||
|
|
||||||
return ensureSceneEncounterPreview(
|
const openingState = applyEquipmentLoadoutToState(
|
||||||
applyEquipmentLoadoutToState(
|
{
|
||||||
{
|
...prev,
|
||||||
...prev,
|
playerCharacter: character,
|
||||||
playerCharacter: character,
|
runtimeMode:
|
||||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
resolvedWorldType === WorldType.CUSTOM
|
||||||
playerProgression: createInitialPlayerProgressionState(),
|
? prev.runtimeMode === 'play'
|
||||||
currentScene: 'Story',
|
? 'play'
|
||||||
storyHistory: [],
|
: 'test'
|
||||||
storyEngineMemory:
|
: (prev.runtimeMode ?? 'play'),
|
||||||
resolvedWorldType === WorldType.CUSTOM
|
runtimePersistenceDisabled:
|
||||||
? buildOpeningStoryEngineMemory(
|
resolvedWorldType === WorldType.CUSTOM
|
||||||
resolvedCustomWorldProfile,
|
? prev.runtimeMode !== 'play'
|
||||||
initialScenePreset?.id,
|
: prev.runtimePersistenceDisabled,
|
||||||
)
|
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||||
: createEmptyStoryEngineMemoryState(),
|
playerProgression: createInitialPlayerProgressionState(),
|
||||||
chapterState: null,
|
currentScene: 'Story',
|
||||||
campaignState: null,
|
storyHistory: [],
|
||||||
activeScenarioPackId:
|
storyEngineMemory:
|
||||||
prev.customWorldProfile?.scenarioPackId ?? null,
|
resolvedWorldType === WorldType.CUSTOM
|
||||||
activeCampaignPackId:
|
? buildOpeningStoryEngineMemory(
|
||||||
prev.customWorldProfile?.campaignPackId ?? null,
|
resolvedCustomWorldProfile,
|
||||||
characterChats: {},
|
initialScenePreset?.id,
|
||||||
currentEncounter: initialEncounter,
|
)
|
||||||
npcInteractionActive: false,
|
: createEmptyStoryEngineMemoryState(),
|
||||||
currentScenePreset: initialScenePreset,
|
chapterState: null,
|
||||||
lastObserveSignsSceneId: null,
|
campaignState: null,
|
||||||
lastObserveSignsReport: null,
|
activeScenarioPackId: prev.customWorldProfile?.scenarioPackId ?? null,
|
||||||
animationState: AnimationState.IDLE,
|
activeCampaignPackId: prev.customWorldProfile?.campaignPackId ?? null,
|
||||||
sceneHostileNpcs: [],
|
characterChats: {},
|
||||||
playerX: 0,
|
currentEncounter: initialEncounter,
|
||||||
playerOffsetY: 0,
|
npcInteractionActive: false,
|
||||||
playerFacing: 'right',
|
currentScenePreset: initialScenePreset,
|
||||||
playerActionMode: 'idle',
|
lastObserveSignsSceneId: null,
|
||||||
scrollWorld: false,
|
lastObserveSignsReport: null,
|
||||||
inBattle: false,
|
animationState: AnimationState.IDLE,
|
||||||
playerHp: playerMaxHp,
|
sceneHostileNpcs: [],
|
||||||
playerMaxHp: playerMaxHp,
|
playerX: 0,
|
||||||
playerMana: getCharacterMaxMana(character),
|
playerOffsetY: 0,
|
||||||
playerMaxMana: getCharacterMaxMana(character),
|
playerFacing: 'right',
|
||||||
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
playerActionMode: 'idle',
|
||||||
activeBuildBuffs: [],
|
scrollWorld: false,
|
||||||
activeCombatEffects: [],
|
inBattle: false,
|
||||||
playerCurrency: getInitialPlayerCurrency(
|
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,
|
resolvedWorldType,
|
||||||
resolvedCustomWorldProfile,
|
resolvedCustomWorldProfile,
|
||||||
),
|
),
|
||||||
playerInventory: mergeStarterInventoryItems<InventoryItem>(
|
),
|
||||||
explicitStarterItems?.inventory ?? [],
|
playerEquipment: createEmptyEquipmentLoadout(),
|
||||||
buildInitialPlayerInventory(
|
npcStates:
|
||||||
character,
|
initialEncounter && initialNpcState
|
||||||
resolvedWorldType,
|
? {
|
||||||
resolvedCustomWorldProfile,
|
[initialEncounter.id!]: initialNpcState,
|
||||||
),
|
}
|
||||||
),
|
: {},
|
||||||
playerEquipment: createEmptyEquipmentLoadout(),
|
quests: [],
|
||||||
npcStates:
|
roster: [],
|
||||||
initialEncounter && initialNpcState
|
companions: [],
|
||||||
? {
|
currentBattleNpcId: null,
|
||||||
[initialEncounter.id!]: initialNpcState,
|
currentNpcBattleMode: null,
|
||||||
}
|
currentNpcBattleOutcome: null,
|
||||||
: {},
|
sparReturnEncounter: null,
|
||||||
quests: [],
|
sparPlayerHpBefore: null,
|
||||||
roster: [],
|
sparPlayerMaxHpBefore: null,
|
||||||
companions: [],
|
sparStoryHistoryBefore: null,
|
||||||
currentBattleNpcId: null,
|
},
|
||||||
currentNpcBattleMode: null,
|
mergedStarterEquipment,
|
||||||
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({
|
const profile = normalizeCustomWorldProfileRecord({
|
||||||
id: 'saved-runtime-profile',
|
id: 'saved-runtime-profile',
|
||||||
settingText: '被海雾吞没的旧航路群岛',
|
settingText: '被海雾吞没的旧航路群岛',
|
||||||
@@ -318,9 +320,9 @@ function buildSavedProfile() {
|
|||||||
title: '第一幕',
|
title: '第一幕',
|
||||||
summary: '陆衡先开口试探玩家。',
|
summary: '陆衡先开口试探玩家。',
|
||||||
stageCoverage: ['opening'],
|
stageCoverage: ['opening'],
|
||||||
encounterNpcIds: ['story-primary-only', 'story-act-only'],
|
encounterNpcIds: ['沈砺旧识', '陆衡'],
|
||||||
primaryNpcId: 'story-primary-only',
|
primaryNpcId: '沈砺旧识',
|
||||||
oppositeNpcId: 'story-act-only',
|
oppositeNpcId: options.openingOppositeNpcId ?? '陆衡',
|
||||||
eventDescription: '陆衡拿着异常账本,在开盘前拦住玩家。',
|
eventDescription: '陆衡拿着异常账本,在开盘前拦住玩家。',
|
||||||
linkedThreadIds: [],
|
linkedThreadIds: [],
|
||||||
advanceRule: 'after_primary_contact',
|
advanceRule: 'after_primary_contact',
|
||||||
@@ -389,6 +391,8 @@ function readSnapshot() {
|
|||||||
isStoryLoading: boolean;
|
isStoryLoading: boolean;
|
||||||
firstLandmarkResidueTitle: string | null;
|
firstLandmarkResidueTitle: string | null;
|
||||||
playerCharacterName: string | null;
|
playerCharacterName: string | null;
|
||||||
|
runtimeMode: string | null;
|
||||||
|
runtimePersistenceDisabled: boolean;
|
||||||
playerInventoryNames: string[];
|
playerInventoryNames: string[];
|
||||||
playerEquipment: {
|
playerEquipment: {
|
||||||
weapon: string | null;
|
weapon: string | null;
|
||||||
@@ -398,8 +402,15 @@ function readSnapshot() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function GameFlowHarness() {
|
function GameFlowHarness({
|
||||||
const profile = useMemo(() => buildSavedProfile(), []);
|
openingOppositeNpcId,
|
||||||
|
}: {
|
||||||
|
openingOppositeNpcId?: string;
|
||||||
|
} = {}) {
|
||||||
|
const profile = useMemo(
|
||||||
|
() => buildSavedProfile({ openingOppositeNpcId }),
|
||||||
|
[openingOppositeNpcId],
|
||||||
|
);
|
||||||
const playableCharacters = useMemo(
|
const playableCharacters = useMemo(
|
||||||
() => buildCustomWorldPlayableCharacters(profile),
|
() => buildCustomWorldPlayableCharacters(profile),
|
||||||
[profile],
|
[profile],
|
||||||
@@ -441,6 +452,8 @@ function GameFlowHarness() {
|
|||||||
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
|
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
|
||||||
?.title ?? null,
|
?.title ?? null,
|
||||||
playerCharacterName: gameState.playerCharacter?.name ?? null,
|
playerCharacterName: gameState.playerCharacter?.name ?? null,
|
||||||
|
runtimeMode: gameState.runtimeMode ?? null,
|
||||||
|
runtimePersistenceDisabled: gameState.runtimePersistenceDisabled === true,
|
||||||
playerInventoryNames: gameState.playerInventory.map((item) => item.name),
|
playerInventoryNames: gameState.playerInventory.map((item) => item.name),
|
||||||
playerEquipment: {
|
playerEquipment: {
|
||||||
weapon: gameState.playerEquipment.weapon?.name ?? null,
|
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().playerCharacterName).toBe('沈砺');
|
||||||
|
expect(readSnapshot().runtimeMode).toBe('test');
|
||||||
|
expect(readSnapshot().runtimePersistenceDisabled).toBe(true);
|
||||||
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
|
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
|
||||||
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
|
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
|
||||||
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
|
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,
|
||||||
.fusion-pixel-app * {
|
.fusion-pixel-app * {
|
||||||
font-family: 'Fusion Pixel', 'Inter', ui-sans-serif, system-ui, sans-serif !important;
|
font-family: 'Fusion Pixel', 'Inter', ui-sans-serif, system-ui, sans-serif !important;
|
||||||
@@ -1493,8 +1553,10 @@ body {
|
|||||||
|
|
||||||
.platform-npc-portrait__grid {
|
.platform-npc-portrait__grid {
|
||||||
opacity: 0.14;
|
opacity: 0.14;
|
||||||
background-image:
|
background-image: linear-gradient(
|
||||||
linear-gradient(var(--platform-line-soft) 1px, transparent 1px),
|
var(--platform-line-soft) 1px,
|
||||||
|
transparent 1px
|
||||||
|
),
|
||||||
linear-gradient(90deg, var(--platform-line-soft) 1px, transparent 1px);
|
linear-gradient(90deg, var(--platform-line-soft) 1px, transparent 1px);
|
||||||
background-size: 16px 16px;
|
background-size: 16px 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
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_API_BASE = '/api/runtime/big-fish/gallery';
|
||||||
const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
|
const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
|
||||||
@@ -12,16 +12,25 @@ const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
|
|||||||
* 读取大鱼吃小鱼公开广场列表。
|
* 读取大鱼吃小鱼公开广场列表。
|
||||||
*/
|
*/
|
||||||
export async function listBigFishGallery() {
|
export async function listBigFishGallery() {
|
||||||
return requestJson<BigFishWorksResponse>(
|
try {
|
||||||
BIG_FISH_GALLERY_API_BASE,
|
return await requestJson<BigFishWorksResponse>(
|
||||||
{
|
BIG_FISH_GALLERY_API_BASE,
|
||||||
method: 'GET',
|
{
|
||||||
},
|
method: 'GET',
|
||||||
'读取大鱼吃小鱼广场失败',
|
},
|
||||||
{
|
'读取大鱼吃小鱼广场失败',
|
||||||
retry: BIG_FISH_GALLERY_READ_RETRY,
|
{
|
||||||
},
|
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 = {
|
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,
|
SceneConnectionInfo,
|
||||||
StoryEngineMemoryState,
|
StoryEngineMemoryState,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { resolveCustomWorldRoleIdReferences } from './customWorldRoleReferences';
|
||||||
|
|
||||||
function toSet(values: string[]) {
|
function toSet(values: string[]) {
|
||||||
return new Set(values.map((value) => value.trim()).filter(Boolean));
|
return new Set(values.map((value) => value.trim()).filter(Boolean));
|
||||||
@@ -227,17 +228,11 @@ export function resolveActiveSceneActEncounterNpcIds(params: {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||||
...new Set(
|
activeAct.primaryNpcId,
|
||||||
[
|
activeAct.oppositeNpcId,
|
||||||
activeAct.primaryNpcId,
|
...activeAct.encounterNpcIds,
|
||||||
activeAct.oppositeNpcId,
|
]);
|
||||||
...activeAct.encounterNpcIds,
|
|
||||||
]
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveActiveSceneActPrimaryNpcId(params: {
|
export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||||
@@ -245,7 +240,9 @@ export function resolveActiveSceneActPrimaryNpcId(params: {
|
|||||||
sceneId: string | null | undefined;
|
sceneId: string | null | undefined;
|
||||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||||
}) {
|
}) {
|
||||||
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
|
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||||
|
resolveActiveSceneActBlueprint(params)?.primaryNpcId,
|
||||||
|
])[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveActiveSceneActOppositeNpcId(params: {
|
export function resolveActiveSceneActOppositeNpcId(params: {
|
||||||
@@ -253,7 +250,9 @@ export function resolveActiveSceneActOppositeNpcId(params: {
|
|||||||
sceneId: string | null | undefined;
|
sceneId: string | null | undefined;
|
||||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||||
}) {
|
}) {
|
||||||
return resolveActiveSceneActBlueprint(params)?.oppositeNpcId?.trim() || null;
|
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||||
|
resolveActiveSceneActBlueprint(params)?.oppositeNpcId,
|
||||||
|
])[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
||||||
@@ -262,12 +261,11 @@ export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
|||||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||||
}) {
|
}) {
|
||||||
const activeAct = resolveActiveSceneActBlueprint(params);
|
const activeAct = resolveActiveSceneActBlueprint(params);
|
||||||
return (
|
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||||
activeAct?.oppositeNpcId?.trim() ||
|
activeAct?.oppositeNpcId,
|
||||||
activeAct?.primaryNpcId?.trim() ||
|
activeAct?.primaryNpcId,
|
||||||
activeAct?.encounterNpcIds[0]?.trim() ||
|
activeAct?.encounterNpcIds[0],
|
||||||
null
|
])[0] ?? null;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveActiveSceneActBackgroundImage(params: {
|
export function resolveActiveSceneActBackgroundImage(params: {
|
||||||
@@ -295,13 +293,18 @@ export function canUseLimitedPrimaryNpcChat(params: {
|
|||||||
storyEngineMemory: params.storyEngineMemory,
|
storyEngineMemory: params.storyEngineMemory,
|
||||||
});
|
});
|
||||||
|
|
||||||
const limitedChatNpcIds = toSet([
|
const limitedChatNpcIds = toSet(
|
||||||
activeAct?.primaryNpcId ?? '',
|
resolveCustomWorldRoleIdReferences(params.profile, [
|
||||||
activeAct?.oppositeNpcId ?? '',
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +313,7 @@ export function canUseLimitedPrimaryNpcChat(params: {
|
|||||||
profile: params.profile,
|
profile: params.profile,
|
||||||
sceneId: params.sceneId,
|
sceneId: params.sceneId,
|
||||||
storyEngineMemory: params.storyEngineMemory,
|
storyEngineMemory: params.storyEngineMemory,
|
||||||
}) === params.npcId
|
}) === normalizedNpcId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export {
|
|||||||
getPuzzleRun,
|
getPuzzleRun,
|
||||||
puzzleRuntimeClient,
|
puzzleRuntimeClient,
|
||||||
startPuzzleRun,
|
startPuzzleRun,
|
||||||
|
submitPuzzleLeaderboard,
|
||||||
swapPuzzlePieces,
|
swapPuzzlePieces,
|
||||||
} from './puzzleRuntimeClient';
|
} from './puzzleRuntimeClient';
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const baseWork: PuzzleWorkSummary = {
|
|||||||
publishReady: true,
|
publishReady: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
|
||||||
return pieces.some((piece) =>
|
return pieces.some((piece) =>
|
||||||
pieces.some((candidate) => {
|
pieces.some((candidate) => {
|
||||||
if (piece.pieceId === candidate.pieceId) {
|
if (piece.pieceId === candidate.pieceId) {
|
||||||
@@ -39,13 +39,18 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
|||||||
const correctColDelta = candidate.correctCol - piece.correctCol;
|
const correctColDelta = candidate.correctCol - piece.correctCol;
|
||||||
return (
|
return (
|
||||||
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
|
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
|
||||||
currentRowDelta === correctRowDelta &&
|
Math.abs(correctRowDelta) + Math.abs(correctColDelta) === 1
|
||||||
currentColDelta === correctColDelta
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boardPositionSignature(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||||
|
return run.currentLevel?.board.pieces
|
||||||
|
.map((piece) => `${piece.currentRow}:${piece.currentCol}`)
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||||
let nextRun = run;
|
let nextRun = run;
|
||||||
for (let index = 0; index < 12; index += 1) {
|
for (let index = 0; index < 12; index += 1) {
|
||||||
@@ -89,13 +94,13 @@ describe('puzzleLocalRuntime', () => {
|
|||||||
expect(firstPositions).not.toEqual(secondPositions);
|
expect(firstPositions).not.toEqual(secondPositions);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('初始棋盘没有任何自动合并块', () => {
|
test('初始棋盘没有任何原图相邻块贴边', () => {
|
||||||
for (let index = 0; index < 12; index += 1) {
|
for (let index = 0; index < 12; index += 1) {
|
||||||
const run = startLocalPuzzleRun(baseWork);
|
const run = startLocalPuzzleRun(baseWork);
|
||||||
const board = run.currentLevel?.board;
|
const board = run.currentLevel?.board;
|
||||||
|
|
||||||
expect(board?.mergedGroups).toEqual([]);
|
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.currentLevel?.status).toBe('cleared');
|
||||||
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
||||||
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
||||||
expect(clearedRun.currentLevel?.leaderboardEntries.length).toBeGreaterThan(0);
|
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||||
expect(
|
expect(clearedRun.leaderboardEntries).toEqual([]);
|
||||||
clearedRun.currentLevel?.leaderboardEntries.some(
|
|
||||||
(entry) => entry.isCurrentPlayer && entry.nickname === '测试作者',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
const nextRun = advanceLocalPuzzleLevel(clearedRun);
|
const nextRun = advanceLocalPuzzleLevel(clearedRun);
|
||||||
|
|
||||||
@@ -300,4 +301,17 @@ describe('puzzleLocalRuntime', () => {
|
|||||||
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
|
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||||
expect(nextRun.recommendedNextProfileId).toBeNull();
|
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,
|
PuzzleBoardSnapshot,
|
||||||
PuzzleCellPosition,
|
PuzzleCellPosition,
|
||||||
PuzzleGridSize,
|
PuzzleGridSize,
|
||||||
PuzzleLeaderboardEntry,
|
|
||||||
PuzzleMergedGroupState,
|
PuzzleMergedGroupState,
|
||||||
PuzzlePieceState,
|
PuzzlePieceState,
|
||||||
PuzzleRunSnapshot,
|
PuzzleRunSnapshot,
|
||||||
@@ -75,11 +74,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
|
|||||||
);
|
);
|
||||||
ensureBoardIsNotSolved(shuffled, gridSize);
|
ensureBoardIsNotSolved(shuffled, gridSize);
|
||||||
const pieces = buildPiecesFromPositions(gridSize, shuffled);
|
const pieces = buildPiecesFromPositions(gridSize, shuffled);
|
||||||
if (!hasAnyCorrectNeighborPair(pieces)) {
|
if (!hasAnyOriginalNeighborPair(pieces)) {
|
||||||
return shuffled;
|
return shuffled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return positions.slice().reverse();
|
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function boardCellKey(row: number, col: number) {
|
function boardCellKey(row: number, col: number) {
|
||||||
@@ -90,48 +89,6 @@ function clampElapsedMs(value: number) {
|
|||||||
return Math.max(1_000, Math.round(value));
|
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[] {
|
function neighborCells(row: number, col: number): PuzzleCellPosition[] {
|
||||||
return [
|
return [
|
||||||
row > 0 ? { row: row - 1, col } : null,
|
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(
|
function resolveMergedGroups(
|
||||||
pieces: PuzzlePieceState[],
|
pieces: PuzzlePieceState[],
|
||||||
): PuzzleMergedGroupState[] {
|
): PuzzleMergedGroupState[] {
|
||||||
@@ -306,15 +376,6 @@ function applyNextBoard(
|
|||||||
const elapsedMs = justCleared
|
const elapsedMs = justCleared
|
||||||
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
|
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
|
||||||
: (run.currentLevel.elapsedMs ?? null);
|
: (run.currentLevel.elapsedMs ?? null);
|
||||||
const leaderboardEntries =
|
|
||||||
justCleared && elapsedMs
|
|
||||||
? buildLocalLeaderboardEntries(
|
|
||||||
elapsedMs,
|
|
||||||
run.currentLevel.authorDisplayName,
|
|
||||||
run.currentLevel.levelIndex,
|
|
||||||
run.currentLevel.gridSize,
|
|
||||||
)
|
|
||||||
: run.currentLevel.leaderboardEntries;
|
|
||||||
return {
|
return {
|
||||||
...run,
|
...run,
|
||||||
clearedLevelCount: nextClearedLevelCount,
|
clearedLevelCount: nextClearedLevelCount,
|
||||||
@@ -324,9 +385,9 @@ function applyNextBoard(
|
|||||||
status,
|
status,
|
||||||
clearedAtMs,
|
clearedAtMs,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
leaderboardEntries,
|
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
|
||||||
},
|
},
|
||||||
leaderboardEntries,
|
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
|
||||||
recommendedNextProfileId:
|
recommendedNextProfileId:
|
||||||
status === 'cleared'
|
status === 'cleared'
|
||||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
DragPuzzlePieceRequest,
|
DragPuzzlePieceRequest,
|
||||||
PuzzleRunResponse,
|
PuzzleRunResponse,
|
||||||
StartPuzzleRunRequest,
|
StartPuzzleRunRequest,
|
||||||
|
SubmitPuzzleLeaderboardRequest,
|
||||||
SwapPuzzlePiecesRequest,
|
SwapPuzzlePiecesRequest,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
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,
|
advanceNextLevel: advancePuzzleNextLevel,
|
||||||
drag: dragPuzzlePieceOrGroup,
|
drag: dragPuzzlePieceOrGroup,
|
||||||
getRun: getPuzzleRun,
|
getRun: getPuzzleRun,
|
||||||
|
submitLeaderboard: submitPuzzleLeaderboard,
|
||||||
startRun: startPuzzleRun,
|
startRun: startPuzzleRun,
|
||||||
swap: swapPuzzlePieces,
|
swap: swapPuzzlePieces,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -202,31 +202,42 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop
|
|||||||
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildRpgCreationPreviewFromSession prefers agent draft profile', () => {
|
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
|
||||||
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
||||||
|
|
||||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
expect(profile?.name).toBe('服务端结果预览');
|
||||||
expect(profile?.summary).toBe('fallback');
|
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
|
||||||
expect(profile?.id).toBe('draft-profile-1');
|
expect(profile?.id).toBe('preview-profile-1');
|
||||||
expect(profile?.playableNpcs[0]?.id).toBe('draft-playable-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({
|
const profile = buildRpgCreationPreviewFromSession({
|
||||||
...sessionWithPreview,
|
...sessionWithPreview,
|
||||||
resultPreview: null,
|
resultPreview: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
expect(profile).toBeNull();
|
||||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
|
||||||
'/generated-characters/draft-playable-1/portrait.png',
|
|
||||||
);
|
|
||||||
expect(profile?.attributeSchema.slots.map((slot) => slot.name)).toEqual([
|
|
||||||
'稿骨',
|
|
||||||
'稿步',
|
|
||||||
'稿识',
|
|
||||||
'稿魄',
|
|
||||||
'稿契',
|
|
||||||
'稿澜',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/s
|
|||||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
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(
|
export function buildCustomWorldProfileFromResultPreview(
|
||||||
resultPreview:
|
resultPreview:
|
||||||
| CustomWorldAgentSessionSnapshot['resultPreview']
|
| CustomWorldAgentSessionSnapshot['resultPreview']
|
||||||
@@ -15,14 +28,14 @@ export function buildCustomWorldProfileFromAgentSession(
|
|||||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||||
): CustomWorldProfile | null {
|
): CustomWorldProfile | null {
|
||||||
return (
|
return (
|
||||||
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null) ??
|
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
|
||||||
buildCustomWorldProfileFromResultPreview(session?.resultPreview)
|
buildCustomWorldProfileFromDraftLegacyResult(session?.draftProfile ?? null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这是工作包 A 提供的新命名兼容层。
|
* 这是工作包 A 提供的新命名兼容层。
|
||||||
* 主入口保持命名稳定,优先消费 Agent 草稿真相源,缺失时才回退到 resultPreview。
|
* 主入口保持命名稳定,只消费结果页运行态快照,避免作品测试读到旧草稿骨架。
|
||||||
*/
|
*/
|
||||||
export const rpgCreationPreviewAdapter = {
|
export const rpgCreationPreviewAdapter = {
|
||||||
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
||||||
@@ -30,6 +43,6 @@ export const rpgCreationPreviewAdapter = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
|
||||||
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
|
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
|
||||||
|
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user