Files
gplan-2/tools/analyze_playtest.py
DKChan 8bfd249402 工程(user目录隔离): 每个代码面 worktree 独立 Godot user 目录+系统测试 V3d 缺帧修正
- 机制: tools/setup_worktree_userdir.sh 在建区后生成 game/override.cfg(use_custom_user_dir + 唯一名 DungeonSweeper_<区名>_<绝对路径 sha1 前 8>), 主工作区 no-op 保持真实档原位; .gitignore 收 game/override.cfg
- 闸壳: run_all_gates.sh 参数解析后先自检/自愈本区隔离, 建不起来 exit 1(fail-closed)——并行批共用一套夹具/日志/截图目录会互相踩盘, 是假红的根源
- 读取面: 新增 tools/godot_user_dir.py 统一解析(有 override→custom dir, 无→默认 appdata/Godot/app_userdata/<config/name>); parity_anchor.py 与 analyze_playtest.py 的硬编码 APPDATA 路径改为调用它
- 流程: process-conventions §1 第 1 条(建区即跑 setup)与 §3 环境基线第 4 条(闸壳自检); iter 技能冷检出三件套→四件套(.zcode/.qoder 两份本机副本已同步, 不入 git)
- 系统测试 V3d: 隔离后空 user 目录(无 settings.cfg)使 reduced_motion 取默认 false, 实查暴露 V3d 真实缺陷——测试同步驱动整场战斗后同帧 flush, RowPacer._was_pumping 从未置真, 演播后排窗的 pump_ended 边沿不触发, _end_overlay 永不打开; 旧绿只因本机 settings.cfg reduced=true 让排播走 consume_now 快路绕过队列。补一帧 await process_frame 让泵先观测到非空队列再 flush, 恢复真实边沿路径
- 取证: 隔离空目录下 00 冒烟 17/17、02 单元与集成 62 套全绿、03 探针 --click 全绿; 01 系统测试修正前 70/1 红(仅 V3d)、修正后 reduced=true 与 false 两档均 70/70 绿
2026-10-27 05:23:13 +08:00

685 lines
33 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 -*-
"""试玩日志统计分析工具(消费 playtest_logger 的 JSONL——user://logs/playtest_*.jsonl)。
统计口径:
· 每轮获得的奖励(效果/治疗/奖励伤害/未生效)与充能点数
· 实际造成伤害 / 实际受到伤害 / 恢复总量
· 增伤贡献(attack_up + 充能穿刺/魔法)、减伤贡献(hero_defense×enemy_output 池挡下的承伤)
· 反事实一「无奖励造伤」:不算增伤和奖励伤害的主角输出(普通分量按无 attack_up 重算)
· 反事实二「无减伤承伤」:不算减伤池(hero_defense/enemy_output 归零)的承伤
· 反事实三「无奖励承伤」:再扣伏击附加与 enrage(池归零已含)
两种日志格式:
· 旧格式(step 行无 payload):反事实按比例近似(误差每行 ±1~2,多 floor 链无法逆精确);
掷点折扣与 ac_up 对敌方判定的影响不可恢复,忽略(已标注)。
· 新格式(step 行携带 roll/ac/tier/crit/normal/pierce/magic/baseline/attach):
反事实按 DamageMath 公式逐行精确重算。
· 敌方伤害行携带 rider(必中附加分量)——计为敌方招式成分,
无减伤世界(cf_tn)含 rider、无奖励世界(cf_tr)保留 rider 只剥伏击附加。
表值硬编码溯源(表值变更须同步——单一事实源仍在 .tres):
· status_effects.tres:attack_up 50 / weaken −65 / enrage 25 / off_guard 25 / sapped −25(percent)
· settlement.tres:pool_clamp [-100,100] · crit_multiplier 200
· 伏击机制已退役(blast 负向=警觉,敌方 AC+1,掷点分布型,无 flat 附加项)——
M_AMB 置 0 保持反事实管道兼容旧日志;新日志不携带 ambush 效果行
· skills.tres:突刺/撕咬 1.5 · 重击 2.0 · 毒爆 2.5 · 普攻 1.0
· armor_effectiveness:pierce 克 ward 150 / heavy 50;magic 克 heavy 150 / ward 50;normal 恒 100
"""
import glob
import json
import os
import sys
from pathlib import Path
from godot_user_dir import resolve_user_dir
# ---- 表值区(溯源见文件头) ----
POOL_CLAMP = (-100, 100)
CRIT_MULT = 200
M_AMB = 0 # 伏击已退役——反事实剥除项归零,旧日志兼容读
SKILL_MULT = {"普通攻击": 1.0, "突刺": 1.5, "重击": 2.0, "撕咬": 1.5, "毒爆": 2.5}
EFFECT_TEXT_MAP = { # 旧日志无 effect_id——按明细行文本反查
"增伤": "attack_up", "护甲强化": "ac_up", "削弱": "weaken", "暴走": "enrage",
"失衡": "off_guard", "乏力": "sapped", "警觉": "alert", "伏击": "ambush", "蓄力": "empower", "硬化": "fortify",
}
EFFECT_CN = {v: k for k, v in EFFECT_TEXT_MAP.items()} # effect_id → 中文显示名
# 正负分类(奖励件 / 惩罚件的口径——数据分析的三维度共用)
POS_NAMES = {"增伤", "护甲强化", "削弱", "群伤", "回复", "清障炸药"}
NEG_NAMES = {"暴走", "失衡", "乏力", "伏击", "警觉", "踩雷直伤"}
POOL_OF = {"attack_up": "hero_offense", "sapped": "hero_offense",
"off_guard": "hero_defense",
"weaken": "enemy_output", "enrage": "enemy_output", "empower": "enemy_output",
"fortify": "enemy_defense"}
ARMOR_EFF = {"pierce": {"light": 100, "ward": 150, "heavy": 50},
"magic": {"light": 100, "ward": 50, "heavy": 150}}
def clamp_pool(v):
return max(POOL_CLAMP[0], min(POOL_CLAMP[1], v))
def charge_comp(points, eff):
return int(points * eff / 100.0) # floor
def rolled_component(base, discount, crit, eff):
v = base * discount / 100.0
if crit:
v *= CRIT_MULT / 100.0
return int(v * eff / 100.0) # floor
def disp_w(s): # 中文显示宽度(终端对齐用)
return sum(2 if ord(c) > 0x2E7F else 1 for c in str(s))
def pad(s, w, right=False):
s = str(s)
gap = w - disp_w(s)
return (" " * gap + s) if right else (s + " " * gap)
def effect_id_of(row):
if row.get("effect_id"):
return row["effect_id"]
for name, eid in EFFECT_TEXT_MAP.items():
if name in row.get("text", ""):
return eid
return None
def parse_log(path):
rows = []
with open(path, encoding="utf-8") as f:
for ln in f:
ln = ln.strip()
if ln:
rows.append(json.loads(ln))
return rows
# ---- 多局聚合模式(monte carlo 共享日志:run 行切分局次,按通关/失败分组对比) ----
def analyze_runs(rows, label, tier=None, policy=None):
"""tier = (atk_pct, hp_pct):只统计该天赋档(None = 全档)。
policy = "normal"/"perfect":只统计该策略画像
(双画像同文件时分开聚合做配对对照;None = 全画像混算)。"""
runs = [] # [{meta, rows}]
cur = None
for r in rows:
k = r.get("kind")
if k == "run":
cur = {"meta": r, "rows": []}
runs.append(cur)
elif cur is not None:
cur["rows"].append(r)
if tier is not None:
runs = [run for run in runs
if int(run["meta"].get("atk_pct", -999)) == tier[0]
and int(run["meta"].get("hp_pct", -999)) == tier[1]]
if policy is not None:
runs = [run for run in runs if run["meta"].get("policy") == policy]
stats = []
for run in runs:
res = analyze_run_core(run["rows"], run["meta"])
if res:
stats.append(res)
print_run_report(label, stats)
def analyze_run_core(rows, meta):
"""单局聚合:返回该局的总账(结果/死亡节点/造伤/承伤/回复/奖励/轮数 + 战斗级明细)。"""
out = dict(meta=meta, result="", death_node=-1, hp_left=-1,
hero_dmg=0, reward_dmg=0, taken=0, healed=0, rest_heal=0,
rounds=0, rewards_n=0, eff={}, battles_won=0, battles=0, battle_list=[],
refresh_ok=0, refresh_rej=0, exits={})
eff_counts = {}
cur_b = None
for r in rows:
k = r.get("kind")
if k == "node" and r.get("type") in ("battle", "boss"):
out["battles"] += 1
cur_b = dict(btype=r.get("type"), depth=int(r.get("node_depth", r.get("depth", -1))),
rounds=0, hero_dmg=0, reward_dmg=0, taken=0, healed=0,
pos_n=0, neg_n=0, result="")
out["battle_list"].append(cur_b)
elif k == "battle_end":
if r.get("result") == "victory":
out["battles_won"] += 1
if cur_b is not None:
cur_b["result"] = r.get("result", "")
cur_b["end_hp"] = int(r.get("hero_hp", -1))
elif k == "level_end":
out["result"] = r.get("result", "")
out["death_node"] = int(r.get("node_index", -1))
out["hp_left"] = int(r.get("hp", -1))
elif k == "settlement":
out["rounds"] += 1
if cur_b is not None:
cur_b["rounds"] += 1
elif k == "step":
t, src = r["type"], r.get("source", "")
if t == "effect":
eid = effect_id_of(r)
if eid:
name = "伏击" if eid == "ambush" else EFFECT_CN.get(eid, eid)
eff_counts[name] = eff_counts.get(name, 0) + 1
if eid != "ambush":
out["rewards_n"] += 1
if cur_b is not None:
if name in NEG_NAMES:
cur_b["neg_n"] += 1
elif name in POS_NAMES:
cur_b["pos_n"] += 1
elif t == "heal":
out["healed"] += int(r["value"])
out["rewards_n"] += 1
eff_counts["回复"] = eff_counts.get("回复", 0) + 1
if cur_b is not None:
cur_b["healed"] += int(r["value"])
cur_b["pos_n"] += 1
elif t == "damage":
v = int(r["value"])
if src == "hero_attack":
out["hero_dmg"] += v
if cur_b is not None:
cur_b["hero_dmg"] += v
elif src in ("safe_cell", "clear_reward", "flag_hit") or (
src == "mine_hit" and "全体敌人" in r.get("text", "")):
out["reward_dmg"] += v
out["rewards_n"] += 1
name = "清障炸药" if src == "clear_reward" else "群伤"
eff_counts[name] = eff_counts.get(name, 0) + 1
if cur_b is not None:
cur_b["reward_dmg"] += v
cur_b["pos_n"] += 1
elif src == "mine_hit":
out["taken"] += v
eff_counts["踩雷直伤"] = eff_counts.get("踩雷直伤", 0) + 1
if cur_b is not None:
cur_b["taken"] += v
cur_b["neg_n"] += 1
elif src == "enemy_action":
out["taken"] += v
if cur_b is not None:
cur_b["taken"] += v
if k == "node" and r.get("type") == "rest":
out["rest_heal"] += int(r.get("healed", 0))
# 弃图刷新与轮出口(常设读数)
elif k == "action" and r.get("op") == "refresh":
if r.get("accepted", False):
out["refresh_ok"] += 1
else:
out["refresh_rej"] += 1
elif k == "ux" and r.get("ux") == "ux_round_exit":
tag = r.get("path_tag", "?")
out["exits"][tag] = out["exits"].get(tag, 0) + 1
out["eff"] = eff_counts
return out if out["rounds"] > 0 else None
def print_run_report(label, stats):
print("=" * 104)
print("多局聚合:%s (共 %d 局)" % (label, len(stats)))
print("=" * 104)
wins = [s for s in stats if s["result"] == "victory"]
loss = [s for s in stats if s["result"] != "victory"]
print("\n通关 %d 局 · 失败 %d 局(通关率 %.0f%%)" % (len(wins), len(loss),
100.0 * len(wins) / max(1, len(stats))))
def avg(group, key):
return sum(s[key] for s in group) / max(1, len(group))
def eff_line(group):
total_rounds = sum(s["rounds"] for s in group) or 1
parts = []
names = ["增伤", "护甲强化", "削弱", "群伤", "回复", "暴走", "失衡", "乏力", "伏击", "清障炸药", "踩雷直伤"]
for n in names:
c = sum(s["eff"].get(n, 0) for s in group)
if c:
parts.append("%s %.2f件/轮" % (n, c / total_rounds))
return "、".join(parts) if parts else "—"
def group_block(name, group):
print("\n【%s · %d 局】" % (name, len(group)))
if not group:
return
print(" 平均总造成伤害(主角攻击) :%.1f" % avg(group, "hero_dmg"))
print(" 平均奖励伤害(群伤/炸药) :%.1f" % avg(group, "reward_dmg"))
print(" 平均总受到伤害 :%.1f" % avg(group, "taken"))
print(" 平均战斗内回复 :%.1f" % avg(group, "healed"))
print(" 平均恢复节点回复 :%.1f" % avg(group, "rest_heal"))
print(" 平均结算轮数 :%.1f" % avg(group, "rounds"))
print(" 平均每局奖励件数 :%.1f(%.2f 件/轮)" % (
avg(group, "rewards_n"), avg(group, "rewards_n") / max(1.0, avg(group, "rounds"))))
# 弃图刷新面:刷新板数÷总板数 = 弃图率
boards = sum(s["battles"] + s["refresh_ok"] for s in group)
refreshes = sum(s["refresh_ok"] for s in group)
print(" 平均每局弃图刷新 :%.2f 次(被拒 %.2f 次) 弃图率:%.0f%%" % (
refreshes / max(1, len(group)),
sum(s["refresh_rej"] for s in group) / max(1, len(group)),
100.0 * refreshes / max(1, boards)))
exits = {}
for s in group:
for k, v in s["exits"].items():
exits[k] = exits.get(k, 0) + v
exit_total = sum(exits.values()) or 1
exit_cn = {"cleared": "清图换板", "stopped": "主动停手", "ap_exhausted": "行动点耗尽"}
print(" 轮出口分布 :%s" % "、".join(
"%s %d(%.0f%%)" % (exit_cn.get(k, k), v, 100.0 * v / exit_total)
for k, v in sorted(exits.items(), key=lambda x: -x[1])))
print(" 地图战斗资源构成(件/轮) :%s" % eff_line(group))
if name.startswith("失败"):
dn = {}
for s in group:
dn[s["death_node"]] = dn.get(s["death_node"], 0) + 1
print(" 死亡节点分布 :%s" % "、".join(
"节点%s ×%d" % (k, v) for k, v in sorted(dn.items(), key=lambda x: -x[1])))
group_block("通关局", wins)
group_block("失败局", loss)
# ---- 战斗状态对照(普通战斗 vs Boss——跨局聚合) ----
def battle_block(name, group):
rows = [b for s in group for b in s.get("battle_list", [])]
if not rows:
return
print("\n【战斗状态对照 · %s】" % name)
hdr = ["战斗类型", "场次", "场均轮数", "场均造成(主角+奖励)", "场均受到", "场均回复",
"场均奖励件", "场均惩罚件", "胜率"]
widths = [10, 6, 10, 20, 10, 10, 11, 11, 8]
print(" " + " | ".join(pad(h, w) for h, w in zip(hdr, widths)))
for btype, label_cn in (("battle", "普通战斗"), ("boss", "Boss")):
sub = [b for b in rows if b.get("btype") == btype]
if not sub:
continue
n = len(sub)
won = sum(1 for b in sub if b.get("result") == "victory")
cells = [label_cn, n,
"%.1f" % (sum(b["rounds"] for b in sub) / n),
"%.1f" % (sum(b["hero_dmg"] + b["reward_dmg"] for b in sub) / n),
"%.1f" % (sum(b["taken"] for b in sub) / n),
"%.1f" % (sum(b["healed"] for b in sub) / n),
"%.1f" % (sum(b["pos_n"] for b in sub) / n),
"%.1f" % (sum(b["neg_n"] for b in sub) / n),
"%.0f%%" % (100.0 * won / n)]
print(" " + " | ".join(pad(c, w) for c, w in zip(cells, widths)))
battle_block("通关局", wins)
battle_block("失败局", loss)
print()
def analyze(rows, label):
# 战斗开始生命预扫描:running_hp 从初始满血起步,battle_end 落账、rest 节点回血中途累加
running_hp = None
for r in rows:
k = r.get("kind")
if k == "settlement" and running_hp is None:
running_hp = int(r.get("hero", {}).get("max_hp", 0))
elif k == "node" and r.get("type") == "rest" and running_hp is not None:
running_hp += int(r.get("healed", 0))
elif k == "battle_end":
r["_start_hp"] = running_hp
running_hp = int(r.get("hero_hp", -1))
battles = [] # [{name, rounds: {round: {"sett": snap, "steps": [rows]}}, prev_ambush}]
cur = None
for r in rows:
k = r.get("kind")
if k == "node" and r.get("type") in ("battle", "boss"):
cur = {"name": "%s D%d" % (r["type"], r.get("depth", -1)),
"btype": r.get("type"), "rounds": {}, "seq": len(battles)}
battles.append(cur)
elif k == "settlement":
if cur is None:
cur = {"name": "独立战斗", "btype": "battle", "rounds": {}, "seq": 0}
battles.append(cur)
cur["rounds"].setdefault(r["round"], {"sett": None, "steps": []})["sett"] = r
elif k == "step":
if cur is None:
continue
cur["rounds"].setdefault(r["round"], {"sett": None, "steps": []})["steps"].append(r)
elif k == "battle_end" and cur is not None:
cur["result"] = r.get("result")
cur["end_hp"] = int(r.get("hero_hp", -1))
cur["start_hp"] = int(r.get("_start_hp", -1))
# ---- 每战斗逐轮分解 ----
per_battle = []
totals = dict(hero_dmg=0, reward_dmg=0, taken=0, healed=0, cf_hero_dmg=0,
cf_taken_nored=0, cf_taken_norw=0, rest_heal=0, ineffective=0,
pos_n=0, neg_n=0, pierce=0, magic=0, mines=0, back_sum=0)
exact_mode = None
for b in battles:
rounds = sorted(b["rounds"].keys())
prev_ambush = 0
bstat = dict(hero_dmg=0, reward_dmg=0, taken=0, healed=0, cf_hero_dmg=0,
cf_taken_nored=0, cf_taken_norw=0, rounds=[],
pos_n=0, neg_n=0, pierce=0, magic=0, mines=0, back_sum=0,
start_hp=int(b.get("start_hp", -1)), end_hp=int(b.get("end_hp", -1)),
btype=b.get("btype", "battle"))
for rd in rounds:
srow = b["rounds"][rd]
sett = srow["sett"] or {}
steps = srow["steps"]
pool = {} # effect_id -> pool_value(挂层行的当轮池值)
ambush_gain = 0
for s in steps:
if s["type"] == "effect":
eid = effect_id_of(s)
if eid == "ambush":
ambush_gain += int(s["value"])
elif eid:
pool[eid] = int(s.get("pool_value", s["value"]))
up = pool.get("attack_up", 0) # 现行实现:hero_offense 走 attack_up 单效果直乘
hero_def = clamp_pool(pool.get("off_guard", 0))
enemy_out = clamp_pool(pool.get("weaken", 0) + pool.get("enrage", 0) + pool.get("empower", 0))
enemy_def = clamp_pool(pool.get("fortify", 0))
ambush_layers = prev_ambush + ambush_gain
attach = ambush_layers * M_AMB
k_taken = (100 + hero_def) * (100 + enemy_out) / 10000.0
enemies = sett.get("enemies", [])
armor0 = enemies[0].get("armor", "light") if enemies else "light"
hero_atk = sett.get("hero", {}).get("atk", 0)
chg = sett.get("round_facts", {}).get("charge_points", {}) if sett else {}
facts = sett.get("round_facts", {}) if sett else {}
rowout = dict(rd=rd, safe=facts.get("safe", 0), mines=facts.get("mines", 0),
flags=facts.get("flag_events", 0), pierce=chg.get("pierce", 0),
magic=chg.get("magic", 0), hero=0, rw=0, taken=0, heal=0,
cf_hero=0, cf_tn=0, cf_tr=0,
eff_counts={}, heal_n=0, heal_sum=0, aoe_n=0, aoe_sum=0,
back_n=0, back_sum=0, clear_n=0, clear_sum=0, ineff_counts={},
pos_n=0, neg_n=0)
for s in steps:
t, src = s["type"], s.get("source", "")
if t == "effect":
eid = effect_id_of(s)
if eid == "ambush":
rowout["eff_counts"]["伏击"] = rowout["eff_counts"].get("伏击", 0) + 1
rowout["neg_n"] += 1
elif eid:
name = EFFECT_CN.get(eid, eid)
rowout["eff_counts"][name] = rowout["eff_counts"].get(name, 0) + 1
if name in NEG_NAMES:
rowout["neg_n"] += 1
elif name in POS_NAMES:
rowout["pos_n"] += 1
elif t == "heal":
rowout["heal"] += int(s["value"])
rowout["heal_n"] += 1
rowout["heal_sum"] += int(s["value"])
rowout["pos_n"] += 1
elif t == "damage":
if src == "hero_attack":
v = int(s["value"])
rowout["hero"] += v
if exact_mode is None:
exact_mode = "normal" in s
if exact_mode:
comp = {a: int(s.get(a, 0)) for a in ("normal", "pierce", "magic")}
cf_n = rolled_component(hero_atk, int(s.get("tier", 100)),
bool(s.get("crit")), 100)
cf_n = int(cf_n * (100 + enemy_def) / 100.0)
cf = max(1, cf_n) if (comp["normal"] + comp["pierce"] + comp["magic"]) >= 1 else 0
rowout["cf_hero"] += cf_n if cf else v
else:
p = charge_comp(chg.get("pierce", 0), ARMOR_EFF["pierce"].get(armor0, 100))
m = charge_comp(chg.get("magic", 0), ARMOR_EFF["magic"].get(armor0, 100))
n = max(0, v - p - m)
cf_n = int(round(n * 100.0 / (100 + up)))
rowout["cf_hero"] += cf_n
elif src in ("safe_cell", "clear_reward", "flag_hit") or (
src == "mine_hit" and "全体敌人" in s.get("text", "")):
# 奖励伤害 = 打敌人的棋盘来源伤害;踩雷负向打主角的(反噬震荡)归承伤侧
rowout["rw"] += int(s["value"])
rowout["pos_n"] += 1
if src == "clear_reward":
rowout["clear_n"] += 1
rowout["clear_sum"] += int(s["value"])
else:
rowout["aoe_n"] += 1
rowout["aoe_sum"] += int(s["value"])
elif src == "mine_hit":
v = int(s["value"])
rowout["taken"] += v
rowout["back_n"] += 1
rowout["back_sum"] += v
rowout["neg_n"] += 1
# 踩雷直伤照吃(不算减伤≠去掉它),但无奖励世界没有这套负向兑付
rowout["cf_tn"] += v
elif src == "enemy_action":
v = int(s["value"])
rowout["taken"] += v
if exact_mode:
base = float(s.get("baseline", v))
att = int(s.get("attach", 0))
rider = int(s.get("rider", 0)) # 必中附加分量——敌方招式成分,非棋盘来源
rolled = rolled_component(base, int(s.get("tier", 100)), bool(s.get("crit")), 100)
pre = rolled + att + rider
cf_nored = max(1, pre)
cf_norw = max(1, rolled + rider) # 无奖励世界只剥棋盘来源的伏击附加,rider 保留
else:
pre = int(round(v / k_taken)) if k_taken > 0 else v
cf_nored = max(1, pre)
cf_norw = max(1, pre - attach)
rowout["cf_tn"] += cf_nored
rowout["cf_tr"] += cf_norw
for s in steps:
if s["type"] == "reward_ineffective":
txt = s.get("text", "")
if "满层" in txt:
key = "增益满层"
elif "满血" in txt:
key = "回血满血"
elif "无存活敌人" in txt:
key = "无存活敌人"
elif "主角已倒下" in txt:
key = "主角已倒下"
else:
key = "其他"
rowout["ineff_counts"][key] = rowout["ineff_counts"].get(key, 0) + 1
bstat["hero_dmg"] += rowout["hero"]
bstat["reward_dmg"] += rowout["rw"]
bstat["taken"] += rowout["taken"]
bstat["healed"] += rowout["heal"]
bstat["cf_hero_dmg"] += rowout["cf_hero"]
bstat["cf_taken_nored"] += rowout["cf_tn"]
bstat["cf_taken_norw"] += rowout["cf_tr"]
bstat["pos_n"] += rowout["pos_n"]
bstat["neg_n"] += rowout["neg_n"]
bstat["pierce"] += rowout["pierce"]
bstat["magic"] += rowout["magic"]
bstat["mines"] += rowout["mines"]
bstat["back_sum"] += rowout["back_sum"]
bstat["rounds"].append(rowout)
prev_ambush = sett.get("ambush_layers", ambush_layers)
bstat["name"] = b.get("name", "?")
bstat["result"] = b.get("result", "?")
per_battle.append(bstat)
for kk in ("hero_dmg", "reward_dmg", "taken", "healed", "cf_hero_dmg",
"cf_taken_nored", "cf_taken_norw", "pos_n", "neg_n",
"pierce", "magic", "mines", "back_sum"):
totals[kk] += bstat[kk]
# ---- 关卡恢复节点(rest) ----
for r in rows:
if r.get("kind") == "node" and r.get("type") == "rest":
totals["rest_heal"] += int(r.get("healed", 0))
print_report(label, per_battle, totals, exact_mode)
return totals
def _reward_parts(r):
"""单轮/单战斗的奖励与惩罚构成文本(聚合计数,不逐件枚举)。"""
pos, neg = [], []
for name, n in r.get("eff_counts", {}).items():
(neg if name in NEG_NAMES else pos).append("%s×%d" % (name, n))
for name, cnt_key, sum_key in (("群伤", "aoe_n", "aoe_sum"),
("清障炸药", "clear_n", "clear_sum")):
if r.get(cnt_key):
pos.append("%s×%d(%d点)" % (name, r[cnt_key], r[sum_key]))
if r.get("heal_n"):
pos.append("回复×%d(+%d)" % (r["heal_n"], r["heal_sum"]))
if r.get("back_n"):
neg.append("踩雷直伤×%d(%d点)" % (r["back_n"], r["back_sum"]))
return pos, neg
def print_report(label, per_battle, t, exact_mode):
W = 22
mode_txt = ("精确重算(payload 日志)" if exact_mode
else "比例近似(旧格式日志,每行误差 ±1~2;掷点折扣与护甲等级对敌方判定的影响不可恢复,忽略)")
print("=" * 104)
print("试玩统计:%s (反事实口径:%s)" % (label, mode_txt))
print("=" * 104)
# ---- 维度一:整个关卡的状态 ----
print("\n" + "=" * 104)
print("【维度一 · 整个关卡的状态】")
print("=" * 104)
rounds_total = sum(len(b["rounds"]) for b in per_battle)
won = sum(1 for b in per_battle if b["result"] == "victory")
print(" 战斗数 / 总轮数 / 通关战斗:%d 场 / %d 轮 / %d 胜" % (len(per_battle), rounds_total, won))
print(" 造成伤害:%d(主角攻击 %d + 奖励伤害 %d)" % (
t["hero_dmg"] + t["reward_dmg"], t["hero_dmg"], t["reward_dmg"]))
print(" 受到伤害:%d(敌方行动 %d + 踩雷直伤 %d)" % (
t["taken"], t["taken"] - t["back_sum"], t["back_sum"]))
print(" 回复总量:%d(战斗内 %d + 恢复节点 %d)" % (
t["healed"] + t["rest_heal"], t["healed"], t["rest_heal"]))
print(" 充能总量:穿刺 %d 点 / 魔法 %d 点" % (t["pierce"], t["magic"]))
print(" 获得奖励件数:%d 件 获得惩罚件数:%d 件 踩危险格数:%d" % (
t["pos_n"], t["neg_n"], t["mines"]))
hp_trail = " → ".join("%d" % b["end_hp"] for b in per_battle if b["end_hp"] >= 0)
print(" 生命轨迹(各战斗结束 HP):%s" % hp_trail)
print(" 不算增伤和奖励伤害的造成伤害:%d(增伤+充能贡献 %d)" % (
t["cf_hero_dmg"], t["hero_dmg"] - t["cf_hero_dmg"]))
print(" 不算减伤的受到伤害:%d(减伤池挡下 %d)" % (
t["cf_taken_nored"], t["cf_taken_nored"] - t["taken"]))
print(" 不算奖励的受到伤害:%d" % t["cf_taken_norw"])
# ---- 维度二:一场战斗的状态 ----
print("\n" + "=" * 104)
print("【维度二 · 一场战斗的状态】")
print("=" * 104)
hdr = ["战斗", "结果", "轮数", "生命(开始→结束)", "造成(主角+奖励)", "受到", "回复",
"奖励件", "惩罚件", "踩雷", "充能(穿/魔)", "获得奖励 / 获得惩罚"]
widths = [10, 6, 5, 14, 16, 6, 5, 7, 7, 5, 11, 46]
print(" " + " | ".join(pad(h, w) for h, w in zip(hdr, widths)))
for b in per_battle:
agg = dict(eff_counts={}, aoe_n=0, aoe_sum=0, clear_n=0, clear_sum=0,
heal_n=0, heal_sum=0, back_n=0, back_sum=0)
for r in b["rounds"]:
for k, v in r["eff_counts"].items():
agg["eff_counts"][k] = agg["eff_counts"].get(k, 0) + v
for k in ("aoe_n", "aoe_sum", "clear_n", "clear_sum", "heal_n", "heal_sum",
"back_n", "back_sum"):
agg[k] += r[k]
pos, neg = _reward_parts(agg)
res_txt = "胜利" if b["result"] == "victory" else str(b["result"])
hp_txt = ("%d→%d" % (b["start_hp"], b["end_hp"])) if b["start_hp"] >= 0 else "—"
cells = [b["name"], res_txt, len(b["rounds"]), hp_txt,
"%d+%d" % (b["hero_dmg"], b["reward_dmg"]), b["taken"], b["healed"],
"%d件" % b["pos_n"], "%d件" % b["neg_n"], b["mines"],
"%d/%d" % (b["pierce"], b["magic"]),
("奖:" + "、".join(pos) if pos else "奖:—") +
(" 罚:" + "、".join(neg) if neg else "")]
print(" " + " | ".join(pad(c, w) for c, w in zip(cells, widths)))
# ---- 维度三:轮次的状态 ----
print("\n" + "=" * 104)
print("【维度三 · 轮次的状态】")
print("=" * 104)
for b in per_battle:
print("\n— %s · %s —" % (b["name"], "胜利" if b["result"] == "victory" else b["result"]))
hdr = ["轮", "开格(安/雷/旗)", "充能(穿/魔)", "获得奖励 / 获得惩罚", "造成伤害", "受到伤害", "回复量",
"不算增伤和奖励伤害的造成伤害", "不算减伤的受到伤害", "不算奖励的受到伤害"]
widths = [4, 14, 12, W + 20, 8, 8, 6, 20, 16, 16]
print(" " + " | ".join(pad(h, w) for h, w in zip(hdr, widths)))
all_ineff = {}
for r in b["rounds"]:
pos, neg = _reward_parts(r)
reward_txt = ("奖:" + "、".join(pos) if pos else "奖:—") + \
(" 罚:" + "、".join(neg) if neg else "")
cells = [r["rd"], "%d/%d/%d" % (r["safe"], r["mines"], r["flags"]),
"%d/%d" % (r["pierce"], r["magic"]), reward_txt,
r["hero"], r["taken"], r["heal"],
r["cf_hero"], r["cf_tn"], r["cf_tr"]]
print(" " + " | ".join(pad(c, w) for c, w in zip(cells, widths)))
for k, n in r["ineff_counts"].items():
all_ineff[k] = all_ineff.get(k, 0) + n
if all_ineff:
print(" 奖励未生效统计:" + "、".join("%s×%d" % (k, n) for k, n in all_ineff.items()))
print()
def main():
log_dir = str(resolve_user_dir(Path(__file__).resolve().parent.parent) / "logs")
path = None
args = sys.argv[1:]
if args and not args[0].startswith("-"):
path = args[0]
else:
cands = sorted(glob.glob(os.path.join(log_dir, "playtest_*.jsonl")), key=os.path.getmtime)
if not cands:
print("未找到日志(%s)" % log_dir)
sys.exit(1)
path = cands[-1]
rows = parse_log(path)
# 命令行第二参 = runs:多局聚合模式(monte carlo 共享日志按 run 行切分,通关/失败分组对比);
# --tier ATK,HP:只统计该天赋档(默认 0,0 基线档——0.5 传作 50)。
if "--runs" in args:
tier = (0, 0)
policy = None
for i, a in enumerate(args):
if a == "--tier" and i + 1 < len(args):
tier = tuple(int(x) for x in args[i + 1].split(","))
elif a.startswith("--tier="):
tier = tuple(int(x) for x in a.split("=")[1].split(","))
elif a == "--policy" and i + 1 < len(args):
policy = args[i + 1]
elif a.startswith("--policy="):
policy = a.split("=")[1]
analyze_runs(rows, os.path.basename(path), tier, policy)
elif "--run" in args:
# 抽单局(大日志里按 run 序号或种子挑一局)走三层维度报告
sel = None
for i, a in enumerate(args):
if a == "--run" and i + 1 < len(args):
sel = args[i + 1]
elif a.startswith("--run="):
sel = a.split("=")[1]
runs, cur = [], None
for r in rows:
if r.get("kind") == "run":
cur = {"meta": r, "rows": []}
runs.append(cur)
elif cur is not None:
cur["rows"].append(r)
pick = None
if sel is not None:
for x in runs: # 先按种子精确匹配(模拟局 seed 唯一)
if str(x["meta"].get("seed")) == sel:
pick = x
break
if pick is None and sel.isdigit(): # 再按基线档(0,0)序号
base = [x for x in runs if int(x["meta"].get("atk_pct", -1)) == 0
and int(x["meta"].get("hp_pct", -1)) == 0]
if int(sel) < len(base):
pick = base[int(sel)]
if pick is None:
print("未找到该局(--run <序号|种子>)")
sys.exit(1)
sub = [dict(r, kind="session")] + pick["rows"]
analyze(sub, "%s · seed=%s · 档位 atk%s%%/hp%s%%" % (
os.path.basename(path), pick["meta"].get("seed"),
pick["meta"].get("atk_pct"), pick["meta"].get("hp_pct")))
else:
analyze(rows, os.path.basename(path))
if __name__ == "__main__":
main()