extends SceneTree ## Characterization test for the bull's damage reception. It pins the OBSERVABLE behaviour of ## take_sword_hit / apply_knock_up — pip loss, i-frame absorption, a single tagged death, and ## upward-only knock-up — before the WS2 slice reroutes them through DamageEvent, so the rewire can ## prove it changed nothing. When receive_damage(DamageEvent) lands, add a block that drives it and ## asserts the identical outcomes. ## Run: godot --headless --script tests/damage_pipeline_test.gd var _pass := 0 var _fail := 0 var _died_count := 0 var _last_cause := "" var _last_hp := -1 func _init() -> void: _run.call_deferred() func _ok(cond: bool, msg: String) -> void: if cond: _pass += 1 print(" PASS: ", msg) else: _fail += 1 print(" FAIL: ", msg) func _on_died(cause: String) -> void: _died_count += 1 _last_cause = cause func _on_health(current: int, _max_hp: int) -> void: _last_hp = current func _run() -> void: (root.get_node("/root/Run") as Node).call("ensure_run") var scene: Node = load("res://scene.tscn").instantiate() root.add_child(scene) current_scene = scene await create_timer(0.8).timeout var players := get_nodes_in_group(&"player") _ok(not players.is_empty(), "bull present in the shell") if players.is_empty(): return _finish() var bull: Node = players[0] bull.connect(&"died", _on_died) bull.connect(&"health_changed", _on_health) # Everything below runs synchronously (no await), so no physics frame or enemy turn interleaves — # the bull's state changes only from the calls under test. bull.set(&"_max_hp", 3) bull.set(&"_hp", 3) bull.set(&"_dead", false) bull.set(&"_hit_iframes", 0.0) bull.set(&"velocity", Vector3.ZERO) bull.call(&"take_sword_hit", "gored") _ok(int(bull.get(&"_hp")) == 2, "a clean hit removes one pip (3→2)") _ok(_last_hp == 2, "health_changed reports the new pip count") bull.call(&"take_sword_hit", "gored") _ok(int(bull.get(&"_hp")) == 2, "i-frames absorb a second hit in the same window") bull.set(&"_hit_iframes", 0.0) bull.call(&"take_sword_hit", "gored") _ok(int(bull.get(&"_hp")) == 1, "a hit after i-frames removes another pip (2→1)") bull.set(&"_hit_iframes", 0.0) bull.call(&"take_sword_hit", "gored") _ok(int(bull.get(&"_hp")) <= 0, "the last pip drops the bull") _ok(_died_count == 1, "died emits exactly once") _ok(_last_cause == "gored", "died carries the hit's cause") bull.set(&"_hit_iframes", 0.0) bull.call(&"take_sword_hit", "thrown") _ok(_died_count == 1, "a downed bull takes no further hits (death latches)") bull.set(&"velocity", Vector3.ZERO) bull.call(&"apply_knock_up", 5.0) _ok(is_equal_approx(float((bull.get(&"velocity") as Vector3).y), 5.0), "knock_up raises velocity.y to the pop") bull.call(&"apply_knock_up", 3.0) _ok(is_equal_approx(float((bull.get(&"velocity") as Vector3).y), 5.0), "a smaller knock_up never lowers it") bull.call(&"apply_knock_up", 8.0) _ok(is_equal_approx(float((bull.get(&"velocity") as Vector3).y), 8.0), "a larger knock_up raises it further") # The typed pipeline (Hitbox → DamageEvent → receive_damage) must produce identical outcomes to # the string path it now wraps. _last_hp = -1 bull.set(&"_hp", 3) bull.set(&"_dead", false) bull.set(&"_hit_iframes", 0.0) bull.set(&"velocity", Vector3.ZERO) bull.call(&"receive_damage", DamageEvent.make(DamageEvent.Cause.MAULED, Vector3.ZERO)) _ok(int(bull.get(&"_hp")) == 2, "receive_damage removes a pip like take_sword_hit") _ok(_last_hp == 2, "receive_damage emits health_changed with the new pip count") bull.set(&"_hit_iframes", 0.0) bull.set(&"velocity", Vector3.ZERO) var popping := DamageEvent.make(DamageEvent.Cause.SLAMMED, Vector3.ZERO) popping.knock_up = 6.0 bull.call(&"receive_damage", popping) _ok(is_equal_approx(float((bull.get(&"velocity") as Vector3).y), 6.0), "receive_damage applies the event's knock_up") _finish() func _finish() -> void: print("Results: %d passed, %d failed" % [_pass, _fail]) quit(1 if _fail > 0 else 0)