7b6a141767
Move the 40-odd root-level scripts and scenes into a tree that groups by role,
per the approved roadmap (WS2/WS3): actors/{player,matador,bear}, systems/{combat,
fx,input,debug,render,crowd,config}, ui/, props/ (was tscn_s/). Rename the shell
scene.tscn -> levels/game_shell.tscn and every PascalCase scene to snake_case
(Player.tscn -> player.tscn, MainMenu.tscn -> main_menu.tscn, etc.) so the
case-insensitive web/macOS filesystems can't alias two files together.
Done as a scripted move: each file travels with its .uid; every quoted res:// path
across scenes, scripts, .tres, and project.godot's 9 autoloads is rewritten; the
.godot uid/class cache is rebuilt from scratch. Scenes still resolve by uid, so the
rewrite is belt-and-suspenders. Lint globs (run_tests.sh + both CI workflows) now
walk the new dirs; an orphan animation_tree.gd.uid and 7 pre-existing trailing-
whitespace lints (barrel/torch, never covered by the old root-only glob) are cleaned.
Full suite green on both sides. Asset consolidation (Assets/Animations/fonts ->
art/) is deferred — moving imported .glb/.png/.ttf is safer done in the editor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
114 lines
3.9 KiB
GDScript
114 lines
3.9 KiB
GDScript
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://levels/game_shell.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)
|