From b44d3234b82315dba5fa9691a7537e236ffb7a98 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 31 Oct 2026 07:44:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=AD=E4=BB=A346=E5=8D=A1A-T2(=E5=85=A8?= =?UTF-8?q?=E5=B1=80damage=5Ftaken=E7=BB=9F=E4=B8=80):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=5Femit=5Fhero=5Fdamage=5Ftaken=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=8F=91=E5=B0=84=E5=8F=A3(payload=E5=BF=85=E5=90=ABkind/amoun?= =?UTF-8?q?t/source/cell/round/hp=5Fratio)=C2=B7=5Fenemy=5Fshield=5Fapply?= =?UTF-8?q?=E6=94=B9=E5=90=8D=5Fapply=5Fhero=5Fdamage=5Feffects=3D?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E6=B6=88=E8=B4=B9=E6=97=8F(=E7=BB=84?= =?UTF-8?q?=E5=90=88=E6=AC=A1=E5=BA=8Fshield=E2=86=92damage=5Freduce?= =?UTF-8?q?=E2=86=92ac=5Fbonus=E2=86=92counter=5Fadd=E5=86=BB=E7=BB=93)?= =?UTF-8?q?=C2=B7=E6=95=8C=E6=96=B9=E6=94=BB=E5=87=BB=E6=94=B9=E8=B5=B0?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=8F=A3(=E6=8E=B7=E7=82=B9=E5=90=8E?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E5=89=8D=E6=97=B6=E7=82=B9=E4=B8=8D=E5=8F=98?= =?UTF-8?q?)=C2=B7=5Fresolve=5Fboard=5Fflat=5Fhero=E4=B8=8B=E9=99=90?= =?UTF-8?q?=E5=89=8D=E6=8E=A5=E5=85=A5(hero=5Fdamage=5Fconsumed=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E9=97=B8=E9=98=B2=E9=87=8D=E5=A4=8D)=C2=B7=E5=9C=B0?= =?UTF-8?q?=E5=BD=A2=E8=B7=AF=E5=BE=84=E5=B9=B6=E5=85=A5=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E6=97=8F=E5=B9=B6=E5=88=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=5Fterrain=5Fdamage=5Freduce(=E5=85=A8=E5=B1=80=E5=8F=97?= =?UTF-8?q?=E4=BC=A4=E8=AF=AD=E4=B9=89=3Dshield=E5=90=8C=E9=9D=A2=E6=B6=88?= =?UTF-8?q?=E8=B4=B9)=C2=B7=E7=A7=81=E6=9C=89=E5=8F=AF=E8=83=BD=E5=87=80?= =?UTF-8?q?=E5=A2=9E1=E2=89=A42=C2=B7refresh=5Ffeats=5Ftest=E6=96=B0?= =?UTF-8?q?=E5=BB=BAV5=E4=B8=A4=E4=B8=93=E9=A1=B9(=E5=9B=9B=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=80=90=E8=B7=AF=E6=81=B0=E4=B8=80=E6=AC=A1+?= =?UTF-8?q?=E6=97=A0=E9=87=8D=E5=A4=8D=E6=B6=88=E8=B4=B9,=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E6=89=BF=E4=BC=A4=E8=AE=A1=E6=95=B0=E4=B8=93=E9=95=BF?= =?UTF-8?q?=E7=BB=8FConfigDB=E6=B3=A8=E5=85=A5)=C2=B7=E7=AA=84=E6=B5=8Bref?= =?UTF-8?q?resh=5Ffeats/damage=5Fcontroller/seal=5Fpolarity/round=5Fflow?= =?UTF-8?q?=E5=85=A8=E7=BB=BF(44/70/134=20checks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/core/damage_controller.gd | 38 ++- game/core/settlement/grant_segment.gd | 34 +-- .../battle_settlement/refresh_feats_test.gd | 234 ++++++++++++++++++ 3 files changed, 272 insertions(+), 34 deletions(-) create mode 100644 game/tests/unit/battle_settlement/refresh_feats_test.gd diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 8d72cb9b..ad8b68cb 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -460,8 +460,8 @@ func _hero_attack_deliver(t: Dictionary, ctx: Dictionary, redemption: Dictionary # 敌方伤害技:掷点 → 折扣/暴击 → 克制点数(attack_type vs 主角护甲类型—— # 无类型恒中性 delta 0,写法同 hero_attack 的 _counter_delta 消费位)→ hero_defense×enemy_output # 池乘区 → 下限 → +rider(后置必中附加)→ 交付 → commit_action(作废单不 commit——死亡即不动手)。 -# 掷点算术归 _enemy_roll;damage_taken 施加面归 _enemy_shield_apply(发射在本函数—— -# 与减伤落快照的先后序固定)。 +# 掷点算术归 _enemy_roll;damage_taken 统一发射+消费归 _emit_hero_damage_taken(发射在本函数—— +# 与减伤落快照的先后序固定;「掷点后、交付前」消费时点不变)。 func _resolve_enemy_skill(t: Dictionary) -> void: var hero: Dictionary[Variant, Variant] = units.hero_unit() var roll := _enemy_roll(t) @@ -469,9 +469,8 @@ func _resolve_enemy_skill(t: Dictionary) -> void: # damage_taken 发射(kind=enemy_skill;铁壁整备减伤订阅面:shield 加法族合计 value×per(charge_layers), # 稳健拓荒树规则同面(rebuild 合成订阅);减后过下限保底。damage_reduce〔背水 hp_ratio when〕/ # counter_add〔保险丝/石肤〕/ac_bonus〔石肤〕同面施加;hp_ratio fact 由 _fx_fact 补充事实面回落提供) - var dt_hits := _fx_hits("damage_taken", {"kind": "enemy_skill", "amount": total, - "source": String(t.context.get("skill_id", "")), "round": int(t.round_index)}) - total = int(_enemy_shield_apply(dt_hits, total)["total"]) + var dt_res: Dictionary = _emit_hero_damage_taken(total, "enemy_skill", String(t.context.get("skill_id", "")), t.origin_cell, int(t.round_index)) + total = int(dt_res["total"]) units.apply_damage(hero, total) var payload: Dictionary[Variant, Variant] = {"target": "hero", "skill_id": String(t.context.get("skill_id", "")), "baseline": int(t.context.get("baseline", 0)), "roll": int(roll["roll"]), "ac": int(roll["threshold"]), "tier": int(roll["discount"]), "attack_type": String(t.attack_type)} @@ -502,9 +501,22 @@ func _enemy_roll(t: Dictionary) -> Dictionary: return {"roll": int(roll_read.roll), "threshold": threshold, "crit": crit, "discount": discount, "total": total} -# damage_taken 施加面(shield 加法族合计;damage_reduce 逐条减量〔背水〕;ac_bonus 转编排器单点 -# 〔石肤 AC 累积归 set_hero_ac_override 消费面〕;counter_add 施加)。返回 {total, shield}。 -func _enemy_shield_apply(hits: Array, total: int) -> Dictionary: +# 全局受伤发射单点(迭代 46 卡A T2——所有主角承伤路径唯一发射口:敌方攻击/地形/危险格/错误封印 +# 资源直伤(后三者经 board_flat_hero 单据进入)都经此 dispatch 一次 damage_taken 并消费一次; +# 同一伤害单据重复发射由 context.hero_damage_consumed 标记闸拦(地形路径在开单点标记)。 +# payload 必含 kind/amount/source/cell/round/hp_ratio;返回 {total, hits} 交调用侧按减后值开单/交付)。 +func _emit_hero_damage_taken(amount: int, damage_kind: String, source: String, cell: Vector2i, ri: int) -> Dictionary: + var hu: Dictionary[Variant, Variant] = units.hero_unit() + var payload: Dictionary[Variant, Variant] = {"kind": damage_kind, "amount": amount, "source": source, + "cell": cell, "round": ri, "hp_ratio": int(round(100.0 * float(int(hu.get("hp", 0))) / float(maxi(1, int(hu.get("max_hp", 1))))))} + var hits: Array = _fx_hits("damage_taken", payload) + return {"total": int(_apply_hero_damage_effects(hits, amount)["total"]), "hits": hits} + + +# damage_taken 唯一消费族(组合次序冻结:shield 加法族合计→damage_reduce 逐条减量〔背水/石层缓冲〕→ +# ac_bonus 转编排器单点〔石肤 AC 累积归 set_hero_ac_override 消费面〕→counter_add 施加〔保险丝/石肤/ +# 换图系临时计数夹具〕。返回 {total, shield})。 +func _apply_hero_damage_effects(hits: Array, total: int) -> Dictionary: var shield := 0 for h in hits: match String(h.get("kind", "")): @@ -565,9 +577,15 @@ func _resolve_board_blast(t: Dictionary) -> void: _outcomes[int(t.ticket_id)] = "redeemed" -# 棋盘主角直伤(反噬/治疗包负向/地形变异):无类型必中,下限保底;context 键并入行载荷 +# 棋盘主角直伤(反噬/治疗包负向/危险格/错误封印资源直伤):无类型必中,下限保底;context 键并入行载荷。 +# damage_taken 全局发射点(迭代 46 卡A T2——单据未标 hero_damage_consumed 时在下限前发射并消费一次; +# 地形路径已在授予段开单点发射并标记,此处跳过防重复) func _resolve_board_flat_hero(t: Dictionary) -> void: - var dealt: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源 + var dealt: int = int(t.base_value) + if not bool((t.get("context", {}) as Dictionary).get("hero_damage_consumed", false)): + var res_flat: Dictionary = _emit_hero_damage_taken(dealt, "board_flat_hero", String(t.get("source_class", "")), t.origin_cell, int(t.round_index)) + dealt = int(res_flat["total"]) + dealt = maxi(int(db.combat.settlement.damage_roll.damage_floor), dealt) # 伤害下限全来源(减后过下限) units.apply_damage(units.hero_unit(), dealt) var extra: Dictionary[Variant, Variant] = {"target": "hero"} for k in t.context.keys(): diff --git a/game/core/settlement/grant_segment.gd b/game/core/settlement/grant_segment.gd index 1c396e51..88df43de 100644 --- a/game/core/settlement/grant_segment.gd +++ b/game/core/settlement/grant_segment.gd @@ -6,7 +6,8 @@ extends RefCounted # 效果施加归 BattleFxHost(段持三引用直调,零转发壳)—— # 本段只搬运时序,零算法改动(🔴 零行为变更契约:同种子完整局输出逐位一致)。 # run 编排骨架+批私有族(_reveal_batch/_terrain_batch/_seal_batch/_round_end_apply,段内施加面 -# 再拆 _reveal_safe_apply/_reveal_mine_apply/_terrain_damage_reduce/_seal_hit_effects 等单面函数); +# 再拆 _reveal_safe_apply/_reveal_mine_apply/_seal_hit_effects 等单面函数);地形 damage_taken +# 消费随迭代 46 卡A T2 并入伤害控制器全局消费族(本段零本地减伤函数); # kind 分派单点化(P1-1 同批):counter_add 五位三行组 / grant_material 三位兑付块 # 收 _apply_counter_hit / _apply_material_hit 两助手(分支本体逐字保留在助手内)。 @@ -167,37 +168,22 @@ func _terrain_batch(ri: int) -> void: if String(vrow.get("effect", "")) != "flat_damage": continue # 触发词表按 effect 路由:flat_damage=on_open 兑现;hide_number/on_open 外组合 = 数据行新词表未接线(显式忽略面) # 地形变异伤害单据——无类型必中,下限保底在控制器管线内 - # damage_taken 发射在单据开立前:damage_reduce〔背水——hp_ratio when - # 面〕先收集再按减后值开单;counter_add〔保险丝/石肤〕+ ac_bonus〔石肤〕照常施加; - # grant_material〔荆棘/地脉〕移到单据提交后(兑付值序一致——收益不因减伤变化) + # damage_taken 统一发射(迭代 46 卡A T2——地形路径并入全局消费族 _emit_hero_damage_taken: + # 发射在单据开立前、减后值开单,组合消费含 shield〔全局受伤语义〕; + # hero_damage_consumed 标记入单据 context——战斗段末 _resolve_board_flat_hero 跳过重复发射; + # grant_material〔荆棘/地脉〕移到单据提交后(兑付值序一致——收益不因减伤变化)) var dt_amount := int(vrow.get("effect_value", 0)) - var dt_hits := fx_host.fx_emit("damage_taken", {"kind": "terrain", "amount": dt_amount, - "source": String(vid), "cell": evt.cell, "round": ri, "terrain_kind": String(vid), - "hp_ratio": int(round(100.0 * float(int(oc._enemy_system.hero_unit().get("hp", 0))) / float(maxi(1, int(oc._enemy_system.hero_unit().get("max_hp", 1))))))}) - dt_amount = _terrain_damage_reduce(dt_hits, dt_amount) + var dt_res: Dictionary = oc._damage._emit_hero_damage_taken(dt_amount, "terrain", String(vid), evt.cell, ri) + dt_amount = int(dt_res["total"]) var vt := oc._damage.open_ticket(DamageController.KIND_BOARD_FLAT_HERO, "地形变异", String(vrow.get("display_name", vid)), {"channel": "board", "base_value": dt_amount, "target_rule": DamageController.TARGET_HERO, - "ri": ri, "origin_cell": evt.cell, "context": {"variant_id": String(vid)}}) + "ri": ri, "origin_cell": evt.cell, "context": {"variant_id": String(vid), "hero_damage_consumed": true}}) reward_grant.submit(vt, false) - for h in dt_hits: + for h in (dt_res["hits"] as Array): if String(h.get("kind", "")) == "grant_material": _apply_material_hit(h, evt.cell, ri) -# damage_taken 施加面(damage_reduce 减量收集〔背水——定值〕/ac_bonus〔石肤〕/counter_add〔保险丝/石肤〕; -# 返回减后 amount 交开单消费) -func _terrain_damage_reduce(dt_hits: Array, dt_amount: int) -> int: - for h in dt_hits: - match String(h.get("kind", "")): - "damage_reduce": - dt_amount = maxi(0, dt_amount - int(h.get("value", 0))) - "ac_bonus": - fx_host.apply_ac_bonus(h) - "counter_add": - _apply_counter_hit(h) - return dt_amount - - # 封印判定批+战封印军规开单段(封印流:seal_judged 批后统一开单:轮末每面封印命中对最前存活敌 # 结算 board_typed 必中伤害〔复用零新配方;「封印所在巢穴敌」简化为 default_target 最前存活敌—— # 台账披露待追认〕;完美布防=本轮封印批全中(hit>0 且 miss==0)时封印伤 ×mult——全对推理奖励)。 diff --git a/game/tests/unit/battle_settlement/refresh_feats_test.gd b/game/tests/unit/battle_settlement/refresh_feats_test.gd new file mode 100644 index 00000000..f746a4a5 --- /dev/null +++ b/game/tests/unit/battle_settlement/refresh_feats_test.gd @@ -0,0 +1,234 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 换图系双基座七件专项宿主(迭代 46 卡A——对照表 V1–V12 按轮落地:V5=T2 全局受伤链路、 +# V2/V3=T3 两基座、V4/V10=T4 成功事实与 GP、V6–V9=T5 五件后续、V11=T6 数据与池; +# V12 无专长基线回归归 round_flow/seal_polarity 既有断言承载)。 +# 临时承伤计数专长经现行 ConfigDB 注入(表行追加+持有集传 id——正常数据配置路径,卡片 §12); +# 真实入口驱动:AutoPlayer 盲挖循环+停手/封印动作;判据绑可观测提交面(damage_taken 命中计数器 +# 与 target=hero 明细行)。 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/refresh_feats_test.gd(expect 0) +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript = preload("res://core/config_db.gd") +const EffectEngineScript = preload("res://core/effect_engine.gd") +const AutoPlayer := preload("res://sim/auto_player.gd") + +# 直接伤型资源类型(负向极性兑付=主角直伤单据的三型——reward 表 pierce/magic/heal 负向行) +const DIRECT_TYPES := ["pierce", "magic", "heal"] + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + if not _db.load_tables("res://data/tables", true): + print("[refresh_feats_test] FAIL — 数值表加载失败") + quit(1) + return + _install_dt_probe() + test_all_hero_damage_paths_emit_once() + test_no_duplicate_damage_taken_event() + quit(Kit.finish("refresh_feats_test")) + + +# ---- 夹具族(真实入口驱动——settle_batch/stop/封印动作;直构只用于编排器受理)---- + +# 临时承伤计数专长(damage_taken → counter_add battle 桶——每张主角承伤单据恰 +1) +func _install_dt_probe() -> void: + if _db.crossrun.feats.row_by_id("probe_dt_counter").is_empty(): + (_db.crossrun.feats.rows as Array).append({ + "feat_id": "probe_dt_counter", "display_name": "承伤计数探针", "rarity": "common", + "desc": "测试夹具:每次主角承伤计数 +1", + "triggers": [{"on": "damage_taken", "do": {"kind": "counter_add", "counter_id": "dt_probe_hits", "value": 1}}], + }) + + +func _orch(seed_v: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_v) + return load("res://core/battle_orchestrator.gd").new(_db, rng) + + +func _enc(feats: Array) -> Dictionary: + return {"level_index": 1, "node_depth": 4, "hero_hp": 200, "hero_atk": 8, "hero_max_hp": 200, + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "feats": feats, "tree_rules": [], + "battle_index": 1, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "boss", "adv_bucket": {}} + + +func _probe_count(orch) -> int: + return EffectEngineScript.counter(orch.fx_host().counters_ctx, "dt_probe_hits") + + +# 主角承伤明细行收集(type=damage 且 target=hero——敌方技与棋盘主角直伤两类单据的行面) +func _collect_hero_rows(orch) -> Array: + var rows: Array = [] + orch.step_row.connect(func(row): + if String(row.get("type", "")) == "damage" and String(row.get("target", "")) == "hero": + rows.append(row)) + return rows + + +# 按来源类分桶计数(rows 收集器的只读后处理) +func _count_by_source(rows: Array, source: String) -> int: + var n := 0 + for r_v in rows: + if String((r_v as Dictionary).get("source_class", "")) == source: + n += 1 + return n + + +# AutoPlayer 盲挖循环直到 cell_revealed 谓词命中(真实挖掘入口;返回 {"cell","evt"} 或空) +func _dig_until(orch, pred: Callable) -> Dictionary: + var ms = orch.minesweeper + var hit := {} + ms.cell_revealed.connect(func(evt): + if hit.is_empty() and bool(pred.call(evt)): + hit["cell"] = evt.cell + hit["evt"] = evt) + var guard := 40 + while hit.is_empty() and guard > 0: + guard -= 1 + var plan = AutoPlayer.choose_dig(ms) + if plan.is_empty(): + break + if plan.hero_cell != ms.hero_pos(): + ms.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + ms.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + return hit + + +# 直接伤型快照判定(未揭示格可查——reward_economy 装板期摇定身份,示警烟火同源读面) +func _is_direct_type(orch, c: Vector2i) -> bool: + var snap_v = orch.reward_grant().reward_economy.query_full_snapshot(c) + return snap_v is Dictionary and DIRECT_TYPES.has(String((snap_v as Dictionary).get("cell_type", ""))) + + +# 可封印的直接伤型安全格扫描(未揭示+非雷+直接伤型+邻接可达 revealed 格——封印动作真实落点位) +func _find_sealable_direct(orch) -> Vector2i: + var ms = orch.minesweeper + var b = ms.current_board() + var revealed := {} + for c_v in ms.revealed_cells(): + revealed[c_v] = true + for y in b.height: + for x in b.width: + var c := Vector2i(x, y) + if ms.is_revealed(c) or b.is_mine(c) or ms.has_seal(c) or not _is_direct_type(orch, c): + continue + for n_v in b.neighbors8(c): + var n := Vector2i(n_v) + if revealed.has(n) and (ms.compute_move_path(n).size() > 0 or n == ms.hero_pos()): + return c + return Vector2i(-1, -1) + + +# 盲挖扩板直至出现可封印直接伤型安全格(有界;返回格位或 (-1,-1)) +func _seal_direct_with_expansion(orch) -> Vector2i: + var c := _find_sealable_direct(orch) + var guard := 12 + while c.x < 0 and guard > 0: + guard -= 1 + var plan = AutoPlayer.choose_dig(orch.minesweeper) + if plan.is_empty(): + break + if plan.hero_cell != orch.minesweeper.hero_pos(): + orch.minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + orch.minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + c = _find_sealable_direct(orch) + return c + + +# ---- V5 全局受伤链路(迭代 46 卡A T2)---- + +# 四条真实路径(敌方攻击/危险格/错误封印/地形)逐路驱动:每条至少一张主角承伤单据在案, +# 且承伤计数器 == 主角承伤单据行数(每单据恰发射一次的全量判据——重复发射即计数器>行数)。 +func test_all_hero_damage_paths_emit_once() -> void: + print("--- V5 全局 damage_taken:四路径逐路恰一次 ---") + # ① 敌方攻击路径(Boss 战零挖掘连停三轮——干净样本:零地形零资源单据;多轮保证伤害技在案) + var orch1 = _orch(46001) + var rows1 := _collect_hero_rows(orch1) + Kit.check("V5① 前置:战斗受理", orch1.begin_battle(_enc(["probe_dt_counter"]))) + for _i in 3: + orch1.minesweeper.settle_batch([{"action": "stop"}]) + var enemy_rows := _count_by_source(rows1, "enemy_action") + Kit.check("V5① 敌方攻击路径:承伤单据在案且逐单恰一次(行 %d=计数 %d)〔换图系批 V5〕" % [enemy_rows, _probe_count(orch1)], + enemy_rows >= 1 and _probe_count(orch1) == enemy_rows) + # ② 地形路径(AutoPlayer 真挖至伤害型变异格开出——发射在授予段开单点、单据标 hero_damage_consumed) + var orch2 = _orch(46002) + var rows2 := _collect_hero_rows(orch2) + Kit.check("V5② 前置:战斗受理", orch2.begin_battle(_enc(["probe_dt_counter"]))) + var terr := _dig_until(orch2, func(evt): + for vid_v in (evt.get("variant_ids", []) as Array): + if String(_db.combat.variation.row_by_id(String(vid_v)).get("effect", "")) == "flat_damage": + return true + return false) + Kit.check("V5② 前置:伤害型变异格已挖开(%s)" % str(terr.get("cell", Vector2i(-1, -1))), not terr.is_empty()) + orch2.minesweeper.settle_batch([{"action": "stop"}]) + var terrain_rows := _count_by_source(rows2, "地形变异") + Kit.check("V5② 地形路径:地形单据在案且全单据逐张恰一次(行 %d=计数 %d——盲挖途经单据一并计入)〔换图系批 V5〕" % [rows2.size(), _probe_count(orch2)], + terrain_rows >= 1 and _probe_count(orch2) == rows2.size()) + # ③ 危险格路径(AutoPlayer 真挖直接伤型危险格——负向直伤单据) + var orch3 = _orch(46003) + var rows3 := _collect_hero_rows(orch3) + Kit.check("V5③ 前置:战斗受理", orch3.begin_battle(_enc(["probe_dt_counter"]))) + var mine_hit := _dig_until(orch3, func(evt): + return String(evt.get("kind", "")) == "mine" and _is_direct_type(orch3, evt.cell)) + Kit.check("V5③ 前置:直接伤型危险格已挖开(%s)" % str(mine_hit.get("cell", Vector2i(-1, -1))), not mine_hit.is_empty()) + orch3.minesweeper.settle_batch([{"action": "stop"}]) + var danger_rows := _count_by_source(rows3, "mine_hit") + Kit.check("V5③ 危险格路径:直伤单据在案且全单据逐张恰一次(行 %d=计数 %d)〔换图系批 V5〕" % [rows3.size(), _probe_count(orch3)], + danger_rows >= 1 and _probe_count(orch3) == rows3.size()) + # ④ 错误封印路径(扩板→封直接伤型安全格不挖→停手轮末判标错——负向直伤单据) + var orch4 = _orch(46004) + var rows4 := _collect_hero_rows(orch4) + Kit.check("V5④ 前置:战斗受理", orch4.begin_battle(_enc(["probe_dt_counter"]))) + var scell := _seal_direct_with_expansion(orch4) + Kit.check("V5④ 前置:可封直接伤型安全格在位(%s)" % str(scell), scell.x >= 0) + if scell.x >= 0: + var plan4 = AutoPlayer.choose_dig(orch4.minesweeper) + # 封印动作真实落点:先就位到目标邻接可达格(封印距离同挖掘距离) + var seal_plan := _plan_seal(orch4, scell) + if not seal_plan.is_empty(): + if seal_plan["hero_cell"] != orch4.minesweeper.hero_pos(): + orch4.minesweeper.settle_batch([{"action": "move", "cell": seal_plan["hero_cell"]}]) + var seal_out: Array = orch4.minesweeper.settle_batch([{"action": "seal_place", "cell": scell}]) + Kit.check("V5④ 前置:封印受理", not seal_out.is_empty() and bool((seal_out[0] as Dictionary).get("accepted", false))) + orch4.minesweeper.settle_batch([{"action": "stop"}]) + var sealwrong_rows := _count_by_source(rows4, "seal_miss") + Kit.check("V5④ 错误封印路径:直伤单据在案且全单据逐张恰一次(行 %d=计数 %d)〔换图系批 V5〕" % [rows4.size(), _probe_count(orch4)], + sealwrong_rows >= 1 and _probe_count(orch4) == rows4.size()) + + +# 封印落点计划(目标格邻接可达 revealed 位——AutoPlayer._plan_on 同构最小私有形) +func _plan_seal(orch, target: Vector2i) -> Dictionary: + var ms = orch.minesweeper + for n_v in ms.current_board().neighbors8(target): + var n := Vector2i(n_v) + if ms.is_revealed(n) and (ms.compute_move_path(n).size() > 0 or n == ms.hero_pos()): + return {"hero_cell": n} + return {} + + +# 同一伤害不得出现两次消费(重复发射=计数器>行数):敌方路径与地形路径两样本逐位核对—— +# 地形单据带 hero_damage_consumed 标记,战斗段末 _resolve_board_flat_hero 跳过二次发射由此钉住。 +func test_no_duplicate_damage_taken_event() -> void: + print("--- V5 全局 damage_taken:无重复消费 ---") + var orch1 = _orch(46011) + var rows1 := _collect_hero_rows(orch1) + Kit.check("ND 前置:战斗受理", orch1.begin_battle(_enc(["probe_dt_counter"]))) + for _i in 2: + orch1.minesweeper.settle_batch([{"action": "stop"}]) + Kit.check("ND 敌方路径零重复(行 %d=计数 %d——重复消费即计数>行)〔换图系批 V5〕" % [rows1.size(), _probe_count(orch1)], + rows1.size() >= 1 and _probe_count(orch1) == rows1.size()) + var orch2 = _orch(46012) + var rows2 := _collect_hero_rows(orch2) + Kit.check("ND 前置:战斗受理", orch2.begin_battle(_enc(["probe_dt_counter"]))) + var terr2 := _dig_until(orch2, func(evt): + for vid_v in (evt.get("variant_ids", []) as Array): + if String(_db.combat.variation.row_by_id(String(vid_v)).get("effect", "")) == "flat_damage": + return true + return false) + Kit.check("ND 前置:伤害型变异格已挖开(%s)" % str(terr2.get("cell", Vector2i(-1, -1))), not terr2.is_empty()) + orch2.minesweeper.settle_batch([{"action": "stop"}]) + Kit.check("ND 地形路径零重复(行 %d=计数 %d——context 标记闸失效即计数翻倍)〔换图系批 V5〕" % [rows2.size(), _probe_count(orch2)], + rows2.size() >= 1 and _probe_count(orch2) == rows2.size())