Files
urm03/tests/smoke.gd
T
vh fc4fc59a2b feat: M2 run skeleton — belt, card draft, node fork, room sequencing, run tally
Belt (canon: charges are the cost, the belt is the ammo): cards are
CARD_TYPES indices consumed on play, next slides into the 2-slot hand;
belt cap 8; HUD shows hand glyphs + upcoming-card ticks. Card pool
grows to 4: SHOCKWAVE, HEATWAVE, DASH (impulse along held movement
direction — aimable while frozen, no aim input), IGNITE (nearest-enemy
heat spike past threshold). Rooms: spawn-budget waves (base 8 +4/room,
4 rooms/run); clear -> interstitial fork (CARD vs SCRAP +8) -> CARD
opens a 3-option draft + scrap decline (+4) -> next room; final clear
or mech destruction -> tally overlay (rooms/kills/scrap/chem) with
relaunch on 1. Interstitial owns the pause while active; the hand
yields input and time authority to it. Mech death now signals the run
manager instead of hard-reloading.

Chain semantics (same-tag surge / cross-tag chemistry trigger)
deliberately deferred to M3.

Smoke gate: belt consumption white-boxed in the card phase, _until
moved to process_frame (physics awaits deadlock under pause), run
phase covers clear->fork->draft->belt growth->room advance->tally.
3/3 green on nh3-dev headless 4.7.1.
2026-08-07 20:30:43 -07:00

364 lines
13 KiB
GDScript

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)