From 1eb090e4a5cdb44f0754682c982c0167f047d0c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 28 Apr 2026 02:05:12 +0800 Subject: [PATCH] 1 --- ...ADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md | 11 + ...PG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md | 127 +++++ ...IME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md | 202 +++++++ .../module-runtime-story-compat/src/battle.rs | 111 ++-- .../src/battle_tests.rs | 87 +++ .../module-runtime-story-compat/src/lib.rs | 2 + src/App.tsx | 2 + src/RpgRuntimeApp.tsx | 16 +- .../GameCanvasEntityLayer.test.tsx | 210 ++++++++ .../game-canvas/GameCanvasEntityLayer.tsx | 15 +- .../game-canvas/GameCanvasRuntime.tsx | 17 +- .../platform-entry/platformEntryTypes.ts | 3 +- .../PuzzleRuntimeShell.test.tsx | 22 +- .../puzzle-runtime/PuzzleRuntimeShell.tsx | 113 +++- .../RpgCreationEntityEditorShared.tsx | 5 +- ...gEntryFlowShell.agent.interaction.test.tsx | 3 +- .../useRpgCreationEnterWorld.test.tsx | 14 +- .../rpg-entry/useRpgCreationEnterWorld.ts | 28 +- .../RpgRuntimeShell.test.tsx | 27 +- .../rpg-runtime-shell/RpgRuntimeShell.tsx | 12 +- src/components/rpg-runtime-shell/types.ts | 3 +- src/data/sceneEncounterPreviews.test.ts | 368 +++++++++++++ src/data/sceneEncounterPreviews.ts | 178 +++++- src/hooks/combat/battlePlan.test.ts | 45 ++ src/hooks/combat/battlePlan.ts | 126 +++-- .../rpg-runtime-story/choiceActions.test.ts | 193 +++++++ .../npcEncounterActions.test.ts | 38 ++ .../rpgRuntimeStoryGateway.ts | 193 ++++++- .../runtimeStoryCoordinator.test.ts | 509 ++++++++++++++++++ .../storyChoiceContinuation.ts | 18 +- .../storyChoiceRuntime.test.ts | 98 ++++ .../rpg-runtime-story/storyChoiceRuntime.ts | 2 +- .../useRpgRuntimeNpcInteraction.ts | 6 + src/hooks/rpg-session/useRpgRuntimeSession.ts | 5 +- .../rpg-session/useRpgSessionBootstrap.ts | 15 +- src/hooks/useGameFlow.customWorld.test.tsx | 6 +- src/services/aiService.ts | 2 +- src/types/core.ts | 2 +- src/types/story.ts | 2 +- 39 files changed, 2671 insertions(+), 165 deletions(-) create mode 100644 docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md create mode 100644 docs/technical/RPG_TEST_RUNTIME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md create mode 100644 server-rs/crates/module-runtime-story-compat/src/battle_tests.rs diff --git a/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md b/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md index b5f3cd40..6fabf6c0 100644 --- a/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md +++ b/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md @@ -66,3 +66,14 @@ 1. 继续复用现有 `PuzzleRuntimeShell` 作为运行时承载组件,不新增平行页面。 2. 设置弹层沿用现有像素风弹窗资源,不单独引入新的弹窗体系。 3. 通关演出只作为前端表现层时序,不改动通关判定与排行榜数据来源。 + +### 5. 拖拽层级规则 + +正在被拖动的拼图片必须临时提升到拼图棋盘最上层,不允许在拖动过程中被其他单块或合并块遮挡。 + +交互约束如下: + +1. 单块拖动时,提升该拼图片所属格子的堆叠层级,并同步提升拼图片自身层级。 +2. 合并块拖动时,直接提升整组容器层级,保证整组视觉保持完整。 +3. 松手、取消拖动、或丢失指针捕获后,必须立即恢复默认层级。 +4. 这条规则只属于前端表现层,不改变拼图交换、合并、拆分和落点判定逻辑。 diff --git a/docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md b/docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md new file mode 100644 index 00000000..29185198 --- /dev/null +++ b/docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md @@ -0,0 +1,127 @@ +# RPG 战斗败北结果修复(2026-04-27) + +更新时间:`2026-04-27` + +## 背景 + +线上出现了一个确定性战斗兼容链 bug: + +1. 玩家在 RPG 战斗中已经被打死。 +2. 画面却突然跳到“对方已经败下阵来”的胜利收束。 +3. 最终走了战斗胜利固定流程,而不是死亡动画与三秒后复活。 + +这与既有战斗结束规则冲突: + +1. 玩家血量小于等于 `0` 时必须优先进入败北/死亡流程。 +2. 战斗胜利只允许在玩家仍然存活、且敌方被击败时成立。 + +## 根因 + +问题出在 `server-rs/crates/module-runtime-story-compat/src/battle.rs` 的 compat 战斗结算: + +1. `apply_player_damage(...)` 旧实现把玩家生命最低钳在 `1`,不会真正写出 `0` 血。 +2. `resolve_battle_action(...)` 旧实现只有 `ongoing / victory / spar_complete` 三种结果,没有正式 `defeat` 分支。 +3. 当同一回合里敌方也被打到 `0` 附近时,结算会只看敌方血量并直接归类到 `victory`。 + +这会把“玩家应败北”的回合错误地写成“敌方已败下阵来”。 + +继续复测后又确认,**作品测试** 里还有第二层根因: + +1. 作品测试并不总是走服务端 compat 战斗结算; +2. 一部分战斗点击仍会走前端本地 `src/hooks/combat/battlePlan.ts`; +3. 这条本地链原先会在玩家或同伴刚打掉最后一只敌人时,立刻: + - 把敌方从 `sceneHostileNpcs` 移除; + - 把 `currentNpcBattleOutcome` 直接写成 `fight_victory`; +4. 这样会导致同一轮中原本还应该出手的敌方单位失去反击机会; +5. 体感上就会变成“我明明该死了,但作品测试先把对方判成败下阵来”。 + +## 本次修复 + +### 1. 后端 compat 战斗结算补齐败北态 + +在 `module-runtime-story-compat::battle` 中补齐: + +1. 显式 `BattleResolutionOutcome`:`ongoing / victory / spar_complete / defeat` +2. `apply_player_damage(...)` 允许把 `playerHp` 写到 `0` +3. 新增统一胜负决议: + - 玩家 `hp <= 0` 时优先判定 `defeat` + - 只有玩家仍存活时,敌方 `hp <= 0` 才能进入 `victory / spar_complete` + +### 2. 服务端状态写回补齐 `fight_defeat` + +当 compat 战斗判定为失败时: + +1. `presentation.battle.outcome = "defeat"` +2. `currentNpcBattleOutcome = "fight_defeat"` +3. 关闭战斗态,清理当前遭遇与敌对列表 +4. 不发放胜利经验、不累计 `hostileNpcsDefeated` + +### 3. 前端服务端战斗回包分支补齐失败态 + +前端 `storyChoiceRuntime` 同步修正: + +1. 服务端回包若 `playerHp <= 0`,无论 battle outcome 是否同时包含敌方掉血,都优先走死亡动画与复活链 +2. 敌方是否播放死亡态时,`defeat` 不再被当成“目标已被击败” + +### 4. 本地 battlePlan 改为整轮后统一收束 + +在 `src/hooks/combat/battlePlan.ts` 中同步修正: + +1. 普通战斗不再在玩家/同伴动作阶段即时写入 `fight_victory` +2. 敌方被打到 `0` 血时,先只保留为“本轮已死亡但尚未清场”的状态 +3. 整轮 turn order 跑完后,再统一按以下优先级判定: + - 玩家 `playerHp <= 0`:`fight_defeat` + - 否则若敌方全部 `hp <= 0`:`fight_victory` + - 否则继续战斗 +4. `spar_complete` 仍维持切磋的即时收束规则,不和正式战斗混用 + +## 类型同步 + +本轮同步扩展: + +1. `NpcBattleOutcome`:新增 `fight_defeat` +2. `StoryNpcChatState.combatContext.battleOutcome`:新增 `defeat` +3. `aiService` 的战斗上下文类型:新增 `defeat` + +## 回归测试 + +新增以下回归: + +1. `module-runtime-story-compat`: + - 同回合双方都归零时,必须优先判定为 `defeat` +2. 前端 `storyChoiceRuntime`: + - 服务端返回 `defeat` 且 `playerHp = 0` 时,必须进入死亡复活流程,不能进入胜利收束 +3. 前端 `battlePlan`: + - 作品测试 / 本地战斗链里,同一轮玩家先手打空敌方但随后自己被打死时,最终必须判定为 `fight_defeat` +4. 前端 `storyChoiceContinuation` / `useRpgRuntimeNpcInteraction`: + - `fight_defeat` 不能再被当成“本地 NPC 战斗胜利”进入战后收束 + - 玩家死亡后必须直接走死亡复活链,且复活时重置到开局场景第一幕,不能先推进下一幕 + +## 继续收口(2026-04-28) + +继续复测后又确认,作品测试里还残留一层“败北被当成胜利”的本地剧情续写问题: + +1. `storyChoiceContinuation` 旧判断把 `currentNpcBattleOutcome` 只要是 truthy 就当成本地 NPC 战斗可收束; +2. 这会把 `fight_defeat` 也误送进 `finalizeNpcBattleResult(...)`; +3. 后续又因为 `!nextState.inBattle` 条件过宽,继续走到 `buildPostBattleVictoryStory(...)`,表现为: + - 死亡后仍显示“对方已经败下阵来” + - 并且还会把场景幕推进到下一幕 + +本轮补充修正如下: + +1. 本地 NPC 战斗收束只接受 `fight_victory / spar_complete` +2. `fight_defeat` 一律交回死亡复活主链 +3. NPC 战后结算 helper 显式拒绝 `fight_defeat` +4. 本地 `battle` 的战后推进只允许: + - 正式 NPC 战斗明确 `fight_victory` + - 切磋明确 `spar_complete` + - 非 NPC 通用敌对战斗 `!inBattle` +5. 这样可以保证作品测试、幕预览与正式游戏在“死亡后回开局第一幕”这一口径上继续对齐 + +## 结论 + +本次修复后,RPG 战斗 compat 主链的胜负判定口径变为: + +1. 玩家死亡优先于敌方倒地胜利 +2. 胜利与败北都只走确定性固定流程 +3. 不再出现“玩家已死却结算成战斗胜利”的串线结果 diff --git a/docs/technical/RPG_TEST_RUNTIME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md b/docs/technical/RPG_TEST_RUNTIME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md new file mode 100644 index 00000000..373d36d2 --- /dev/null +++ b/docs/technical/RPG_TEST_RUNTIME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md @@ -0,0 +1,202 @@ +# RPG 作品测试与正式游戏运行态对齐方案 + +更新时间:`2026-04-27` + +## 1. 背景 + +本轮复测已经确认,战斗回合与同伴表现的局部修复虽然已经落地,但用户在“作品测试”入口中仍然能复现问题。这说明问题根因不只在战斗模块内部,还在于“作品测试”和“正式游戏”本身跑的不是同一条运行链。 + +旧实现中,作品测试会以 `runtimeMode: 'test'` 进入世界,而正式游戏使用 `runtimeMode: 'play'`。只要这条模式分叉继续存在,就会持续带来以下风险: + +1. 战斗、剧情、选项、同伴、存档恢复等后续逻辑容易被模式判断继续分流。 +2. 某些修复只会覆盖正式链,作品测试仍保留旧行为,导致“结果页里测不准、正式入口里才正常”。 +3. 用户在创作结果页看到的世界与真正测试到的运行态体验不一致,测试结果失去参考意义。 + +因此本次必须把“作品测试”从“独立测试模式”收口为“正式运行态的一种启动方式”。 + +## 2. 对齐目标 + +### 2.1 核心原则 + +1. 作品测试进入世界后,游戏逻辑必须与正式游戏完全一致。 +2. 包括但不限于: + - 战斗回合排序 + - 选项点击后的剧情推进 + - 同伴显示与参战 + - 运行时剧情刷新 + - 场景、遇敌、交互链路 +3. 作品测试不能再通过 `runtimeMode: 'test'` 驱动任何游戏行为差异。 + +### 2.2 唯一允许保留的差异 + +1. 从结果页启动作品测试后,退出运行态时应返回结果页。 +2. 作品测试阶段允许关闭运行态持久化,避免把临时试玩覆盖正式存档。 +3. “结束测试”按钮可以保留,但它只代表退出导航能力,不能代表另一套游戏模式。 + +## 3. 新的入口语义 + +### 3.1 作品测试入口 + +作品测试入口调整为: + +1. 使用与正式游戏一致的 `mode: 'play'` 进入世界。 +2. 继续携带 `returnStage: 'custom-world-result'`,用于退出后回到结果页。 +3. 额外显式传递 `disablePersistence: true`,只影响是否自动持久化,不影响任何游戏逻辑。 + +### 3.2 正式进入世界 + +正式进入世界维持: + +1. `mode: 'play'` +2. 默认返回平台首页或原有正式入口目标 +3. 默认允许持久化 + +## 4. 运行态约束 + +### 4.1 runtimeMode 约束 + +1. 自定义世界从结果页进入作品测试时,`runtimeMode` 必须是 `play`。 +2. `test` 不再作为作品测试的真实运行模式。 +3. 编辑器幕预览过去使用过 `preview`;本轮起也统一改为复用 `play + runtimePersistenceDisabled = true` 的运行口径。 +4. 若仓库内还保留 `test / preview` 类型,只视为历史兼容标签,不能再作为当前作品测试主链依赖。 + +### 4.2 退出按钮约束 + +1. “结束测试”按钮是否显示,不再依赖 `gameState.runtimeMode`。 +2. 它应改为依赖“当前运行态是否提供测试退出能力”。 +3. 也就是:来自结果页测试启动时显示;普通正式入口不显示。 + +### 4.3 持久化约束 + +1. 自动存档与手动保存是否启用,统一由 `runtimePersistenceDisabled` 控制。 +2. 作品测试可通过 `disablePersistence: true` 关闭持久化。 +3. 正式运行保持 `runtimePersistenceDisabled: false`。 +4. 持久化开关不能再反向决定剧情、战斗、同伴或场景逻辑。 + +## 5. 验收口径 + +1. 从结果页点“作品测试”后,进入的运行态与正式游戏共用同一条逻辑链。 +2. 作品测试中点击战斗选项,不会再因为旧测试链分叉而跳过整场战斗。 +3. 作品测试中同伴站位、显示和按速度参战行为,与正式游戏一致。 +4. 作品测试退出后,仍能正确回到结果页。 +5. 作品测试默认不污染正式存档。 + +## 5.1 当前已落地的入口收口 + +本轮已经把以下入口统一到同一条 `play` 运行链: + +1. 结果页 `作品测试` +2. `RpgRuntimeApp` 自定义世界启动 intent +3. `useRpgSessionBootstrap.handleCustomWorldSelect(...)` +4. 编辑器内 `SceneActPreviewRuntime` 的幕预览启动状态 + +现在这些入口的唯一差异只剩: + +1. `disablePersistence: true` +2. `returnStage: 'custom-world-result'` +3. 是否显示“结束测试”退出按钮 + +## 6. 本轮补充修正 + +在继续排查后,又确认了第二层根因: + +1. 部分“作品测试 / 幕预览”链路在执行 `npc_fight / npc_spar` 后,服务端快照已经切换成战斗状态; +2. 但返回的 snapshot 可能只带回 `currentBattleNpcId / currentNpcBattleMode / inBattle`; +3. 没有同步把 `sceneHostileNpcs` 一起补齐; +4. 这会导致前端本地 `battlePlan` 在点击 `battle_*` 时看到“当前战场为空”,从而直接把整场战斗判定为结束,体感上就像“点一下战斗选项直接跳过整场”。 + +因此除入口模式对齐外,还必须增加一层运行态网关兜底: + +1. 当 `npc_fight / npc_spar` 返回的 snapshot 已经进入战斗; +2. 且 `sceneHostileNpcs` 仍为空; +3. 且前一刻仍握有当前 NPC 遭遇; +4. 前端必须基于当前遭遇立即补建本地 NPC 战场单位,再交给后续本地逐轮结算链。 + +## 7. 本轮继续修正:幕预览敌方后排消失与站位突变 + +在继续复测后,又确认“作品测试 / 幕预览”里还有第二个更深层的问题: + +1. 点击 `战斗` 后,对面后排角色会消失; +2. 敌方会在开战瞬间整体跳位; +3. 被保留下来的往往只剩一个前排目标; +4. 这会进一步导致后排角色无法按速度参与回合。 + +### 7.1 根因梳理 + +这次不是单纯的画布渲染问题,而是战斗前后使用了两套不同的阵容真相: + +1. 幕预览和平态里,当前交互目标是 `currentEncounter`; +2. 同幕其余角色只是通过 `sceneActAmbientEncounters` 作为可见实体挂在画布后排; +3. 一旦执行 `npc_fight / npc_spar`,运行态网关此前只会根据 `currentEncounter` 调一次 `createNpcBattleMonster(...)`; +4. 这会把原本同幕存在的多角色阵容压缩成单个前排单位; +5. 战斗播放随后再基于这份“新单体阵容”计算位置,就会表现为: + - 后排消失 + - 开战瞬间跳位 + - 后排无法轮番出手 + +### 7.2 本次收口原则 + +本次修正不再允许“点击战斗”和“自动开战”各自定义自己的 NPC 战斗阵容。 + +统一规则如下: + +1. 只要是 NPC 战斗,无论来自正式运行、结果页作品测试还是幕预览; +2. 都必须先解析当前幕 active act 的 `encounterNpcIds`; +3. 若当前幕存在多名同场角色,则统一生成完整敌方战斗编队; +4. 编队位置固定为: + - 主对手保留前排中心位 + - 同幕后排角色保留后排上下位 +5. runtime gateway 在服务端快照缺失 `sceneHostileNpcs` 时,优先复用这份完整编队; +6. 若上一帧已经存在敌方战斗阵容,则优先沿用上一帧的 `xMeters / yOffset / facing`,避免位置突变。 + +### 7.3 落地结果 + +本轮已将以下两条链收口为同一套 NPC 战斗编队生成逻辑: + +1. `sceneEncounterPreviews.ts` + - 负责幕预览 / 自动开战时,把 active act 中的多名 encounter NPC 转成完整战斗编队 +2. `rpgRuntimeStoryGateway.ts` + - 负责 `npc_fight / npc_spar` 返回空战场快照时,按同一套规则恢复完整敌方阵容 + +这样可以保证: + +1. 后排角色不会在开战时丢失; +2. 敌方整体不会因为单体兜底而跳到新的默认位置; +3. 后排角色会进入 battle plan,按自身速度轮番出手; +4. 作品测试、幕预览与正式运行的战斗开场表现继续收口一致。 + +## 8. 本轮继续修正:战斗开场敌方整队右下偏移 + +在用户直接通过 in-app 浏览器复现并停留在 bug 现场后,本轮没有再只依赖推断日志,而是直接对照当前战斗画面与渲染层实现继续下钻。 + +### 8.1 现场特征 + +本次现场有两个关键事实: + +1. 浏览器控制台没有额外报错,说明不是运行时异常把敌人“删掉”; +2. 敌方角色仍然存在,但会在进入战斗瞬间整体偏到右下,说明问题发生在战斗态画布定位阶段。 + +### 8.2 最终根因 + +继续对照 `GameCanvasEntityLayer.tsx` 后确认,真正导致“刚进入战斗敌方整队瞬移”的原因之一是: + +1. 部分作品测试敌方角色使用了自定义世界场景立绘; +2. 这类角色在 `getEncounterCharacterOpponentBottom(...)` 中已经按场景立绘脚底锚点完成过一次落地修正; +3. 进入战斗后,`GameCanvasEntityLayer` 又把 `getSceneNpcVisualBottomOffsetPx(...)` 额外叠加到 battle entity bottom; +4. 于是同一个 `78px` 的场景立绘下沉偏移被重复应用了一次; +5. 视觉上就会表现为敌方整队在开战瞬间突然向右下坠落。 + +### 8.3 本次修正 + +本轮对战斗态敌方底边计算做了收口: + +1. 若敌方是 `characterId` 对应的角色型 NPC,则战斗态不再重复叠加 `getSceneNpcVisualBottomOffsetPx(...)`; +2. 保留 `getEncounterCharacterOpponentBottom(...)` 的单次场景立绘脚底修正; +3. 非角色型怪物 / 通用 NPC 仍沿用既有底边偏移逻辑。 + +### 8.4 验证 + +本轮新增并通过了回归验证: + +1. 自定义世界角色型敌人在战斗态不会再重复叠加场景立绘下沉偏移; +2. 相关战斗编队、runtime gateway 与 battle plan 既有回归继续通过。 diff --git a/server-rs/crates/module-runtime-story-compat/src/battle.rs b/server-rs/crates/module-runtime-story-compat/src/battle.rs index a380d118..39739529 100644 --- a/server-rs/crates/module-runtime-story-compat/src/battle.rs +++ b/server-rs/crates/module-runtime-story-compat/src/battle.rs @@ -56,6 +56,37 @@ struct BattleInventoryItemView { use_profile: Option, } +/// 兼容战斗结算的胜负状态。 +/// +/// 这里显式补齐失败分支,避免“玩家已死但敌方也被打空时”被错误归类成胜利。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BattleResolutionOutcome { + Ongoing, + Victory, + SparComplete, + Defeat, +} + +impl BattleResolutionOutcome { + fn as_battle_outcome(self) -> &'static str { + match self { + Self::Ongoing => "ongoing", + Self::Victory => "victory", + Self::SparComplete => "spar_complete", + Self::Defeat => "defeat", + } + } + + fn as_status_outcome(self) -> Option<&'static str> { + match self { + Self::Ongoing => None, + Self::Victory => Some("fight_victory"), + Self::SparComplete => Some("spar_complete"), + Self::Defeat => Some("fight_defeat"), + } + } +} + pub fn resolve_battle_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, @@ -114,43 +145,31 @@ pub fn resolve_battle_action( increment_runtime_stat(game_state, "itemsUsed", 1); } - apply_player_damage(game_state, plan.damage_taken); + let player_hp = apply_player_damage(game_state, plan.damage_taken); let target_hp = apply_target_damage(game_state, plan.damage_dealt); - let outcome = if target_hp <= 0 { - if battle_mode == "spar" { - "spar_complete" - } else { - "victory" - } - } else { - "ongoing" - }; + let outcome = resolve_battle_resolution_outcome(player_hp, target_hp, battle_mode.as_str()); - let victory_experience = if outcome == "victory" { + let victory_experience = if outcome == BattleResolutionOutcome::Victory { battle_victory_experience_reward(game_state) } else { 0 }; - if outcome != "ongoing" { + if outcome != BattleResolutionOutcome::Ongoing { write_bool_field(game_state, "inBattle", false); write_bool_field(game_state, "npcInteractionActive", false); write_null_field(game_state, "currentNpcBattleMode"); - write_string_field( - game_state, - "currentNpcBattleOutcome", - if outcome == "spar_complete" { - "spar_complete" - } else { - "fight_victory" - }, - ); - if outcome == "victory" { + if let Some(status_outcome) = outcome.as_status_outcome() { + write_string_field(game_state, "currentNpcBattleOutcome", status_outcome); + } + if outcome == BattleResolutionOutcome::Victory { clear_encounter_only(game_state); increment_runtime_stat(game_state, "hostileNpcsDefeated", 1); if victory_experience > 0 { grant_player_progression_experience(game_state, victory_experience, "hostile_npc"); } + } else if outcome == BattleResolutionOutcome::Defeat { + clear_encounter_state(game_state); } } @@ -160,20 +179,22 @@ pub fn resolve_battle_action( target_id: Some(target_id.clone()), damage_dealt: Some(plan.damage_dealt), damage_taken: Some(plan.damage_taken), - outcome: outcome.to_string(), + outcome: outcome.as_battle_outcome().to_string(), }, build_status_patch(game_state), ]; - if outcome == "victory" { + if outcome == BattleResolutionOutcome::Victory { patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None }); } Ok(StoryResolution { action_text: resolve_action_text(plan.action_text.as_str(), request), - result_text: if outcome == "ongoing" { + result_text: if outcome == BattleResolutionOutcome::Ongoing { plan.result_text - } else if outcome == "spar_complete" { + } else if outcome == BattleResolutionOutcome::SparComplete { format!("{target_name} 收住了最后一击,这场切磋已经分出结果。") + } else if outcome == BattleResolutionOutcome::Defeat { + format!("你在与 {target_name} 的交锋中被压制倒下,这场战斗以你的败北收束。") } else { format!("{target_name} 被你压制下去,眼前的战斗已经结束。") }, @@ -186,7 +207,7 @@ pub fn resolve_battle_action( target_name: Some(target_name), damage_dealt: Some(plan.damage_dealt), damage_taken: Some(plan.damage_taken), - outcome: Some(outcome.to_string()), + outcome: Some(outcome.as_battle_outcome().to_string()), }), toast: battle_action_toast(function_id, request), }) @@ -263,12 +284,15 @@ fn spend_player_mana(game_state: &mut Value, mana_cost: i32) { write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0)); } -fn apply_player_damage(game_state: &mut Value, damage: i32) { - if damage <= 0 { - return; - } - let hp = read_i32_field(game_state, "playerHp").unwrap_or(1); - write_i32_field(game_state, "playerHp", (hp - damage).max(1)); +fn apply_player_damage(game_state: &mut Value, damage: i32) -> i32 { + let hp = read_i32_field(game_state, "playerHp").unwrap_or(1).max(0); + let next_hp = if damage <= 0 { + hp + } else { + (hp - damage).max(0) + }; + write_i32_field(game_state, "playerHp", next_hp); + next_hp } fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 { @@ -290,6 +314,27 @@ fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 { next_hp } +fn resolve_battle_resolution_outcome( + player_hp: i32, + target_hp: i32, + battle_mode: &str, +) -> BattleResolutionOutcome { + // 中文注释:玩家死亡优先级高于敌方倒地。 + // 这样即便同一回合双方都被打到 0,也必须按玩家败北处理,不能误发胜利。 + if player_hp <= 0 { + return BattleResolutionOutcome::Defeat; + } + if target_hp <= 0 { + if battle_mode == "spar" { + BattleResolutionOutcome::SparComplete + } else { + BattleResolutionOutcome::Victory + } + } else { + BattleResolutionOutcome::Ongoing + } +} + fn read_player_skills(game_state: &Value) -> Vec { read_field(game_state, "playerCharacter") .map(|character| read_array_field(character, "skills")) diff --git a/server-rs/crates/module-runtime-story-compat/src/battle_tests.rs b/server-rs/crates/module-runtime-story-compat/src/battle_tests.rs new file mode 100644 index 00000000..4d6c1263 --- /dev/null +++ b/server-rs/crates/module-runtime-story-compat/src/battle_tests.rs @@ -0,0 +1,87 @@ +use serde_json::json; + +use shared_contracts::runtime_story::{RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryPatch}; + +use crate::{ + battle::resolve_battle_action, + build_status_patch, + read_bool_field, + read_i32_field, + read_optional_string_field, +}; + +fn build_battle_fixture() -> serde_json::Value { + json!({ + "inBattle": true, + "npcInteractionActive": false, + "playerHp": 4, + "playerMaxHp": 40, + "playerMana": 10, + "playerMaxMana": 10, + "playerSkillCooldowns": {}, + "runtimeStats": { + "hostileNpcsDefeated": 0, + "itemsUsed": 0, + "questsAccepted": 0, + "scenesTraveled": 0, + "playTimeMs": 0, + "lastPlayTickAt": null + }, + "currentNpcBattleMode": "fight", + "currentNpcBattleOutcome": null, + "currentEncounter": { + "kind": "npc", + "id": "npc_bandit_01", + "npcName": "断桥匪首", + "hostile": true, + "hp": 8, + "experienceReward": 24 + }, + "sceneHostileNpcs": [{ + "id": "npc_bandit_01", + "name": "断桥匪首", + "hp": 8, + "maxHp": 80, + "experienceReward": 24 + }] + }) +} + +fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequest { + RuntimeStoryActionRequest { + session_id: "runtime-main".to_string(), + client_version: Some(0), + action: RuntimeStoryChoiceAction { + action_type: "story_choice".to_string(), + function_id: function_id.to_string(), + target_id: None, + payload: Some(json!({ + "optionText": option_text + })), + }, + snapshot: None, + } +} + +#[test] +fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() { + let request = build_request("battle_all_in_crush", "全力压制"); + let mut game_state = build_battle_fixture(); + + let resolution = resolve_battle_action(&mut game_state, &request, "battle_all_in_crush") + .expect("battle action should resolve"); + + assert_eq!(read_i32_field(&game_state, "playerHp"), Some(0)); + assert_eq!( + read_optional_string_field(&game_state, "currentNpcBattleOutcome"), + Some("fight_defeat".to_string()) + ); + assert_eq!(read_bool_field(&game_state, "inBattle"), Some(false)); + assert!(resolution.result_text.contains("败北")); + assert!(matches!( + resolution.patches.first(), + Some(RuntimeStoryPatch::BattleResolved { outcome, .. }) if outcome == "defeat" + )); + assert_eq!(resolution.patches.get(1), Some(&build_status_patch(&game_state))); + assert_eq!(resolution.battle.and_then(|battle| battle.outcome), Some("defeat".to_string())); +} diff --git a/server-rs/crates/module-runtime-story-compat/src/lib.rs b/server-rs/crates/module-runtime-story-compat/src/lib.rs index af4758a1..b3f4f161 100644 --- a/server-rs/crates/module-runtime-story-compat/src/lib.rs +++ b/server-rs/crates/module-runtime-story-compat/src/lib.rs @@ -5,6 +5,8 @@ use shared_contracts::runtime_story::{ }; pub mod battle; +#[cfg(test)] +mod battle_tests; pub mod core; pub mod forge; pub mod forge_actions; diff --git a/src/App.tsx b/src/App.tsx index 08428da2..99acc24b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -102,6 +102,8 @@ export default function App() { kind: 'custom-world', profile: customWorldProfile, mode: options?.mode ?? 'play', + disablePersistence: options?.disablePersistence, + exitToResult: options?.returnStage === 'custom-world-result', }); }, [createRuntimeIntent], diff --git a/src/RpgRuntimeApp.tsx b/src/RpgRuntimeApp.tsx index adbc58cd..1706fe30 100644 --- a/src/RpgRuntimeApp.tsx +++ b/src/RpgRuntimeApp.tsx @@ -10,7 +10,9 @@ export type RpgRuntimeAppIntent = token: number; kind: 'custom-world'; profile: CustomWorldProfile; - mode?: 'play' | 'test'; + mode?: 'play'; + disablePersistence?: boolean; + exitToResult?: boolean; } | { token: number; @@ -37,6 +39,7 @@ export function RpgRuntimeApp({ if (initialIntent.kind === 'custom-world') { gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile, { mode: initialIntent.mode ?? 'play', + disablePersistence: initialIntent.disablePersistence, }); return; } @@ -44,7 +47,16 @@ export function RpgRuntimeApp({ gameShellProps.entry.handleContinueGame(initialIntent.snapshot); }, [gameShellProps.entry, initialIntent]); - return ; + return ( + + ); } export default RpgRuntimeApp; diff --git a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx index fae85db4..d73702fe 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx @@ -14,6 +14,7 @@ import { getEncounterCharacterBottomOffsetPx, getEncounterCharacterOpponentBottom, getHostileNpcSceneBottomOffsetPx, + getMonsterWorldLeft, getMirroredStageEntityLeft, getNpcCombatHpTop, getSceneNpcVisualBottomOffsetPx, @@ -173,6 +174,59 @@ describe('GameCanvasEntityLayer', () => { expect(getNpcCombatHpTop(null, null)).toBe(CHARACTER_COMBAT_HP_TOP_PX); }); + it('does not apply scene visual ground offset twice for custom character enemies in battle', () => { + const hostileNpc = createHostileNpc({ + encounter: createEncounter({ + id: 'npc-shark-elder', + npcName: '珊瑚长老', + characterId: 'hero', + imageSrc: '/generated-custom-world-npc/shark-elder.png', + }), + }); + + const html = renderToStaticMarkup( + '70%'} + groundBottom="18%" + stageLiftPx={68} + encounter={null} + sideAnchor="15%" + cameraAnchorX={0} + monsterAnchorMeters={3.2} + playerX={0} + />, + ); + + expect(html).toContain('bottom:calc(calc(18% + 68px) + -78px)'); + expect(html).not.toContain('bottom:calc(calc(18% + 68px - 78px) + -78px)'); + }); + it('renders affinity effect on the matching hostile npc', () => { const html = renderEntityLayer('npc-liu'); @@ -283,4 +337,160 @@ describe('GameCanvasEntityLayer', () => { expect(html).toContain('查看后排甲详情'); expect(html).toContain('查看后排乙详情'); }); + + it('keeps hostile combatant identity stable while attack position changes', () => { + const sideAnchor = '15%'; + const cameraAnchorX = 0; + const monsterAnchorMeters = 3.2; + const attackingNpc = createHostileNpc({ + id: 'npc-attacker', + xMeters: 0.1, + animation: 'attack', + combatMode: 'melee', + encounter: createEncounter({ + id: 'npc-attacker', + npcName: '突进敌人', + }), + }); + + const renderedLeft = getMonsterWorldLeft( + sideAnchor, + attackingNpc.xMeters, + cameraAnchorX, + monsterAnchorMeters, + ); + + const html = renderToStaticMarkup( + + getMonsterWorldLeft( + sideAnchor, + hostileNpc.xMeters, + cameraAnchorX, + monsterAnchorMeters, + ) + } + groundBottom="18%" + stageLiftPx={68} + encounter={null} + sideAnchor={sideAnchor} + cameraAnchorX={cameraAnchorX} + monsterAnchorMeters={monsterAnchorMeters} + playerX={0} + />, + ); + + expect(html).toContain(`left:${renderedLeft}`); + }); + + it('keeps enemy formation positions when battle starts before any attack dash', () => { + const sideAnchor = '15%'; + const cameraAnchorX = 0; + const monsterAnchorMeters = 3.2; + const frontNpc = createHostileNpc({ + id: 'npc-front', + xMeters: 3.2, + encounter: createEncounter({ + id: 'npc-front', + npcName: '前排敌人', + }), + }); + const backNpc = createHostileNpc({ + id: 'npc-back', + xMeters: 4.28, + yOffset: 62, + encounter: createEncounter({ + id: 'npc-back', + npcName: '后排敌人', + }), + }); + + const html = renderToStaticMarkup( + + getMonsterWorldLeft( + sideAnchor, + hostileNpc.xMeters, + cameraAnchorX, + monsterAnchorMeters, + ) + } + groundBottom="18%" + stageLiftPx={68} + encounter={null} + sideAnchor={sideAnchor} + cameraAnchorX={cameraAnchorX} + monsterAnchorMeters={monsterAnchorMeters} + playerX={0} + />, + ); + + const frontLeft = `left:${getMonsterWorldLeft( + sideAnchor, + frontNpc.xMeters, + cameraAnchorX, + monsterAnchorMeters, + )}`; + const backLeft = `left:${getMonsterWorldLeft( + sideAnchor, + backNpc.xMeters, + cameraAnchorX, + monsterAnchorMeters, + )}`; + + expect(html).toContain(frontLeft); + expect(html).toContain(backLeft); + }); }); diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx index 7db5f3f8..aafe61bd 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx @@ -445,8 +445,6 @@ export function GameCanvasEntityLayer({ const hostileRenderKey = [ hostileNpc.id, npcEncounter.id ?? npcEncounter.npcName, - hostileNpc.xMeters, - hostileNpc.yOffset ?? 0, index, ].join(':'); const config = monsters.find(item => item.id === hostileNpc.id); @@ -469,18 +467,25 @@ export function GameCanvasEntityLayer({ npcMonsterConfig ? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig) : getSceneNpcVisualBottomOffsetPx(npcEncounter); + // 中文注释:带 characterId 的自定义世界角色在 getEncounterCharacterOpponentBottom() + // 里已经按场景立绘脚底锚点完成了一次落地修正。 + // 若这里再把 getSceneNpcVisualBottomOffsetPx() 叠加到战斗实体底边, + // 就会在刚进入战斗时整队额外下沉 78px,表现成敌方瞬间偏到右下角。 + const battleEntityVisualOffsetPx = npcCharacter + ? 0 + : hostileNpcBottomOffsetPx; const opponentBottom = npcCharacter ? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter) : `calc(${groundBottom} + ${stageLiftPx}px)`; - const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`; + const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx}px)`; const entityBottomOffsetPx = npcCharacter ? getEncounterCharacterBottomOffsetPx( stageLiftPx, npcEncounter, npcCharacter, - (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx, + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx, ) - : stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx; + : stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx; return (
{ - if (!scrollWorld && hostileNpc.animation !== 'attack') { - return opponentStageLeft; + if (hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld) { + return monsterMeleeLeft; } - const baseLeft = - hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld - ? monsterMeleeLeft - : getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters); - return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`; + return getMonsterWorldLeft( + sideAnchor, + hostileNpc.xMeters, + cameraAnchorX, + monsterAnchorMeters, + ); }; const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => { const base = playerActionMode === 'melee' && !scrollWorld diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index b6905707..805b67dc 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -4,10 +4,11 @@ import type { import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { CustomWorldProfile } from '../../types'; -export type CustomWorldRuntimeLaunchMode = 'play' | 'test'; +export type CustomWorldRuntimeLaunchMode = 'play'; export type CustomWorldRuntimeLaunchOptions = { mode?: CustomWorldRuntimeLaunchMode; + disablePersistence?: boolean; returnStage?: SelectionStage | null; }; diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx index b76db29a..064c6dc5 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx @@ -1,11 +1,16 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, 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'; +import { + PuzzleRuntimeShell, + resolveDraggedMergedGroupLayer, + resolveDraggedPieceCellLayer, + resolveDraggedPieceLayer, +} from './PuzzleRuntimeShell'; vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ useResolvedAssetReadUrl: () => ({ @@ -247,3 +252,16 @@ test('合并块按实际拼块外轮廓描边', () => { expect(outlinedPieces[2]?.className).toContain('border-t-0'); expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]'); }); + +test('拖拽层级辅助函数只提升当前被拖动对象', () => { + expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', false)).toBe(80); + expect(resolveDraggedPieceCellLayer('piece-0', 'piece-1', false)).toBeUndefined(); + expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', true)).toBeUndefined(); + + expect(resolveDraggedPieceLayer('piece-0', 'piece-0', false)).toBe(81); + expect(resolveDraggedPieceLayer('piece-0', null, false)).toBeUndefined(); + expect(resolveDraggedPieceLayer('piece-0', 'piece-0', true)).toBeUndefined(); + + expect(resolveDraggedMergedGroupLayer('group-1', 'group-1')).toBe(90); + expect(resolveDraggedMergedGroupLayer('group-1', 'group-2')).toBeUndefined(); +}); diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index ec536065..8f4f6cad 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -65,6 +65,35 @@ function buildLocalCellKey(row: number, col: number) { return `${row}:${col}`; } +export function resolveDraggedPieceCellLayer( + pieceId: string | null | undefined, + draggingPieceId: string | null, + isMerged: boolean, +) { + if (!pieceId || isMerged || pieceId !== draggingPieceId) { + return undefined; + } + return 80; +} + +export function resolveDraggedPieceLayer( + pieceId: string | null | undefined, + draggingPieceId: string | null, + isMerged: boolean, +) { + if (!pieceId || isMerged || pieceId !== draggingPieceId) { + return undefined; + } + return 81; +} + +export function resolveDraggedMergedGroupLayer( + groupId: string, + draggingGroupId: string | null, +) { + return groupId === draggingGroupId ? 90 : undefined; +} + function resolveMergedPieceOutlineClass( group: PuzzleMergedGroupViewModel, piece: PuzzleMergedGroupViewModel['pieces'][number], @@ -186,8 +215,13 @@ export function PuzzleRuntimeShell({ } | null>(null); const dragVisualFrameRef = useRef(null); const dragOffsetRef = useRef<{ x: number; y: number } | null>(null); + const pieceCellElementRefMap = useRef(new Map()); const pieceElementRefMap = useRef(new Map()); const groupElementRefMap = useRef(new Map()); + const [dragRenderTarget, setDragRenderTarget] = useState<{ + pieceId: string; + groupId: string | null; + } | null>(null); const [dismissedClearKey, setDismissedClearKey] = useState( null, ); @@ -250,8 +284,18 @@ export function PuzzleRuntimeShell({ [pieces], ); + const resolvePieceCellElement = (pieceId: string) => { + const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null; + const pieceCellElement = + (pieceElement?.parentElement as HTMLDivElement | null) ?? + pieceCellElementRefMap.current.get(pieceId) ?? + null; + return pieceCellElement; + }; + const resetDragVisualTarget = () => { const dragVisualTarget = dragVisualTargetRef.current; + setDragRenderTarget(null); if (!dragVisualTarget) { return; } @@ -259,6 +303,10 @@ export function PuzzleRuntimeShell({ const pieceElement = pieceElementRefMap.current.get( dragVisualTarget.pieceId, ); + const pieceCellElement = resolvePieceCellElement(dragVisualTarget.pieceId); + if (pieceCellElement) { + pieceCellElement.style.zIndex = ''; + } if (pieceElement) { pieceElement.style.transform = ''; pieceElement.style.willChange = ''; @@ -319,6 +367,15 @@ export function PuzzleRuntimeShell({ resetDragVisualTarget(); } dragVisualTargetRef.current = nextTarget; + setDragRenderTarget((currentTarget) => { + if ( + currentTarget?.pieceId === nextTarget.pieceId && + currentTarget.groupId === nextTarget.groupId + ) { + return currentTarget; + } + return nextTarget; + }); const offsetX = dragSession.currentX - dragSession.startX; const offsetY = dragSession.currentY - dragSession.startY; @@ -327,11 +384,16 @@ export function PuzzleRuntimeShell({ if (groupId) { const groupElement = groupElementRefMap.current.get(groupId); if (groupElement) { + // 合并块拖动时直接提升整个组容器层级,确保完整拼块永远压在单块之上。 groupElement.style.willChange = 'transform'; groupElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.02)`; - groupElement.style.zIndex = '80'; + groupElement.style.zIndex = '90'; groupElement.style.opacity = '0.95'; } + const pieceCellElement = resolvePieceCellElement(dragSession.pieceId); + if (pieceCellElement) { + pieceCellElement.style.zIndex = ''; + } const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId); if (pieceElement) { pieceElement.style.transform = ''; @@ -342,11 +404,16 @@ export function PuzzleRuntimeShell({ return; } + const pieceCellElement = resolvePieceCellElement(dragSession.pieceId); + if (pieceCellElement) { + // 单块拖动时提升所属格子的堆叠层级,避免被后绘制的拼块或合并块遮住。 + pieceCellElement.style.zIndex = '80'; + } const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId); if (pieceElement) { pieceElement.style.willChange = 'transform'; pieceElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.03)`; - pieceElement.style.zIndex = '70'; + pieceElement.style.zIndex = '81'; pieceElement.style.opacity = '0.95'; } }; @@ -559,7 +626,8 @@ export function PuzzleRuntimeShell({ return; } - // 拖动中的视觉更新直接写入 DOM transform,避免 pointermove 触发整盘 React 重渲染导致跟手延迟。 + // 首帧拖拽反馈立即落到 DOM,确保层级提升不会滞后一帧;后续仍保留 raf 兜底连续刷新。 + flushDragVisual(); scheduleDragVisual(); }; @@ -575,6 +643,8 @@ export function PuzzleRuntimeShell({ currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey && isClearResultReady; + const draggingPieceId = dragRenderTarget?.pieceId ?? null; + const draggingGroupId = dragRenderTarget?.groupId ?? null; return (
@@ -643,7 +713,28 @@ export function PuzzleRuntimeShell({ const isSelected = piece?.pieceId === selectedPieceId; return ( -
+
{ + if (!piece) { + return; + } + if (node) { + pieceCellElementRefMap.current.set(piece.pieceId, node); + return; + } + pieceCellElementRefMap.current.delete(piece.pieceId); + }} + data-piece-cell-id={piece?.pieceId ?? undefined} + className="relative p-1" + style={{ + zIndex: resolveDraggedPieceCellLayer( + piece?.pieceId, + draggingPieceId, + isMerged, + ), + }} + >
{ if (!piece) { @@ -656,7 +747,7 @@ export function PuzzleRuntimeShell({ pieceElementRefMap.current.delete(piece.pieceId); }} data-piece-id={piece?.pieceId ?? undefined} - className={`flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${ + className={`relative flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${ occupied ? isSelected ? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]' @@ -669,6 +760,13 @@ export function PuzzleRuntimeShell({ ? 'transition-colors' : 'transition-[background-color,border-color,box-shadow,opacity]' }`} + style={{ + zIndex: resolveDraggedPieceLayer( + piece?.pieceId, + draggingPieceId, + isMerged, + ), + }} onPointerDown={(event) => { if (!piece || isMerged) { return; @@ -734,8 +832,13 @@ export function PuzzleRuntimeShell({ } groupElementRefMap.current.delete(group.groupId); }} + data-merged-group-id={group.groupId} className="pointer-events-none absolute z-10 p-1" style={{ + zIndex: resolveDraggedMergedGroupLayer( + group.groupId, + draggingGroupId, + ), left: `${(group.minCol / board.cols) * 100}%`, top: `${(group.minRow / board.rows) * 100}%`, width: `${(group.colSpan / board.cols) * 100}%`, diff --git a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx index 2f010b52..db5fac0d 100644 --- a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx +++ b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx @@ -2070,8 +2070,9 @@ function SceneActPreviewRuntime({ ...current, worldType: WorldType.CUSTOM, customWorldProfile: profile, - // 中文注释:幕预览只复用运行时表现,不应进入正式存档和个人游玩记录。 - runtimeMode: 'preview', + // 中文注释:幕预览也统一复用正式 play 运行链, + // 只通过禁持久化控制“不写正式存档”。 + runtimeMode: 'play', runtimePersistenceDisabled: true, currentScene: 'Story', currentScenePreset: previewScenePreset, diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 3b84e2be..f22cd9c3 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -2527,7 +2527,8 @@ test('agent draft result test button enters current draft without publish gate', expect(handleCustomWorldSelect).toHaveBeenCalledWith( expect.objectContaining({ name: '潮雾列岛' }), expect.objectContaining({ - mode: 'test', + mode: 'play', + disablePersistence: true, returnStage: 'custom-world-result', }), ); diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx index 732811ea..4f0ef828 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx @@ -114,10 +114,10 @@ function buildSession(): CustomWorldAgentSessionSnapshot { } describe('useRpgCreationEnterWorld', () => { - it('Agent 草稿测试进入游戏时使用结果页当前 profile 的角色形象', async () => { + it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => { const staleResultProfile = buildProfile({ - id: 'stale-result', - name: '旧结果页快照', + id: 'session-profile', + name: '会话旧快照', imageSrc: '/template/old-role.png', }); const resultProfile = buildProfile({ @@ -133,8 +133,8 @@ describe('useRpgCreationEnterWorld', () => { const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({ isAgentDraftResultView: true, activeAgentSessionId: 'session-1', - generatedCustomWorldProfile: staleResultProfile, - agentSessionProfile: resultProfile, + generatedCustomWorldProfile: resultProfile, + agentSessionProfile: staleResultProfile, agentSession: buildSession(), handleCustomWorldSelect, executePublishWorld, @@ -158,9 +158,11 @@ describe('useRpgCreationEnterWorld', () => { expect(executePublishWorld).not.toHaveBeenCalled(); expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, { - mode: 'test', + mode: 'play', + disablePersistence: true, returnStage: 'custom-world-result', }); + expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(resultProfile); expect( handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc, ).toBe('/generated-characters/draft-role/portrait.png'); diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.ts b/src/components/rpg-entry/useRpgCreationEnterWorld.ts index dbb6edba..052a8721 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.ts +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.ts @@ -21,7 +21,7 @@ type UseRpgCreationEnterWorldParams = { /** * 统一“进入世界”前的最终同步策略。 - * Agent 草稿结果进入游戏时只读当前结果页 profile,不再静默回退到基础 draftProfile。 + * Agent 草稿结果进入游戏时只读当前结果页 profile,不再静默回退到会话侧旧快照。 */ export function useRpgCreationEnterWorld( params: UseRpgCreationEnterWorldParams, @@ -42,26 +42,22 @@ export function useRpgCreationEnterWorld( return; } - if (!isAgentDraftResultView || !activeAgentSessionId) { - handleCustomWorldSelect(generatedCustomWorldProfile, { - mode: 'test', - returnStage: 'custom-world-result', - }); - return; + // 中文注释:作品测试必须复用“结果页当前真相源”。 + // 用户在结果页看到并可能继续编辑的是 generatedCustomWorldProfile; + // 如果这里又回退成会话里的 agentSessionProfile,就会出现 + // “结果页看起来已经是新版,但作品测试实际进入的是旧版快照”的错位。 + if (isAgentDraftResultView && activeAgentSessionId) { + setGeneratedCustomWorldProfile(generatedCustomWorldProfile); } - - if (!agentSessionProfile) { - return; - } - - setGeneratedCustomWorldProfile(agentSessionProfile); - handleCustomWorldSelect(agentSessionProfile, { - mode: 'test', + handleCustomWorldSelect(generatedCustomWorldProfile, { + // 中文注释:作品测试现在复用正式 play 运行链,只保留 + // “返回结果页 + 禁止写正式持久化”的入口语义。 + mode: 'play', + disablePersistence: true, returnStage: 'custom-world-result', }); }, [ activeAgentSessionId, - agentSessionProfile, generatedCustomWorldProfile, handleCustomWorldSelect, isAgentDraftResultView, diff --git a/src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx b/src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx index de1630d7..c71a385c 100644 --- a/src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx +++ b/src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx @@ -83,7 +83,10 @@ vi.mock('./RpgRuntimeStageRouter', () => ({ let mockVisibleGameState: GameState; -function createGameState(runtimeMode: GameState['runtimeMode']): GameState { +function createGameState( + runtimeMode: GameState['runtimeMode'], + runtimePersistenceDisabled?: boolean, +): GameState { return { worldType: WorldType.CUSTOM, customWorldProfile: null, @@ -112,7 +115,7 @@ function createGameState(runtimeMode: GameState['runtimeMode']): GameState { initialItems: [], }, runtimeMode, - runtimePersistenceDisabled: runtimeMode !== 'play', + runtimePersistenceDisabled: runtimePersistenceDisabled ?? false, runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, @@ -168,8 +171,11 @@ function createGameState(runtimeMode: GameState['runtimeMode']): GameState { }; } -function buildProps(runtimeMode: GameState['runtimeMode']): RpgRuntimeShellProps { - const gameState = createGameState(runtimeMode); +function buildProps( + runtimeMode: GameState['runtimeMode'], + runtimePersistenceDisabled?: boolean, +): RpgRuntimeShellProps { + const gameState = createGameState(runtimeMode, runtimePersistenceDisabled); mockVisibleGameState = gameState; return { session: { @@ -254,24 +260,25 @@ beforeEach(() => { mockVisibleGameState = createGameState('play'); }); -test('测试态显示结束测试按钮并触发退出回调', async () => { +test('结果页测试入口可显示结束测试按钮并触发退出回调', async () => { const user = userEvent.setup(); - const onExitTestRuntime = vi.fn(); + const onExitRuntimePreview = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: '结束测试' })); - expect(onExitTestRuntime).toHaveBeenCalledTimes(1); + expect(onExitRuntimePreview).toHaveBeenCalledTimes(1); }); test('正式运行态不显示结束测试按钮', () => { - render( {}} />); + render(); expect(screen.queryByRole('button', { name: '结束测试' })).toBeNull(); }); diff --git a/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx b/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx index 68ef22d5..d4d5e592 100644 --- a/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx +++ b/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx @@ -37,7 +37,8 @@ export function RpgRuntimeShell({ companions, audio, chrome, - onExitTestRuntime, + onExitRuntimePreview, + showRuntimePreviewExit, }: RpgRuntimeShellComponentProps) { const authUi = useAuthUi(); const isPlatformShell = !session.gameState.worldType; @@ -133,7 +134,10 @@ export function RpgRuntimeShell({ playerProgression.currentLevelXp / playerProgression.xpToNextLevel, ), ); - const isTestRuntime = gameState.runtimeMode === 'test'; + const canExitRuntimePreview = + Boolean(gameState.worldType) && + Boolean(showRuntimePreviewExit) && + Boolean(onExitRuntimePreview); useEffect(() => { if (gameState.worldType && !gameState.playerCharacter) { @@ -209,7 +213,7 @@ export function RpgRuntimeShell({
)} - {gameState.worldType && isTestRuntime && onExitTestRuntime ? ( + {canExitRuntimePreview ? (
@@ -528,8 +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().runtimeMode).toBe('play'); + expect(readSnapshot().runtimePersistenceDisabled).toBe(false); expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃'); expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页'); expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃'); diff --git a/src/services/aiService.ts b/src/services/aiService.ts index d28aefd7..8297632a 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -436,7 +436,7 @@ export async function streamNpcChatTurn( combatContext?: { summary: string; logLines: string[]; - battleOutcome: 'victory' | 'spar_complete'; + battleOutcome: 'victory' | 'defeat' | 'spar_complete'; } | null; chatDirective?: NpcChatTurnDirective | null; npcInitiatesConversation?: boolean; diff --git a/src/types/core.ts b/src/types/core.ts index 1db421db..f53a51f6 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -94,7 +94,7 @@ export type NpcInteractionAction = | 'quest_turn_in'; export type TreasureInteractionAction = 'secure' | 'inspect' | 'leave'; export type NpcBattleMode = 'fight' | 'spar'; -export type NpcBattleOutcome = 'fight_victory' | 'spar_complete'; +export type NpcBattleOutcome = 'fight_victory' | 'fight_defeat' | 'spar_complete'; export type CombatDelivery = 'melee' | 'ranged'; export type CombatActionMode = 'idle' | CombatDelivery; export type SkillEffectPhase = 'cast' | 'travel' | 'impact'; diff --git a/src/types/story.ts b/src/types/story.ts index 466fda71..836da947 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -138,7 +138,7 @@ export interface StoryNpcChatState { combatContext?: { summary: string; logLines: string[]; - battleOutcome: 'victory' | 'spar_complete'; + battleOutcome: 'victory' | 'defeat' | 'spar_complete'; } | null; }