Files
gplan-2/game/tests/unit/contracts/system_contracts_test.gd
DKChan 490ea35c53 工程(迭代51连带面随迁): 四红项测试与标注补改,行为零变更
- 归因: 四条红全部由迭代 51 合回(fb27dc2f)引入,重塑后两笔——
  1cd6e2b1 卡①(effect_engine.gd:14 删 charge_full 词表位)与
  372b4fdc 卡②(damage_controller.gd:50 _init 加 facts_ref 第五参;slot_store.gd:155
  migrate_legacy_save 公开化;breakpoint_store 新增 13 公开读写方法)。
  首现于 run 56(5d7579e7 合回,因动 game/tests/.gates/.gitignore 被判 full 域)——
  迭代 51 之后第一次代码域全量真跑;run 51 倒在导入步(runner 故障)、run 54 实为
  域=docs、run 55 零触发跑闸步 skipped,故这三跑都没照出。
- 随迁面:
  1) forge_table_test.gd:219 EVENTS 闭集计数 22→21(卡①残留面清单 13 处+注释 3 处
     未含测试内闭集计数断言,属卡片考证缺面)。
  2) system_contracts_test.gd 补 BattleFactsScript preload 与 :108 第五参;:102 注释
     「四个依赖」改「五个依赖」(定型标注面同步)。
  3) save_manager_domain_test.gd:481/492 改公开名 migrate_legacy_save。
  4) breakpoint_store.gd:225 bind_save_manager 参数补类型 sm: SaveManagerCore(09 R3)。
- 验证: 三套件单跑 exit 0(forge_table/system_contracts/save_manager_domain);
  audit_type_hints PASS 产品码类型闸归零;--code 档 09 静态扫描已离红名单。
- 一条与本轮无关的本机红在案: settlement_wiring_snapshot_test V1 同种子 golden 对拍
  本机(Windows)红、远端(Mac,同字节 run 56)绿——把码面改动全摘回纯净 master 单跑
  仍红,故非本批引入;golden 平台绑定成因未查,另条登记待裁。
2026-11-04 07:51:06 +08:00

295 lines
18 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# tests/unit/contracts/system_contracts_test.gd — 系统间接口契约断言(宁缺勿错——只断言已定稿行为)
# 运行:godot --headless --path game --script res://tests/unit/contracts/system_contracts_test.gd
# 契约线清单来源:design/engineering/architecture/architecture.md「API Boundaries」表 + 各系统 AC 前言(规范源)。
# 覆盖面:
# 各系统→D2 ConfigDB 类型化单点(TABLE_MANIFEST 词表封闭——伤害单据 kind/目标规则/结账政策常量);
# D2↔B1 掷点用途标签 ∈ RngService 封闭词表(EC12——settlement.armor_roll_stream 成员性);
# B1→D1 行协议公共字段形态(open_ticket 九栏缺省 + 协议公共键);
# B4→B3 增益授予布尔受理口径(域闭 EC1 / 未知 id EC2 / 受理后投影)。
extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
const RngServiceScript := preload("res://core/rng_service.gd")
const StatusContainerScript := preload("res://core/status_container.gd")
const UnitContainerScript := preload("res://core/unit_container.gd")
const DamageControllerScript := preload("res://core/damage_controller.gd")
const BattleFactsScript := preload("res://core/battle_facts.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
func _init() -> void:
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if load_ok:
_test_damage_controller_vocabularies()
_test_rng_tag_closure(db)
_test_ticket_nine_columns()
_test_status_boolean_acceptance(db)
_test_status_individual_isolation(db)
_test_status_grant_all_per_unit(db)
_test_status_death_clears(db)
_test_status_merge_per_unit(db)
_test_status_decay_and_clear(db)
_test_status_stack_full_reject(db)
_test_status_independent_branch(db)
_test_status_snapshot_first_grant(db)
quit(Kit.finish("system_contracts_test"))
# ---- B1 兑付配方词表:五配方/三目标规则/双结账政策——常量非空且互异(封闭枚举负空间) ----
func _test_damage_controller_vocabularies() -> void:
var kinds: Array = [
DamageControllerScript.KIND_HERO_ATTACK, DamageControllerScript.KIND_ENEMY_SKILL,
DamageControllerScript.KIND_BOARD_TYPED, DamageControllerScript.KIND_BOARD_BLAST,
DamageControllerScript.KIND_BOARD_FLAT_HERO,
]
var nonempty := true
for k in kinds:
if String(k).is_empty():
nonempty = false
Kit.check("五配方常量非空(开单面唯一分派键)", nonempty)
var unique_kinds: Dictionary = {}
for k in kinds:
unique_kinds[String(k)] = true
Kit.check("五配方常量互异(词表封闭无别名)", unique_kinds.size() == kinds.size())
var targets: Array = [
DamageControllerScript.TARGET_FRONT_ALIVE, DamageControllerScript.TARGET_HERO,
DamageControllerScript.TARGET_ALL_ALIVE,
]
var unique_targets: Dictionary = {}
for t in targets:
unique_targets[String(t)] = true
Kit.check("三目标规则常量互异(结账时刻现场解析词表)", unique_targets.size() == targets.size())
Kit.check("双结账政策常量互异(A 即结 / B 攒批回切位)",
String(DamageControllerScript.POLICY_IMMEDIATE) != String(DamageControllerScript.POLICY_SEGMENT))
# ---- D2↔B1:掷点用途标签 ∈ RngService 封闭词表(EC12——跨系统字面量契约的成员性断言) ----
func _test_rng_tag_closure(db) -> void:
var stream_label: String = String(db.combat.settlement.damage_roll.armor_roll_stream)
Kit.check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)",
RngServiceScript.PURPOSE_TAGS.has(stream_label))
var unique_tags: Dictionary = {}
for t in RngServiceScript.PURPOSE_TAGS:
unique_tags[String(t)] = true
Kit.check("RngService 用途标签词表非空且互异(封闭语义由流改标签)",
RngServiceScript.PURPOSE_TAGS.size() > 0 and unique_tags.size() == RngServiceScript.PURPOSE_TAGS.size())
# ---- B1→D1 行协议:单据九栏缺省齐备 + 协议公共键形态 ----
func _test_ticket_nine_columns() -> void:
var ctrl = _bare_controller()
var t: Dictionary = ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "契约探针",
{"channel": "棋盘兑付", "base_value": 1, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 0})
var columns := ["kind", "source_class", "origin_cell", "round_index", "display",
"channel", "attack_type", "base_value", "target_rule", "rider", "highlight", "context",
"requires", "source_unit"]
var all_present := true
for c in columns:
if not t.has(c):
all_present = false
Kit.check("单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)", all_present)
Kit.check("缺省无类型轴(attack_type 空 = 恒不查克制矩阵)", String(t.attack_type) == "")
Kit.check("缺省无前置依赖(requires = -1)", int(t.requires) == -1)
func _bare_controller():
var db = ConfigDBScript.new()
# 只取 open_ticket 纯构造面——五个依赖一律传真实实例,不用空桩:
# DamageController 的 db/rng/units/effects/facts 全是定型标注,传无类型的空桩会触发
# 「Trying to assign value of type '' to a variable of type '…'」脚本错误——该错误不改退出码,
# 却让赋值静默失败(成员留 null),脚本错误扫描落地后必红。三者均可零消费构造。
var units = UnitContainerScript.new(db)
var effects = StatusContainerScript.new(db)
return DamageControllerScript.new(db, RngServiceScript.new(), units, effects, BattleFactsScript.new())
# ---- B4→B3:增益授予布尔受理口径(受理/拒绝二元面 + 投影一致性) ----
func _test_status_boolean_acceptance(db) -> void:
var effects = StatusContainerScript.new(db)
# EC1 域闭:notify_battle_start 前授予 = false(受理面封闭)
var any_row: Dictionary = {}
if db.combat.status_effects.rows.size() > 0:
any_row = db.combat.status_effects.rows[0]
Kit.check("前置:状态表非空", not any_row.is_empty())
if any_row.is_empty():
return
var probe_id: String = String(any_row.effect_id)
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
Kit.check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, StatusContainerScript.OWNER_HERO, src) == false)
Kit.check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", StatusContainerScript.OWNER_HERO, src) == false)
effects.notify_battle_start()
Kit.check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, StatusContainerScript.OWNER_HERO, src) == true)
var active: Array = effects.query_active(StatusContainerScript.OWNER_HERO)
var projected := false
for a in active:
if String(a.effect_id) == probe_id:
projected = int(a.effective_value) == int(a.layers) * int(a.snap_magnitude)
Kit.check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected)
# ---- B4 个体归属契约(增益状态 v1.5——敌方状态个体化批):个体隔离/全体逐体发/死亡即清/同体合并 ----
# 个体键直传字面量("e1"/"e2"/"e3"——拼写约定单点在 StatusContainer.owner_key_for,测试面模拟授予路径逐只授)
func _test_status_individual_isolation(db) -> void:
var effects = StatusContainerScript.new(db)
effects.notify_battle_start()
var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var wrow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("weaken")
Kit.check("前置:weaken 行在册(累加策略——个体合并语义载体)", not wrow.is_empty() and String(wrow.stack_policy) == "accumulate")
Kit.check("个体隔离:A 敌受理", effects.apply_effect("weaken", "e1", src) == true)
Kit.check("个体隔离:B 敌受理(同名不同体互不合并)", effects.apply_effect("weaken", "e2", src) == true)
Kit.check("个体隔离:A/B 各持一条(各体桶独立)〔个体化批 V1〕",
effects.query_active("e1").size() == 1 and effects.query_active("e2").size() == 1)
effects.decay_round() # A/B 各自 −1——先验证互不干扰后验证独立倒数
var a_after: Array = effects.query_active("e1")
var b_after: Array = effects.query_active("e2")
var a_rem: int = int(a_after[0].remaining_settlements) if a_after.size() == 1 else -1
var b_rem: int = int(b_after[0].remaining_settlements) if b_after.size() == 1 else -1
Kit.check("个体隔离:各体独立倒数(A=表值−1、B=表值−1 同步各减)〔个体化批 V1〕",
a_rem == int(wrow.duration_settlements) - 1 and b_rem == int(wrow.duration_settlements) - 1)
Kit.check("个体隔离:投影携带 owner_key(逐个体承载出户)〔个体化批 V1〕",
a_after.size() == 1 and String(a_after[0].owner_key) == "e1" and String(b_after[0].owner_key) == "e2")
func _test_status_grant_all_per_unit(db) -> void:
var effects = StatusContainerScript.new(db)
effects.notify_battle_start()
var src := {"source_class": "mine_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("enrage")
Kit.check("前置:enrage 行在册(叠加策略——逐体叠层载体)", not erow.is_empty() and String(erow.stack_policy) == "stack")
# 模拟授予路径全体规则(reward_grant._apply_percent_row 同构):循环三敌逐只各发一条
var keys: Array[String] = ["e1", "e2", "e3"]
var granted := 0
for k in keys:
if effects.apply_effect("enrage", k, src):
granted += 1
Kit.check("全体授予逐体发:三敌各受理一条(对全体=逐只各发)〔个体化批 V2〕", granted == 3)
var e2_ok: bool = effects.apply_effect("enrage", "e2", src) # e2 再授一层——各体独立合并
Kit.check("全体授予逐体发:同体叠加至 2 层、异体层数不动〔个体化批 V2〕",
e2_ok and int(effects.query_active("e2")[0].layers) == 2
and int(effects.query_active("e1")[0].layers) == 1 and int(effects.query_active("e3")[0].layers) == 1)
effects.decay_round()
var all_decayed := true
for k in keys:
for eff in effects.query_active(k):
if int(eff.remaining_settlements) != int(erow.duration_settlements) - 1:
all_decayed = false
Kit.check("全体授予逐体发:各体同轮独立衰减 −1(逐桶各减)〔个体化批 V2〕", all_decayed)
func _test_status_death_clears(db) -> void:
var effects = StatusContainerScript.new(db)
effects.notify_battle_start()
var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0}
effects.apply_effect("weaken", "e1", src)
effects.apply_effect("weaken", "e2", src)
Kit.check("死亡即清:purge_owner 返回移除数(e1 名下 1 条)〔个体化批 V3〕", effects.purge_owner("e1") == 1)
Kit.check("死亡即清:e1 桶清零(重 purge 零移除)〔个体化批 V3〕", effects.query_active("e1").is_empty() and effects.purge_owner("e1") == 0)
Kit.check("死亡即清:e2 不受影响(不转移不并回)〔个体化批 V3〕", effects.query_active("e2").size() == 1)
var side_all: Array = effects.query_active_side("enemy")
Kit.check("死亡即清:侧汇总只含存活个体实例〔个体化批 V3〕", side_all.size() == 1 and String(side_all[0].owner_key) == "e2")
func _test_status_merge_per_unit(db) -> void:
var effects = StatusContainerScript.new(db)
effects.notify_battle_start()
var src := {"source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var wrow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("weaken")
var w_dur: int = int(wrow.duration_settlements)
effects.apply_effect("weaken", "e1", src)
effects.apply_effect("weaken", "e1", src)
Kit.check("同体合并照旧:累加行同体再授时长累加(%d+%d——仍单实例)〔个体化批 V4〕" % [w_dur, w_dur],
effects.query_active("e1").size() == 1 and int(effects.query_active("e1")[0].remaining_settlements) == w_dur * 2)
var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("enrage")
var max_stack: int = int(erow.max_stack)
var granted_n := 0
for i in range(max_stack + 1):
if effects.apply_effect("enrage", "e2", src):
granted_n += 1
Kit.check("同体合并照旧:叠加行满层拒绝(恰授 max_stack=%d 层)〔个体化批 V4〕" % max_stack, granted_n == max_stack)
Kit.check("同体合并照旧:满层时异体仍受理(拒绝只在同体内原子)〔个体化批 V4〕", effects.apply_effect("enrage", "e3", src) == true)
# ---- B4 结算链契约(断言补齐批——①增益状态结算链):衰减逐值/战斗结束全清/满层拒绝/独立并存/首授快照 ----
func _test_status_decay_and_clear(db) -> void:
var effects = StatusContainerScript.new(db)
effects.notify_battle_start()
var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var wrow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("weaken")
var w_dur: int = int(wrow.duration_settlements)
Kit.check("V1 前置:weaken 时长 ≥2(两拍衰减观测面)", w_dur >= 2)
Kit.check("V1 前置:e1 受理", effects.apply_effect("weaken", "e1", src))
Kit.check("衰减逐值:首次 decay 返回移除数 0(未归零)〔断言补齐批 V1〕", effects.decay_round() == 0)
Kit.check("衰减逐值:每结算恰 −1(remaining=%d−1)〔断言补齐批 V1〕" % (w_dur - 1),
effects.query_active("e1").size() == 1 and int(effects.query_active("e1")[0].remaining_settlements) == w_dur - 1)
Kit.check("到期移除:第二次 decay 返回移除数 1〔断言补齐批 V2〕", effects.decay_round() == 1)
Kit.check("到期移除:归零实例当轮销毁(投影零行)〔断言补齐批 V2〕", effects.query_active("e1").is_empty())
effects.apply_effect("weaken", "e2", src)
effects.apply_effect("attack_up", StatusContainerScript.OWNER_HERO, src)
Kit.check("V2 前置:两桶各持实例", effects.query_active_side("enemy").size() == 1 and effects.query_active(StatusContainerScript.OWNER_HERO).size() == 1)
Kit.check("战斗结束全清:notify_battle_end 返回 true 且全实例清空(两桶零行)〔断言补齐批 V2〕",
effects.notify_battle_end() and effects.query_active_side("enemy").is_empty() and effects.query_active(StatusContainerScript.OWNER_HERO).is_empty())
func _test_status_stack_full_reject(db) -> void:
var effects = StatusContainerScript.new(db)
effects.notify_battle_start()
var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("enrage")
var max_stack: int = int(erow.max_stack)
for i in range(max_stack):
effects.apply_effect("enrage", "e1", src)
Kit.check("V3 前置:满层(恰 %d 层)" % max_stack,
effects.query_active("e1").size() == 1 and int(effects.query_active("e1")[0].layers) == max_stack)
var before_layers: int = int(effects.query_active("e1")[0].layers)
var before_rem: int = int(effects.query_active("e1")[0].remaining_settlements)
Kit.check("满层拒绝:再授返 false〔断言补齐批 V3〕", effects.apply_effect("enrage", "e1", src) == false)
Kit.check("满层拒绝:层数与剩余零写入(容器逐字不变——原子拒绝)〔断言补齐批 V3〕",
int(effects.query_active("e1")[0].layers) == before_layers and int(effects.query_active("e1")[0].remaining_settlements) == before_rem)
func _test_status_independent_branch(db) -> void:
var effects = StatusContainerScript.new(db)
effects.notify_battle_start()
var probe := {
"effect_id": "probe_independent_v4",
"display_name": "并存试验",
"target_side": "enemy",
"magnitude": 7,
"unit": "flat",
"duration_settlements": 2,
"stack_policy": "independent",
"max_stack": 1,
}
db.combat.status_effects.rows.append(probe) # 直改已加载表(运行时注入试验行——并存分支零内容行,夹具面)
var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0}
Kit.check("independent 并存:第一次授予受理〔断言补齐批 V4〕", effects.apply_effect("probe_independent_v4", "e1", src))
Kit.check("independent 并存:第二次授予受理(并存——无合并无满层语义)〔断言补齐批 V4〕", effects.apply_effect("probe_independent_v4", "e1", src))
var both: Array = effects.query_active("e1")
Kit.check("independent 并存:两实例各持时长 2 独立倒数〔断言补齐批 V4〕",
both.size() == 2 and int(both[0].remaining_settlements) == 2 and int(both[1].remaining_settlements) == 2)
db.combat.status_effects.rows.pop_back() # 测毕还原(表行零残留)
Kit.check("independent 试验行测毕还原(row_by_id 零命中)", db.combat.status_effects.row_by_id("probe_independent_v4").is_empty())
func _test_status_snapshot_first_grant(db) -> void:
var effects = StatusContainerScript.new(db)
effects.notify_battle_start()
var src_first := {"source_class": "seal_hit", "origin_cell": Vector2i(3, 4), "round_index": 1}
var src_second := {"source_class": "mine_hit", "origin_cell": Vector2i(8, 9), "round_index": 5}
effects.apply_effect("weaken", "e1", src_first)
effects.apply_effect("weaken", "e1", src_second) # 累加:时长加、归因与快照沿首授
var inst: Array = effects.query_active("e1")
Kit.check("首授快照:累加合并仍单实例〔断言补齐批 V5〕", inst.size() == 1)
Kit.check("首授快照:origin_cell/round_index/source_class 沿首授(合并实例只持首授归因)〔断言补齐批 V5〕",
String(inst[0].source_class) == "seal_hit" and inst[0].origin_cell == Vector2i(3, 4) and int(inst[0].round_index) == 1)
Kit.check("首授快照:幅度快照 = 首授时表值(抗热重载域)〔断言补齐批 V5〕",
int(inst[0].snap_magnitude) == int(db.combat.status_effects.row_by_id("weaken").magnitude))