The game is now called Shitbox Infinity (project name, window title, title screen, README). The Gitea repo keeps its name until John renames it -- gitea redirects, and the remote gets a set-url then. The Shed (levels/the_shed) is the payoff for the whole garage arc: a drivable suburban block on a quiet cul-de-sac. Single-storey brick house with a corrugated roof and a porch light that works at night; paling fence (new FLUX texture) with a gate gap for the driveway; hills hoist mid-spin with three towels on it; wheelie bins at the gate and a spare fleet shitbox parked on the street; gums; neighbours across the road so you feel watched. The shed itself is a 10x12 corrugated double garage on a slab: FLUX pegboard wall, workbench, fluoro batten (a real light at night), and a painted bay. The tools you OWN (Garage.tools()) stand in it as the real TRELLIS'd cashies models -- sockets and the code reader ON the bench, the engine crane by the wall, ramps at the bay mouth -- while unowned tools leave an oil-stained outline of where they'll go. The rest of the tool library is permanent clutter via the new shared ToolProps helper (dragway.gd refactored onto it too). Roll into the bay in Cruise, hold still 1.4 s, and game.gd opens the garage -- diegetic shopping. Headless suites have no current_scene to swap, so the trigger sets garage_requested first and the smoke suite asserts on that: owned-tool display (sockets present, ramps absent on the test save), bay marker found, bay trigger fires. Suite covers 9 levels now. Scale lessons baked in: facade_brick at 0.14 tiling reads as a three-storey office (0.045 reads domestic), and corrugated iron at 0.35 has metre-wide ridges (1.4 reads like roofing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
408 lines
18 KiB
GDScript
408 lines
18 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()
|
|
# THE SHED home base: owned tools materialise (sockets were bought above,
|
|
# ramps were not), and rolling into the bay and holding still opens the garage
|
|
print(" .. shed")
|
|
Game.level_path = levels["the_shed"]
|
|
gg = await _boot("cruise")
|
|
_check(gg.garage_bay != null, "shed has no GarageBay")
|
|
var owned_props: Array = gg.find_children("*", "RigidBody3D", true, false).filter(
|
|
func(n): return n.has_meta("shed_tool"))
|
|
_check(owned_props.size() == 1 and owned_props[0].get_meta("shed_tool") == "sockets",
|
|
"owned-tool display wrong (%d shed_tool props)" % owned_props.size())
|
|
var bay_at: Vector3 = gg.garage_bay.global_position
|
|
var bay_xf := Transform3D(Basis.IDENTITY, bay_at + Vector3.UP * 0.75)
|
|
PhysicsServer3D.body_set_state(gg.car.get_rid(), PhysicsServer3D.BODY_STATE_TRANSFORM, bay_xf)
|
|
gg.car.linear_velocity = Vector3.ZERO
|
|
gg.car._prev_lv = Vector3.ZERO
|
|
for i in 150:
|
|
await physics_frame
|
|
gg.car.linear_velocity = Vector3.ZERO
|
|
if gg.garage_requested:
|
|
break
|
|
_check(gg.garage_requested, "rolling into the bay never opened the garage")
|
|
gg.free()
|
|
Game.level_path = levels.get("woolies_carpark", levels.values()[0])
|
|
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())
|
|
# TRELLIS'd cashies tools: once the library exists, the paddock is strewn
|
|
var tool_dir := DirAccess.open("res://assets/tools")
|
|
if tool_dir != null:
|
|
var glbs := 0
|
|
for f in tool_dir.get_files():
|
|
if f.ends_with(".glb"):
|
|
glbs += 1
|
|
if glbs > 0:
|
|
var strewn := 0
|
|
for n in game.find_children("*", "RigidBody3D", true, false):
|
|
if n.has_meta("cc_tool"):
|
|
strewn += 1
|
|
_check(strewn == glbs, "tool library has %d GLBs but %d spawned" % [glbs, strewn])
|
|
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
|