config/是这套设计案的唯一真源。这一页是它的字典:每个字段是什么、什么类型、什么范围,以及一份能在跑仿真之前先把错配置拦下来的 schema 校验脚本。
一、为什么配置需要 schema
配置驱动的好处是「改一个数字,仿真和原型都变」。代价是:改错了也会两边一起错。
比如把 outputGrowthDen 写成 0,运行时就是一个除零;把 counterMultiplierPct 写成 1.5(本该是整数百分比 150),跨语言一致性就没了;把 maxLevel 写成 0,升级循环直接空转。
这些错误在仿真跑起来之前就该被拦住。所以这套书把配置表也当成有 schema 的数据来对待——先校验,再消费。
二、字段字典
resources.json
| 字段 | 类型 | 范围 | 含义 |
|---|---|---|---|
version | int | ≥ 1 | 配置版本 |
resources[].id | string | 非空、唯一 | 资源标识,如 wood |
resources[].name | string | 非空 | 显示名,如「木材」 |
resources[].tier | string | basic / advanced / rare | 资源层级 |
resources[].cap | int | > 0 | 资源上限,结算时截断 |
buildings.json
| 字段 | 类型 | 范围 | 含义 |
|---|---|---|---|
buildings[].id | string | 非空、唯一 | 建筑标识 |
buildings[].resource | string | 必须存在于 resources | 该建筑产出的资源 |
buildings[].maxLevel | int | ≥ 1 | 最高等级 |
buildings[].outputBase | int | > 0 | 1 级时每小时产出 |
buildings[].outputGrowthNum | int | > 0 | 产出增长率分子 |
buildings[].outputGrowthDen | int | > 0 | 产出增长率分母(不可为 0) |
buildings[].upgradeCostBase | object | 值 > 0 | 1→2 级的各资源花费 |
buildings[].upgradeCostGrowthNum | int | > 0 | 成本增长率分子 |
buildings[].upgradeCostGrowthDen | int | > 0 | 成本增长率分母 |
产出公式:output(L) = floor(outputBase × Num^(L-1) / Den^(L-1))。
units.json
| 字段 | 类型 | 范围 | 含义 |
|---|---|---|---|
counter | object | 键值均为单位 id | 克制环:攻击方 id → 被克制的单位 id |
counterMultiplierPct | int | ≥ 100 | 克制时的伤害百分比,如 150 表示 1.5 倍 |
units[].id | string | 非空、唯一 | 兵种标识 |
units[].atk | int | > 0 | 攻击 |
units[].def | int | ≥ 0 | 防御 |
units[].hp | int | > 0 | 单兵生命 |
units[].speed | int | > 0 | 行军速度 |
units[].load | int | ≥ 0 | 负重 |
克制环必须是环。
spearman → cavalry → archer → spearman是一个合法的三元环。校验脚本会检查每个单位的克制目标存在,且不出现自克制。
battle.json
| 字段 | 类型 | 范围 | 含义 |
|---|---|---|---|
rules.defenseFactorPct | int | 0–100 | 防御减伤百分比,50 表示防御值打五折 |
rules.minDamage | int | ≥ 1 | 最小伤害,避免伤害归零 |
rules.maxRounds | int | ≥ 1 | 最大回合数,防止死循环 |
rules.terrainBonusPct | int | ≥ 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。新增配置表遵守同样三条约定:
- 不用浮点系数——增长率写整数比,比例写整数百分比。
- 每个字段有类型与范围,并且分母类字段必须 > 0。
- 跨表引用必须可解析(比如
pricing.json里的礼包内容必须指向存在的资源或道具)。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。