refactor: production 解散+docs 并入 design+tests 归 game 三层收敛——active.md/roadmap/stage/review-mode 升根目录;qa 报告→design/qa(gate-checks/alpha 验收/任务书→research);docs/→design/dev+registry 并档+散提案→research;tests/→game/tests(空架子 README+示例,真测试在 game/smoke);game/tools→tools+样例输出→sim-data;qa-data→sim-data;design/ux→gdd/ux;删 loop-dashboard.html(可再生成)+route_overlay_preview(零引用);钩子三件+loop_dashboard/balance_verifier/render_sample_sheet 路径随迁;断链修复 12 文件,余 7 条全为搬前幽灵链

This commit is contained in:
DKChan committed 2026-09-27 04:56:27 +08:00
1 parent 97e20d51fb
commit 7f24b640ea
179 files changed
+81 -579

No files matched your search

+2 -2
View File
@@ -33,7 +33,7 @@ r"""数值验证工具(判定端)——自动化评判 + 元进度爬升测
python tools/balance_verifier.py --na 60 --k 60 --seed0 9000 --spend atk_first
python tools/balance_verifier.py --only adventure --inject max_hp:3 --na 100
python tools/balance_verifier.py --from-raw <raw.jsonl> # 免重跑,对既有采集重新判定/出报告
输出:控制台判定结论 + production/qa/balance-verifier-<时间戳>.md(报告)+ 同名 .raw.jsonl(原始逐局数据)。
输出:控制台判定结论 + design/qa/balance-verifier-<时间戳>.md(报告)+ 同名 .raw.jsonl(原始逐局数据)。
兼容注:2026-09-23 前的旧 raw.jsonl 含 level 段(直入口单关)行——本版判定自动忽略,
逐关读数一律由冒险行派生。
"""
@@ -51,7 +51,7 @@ ROOT = Path(__file__).resolve().parent.parent
SLICE = ROOT / "game"
GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe"
SCRIPT = "res://smoke/verifier_batch.gd"
QA_DIR = ROOT / "production" / "qa"
QA_DIR = ROOT / "design" / "qa"
VJSON_PREFIX = "VJSON "
ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error")
+628
View File
@@ -0,0 +1,628 @@
#!/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,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())
+15 -11
View File
@@ -2,7 +2,7 @@
r"""loop_dashboard.py — 迭代闭环仪表盘生成器
用法:
python tools/loop_dashboard.py # 生成 production/loop-dashboard.html + 控制台摘要
python tools/loop_dashboard.py # 生成 sim-data/loop-dashboard.html + 控制台摘要
python tools/loop_dashboard.py --open # 生成后用系统默认浏览器打开
python tools/loop_dashboard.py --out <path>
@@ -32,12 +32,12 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
GDD_DIR = ROOT / "design" / "gdd"
ACTIVE_MD = ROOT / "production" / "session-state" / "active.md"
ROADMAP_MD = ROOT / "production" / "roadmap.md"
ACTIVE_MD = ROOT / "active.md"
ROADMAP_MD = ROOT / "roadmap.md"
TODO_MD = GDD_DIR / "06-待办清单.md"
VERIFY_MD = GDD_DIR / "07-体验验证清单.md"
STAGE_TXT = ROOT / "production" / "stage.txt"
DEFAULT_OUT = ROOT / "production" / "loop-dashboard.html"
STAGE_TXT = ROOT / "stage.txt"
DEFAULT_OUT = ROOT / "sim-data" / "loop-dashboard.html"
LOOP_ORDER = ["顶层文档", "排雷循环", "战斗循环", "冒险循环", "表现与支撑"]
@@ -65,18 +65,22 @@ def classify_path(rel: str):
rel = rel.replace("\\", "/")
if rel.startswith("design/gdd/"):
return "GDD 文档"
if rel == "active.md" or rel == "roadmap.md":
return "会话与路线"
if rel.startswith("design/qa/") or rel.startswith("design/research/"):
return "验证与调研"
if rel.startswith("design/"):
return "设计侧其他(registry/ux/art/research)"
return "设计侧其他(registry/art/dev)"
if rel.startswith("game/"):
if rel.endswith(".gd"):
return "游戏代码"
if rel.endswith(".tres"):
return "数据表"
return "切片其他"
return "游戏工程其他"
if rel.startswith("tools/"):
return "工具"
if rel.startswith("production/"):
return "会话产物"
if rel.startswith("sim-data/"):
return "数据产物"
return "其他"
@@ -477,7 +481,7 @@ def render_html(stage, git_info, docs, active, todos, verify, roadmap, out_path)
<div class="grid">{"".join(todo_cards)}</div>
{'<div class="card" style="margin-top:14px"><h3>解决态残留(按「解决即移除」口径应从 06 移除,去向归台账与 git 历史)</h3><ul>' + "".join(f"<li>{esc(t['system'])} · {esc(t['title'])}</li>" for t in resolved_all) + '</ul></div>' if resolved_all else ''}
<h2>路线图(production/roadmap.md)</h2>
<h2>路线图(roadmap.md)</h2>
<div class="card"><ol>{route}</ol></div>
<h2>台账健康</h2>
@@ -487,7 +491,7 @@ def render_html(stage, git_info, docs, active, todos, verify, roadmap, out_path)
<div class="card"><ul>{commits}</ul></div>
<footer>
数据源: production/session-state/active.md · production/roadmap.md · production/stage.txt ·
数据源: active.md · roadmap.md · stage.txt ·
design/gdd/06-待办清单.md · design/gdd/07-体验验证清单.md · design/gdd/** · git status/log。
生成工具 tools/loop_dashboard.py;六处信息源格式变更时同步改解析约定(见工具头注)。
</footer>
+4 -4
View File
@@ -18,8 +18,8 @@ v0 四 FATAL 案底(2026-09-26 用户否决)全部断言化——生成即验,
素材源 = game/assets/art(board 21 瓦片 + character 小人/立绘)+ ui/icons 状态图标;
素材不入 git——先查本仓库,缺位回退主检出(D:/code/g-plan2)。
词表事实源 = design/research/board-tile-vocab-plan-a-2026-09-26.md;画法参数事实源 =
production/mockups/画法参数四源对照表-2026-09-26.md(四源对表先行产物)。
输出 = production/mockups/sample-batch-v1-2026-09-26.png + sample-v1-机检报告-2026-09-26.md。
artwork/mockups/画法参数四源对照表-2026-09-26.md(四源对表先行产物)。
输出 = artwork/mockups/sample-batch-v1-2026-09-26.png + sample-v1-机检报告-2026-09-26.md。
用法:python tools/render_sample_sheet.py
"""
from PIL import Image, ImageDraw, ImageFilter, ImageChops, ImageFont
@@ -45,8 +45,8 @@ def asset_root(*parts):
BOARD_DIR = ("game", "assets", "art", "board")
CHAR_DIR = ("game", "assets", "art", "character")
ICON_DIR = ("game", "assets", "ui", "icons")
OUT_PNG = os.path.join(ROOT, "production", "mockups", "sample-batch-v1-2026-09-26.png")
OUT_REPORT = os.path.join(ROOT, "production", "mockups", "sample-v1-机检报告-2026-09-26.md")
OUT_PNG = os.path.join(ROOT, "artwork", "mockups", "sample-batch-v1-2026-09-26.png")
OUT_REPORT = os.path.join(ROOT, "artwork", "mockups", "sample-v1-机检报告-2026-09-26.md")
T_FONT = asset_root("design", "art", "candidates", "ui-reuse-2026-09-23",
"4-字体", "fusion-pixel-12px(OFL)", "fusion-pixel-12px-proportional-latin.ttf")
ZH_FONT = r"C:\Windows\Fonts\msyh.ttc"
+99
View File
@@ -0,0 +1,99 @@
# log_analysis.py 指标口径说明
试玩日志分析器(`tools/log_analysis.py`,Python 3 标准库单文件)的指标定义、
自验对账记录与 AC 掷点批适配点。schema 事实源 = `presentation/playtest_logger.gd`。
## 用法
```
python log_analysis.py <jsonl 文件 | 目录 | 通配> [--per-run] [--rounds]
```
- 目录 = 收取其下全部 `playtest_*.jsonl`;通配/目录 = 批量汇总(跨文件聚合)。
- `--per-run` 附加逐局明细表;`--rounds` 附加逐轮流水(含每轮增益复原)。
## 局的切分
- 批量日志一文件多局:按 `run` 行切局(run 行携带该局种子/档位)。
- 旧日志无 `run` 行:整文件一局,种子取 `session` 行。
- 无 `session`/`run` 头的残缺文件:兜底开局,数据不丢。
## 关卡级指标
| 指标 | 定义 |
| --- | --- |
| 局数 | run 行切出的局数(旧日志 = 文件数)。 |
| 通关率 | `level_end.result == "victory"` 的局占比。 |
| 平均轮数/局 | 每局 `settlement` 快照数(=该局回合总数,快照每结算必发)。 |
| 进 Boss HP 均 | **快照反推法**:Boss 首结算快照 HP + 首轮承伤 − 首轮治疗。快照是回合末状态,用首轮回合内净额反推进场 HP,精确且自包含;仅统计到达 Boss 的局。 |
| 通关剩余 HP 均 | 通关局 `level_end.hp` 均值。 |
| 整局承伤均 | 每局全部敌方落地伤害之和的均值(见战斗级"承伤")。 |
## 战斗级指标
- 分组键 =(关卡,节点类型,节点深度);`battle` 与 `boss` 分开输出。
- **承伤** = 该场全部 `step` 行中 `source=enemy_action` 且 `type=damage` 的 `value` 合计
(即敌方攻击实际落地伤害;含"被减伤归零"的 0 值行)。
- **占生效 max_hp 比** = 承伤 ÷ 该场快照 `hero.max_hp`("生效"指取快照实时值,非写死 50)。
- 承伤带判定:普通场 ≤20%、Boss 场 ≤50%,均值超带在"判定"列标 ✗。
- `battle_end` 行由核心延迟发出(落在下一 node 行之后)且不带深度,本工具仅按 FIFO
对账其配对数(数据质量面),核心统计不依赖它——战斗上下文以 node 行开、相邻 node/局末收。
## 轮级指标(AC 批验收硬指标所在)
- **零承伤轮(窄口径,验收主指标)**:本轮**有敌方攻击落地**(≥1 行 `enemy_action`×`damage`)
且主角掉血为 0(该轮敌方落地伤害合计 = 0)。分母 = 有攻击落地的轮数。
- 蓄力拍(`type=charge`,含空拍/踩雷白喂)**不计**为"有攻击落地"——口径硬性排除。
- AC 批落地含"伤害下限 1"后,"被减伤归零"型轮将变为 1 点实伤,该值应趋零。
- **零掉血轮(宽口径,参考)**:本轮主角 HP 不降(首回合按进场反推 HP 差分,
其后按相邻快照差)。分母 = **敌战轮**——"有过敌人的战斗"(任一轮出现敌人或攻击落地)
的全部轮数。不能按单轮快照 enemies 判定:敌人当轮被歼的收尾轮快照 enemies 为空,
但该轮仍属敌战(5678 局按单轮判定会得 4/7,按敌战轮才是 8/11)。
## 战斗期增益复原(盲区补偿面)
- **事实**:`settlement` 快照在衰减段之后发出、且现有状态寿命全部为 1 结算
→ 快照 `buffs` 字段恒为空(本次自验 7,473 个快照 0 非空)。**禁止把快照 buffs
当战斗期状态使用**。
- **复原口径**:每结算的"轮窗口"= 上一 `settlement` 之后至本 `settlement` 之间的全部
`step` 行;窗口内 `type=effect` 的行按显示文本聚为增益键(增伤→attack_up、
削弱→weaken、减伤→defend_up;未登记文本原样作键,如 4200 批浮现的 失衡/暴走/乏力/敌方伏击+2 层),
行数即层数。逐层数明细看 `--rounds` 流水的"增益复原"列。
- 快照 buffs 非空计数仍照常输出(当前恒 0),作为盲区持续暴露面:将来衰减时序改动
若使快照变非空,这里会第一个显示出来。
## 掷点面(AC 批预留,当前降级)
- **激活条件**:step 行出现候选掷点字段(`ROLL/TIER/CRIT/AC_FIELD_CANDIDATES`)。
- 字段存在 → 输出:d20 直方图、档位占比(缺口=AC−读数:达标=全额,1-3→×0.75,
4-7→×0.5,8+→×0.25;优先读落批直供的档位字段)、nat20 暴击率(理论 5% ±2pp 健康检查)、
观察 EV 减免 vs 理论值(AC10 → 21.25%)、按攻击方向(主角→敌 / 敌→主角)分组。
- 字段不存在(AC 批前日志)→ 显示"不适用(AC 批前日志)",其余指标照常。
- **落批适配入口(一行)**:【数据质量】节的"step 行未知字段观测面"会自动列出全部新键及样例;
把真实字段名补进 `FIELD_CANDIDATES` 常量即可,其余零改动。
## 已登记差异(等落批后核对)
1. 任务背景称 step 行含分量字段 `baseline/attach/normal/pierce/magic`——
**现行日志(2026-09-16 schema)没有这些字段**:`_on_step_row` 只落
`kind/type/source/cell/round/text/value/highlight` 八键。本工具的未知键观测面
会在它们出现时自动捕获;届时按落批语义接入,不动 AC 批范围内文件。
2. 掷点字段名以落批实际为准(候选名单只作探测,不作断言)。
## 自验对账记录(2026-09-16,真实日志)
| 局 | 人工对账 | 结果 |
| --- | --- | --- |
| 5678(perfect 通关) | 整关掉血合计 = 深度2 场 1 + 深度3 场 1 + Boss 场 4 = **6** | 工具输出 6 ✓ |
| 5678 | 零掉血轮宽口径:敌战 11 轮 − 掉血 3 轮 = **8/11** | 工具输出 8/11 ✓ |
| 5678 | 零承伤轮窄口径:被减伤归零型 3 轮(深度1 r1 / Boss r1 / Boss r4) | 工具输出 3 ✓ |
| 1234(perfect 通关) | 独立脚本计数:敌方伤害 12、治疗 8、11 轮(HP 守恒 50−12+8=50) | 工具输出 12/11/终局 50 ✓ |
| 4200 批(233,254 行) | run 行 600、level_end 600(500 胜 100 败) | 切 600 局,normal 66.7% + perfect 100% = 500 胜 ✓ |
- 大文件全量解析耗时:**233,254 行 / 1.35–1.53s**(python 3.13,单遍流式,含 json 解析与全部聚合)。
- `battle_end` FIFO 对账:两种子局 + 600 局批均 0 未配对。
## 边界
- 本工具只读日志,不写任何游戏文件;与 `playtest_logger.gd` 的字段集对齐以 2026-09-16 版为准。
- 快照 buffs、`battle_end`、`action`/`ux` 行族的更多消费面留待需要时扩展。