extends Node ## Headless smoke test for the tracer bullet. Run: ## godot --headless --path . res://tests/smoke.tscn ## Asserts: arena boots, piloting moves the mech, grunts spawn, the ## weapon auto-fires with no input, a grunt dies, knockback displaces, ## build hits accrue charges, opening the hand freezes time, the HEAT ## card paints heat, and stacked heat detonates on release. ## Exit 0 = pass, 1 = fail. ## ## Await discipline: physics_frame only while time flows — with the ## hand open at dilation 0.0 physics never steps, so frozen phases ## await process_frame instead. const ARENA := preload("res://scenes/arena.tscn") var _failures: Array[String] = [] var _done := false func _ready() -> void: get_tree().create_timer(60.0).timeout.connect(_on_global_timeout) await _run() _finish() func _on_global_timeout() -> void: if not _done: _failures.append("global 60s timeout — test hung") _finish() func _run() -> void: var arena := ARENA.instantiate() # Mechanic phases need the old endless trickle; the run-structure # phase re-enables rooms with tiny budgets at the end. arena.rooms_enabled = false add_child(arena) await get_tree().physics_frame await get_tree().physics_frame var mech := arena.get_node_or_null("Mech") as CharacterBody2D if mech == null: _failures.append("no Mech node in arena") return # God-mode the mech: the long sim phases would otherwise drain hull # to zero mid-test and reload the scene under our assertions. mech.hull_max = 1.0e9 mech.hull = 1.0e9 # Piloting: press right, mech must move right. var x0 := mech.global_position.x Input.action_press("move_right") for i in 30: await get_tree().physics_frame Input.action_release("move_right") if mech.global_position.x <= x0 + 10.0: _failures.append("piloting: mech did not move right (x %.1f -> %.1f)" % [x0, mech.global_position.x]) # Spawner: grunts appear. if not await _until(func() -> bool: return get_tree().get_nodes_in_group("enemies").size() > 0, 5.0, "no grunts spawned"): return # Auto-fire: a projectile appears with zero input. await _until(func() -> bool: return get_tree().get_nodes_in_group("projectiles").size() > 0, 5.0, "weapon never auto-fired") # Lethality: at least one grunt dies to auto-fire alone. await _until(func() -> bool: return arena.kills >= 1, 10.0, "no grunt died to auto-fire") # Knockback: a zero-damage hit must displace a grunt along the hit # direction. Probe the healthiest grunt — one mid-death from # auto-fire would vanish under the probe and fake a failure. var g: CharacterBody2D = null for cand in get_tree().get_nodes_in_group("enemies"): if g == null or cand.hp > g.hp: g = cand if g != null: var gx := g.global_position.x g.hit(0.0, Vector2(400, 0)) for i in 5: await get_tree().physics_frame if not is_instance_valid(g): _failures.append("knockback probe grunt died to a zero-damage hit") elif g.global_position.x <= gx + 2.0: _failures.append("knockback: grunt did not displace (x %.1f -> %.1f)" % [gx, g.global_position.x]) await _test_hand(arena, mech) func _test_hand(arena: Node, mech: CharacterBody2D) -> void: var hand := arena.get_node_or_null("Hand") if hand == null: _failures.append("no Hand node in arena") return # Charges accrue from kills alone (kills_per_charge, auto-fire only). if not await _until(func() -> bool: return hand.charges >= 2, 25.0, "kills never accrued 2 charges"): return # A grunt must be near the mech when we freeze — cards are radial # around M03 and nothing walks while time is stopped. The nearest # grunt can die to auto-fire during the toggle tap, so retry. var target: Node2D = null for attempt in 4: if not await _until(func() -> bool: return _nearest_grunt(mech) != null, 10.0, "no grunt approached within heat radius"): return await _tap("hand_toggle") if not hand.hand_open: _failures.append("hand did not open on hand_toggle") return # Pick the target while frozen — it can neither die nor wander. target = _nearest_grunt(mech) if target != null: break await _tap("hand_toggle") if target == null: _failures.append("grunt near mech vanished before freeze on 4 attempts") return var expect_pause: bool = (hand.meter_mode and hand.frac() >= hand.band_thaw) \ or (not hand.meter_mode and hand.dilation_scale == 0.0) if expect_pause and not get_tree().paused: _failures.append("hand open in freeze state did not pause the world") # Belt white-box: slot 2 must serve HEAT twice in a row (plays # consume belt entries, the next slides in). hand.belt.assign([0, 1, 1, 0]) var c0: int = hand.charges # Play HEAT twice: charges spent, heat painted past threshold. await _tap("card_2") await _tap("card_2") if hand.charges != c0 - 2: _failures.append("HEAT x2 spent %d charges, expected 2" % (c0 - hand.charges)) if target.heat < target.heat_threshold: _failures.append("HEAT x2 left target below threshold (heat %.1f)" % target.heat) # Release: the world unpauses into the spool-up ramp (not a snap), # reaches full speed, and the over-threshold grunt detonates. await _tap("hand_toggle") if hand.hand_open or get_tree().paused: _failures.append("hand did not close / world did not resume") return if hand.release_thaw_s > 0.0 and Engine.time_scale >= 1.0: _failures.append("release snapped straight to full speed — no ramp") if not await _until(func() -> bool: return Engine.time_scale >= 0.999, 3.0, "release ramp never reached full speed"): return for i in 10: await get_tree().physics_frame if is_instance_valid(target): _failures.append("heated grunt failed to detonate after release (heat %.1f)" % target.heat) await _test_meter(hand) await _test_salvage(hand, mech) await _test_chemistry(arena, mech) await _test_run(arena, hand) func _test_run(arena: Node, hand: Node) -> void: # Run structure with tiny budgets: clear -> fork -> draft grows the # belt -> room advances -> final clear -> tally (left open; the # relaunch path would reload the smoke scene). arena.rooms_per_run = 2 arena.room = 1 arena.room_budget_base = 2 arena.room_budget_per_room = 0 arena.rooms_enabled = true arena._begin_room() for e in get_tree().get_nodes_in_group("enemies"): e.hit(9999.0, Vector2.ZERO) var inter: CanvasLayer = arena.get_node("Interstitial") if not await _until(func() -> bool: return inter.active, 30.0, "room clear never opened the interstitial"): return if inter.mode != "fork": _failures.append("expected fork after non-final room, got '%s'" % inter.mode) return if not get_tree().paused: _failures.append("interstitial did not pause the world") await _tap("opt_1") if not inter.active or inter.mode != "draft": _failures.append("fork CARD choice did not open the draft (mode '%s')" % inter.mode) return var b0: int = hand.belt.size() await _tap("opt_1") if hand.belt.size() != b0 + 1: _failures.append("draft pick did not grow the belt (%d -> %d)" % [b0, hand.belt.size()]) if inter.active or get_tree().paused: _failures.append("interstitial did not close after the draft pick") return if arena.room != 2: _failures.append("room did not advance after draft (room %d)" % arena.room) return if not await _until(func() -> bool: return inter.active, 30.0, "final room never reached the tally"): return if inter.mode != "tally": _failures.append("expected tally after final room, got '%s'" % inter.mode) func _test_salvage(hand: Node, mech: CharacterBody2D) -> void: # Kills drop scrap pickups. if not await _until(func() -> bool: return get_tree().get_first_node_in_group("salvage") != null, 15.0, "kills never dropped salvage"): return # Collection: teleport a drop onto the mech, expect pickup + count. var s: Area2D = get_tree().get_first_node_in_group("salvage") var s0: int = hand.salvage_total s.global_position = mech.global_position for i in 10: await get_tree().physics_frame if hand.salvage_total <= s0: _failures.append("salvage on the mech was not collected (total %d)" % hand.salvage_total) return if is_instance_valid(s): _failures.append("collected salvage node was not freed") # Level gating: cumulative scrap crosses thresholds, white-box. if hand.card_level() != 1 and hand.salvage_total < hand.level2_salvage: _failures.append("card level %d with only %d salvage" % [hand.card_level(), hand.salvage_total]) hand.add_salvage(hand.level3_salvage) if hand.card_level() != 3: _failures.append("card level %d after crossing level3 threshold" % hand.card_level()) func _test_meter(hand: Node) -> void: # Band ladder, white-box: set the meter, open, check the time state. # THAW band: open, unpaused, slow-mo strictly between 0 and 1. hand.meter = hand.meter_max * 0.3 await _tap("hand_toggle") if not hand.hand_open: _failures.append("hand refused to open in thaw band") return if get_tree().paused: _failures.append("thaw band paused the world") if Engine.time_scale <= 0.0 or Engine.time_scale >= 1.0: _failures.append("thaw band time_scale %.2f not in (0,1)" % Engine.time_scale) # REALTIME band: open, world at full speed. hand.meter = hand.meter_max * 0.14 await get_tree().process_frame await get_tree().process_frame if get_tree().paused or Engine.time_scale != 1.0: _failures.append("realtime band: paused=%s time_scale=%.2f, expected live x1" % [get_tree().paused, Engine.time_scale]) # Depletion force-closes the hand... hand.meter = 0.0 await get_tree().process_frame await get_tree().process_frame if hand.hand_open: _failures.append("hand did not force-close at meter depletion") return # ...and it will not reopen while empty (no Space-spam refreeze). await _tap("hand_toggle") if hand.hand_open: _failures.append("hand reopened on an empty meter") return # The Freeze charge grows while the hand is closed. var m0: float = hand.meter for i in 30: await get_tree().process_frame if hand.meter <= m0: _failures.append("meter did not regenerate while closed") # TOGGLE mode (the A/B flip) still hard-freezes at knob 0. hand.meter_mode = false await _tap("hand_toggle") if not hand.hand_open or not get_tree().paused: _failures.append("toggle mode at knob 0 did not open+pause") await _tap("hand_toggle") hand.meter_mode = true if get_tree().paused: _failures.append("world stayed paused after toggle-mode close") await _until(func() -> bool: return Engine.time_scale >= 0.999, 3.0, "toggle-mode release ramp never reached full speed") func _test_chemistry(arena: Node, mech: CharacterBody2D) -> void: # Burster: a self-heating enemy spawns and its heat grows on its own. # It may die to auto-fire or pop mid-observation, so retry a few. var observed_growth := false for attempt in 4: if not await _until(func() -> bool: return get_tree().get_first_node_in_group("bursters") != null, 20.0, "no burster ever spawned"): return var b: Node2D = get_tree().get_first_node_in_group("bursters") var h0: float = b.heat for i in 30: await get_tree().physics_frame if is_instance_valid(b): if b.heat > h0: observed_growth = true break _failures.append("burster heat did not grow (%.2f -> %.2f)" % [h0, b.heat]) return if not observed_growth: _failures.append("never observed a burster long enough to confirm self-heat") return # Chemistry row KINETIC+HEAT, deterministic: stage a heated striker # and a cold neighbor in the far corner (outside weapon range), # slam the striker into the neighbor, expect the proc: heat spread, # momentum passed, arena proc counter up. var plain: Array[Node2D] = [] for e in get_tree().get_nodes_in_group("enemies"): if e.self_heat_rate == 0.0 and not e._dead: plain.append(e) if plain.size() < 2: _failures.append("not enough plain grunts alive to stage chemistry") return var striker := plain[0] var struck := plain[1] striker.global_position = Vector2(80.0, 80.0) struck.global_position = Vector2(104.0, 80.0) striker.heat = 1.0 struck.heat = 0.0 var procs0: int = arena.chem_procs striker.hit(0.0, Vector2(500.0, 0.0)) if not await _until(func() -> bool: return arena.chem_procs > procs0, 5.0, "chemistry proc never fired on knocked-heated impact"): return if not is_instance_valid(struck) or struck.heat <= 0.0: _failures.append("chemistry proc fired but heat did not spread to the struck enemy") func _nearest_grunt(mech: CharacterBody2D) -> Node2D: var best: Node2D = null var best_d := 120.0 * 120.0 for e in get_tree().get_nodes_in_group("enemies"): var d: float = mech.global_position.distance_squared_to(e.global_position) if d < best_d: best_d = d best = e return best ## Synthetic key tap via the action layer, safe under freeze: presses, ## lets two process frames run so is_action_just_pressed is seen, then ## releases and lets one more run. func _tap(action: String) -> void: Input.action_press(action) await get_tree().process_frame await get_tree().process_frame Input.action_release(action) await get_tree().process_frame ## Awaits process_frame, not physics_frame: physics stops under pause ## (hand freeze, interstitial) and a physics await would deadlock. func _until(pred: Callable, timeout: float, what: String) -> bool: var t := 0.0 while t < timeout: if pred.call(): return true await get_tree().process_frame t += 1.0 / 60.0 _failures.append("timeout: " + what) return false func _finish() -> void: if _done: return _done = true if _failures.is_empty(): print("SMOKE PASS") get_tree().quit(0) else: for f in _failures: printerr("SMOKE FAIL: " + f) get_tree().quit(1)