#!/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'{esc(row["display_name"])}
')
out.append('{esc(eid)}'
f'{esc(badge)}')
for key in row:
if key == "pool":
continue
out.append(f'
')
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(' ')
out.append('{esc(FIELD_CN.get(key, key))} '
f'{esc(key)}{attr_value(row, key)} '
' ')
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'技能 权重 份额 语义 ')
out.append('{esc(srow["display_name"])} '
f'{esc(sid)}{w} {share} '
f'{skill_semantics(srow)}
本页由外置图鉴工具生成(tools/compendium_gen.py),数据来自 ' 'sim-data/compendium/dump.json(game/data/tables/enemy.tres 与 skills.tres 经 ConfigDB ' '单点读取导出)。只读对账面,非游戏内界面;重复生成直接覆盖。
', f'敌行 {len(enemy_rows)} 行 · 技行 ' f'{len(skill_rows)} 行(counts 计数核对一致)
'] for lv in sorted(levels): rows = levels[lv] out.append(f'level_index={lv}({len(rows)} 行)depth_band={band}