游戏内容导表不能只有一道校验:结构、业务与运行时语义如何分权
出库场景
策划把怪物、装备和关卡改进 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,也不要让运行时成为出库工具的依赖。架构评审应追问的不是有几道校验,而是每条规则由哪一层掌握完整事实,以及失败后旧的可运行内容是否仍然完好。