Files
urm03/tests/smoke.gd
T
vh 3d2410c8fd feat: release spools time back up — thaw ramp on hand close, curved in-band thaw
Closing the hand no longer snaps to full speed: time ramps from where
it stood (near-zero out of a freeze, current slow-mo out of a thaw,
x1 out of realtime — depletion force-close gifts no slow-mo) up to x1
over release_thaw_s wall-clock seconds (default 0.5; 0 = snap).
Reopening mid-ramp cancels it. In-band thaw gains an acceleration
curve (thaw_curve, default 2.2): hangs slow longer, then rushes to
full speed, instead of linear.

Smoke gate: release must NOT be at x1 immediately after close (ramp
present) and must reach x1 within 3s; both modes covered. 3/3 green
on nh3-dev headless 4.7.1.
2026-08-07 11:37:57 -07:00

241 lines
8.2 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()
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 build hits alone (~4 hits per charge).
if not await _until(func() -> bool: return hand.charges >= 2, 20.0, "build hits never accrued 2 charges"):
return
# A grunt must be near the mech before we freeze — cards are radial
# around M03 and nothing walks while time is stopped.
if not await _until(func() -> bool: return _nearest_grunt(mech) != null, 10.0, "no grunt approached within heat radius"):
return
# Open the hand: time must freeze to the dilation knob (default 0).
await _tap("hand_toggle")
if not hand.hand_open:
_failures.append("hand did not open on hand_toggle")
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")
# Pick the target NOW — the world is frozen, so it can neither die
# nor wander out of the heat radius before the casts land.
var c0: int = hand.charges
var target := _nearest_grunt(mech)
if target == null:
_failures.append("grunt near mech vanished before freeze")
return
# 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)
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 _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
func _until(pred: Callable, timeout: float, what: String) -> bool:
var t := 0.0
while t < timeout:
if pred.call():
return true
await get_tree().physics_frame
t += get_physics_process_delta_time()
_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)