diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 263a7a2f..45227ab3 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -12,11 +12,18 @@ signal round_started(round_index: int) signal board_changed() signal settlement_snapshot(snap: Dictionary) ## 每结算末尾的全量状态快照(playtest_logger 消费——JSONL 数值分析) -# 明细流水行型协议(战斗结算 Core Rule;9 类; +# 明细流水行型协议(战斗结算 Core Rule;两层词表——基础 9 类封闭+扩展行 1 类; # 清单改动须同步 battle_log_view 的 _format_row 与本文档头注): -# 效果行 effect · 伤害行 damage · 治疗行 heal · +# 基础 9 类:效果行 effect · 伤害行 damage · 治疗行 heal · # 奖励未生效行 reward_ineffective · 封印未中行 seal_miss · 施放行 cast · 衰减行 decay · # 判定行 verdict · 高光行 highlight +# 扩展行:行型值=extended(单一扩展行型),效果身份由 effect_id 载荷携带(_emit_row 强制必载, +# 缺载即断言失败零发射)。现役效果身份与发射点: +# fx_reveal(专长揭示掷格——battle_orchestrator _flush_pending_reveals 三发射点:兑付行+落空行×2) +# mine_neutralized(封印匠·危险格中立移除——grant_segment 封印命中 remove_mines 面) +# fx_preview_area(示警烟火·周围资源类型——grant_segment 封印命中 preview_area 面) +# insurance_used(完美保险·标错豁免——grant_segment 标错 forgive_seal_miss 面) +# 新增专长效果只新增 effect_id 身份不再新增行型;行型词表改动须同步 owner 文档「扩展行」节。 # 行来源断言豁免集合(行型层)——判定段 / 衰减段本就无棋盘来源,勿一刀切误炸 const SOURCE_EXEMPT_TYPES := ["decay", "verdict"] # 行来源断言豁免集合(来源类别层)——事实来源是单位 / 清图而非棋盘格; @@ -146,7 +153,7 @@ func _flush_pending_reveals(ri: int) -> void: if not minesweeper.is_revealed(c) and not board.is_mine(c): cands.append(c) if cands.is_empty(): - _emit_row("fx_reveal", "fx_reveal", Vector2i(-1, -1), ri, "揭示落空(无未揭示安全格)", 0, {}, "") + _emit_row("extended", "fx_reveal", Vector2i(-1, -1), ri, "揭示落空(无未揭示安全格)", 0, {"effect_id": "fx_reveal"}, "") continue var rg: RandomNumberGenerator = rng.open_stream("fx_reveal") var cell: Vector2i = cands[rg.randi_range(0, cands.size() - 1)] @@ -154,12 +161,12 @@ func _flush_pending_reveals(ri: int) -> void: var ok: bool = minesweeper.fx_reveal(cell) _fx_revealing = false if not ok: - _emit_row("fx_reveal", "fx_reveal", Vector2i(-1, -1), ri, "揭示落空(格子不可揭)", 0, {}, "") + _emit_row("extended", "fx_reveal", Vector2i(-1, -1), ri, "揭示落空(格子不可揭)", 0, {"effect_id": "fx_reveal"}, "") continue var snap = _reward_economy.query_full_snapshot(cell) if snap is Dictionary: _grant_routed(snap, "positive", "fx_reveal", cell, ri, "fx_reveal") - _emit_row("fx_reveal", "fx_reveal", cell, ri, "专长揭示", 0, {}, "fx_reveal") + _emit_row("extended", "fx_reveal", cell, ri, "专长揭示", 0, {"effect_id": "fx_reveal"}, "fx_reveal") # 补充事实面(fact 取值第二优先源——payload 未携带的运行态读数) @@ -1260,6 +1267,11 @@ func _reset_round_facts() -> void: # ---- 行协议(Core Rule:公共字段 + 行类型特有字段)---- func _emit_row(type: String, source_class: String, origin_cell: Vector2i, ri: int, display: String, value: int, extra: Dictionary, highlight: String = "") -> void: + # 扩展行必载效果身份(行协议 fail-fast——违约行先炸后不发,零发射;开发期违约即炸, + # 与 _assert_row_source 同族 assert 形态) + if type == "extended" and String(extra.get("effect_id", "")) == "": + assert(false, "[战斗结算] 扩展行缺 effect_id 载荷——行协议违约") + return _assert_row_source(type, source_class, origin_cell) var row := { "type": type, diff --git a/game/core/settlement/grant_segment.gd b/game/core/settlement/grant_segment.gd index 39356838..a024443a 100644 --- a/game/core/settlement/grant_segment.gd +++ b/game/core/settlement/grant_segment.gd @@ -268,7 +268,7 @@ func _judge_seal(evt: Dictionary, ri: int) -> void: "remove_mines": var removed: Array[Variant] = oc.minesweeper.remove_mines_in([cell]) if not removed.is_empty(): - oc._emit_row("mine_neutralized", "fx_seal_smith", cell, ri, "封印匠·危险格中立移除", 0, {}, "") + oc._emit_row("extended", "fx_seal_smith", cell, ri, "封印匠·危险格中立移除", 0, {"effect_id": "mine_neutralized"}, "") "preview_area": var board_p: BoardData = oc.minesweeper.current_board() var types: Array[Variant] = [] @@ -278,7 +278,7 @@ func _judge_seal(evt: Dictionary, ri: int) -> void: types.append(String((n_snap as Dictionary).get("cell_type", ""))) if oc.treasure_inventory != null and not types.is_empty(): oc.treasure_inventory.apply_overlay("reward_preview", board_p.neighbors8(cell)) - oc._emit_row("fx_preview_area", "", cell, ri, "示警烟火·周围资源类型", 0, {"types": types}, "") + oc._emit_row("extended", "fx_warning_beacon", cell, ri, "示警烟火·周围资源类型", 0, {"effect_id": "fx_preview_area", "types": types}, "") # 雷区图谱(封印流——标中雷额外获得一份格面资源:封印开始产出对标开格; # 复用 grant 单点,额外兑付走同一 _grant_routed 正向路由) for h in fh_hits: @@ -306,8 +306,8 @@ func _judge_seal(evt: Dictionary, ri: int) -> void: # _perfect_forgiven,绝不回写 _perfect_hit_false=契约 2 计数纯净;发射时机与消费点零改动=契约 3) oc._perfect_forgiven += 1 _apply_counter_hit(h) # 豁免同时记账(insurance_used battle 域——下次标错 when lt 1 不再命中) - oc._emit_row("insurance_used", "seal_miss", cell, ri, "完美保险·标错豁免", 0, - {"forgiven": oc._perfect_forgiven}, "") + oc._emit_row("extended", "seal_miss", cell, ri, "完美保险·标错豁免", 0, + {"effect_id": "insurance_used", "forgiven": oc._perfect_forgiven}, "") var hl := "perfect_broken" if oc._perfect_hit_false - oc._perfect_forgiven == 1 else "" oc._emit_row("seal_miss", "seal_miss", cell, ri, "封印未中", 0, {}, hl) var snap = oc._reward_economy.grant(cell) diff --git a/game/tests/evidence/parity/anchor.json b/game/tests/evidence/parity/anchor.json index 5786fdfb..c47e19cf 100644 --- a/game/tests/evidence/parity/anchor.json +++ b/game/tests/evidence/parity/anchor.json @@ -49,7 +49,7 @@ "rows": 9216 }, "perfect": { - "sha256": "a37ba2ca47f8619f22aa3dd8a761fffc71916d6c3172f5ff0aeb2b0557aba7e5", + "sha256": "962b6f68440bcf95f2b21ac4cc07a8523655f044ae6355aa674d0db27bdecc1d", "rows": 60774 } } @@ -103,7 +103,7 @@ "rows": 9670 }, "perfect": { - "sha256": "f6bb9fd5ba5fc183fd29adbb00ae526a5c6852e7f52f9f597a0b4d853baf05d6", + "sha256": "06cb4dcc26c56343c61bc8d972238be81b88e3132d7e9b274399f4cfd04cb56e", "rows": 62765 } } @@ -157,7 +157,7 @@ "rows": 7124 }, "perfect": { - "sha256": "7d16b4efb2bb48355a7919011b265de83ff907bd9c83818dc66e69108967d5a3", + "sha256": "c3d0d37c5f40d208fb35d323b4c7a11c4f7ebe77f9f0eaba0fc3445f00971277", "rows": 59435 } } diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 90ef459f..37997467 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -70,6 +70,7 @@ func _suites() -> Array: {"path": "res://tests/unit/battle_settlement/round_flow_skill_pool_test.gd", "expect": 0}, # 轮内流与技能池宿主(全战斗回放/轮内刷新与 V5 快照单源/遭遇区间骰/结算五段全序/技能池数据面·回放独立·EV 锚·死亡打断·校验拒绝/妨害附加/四桶计数——批 B 自 smoke_all 迁入) {"path": "res://tests/unit/battle_settlement/variant_placement_test.gd", "expect": 0}, # 变异布置宿主(战内布置段门控与数量公式/全局变异兜底回落与资源面偏斜——批 B 自 smoke_all 迁入) {"path": "res://tests/unit/battle_settlement/skill_unlock_test.gd", "expect": 0}, # Boss 半血解锁技·容器机制(门谓词/闩不可逆/双面过滤/保底不逼锁定/零扰动) + {"path": "res://tests/unit/battle_settlement/extended_row_violation_test.gd", "expect": 1, "negative": true}, # 卡④ wr2-np 扩展行必载 effect_id 负路径(行协议运行期违约面首个负面套件——独立进程豁免错误扫描) {"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0}, {"path": "res://tests/unit/contracts/source_guard_test.gd", "expect": 0}, # 源码契约宿主(project.godot 警告配置与类型闸/产品码卫生死代码·影子账·层序单口——批 B 自 smoke_all 迁入;被测对象=仓库自身源文件,裁决五新设) {"path": "res://tests/unit/presentation/ui_font_asset_test.gd", "expect": 0}, diff --git a/game/tests/unit/battle_settlement/extended_row_violation_test.gd b/game/tests/unit/battle_settlement/extended_row_violation_test.gd new file mode 100644 index 00000000..c2f31718 --- /dev/null +++ b/game/tests/unit/battle_settlement/extended_row_violation_test.gd @@ -0,0 +1,38 @@ +# 致命类用例(独立进程,期望退出码 1)——迭代 38 卡④ wr2-np:扩展行必载 effect_id 负路径断言 +# 违约直调 _emit_row(type=extended 无 effect_id 载荷)→ a2 行协议校验 assert 炸(debug 实况, +# 输出中的 SCRIPT ERROR 为其测试目标——负面套件全文折叠)→ 零发射; +# 回归态(校验段被移除)= 违约行照发 → 退出码 2 ≠ 期望 1(闸红)。 +# 独立进程理由:GDScript assert 失败必产 SCRIPT ERROR 且不可捕获,非负面套件会被 run_all 错误 +# 扫描判红(迭代 38 卡④ grilling 第二轮同款结论——负面豁免机制为此存在,counter_prefix 先例)。 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/extended_row_violation_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") + + +func _init() -> void: + var db = load("res://core/config_db.gd").new() + if not db.load_tables("res://data/tables", false): + push_error("[extended_row_violation_test] 表加载失败") + quit(2) + return + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(74100) + var oc = load("res://core/battle_orchestrator.gd").new(db, rng) + var hits: Array = [] + oc.step_row.connect(func(row): hits.append(row)) + # 违约直调(缺 effect_id 载荷——行协议违约):debug 下 a2 校验 assert 炸中断 _emit_row 本体, + # 控制流回到本函数(headless assert 语义=中断当前函数不中止运行);release 下 return 零发射同效 + oc._emit_row("extended", "fx_reveal", Vector2i.ZERO, 1, "负路径", 0, {}, "") + if not hits.is_empty(): + push_error("[extended_row_violation_test] 违约行被发射——a2 校验失守(回归)") + quit(2) + return + # 对照组:合法扩展行(带 effect_id)照常发射——校验只拦违约形态 + oc._emit_row("extended", "fx_reveal", Vector2i.ZERO, 1, "合法对照", 0, {"effect_id": "fx_reveal"}, "") + if hits.size() != 1 or String(hits[0].get("type", "")) != "extended": + push_error("[extended_row_violation_test] 合法扩展行未发射——校验误拦(回归)") + quit(2) + return + print("[extended_row_violation_test] 违约零发射+合法照发(a2 校验在位)") + quit(1) diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd index 066a3ad0..ee4ae52e 100644 --- a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd +++ b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd @@ -38,6 +38,7 @@ func _init() -> void: _smoke_settlement_segments() _smoke_skill_pool() _smoke_counter_buckets() + _smoke_extended_rows() quit(Kit.finish("round_flow_skill_pool_test")) @@ -994,6 +995,8 @@ func _smoke_counter_buckets() -> void: "node_type": "battle", "ledger": load("res://core/run_ledger.gd").new()} var oA = RigS.new(_db, _fresh_rng(779401)) oA.begin_battle(encA) + var v6_rows: Array = [] + oA.step_row.connect(func(row): v6_rows.append(row)) # 卡④ wr2①——标错行捕获(真实 step_row 发射面) var safe_a: Variant = null for ca in oA.minesweeper.current_board().all_cells(): if not oA.minesweeper.current_board().is_mine(ca): @@ -1001,8 +1004,62 @@ func _smoke_counter_buckets() -> void: break oA._grant_seg._judge_seal({"cell": safe_a, "hit": false}, 1) Kit.check("V6 第1战标错落 battle 桶 insurance_used=1(_apply_counter_hit 静态写口经 ctx)〔无状态化 V6〕", EffectEngine.counter(oA.counters_ctx, "insurance_used") == 1) + # 卡④ wr2①——完美保险标错行=单一扩展行型(原 insurance_used 实名行型收敛) + var v6_ins := {} + for r in v6_rows: + if String(r.get("effect_id", "")) == "insurance_used": + v6_ins = r + Kit.check("卡④ wr2①:完美保险标错行 type=extended 且 effect_id=insurance_used(effect_id 必载)〔明细收敛 V2〕", + not v6_ins.is_empty() and String(v6_ins.get("type", "")) == "extended" + and String(v6_ins.get("source_class", "")) == "seal_miss" and bool(v6_ins.get("forgiven", 0))) var encB = encA.duplicate(true) encB["battle_index"] = 2 var oB = RigS.new(_db, _fresh_rng(779402)) oB.begin_battle(encB) Kit.check("V6 battle 桶随宿主销毁:次战新编排器 fx_counters_battle 天然空,读 insurance_used=0〔无状态化 V6〕", EffectEngine.counter(oB.counters_ctx, "insurance_used") == 0) + + +# ---- 扩展行收敛(迭代 38 卡④)——fx_reveal 兑付行与落空行经真实揭示管线发射 ---- +# 驱动形态:装配揭示类专长(geologist)的编排器上走生产同一入队/兑付管线 +# (_fx_enqueue_reveal → _flush_pending_reveals——grant/damage 揭示订阅面的共用出口)。 +# V6 形态先例同源:直入真实管线缝(_judge_seal 直调同款口径)。 + +func _smoke_extended_rows() -> void: + print("--- 扩展行收敛:fx_reveal 兑付/落空 ---") + var RigS = load("res://core/battle_orchestrator.gd") # 局部装配别名(_smoke_counter_buckets 同款——跨函数不共享局部量) + var enc_fx = {"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, + "feats": ["geologist"], "tree_rules": [], "battle_index": 1, + "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, + "node_type": "battle", "ledger": load("res://core/run_ledger.gd").new()} + # ② 兑付行:有未揭示安全格 → 掷格揭示成功 → 扩展行带真实格与 fx_reveal 身份 + var o_fx = RigS.new(_db, _fresh_rng(779501)) + o_fx.begin_battle(enc_fx) + var fx_rows: Array = [] + o_fx.step_row.connect(func(row): fx_rows.append(row)) + o_fx._fx_enqueue_reveal() + o_fx._flush_pending_reveals(1) + var paid := {} + for r in fx_rows: + if String(r.get("effect_id", "")) == "fx_reveal": + paid = r + Kit.check("卡④ wr2②:专长揭示兑付行 type=extended 且 effect_id=fx_reveal 且来源格真实〔明细收敛 V2〕", + not paid.is_empty() and String(paid.get("type", "")) == "extended" + and Vector2i(paid["origin_cell"][0], paid["origin_cell"][1]).x >= 0) + # ③ 落空行:无未揭示安全格态(全安全格先揭)→ 候选空 → 落空归因行(origin_cell 越界哨值) + var o_void = RigS.new(_db, _fresh_rng(779502)) + o_void.begin_battle(enc_fx) + var void_rows: Array = [] + o_void.step_row.connect(func(row): void_rows.append(row)) + for cv in o_void.minesweeper.current_board().all_cells(): + if not o_void.minesweeper.current_board().is_mine(cv): + o_void.minesweeper.fx_reveal(cv) + o_void._fx_enqueue_reveal() + o_void._flush_pending_reveals(1) + var missed := {} + for r in void_rows: + if String(r.get("effect_id", "")) == "fx_reveal" and String(r.get("display_name", "")).contains("落空"): + missed = r + Kit.check("卡④ wr2③:揭示落空行 type=extended 且 effect_id=fx_reveal 且 origin_cell=越界哨值〔明细收敛 V2〕", + not missed.is_empty() and String(missed.get("type", "")) == "extended" + and Vector2i(missed["origin_cell"][0], missed["origin_cell"][1]).x == -1) diff --git a/game/tests/unit/core/treasure_effects_test.gd b/game/tests/unit/core/treasure_effects_test.gd index 95fbd150..5b7d33ed 100644 --- a/game/tests/unit/core/treasure_effects_test.gd +++ b/game/tests/unit/core/treasure_effects_test.gd @@ -300,6 +300,60 @@ func _init() -> void: and (adr14.runner()._attunement as Array) == snap14, "装配不可变:战斗运行态装配集与冒险装配集逐位同源(运行零写口——快照对比)") + # ⑮ 扩展行收敛(迭代 38 卡④ wr2④⑤——套件首次 feats 注入,V6 形态自 round_flow_skill_pool_test 移植: + # enc 带 feats 键+ledger,专长触发经 _judge_seal 真实封印判定管线) + var enc_feats = {"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, + "feats": ["seal_smith"], "tree_rules": [], "battle_index": 1, + "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, + "node_type": "battle", "ledger": load("res://core/run_ledger.gd").new()} + var rng15 = load("res://core/rng_service.gd").new(); rng15.set_master_seed(341) + var ob15 = orch_s.new(db, rng15) + ob15.begin_battle(enc_feats) + var rows15: Array = [] + ob15.step_row.connect(func(row): rows15.append(row)) + # ④ 封印匠(每第 3 次封印命中→危险格中立移除):三枚不同雷格各封一次 + #(_judge_seal 每格去重——_seal_consumed 以危险格实例为键,同格重复命中不计数) + var mines15: Array = [] + for cv in ob15.minesweeper.current_board().all_cells(): + if ob15.minesweeper.current_board().is_mine(cv): + mines15.append(cv) + if mines15.size() >= 3: + break + Kit.check_quiet(mines15.size() == 3, "封印匠:板上存在三枚危险格(断言前提)") + if mines15.size() == 3: + for mv in mines15: + ob15._grant_seg._judge_seal({"cell": mv, "hit": true}, 1) + var smith_row := {} + for r in rows15: + if String(r.get("effect_id", "")) == "mine_neutralized": + smith_row = r + Kit.check_quiet(not smith_row.is_empty() and String(smith_row.get("type", "")) == "extended" + and String(smith_row.get("source_class", "")) == "fx_seal_smith", + "封印匠第三次命中:危险格中立移除行=扩展行型(type=extended+effect_id=mine_neutralized)〔明细收敛 V2〕") + # ⑤ 示警烟火(封印命中→周围资源类型预告):fx_preview_area 扩展行+来源类别修复断言(wr3) + var enc_beacon = enc_feats.duplicate(true) + enc_beacon["feats"] = ["warning_beacon"] + var rng15b = load("res://core/rng_service.gd").new(); rng15b.set_master_seed(342) + var ob15b = orch_s.new(db, rng15b) + ob15b.begin_battle(enc_beacon) + var rows15b: Array = [] + ob15b.step_row.connect(func(row): rows15b.append(row)) + var cell15b: Variant = null + for cv in ob15b.minesweeper.current_board().all_cells(): + if not ob15b.minesweeper.current_board().is_mine(cv): + cell15b = cv + break + if cell15b != null: + ob15b._grant_seg._judge_seal({"cell": cell15b, "hit": true}, 1) + var beacon_row := {} + for r in rows15b: + if String(r.get("effect_id", "")) == "fx_preview_area": + beacon_row = r + Kit.check_quiet(not beacon_row.is_empty() and String(beacon_row.get("type", "")) == "extended" + and String(beacon_row.get("source_class", "")) == "fx_warning_beacon", + "示警烟火命中行:type=extended+effect_id=fx_preview_area+来源类别=fx_warning_beacon(空串违约修复)〔明细收敛 V2〕") + quit(Kit.finish("treasure_effects_test", "PASS — 新目录 12 件逐件行为断言全绿〔秘宝批 V7 附属表〕"))