extends SceneTree ## Measure what each crash junction is actually worth, then write per-event ## medal targets back into levels//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 ""