Files
gplan-2/tools/log_analysis.py
DKChan 7aee5eb71a docs(流程文档迁出GDD批): 待办清单与体验验证清单移到仓库根并去掉编号前缀
两文件是流水线流程件而非游戏描述件,此前住在 design/gdd/ 造成三处不适配:
gdd_lint 必须为它们逐条关掉体裁规则、格式权威却写在 doc-conventions §12、
与同族的 roadmap(在仓库根)分居两处。迁到根后按 roadmap.md 的无编号风格改名。

- 迁出:design/gdd/06-待办清单.md → 待办清单.md;07-体验验证清单.md → 体验验证清单.md
- 指向改写 53 个文件:GDD 各系统文档指路存根、四份规约、02 根索引、README/roadmap/AGENTS
- 两份清单自身 74 处出站链接按新基址重指(../art → design/art、systems → design/gdd/systems)
- gdd_lint:待办/体验验证两文件的 R1/R3/R5/R7 豁免行随迁删除(已脱离扫描面),后门表清零
- pipeline_gate 默认路径、log_analysis/render_gate 称谓、repo_structure_audit 根文件白名单同步
- 保护面补写:两份清单列入实现批禁写清单(AGENTS 两类批条 / process-conventions §12 / 架构文档域表),
  条目状态只由流水线各段推进
- 范围外未动(用户令「全不碰」):reports/ 与 design/research/ 的 22 处历史指向、game/ 两处注释
2026-10-21 00:09:40 +08:00

629 lines
29 KiB
Python
Raw Permalink 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.
#!/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 <jsonl 文件 | 目录 | 通配> [--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)
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):
# 刷新经济监控(体验验证清单⑦):只计受理成功的弃图刷新——被拒尝试不构成弃图。
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(" 弃图率=每局受理刷新次数(体验验证清单⑦ 基线读数,与 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())