ShitboxInfinity/tools/tune_junctions.gd
m3ultra 0c13c82eac Pursuit, drag and derby modes; crash-damage paint; junction fixes
Three new modes, all our own code:
- Pursuit (Simpsons Hit & Run's meter): smashing in cruise fills heat, at full
  the police hunt you. Open 250 m for 6 s to escape, get pinned 4 s to be busted.
  Heat decays, so picking a fight is a choice not a timer.
- Drag (NFSU2's): the gearbox IS the event. The builder finds each level's
  longest genuinely straight run of wide road (442-628 m across the five OSM
  levels) and drops DragStart/DragEnd, so no level needs hand-authored coords.
- Derby: five opponents in the carpark, last one running. Reuses the pursuit AI,
  each hunting a different car so it's a brawl not a mob.

Crash damage now shows: total control-point travel drives paint dulling,
roughness and a scratch layer, so scuffs and crumple can never disagree.

Junction generator fixes, all found by measurement rather than inspection:
- streams[:6] truncated after street A's ways, so junctions where A yielded 3+
  usable ways shipped with ZERO cross traffic. Interleaved before capping;
  every event now has 2-4 crossing streams.
- RADIUS 170 -> 95 spread the convoy over a 340 m window, so you threaded the
  intersection through a 20 m gap. Margaret x Edward was unwinnable: 27 convoy
  cars alive, zero collisions, $0.
- Launch points now walk the street to exactly SPAWN_DIST and pick the busiest
  shared node, and _clear_spawn() unblocks a buried start. Albert x Adelaide
  went from $0 (drove 1 m into a building) to $91k.
- build_all_junctions.sh records the street pairs, which existed nowhere before.

Fixes from an adversarial review (23 confirmed findings), the worst being:
- Derby could only ever be LOST: wrecked opponents un-wrecked 2.2 s later via
  Car._recover, so "last one running" was unreachable and the kill bounty could
  be farmed to the boost cap off one revived car. They now hold their wreck.
- Drag reported WON to a beaten player: the AI's lookahead wrapped on a 2-point
  path, U-turning before the line so its distance-to-finish grew again.
- The scratch layer used BLEND_MODE_MIX, whose mask defaults to white -- it
  replaced the paint entirely rather than scuffing it. Now MUL.
- Drag on a level with no strip left the car at the origin, half-buried, unable
  to end. Cop spawns used an unflattened basis. Stale "WANTED" HUD text.

Test harness: smoke covers all seven modes, and failures now exit instead of
hanging -- a failed assert in a headless SceneTree halts the script but leaves
the tree spinning, which cost 53 minutes of wall clock to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:07:41 +10:00

105 lines
3.6 KiB
GDScript

extends SceneTree
## Measure what each crash junction is actually worth, then write per-event
## medal targets back into levels/<id>/junctions.json.
##
## Godot --headless --path . --script tools/tune_junctions.gd -- runs=12
##
## Every junction shipped with the same 15/35/60k guess, which is meaningless:
## a six-lane approach with four convoy streams pays several times what a
## carpark aisle does. This drives each event headless with a spread of launch
## speeds and steering biases, takes the damage distribution, and sets
## bronze/silver/gold at the 35th/65th/88th percentile of what was achieved.
##
## Percentiles, not max: gold should be a good run, not a freak one. Runs are
## seeded per (event, attempt) so the numbers reproduce.
const SETTLE_FRAMES := 1500
func _initialize() -> void:
_run()
func _run() -> void:
var runs := 12
for a in OS.get_cmdline_user_args():
if a.begins_with("runs="):
runs = int(a.split("=")[1])
var by_level := {}
for j in Registry.junctions():
by_level.get_or_add(j.level_id, []).append(j)
for level_id in by_level:
var path := "res://levels/%s/junctions.json" % level_id
var events: Array = JSON.parse_string(FileAccess.get_file_as_string(path))
for j in by_level[level_id]:
var scores: Array[float] = []
for attempt in runs:
scores.append(await _play(j.data, attempt))
scores.sort()
var pick := func(p: float) -> int:
var v: float = scores[clampi(int(round(p * (scores.size() - 1))), 0, scores.size() - 1)]
return int(round(v / 500.0)) * 500 # tidy numbers, not $47,213
var targets := [pick.call(0.35), pick.call(0.65), pick.call(0.88)]
# guarantee a real gap between medals even on a flat distribution
targets[0] = maxi(targets[0], 2000)
targets[1] = maxi(targets[1], targets[0] + 2500)
targets[2] = maxi(targets[2], targets[1] + 4000)
var duds := 0
for s in scores:
if s <= 0.0:
duds += 1
if duds * 2 >= scores.size():
push_warning("%s: %d/%d runs scored nothing -- event may be unwinnable"
% [j.name, duds, scores.size()])
print(" !! %s scored nothing in %d/%d runs" % [j.name, duds, scores.size()])
for ev in events:
if ev.get("name") == j.name:
ev["targets"] = targets
print("%-18s %-24s min $%d med $%d max $%d -> %s"
% [level_id, j.name, int(scores[0]), int(scores[scores.size() / 2]),
int(scores[-1]), str(targets)])
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(JSON.stringify(events, " ") + "\n")
f.close()
print("tuned: wrote targets for %d levels" % by_level.size())
quit(0)
func _play(data: Dictionary, attempt: int) -> float:
seed(hash(str(data.get("name", "")) + str(attempt)))
Game.car_path = Registry.cars()["kingswood_hz"]
Game.level_path = Registry.levels()[_level_of(data)]
Game.mode = "junction"
Game.junction = data
var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate()
root.add_child(game)
await process_frame
# vary the launch so the spread reflects skill, not one scripted line
var lean := (attempt % 5 - 2) * 0.16
var boosty := attempt % 3 == 0
Input.action_press("throttle")
if boosty:
Input.action_press("boost")
var fired := false
for i in SETTLE_FRAMES:
await physics_frame
if i < 90:
game.car.steer_in = lean
if game.jstate == "settle" and not fired:
Input.action_release("throttle")
Input.action_press("boost")
fired = true
if game.over:
break
Input.action_release("throttle")
Input.action_release("boost")
var s: float = game.score
game.free()
await process_frame
return s
func _level_of(data: Dictionary) -> String:
for j in Registry.junctions():
if j.data == data:
return j.level_id
return ""