附录 B:配置表字段字典与 schema

bastion 的配置表字段字典与 schema:resources / buildings / units / battle 四张核心表的每个字段含义、类型与取值范围,整数比与整数百分比的表达约定,以及一份可跑的 schema 校验脚本——它能在仿真和原型跑之前,先把配错字段的配置拦下来。

config/ 是这套设计案的唯一真源。这一页是它的字典:每个字段是什么、什么类型、什么范围,以及一份能在跑仿真之前先把错配置拦下来的 schema 校验脚本。

一、为什么配置需要 schema

配置驱动的好处是「改一个数字,仿真和原型都变」。代价是:改错了也会两边一起错。

比如把 outputGrowthDen 写成 0,运行时就是一个除零;把 counterMultiplierPct 写成 1.5(本该是整数百分比 150),跨语言一致性就没了;把 maxLevel 写成 0,升级循环直接空转。

这些错误在仿真跑起来之前就该被拦住。所以这套书把配置表也当成有 schema 的数据来对待——先校验,再消费。

二、字段字典

resources.json

字段类型范围含义
versionint≥ 1配置版本
resources[].idstring非空、唯一资源标识,如 wood
resources[].namestring非空显示名,如「木材」
resources[].tierstringbasic / advanced / rare资源层级
resources[].capint> 0资源上限,结算时截断

buildings.json

字段类型范围含义
buildings[].idstring非空、唯一建筑标识
buildings[].resourcestring必须存在于 resources该建筑产出的资源
buildings[].maxLevelint≥ 1最高等级
buildings[].outputBaseint> 01 级时每小时产出
buildings[].outputGrowthNumint> 0产出增长率分子
buildings[].outputGrowthDenint> 0产出增长率分母(不可为 0)
buildings[].upgradeCostBaseobject值 > 01→2 级的各资源花费
buildings[].upgradeCostGrowthNumint> 0成本增长率分子
buildings[].upgradeCostGrowthDenint> 0成本增长率分母

产出公式:output(L) = floor(outputBase × Num^(L-1) / Den^(L-1))。

units.json

字段类型范围含义
counterobject键值均为单位 id克制环:攻击方 id → 被克制的单位 id
counterMultiplierPctint≥ 100克制时的伤害百分比,如 150 表示 1.5 倍
units[].idstring非空、唯一兵种标识
units[].atkint> 0攻击
units[].defint≥ 0防御
units[].hpint> 0单兵生命
units[].speedint> 0行军速度
units[].loadint≥ 0负重

克制环必须是环。 spearman → cavalry → archer → spearman 是一个合法的三元环。校验脚本会检查每个单位的克制目标存在,且不出现自克制。

battle.json

字段类型范围含义
rules.defenseFactorPctint0–100防御减伤百分比,50 表示防御值打五折
rules.minDamageint≥ 1最小伤害,避免伤害归零
rules.maxRoundsint≥ 1最大回合数,防止死循环
rules.terrainBonusPctint≥ 0地形加成百分比

单兵伤害公式:dmg = max(minDamage, floor((atk × counterPct − def × defenseFactorPct) × (100 + terrainPct) / 100 / 100))。

三、schema 校验脚本

下面这段校验脚本读 config/ 下四张表,逐条检查字段类型、取值范围与跨表引用。它不依赖任何第三方库。

# file: sim/config_schema.py
# tier: S
# verify: python3 sim/config_schema.py --config config/
"""config/ 的 schema 校验:字段类型、取值范围、跨表引用。

在仿真与原型消费配置之前先跑它,把配错字段的配置拦下来。
"""

from __future__ import annotations

import argparse
import json
import os

TIERS = {"basic", "advanced", "rare"}


def load(cfg_dir: str, name: str) -> dict:
    with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def is_pos_int(v) -> bool:
    return isinstance(v, int) and not isinstance(v, bool) and v > 0


def is_nonneg_int(v) -> bool:
    return isinstance(v, int) and not isinstance(v, bool) and v >= 0


def check_resources(cfg: dict, errs: list[str]) -> set[str]:
    ids = set()
    for r in cfg.get("resources", []):
        rid = r.get("id")
        if not rid or not isinstance(rid, str):
            errs.append("resources: 缺 id 或 id 非字符串")
            continue
        if rid in ids:
            errs.append(f"resources: id 重复 {rid}")
        ids.add(rid)
        if not is_pos_int(r.get("cap")):
            errs.append(f"resources[{rid}]: cap 必须为正整数")
        if r.get("tier") not in TIERS:
            errs.append(f"resources[{rid}]: tier 非法 {r.get('tier')!r}")
    return ids


def check_buildings(cfg: dict, resource_ids: set[str], errs: list[str]) -> None:
    ids = set()
    for b in cfg.get("buildings", []):
        bid = b.get("id")
        if not bid:
            errs.append("buildings: 缺 id")
            continue
        if bid in ids:
            errs.append(f"buildings: id 重复 {bid}")
        ids.add(bid)
        if b.get("resource") not in resource_ids:
            errs.append(f"buildings[{bid}]: resource {b.get('resource')!r} 不在 resources 中")
        if not is_pos_int(b.get("maxLevel")):
            errs.append(f"buildings[{bid}]: maxLevel 必须为正整数")
        if not is_pos_int(b.get("outputBase")):
            errs.append(f"buildings[{bid}]: outputBase 必须为正整数")
        # 分母不可为 0 —— 这是最常见的除零来源
        if not is_pos_int(b.get("outputGrowthDen")):
            errs.append(f"buildings[{bid}]: outputGrowthDen 必须为正整数(不可为 0)")
        if not is_pos_int(b.get("outputGrowthNum")):
            errs.append(f"buildings[{bid}]: outputGrowthNum 必须为正整数")
        if not is_pos_int(b.get("upgradeCostGrowthDen")):
            errs.append(f"buildings[{bid}]: upgradeCostGrowthDen 必须为正整数")
        base = b.get("upgradeCostBase")
        if not isinstance(base, dict) or not base:
            errs.append(f"buildings[{bid}]: upgradeCostBase 必须为非空对象")
        else:
            for k, v in base.items():
                if k not in resource_ids:
                    errs.append(f"buildings[{bid}]: upgradeCostBase 的资源 {k!r} 不存在")
                if not is_pos_int(v):
                    errs.append(f"buildings[{bid}]: upgradeCostBase[{k}] 必须为正整数")


def check_units(cfg: dict, errs: list[str]) -> None:
    ids = {u.get("id") for u in cfg.get("units", [])}
    counter = cfg.get("counter", {})
    if not isinstance(counter, dict) or not counter:
        errs.append("units: counter 必须为非空对象")
    for src, dst in (counter or {}).items():
        if src not in ids:
            errs.append(f"units.counter: 攻击方 {src!r} 不在 units 中")
        if dst not in ids:
            errs.append(f"units.counter: 被克方 {dst!r} 不在 units 中")
        if src == dst:
            errs.append(f"units.counter: {src} 克制自己")
    if not is_pos_int(cfg.get("counterMultiplierPct")):
        errs.append("units: counterMultiplierPct 必须为正整数(用 150 而非 1.5)")
    elif cfg["counterMultiplierPct"] < 100:
        errs.append("units: counterMultiplierPct 不应小于 100(克制不该降低伤害)")
    seen = set()
    for u in cfg.get("units", []):
        uid = u.get("id")
        if uid in seen:
            errs.append(f"units: id 重复 {uid}")
        seen.add(uid)
        for f in ("atk", "hp", "speed"):
            if not is_pos_int(u.get(f)):
                errs.append(f"units[{uid}]: {f} 必须为正整数")
        if not is_nonneg_int(u.get("def")):
            errs.append(f"units[{uid}]: def 必须为非负整数")
        if not is_nonneg_int(u.get("load")):
            errs.append(f"units[{uid}]: load 必须为非负整数")


def check_battle(cfg: dict, errs: list[str]) -> None:
    r = cfg.get("rules")
    if not isinstance(r, dict):
        errs.append("battle: 缺 rules 对象")
        return
    pct = r.get("defenseFactorPct")
    if not is_nonneg_int(pct) or pct > 100:
        errs.append("battle: defenseFactorPct 必须为 0–100 的整数")
    if not is_pos_int(r.get("minDamage")):
        errs.append("battle: minDamage 必须为正整数")
    if not is_pos_int(r.get("maxRounds")):
        errs.append("battle: maxRounds 必须为正整数")
    if not is_nonneg_int(r.get("terrainBonusPct")):
        errs.append("battle: terrainBonusPct 必须为非负整数")


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config")
    args = ap.parse_args()

    errs: list[str] = []
    resource_ids = check_resources(load(args.config, "resources"), errs)
    check_buildings(load(args.config, "buildings"), resource_ids, errs)
    check_units(load(args.config, "units"), errs)
    check_battle(load(args.config, "battle"), errs)

    if errs:
        for e in errs:
            print(f"FAIL {e}")
        print(f"== schema: {len(errs)} 处不合法")
        return 1
    print("== schema: OK(resources / buildings / units / battle 全部合法)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑输出:

== schema: OK(resources / buildings / units / battle 全部合法)

四、一个反例

把 buildings.json 里伐木场的 outputGrowthDen 改成 0,再跑校验:

FAIL buildings[lumberyard]: outputGrowthDen 必须为正整数(不可为 0)
== schema: 1 处不合法

如果没这道校验,这个错误会一路跑到仿真里,变成 ZeroDivisionError,然后你才回头找是哪张表哪个字段。把校验前置,是配置驱动设计能长期维护下去的前提。

五、扩展约定

卷二会往 config/ 里加 season.json 与 pricing.json。新增配置表遵守同样三条约定:

  1. 不用浮点系数——增长率写整数比,比例写整数百分比。
  2. 每个字段有类型与范围,并且分母类字段必须 > 0。
  3. 跨表引用必须可解析(比如 pricing.json 里的礼包内容必须指向存在的资源或道具)。

复现环境与全部实跑命令见附录 A 。回到目录页 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引