- 新表 forge.tres(schema forge_table.gd):default_attack_type/armor_type 默认值(契约 2 所有权移交——hero.tres 两轴随后批删除)+routes 六行(sword/spear/staff/light/heavy/ward) - provisional 沿需求卡片 T1 锚表:cap 5/每级增量 +1/基础价 20×150%^n(20/30/45/67/101)/解锁价 200/甲型基础 AC 10-12-9/初始持有 sword+light/并联双通道前置 spear·staff←sword、heavy·ward←light - schema validate 22 类正反(行 id 唯一/类型轴分族枚举/恰六行/初始持有恰一武器一甲/默认轴=初始路线类型轴/前置类型相符…) - ConfigDB:TABLE_MANIFEST 登 forge+类型化持用+forge_route/forge_routes/forge_default_*/forge_weapon_route_by_type/forge_armor_route_by_type 访问器 - 新单测 forge_table_test 41 断言(V1 生产面+22 类负向/V11 行字段闭集+效果类型词表 31 钉现/V2 价格梯);run_all 22→23 套 - 事实订正(L2):卡片 F11「效果类型闭集 24」系 2026-10-07 旧快照——现行 31(iter20 +7/iter22 +1),本批零扩签钉 31 - 轻闸:冒烟 898/0 零扰动
201 lines
11 KiB
GDScript
201 lines
11 KiB
GDScript
# tests/unit/data_config/forge_table_test.gd — 锻造工坊配置表单测(迭代 23 T1)
|
||
# 覆盖(需求卡片 §7.2):V1 六路线 schema 零违例(id 唯一/类型轴枚举/值域——出域行逐类构造各一行全部被拒)
|
||
# + V11 满级特效留空(行字段闭集=无特效参数位 + 效果类型闭集 24 种零新增)+ 几何价格梯 20/30/45/67/101。
|
||
# 运行:godot --headless --path game --script res://tests/unit/data_config/forge_table_test.gd(expect 0)
|
||
# 协议(ADR-0004 + tests/README):extends SceneTree + quit(code);负向用例资源直构不碰生产表(enemy_table 先例)。
|
||
extends SceneTree
|
||
|
||
const ConfigDBScript := preload("res://core/config_db.gd")
|
||
const ForgeTableScript := preload("res://data/schemas/forge_table.gd")
|
||
const EffectEngineScript := preload("res://core/effect_engine.gd")
|
||
const Kit := preload("res://tests/unit/test_kit.gd")
|
||
|
||
|
||
func _init() -> void:
|
||
var db = ConfigDBScript.new()
|
||
var load_ok: bool = db.load_tables("res://data/tables", true)
|
||
Kit.check("生产 forge.tres 随全表加载 + validate 零违例〔锻造工坊批 V1〕", load_ok and db.diagnostics_count("error") == 0)
|
||
if load_ok:
|
||
_test_production_rows(db)
|
||
_test_price_ladder(db)
|
||
_test_negative_cases()
|
||
_test_effect_kinds_closed()
|
||
quit(Kit.finish("forge_table_test"))
|
||
|
||
|
||
# ---- 生产表六行正向(V1 生产面 + V11 行字段闭集)----
|
||
|
||
func _test_production_rows(db) -> void:
|
||
var rows: Array = db.forge_routes()
|
||
Kit.check("六路线行族规模恰 6(三武器+三甲)〔锻造工坊批 V1〕", rows.size() == 6)
|
||
var ids := []
|
||
for row in rows:
|
||
ids.append(String(row.get("route_id", "")))
|
||
ids.sort()
|
||
Kit.check("路线 id 全集 = {sword, spear, staff, light, heavy, ward}〔锻造工坊批 V1〕",
|
||
ids == ["heavy", "light", "spear", "staff", "sword", "ward"])
|
||
# V11:行字段闭集——键集恰 12 项,无特效参数位(满级特效本期不做,契约 7)
|
||
var expect_keys := ForgeTableScript.ROW_KEYS.duplicate()
|
||
expect_keys.sort()
|
||
var keys_closed := true
|
||
for row in rows:
|
||
var ks := (row as Dictionary).keys()
|
||
ks.sort()
|
||
if ks != expect_keys:
|
||
keys_closed = false
|
||
Kit.check("六行字段键集恰 = ROW_KEYS 闭集(无特效参数位)〔锻造工坊批 V11〕", keys_closed)
|
||
# 初始持有恰长剑+轻甲(语义继承自已退役属性锻造行)
|
||
var initial := []
|
||
for row in rows:
|
||
if bool(row.get("initial_owned", false)):
|
||
initial.append(String(row.get("route_id", "")))
|
||
initial.sort()
|
||
Kit.check("初始持有恰 sword+light〔锻造工坊批 V1〕", initial == ["light", "sword"])
|
||
# 默认两轴(契约 2 所有权移交——裸体态 normal/light)
|
||
Kit.check("默认攻击类型轴 = normal(常驻持有)〔锻造工坊批 V1〕", db.forge_default_attack_type() == "normal")
|
||
Kit.check("默认甲型轴 = light(常驻持有)〔锻造工坊批 V1〕", db.forge_default_armor_type() == "light")
|
||
# 类型轴分族与并联双通道前置(4b-1 裁)
|
||
Kit.check("长枪/法杖 ← 长剑练满(并联双通道①)〔锻造工坊批 V1〕",
|
||
String(db.forge_route("spear").get("prereq_route", "")) == "sword"
|
||
and String(db.forge_route("staff").get("prereq_route", "")) == "sword")
|
||
Kit.check("重甲/魔法护甲 ← 轻甲练满(并联双通道①)〔锻造工坊批 V1〕",
|
||
String(db.forge_route("heavy").get("prereq_route", "")) == "light"
|
||
and String(db.forge_route("ward").get("prereq_route", "")) == "light")
|
||
Kit.check("四待解锁路线一次性解锁价 = 200〔锻造工坊批 V1〕",
|
||
int(db.forge_route("spear").get("unlock_price", 0)) == 200
|
||
and int(db.forge_route("staff").get("unlock_price", 0)) == 200
|
||
and int(db.forge_route("heavy").get("unlock_price", 0)) == 200
|
||
and int(db.forge_route("ward").get("unlock_price", 0)) == 200)
|
||
# 甲型基础 AC(provisional 现役结构值——settlement.tres 矩阵同行)
|
||
Kit.check("甲型基础 AC = 轻 10 / 重 12 / 魔法护甲 9〔锻造工坊批 V1〕",
|
||
int(db.forge_route("light").get("base_ac", 0)) == 10
|
||
and int(db.forge_route("heavy").get("base_ac", 0)) == 12
|
||
and int(db.forge_route("ward").get("base_ac", 0)) == 9)
|
||
# 按类型轴反查(甲路线 id=甲型同值的显式化入口)
|
||
Kit.check("forge_armor_route_by_type(ward) = 魔法护甲锻造〔锻造工坊批 V1〕",
|
||
String(db.forge_armor_route_by_type("ward").get("route_id", "")) == "ward")
|
||
Kit.check("forge_weapon_route_by_type(pierce) = 长枪锻造〔锻造工坊批 V1〕",
|
||
String(db.forge_weapon_route_by_type("pierce").get("route_id", "")) == "spear")
|
||
# 缺行/未知轴 = 空字典合法态
|
||
Kit.check("未知 route_id / 未知类型轴 = 空字典〔锻造工坊批 V1〕",
|
||
db.forge_route("blade").is_empty() and db.forge_armor_route_by_type("plate").is_empty())
|
||
|
||
|
||
# ---- 几何价格梯(V2 价格面单测半边——购买写在 T3 冒烟)----
|
||
|
||
func _test_price_ladder(db) -> void:
|
||
var row: Dictionary = db.forge_route("sword")
|
||
var prices := []
|
||
for owned in range(5):
|
||
prices.append(ForgeTableScript.level_price(row, owned))
|
||
Kit.check("长剑各级价格 = 20/30/45/67/101(基础 20×150%^已购级数)〔锻造工坊批 V2〕",
|
||
prices == [20, 30, 45, 67, 101])
|
||
Kit.check("已满级(owned=5)再购价照算 151(cap 闸归写口,价格函数纯几何)〔锻造工坊批 V2〕",
|
||
ForgeTableScript.level_price(row, 5) == 151)
|
||
|
||
|
||
# ---- 负向:出域行逐类构造各一行全部被拒(V1)----
|
||
|
||
func _base_rows() -> Array[Dictionary]:
|
||
var out: Array[Dictionary] = []
|
||
var mk := func(rid: String, kind: String, axis: String, initial: bool, prereq: String, unlock: int) -> Dictionary:
|
||
return {"route_id": rid, "display_name": rid + "锻造", "kind": kind, "type_axis": axis,
|
||
"cap": 5, "per_level_gain": 1, "base_ac": 10 if kind == "armor" else 0,
|
||
"base_price": 20, "price_ratio_pct": 150, "initial_owned": initial,
|
||
"prereq_route": prereq, "unlock_price": unlock}
|
||
out.append(mk.call("sword", "weapon", "normal", true, "", 0))
|
||
out.append(mk.call("spear", "weapon", "pierce", false, "sword", 200))
|
||
out.append(mk.call("staff", "weapon", "magic", false, "sword", 200))
|
||
out.append(mk.call("light", "armor", "light", true, "", 0))
|
||
out.append(mk.call("heavy", "armor", "heavy", false, "light", 200))
|
||
out.append(mk.call("ward", "armor", "ward", false, "light", 200))
|
||
return out
|
||
|
||
|
||
func _has_cat(errs: Array, cat: String) -> bool:
|
||
for e in errs:
|
||
if String(e.get("category", "")) == cat:
|
||
return true
|
||
return false
|
||
|
||
|
||
# 多字段变异器(lambda 单表达式装不下的用例——命名函数承载)
|
||
func _mut_spear_initial(t) -> void:
|
||
t.routes[1]["initial_owned"] = true
|
||
t.routes[1]["prereq_route"] = ""
|
||
t.routes[1]["unlock_price"] = 0
|
||
|
||
|
||
func _test_negative_cases() -> void:
|
||
# 夹具本身须零违例(否则负向判读失效)
|
||
var t_ok = ForgeTableScript.new()
|
||
t_ok.routes = _base_rows()
|
||
Kit.check("负向夹具(合法六行)validate 零违例〔锻造工坊批 V1〕", t_ok.validate().is_empty())
|
||
|
||
var cases := []
|
||
# route_id 出域
|
||
cases.append(["route_id 出域即拒", func(t): t.routes[0]["route_id"] = "blade", "enum"])
|
||
# route_id 重复
|
||
cases.append(["route_id 重复即拒", func(t): t.routes[1]["route_id"] = "sword", "dup_key"])
|
||
# kind 出域
|
||
cases.append(["kind 出域即拒", func(t): t.routes[1]["kind"] = "shield", "enum"])
|
||
# 武器类型轴出域
|
||
cases.append(["武器 type_axis 出域即拒", func(t): t.routes[1]["type_axis"] = "ward", "enum"])
|
||
# 同攻击类型两路线
|
||
cases.append(["同攻击类型第二路线即拒", func(t): t.routes[2]["type_axis"] = "normal", "dup_key"])
|
||
# cap 越域
|
||
cases.append(["cap 越域即拒", func(t): t.routes[0]["cap"] = 0, "domain"])
|
||
# 每级增量越域
|
||
cases.append(["per_level_gain 越域即拒", func(t): t.routes[0]["per_level_gain"] = -1, "domain"])
|
||
# 基础价越域
|
||
cases.append(["base_price 越域即拒", func(t): t.routes[0]["base_price"] = 0, "domain"])
|
||
# 公比越域
|
||
cases.append(["price_ratio_pct 越域即拒", func(t): t.routes[0]["price_ratio_pct"] = 99, "domain"])
|
||
# 武器行 base_ac 非零
|
||
cases.append(["武器行 base_ac 非零即拒", func(t): t.routes[0]["base_ac"] = 5, "cross_field"])
|
||
# 甲行 base_ac 越域
|
||
cases.append(["甲行 base_ac 越域即拒", func(t): t.routes[3]["base_ac"] = -1, "domain"])
|
||
# 待解锁行无解锁价
|
||
cases.append(["待解锁行无一次性解锁价即拒", func(t): t.routes[1]["unlock_price"] = 0, "cross_field"])
|
||
# 初始持有行带前置
|
||
cases.append(["初始持有行带前置即拒", func(t): t.routes[0]["prereq_route"] = "spear", "cross_field"])
|
||
# 初始持有行带解锁价
|
||
cases.append(["初始持有行带解锁价即拒", func(t): t.routes[3]["unlock_price"] = 100, "cross_field"])
|
||
# 前置出域
|
||
cases.append(["prereq_route 出域即拒", func(t): t.routes[1]["prereq_route"] = "blade", "enum"])
|
||
# 前置类型不符(武器←甲)
|
||
cases.append(["前置类型不符即拒(武器行前置甲路线)", func(t): t.routes[1]["prereq_route"] = "light", "cross_field"])
|
||
# 前置自指
|
||
cases.append(["前置自指即拒", func(t): t.routes[1]["prereq_route"] = "spear", "cross_field"])
|
||
# 规模非六
|
||
cases.append(["行族规模非六即拒", func(t): t.routes.pop_back(), "structural"])
|
||
# 初始持有非一武器一甲(spear 转初始且清前置/解锁价——隔离出 coverage 单类)
|
||
cases.append(["初始持有非一武器一甲即拒", Callable(self, "_mut_spear_initial"), "coverage"])
|
||
# 默认轴≠初始路线类型轴
|
||
cases.append(["默认攻击轴≠初始武器类型轴即拒", func(t): t.default_attack_type = "pierce", "cross_field"])
|
||
# 默认甲轴出域
|
||
cases.append(["默认甲轴出域即拒", func(t): t.default_armor_type = "plate", "enum"])
|
||
# 同甲型两路线
|
||
cases.append(["同甲型第二路线即拒", func(t): t.routes[4]["type_axis"] = "light", "dup_key"])
|
||
|
||
for c in cases:
|
||
var label: String = c[0]
|
||
var mutate: Callable = c[1]
|
||
var expect_cat: String = c[2]
|
||
var t = ForgeTableScript.new()
|
||
t.routes = _base_rows()
|
||
mutate.call(t)
|
||
var errs: Array = t.validate()
|
||
Kit.check("锻造 schema 负向:%s〔锻造工坊批 V1〕" % label, _has_cat(errs, expect_cat))
|
||
|
||
|
||
# ---- V11:效果类型闭集零新增(满级特效本期不开新词)----
|
||
# 实数订正(L2 在册):卡片 F11「24 种」系 2026-10-07 设计文档事实快照——迭代 20 T1 +7、迭代 22 T3 +1
|
||
# 后现行 31;本批零扩签,钉现行数(将来任何一批加词即转红提示复核)。
|
||
|
||
func _test_effect_kinds_closed() -> void:
|
||
Kit.check("效果引擎效果类型闭集 31 种零新增(锻造工坊批不开效果类型词)〔锻造工坊批 V11〕",
|
||
EffectEngineScript.EFFECT_KINDS.size() == 31)
|
||
Kit.check("效果引擎事件词表在册 20 种(本批零扩签参照面)〔锻造工坊批 V11〕",
|
||
EffectEngineScript.EVENTS.size() == 20)
|