游戏内容导表不能只有一道校验:结构、业务与运行时语义如何分权

出库场景

策划把怪物、装备和关卡改进 Excel 后,文件能导入数据库,并不等于游戏能跑:next 可能指向不存在的关卡,掉率可能越界,前两关经验也可能不足以升级。当前 Godot 项目没有把所有判断塞进一个脚本,而是让导表器、SQLite、Python 业务校验器与引擎烟测分别守住自己能证明的事实。

# tools/content_validate.py · validate
def validate(conn: sqlite3.Connection) -> list:
    conn.row_factory = sqlite3.Row
    errors: list = []
    _validate_categories(conn, errors)
    _validate_rarities(conn, errors)
    _validate_item_definitions(conn, errors)
    _validate_equipment(conn, errors)
    _validate_heroes(conn, errors)
    _validate_monsters(conn, errors)
    _validate_levels(conn, errors)
    _validate_drops(conn, errors)
    return errors

从内容卡片到游戏运行时的三道检查门

三层所有权

第一层拥有表结构:三行表头定义字段、类型和 ref: 外键,SQLite 再执行 DDL 与外键检查。第二层拥有出库业务:装备至少 20 件、关卡至少 5 个、掉率不超过 0.95。第三层拥有运行时语义:它能调用升级曲线并看到内容门面导出的完整快照。两个不变量由此明确:失败构建不得覆盖当前 content.db;依赖运行时 API 的规则不得伪装成纯数据校验。

门禁 唯一事实来源 失败结果
类型、列、外键 Schema 与 SQLite 拒绝构建
槽位、数量、概率、引用链 Python 出库校验 不替换旧库
升级曲线与运行时快照 Godot 内容门面 阻断开发烟测
# scripts/content/content_database.gd · export_content_tables
func export_content_tables() -> Dictionary:
    var tables := _item_catalog.export_tables()
    tables.merge(_world_catalog.export_tables())
    return tables

# scripts/content/content_validator.gd · validate
func validate(content) -> Dictionary:
    var tables: Dictionary = content.export_content_tables()
    var errors: Array[String] = []
    _validate_required_tables(tables, errors)
    if not errors.is_empty():
        return _result(errors)
    _validate_categories(tables, errors)
    _validate_rarities(tables, errors)
    _validate_item_definitions(tables, errors)
    _validate_equipment(tables, errors)
    _validate_world(tables, errors)
    _validate_progression_shape(content, tables, errors)
    return _result(errors)

正常时序

正常路径是“Excel 解析到临时库 → 结构与外键检查 → Python 业务检查 → 原子替换 → Godot 加载后烟测”。运行时不直接理解单元格坐标,导表器也不模拟成长服务;边界让每个错误既靠近修复者,又不会要求引擎启动才能发现一个普通类型错值。

# tools/xlsx_to_db.py · check / run
def check(conn: sqlite3.Connection) -> list:
    errors = []
    fk_failures = conn.execute("PRAGMA foreign_key_check").fetchall()
    for fail in fk_failures:
        errors.append("FOREIGN_KEY_VIOLATION_%s" % str(fail[0]).upper())
    errors.extend(content_validate.validate(conn))
    return errors

def run(xlsx_path: Path, out_path: Path, content_version) -> int:
    tmp_path = out_path.with_suffix(".db.tmp")
    if tmp_path.exists():
        tmp_path.unlink()
    conn = sqlite3.connect(tmp_path)
    try:
        build(conn, xlsx_path, content_version)
        errors = check(conn)
        if errors:
            conn.close()
            tmp_path.unlink()
            return 1
        conn.commit()
    except BuildError:
        if tmp_path.exists():
            tmp_path.unlink()
        return 1
    finally:
        conn.close()
    tmp_path.replace(out_path)
    return 0

这条链选择了重复校验的维护成本,换取编辑期短反馈和运行时最终确认。但重复部分只能是有意的交集,不能宣称两份校验器完全等价:Python 明确略过依赖 get_exp_to_next() 的早期成长检查,Godot 才拥有那段语义。

失败时序

看似合理的替代方案是只保留 Godot 校验器,导表成功后再开引擎检查。它会让坏库先成为产物,也把单元格类型、外键与玩法曲线混成同一种失败。相反,当前测试先向 Excel 写入字符串 abc 和悬空怪物引用,证明它们在出库前被拦截;另一个固定输入只修改内存数据库,不污染项目数据。

# tools/roundtrip_test.py · test_error_detection(节选)
wb = load_workbook(tmp_xlsx)
ws = wb["monsters"]
hp_col = [ws.cell(2, c).value for c in range(
    1, ws.max_column + 1
)].index("max_hp") + 1
ws.cell(cs.HEADER_ROWS + 1, hp_col, "abc")
wb.save(tmp_xlsx)
r1 = run_cmd(["xlsx_to_db.py", "--xlsx", str(tmp_xlsx),
              "--out", str(tmp_db)])

wb = load_workbook(tmp_xlsx)
ws = wb["levels"]
mid_col = [ws.cell(2, c).value for c in range(
    1, ws.max_column + 1
)].index("monster_id") + 1
ws.cell(cs.HEADER_ROWS + 1, mid_col, "ghost_monster")
wb.save(tmp_xlsx)
r2 = run_cmd(["xlsx_to_db.py", "--xlsx", str(tmp_xlsx),
              "--out", str(tmp_db)])

# 当天固定坏样本:仅修改内存副本
src = sqlite3.connect("data/content/content.db")
mem = sqlite3.connect(":memory:")
src.backup(mem)
mem.execute(
    "UPDATE levels SET next_id='missing_level' WHERE id='level_1'"
)
errors = validate(mem)
assert errors == ["LEVEL_NEXT_INVALID_LEVEL_1"]

失败恢复也因此简单:类型或外键失败回到具体单元格;业务失败依据稳定错误码定位规则;运行时失败保留旧库并阻断交付。任何一层都不通过“放宽校验”绕过上一层。

结构、业务与运行时语义的所有权和失败归属

当天复算

当天对 13 张表做 round-trip,随后运行 Godot 烟测,再用固定断链输入验证错误码。这里测的是一致性与失败语义,不是导表性能;耗时只记录可复现的执行事实,不据此声称优化。

cd "$PROJECT_ROOT"
/usr/bin/time -p python3 tools/roundtrip_test.py
/usr/bin/time -p Godot --headless --path . \
  --script scripts/dev/content_validation_smoke_test.gd
/usr/bin/time -p python3 - <<'PY'
import sqlite3
from tools.content_validate import validate
src = sqlite3.connect("data/content/content.db")
mem = sqlite3.connect(":memory:")
src.backup(mem)
mem.execute(
    "UPDATE levels SET next_id='missing_level' WHERE id='level_1'"
)
errors = validate(mem)
print("FIXED_OUTPUT", ",".join(errors))
assert errors == ["LEVEL_NEXT_INVALID_LEVEL_1"]
PY
round-trip: 13 tables matched
invalid type: monsters!E4 expected int, got 'abc'
dangling ref: levels!D4 ghost_monster not in monsters
result: round-trip passed, error interception passed
real 6.70

CONTENT_VALIDATION_SMOKE_OK
real 2.00

FIXED_INPUT level_1.next_id=missing_level
FIXED_OUTPUT LEVEL_NEXT_INVALID_LEVEL_1
FIXED_ASSERT_OK errors=1
real 0.11

exit=0  passed=3  failed=0  skipped=0  process_time=8.81s

演进判断

当前规模下,规则数量有限、单库单进程,Python 与 GDScript 的小段重复仍比引入共享 DSL 更容易审查。触发升级的信号不是“出现重复”本身,而是同一坏样本在两端得到不同错误码、阈值频繁漂移,或内容开始分包并行构建。届时应先建立语言无关的坏样本契约与规则清单,再考虑生成两端校验代码;不要直接把所有规则搬进 SQL,也不要让运行时成为出库工具的依赖。架构评审应追问的不是有几道校验,而是每条规则由哪一层掌握完整事实,以及失败后旧的可运行内容是否仍然完好。