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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

+67
View File
@@ -0,0 +1,67 @@
# Test Infrastructure — Dungeon Sweeper
**Engine**: Godot 4.7.2-stable(GDScript 单语言)
**Test Framework**: 双载体(ADR-0004)——`--headless --script` 逻辑冒烟 + headless 场景探针;gdUnit4 **deferred**(D2 OQ1 关闭形态,正式 CI 期再议)
**CI**: `.github/workflows/tests.yml`(首次 push 到 GitHub 后自动生效;仓库 2026-09-12 尚未初始化 git)
**Setup date**: 2026-09-12
**参考实现**: `game/smoke/`(冒烟 96/96 + 三 UI 探针——正式工程的活样板)
## Directory Layout
```
tests/
unit/ # 隔离单元测试(公式/状态机/纯逻辑)——extends SceneTree + quit(code)
integration/ # 跨系统测试(Core-Core 集成、回放确定性、存档往返)
smoke/ # critical-paths.md——人工 gate 清单(/smoke-check 读取)
evidence/ # 截图与人工签核记录
run_all.gd # CI 冒烟统一入口(套件 manifest 协议,D2 AC-2b)
static_scan.sh # ADR-0004 静态扫描域 S1–S8 可执行子集
```
## Running Tests
```bash
# 逻辑冒烟(双载体之一)——Windows 须用 console 版 exe 取 stdout
godot --headless --script tests/run_all.gd # 统一入口(CI 调用点)
godot --headless --script tests/unit/example_test.gd
# 场景探针(双载体之二)——以探针场景为运行目标,走完整项目引导(autoload 在位)
godot --headless --path . res://tests/probes/<probe>.tscn
# CI 旁必须配外部 wall-clock 超时(ADR-0004:引擎无死锁探测)
timeout 120s godot --headless --script tests/run_all.gd
```
**协议要点**(D2 冒烟协议 + ADR-0004):
- 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)`
- `--script` 模式**不加载 autoload 且不注册全局标识符**(裸引用 `ConfigDB` = parse error)——ConfigDB/RngService 由脚本显式实例化,core 一律构造注入
- 致命类用例独立进程,runner manifest 登记"调用 → 期望退出码"
- 每用例先 `reset_diagnostics()`(诊断收集器协议)
- 期望值由表读值推导,不写死玩法数值
## Test Naming
- **Files**: `[system]_[feature]_test.gd`(领域命名,禁 ID 前缀——docs/code-conventions.md)
- **Functions**: `test_[scenario]_[expected]`
- **Example**: `reward_roll_test.gd` → `test_power_interval_rolls_within_bounds()`
## Story Type → Test Evidence
| Story Type | Required Evidence | Location |
|---|---|---|
| Logic | Automated unit test — must pass | `tests/unit/[system]/` |
| Integration | Integration test OR playtest doc | `tests/integration/[system]/` |
| Visual/Feel | Screenshot + lead sign-off | `tests/evidence/` |
| UI | Manual walkthrough OR interaction test(场景探针为首选) | `tests/evidence/` |
| Config/Data | Smoke check pass | `production/qa/smoke-*.md` |
## Static Scan(提交前双跑:本地 + CI)
`tests/static_scan.sh` 实现 ADR-0004 扫描域 S1–S8 的可执行子集;规则全集与动机见
`docs/architecture/adr-0004-test-carrier-and-ci.md`。新违规案例必须**先补扫描规则再修码**。
## CI
Tests run automatically on every push to `main` and on every pull request.
A failed test suite blocks merging. **gdUnit4 安装说明(deferred,届时启用)**:
AssetLib → GdUnit4 → Install → Project Settings → Plugins 勾选 → 重启编辑器 → 确认 `res://addons/gdunit4/`。
+6
View File
@@ -0,0 +1,6 @@
# tests/evidence/ — 人工测试证据
截图、录屏、人工签核记录。命名:`[日期]-[里程碑]-[检查项].md`。
面向表现/手感的验收(视觉锚三硬约束、格子样张测试、结算屏可读性、呈现义务台账核对)归此处——
自动化探针(场景级)能覆盖的走 `tests/unit` / 探针场景,不能 G-W-T 的走本目录。
+5
View File
@@ -0,0 +1,5 @@
# tests/integration/ — 跨系统测试
承载:Core-Core 集成(真 A3 + 真 B2/B3/B4 容器实例)、回放确定性(同种子步骤流逐行相同——B1 AC-33 形态)、存档/RNG 快照往返(D3 落地后)、A1×A2 双包含端到端。
各 GDD 登记的 deferral 集成用例(以对侧登记为生效条件)随对应系统实现迁入本目录。
+41
View File
@@ -0,0 +1,41 @@
# tests/run_all.gd — CI 冒烟统一入口(ADR-0004 双载体的逻辑冒烟调用点)
# 用法:godot --headless --script tests/run_all.gd (CI 侧包装外部超时:timeout 120s …)
# 协议:套件清单逐条"套件 → 期望退出码"(D2 AC-2b manifest);致命类独立进程。
# 现状:正式 Godot 工程尚未在根目录创建(代码在 game/,
# 其 smoke/smoke_all.gd 96/96 为参考实现)——本入口当前仅验证运行协议自身;
# 正式工程落地后,把套件清单登记进 _suites()。
extends SceneTree
func _init() -> void:
print("[run_all] formal smoke entry — protocol check")
if DisplayServer.get_name() != "headless":
push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)")
var failures := 0
for suite in _suites():
failures += _run_suite(suite)
if failures > 0:
push_error("[run_all] %d 个套件失败" % failures)
quit(1)
return
print("[run_all] all green (%d suites)" % _suites().size())
quit(0)
# 返回 Array of Dictionary:{ "path": String, "expect": int }
# 正式工程落地后在此登记;探针场景(.tscn 载体)由 CI 单独步骤调用,不经本入口。
func _suites() -> Array:
return [
# {"path": "res://tests/unit/example_test.gd", "expect": 0},
# {"path": "res://tests/unit/reward_economy/reward_roll_test.gd", "expect": 0},
# 致命类(独立进程,期望非 0):
# {"path": "res://tests/unit/d2/fixture_missing_table_test.gd", "expect": 1},
]
func _run_suite(suite: Dictionary) -> int:
# SceneTree 脚本内不能直接以 OS.execute 递归调 godot 的成熟形态留待正式工程
# (需解析 godot 二进制路径);当前协议检查阶段只断言清单形态合法。
var path: String = suite.get("path", "")
if path.is_empty():
push_error("[run_all] suite missing path")
return 1
print("[run_all] registered: %s (expect=%d)" % [path, suite.get("expect", 0)])
return 0
+33
View File
@@ -0,0 +1,33 @@
# Smoke Test: Critical Paths — Dungeon Sweeper
**Purpose**: QA 交接前 15 分钟内人工跑完的关键路径清单。
**Run via**: `/smoke-check`(读本文件)。
**Update**: 新核心系统落地后增补条目。
**分工注记**:自动化冒烟(切片 96/96)覆盖规则正确性;本清单面向**表现与手感**(自动化探针不可 G-W-T 的部分)。
## Core Stability(恒跑)
1. 启动到开始界面无崩溃;开始界面响应全部输入
2. 开战:装板后初始展开可见、小人落位出生区、AP 显示 8
## Core Mechanic
3. 挖掘:单格 1 AP、数字亮起与 8 邻雷数一致、连锁免费撕开一片
4. 插旗/拔旗/挪旗:AP 价目正确、连锁止于旗(旗挡链)属正常
5. 移动:已开格瞬移、**已引爆雷格可通行可读**(工程债呈现半边)
6. 停手:RAP 1:1 转存、下轮 AP 硬重置同图继续
7. 轮末结算:明细逐行呈现、来源标注可读(P2——"这一行是我哪步扫雷带来的"可答)
8. 道具使用窗口:授予段后弹出、至多选一件、满血治疗包 usable_now 禁选态正确
9. 清图:清图奖励兑付 + 完美排雷高光(零误标时双份)+ 换新图;未刷新的停手轮不换图
10. 轮内刷新(方案 A):Active 当场换图、AP 保留、RAP 扣减、每轮 ≤2 次、跨图事实结算完整
## Battle & Level
11. 战斗段:主角先手攻击行(hero_attack 来源)、敌方行为环读怪(姿态↔下一拍)、死亡即不动手
12. 胜负判定与 battle_over 清理全序;库存跨战斗延续、关卡末"剩余道具 ×N 清空"反馈
13. 关卡推进:节点链兑付(奖励点/恢复点)、Boss 房(Dmax 行)、层间 HP 携带
## Data & Perf
14. 数值调整板:**打开可见**(覆盖层层级回归——缺陷⑤)、滑条即时生效、批量模拟可跑
15. 无明显卡顿(60fps 目标);连续 5 分钟无内存异常增长;窗口/结算屏文案中文显示正常(CJK 字体核对)
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# tests/static_scan.sh — ADR-0004 静态扫描域 S1–S8 可执行子集
# 规则全集与动机:docs/architecture/adr-0004-test-carrier-and-ci.md §Decision 3
# 正式工程(core/ 存在)时执行;否则优雅跳过(工程在 game/core——2026-09-24 转正结构批后仓库根布局)。
# 纪律:新违规案例必须先补本脚本规则再修码(docs/code-conventions.md 缺陷-规范对照表)。
set -u
cd "$(dirname "$0")/.."
if [ ! -d game/core ]; then
echo "[static_scan] game/core 不存在(工程未创建)——跳过,退出 0"
exit 0
fi
fail=0
# S1/S2 — 玩法代码禁 randomize()/自建 RandomNumberGenerator/全局随机函数(绕开 RngService 的口子)
# 2026-09-24 首次实跑收敛(转正结构批):违规签名=裸 randomize/rand* 调用(实例方法 .randi() 排除)
# + RngService 之外的 RandomNumberGenerator.new();接收服务注入流的类型标注不属违规(core 五文件 15 处实证)
hits=$(grep -rnE '\brandomize[[:space:]]*\(|(^|[^.[:alnum:]_])randi[[:space:]]*\(|(^|[^.[:alnum:]_])randf[[:space:]]*\(|(^|[^.[:alnum:]_])randf_range[[:space:]]*\(' \
game/core game/presentation --include='*.gd' 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' | wc -l)
hits2=$(grep -rn 'RandomNumberGenerator\.new()' game/core game/presentation --include='*.gd' 2>/dev/null | grep -vc rng_service)
if [ "$hits" -gt 0 ] || [ "$hits2" -gt 0 ]; then echo "[static_scan] S1/S2 FAIL: 全局随机/RNG 自建命中 $hits+$hits2 处"; grep -rnE '\brandomize[[:space:]]*\(|(^|[^.[:alnum:]_])randi[[:space:]]*\(|(^|[^.[:alnum:]_])randf[[:space:]]*\(|(^|[^.[:alnum:]_])randf_range[[:space:]]*\(' game/core game/presentation --include='*.gd' 2>/dev/null; grep -rn 'RandomNumberGenerator\.new()' game/core game/presentation --include='*.gd' 2>/dev/null | grep -v rng_service; fail=1; else echo "[static_scan] S1/S2 ok"; fi
# S4 — core/ 禁 extends Control/Node2D、禁 .tscn preload(headless 债)
hits=$(grep -rnE 'extends[[:space:]]+(Control|Node2D)\b|\.tscn' game/core --include='*.gd' 2>/dev/null | wc -l)
if [ "$hits" -gt 0 ]; then echo "[static_scan] S4 FAIL: core 引擎/UI 依赖命中 $hits 处"; grep -rnE 'extends[[:space:]]+(Control|Node2D)\b|\.tscn' game/core --include='*.gd' 2>/dev/null; fail=1; else echo "[static_scan] S4 ok"; fi
# S3 — core/ 禁 ConfigDB 外 data 表加载(schema class_name 直引豁免)
hits=$(grep -rnE '\b(load|preload)[[:space:]]*\(|ResourceLoader\.load' core --include='*.gd' 2>/dev/null \
| grep -v 'res://tests/' | wc -l)
if [ "$hits" -gt 0 ]; then echo "[static_scan] S3 WARNING(人工复核——class_name 直引误报需逐条排除): $hits 处"; grep -rnE '\b(load|preload)[[:space:]]*\(|ResourceLoader\.load' core --include='*.gd' 2>/dev/null; fi
# TODO(正式工程落地后精化为扫描配置并升格为硬失败):
# S5 core/ 禁裸引用 ConfigDB/RngService 全局标识符(--script 模式 parse error;grep 近似误报多,需 AST 级或精确白名单)
# S6 core↔core 信号连接只许方法 Callable(禁 lambda 捕获 self);发射者禁反向引用
# S7 B2/B3/B4 API 符号命中文件 ⊆ B1 文件集(编排唯一性)
# S8 结算路径 await 零命中(B1 结算/编排文件集)
exit $fail
+6
View File
@@ -0,0 +1,6 @@
# tests/unit/ — 隔离单元测试
每系统一个子目录(`reward_economy/`、`board_generator/`…)。协议与命名见 `tests/README.md`。
**活样板**:`example_test.gd`(本目录)——第一个真实测试请以它为模板。
**参考实现**:`game/smoke/smoke_all.gd`(96 条断言的组织方式)。
+37
View File
@@ -0,0 +1,37 @@
# tests/unit/example_test.gd — 示例测试:项目测试协议的活样板(gate-check 硬性要求 ≥1 示例测试)
# 运行:godot --headless --script tests/unit/example_test.gd
# 协议要点(ADR-0004 + D2 冒烟协议):
# extends SceneTree 以获得 quit(code);断言失败 → push_error + quit(1);全绿 → quit(0)
# --script 模式无 autoload 且无全局标识符(裸引用 ConfigDB = parse error)——
# 涉及表值的测试须显式实例化 ConfigDB,期望由表读值推导不写死
# 第一个真实测试:复制本文件为 tests/unit/<system>/<system>_<feature>_test.gd 并替换占位断言
extends SceneTree
func _init() -> void:
var failures := 0
failures += _check("headless SceneTree 协议可用(quit(code) 通道)", _test_headless_protocol())
failures += _check("命名规范可执行:test_[scenario]_[expected]", _test_naming_convention_followed())
if failures > 0:
push_error("[example_test] %d 项失败" % failures)
quit(1)
return
print("[example_test] all green")
quit(0)
func _check(desc: String, ok: bool) -> int:
if not ok:
push_error("[example_test] FAIL: " + desc)
return 1
print("[example_test] PASS: " + desc)
return 0
func _test_headless_protocol() -> bool:
# 占位断言:验证脚本本身在 SceneTree 上下文可执行。
# 真实测试写法示例(引用不执行):
# var cfg := ConfigDBStub.new() # 显式实例化,非全局标识符
# return cfg.action_economy.ap_per_round >= 1
return true
func _test_naming_convention_followed() -> bool:
# 占位断言:本文件函数名均以 test_ 前缀 + 场景_期望 结构(docs/code-conventions.md)
return true
-628
View File
@@ -1,628 +0,0 @@
#!/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())
-99
View File
@@ -1,99 +0,0 @@
# 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` 行族的更多消费面留待需要时扩展。
-37
View File
@@ -1,37 +0,0 @@
== 输入概况 ==
文件 1 个,共 233254 行,解析 1.35s(大文件单文件耗时见下)
playtest_4200_20260916T003953.jsonl: 233254 行 / 1.35s / 600 局 / 掷点面:本日志无掷点字段(AC 批前日志,已降级)
== 掷点面(AC 批验收统计)==
不适用(AC 批前日志):本日志无掷点字段。
其余指标照常输出;落批后重跑本工具,先看【数据质量】的未知字段观测面。
== 关卡级统计 ==
口径:局=run 行切分(无 run 行的旧日志整文件一局);通关=result=victory;
进BossHP=Boss首结算快照HP+首轮承伤−首轮治疗(精确反推);平均轮数=每局settlement数。
关卡 档位 局数 通关率 平均轮数 进BossHP均 通关剩余HP均 整局承伤均
1 normal 300 66.7% 13.0 67.7 45.7 65.1
1 perfect 300 100.0% 11.8 75.0 73.7 8.5
== 战斗级统计(按 关卡/节点类型/深度 分组;对照承伤带 普通≤20%、Boss≤50% max_hp)==
关卡 类型 深度 场数 场均轮数 场均承伤 承伤/max_hp 承伤带 判定
1 battle 0 600 1.8 5.1 7.7% ≤20% ✓
1 battle 1 598 2.2 5.8 8.7% ≤20% ✓
1 battle 2 589 1.7 2.2 3.2% ≤20% ✓
1 battle 3 584 2.5 4.4 6.5% ≤20% ✓
1 boss 4 562 4.7 20.8 29.3% ≤50% ✓
== 轮级统计 ==
总轮数 7449|敌战轮(有过敌人的战斗全部轮数)6988|有攻击落地轮 3749
零承伤轮(窄口径,AC 验收主指标): 1334 / 有攻击落地轮 3749(35.6%)——AC 批落地含'伤害下限 1'后该值应趋零
零掉血轮(宽口径,参考): 4259 / 敌战轮 6988(60.9%)
== 增益战斗期复原(来自 step 行,按结算轮窗口聚合)==
各增益出现总层数: {'失衡': 2821, 'defend_up': 8180, 'weaken': 8103, '暴走': 3287, 'attack_up': 9893, '乏力': 2075, '敌方伏击 +2 层': 2459}
有增益激活的结算数: 7396(每结算层数明细见 --rounds 流水)
快照 buffs 非空: 0/7449(已知盲区:快照在衰减段后发出且状态寿命=1 结算 → 恒空;此行仅作盲区持续暴露)
== 数据质量 ==
解析失败行: 0|未配对 battle_end(延迟发出越局): 0
step 行未知字段: 无(当前日志 = 2026-09-16 schema,无掷点/分量字段)
AC 批落批后:新字段先出现在上面观测面 → 在 FIELD_CANDIDATES 登记真实字段名 → 掷点面自动激活。
-54
View File
@@ -1,54 +0,0 @@
== 输入概况 ==
文件 1 个,共 340 行,解析 0.00s(大文件单文件耗时见下)
playtest_1234_20260916T004132.jsonl: 340 行 / 0.00s / 1 局 / 掷点面:本日志无掷点字段(AC 批前日志,已降级)
== 掷点面(AC 批验收统计)==
不适用(AC 批前日志):本日志无掷点字段。
其余指标照常输出;落批后重跑本工具,先看【数据质量】的未知字段观测面。
== 关卡级统计 ==
口径:局=run 行切分(无 run 行的旧日志整文件一局);通关=result=victory;
进BossHP=Boss首结算快照HP+首轮承伤−首轮治疗(精确反推);平均轮数=每局settlement数。
关卡 档位 局数 通关率 平均轮数 进BossHP均 通关剩余HP均 整局承伤均
1 perfect 1 100.0% 11.0 50.0 50.0 12.0
== 战斗级统计(按 关卡/节点类型/深度 分组;对照承伤带 普通≤20%、Boss≤50% max_hp)==
关卡 类型 深度 场数 场均轮数 场均承伤 承伤/max_hp 承伤带 判定
1 battle 0 1 2.0 0.0 0.0% ≤20% ✓
1 battle 1 1 2.0 0.0 0.0% ≤20% ✓
1 battle 2 1 2.0 0.0 0.0% ≤20% ✓
1 battle 3 1 2.0 8.0 16.0% ≤20% ✓
1 boss 4 1 3.0 4.0 8.0% ≤50% ✓
== 轮级统计 ==
总轮数 11|敌战轮(有过敌人的战斗全部轮数)11|有攻击落地轮 6
零承伤轮(窄口径,AC 验收主指标): 4 / 有攻击落地轮 6(66.7%)——AC 批落地含'伤害下限 1'后该值应趋零
零掉血轮(宽口径,参考): 9 / 敌战轮 11(81.8%)
== 增益战斗期复原(来自 step 行,按结算轮窗口聚合)==
各增益出现总层数: {'attack_up': 18, 'defend_up': 15, 'weaken': 14}
有增益激活的结算数: 11(每结算层数明细见 --rounds 流水)
快照 buffs 非空: 0/11(已知盲区:快照在衰减段后发出且状态寿命=1 结算 → 恒空;此行仅作盲区持续暴露)
== 逐局明细(--per-run)==
文件 种子 档位 结果 轮数 承伤 零承伤轮 进BossHP 终局HP
playtest_1234_20260916T004132.jsonl 1234 perfect victory 11 12 4 50 50
== 逐轮流水(--rounds)==
列: 种子 节点 深度 轮 | 攻击落地 蓄力 承伤 治疗 结算HP | 零承伤 零掉血 | 增益复原 | 出路
1234 battle 0 r1 | 1 0 0 0 50 | Y Y | attack_up×2,defend_up×2,weaken×2 | ap_exhausted
1234 battle 0 r2 | 0 0 0 0 50 | - Y | attack_up×1,defend_up×2,weaken×1 | ap_exhausted
1234 battle 1 r1 | 2 0 0 0 50 | Y Y | attack_up×2,weaken×1 | ap_exhausted
1234 battle 1 r2 | 0 0 0 0 50 | - Y | attack_up×2,defend_up×2 | ap_exhausted
1234 battle 2 r1 | 1 0 0 0 50 | Y Y | attack_up×2,defend_up×1,weaken×2 | ap_exhausted
1234 battle 2 r2 | 0 0 0 0 50 | - Y | attack_up×1,defend_up×1,weaken×3 | ap_exhausted
1234 battle 3 r1 | 1 0 8 0 42 | - - | attack_up×2 | ap_exhausted
1234 battle 3 r2 | 0 0 0 4 46 | - Y | attack_up×2,defend_up×2,weaken×2 | ap_exhausted
1234 boss 4 r1 | 1 0 0 0 50 | Y Y | attack_up×1,defend_up×2,weaken×1 | ap_exhausted
1234 boss 4 r2 | 1 0 4 0 46 | - - | attack_up×2,defend_up×1 | ap_exhausted
1234 boss 4 r3 | 0 0 0 4 50 | - Y | attack_up×1,defend_up×2,weaken×2 | cleared
== 数据质量 ==
解析失败行: 0|未配对 battle_end(延迟发出越局): 0
step 行未知字段: 无(当前日志 = 2026-09-16 schema,无掷点/分量字段)
AC 批落批后:新字段先出现在上面观测面 → 在 FIELD_CANDIDATES 登记真实字段名 → 掷点面自动激活。
-55
View File
@@ -1,55 +0,0 @@
== 输入概况 ==
文件 1 个,共 380 行,解析 0.00s(大文件单文件耗时见下)
playtest_5678_20260916T004134.jsonl: 380 行 / 0.00s / 1 局 / 掷点面:本日志无掷点字段(AC 批前日志,已降级)
== 掷点面(AC 批验收统计)==
不适用(AC 批前日志):本日志无掷点字段。
其余指标照常输出;落批后重跑本工具,先看【数据质量】的未知字段观测面。
== 关卡级统计 ==
口径:局=run 行切分(无 run 行的旧日志整文件一局);通关=result=victory;
进BossHP=Boss首结算快照HP+首轮承伤−首轮治疗(精确反推);平均轮数=每局settlement数。
关卡 档位 局数 通关率 平均轮数 进BossHP均 通关剩余HP均 整局承伤均
1 perfect 1 100.0% 12.0 50.0 50.0 6.0
== 战斗级统计(按 关卡/节点类型/深度 分组;对照承伤带 普通≤20%、Boss≤50% max_hp)==
关卡 类型 深度 场数 场均轮数 场均承伤 承伤/max_hp 承伤带 判定
1 battle 0 1 1.0 0.0 0.0% ≤20% ✓
1 battle 1 1 2.0 0.0 0.0% ≤20% ✓
1 battle 2 1 2.0 1.0 2.0% ≤20% ✓
1 battle 3 1 2.0 1.0 2.0% ≤20% ✓
1 boss 4 1 5.0 4.0 8.0% ≤50% ✓
== 轮级统计 ==
总轮数 12|敌战轮(有过敌人的战斗全部轮数)11|有攻击落地轮 6
零承伤轮(窄口径,AC 验收主指标): 3 / 有攻击落地轮 6(50.0%)——AC 批落地含'伤害下限 1'后该值应趋零
零掉血轮(宽口径,参考): 8 / 敌战轮 11(72.7%)
== 增益战斗期复原(来自 step 行,按结算轮窗口聚合)==
各增益出现总层数: {'weaken': 18, 'defend_up': 12, 'attack_up': 21}
有增益激活的结算数: 12(每结算层数明细见 --rounds 流水)
快照 buffs 非空: 0/12(已知盲区:快照在衰减段后发出且状态寿命=1 结算 → 恒空;此行仅作盲区持续暴露)
== 逐局明细(--per-run)==
文件 种子 档位 结果 轮数 承伤 零承伤轮 进BossHP 终局HP
playtest_5678_20260916T004134.jsonl 5678 perfect victory 12 6 3 50 50
== 逐轮流水(--rounds)==
列: 种子 节点 深度 轮 | 攻击落地 蓄力 承伤 治疗 结算HP | 零承伤 零掉血 | 增益复原 | 出路
5678 battle 0 r1 | 0 0 0 0 50 | - Y | attack_up×1,defend_up×2,weaken×1 | ap_exhausted
5678 battle 1 r1 | 1 0 0 0 50 | Y Y | attack_up×2,defend_up×1,weaken×3 | ap_exhausted
5678 battle 1 r2 | 0 0 0 0 50 | - Y | attack_up×1,defend_up×1,weaken×1 | ap_exhausted
5678 battle 2 r1 | 1 0 1 0 49 | - - | attack_up×2,defend_up×2,weaken×1 | ap_exhausted
5678 battle 2 r2 | 0 0 0 1 50 | - Y | attack_up×2,defend_up×1,weaken×3 | ap_exhausted
5678 battle 3 r1 | 1 0 1 0 49 | - - | attack_up×2,weaken×1 | ap_exhausted
5678 battle 3 r2 | 0 0 0 1 50 | - Y | attack_up×1,defend_up×2,weaken×3 | ap_exhausted
5678 boss 4 r1 | 1 0 0 0 50 | Y Y | attack_up×2,weaken×2 | ap_exhausted
5678 boss 4 r2 | 1 0 4 0 46 | - - | attack_up×2,defend_up×2 | ap_exhausted
5678 boss 4 r3 | 0 1 0 4 50 | - Y | attack_up×2,weaken×1 | ap_exhausted
5678 boss 4 r4 | 1 0 0 0 50 | Y Y | attack_up×2,defend_up×1,weaken×2 | ap_exhausted
5678 boss 4 r5 | 0 0 0 0 50 | - Y | attack_up×2 | ap_exhausted
== 数据质量 ==
解析失败行: 0|未配对 battle_end(延迟发出越局): 0
step 行未知字段: 无(当前日志 = 2026-09-16 schema,无掷点/分量字段)
AC 批落批后:新字段先出现在上面观测面 → 在 FIELD_CANDIDATES 登记真实字段名 → 掷点面自动激活。