2 Commits

Author SHA1 Message Date
271db02e4a Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
2026-04-27 22:50:20 +08:00
b6c6640548 1 2026-04-27 22:50:18 +08:00
77 changed files with 5240 additions and 833 deletions

View File

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

View File

@@ -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. 通关演出只作为前端表现层时序,不改动通关判定与排行榜数据来源。

View File

@@ -15,7 +15,7 @@
5. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。
6. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。
7. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。
8. 对负好感或敌对 NPC在聊天终止后的后续流程仍沿用敌对出口:继续推进后回到原有战斗或逃跑选
8. 对负好感或敌对 NPC在聊天终止后的后续流程仍沿用敌对出口继续推进后展示一个“战斗”选项,以及按相邻场景和当前场景起点展开的多个逃跑选
9. 聊天候选中允许混入当前 NPC 可执行 function例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。
10. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。
11. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。
@@ -68,6 +68,14 @@
5. 相邻场景选项继续使用 `idle_travel_next_scene`,并在 `runtimePayload.targetSceneId` 中携带目标场景,后续点击沿用现有地图跳转结算。
6. 若没有场景幕数据,则继续使用当前可用选项作为兜底,不额外生成规则说明文案。
## 8. 补充规则:敌对聊天逃跑目标展开
1. 负好感或敌对 NPC 聊天终止后,`npc_fight` 只保留一个,按钮文案固定为“战斗”,原有 NPC 战斗交互与结算链路不变。
2. 原单一“逃跑”按钮改为多个 `battle_escape_breakout` 选项:当前场景每个相邻场景生成“逃往{场景名}”,并额外生成“逃回当前场景起点”。
3. 逃往相邻场景的选项在 `runtimePayload.targetSceneId` 中写入目标场景 id逃回起点的选项在 `runtimePayload.escapeReturnToSceneStart` 中写入 `true`,并保留当前场景 id 作为目标。
4. 点击任一逃跑类选项时,先复用现有主角向左转身跑出屏幕的逃离动画,再把运行态切到目标场景或当前场景起点,最后从左侧入场并面向右侧。
5. 逃跑类选项只负责运行态目标和表现,不重新请求剧情推理,也不把规则说明显示到 UI。
## 7. 验收
1. 负好感主 NPC 不再出现固定 `turnLimit: 5`

View File

@@ -253,3 +253,40 @@ AI 可以解释世界,但不能私自改世界。
这类 AI 冒险 RPG 的开发,最难的不是“把功能做出来”,而是:
**让 function 边界、世界状态、视觉演绎、移动端面板和大模型文本在同一套规则下稳定协作。**
## 7. 聊天输入区布局补充经验
### 7.1 聊天框变大时,要优先增加“消息展示区”而不是只放大输入框
- 玩家感知里的“聊天框高度”主要来自消息气泡和剧情滚动区,不是输入栏本身。
- 如果只把输入框做高,实际会压缩选项区和底部按钮区,移动端反而更挤。
- 更稳妥的做法是:
先让剧情/聊天滚动区在剩余空间里拿到更高的伸缩优先级,再微调输入条高度和底部留白。
- 移动端不要随意给聊天区写死过大的最小高度,否则很容易把选项按钮和自定义输入一起挤出首屏。
### 7.2 底部输入区要向安全区贴近,但不能直接贴死
- 自定义输入要更贴近屏幕底部,应该缩小底部控制区的额外 padding而不是去掉安全区。
- `env(safe-area-inset-bottom)` 仍然要保留,否则刘海屏、手势条机型会出现输入框被顶起或遮挡的问题。
- 正确方向是:
保留安全区补偿,只减少设计层自己额外加上的底部留白。
### 7.3 底部操作区下沉时,要同步增加和聊天区之间的呼吸感
- 当“队伍 / 背包 / 换一换 / 退出聊天 / 自定义输入”整体下移后,上下区块更容易挤在一起。
- 这时要略微增加聊天区和操作区之间的垂直间距,保证视觉层级仍然清楚。
- 目标不是做出更厚的面板,而是让用户一眼分清:
上面是正在发生的对话,下面是马上可点的操作。
## 8. 战斗态底部面板布局经验
### 8.1 战斗态不应继续保留剧情框占位
- 战斗画面里玩家关注的是敌我状态与可执行动作,剧情文本框如果继续占据底部高度,会直接挤压操作按钮。
- 战斗态应隐藏剧情框组件,只保留操作区;战斗结果叙事可放到结算或下一次非战斗剧情里展示。
### 8.2 战斗选项数量要由剩余高度决定
- 不要固定渲染全部战斗选项,否则移动端低高度屏幕会把按钮挤出可点击区域。
- 更稳妥的做法是测量底部操作区可用高度,用单个按钮的最小高度和间距计算本帧可显示数量。
- 至少保留 1 个操作,避免极端高度下玩家看不到任何战斗入口。

View File

@@ -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 推进结果可见

View File

@@ -546,9 +546,9 @@ function resolvePuzzleGridSize(clearedLevelCount: number): 3 | 4 {
1. 初始局面不是已完成态
2. 初始局面至少存在可推进空间
3. 初始局面不能存在任何已经正确相邻的两块,避免玩家开局即看到自动合并块
3. 初始局面不能存在任何在原图中相邻的两块互相贴边,避免玩家开局即看到接近完成的局部结构
初始化算法必须对候选打乱结果做正确相邻关系扫描:若任意两块在当前棋盘四向相邻,且它们在原图中的正确坐标也以相同方向相邻,则该候选布局无效,需要继续洗牌。多次随机尝试仍未得到合法布局时,使用确定性反序布局兜底;该布局等价于完整棋盘旋转 180 度,可保证原图相邻块不会以正确方向相邻
初始化算法必须对候选打乱结果做原图相邻关系扫描:若任意两块在当前棋盘四向相邻,且它们在原图中的正确坐标也向相邻,则该候选布局无效,需要继续洗牌。多次随机尝试仍未得到合法布局时,使用确定性约束搜索兜底,逐格放置拼块并排除所有原图相邻块贴边的候选
## 9.5 交互规则总览

View File

@@ -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`,仍应播放本地战斗过程,而不是直接跳到结果快照。

View File

@@ -397,8 +397,8 @@ finalScore = tagSimilarityScore * 0.7 + sameAuthorScore * 0.3;
1. `build_initial_board_with_seed` 使用种子化洗牌生成初始棋盘,不再固定左移一格。
2. 正式 run 的种子由 `runId + profileId + levelIndex + gridSize` 派生;由于每次进入都会创建新的 `runId`,同一作品多次进入也会得到不同打乱样式。
3. 洗牌后若极端情况下仍为完成态,强制旋转一次,保证新关卡不是已完成局面。
4. 初始棋盘不得存在任何已经正确相邻的两块;初始化会多次洗牌筛选,若极端情况下未命中,则使用反序排列兜底,避免开局自动出现合并块
5. `module-puzzle` 与本地 fallback 的测试都必须直接断言初始棋盘不存在正确相邻对,不能只检查 `mergedGroups = []`
4. 初始棋盘不得存在任何原图相邻块互相贴边;初始化会多次洗牌筛选,若极端情况下未命中,则使用确定性约束搜索兜底,避免开局出现局部连续结构
5. `module-puzzle` 与本地 fallback 的测试都必须直接断言初始棋盘不存在原图相邻贴边对,不能只检查 `mergedGroups = []`
### 11.2 局部重算与合并
@@ -442,3 +442,15 @@ finalScore = tagSimilarityScore * 0.7 + sameAuthorScore * 0.3;
1. `dragState` 持续记录 `currentX/currentY`,拖动中按指针偏移对单块或合并块做 `translate3d` 跟手反馈。
2. 棋盘与合并块交互层增加 `touch-none select-none`,避免移动端滚动、选中文本等默认行为打断拖动。
3. 松手后仍只提交 `pieceId + targetRow + targetCol`,最终交换、合并、拆分和通关继续以后端/本地运行态快照为准。
### 12.3 合并块外轮廓描边修正
用户反馈“合并的块的边界显示要描边自己的块的边界,不要搞一个正方形或者矩形的边界”后,移除合并块外接矩形 `ring` 层。运行态现在按合并组真实占据格逐格判断四向邻居:某一边没有同组合并格时才画该边描边,同组内部相邻边不画线。这样 L 形、长条或其他非矩形合并块只显示自身外轮廓,拖动热区仍只覆盖真实拼块格。
后续反馈要求合并块边界也要圆角后,外轮廓描边补充按四个角判断:只有相邻两条外露边同时存在的真实外轮廓角才应用圆角,内部拼接角保持直角且不显示分界线。
### 12.4 第二关后打乱规则旁路修正
用户反馈“从第二关开始打乱规则像是完全相同”后,检查发现 `api-server` 的本地下一关 fallback 仍使用旧版 `build_local_puzzle_board` 固定左移一格,没有复用 `module-puzzle` 的种子化初始化规则。该路径会在图库/正式推荐不可用、由 API 临时构造下一关时触发。
修正后 `api-server` 本地下一关构造改为调用 `module_puzzle::build_initial_board_with_seed`,种子由 `runId + profileId + levelIndex + gridSize` 派生;因此第二关、第三关以及后续 fallback 关卡也会得到不同布局,并继续满足“开局没有原图相邻块贴边”的约束。

View 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. 下一关开启后上一关榜单不会污染新关卡。

View File

@@ -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. 战斗入场后,主角后方同伴仍稳定可见,不会因为站位重叠被完全遮住。

View File

@@ -150,3 +150,86 @@ npm run typecheck -- --pretty false
```
以上局部测试、局部 ESLint 与全量类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`
## 2026-04-27 第四轮复查修正
用户复测后仍出现“进入作品测试没有显示幕配置角色”。本轮继续沿真实结果页入口复查,确认前几轮的第一幕解析已经覆盖 `oppositeNpcId -> primaryNpcId -> encounterNpcIds`,但作品测试入口仍可能在进入选角前拿到旧 profile。
定位结论:
1. 结果页展示和自动保存期望消费 `session.resultPreview.preview`,或者在缺少 resultPreview 时消费 `draftProfile.legacyResultProfile`
2. `rpgCreationPreviewAdapter.buildPreviewFromSession()` 原先优先规范化 `session.draftProfile`,会把基础草稿骨架当成运行态 profile。
3. 当基础草稿骨架与结果页预览中的 `sceneChapterBlueprints`、角色、第一幕对面角色不一致时,作品测试即使后续严格读取第一幕,也会读取到旧世界数据。
本轮修正:
1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts`
- 结果页 profile 解析顺序调整为:`session.resultPreview.preview -> draftProfile.legacyResultProfile -> draftProfile`
- 作品测试、结果页展示和自动保存使用同一份当前结果页 profile避免选角后加载旧草稿骨架。
2. `src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts`
- 覆盖 `resultPreview.preview` 优先于 `draftProfile`
- 覆盖缺少 resultPreview 时回退到 `draftProfile.legacyResultProfile`
3. `src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx`
- 将作品测试入口断言改为使用“结果页当前 profile”保证入口语义与 UI 展示一致。
本轮语义补齐为:结果页点“作品测试”后,先用当前结果页 profile 进入角色选择;选完角色后再直接加载该 profile 的开局场景第一幕,并把第一幕 `oppositeNpcId` 作为对面 NPC 启动聊天。
验证命令:
```bash
npm test -- --run src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/data/customWorldLibrary.test.ts
npx eslint src/services/rpg-creation/rpgCreationPreviewAdapter.ts src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.ts src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/data/customWorldLibrary.ts src/data/customWorldLibrary.test.ts src/data/scenePresets.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-session/useRpgSessionBootstrap.ts src/services/customWorldRoleReferences.ts src/services/big-fish-gallery/bigFishGalleryClient.ts
npm run typecheck -- --pretty false
npm run check:encoding
```
以上相关测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本轮修改中触碰因此未执行 `npm run api-server:maincloud`
## 2026-04-27 第五轮误导链路闭口
第四轮修复后,继续清理会误导后续迭代的旧入口:
1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts`
- 删除普通 `draftProfile -> CustomWorldProfile` 兜底。
- Agent 结果页 profile 只允许来自 `session.resultPreview.preview`,或缺少 resultPreview 时来自明确的 `draftProfile.legacyResultProfile` 兼容快照。
- 基础 `draftProfile` 不再能被静默当作运行态 profile避免作品测试再次读到旧草稿骨架。
2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
- 自定义世界选角后的开局状态已经显式构造第一幕 encounter 时,直接返回开局状态。
- 不再把自定义世界开局交给 `ensureSceneEncounterPreview()` 二次推断,避免旧的友好 NPC / 场景预览链路覆盖第一幕 `oppositeNpcId`
3. `src/components/rpg-entry/useRpgCreationEnterWorld.ts``src/components/rpg-entry/useRpgCreationResultAutosave.ts`
- 移除“只读 session.draftProfile / draftProfile 是真相源”这类已经误导本次排查的注释。
- 明确作品测试读取当前结果页 profile不静默回退到基础 draftProfile。
闭口后的主链路:结果页 profile -> 作品测试选角 -> 第一章第一幕 -> `oppositeNpcId` encounter。普通场景预览只作为非自定义世界或非开局场景的兜底不再参与作品测试开局第一幕的角色裁决。
补充闭口:
1. Agent 结果页作品测试与发布入口要求存在当前结果页 profile。
2. 若当前结果页 profile 缺失,入口直接停止,不再使用 `generatedCustomWorldProfile` 旧内存态兜底。
## 2026-04-27 第六轮入口引用与测试态收口
用户复测后再次出现“进入后没有正确显示幕配置角色,且没有进入聊天状态”。本轮继续把问题压回作品测试真实入口,确认前几轮在标准 `oppositeNpcId` 写法下可以正确进入聊天,但真实生成数据可能把第一幕角色引用写成运行时 NPC 形态,例如 `character-npc-角色id`,或混用角色 id、名称、标题、角色职责文本。旧引用解析只覆盖了部分标准形态导致第一幕 encounter 解析失败后,运行态会退回普通开局剧情或其他场景角色,自然也不会进入该幕 NPC 聊天。
本轮修正:
1. `src/services/customWorldRoleReferences.ts`
- 角色引用归一化新增 `character-npc-*``npc-*``story-*``playable-*` 等运行时/草稿前缀剥离。
- 角色别名新增“职责+姓名”“姓名+职责”等组合,兼容生成器把 `role` 文本写入幕槽位的情况。
2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
- 第一幕候选 NPC 解析在进入优先队列前先通过当前 profile 统一归一化。
- 跳过当前玩家角色时,不再只比较 `character.id`,而是同时用角色引用解析比较 id/name避免玩家本人被 `oppositeNpcId` 的别名误判为对面 NPC。
- 自定义世界作品测试进入选择世界与选角后的运行态明确标记 `runtimeMode: 'test'``runtimePersistenceDisabled: true`,避免作品测试被普通游玩存档/自动保存链路污染。
3. `src/hooks/useGameFlow.customWorld.test.tsx`
- 增加复现测试:当第一幕 `oppositeNpcId` 写成 `character-npc-story-act-only` 时,选角后仍必须命中陆衡,并由陆衡主动进入聊天。
- 增加作品测试态断言,确保测试入口不参与普通持久化。
补充验证命令:
```bash
npm test -- --run src/hooks/useGameFlow.customWorld.test.tsx src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx
npx eslint src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow.customWorld.test.tsx src/services/customWorldRoleReferences.ts
npm run typecheck -- --pretty false
```
以上测试、ESLint 与类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`

View 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` 继续跳过测试态存档,不新增正式游玩记录。

View File

@@ -79,6 +79,13 @@ export interface PuzzleRunResponse {
run: PuzzleRunSnapshot;
}
export interface SubmitPuzzleLeaderboardRequest {
profileId: string;
gridSize: PuzzleGridSize;
elapsedMs: number;
nickname: string;
}
export interface SwapPuzzlePiecesRequest {
firstPieceId: string;
secondPieceId: string;

View File

@@ -83,6 +83,7 @@ use crate::{
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
submit_puzzle_leaderboard,
swap_puzzle_pieces,
},
refresh_session::refresh_session,
@@ -673,6 +674,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/leaderboard",
post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(

View File

@@ -28,11 +28,8 @@ use crate::{
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = [
"character_visual",
"scene_image",
"puzzle_cover_image",
];
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] =
["character_visual", "scene_image", "puzzle_cover_image"];
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
@@ -480,7 +477,9 @@ mod tests {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(!super::is_supported_asset_history_kind("puzzle_preview_image"));
assert!(!super::is_supported_asset_history_kind(
"puzzle_preview_image"
));
}
#[test]

View File

@@ -62,7 +62,7 @@
"anchorQuestions": [
{
"key": "themePromise",
"label": "题材承诺",
"label": "题材",
"question": "这张拼图给玩家的题材和完成期待是什么?",
"requiredEffect": "明确拼图主题、辨识度和完成后的满足感。"
},

View File

@@ -17,7 +17,7 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::PuzzleGeneratedImageCandidate;
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
@@ -37,9 +37,10 @@ use shared_contracts::{
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
puzzle_runtime::{
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
PuzzleCellPositionResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
StartPuzzleRunRequest, SwapPuzzlePiecesRequest,
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse,
PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse,
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
@@ -53,7 +54,8 @@ use spacetime_client::{
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
@@ -1104,6 +1106,54 @@ pub async fn advance_local_puzzle_next_level(
))
}
pub async fn submit_puzzle_leaderboard(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SubmitPuzzleLeaderboardRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
profile_id: payload.profile_id,
grid_size: payload.grid_size,
elapsed_ms: payload.elapsed_ms.max(1_000),
nickname: payload.nickname.trim().to_string(),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(run),
},
))
}
fn map_puzzle_agent_session_response(
session: PuzzleAgentSessionRecord,
) -> PuzzleAgentSessionSnapshotResponse {
@@ -1303,7 +1353,11 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_runtime_level_response),
recommended_next_profile_id: run.recommended_next_profile_id,
leaderboard_entries: Vec::new(),
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
@@ -1318,6 +1372,11 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_level_request_record),
recommended_next_profile_id: run.recommended_next_profile_id,
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
@@ -1335,6 +1394,25 @@ fn map_puzzle_level_request_record(
cover_image_src: level.cover_image_src,
board: map_puzzle_board_request_record(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
fn map_puzzle_leaderboard_request_record(
entry: PuzzleLeaderboardEntryResponse,
) -> PuzzleLeaderboardEntryRecord {
PuzzleLeaderboardEntryRecord {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
is_current_player: entry.is_current_player,
}
}
@@ -1389,10 +1467,25 @@ fn map_puzzle_runtime_level_response(
cover_image_src: level.cover_image_src,
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: 0,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
fn map_puzzle_leaderboard_entry_response(
entry: PuzzleLeaderboardEntryRecord,
) -> PuzzleLeaderboardEntryResponse {
PuzzleLeaderboardEntryResponse {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
is_current_player: entry.is_current_player,
}
}
@@ -1922,6 +2015,7 @@ fn build_next_run_from_parts(
if !played_profile_ids.contains(&profile_id) {
played_profile_ids.push(profile_id.clone());
}
let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index);
PuzzleRunRecord {
run_id: run.run_id.clone(),
entry_profile_id: run.entry_profile_id,
@@ -1939,52 +2033,125 @@ fn build_next_run_from_parts(
author_display_name,
theme_tags,
cover_image_src,
board: build_local_puzzle_board(grid_size),
board,
status: "playing".to_string(),
started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
}
}
fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
let total = grid_size * grid_size;
let mut positions = (0..total)
.map(|index| PuzzleCellPositionRecord {
row: index / grid_size,
col: index % grid_size,
})
.collect::<Vec<_>>();
if !positions.is_empty() {
let first = positions.remove(0);
positions.push(first);
fn build_local_puzzle_board(
grid_size: u32,
run_id: &str,
profile_id: &str,
level_index: u32,
) -> PuzzleBoardRecord {
let board = module_puzzle::build_initial_board_with_seed(
grid_size,
build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size),
)
.unwrap_or_else(|_| {
module_puzzle::build_initial_board_with_seed(3, 1)
.expect("fallback puzzle board should use supported grid size")
});
map_puzzle_board_snapshot_record(board)
}
fn build_local_puzzle_shuffle_seed(
run_id: &str,
profile_id: &str,
level_index: u32,
grid_size: u32,
) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in run_id
.bytes()
.chain(profile_id.bytes())
.chain(level_index.to_le_bytes())
.chain(grid_size.to_le_bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
let pieces = (0..total)
.map(|index| {
let current =
positions
.get(index as usize)
.cloned()
.unwrap_or(PuzzleCellPositionRecord {
row: index / grid_size,
col: index % grid_size,
});
PuzzlePieceStateRecord {
piece_id: format!("piece-{index}"),
correct_row: index / grid_size,
correct_col: index % grid_size,
current_row: current.row,
current_col: current.col,
merged_group_id: None,
}
})
.collect();
hash
}
fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord {
PuzzleBoardRecord {
rows: grid_size,
cols: grid_size,
pieces,
merged_groups: Vec::new(),
selected_piece_id: None,
all_tiles_resolved: false,
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateRecord {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupRecord {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionRecord {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn board_positions(board: &PuzzleBoardRecord) -> Vec<(u32, u32)> {
board
.pieces
.iter()
.map(|piece| (piece.current_row, piece.current_col))
.collect()
}
fn has_original_neighbor_pair(board: &PuzzleBoardRecord) -> bool {
board.pieces.iter().any(|piece| {
board.pieces.iter().any(|candidate| {
piece.piece_id != candidate.piece_id
&& piece.current_row.abs_diff(candidate.current_row)
+ piece.current_col.abs_diff(candidate.current_col)
== 1
&& piece.correct_row.abs_diff(candidate.correct_row)
+ piece.correct_col.abs_diff(candidate.correct_col)
== 1
})
})
}
#[test]
fn local_next_level_board_shuffle_changes_by_level() {
let second = build_local_puzzle_board(3, "run-a", "profile-level-2", 2);
let third = build_local_puzzle_board(3, "run-a", "profile-level-3", 3);
assert_ne!(board_positions(&second), board_positions(&third));
assert!(!has_original_neighbor_pair(&second));
assert!(!has_original_neighbor_pair(&third));
}
}

View File

@@ -239,6 +239,15 @@ pub struct PuzzleMergedGroupState {
pub occupied_cells: Vec<PuzzleCellPosition>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleLeaderboardEntry {
pub rank: u32,
pub nickname: String,
pub elapsed_ms: u64,
pub is_current_player: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleBoardSnapshot {
@@ -263,6 +272,10 @@ pub struct PuzzleRuntimeLevelSnapshot {
pub cover_image_src: Option<String>,
pub board: PuzzleBoardSnapshot,
pub status: PuzzleRuntimeLevelStatus,
pub started_at_ms: u64,
pub cleared_at_ms: Option<u64>,
pub elapsed_ms: Option<u64>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -277,6 +290,7 @@ pub struct PuzzleRunSnapshot {
pub previous_level_tags: Vec<String>,
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
pub recommended_next_profile_id: Option<String>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -438,6 +452,18 @@ pub struct PuzzleRunNextLevelInput {
pub advanced_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleLeaderboardSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub grid_size: u32,
pub elapsed_ms: u64,
pub nickname: String,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentSessionProcedureResult {
@@ -924,6 +950,7 @@ pub fn start_run_with_shuffle_seed(
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
let started_at_ms = current_unix_ms();
Ok(PuzzleRunSnapshot {
run_id: run_id.clone(),
entry_profile_id: entry_profile.profile_id.clone(),
@@ -943,8 +970,13 @@ pub fn start_run_with_shuffle_seed(
cover_image_src: entry_profile.cover_image_src.clone(),
board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
})
}
@@ -1163,8 +1195,13 @@ pub fn advance_next_level(
cover_image_src: next_profile.cover_image_src.clone(),
board: next_board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms: current_unix_ms(),
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
})
}
@@ -1389,7 +1426,6 @@ fn build_initial_pieces_without_correct_neighbors(
grid_size: u32,
shuffle_seed: u64,
) -> Vec<PuzzlePieceState> {
let total = grid_size * grid_size;
let base_positions = build_correct_positions(grid_size);
for attempt in 0..PUZZLE_INITIAL_SHUFFLE_ATTEMPTS {
let mut positions = base_positions.clone();
@@ -1399,16 +1435,15 @@ fn build_initial_pieces_without_correct_neighbors(
);
ensure_board_is_not_solved(&mut positions, grid_size);
let pieces = build_pieces_from_positions(grid_size, &positions);
if !has_any_correct_neighbor_pair(&pieces) {
if !has_any_original_neighbor_pair(&pieces) {
return pieces;
}
}
// 反序布局等价于把完整棋盘旋转 180 度;任意原图相邻块在当前棋盘中的方向都会反向,
// 因此可作为“开局没有正确相邻块”的确定性兜底。
let fallback_pieces =
build_pieces_from_positions(grid_size, &build_reverse_positions(total, grid_size));
debug_assert!(!has_any_correct_neighbor_pair(&fallback_pieces));
// 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。
let fallback_pieces = build_original_neighbor_free_pieces(grid_size, shuffle_seed)
.unwrap_or_else(|| build_pieces_from_positions(grid_size, &base_positions));
debug_assert!(!has_any_original_neighbor_pair(&fallback_pieces));
fallback_pieces
}
@@ -1422,16 +1457,6 @@ fn build_correct_positions(grid_size: u32) -> Vec<PuzzleCellPosition> {
.collect()
}
fn build_reverse_positions(total: u32, grid_size: u32) -> Vec<PuzzleCellPosition> {
(0..total)
.rev()
.map(|index| PuzzleCellPosition {
row: index / grid_size,
col: index % grid_size,
})
.collect()
}
fn build_pieces_from_positions(
grid_size: u32,
positions: &[PuzzleCellPosition],
@@ -1466,7 +1491,7 @@ fn ensure_board_is_not_solved(positions: &mut [PuzzleCellPosition], grid_size: u
}
}
fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
fn has_any_original_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
let pieces_by_cell = pieces
.iter()
.map(|piece| ((piece.current_row, piece.current_col), piece))
@@ -1476,10 +1501,138 @@ fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
neighbor_cells(piece.current_row, piece.current_col)
.into_iter()
.filter_map(|cell| pieces_by_cell.get(&cell))
.any(|neighbor| are_correct_neighbors(piece, neighbor))
.any(|neighbor| are_original_neighbors(piece, neighbor))
})
}
fn are_original_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> bool {
left.correct_row.abs_diff(right.correct_row) + left.correct_col.abs_diff(right.correct_col) == 1
}
fn build_original_neighbor_free_pieces(
grid_size: u32,
shuffle_seed: u64,
) -> Option<Vec<PuzzlePieceState>> {
let total = (grid_size * grid_size) as usize;
let mut piece_order = (0..total as u32).collect::<Vec<_>>();
sort_indices_by_seed(&mut piece_order, shuffle_seed ^ 0xa076_1d64_78bd_642f);
let mut cell_order = build_correct_positions(grid_size);
sort_cells_by_seed(&mut cell_order, shuffle_seed ^ 0xe703_7ed1_a0b4_28db);
let mut placements = vec![None; total];
let mut used_cells = BTreeSet::new();
if place_neighbor_free_piece(
grid_size,
&piece_order,
&cell_order,
0,
&mut placements,
&mut used_cells,
) {
Some(
placements
.into_iter()
.enumerate()
.filter_map(|(index, current)| {
current.map(|current| PuzzlePieceState {
piece_id: format!("piece-{index}"),
correct_row: index as u32 / grid_size,
correct_col: index as u32 % grid_size,
current_row: current.row,
current_col: current.col,
merged_group_id: None,
})
})
.collect(),
)
} else {
None
}
}
fn place_neighbor_free_piece(
grid_size: u32,
piece_order: &[u32],
cell_order: &[PuzzleCellPosition],
depth: usize,
placements: &mut [Option<PuzzleCellPosition>],
used_cells: &mut BTreeSet<(u32, u32)>,
) -> bool {
let Some(piece_index) = piece_order.get(depth).copied() else {
return true;
};
for cell in cell_order {
if used_cells.contains(&(cell.row, cell.col)) {
continue;
}
if cell.row == piece_index / grid_size && cell.col == piece_index % grid_size {
continue;
}
if violates_original_neighbor_free_rule(grid_size, piece_index, cell.clone(), placements) {
continue;
}
placements[piece_index as usize] = Some(cell.clone());
used_cells.insert((cell.row, cell.col));
if place_neighbor_free_piece(
grid_size,
piece_order,
cell_order,
depth + 1,
placements,
used_cells,
) {
return true;
}
used_cells.remove(&(cell.row, cell.col));
placements[piece_index as usize] = None;
}
false
}
fn violates_original_neighbor_free_rule(
grid_size: u32,
piece_index: u32,
cell: PuzzleCellPosition,
placements: &[Option<PuzzleCellPosition>],
) -> bool {
placements
.iter()
.enumerate()
.filter_map(|(placed_index, placed_cell)| {
placed_cell
.as_ref()
.map(|placed_cell| (placed_index as u32, placed_cell))
})
.any(|(placed_index, placed_cell)| {
let original_neighbors = (piece_index / grid_size).abs_diff(placed_index / grid_size)
+ (piece_index % grid_size).abs_diff(placed_index % grid_size)
== 1;
let current_neighbors =
cell.row.abs_diff(placed_cell.row) + cell.col.abs_diff(placed_cell.col) == 1;
original_neighbors && current_neighbors
})
}
fn sort_indices_by_seed(indices: &mut [u32], seed: u64) {
indices.sort_by_key(|index| seeded_order_key(seed, u64::from(*index)));
}
fn sort_cells_by_seed(cells: &mut [PuzzleCellPosition], seed: u64) {
cells.sort_by_key(|cell| seeded_order_key(seed, u64::from(cell.row * 16 + cell.col)));
}
fn seeded_order_key(seed: u64, value: u64) -> u64 {
let mut state = seed ^ value.wrapping_mul(0x9e37_79b9_7f4a_7c15);
state ^= state >> 30;
state = state.wrapping_mul(0xbf58_476d_1ce4_e5b9);
state ^= state >> 27;
state = state.wrapping_mul(0x94d0_49bb_1331_11eb);
state ^ (state >> 31)
}
fn rebuild_board_snapshot(
grid_size: u32,
pieces: Vec<PuzzlePieceState>,
@@ -1808,15 +1961,32 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) ->
if let Some(current_level) = next_run.current_level.as_mut() {
current_level.board = next_board;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
let cleared_at_ms = current_unix_ms();
current_level.cleared_at_ms = Some(cleared_at_ms);
current_level.elapsed_ms =
Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000));
}
current_level.status = next_level_status;
}
if is_cleared {
if is_cleared && run.current_level.as_ref().map(|level| level.status)
!= Some(PuzzleRuntimeLevelStatus::Cleared)
{
next_run.cleared_level_count += 1;
}
next_run
}
fn current_unix_ms() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1951,14 +2121,14 @@ mod tests {
}
#[test]
fn initial_board_has_no_correct_neighbor_pairs() {
fn initial_board_has_no_original_neighbor_pairs() {
for grid_size in [3, 4] {
for shuffle_seed in 0..128 {
let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board");
assert!(board.merged_groups.is_empty());
assert!(
!has_any_correct_neighbor_pair(&board.pieces),
!has_any_original_neighbor_pair(&board.pieces),
"grid_size={grid_size}, shuffle_seed={shuffle_seed}"
);
}

View File

@@ -29,6 +29,15 @@ pub struct DragPuzzlePieceRequest {
pub target_col: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SubmitPuzzleLeaderboardRequest {
pub profile_id: String,
pub grid_size: u32,
pub elapsed_ms: u64,
pub nickname: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCellPositionResponse {

View File

@@ -29,7 +29,8 @@ pub use mapper::{
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,

View File

@@ -2252,6 +2252,11 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz
.current_level
.map(map_puzzle_runtime_level_snapshot),
recommended_next_profile_id: snapshot.recommended_next_profile_id,
leaderboard_entries: snapshot
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry)
.collect(),
}
}
@@ -2269,6 +2274,25 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
cover_image_src: snapshot.cover_image_src,
board: map_puzzle_board_snapshot(snapshot.board),
status: snapshot.status.as_str().to_string(),
started_at_ms: snapshot.started_at_ms,
cleared_at_ms: snapshot.cleared_at_ms,
elapsed_ms: snapshot.elapsed_ms,
leaderboard_entries: snapshot
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry)
.collect(),
}
}
pub(crate) fn map_puzzle_leaderboard_entry(
snapshot: module_puzzle::PuzzleLeaderboardEntry,
) -> PuzzleLeaderboardEntryRecord {
PuzzleLeaderboardEntryRecord {
rank: snapshot.rank,
nickname: snapshot.nickname,
elapsed_ms: snapshot.elapsed_ms,
is_current_player: snapshot.is_current_player,
}
}
@@ -4299,6 +4323,14 @@ pub struct PuzzleMergedGroupRecord {
pub occupied_cells: Vec<PuzzleCellPositionRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleLeaderboardEntryRecord {
pub rank: u32,
pub nickname: String,
pub elapsed_ms: u64,
pub is_current_player: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleBoardRecord {
pub rows: u32,
@@ -4321,6 +4353,10 @@ pub struct PuzzleRuntimeLevelRecord {
pub cover_image_src: Option<String>,
pub board: PuzzleBoardRecord,
pub status: String,
pub started_at_ms: u64,
pub cleared_at_ms: Option<u64>,
pub elapsed_ms: Option<u64>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -4334,6 +4370,18 @@ pub struct PuzzleRunRecord {
pub previous_level_tags: Vec<String>,
pub current_level: Option<PuzzleRuntimeLevelRecord>,
pub recommended_next_profile_id: Option<String>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleLeaderboardSubmitRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub grid_size: u32,
pub elapsed_ms: u64,
pub nickname: String,
pub submitted_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -296,6 +296,8 @@ pub mod puzzle_agent_session_row_type;
pub mod puzzle_agent_stage_type;
pub mod puzzle_draft_compile_input_type;
pub mod puzzle_generated_images_save_input_type;
pub mod puzzle_leaderboard_entry_row_type;
pub mod puzzle_leaderboard_submit_input_type;
pub mod puzzle_publication_status_type;
pub mod puzzle_publish_input_type;
pub mod puzzle_run_drag_input_type;
@@ -443,6 +445,7 @@ pub mod story_session_type;
pub mod submit_big_fish_message_procedure;
pub mod submit_custom_world_agent_message_procedure;
pub mod submit_puzzle_agent_message_procedure;
pub mod submit_puzzle_leaderboard_entry_procedure;
pub mod swap_puzzle_pieces_procedure;
pub mod treasure_interaction_action_type;
pub mod treasure_record_procedure_result_type;
@@ -758,6 +761,8 @@ pub use puzzle_agent_session_row_type::PuzzleAgentSessionRow;
pub use puzzle_agent_stage_type::PuzzleAgentStage;
pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput;
pub use puzzle_generated_images_save_input_type::PuzzleGeneratedImagesSaveInput;
pub use puzzle_leaderboard_entry_row_type::PuzzleLeaderboardEntryRow;
pub use puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput;
pub use puzzle_publication_status_type::PuzzlePublicationStatus;
pub use puzzle_publish_input_type::PuzzlePublishInput;
pub use puzzle_run_drag_input_type::PuzzleRunDragInput;
@@ -905,6 +910,7 @@ pub use story_session_type::StorySession;
pub use submit_big_fish_message_procedure::submit_big_fish_message;
pub use submit_custom_world_agent_message_procedure::submit_custom_world_agent_message;
pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message;
pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry;
pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces;
pub use treasure_interaction_action_type::TreasureInteractionAction;
pub use treasure_record_procedure_result_type::TreasureRecordProcedureResult;

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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,
);
}
}

View File

@@ -462,4 +462,32 @@ impl SpacetimeClient {
})
.await
}
pub async fn submit_puzzle_leaderboard_entry(
&self,
input: PuzzleLeaderboardSubmitRecordInput,
) -> Result<PuzzleRunRecord, SpacetimeClientError> {
let procedure_input = PuzzleLeaderboardSubmitInput {
run_id: input.run_id,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
grid_size: input.grid_size,
elapsed_ms: input.elapsed_ms,
nickname: input.nickname,
submitted_at_micros: input.submitted_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().submit_puzzle_leaderboard_entry_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_puzzle_run_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
}

View File

@@ -4,14 +4,14 @@ use module_puzzle::{
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft,
PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput,
PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate,
build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack,
normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size, select_next_profile,
start_run, swap_pieces,
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput,
PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput,
PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags,
publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces,
};
use serde_json::from_str as json_from_str;
use serde_json::to_string as json_to_string;
@@ -102,6 +102,25 @@ pub struct PuzzleRuntimeRunRow {
updated_at: Timestamp,
}
/// 拼图关卡真实成绩表。
/// 每个用户在同一作品同一网格规格下只保留一条最佳成绩,用于结算弹窗排行榜。
#[spacetimedb::table(
accessor = puzzle_leaderboard_entry,
index(accessor = by_puzzle_leaderboard_profile_grid, btree(columns = [profile_id, grid_size])),
index(accessor = by_puzzle_leaderboard_user_profile_grid, btree(columns = [user_id, profile_id, grid_size]))
)]
pub struct PuzzleLeaderboardEntryRow {
#[primary_key]
entry_id: String,
profile_id: String,
grid_size: u32,
user_id: String,
nickname: String,
best_elapsed_ms: u64,
last_run_id: String,
updated_at: Timestamp,
}
#[spacetimedb::procedure]
pub fn create_puzzle_agent_session(
ctx: &mut ProcedureContext,
@@ -460,6 +479,25 @@ pub fn advance_puzzle_next_level(
}
}
#[spacetimedb::procedure]
pub fn submit_puzzle_leaderboard_entry(
ctx: &mut ProcedureContext,
input: PuzzleLeaderboardSubmitInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| submit_puzzle_leaderboard_entry_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
error_message: Some(message),
},
}
}
fn create_puzzle_agent_session_tx(
ctx: &TxContext,
input: PuzzleAgentSessionCreateInput,
@@ -1017,6 +1055,15 @@ fn start_puzzle_run_tx(
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
let mut run =
start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?;
let current_grid_size = run.current_grid_size;
let current_profile_id = entry_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
ctx,
&mut run,
&input.owner_user_id,
current_profile_id.as_str(),
current_grid_size,
);
run.recommended_next_profile_id = select_next_profile(
&entry_profile,
&run.played_profile_ids,
@@ -1034,7 +1081,21 @@ fn get_puzzle_run_tx(
input: PuzzleRunGetInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
deserialize_run(&row.snapshot_json)
let mut run = deserialize_run(&row.snapshot_json)?;
if let Some((profile_id, grid_size)) = run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
Ok(run)
}
fn swap_puzzle_pieces_tx(
@@ -1098,6 +1159,15 @@ fn advance_puzzle_next_level_tx(
.clone();
let mut next_run = module_puzzle::advance_next_level(&current_run, &next_profile)
.map_err(|error| error.to_string())?;
let next_grid_size = next_run.current_grid_size;
let next_profile_id = next_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
ctx,
&mut next_run,
&input.owner_user_id,
&next_profile_id,
next_grid_size,
);
next_run.recommended_next_profile_id =
select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates)
.map(|value| value.profile_id.clone());
@@ -1114,6 +1184,58 @@ fn advance_puzzle_next_level_tx(
Ok(next_run)
}
fn submit_puzzle_leaderboard_entry_tx(
ctx: &TxContext,
input: PuzzleLeaderboardSubmitInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let mut run = deserialize_run(&row.snapshot_json)?;
let current_level = run
.current_level
.as_ref()
.ok_or_else(|| "拼图关卡不存在".to_string())?;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
return Err("当前关卡尚未通关".to_string());
}
if current_level.profile_id != input.profile_id {
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
}
if current_level.grid_size != input.grid_size {
return Err("提交成绩的网格规格与当前关卡不匹配".to_string());
}
let nickname = input.nickname.trim();
if nickname.is_empty() {
return Err("排行榜昵称不能为空".to_string());
}
upsert_puzzle_leaderboard_entry(
ctx,
&input.owner_user_id,
&input.profile_id,
input.grid_size,
nickname,
input.elapsed_ms.max(1_000),
&input.run_id,
input.submitted_at_micros,
);
let leaderboard_entries = list_puzzle_leaderboard_entries(
ctx,
&input.profile_id,
input.grid_size,
&input.owner_user_id,
10,
);
if let Some(level) = run.current_level.as_mut() {
level.elapsed_ms = Some(input.elapsed_ms.max(1_000));
level.leaderboard_entries = leaderboard_entries.clone();
}
run.leaderboard_entries = leaderboard_entries;
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
Ok(run)
}
fn build_puzzle_agent_session_snapshot(
ctx: &TxContext,
row: &PuzzleAgentSessionRow,
@@ -1536,6 +1658,116 @@ fn refresh_next_profile_recommendation(
Ok(())
}
fn hydrate_puzzle_leaderboard_entries(
ctx: &TxContext,
run: &mut PuzzleRunSnapshot,
current_user_id: &str,
profile_id: &str,
grid_size: u32,
) {
let leaderboard_entries =
list_puzzle_leaderboard_entries(ctx, profile_id, grid_size, current_user_id, 10);
run.leaderboard_entries = leaderboard_entries.clone();
if let Some(level) = run.current_level.as_mut() {
level.leaderboard_entries = leaderboard_entries;
}
}
fn build_puzzle_leaderboard_entry_id(user_id: &str, profile_id: &str, grid_size: u32) -> String {
format!("puzzle-leaderboard-{user_id}-{profile_id}-{grid_size}")
}
fn upsert_puzzle_leaderboard_entry(
ctx: &TxContext,
user_id: &str,
profile_id: &str,
grid_size: u32,
nickname: &str,
elapsed_ms: u64,
run_id: &str,
updated_at_micros: i64,
) {
let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size);
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
if let Some(existing) = ctx
.db
.puzzle_leaderboard_entry()
.entry_id()
.find(&entry_id)
{
let should_replace = elapsed_ms < existing.best_elapsed_ms
|| (elapsed_ms == existing.best_elapsed_ms
&& updated_at.to_micros_since_unix_epoch()
< existing.updated_at.to_micros_since_unix_epoch());
let next_row = PuzzleLeaderboardEntryRow {
entry_id: existing.entry_id.clone(),
profile_id: existing.profile_id.clone(),
grid_size: existing.grid_size,
user_id: existing.user_id.clone(),
nickname: nickname.to_string(),
best_elapsed_ms: if should_replace {
elapsed_ms
} else {
existing.best_elapsed_ms
},
last_run_id: if should_replace {
run_id.to_string()
} else {
existing.last_run_id.clone()
},
updated_at,
};
ctx.db
.puzzle_leaderboard_entry()
.entry_id()
.delete(&existing.entry_id);
ctx.db.puzzle_leaderboard_entry().insert(next_row);
return;
}
ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow {
entry_id,
profile_id: profile_id.to_string(),
grid_size,
user_id: user_id.to_string(),
nickname: nickname.to_string(),
best_elapsed_ms: elapsed_ms,
last_run_id: run_id.to_string(),
updated_at,
});
}
fn list_puzzle_leaderboard_entries(
ctx: &TxContext,
profile_id: &str,
grid_size: u32,
current_user_id: &str,
limit: usize,
) -> Vec<PuzzleLeaderboardEntry> {
let mut rows = ctx
.db
.puzzle_leaderboard_entry()
.iter()
.filter(|row| row.profile_id == profile_id && row.grid_size == grid_size)
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.best_elapsed_ms
.cmp(&right.best_elapsed_ms)
.then_with(|| left.updated_at.cmp(&right.updated_at))
.then_with(|| left.user_id.cmp(&right.user_id))
});
rows.into_iter()
.take(limit)
.enumerate()
.map(|(index, row)| PuzzleLeaderboardEntry {
rank: index as u32 + 1,
nickname: row.nickname,
elapsed_ms: row.best_elapsed_ms,
is_current_player: row.user_id == current_user_id,
})
.collect()
}
fn serialize_json<T: ::serde::Serialize>(value: &T) -> String {
json_to_string(value).unwrap_or_else(|_| "{}".to_string())
}
@@ -1568,6 +1800,7 @@ mod tests {
use super::*;
use module_puzzle::{
build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score,
PuzzleLeaderboardEntry,
};
#[test]
@@ -1582,6 +1815,7 @@ mod tests {
previous_level_tags: vec!["蒸汽城市".to_string()],
current_level: None,
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
};
let serialized = serialize_json(&snapshot);
let parsed = deserialize_run(&serialized).expect("run json should parse");
@@ -1681,4 +1915,31 @@ mod tests {
> tag_similarity_score(&left.theme_tags, &right.theme_tags)
);
}
#[test]
fn puzzle_leaderboard_entries_sort_by_elapsed_time() {
let mut entries = vec![
PuzzleLeaderboardEntry {
rank: 0,
nickname: "玩家 B".to_string(),
elapsed_ms: 5200,
is_current_player: false,
},
PuzzleLeaderboardEntry {
rank: 0,
nickname: "玩家 A".to_string(),
elapsed_ms: 3100,
is_current_player: true,
},
];
entries.sort_by(|left, right| left.elapsed_ms.cmp(&right.elapsed_ms));
for (index, entry) in entries.iter_mut().enumerate() {
entry.rank = index as u32 + 1;
}
assert_eq!(entries[0].nickname, "玩家 A");
assert_eq!(entries[0].rank, 1);
assert_eq!(entries[1].nickname, "玩家 B");
assert_eq!(entries[1].rank, 2);
}
}

View File

@@ -2,7 +2,10 @@ import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { useAuthUi } from './components/auth/AuthUiContext';
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
import type { SelectionStage } from './components/platform-entry/platformEntryTypes';
import type {
CustomWorldRuntimeLaunchOptions,
SelectionStage,
} from './components/platform-entry/platformEntryTypes';
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
import {
APP_RUNTIME_ROUTES,
@@ -40,6 +43,8 @@ export default function App() {
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
resolveSelectionStageFromPath(window.location.pathname),
);
const [runtimeReturnStage, setRuntimeReturnStage] =
useState<SelectionStage>('platform');
const setSelectionStage = useCallback((stage: SelectionStage) => {
setRawSelectionStage(stage);
@@ -86,10 +91,17 @@ export default function App() {
);
const handleCustomWorldSelect = useCallback(
(customWorldProfile: CustomWorldProfile) => {
(
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => {
// 中文注释:作品测试需要在结束测试后精确返回启动它的结果页;
// 正式进入世界仍保持既有平台首页返回语义。
setRuntimeReturnStage(options?.returnStage ?? 'platform');
createRuntimeIntent({
kind: 'custom-world',
profile: customWorldProfile,
mode: options?.mode ?? 'play',
});
},
[createRuntimeIntent],
@@ -102,7 +114,13 @@ export default function App() {
if (isRuntimeActive) {
return (
<Suspense fallback={null}>
<RpgRuntimeApp initialIntent={runtimeIntent} />
<RpgRuntimeApp
initialIntent={runtimeIntent}
onExitRuntime={() => {
setIsRuntimeActive(false);
setSelectionStage(runtimeReturnStage);
}}
/>
</Suspense>
);
}

View File

@@ -80,6 +80,8 @@ export default function PuzzlePlaygroundApp() {
return (
<PuzzleRuntimeShell
run={run}
isBusy={false}
error={null}
onBack={handleRestart}
onSwapPieces={handleSwapPieces}
onDragPiece={handleDragPiece}

View File

@@ -10,6 +10,7 @@ export type RpgRuntimeAppIntent =
token: number;
kind: 'custom-world';
profile: CustomWorldProfile;
mode?: 'play' | 'test';
}
| {
token: number;
@@ -19,8 +20,10 @@ export type RpgRuntimeAppIntent =
export function RpgRuntimeApp({
initialIntent,
onExitRuntime,
}: {
initialIntent: RpgRuntimeAppIntent | null;
onExitRuntime?: () => void;
}) {
const gameShellProps = useRpgRuntimeSession();
const handledIntentTokenRef = useRef<number | null>(null);
@@ -32,14 +35,16 @@ export function RpgRuntimeApp({
handledIntentTokenRef.current = initialIntent.token;
if (initialIntent.kind === 'custom-world') {
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile);
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile, {
mode: initialIntent.mode ?? 'play',
});
return;
}
gameShellProps.entry.handleContinueGame(initialIntent.snapshot);
}, [gameShellProps.entry, initialIntent]);
return <RpgRuntimeShell {...gameShellProps} />;
return <RpgRuntimeShell {...gameShellProps} onExitTestRuntime={onExitRuntime} />;
}
export default RpgRuntimeApp;

View File

@@ -187,6 +187,55 @@ describe('GameCanvasEntityLayer', () => {
expect(html).not.toContain('好感度变化 +3');
});
it('keeps hostile combat hp bar visible during post-hit afterimage frames', () => {
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[
createHostileNpc({
hp: 4,
maxHp: 10,
animation: 'die',
}),
]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).toContain('from-rose-500 to-red-400');
});
it('renders scene act back-row encounters alongside the primary encounter', () => {
const html = renderToStaticMarkup(
<GameCanvasEntityLayer

View File

@@ -27,6 +27,7 @@ import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
getBattleCompanionSlotOffset,
getCompanionSlotOffset,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
@@ -222,11 +223,23 @@ export function GameCanvasEntityLayer({
const [combatFeedbackEvents, setCombatFeedbackEvents] = useState<CombatFeedbackEvent[]>([]);
const previousCombatSamplesRef = useRef<Map<string, CombatFeedbackHealthSample> | null>(null);
const combatFeedbackSequenceRef = useRef(0);
const hasCombatAfterimage = useMemo(
() =>
combatFeedbackEvents.length > 0 ||
sceneCombatants.some(
(hostileNpc) =>
hostileNpc.hp < hostileNpc.maxHp ||
hostileNpc.animation === 'attack' ||
hostileNpc.animation === 'die',
),
[combatFeedbackEvents.length, sceneCombatants],
);
const shouldRenderCombatPresentation = inBattle || hasCombatAfterimage;
const shouldRenderPeacefulEncounter =
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
() => {
if (!inBattle) return [];
if (!shouldRenderCombatPresentation) return [];
return [
{key: 'player', kind: 'player', hp: playerHp},
@@ -242,7 +255,7 @@ export function GameCanvasEntityLayer({
})),
];
},
[companions, inBattle, playerHp, sceneCombatants],
[companions, playerHp, sceneCombatants, shouldRenderCombatPresentation],
);
const combatFeedbackByTarget = useMemo(() => {
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
@@ -259,7 +272,7 @@ export function GameCanvasEntityLayer({
};
useEffect(() => {
if (!inBattle) {
if (!shouldRenderCombatPresentation) {
previousCombatSamplesRef.current = null;
setCombatFeedbackEvents([]);
return;
@@ -283,12 +296,14 @@ export function GameCanvasEntityLayer({
previousCombatSamplesRef.current = new Map(
combatHealthSamples.map(sample => [sample.key, sample]),
);
}, [combatHealthSamples, inBattle]);
}, [combatHealthSamples, shouldRenderCombatPresentation]);
return (
<>
{companions.map(companion => {
const slotOffset = getCompanionSlotOffset(companion.slot);
const slotOffset = inBattle
? getBattleCompanionSlotOffset(companion.slot)
: getCompanionSlotOffset(companion.slot);
const feedbackTargetKey = `companion:${companion.npcId}`;
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
const companionFacing = companion.facing ?? 'right';
@@ -314,7 +329,7 @@ export function GameCanvasEntityLayer({
style={{
left: companionAnchorLeft,
bottom: companionAnchorBottom,
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom) + (inBattle ? 1 : 0),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
@@ -336,7 +351,7 @@ export function GameCanvasEntityLayer({
className="relative flex w-28 flex-col items-center"
>
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
{inBattle && (
{shouldRenderCombatPresentation && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
@@ -385,7 +400,7 @@ export function GameCanvasEntityLayer({
events={combatFeedbackByTarget.get('player') ?? []}
onDone={removeCombatFeedbackEvent}
/>
{inBattle && (
{shouldRenderCombatPresentation && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
@@ -484,7 +499,7 @@ export function GameCanvasEntityLayer({
className="relative flex w-28 flex-col items-center"
>
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
{inBattle && (
{shouldRenderCombatPresentation && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${npcCombatHpTop}px`}}

View File

@@ -108,6 +108,12 @@ export function getCompanionSlotOffset(slot: CompanionRenderState['slot']) {
: {left: -34, bottom: 10};
}
export function getBattleCompanionSlotOffset(slot: CompanionRenderState['slot']) {
return slot === 'upper'
? {left: -118, bottom: 86}
: {left: -92, bottom: 26};
}
export function mapHostileNpcAnimationToCharacterState(animation: SceneHostileNpc['animation']) {
if (animation === 'move') return AnimationState.RUN;
if (animation === 'attack') return AnimationState.ATTACK;

View File

@@ -28,7 +28,10 @@ import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type {
PuzzleRunSnapshot,
SubmitPuzzleLeaderboardRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
@@ -80,7 +83,10 @@ import {
getPuzzleGalleryDetail,
listPuzzleGallery,
} from '../../services/puzzle-gallery';
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
import {
advanceLocalPuzzleNextLevel,
submitPuzzleLeaderboard,
} from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
startLocalPuzzleRun,
@@ -427,6 +433,8 @@ export function PlatformEntryFlowShellImpl({
useState<PuzzleDetailReturnTarget | null>(null);
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [puzzleGenerationState, setPuzzleGenerationState] =
@@ -1236,6 +1244,50 @@ export function PlatformEntryFlowShellImpl({
[isPuzzleBusy, puzzleRun],
);
useEffect(() => {
const currentLevel = puzzleRun?.currentLevel ?? null;
if (!puzzleRun || !currentLevel || currentLevel.status !== 'cleared') {
return;
}
if (currentLevel.elapsedMs === null) {
return;
}
if ((currentLevel.leaderboardEntries ?? []).length > 0) {
return;
}
const submitKey = `${puzzleRun.runId}:${currentLevel.profileId}:${currentLevel.gridSize}:${currentLevel.elapsedMs}`;
if (submittedPuzzleLeaderboardKeysRef.current.has(submitKey)) {
return;
}
submittedPuzzleLeaderboardKeysRef.current.add(submitKey);
setIsPuzzleLeaderboardBusy(true);
const payload: SubmitPuzzleLeaderboardRequest = {
profileId: currentLevel.profileId,
gridSize: currentLevel.gridSize,
elapsedMs: currentLevel.elapsedMs,
nickname: authUi?.user?.displayName?.trim() || '玩家',
};
void submitPuzzleLeaderboard(puzzleRun.runId, payload)
.then(({ run }) => {
setPuzzleRun(run);
})
.catch((error) => {
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'));
})
.finally(() => {
setIsPuzzleLeaderboardBusy(false);
});
}, [
authUi?.user?.displayName,
puzzleRun,
resolvePuzzleErrorMessage,
setPuzzleError,
]);
const advancePuzzleLevel = useCallback(async () => {
if (!puzzleRun || isPuzzleBusy) {
return;
@@ -2407,13 +2459,17 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
>
<PuzzleRuntimeShell
run={puzzleRun}
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
error={puzzleError}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
<PuzzleRuntimeShell
run={puzzleRun}
isBusy={
isPuzzleBusy ||
isPuzzleNextLevelGenerating ||
isPuzzleLeaderboardBusy
}
error={puzzleError}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
onSwapPieces={(payload) => {
void swapPuzzlePiecesInRun(payload);
}}

View File

@@ -4,6 +4,13 @@ import type {
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile } from '../../types';
export type CustomWorldRuntimeLaunchMode = 'play' | 'test';
export type CustomWorldRuntimeLaunchOptions = {
mode?: CustomWorldRuntimeLaunchMode;
returnStage?: SelectionStage | null;
};
export type SelectionStage =
| 'platform'
| 'detail'
@@ -38,5 +45,8 @@ export type PlatformEntryFlowShellProps = {
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleCustomWorldSelect: (
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => void;
};

View File

@@ -1,9 +1,10 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { AuthUiContext } from '../auth/AuthUiContext';
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
@@ -18,6 +19,34 @@ vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
}));
function createAuthValue() {
return {
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: (action: () => void) => action(),
openSettingsModal: () => {},
openAccountModal: () => {},
logout: async () => {},
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light' as const,
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
};
}
function renderPuzzleRuntime(
ui: React.ReactElement,
authValue = createAuthValue(),
) {
return render(
<AuthUiContext.Provider value={authValue}>{ui}</AuthUiContext.Provider>,
);
}
const clearedRun: PuzzleRunSnapshot = {
runId: 'run-1',
entryProfileId: 'profile-1',
@@ -85,9 +114,10 @@ const clearedRun: PuzzleRunSnapshot = {
};
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
render(
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
@@ -97,6 +127,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
/>,
);
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
expect(screen.getByTestId('puzzle-clear-flash')).toBeTruthy();
act(() => {
vi.advanceTimersByTime(1_400);
});
const dialog = screen.getByRole('dialog', { name: '通关完成' });
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
expect(within(dialog).getByText('排行榜')).toBeTruthy();
@@ -106,10 +143,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
render(
vi.useFakeTimers();
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
@@ -119,8 +159,91 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
vi.useRealTimers();
});
test('右上角设置按钮打开拼图设置并支持音量调节', () => {
const authValue = createAuthValue();
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
authValue,
);
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' });
fireEvent.change(slider, { target: { value: '77' } });
expect(within(dialog).getByText('第 1 关')).toBeTruthy();
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
});
test('合并块按实际拼块外轮廓描边', () => {
const mergedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
mergedGroups: [
{
groupId: 'group-l',
pieceIds: ['piece-0', 'piece-1', 'piece-3'],
occupiedCells: [
{ row: 0, col: 0 },
{ row: 0, col: 1 },
{ row: 1, col: 0 },
],
},
],
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
? { ...piece, mergedGroupId: 'group-l' }
: piece,
),
},
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const outlinedPieces = container.querySelectorAll(
'[data-merged-piece-outline="true"]',
);
expect(outlinedPieces).toHaveLength(3);
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
expect(outlinedPieces[0]?.className).toContain('border-r-0');
expect(outlinedPieces[0]?.className).toContain('border-b-0');
expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
expect(outlinedPieces[0]?.className).toContain('rounded-tr-none');
expect(outlinedPieces[0]?.className).toContain('rounded-bl-none');
expect(outlinedPieces[1]?.className).toContain('border-l-0');
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
expect(outlinedPieces[2]?.className).toContain('border-t-0');
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
});

View File

@@ -1,4 +1,4 @@
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy, X } from 'lucide-react';
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
@@ -9,7 +9,10 @@ import type {
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleRuntimeShellProps = {
@@ -29,7 +32,6 @@ type PuzzleBoardPieceViewModel = {
correctRow: number;
correctCol: number;
mergedGroupId: string | null;
label: string;
};
type PuzzleMergedGroupViewModel = {
@@ -59,9 +61,41 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
}));
}
function buildPieceLabel(pieceId: string) {
const fallback = pieceId.slice(-2).toUpperCase();
return fallback || '块';
function buildLocalCellKey(row: number, col: number) {
return `${row}:${col}`;
}
function resolveMergedPieceOutlineClass(
group: PuzzleMergedGroupViewModel,
piece: PuzzleMergedGroupViewModel['pieces'][number],
) {
const groupCellKeys = new Set(
group.pieces.map((groupPiece) =>
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
),
);
const hasTopEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow - 1, piece.localCol),
);
const hasRightEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow, piece.localCol + 1),
);
const hasBottomEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow + 1, piece.localCol),
);
const hasLeftEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow, piece.localCol - 1),
);
return [
hasTopEdge ? 'border-t-2' : 'border-t-0',
hasRightEdge ? 'border-r-2' : 'border-r-0',
hasBottomEdge ? 'border-b-2' : 'border-b-0',
hasLeftEdge ? 'border-l-2' : 'border-l-0',
hasTopEdge && hasLeftEdge ? 'rounded-tl-[0.85rem]' : 'rounded-tl-none',
hasTopEdge && hasRightEdge ? 'rounded-tr-[0.85rem]' : 'rounded-tr-none',
hasBottomEdge && hasRightEdge ? 'rounded-br-[0.85rem]' : 'rounded-br-none',
hasBottomEdge && hasLeftEdge ? 'rounded-bl-[0.85rem]' : 'rounded-bl-none',
].join(' ');
}
function buildMergedGroupViewModels(
@@ -117,6 +151,10 @@ function formatElapsedMs(elapsedMs: number | null | undefined) {
.padStart(2, '0')}`;
}
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
/**
* 拼图运行时壳层。
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
@@ -130,7 +168,9 @@ export function PuzzleRuntimeShell({
onDragPiece,
onAdvanceNextLevel,
}: PuzzleRuntimeShellProps) {
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const dragSessionRef = useRef<{
pieceId: string;
pointerId: number;
@@ -148,10 +188,21 @@ export function PuzzleRuntimeShell({
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(null);
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
null,
);
const [isClearFlashVisible, setIsClearFlashVisible] = useState(false);
const [isClearResultReady, setIsClearResultReady] = useState(false);
const clearPresentationKeyRef = useRef<string | null>(null);
const clearPresentationTimeoutIdsRef = useRef<number[]>([]);
const boardRef = useRef<HTMLDivElement | null>(null);
const currentLevel = run?.currentLevel ?? null;
const board = currentLevel?.board ?? null;
const clearResultKey = currentLevel
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
: null;
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
@@ -167,7 +218,6 @@ export function PuzzleRuntimeShell({
correctRow: piece.correctRow,
correctCol: piece.correctCol,
mergedGroupId: piece.mergedGroupId,
label: buildPieceLabel(piece.pieceId),
}));
}, [board]);
@@ -206,7 +256,9 @@ export function PuzzleRuntimeShell({
return;
}
const pieceElement = pieceElementRefMap.current.get(dragVisualTarget.pieceId);
const pieceElement = pieceElementRefMap.current.get(
dragVisualTarget.pieceId,
);
if (pieceElement) {
pieceElement.style.transform = '';
pieceElement.style.willChange = '';
@@ -215,7 +267,9 @@ export function PuzzleRuntimeShell({
}
if (dragVisualTarget.groupId) {
const groupElement = groupElementRefMap.current.get(dragVisualTarget.groupId);
const groupElement = groupElementRefMap.current.get(
dragVisualTarget.groupId,
);
if (groupElement) {
groupElement.style.transform = '';
groupElement.style.willChange = '';
@@ -304,10 +358,66 @@ export function PuzzleRuntimeShell({
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
};
useEffect(() => () => {
cancelDragVisualFrame();
resetDragVisualTarget();
}, []);
useEffect(
() => () => {
cancelDragVisualFrame();
resetDragVisualTarget();
},
[],
);
const clearPresentationTimeouts = () => {
for (const timeoutId of clearPresentationTimeoutIdsRef.current) {
window.clearTimeout(timeoutId);
}
clearPresentationTimeoutIdsRef.current = [];
};
useEffect(
() => () => {
clearPresentationTimeouts();
},
[],
);
useEffect(() => {
if (!currentLevel || !clearResultKey) {
clearPresentationKeyRef.current = null;
clearPresentationTimeouts();
setIsClearFlashVisible(false);
setIsClearResultReady(false);
return;
}
if (currentLevel.status !== 'cleared') {
clearPresentationKeyRef.current = null;
clearPresentationTimeouts();
setIsClearFlashVisible(false);
setIsClearResultReady(false);
return;
}
if (
dismissedClearKey === clearResultKey ||
clearPresentationKeyRef.current === clearResultKey
) {
return;
}
// 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。
clearPresentationKeyRef.current = clearResultKey;
clearPresentationTimeouts();
setIsClearFlashVisible(true);
setIsClearResultReady(false);
clearPresentationTimeoutIdsRef.current = [
window.setTimeout(() => {
setIsClearFlashVisible(false);
}, PUZZLE_CLEAR_FLASH_DURATION_MS),
window.setTimeout(() => {
setIsClearResultReady(true);
}, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS),
];
}, [clearResultKey, currentLevel, dismissedClearKey]);
if (!run || !currentLevel || !board) {
return (
@@ -453,17 +563,18 @@ export function PuzzleRuntimeShell({
scheduleDragVisual();
};
const statusLabel =
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中';
const nextAvailable =
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
const clearResultKey = `${run.runId}:${currentLevel.profileId}:${currentLevel.levelIndex}`;
const levelLabel = `${currentLevel.levelIndex}`;
const leaderboardEntries =
(currentLevel.leaderboardEntries ?? []).length > 0
? currentLevel.leaderboardEntries
: (run.leaderboardEntries ?? []);
const isClearResultOpen =
currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey;
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
@@ -478,26 +589,41 @@ export function PuzzleRuntimeShell({
) : null}
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
<div className="absolute left-0 top-0 z-20 flex w-full items-start justify-between gap-3 px-4 py-4">
<button
type="button"
onClick={onBack}
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="absolute left-0 top-0 z-20 w-full px-4 py-4">
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-3">
<button
type="button"
onClick={onBack}
aria-label="返回上一页"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex max-w-[70vw] flex-col items-end gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-right backdrop-blur">
<div className="text-[0.68rem] font-semibold tracking-[0.2em] text-white/70">
PUZZLE
</div>
<div className="line-clamp-1 text-sm font-bold text-white">
{currentLevel.levelName}
</div>
<div className="text-xs text-white/74">
{currentLevel.authorDisplayName} · {currentLevel.levelIndex} ·{' '}
{statusLabel}
<div className="flex min-w-0 flex-col items-center gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-center backdrop-blur">
<div className="line-clamp-1 text-sm font-bold text-white sm:text-base">
{currentLevel.levelName}
</div>
<div className="line-clamp-1 text-xs text-white/78">
{currentLevel.authorDisplayName}
</div>
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
{levelLabel}
</div>
</div>
<button
type="button"
onClick={() => setIsSettingsPanelOpen(true)}
aria-label="打开拼图设置"
title="打开拼图设置"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/60"
>
<PixelIcon
src={CHROME_ICONS.settings}
className="h-[1.4rem] w-[1.4rem] drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)]"
/>
</button>
</div>
</div>
@@ -517,10 +643,7 @@ export function PuzzleRuntimeShell({
const isSelected = piece?.pieceId === selectedPieceId;
return (
<div
key={`${cell.row}:${cell.col}`}
className="relative p-1"
>
<div key={`${cell.row}:${cell.col}`} className="relative p-1">
<div
ref={(node) => {
if (!piece) {
@@ -542,7 +665,9 @@ export function PuzzleRuntimeShell({
: 'border-white/18 bg-white/12 text-white'
: 'border-white/8 bg-black/18 text-white/20'
} ${
isMerged ? 'transition-colors' : 'transition-[background-color,border-color,box-shadow,opacity]'
isMerged
? 'transition-colors'
: 'transition-[background-color,border-color,box-shadow,opacity]'
}`}
onPointerDown={(event) => {
if (!piece || isMerged) {
@@ -591,11 +716,6 @@ export function PuzzleRuntimeShell({
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
)}
<div className="absolute inset-0 bg-black/10" />
{!isMerged ? (
<div className="absolute bottom-1 right-1 rounded-full bg-black/38 px-1.5 py-0.5 text-[10px] font-black text-white/86">
{piece.label}
</div>
) : null}
</div>
) : (
''
@@ -632,7 +752,11 @@ export function PuzzleRuntimeShell({
{group.pieces.map((piece) => (
<div
key={piece.pieceId}
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10"
className={`pointer-events-auto relative touch-none overflow-hidden border-emerald-100/72 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
group,
piece,
)}`}
data-merged-piece-outline="true"
style={{
gridColumn: piece.localCol + 1,
gridRow: piece.localRow + 1,
@@ -676,7 +800,6 @@ export function PuzzleRuntimeShell({
<div className="absolute inset-0 bg-black/8" />
</div>
))}
<div className="pointer-events-none absolute inset-0 rounded-[1rem] ring-2 ring-emerald-100/58 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_14px_32px_rgba(6,78,59,0.24)]" />
</div>
</div>
))}
@@ -717,6 +840,142 @@ export function PuzzleRuntimeShell({
</div>
</div>
{isClearFlashVisible ? (
<div
data-testid="puzzle-clear-flash"
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-30 overflow-hidden"
>
<div className="puzzle-clear-flash-overlay absolute inset-0" />
<div className="puzzle-clear-flash-beam" />
</div>
) : null}
{isSettingsPanelOpen ? (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsSettingsPanelOpen(false)}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-settings-title"
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<header className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<h2
id="puzzle-settings-title"
className="text-sm font-semibold text-white"
>
</h2>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<button
type="button"
aria-label="关闭拼图设置"
onClick={() => setIsSettingsPanelOpen(false)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
<div className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_65%),rgba(0,0,0,0.24)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[10px] tracking-[0.24em] text-sky-200/80">
</div>
<div className="mt-2 text-sm font-semibold text-white">
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/28 px-2 py-1 text-xs text-white/80">
{Math.round(musicVolume * 100)}%
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={1}
aria-label="拼图音乐音量"
value={Math.round(musicVolume * 100)}
onChange={(event) =>
onMusicVolumeChange(
Number(event.currentTarget.value) / 100,
)
}
className="h-2 w-full cursor-pointer accent-sky-400"
/>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-3 space-y-2 text-sm text-white/82">
<div className="flex items-center justify-between gap-3">
<span className="text-white/56"></span>
<span className="font-semibold text-white">
{levelLabel}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-white/56"></span>
<span className="font-semibold text-white">
{run.clearedLevelCount}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-white/56"></span>
<span className="font-semibold text-white">
{statusLabel}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-white/56"></span>
<span className="font-mono font-semibold text-white">
{formatElapsedMs(currentLevel.elapsedMs)}
</span>
</div>
</div>
</div>
</div>
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5">
<button
type="button"
onClick={() => setIsSettingsPanelOpen(false)}
className="rounded-full border border-white/12 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:text-white"
>
</button>
<button
type="button"
onClick={() => {
setIsSettingsPanelOpen(false);
onBack();
}}
className="rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100"
>
</button>
</footer>
</section>
</div>
) : null}
{isClearResultOpen ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
<section
@@ -748,7 +1007,7 @@ export function PuzzleRuntimeShell({
setDismissedClearKey(clearResultKey);
}}
>
<X className="h-4 w-4" />
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</header>
@@ -768,7 +1027,9 @@ export function PuzzleRuntimeShell({
</div>
<div className="mt-4">
<div className="mb-2 text-sm font-bold text-white"></div>
<div className="mb-2 text-sm font-bold text-white">
</div>
<div className="overflow-hidden rounded-[1rem] border border-white/10">
<div className="grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] bg-white/6 px-3 py-2 text-[11px] font-bold text-white/48">
<span></span>
@@ -776,24 +1037,32 @@ export function PuzzleRuntimeShell({
<span className="text-right"></span>
</div>
<div className="max-h-56 overflow-y-auto">
{leaderboardEntries.map((entry) => (
<div
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'bg-amber-200/14 text-amber-50'
: 'border-t border-white/8 text-white/78'
}`}
>
<span className="font-mono font-black">#{entry.rank}</span>
<span className="truncate font-semibold">
{entry.nickname}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
</span>
{leaderboardEntries.length > 0 ? (
leaderboardEntries.map((entry) => (
<div
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'bg-amber-200/14 text-amber-50'
: 'border-t border-white/8 text-white/78'
}`}
>
<span className="font-mono font-black">
#{entry.rank}
</span>
<span className="truncate font-semibold">
{entry.nickname}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
</span>
</div>
))
) : (
<div className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
</div>
))}
)}
</div>
</div>
</div>

View File

@@ -5,8 +5,8 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { ApiClientError } from '../../services/apiClient';
@@ -2526,6 +2526,10 @@ test('agent draft result test button enters current draft without publish gate',
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({ name: '潮雾列岛' }),
expect.objectContaining({
mode: 'test',
returnStage: 'custom-world-result',
}),
);
});
expect(

View File

@@ -4,7 +4,7 @@ import { act, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import { WorldType, type CustomWorldProfile } from '../../types';
import { type CustomWorldProfile, WorldType } from '../../types';
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
function buildProfile(params: {
@@ -88,7 +88,11 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
stage: 'ready_to_publish',
focusCardId: null,
creatorIntent: null,
creatorIntentReadiness: { isReady: true, completedKeys: [], missingKeys: [] },
creatorIntentReadiness: {
isReady: true,
completedKeys: [],
missingKeys: [],
},
anchorPack: null,
lockState: null,
draftProfile: null,
@@ -110,15 +114,15 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
}
describe('useRpgCreationEnterWorld', () => {
it('Agent 草稿进入游戏时使用 session draft profile 的角色形象', async () => {
it('Agent 草稿测试进入游戏时使用结果页当前 profile 的角色形象', async () => {
const staleResultProfile = buildProfile({
id: 'stale-result',
name: '旧结果页快照',
imageSrc: '/template/old-role.png',
});
const draftProfile = buildProfile({
const resultProfile = buildProfile({
id: 'draft-profile',
name: '草稿真相源',
name: '结果页真相源',
imageSrc: '/generated-characters/draft-role/portrait.png',
});
const handleCustomWorldSelect = vi.fn();
@@ -130,7 +134,7 @@ describe('useRpgCreationEnterWorld', () => {
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
generatedCustomWorldProfile: staleResultProfile,
agentSessionProfile: draftProfile,
agentSessionProfile: resultProfile,
agentSession: buildSession(),
handleCustomWorldSelect,
executePublishWorld,
@@ -138,7 +142,10 @@ describe('useRpgCreationEnterWorld', () => {
});
return (
<button type="button" onClick={() => void enterWorldForTestFromCurrentResult()}>
<button
type="button"
onClick={() => void enterWorldForTestFromCurrentResult()}
>
</button>
);
@@ -150,9 +157,12 @@ describe('useRpgCreationEnterWorld', () => {
});
expect(executePublishWorld).not.toHaveBeenCalled();
expect(handleCustomWorldSelect).toHaveBeenCalledWith(draftProfile);
expect(handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-role/portrait.png',
);
expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, {
mode: 'test',
returnStage: 'custom-world-result',
});
expect(
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
).toBe('/generated-characters/draft-role/portrait.png');
});
});

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
@@ -10,14 +11,17 @@ type UseRpgCreationEnterWorldParams = {
generatedCustomWorldProfile: CustomWorldProfile | null;
agentSessionProfile: CustomWorldProfile | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleCustomWorldSelect: (
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => void;
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
};
/**
* 统一“进入世界”前的最终同步策略。
* Agent 草稿结果进入游戏时只读 session.draftProfile不再把结果页快照回写成新的运行时 profile。
* Agent 草稿结果进入游戏时只读当前结果页 profile不再静默回退到基础 draftProfile。
*/
export function useRpgCreationEnterWorld(
params: UseRpgCreationEnterWorldParams,
@@ -39,13 +43,22 @@ export function useRpgCreationEnterWorld(
}
if (!isAgentDraftResultView || !activeAgentSessionId) {
handleCustomWorldSelect(generatedCustomWorldProfile);
handleCustomWorldSelect(generatedCustomWorldProfile, {
mode: 'test',
returnStage: 'custom-world-result',
});
return;
}
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
handleCustomWorldSelect(latestProfile);
if (!agentSessionProfile) {
return;
}
setGeneratedCustomWorldProfile(agentSessionProfile);
handleCustomWorldSelect(agentSessionProfile, {
mode: 'test',
returnStage: 'custom-world-result',
});
}, [
activeAgentSessionId,
agentSessionProfile,
@@ -64,8 +77,11 @@ export function useRpgCreationEnterWorld(
return generatedCustomWorldProfile;
}
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
if (!agentSessionProfile) {
return null;
}
setGeneratedCustomWorldProfile(agentSessionProfile);
const latestSession = agentSession;
const canEnterPublishedWorld =
@@ -73,13 +89,13 @@ export function useRpgCreationEnterWorld(
latestSession.resultPreview?.canEnterWorld;
if (canEnterPublishedWorld) {
return latestProfile;
return agentSessionProfile;
}
const publishedSession = await executePublishWorld();
const publishedProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
latestProfile;
agentSessionProfile;
setGeneratedCustomWorldProfile(publishedProfile);
return publishedProfile;
@@ -89,7 +105,6 @@ export function useRpgCreationEnterWorld(
agentSessionProfile,
executePublishWorld,
generatedCustomWorldProfile,
handleCustomWorldSelect,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
]);

View File

@@ -219,7 +219,7 @@ export function useRpgCreationResultAutosave(
}
// Agent 结果页不再把前端 profile 回写到 session。
// session.draftProfile 是真相源;这里只刷新后端最新快照,避免在采集/生成早期误触 sync_result_profile。
// 这里只刷新后端结果页快照,避免在采集/生成早期误触 sync_result_profile。
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
const latestProfile = normalizeAgentBackedProfile(
buildDraftResultProfile(latestSession) ?? profile,

View File

@@ -52,6 +52,7 @@ function renderPanel(
isLoading?: boolean;
onSubmitNpcChatInput?: (input: string) => boolean;
onExitNpcChat?: () => boolean;
inBattle?: boolean;
} = {},
) {
return renderToStaticMarkup(
@@ -97,7 +98,7 @@ function renderPanel(
playerMana={20}
playerMaxMana={20}
playerSkillCooldowns={{}}
inBattle={false}
inBattle={overrides.inBattle ?? false}
currentNpcBattleMode={null}
statistics={{
playTimeMs: 0,
@@ -283,3 +284,53 @@ test('adventure panel renders narrative story text without italics and hides opt
expect(html).toContain('text-[15px]');
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
});
test('adventure panel hides narrative story section during battle', () => {
const option = createOption('battle_attack_basic', '挥剑压上');
const currentStory: StoryMoment = {
text: '敌人的刀光逼到眼前,这段剧情框在战斗中不应该占位。',
options: [option],
};
const html = renderPanel(currentStory, [option], {
inBattle: true,
});
expect(html).not.toContain('敌人的刀光逼到眼前');
expect(html).toContain('挥剑压上');
});
test('adventure panel limits battle choices before viewport measurement', () => {
const options = Array.from({ length: 6 }, (_, index) =>
createOption('battle_attack_basic', `战斗动作${index + 1}`),
);
const currentStory: StoryMoment = {
text: '战斗中剧情框不占底部空间。',
options,
};
const html = renderPanel(currentStory, options, {
inBattle: true,
});
expect(html).toContain('战斗动作1');
expect(html).toContain('战斗动作4');
expect(html).not.toContain('战斗动作5');
expect(html).not.toContain('战斗动作6');
});
test('adventure panel uses combat settlement loading copy during battle', () => {
const option = createOption('battle_attack_basic', '挥剑压上');
const currentStory: StoryMoment = {
text: '战斗中的加载态不该再提示剧情推演。',
options: [option],
};
const html = renderPanel(currentStory, [option], {
inBattle: true,
isLoading: true,
});
expect(html).toContain('战斗结算中...');
expect(html).not.toContain('剧情推演中...');
});

View File

@@ -53,6 +53,10 @@ import {
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import { PixelIcon } from '../PixelIcon';
const BATTLE_OPTION_ROW_MIN_HEIGHT = 58;
const BATTLE_OPTION_ROW_GAP = 6;
const DEFAULT_BATTLE_VISIBLE_OPTION_COUNT = 4;
export interface RpgAdventurePanelProps {
aiError: string | null;
currentStory: StoryMoment;
@@ -140,6 +144,57 @@ function getOptionActionTextClass(option: StoryOption) {
return 'text-zinc-300 group-hover:text-white';
}
function getBattleVisibleOptionCount(availableHeight: number, total: number) {
if (total <= 0) return 0;
if (!Number.isFinite(availableHeight) || availableHeight <= 0) {
return Math.min(total, DEFAULT_BATTLE_VISIBLE_OPTION_COUNT);
}
return Math.max(
1,
Math.min(
total,
Math.floor(
(availableHeight + BATTLE_OPTION_ROW_GAP) /
(BATTLE_OPTION_ROW_MIN_HEIGHT + BATTLE_OPTION_ROW_GAP),
),
),
);
}
function useMeasuredElementHeight<T extends HTMLElement>(enabled: boolean) {
const elementRef = useRef<T | null>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (!enabled) {
setHeight(0);
return;
}
const element = elementRef.current;
if (!element) return;
const updateHeight = () => {
setHeight(element.getBoundingClientRect().height);
};
updateHeight();
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(updateHeight);
observer.observe(element);
return () => observer.disconnect();
}
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}, [enabled]);
return [elementRef, height] as const;
}
function getOptionFunctionTagText(option: StoryOption) {
const tagByFunctionId: Record<string, string> = {
battle_all_in_crush: '战斗',
@@ -692,11 +747,14 @@ function RpgAdventureStorySection(props: {
isStoryStreaming,
currentStory,
} = props;
const storyPanelClassName = isNpcChatMode
? 'flex-[1.18] sm:min-h-[15rem]'
: 'flex-1 sm:min-h-[14rem]';
return (
<div
ref={storyScrollContainerRef}
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
className={`pixel-nine-slice pixel-panel mb-3 min-h-0 overflow-y-auto pr-1 scrollbar-hide ${storyPanelClassName}`}
style={getNineSliceStyle(UI_CHROME.storyPanel)}
>
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
@@ -787,6 +845,7 @@ function RpgAdventureChoiceSection(props: {
setNpcChatDraft: (value: string) => void;
npcChatPlaceholder: string;
submitNpcChatDraft: () => void;
inBattle: boolean;
}) {
const {
isNpcChatMode,
@@ -813,11 +872,29 @@ function RpgAdventureChoiceSection(props: {
setNpcChatDraft,
npcChatPlaceholder,
submitNpcChatDraft,
inBattle,
} = props;
const [battleChoiceViewportRef, battleChoiceViewportHeight] =
useMeasuredElementHeight<HTMLDivElement>(
inBattle && !isNpcChatMode && !shouldHideChoiceUi,
);
const visibleDisplayedOptions =
inBattle && !isNpcChatMode && !shouldHideChoiceUi
? displayedOptions.slice(
0,
getBattleVisibleOptionCount(
battleChoiceViewportHeight,
displayedOptions.length,
),
)
: displayedOptions;
return (
<div className="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
<div
className={`mt-auto min-h-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.1rem)] pt-1.5 ${inBattle ? 'flex flex-1 flex-col' : 'shrink-0'}`}
>
{/* 让底部操作区整体更贴近屏幕底部,同时把上方聊天区腾出更稳定的展示高度。 */}
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
@@ -880,12 +957,15 @@ function RpgAdventureChoiceSection(props: {
</div>
</div>
<div className="space-y-1.5">
<div
ref={battleChoiceViewportRef}
className={`space-y-1.5 ${inBattle ? 'min-h-0 flex-1 overflow-hidden' : ''}`}
>
{isLoading && !isStoryStreaming ? (
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs uppercase tracking-widest">
...
{inBattle ? '战斗结算中...' : '剧情推演中...'}
</span>
</div>
) : isStoryStreaming ? (
@@ -896,7 +976,7 @@ function RpgAdventureChoiceSection(props: {
<div className="p-4" aria-hidden="true" />
) : (
<>
{displayedOptions.map((option, index) => {
{visibleDisplayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary(
option,
playerCharacter,
@@ -970,7 +1050,7 @@ function RpgAdventureChoiceSection(props: {
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 px-1.5 pb-1.5 pt-1">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
@@ -985,7 +1065,7 @@ function RpgAdventureChoiceSection(props: {
}
}}
placeholder={npcChatPlaceholder}
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
@@ -993,7 +1073,7 @@ function RpgAdventureChoiceSection(props: {
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
className="inline-flex h-10 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button>
@@ -1161,6 +1241,7 @@ export function RpgAdventurePanel({
playerMana,
playerMaxMana,
playerSkillCooldowns,
inBattle,
currentNpcBattleMode,
statistics,
musicVolume,
@@ -1550,18 +1631,20 @@ export function RpgAdventurePanel({
</div>
)}
<RpgAdventureStorySection
currentSceneActTitle={currentSceneActTitle}
currentSceneActIndex={currentSceneActIndex}
currentSceneActCount={currentSceneActCount}
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
storyScrollContainerRef={storyScrollContainerRef}
isDialogueStory={isDialogueStory}
dialogueTurns={dialogueTurns}
isNpcChatMode={isNpcChatMode}
isStoryStreaming={isStoryStreaming}
currentStory={currentStory}
/>
{!inBattle ? (
<RpgAdventureStorySection
currentSceneActTitle={currentSceneActTitle}
currentSceneActIndex={currentSceneActIndex}
currentSceneActCount={currentSceneActCount}
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
storyScrollContainerRef={storyScrollContainerRef}
isDialogueStory={isDialogueStory}
dialogueTurns={dialogueTurns}
isNpcChatMode={isNpcChatMode}
isStoryStreaming={isStoryStreaming}
currentStory={currentStory}
/>
) : null}
<RpgAdventureChoiceSection
isNpcChatMode={isNpcChatMode}
@@ -1590,6 +1673,7 @@ export function RpgAdventurePanel({
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
}
submitNpcChatDraft={submitNpcChatDraft}
inBattle={inBattle}
/>
<RpgAdventureOverlaySection

View 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();
});

View File

@@ -37,6 +37,7 @@ export function RpgRuntimeShell({
companions,
audio,
chrome,
onExitTestRuntime,
}: RpgRuntimeShellComponentProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
@@ -132,6 +133,7 @@ export function RpgRuntimeShell({
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
),
);
const isTestRuntime = gameState.runtimeMode === 'test';
useEffect(() => {
if (gameState.worldType && !gameState.playerCharacter) {
@@ -207,6 +209,23 @@ export function RpgRuntimeShell({
</div>
)}
{gameState.worldType && isTestRuntime && onExitTestRuntime ? (
<div
className="fixed inset-x-0 z-[170] flex justify-center px-4"
style={{
top: 'calc(36vh - 3.25rem)',
}}
>
<button
type="button"
onClick={onExitTestRuntime}
className="inline-flex min-h-[2.75rem] items-center justify-center rounded-full border border-white/15 bg-black/65 px-5 text-sm font-semibold text-white shadow-[0_12px_30px_rgba(0,0,0,0.38)] backdrop-blur-sm transition hover:border-white/28 hover:bg-black/78"
>
</button>
</div>
) : null}
<RpgRuntimeStageRouter
gameState={gameState}
visibleGameState={visibleGameState}

View File

@@ -17,6 +17,7 @@ import type {
StoryMoment,
StoryOption,
} from '../../types';
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
export interface RpgRuntimeSessionProps {
gameState: GameState;
@@ -53,7 +54,10 @@ export interface RpgEntrySessionProps {
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleCustomWorldSelect: (
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: Character) => void;
}
@@ -107,4 +111,5 @@ export interface RpgRuntimeShellProps {
companions: RpgRuntimeCompanionProps;
audio: RpgRuntimeAudioProps;
chrome?: RpgRuntimeShellChromeOptions;
onExitTestRuntime?: () => void;
}

View File

@@ -73,6 +73,51 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
});
it('把幕配置里的角色名归一到真实角色 id', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
playableNpcs: [
{
id: 'playable-cendeng',
name: '岑灯',
title: '返乡守灯人',
role: '主角代理',
},
],
storyNpcs: [
{
id: 'story-luheng',
name: '陆衡',
title: '航运公会审计员',
role: '第一幕主NPC',
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'custom-scene-camp',
title: '开局章节',
acts: [
{
id: 'act-1',
title: '第一幕',
summary: '陆衡先拦住玩家。',
encounterNpcIds: ['陆衡'],
primaryNpcId: '航运公会审计员',
oppositeNpcId: '陆衡',
},
],
},
],
});
const act = profile?.sceneChapterBlueprints?.[0]?.acts[0];
expect(act?.encounterNpcIds).toEqual(['story-luheng']);
expect(act?.primaryNpcId).toBe('story-luheng');
expect(act?.oppositeNpcId).toBe('story-luheng');
});
it('直接读取 Rust 草稿角色字段和形象资源', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
@@ -121,4 +166,3 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
);
});
});

View File

@@ -13,6 +13,7 @@ import {
normalizeCustomWorldLockState,
} from '../services/customWorldCreatorIntent';
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
import {
AnimationState,
CharacterAnimationConfig,
@@ -971,18 +972,30 @@ function normalizeSceneActBlueprint(
value: unknown,
index: number,
sceneId: string,
profileRoles?: {
playableNpcs: CustomWorldPlayableNpc[];
storyNpcs: CustomWorldNpc[];
} | null,
): SceneActBlueprint | null {
if (!isRecord(value)) {
return null;
}
const encounterNpcIds = toStringArray(value.encounterNpcIds);
const encounterNpcIds = toStringArray(value.encounterNpcIds).map((npcId) =>
resolveCustomWorldRoleIdReference(profileRoles, npcId),
);
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
const advanceRule = toText(value.advanceRule);
const title = toText(value.title);
const summary = toText(value.summary);
const primaryNpcId = toText(value.primaryNpcId, encounterNpcIds[0] ?? '');
const oppositeNpcId = toText(value.oppositeNpcId, primaryNpcId);
const primaryNpcId = resolveCustomWorldRoleIdReference(
profileRoles,
toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
);
const oppositeNpcId = resolveCustomWorldRoleIdReference(
profileRoles,
toText(value.oppositeNpcId, primaryNpcId),
);
if (!title && !summary && encounterNpcIds.length === 0) {
return null;
@@ -1020,7 +1033,13 @@ function normalizeSceneActBlueprint(
};
}
function normalizeSceneChapterBlueprints(value: unknown) {
function normalizeSceneChapterBlueprints(
value: unknown,
profileRoles?: {
playableNpcs: CustomWorldPlayableNpc[];
storyNpcs: CustomWorldNpc[];
} | null,
) {
if (!Array.isArray(value)) {
return null;
}
@@ -1036,7 +1055,12 @@ function normalizeSceneChapterBlueprints(value: unknown) {
const acts = Array.isArray(entry.acts)
? entry.acts
.map((act, actIndex) =>
normalizeSceneActBlueprint(act, actIndex, sceneId),
normalizeSceneActBlueprint(
act,
actIndex,
sceneId,
profileRoles,
),
)
.filter((act): act is SceneActBlueprint => Boolean(act))
: [];
@@ -1126,6 +1150,11 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
: [];
const playableNpcs = Array.isArray(value.playableNpcs)
? value.playableNpcs
.map((entry, index) => normalizePlayableNpc(entry, index))
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
: [];
const normalizedProfile = {
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
settingText,
@@ -1144,11 +1173,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
value.attributeSchema,
generatedAttributeSchema,
),
playableNpcs: Array.isArray(value.playableNpcs)
? value.playableNpcs
.map((entry, index) => normalizePlayableNpc(entry, index))
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
: [],
playableNpcs,
storyNpcs,
items: Array.isArray(value.items)
? value.items
@@ -1168,6 +1193,10 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
value.sceneChapterBlueprints,
{
playableNpcs,
storyNpcs,
},
),
anchorContent: preserveStructuredRecord<EightAnchorContent>(
value.anchorContent,

View File

@@ -1,3 +1,4 @@
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
import {
canUseLimitedPrimaryNpcChat,
resolveActiveSceneActEncounterFocusNpcId,
@@ -145,9 +146,16 @@ function getAvailableActiveSceneActNpcs(state: GameState) {
return (state.currentScenePreset?.npcs ?? [])
.filter(candidate => {
const candidateIds = [candidate.id, candidate.characterId].filter(
(value): value is string => Boolean(value),
);
const candidateIds = [
candidate.id,
candidate.characterId,
candidate.name,
candidate.title,
]
.map((value) =>
resolveCustomWorldRoleIdReference(state.customWorldProfile, value),
)
.filter(Boolean);
return candidateIds.some(id => activeActNpcIdSet.has(id));
})
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
@@ -180,8 +188,19 @@ function pickFriendlySceneNpcForActiveAct(state: GameState, npcs: SceneNpc[]) {
return (
npcs.find(
(npc) =>
npc.id === focusNpcId ||
(npc.characterId ? npc.characterId === focusNpcId : false),
resolveCustomWorldRoleIdReference(state.customWorldProfile, npc.id) === focusNpcId ||
resolveCustomWorldRoleIdReference(
state.customWorldProfile,
npc.characterId,
) === focusNpcId ||
resolveCustomWorldRoleIdReference(
state.customWorldProfile,
npc.name,
) === focusNpcId ||
resolveCustomWorldRoleIdReference(
state.customWorldProfile,
npc.title,
) === focusNpcId,
) ?? pickRandomItem(npcs)
);
}

View File

@@ -1,5 +1,6 @@
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
import { resolveCustomWorldRoleIdReferences } from '../services/customWorldRoleReferences';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -406,9 +407,11 @@ function collectSceneActNpcIdsForScene(
}
chapter.acts.forEach((act) => {
pushNpcId(act.primaryNpcId);
pushNpcId(act.oppositeNpcId);
act.encounterNpcIds.forEach(pushNpcId);
resolveCustomWorldRoleIdReferences(profile, [
act.primaryNpcId,
act.oppositeNpcId,
...act.encounterNpcIds,
]).forEach(pushNpcId);
});
});

View File

@@ -229,7 +229,92 @@ describe('buildBattlePlan', () => {
);
});
it('does not turn recovery fallback into a random player attack', () => {
it('keeps battle_attack_basic as a single basic attack instead of randomly selecting another skill', () => {
const state = {
...createBaseState(),
playerMana: 20,
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 80,
maxHp: 80,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_attack_basic',
},
character: createTestCharacter(),
totalSequenceMs: 900,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 1,
});
const playerTurns = plan.turns.filter((turn) => turn.actor === 'player');
expect(playerTurns).toHaveLength(1);
expect(playerTurns[0]).toEqual(
expect.objectContaining({
selectedSkillId: 'battle-basic-attack',
}),
);
expect(plan.finalState.playerMana).toBe(state.playerMana);
});
it('resolves one full speed-ordered round when combat continues', () => {
const state = {
...createBaseState(),
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 120,
maxHp: 120,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_attack_basic',
},
character: createTestCharacter(),
totalSequenceMs: 6000,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 6,
});
expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']);
expect(plan.finalState.inBattle).toBe(true);
expect(plan.finalState.sceneHostileNpcs[0]?.hp).toBeGreaterThan(0);
});
it('keeps recovery as a player turn without converting it into an attack', () => {
const state = {
...createBaseState(),
playerHp: 40,
@@ -265,8 +350,77 @@ describe('buildBattlePlan', () => {
minTurnCount: 1,
});
expect(plan.turns.some((turn) => turn.actor === 'player')).toBe(false);
expect(plan.preparedState.playerHp).toBeGreaterThan(state.playerHp);
expect(plan.preparedState.playerMana).toBeGreaterThan(state.playerMana);
const playerTurn = plan.turns.find((turn) => turn.actor === 'player');
expect(playerTurn).toEqual(
expect.objectContaining({
actor: 'player',
actionKind: 'recover',
selectedSkillId: null,
damage: 0,
}),
);
expect(plan.finalState.playerHp).toBeGreaterThan(state.playerHp);
expect(plan.finalState.playerMana).toBeGreaterThan(state.playerMana);
});
it('includes companion turns in fight mode and orders the round by speed', () => {
const state = {
...createBaseState(),
currentNpcBattleMode: 'fight' as const,
companions: [
{
npcId: 'companion-1',
characterId: 'archer-hero',
joinedAtAffinity: 10,
hp: 60,
maxHp: 60,
mana: 20,
maxMana: 20,
skillCooldowns: {},
},
],
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 0.5,
hp: 120,
maxHp: 120,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_attack_basic',
},
character: createTestCharacter(),
totalSequenceMs: 6000,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 6,
});
expect(plan.turns.map((turn) => turn.actor)).toEqual([
'companion',
'player',
'monster',
]);
expect(plan.turns[0]).toEqual(
expect.objectContaining({
actor: 'companion',
companionNpcId: 'companion-1',
}),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -185,4 +185,62 @@ describe('escapeFlow', () => {
expect(result.scrollWorld).toBe(false);
expect(result.playerFacing).toBe('right');
});
it('plays left exit and right-facing entry when escape targets a scene start', async () => {
const state = {
...createState(),
currentScenePreset: {
id: 'scene-bridge',
name: 'Bridge',
description: 'Bridge',
imageSrc: '/bridge.png',
worldType: WorldType.WUXIA,
connectedSceneIds: [],
connections: [],
npcs: [],
treasureHints: [],
},
};
const targetScene = {
...state.currentScenePreset!,
id: 'scene-east',
name: 'East Street',
};
const option = {
...createEscapeOption(),
runtimePayload: {
escapeTargetSceneId: targetScene.id,
escapeEntry: 'from_left',
},
};
const finalState = buildEscapeAfterSequence(state, option, targetScene);
const committedStates: GameState[] = [];
const result = await playEscapeSequenceWithStorySync({
setGameState: (nextState: GameState) => {
committedStates.push(nextState);
},
state,
option,
finalState,
sleepMs: async () => {
await Promise.resolve();
},
});
expect(committedStates[0]).toEqual(expect.objectContaining({
playerFacing: 'left',
animationState: AnimationState.RUN,
scrollWorld: true,
}));
expect(committedStates.some((committedState) =>
committedState.currentScenePreset?.id === 'scene-east' &&
committedState.playerX < 0 &&
committedState.playerFacing === 'right',
)).toBe(true);
expect(result.currentScenePreset?.id).toBe('scene-east');
expect(result.playerX).toBe(0);
expect(result.playerFacing).toBe('right');
expect(result.scrollWorld).toBe(false);
});
});

View File

@@ -1,9 +1,19 @@
import type { Dispatch, SetStateAction } from 'react';
import {
buildEncounterEntryState,
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import {
getFacingTowardPlayer,
settleHostileNpcAnimations,
} from '../../data/hostileNpcs';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { getFunctionEffect } from '../../data/stateFunctions';
import {
AnimationState,
@@ -15,6 +25,9 @@ import {
const ESCAPE_RUN_MS = 5000;
const ESCAPE_TICK_MS = 250;
const ESCAPE_TURN_PAUSE_MS = 180;
const ESCAPE_ENTRY_MS = 900;
const ESCAPE_ENTRY_TICK_MS = 90;
const ESCAPE_PLAYER_ENTRY_X = -1.4;
export type EscapePlaybackSync = {
waitForStoryResponse?: Promise<void>;
@@ -22,7 +35,7 @@ export type EscapePlaybackSync = {
type SetGameStateFn = Dispatch<SetStateAction<GameState>> | ((state: GameState) => void);
function sleep(ms: number) {
function sleep(ms: number): Promise<void> {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
@@ -35,6 +48,10 @@ function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
}));
}
function lerpMeters(start: number, end: number, progress: number) {
return Number((start + ((end - start) * progress)).toFixed(2));
}
export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
const escapeDistance = getFunctionEffect(option.functionId).escapeDistance ?? 5;
const settleOffset = Math.max(1, Math.min(1.4, escapeDistance * 0.24));
@@ -44,18 +61,22 @@ export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
export function buildEscapeAfterSequence(
state: GameState,
option: StoryOption,
nextScenePreset: GameState['currentScenePreset'] = state.currentScenePreset,
) {
const escapePlayerX = getEscapeSettlePlayerX(state, option);
return {
const shouldResetToSceneStart =
nextScenePreset?.id !== state.currentScenePreset?.id ||
option.runtimePayload?.escapeReturnToSceneStart === true;
const baseState = {
...state,
currentScenePreset: nextScenePreset ?? state.currentScenePreset,
currentEncounter: null,
npcInteractionActive: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sceneHostileNpcs: [],
playerX: escapePlayerX,
playerX: shouldResetToSceneStart ? 0 : escapePlayerX,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
@@ -66,6 +87,66 @@ export function buildEscapeAfterSequence(
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
} satisfies GameState;
const previewState = shouldResetToSceneStart
? ({
...baseState,
...createSceneEncounterPreview(baseState),
} satisfies GameState)
: baseState;
return hasEncounterEntity(previewState)
? resolveSceneEncounterPreview(previewState)
: baseState;
}
async function playEscapeEntrySequence(params: {
setGameState: SetGameStateFn;
finalState: GameState;
sleepMs: (ms: number) => Promise<void>;
}) {
const entryState = buildEncounterEntryState(
{
...params.finalState,
playerX: ESCAPE_PLAYER_ENTRY_X,
playerFacing: 'right',
animationState: AnimationState.RUN,
playerActionMode: 'idle',
scrollWorld: true,
},
CALL_OUT_ENTRY_X_METERS,
);
const runTicks = Math.max(1, Math.ceil(ESCAPE_ENTRY_MS / ESCAPE_ENTRY_TICK_MS));
const tickDurationMs = Math.max(1, Math.round(ESCAPE_ENTRY_MS / runTicks));
let currentState = entryState;
params.setGameState(currentState);
for (let tick = 1; tick <= runTicks; tick += 1) {
const progress = tick / runTicks;
const interpolatedState = interpolateEncounterTransitionState(
entryState,
params.finalState,
progress,
);
currentState = {
...interpolatedState,
playerX: lerpMeters(
ESCAPE_PLAYER_ENTRY_X,
params.finalState.playerX,
progress,
),
playerFacing: 'right',
animationState:
progress < 1 ? AnimationState.RUN : params.finalState.animationState,
playerActionMode: 'idle',
scrollWorld: progress < 1,
};
params.setGameState(currentState);
await params.sleepMs(tickDurationMs);
}
params.setGameState(params.finalState);
return params.finalState;
}
export async function playEscapeSequenceWithStorySync(params: {
@@ -93,6 +174,9 @@ export async function playEscapeSequenceWithStorySync(params: {
const settlePlayerX = finalState.playerX;
let storyResponseReady = !sync?.waitForStoryResponse;
let elapsedMs = 0;
const shouldPlayEntry =
finalState.currentScenePreset?.id !== state.currentScenePreset?.id ||
option.runtimePayload?.escapeReturnToSceneStart === true;
void sync?.waitForStoryResponse?.then(() => {
storyResponseReady = true;
@@ -127,19 +211,39 @@ export async function playEscapeSequenceWithStorySync(params: {
await sleepMs(ESCAPE_TICK_MS);
}
currentState = {
...finalState,
playerX: settlePlayerX,
playerFacing: 'left',
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
};
const settledExitState: GameState = shouldPlayEntry
? {
...finalState,
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, 0),
}
: {
...finalState,
playerX: settlePlayerX,
playerFacing: 'left' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
};
currentState = settledExitState;
setGameState(currentState);
await sleepMs(ESCAPE_TURN_PAUSE_MS);
if (shouldPlayEntry) {
return playEscapeEntrySequence({
setGameState,
finalState: settledExitState,
sleepMs,
});
}
currentState = {
...currentState,
playerFacing: 'right',

View File

@@ -52,6 +52,20 @@ function sleep(ms: number) {
}
function getSkillById(character: Character, skillId: string) {
if (skillId === 'battle-basic-attack') {
return {
id: 'battle-basic-attack',
name: '普通攻击',
animation: AnimationState.ATTACK,
damage: 0,
manaCost: 0,
cooldownTurns: 0,
range: 1,
style: 'steady',
delivery: 'melee',
} satisfies CharacterSkillDefinition;
}
return character.skills.find(skill => skill.id === skillId) ?? null;
}
@@ -192,6 +206,47 @@ async function playBattleSequence(params: CombatPlaybackParams & {
};
setGameState(currentState);
if (step.actionKind === 'recover') {
currentState = {
...currentState,
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
playerHp: step.playerHpAfterAction,
playerMana: step.playerManaAfterAction,
playerSkillCooldowns: step.appliedCooldowns,
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(resetStageMs);
continue;
}
if (step.actionKind === 'inventory') {
currentState = {
...currentState,
animationState: AnimationState.ACQUIRE,
playerActionMode: 'idle',
playerHp: step.playerHpAfterAction,
playerMana: step.playerManaAfterAction,
playerSkillCooldowns: step.appliedCooldowns,
playerInventory:
step.playerInventoryAfterAction ?? currentState.playerInventory,
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(Math.max(180, resetStageMs));
currentState = {
...currentState,
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(resetStageMs);
continue;
}
const skill = step.selectedSkillId ? getSkillById(character, step.selectedSkillId) : null;
if (!skill) {
await sleep(resetStageMs);
@@ -353,7 +408,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
};
setGameState(currentState);
if (step.delivery === 'melee' && step.strikeOffsetX > 0) {
if (step.delivery === 'melee' && Math.abs(step.strikeOffsetX) > 0.01) {
currentState = {
...currentState,
companions: updateCompanionState(
@@ -740,4 +795,3 @@ export function createCombatPlayback(params: CombatPlaybackParams) {
playResolvedChoice,
};
}

View File

@@ -240,6 +240,50 @@ describe('buildResolvedChoiceState', () => {
expect(resolved.afterSequence.playerFacing).toBe('right');
});
it('moves escape result to explicit target scene and resets player to scene start', () => {
const state = {
...createBaseState(),
currentScenePreset: scenes[0] as GameState['currentScenePreset'],
sceneHostileNpcs: [
{
id: 'monster-1',
name: 'Wolf',
action: 'growls',
description: 'A wolf',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const option = {
...createOption('battle_escape_breakout'),
runtimePayload: {
escapeTargetSceneId: 'scene-3',
escapeEntry: 'from_left',
},
};
const resolved = buildResolvedChoiceState({
state,
option,
character: createTestCharacter(),
buildBattlePlan: vi.fn(),
});
expect(resolved.optionKind).toBe('escape');
expect(resolved.afterSequence.currentScenePreset?.id).toBe('scene-3');
expect(resolved.afterSequence.playerX).toBe(0);
expect(resolved.afterSequence.playerFacing).toBe('right');
expect(resolved.afterSequence.inBattle).toBe(false);
});
it('keeps idle follow-up generation separate from combat planning', () => {
const state = {
...createBaseState(),

View File

@@ -58,15 +58,17 @@ function getSceneTargetForFunction(
): GameState['currentScenePreset'] {
if (!worldType) return currentScenePreset;
if (option.functionId === 'idle_travel_next_scene') {
const targetSceneId =
typeof option.runtimePayload?.targetSceneId === 'string'
? option.runtimePayload.targetSceneId
const targetSceneId =
typeof option.runtimePayload?.targetSceneId === 'string'
? option.runtimePayload.targetSceneId
: typeof option.runtimePayload?.escapeTargetSceneId === 'string'
? option.runtimePayload.escapeTargetSceneId
: null;
if (targetSceneId) {
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
}
if (targetSceneId) {
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
}
if (option.functionId === 'idle_travel_next_scene') {
return getTravelScenePreset(worldType, currentScenePreset?.id) ?? currentScenePreset;
}
@@ -114,7 +116,7 @@ export function buildResolvedChoiceState(params: {
return {
optionKind,
battlePlan: null,
afterSequence: buildEscapeAfterSequence(state, option),
afterSequence: buildEscapeAfterSequence(state, option, nextScenePreset),
} satisfies ResolvedChoiceState;
}

View File

@@ -738,4 +738,204 @@ describe('createStoryChoiceActions', () => {
expect.objectContaining({ hostileNpcsDefeated: 0 }),
);
});
it('keeps battle attack and skill choices on the local combat path even if runtime server supports them', async () => {
const state = {
...createBaseState(),
sceneHostileNpcs: [
{
id: 'wolf-1',
name: '山狼',
action: '低伏逼近',
description: '一头山狼',
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.4,
speed: 7,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const option: StoryOption = {
...createBattleOption('battle_use_skill'),
runtimePayload: {
skillId: 'skill-basic',
},
};
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const buildResolvedChoiceState = vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence: state,
}));
const playResolvedChoice = vi.fn().mockResolvedValue(state);
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: true,
playerX: 0,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => [option]),
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
getResolvedSceneHostileNpcs: vi.fn(
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
state,
option,
state.playerCharacter!,
);
expect(playResolvedChoice).toHaveBeenCalled();
expect(setGameState).toHaveBeenCalled();
expect(setCurrentStory).toHaveBeenCalled();
});
it('keeps stale battle panel choices on the local combat path when combat presentation is still visible', async () => {
const battleOption = createBattleOption('battle_attack_basic');
const state = {
...createBaseState(),
inBattle: false,
sceneHostileNpcs: [
{
id: 'wolf-1',
name: '山狼',
action: '低伏逼近',
description: '一头山狼',
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.4,
speed: 7,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const currentStory: StoryMoment = {
text: '山狼还在你面前压低身位,战斗并未真正结束。',
options: [battleOption],
};
const buildResolvedChoiceState = vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence: {
...state,
inBattle: true,
},
}));
const playResolvedChoice = vi.fn().mockResolvedValue({
...state,
inBattle: true,
});
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory,
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: true,
playerX: 0,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => [battleOption]),
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
getResolvedSceneHostileNpcs: vi.fn(
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(battleOption);
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
state,
battleOption,
state.playerCharacter!,
);
expect(playResolvedChoice).toHaveBeenCalled();
});
});

View File

@@ -77,6 +77,39 @@ type IncrementRuntimeStats = (
increments: RuntimeStatsIncrements,
) => GameState;
function isImmediateCombatChoice(option: StoryOption) {
return (
option.functionId.startsWith('battle_') ||
option.functionId === 'inventory_use'
);
}
function shouldResolveCombatChoiceLocally(
gameState: GameState,
currentStory: StoryMoment | null,
option: StoryOption,
) {
if (!isImmediateCombatChoice(option)) {
return false;
}
if (gameState.inBattle) {
return true;
}
const hasBattleMarkers =
Boolean(gameState.currentBattleNpcId || gameState.currentNpcBattleMode) ||
gameState.sceneHostileNpcs.some((hostileNpc) => hostileNpc.hp > 0);
const storyStillShowsBattleChoices = Boolean(
currentStory?.options.some(isImmediateCombatChoice),
);
// 中文注释:真实运行态里可能短暂出现“可见层仍在战斗,但逻辑态 inBattle
// 已经被提前切回 false”的窗口。如果这时玩家点击了还在面板上的 battle_* /
// inventory_use 选项,必须继续走本地逐帧战斗链,不能误分流到服务端直结算。
return hasBattleMarkers || storyStillShowsBattleChoices;
}
export function createStoryChoiceActions({
gameState,
currentStory,
@@ -219,7 +252,10 @@ export function createStoryChoiceActions({
return;
}
if (isRpgRuntimeServerFunctionId(option.functionId)) {
if (
isRpgRuntimeServerFunctionId(option.functionId) &&
!shouldResolveCombatChoiceLocally(gameState, currentStory, option)
) {
await runServerRuntimeChoiceAction({
gameState,
currentStory,

View File

@@ -1284,7 +1284,7 @@ describe('npcEncounterActions', () => {
]);
});
it('lets player exit hostile chat and offers fight or escape instead of continuing adventure', async () => {
it('lets player exit hostile chat and offers fight plus scene escape routes instead of continuing adventure', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
@@ -1320,19 +1320,43 @@ describe('npcEncounterActions', () => {
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect(lastStory.options[0]).toEqual(expect.objectContaining({
functionId: 'npc_fight',
actionText: '战斗',
interaction: expect.objectContaining({
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
}));
expect(lastStory.options.slice(1)).toEqual([
expect.objectContaining({
functionId: 'npc_fight',
actionText: '战斗',
interaction: expect.objectContaining({
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
functionId: 'battle_escape_breakout',
actionText: '逃往东侧旧街还亮着灯。',
runtimePayload: expect.objectContaining({
targetSceneId: 'scene-east',
escapeTargetSceneId: 'scene-east',
escapeEntry: 'from_left',
}),
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃',
actionText: '逃往南侧河滩雾气更重。',
runtimePayload: expect.objectContaining({
targetSceneId: 'scene-south',
escapeTargetSceneId: 'scene-south',
escapeEntry: 'from_left',
}),
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃回当前场景起点',
runtimePayload: expect.objectContaining({
targetSceneId: 'scene-bridge',
escapeTargetSceneId: 'scene-bridge',
escapeReturnToSceneStart: true,
escapeEntry: 'from_left',
}),
}),
]);
expect(lastStory.options).not.toEqual(
@@ -1417,7 +1441,15 @@ describe('npcEncounterActions', () => {
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃',
actionText: '逃往东侧旧街还亮着灯。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往南侧河滩雾气更重。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃回当前场景起点',
}),
]);
expect(lastStory.deferredOptions).toBeUndefined();
@@ -1469,6 +1501,15 @@ describe('npcEncounterActions', () => {
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往东侧旧街还亮着灯。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往南侧河滩雾气更重。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃回当前场景起点',
}),
]);
expect(lastStory.deferredOptions).toBeUndefined();
@@ -1523,6 +1564,15 @@ describe('npcEncounterActions', () => {
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往东侧旧街还亮着灯。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往南侧河滩雾气更重。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃回当前场景起点',
}),
]);
expect(lastStory.deferredRuntimeState).toBeUndefined();

View File

@@ -1,16 +1,18 @@
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import { getForwardScenePreset } from '../../data/scenePresets';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStoryState,
resolveRpgRuntimeStoryAction,
type RuntimeStorySnapshotRequest,
resolveRpgRuntimeStoryMoment,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
type RuntimeStorySnapshotRequest,
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import type { GameState, StoryMoment, StoryOption } from '../../types';
import { buildMapTravelResolution } from './storyGenerationState';
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
return response.viewModel.availableOptions.length > 0
@@ -29,6 +31,95 @@ function buildRuntimeSnapshotRequest(
};
}
function resolveServerTravelTargetSceneId(params: {
previousState: GameState;
snapshotState: GameState;
}) {
const { previousState, snapshotState } = params;
const snapshotSceneId = snapshotState.currentScenePreset?.id ?? null;
if (
snapshotSceneId &&
snapshotSceneId !== previousState.currentScenePreset?.id
) {
return snapshotSceneId;
}
if (!previousState.worldType) {
return null;
}
return (
getForwardScenePreset(
previousState.worldType,
previousState.currentScenePreset?.id,
)?.id ??
previousState.currentScenePreset?.forwardSceneId ??
null
);
}
function bridgeServerSceneTravelSnapshot(params: {
previousState: GameState;
hydratedSnapshot: HydratedSavedGameSnapshot;
functionId: string;
}) {
const { previousState, hydratedSnapshot, functionId } = params;
if (functionId !== 'idle_travel_next_scene' || !previousState.worldType) {
return hydratedSnapshot;
}
const targetSceneId = resolveServerTravelTargetSceneId({
previousState,
snapshotState: hydratedSnapshot.gameState,
});
if (!targetSceneId) {
return hydratedSnapshot;
}
const travelResolution = buildMapTravelResolution(previousState, targetSceneId);
if (!travelResolution) {
return hydratedSnapshot;
}
return {
...hydratedSnapshot,
gameState: {
...hydratedSnapshot.gameState,
// 中文注释:服务端 compat 当前只保证“本轮旅行动作已经结算完成”,
// 前端这里复用既有地图旅行真相,补齐下一幕场景 preset、遭遇预览和任务推进结果。
currentScenePreset: travelResolution.nextState.currentScenePreset,
currentEncounter: travelResolution.nextState.currentEncounter,
npcInteractionActive: travelResolution.nextState.npcInteractionActive,
sceneHostileNpcs: travelResolution.nextState.sceneHostileNpcs,
playerX: travelResolution.nextState.playerX,
playerFacing: travelResolution.nextState.playerFacing,
animationState: travelResolution.nextState.animationState,
playerActionMode: travelResolution.nextState.playerActionMode,
activeCombatEffects: travelResolution.nextState.activeCombatEffects,
scrollWorld: travelResolution.nextState.scrollWorld,
inBattle: travelResolution.nextState.inBattle,
lastObserveSignsSceneId: travelResolution.nextState.lastObserveSignsSceneId,
lastObserveSignsReport: travelResolution.nextState.lastObserveSignsReport,
currentBattleNpcId: travelResolution.nextState.currentBattleNpcId,
currentNpcBattleMode: travelResolution.nextState.currentNpcBattleMode,
currentNpcBattleOutcome: travelResolution.nextState.currentNpcBattleOutcome,
sparReturnEncounter: travelResolution.nextState.sparReturnEncounter,
sparPlayerHpBefore: travelResolution.nextState.sparPlayerHpBefore,
sparPlayerMaxHpBefore: travelResolution.nextState.sparPlayerMaxHpBefore,
sparStoryHistoryBefore: travelResolution.nextState.sparStoryHistoryBefore,
runtimeStats: {
...hydratedSnapshot.gameState.runtimeStats,
scenesTraveled:
travelResolution.nextState.runtimeStats.scenesTraveled,
},
quests:
hydratedSnapshot.gameState.quests.length > 0
? hydratedSnapshot.gameState.quests
: travelResolution.nextState.quests,
},
} satisfies HydratedSavedGameSnapshot;
}
/**
* 前端访问服务端 runtime story 的统一网关。
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
@@ -111,7 +202,11 @@ export async function resolveServerRuntimeChoice(params: {
payload: params.payload,
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
});
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
previousState: params.gameState,
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
functionId: params.option.functionId,
});
return {
response,

View File

@@ -56,6 +56,106 @@ function createGameState(): GameState {
} as GameState;
}
function createTravelGameState(): GameState {
return {
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 7,
worldType: WorldType.WUXIA,
currentScene: 'Story',
playerCharacter: {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '站在桥口的人。',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
personality: '谨慎',
attributes: {
strength: 8,
agility: 8,
intelligence: 6,
spirit: 6,
},
skills: [],
adventureOpenings: {},
} as GameState['playerCharacter'],
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
storyHistory: [],
characterChats: {},
animationState: 'idle',
currentEncounter: {
kind: 'npc',
id: 'encounter-current',
npcName: '桥头行商',
npcDescription: '正准备收摊离开的行商',
context: '桥口',
hostile: false,
},
npcInteractionActive: true,
currentScenePreset: {
id: 'wuxia-bamboo-road',
name: '竹林古道',
description: '风穿竹影,路面狭长。',
imageSrc: '/scene-a.png',
worldType: WorldType.WUXIA,
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street'],
connections: [
{
sceneId: 'wuxia-rain-street',
relativePosition: 'forward',
summary: '沿石板路继续前行',
},
],
forwardSceneId: 'wuxia-rain-street',
treasureHints: [],
npcs: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 38,
playerMaxHp: 40,
playerMana: 12,
playerMaxMana: 16,
playerSkillCooldowns: {},
activeCombatEffects: [],
activeBuildBuffs: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
customWorldProfile: null,
} as unknown as GameState;
}
function createRuntimeNpcBattleSnapshot(
overrides: Partial<HydratedSavedGameSnapshot['gameState']> = {},
) {
@@ -553,6 +653,97 @@ describe('runtimeStoryCoordinator', () => {
);
});
it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => {
const gameState = createTravelGameState();
const currentStory = createStory('桥口这一段已经收束。');
const option = {
functionId: 'idle_travel_next_scene',
actionText: '前往相邻场景',
text: '前往相邻场景',
visuals: {
playerAnimation: 'run',
playerMoveMeters: 1,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
const serverSnapshot = {
version: 8,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure' as const,
currentStory: createStory('你顺着石板路继续前行。'),
gameState: {
...gameState,
runtimeActionVersion: 8,
currentScenePreset: {
...gameState.currentScenePreset!,
id: 'wuxia-rain-street',
name: '夜雨长街',
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
},
} as HydratedSavedGameSnapshot;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 38,
maxHp: 40,
mana: 12,
maxMana: 16,
},
encounter: null,
companions: [],
availableOptions: [
{
functionId: 'idle_observe_signs',
actionText: '观察周围迹象',
scope: 'story',
},
],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '前往相邻场景',
resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。',
storyText: '',
options: [],
},
patches: [],
snapshot: serverSnapshot,
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
});
expect(result.hydratedSnapshot.gameState.currentScenePreset?.id).toBe(
'wuxia-rain-street',
);
expect(
result.hydratedSnapshot.gameState.runtimeStats.scenesTraveled,
).toBe(1);
expect(
Boolean(
result.hydratedSnapshot.gameState.currentEncounter ||
result.hydratedSnapshot.gameState.sceneHostileNpcs.length > 0,
),
).toBe(true);
});
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
runtimeActionVersion: 7,

View File

@@ -451,4 +451,101 @@ describe('storyChoiceRuntime', () => {
);
expect(setGameState).toHaveBeenLastCalledWith(finalState);
});
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
const gameState = createState({
currentScenePreset: {
id: 'wuxia-bamboo-road',
name: '竹林古道',
description: '风穿竹影,路面狭长。',
imageSrc: '/scene-a.png',
connectedSceneIds: ['wuxia-rain-street'],
connections: [
{
sceneId: 'wuxia-rain-street',
relativePosition: 'forward',
summary: '沿石板路继续前行',
},
],
forwardSceneId: 'wuxia-rain-street',
treasureHints: [],
npcs: [],
},
currentEncounter: {
kind: 'npc',
id: 'npc-bridge',
npcName: '桥头行商',
npcDescription: '正准备收摊离开的行商',
npcAvatar: '桥',
context: '桥口',
hostile: false,
},
npcInteractionActive: true,
});
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: {
...gameState,
runtimeActionVersion: 3,
currentScenePreset: {
id: 'wuxia-rain-street',
name: '夜雨长街',
description: '雨丝压低灯火,街面反着潮光。',
imageSrc: '/scene-b.png',
connectedSceneIds: ['wuxia-bamboo-road'],
connections: [
{
sceneId: 'wuxia-bamboo-road',
relativePosition: 'back',
summary: '可以沿原路退回竹林古道',
},
],
forwardSceneId: 'wuxia-ferry-bridge',
treasureHints: [],
npcs: [],
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
runtimeStats: {
...gameState.runtimeStats,
scenesTraveled: 1,
},
},
},
nextStory: createStory('服务端故事'),
});
await runServerRuntimeChoiceAction({
gameState,
currentStory: createStory('当前故事'),
option: createOption('idle_travel_next_scene'),
character: createCharacter(),
setBattleReward: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
});
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
currentScenePreset: expect.objectContaining({
id: 'wuxia-rain-street',
}),
runtimeStats: expect.objectContaining({
scenesTraveled: 1,
}),
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '服务端故事',
}),
);
});
});

View File

@@ -1103,7 +1103,11 @@ export function createStoryNpcEncounterActions({
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (character: Character): StoryOption => {
const buildHostileNpcEscapeOption = (
character: Character,
actionText = '逃跑',
runtimePayload?: StoryOption['runtimePayload'],
): StoryOption => {
const functionContext = gameState.worldType
? {
worldType: gameState.worldType,
@@ -1125,16 +1129,20 @@ export function createStoryNpcEncounterActions({
if (resolvedOption) {
return {
...resolvedOption,
actionText: '逃跑',
text: '逃跑',
actionText,
text: actionText,
detailText: '',
runtimePayload: {
...(resolvedOption.runtimePayload ?? {}),
...(runtimePayload ?? {}),
},
};
}
return {
functionId: 'battle_escape_breakout',
actionText: '逃跑',
text: '逃跑',
actionText,
text: actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.RUN,
@@ -1144,9 +1152,61 @@ export function createStoryNpcEncounterActions({
scrollWorld: true,
monsterChanges: [],
},
runtimePayload,
};
};
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
const currentScene = gameState.currentScenePreset;
const worldType = gameState.worldType;
const options: StoryOption[] = [];
const seenSceneIds = new Set<string>();
if (worldType && currentScene) {
for (const connection of currentScene.connections ?? []) {
if (!connection.sceneId || seenSceneIds.has(connection.sceneId)) {
continue;
}
seenSceneIds.add(connection.sceneId);
const targetScene = getScenePresetById(worldType, connection.sceneId);
const targetSceneName =
targetScene?.name ??
connection.summary?.trim() ??
connection.sceneId;
options.push(
buildHostileNpcEscapeOption(
character,
`逃往${targetSceneName}`,
{
targetSceneId: connection.sceneId,
escapeTargetSceneId: connection.sceneId,
escapeEntry: 'from_left',
},
),
);
}
options.push(
buildHostileNpcEscapeOption(
character,
'逃回当前场景起点',
{
targetSceneId: currentScene.id,
escapeTargetSceneId: currentScene.id,
escapeReturnToSceneStart: true,
escapeEntry: 'from_left',
},
),
);
}
return options.length > 0
? options
: [buildHostileNpcEscapeOption(character)];
};
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
functionId: NPC_FIGHT_FUNCTION.id,
actionText: '与他对战',
@@ -1177,8 +1237,8 @@ export function createStoryNpcEncounterActions({
return {
text: declarationText,
options: [
buildHostileNpcEscapeOption(character),
buildHostileNpcFightOption(encounter),
...buildHostileNpcEscapeOptions(character),
],
displayMode: 'dialogue',
dialogue: [
@@ -1220,7 +1280,7 @@ export function createStoryNpcEncounterActions({
actionText: '战斗',
text: '战斗',
},
buildHostileNpcEscapeOption(character),
...buildHostileNpcEscapeOptions(character),
];
};

View File

@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
import { useAuthUi } from '../../components/auth/AuthUiContext';
import type { CustomWorldRuntimeLaunchOptions } from '../../components/platform-entry/platformEntryTypes';
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
import { syncGameStatePlayTime } from '../../data/runtimeStats';
@@ -87,9 +88,10 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
const handleCustomWorldSelect = (
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
options?: CustomWorldRuntimeLaunchOptions,
) => {
storyFlow.resetStoryState();
selectCustomWorld(customWorldProfile);
selectCustomWorld(customWorldProfile, { mode: options?.mode });
};
const handleCharacterSelect = (

View File

@@ -30,6 +30,11 @@ import {
getScenePresetById,
getWorldCampScenePreset,
} from '../../data/scenePresets';
import {
findCustomWorldRoleByReference,
resolveCustomWorldRoleIdReference,
resolveCustomWorldRoleIdReferences,
} from '../../services/customWorldRoleReferences';
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
@@ -39,6 +44,7 @@ import {
Encounter,
EquipmentLoadout,
GameState,
GameRuntimeMode,
InventoryItem,
SceneActBlueprint,
SceneChapterBlueprint,
@@ -313,23 +319,39 @@ function resolveCustomWorldScenePresetByConfiguredId(
);
}
function resolveOpeningActNpcIdPriority(openingAct: SceneActBlueprint) {
return [
function resolveOpeningActNpcIdPriority(
profile: CustomWorldProfile,
openingAct: SceneActBlueprint,
) {
return resolveCustomWorldRoleIdReferences(profile, [
openingAct.oppositeNpcId,
openingAct.primaryNpcId,
...openingAct.encounterNpcIds,
]
.map((npcId) => npcId.trim())
.filter((npcId, index, list) => npcId && list.indexOf(npcId) === index);
]);
}
function doRoleReferencesMatch(
profile: CustomWorldProfile | null,
left: string | null | undefined,
right: string | null | undefined,
) {
const normalizedLeft = resolveCustomWorldRoleIdReference(profile, left);
const normalizedRight = resolveCustomWorldRoleIdReference(profile, right);
return Boolean(normalizedLeft && normalizedLeft === normalizedRight);
}
function findSceneNpcByRuntimeRoleId(
scenePreset: GameState['currentScenePreset'],
profile: CustomWorldProfile | null,
roleId: string,
) {
return (
scenePreset?.npcs?.find(
(npc) => npc.id === roleId || npc.characterId === roleId,
(npc) =>
doRoleReferencesMatch(profile, npc.id, roleId) ||
doRoleReferencesMatch(profile, npc.characterId, roleId) ||
doRoleReferencesMatch(profile, npc.name, roleId) ||
doRoleReferencesMatch(profile, npc.title, roleId),
) ?? null
);
}
@@ -339,9 +361,7 @@ function buildOpeningEncounterFromCustomWorldRole(
roleId: string,
): Encounter | null {
const role =
profile.storyNpcs.find((npc) => npc.id === roleId) ??
profile.playableNpcs.find((npc) => npc.id === roleId) ??
null;
findCustomWorldRoleByReference(profile, roleId);
if (!role) {
return null;
}
@@ -388,12 +408,27 @@ function resolveOpeningActEncounter(params: {
return null;
}
for (const npcId of resolveOpeningActNpcIdPriority(opening.act)) {
if (npcId === params.playerCharacter.id) {
for (const npcId of resolveOpeningActNpcIdPriority(params.profile, opening.act)) {
if (
doRoleReferencesMatch(
params.profile,
npcId,
params.playerCharacter.id,
) ||
doRoleReferencesMatch(
params.profile,
npcId,
params.playerCharacter.name,
)
) {
continue;
}
const sceneNpc = findSceneNpcByRuntimeRoleId(params.scenePreset, npcId);
const sceneNpc = findSceneNpcByRuntimeRoleId(
params.scenePreset,
params.profile,
npcId,
);
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
return {
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
@@ -456,8 +491,13 @@ export function useRpgSessionBootstrap() {
setGameState(createInitialGameState());
};
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
const handleCustomWorldSelect = (
customWorldProfile: CustomWorldProfile,
options?: { mode?: GameRuntimeMode },
) => {
const resolvedWorldType = WorldType.CUSTOM;
const runtimeMode: GameRuntimeMode =
options?.mode === 'play' ? 'play' : 'test';
setRuntimeCustomWorldProfile(customWorldProfile);
setRuntimeCharacterOverrides(
buildCustomWorldRuntimeCharacters(customWorldProfile),
@@ -469,6 +509,8 @@ export function useRpgSessionBootstrap() {
...prev,
worldType: resolvedWorldType,
customWorldProfile,
runtimeMode,
runtimePersistenceDisabled: runtimeMode !== 'play',
currentScenePreset: initialScenePreset,
sceneHostileNpcs: [],
currentEncounter: null,
@@ -552,82 +594,92 @@ export function useRpgSessionBootstrap() {
resolvedCustomWorldProfile,
);
return ensureSceneEncounterPreview(
applyEquipmentLoadoutToState(
{
...prev,
playerCharacter: character,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
storyHistory: [],
storyEngineMemory:
resolvedWorldType === WorldType.CUSTOM
? buildOpeningStoryEngineMemory(
resolvedCustomWorldProfile,
initialScenePreset?.id,
)
: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId:
prev.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId:
prev.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: playerMaxHp,
playerMaxHp: playerMaxHp,
playerMana: getCharacterMaxMana(character),
playerMaxMana: getCharacterMaxMana(character),
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(
const openingState = applyEquipmentLoadoutToState(
{
...prev,
playerCharacter: character,
runtimeMode:
resolvedWorldType === WorldType.CUSTOM
? prev.runtimeMode === 'play'
? 'play'
: 'test'
: (prev.runtimeMode ?? 'play'),
runtimePersistenceDisabled:
resolvedWorldType === WorldType.CUSTOM
? prev.runtimeMode !== 'play'
: prev.runtimePersistenceDisabled,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
storyHistory: [],
storyEngineMemory:
resolvedWorldType === WorldType.CUSTOM
? buildOpeningStoryEngineMemory(
resolvedCustomWorldProfile,
initialScenePreset?.id,
)
: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: prev.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId: prev.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: playerMaxHp,
playerMaxHp: playerMaxHp,
playerMana: getCharacterMaxMana(character),
playerMaxMana: getCharacterMaxMana(character),
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(
resolvedWorldType,
resolvedCustomWorldProfile,
),
playerInventory: mergeStarterInventoryItems<InventoryItem>(
explicitStarterItems?.inventory ?? [],
buildInitialPlayerInventory(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
),
playerInventory: mergeStarterInventoryItems<InventoryItem>(
explicitStarterItems?.inventory ?? [],
buildInitialPlayerInventory(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
),
),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates:
initialEncounter && initialNpcState
? {
[initialEncounter.id!]: initialNpcState,
}
: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
mergedStarterEquipment,
),
),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates:
initialEncounter && initialNpcState
? {
[initialEncounter.id!]: initialNpcState,
}
: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
mergedStarterEquipment,
);
return resolvedWorldType === WorldType.CUSTOM
? openingState
: ensureSceneEncounterPreview(openingState);
});
};

View File

@@ -72,7 +72,9 @@ function buildBackstoryReveal(label: string) {
};
}
function buildSavedProfile() {
function buildSavedProfile(options: {
openingOppositeNpcId?: string;
} = {}) {
const profile = normalizeCustomWorldProfileRecord({
id: 'saved-runtime-profile',
settingText: '被海雾吞没的旧航路群岛',
@@ -318,9 +320,9 @@ function buildSavedProfile() {
title: '第一幕',
summary: '陆衡先开口试探玩家。',
stageCoverage: ['opening'],
encounterNpcIds: ['story-primary-only', 'story-act-only'],
primaryNpcId: 'story-primary-only',
oppositeNpcId: 'story-act-only',
encounterNpcIds: ['沈砺旧识', '陆衡'],
primaryNpcId: '沈砺旧识',
oppositeNpcId: options.openingOppositeNpcId ?? '陆衡',
eventDescription: '陆衡拿着异常账本,在开盘前拦住玩家。',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
@@ -389,6 +391,8 @@ function readSnapshot() {
isStoryLoading: boolean;
firstLandmarkResidueTitle: string | null;
playerCharacterName: string | null;
runtimeMode: string | null;
runtimePersistenceDisabled: boolean;
playerInventoryNames: string[];
playerEquipment: {
weapon: string | null;
@@ -398,8 +402,15 @@ function readSnapshot() {
};
}
function GameFlowHarness() {
const profile = useMemo(() => buildSavedProfile(), []);
function GameFlowHarness({
openingOppositeNpcId,
}: {
openingOppositeNpcId?: string;
} = {}) {
const profile = useMemo(
() => buildSavedProfile({ openingOppositeNpcId }),
[openingOppositeNpcId],
);
const playableCharacters = useMemo(
() => buildCustomWorldPlayableCharacters(profile),
[profile],
@@ -441,6 +452,8 @@ function GameFlowHarness() {
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
?.title ?? null,
playerCharacterName: gameState.playerCharacter?.name ?? null,
runtimeMode: gameState.runtimeMode ?? null,
runtimePersistenceDisabled: gameState.runtimePersistenceDisabled === true,
playerInventoryNames: gameState.playerInventory.map((item) => item.name),
playerEquipment: {
weapon: gameState.playerEquipment.weapon?.name ?? null,
@@ -515,6 +528,8 @@ test('saved custom world result settings flow into game state after entering the
});
expect(readSnapshot().playerCharacterName).toBe('沈砺');
expect(readSnapshot().runtimeMode).toBe('test');
expect(readSnapshot().runtimePersistenceDisabled).toBe(true);
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
@@ -547,3 +562,38 @@ test('saved custom world result settings flow into game state after entering the
}),
);
});
test('custom world opening act accepts runtime npc id references and still starts configured npc chat', async () => {
const user = userEvent.setup();
render(<GameFlowHarness openingOppositeNpcId="character-npc-story-act-only" />);
await user.click(screen.getByRole('button', { name: '选择世界' }));
await waitFor(() => {
expect(readSnapshot().worldType).toBe(WorldType.CUSTOM);
});
await user.click(screen.getByRole('button', { name: '确认角色' }));
await waitFor(() => {
expect(readSnapshot().currentEncounterId).toBe('story-act-only');
});
expect(readSnapshot().currentEncounterName).toBe('陆衡');
await waitFor(() => {
expect(readSnapshot().currentStoryNpcName).toBe('陆衡');
});
expect(aiServiceMocks.streamNpcChatTurn).toHaveBeenCalledWith(
WorldType.CUSTOM,
expect.objectContaining({ name: '沈砺' }),
expect.objectContaining({ id: 'story-act-only', npcName: '陆衡' }),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'',
expect.anything(),
expect.objectContaining({
npcInitiatesConversation: true,
}),
);
});

View File

@@ -76,6 +76,66 @@ body {
}
}
@keyframes puzzle-clear-flash-sweep {
0% {
transform: translate3d(-135%, -135%, 0) rotate(18deg);
opacity: 0;
}
18% {
opacity: 0.18;
}
45% {
opacity: 0.92;
}
78% {
opacity: 0.22;
}
100% {
transform: translate3d(135%, 135%, 0) rotate(18deg);
opacity: 0;
}
}
.puzzle-clear-flash-overlay {
background: radial-gradient(
circle at 22% 24%,
rgba(255, 250, 214, 0.22),
transparent 28%
),
radial-gradient(
circle at 76% 74%,
rgba(255, 214, 150, 0.16),
transparent 30%
),
linear-gradient(
180deg,
rgba(255, 251, 235, 0.08),
rgba(255, 251, 235, 0.02)
);
}
.puzzle-clear-flash-beam {
position: absolute;
inset: -48%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 248, 214, 0.18) 38%,
rgba(255, 255, 255, 0.96) 50%,
rgba(255, 235, 166, 0.28) 62%,
rgba(255, 255, 255, 0) 100%
);
box-shadow:
0 0 36px rgba(255, 250, 214, 0.28),
0 0 110px rgba(255, 233, 163, 0.12);
mix-blend-mode: screen;
animation: puzzle-clear-flash-sweep 0.9s ease-out forwards;
}
.fusion-pixel-app,
.fusion-pixel-app * {
font-family: 'Fusion Pixel', 'Inter', ui-sans-serif, system-ui, sans-serif !important;
@@ -1493,8 +1553,10 @@ body {
.platform-npc-portrait__grid {
opacity: 0.14;
background-image:
linear-gradient(var(--platform-line-soft) 1px, transparent 1px),
background-image: linear-gradient(
var(--platform-line-soft) 1px,
transparent 1px
),
linear-gradient(90deg, var(--platform-line-soft) 1px, transparent 1px);
background-size: 16px 16px;
}

View File

@@ -1,5 +1,5 @@
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import { type ApiRetryOptions, requestJson } from '../apiClient';
import { ApiClientError, type ApiRetryOptions, requestJson } from '../apiClient';
const BIG_FISH_GALLERY_API_BASE = '/api/runtime/big-fish/gallery';
const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
@@ -12,16 +12,25 @@ const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
* 读取大鱼吃小鱼公开广场列表。
*/
export async function listBigFishGallery() {
return requestJson<BigFishWorksResponse>(
BIG_FISH_GALLERY_API_BASE,
{
method: 'GET',
},
'读取大鱼吃小鱼广场失败',
{
retry: BIG_FISH_GALLERY_READ_RETRY,
},
);
try {
return await requestJson<BigFishWorksResponse>(
BIG_FISH_GALLERY_API_BASE,
{
method: 'GET',
},
'读取大鱼吃小鱼广场失败',
{
retry: BIG_FISH_GALLERY_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
} catch (error) {
if (error instanceof ApiClientError && error.status === 404) {
return { items: [] };
}
throw error;
}
}
export const bigFishGalleryClient = {

View 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),
),
];
}

View File

@@ -8,6 +8,7 @@ import type {
SceneConnectionInfo,
StoryEngineMemoryState,
} from '../types';
import { resolveCustomWorldRoleIdReferences } from './customWorldRoleReferences';
function toSet(values: string[]) {
return new Set(values.map((value) => value.trim()).filter(Boolean));
@@ -227,17 +228,11 @@ export function resolveActiveSceneActEncounterNpcIds(params: {
return [];
}
return [
...new Set(
[
activeAct.primaryNpcId,
activeAct.oppositeNpcId,
...activeAct.encounterNpcIds,
]
.map((entry) => entry.trim())
.filter(Boolean),
),
];
return resolveCustomWorldRoleIdReferences(params.profile, [
activeAct.primaryNpcId,
activeAct.oppositeNpcId,
...activeAct.encounterNpcIds,
]);
}
export function resolveActiveSceneActPrimaryNpcId(params: {
@@ -245,7 +240,9 @@ export function resolveActiveSceneActPrimaryNpcId(params: {
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
return resolveCustomWorldRoleIdReferences(params.profile, [
resolveActiveSceneActBlueprint(params)?.primaryNpcId,
])[0] ?? null;
}
export function resolveActiveSceneActOppositeNpcId(params: {
@@ -253,7 +250,9 @@ export function resolveActiveSceneActOppositeNpcId(params: {
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return resolveActiveSceneActBlueprint(params)?.oppositeNpcId?.trim() || null;
return resolveCustomWorldRoleIdReferences(params.profile, [
resolveActiveSceneActBlueprint(params)?.oppositeNpcId,
])[0] ?? null;
}
export function resolveActiveSceneActEncounterFocusNpcId(params: {
@@ -262,12 +261,11 @@ export function resolveActiveSceneActEncounterFocusNpcId(params: {
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
const activeAct = resolveActiveSceneActBlueprint(params);
return (
activeAct?.oppositeNpcId?.trim() ||
activeAct?.primaryNpcId?.trim() ||
activeAct?.encounterNpcIds[0]?.trim() ||
null
);
return resolveCustomWorldRoleIdReferences(params.profile, [
activeAct?.oppositeNpcId,
activeAct?.primaryNpcId,
activeAct?.encounterNpcIds[0],
])[0] ?? null;
}
export function resolveActiveSceneActBackgroundImage(params: {
@@ -295,13 +293,18 @@ export function canUseLimitedPrimaryNpcChat(params: {
storyEngineMemory: params.storyEngineMemory,
});
const limitedChatNpcIds = toSet([
activeAct?.primaryNpcId ?? '',
activeAct?.oppositeNpcId ?? '',
]);
const limitedChatNpcIds = toSet(
resolveCustomWorldRoleIdReferences(params.profile, [
activeAct?.primaryNpcId,
activeAct?.oppositeNpcId,
]),
);
const normalizedNpcId =
resolveCustomWorldRoleIdReferences(params.profile, [params.npcId])[0] ??
params.npcId;
// 中文注释:第一幕对面角色即使是负好感,也必须先进入剧情对话;普通敌人仍按战斗处理。
if (limitedChatNpcIds.has(params.npcId)) {
if (limitedChatNpcIds.has(normalizedNpcId)) {
return true;
}
@@ -310,7 +313,7 @@ export function canUseLimitedPrimaryNpcChat(params: {
profile: params.profile,
sceneId: params.sceneId,
storyEngineMemory: params.storyEngineMemory,
}) === params.npcId
}) === normalizedNpcId
);
}

View File

@@ -5,5 +5,6 @@ export {
getPuzzleRun,
puzzleRuntimeClient,
startPuzzleRun,
submitPuzzleLeaderboard,
swapPuzzlePieces,
} from './puzzleRuntimeClient';

View File

@@ -27,7 +27,7 @@ const baseWork: PuzzleWorkSummary = {
publishReady: true,
};
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
return pieces.some((piece) =>
pieces.some((candidate) => {
if (piece.pieceId === candidate.pieceId) {
@@ -39,13 +39,18 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
const correctColDelta = candidate.correctCol - piece.correctCol;
return (
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
currentRowDelta === correctRowDelta &&
currentColDelta === correctColDelta
Math.abs(correctRowDelta) + Math.abs(correctColDelta) === 1
);
}),
);
}
function boardPositionSignature(run: ReturnType<typeof startLocalPuzzleRun>) {
return run.currentLevel?.board.pieces
.map((piece) => `${piece.currentRow}:${piece.currentCol}`)
.join('|');
}
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
let nextRun = run;
for (let index = 0; index < 12; index += 1) {
@@ -89,13 +94,13 @@ describe('puzzleLocalRuntime', () => {
expect(firstPositions).not.toEqual(secondPositions);
});
test('初始棋盘没有任何自动合并块', () => {
test('初始棋盘没有任何原图相邻块贴边', () => {
for (let index = 0; index < 12; index += 1) {
const run = startLocalPuzzleRun(baseWork);
const board = run.currentLevel?.board;
expect(board?.mergedGroups).toEqual([]);
expect(hasAnyCorrectNeighborPair(board?.pieces ?? [])).toBe(false);
expect(hasAnyOriginalNeighborPair(board?.pieces ?? [])).toBe(false);
}
});
@@ -283,12 +288,8 @@ describe('puzzleLocalRuntime', () => {
expect(clearedRun.currentLevel?.status).toBe('cleared');
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
expect(clearedRun.currentLevel?.leaderboardEntries.length).toBeGreaterThan(0);
expect(
clearedRun.currentLevel?.leaderboardEntries.some(
(entry) => entry.isCurrentPlayer && entry.nickname === '测试作者',
),
).toBe(true);
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
expect(clearedRun.leaderboardEntries).toEqual([]);
const nextRun = advanceLocalPuzzleLevel(clearedRun);
@@ -300,4 +301,17 @@ describe('puzzleLocalRuntime', () => {
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
expect(nextRun.recommendedNextProfileId).toBeNull();
});
test('连续推进下一关会重新打乱棋盘', () => {
const firstClearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
const secondRun = advanceLocalPuzzleLevel(firstClearedRun);
const secondClearedRun = solveCurrentLevel(secondRun);
const thirdRun = advanceLocalPuzzleLevel(secondClearedRun);
expect(secondRun.currentLevelIndex).toBe(2);
expect(thirdRun.currentLevelIndex).toBe(3);
expect(boardPositionSignature(secondRun)).not.toBe(boardPositionSignature(thirdRun));
expect(hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? [])).toBe(false);
expect(hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? [])).toBe(false);
});
});

View File

@@ -3,7 +3,6 @@ import type {
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleGridSize,
PuzzleLeaderboardEntry,
PuzzleMergedGroupState,
PuzzlePieceState,
PuzzleRunSnapshot,
@@ -75,11 +74,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
);
ensureBoardIsNotSolved(shuffled, gridSize);
const pieces = buildPiecesFromPositions(gridSize, shuffled);
if (!hasAnyCorrectNeighborPair(pieces)) {
if (!hasAnyOriginalNeighborPair(pieces)) {
return shuffled;
}
}
return positions.slice().reverse();
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
}
function boardCellKey(row: number, col: number) {
@@ -90,48 +89,6 @@ function clampElapsedMs(value: number) {
return Math.max(1_000, Math.round(value));
}
function rankLeaderboardEntries(
entries: Omit<PuzzleLeaderboardEntry, 'rank'>[],
): PuzzleLeaderboardEntry[] {
return entries
.map((entry) => ({ ...entry }))
.sort((left, right) => left.elapsedMs - right.elapsedMs)
.map((entry, index) => ({
...entry,
rank: index + 1,
}));
}
// V1 本地榜单只用于单次游玩闭环展示;正式榜单后续迁移到 SpacetimeDB 表或 view。
function buildLocalLeaderboardEntries(
elapsedMs: number,
playerNickname: string,
levelIndex: number,
gridSize: PuzzleGridSize,
): PuzzleLeaderboardEntry[] {
const normalizedElapsedMs = clampElapsedMs(elapsedMs);
const baseOffsetMs = gridSize === 3 ? 4_000 : 8_000;
return rankLeaderboardEntries([
{
nickname: playerNickname.trim() || '玩家',
elapsedMs: normalizedElapsedMs,
isCurrentPlayer: true,
},
{
nickname: '星桥旅人',
elapsedMs: normalizedElapsedMs + baseOffsetMs + levelIndex * 700,
},
{
nickname: '月港拼图手',
elapsedMs: Math.max(1_000, normalizedElapsedMs - baseOffsetMs / 2),
},
{
nickname: '雾灯收藏家',
elapsedMs: normalizedElapsedMs + baseOffsetMs * 2 + levelIndex * 900,
},
]);
}
function neighborCells(row: number, col: number): PuzzleCellPosition[] {
return [
row > 0 ? { row: row - 1, col } : null,
@@ -179,6 +136,119 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
);
}
function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
return (
Math.abs(right.correctRow - left.correctRow) +
Math.abs(right.correctCol - left.correctCol) ===
1
);
}
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
const piecesByCell = new Map(
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
);
return pieces.some((piece) =>
neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => {
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
return Boolean(neighborPiece && areOriginalNeighbors(piece, neighborPiece));
}),
);
}
function seededOrderKey(seed: number, value: number) {
let state = (seed ^ Math.imul(value, 2654435761)) >>> 0;
state ^= state >>> 16;
state = Math.imul(state, 2246822507) >>> 0;
state ^= state >>> 13;
state = Math.imul(state, 3266489909) >>> 0;
return (state ^ (state >>> 16)) >>> 0;
}
function buildOriginalNeighborFreePositions(
gridSize: PuzzleGridSize,
seed: number,
) {
const total = gridSize * gridSize;
const pieceOrder = Array.from({ length: total }, (_, index) => index).sort(
(left, right) =>
seededOrderKey(seed ^ 0xa0761d64, left) -
seededOrderKey(seed ^ 0xa0761d64, right),
);
const cellOrder = Array.from({ length: total }, (_, index) => ({
row: Math.floor(index / gridSize),
col: index % gridSize,
})).sort(
(left, right) =>
seededOrderKey(seed ^ 0xe7037ed1, left.row * 16 + left.col) -
seededOrderKey(seed ^ 0xe7037ed1, right.row * 16 + right.col),
);
const placements: Array<PuzzleCellPosition | null> = Array.from(
{ length: total },
() => null,
);
const usedCells = new Set<string>();
const placePiece = (depth: number): boolean => {
const pieceIndex = pieceOrder[depth];
if (pieceIndex === undefined) {
return true;
}
for (const cell of cellOrder) {
const cellKey = boardCellKey(cell.row, cell.col);
if (usedCells.has(cellKey)) {
continue;
}
if (
cell.row === Math.floor(pieceIndex / gridSize) &&
cell.col === pieceIndex % gridSize
) {
continue;
}
if (
violatesOriginalNeighborFreeRule(gridSize, pieceIndex, cell, placements)
) {
continue;
}
placements[pieceIndex] = cell;
usedCells.add(cellKey);
if (placePiece(depth + 1)) {
return true;
}
usedCells.delete(cellKey);
placements[pieceIndex] = null;
}
return false;
};
return placePiece(0) && placements.every(Boolean)
? (placements as PuzzleCellPosition[])
: null;
}
function violatesOriginalNeighborFreeRule(
gridSize: PuzzleGridSize,
pieceIndex: number,
cell: PuzzleCellPosition,
placements: Array<PuzzleCellPosition | null>,
) {
return placements.some((placedCell, placedIndex) => {
if (!placedCell) {
return false;
}
const originalNeighbors =
Math.abs(Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize)) +
Math.abs((pieceIndex % gridSize) - (placedIndex % gridSize)) ===
1;
const currentNeighbors =
Math.abs(cell.row - placedCell.row) + Math.abs(cell.col - placedCell.col) ===
1;
return originalNeighbors && currentNeighbors;
});
}
function resolveMergedGroups(
pieces: PuzzlePieceState[],
): PuzzleMergedGroupState[] {
@@ -306,15 +376,6 @@ function applyNextBoard(
const elapsedMs = justCleared
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
: (run.currentLevel.elapsedMs ?? null);
const leaderboardEntries =
justCleared && elapsedMs
? buildLocalLeaderboardEntries(
elapsedMs,
run.currentLevel.authorDisplayName,
run.currentLevel.levelIndex,
run.currentLevel.gridSize,
)
: run.currentLevel.leaderboardEntries;
return {
...run,
clearedLevelCount: nextClearedLevelCount,
@@ -324,9 +385,9 @@ function applyNextBoard(
status,
clearedAtMs,
elapsedMs,
leaderboardEntries,
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
},
leaderboardEntries,
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
recommendedNextProfileId:
status === 'cleared'
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)

View File

@@ -3,6 +3,7 @@ import type {
DragPuzzlePieceRequest,
PuzzleRunResponse,
StartPuzzleRunRequest,
SubmitPuzzleLeaderboardRequest,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { type ApiRetryOptions, requestJson } from '../apiClient';
@@ -112,6 +113,27 @@ export async function advancePuzzleNextLevel(runId: string) {
);
}
/**
* 提交通关成绩并读取真实排行榜。
*/
export async function submitPuzzleLeaderboard(
runId: string,
payload: SubmitPuzzleLeaderboardRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'提交拼图排行榜失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 单机运行态进入下一关,图片来源选择全部由后端裁决。
*/
@@ -137,6 +159,7 @@ export const puzzleRuntimeClient = {
advanceNextLevel: advancePuzzleNextLevel,
drag: dragPuzzlePieceOrGroup,
getRun: getPuzzleRun,
submitLeaderboard: submitPuzzleLeaderboard,
startRun: startPuzzleRun,
swap: swapPuzzlePieces,
};

View File

@@ -202,31 +202,42 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
});
test('buildRpgCreationPreviewFromSession prefers agent draft profile', () => {
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
expect(profile?.summary).toBe('fallback');
expect(profile?.id).toBe('draft-profile-1');
expect(profile?.playableNpcs[0]?.id).toBe('draft-playable-1');
expect(profile?.name).toBe('服务端结果预览');
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
expect(profile?.id).toBe('preview-profile-1');
expect(profile?.playableNpcs).toEqual([]);
});
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
test('buildRpgCreationPreviewFromSession falls back to draft legacy result profile', () => {
const profile = buildRpgCreationPreviewFromSession({
...sessionWithPreview,
resultPreview: null,
draftProfile: {
...sessionWithPreview.draftProfile,
legacyResultProfile: {
...sessionWithPreview.resultPreview!.preview,
id: 'legacy-result-profile-1',
name: '草稿内嵌结果页',
summary: 'resultPreview 缺失时继续使用 draft 内嵌的结果页快照。',
},
},
});
expect(profile?.name).toBe('草稿内嵌结果页');
expect(profile?.summary).toBe(
'resultPreview 缺失时继续使用 draft 内嵌的结果页快照。',
);
expect(profile?.id).toBe('legacy-result-profile-1');
});
test('buildRpgCreationPreviewFromSession does not treat draftProfile as runtime profile', () => {
const profile = buildRpgCreationPreviewFromSession({
...sessionWithPreview,
resultPreview: null,
});
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-playable-1/portrait.png',
);
expect(profile?.attributeSchema.slots.map((slot) => slot.name)).toEqual([
'稿骨',
'稿步',
'稿识',
'稿魄',
'稿契',
'稿澜',
]);
expect(profile).toBeNull();
});

View File

@@ -2,6 +2,19 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/s
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { CustomWorldProfile } from '../../types';
function buildCustomWorldProfileFromDraftLegacyResult(
draftProfile: CustomWorldAgentSessionSnapshot['draftProfile'],
): CustomWorldProfile | null {
if (!draftProfile || typeof draftProfile !== 'object') {
return null;
}
return normalizeCustomWorldProfileRecord(
(draftProfile as { legacyResultProfile?: unknown }).legacyResultProfile ??
null,
);
}
export function buildCustomWorldProfileFromResultPreview(
resultPreview:
| CustomWorldAgentSessionSnapshot['resultPreview']
@@ -15,14 +28,14 @@ export function buildCustomWorldProfileFromAgentSession(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
return (
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null) ??
buildCustomWorldProfileFromResultPreview(session?.resultPreview)
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
buildCustomWorldProfileFromDraftLegacyResult(session?.draftProfile ?? null)
);
}
/**
* 这是工作包 A 提供的新命名兼容层。
* 主入口保持命名稳定,优先消费 Agent 草稿真相源,缺失时才回退到 resultPreview
* 主入口保持命名稳定,只消费结果页运行态快照,避免作品测试读到旧草稿骨架
*/
export const rpgCreationPreviewAdapter = {
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
@@ -30,6 +43,6 @@ export const rpgCreationPreviewAdapter = {
};
export {
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
};