Files
gplan-2/game/sim/damage_probe.gd
T

428 lines
19 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# 定位:诊断工具·非验收件——不出入任何闸(probe_gate/render_gate/run_smoke 全不涉)。
# playtest-2026-09-12 报告 Top1——零承伤根因分割探针
# 检查面:H1 hero_defense 池仅 off_guard 正向行(×0 墙结构面不存在);
# H2 Boss/敌攻击链健康——fresh 池下非蓄力拍 d_tick = max(下限, 池乘区(掷点档位基线))。
# 快照盲区说明:settlement_snapshot 取在衰减段之后,时效 1 的 ac_up 当轮授予当轮衰减——
# 真人局 JSONL buffs[] 恒空 ≠ 状态为空,本探针改从结算行(授予/续存 effect 行的 pool_value)重建战斗段时点池值。
# 观测面:全部读战斗结算步骤行 / 表值 / 呈现快照(零核心穿透);池归属/档位/下限全读表(数据配置 Rule 1——零数值字面量)。
# 模式(-- 后参数):boss [seed](默认 42:Run A 零挖掘 fresh 池 + Run B 自动挖掘授予行为)
# level [seed](全关遭遇回放 + 多种子 spawn 扫描——报告 Bug#1 证据面)
# 运行:Godot_console.exe --headless --path . --script res://sim/damage_probe.gd -- boss 42
# Godot_console.exe --headless --path . --script res://sim/damage_probe.gd -- level 42
extends SceneTree
const BattleDriver := preload("res://sim/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
var _checks := 0
var _fails := 0
func check(cond: bool, label: String) -> void:
_checks += 1
if cond:
print(" [PASS] " + label)
else:
_fails += 1
print(" [FAIL] " + label)
func _initialize() -> void:
print("=== 零承伤根因分割探针(playtest-2026-09-12 报告 Top1)===")
var db = load("res://core/config_db.gd").new()
if not db.load_tables("res://data/tables", false):
print("表加载失败")
quit(1)
return
var args := OS.get_cmdline_user_args()
var mode := "boss"
if args.size() >= 1 and (args[0] == "boss" or args[0] == "level"):
mode = args[0]
var seed_val := 42
if args.size() >= 2 and args[1].is_valid_int():
seed_val = int(args[1])
if mode == "boss":
_probe_boss(db, seed_val)
else:
_probe_level(db, seed_val)
print("=== 探针完成:%d 项检查,%d 项失败 ===" % [_checks, _fails])
quit(1 if _fails > 0 else 0)
# ==================== boss 模式:逐结算行打印 + 根因判定 ====================
func _probe_boss(db, seed_val: int) -> void:
var math = load("res://core/damage_math.gd")
var clamp_lo: int = int(db.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.settlement.core.pool_clamp_hi)
var hero_def_negative := 0
for eff_id in db.settlement.core.effect_pool_map.keys():
if String(db.settlement.core.effect_pool_map[eff_id]) != "hero_defense":
continue
var row: Dictionary = db.status_effect_row(eff_id)
if not row.is_empty() and String(row.get("unit", "")) == "percent":
hero_def_negative = mini(hero_def_negative, int(row.get("magnitude", 0)))
print("表值面:hero_defense 池最负 percent 行 %d · pool_clamp [%d, %d] · damage_floor %d · AC 基准 %d(+ac_up×%d 层)" % [
hero_def_negative, clamp_lo, clamp_hi, int(db.settlement.damage_roll.damage_floor), int(db.hero.armor_class), int(db.status_effect_row("ac_up").get("max_stack", 0))])
print("--- Run A:fresh 池(零挖掘停手轮——无授予,攻击链裸验)---")
var run_a := _run_boss(db, seed_val, false)
_print_rounds(db, math, run_a)
var a_ok := _assert_rounds(db, math, run_a, true)
check(a_ok.fresh_and_full, "A1 攻击链健康(fresh 池·掷点口径):非蓄力拍 d_tick = max(下限, 掷点档位基线+附加)(×%d 拍)——H2 攻击链缺陷否证" % a_ok.n_attack)
check(a_ok.arith_ok, "A2 池算术完整:全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % a_ok.n_attack)
check(String(run_a.result) == "defeat", "A3 攻击链致死可达:fresh 池 Boss 在 %d 轮内击倒英雄(result=%s——伤害确实落地)" % [run_a.snaps.size(), str(run_a.result)])
var run_a2 := _run_boss(db, seed_val, false)
check(JSON.stringify(run_a.rows) == JSON.stringify(run_a2.rows), "A4 同种子回放:Run A 两遍结算行逐行相同(含 armor_roll 掷点流)")
print("--- Run B:自动挖掘(授予照常发生——当前数据承伤行为实测)---")
var run_b := _run_boss(db, seed_val, true)
_print_rounds(db, math, run_b)
var b_ok := _assert_rounds(db, math, run_b, false)
check(b_ok.arith_ok, "B1 池算术完整(含授予轮):全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % b_ok.n_attack)
check(b_ok.wall_tick_positive, "B2 负池轮不归零(伤害下限):hero_def<0 的轮 d_tick 恒 ≥ damage_floor(×%d 轮,最深实测池 %d)" % [b_ok.n_wall, b_ok.deepest])
check(hero_def_negative * 16 > clamp_lo or hero_def_negative >= 0, "B3 墙归零不可达(表值判定):hero_defense 池最负行 %d——叠满不可触池底 %d(×0 免伤墙结构面已随 AC 换装消失)" % [hero_def_negative, clamp_lo])
func _run_boss(db, seed_val: int, dig: bool) -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
var cap := {"rows": [], "snaps": [], "result": ""}
settlement.step_row.connect(func(row): cap["rows"].append(row))
settlement.settlement_snapshot.connect(func(s): cap["snaps"].append(s))
settlement.battle_finished.connect(func(p): cap["result"] = String(p.get("result", "")))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.hero.max_hp), "node_type": "boss"}):
cap["result"] = "begin_failed"
return cap
var rounds := 0
while settlement.battle_result == "" and rounds < 40:
rounds += 1
if dig:
BattleDriver.drive_round(settlement)
else:
settlement.minesweeper.settle_batch([{"action": "stop"}])
return cap
# 逐轮:授予行汇总 → 重建战斗段时点池值(effect_pool_map 表驱动归属)→ 敌行动行逐行打印
func _print_rounds(db, math, run: Dictionary) -> void:
var groups := _round_groups(run.rows)
var beats := _played_beats(run, db)
var rounds: Array = groups.keys()
rounds.sort()
for ri in rounds:
var rows: Array = groups[ri]
var an := _analyze_round(db, math, rows, beats)
var grant_txt := "(无)"
if not an.grants.is_empty():
grant_txt = " · ".join(an.grants)
print(" [R%d] 授予: %s" % [ri, grant_txt])
for line in an.lines:
print(" " + String(line))
var hp_txt := ""
if int(ri) - 1 < run.snaps.size():
var s: Dictionary = run.snaps[int(ri) - 1]
var hero: Dictionary = s.get("hero", {})
hp_txt = " · hero %d/%d · verdict %s" % [int(hero.get("hp", -1)), int(hero.get("max_hp", -1)), String(s.get("verdict", ""))]
print(" [R%d] 池值@战斗段: hero_def=%d enemy_out=%d%s" % [ri, an.pd, an.pe, hp_txt])
func _assert_rounds(db, math, run: Dictionary, fresh_mode: bool) -> Dictionary:
var groups := _round_groups(run.rows)
var beats := _played_beats(run, db)
var rounds: Array = groups.keys()
rounds.sort()
var out := {"fresh_and_full": true, "arith_ok": true, "wall_tick_positive": true,
"n_attack": 0, "n_wall": 0, "deepest": 0}
for ri in rounds:
var an := _analyze_round(db, math, groups[ri], beats)
for t in an.ticks:
out.n_attack += 1
if fresh_mode and (an.pd != 0 or an.pe != 0):
out.fresh_and_full = false
if not bool(t.ok):
out.arith_ok = false
if fresh_mode and (int(t.tick) <= 0 or int(t.raw) <= 0):
out.fresh_and_full = false
if an.pd < 0:
out.n_wall += 1
out.deepest = mini(out.deepest, an.pd)
if int(t.tick) <= 0:
out.wall_tick_positive = false
return out
func _round_groups(rows: Array) -> Dictionary:
var groups := {}
for row in rows:
var ri := int(row.get("round_index", 0))
if not groups.has(ri):
groups[ri] = []
groups[ri].append(row)
return groups
func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary:
var last_pool := {}
var grants: Array = []
for row in rows:
if String(row.get("type", "")) == "effect":
var eid := String(row.get("effect_id", ""))
var pv := int(row.get("pool_value", 0))
last_pool[eid] = pv
grants.append("%s→池%d" % [String(row.get("display_name", "")), pv])
# 池归属表驱动(settlement.effect_pool_map),percent 池加算后 clamp(战斗结算 F2 同口径)
var pools := {"hero_defense": 0, "enemy_output": 0, "hero_offense": 0, "flat_attach": 0}
for eid in last_pool:
var pool_name: String = String(db.settlement.core.effect_pool_map.get(eid, ""))
if pools.has(pool_name):
pools[pool_name] += int(last_pool[eid])
var clamp_lo: int = int(db.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.settlement.core.pool_clamp_hi)
var pd: int = clampi(int(pools["hero_defense"]), clamp_lo, clamp_hi)
var pe: int = clampi(int(pools["enemy_output"]), clamp_lo, clamp_hi)
var lines: Array = []
var ticks: Array = []
for row in rows:
var t := String(row.get("type", ""))
if t == "cast":
lines.append("拍=cast(施放行零兑付——增益技不读池,技能池批由蓄力行改型)")
elif t == "damage" and String(row.get("source_class", "")) == "enemy_action":
var baseline: int = int(row.get("baseline", 0))
var attach: int = int(row.get("attach", 0))
# 掷点口径重建:基线按行留档档位打折(暴击 ×倍率),附加必中
var tier: int = int(row.get("tier", 100))
var crit: bool = bool(row.get("crit", false))
var base_factor: float = float(tier) * (float(db.settlement.damage_roll.crit_multiplier) / 100.0 if crit else 1.0)
var rolled_base := int(floor(float(baseline) * base_factor / 100.0))
var raw: int = rolled_base + attach
var tick: int = int(row.get("value", 0))
var expected: int = maxi(int(db.settlement.damage_roll.damage_floor), math.pool_damage(raw, pd, pe))
var roll_txt := "掷%s" % str(int(row.get("roll", -1)))
var zeroed := "✓" if tick == expected else "✗ 算术不符!"
lines.append("拍=%s %s/档%d%s 基线%d→掷后%d 附加%d hero_def=%d enemy_out=%d d_tick=%d 期望=%d %s" % [
String(beats.get(int(row.get("round_index", 0)), "?")), roll_txt, tier, "暴击" if crit else "",
baseline, rolled_base, attach, pd, pe, tick, expected, zeroed])
ticks.append({"raw": raw, "tick": tick, "expected": expected, "ok": tick == expected})
elif t == "damage" and String(row.get("source_class", "")) == "hero_attack":
lines.append("主角攻击 %d(普通 %d + 穿刺 %d + 魔法 %d)→ %s" % [
int(row.get("value", 0)), int(row.get("normal", 0)), int(row.get("pierce", 0)), int(row.get("magic", 0)), String(row.get("target", ""))])
return {"pd": pd, "pe": pe, "grants": grants, "lines": lines, "ticks": ticks}
# 拍名重建:pending_skill 字段 = 该结算开轮摇定的动作 id(快照取在结算末尾)——
# round r 的拍 = 快照 r 的 enemies[0].pending_skill。快照 enemies 空时(敌死亡轮)回退 "?"——该轮敌已无行动行。
func _played_beats(run: Dictionary, db) -> Dictionary:
var beats := {}
for r in range(1, run.snaps.size() + 1):
var s: Dictionary = run.snaps[r - 1]
var en: Array = s.get("enemies", [])
if en.size() > 0:
beats[r] = String(en[0].get("pending_skill", "?"))
return beats
# ==================== level 模式:全关遭遇回放(Bug#1 证据面) ====================
func _probe_level(db, seed_val: int) -> void:
print("--- 全关回放 seed %d(战×2→道具→战×2→恢复→Boss)---" % seed_val)
var prediction := _encounter_prediction(db, seed_val)
var cap := _run_level(db, seed_val)
var battles: Array = cap.battles
for b in battles:
var depth: int = int(b.get("depth", -1))
var spawn: int = int(b.get("spawn", -1))
var predicted: int = int(prediction.get(depth, -1))
var verdict_txt := _battle_verdict_txt(cap, depth)
var match_txt := "✓" if spawn == predicted else "✗ 与遭遇骰预测不符!"
print(" d%d %s: spawn=%d(遭遇骰预测 %d %s)%s" % [depth, String(b.get("type", "")), spawn, predicted, match_txt, verdict_txt])
var all_match := true
var all_ge_1 := true
for b in battles:
var spawn2: int = int(b.get("spawn", -1))
if spawn2 < 1:
all_ge_1 = false
if spawn2 != int(prediction.get(int(b.get("depth", -1)), -2)):
all_match = false
check(all_ge_1, "全部战斗 spawn ≥ 1(难度曲线与Boss Rule 8 区间下限——空遭遇在当前实现不可达)")
check(all_match, "spawn 与独立遭遇骰预测逐战斗一致(encounter 封闭流同种子可回放)")
var expect_battles := _battle_nodes_upto(db, 1, int(cap.get("end_node", -1)))
check(battles.size() == expect_battles, "战斗推进数与节点链一致(实测 %d/预期 %d——败北截断或全链跑通)" % [battles.size(), expect_battles])
print(" 关卡结果: %s" % str(cap.result))
print("--- 多种子 spawn 扫描(报告 Bug#1 回归面:20 种子 × 5 战斗节点)---")
var scan := _scan_spawns(db, seed_val, 20)
check(scan.bad == 0, "%d 场战斗 spawn 全域内(∈[unit_count, unit_count_max] 且 ≥1)——越域 %d 场" % [scan.total, scan.bad])
func _encounter_prediction(db, seed_val: int) -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var out := {}
var scope := 0
var lrow := _level_row(db, 1)
for node in lrow.get("nodes", []):
var t := String(node.get("type", ""))
if t != "battle" and t != "boss":
continue
var depth := int(node.get("node_depth", -1))
var row: Dictionary = db.resolve_enemy_rowset(1, depth, t)[0] # 行集首行(row_kind 随节点类型)
var lo := int(row.get("unit_count", 1))
var hi := int(row.get("unit_count_max", lo))
var rolled := lo
if hi > lo:
var rg := RandomNumberGenerator.new()
rg.seed = rng.derive_at(scope)
scope += 1
rolled = lo + rg.randi_range(0, hi - lo)
out[depth] = rolled
return out
func _level_row(db, level_index: int) -> Dictionary:
for row in db.level.rows:
if int(row.level_index) == level_index:
return row
return {}
func _run_level(db, seed_val: int) -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var runner = load("res://core/level_runner.gd").new(db, rng)
var cap := {"battles": [], "rows": [], "result": "", "end_node": -1}
runner.step_row.connect(func(row): cap["rows"].append(row))
runner.node_entered.connect(_on_level_node_entered.bind(runner, cap))
runner.round_started.connect(_on_level_round_started.bind(runner, cap))
runner.level_finished.connect(_on_level_finished.bind(cap))
runner.start_level(1, seed_val)
var guard_battles := 20
while cap["result"] == "" and guard_battles > 0:
guard_battles -= 1
var settlement = runner.current_battle()
if settlement == null:
break
_drive_battle(runner, settlement, cap)
return cap
func _on_level_finished(p: Dictionary, cap: Dictionary) -> void:
cap["result"] = String(p.get("result", ""))
cap["end_node"] = int(p.get("node_index", -1))
func _battle_nodes_upto(db, level_index: int, node_idx: int) -> int:
var n := 0
var nodes: Array = _level_row(db, level_index).get("nodes", [])
for i in range(mini(node_idx + 1, nodes.size())):
var t := String(nodes[i].get("type", ""))
if t == "battle" or t == "boss":
n += 1
return n
func _on_level_node_entered(node: Dictionary, _info: Dictionary, runner, cap: Dictionary) -> void:
var t := String(node.get("type", ""))
if t == "battle" or t == "boss":
cap["battles"].append({"type": t, "depth": int(node.get("node_depth", -1)), "spawn": -1})
else:
runner.continue_after_node()
func _on_level_round_started(ri: int, runner, cap: Dictionary) -> void:
if ri != 1 or cap["battles"].is_empty():
return
var settlement = runner.current_battle()
if settlement == null:
return
var s: Dictionary = settlement.presentation_snapshot()
cap["battles"][cap["battles"].size() - 1]["spawn"] = s.enemies.size()
func _drive_battle(runner, settlement, cap: Dictionary) -> void:
var round_guard := 60
while cap["result"] == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
round_guard -= 1
BattleDriver.drive_round(settlement, func() -> bool:
return runner.current_battle() == settlement and settlement.battle_result == "")
# 战斗判定摘要:round-1 victory 的战斗必须能从结算行分辨「首轮全灭」vs「空场」——
# 判据:首轮存在伤害行 = 有敌且被杀(wipe);零战斗行 = 空场(Bug#1 特征)
func _battle_verdict_txt(cap: Dictionary, depth: int) -> String:
# 逐战斗行段切分:round_index 回卷 = 新战斗(defense_scan 同口径)
var battle_rows: Array = []
var segs: Array = []
var last_round := 0
for row in cap.rows:
var r: int = int(row.get("round_index", 0))
if r < last_round:
segs.append(battle_rows)
battle_rows = []
last_round = r
battle_rows.append(row)
if not battle_rows.is_empty():
segs.append(battle_rows)
var target := _battle_index_of_depth(depth, cap)
if target >= 0 and target < segs.size():
return _summarize_rows(segs[target])
return ""
func _battle_index_of_depth(depth: int, cap: Dictionary) -> int:
for i in cap["battles"].size():
if int(cap["battles"][i].get("depth", -1)) == depth:
return i
return -1
func _summarize_rows(rows: Array) -> String:
var verdict := ""
var r1_rows: Array = []
for row in rows:
if String(row.get("type", "")) == "verdict":
verdict = String(row.get("display_name", ""))
if int(row.get("round_index", 0)) == 1:
r1_rows.append(row)
if verdict != "战斗胜利" or r1_rows.is_empty():
return "· %s(%d 行)" % [verdict, rows.size()]
var combat_rows := 0
var kills := 0
for row in r1_rows:
var t := String(row.get("type", ""))
if t == "damage":
combat_rows += 1
if bool(row.get("killed", false)):
kills += 1
if combat_rows > 0:
return "· round-1 胜利 = 首轮全灭(wipe):战斗行 ×%d、击杀 ×%d——非空场" % [combat_rows, kills]
return "· round-1 胜利且零战斗行 = 空场特征(Bug#1)!"
func _scan_spawns(db, seed0: int, n_seeds: int) -> Dictionary:
var depths: Array = []
for node in _level_row(db, 1).get("nodes", []):
var t := String(node.get("type", ""))
if t == "battle" or t == "boss":
depths.append({"depth": int(node.get("node_depth", -1)), "kind": t})
var total := 0
var bad := 0
for i in range(n_seeds):
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed0 + i)
for dep in depths:
var depth: int = int(dep.get("depth", -1))
var kind: String = String(dep.get("kind", "battle"))
total += 1
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
if not settlement.begin_battle({"level_index": 1, "node_depth": depth, "node_type": kind, "hero_hp": int(db.hero.max_hp)}):
bad += 1
continue
var n: int = settlement.presentation_snapshot().enemies.size()
var row: Dictionary = db.resolve_enemy_rowset(1, depth, kind)[0] # 行集首行(同上,row_kind 随节点类型)
var lo := int(row.get("unit_count", 1))
var hi := int(row.get("unit_count_max", lo))
if n < 1 or n < lo or n > hi:
bad += 1
return {"total": total, "bad": bad}