From 955413b7356da85a60027f35e6e017d742c8ef4e Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 31 Jul 2026 19:12:15 +1000 Subject: [PATCH] Wrench phase 3: driveway customers -- the shed becomes a business Visit The Shed and one of the locals is waiting on the driveway with a broken shitbox and cash: their actual car model parked there under a floating Label3D job sign -- who they are, what they reckon is wrong ("it makes a noise at church speed"), the honest fault name, and the offer. Pull up beside it and hold still to take the job; the wrench minigame opens on THEIR car. The roster is the extended universe: Nanna, P-Plate Pepper, Mullet Merv, Torque'n Tezza, the Self-Described Bathurst Legend, Your Mate Dazza (don't tell Shazza), Karen from Number 12, and the Widowmaker, who says nothing and pays cash. Roster and fault rotate deterministically off jobs_done. Settlement: full pay at 60+, a $50 tip at 85+, half below 60 -- and SHED REP moves with the workmanship (+3 clean, -6 botched, cap 40), scaling the next offer from 70% of shop rate at rep 0 to 100% at cap. Rep shows on the menu and garage header. Customer jobs bank NO quality buffs: the customer's car id might be a car the player owns, and fixing Merv's Kingswood must not secretly tune yours -- the wrench consumes Garage.ui_customer and routes _finish to customer_done() instead of clear_fault/set_quality. The shed guards the spawn (parent is Game and mode == cruise) so the wrench scene's backdrop copy of the level never summons a customer mid-job. The spare parked shitbox moves to the kerb; the driveway belongs to commerce now. Smoke: customer waits after a shed visit, holding beside the spot takes the job (flag-guarded for headless), the driven wrench job pays, rep rises, the driveway clears, and the customer's own-car quality stays untouched. Co-Authored-By: Claude Opus 5 --- README.md | 16 ++++++++-- levels/the_shed/shed.gd | 38 ++++++++++++++++++++++-- src/game.gd | 25 ++++++++++++++++ src/garage.gd | 65 +++++++++++++++++++++++++++++++++++++++++ src/garage_ui.gd | 2 +- src/main.gd | 2 +- src/wrench.gd | 25 ++++++++++++---- tests/smoke.gd | 32 ++++++++++++++++++++ 8 files changed, 193 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 41820fb..6632ada 100644 --- a/README.md +++ b/README.md @@ -182,8 +182,20 @@ into BRRRT clicks** (torque windows keep the hand skill -- guns strip), ramps speed the jacking, the fluids shelf speeds pours, and the tier-3 scan tool skips diagnosis entirely. Workmanship reaches the strip: an 85+ gearbox rebuild takes an **extra blown shift** before grenading, an 85+ gasket job -**raises the overheat ceiling** -- both asserted in the suite. Phase 3 is -driveway customers. +**raises the overheat ceiling** -- both asserted in the suite. + +Phase 3 makes the shed a business: **driveway customers**. Visit The Shed and +one of the locals is waiting on the driveway -- Nanna's Morry ("it makes a +noise at church speed"), Your Mate Dazza's ute ("don't tell Shazza"), Karen +from Number 12 ("I will be watching. the entire time") -- their actual car +parked there with a floating job sign naming the fault and the offer. Pull up +beside it and hold still to take the job; the wrench opens on THEIR car. Cash +on completion, a $50 tip at 85+, half pay below 60 -- and **SHED REP** moves +with the workmanship (+3 clean, -6 botched, capped at 40), scaling what the +next customer offers (70% of shop rate at rep 0 up to 100% at cap). Roster +and fault rotate deterministically off jobs done. Customer jobs bank no +quality buffs -- their car id might be a car you own, and fixing Merv's +Kingswood must not secretly tune yours. **The Shed is also a place** (`levels/the_shed`): a drivable suburban home base — brick house, paling fence, hills hoist mid-spin with the washing still on it, diff --git a/levels/the_shed/shed.gd b/levels/the_shed/shed.gd index 51d809e..b77379f 100644 --- a/levels/the_shed/shed.gd +++ b/levels/the_shed/shed.gd @@ -55,11 +55,43 @@ func _ready() -> void: m.name = "WB_%d" % i m.position = Vector3(18.8, 0, 17.0 - i * 1.4) add_child(m) + # the spare shitbox parks on the street now -- the driveway is for customers var pp := Marker3D.new() - pp.name = "PP_drive" - pp.position = Vector3(12, 0, 3) - pp.rotate_y(0.06) + pp.name = "PP_kerb" + pp.position = Vector3(-8, 0, 30.5) + pp.rotate_y(PI * 0.5 + 0.04) add_child(pp) + # a customer waits on the driveway (real play only: the wrench scene uses + # this level as backdrop and must never summon one mid-job) + if get_parent() is Game and Game.mode == "cruise": + _customer() + +func _customer() -> void: + var cust := Garage.customer_arrive() + if cust.is_empty(): + return + var cars := Registry.cars() + if cars.has(cust["car"]): + var st: CarStats = load(cars[cust["car"]]) + if st.model_path != "" and ResourceLoader.exists(st.model_path): + var m := (load(st.model_path) as PackedScene).instantiate() + add_child(m) + m.position = Vector3(12, 0.72, 6.5) + m.rotate_y(0.12) + var spot := Marker3D.new() + spot.name = "CustomerSpot" + spot.position = Vector3(12, 0, 6.5) + add_child(spot) + var sign := Label3D.new() + sign.text = "%s\n\"%s\"\n%s -- $%d" % [cust["name"], cust["blurb"], + Garage.FAULTS[cust["fault"]]["name"], cust["pay"]] + sign.font_size = 64 + sign.pixel_size = 0.004 + sign.billboard = BaseMaterial3D.BILLBOARD_ENABLED + sign.modulate = Color(1, 0.85, 0.4) + sign.outline_size = 12 + sign.position = Vector3(12, 2.7, 6.5) + add_child(sign) func _process(delta: float) -> void: if hoist: diff --git a/src/game.gd b/src/game.gd index 7458a79..9c805c3 100644 --- a/src/game.gd +++ b/src/game.gd @@ -140,6 +140,9 @@ var _oncoming_toast := 0.0 var garage_bay: Node3D = null # The Shed's bay: roll in, stop, garage opens var bay_t := 0.0 var garage_requested := false +var customer_spot: Node3D = null # the driveway job: stop beside it to accept +var cust_t := 0.0 +var job_taken := false var car: Car var cam: Camera3D @@ -196,6 +199,7 @@ func _ready() -> void: race_path = path garage_bay = level.find_child("GarageBay", true, false) + customer_spot = level.find_child("CustomerSpot", true, false) car = (load("res://src/car.tscn") as PackedScene).instantiate() car.stats = load(car_path) @@ -740,6 +744,27 @@ func _physics_process(delta: float) -> void: get_tree().change_scene_to_file("res://src/garage.tscn") else: bay_t = 0.0 + if mode == "cruise" and customer_spot != null and not job_taken and not car.wrecked: + # the driveway customer: pull up beside their heap, hold still, job's yours + var cd := car.global_position.distance_to(customer_spot.global_position) + if cd < 5.0 and car.linear_velocity.length() < 1.5: + var was_c := cust_t + cust_t += delta + var cust := Garage.customer() + if not cust.is_empty(): + if was_c < 0.3 and cust_t >= 0.3: + _msg("%s: \"%s\"\nHOLD STILL TO TAKE THE JOB ($%d)" + % [cust["name"], cust["blurb"], cust["pay"]], 32) + if cust_t >= 1.4: + job_taken = true + Garage.ui_stats_path = Registry.cars()[cust["car"]] + Garage.ui_fault = cust["fault"] + Garage.ui_customer = true + Sfx.shot_2d("clunk", -4.0) + if get_tree().current_scene != null: + get_tree().change_scene_to_file("res://src/wrench.tscn") + else: + cust_t = 0.0 if mode == "drag" and not over: _drag_tick(delta) if mode == "rage" and not over: diff --git a/src/garage.gd b/src/garage.gd index 62c1b55..f705de6 100644 --- a/src/garage.gd +++ b/src/garage.gd @@ -16,6 +16,7 @@ const SAVE := "user://garage.save" static var save_path := SAVE static var ui_stats_path := "" # menu -> garage screen: which car is selected static var ui_fault := "" # garage screen -> repair minigame: which fault +static var ui_customer := false # wrench job is a CUSTOMER's car, not yours ## id -> {name, desc, price, effects, build (visual constructor)} ## Effects: power_mult (multiplies engine_power), grip_add, boost_add (base @@ -303,6 +304,70 @@ static func add_fault(car_id: String, fault: String) -> void: _data["faults"] = f _flush() +## ---- driveway customers: the shed becomes a business ----------------------- +## Locals turn up at The Shed with a broken shitbox and cash. Fix it in the +## wrench minigame; a clean job (85+) builds REP, a botch (<60) bleeds it, and +## rep scales what the next customer offers. Roster and fault rotate +## deterministically off jobs_done, so the queue is fair and testable. + +const CUSTOMERS := [ + {"name": "NANNA (SHOPPING RUN)", "car": "morry", "blurb": "it makes a noise at church speed"}, + {"name": "P-PLATE PEPPER", "car": "excel_x3", "blurb": "mum says it's fine. it is not fine"}, + {"name": "MULLET MERV", "car": "kingswood_hq", "blurb": "she's mint mate. just needs a tickle"}, + {"name": "TORQUE'N TEZZA", "car": "vl_terbo", "blurb": "started making the EXPENSIVE noise"}, + {"name": "SELF-DESCRIBED BATHURST LEGEND", "car": "floored_xd", "blurb": "raced Brocky once. anyway. it's cooked"}, + {"name": "YOUR MATE DAZZA", "car": "hardon_wb_ute", "blurb": "mate. MATE. don't tell Shazza"}, + {"name": "KAREN FROM NUMBER 12", "car": "chaebol_90", "blurb": "I will be watching. the entire time"}, + {"name": "THE WILLOWBANK WIDOWMAKER", "car": "falcodore_gtho", "blurb": "... (pays cash)"}, +] +const REP_CAP := 40 + +static func rep() -> int: + _load() + return int(_data.get("rep", 0)) + +static func customer() -> Dictionary: + _load() + return _data.get("customer", {}) + +static func customer_arrive() -> Dictionary: + ## One waiting customer at a time; returns the current or a fresh one. + ## Pay offer = shop rate scaled by rep (70% at rep 0 up to 100% at cap). + _load() + var cur: Dictionary = _data.get("customer", {}) + if not cur.is_empty(): + return cur + var jobs := int(_data.get("jobs_done", 0)) + var who: Dictionary = CUSTOMERS[jobs % CUSTOMERS.size()] + var fkeys := FAULTS.keys() + var fault: String = fkeys[jobs % fkeys.size()] + var pay := int(int(FAULTS[fault]["shop"]) * (0.7 + mini(rep(), REP_CAP) * 0.0075)) + cur = {"name": who["name"], "car": who["car"], "blurb": who["blurb"], + "fault": fault, "pay": pay} + _data["customer"] = cur + _flush() + return cur + +static func customer_done(quality: float) -> int: + ## Settle up: full pay at 60+, a $50 tip at 85+, half pay below 60. + ## Rep moves with the workmanship -- word gets around either way. + _load() + var cur: Dictionary = _data.get("customer", {}) + if cur.is_empty(): + return 0 + var pay := int(cur["pay"]) + if quality >= 85.0: + pay += 50 + _data["rep"] = mini(rep() + 3, REP_CAP) + elif quality < 60.0: + pay = pay / 2 + _data["rep"] = maxi(rep() - 6, 0) + _data["bucks"] = int(_data["bucks"]) + pay + _data["jobs_done"] = int(_data.get("jobs_done", 0)) + 1 + _data["customer"] = {} + _flush() + return pay + ## ---- workmanship: 0-100 per fixed system, 85+ is a buff -------------------- static func quality(car_id: String, sys: String) -> float: diff --git a/src/garage_ui.gd b/src/garage_ui.gd index 8165a2c..637a0d7 100644 --- a/src/garage_ui.gd +++ b/src/garage_ui.gd @@ -86,7 +86,7 @@ func _refresh() -> void: var keep := list.get_selected_items() list.clear() entries.clear() - bucks_label.text = "BUCKS: $%d" % Garage.bucks() + bucks_label.text = "BUCKS: $%d | SHED REP %d" % [Garage.bucks(), Garage.rep()] var flt := Garage.faults(car_id) if not flt.is_empty(): _header("--- IT'S BROKEN (fix it or pay up) ---") diff --git a/src/main.gd b/src/main.gd index 6171a5a..e3070ec 100644 --- a/src/main.gd +++ b/src/main.gd @@ -39,7 +39,7 @@ func _ready() -> void: if not level_paths.is_empty(): level_list.select(0) mode_list.select(0) - $VBox/Bucks.text = "BUCKS: $%d" % Garage.bucks() + $VBox/Bucks.text = "BUCKS: $%d | SHED REP %d" % [Garage.bucks(), Garage.rep()] $VBox/Buttons/Start.pressed.connect(_start) $VBox/Buttons/Garage.pressed.connect(_garage) diff --git a/src/wrench.gd b/src/wrench.gd index b6d0fac..4afe5e0 100644 --- a/src/wrench.gd +++ b/src/wrench.gd @@ -27,6 +27,7 @@ const LIGHT_POOL := ["ENGINE TEMP", "DRIVETRAIN", "OIL PRESSURE", "BATTERY", "AI var car_id := "" var fault := "" +var customer := false # it's THEIR car: payout + rep, no buffs for you var stats: CarStats var steps: Array = [] var step_index := 0 @@ -57,6 +58,8 @@ var _oil_fx: GPUParticles3D = null func _ready() -> void: car_id = Garage.car_id_of(Garage.ui_stats_path) fault = Garage.ui_fault + customer = Garage.ui_customer + Garage.ui_customer = false # consume: the next wrench is yours by default stats = load(Garage.ui_stats_path) add_child((load("res://levels/the_shed/level.tscn") as PackedScene).instantiate()) @@ -601,11 +604,23 @@ func _advance() -> void: func _finish() -> void: done = true quality = clampf(quality, 0.0, 100.0) - Garage.clear_fault(car_id, fault) - Garage.set_quality(car_id, fault, quality) - step_label.text = "DONE. QUALITY %d%%" % int(quality) - note_label.text = ("that's a PROPER fix -- it'll drive better than stock" if quality >= 85.0 - else "it'll hold. probably. no buff at that standard, mate") + if customer: + # their car, your invoice: cash now, rep either way. No buffs banked -- + # set_quality on THEIR car id would secretly tune your own copy of it. + var pay := Garage.customer_done(quality) + step_label.text = "PAID $%d. QUALITY %d%%" % [pay, int(quality)] + if quality >= 85.0: + note_label.text = "word gets around. the GOOD word. (rep %d)" % Garage.rep() + elif quality >= 60.0: + note_label.text = "it'll hold. invoice says so. (rep %d)" % Garage.rep() + else: + note_label.text = "that one's coming back on a truck. (rep %d)" % Garage.rep() + else: + Garage.clear_fault(car_id, fault) + Garage.set_quality(car_id, fault, quality) + step_label.text = "DONE. QUALITY %d%%" % int(quality) + note_label.text = ("that's a PROPER fix -- it'll drive better than stock" if quality >= 85.0 + else "it'll hold. probably. no buff at that standard, mate") Sfx.shot_2d("sting_win" if quality >= 85.0 else "clunk", -4.0) var b := Button.new() b.text = "BACK TO THE GARAGE" diff --git a/tests/smoke.gd b/tests/smoke.gd index 461e802..75cc83e 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -140,6 +140,38 @@ func _run() -> void: break _check(gg.garage_requested, "rolling into the bay never opened the garage") gg.free() + # phase 3: a driveway customer waits, gets their car fixed, and pays + print(" .. customer") + var cust := Garage.customer() + _check(not cust.is_empty(), "no customer turned up at the shed") + gg = await _boot("cruise") + _check(gg.customer_spot != null, "customer car has no CustomerSpot") + var cs_xf := Transform3D(Basis.IDENTITY, gg.customer_spot.global_position + Vector3.UP * 0.75) + PhysicsServer3D.body_set_state(gg.car.get_rid(), PhysicsServer3D.BODY_STATE_TRANSFORM, cs_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.job_taken: + break + _check(gg.job_taken and Garage.ui_customer, "holding by the customer never took the job") + _check(Garage.ui_fault == cust["fault"], "job fault mismatch") + gg.free() + var bucks_before: int = Garage.bucks() + var rep_before: int = Garage.rep() + var cw = (load("res://src/wrench.tscn") as PackedScene).instantiate() + root.add_child(cw) + await process_frame + _check(cw.customer, "wrench didn't know it was a customer job") + await _drive_wrench(cw, false) + _check(cw.done, "customer job never finished (step %d)" % cw.step_index) + _check(Garage.bucks() > bucks_before, "customer didn't pay") + _check(Garage.rep() > rep_before, "clean customer job earned no rep") + _check(Garage.customer().is_empty(), "customer never left the driveway") + _check(Garage.quality(String(cust["car"]), String(cust["fault"])) == 0.0, + "customer job polluted own-car quality") + cw.free() Game.level_path = levels.get("woolies_carpark", levels.values()[0]) # wrench phase 1: the 3D shed jobs. A clean flat-tyre run scores 90+ and # buffs the car; a sabotaged torque window strips a thread on the oil job.