Carnage now follows the car home. Grenading the gearbox or blowing the
head gasket on the strip records a persistent FAULT in the garage save,
and the car boots WOUNDED everywhere -- gearbox: 80% power and leaking
oil patches; gasket: 85% power and steaming from the bonnet -- until
someone fixes it. The garage screen grows an "IT'S BROKEN" section with
two routes per fault: pay the shop's someone-else's-problem rate ($700
to $900), or GET UNDER IT for free in the shed.
Getting under it opens the repair minigame (src/repair_ui.gd, all code
like every other screen). Step one is diagnosis off the dash warning
lights: with the CASHIES CODE READER the fault name comes through the
cracked screen ("D!IV!TR!IN") and the right light glows amber; without
it you're on vibes, and a wrong pick adds two penalty turns to every
nut in the job. Then the hands-on steps: hold-and-circle the mouse to
spin nuts (direction enforced -- a full turn the wrong way earns
"OTHER WAY."), click to pump the jack, drag the dead box to the bench.
The shed skill tree pays off physically: ALDI SOCKETS halve every nut,
SUPERCHEAP RAMPS halve the jacking, the CASHIES HOIST deletes the 60 kg
drag-the-box step entirely, the ADDITIVE CRATE shortens the coolant
top-up -- and finishing a job without the right tools leaves three
bolts over ("they're spares now").
Mouse plumbing routes everything through pick_light/twist/pump/drop_at,
so the smoke suite plays a complete toolless gearbox swap headless:
wrong light penalised, right light advances, every step type exercised,
fault cleared at the end. Wounded boot, shop fix and the drag-finish
fault handoff are covered too.
Two traps re-confirmed on the way: `var x :=` cannot infer through
InputEvent.position (base-class Variant) -- the parse error took the
whole suite down as a silent hang; and a full-rect background ColorRect
eats every mouse event before root._gui_input sees it unless its
mouse_filter is IGNORE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
16 KiB
GDScript
372 lines
16 KiB
GDScript
extends SceneTree
|
|
## Headless smoke test:
|
|
## /Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tests/smoke.gd
|
|
## Registry finds content; each mode boots and simulates; the car drives; traffic
|
|
## gets smashed when driven through.
|
|
|
|
func _initialize() -> void:
|
|
_run()
|
|
|
|
func _run() -> void:
|
|
var cars := Registry.cars()
|
|
var levels := Registry.levels()
|
|
_check(not cars.is_empty(), "no cars found")
|
|
_check(not levels.is_empty(), "no levels found")
|
|
for p in cars.values():
|
|
var s: CarStats = load(p)
|
|
_check(s != null and s.engine_power > 0.0, "bad stats: %s" % p)
|
|
if s.model_path != "":
|
|
_check(load(s.model_path) is PackedScene, "bad model: %s" % s.model_path)
|
|
# datto is procedural (has det_* parts); imported models legitimately have none
|
|
Game.car_path = cars.get("datto_120y", cars.values()[0])
|
|
# the carpark is the controlled arena; city levels have walls that complicate the ram test
|
|
Game.level_path = levels.get("woolies_carpark", levels.values()[0])
|
|
|
|
# garage economy: scratch save (NEVER the real one), earn/buy roundtrip,
|
|
# stats + bolt-ons applied to the player, then reset so later stages run stock
|
|
print(" .. garage")
|
|
Garage.save_path = "user://garage_smoketest.save"
|
|
Garage._reset_for_test()
|
|
_check(Garage.bucks() == 0, "scratch garage not empty")
|
|
Garage.earn(4000)
|
|
_check(Garage.bucks() == 4000, "earn didn't bank")
|
|
_check(Garage.buy("datto_120y", "spoiler"), "couldn't buy the wing")
|
|
_check(Garage.buy("datto_120y", "turbo"), "couldn't buy the turbo")
|
|
_check(not Garage.buy("datto_120y", "spoiler"), "bought the same wing twice")
|
|
_check(not Garage.buy("datto_120y", "exhaust"), "bought a $2000 pipe with $500")
|
|
_check(Garage.bucks() == 500, "change is wrong (%d)" % Garage.bucks())
|
|
var base_power: float = (load("res://cars/datto_120y/stats.tres") as CarStats).engine_power
|
|
var gg: Game = await _boot("cruise")
|
|
_check(gg.car.stats.engine_power > base_power * 1.15, "turbo didn't boost power")
|
|
_check(gg.car.stats.grip > (load("res://cars/datto_120y/stats.tres") as CarStats).grip + 0.5,
|
|
"wing added no grip")
|
|
_check(gg.car.stats.resource_path == "", "upgrades mutated the SHARED stats resource")
|
|
gg.free()
|
|
# the shed: tunes are gated on tools, and retorque hardens the panels
|
|
Garage.earn(2000)
|
|
_check(not Garage.buy_tune("datto_120y", "retorque"), "tuned without owning the tool")
|
|
_check(Garage.buy_tool("sockets"), "couldn't buy the socket set")
|
|
_check(Garage.buy_tune("datto_120y", "retorque"), "tool owned but tune refused")
|
|
_check(not Garage.buy_tune("datto_120y", "retorque"), "same tune sold twice")
|
|
gg = await _boot("cruise")
|
|
_check(gg.car.part_dv > Car.PART_DV + 1.0, "retorque didn't harden panels (%.1f)" % gg.car.part_dv)
|
|
gg.free()
|
|
# raceday persistence: ladder rung and per-car quarter-mile PBs
|
|
_check(Garage.drag_rung() == 0, "fresh save not on ladder rung 0")
|
|
Garage.drag_advance()
|
|
_check(Garage.drag_rung() == 1, "ladder didn't advance")
|
|
_check(Garage.record_drag_pb("datto_120y", 14.5), "first ET not a PB")
|
|
_check(not Garage.record_drag_pb("datto_120y", 15.0), "slower ET counted as a PB")
|
|
_check(Garage.record_drag_pb("datto_120y", 13.9), "faster ET refused as PB")
|
|
_check(absf(Garage.drag_pb("datto_120y") - 13.9) < 0.01, "PB not stored")
|
|
# faults: break it, boot wounded, pay the shop for one, DIY the other
|
|
print(" .. repair")
|
|
Garage.add_fault("datto_120y", "gearbox")
|
|
Garage.add_fault("datto_120y", "gasket")
|
|
_check(Garage.faults("datto_120y").size() == 2, "faults didn't record")
|
|
gg = await _boot("cruise")
|
|
_check(gg.car.stats.engine_power < base_power * 0.9, "wounded car boots at full power")
|
|
_check(gg.car.leaking, "gearbox-faulted car isn't leaking")
|
|
gg.free()
|
|
Garage.earn(1000)
|
|
_check(Garage.shop_fix("datto_120y", "gasket"), "shop wouldn't take the money")
|
|
_check(not Garage.faults("datto_120y").has("gasket"), "shop fix didn't clear the fault")
|
|
# DIY the gearbox: play the whole minigame headless through its API
|
|
Garage.ui_stats_path = "res://cars/datto_120y/stats.tres"
|
|
Garage.ui_fault = "gearbox"
|
|
var ui = (load("res://src/repair.tscn") as PackedScene).instantiate()
|
|
root.add_child(ui)
|
|
await process_frame
|
|
_check(ui.steps.size() >= 6, "gearbox job suspiciously short (%d steps)" % ui.steps.size())
|
|
var right := -1
|
|
for i in ui.light_btns.size():
|
|
if ui.light_btns[i]["light"] == Garage.FAULTS["gearbox"]["light"]:
|
|
right = i
|
|
ui.pick_light((right + 1) % 3)
|
|
_check(ui.penalty_turns > 0.0, "wrong diagnosis cost nothing")
|
|
ui.pick_light(right)
|
|
_check(ui.step_index == 1, "right light didn't advance the job")
|
|
var guard := 0
|
|
while not ui.done and guard < 12:
|
|
guard += 1
|
|
var st: Dictionary = ui.steps[ui.step_index]
|
|
match st["kind"]:
|
|
"twist":
|
|
ui.twist(float(st["dir"]) * ((float(st["turns"]) + ui.penalty_turns) * TAU + 0.1))
|
|
"pump":
|
|
for i in int(st["clicks"]):
|
|
ui.pump()
|
|
"drag":
|
|
ui.drop_at(ui.drag_target.get_global_rect().get_center())
|
|
_check(ui.done, "repair never finished (stuck on step %d)" % ui.step_index)
|
|
_check(not Garage.faults("datto_120y").has("gearbox"), "DIY fix didn't clear the fault")
|
|
ui.free()
|
|
Garage._reset_for_test() # later stages must run stock
|
|
|
|
# cruise: throttle 3s, car must move
|
|
print(" .. cruise")
|
|
var game: Game = await _boot("cruise")
|
|
if game.car.stats.model_path != "":
|
|
_check(game.car._parts.size() >= 8, "model has no detachable det_* parts")
|
|
var start: Vector3 = game.car.global_position
|
|
Input.action_press("throttle")
|
|
for i in 180:
|
|
await physics_frame
|
|
var dist := start.distance_to(game.car.global_position)
|
|
_check(dist > 5.0, "car didn't move (%.1f m)" % dist)
|
|
Input.action_release("throttle")
|
|
game.free()
|
|
|
|
# crash mode: traffic spawned, ram the nearest one, score must rise
|
|
print(" .. crash")
|
|
game = await _boot("crash")
|
|
_check(game.traffic.size() == Game.TRAFFIC_COUNT["crash"], "traffic missing")
|
|
var victim: TrafficCar = game.traffic[0]
|
|
await physics_frame # traffic takes position on its first physics tick
|
|
Input.action_press("throttle")
|
|
for attempt in 8: # re-aim: victim keeps moving along the loop
|
|
if not victim.live:
|
|
break
|
|
var to_victim := (victim.global_position - game.car.global_position)
|
|
game.car.global_position = victim.global_position - victim.global_basis.z * 8.0
|
|
game.car.look_at(victim.global_position)
|
|
to_victim = (victim.global_position - game.car.global_position).normalized()
|
|
game.car.linear_velocity = to_victim * 18.0
|
|
for i in 30:
|
|
await physics_frame
|
|
if not victim.live:
|
|
break
|
|
Input.action_release("throttle")
|
|
_check(not victim.live, "rammed traffic never went dynamic")
|
|
_check(game.score > 0.0, "crash mode scored nothing")
|
|
var crash_score := game.score
|
|
game.free()
|
|
|
|
# junction: convoys spawn, driving straight in causes the crash -> settle ->
|
|
# crashbreaker -> tally
|
|
var jcts := Registry.junctions().filter(func(j): return j.level_id == "woolies_carpark")
|
|
_check(not jcts.is_empty(), "no woolies junction event")
|
|
Game.junction = jcts[0].data
|
|
print(" .. junction")
|
|
game = await _boot("junction")
|
|
_check(game.traffic.size() >= 8, "junction convoys missing (%d)" % game.traffic.size())
|
|
Input.action_press("throttle")
|
|
for i in 950:
|
|
await physics_frame
|
|
if game.jstate == "settle" and not game.breaker_used:
|
|
Input.action_release("throttle")
|
|
Input.action_press("boost")
|
|
elif game.breaker_used:
|
|
Input.action_release("boost")
|
|
if game.over:
|
|
break
|
|
Input.action_release("throttle")
|
|
Input.action_release("boost")
|
|
_check(game.jstate != "run", "never crashed into the convoy")
|
|
_check(game.breaker_used, "crashbreaker never fired")
|
|
_check(game.over and game.score > 0.0, "junction never tallied (score %d)" % int(game.score))
|
|
var jct_score := game.score
|
|
Game.junction = {}
|
|
game.free()
|
|
|
|
# outrun: roaming rivals exist in cruise; pulling alongside at speed arms the
|
|
# duel, and the win path pays out and clears state
|
|
game = await _boot("cruise")
|
|
_check(game.rivals.size() == Game.ROAM_RIVALS, "no roaming rivals in cruise")
|
|
var rr: Rival = game.rivals[0]
|
|
await physics_frame
|
|
game.car.global_position = rr.global_position + Vector3(3, 0.3, 0)
|
|
game.car.linear_velocity = Vector3(0, 0, -12) # above the 8 m/s arming speed
|
|
for i in 5:
|
|
await physics_frame
|
|
if game.outrun_rival != null:
|
|
break
|
|
_check(game.outrun_rival == rr, "outrun didn't arm on proximity")
|
|
var bm0: float = game.car.boost_max
|
|
game._outrun_end(true, "test")
|
|
_check(game.outrun_rival == null and game.outruns_won == 1, "outrun win didn't settle")
|
|
_check(game.car.boost_max > bm0, "outrun win paid no boost")
|
|
_check(game.outrun_cd > 0.0, "no re-arm cooldown after outrun")
|
|
game.free()
|
|
|
|
# pursuit: smashing raises heat, heat summons cops, fleeing loses them
|
|
print(" .. pursuit")
|
|
game = await _boot("cruise")
|
|
for i in 10:
|
|
game.add_heat(Game.HEAT_PER_SMASH)
|
|
_check(game.cops.size() >= Game.COPS_MIN, "heat didn't summon cops")
|
|
game.car.global_position += Vector3(900, 0, 900)
|
|
for i in 700:
|
|
await physics_frame
|
|
if game.cops.is_empty():
|
|
break
|
|
_check(game.escapes == 1 and game.cops.is_empty(), "escape never resolved")
|
|
_check(game.heat_cd > 0.0, "no cooldown after escape")
|
|
game.free()
|
|
|
|
# drag: the Willowbank quarter mile -- tree staging, ladder rival, then the box
|
|
Game.level_path = levels["willowbank_dragway"]
|
|
print(" .. drag")
|
|
game = await _boot("drag")
|
|
_check(absf(game.drag_len - 402.0) < 5.0, "quarter mile measures %.0f m" % game.drag_len)
|
|
_check(game.rivals.size() == 1, "drag had no opponent")
|
|
_check(game.drag_state == "stage", "no tree staging phase")
|
|
_check(game.drag_rung_now == 0, "fresh ladder not starting at rung 0")
|
|
_check(game.rivals[0].stats.resource_path.contains("morry"),
|
|
"rung 0 rival isn't Nanna (%s)" % game.rivals[0].stats.resource_path)
|
|
_check(game._tree_bulbs.size() == 6, "christmas tree bulbs missing (%d)" % game._tree_bulbs.size())
|
|
await physics_frame
|
|
_check(game.rivals[0].power_scale == 0.0, "rival not held at the tree")
|
|
# burnout in the box: DRIFT+GAS warms the tyres and must NOT roll the beams
|
|
Input.action_press("drift")
|
|
Input.action_press("throttle")
|
|
for i in 80:
|
|
await physics_frame
|
|
if game.drag_state != "stage":
|
|
break
|
|
_check(game.tyre_temp > 0.25, "burnout warmed nothing (%.2f)" % game.tyre_temp)
|
|
_check(not game.jumped, "burnout rolled the beams (red light)")
|
|
_check(game.car.burnout_on, "burnout fx never engaged")
|
|
Input.action_release("drift")
|
|
Input.action_release("throttle")
|
|
# hold still like a disciplined launch: no red light, wait out the ambers
|
|
var tree_spin := 0
|
|
while game.drag_state != "run" and tree_spin < 400:
|
|
await physics_frame
|
|
tree_spin += 1
|
|
_check(game.drag_state == "run", "tree never went green")
|
|
_check(not game.jumped, "stationary car scored a red light")
|
|
var green_m := (game._tree_bulbs["TreeGreen"] as MeshInstance3D).material_override as StandardMaterial3D
|
|
_check(green_m.emission_enabled, "green bulb didn't light")
|
|
Input.action_press("throttle")
|
|
for i in 10:
|
|
await physics_frame
|
|
_check(game.revs > 0.0, "revs never climbed")
|
|
var g0: int = game.gear
|
|
# shift the moment revs enter the window. First gear redlines in ~1.6 s, so
|
|
# idling here first pins revs at 1.0 and the shift correctly scores as blown.
|
|
var spin := 0
|
|
while game.revs < Game.SHIFT_LO and spin < 600: # bounded: never hang the suite
|
|
await physics_frame
|
|
spin += 1
|
|
_check(game.revs >= Game.SHIFT_LO and game.revs < Game.REDLINE,
|
|
"revs %.2f not inside the shift window [%.2f, %.2f)" % [game.revs, Game.SHIFT_LO, Game.REDLINE])
|
|
Input.action_press("boost")
|
|
await physics_frame
|
|
Input.action_release("boost")
|
|
await physics_frame
|
|
_check(game.gear == g0 + 1, "shift didn't change gear")
|
|
_check(game.clean_shifts == 1 and game.blown_shifts == 0,
|
|
"in-window shift scored as blown (%d/%d)" % [game.clean_shifts, game.blown_shifts])
|
|
# grenade the box: three panic shifts nowhere near the window
|
|
for i in 3:
|
|
game.revs = 0.10
|
|
Input.action_press("boost")
|
|
await physics_frame
|
|
Input.action_release("boost")
|
|
await physics_frame
|
|
_check(game.box_blown, "gearbox survived three blown shifts (%d strikes)" % game.box_strikes)
|
|
_check(game.car.leaking, "grenaded box isn't leaking oil")
|
|
var locked: int = game.gear
|
|
Input.action_press("boost")
|
|
await physics_frame
|
|
Input.action_release("boost")
|
|
await physics_frame
|
|
_check(game.gear == locked, "blown box still shifts")
|
|
# and cook the coolant: past 1.0 the head gasket lets go
|
|
game.eng_temp = 1.05
|
|
await physics_frame
|
|
_check(game.gasket_blown, "overcooked engine kept its head gasket")
|
|
# finishing the run sends the carnage home as persistent faults
|
|
game._drag_finish(true)
|
|
_check(Garage.faults("datto_120y").has("gearbox") and Garage.faults("datto_120y").has("gasket"),
|
|
"carnage didn't follow the car home as faults")
|
|
Garage._reset_for_test() # don't leave a wounded car for later stages
|
|
Input.action_release("throttle")
|
|
game.free()
|
|
Game.level_path = levels.get("woolies_carpark", levels.values()[0])
|
|
|
|
# derby: opponents spawn in a ring and each hunts something
|
|
print(" .. derby")
|
|
game = await _boot("derby")
|
|
_check(game.derby.size() == Game.DERBY_CARS, "derby field wrong size")
|
|
await physics_frame
|
|
for d in game.derby:
|
|
_check(d.target != null and d.target != d, "derby car has no/self target")
|
|
game.free()
|
|
|
|
# road rage: rivals spawn, a takedown near the player counts and pays
|
|
print(" .. rage")
|
|
game = await _boot("rage")
|
|
_check(game.rivals.size() == Game.RAGE_RIVALS, "rage field wrong size")
|
|
var prey: Rival = game.rivals[0]
|
|
await physics_frame
|
|
game.car.global_position = prey.global_position + Vector3(3, 0.2, 0)
|
|
await physics_frame
|
|
prey.wreck_started.emit()
|
|
_check(game.rage_downs == 1, "rage takedown didn't count (%d)" % game.rage_downs)
|
|
game.time_left = 0.05
|
|
for i in 10:
|
|
await physics_frame
|
|
_check(game.over, "rage never tallied")
|
|
game.free()
|
|
|
|
# oncoming: rolling against the road tangent at speed earns boost
|
|
game = await _boot("cruise")
|
|
await physics_frame
|
|
var curve := game.race_path.curve
|
|
var road_pt := game.race_path.to_global(curve.sample_baked(20.0))
|
|
var tan := (game.race_path.to_global(curve.sample_baked(24.0)) - road_pt).normalized()
|
|
# Teleport via the physics server: setting global_position on a
|
|
# continuous_cd body SWEEPS it there, slamming through everything en route
|
|
# -- the first version of this test wrecked the car before it could earn.
|
|
var xf := Transform3D(Basis.looking_at(-tan, Vector3.UP), road_pt + Vector3.UP * 0.75)
|
|
PhysicsServer3D.body_set_state(game.car.get_rid(), PhysicsServer3D.BODY_STATE_TRANSFORM, xf)
|
|
game.car.wrecked = false
|
|
game.car.boost = 0.0
|
|
game.car._prev_lv = -tan * 16.0 # don't let the velocity jump read as a crash
|
|
game.car.linear_velocity = -tan * 16.0
|
|
for i in 45:
|
|
await physics_frame
|
|
game.car.linear_velocity = -tan * 16.0 # hold course against drag
|
|
_check(game.car.boost > 0.05, "oncoming earned no boost (%.3f)" % game.car.boost)
|
|
game.free()
|
|
|
|
# race: grid up at Queensland Raceway -- the paperclip is ~3126 m of Path3D
|
|
Game.level_path = levels["qld_raceway"]
|
|
print(" .. race")
|
|
game = await _boot("race")
|
|
var lap: float = game.race_path.curve.get_baked_length()
|
|
_check(absf(lap - 3126.0) < 60.0, "paperclip lap measures %.0f m" % lap)
|
|
_check(game.rivals.size() == Game.RIVAL_COUNT, "rivals missing")
|
|
var p0: float = game.rivals[0].progress
|
|
for i in 240:
|
|
await physics_frame
|
|
var moved := false
|
|
for r in game.rivals:
|
|
if absf(r.progress - p0) > 3.0 or r.linear_velocity.length() > 3.0:
|
|
moved = true
|
|
_check(moved, "no rival is driving")
|
|
game.free()
|
|
|
|
print("smoke ok: %d cars, %d levels | cruise %.1f m | crash $%d | junction $%d | outrun arms+pays | pursuit spawns+escapes | quarter mile stages+shifts | derby %d cars | paperclip race AI driving" %
|
|
[cars.size(), levels.size(), dist, int(crash_score), int(jct_score), Game.DERBY_CARS])
|
|
quit(0)
|
|
|
|
func _check(cond: bool, msg: String) -> void:
|
|
## NOT assert(): a failed assert in a headless SceneTree script halts the
|
|
## script but leaves the tree running, so the process spins forever instead
|
|
## of failing. One bad assertion cost 53 minutes of wall clock before this.
|
|
if cond:
|
|
return
|
|
push_error(msg)
|
|
print("SMOKE FAIL: %s" % msg)
|
|
quit(1)
|
|
|
|
func _boot(mode: String) -> Game:
|
|
Game.mode = mode
|
|
var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate()
|
|
root.add_child(game)
|
|
await process_frame # _ready is deferred until the main loop runs
|
|
return game
|