Earn bucks from every mode (takedowns, outruns, escapes, derby kills, medals, a cut of crash damage) and spend them on performance parts of deeply questionable provenance: K-MART MEGA WING, GO-FASTER STRIPES, SERVO FLAME KIT, BLINGY MAGS, SERVO NEONS, BUNNINGS DRAINPIPE, TURBO THRU THE BONNET. Every part gives a real stat bump AND bolts a procedural eyesore onto the car, sized off the model AABB so the same tat fits a BUBBLA or a Kingswood. Fully kitted: +35% power, +1.1 grip, +0.55 boost. Per-car, persistent (user://garage.save), player-only -- stats apply to a DUPLICATE of the shared CarStats so tuning your Lazza doesn't secretly tune every Lazza in the city, and the save path is redirectable so the smoke test can never wipe real progression. Bolt-ons attach after the deformer snapshot: the wing survives every crash, as is right and proper. GARAGE button + bucks readout in the menu; shop screen with prices, blurbs and FITTED states. Smoke covers the earn/buy/duplicate-stats roundtrip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
258 lines
10 KiB
GDScript
258 lines
10 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()
|
|
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: needs a level with DragStart/DragEnd, so not the code-built carpark
|
|
Game.level_path = levels["southbank"]
|
|
print(" .. drag")
|
|
game = await _boot("drag")
|
|
_check(game.drag_len > 100.0, "no drag strip found (%.0f m)" % game.drag_len)
|
|
_check(game.rivals.size() == 1, "drag had no opponent")
|
|
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])
|
|
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: rivals spawn and make progress along the path
|
|
print(" .. race")
|
|
game = await _boot("race")
|
|
_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 | drag shifts | derby %d cars | 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
|