#!/usr/bin/env python3 # -*- coding: utf-8 -*- """试玩日志分析器(垂直切片 tools/ 专用,纯标准库,零第三方依赖)。 消费 presentation/playtest_logger.gd 产生的 playtest_*.jsonl, 产出 关卡 / 战斗 / 轮次 三级统计,并为护甲等级(AC)掷点批预留验收统计面。 已知测量盲区(本工具的核心补偿,勿绕过): settlement 快照在衰减段之后发出、且现有状态寿命全部为 1 结算 → 快照 buffs 字段恒为空。战斗期真实增益只能从 step 行(type=effect, 文本 增伤/削弱/减伤/…)按结算轮窗口复原。本工具从不把快照 buffs 当战斗期状态使用,只将其并列输出以持续暴露盲区。 AC 掷点批预留位: 落批后伤害 step 行会新增掷点字段(读数/档位/暴击,字段名以落批实际为准)。 本工具按 FIELD_CANDIDATES 探测:字段存在 → 激活掷点统计面; 不存在(AC 批前旧日志)→ 降级输出"不适用(AC 批前日志)"。 同时所有 step 行的未知键都会进"数据质量"观测面——落批后先看那里 拿到真实字段名,再在 FIELD_CANDIDATES 里补一行适配即可。 用法: python log_analysis.py [--per-run] [--rounds] 目录/通配 = 批量汇总;--per-run 附加逐局明细;--rounds 附加逐轮流水。 """ import argparse import glob import json import os import sys import time from collections import Counter, defaultdict # ---------------------------------------------------------------- 常量与映射 # step 行已知键(schema 事实源 = playtest_logger.gd _on_step_row,2026-09-16 版) STEP_KNOWN_KEYS = {"kind", "type", "source", "cell", "round", "text", "value", "highlight"} # AC 掷点批候选字段名(落批后按实际字段名在此登记——预留的一行适配点) ROLL_FIELD_CANDIDATES = ("roll", "d20", "dice", "nat", "nat_roll", "roll_value") TIER_FIELD_CANDIDATES = ("tier", "discount", "discount_tier", "damage_tier", "bracket") CRIT_FIELD_CANDIDATES = ("crit", "critical", "is_crit", "nat20") AC_FIELD_CANDIDATES = ("ac", "target_ac", "defense_ac") # effect 行显示文本 → 增益语义键(未登记文本原样作键,新增益自动浮现) TEXT_TO_BUFF = {"增伤": "attack_up", "削弱": "weaken", "减伤": "defend_up"} # 档位语义:缺口 = AC − 掷点读数(读数达标 = 全额) TIER_MULTIPLIER = {"全额": 1.0, "×0.75": 0.75, "×0.5": 0.5, "×0.25": 0.25} THEORY_EV_REDUCTION_AC10 = 0.2125 # AC10 理论 EV 减免(任务口径) THEORY_CRIT_RATE = 0.05 # nat20 暴击率理论值 CRIT_TOLERANCE = 0.02 # 健康检查宽容度 ±2 个百分点 BAND_NORMAL = 0.20 # 承伤带:普通场 ≤20% max_hp BAND_BOSS = 0.50 # 承伤带:Boss 场 ≤50% max_hp BATTLE_TYPES = ("battle", "boss") def _first(row, candidates): """返回 row 中第一个命中的候选字段值。""" for c in candidates: if c in row: return row[c] return None # ---------------------------------------------------------------- 逐局状态机 class BattleAcc: """一场战斗(battle/boss 节点)的累计器。""" def __init__(self, node_type, depth): self.node_type = node_type self.depth = depth self.rounds = [] # 每结算一个 dict(见 RunAcc._finalize_round) self.damage = 0 # Σ 敌方攻击落地伤害(enemy_action × type=damage 的 value) self.heals = 0 # Σ 主角治疗(type=heal 的 value) self.attack_rounds = 0 # 有敌方攻击落地的轮数 self.max_hp = None self.start_hp = None # 首结算快照 HP + 首轮承伤 − 首轮治疗 反推的进场 HP self.last_hp = None class RunAcc: """一局(run 行切分;无 run 行的旧日志 = 整文件一局,种子取 session 行)。""" def __init__(self, src_file, session_seed): self.src_file = src_file self.session_seed = session_seed self.seed = session_seed self.policy = "" self.level_index = None self.result = None self.end_hp = None self.max_hp = None self.round_steps = [] # 当前结算轮窗口内的 step 行缓冲 self.battle = None self.battles = [] self.battle_fifo = [] # battle_end 延迟发出且不带 depth,按 FIFO 对账 self.late_battle_ends = 0 # 未能配对的 battle_end(数据质量) self.boss_node_seen = False self.rounds_total = 0 self.rounds_with_enemy = 0 self.damage_total = 0 self.heals_total = 0 self.zero_narrow = 0 # 零承伤轮(窄口径,AC 验收主指标) self.attack_landed_rounds = 0 self.zero_loss_wide = 0 # 零掉血轮(宽口径,参考) self.snapshot_buffs_nonempty = 0 # 盲区暴露面:快照 buffs 恒空计数 self.snapshot_buffs_total = 0 self.round_ledger = [] # --rounds 逐轮流水 self.roll_rows = [] # 命中掷点候选字段的 step 行 self.refresh_count = 0 # 受理的弃图刷新次数(action 行 op=refresh accepted=True) self._prev_hp = None # 上一结算快照的主角 HP(宽口径逐轮差分用) self._last_enemy_present = False # -- 事件入口 --------------------------------------------------------- def on_run_row(self, e): self.seed = e.get("seed", self.session_seed) self.policy = "" if e.get("policy") is None else str(e.get("policy")) self.level_index = e.get("level_index") def on_node(self, e): t = e.get("type", "") if t in BATTLE_TYPES: # 先收掉上一场(战斗的 settlement 严格落在相邻 node 行之间) self._close_battle() self.battle = BattleAcc(t, e.get("depth")) if t == "boss": self.boss_node_seen = True # rest / reward 节点不切战斗上下文(与 battle 同 index 出现) def on_step(self, e): self.round_steps.append(e) if any(c in e for c in ROLL_FIELD_CANDIDATES): self.roll_rows.append(e) def on_settlement(self, e): b = self.battle self.rounds_total += 1 hero = e.get("hero", {}) hp = int(hero.get("hp", 0)) if hero.get("max_hp"): self.max_hp = int(hero.get("max_hp")) enemy_present = bool(e.get("enemies")) if enemy_present: self.rounds_with_enemy += 1 # 盲区暴露面:快照 buffs 只计数对照,从不当作战斗期状态 self.snapshot_buffs_total += 1 if e.get("buffs"): self.snapshot_buffs_nonempty += 1 self._finalize_round(e, b, hp, enemy_present) def on_battle_end(self, e): # 延迟发出、不带 depth:只做 FIFO 对账(数据质量),核心统计不依赖它 if self.battle_fifo: self.battle_fifo.pop(0) else: self.late_battle_ends += 1 def on_level_end(self, e): self._close_battle() self.result = e.get("result", "") self.end_hp = int(e.get("hp", 0)) def on_action(self, e): # 刷新经济监控(07 清单⑦):只计受理成功的弃图刷新——被拒尝试不构成弃图。 if e.get("op") == "refresh" and e.get("accepted"): self.refresh_count += 1 # -- 轮窗口收口(核心口径都在这里)------------------------------------ def _finalize_round(self, snap, battle, hp, enemy_present): steps, self.round_steps = self.round_steps, [] dmg = sum(int(s.get("value", 0)) for s in steps if s.get("type") == "damage" and s.get("source") == "enemy_action") heal = sum(int(s.get("value", 0)) for s in steps if s.get("type") == "heal") attacks = sum(1 for s in steps if s.get("type") == "damage" and s.get("source") == "enemy_action") charges = sum(1 for s in steps if s.get("type") == "charge") # 蓄力拍(type=charge,含空拍/白喂)不算"攻击落地"——验收口径硬性排除。 # 战斗期增益复原:本结算轮窗口内的 effect 行(快照 buffs 恒空,禁用) buffs = Counter() for s in steps: if s.get("type") == "effect": key = TEXT_TO_BUFF.get(s.get("text", ""), s.get("text", "")) buffs[key or "<未登记effect文本>"] += 1 # 宽口径(整轮掉血 0):首轮用进场反推 HP 差分,之后用相邻快照差 if self._prev_hp is None: entry_hp = hp + dmg - heal loss = entry_hp - hp else: loss = max(0, self._prev_hp - hp) self._prev_hp = hp zero_narrow = attacks > 0 and dmg == 0 # 窄口径:有攻击落地且掉血 0 zero_wide = loss == 0 # 宽口径:整轮 HP 不降(聚合时限敌战轮) self.damage_total += dmg self.heals_total += heal if attacks > 0: self.attack_landed_rounds += 1 if zero_narrow: self.zero_narrow += 1 if zero_wide: self.zero_loss_wide += 1 rec = { "type": battle.node_type if battle else "?", "depth": snap.get("depth"), "round": snap.get("round"), "dmg": dmg, "heal": heal, "hp": hp, "attacks": attacks, "charges": charges, "zero_narrow": zero_narrow, "zero_wide": zero_wide, "buffs": dict(buffs), "path": (snap.get("round_facts") or {}).get("path", ""), "enemy_present": enemy_present, } self.round_ledger.append(rec) if battle is not None: battle.rounds.append(rec) battle.damage += dmg battle.heals += heal battle.attack_rounds += 1 if attacks > 0 else 0 battle.max_hp = self.max_hp battle.last_hp = hp if battle.start_hp is None: battle.start_hp = hp + dmg - heal def _close_battle(self): if self.battle is None: return b, self.battle = self.battle, None if b.rounds: # 有过敌人的战斗(任一轮出现过敌人或攻击落地)——宽口径分母用其全部轮数。 # 不能按单轮快照 enemies 判定:敌人当轮被歼的轮快照为空,但轮仍属于敌战。 b.enemy_flag = any(r["attacks"] > 0 or r["enemy_present"] for r in b.rounds) for rec in b.rounds: rec["enemy_battle"] = b.enemy_flag self.battles.append(b) self.battle_fifo.append(b) # -- 派生量 ----------------------------------------------------------- def boss_battle(self): for b in self.battles: if b.node_type == "boss": return b return None def boss_enter_hp(self): """进 Boss HP = Boss 首结算快照 HP + 首轮承伤 − 首轮治疗(精确反推)。""" b = self.boss_battle() if b is None or not b.rounds: return None r0 = b.rounds[0] return r0["hp"] + r0["dmg"] - r0["heal"] # ---------------------------------------------------------------- 文件解析 class FileResult: def __init__(self, path): self.path = path self.lines = 0 self.bad_lines = 0 self.parse_seconds = 0.0 self.session_seeds = [] self.runs = [] self.unknown_step_keys = Counter() # 未知 step 键 → 次数(AC 落批观察面) self.unknown_step_samples = {} # 未知 step 键 → 样例值 self.dice_active = False def parse_file(path): res = FileResult(path) t0 = time.perf_counter() run = None with open(path, encoding="utf-8", errors="replace") as f: for line in f: line = line.strip() if not line: continue res.lines += 1 try: e = json.loads(line) except json.JSONDecodeError: res.bad_lines += 1 continue kind = e.get("kind", "") if kind == "session": res.session_seeds.append(e.get("seed")) if run is None: run = RunAcc(os.path.basename(path), e.get("seed")) res.runs.append(run) elif kind == "run": if run is not None and (run.rounds_total or run.result or run.battles): run = RunAcc(os.path.basename(path), res.session_seeds[-1] if res.session_seeds else None) res.runs.append(run) elif run is None: run = RunAcc(os.path.basename(path), None) res.runs.append(run) run.on_run_row(e) elif run is None: # 无 session/run 头的残缺文件:兜底开一局,保证数据不丢 run = RunAcc(os.path.basename(path), None) res.runs.append(run) _feed(run, res, kind, e) else: _feed(run, res, kind, e) if run is not None: run._close_battle() res.parse_seconds = time.perf_counter() - t0 res.dice_active = any(r.roll_rows for r in res.runs) return res def _feed(run, res, kind, e): if kind == "node": run.on_node(e) elif kind == "step": run.on_step(e) for k in e: if k not in STEP_KNOWN_KEYS: res.unknown_step_keys[k] += 1 res.unknown_step_samples.setdefault(k, repr(e[k])[:60]) elif kind == "settlement": run.on_settlement(e) elif kind == "battle_end": run.on_battle_end(e) elif kind == "level_end": run.on_level_end(e) elif kind == "action": run.on_action(e) # ux / diagnosis 等其余 kind:本工具当前无消费面,静默跳过 def resolve_inputs(arg): """文件 / 目录 / 通配 → 排序后的 playtest_*.jsonl 列表。""" if os.path.isdir(arg): return sorted(glob.glob(os.path.join(arg, "playtest_*.jsonl"))) if any(ch in arg for ch in "*?["): return sorted(glob.glob(arg)) return [arg] # ---------------------------------------------------------------- 聚合与渲染 def fmt(x, nd=1): return "—" if x is None else f"{x:.{nd}f}" def aggregate_levels(runs): """关卡级:按(关卡, 档位)聚合。""" groups = defaultdict(list) for r in runs: groups[(r.level_index, r.policy)].append(r) rows = [] for (lvl, policy), rs in sorted(groups.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1]))): n = len(rs) wins = sum(1 for r in rs if r.result == "victory") boss_rs = [r for r in rs if r.boss_enter_hp() is not None] win_rs = [r for r in rs if r.result == "victory"] total_rounds = sum(r.rounds_total for r in rs) total_refresh = sum(r.refresh_count for r in rs) rows.append({ "level": lvl, "policy": policy or "—", "runs": n, "clear_rate": wins / n if n else None, "avg_rounds": sum(r.rounds_total for r in rs) / n if n else None, "avg_boss_hp": (sum(r.boss_enter_hp() for r in boss_rs) / len(boss_rs)) if boss_rs else None, "avg_end_hp": (sum(r.end_hp for r in win_rs) / len(win_rs)) if win_rs else None, "avg_dmg": sum(r.damage_total for r in rs) / n if n else None, "refresh_per_run": total_refresh / n if n else None, # 弃图率(与 MC 刷新/局同口径) "refresh_per_round": total_refresh / total_rounds if total_rounds else None, # 每轮刷新率 }) return rows def aggregate_battles(runs): """战斗级:按(关卡, 节点类型, 深度)分组,普通场与 Boss 场分开对照承伤带。""" groups = defaultdict(list) for r in runs: for b in r.battles: if b.max_hp: groups[(r.level_index, b.node_type, b.depth)].append(b) rows = [] for (lvl, ntype, depth), bs in sorted( groups.items(), key=lambda kv: (str(kv[0][0]), kv[0][1], str(kv[0][2]))): n = len(bs) avg_dmg = sum(b.damage for b in bs) / n avg_ratio = sum(b.damage / b.max_hp for b in bs) / n band = BAND_BOSS if ntype == "boss" else BAND_NORMAL rows.append({ "level": lvl, "type": ntype, "depth": depth, "count": n, "avg_dmg": avg_dmg, "avg_ratio": avg_ratio, "band": band, "pass": avg_ratio <= band, "avg_rounds": sum(len(b.rounds) for b in bs) / n, "avg_attack_rounds": sum(b.attack_rounds for b in bs) / n, }) return rows def aggregate_rounds(runs): t = sum(r.rounds_total for r in runs) e = sum(r.rounds_with_enemy for r in runs) a = sum(r.attack_landed_rounds for r in runs) zn = sum(r.zero_narrow for r in runs) # 宽口径分母 = 有过敌人的战斗的全部轮数(含敌人当轮被歼的收尾轮) enemy_battle_rounds = 0 zw = 0 for r in runs: for b in r.battles: if getattr(b, "enemy_flag", False): enemy_battle_rounds += len(b.rounds) zw += sum(1 for rec in b.rounds if rec["zero_wide"]) return {"rounds": t, "with_enemy": e, "attack_landed": a, "zero_narrow": zn, "zero_loss_wide": zw, "enemy_battle_rounds": enemy_battle_rounds} def derive_tier(roll, ac): """缺口 = AC − 掷点读数 → 档位(1-3/4-7/8+ → 75/50/25%,达标=全额)。""" gap = ac - roll if gap <= 0: return "全额" if gap <= 3: return "×0.75" if gap <= 7: return "×0.5" return "×0.25" def aggregate_dice(res_files): """掷点面(AC 批落地后激活)。字段名以 FIELD_CANDIDATES 命中为准。""" rolls = [] # (direction, roll, tier, crit) for res in res_files: for run in res.runs: for row in run.roll_rows: roll = _first(row, ROLL_FIELD_CANDIDATES) if not isinstance(roll, int): continue direction = "主角→敌" if row.get("source") == "hero_attack" else "敌→主角" ac = _first(row, AC_FIELD_CANDIDATES) ac = int(ac) if isinstance(ac, int) else 10 # 基线 AC10 tier_v = _first(row, TIER_FIELD_CANDIDATES) tier = tier_v if tier_v in TIER_MULTIPLIER else derive_tier(roll, ac) crit_v = _first(row, CRIT_FIELD_CANDIDATES) crit = bool(crit_v) if crit_v is not None else (roll == 20) rolls.append((direction, roll, tier, crit)) if not rolls: return None hist = Counter(r for _, r, _, _ in rolls) tiers = Counter(t for _, _, t, _ in rolls) crits = sum(1 for *_, c in rolls if c) ev_red = 1 - sum(TIER_MULTIPLIER[t] for _, _, t, _ in rolls) / len(rolls) by_dir = defaultdict(list) for d, r, t, c in rolls: by_dir[d].append((r, t, c)) return {"hist": hist, "tiers": tiers, "n": len(rolls), "crit_rate": crits / len(rolls), "ev_reduction": ev_red, "by_dir": by_dir} def render_dice(dice, out): print("== 掷点面(AC 批验收统计)==", file=out) if dice is None: print(" 不适用(AC 批前日志):本日志无掷点字段。", file=out) print(" 其余指标照常输出;落批后重跑本工具,先看【数据质量】的未知字段观测面。", file=out) return print(f" 样本 {dice['n']} 次掷点", file=out) print(" 掷点直方图(d20):", file=out) for v in range(1, 21): c = dice["hist"].get(v, 0) if c: print(f" {v:2d}: {'#' * min(c, 60)}{c if c > 60 else ''}", file=out) n = dice["n"] print(" 档位占比:", file=out) for t in ("全额", "×0.75", "×0.5", "×0.25"): c = dice["tiers"].get(t, 0) print(f" {t:>6}: {c:6d} ({c / n * 100:5.1f}%)", file=out) cr = dice["crit_rate"] ok = abs(cr - THEORY_CRIT_RATE) <= CRIT_TOLERANCE print(f" nat20 暴击率: {cr * 100:.2f}%(理论 {THEORY_CRIT_RATE * 100:.0f}% ±{CRIT_TOLERANCE * 100:.0f}pp)" f" → {'健康' if ok else '超界,需排查'}", file=out) print(f" 观察 EV 减免: {dice['ev_reduction'] * 100:.2f}%" f"(理论 AC10 = {THEORY_EV_REDUCTION_AC10 * 100:.2f}%)", file=out) print(" 按攻击方向:", file=out) for d, items in sorted(dice["by_dir"].items()): rr = [r for r, _, _ in items] cc = sum(1 for *_, c in items if c) red = 1 - sum(TIER_MULTIPLIER[t] for _, t, _ in items) / len(items) print(f" {d}: n={len(items)} 均值掷点={sum(rr) / len(rr):.2f} " f"暴击率={cc / len(items) * 100:.2f}% EV减免={red * 100:.2f}%", file=out) def render(res_files, args, out): total_lines = sum(r.lines for r in res_files) total_secs = sum(r.parse_seconds for r in res_files) all_runs = [run for res in res_files for run in res.runs] print("== 输入概况 ==", file=out) print(f" 文件 {len(res_files)} 个,共 {total_lines} 行,解析 {total_secs:.2f}s" f"(大文件单文件耗时见下)", file=out) for res in res_files: dice_tag = "掷点面:激活" if res.dice_active else "掷点面:本日志无掷点字段(AC 批前日志,已降级)" print(f" {os.path.basename(res.path)}: {res.lines} 行 / {res.parse_seconds:.2f}s / " f"{len(res.runs)} 局 / {dice_tag}", file=out) print(file=out) render_dice(aggregate_dice(res_files), out) print(file=out) print("== 关卡级统计 ==", file=out) print(" 口径:局=run 行切分(无 run 行的旧日志整文件一局);通关=result=victory;", file=out) print(" 进BossHP=Boss首结算快照HP+首轮承伤−首轮治疗(精确反推);平均轮数=每局settlement数。", file=out) print(" 弃图率=每局受理刷新次数(07⑦ 基线读数,与 MC 刷新/局同口径);", file=out) print(" 每轮刷新率=刷新次数÷结算轮数(组内加权)。阈值数字归调参批,本表只出基线。", file=out) hdr = (f" {'关卡':>6} {'档位':>8} {'局数':>5} {'通关率':>7} {'平均轮数':>8} {'进BossHP均':>10} " f"{'通关剩余HP均':>12} {'整局承伤均':>10} {'弃图率':>7} {'每轮刷新率':>9}") print(hdr, file=out) for row in aggregate_levels(all_runs): print(f" {str(row['level']):>6} {row['policy']:>8} {row['runs']:>5} " f"{fmt(row['clear_rate'] * 100 if row['clear_rate'] is not None else None):>6}% " f"{fmt(row['avg_rounds']):>8} {fmt(row['avg_boss_hp']):>10} " f"{fmt(row['avg_end_hp']):>12} {fmt(row['avg_dmg']):>10} " f"{fmt(row['refresh_per_run'], 2):>7} {fmt(row['refresh_per_round'], 3):>9}", file=out) print(file=out) print("== 战斗级统计(按 关卡/节点类型/深度 分组;对照承伤带 普通≤20%、Boss≤50% max_hp)==", file=out) print(f" {'关卡':>6} {'类型':>6} {'深度':>4} {'场数':>5} {'场均轮数':>8} {'场均承伤':>8} {'承伤/max_hp':>11} {'承伤带':>7} {'判定':>4}", file=out) for row in aggregate_battles(all_runs): print(f" {str(row['level']):>6} {row['type']:>6} {str(row['depth']):>4} {row['count']:>5} " f"{fmt(row['avg_rounds']):>8} {fmt(row['avg_dmg']):>8} {fmt(row['avg_ratio'] * 100):>10}% " f"{'≤' + format(row['band'] * 100, '.0f') + '%':>7} {'✓' if row['pass'] else '✗':>4}", file=out) print(file=out) rr = aggregate_rounds(all_runs) print("== 轮级统计 ==", file=out) print(f" 总轮数 {rr['rounds']}|敌战轮(有过敌人的战斗全部轮数){rr['enemy_battle_rounds']}|有攻击落地轮 {rr['attack_landed']}", file=out) zn_pct = rr['zero_narrow'] / rr['attack_landed'] * 100 if rr['attack_landed'] else 0.0 ebr = rr['enemy_battle_rounds'] zw_pct = rr['zero_loss_wide'] / ebr * 100 if ebr else 0.0 print(f" 零承伤轮(窄口径,AC 验收主指标): {rr['zero_narrow']} / 有攻击落地轮 {rr['attack_landed']}" f"({zn_pct:.1f}%)——AC 批落地含'伤害下限 1'后该值应趋零", file=out) print(f" 零掉血轮(宽口径,参考): {rr['zero_loss_wide']} / 敌战轮 {ebr}" f"({zw_pct:.1f}%)", file=out) print(file=out) # 增益战斗期复原 buff_totals = Counter() buff_settlements = 0 for run in all_runs: seen_in_run = False for rec in run.round_ledger: for k, v in rec["buffs"].items(): buff_totals[k] += v seen_in_run = True if seen_in_run: buff_settlements += sum(1 for rec in run.round_ledger if rec["buffs"]) snap_total = sum(r.snapshot_buffs_total for r in all_runs) snap_nonempty = sum(r.snapshot_buffs_nonempty for r in all_runs) print("== 增益战斗期复原(来自 step 行,按结算轮窗口聚合)==", file=out) print(f" 各增益出现总层数: {dict(buff_totals) if buff_totals else '(无 effect 行)'}", file=out) print(f" 有增益激活的结算数: {buff_settlements}(每结算层数明细见 --rounds 流水)", file=out) print(f" 快照 buffs 非空: {snap_nonempty}/{snap_total}" f"(已知盲区:快照在衰减段后发出且状态寿命=1 结算 → 恒空;此行仅作盲区持续暴露)", file=out) print(file=out) if args.per_run: print("== 逐局明细(--per-run)==", file=out) print(f" {'文件':<28} {'种子':>6} {'档位':>8} {'结果':>8} {'轮数':>4} {'承伤':>5} {'零承伤轮':>8} {'进BossHP':>8} {'终局HP':>6} {'刷新':>4}", file=out) for run in all_runs: print(f" {run.src_file:<28} {str(run.seed):>6} {run.policy or '—':>8} " f"{run.result or '—':>8} {run.rounds_total:>4} {run.damage_total:>5} " f"{run.zero_narrow:>8} {fmt(run.boss_enter_hp(), 0):>8} {str(run.end_hp):>6} {run.refresh_count:>4}", file=out) print(file=out) if args.rounds: print("== 逐轮流水(--rounds)==", file=out) print(" 列: 种子 节点 深度 轮 | 攻击落地 蓄力 承伤 治疗 结算HP | 零承伤 零掉血 | 增益复原 | 出路", file=out) for run in all_runs: for rec in run.round_ledger: buffs = ",".join(f"{k}×{v}" for k, v in sorted(rec["buffs"].items())) or "—" print(f" {run.seed} {rec['type']} {rec['depth']} r{rec['round']} | " f"{rec['attacks']} {rec['charges']} {rec['dmg']} {rec['heal']} {rec['hp']:>3} | " f"{'Y' if rec['zero_narrow'] else '-'} {'Y' if rec['zero_wide'] else '-'} | " f"{buffs} | {rec['path']}", file=out) print(file=out) print("== 数据质量 ==", file=out) bad = sum(r.bad_lines for r in res_files) late = sum(run.late_battle_ends for res in res_files for run in res.runs) print(f" 解析失败行: {bad}|未配对 battle_end(延迟发出越局): {late}", file=out) if res_files and any(res.unknown_step_keys for res in res_files): print(" step 行未知字段观测面(AC 批落批后的适配入口——新掷点字段会先出现在这里):", file=out) for res in res_files: for k, c in res.unknown_step_keys.most_common(): print(f" {os.path.basename(res.path)}: {k} ×{c} 样例 {res.unknown_step_samples[k]}", file=out) else: print(" step 行未知字段: 无(当前日志 = 2026-09-16 schema,无掷点/分量字段)", file=out) print(" AC 批落批后:新字段先出现在上面观测面 → 在 FIELD_CANDIDATES 登记真实字段名 → 掷点面自动激活。", file=out) # ---------------------------------------------------------------- 入口 def main(argv=None): ap = argparse.ArgumentParser(description="试玩 JSONL 日志三级统计分析器(关卡/战斗/轮次 + AC 掷点预留面)") ap.add_argument("input", help="jsonl 文件 | 目录 | 通配(目录按 playtest_*.jsonl 收取)") ap.add_argument("--per-run", action="store_true", help="附加逐局明细") ap.add_argument("--rounds", action="store_true", help="附加逐轮流水") args = ap.parse_args(argv) if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") files = resolve_inputs(args.input) files = [f for f in files if os.path.isfile(f)] if not files: print(f"未找到输入:{args.input}", file=sys.stderr) return 2 res_files = [parse_file(f) for f in files] render(res_files, args, sys.stdout) return 0 if __name__ == "__main__": sys.exit(main())