合并(工程批·图鉴外置工具·用户令): compendium_dump经ConfigDB导出enemy+skills全行JSON(out/tables_root参数化)+compendium_gen生成自包含HTML图鉴(按关按带分组·池份额·技能语义); 对账三件+悬空引用fail-fast非零退出+件核直跑复验(28敌卡·退出码核)过; 结构审计PASS
ci / gates (push) Successful in 8m1s

This commit is contained in:
DKChan committed 2026-11-04 02:03:26 +08:00
commit 70424e0d06
3 files changed
+357

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
## 怪物图鉴外置工具·采集端:经 ConfigDB 单点读 enemy + skills 两表全行,行数据原样导出 JSON
## 到 sim-data/compendium/dump.json(渲染端 tools/compendium_gen.py 消费,生成自包含 HTML 图鉴)。
## 运行:Godot_console.exe --headless --path . --script res://sim/compendium_dump.gd -- [out=<绝对路径>] [tables_root=<res:// 路径>]
## out 缺省 = 仓库根 sim-data/compendium/dump.json(res:// 的上一级);目录不存在则创建。
## tables_root 缺省 = res://data/tables;fail-fast 路径验证时可指向临时表目录。
## 导出形态:顶层 counts 带两表行数计数 + enemy_rows/skill_rows 行字典原样(零新增字段、零改名;
## 表内可省略字段缺席即缺席)。本件零随机、零数值硬编码、零判定逻辑。
## fail-fast:表加载失败(缺表/行域校验/跨表悬空引用)、行缺关键字段、pool.skill_id 在技能表无对应行
## → 逐条报错并以退出码 1 中止,禁软回退。
extends SceneTree
const DEFAULT_OUT := "../sim-data/compendium/dump.json" # 相对 res://(game/ 上一级 = 仓库根)
const DEFAULT_TABLES_ROOT := "res://data/tables"
# 行关键字段(缺一即 fail-fast):取两表 schema 加载期必在的键——
# enemy 的 unit_count_max/atk_min/atk_max/attack_type/armor_type/armor_class 与技能行的
# trigger/buff_ref/buff_duration/damage_mult/rider_mult 均为 schema 可省略字段,不列入。
const ENEMY_REQUIRED_KEYS := ["enemy_id", "display_name", "row_kind", "level_index", "depth_band", "unit_count", "hp", "atk", "pool"]
const SKILL_REQUIRED_KEYS := ["skill_id", "display_name", "type", "cooldown_beats"]
func _initialize() -> void:
var opt := _parse_args(OS.get_cmdline_user_args())
var db = load("res://core/config_db.gd").new()
if not db.load_tables(String(opt.tables_root), false):
_print_diags(db)
push_error("compendium_dump: 表加载失败(fail-fast)")
quit(1)
return
var enemy_rows: Array = db.combat.enemy.rows
var skill_rows: Array = db.combat.skill.rows
var errs := _validate_rows(enemy_rows, skill_rows)
if not errs.is_empty():
for e in errs:
push_error("compendium_dump: %s" % e)
quit(1)
return
var payload := {
"counts": {"enemy_rows": enemy_rows.size(), "skill_rows": skill_rows.size()},
"enemy_rows": enemy_rows,
"skill_rows": skill_rows,
}
var out_path := String(opt.out)
var dir := out_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir):
var mk := DirAccess.make_dir_recursive_absolute(dir)
if mk != OK:
push_error("compendium_dump: 目录创建失败 %s(err=%d)" % [dir, mk])
quit(1)
return
var f := FileAccess.open(out_path, FileAccess.WRITE)
if f == null:
push_error("compendium_dump: 导出文件打开失败 %s(err=%d)" % [out_path, FileAccess.get_open_error()])
quit(1)
return
f.store_string(JSON.stringify(payload))
f.close()
print("图鉴导出完成: enemy_rows=%d skill_rows=%d → %s" % [enemy_rows.size(), skill_rows.size(), out_path])
quit(0)
func _parse_args(args: PackedStringArray) -> Dictionary:
var opt := {"out": "", "tables_root": DEFAULT_TABLES_ROOT}
for a in args:
var kv := String(a).split("=", true, 1)
if kv.size() != 2:
continue
if kv[0] == "out":
opt["out"] = String(kv[1])
elif kv[0] == "tables_root":
opt["tables_root"] = String(kv[1])
if String(opt.out).is_empty():
opt["out"] = ProjectSettings.globalize_path("res://").path_join(DEFAULT_OUT)
return opt
# 行面 fail-fast 校验:关键字段在位 + pool.skill_id 引用闭合(技能表行集先建索引再对账)
func _validate_rows(enemy_rows: Array, skill_rows: Array) -> Array:
var errs: Array = []
var skill_ids := {}
for row_v in skill_rows:
var row: Dictionary = row_v
var sid := String(row.get("skill_id", ""))
if sid.is_empty():
errs.append("技能行缺 skill_id")
for k in SKILL_REQUIRED_KEYS:
if not row.has(k):
errs.append("技能行 %s 缺关键字段: %s" % [sid, k])
skill_ids[sid] = true
for row_v in enemy_rows:
var row: Dictionary = row_v
var eid := String(row.get("enemy_id", ""))
if eid.is_empty():
errs.append("敌行缺 enemy_id")
for k in ENEMY_REQUIRED_KEYS:
if not row.has(k):
errs.append("敌行 %s 缺关键字段: %s" % [eid, k])
for entry_v in row.get("pool", []):
var entry: Dictionary = entry_v
var sid := String(entry.get("skill_id", ""))
if not skill_ids.has(sid):
errs.append("敌行 %s pool 引用不存在的 skill_id: %s(悬空致命)" % [eid, sid])
return errs
func _print_diags(db) -> void:
for d in db.diagnostics():
print("[ConfigDB/%s] %s: %s" % [d.category, d.severity, d.message])
+1
View File
@@ -0,0 +1 @@
uid://bnbug3wg477av
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""怪物图鉴外置工具·渲染端——读 dump.json,生成自包含 index.html(零判定逻辑,只做组织与呈现)。
数据源 = game/sim/compendium_dump.gd 的导出件(enemy + skills 两表行数据原样 + 两表行数计数)。
组织:按 level_index 分关、关内按 depth_band 分组、组内保持表内行序;每行一张敌种卡——
显示名 / enemy_id / row_kind / 全部属性行值 / 池构成(技能显示名 + 权重 + 份额)+ 每技能语义
(类型 / 倍率 / 必中附加分量 / 冷却 / 触发条件 / 增益引用与持续)。
自包含:纯 python 标准库、内容全部内嵌标记、无外链无网络请求无构建工具链,双击可开;
同路径重复生成直接覆盖。字段名保留英文原文,值尽量用表内中文 display_name 与枚举中文名。
用法:
python tools/compendium_gen.py # 缺省 dump = sim-data/compendium/dump.json,输出同目录 index.html
python tools/compendium_gen.py --dump <路径> --out <路径>
fail-fast:dump 缺失 / JSON 损坏 / counts 与实际行数不符 / 行缺展示必需字段 / 池引用悬空
→ 报错退出码 1,禁软回退。
"""
import argparse
import json
import sys
from html import escape
from pathlib import Path
from typing import NoReturn
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DUMP = ROOT / "sim-data" / "compendium" / "dump.json"
DEFAULT_OUT = ROOT / "sim-data" / "compendium" / "index.html"
# 展示必需字段(dump 采集端 fail-fast 契约的对应面;缺一即死)
ENEMY_REQUIRED = ("enemy_id", "display_name", "row_kind", "level_index", "depth_band")
SKILL_REQUIRED = ("skill_id", "display_name", "type", "cooldown_beats")
# 以下中文名均为表 schema 封闭枚举的展示词(enemy_table.gd / skill_table.gd),非玩法数值
LEVEL_CN = {1: "第一关", 2: "第二关", 3: "第三关"}
ROW_KIND_CN = {"battle": "战斗行", "boss": "Boss行"}
ATTACK_TYPE_CN = {"normal": "普通", "pierce": "穿刺", "magic": "魔法"}
ARMOR_TYPE_CN = {"light": "轻甲", "ward": "魔法护甲", "heavy": "重甲"}
SKILL_TYPE_CN = {"basic": "普攻", "damage": "伤害", "buff": "增益", "debuff": "妨害"}
TRIGGER_FACT_CN = {
"self_hp_pct": "自身血量%", "hero_hp_pct": "主角血量%", "round_index": "回合序号",
"last_round_end": "上轮结束", "mines_hit_last": "上轮踩雷数", "mines_hit_total": "累计踩雷数",
}
TRIGGER_OP_CN = {"eq": "==", "neq": "≠", "gt": ">", "gte": "≥", "lt": "<", "lte": "≤"}
FIELD_CN = {
"level_index": "关卡", "row_kind": "行类", "depth_band": "深度带", "enemy_id": "敌种ID",
"display_name": "显示名", "unit_count": "单位数", "unit_count_max": "单位数上限",
"hp": "生命", "atk": "攻击基线", "atk_min": "攻击下限", "atk_max": "攻击上限",
"attack_type": "攻击类型", "armor_type": "护甲类型", "armor_class": "护甲等级",
"skill_id": "技能ID", "type": "类型", "damage_mult": "伤害倍率", "rider_mult": "必中附加分量",
"cooldown_beats": "冷却拍数", "trigger": "触发条件", "buff_ref": "增益引用", "buff_duration": "增益持续",
}
def die(msg: str) -> NoReturn:
print(f"[compendium_gen] FAIL:{msg}", file=sys.stderr)
sys.exit(1)
def esc(v) -> str:
return escape(str(v), quote=True)
def enum_cn(table: dict, key: str, cn_map: dict) -> str:
"""枚举值 → 「中文名 (raw)」;未知枚举原样展示(fail-fast 只拦必需字段,展示层不发明)"""
raw = table.get(key, "")
cn = cn_map.get(raw)
return f"{cn} ({raw})" if cn is not None else str(raw)
def level_cn(level_index) -> str:
try:
n = int(level_index)
except (TypeError, ValueError):
return esc(level_index)
return LEVEL_CN.get(n, f"第{n}关")
def trigger_desc(trig) -> str:
if not isinstance(trig, dict):
die(f"trigger 字段须为字典,实际: {trig!r}")
fact = trig.get("fact", "")
op = trig.get("op", "")
parts = [f"{TRIGGER_FACT_CN.get(fact, fact)} {TRIGGER_OP_CN.get(op, op)} {trig.get('value', '')}".strip()]
if trig.get("latch"):
parts.append("一次性(latch)")
return " · ".join(parts)
def skill_semantics(srow: dict) -> str:
bits = [enum_cn(srow, "type", SKILL_TYPE_CN)]
if "damage_mult" in srow:
bits.append(f"倍率 ×{srow['damage_mult']}")
if "rider_mult" in srow:
bits.append(f"必中附加 ×{srow['rider_mult']}")
bits.append(f"冷却 {srow.get('cooldown_beats', '?')} 拍")
if "trigger" in srow:
bits.append(f"触发: {trigger_desc(srow['trigger'])}")
if "buff_ref" in srow:
dur = f" · 持续 {srow['buff_duration']}" if "buff_duration" in srow else ""
bits.append(f"增益引用 {srow['buff_ref']}{dur}")
return " · ".join(bits)
def attr_value(row: dict, key: str) -> str:
if key == "row_kind":
return enum_cn(row, key, ROW_KIND_CN)
if key == "attack_type":
return enum_cn(row, key, ATTACK_TYPE_CN)
if key == "armor_type":
return enum_cn(row, key, ARMOR_TYPE_CN)
return esc(row[key])
def render_enemy_card(row: dict, skill_by_id: dict) -> str:
eid = row["enemy_id"]
badge = ROW_KIND_CN.get(row["row_kind"], row["row_kind"])
out = [f'<section class="enemy" data-enemy-row="{esc(eid)}">']
out.append(f'<h4>{esc(row["display_name"])} <code>{esc(eid)}</code>'
f'<span class="badge">{esc(badge)}</span></h4>')
out.append('<table class="attrs"><tbody>')
for key in row:
if key == "pool":
continue
out.append(f'<tr><th>{esc(FIELD_CN.get(key, key))} <code>{esc(key)}</code></th>'
f'<td>{attr_value(row, key)}</td></tr>')
out.append('</tbody></table>')
pool = row.get("pool")
if not isinstance(pool, list) or not pool:
die(f"敌行 {eid} pool 缺失或非数组(dump 采集端契约破坏)")
weights = []
for entry in pool:
if not isinstance(entry, dict) or "skill_id" not in entry or "weight" not in entry:
die(f"敌行 {eid} pool 条目缺 skill_id/weight: {entry!r}")
weights.append(int(entry["weight"]))
total = sum(weights)
if total <= 0:
die(f"敌行 {eid} pool 权重合计非正: {total}")
out.append('<table class="pool"><thead><tr>'
'<th>技能</th><th>权重</th><th>份额</th><th>语义</th></tr></thead><tbody>')
for entry, w in zip(pool, weights):
sid = str(entry["skill_id"])
if sid not in skill_by_id:
die(f"敌行 {eid} pool 引用不存在的 skill_id: {sid}(悬空致命)")
srow = skill_by_id[sid]
share = f"{w / total * 100:.1f}%"
out.append(f'<tr><td>{esc(srow["display_name"])} <code>{esc(sid)}</code></td>'
f'<td>{w}</td><td>{share}</td>'
f'<td class="sem">{skill_semantics(srow)}</td></tr>')
out.append('</tbody></table></section>')
return "\n".join(out)
CSS = """\
body{font-family:"Microsoft YaHei",system-ui,sans-serif;margin:2em auto;max-width:60em;
padding:0 1em;color:#222;background:#faf8f4;line-height:1.5}
h1{border-bottom:3px solid #6b5a3e;padding-bottom:.2em}
h2{margin-top:1.6em;color:#4a3b26}
h3{margin:1.2em 0 .4em;color:#5a4a30}
section.enemy{border:1px solid #c9bda5;border-radius:6px;padding:.6em 1em;margin:.8em 0;background:#fff}
section.enemy h4{margin:.2em 0 .5em}
code{background:#efe9dc;border-radius:3px;padding:0 .3em;font-size:.9em}
.badge{float:right;background:#6b5a3e;color:#fff;border-radius:3px;padding:.05em .5em;font-size:.8em}
table{border-collapse:collapse;width:100%;margin:.3em 0}
th,td{border:1px solid #d8cdb8;padding:.25em .6em;text-align:left;vertical-align:top}
th{background:#f1ebdd;white-space:nowrap;font-weight:600}
table.pool th{background:#e7dfd0}
td.sem{font-size:.92em}
p.note{color:#6a5c44;font-size:.92em}
.counts{background:#efe9dc;border-radius:4px;padding:.3em .8em;display:inline-block}
"""
def build_html(dump: dict) -> str:
counts = dump.get("counts")
enemy_rows = dump.get("enemy_rows")
skill_rows = dump.get("skill_rows")
if not isinstance(counts, dict) or not isinstance(enemy_rows, list) or not isinstance(skill_rows, list):
die("dump.json 顶层缺 counts/enemy_rows/skill_rows(结构不符)")
if counts.get("enemy_rows") != len(enemy_rows) or counts.get("skill_rows") != len(skill_rows):
die(f"counts 与行数不符: counts={counts} 实际 enemy={len(enemy_rows)} skill={len(skill_rows)}")
skill_by_id = {}
for r in skill_rows:
if not isinstance(r, dict):
die(f"技能行非字典: {r!r}")
for k in SKILL_REQUIRED:
if k not in r:
die(f"技能行缺必需字段 {k}: {r!r}")
skill_by_id[str(r["skill_id"])] = r
for r in enemy_rows:
if not isinstance(r, dict):
die(f"敌行非字典: {r!r}")
for k in ENEMY_REQUIRED:
if k not in r:
die(f"敌行缺必需字段 {k}: {r!r}")
levels = {}
for r in enemy_rows:
levels.setdefault(int(r["level_index"]), []).append(r)
out = ['<!DOCTYPE html>', '<html lang="zh-CN">', '<head>',
'<meta charset="utf-8">', '<title>怪物图鉴</title>',
f'<style>{CSS}</style>', '</head>', '<body>',
'<h1>怪物图鉴</h1>',
'<p class="note">本页由外置图鉴工具生成(tools/compendium_gen.py),数据来自 '
'sim-data/compendium/dump.json(game/data/tables/enemy.tres 与 skills.tres 经 ConfigDB '
'单点读取导出)。只读对账面,非游戏内界面;重复生成直接覆盖。</p>',
f'<p class="counts">敌行 <strong>{len(enemy_rows)}</strong> 行 · 技行 '
f'<strong>{len(skill_rows)}</strong> 行(counts 计数核对一致)</p>']
for lv in sorted(levels):
rows = levels[lv]
out.append(f'<h2>{level_cn(lv)} <code>level_index={lv}</code>({len(rows)} 行)</h2>')
bands = {}
for r in rows:
bands.setdefault(int(r["depth_band"]), []).append(r)
for band in sorted(bands):
out.append(f'<h3>深度带 {band} <code>depth_band={band}</code></h3>')
for r in bands[band]:
out.append(render_enemy_card(r, skill_by_id))
out.extend(['</body>', '</html>'])
return "\n".join(out)
def main() -> int:
ap = argparse.ArgumentParser(description="怪物图鉴渲染端:dump.json → 自包含 index.html")
ap.add_argument("--dump", default=str(DEFAULT_DUMP), help="采集端导出 JSON 路径")
ap.add_argument("--out", default=str(DEFAULT_OUT), help="输出 HTML 路径")
args = ap.parse_args()
dump_path, out_path = Path(args.dump), Path(args.out)
if not dump_path.is_file():
die(f"dump.json 不存在: {dump_path}")
try:
dump = json.loads(dump_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
die(f"dump.json 读取/解析失败: {e}")
html = build_html(dump)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(html, encoding="utf-8")
n_enemy = len(dump["enemy_rows"])
n_skill = len(dump["skill_rows"])
print(f"[compendium_gen] 完成: 敌行 {n_enemy} · 技行 {n_skill} → {out_path}")
return 0
if __name__ == "__main__":
sys.exit(main())