test(unit): T1 单元测试与契约层——七套件实跑入口落 tests/run_all.gd
- data_config: ConfigDB 表加载+跨表护栏零诊断/进度坐标 ceil-clamp 解析与警告去重/变异池门控/shop·meta 消费面/manifest↔目录同步/越域 fatal 退出码(独立进程 expect=1,deferred 触发保 quit 通道)/统一随机流(派生确定性/计数器账本/标签不参与派生/负种子归一化) - battle_settlement: DamageMath 四纯函数(池乘区/克制分量/掷点查档/吃掷点分量)+DamageController 伤害单据五配方+B/A 双结账模式+来源敌死作废+requires 前置门+主角倒下 halt+AC 合流三通道+池 clamp 与 refresh 政策 - contracts: 五配方/三目标/双政策词表封闭+掷点标签∈RNG 封闭词表(EC12)+单据九栏缺省+增益布尔受理口径(EC1/EC2) - 红线: 期望值全由表读值推导(零玩法数值字面量)/零自建 RNG(夹具种子仅求复现) - 轻闸: 冒烟 557/0 + ui_input_probe 十一断言全绿(基线零漂移)
This commit is contained in:
1 parent
33281d5052
commit
5f51a916d4
16 files changed
+968
-16
No files matched your search
@@ -32,6 +32,8 @@ godot --headless --path . res://tests/probes/<probe>.tscn
|
|||||||
timeout 120s godot --headless --script tests/run_all.gd
|
timeout 120s godot --headless --script tests/run_all.gd
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 单元/契约套件状态(单元测试与契约层批 2026-09-28):`_suites()` manifest 已登记实跑七套件——data_config(ConfigDB 加载/进度坐标 ceil 解析/fatal 退出码/统一随机流)、battle_settlement(DamageMath 纯函数/DamageController 伤害单据五配方)、contracts(系统间接口契约断言);致命类独立进程 `expect=1`。期望值一律由表读值推导(零玩法数值字面量)、随机只经 RngService 统一流。
|
||||||
|
|
||||||
**协议要点**(D2 冒烟协议 + ADR-0004):
|
**协议要点**(D2 冒烟协议 + ADR-0004):
|
||||||
- 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)`
|
- 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)`
|
||||||
- `--script` 模式**不加载 autoload 且不注册全局标识符**(裸引用 `ConfigDB` = parse error)——ConfigDB/RngService 由脚本显式实例化,core 一律构造注入
|
- `--script` 模式**不加载 autoload 且不注册全局标识符**(裸引用 `ConfigDB` = parse error)——ConfigDB/RngService 由脚本显式实例化,core 一律构造注入
|
||||||
|
|||||||
+29
-16
@@ -1,13 +1,15 @@
|
|||||||
# tests/run_all.gd — CI 冒烟统一入口(ADR-0004 双载体的逻辑冒烟调用点)
|
# tests/run_all.gd — 单元测试统一入口(ADR-0004 双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b)
|
||||||
# 用法:godot --headless --script tests/run_all.gd (CI 侧包装外部超时:timeout 120s …)
|
# 用法:godot --headless --path game --script res://tests/run_all.gd (CI 侧包装外部超时:timeout 120s …)
|
||||||
# 协议:套件清单逐条"套件 → 期望退出码"(D2 AC-2b manifest);致命类独立进程。
|
# 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。
|
||||||
# 现状:正式 Godot 工程尚未在根目录创建(代码在 game/,
|
# 执行形态(单元测试与契约层批 2026-09-28):逐套件起独立 Godot 子进程实跑
|
||||||
# 其 smoke/smoke_all.gd 96/96 为参考实现)——本入口当前仅验证运行协议自身;
|
#(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符,
|
||||||
# 正式工程落地后,把套件清单登记进 _suites()。
|
# 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。
|
||||||
|
# 场景探针(.tscn 载体)由 CI/轻闸单独步骤调用,不经本入口。
|
||||||
extends SceneTree
|
extends SceneTree
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
print("[run_all] formal smoke entry — protocol check")
|
print("[run_all] unit/contract suite entry — %d suites" % _suites().size())
|
||||||
if DisplayServer.get_name() != "headless":
|
if DisplayServer.get_name() != "headless":
|
||||||
push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)")
|
push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)")
|
||||||
var failures := 0
|
var failures := 0
|
||||||
@@ -20,22 +22,33 @@ func _init() -> void:
|
|||||||
print("[run_all] all green (%d suites)" % _suites().size())
|
print("[run_all] all green (%d suites)" % _suites().size())
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|
||||||
# 返回 Array of Dictionary:{ "path": String, "expect": int }
|
|
||||||
# 正式工程落地后在此登记;探针场景(.tscn 载体)由 CI 单独步骤调用,不经本入口。
|
# 套件清单:{ "path": String, "expect": int };致命类(期望非 0)独立进程断言退出码。
|
||||||
func _suites() -> Array:
|
func _suites() -> Array:
|
||||||
return [
|
return [
|
||||||
# {"path": "res://tests/unit/example_test.gd", "expect": 0},
|
{"path": "res://tests/unit/example_test.gd", "expect": 0},
|
||||||
# {"path": "res://tests/unit/reward_economy/reward_roll_test.gd", "expect": 0},
|
{"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0},
|
||||||
# 致命类(独立进程,期望非 0):
|
{"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1},
|
||||||
# {"path": "res://tests/unit/d2/fixture_missing_table_test.gd", "expect": 1},
|
{"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0},
|
||||||
|
{"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0},
|
||||||
|
{"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0},
|
||||||
|
{"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
func _run_suite(suite: Dictionary) -> int:
|
func _run_suite(suite: Dictionary) -> int:
|
||||||
# SceneTree 脚本内不能直接以 OS.execute 递归调 godot 的成熟形态留待正式工程
|
|
||||||
# (需解析 godot 二进制路径);当前协议检查阶段只断言清单形态合法。
|
|
||||||
var path: String = suite.get("path", "")
|
var path: String = suite.get("path", "")
|
||||||
|
var expect: int = int(suite.get("expect", 0))
|
||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
push_error("[run_all] suite missing path")
|
push_error("[run_all] suite missing path")
|
||||||
return 1
|
return 1
|
||||||
print("[run_all] registered: %s (expect=%d)" % [path, suite.get("expect", 0)])
|
var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://"), "--script", path])
|
||||||
|
var out := PackedStringArray()
|
||||||
|
var code: int = OS.execute(OS.get_executable_path(), args, out, true, false)
|
||||||
|
var text := "\n".join(out)
|
||||||
|
print(text)
|
||||||
|
if code != expect:
|
||||||
|
push_error("[run_all] FAIL %s — 退出码 %d ≠ 期望 %d" % [path, code, expect])
|
||||||
|
return 1
|
||||||
|
print("[run_all] PASS %s (exit=%d)" % [path, code])
|
||||||
return 0
|
return 0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://f1xscrew4ser
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# tests/unit/battle_settlement/damage_controller_test.gd — 伤害控制器·伤害单据五配方单元测试
|
||||||
|
# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/damage_controller_test.gd
|
||||||
|
# 覆盖:hero_attack / enemy_skill / board_typed / board_blast / board_flat_hero 五配方 +
|
||||||
|
# 结账时机双模式(A 即结 / B 攒批 flush)+ 死亡作废(来源敌死/主角倒下 halt)+ requires 前置门 +
|
||||||
|
# 池乘区收口(clamped_pool:池内加算→clamp;ac 型不入池)+ AC 合流(override/表值兜底/状态叠层)+
|
||||||
|
# 行协议公共字段(architecture.md API Boundaries B1→D1)+ 同种子逐位复现。
|
||||||
|
# 红线:期望值由表读值推导(零玩法数值字面量);零自建 RNG(RngService 统一流,夹具种子仅求复现)。
|
||||||
|
# 断言口径:归因等式在「无敌人死亡」时严格成立;有死亡时降为下界(超额伤害溢出属合法语义)。
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const ConfigDBScript := preload("res://core/config_db.gd")
|
||||||
|
const RngServiceScript := preload("res://core/rng_service.gd")
|
||||||
|
const UnitContainerScript := preload("res://core/unit_container.gd")
|
||||||
|
const StatusContainerScript := preload("res://core/status_container.gd")
|
||||||
|
const DamageControllerScript := preload("res://core/damage_controller.gd")
|
||||||
|
|
||||||
|
const TEST_SEED := 20260928 # 夹具种子(基建值非玩法数值——回放确定性契约的输入)
|
||||||
|
|
||||||
|
var _failures := 0
|
||||||
|
var _checks := 0
|
||||||
|
var _rows: Array = [] # 当前 run 的行协议收集(_on_row 绑定法——避 lambda 多行控制流坑)
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
var db = ConfigDBScript.new()
|
||||||
|
var load_ok: bool = db.load_tables("res://data/tables", true)
|
||||||
|
_check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
|
||||||
|
if load_ok:
|
||||||
|
_test_five_recipes_and_replay(db)
|
||||||
|
_test_policy_immediate_mode(db)
|
||||||
|
_test_source_death_void(db)
|
||||||
|
_test_requires_gate_and_zero(db)
|
||||||
|
_test_hero_death_halt(db)
|
||||||
|
_test_ac_merge(db)
|
||||||
|
_test_pool_clamp(db)
|
||||||
|
if _failures > 0:
|
||||||
|
push_error("[damage_controller_test] %d/%d 项失败" % [_failures, _checks])
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("[damage_controller_test] all green (%d checks)" % _checks)
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(desc: String, ok: bool) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if ok:
|
||||||
|
print(" [PASS] " + desc)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error(" [FAIL] " + desc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 夹具 ----
|
||||||
|
|
||||||
|
func _on_row(type, source_class, origin_cell, ri, display, value, extra, highlight) -> void:
|
||||||
|
_rows.append({"type": type, "source_class": source_class, "origin_cell": origin_cell,
|
||||||
|
"round_index": ri, "display": display, "value": value, "extra": extra, "highlight": highlight})
|
||||||
|
|
||||||
|
|
||||||
|
func _harness(db):
|
||||||
|
var rng = RngServiceScript.new()
|
||||||
|
rng.set_master_seed(TEST_SEED)
|
||||||
|
var units = UnitContainerScript.new(db)
|
||||||
|
var effects = StatusContainerScript.new(db)
|
||||||
|
var ctrl = DamageControllerScript.new(db, rng, units, effects)
|
||||||
|
ctrl.emit_row_cb = Callable(self, "_on_row")
|
||||||
|
effects.notify_battle_start()
|
||||||
|
var spawn_ok: bool = units.spawn(1, 0, int(db.hero.max_hp))
|
||||||
|
return {"db": db, "rng": rng, "units": units, "effects": effects, "ctrl": ctrl, "spawn_ok": spawn_ok}
|
||||||
|
|
||||||
|
|
||||||
|
func _row_by_display_prefix(prefix: String) -> Dictionary:
|
||||||
|
for r in _rows:
|
||||||
|
if String(r.display).begins_with(prefix):
|
||||||
|
return r
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 五配方一次全流程 + 双跑逐位复现 ----
|
||||||
|
|
||||||
|
func _run_recipe_sequence(db) -> Array:
|
||||||
|
_rows = []
|
||||||
|
var h = _harness(db)
|
||||||
|
var units = h.units
|
||||||
|
var ctrl = h.ctrl
|
||||||
|
if not h.spawn_ok:
|
||||||
|
_check("前置:units.spawn(1,0) 成功(遭遇行在案)", false)
|
||||||
|
return []
|
||||||
|
var hero_before: int = int(units.hero_unit().hp)
|
||||||
|
var enemy_hp_before: Array = []
|
||||||
|
for e in units.all_enemies():
|
||||||
|
enemy_hp_before.append(int(e.hp))
|
||||||
|
var front = units.default_target()
|
||||||
|
var threshold_expected: int = ctrl.defender_ac(front) + int(h.db.settlement.attack_bonus)
|
||||||
|
var atk_type := ""
|
||||||
|
var eff_keys: Array = h.db.settlement.armor_effectiveness.keys()
|
||||||
|
if eff_keys.size() > 0:
|
||||||
|
atk_type = String(eff_keys[0])
|
||||||
|
var floor_v: int = int(h.db.settlement.damage_floor)
|
||||||
|
|
||||||
|
var t1: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack",
|
||||||
|
"T1主角攻击", "战斗段", int(h.db.hero.atk), DamageControllerScript.TARGET_FRONT_ALIVE, 1))
|
||||||
|
# 敌方技来源取后位敌(前位是攻击/直伤/群伤的靶位——来源与靶位分离,免死置干扰配方 redeemed 断言)
|
||||||
|
var alive_list: Array = units.query_alive("enemy")
|
||||||
|
var src0: Dictionary = alive_list[alive_list.size() - 1]
|
||||||
|
var ticket2: Dictionary = ctrl.open_ticket(DamageControllerScript.KIND_ENEMY_SKILL, "enemy_action",
|
||||||
|
"T2敌方技", "战斗段", int(src0.atk), DamageControllerScript.TARGET_HERO, 1,
|
||||||
|
Vector2i(-1, -1), "", floor_v, "", {"skill_id": "fixture_skill", "baseline": int(src0.atk)})
|
||||||
|
ticket2["source_unit"] = src0
|
||||||
|
var t2: int = ctrl.submit(ticket2)
|
||||||
|
var t3: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_TYPED, "clear_reward",
|
||||||
|
"T3克制直伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_FRONT_ALIVE, 1,
|
||||||
|
Vector2i(-1, -1), atk_type))
|
||||||
|
var t4: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward",
|
||||||
|
"T4群伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_ALL_ALIVE, 1))
|
||||||
|
var t5: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit",
|
||||||
|
"T5反噬", "棋盘兑付", floor_v, DamageControllerScript.TARGET_HERO, 1,
|
||||||
|
Vector2i(-1, -1), "", 0, "", {"terrain": "fixture"}))
|
||||||
|
ctrl.flush()
|
||||||
|
|
||||||
|
var outcomes_redeemed := true
|
||||||
|
for id in [t1, t2, t3, t4, t5]:
|
||||||
|
if ctrl.outcome(int(id)) != "redeemed":
|
||||||
|
outcomes_redeemed = false
|
||||||
|
_check("五配方单据 flush 后全部 redeemed(B 模式段界结账)", outcomes_redeemed)
|
||||||
|
|
||||||
|
var hero_after: int = int(units.hero_unit().hp)
|
||||||
|
var r2: Dictionary = _row_by_display_prefix("T2敌方技")
|
||||||
|
var r5: Dictionary = _row_by_display_prefix("T5反噬")
|
||||||
|
_check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)",
|
||||||
|
hero_before - hero_after == int(r2.get("value", -1)) + int(r5.get("value", -1)))
|
||||||
|
_check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v)
|
||||||
|
_check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v)
|
||||||
|
|
||||||
|
var r1: Dictionary = _row_by_display_prefix("T1主角攻击")
|
||||||
|
var ex1: Dictionary = r1.get("extra", {})
|
||||||
|
_check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected)
|
||||||
|
_check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.roll_faces))
|
||||||
|
var tier_ok := false
|
||||||
|
for dv in h.db.settlement.roll_tier_discounts:
|
||||||
|
if int(dv) == int(ex1.get("tier", -1)):
|
||||||
|
tier_ok = true
|
||||||
|
if int(ex1.get("tier", -1)) == 100:
|
||||||
|
tier_ok = true
|
||||||
|
_check("掷点档位 ∈ 表档位值 ∪ 满额", tier_ok)
|
||||||
|
|
||||||
|
var r4: Dictionary = _row_by_display_prefix("T4群伤")
|
||||||
|
var alive_at_blast: int = int(r4.get("extra", {}).get("count", -1))
|
||||||
|
var any_enemy_dead := false
|
||||||
|
var enemy_drop_total := 0
|
||||||
|
var idx := 0
|
||||||
|
for e in units.all_enemies():
|
||||||
|
var drop: int = enemy_hp_before[idx] - int(e.hp)
|
||||||
|
enemy_drop_total += maxi(0, drop)
|
||||||
|
if not bool(e.alive):
|
||||||
|
any_enemy_dead = true
|
||||||
|
idx += 1
|
||||||
|
var attack_landed: int = int(r1.get("value", 0))
|
||||||
|
var typed_landed: int = int(_row_by_display_prefix("T3克制直伤").get("value", 0))
|
||||||
|
if any_enemy_dead:
|
||||||
|
_check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)",
|
||||||
|
enemy_drop_total >= attack_landed + typed_landed + int(r4.get("value", 0)) * maxi(0, alive_at_blast))
|
||||||
|
else:
|
||||||
|
_check("敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)",
|
||||||
|
enemy_drop_total == attack_landed + typed_landed + int(r4.get("value", 0)) * alive_at_blast)
|
||||||
|
|
||||||
|
var protocol_ok := true
|
||||||
|
for r in _rows:
|
||||||
|
if String(r.type) != "damage":
|
||||||
|
continue
|
||||||
|
if String(r.source_class).is_empty() or String(r.display).is_empty():
|
||||||
|
protocol_ok = false
|
||||||
|
if int(r.value) < int(h.db.settlement.damage_floor):
|
||||||
|
protocol_ok = false
|
||||||
|
if not (r.extra is Dictionary) or not r.extra.has("target"):
|
||||||
|
protocol_ok = false
|
||||||
|
_check("行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)", protocol_ok)
|
||||||
|
|
||||||
|
# 复现序列(供双跑对拍):逐行 (display, value)
|
||||||
|
var seq: Array = []
|
||||||
|
for r in _rows:
|
||||||
|
seq.append([String(r.display), int(r.value)])
|
||||||
|
return seq
|
||||||
|
|
||||||
|
|
||||||
|
func _test_five_recipes_and_replay(db) -> void:
|
||||||
|
var run1: Array = _run_recipe_sequence(db)
|
||||||
|
_check("五配方序列产出 5 行伤害行", run1.size() == 5)
|
||||||
|
var run2: Array = _run_recipe_sequence(db)
|
||||||
|
_check("同种子双跑行序列逐位一致(回放确定性契约)", run1 == run2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- A 模式(开单即结——回切位) ----
|
||||||
|
|
||||||
|
func _test_policy_immediate_mode(db) -> void:
|
||||||
|
_rows = []
|
||||||
|
var h = _harness(db)
|
||||||
|
if not h.spawn_ok:
|
||||||
|
return
|
||||||
|
h.ctrl.policy = DamageControllerScript.POLICY_IMMEDIATE
|
||||||
|
var floor_v: int = int(h.db.settlement.damage_floor)
|
||||||
|
var t: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit",
|
||||||
|
"A模式反噬", "棋盘兑付", floor_v, DamageControllerScript.TARGET_HERO, 1))
|
||||||
|
_check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed")
|
||||||
|
_check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 来源敌死亡:队列中未结单据作废(死亡即不动手) ----
|
||||||
|
|
||||||
|
func _test_source_death_void(db) -> void:
|
||||||
|
_rows = []
|
||||||
|
var h = _harness(db)
|
||||||
|
if not h.spawn_ok:
|
||||||
|
return
|
||||||
|
var alive_list: Array = h.units.query_alive("enemy")
|
||||||
|
var src: Dictionary = alive_list[alive_list.size() - 1]
|
||||||
|
var hero_before: int = int(h.units.hero_unit().hp)
|
||||||
|
var ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_ENEMY_SKILL, "enemy_action",
|
||||||
|
"亡敌未结单", "战斗段", int(src.atk), DamageControllerScript.TARGET_HERO, 1)
|
||||||
|
ticket["source_unit"] = src
|
||||||
|
var t: int = h.ctrl.submit(ticket)
|
||||||
|
h.units.apply_damage(src, int(src.max_hp)) # 敌当场倒下 → unit_died → 该敌未结单作废
|
||||||
|
h.ctrl.flush()
|
||||||
|
_check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided")
|
||||||
|
_check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty())
|
||||||
|
|
||||||
|
|
||||||
|
# ---- requires 前置门:前置单未实际有效兑付 → 后继单静默作废;无存活敌 blast = 合法零兑付 ----
|
||||||
|
|
||||||
|
func _test_requires_gate_and_zero(db) -> void:
|
||||||
|
_rows = []
|
||||||
|
var h = _harness(db)
|
||||||
|
if not h.spawn_ok:
|
||||||
|
return
|
||||||
|
var floor_v: int = int(h.db.settlement.damage_floor)
|
||||||
|
# 先清场(flush_submit 同步开单——清图批专用通道的契约面同时被覆盖)
|
||||||
|
var front_hp: int = int(h.units.query_alive("enemy")[0].max_hp)
|
||||||
|
var big: int = front_hp * 10
|
||||||
|
var t_clear: int = h.ctrl.flush_submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward",
|
||||||
|
"清场群伤", "棋盘兑付", big, DamageControllerScript.TARGET_ALL_ALIVE, 1))
|
||||||
|
_check("flush_submit 同步开单即结(清图批契约)", h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty())
|
||||||
|
var t_zero: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward",
|
||||||
|
"空场群伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_ALL_ALIVE, 1))
|
||||||
|
var gated_ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack",
|
||||||
|
"前置门攻击", "战斗段", int(h.db.hero.atk), DamageControllerScript.TARGET_FRONT_ALIVE, 1)
|
||||||
|
gated_ticket["requires"] = t_zero
|
||||||
|
var t_gated: int = h.ctrl.submit(gated_ticket)
|
||||||
|
h.ctrl.flush()
|
||||||
|
_check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero")
|
||||||
|
_check("前置单未实际有效 → 后继单 voided(requires 门)", h.ctrl.outcome(t_gated) == "voided")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 主角倒下:flush 余单全部作废(_halted 终止) ----
|
||||||
|
|
||||||
|
func _test_hero_death_halt(db) -> void:
|
||||||
|
_rows = []
|
||||||
|
var h = _harness(db)
|
||||||
|
if not h.spawn_ok:
|
||||||
|
return
|
||||||
|
var max_hp: int = int(h.db.hero.max_hp)
|
||||||
|
var t_kill: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit",
|
||||||
|
"致死反噬", "棋盘兑付", max_hp, DamageControllerScript.TARGET_HERO, 1))
|
||||||
|
var t_after: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit",
|
||||||
|
"余单", "棋盘兑付", 1, DamageControllerScript.TARGET_HERO, 2))
|
||||||
|
h.ctrl.flush()
|
||||||
|
_check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0)
|
||||||
|
_check("主角倒下后余单 voided(flush 终止)", h.ctrl.outcome(t_after) == "voided")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- AC 合流:override 入参 / 表值兜底 / ac 型状态叠层(不入池) ----
|
||||||
|
|
||||||
|
func _test_ac_merge(db) -> void:
|
||||||
|
_rows = []
|
||||||
|
var h = _harness(db)
|
||||||
|
if not h.spawn_ok:
|
||||||
|
return
|
||||||
|
var base_ac: int = int(h.db.hero.armor_class)
|
||||||
|
_check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac)
|
||||||
|
var override_v: int = base_ac + int(h.db.settlement.damage_floor)
|
||||||
|
h.ctrl.set_hero_ac_override(override_v)
|
||||||
|
_check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v)
|
||||||
|
# ac 型状态效果叠其上(找 hero 侧 unit=ac 行)
|
||||||
|
var ac_eff := {}
|
||||||
|
for row in h.db.status_effects.rows:
|
||||||
|
if String(row.get("unit", "")) == "ac" and String(row.get("target_side", "")) == "hero":
|
||||||
|
ac_eff = row
|
||||||
|
break
|
||||||
|
if ac_eff.is_empty():
|
||||||
|
_check("前置:状态表存在 hero 侧 ac 型行", false)
|
||||||
|
return
|
||||||
|
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
|
||||||
|
h.effects.apply_effect(String(ac_eff.effect_id), src)
|
||||||
|
var expected: int = override_v + int(ac_eff.magnitude)
|
||||||
|
_check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 池乘区收口:池内加算 → clamp;ac 型不入池 ----
|
||||||
|
|
||||||
|
func _test_pool_clamp(db) -> void:
|
||||||
|
_rows = []
|
||||||
|
var h = _harness(db)
|
||||||
|
if not h.spawn_ok:
|
||||||
|
return
|
||||||
|
var lo: int = int(h.db.settlement.pool_clamp_lo)
|
||||||
|
var hi: int = int(h.db.settlement.pool_clamp_hi)
|
||||||
|
_check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0)
|
||||||
|
# 找 hero_offense 池的 percent 型 hero 侧效果行
|
||||||
|
var pool_eff := {}
|
||||||
|
for row in h.db.status_effects.rows:
|
||||||
|
var eid := String(row.get("effect_id", ""))
|
||||||
|
if String(row.get("unit", "")) == "percent" and String(row.get("target_side", "")) == "hero" \
|
||||||
|
and String(h.db.settlement.effect_pool_map.get(eid, "")) == "hero_offense":
|
||||||
|
pool_eff = row
|
||||||
|
break
|
||||||
|
if pool_eff.is_empty():
|
||||||
|
_check("前置:hero_offense 池存在 percent 型 hero 侧行", false)
|
||||||
|
return
|
||||||
|
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
|
||||||
|
var first_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src)
|
||||||
|
var expected1: int = clampi(int(pool_eff.magnitude), lo, hi)
|
||||||
|
_check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1)
|
||||||
|
var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src)
|
||||||
|
_check("refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变",
|
||||||
|
second_ok and h.ctrl.clamped_pool("hero_offense") == expected1)
|
||||||
|
# ac 型效果不入 percent 池
|
||||||
|
var ac_eff2 := {}
|
||||||
|
for row2 in h.db.status_effects.rows:
|
||||||
|
if String(row2.get("unit", "")) == "ac" and String(row2.get("target_side", "")) == "hero":
|
||||||
|
ac_eff2 = row2
|
||||||
|
break
|
||||||
|
if not ac_eff2.is_empty():
|
||||||
|
var before: int = h.ctrl.clamped_pool("hero_offense")
|
||||||
|
h.effects.apply_effect(String(ac_eff2.effect_id), src)
|
||||||
|
_check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before)
|
||||||
|
# stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入)
|
||||||
|
var stack_eff := {}
|
||||||
|
for row3 in h.db.status_effects.rows:
|
||||||
|
if String(row3.get("stack_policy", "")) == "stack":
|
||||||
|
stack_eff = row3
|
||||||
|
break
|
||||||
|
if stack_eff.is_empty():
|
||||||
|
_check("前置:状态表存在 stack 政策行", false)
|
||||||
|
return
|
||||||
|
var effects2 = StatusContainerScript.new(h.db)
|
||||||
|
effects2.notify_battle_start()
|
||||||
|
var granted := 0
|
||||||
|
var max_n: int = int(stack_eff.max_stack)
|
||||||
|
for i in range(max_n + 1):
|
||||||
|
if effects2.apply_effect(String(stack_eff.effect_id), src):
|
||||||
|
granted += 1
|
||||||
|
_check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bg3nmj4y2rxxk
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# tests/unit/battle_settlement/damage_math_test.gd — 战斗结算·伤害公式族纯函数单元测试
|
||||||
|
# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/damage_math_test.gd
|
||||||
|
# 协议:DamageMath = 零状态纯算术宿主(headless 可跑);掷点档位参数全读 settlement 表(零玩法数值字面量)。
|
||||||
|
# 注:0/100 等出现在断言中的数值 = 语义常量(中性池/百分比满额),非玩法行值。
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const DamageMathScript := preload("res://core/damage_math.gd")
|
||||||
|
const ConfigDBScript := preload("res://core/config_db.gd")
|
||||||
|
|
||||||
|
var _failures := 0
|
||||||
|
var _checks := 0
|
||||||
|
var db
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
db = ConfigDBScript.new()
|
||||||
|
var load_ok: bool = db.load_tables("res://data/tables", true)
|
||||||
|
_check("前置:settlement 表加载零 error", load_ok and db.diagnostics_count("error") == 0)
|
||||||
|
if load_ok:
|
||||||
|
_test_pool_damage()
|
||||||
|
_test_counter_component()
|
||||||
|
_test_roll_discount_percent()
|
||||||
|
_test_rolled_component()
|
||||||
|
if _failures > 0:
|
||||||
|
push_error("[damage_math_test] %d/%d 项失败" % [_failures, _checks])
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("[damage_math_test] all green (%d checks)" % _checks)
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(desc: String, ok: bool) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if ok:
|
||||||
|
print(" [PASS] " + desc)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error(" [FAIL] " + desc)
|
||||||
|
|
||||||
|
|
||||||
|
func _floor_val() -> int:
|
||||||
|
return int(db.settlement.damage_floor)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- F2 乘区:池内加算 → 池间乘算 → floor(单一取整规则)----
|
||||||
|
|
||||||
|
func _test_pool_damage() -> void:
|
||||||
|
var d := _floor_val() + 9 # 表值推导的任意正探针量(非玩法行值)
|
||||||
|
_check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d)
|
||||||
|
_check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.pool_clamp_lo)) <= d)
|
||||||
|
_check("正主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_hi), 0) >= d)
|
||||||
|
_check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0))
|
||||||
|
_check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30))
|
||||||
|
_check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_lo)) >= 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 克制分量:空桶不吃修正恒 0;落地下限保底([1,+∞) 全来源)----
|
||||||
|
|
||||||
|
func _test_counter_component() -> void:
|
||||||
|
var floor_v := _floor_val()
|
||||||
|
_check("空桶(点数≤0)恒 0 不吃修正", DamageMathScript.counter_component(0, floor_v, floor_v) == 0
|
||||||
|
and DamageMathScript.counter_component(-1, 5, floor_v) == 0)
|
||||||
|
_check("正向修正直加", DamageMathScript.counter_component(floor_v, floor_v, floor_v) == floor_v * 2)
|
||||||
|
_check("负修正触底保落地伤害下限", DamageMathScript.counter_component(floor_v, -floor_v * 10, floor_v) == floor_v)
|
||||||
|
_check("下限参数缺省 = 0(无下限语义面)", DamageMathScript.counter_component(floor_v, -floor_v * 10) == 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 掷点折扣查档(纯函数):满阈值恒全额;档位边界/末档语义与表数组一致 ----
|
||||||
|
|
||||||
|
func _test_roll_discount_percent() -> void:
|
||||||
|
var threshold := 10 # 查档探针阈值(夹具值——档位参数本身全部来自表)
|
||||||
|
var bounds: Array = db.settlement.roll_tier_bounds
|
||||||
|
var discounts: Array = db.settlement.roll_tier_discounts
|
||||||
|
_check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0)
|
||||||
|
_check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100
|
||||||
|
and DamageMathScript.roll_discount_percent(threshold + 5, threshold, bounds, discounts) == 100)
|
||||||
|
# 缺口 = 1:落首档(缺口 ∈ [1, bounds[0]] → discounts[0])
|
||||||
|
var tier0_expected: int = int(discounts[0])
|
||||||
|
if int(bounds[0]) >= 1:
|
||||||
|
_check("缺口 1 落首档", DamageMathScript.roll_discount_percent(threshold - 1, threshold, bounds, discounts) == tier0_expected)
|
||||||
|
# 缺口超末边界:落末档
|
||||||
|
var last_expected: int = int(discounts[-1])
|
||||||
|
_check("缺口超末边界落末档", DamageMathScript.roll_discount_percent(threshold - int(bounds[-1]) - 1, threshold, bounds, discounts) == last_expected)
|
||||||
|
# 档位值降序(重档折扣更深)——表自检
|
||||||
|
var descending := true
|
||||||
|
for i in range(discounts.size() - 1):
|
||||||
|
if int(discounts[i]) < int(discounts[i + 1]):
|
||||||
|
descending = false
|
||||||
|
_check("档位值随档位加深不升(折扣单调)", descending)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 吃掷点分量算术:折扣→(暴击)→floor→+克制修正(单一取整链)----
|
||||||
|
|
||||||
|
func _test_rolled_component() -> void:
|
||||||
|
var base := float(_floor_val() + 7) # 表值推导探针基线
|
||||||
|
var crit_mult: int = int(db.settlement.crit_multiplier)
|
||||||
|
_check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base)))
|
||||||
|
_check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0)))
|
||||||
|
_check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0))
|
||||||
|
_check("克制修正加在 floor 之后(加法核)", DamageMathScript.rolled_component(base, 100, false, crit_mult, _floor_val()) == int(floor(base)) + _floor_val())
|
||||||
|
_check("复合序 = 基线×折扣%×暴击%(floor 只在链末)",
|
||||||
|
DamageMathScript.rolled_component(base, 50, true, crit_mult, 0) == int(floor(base * 50.0 / 100.0 * float(crit_mult) / 100.0)))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ce116wl7vbdiu
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# tests/unit/contracts/system_contracts_test.gd — 系统间接口契约断言(宁缺勿错——只断言已定稿行为)
|
||||||
|
# 运行:godot --headless --path game --script res://tests/unit/contracts/system_contracts_test.gd
|
||||||
|
# 契约线清单来源:design/dev/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 DamageControllerScript := preload("res://core/damage_controller.gd")
|
||||||
|
|
||||||
|
var _failures := 0
|
||||||
|
var _checks := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
var db = ConfigDBScript.new()
|
||||||
|
var load_ok: bool = db.load_tables("res://data/tables", true)
|
||||||
|
_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)
|
||||||
|
if _failures > 0:
|
||||||
|
push_error("[system_contracts_test] %d/%d 项失败" % [_failures, _checks])
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("[system_contracts_test] all green (%d checks)" % _checks)
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(desc: String, ok: bool) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if ok:
|
||||||
|
print(" [PASS] " + desc)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error(" [FAIL] " + desc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 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
|
||||||
|
_check("五配方常量非空(开单面唯一分派键)", nonempty)
|
||||||
|
var unique_kinds: Dictionary = {}
|
||||||
|
for k in kinds:
|
||||||
|
unique_kinds[String(k)] = true
|
||||||
|
_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
|
||||||
|
_check("三目标规则常量互异(结账时刻现场解析词表)", unique_targets.size() == targets.size())
|
||||||
|
_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.settlement.armor_roll_stream)
|
||||||
|
_check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)",
|
||||||
|
RngServiceScript.PURPOSE_TAGS.has(stream_label))
|
||||||
|
_check("RngService 用途标签词表非空且互异(封闭语义由流改标签)",
|
||||||
|
RngServiceScript.PURPOSE_TAGS.size() > 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 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",
|
||||||
|
"契约探针", "棋盘兑付", 1, DamageControllerScript.TARGET_HERO, 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
|
||||||
|
_check("单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)", all_present)
|
||||||
|
_check("缺省无类型轴(attack_type 空 = 恒不查克制矩阵)", String(t.attack_type) == "")
|
||||||
|
_check("缺省无前置依赖(requires = -1)", int(t.requires) == -1)
|
||||||
|
|
||||||
|
|
||||||
|
func _bare_controller():
|
||||||
|
var db = ConfigDBScript.new()
|
||||||
|
# 只取 open_ticket 纯构造面——容器/依赖传桩(构造期不消费)
|
||||||
|
var units = UnitContainerStub.new()
|
||||||
|
var effects = StatusContainerStub.new()
|
||||||
|
return DamageControllerScript.new(db, RngStub.new(), units, effects)
|
||||||
|
|
||||||
|
|
||||||
|
# 桩容器:构造期零消费(DamageController._init 只 connect unit_died 信号——桩需同名信号)
|
||||||
|
class UnitContainerStub:
|
||||||
|
signal unit_died(u: Dictionary)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusContainerStub:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RngStub:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 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.status_effects.rows.size() > 0:
|
||||||
|
any_row = db.status_effects.rows[0]
|
||||||
|
_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}
|
||||||
|
_check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, src) == false)
|
||||||
|
_check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", src) == false)
|
||||||
|
effects.notify_battle_start()
|
||||||
|
_check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, src) == true)
|
||||||
|
var active: Array = effects.query_active(String(any_row.target_side))
|
||||||
|
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)
|
||||||
|
_check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://k4da3suvcbn
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# tests/unit/data_config/config_db_fatal_oob_test.gd — 致命类用例(独立进程,期望退出码 1)
|
||||||
|
# 进度坐标低于合法域(关卡序号 <1)触发 ConfigDB fail-fast(运行期数据致命——2026-09-18 用户拍板)。
|
||||||
|
# 运行:godot --headless --path game --script res://tests/unit/data_config/config_db_fatal_oob_test.gd
|
||||||
|
# 退出码协议:1 = fatal 通道正确触发(预期);2 = fatal 未触发(回归信号,与 1 区分便于诊断)。
|
||||||
|
# 时序注:_fatal 的 quit 通道依赖 Engine.get_main_loop() 已注册主循环——--script 的 _init 期未注册,
|
||||||
|
# 越域请求延后到首帧(call_deferred)再触发,保 quit(1) 通道而非 OS.crash 兜底。
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const ConfigDBScript := preload("res://core/config_db.gd")
|
||||||
|
|
||||||
|
var _db
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_db = ConfigDBScript.new()
|
||||||
|
var load_ok: bool = _db.load_tables("res://data/tables", true)
|
||||||
|
if not load_ok:
|
||||||
|
push_error("[fatal_oob_test] 前置表加载失败,无法测 fatal 通道")
|
||||||
|
quit(2)
|
||||||
|
return
|
||||||
|
_db.reset_diagnostics()
|
||||||
|
call_deferred("_trigger_oob")
|
||||||
|
|
||||||
|
|
||||||
|
func _trigger_oob() -> void:
|
||||||
|
# 越域请求(关卡序号 0 < 合法域 ≥1)——_fatal 内部 push fatal 诊断并 quit(1)(帧末退出)
|
||||||
|
var got: Dictionary = _db.resolve_board_gen(0, 0)
|
||||||
|
if _db.diagnostics_count("fatal") >= 1:
|
||||||
|
print("[fatal_oob_test] fatal 通道已触发(诊断在案)")
|
||||||
|
quit(1) # 与 _fatal 的 quit(1) 同码双保险
|
||||||
|
return
|
||||||
|
# 兜底:fatal 未触发 = 回归——显式退出码 2 与预期 1 区分
|
||||||
|
if got.is_empty():
|
||||||
|
push_error("[fatal_oob_test] 返回空但无 fatal 诊断——fail-fast 通道静默化(回归)")
|
||||||
|
else:
|
||||||
|
push_error("[fatal_oob_test] 越域请求未触发 fatal 且返回了行——fail-fast 失效(回归)")
|
||||||
|
quit(2)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b5rujibws7uqp
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# tests/unit/data_config/config_db_test.gd — 数据配置单元测试:表加载/进度坐标 ceil 解析/变异池门控/消费面访问器
|
||||||
|
# 运行:godot --headless --path game --script res://tests/unit/data_config/config_db_test.gd
|
||||||
|
# 协议(ADR-0004 + tests/README):extends SceneTree + quit(code);--script 模式无 autoload——显式实例化。
|
||||||
|
# 红线:期望值由表读值推导,不写死玩法数值(tests/README 协议要点)。
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const ConfigDBScript := preload("res://core/config_db.gd")
|
||||||
|
|
||||||
|
var _failures := 0
|
||||||
|
var _checks := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
var db = ConfigDBScript.new()
|
||||||
|
var load_ok: bool = db.load_tables("res://data/tables", true)
|
||||||
|
_check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0)
|
||||||
|
_check("is_ready 通道随加载置位", db.is_ready)
|
||||||
|
if not load_ok:
|
||||||
|
for d in db.diagnostics():
|
||||||
|
if d.severity == "error":
|
||||||
|
push_error("[config_db_test] 加载 error: [%s] %s" % [d.category, d.message])
|
||||||
|
else:
|
||||||
|
_test_progress_resolution(db)
|
||||||
|
_test_variant_pool_gate(db)
|
||||||
|
_test_shop_accessors(db)
|
||||||
|
_test_meta_accessors(db)
|
||||||
|
_test_manifest_dir_sync(db)
|
||||||
|
if _failures > 0:
|
||||||
|
push_error("[config_db_test] %d/%d 项失败" % [_failures, _checks])
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("[config_db_test] all green (%d checks)" % _checks)
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(desc: String, ok: bool) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if ok:
|
||||||
|
print(" [PASS] " + desc)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error(" [FAIL] " + desc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 进度坐标 ceil 解析(R1:向上下取整保证偏难;越最大行 clamp 取最大值 + 警告去重)----
|
||||||
|
|
||||||
|
func _test_progress_resolution(db) -> void:
|
||||||
|
var bg_rows: Array = db.board_gen.rows
|
||||||
|
# 行键全集自表推导(禁写死关卡/深度数值)
|
||||||
|
var level_keys: Array = []
|
||||||
|
var depth_keys_by_level: Dictionary = {}
|
||||||
|
for row in bg_rows:
|
||||||
|
var lv := int(row.level_index)
|
||||||
|
if not level_keys.has(lv):
|
||||||
|
level_keys.append(lv)
|
||||||
|
if not depth_keys_by_level.has(lv):
|
||||||
|
depth_keys_by_level[lv] = []
|
||||||
|
var dv := int(row.node_depth)
|
||||||
|
if not depth_keys_by_level[lv].has(dv):
|
||||||
|
depth_keys_by_level[lv].append(dv)
|
||||||
|
level_keys.sort()
|
||||||
|
var min_l: int = level_keys[0]
|
||||||
|
var max_l: int = level_keys[-1]
|
||||||
|
var min_depths: Array = depth_keys_by_level[min_l]
|
||||||
|
min_depths.sort()
|
||||||
|
|
||||||
|
# 精确命中:请求 = 表内现存 (L,D) → 原行返回(字段逐位相等)
|
||||||
|
var probe_row: Dictionary = bg_rows[0]
|
||||||
|
var got: Dictionary = db.resolve_board_gen(int(probe_row.level_index), int(probe_row.node_depth))
|
||||||
|
_check("进度解析精确命中返回原行(board_gen 首行)",
|
||||||
|
int(got.get("level_index", -99)) == int(probe_row.level_index)
|
||||||
|
and int(got.get("node_depth", -99)) == int(probe_row.node_depth)
|
||||||
|
and String(got.get("board_type", "")) == String(probe_row.get("board_type", "")))
|
||||||
|
|
||||||
|
# enemy 行集同通道:同 (L,D) 精确行存在(关卡节点地图 前置——battle 节点双表精确行契约)
|
||||||
|
var en_got: Dictionary = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth))
|
||||||
|
_check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty())
|
||||||
|
|
||||||
|
# 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重)
|
||||||
|
db.reset_diagnostics()
|
||||||
|
var deep: Dictionary = db.resolve_board_gen(min_l, int(min_depths[-1]) + 1)
|
||||||
|
_check("深度越最大行 clamp 取该关最大深度行",
|
||||||
|
int(deep.get("level_index", -99)) == min_l and int(deep.get("node_depth", -99)) == int(min_depths[-1]))
|
||||||
|
_check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1)
|
||||||
|
var found_clamp := false
|
||||||
|
for d in db.diagnostics():
|
||||||
|
if d.category == "resolve_depth_clamp":
|
||||||
|
found_clamp = true
|
||||||
|
_check("警告类别 = resolve_depth_clamp", found_clamp)
|
||||||
|
var warn_before: int = db.diagnostics_count("warning")
|
||||||
|
db.resolve_board_gen(min_l, int(min_depths[-1]) + 1)
|
||||||
|
_check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before)
|
||||||
|
|
||||||
|
# 关卡 clamp:请求关卡超最大行 → 取最大关卡行
|
||||||
|
db.reset_diagnostics()
|
||||||
|
var high: Dictionary = db.resolve_board_gen(max_l + 1, 0)
|
||||||
|
_check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 变异池门控(unlock_level 闸 + ∅ board_types 通配)----
|
||||||
|
|
||||||
|
func _test_variant_pool_gate(db) -> void:
|
||||||
|
var rows: Array = db.variation.rows
|
||||||
|
var gated_row := {}
|
||||||
|
var wildcard_row := {}
|
||||||
|
for row in rows:
|
||||||
|
var tags: Array = row.get("board_types", [])
|
||||||
|
if gated_row.is_empty() and not tags.is_empty():
|
||||||
|
gated_row = row
|
||||||
|
if wildcard_row.is_empty() and tags.is_empty():
|
||||||
|
wildcard_row = row
|
||||||
|
if not gated_row.is_empty():
|
||||||
|
var u: int = int(gated_row.unlock_level)
|
||||||
|
var tag: String = String(gated_row.board_types[0])
|
||||||
|
var before: Array = db.variant_pool(u - 1, tag)
|
||||||
|
var after: Array = db.variant_pool(u, tag)
|
||||||
|
_check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row))
|
||||||
|
_check("变异池 unlock 当级含该行", _rows_contain(after, gated_row))
|
||||||
|
if not wildcard_row.is_empty():
|
||||||
|
var u2: int = int(wildcard_row.unlock_level)
|
||||||
|
var any_pool: Array = db.variant_pool(u2, "nonexistent_board_type_tag")
|
||||||
|
_check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row))
|
||||||
|
|
||||||
|
|
||||||
|
func _rows_contain(pool: Array, row: Dictionary) -> bool:
|
||||||
|
for r in pool:
|
||||||
|
if r == row:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ShopTable 消费面(行集解析单点——runner/编排禁自扫表的契约消费侧验证)----
|
||||||
|
|
||||||
|
func _test_shop_accessors(db) -> void:
|
||||||
|
var products: Array = db.shop_products()
|
||||||
|
if products.is_empty():
|
||||||
|
_check("ShopTable products 行族非空(表已落籍前提)", false)
|
||||||
|
return
|
||||||
|
_check("ShopTable products 行族非空", true)
|
||||||
|
var first: Dictionary = products[0]
|
||||||
|
var got: Dictionary = db.shop_product_row(String(first.product_id))
|
||||||
|
_check("shop_product_row 按 product_id 精确命中首行", got == first)
|
||||||
|
_check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty())
|
||||||
|
var tier_rows: Array = db._shop_rows("gp_drops")
|
||||||
|
if not tier_rows.is_empty():
|
||||||
|
var tier0: String = String(tier_rows[0].tier)
|
||||||
|
_check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty())
|
||||||
|
var bonus_rows: Array = db._shop_rows("level_gp_bonus")
|
||||||
|
if not bonus_rows.is_empty():
|
||||||
|
var blv: int = int(bonus_rows[0].level_index)
|
||||||
|
_check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus))
|
||||||
|
_check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0)
|
||||||
|
var price_rows: Array = db._shop_rows("price_level_bonus")
|
||||||
|
if not price_rows.is_empty():
|
||||||
|
var plv: int = int(price_rows[0].level_index)
|
||||||
|
_check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- MetaTable 消费面 ----
|
||||||
|
|
||||||
|
func _test_meta_accessors(db) -> void:
|
||||||
|
var forge_rows: Array = db._meta_rows("forge_products")
|
||||||
|
if forge_rows.is_empty():
|
||||||
|
_check("MetaTable forge_products 行族非空", false)
|
||||||
|
else:
|
||||||
|
var attr0: String = String(forge_rows[0].attr)
|
||||||
|
_check("meta_forge_row 按 attr 命中首行", db.meta_forge_row(attr0) == forge_rows[0])
|
||||||
|
_check("meta_forge_row 未知 attr 返回空字典", db.meta_forge_row("no_such_attr__").is_empty())
|
||||||
|
var key_rows: Array = db._meta_rows("boss_keystone")
|
||||||
|
if not key_rows.is_empty():
|
||||||
|
var klv: int = int(key_rows[0].level_index)
|
||||||
|
_check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0])
|
||||||
|
var conv_rows: Array = db._meta_rows("conversion")
|
||||||
|
if not conv_rows.is_empty():
|
||||||
|
var cattr: String = String(conv_rows[0].attr)
|
||||||
|
_check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0])
|
||||||
|
|
||||||
|
|
||||||
|
# ---- TABLE_MANIFEST ↔ data/tables 目录同步(各系统→D2 单点契约的登记面)----
|
||||||
|
|
||||||
|
func _test_manifest_dir_sync(db) -> void:
|
||||||
|
var manifest: Dictionary = db.TABLE_MANIFEST
|
||||||
|
var dir := DirAccess.open("res://data/tables")
|
||||||
|
var disk_files: Array = []
|
||||||
|
if dir != null:
|
||||||
|
dir.list_dir_begin()
|
||||||
|
var f := dir.get_next()
|
||||||
|
while f != "":
|
||||||
|
if not dir.current_is_dir() and f.ends_with(".tres"):
|
||||||
|
disk_files.append(f)
|
||||||
|
f = dir.get_next()
|
||||||
|
dir.list_dir_end()
|
||||||
|
var synced := disk_files.size() == manifest.size()
|
||||||
|
if synced:
|
||||||
|
for key in manifest.keys():
|
||||||
|
if not disk_files.has(String(manifest[key])):
|
||||||
|
synced = false
|
||||||
|
_check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cxjn3c8p4dl68
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# tests/unit/data_config/rng_service_test.gd — 统一随机流单元测试:单盐派生确定性/计数器账本/标签词表封闭
|
||||||
|
# 运行:godot --headless --path game --script res://tests/unit/data_config/rng_service_test.gd
|
||||||
|
# 红线:零自建 RNG——本测试只经 RngService 统一流取数(确定性契约:同种子逐位复现)。
|
||||||
|
# 测试种子为夹具基建值(非玩法数值),与冒烟黄金向量锚(smoke_all m=42 scope 0-4)分工:本文件测
|
||||||
|
# 「同种子两实例逐位一致」的相对确定性,绝对向量锚归冒烟单点,避免双处维护同一常量。
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const RngServiceScript := preload("res://core/rng_service.gd")
|
||||||
|
|
||||||
|
const TEST_SEED := 20260928 # 夹具种子(任意固定值;语义 = 可复现性,非玩法数值)
|
||||||
|
|
||||||
|
var _failures := 0
|
||||||
|
var _checks := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_test_derive_determinism()
|
||||||
|
_test_counter_ledger()
|
||||||
|
_test_stream_draw_determinism()
|
||||||
|
_test_negative_seed_normalization()
|
||||||
|
if _failures > 0:
|
||||||
|
push_error("[rng_service_test] %d/%d 项失败" % [_failures, _checks])
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("[rng_service_test] all green (%d checks)" % _checks)
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(desc: String, ok: bool) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if ok:
|
||||||
|
print(" [PASS] " + desc)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error(" [FAIL] " + desc)
|
||||||
|
|
||||||
|
|
||||||
|
func _fresh_service():
|
||||||
|
var rng = RngServiceScript.new()
|
||||||
|
rng.set_master_seed(TEST_SEED)
|
||||||
|
return rng
|
||||||
|
|
||||||
|
|
||||||
|
# 单盐派生纯函数:两全新实例同种子逐位一致(derive_at 不推计数器——纯函数契约)
|
||||||
|
func _test_derive_determinism() -> void:
|
||||||
|
var a = _fresh_service()
|
||||||
|
var b = _fresh_service()
|
||||||
|
var same := true
|
||||||
|
for scope in range(8):
|
||||||
|
if a.derive_at(scope) != b.derive_at(scope):
|
||||||
|
same = false
|
||||||
|
_check("同种子两实例 derive_at(0..7) 逐位一致", same)
|
||||||
|
var a2 = _fresh_service()
|
||||||
|
var before: int = a2.counter()
|
||||||
|
a2.derive_at(0)
|
||||||
|
_check("derive_at 为纯函数不推计数器", a2.counter() == before)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局取数计数器:唯一账本——open_stream 逐次 +1(scope 单调)
|
||||||
|
func _test_counter_ledger() -> void:
|
||||||
|
var rng = _fresh_service()
|
||||||
|
var c0: int = rng.counter()
|
||||||
|
rng.open_stream("armor_roll")
|
||||||
|
var c1: int = rng.counter()
|
||||||
|
rng.open_stream("armor_roll")
|
||||||
|
var c2: int = rng.counter()
|
||||||
|
_check("open_stream 逐次推进全局计数器(+1/次)", c1 == c0 + 1 and c2 == c0 + 2)
|
||||||
|
# 用途标签只进事件日志不参与派生——同 scope 同派生种子(ADR-0007 裁丙口径)
|
||||||
|
var rng_x = _fresh_service()
|
||||||
|
var rng_y = _fresh_service()
|
||||||
|
var s1: int = int(rng_x.open_stream("board_gen").seed)
|
||||||
|
var s2: int = int(rng_y.open_stream("reward_roll").seed)
|
||||||
|
_check("同 scope 异用途标签派生种子一致(标签不参与派生)", s1 == s2)
|
||||||
|
|
||||||
|
|
||||||
|
# 同种子两实例同标签流的实际取数序列逐位一致(回放确定性契约——存档快照=主种子+计数器的前提)
|
||||||
|
func _test_stream_draw_determinism() -> void:
|
||||||
|
var faces := 20 # d20 探针面数(夹具值,非玩法表值——只用于产生可比较序列)
|
||||||
|
var a = _fresh_service()
|
||||||
|
var b = _fresh_service()
|
||||||
|
var same := true
|
||||||
|
for i in range(12):
|
||||||
|
var ra: int = a.open_stream("armor_roll").randi_range(1, faces)
|
||||||
|
var rb: int = b.open_stream("armor_roll").randi_range(1, faces)
|
||||||
|
if ra != rb:
|
||||||
|
same = false
|
||||||
|
_check("同种子两实例同标签流取数 12 次逐位一致", same)
|
||||||
|
|
||||||
|
|
||||||
|
# 负输入 abs 归一化(警告通道)——|−s| 与 s 同派生
|
||||||
|
func _test_negative_seed_normalization() -> void:
|
||||||
|
var pos = _fresh_service()
|
||||||
|
var neg = RngServiceScript.new()
|
||||||
|
neg.set_master_seed(-TEST_SEED)
|
||||||
|
_check("负种子 abs 归一化后派生与正种子一致", pos.derive_at(0) == neg.derive_at(0))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bgqcfrksa3g6w
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://csesvbdvqe8m3
|
||||||
Reference in new issue
Block a user