Files
gplan-2/tools/analyze_playtest.py
T
DKChan d236d9f8e1 feat(slice): 技能池新效应词汇批——妨害技+必中附加分量(L2 Boss 卓尔实装;多段命中退役;2026-09-19 用户逐项拍板,设计权威 docs/proposal-status-vocabulary.md v1.0 新建)——①三词收敛两词:妨害技=第四技能类型 debuff(0 伤害施放行,字段复用 buff_ref/buff_duration,落点由状态行 target_side 路由——容器零改动;池纪律+每池妨害≤1;施放类 side 守卫 buff→enemy/debuff→hero);固定值伤害改形必中附加分量 rider_mult(域 (0,1],分量值 max(floor, floor(atk×rider)) 随基线缩放——固定值不通用/跨关稀释析出此改形;价位锚 0.75=AC 折扣档最轻档"不掉档的稳定伤害",可掷分量期望 AC10≈0.80/AC12≈0.76 对表,×0.5 永久第三档案经用户质询否决);多段命中退役(无防御磨损体系无对照机制,P1 字段位保留);②实装面=仅 L2 Boss 蛛化卓尔精灵(用户拍板,其余敌种不动):毒爆+必中毒分量(atk3→毒2,atk 区间 2-4→1-3 随开战掷定缩放);新行蛛网束缚 web_snare(debuff→off_guard 失衡〔首发轴用户拍板〕,duration 2=1 有效拍,CD4,池 6/2/2→6/2/2/2 全 provisional);③结算面:三乘法链尾部+rider 后置加法(max(floor,主分量链)+rider,payload rider 键+行文本「+附加N(必中)」);意图徽标三档扩四档(+妨害系 ☠ 紫);④加载校验扩两族(pool_composition 妨害>1 / cast_side 施放类 side 守卫——validate_skill_pool_links 增 status_table 显式入参修装载序 Nil 崩);⑤验证:冒烟 306→317 项 0 失败(新探针 _smoke_debuff_rider 四断言:毒爆行逐拍精确复算/rider 键封闭/蛛网束缚落主角端到端/种子可达+校验拒绝组六断言)+四探针 PASS;顺手修并行 board_type 批漏网(smoke variant_pool Array→String 字面量,原断言被 SCRIPT ERROR 静默吞);800 局模拟试玩(两关×20 种子×双画像 4200-4219):L2 基线通关 90%→80%、剩余死因 100% 集中 Boss(设计方向生效)、台阶倒挂收窄 65vs90→65vs80,L1 65%/85% 与批前一致零行为变更回归;analyze_playtest.py 反事实重算+rider 识别(敌方招式成分,无奖励世界只剥伏击保留 rider);⑥文档:敌人系统 v0.10(枚举四类/词汇表 rider 行/池纪律/意图三族/P1 表核销)+战斗结算 v0.17(三乘法链尾 rider/公式行/必中家族/验收两条)+增益状态 v0.10(off_guard 第二来源=敌方技能施放)+registry skill 条目改写+off_guard 行+修订块+07 头注与速览行(重测变量+1)+README 批节+06 条目解决移除;数值全 provisional 归 难度曲线与Boss;本提交一并入库并行会话已获用户批准的完整批次:06 残留清理批(75→55 条,台账 81)+旗行为基线前置采集批(冒烟 11 断言,台账 83)+两处悬空指认连带(关卡节点地图/acceptance-ap6-ac)——e66d156 先例
2026-09-19 05:54:54 +08:00

646 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""试玩日志统计分析工具(消费 playtest_logger 的 JSONL——user://logs/playtest_*.jsonl)。
统计口径(2026-09-17 用户定义):
· 每轮获得的奖励(效果/治疗/奖励伤害/未生效)与充能点数
· 实际造成伤害 / 实际受到伤害 / 恢复总量
· 增伤贡献(attack_up + 充能穿刺/魔法)、减伤贡献(hero_defense×enemy_output 池挡下的承伤)
· 反事实一「无奖励造伤」:不算增伤和奖励伤害的主角输出(普通分量按无 attack_up 重算)
· 反事实二「无减伤承伤」:不算减伤池(hero_defense/enemy_output 归零)的承伤
· 反事实三「无奖励承伤」:再扣伏击附加与 enrage(池归零已含)
两种日志格式:
· 旧格式(step 行无 payload):反事实按比例近似(误差每行 ±1~2,多 floor 链无法逆精确);
掷点折扣与 ac_up 对敌方判定的影响不可恢复,忽略(已标注)。
· 新格式(2026-09-17 起 step 行携带 roll/ac/tier/crit/normal/pierce/magic/baseline/attach):
反事实按 DamageMath 公式逐行精确重算。
· 新效应词汇批(2026-09-19):敌方伤害行携带 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 · 伏击附加 = ambush 层数×3(magnitude)
· 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
# ---- 表值区(溯源见文件头) ----
POOL_CLAMP = (-100, 100)
CRIT_MULT = 200
M_AMB = 3 # ambush.magnitude:每层伏击附加伤害
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", "伏击": "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):
"""tier = (atk_pct, hp_pct):只统计该天赋档(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]]
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=[])
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))
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"))))
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)) # 必中附加分量(新效应词汇批 2026-09-19)——敌方招式成分,非棋盘来源
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 = os.path.join(os.environ.get("APPDATA", ""), "Godot", "app_userdata",
"DungeonSweeperVerticalSlice", "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)
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(","))
analyze_runs(rows, os.path.basename(path), tier)
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()