3d2410c8fd
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.
241 lines
8.2 KiB
GDScript
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)
|