From 092eb7f8d862acfe8e297aee9df9b90f85b4e10a Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Wed, 16 Sep 2026 08:01:28 +0800 Subject: [PATCH] feat(m80-i10b): carry battle HP across rounds and raise enemy tempo --- docs/EVIDENCE_LEDGER.md | 1 + docs/plans/M80_BATTLE_SIMULATION.md | 5 +++- runtime/domain/battle_stat_authoring.gd | 6 ++--- runtime/domain/contract_run.gd | 36 +++++++++++++++++++++++++ runtime/entry/battle_sim_bridge.gd | 7 +++++ runtime/tools/m80_balance_sweep.gd | 17 +++++++++++- 6 files changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/EVIDENCE_LEDGER.md b/docs/EVIDENCE_LEDGER.md index 3dc30a30..e04eceb5 100644 --- a/docs/EVIDENCE_LEDGER.md +++ b/docs/EVIDENCE_LEDGER.md @@ -17,6 +17,7 @@ | 2026-09-15 | M79-F 区域 6 内容(数据/本地化/立绘/音乐/战场)+ 推进门到第十一份合同 + 注册表测试 R6 计修正 | `feat(m79-f)` `c728a49a`/`a4de1565` | `m79-region6` `20260915T050042Z-16542` | runtime_verified | 240 装备/45 遗物/74 敌人/12 Boss/12 合同/461 本地化;`contracts=11 region=RG-06`;15 marker 各 1 次 | | 2026-09-15 | M79-E 区域 5 内容(数据/本地化/立绘/音乐/战场)+ 推进门到第九份合同 + 注册表验证器修 13 敌人 | `feat(m79-e)` `aff0358b`/`6a9518e3` | `m79-region5` `20260915T023248Z-65213` | runtime_verified | 200 装备/37 遗物/61 敌人/10 Boss/10 合同/395 本地化;`contracts=9 region=RG-05`;15 marker 各 1 次 | | 2026-09-15 | M79-D 区域 4 美术+音乐+接线(含 R2/R3 姿态回填;risk 选项轮询去 flake) | `feat(m79-d)` `5f9723d2` + `test(m79-d)` `c7abcb97` | `m79-region4` `20260915T022134Z-55448` | runtime_verified | 28 张 R4 立绘+卷根战场+`battle_r4`/`boss_r4`;R2/R3 42 条姿态接线回填;15 marker 各 1 次 | +| 2026-09-15 | M80-I10 (d)+(c):合同级 HP 延续与敌方行动频率 | `feat(m80-i10b)` | runtime_verified | 时长 28-35s、仍全胜;(a)/(b) 待 owner 定调 | | 2026-09-15 | M80-I10 第一版遭遇加强(rank HP/power 上调 + 规模对齐容量) | `tune(m80-i10a)` | runtime_verified | 时长 20-27s → 28-34s;仍全胜,缺口记为设计决策待定调 | | 2026-09-15 | M80-B4 切片 3:资源门/HP 带/邻位条件行启用 + 夹具双缺陷修复(recruit_id、敌人属性成长) | `feat(m80-b4c)` | runtime_verified | `ABG_M80_CONDITION_ROWS_OK enabled=14 pending=9 fired=true`;遭遇侧偏弱记为 M80-I10 | | 2026-09-15 | M80-B4 切片 2:§14.1 强制位移(push/pull/碰撞伤害) | `feat(m80-b4b)` | runtime_verified | `ABG_M80_MOVEMENT_OK push=swaps pull=swaps collision=formula immovable=blocks` | diff --git a/docs/plans/M80_BATTLE_SIMULATION.md b/docs/plans/M80_BATTLE_SIMULATION.md index faa390bc..eecfeb56 100644 --- a/docs/plans/M80_BATTLE_SIMULATION.md +++ b/docs/plans/M80_BATTLE_SIMULATION.md @@ -84,7 +84,10 @@ reach/目标选择、Guard/护盾、伤害公式(含暴击与标签化随机 (投射屏反射、标签吞噬、带位延迟、双足印旋转…)仍需 B3 后续把它实现为效果类而非仅声明 | | `M80-I05` | RESOLVED(2026-09-15)| 320/320 行全部声明 §17 合法钩子 + 效果类(230 行关键词分类 + 90 行逐行点名; 233 行落在模拟已实现的效果类,其余按未实现效果类登记,绝不猜);后续把未实现效果类逐类落地即可 | -| `M80-I10` | OPEN(数值设计决策待 owner 定调)| 第一版加强已落地(敌人规模上限=队伍人数;rank HP 150/260/520→260/420/780、power 10/16/22→13/20/26): +| `M80-I10` | OPEN((d)+(c) 已落地,仍缺 (a)/(b))| (d) 回合间 HP 延续(有文档依据:§5"晋升保留 HP 百分比"、§9"MaxHP 变化保留 HP 百分比且不治疗")已进领域层 + (`ContractRun.apply_carry/carry_from/casualties`)+ 桥读取 + 扫描使用;(c) 敌人 tempo/initiative +4。实测:第 1/8 区时长 28-34 s、 + 仍 100% 通关 → **(d)+(c) 不足以拉开难度**。剩余杠杆均为产品决策:(a) 敌方总量超容量;(b) 削弱满编职业组合的治疗/护卫/资源循环 | +| `M80-I10a` | DONE(记录)| 第一版加强已落地(敌人规模上限=队伍人数;rank HP 150/260/520→260/420/780、power 10/16/22→13/20/26): 时长 20-27 s → 28-34 s,仍 100% 通关、几乎无伤亡。结论:**满容量 12 职业队对同数敌人是碾压**, 继续加数值收益递减。需要 owner 定调的是产品选择:(a) 遭遇敌方总量显著超过我方容量(可读性风险); (b) 削弱满编强度(职业/治疗/护卫的资源循环太强);(c) 提高敌方行动频率(tempo/initiative); diff --git a/runtime/domain/battle_stat_authoring.gd b/runtime/domain/battle_stat_authoring.gd index 884aa66d..06181c09 100644 --- a/runtime/domain/battle_stat_authoring.gd +++ b/runtime/domain/battle_stat_authoring.gd @@ -28,11 +28,11 @@ const PROFESSION_BASES := { const ENEMY_RANK := { # `level_offset` says how far the row sits ahead of the fastest party at its # region entry; attributes then slope with the encounter level. - "ordinary": {"hp": 260, "power": 13, "guard": 8, "tempo": 26, "initiative": 10, "multiplier": 1.00, + "ordinary": {"hp": 260, "power": 13, "guard": 8, "tempo": 30, "initiative": 14, "multiplier": 1.00, "level_offset": 0, "might": 5, "finesse": 5, "insight": 4, "vigor": 6, "resolve": 4}, - "elite": {"hp": 420, "power": 20, "guard": 14, "tempo": 30, "initiative": 16, "multiplier": 1.25, + "elite": {"hp": 420, "power": 20, "guard": 14, "tempo": 34, "initiative": 20, "multiplier": 1.25, "level_offset": 1, "might": 7, "finesse": 7, "insight": 6, "vigor": 8, "resolve": 6}, - "boss": {"hp": 780, "power": 26, "guard": 20, "tempo": 28, "initiative": 12, "multiplier": 1.50, + "boss": {"hp": 780, "power": 26, "guard": 20, "tempo": 32, "initiative": 16, "multiplier": 1.50, "level_offset": 3, "might": 9, "finesse": 9, "insight": 8, "vigor": 10, "resolve": 8}, } diff --git a/runtime/domain/contract_run.gd b/runtime/domain/contract_run.gd index b828d6a1..e8be2b6e 100644 --- a/runtime/domain/contract_run.gd +++ b/runtime/domain/contract_run.gd @@ -121,6 +121,42 @@ static func run_boss_with_escort(contract: Dictionary, region_index: int, allies return simulation.run() +static func apply_carry(recruits: Array, carry: Dictionary) -> Array: + # Returns the recruit rows with the carried current HP/guard folded in. + var rows: Array = [] + for recruit in recruits: + var entry: Dictionary = Dictionary(recruit).duplicate(true) + var recruit_id := str(entry.get("recruit_id", entry.get("id", ""))) + if carry.has(recruit_id): + var state: Dictionary = Dictionary(carry[recruit_id]) + entry["hp"] = int(state.get("hp", 0)) + entry["guard"] = int(state.get("guard", 0)) + rows.append(entry) + return rows + + +static func carry_from(result: Dictionary) -> Dictionary: + # Current HP/guard of the living after a battle, keyed by recruit id. + var carry := {} + for ally in Array(result.get("allies", [])): + var entry: Dictionary = Dictionary(ally) + if bool(entry.get("fallen", false)): + continue + var ally_id := str(entry.get("id", "")) + var recruit_id := ally_id.trim_prefix("ally-") + carry[recruit_id] = {"hp": int(entry.get("hp", 0)), "guard": int(entry.get("guard", 0))} + return carry + + +static func casualties(result: Dictionary) -> Array[String]: + var fallen: Array[String] = [] + for ally in Array(result.get("allies", [])): + var entry: Dictionary = Dictionary(ally) + if bool(entry.get("fallen", false)): + fallen.append(str(entry.get("id", "")).trim_prefix("ally-")) + return fallen + + static func advance(state: Dictionary, victory: bool) -> Dictionary: var next: Dictionary = state.duplicate(true) if victory: diff --git a/runtime/entry/battle_sim_bridge.gd b/runtime/entry/battle_sim_bridge.gd index bdfd1774..e908ccf9 100644 --- a/runtime/entry/battle_sim_bridge.gd +++ b/runtime/entry/battle_sim_bridge.gd @@ -159,6 +159,13 @@ static func _ally_row(recruit_id: String, position: int, recruit: Dictionary) -> "armor_guard": 2, "armor_ward": 1, "weapon_power": 3, "focus_power": 1, } row.merge(bases, false) + # Section 9/5: HP persists between battles and a MaxHP change preserves the + # HP percentage. A carried value therefore enters the battle as the current + # HP rather than a full heal. + if int(recruit.get("hp", 0)) > 0: + row["hp"] = int(recruit["hp"]) + if int(recruit.get("guard", 0)) >= 0 and recruit.has("guard"): + row["guard"] = int(recruit["guard"]) var item_id := str(recruit.get("equipped_item_id", "")) if not item_id.is_empty(): var support := Authoring.equipment_row(item_id, int(row.level), ContentRegistry.snapshot()) diff --git a/runtime/tools/m80_balance_sweep.gd b/runtime/tools/m80_balance_sweep.gd index da245b34..2a0936a4 100644 --- a/runtime/tools/m80_balance_sweep.gd +++ b/runtime/tools/m80_balance_sweep.gd @@ -53,21 +53,36 @@ func _run() -> void: var clears := 0 var ticks_total := 0 var survivors_total := 0 + var carry := {} + var deaths: Array[String] = [] + var active := recruits.duplicate(true) for attempt in range(attempts): var encounter := ContractRun.ordinary_encounter(region_index, enemy_ids, round_index, attempt, party_size) var ids: Array = [] for entry in encounter: ids.append(str(Dictionary(entry).get("enemy_id", ""))) - var result := Bridge.run("sweep|r%d|rnd%d|a%d" % [region_index, round_index, attempt], formation, recruits, ids, region_index, _meta_at(enemy_meta, level + 2)) + # Section 9/5 carry: a cleared round keeps its current HP, so a + # contract is a sequence of fights rather than twelve full heals. + # A failed attempt is retired at the Market and retried at the + # state it entered with, which keeps the retry rule simple here. + var before := carry.duplicate(true) + var result := Bridge.run("sweep|r%d|rnd%d|a%d" % [region_index, round_index, attempt], formation, active, ids, region_index, _meta_at(enemy_meta, level + 2)) ticks_total += int(result.get("ticks", 0)) survivors_total += _survivors(result) if str(result.get("outcome", "")) == "victory": clears += 1 + carry = ContractRun.carry_from(result) + else: + carry = before + for fallen in ContractRun.casualties(result): + if not deaths.has(fallen): + deaths.append(fallen) var clear_rate := float(clears) / float(attempts) if clear_rate < 0.5: hard += 1 print("%d %d %d %d %.2f %.2f %.0f %.1f %.1f" % [ region_index, round_index, party_size, level, 1.0 - clear_rate, clear_rate, + float(ticks_total) / float(attempts), float(ticks_total) / float(attempts) / 10.0, float(survivors_total) / float(attempts)])