diff --git a/game/core/config_db.gd b/game/core/config_db.gd index 6267e53d..e3a21806 100644 --- a/game/core/config_db.gd +++ b/game/core/config_db.gd @@ -27,6 +27,7 @@ const TABLE_MANIFEST := { "treasure_items": "treasure_items.tres", # 迭代 7 T1 秘宝内容批(TreasureItemTable 六行——定义表 Rule 4 豁免;罗盘 retired/失义件 deferred 标记见 schema 头注) "adventure": "adventure.tres", # 冒险结构实现批 2026-09-21(AdventureTable 建表——大奖励与钥石行值单点) "feats": "feats.tres", # 迭代 12 T1 双层 BD 规则改写型特性基建(FeatTable 定义表 Rule 4 豁免——TreasureItemTable 先例;空表合法态=主时间线零扰动) + "forge": "forge.tres", # 迭代 23 T1 锻造工坊实装批(ForgeTable——两根类型轴默认值+六路线行族;攻防所有权切分唯一数据面) } var action_economy: ActionEconomyTable @@ -49,6 +50,7 @@ var meta_tree: MetaTreeTable # 迭代 12 T8 类型化持用(消费面走 tre var treasure_items: TreasureItemTable # 迭代 7 T1 类型化持用(消费面走 treasure_* 访问器) var adventure: AdventureTable var feats: FeatTable # 迭代 12 T1 类型化持用(消费面走 feat_* 访问器) +var forge: ForgeTable # 迭代 23 T1 锻造工坊配置(默认两轴+六路线行族——消费面走 forge_* 访问器) var is_ready := false var _diagnostics: Array[Dictionary] = [] # {severity, category, message} @@ -140,6 +142,7 @@ func load_tables(root: String, collect_only: bool) -> bool: treasure_items = loaded.get("treasure_items") adventure = loaded.get("adventure") feats = loaded.get("feats") + forge = loaded.get("forge") meta_tree = loaded.get("meta_tree") is_ready = not has_errors return not has_errors @@ -387,6 +390,48 @@ func _meta_rows(family: String) -> Array: return arr if arr is Array else [] +# ---- 锻造工坊(迭代 23 T1——ForgeTable 消费面:两根类型轴默认值+六路线行族;禁自扫行集)---- +# 设计权威 design/gdd/systems/跨局循环/锻造工坊.md v0.6:默认值归本表常驻持有(hero.tres 两轴已删—— +# 契约 2);路线行族=攻防数值成长唯一数据面(契约 1)。数值全 provisional 归 M4。 + +# 两根类型轴默认值(表未落籍=裸体态兜底 normal/light——与 ForgeTable 缺省值同口径) +func forge_default_attack_type() -> String: + return String(forge.default_attack_type) if forge != null else "normal" + + +func forge_default_armor_type() -> String: + return String(forge.default_armor_type) if forge != null else "light" + + +# 六路线行族(副本——调用方禁改) +func forge_routes() -> Array: + return forge.routes.duplicate() if forge != null else [] + + +# 路线行单点(键 route_id;缺行=空字典) +func forge_route(route_id: String) -> Dictionary: + for row in forge_routes(): + if String(row.get("route_id", "")) == route_id: + return row + return {} + + +# 按类型轴反查武器路线(attack_type → 路线行;甲路线 id=甲型同值的显式化入口,避免消费侧依赖该巧合) +func forge_weapon_route_by_type(attack_type: String) -> Dictionary: + for row in forge_routes(): + if String(row.get("kind", "")) == "weapon" and String(row.get("type_axis", "")) == attack_type: + return row + return {} + + +# 按类型轴反查护甲路线(armor_type → 路线行——入战 AC 三层之「甲型基础 AC」取数口) +func forge_armor_route_by_type(armor_type: String) -> Dictionary: + for row in forge_routes(): + if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) == armor_type: + return row + return {} + + # 技能行单点读取(敌人技能池批——单位容器执行上报 / 战斗结算兑现的消费面) func skill_row(skill_id: String) -> Dictionary: return skill.row_by_id(skill_id) diff --git a/game/data/schemas/forge_table.gd b/game/data/schemas/forge_table.gd new file mode 100644 index 00000000..4106ee8a --- /dev/null +++ b/game/data/schemas/forge_table.gd @@ -0,0 +1,131 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Date: 2026-10-12(迭代 23 锻造工坊实装批 T1——设计权威 design/gdd/systems/跨局循环/锻造工坊.md v0.6) +# 锻造工坊配置表(ForgeTable):两根类型轴默认值(常驻持有——契约 2)+ 六条锻造路线行族(三武器+三甲)。 +# 所有权切分(契约 1 红线):攻击唯一入口=武器路线、AC 唯一入口=甲路线——树零攻防来源,本表是攻防 +# 数值成长的唯一数据面。等级语义(Q1 用户裁):未解锁/未锻造=0 加值(裸体态,类型=默认轴值); +# 解锁并锻造第一级=「获得装备」=1 级=首个 +1;逐级递进(2 级=+2 量级)。 +# 数值全 provisional(需求卡片 T1 锚表投值——终值归 M4 数值批):cap 5/每级增量统一 +1(v1.2 用户裁)/ +# 基础价 20/公比 150%(各级价格 20/30/45/67/101)/一次性解锁价 200/甲型基础 AC 10-12-9(现役结构值)。 +# 表运行期只读(铁律):永久强化只写存档(forge_routes 域),本表零写面。 +class_name ForgeTable +extends Resource + +# 路线 id 词表(代拍板立项:甲路线 id=甲型值本身——ward=魔法护甲,3a 裁统一命名) +const WEAPON_IDS := ["sword", "spear", "staff"] +const ARMOR_IDS := ["light", "heavy", "ward"] +const ROUTE_IDS := ["sword", "spear", "staff", "light", "heavy", "ward"] +# 两根类型轴枚举域(hero.tres 静态表两轴随本批删除——词表归本表常驻持有) +const ATTACK_TYPES := ["normal", "pierce", "magic"] +const ARMOR_TYPES := ["light", "ward", "heavy"] +# 行字段闭集(V11:满级特效本期不做——行族无特效参数位;键集出域即拒) +const ROW_KEYS := ["route_id", "display_name", "kind", "type_axis", "cap", "per_level_gain", + "base_ac", "base_price", "price_ratio_pct", "initial_owned", "prereq_route", "unlock_price"] + +@export var default_attack_type: String = "normal" # 攻击类型轴默认值(常驻持有——与柜台解锁状态解耦:未点亮=无交互入口但默认值照常注入) +@export var default_armor_type: String = "light" # 甲型轴默认值(常驻持有) +@export var routes: Array[Dictionary] = [] # 六路线行族(键 route_id) + + +# 各级价格(几何递增:基础价 × 150%^已购级数——锚表 20/30/45/67/101;1.5^n 二进制精确,无浮点尘) +static func level_price(row: Dictionary, owned_levels: int) -> int: + var base := int(row.get("base_price", 0)) + var ratio := float(int(row.get("price_ratio_pct", 100))) / 100.0 + return int(floor(float(base) * pow(ratio, maxi(0, owned_levels)))) + + +func validate() -> Array: + var errs: Array[Dictionary] = [] + # 默认两轴枚举域 + if not ATTACK_TYPES.has(default_attack_type): + errs.append({"category": "enum", "message": "default_attack_type 出域 %s: %s" % [ATTACK_TYPES, default_attack_type]}) + if not ARMOR_TYPES.has(default_armor_type): + errs.append({"category": "enum", "message": "default_armor_type 出域 %s: %s" % [ARMOR_TYPES, default_armor_type]}) + # 行族规模恰六(三武器+三甲——需求卡片 §1①) + if routes.size() != 6: + errs.append({"category": "structural", "message": "锻造路线行族规模 %d ≠ 6(三武器+三甲)" % routes.size()}) + var seen := {} + var type_seen := {} + var kind_by_id := {} + var initial_ids := {} + for row in routes: # 预扫:route_id→kind 全集(前置反查需前向引用——轻甲行在长枪行之后) + kind_by_id[String(row.get("route_id", ""))] = String(row.get("kind", "")) + for row in routes: + var rid := String(row.get("route_id", "")) + if not ROUTE_IDS.has(rid): + errs.append({"category": "enum", "message": "锻造路线 route_id 出域 %s: %s" % [ROUTE_IDS, rid]}) + if seen.has(rid): + errs.append({"category": "dup_key", "message": "锻造路线 route_id 重复: %s" % rid}) + seen[rid] = true + var kind := String(row.get("kind", "")) + if not ["weapon", "armor"].has(kind): + errs.append({"category": "enum", "message": "%s kind 出域 {weapon, armor}: %s" % [rid, kind]}) + continue + # 类型轴:武器=attack_type 域、甲=armor_type 域;每型恰一路线(三武器三甲各一) + var axis := String(row.get("type_axis", "")) + var domain: Array = ATTACK_TYPES if kind == "weapon" else ARMOR_TYPES + if not domain.has(axis): + errs.append({"category": "enum", "message": "%s type_axis 出域 %s: %s" % [rid, domain, axis]}) + var type_key := "%s|%s" % [kind, axis] + if type_seen.has(type_key): + errs.append({"category": "dup_key", "message": "%s 类型轴重复(每型恰一路线): %s %s" % [kind, axis, rid]}) + type_seen[type_key] = true + # 等级/增量/价格/基础 AC 值域 + var cap_v: int = int(row.get("cap", -1)) + if cap_v < 1 or cap_v > 9: + errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [rid, cap_v]}) + var gain: int = int(row.get("per_level_gain", -1)) + if gain < 0 or gain > 9: + errs.append({"category": "domain", "message": "%s per_level_gain 越域 [0,9]: %d" % [rid, gain]}) + var base_ac: int = int(row.get("base_ac", -1)) + if kind == "armor": + if base_ac < 0 or base_ac > 999: + errs.append({"category": "domain", "message": "%s base_ac 越域 [0,999]: %d" % [rid, base_ac]}) + elif base_ac != 0: + errs.append({"category": "cross_field", "message": "%s 武器行 base_ac 须为 0(甲型基础 AC 只属护甲路线): %d" % [rid, base_ac]}) + var base_price: int = int(row.get("base_price", -1)) + if base_price < 1 or base_price > 999: + errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [rid, base_price]}) + var ratio: int = int(row.get("price_ratio_pct", -1)) + if ratio < 100 or ratio > 500: + errs.append({"category": "domain", "message": "%s price_ratio_pct 越域 [100,500]: %d" % [rid, ratio]}) + var unlock_price: int = int(row.get("unlock_price", -1)) + if unlock_price < 0 or unlock_price > 999: + errs.append({"category": "domain", "message": "%s unlock_price 越域 [0,999]: %d" % [rid, unlock_price]}) + # 解锁通道(并联双通道):初始持有行无前置无解锁价;待解锁行须有一次性解锁价+合法前置 + var initial: bool = bool(row.get("initial_owned", false)) + var prereq := String(row.get("prereq_route", "")) + if initial: + if prereq != "": + errs.append({"category": "cross_field", "message": "%s 初始持有行不得带前置: %s" % [rid, prereq]}) + if unlock_price != 0: + errs.append({"category": "cross_field", "message": "%s 初始持有行解锁价须为 0: %d" % [rid, unlock_price]}) + initial_ids[rid] = kind + else: + if unlock_price < 1: + errs.append({"category": "cross_field", "message": "%s 待解锁行须有一次性解锁价(并联双通道②): %d" % [rid, unlock_price]}) + if not ROUTE_IDS.has(prereq): + errs.append({"category": "enum", "message": "%s prereq_route 出域 %s: %s" % [rid, ROUTE_IDS, prereq]}) + elif prereq == rid: + errs.append({"category": "cross_field", "message": "%s prereq_route 不得自指" % rid}) + elif kind_by_id.has(prereq) and String(kind_by_id[prereq]) != kind: + errs.append({"category": "cross_field", "message": "%s 前置 %s 类型不符(武器←武器/甲←甲): %s vs %s" % [rid, prereq, kind, String(kind_by_id[prereq])]}) + # 初始持有恰两行且一武器一甲(长剑/轻甲——语义继承自已退役属性锻造行) + var init_weapons := 0 + var init_armors := 0 + for k in initial_ids.keys(): + if String(initial_ids[k]) == "weapon": + init_weapons += 1 + elif String(initial_ids[k]) == "armor": + init_armors += 1 + if init_weapons != 1 or init_armors != 1: + errs.append({"category": "coverage", "message": "初始持有须恰一武器一甲(现 sword/light): 武器 %d / 甲 %d" % [init_weapons, init_armors]}) + # 默认轴=初始持有路线类型轴(Q1 裸体态语义:未锻造=0 加值且类型=普通/轻甲) + if init_weapons == 1 and init_armors == 1: + for row in routes: + if not bool(row.get("initial_owned", false)): + continue + if String(row.get("kind", "")) == "weapon" and String(row.get("type_axis", "")) != default_attack_type: + errs.append({"category": "cross_field", "message": "default_attack_type 须=初始武器路线类型轴(裸体态普通): %s vs %s" % [default_attack_type, String(row.get("type_axis", ""))]}) + if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) != default_armor_type: + errs.append({"category": "cross_field", "message": "default_armor_type 须=初始甲路线类型轴(裸体态轻甲): %s vs %s" % [default_armor_type, String(row.get("type_axis", ""))]}) + return errs diff --git a/game/data/schemas/forge_table.gd.uid b/game/data/schemas/forge_table.gd.uid new file mode 100644 index 00000000..3c773035 --- /dev/null +++ b/game/data/schemas/forge_table.gd.uid @@ -0,0 +1 @@ +uid://08fuv4rzuos2h diff --git a/game/data/tables/forge.tres b/game/data/tables/forge.tres new file mode 100644 index 00000000..1d629efb --- /dev/null +++ b/game/data/tables/forge.tres @@ -0,0 +1,98 @@ +[gd_resource type="Resource" script_class="ForgeTable" load_steps=2 format=3] + +[ext_resource type="Script" path="res://data/schemas/forge_table.gd" id="1"] + +[resource] +script = ExtResource("1") +default_attack_type = "normal" +default_armor_type = "light" +routes = [{ +"route_id": "sword", +"display_name": "长剑锻造", +"kind": "weapon", +"type_axis": "normal", +"cap": 5, +"per_level_gain": 1, +"base_ac": 0, +"base_price": 20, +"price_ratio_pct": 150, +"initial_owned": true, +"prereq_route": "", +"unlock_price": 0 +}, { +"route_id": "spear", +"display_name": "长枪锻造", +"kind": "weapon", +"type_axis": "pierce", +"cap": 5, +"per_level_gain": 1, +"base_ac": 0, +"base_price": 20, +"price_ratio_pct": 150, +"initial_owned": false, +"prereq_route": "sword", +"unlock_price": 200 +}, { +"route_id": "staff", +"display_name": "法杖锻造", +"kind": "weapon", +"type_axis": "magic", +"cap": 5, +"per_level_gain": 1, +"base_ac": 0, +"base_price": 20, +"price_ratio_pct": 150, +"initial_owned": false, +"prereq_route": "sword", +"unlock_price": 200 +}, { +"route_id": "light", +"display_name": "轻甲锻造", +"kind": "armor", +"type_axis": "light", +"cap": 5, +"per_level_gain": 1, +"base_ac": 10, +"base_price": 20, +"price_ratio_pct": 150, +"initial_owned": true, +"prereq_route": "", +"unlock_price": 0 +}, { +"route_id": "heavy", +"display_name": "重甲锻造", +"kind": "armor", +"type_axis": "heavy", +"cap": 5, +"per_level_gain": 1, +"base_ac": 12, +"base_price": 20, +"price_ratio_pct": 150, +"initial_owned": false, +"prereq_route": "light", +"unlock_price": 200 +}, { +"route_id": "ward", +"display_name": "魔法护甲锻造", +"kind": "armor", +"type_axis": "ward", +"cap": 5, +"per_level_gain": 1, +"base_ac": 9, +"base_price": 20, +"price_ratio_pct": 150, +"initial_owned": false, +"prereq_route": "light", +"unlock_price": 200 +}] + +; 锻造工坊实装批 T1(2026-10-12——迭代 23;设计权威 design/gdd/systems/跨局循环/锻造工坊.md v0.6, +; 需求卡片 design/research/需求卡片-锻造工坊实装批-2026-10-12.md T1 锚表): +; 六路线 provisional 投值——cap 5/每级增量统一 +1(v1.2 用户裁:provisional 线性起步,4a 前缓后陡为 +; 终值形状方向归 M4)/甲型基础 AC 10-12-9(现役结构值 settlement.tres 矩阵同行)/基础价 20/公比 150% +; (历史考古参数 meta.tres:82-84 退役批注)/各级价格 20/30/45/67/101/一次性解锁价 200(中段三级 213 九折)。 +; 初始持有=长剑+轻甲(语义继承自已退役属性锻造行——迭代 21 T5 行族删除后的新建行族,非改造)。 +; 并联双通道(4b-1 裁):长枪/法杖←长剑练满、重甲/魔法护甲←轻甲练满,或一次性解锁价直购,互不影响。 +; 默认两轴(契约 2 所有权移交):hero.tres 静态表两轴随本批删除,默认值归本表常驻持有——normal/light +; = 裸体态类型(Q1:未解锁/未锻造=0 加值)。全部数值 provisional 终值归 M4 数值批。 +; 表运行期只读(铁律):永久强化只写存档 forge_routes 域,本表零写面。 diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index a006b27f..278b6079 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -28,6 +28,7 @@ func _suites() -> Array: return [ {"path": "res://tests/unit/example_test.gd", "expect": 0}, {"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0}, + {"path": "res://tests/unit/data_config/forge_table_test.gd", "expect": 0}, # 迭代 23 锻造工坊批 T1——六路线 schema 正反(V1)+特效留空(V11)+几何价格梯(V2 半边) {"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1}, {"path": "res://tests/unit/data_config/treasure_target_violation_test.gd", "expect": 1}, # 迭代 7 T1——真视窄域护栏 {"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1}, # 迭代 7 T1——秘宝双表校验 diff --git a/game/tests/unit/data_config/forge_table_test.gd b/game/tests/unit/data_config/forge_table_test.gd new file mode 100644 index 00000000..8bf9d299 --- /dev/null +++ b/game/tests/unit/data_config/forge_table_test.gd @@ -0,0 +1,200 @@ +# 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) diff --git a/game/tests/unit/data_config/forge_table_test.gd.uid b/game/tests/unit/data_config/forge_table_test.gd.uid new file mode 100644 index 00000000..f843b059 --- /dev/null +++ b/game/tests/unit/data_config/forge_table_test.gd.uid @@ -0,0 +1 @@ +uid://frunrpvqgocl3