extends SceneTree ## Gameplay regression test — runs the full scene, captures a motion frame strip, ## and asserts that physics/IK stays within sane bounds. ## ## Run (headless, assertions only — screenshots will be blank): ## godot --headless --script tests/gameplay_test.gd ## ## Run (with display for real screenshots): ## godot --script tests/gameplay_test.gd ## ## Outputs: tests/output/gameplay/*.png + tests/output/gameplay/report.txt ## Exit code 0 = all assertions passed, 1 = any failure. const OUTPUT_DIR := "res://tests/output/gameplay" # Tail bone names mirrored from bull_legs.gd — update both if rig changes. const TAIL_BONES: Array[StringName] = [ &"tail_1", &"tail_2", &"tail_3", &"tail_4", &"tail_5", &"tail_6", &"tail_tip_1", &"tail_tip_2", ] var _passed: int = 0 var _failed: int = 0 var _report: PackedStringArray = [] var _matador_scene: PackedScene = null # Loaded lazily (not preloaded): as a --script SceneTree entry, a top-level # preload of a matador scene compiles matador.gd before the DP autoload binds. func _mat_scene() -> PackedScene: if _matador_scene == null: _matador_scene = load("res://Matador.tscn") return _matador_scene func _init() -> void: _run.call_deferred() func _run() -> void: DirAccess.make_dir_recursive_absolute(OUTPUT_DIR) var scene_res := load("res://scene.tscn") if not scene_res: push_error("gameplay_test: failed to load scene.tscn") quit(1) return var scene: Node = scene_res.instantiate() root.add_child(scene) current_scene = scene # match runtime: game code (sword throw, overlays) uses current_scene # Let physics, AI, and IK warm up. await create_timer(0.5).timeout # The matador is lethal on contact now, so a wandering one could end the match # during the non-combat checks below. Park the bull on a temp pad far out in the # void, well out of any matador's reach (the roll tests use this spot too). _isolate_bull(scene) _check_bull_animation() await _check_overlays() # The overlay check needed live matadors; now remove them. The aggressive matador # pursues the bull, and the ragdoll check spawns its own fresh one, so none should # be roaming during the capture / tail / roll phases. for m: Node in get_nodes_in_group(&"matador"): m.queue_free() await physics_frame # Capture 5 frames ~0.4 s apart (covers roughly one wander step and walk cycle). for i in range(5): _capture_frame("motion_%02d.png" % i) await create_timer(0.4).timeout # After ~2.5 s the tail Verlet chain and IK should be stable. _check_tail_integrity() # Roll first, in open space, before any matador dies (a kill ends the match). await _check_roll_ability(scene) await create_timer(0.1).timeout # let the roll pop-test's throwaway matador free # Ragdoll check on a FRESH matador spawned at the origin (the isolated bull is far # away, so this one can't reach it). Not wired to the spawner, so its death won't # trip the win screen. Ragdoll it immediately and check the bones don't explode. var mat_scene := _mat_scene() if mat_scene != null: var mat: Node3D = mat_scene.instantiate() scene.add_child(mat) mat.global_position = Vector3(0.0, 1.0, 0.0) await create_timer(0.3).timeout var start_pos: Vector3 = mat.global_position mat._enter_ragdoll(Vector3(0.0, 0.0, 1.0), 10.0) _capture_frame("ragdoll_trigger.png") await create_timer(0.8).timeout _capture_frame("ragdoll_result.png") _check_ragdoll_sanity(mat, start_pos) else: _note("ragdoll check skipped — Matador.tscn missing") # Last — a bull hit raises the lose screen and pauses the tree. await _check_game_over_wiring(scene) _finish() # Drop a small static floor at (-45, -45) and stand the bull on it — 60+ m from the # arena, so no matador can close the gap within the test window. func _isolate_bull(scene: Node) -> void: var pad := StaticBody3D.new() var cs := CollisionShape3D.new() var box := BoxShape3D.new() box.size = Vector3(20, 1, 20) cs.shape = box pad.add_child(cs) scene.add_child(pad) pad.global_position = Vector3(-45, -0.5, -45) # top surface at y = 0 var players := get_nodes_in_group(&"player") if not players.is_empty(): (players[0] as Node3D).global_position = Vector3(-45, 1, -45) # ── Roll ability (Rammus Powerball) ──────────────────────────────────────────── # Two behaviours: the speed ramps up the longer the ball rolls (base → max), capped # at roll_max_speed with no wall-boost overshoot; and ramming a matador pops the ball # (the roll ends). Rolled in empty space so it doesn't touch the real match matadors. func _check_roll_ability(scene: Node) -> void: print("\n-- check_roll_ability --") var dp: Node = root.get_node_or_null("/root/DP") var players := get_nodes_in_group(&"player") if dp == null or players.is_empty(): _note("roll check: DP / player missing") return var player: Node = players[0] var ap: AnimationPlayer = _find_anim_player(player) _assert_true(player.ability_cd.size() == 4, "bull has 4 ability slots (roll added)") var restore_dur: float = dp.f("roll_duration") var restore_ramp: float = dp.f("roll_rampup_time") dp.set_value("roll_duration", 1.2) dp.set_value("roll_rampup_time", 0.7) player.global_position = Vector3(-45, 1, -45) # empty void, away from the arena player.velocity = Vector3.ZERO player.cube_guy.rotation.y = 0.0 player.ability_cd[3] = 0.0 player._activate_roll() _assert_true(player._active_ability == 3, "roll activates in slot 3") if ap != null: _assert_true(ap.current_animation == &"Armature|ROLL", "roll plays the ROLL clip") var early := -1.0 var peak := 0.0 for n in 30: await create_timer(0.03).timeout var spd := Vector2(player.velocity.x, player.velocity.z).length() if n == 1: early = spd peak = maxf(peak, spd) _assert_true(peak > early + 5.0, "roll speed ramps up over time (Powerball)") _assert_true(peak <= dp.f("roll_max_speed") + 2.0, "roll speed caps at roll_max_speed (no wall boost)") dp.set_value("roll_duration", restore_dur) dp.set_value("roll_rampup_time", restore_ramp) await _check_roll_pop(scene, player) # Roll into a throwaway matador (not one of the match spawns, so its death doesn't # end the game) and confirm the ball pops: the roll ability ends on contact. func _check_roll_pop(scene: Node, player: Node) -> void: var mat_scene := _mat_scene() if mat_scene == null: _note("roll pop: Matador.tscn missing") return var mat: Node3D = mat_scene.instantiate() scene.add_child(mat) mat.global_position = Vector3(-45, 1, -38) # ~7 m ahead of the bull along +Z await create_timer(0.2).timeout player.global_position = Vector3(-45, 1, -45) player.velocity = Vector3.ZERO player.cube_guy.rotation.y = 0.0 # face +Z, toward the matador player.ability_cd[3] = 0.0 player._activate_roll() var popped := false for _n in 45: await create_timer(0.03).timeout if player._active_ability != 3: popped = true break _assert_true(popped, "roll pops (ends) on ramming a matador") if is_instance_valid(mat): mat.queue_free() # ── Bull animation ──────────────────────────────────────────────────────────── # The bull sits idle in this test (no input), so it must be playing its looping # IDLE clip — guards the idle wiring and the KICK clip rename in player.gd. func _check_bull_animation() -> void: print("\n-- check_bull_animation --") var players := get_nodes_in_group(&"player") if players.is_empty(): _note("bull anim check: no player in scene") return var ap: AnimationPlayer = _find_anim_player(players[0]) if ap == null: _note("bull anim check: no AnimationPlayer under player") return _assert_true(ap.has_animation(&"Armature|IDLE"), "bull has IDLE clip") _assert_true(ap.has_animation(&"Armature|KICK"), "bull has KICK clip (not stale FOOTKICK)") _assert_true(ap.current_animation == &"Armature|IDLE", "bull plays IDLE at rest") if ap.has_animation(&"Armature|IDLE"): _assert_true(ap.get_animation(&"Armature|IDLE").loop_mode == Animation.LOOP_LINEAR, "bull IDLE clip loops") func _find_anim_player(node: Node) -> AnimationPlayer: if node is AnimationPlayer: return node for child in node.get_children(): var r := _find_anim_player(child) if r != null: return r return null # ── Win / lose wiring ───────────────────────────────────────────────────────── # One clean sword hit must end the run in a loss, and the signal chain must be in # place for a win (spawner.all_defeated → HUD). Runs last: it pauses the tree. func _check_game_over_wiring(scene: Node) -> void: print("\n-- check_game_over_wiring --") var hud: Node = _find_hud(scene) var players := get_nodes_in_group(&"player") if hud == null or players.is_empty(): _note("game over check: HUD / player missing") return var player: Node = players[0] _assert_true(player.has_signal(&"died"), "player exposes a died signal") var spawners := get_nodes_in_group(&"matador_spawn") _assert_true(spawners.is_empty() or spawners[0].has_signal(&"all_defeated"), "spawner exposes an all_defeated signal (win route)") # The bull now soaks bull_max_hp hits. Zero the i-frame window so the test can # land consecutive blades, then confirm it survives every hit but the last. var dp: Node = root.get_node_or_null("/root/DP") if dp != null: dp.set_value("bull_hit_iframes", 0.0) var max_hp: int = player.call(&"get_max_hp") for i: int in max_hp - 1: player.take_sword_hit() await create_timer(0.05).timeout _assert_true(not hud._game_over, "hit %d of %d does not end the run" % [i + 1, max_hp]) _assert_true(player.call(&"get_hp") == 1, "bull sits at 1 HP before the killing blow") player.take_sword_hit() await create_timer(0.05).timeout _assert_true(hud._game_over, "draining the last HP raises the game-over screen") _assert_true(not hud._result_win, "a bull hit is a loss, not a win") func _find_hud(node: Node) -> Node: if node is CanvasLayer and node.has_method(&"_show_game_over"): return node for child in node.get_children(): var r := _find_hud(child) if r != null: return r return null # ── Debug overlays ──────────────────────────────────────────────────────────── # Turn on every DebugDraw overlay against the live scene, confirm it builds, then # turn them off and confirm it tears down — guards the reconcile bookkeeping. func _check_overlays() -> void: print("\n-- check_overlays --") var dp: Node = root.get_node_or_null("/root/DP") var draw: Node = root.get_node_or_null("/root/DebugDraw") if dp == null or draw == null: _note("overlay check: DP / DebugDraw autoload missing") return var flags := [ "show_states", "show_stats", "show_collisions", "show_hitboxes", "show_bones", "show_animation_name", "show_wireframe", "show_grid", "show_axes", ] for flag: String in flags: dp.set_value(flag, true) await create_timer(0.3).timeout _assert_true(draw._state_labels.size() > 0, "overlay builds matador state labels") _assert_true(draw._overlays.size() > 0, "overlay builds collision meshes") _assert_true(draw._anim_labels.size() > 0, "overlay builds animation-name labels") _assert_true(draw._stats_layer != null and draw._stats_layer.visible, "stats overlay visible") _assert_true(not draw._stats_label.text.is_empty(), "stats overlay composes text") for flag: String in flags: dp.set_value(flag, false) await create_timer(0.3).timeout _assert_true(draw._state_labels.is_empty(), "overlay clears state labels when off") _assert_true(draw._overlays.is_empty(), "overlay clears collision meshes when off") _assert_true(draw._anim_labels.is_empty(), "overlay clears animation-name labels when off") # ── Tail integrity ──────────────────────────────────────────────────────────── func _check_tail_integrity() -> void: print("\n-- check_tail_integrity --") var players := get_nodes_in_group(&"player") if players.is_empty(): _note("tail check: no player in scene") return # Prefer reading from the live Verlet chain in the legs node. var legs: Node = _find_legs_node(players[0]) if legs: _check_tail_verlet(legs) return # Fallback: sample bone global poses from Skeleton3D. var skel: Skeleton3D = _find_skeleton(players[0]) as Skeleton3D if not skel: _note("tail check: no Skeleton3D found under player") return _check_tail_skeleton(skel) func _check_tail_verlet(legs: Node) -> void: var chain: Array = legs._tail_world if chain.size() < 2: _note("tail Verlet: chain has fewer than 2 nodes — skipping") return for i in range(chain.size()): var p: Vector3 = chain[i] _assert_true(p.is_finite(), "tail Verlet node %d is finite" % i) for i in range(1, chain.size()): var dist: float = (chain[i] as Vector3).distance_to(chain[i - 1]) _assert_true(dist > 0.005, "tail Verlet segment %d–%d not collapsed (%.4f m)" % [i - 1, i, dist]) var span: float = (chain[0] as Vector3).distance_to(chain[-1]) _assert_true(span > 0.05, "tail chain root-to-tip span non-zero (%.3f m)" % span) # Verify no two nodes are at exactly the same position (bunching symptom). for i in range(1, chain.size()): for j in range(i + 1, chain.size()): var d: float = (chain[i] as Vector3).distance_to(chain[j]) _assert_true(d > 0.001, "tail nodes %d and %d are distinct (%.4f m apart)" % [i, j, d]) _note("tail Verlet: %d nodes, span %.3f m" % [chain.size(), span]) func _check_tail_skeleton(skel: Skeleton3D) -> void: var positions: Array[Vector3] = [] for bone_name: StringName in TAIL_BONES: var idx: int = skel.find_bone(bone_name) if idx == -1: continue positions.append(skel.to_global(skel.get_bone_global_pose(idx).origin)) if positions.size() < 2: _note("tail skeleton: fewer than 2 bones found") return for i in range(positions.size()): _assert_true(positions[i].is_finite(), "tail bone %d finite" % i) for i in range(1, positions.size()): var dist: float = positions[i].distance_to(positions[i - 1]) _assert_true(dist > 0.005, "tail bone segment %d–%d not collapsed (%.4f m)" % [i - 1, i, dist]) _note("tail skeleton: %d bones checked" % positions.size()) # ── Ragdoll sanity ──────────────────────────────────────────────────────────── func _check_ragdoll_sanity(matador: Node3D, start_pos: Vector3) -> void: print("\n-- check_ragdoll_sanity --") var sim: Node = _find_class_recursive(matador, "PhysicalBoneSimulator3D") if not sim: _note("ragdoll: no PhysicalBoneSimulator3D — check skipped") return var checked := 0 var all_at_origin := true for child in sim.get_children(): if not (child is PhysicalBone3D): continue var bone := child as PhysicalBone3D var bp: Vector3 = bone.global_position checked += 1 if bp.distance_to(Vector3.ZERO) > 0.1: all_at_origin = false _assert_true(bp.is_finite(), "ragdoll '%s' position finite" % bone.bone_name) if bp.is_finite(): var dist: float = bp.distance_to(start_pos) # 6 m: impulse + 0.8 s of movement should stay inside this. # Regression guard: bodies stuck at world origin fail (spawn is 10-20 m away). _assert_true(dist < 6.0, "ragdoll '%s' near spawn (%.1f m)" % [bone.bone_name, dist]) _assert_true(bp.y > -1.0, "ragdoll '%s' above floor (y = %.2f)" % [bone.bone_name, bp.y]) _assert_true(bp.y < 3.0, "ragdoll '%s' settled below 3 m (y = %.2f)" % [bone.bone_name, bp.y]) if checked == 0: _note("ragdoll: simulator has no PhysicalBone3D children") elif all_at_origin: _note("ragdoll: all bones at world origin — physics may not have simulated (headless?)") else: _note("ragdoll: %d bones checked" % checked) # ── Frame capture ───────────────────────────────────────────────────────────── func _capture_frame(filename: String) -> void: var img: Image = root.get_viewport().get_texture().get_image() if img == null: _note("frame capture failed: %s" % filename) return var path := OUTPUT_DIR + "/" + filename img.save_png(path) _note("captured %s (%d×%d)" % [filename, img.get_width(), img.get_height()]) # ── Scene helpers ───────────────────────────────────────────────────────────── func _find_legs_node(player: Node) -> Node: for child in player.get_children(): var s: Script = child.get_script() as Script if s and s.resource_path.ends_with("bull_legs.gd"): return child return null func _find_skeleton(node: Node) -> Node: if node is Skeleton3D: return node for child in node.get_children(): var r := _find_skeleton(child) if r: return r return null func _find_class_recursive(node: Node, class_name_str: String) -> Node: if node.get_class() == class_name_str: return node for child in node.get_children(): var r := _find_class_recursive(child, class_name_str) if r: return r return null # ── Assertions & report ─────────────────────────────────────────────────────── func _assert_true(condition: bool, desc: String) -> void: if condition: _passed += 1 print(" PASS: %s" % desc) _report.append("PASS: " + desc) else: _failed += 1 print(" FAIL: %s" % desc) _report.append("FAIL: " + desc) func _note(msg: String) -> void: print(" NOTE: %s" % msg) _report.append("NOTE: " + msg) func _finish() -> void: var f := FileAccess.open(OUTPUT_DIR + "/report.txt", FileAccess.WRITE) if f: f.store_string("\n".join(Array(_report))) f.close() print("") print("=".repeat(60)) print("GAMEPLAY TESTS: %d passed, %d failed" % [_passed, _failed]) print("Frame strip: %s" % OUTPUT_DIR) print("=".repeat(60)) quit(1 if _failed > 0 else 0)