1
This commit is contained in:
@@ -66,3 +66,14 @@
|
||||
1. 继续复用现有 `PuzzleRuntimeShell` 作为运行时承载组件,不新增平行页面。
|
||||
2. 设置弹层沿用现有像素风弹窗资源,不单独引入新的弹窗体系。
|
||||
3. 通关演出只作为前端表现层时序,不改动通关判定与排行榜数据来源。
|
||||
|
||||
### 5. 拖拽层级规则
|
||||
|
||||
正在被拖动的拼图片必须临时提升到拼图棋盘最上层,不允许在拖动过程中被其他单块或合并块遮挡。
|
||||
|
||||
交互约束如下:
|
||||
|
||||
1. 单块拖动时,提升该拼图片所属格子的堆叠层级,并同步提升拼图片自身层级。
|
||||
2. 合并块拖动时,直接提升整组容器层级,保证整组视觉保持完整。
|
||||
3. 松手、取消拖动、或丢失指针捕获后,必须立即恢复默认层级。
|
||||
4. 这条规则只属于前端表现层,不改变拼图交换、合并、拆分和落点判定逻辑。
|
||||
|
||||
127
docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md
Normal file
127
docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md
Normal file
@@ -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. 不再出现“玩家已死却结算成战斗胜利”的串线结果
|
||||
@@ -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 既有回归继续通过。
|
||||
@@ -56,6 +56,37 @@ struct BattleInventoryItemView {
|
||||
use_profile: Option<BattleInventoryUseProfile>,
|
||||
}
|
||||
|
||||
/// 兼容战斗结算的胜负状态。
|
||||
///
|
||||
/// 这里显式补齐失败分支,避免“玩家已死但敌方也被打空时”被错误归类成胜利。
|
||||
#[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<BattleSkillView> {
|
||||
read_field(game_state, "playerCharacter")
|
||||
.map(|character| read_array_field(character, "skills"))
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 <RpgRuntimeShell {...gameShellProps} onExitTestRuntime={onExitRuntime} />;
|
||||
return (
|
||||
<RpgRuntimeShell
|
||||
{...gameShellProps}
|
||||
onExitRuntimePreview={onExitRuntime}
|
||||
showRuntimePreviewExit={
|
||||
initialIntent?.kind === 'custom-world' &&
|
||||
initialIntent.exitToResult === true
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgRuntimeApp;
|
||||
|
||||
@@ -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(
|
||||
<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={true}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.IDLE}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={null}
|
||||
sceneCombatants={[hostileNpc]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '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(
|
||||
<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={true}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.IDLE}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={null}
|
||||
sceneCombatants={[attackingNpc]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={(hostileNpc) =>
|
||||
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(
|
||||
<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={true}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.IDLE}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={null}
|
||||
sceneCombatants={[frontNpc, backNpc]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={(hostileNpc) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
getMirroredStageEntityLeft,
|
||||
getMonsterWorldLeft,
|
||||
getPlayerWorldLeft,
|
||||
HOSTILE_NPC_SCENE_INSET_PX,
|
||||
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
|
||||
SCENE_TRANSITION_SPEED_PX_PER_S,
|
||||
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
|
||||
@@ -119,7 +118,6 @@ export function GameCanvasRuntime({
|
||||
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
|
||||
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
|
||||
const playerStageLeft = getMirroredStageEntityLeft(sideAnchor, 'player');
|
||||
const opponentStageLeft = getMirroredStageEntityLeft(sideAnchor, 'opponent');
|
||||
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
|
||||
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
|
||||
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
|
||||
@@ -132,15 +130,16 @@ export function GameCanvasRuntime({
|
||||
: playerStageLeft;
|
||||
const monsterAnchorMeters = 3.2;
|
||||
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pieceCellElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const [dragRenderTarget, setDragRenderTarget] = useState<{
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
@@ -643,7 +713,28 @@ 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}`}
|
||||
ref={(node) => {
|
||||
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,
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(node) => {
|
||||
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}%`,
|
||||
|
||||
@@ -2070,8 +2070,9 @@ function SceneActPreviewRuntime({
|
||||
...current,
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: profile,
|
||||
// 中文注释:幕预览只复用运行时表现,不应进入正式存档和个人游玩记录。
|
||||
runtimeMode: 'preview',
|
||||
// 中文注释:幕预览也统一复用正式 play 运行链,
|
||||
// 只通过禁持久化控制“不写正式存档”。
|
||||
runtimeMode: 'play',
|
||||
runtimePersistenceDisabled: true,
|
||||
currentScene: 'Story',
|
||||
currentScenePreset: previewScenePreset,
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<RpgRuntimeShell
|
||||
{...buildProps('test')}
|
||||
onExitTestRuntime={onExitTestRuntime}
|
||||
{...buildProps('play', true)}
|
||||
onExitRuntimePreview={onExitRuntimePreview}
|
||||
showRuntimePreviewExit
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '结束测试' }));
|
||||
|
||||
expect(onExitTestRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(onExitRuntimePreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('正式运行态不显示结束测试按钮', () => {
|
||||
render(<RpgRuntimeShell {...buildProps('play')} onExitTestRuntime={() => {}} />);
|
||||
render(<RpgRuntimeShell {...buildProps('play')} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '结束测试' })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState.worldType && isTestRuntime && onExitTestRuntime ? (
|
||||
{canExitRuntimePreview ? (
|
||||
<div
|
||||
className="fixed inset-x-0 z-[170] flex justify-center px-4"
|
||||
style={{
|
||||
@@ -218,7 +222,7 @@ export function RpgRuntimeShell({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExitTestRuntime}
|
||||
onClick={onExitRuntimePreview}
|
||||
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"
|
||||
>
|
||||
结束测试
|
||||
|
||||
@@ -111,5 +111,6 @@ export interface RpgRuntimeShellProps {
|
||||
companions: RpgRuntimeCompanionProps;
|
||||
audio: RpgRuntimeAudioProps;
|
||||
chrome?: RpgRuntimeShellChromeOptions;
|
||||
onExitTestRuntime?: () => void;
|
||||
onExitRuntimePreview?: () => void;
|
||||
showRuntimePreviewExit?: boolean;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getMonsterPresetsByWorld } from './hostileNpcPresets';
|
||||
import { createSceneHostileNpc } from './hostileNpcs';
|
||||
import { buildInitialNpcState } from './npcInteractions';
|
||||
import {
|
||||
buildNpcBattleFormationFromEncounter,
|
||||
createSceneEncounterPreview,
|
||||
hasAutoBattleSceneEncounter,
|
||||
resolveSceneEncounterPreview,
|
||||
@@ -495,4 +496,371 @@ describe('sceneEncounterPreviews', () => {
|
||||
expect(resolved.inBattle).toBe(false);
|
||||
expect(resolved.currentEncounter?.id).toBe('npc-hostile-opposite');
|
||||
});
|
||||
|
||||
it('builds active act npc battle formations with stable back-row slots', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'npc-front',
|
||||
name: '正面对手',
|
||||
title: '刀客',
|
||||
description: '正面对手',
|
||||
initialAffinity: -30,
|
||||
imageSrc: '',
|
||||
role: '敌对角色',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
id: 'npc-back-1',
|
||||
name: '后排甲',
|
||||
title: '弓手',
|
||||
description: '后排甲',
|
||||
initialAffinity: -25,
|
||||
imageSrc: '',
|
||||
role: '敌对角色',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
id: 'npc-back-2',
|
||||
name: '后排乙',
|
||||
title: '术士',
|
||||
description: '后排乙',
|
||||
initialAffinity: -20,
|
||||
imageSrc: '',
|
||||
role: '敌对角色',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '旧桥章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-raw-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile,
|
||||
currentScenePreset: {
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
imageSrc: '/bridge.png',
|
||||
connectedSceneIds: [],
|
||||
treasureHints: [],
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc-front',
|
||||
name: '正面对手',
|
||||
description: '正面对手',
|
||||
avatar: '正',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -30,
|
||||
hostile: true,
|
||||
attributeProfile: {
|
||||
attributes: {},
|
||||
combat: {
|
||||
maxHp: 96,
|
||||
attack: 12,
|
||||
defense: 8,
|
||||
speed: 10,
|
||||
},
|
||||
} as SceneNpc['attributeProfile'],
|
||||
},
|
||||
{
|
||||
id: 'npc-back-1',
|
||||
name: '后排甲',
|
||||
description: '后排甲',
|
||||
avatar: '甲',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -25,
|
||||
hostile: true,
|
||||
attributeProfile: {
|
||||
attributes: {},
|
||||
combat: {
|
||||
maxHp: 82,
|
||||
attack: 10,
|
||||
defense: 6,
|
||||
speed: 9,
|
||||
},
|
||||
} as SceneNpc['attributeProfile'],
|
||||
},
|
||||
{
|
||||
id: 'npc-back-2',
|
||||
name: '后排乙',
|
||||
description: '后排乙',
|
||||
avatar: '乙',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
attributeProfile: {
|
||||
attributes: {},
|
||||
combat: {
|
||||
maxHp: 78,
|
||||
attack: 9,
|
||||
defense: 5,
|
||||
speed: 11,
|
||||
},
|
||||
} as SceneNpc['attributeProfile'],
|
||||
},
|
||||
] satisfies SceneNpc[],
|
||||
},
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '正',
|
||||
context: '敌对角色',
|
||||
hostile: true,
|
||||
initialAffinity: -30,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-front': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -30,
|
||||
},
|
||||
'npc-back-1': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -25,
|
||||
},
|
||||
'npc-back-2': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -20,
|
||||
},
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const formation = buildNpcBattleFormationFromEncounter({
|
||||
state,
|
||||
encounter: state.currentEncounter!,
|
||||
});
|
||||
|
||||
expect(formation).toHaveLength(3);
|
||||
expect(formation.map((monster) => monster.encounter?.id)).toEqual([
|
||||
'npc-front',
|
||||
'npc-back-1',
|
||||
'npc-back-2',
|
||||
]);
|
||||
expect(
|
||||
formation.map((monster) => ({
|
||||
id: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: 'npc-front', xMeters: 3.2, yOffset: 0 },
|
||||
{ id: 'npc-back-1', xMeters: 4.28, yOffset: 62 },
|
||||
{ id: 'npc-back-2', xMeters: 4.28, yOffset: -46 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps scene-act formation order even when the clicked encounter comes from the back row', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '海底遗址',
|
||||
description: '海底遗址',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '海底章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-raw-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile,
|
||||
currentScenePreset: {
|
||||
id: 'landmark-raw-1',
|
||||
name: '海底遗址',
|
||||
description: '海底遗址',
|
||||
imageSrc: '/underwater.png',
|
||||
connectedSceneIds: [],
|
||||
treasureHints: [],
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc-front',
|
||||
name: '珊瑚祭司',
|
||||
description: '前排祭司',
|
||||
avatar: '祭',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
},
|
||||
{
|
||||
id: 'npc-back-1',
|
||||
name: '赤发护卫',
|
||||
description: '后排护卫',
|
||||
avatar: '卫',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
},
|
||||
{
|
||||
id: 'npc-back-2',
|
||||
name: '潮歌侍从',
|
||||
description: '后排侍从',
|
||||
avatar: '侍',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
},
|
||||
] satisfies SceneNpc[],
|
||||
},
|
||||
currentEncounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '赤发护卫',
|
||||
npcDescription: '后排护卫',
|
||||
npcAvatar: '卫',
|
||||
context: '敌对角色',
|
||||
hostile: true,
|
||||
initialAffinity: -20,
|
||||
xMeters: 4.28,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-front': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -20,
|
||||
},
|
||||
'npc-back-1': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -20,
|
||||
},
|
||||
'npc-back-2': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -20,
|
||||
},
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const formation = buildNpcBattleFormationFromEncounter({
|
||||
state,
|
||||
encounter: state.currentEncounter!,
|
||||
});
|
||||
|
||||
expect(
|
||||
formation.map((monster) => ({
|
||||
id: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: 'npc-front', xMeters: 3.2, yOffset: 0 },
|
||||
{ id: 'npc-back-1', xMeters: 4.28, yOffset: 62 },
|
||||
{ id: 'npc-back-2', xMeters: 4.28, yOffset: -46 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,14 @@ import {
|
||||
resolveActiveSceneActEncounterFocusNpcId,
|
||||
resolveActiveSceneActEncounterNpcIds,
|
||||
} from '../services/customWorldSceneActRuntime';
|
||||
import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types';
|
||||
import {
|
||||
AnimationState,
|
||||
Encounter,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getRecruitedNpcIds } from './companionRoster';
|
||||
import {
|
||||
createSceneHostileNpcsFromEncounters,
|
||||
@@ -27,6 +34,41 @@ export const PREVIEW_ENTITY_X_METERS = 12;
|
||||
export const RESOLVED_ENTITY_X_METERS = 3.2;
|
||||
export const CALL_OUT_ENTRY_X_METERS = 18;
|
||||
export const TREASURE_ENCOUNTERS_ENABLED = false;
|
||||
const SCENE_ACT_BACK_ROW_BATTLE_X_METERS = Number(
|
||||
(RESOLVED_ENTITY_X_METERS + 1.08).toFixed(2),
|
||||
);
|
||||
const SCENE_ACT_BACK_ROW_BATTLE_Y_OFFSETS = [62, -46] as const;
|
||||
|
||||
function isNpcBattleAlignmentDebugEnabled() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' ||
|
||||
window.location.search.includes('npcBattleAlignmentDebug=1')
|
||||
);
|
||||
}
|
||||
|
||||
function logNpcBattleFormation(
|
||||
label: string,
|
||||
monsters: Array<Pick<SceneHostileNpc, 'id' | 'xMeters' | 'yOffset' | 'encounter'>>,
|
||||
) {
|
||||
if (!isNpcBattleAlignmentDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[npc-battle-formation] ${label}`,
|
||||
monsters.map((monster) => ({
|
||||
id: monster.id,
|
||||
encounterId: monster.encounter?.id ?? null,
|
||||
encounterName: monster.encounter?.npcName ?? null,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function getNpcEncounterKey(encounter: Encounter) {
|
||||
return encounter.id ?? encounter.npcName;
|
||||
@@ -54,18 +96,138 @@ function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounte
|
||||
return npcState.affinity < 0 || (npcState.relationState?.affinity ?? npcState.affinity) < 0;
|
||||
}
|
||||
|
||||
function resolveSceneActEncounterMembers(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) {
|
||||
const currentSceneNpcs = state.currentScenePreset?.npcs ?? [];
|
||||
if (currentSceneNpcs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
});
|
||||
if (activeActNpcIds.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenNpcIds = new Set<string>();
|
||||
|
||||
return currentSceneNpcs
|
||||
.filter((candidate) => {
|
||||
const candidateIds = [
|
||||
candidate.id,
|
||||
candidate.characterId,
|
||||
candidate.name,
|
||||
candidate.title,
|
||||
]
|
||||
.map((value) =>
|
||||
resolveCustomWorldRoleIdReference(state.customWorldProfile, value),
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
return candidateIds.some((candidateId) => activeActNpcIds.includes(candidateId));
|
||||
})
|
||||
.filter((npc): npc is SceneNpc => Boolean(npc))
|
||||
.filter((npc) => {
|
||||
if (seenNpcIds.has(npc.id)) {
|
||||
return false;
|
||||
}
|
||||
seenNpcIds.add(npc.id);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function getSceneActBattleSlots(primaryX: number) {
|
||||
return [
|
||||
{
|
||||
xMeters: primaryX,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
xMeters: SCENE_ACT_BACK_ROW_BATTLE_X_METERS,
|
||||
yOffset: SCENE_ACT_BACK_ROW_BATTLE_Y_OFFSETS[0],
|
||||
},
|
||||
{
|
||||
xMeters: SCENE_ACT_BACK_ROW_BATTLE_X_METERS,
|
||||
yOffset: SCENE_ACT_BACK_ROW_BATTLE_Y_OFFSETS[1],
|
||||
},
|
||||
] satisfies Array<Pick<SceneHostileNpc, 'xMeters' | 'yOffset'>>;
|
||||
}
|
||||
|
||||
export function buildNpcBattleFormationFromEncounter(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
mode?: 'fight' | 'spar';
|
||||
}) {
|
||||
const { state, encounter, mode = 'fight' } = params;
|
||||
const sceneActMembers = resolveSceneActEncounterMembers(state, encounter);
|
||||
const primaryX =
|
||||
sceneActMembers.length > 1
|
||||
? RESOLVED_ENTITY_X_METERS
|
||||
: encounter.xMeters ?? RESOLVED_ENTITY_X_METERS;
|
||||
const formationSourceEncounters =
|
||||
sceneActMembers.length > 1
|
||||
? sceneActMembers.map((member, index) =>
|
||||
buildEncounterFromSceneNpc(
|
||||
member,
|
||||
index === 0 ? primaryX : SCENE_ACT_BACK_ROW_BATTLE_X_METERS,
|
||||
),
|
||||
)
|
||||
: [encounter];
|
||||
const slots = getSceneActBattleSlots(primaryX);
|
||||
|
||||
const resolvedFormation = formationSourceEncounters.map((memberEncounter, index) => {
|
||||
const slot = slots[index] ?? slots[slots.length - 1];
|
||||
const npcState = getResolvedNpcState(state, memberEncounter);
|
||||
const battleMonster = createNpcBattleMonster(
|
||||
memberEncounter,
|
||||
npcState,
|
||||
mode,
|
||||
{
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...battleMonster,
|
||||
xMeters: slot.xMeters,
|
||||
yOffset: slot.yOffset,
|
||||
facing: getFacingTowardPlayer(slot.xMeters, PLAYER_BASE_X_METERS),
|
||||
encounter: battleMonster.encounter
|
||||
? {
|
||||
...battleMonster.encounter,
|
||||
xMeters: slot.xMeters,
|
||||
}
|
||||
: battleMonster.encounter,
|
||||
} satisfies SceneHostileNpc;
|
||||
});
|
||||
|
||||
logNpcBattleFormation(
|
||||
`buildNpcBattleFormationFromEncounter:${encounter.id ?? encounter.npcName}`,
|
||||
resolvedFormation,
|
||||
);
|
||||
|
||||
return resolvedFormation;
|
||||
}
|
||||
|
||||
function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
|
||||
const npcState = getResolvedNpcState(state, encounter);
|
||||
const battleNpcId = getNpcEncounterKey(encounter);
|
||||
|
||||
return {
|
||||
...state,
|
||||
sceneHostileNpcs: [
|
||||
createNpcBattleMonster(encounter, npcState, 'fight', {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
}),
|
||||
],
|
||||
// 中文注释:幕预览和正式运行都统一走这一套 NPC 战斗编队生成,
|
||||
// 避免开战时把同幕后排角色压缩成单体,导致阵容缺失和站位突变。
|
||||
sceneHostileNpcs: buildNpcBattleFormationFromEncounter({
|
||||
state,
|
||||
encounter,
|
||||
mode: 'fight',
|
||||
}),
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerX: 0,
|
||||
|
||||
@@ -423,4 +423,49 @@ describe('buildBattlePlan', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers fight_defeat over fight_victory when the round ends with player death after local battle settlement', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentBattleNpcId: 'npc-opponent',
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
playerHp: 6,
|
||||
playerMaxHp: 30,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent',
|
||||
name: '山道客',
|
||||
action: '提刀逼近',
|
||||
description: '测试敌人',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 8,
|
||||
maxHp: 8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: {
|
||||
...createBattleOption(),
|
||||
functionId: 'battle_all_in_crush',
|
||||
},
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 900,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 1,
|
||||
});
|
||||
|
||||
expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']);
|
||||
expect(plan.finalState.playerHp).toBe(0);
|
||||
expect(plan.finalState.inBattle).toBe(false);
|
||||
expect(plan.finalState.currentNpcBattleOutcome).toBe('fight_defeat');
|
||||
expect(plan.finalState.sceneHostileNpcs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,22 @@ export type BattlePlan = {
|
||||
finalState: GameState;
|
||||
};
|
||||
|
||||
function resolveFightBattleOutcome(state: GameState): GameState['currentNpcBattleOutcome'] {
|
||||
if (state.currentNpcBattleMode === 'spar') {
|
||||
return state.currentNpcBattleOutcome;
|
||||
}
|
||||
if (state.playerHp <= 0) {
|
||||
return state.currentBattleNpcId ? 'fight_defeat' : state.currentNpcBattleOutcome;
|
||||
}
|
||||
if (
|
||||
state.currentBattleNpcId &&
|
||||
state.sceneHostileNpcs.every((monster) => monster.hp <= 0)
|
||||
) {
|
||||
return 'fight_victory';
|
||||
}
|
||||
return state.currentNpcBattleOutcome;
|
||||
}
|
||||
|
||||
function createEmptyCooldowns(character: Character) {
|
||||
return Object.fromEntries(character.skills.map((skill) => [skill.id, 0]));
|
||||
}
|
||||
@@ -543,11 +559,29 @@ export function buildBattlePlan({
|
||||
const preparedState = simulatedState;
|
||||
const turns: BattlePlanStep[] = [];
|
||||
const turnOrder = buildRoundTurnOrder(simulatedState, character);
|
||||
const pendingMonsterTurnIds = new Set(
|
||||
turnOrder
|
||||
.filter(
|
||||
(turnActor): turnActor is Extract<BattleTurnActor, {actor: 'monster'}> =>
|
||||
turnActor.actor === 'monster',
|
||||
)
|
||||
.map((turnActor) => turnActor.monsterId),
|
||||
);
|
||||
|
||||
for (const turnActor of turnOrder) {
|
||||
if (!simulatedState.inBattle || simulatedState.playerHp <= 0) {
|
||||
if (
|
||||
simulatedState.playerHp <= 0 ||
|
||||
simulatedState.currentNpcBattleOutcome === 'spar_complete' ||
|
||||
simulatedState.currentNpcBattleOutcome === 'fight_defeat'
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (!simulatedState.inBattle && pendingMonsterTurnIds.size === 0) {
|
||||
break;
|
||||
}
|
||||
if (turnActor.actor === 'monster') {
|
||||
pendingMonsterTurnIds.delete(turnActor.monsterId);
|
||||
}
|
||||
|
||||
if (
|
||||
turnActor.actor === 'player' &&
|
||||
@@ -725,20 +759,14 @@ export function buildBattlePlan({
|
||||
}
|
||||
: monster,
|
||||
);
|
||||
const playerDefeated =
|
||||
const targetDefeated =
|
||||
!isNpcSpar &&
|
||||
resolvedMonsters.some(
|
||||
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
|
||||
);
|
||||
const remainingMonsters = playerDefeated
|
||||
? resolvedMonsters.filter(
|
||||
(monster) =>
|
||||
!(monster.id === currentTarget.id && monster.hp <= 0),
|
||||
)
|
||||
: resolvedMonsters;
|
||||
const nextTarget = getClosestHostileNpc(
|
||||
originalPlayerX,
|
||||
remainingMonsters,
|
||||
resolvedMonsters.filter((monster) => monster.hp > 0),
|
||||
);
|
||||
|
||||
simulatedState = {
|
||||
@@ -757,7 +785,7 @@ export function buildBattlePlan({
|
||||
simulatedState.playerMana - selectedSkill.manaCost,
|
||||
),
|
||||
playerSkillCooldowns: appliedCooldowns,
|
||||
sceneHostileNpcs: remainingMonsters.map((monster) => ({
|
||||
sceneHostileNpcs: resolvedMonsters.map((monster) => ({
|
||||
...monster,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
@@ -766,14 +794,17 @@ export function buildBattlePlan({
|
||||
inBattle:
|
||||
isNpcSpar
|
||||
? !wouldEndSpar
|
||||
: remainingMonsters.length > 0 && simulatedState.playerHp > 0,
|
||||
: (resolvedMonsters.some((monster) => monster.hp > 0) ||
|
||||
pendingMonsterTurnIds.size > 0) &&
|
||||
simulatedState.playerHp > 0,
|
||||
currentNpcBattleOutcome: wouldEndSpar
|
||||
? 'spar_complete'
|
||||
: !isNpcSpar &&
|
||||
remainingMonsters.length === 0 &&
|
||||
simulatedState.currentBattleNpcId
|
||||
? 'fight_victory'
|
||||
: simulatedState.currentNpcBattleOutcome,
|
||||
: pendingMonsterTurnIds.size > 0
|
||||
? simulatedState.currentNpcBattleOutcome
|
||||
: resolveFightBattleOutcome({
|
||||
...simulatedState,
|
||||
sceneHostileNpcs: resolvedMonsters,
|
||||
}),
|
||||
};
|
||||
|
||||
turns.push({
|
||||
@@ -787,7 +818,7 @@ export function buildBattlePlan({
|
||||
appliedCooldowns,
|
||||
damage: playerDamage,
|
||||
criticalHit: playerCriticalHit,
|
||||
defeated: playerDefeated,
|
||||
defeated: targetDefeated,
|
||||
endsBattle: wouldEndSpar,
|
||||
delivery: playerDelivery,
|
||||
playerHpAfterAction: simulatedState.playerHp,
|
||||
@@ -870,16 +901,9 @@ export function buildBattlePlan({
|
||||
const defeated = resolvedMonsters.some(
|
||||
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
|
||||
);
|
||||
const remainingMonsters = defeated
|
||||
? resolvedMonsters.filter(
|
||||
(monster) =>
|
||||
!(monster.id === currentTarget.id && monster.hp <= 0),
|
||||
)
|
||||
: resolvedMonsters;
|
||||
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
sceneHostileNpcs: remainingMonsters.map((monster) => ({
|
||||
sceneHostileNpcs: resolvedMonsters.map((monster) => ({
|
||||
...monster,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
@@ -894,11 +918,16 @@ export function buildBattlePlan({
|
||||
}),
|
||||
),
|
||||
inBattle:
|
||||
remainingMonsters.length > 0 && simulatedState.playerHp > 0,
|
||||
(resolvedMonsters.some((monster) => monster.hp > 0) ||
|
||||
pendingMonsterTurnIds.size > 0) &&
|
||||
simulatedState.playerHp > 0,
|
||||
currentNpcBattleOutcome:
|
||||
remainingMonsters.length === 0 && simulatedState.currentBattleNpcId
|
||||
? 'fight_victory'
|
||||
: simulatedState.currentNpcBattleOutcome,
|
||||
pendingMonsterTurnIds.size > 0
|
||||
? simulatedState.currentNpcBattleOutcome
|
||||
: resolveFightBattleOutcome({
|
||||
...simulatedState,
|
||||
sceneHostileNpcs: resolvedMonsters,
|
||||
}),
|
||||
};
|
||||
|
||||
turns.push({
|
||||
@@ -923,7 +952,8 @@ export function buildBattlePlan({
|
||||
}
|
||||
|
||||
const actingMonster = simulatedState.sceneHostileNpcs.find(
|
||||
(monster) => monster.id === turnActor.monsterId && monster.hp > 0,
|
||||
(monster) =>
|
||||
monster.id === turnActor.monsterId,
|
||||
);
|
||||
if (!actingMonster) {
|
||||
continue;
|
||||
@@ -1035,10 +1065,18 @@ export function buildBattlePlan({
|
||||
inBattle:
|
||||
isNpcSpar
|
||||
? !wouldEndSpar
|
||||
: nextPlayerHp > 0 && simulatedState.sceneHostileNpcs.length > 0,
|
||||
: nextPlayerHp > 0 &&
|
||||
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
|
||||
pendingMonsterTurnIds.size > 0),
|
||||
currentNpcBattleOutcome: wouldEndSpar
|
||||
? 'spar_complete'
|
||||
: simulatedState.currentNpcBattleOutcome,
|
||||
: pendingMonsterTurnIds.size > 0
|
||||
? simulatedState.currentNpcBattleOutcome
|
||||
: resolveFightBattleOutcome({
|
||||
...simulatedState,
|
||||
...damagedState,
|
||||
playerHp: nextPlayerHp,
|
||||
}),
|
||||
};
|
||||
|
||||
turns.push({
|
||||
@@ -1115,10 +1153,18 @@ export function buildBattlePlan({
|
||||
inBattle:
|
||||
isNpcSpar
|
||||
? !wouldEndSpar
|
||||
: nextPlayerHp > 0 && simulatedState.sceneHostileNpcs.length > 0,
|
||||
: nextPlayerHp > 0 &&
|
||||
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
|
||||
pendingMonsterTurnIds.size > 0),
|
||||
currentNpcBattleOutcome: wouldEndSpar
|
||||
? 'spar_complete'
|
||||
: simulatedState.currentNpcBattleOutcome,
|
||||
: pendingMonsterTurnIds.size > 0
|
||||
? simulatedState.currentNpcBattleOutcome
|
||||
: resolveFightBattleOutcome({
|
||||
...simulatedState,
|
||||
...damagedState,
|
||||
playerHp: nextPlayerHp,
|
||||
}),
|
||||
};
|
||||
|
||||
turns.push({
|
||||
@@ -1144,8 +1190,8 @@ export function buildBattlePlan({
|
||||
return {
|
||||
preparedState,
|
||||
turns,
|
||||
finalState: {
|
||||
...simulatedState,
|
||||
finalState: {
|
||||
...simulatedState,
|
||||
companions: resetCompanionCombatPresentation(simulatedState.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
@@ -1155,11 +1201,15 @@ export function buildBattlePlan({
|
||||
simulatedState.currentNpcBattleOutcome === 'spar_complete' ||
|
||||
simulatedState.playerHp <= 0
|
||||
? false
|
||||
: simulatedState.sceneHostileNpcs.length > 0,
|
||||
: simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0),
|
||||
sceneHostileNpcs: resetCombatPresentation(
|
||||
simulatedState.sceneHostileNpcs,
|
||||
simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
|
||||
simulatedState.playerX,
|
||||
),
|
||||
currentNpcBattleOutcome: resolveFightBattleOutcome({
|
||||
...simulatedState,
|
||||
sceneHostileNpcs: simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../../services/rpg-runtime', () => ({
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
|
||||
@@ -138,6 +139,60 @@ function createFallbackStory(text = 'fallback'): StoryMoment {
|
||||
};
|
||||
}
|
||||
|
||||
function createCustomWorldProfileForSceneAct(sceneId: string) {
|
||||
return {
|
||||
id: 'custom-world-test',
|
||||
name: '场景幕重置测试',
|
||||
summary: '用于验证战败后回到首幕。',
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: `${sceneId}-chapter`,
|
||||
sceneId,
|
||||
title: '测试章节',
|
||||
summary: '测试章节摘要',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: `${sceneId}-act-1`,
|
||||
sceneId,
|
||||
title: '第一幕',
|
||||
summary: '开场第一幕',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/act-1.png',
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: null,
|
||||
oppositeNpcId: null,
|
||||
eventDescription: '第一幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '完成第一幕目标',
|
||||
transitionHook: '第一幕过渡',
|
||||
},
|
||||
{
|
||||
id: `${sceneId}-act-2`,
|
||||
sceneId,
|
||||
title: '第二幕',
|
||||
summary: '推进第二幕',
|
||||
stageCoverage: ['expansion'],
|
||||
backgroundImageSrc: '/act-2.png',
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: null,
|
||||
oppositeNpcId: null,
|
||||
eventDescription: '第二幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '完成第二幕目标',
|
||||
transitionHook: '第二幕过渡',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as NonNullable<GameState['customWorldProfile']>;
|
||||
}
|
||||
|
||||
const neverNpcEncounter = (
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter => false;
|
||||
@@ -634,6 +689,144 @@ describe('createStoryChoiceActions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
|
||||
const customWorldProfile = createCustomWorldProfileForSceneAct(firstScene.id);
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
customWorldProfile,
|
||||
currentScenePreset: firstScene,
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: firstScene.id,
|
||||
chapterId: `${firstScene.id}-chapter`,
|
||||
currentActId: `${firstScene.id}-act-2`,
|
||||
currentActIndex: 1,
|
||||
completedActIds: [`${firstScene.id}-act-1`],
|
||||
visitedActIds: [`${firstScene.id}-act-1`, `${firstScene.id}-act-2`],
|
||||
},
|
||||
},
|
||||
currentEncounter: {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路旧敌',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道旧案',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
const option = createBattleOption();
|
||||
const afterSequence = {
|
||||
...state,
|
||||
playerHp: 0,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_defeat' as const,
|
||||
};
|
||||
const finalizeNpcBattleResult = vi.fn(() => ({
|
||||
nextState: afterSequence,
|
||||
resultText: '不应该进入胜利结算',
|
||||
}));
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'battle' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 0,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
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,
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
const choicePromise = handleChoice(option);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await choicePromise;
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(finalizeNpcBattleResult).not.toHaveBeenCalled();
|
||||
expect(setGameState).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
playerHp: 0,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
animationState: AnimationState.DIE,
|
||||
}),
|
||||
);
|
||||
expect(setGameState).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: firstScene.id,
|
||||
}),
|
||||
playerHp: 100,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: null,
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
sceneId: firstScene.id,
|
||||
currentActId: `${firstScene.id}-act-1`,
|
||||
currentActIndex: 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('settles escape locally without ai continuation', async () => {
|
||||
const mockedGenerateNextStep = vi.mocked(generateNextStep);
|
||||
|
||||
|
||||
@@ -683,6 +683,44 @@ describe('npcEncounterActions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not turn fight_defeat into a local npc victory settlement', () => {
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
currentBattleNpcId: 'npc-rival',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-rival',
|
||||
name: '断桥客',
|
||||
action: '逼近',
|
||||
description: '拦路旧敌',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 12,
|
||||
maxHp: 12,
|
||||
renderKind: 'npc',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = actions.finalizeNpcBattleResult(
|
||||
actions.gameState,
|
||||
actions.gameState.playerCharacter!,
|
||||
'fight',
|
||||
'fight_defeat',
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
|
||||
const encounter = createEncounter();
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { createNpcBattleMonster } from '../../data/npcInteractions';
|
||||
import {
|
||||
buildNpcBattleFormationFromEncounter,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getForwardScenePreset } from '../../data/scenePresets';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
@@ -11,9 +16,93 @@ import {
|
||||
type RuntimeStoryResponse,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import type { GameState, SceneHostileNpc, StoryMoment, StoryOption } from '../../types';
|
||||
import { buildMapTravelResolution } from './storyGenerationState';
|
||||
|
||||
function isNpcBattleAlignmentDebugEnabled() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' ||
|
||||
window.location.search.includes('npcBattleAlignmentDebug=1')
|
||||
);
|
||||
}
|
||||
|
||||
function logNpcBattleAlignment(label: string, monsters: GameState['sceneHostileNpcs']) {
|
||||
if (!isNpcBattleAlignmentDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[npc-battle-alignment] ${label}`,
|
||||
monsters.map((monster) => ({
|
||||
id: monster.id,
|
||||
encounterId: monster.encounter?.id ?? null,
|
||||
encounterName: monster.encounter?.npcName ?? null,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
facing: monster.facing,
|
||||
animation: monster.animation,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneBattleFormation(monsters: GameState['sceneHostileNpcs']) {
|
||||
return monsters.map(
|
||||
(monster) =>
|
||||
({
|
||||
...monster,
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
}
|
||||
: monster.encounter,
|
||||
}) satisfies SceneHostileNpc,
|
||||
);
|
||||
}
|
||||
|
||||
function alignBattleFormationToVisibleFormation(params: {
|
||||
visibleFormation: GameState['sceneHostileNpcs'];
|
||||
battleFormation: GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const { visibleFormation, battleFormation } = params;
|
||||
if (visibleFormation.length === 0 || battleFormation.length === 0) {
|
||||
return battleFormation;
|
||||
}
|
||||
|
||||
const visibleFormationByEncounterId = new Map(
|
||||
visibleFormation.map((monster) => [
|
||||
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id,
|
||||
monster,
|
||||
]),
|
||||
);
|
||||
|
||||
return battleFormation.map((monster) => {
|
||||
const encounterKey =
|
||||
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id;
|
||||
const visibleMonster = visibleFormationByEncounterId.get(encounterKey);
|
||||
if (!visibleMonster) {
|
||||
return monster;
|
||||
}
|
||||
|
||||
return {
|
||||
...monster,
|
||||
xMeters: visibleMonster.xMeters,
|
||||
yOffset: visibleMonster.yOffset,
|
||||
facing: visibleMonster.facing,
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
xMeters:
|
||||
visibleMonster.encounter?.xMeters ?? visibleMonster.xMeters,
|
||||
}
|
||||
: monster.encounter,
|
||||
} satisfies SceneHostileNpc;
|
||||
});
|
||||
}
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
? response.viewModel.availableOptions
|
||||
@@ -120,6 +209,102 @@ function bridgeServerSceneTravelSnapshot(params: {
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
function bridgeServerNpcBattleSnapshot(params: {
|
||||
previousState: GameState;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
functionId: string;
|
||||
}) {
|
||||
const { previousState, hydratedSnapshot, functionId } = params;
|
||||
if (functionId !== 'npc_fight' && functionId !== 'npc_spar') {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const snapshotState = hydratedSnapshot.gameState;
|
||||
const isNpcBattleActive =
|
||||
snapshotState.inBattle &&
|
||||
Boolean(snapshotState.currentBattleNpcId) &&
|
||||
Boolean(snapshotState.currentNpcBattleMode);
|
||||
const hasResolvedBattleMonster = snapshotState.sceneHostileNpcs.length > 0;
|
||||
const sourceEncounter =
|
||||
previousState.currentEncounter?.kind === 'npc'
|
||||
? previousState.currentEncounter
|
||||
: null;
|
||||
|
||||
// 中文注释:作品测试/幕预览里最容易出现的错位,是服务端已经把
|
||||
// currentBattleNpcId / currentNpcBattleMode 切进战斗,但快照里没有把
|
||||
// sceneHostileNpcs 一起带回。这样前端本地 battlePlan 会直接判定
|
||||
// “场上没有敌人”,点击 battle_* 后立刻把整场战斗收掉。
|
||||
// 这里统一在网关层补齐 NPC 战场快照,保证后续本地逐轮回合一定有敌方单位可结算。
|
||||
if (!isNpcBattleActive || !sourceEncounter) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const fallbackNpcState =
|
||||
snapshotState.npcStates[
|
||||
snapshotState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
|
||||
] ??
|
||||
previousState.npcStates[
|
||||
previousState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
|
||||
] ?? {
|
||||
affinity: sourceEncounter.initialAffinity ?? (sourceEncounter.hostile ? -10 : 0),
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
};
|
||||
|
||||
const battleMode =
|
||||
snapshotState.currentNpcBattleMode === 'spar' ? 'spar' : 'fight';
|
||||
const fallbackFormationFromSceneAct = buildNpcBattleFormationFromEncounter({
|
||||
state: previousState,
|
||||
encounter: {
|
||||
...sourceEncounter,
|
||||
xMeters: sourceEncounter.xMeters ?? RESOLVED_ENTITY_X_METERS,
|
||||
},
|
||||
mode: battleMode,
|
||||
});
|
||||
const fallbackFormation =
|
||||
previousState.sceneHostileNpcs.length > 0
|
||||
? cloneBattleFormation(previousState.sceneHostileNpcs)
|
||||
: fallbackFormationFromSceneAct.length > 0
|
||||
? fallbackFormationFromSceneAct
|
||||
: [
|
||||
createNpcBattleMonster(
|
||||
sourceEncounter,
|
||||
fallbackNpcState,
|
||||
battleMode,
|
||||
{
|
||||
worldType: snapshotState.worldType,
|
||||
customWorldProfile: snapshotState.customWorldProfile,
|
||||
},
|
||||
),
|
||||
];
|
||||
const resolvedBattleFormation = hasResolvedBattleMonster
|
||||
? alignBattleFormationToVisibleFormation({
|
||||
visibleFormation: previousState.sceneHostileNpcs,
|
||||
battleFormation: snapshotState.sceneHostileNpcs,
|
||||
})
|
||||
: fallbackFormation;
|
||||
|
||||
logNpcBattleAlignment('previous-visible-formation', previousState.sceneHostileNpcs);
|
||||
logNpcBattleAlignment('server-battle-formation', snapshotState.sceneHostileNpcs);
|
||||
logNpcBattleAlignment('resolved-battle-formation', resolvedBattleFormation);
|
||||
|
||||
return {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...snapshotState,
|
||||
// 中文注释:优先沿用进入战斗前已经可见的阵容与站位;
|
||||
// 若上一帧还没有 battle combatants,则从幕预览/当前遭遇恢复完整 NPC 编队,
|
||||
// 避免只补出一个前排角色,造成后排消失和敌方位置突变。
|
||||
sceneHostileNpcs: resolvedBattleFormation,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
@@ -204,7 +389,11 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
});
|
||||
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
hydratedSnapshot: bridgeServerNpcBattleSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
functionId: params.option.functionId,
|
||||
}),
|
||||
functionId: params.option.functionId,
|
||||
});
|
||||
|
||||
|
||||
@@ -653,6 +653,515 @@ describe('runtimeStoryCoordinator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills npc battle monsters when npc_fight snapshot marks battle active but omits sceneHostileNpcs', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '拦路的刀客',
|
||||
npcAvatar: '/npc-bandit.png',
|
||||
context: '断桥口',
|
||||
hostile: true,
|
||||
initialAffinity: -12,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
} as GameState;
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-bandit',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
hostile: true,
|
||||
affinity: -12,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '断桥匪首已经摆开架势。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: createRuntimeNpcBattleSnapshot({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '拦路的刀客',
|
||||
context: '断桥口',
|
||||
hostile: true,
|
||||
} as GameState['currentEncounter'],
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'npc-bandit',
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toHaveLength(1);
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
encounter: expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
}),
|
||||
renderKind: 'npc',
|
||||
}),
|
||||
);
|
||||
expect(result.hydratedSnapshot.gameState.currentEncounter).toBeNull();
|
||||
expect(result.hydratedSnapshot.gameState.npcInteractionActive).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves previous hostile formation when npc_fight snapshot omits battle members', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
initialAffinity: -20,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent-npc-front',
|
||||
name: '正面对手',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '正面对手',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 8,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'npc-opponent-npc-back-1',
|
||||
name: '后排甲',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '后排甲',
|
||||
animation: 'idle',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 76,
|
||||
maxHp: 76,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '后排甲',
|
||||
npcDescription: '后排甲',
|
||||
npcAvatar: '/npc-back-1.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 4.28,
|
||||
},
|
||||
},
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
} as GameState;
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-front',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
hostile: true,
|
||||
affinity: -20,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '正面对手带着同伴压了上来。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: createRuntimeNpcBattleSnapshot({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-front',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
} as GameState['currentEncounter'],
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'npc-front',
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
|
||||
encounterId: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
encounterId: 'npc-front',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
encounterId: 'npc-back-1',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
initialAffinity: -20,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent-npc-front',
|
||||
name: '正面对手',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '正面对手',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 8,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'npc-opponent-npc-back-1',
|
||||
name: '后排甲',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '后排甲',
|
||||
animation: 'idle',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 76,
|
||||
maxHp: 76,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '后排甲',
|
||||
npcDescription: '后排甲',
|
||||
npcAvatar: '/npc-back-1.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 4.28,
|
||||
},
|
||||
},
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
} as GameState;
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-front',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
hostile: true,
|
||||
affinity: -20,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '正面对手带着同伴压了上来。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: createRuntimeNpcBattleSnapshot({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-front',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
} as GameState['currentEncounter'],
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent-npc-front',
|
||||
name: '正面对手',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '正面对手',
|
||||
animation: 'idle',
|
||||
xMeters: 1.4,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 8,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 1.4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'npc-opponent-npc-back-1',
|
||||
name: '后排甲',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '后排甲',
|
||||
animation: 'idle',
|
||||
xMeters: 2.1,
|
||||
yOffset: 16,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 76,
|
||||
maxHp: 76,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '后排甲',
|
||||
npcDescription: '后排甲',
|
||||
npcAvatar: '/npc-back-1.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 2.1,
|
||||
},
|
||||
},
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'npc-front',
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
|
||||
encounterId: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
encounterId: 'npc-front',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
encounterId: 'npc-back-1',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => {
|
||||
const gameState = createTravelGameState();
|
||||
const currentStory = createStory('桥口这一段已经收束。');
|
||||
|
||||
@@ -151,6 +151,14 @@ function buildDeterministicStoryForState(params: {
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function isLocalNpcBattleVictoryOutcome(
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) {
|
||||
return (
|
||||
battleOutcome === 'fight_victory' || battleOutcome === 'spar_complete'
|
||||
);
|
||||
}
|
||||
|
||||
export async function runLocalStoryChoiceContinuation(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
@@ -239,9 +247,7 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(projectedState.currentNpcBattleOutcome ||
|
||||
(baseChoiceState.currentNpcBattleMode === 'fight' &&
|
||||
!projectedState.inBattle)),
|
||||
isLocalNpcBattleVictoryOutcome(projectedState.currentNpcBattleOutcome),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
@@ -447,7 +453,11 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
|
||||
if (
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(!nextState.inBattle || nextState.currentNpcBattleOutcome === 'spar_complete')
|
||||
(
|
||||
nextState.currentNpcBattleOutcome === 'fight_victory' ||
|
||||
nextState.currentNpcBattleOutcome === 'spar_complete' ||
|
||||
(!baseChoiceState.currentBattleNpcId && !nextState.inBattle)
|
||||
)
|
||||
) {
|
||||
const postBattleState = buildPostBattleVictoryState(nextState);
|
||||
const postBattle = buildPostBattleVictoryStory(
|
||||
|
||||
@@ -452,6 +452,104 @@ describe('storyChoiceRuntime', () => {
|
||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
||||
});
|
||||
|
||||
it('routes server defeat outcomes into death and revive flow instead of victory settlement', async () => {
|
||||
const gameState = createState({
|
||||
worldType: 'WUXIA',
|
||||
inBattle: true,
|
||||
playerHp: 6,
|
||||
playerMaxHp: 30,
|
||||
playerMana: 10,
|
||||
playerMaxMana: 10,
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风穿竹影,路面狭长。',
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: [],
|
||||
connections: [],
|
||||
forwardSceneId: null,
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf',
|
||||
name: '山狼',
|
||||
action: '逼近',
|
||||
description: '山狼',
|
||||
animation: 'idle',
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 4,
|
||||
maxHp: 18,
|
||||
},
|
||||
],
|
||||
});
|
||||
const finalState = createState({
|
||||
...gameState,
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
currentEncounter: null,
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
});
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
presentation: {
|
||||
battle: {
|
||||
targetId: 'wolf',
|
||||
damageDealt: 22,
|
||||
damageTaken: 8,
|
||||
outcome: 'defeat',
|
||||
},
|
||||
resultText: '你在山狼的反扑下倒地。',
|
||||
},
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
gameState: finalState,
|
||||
},
|
||||
nextStory: createStory('不会进入胜利文本'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: createStory('当前故事'),
|
||||
option: createOption('battle_all_in_crush'),
|
||||
character: createCharacter(),
|
||||
setBattleReward: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
turnVisualMs: 1,
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
playerHp: 0,
|
||||
animationState: 'die',
|
||||
inBattle: false,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '不会进入胜利文本',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
|
||||
const gameState = createState({
|
||||
currentScenePreset: {
|
||||
|
||||
@@ -458,7 +458,7 @@ async function playServerBattlePresentation(params: {
|
||||
const targetDefeated =
|
||||
battle.outcome === 'victory' ||
|
||||
battle.outcome === 'spar_complete' ||
|
||||
(!finalTarget && (battle.damageDealt ?? 0) > 0);
|
||||
(battle.outcome !== 'defeat' && !finalTarget && (battle.damageDealt ?? 0) > 0);
|
||||
params.setGameState({
|
||||
...actingState,
|
||||
playerHp: params.finalState.playerHp,
|
||||
|
||||
@@ -424,6 +424,12 @@ export function createStoryNpcEncounterActions({
|
||||
if (!npcState) return null;
|
||||
const activeBattleHostiles = state.sceneHostileNpcs;
|
||||
|
||||
// 中文注释:只有正式胜利或切磋完成才允许进入 NPC 战后收束;
|
||||
// 若当前是 fight_defeat,则必须交回死亡复活链,不能继续发奖励或推进剧情幕。
|
||||
if (battleMode === 'fight' && battleOutcome !== 'fight_victory') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
|
||||
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
|
||||
const restoredEncounter = state.sparReturnEncounter;
|
||||
|
||||
@@ -91,7 +91,10 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile, { mode: options?.mode });
|
||||
selectCustomWorld(customWorldProfile, {
|
||||
mode: options?.mode,
|
||||
disablePersistence: options?.disablePersistence,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (
|
||||
|
||||
@@ -493,11 +493,12 @@ export function useRpgSessionBootstrap() {
|
||||
|
||||
const handleCustomWorldSelect = (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: { mode?: GameRuntimeMode },
|
||||
options?: { mode?: GameRuntimeMode; disablePersistence?: boolean },
|
||||
) => {
|
||||
const resolvedWorldType = WorldType.CUSTOM;
|
||||
const runtimeMode: GameRuntimeMode =
|
||||
options?.mode === 'play' ? 'play' : 'test';
|
||||
const runtimeMode: GameRuntimeMode = 'play';
|
||||
const runtimePersistenceDisabled =
|
||||
options?.disablePersistence ?? false;
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
buildCustomWorldRuntimeCharacters(customWorldProfile),
|
||||
@@ -510,7 +511,7 @@ export function useRpgSessionBootstrap() {
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
runtimeMode,
|
||||
runtimePersistenceDisabled: runtimeMode !== 'play',
|
||||
runtimePersistenceDisabled,
|
||||
currentScenePreset: initialScenePreset,
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
@@ -600,13 +601,11 @@ export function useRpgSessionBootstrap() {
|
||||
playerCharacter: character,
|
||||
runtimeMode:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? prev.runtimeMode === 'play'
|
||||
? 'play'
|
||||
: 'test'
|
||||
? (prev.runtimeMode ?? 'play')
|
||||
: (prev.runtimeMode ?? 'play'),
|
||||
runtimePersistenceDisabled:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? prev.runtimeMode !== 'play'
|
||||
? prev.runtimePersistenceDisabled === true
|
||||
: prev.runtimePersistenceDisabled,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
|
||||
@@ -466,7 +466,7 @@ function GameFlowHarness({
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCustomWorldSelect(profile)}
|
||||
onClick={() => handleCustomWorldSelect(profile, { mode: 'play' })}
|
||||
>
|
||||
选择世界
|
||||
</button>
|
||||
@@ -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('旧潮短刃');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface StoryNpcChatState {
|
||||
combatContext?: {
|
||||
summary: string;
|
||||
logLines: string[];
|
||||
battleOutcome: 'victory' | 'spar_complete';
|
||||
battleOutcome: 'victory' | 'defeat' | 'spar_complete';
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user