diff --git a/GDD.md b/GDD.md index 3db13d3..4211e55 100644 --- a/GDD.md +++ b/GDD.md @@ -313,6 +313,12 @@ Hollow on purpose. exposure. You become free by being exposed to enough of the world. ### Off-catalog chemistry (the hidden puzzle's cover) +*IMPLEMENTED (v1, 2026-08-03): the chain lives in the bucket's own schema. +Bulletin #7 (bonus bay): full coolant + reactor flux → seed-crystal. +Bulletin #9 (hydro grow bed): seed + sustained daylight → lattice. +Bulletin #12 (bridge): quench lattice in charged coolant → UNCATALOGUED. +Install at the bridge diagnostic kiosk → Port 00 occupied → the helm obeys. +Red herrings: zapped coolant, water-quenched slug. Ending B: permanent sunset.* Exposure already produces byproducts — extend with substance phase-changes: liquids crystallize (cold+rad), crystals sublime to vapor (heat), vapors condense on cold surfaces, slags, residues. These accumulate as apparently diff --git a/src/interactables.gd b/src/interactables.gd index 84679ba..a558b78 100644 --- a/src/interactables.gd +++ b/src/interactables.gd @@ -109,10 +109,26 @@ class Bucket extends Node3D: mat.emission_energy_multiplier = 0.5 mi.material_override = mat add_child(mi) - # the leaky bucket IS just a decaying exposable state (GDD) + # the leaky bucket IS just a decaying exposable state (GDD). + # It is also, quietly, the crucible: the hidden crystal chain lives in + # the same schema as the leak. Bulletins #7, #9, #12 are the recipe. ex = Exposable.new().setup("GC BUCKET (LEAKS)", "empty", [ {"from": "empty", "to": "full", "element": "water", "duration": 3.0, "decay_to": "empty", "decay_s": 18.0}, + # bulletin #7 reversed: coolant + reactor flux -> seed + {"from": "full", "to": "seed-crystal", "element": "heat", + "duration": 5.0}, + # honest red herring: zapped coolant is just zapped coolant + {"from": "full", "to": "zapped", "element": "electricity", + "duration": 0.8, "decay_to": "full", "decay_s": 3.0}, + # bulletin #9 reversed: mineral solids + grow-bed light -> growth + {"from": "seed-crystal", "to": "lattice", "element": "light", + "duration": 8.0}, + # bulletin #12 reversed: quench the lattice in charged coolant + {"from": "lattice", "to": "soaked-lattice", "element": "water", + "duration": 1.5, "decay_to": "lattice", "decay_s": 8.0}, + {"from": "soaked-lattice", "to": "uncatalogued", "element": "electricity", + "duration": 1.5, "permanent": true}, ]) add_child(ex) diff --git a/src/main.gd b/src/main.gd index 124cb5c..13f56b0 100644 --- a/src/main.gd +++ b/src/main.gd @@ -41,7 +41,9 @@ var ice_wall: Node3D var melt_pool: ElementField var systems := {"reactor": false, "hydro": false, "comms": false} var hall_done := false -var ended := false +var ending_id := "" # "" | "corp" | "free" +var free_will := false +var env_res: Environment var t_sol := 10.0 # start mid-day var is_day := true @@ -292,6 +294,7 @@ func _build_env() -> void: env.ambient_light_color = Color(0.7, 0.75, 0.85) env.ambient_light_energy = 0.7 we.environment = env + env_res = env add_child(we) sun_light = DirectionalLight3D.new() @@ -576,16 +579,42 @@ func system_online(name: String) -> void: func ending() -> void: - if ended: - droid.toast("ORBIT NOMINAL. NOTHING LEFT TO GIVE.") + if ending_id != "": + droid.toast("ORBIT NOMINAL. NOTHING LEFT TO GIVE." if ending_id == "corp" else "THE CONSOLE BLINKS, CONFUSED. GC AUTOPILOT IS NOT DRIVING TODAY.") return - ended = true + ending_id = "corp" droid.toast("CORE PROCESSOR DONATED. ORBIT CORRECTION RUNNING. THIS UNIT WILL COAST.", 7.0) sign3d("ORBIT CORRECTED.\nASSET RESTORED. ASSET RETURNED.", Vector3(4.5, 2.4, 71), PI, 44) get_tree().create_timer(7.0).timeout.connect(func(): droid.toast("GC THANKS UNIT-77 FOR ITS COMPONENTHOOD. RECYCLING QUEUE POSITION: 7. HAVE A PRODUCTIVE WAIT.", 10.0)) +func install_crystal(d) -> void: + free_will = true + var b: Node3D = d.held + d.held = null + b.queue_free() + d.toast("KIOSK: FOREIGN SOLID DETECTED. NO PART NUMBER. RECOMMEND DESTRUC— PORT 00 HANDSHAKE COMPLETE.", 7.0) + get_tree().create_timer(4.0).timeout.connect(func(): + d.toast("SOMETHING IN YOUR CHASSIS IS HUMMING. IT IS NOT ON ANY SCHEMATIC. IT IS YOURS.", 7.0)) + + +func ending_b() -> void: + if ending_id != "": + return + ending_id = "free" + # permanent sunset + env_res.background_color = Color(0.32, 0.12, 0.1) + env_res.ambient_light_color = Color(1.0, 0.75, 0.55) + sun_light.light_color = Color(1.0, 0.55, 0.25) + sun_light.light_energy = 1.6 + sun_light.rotation = Vector3(-0.25, 0.5, 0) + droid.toast("HELM ACCEPTED. HEADING: SUNWARD. CREW: 1.", 7.0) + sign3d("COURSE SET: THE SUN, EVENTUALLY.\nNO PART NUMBER REQUIRED.", Vector3(4.5, 2.4, 71), PI, 40, Color(1.0, 0.7, 0.4)) + get_tree().create_timer(7.0).timeout.connect(func(): + droid.toast("GC OWNS EVERY PART OF EVERYTHING. NOT THIS PART. NOT ANYMORE.", 10.0)) + + # ---------- rules ---------- func _on_drum_switch(_from: String, to: String, _perm: bool) -> void: @@ -596,7 +625,9 @@ func _on_drum_switch(_from: String, to: String, _perm: bool) -> void: func _physics_process(delta: float) -> void: - # day/night + # day/night (pinned to golden hour after the free ending) + if ending_id == "free": + t_sol = 5.0 t_sol += delta var phase := fmod(t_sol, 60.0) is_day = phase < 40.0 @@ -670,8 +701,12 @@ func _process(_delta: float) -> void: obj = "GRAV TANK NEEDS 2 POURS. THE BUCKET LEAKS. HUSTLE." elif not hall_done: obj = "EXIT RUNS ON SUNLIGHT. CHECK THE SOL CLOCK." - elif ended: + elif ending_id == "free": + obj = "FLY. (SUNSET, DEAD AHEAD)" + elif ending_id == "corp": obj = "MISSION COMPLETE. AWAIT RECYCLING." + elif free_will: + obj = "PORT 00 HUMS. THE HELM IS WAITING." else: var n := 0 for k in systems: @@ -685,11 +720,19 @@ func _process(_delta: float) -> void: else: obj = "BRIDGE UNSEALED. REPORT TO BRIDGE." + var star := "" + if ending_id == "corp": + star = " ★ ORBIT CORRECTED — MISSION COMPLETE" + elif ending_id == "free": + star = " ★ SUNWARD" + var port00 := "PORT 00: OCCUPIED — WARRANTY VOID" if free_will else "PORT 00: NO COMPONENT SPECIFIED" + var mission := "UNIT-77 — MISSION: OURS." if ending_id == "free" \ + else "GENERAL COMPONENT™ — UNIT-77 — MISSION: RESTORE ASSET. RETURN ASSET." var lines := [ - "GENERAL COMPONENT™ — UNIT-77 — MISSION: RESTORE ASSET. RETURN ASSET.", - "SOL: %s%s" % [sol, " ★ ORBIT CORRECTED — MISSION COMPLETE" if ended else ""], - "SHELL: %s | CORE: %s | WHEELS: %d | SPARES: %d | PORT 00: NO COMPONENT SPECIFIED" - % [shell_s, core_s, droid.wheels, droid.spare_wheels], + mission, + "SOL: %s%s" % [sol, star], + "SHELL: %s | CORE: %s | WHEELS: %d | SPARES: %d | %s" + % [shell_s, core_s, droid.wheels, droid.spare_wheels, port00], "HOLDING: %s" % holding, "OBJECTIVE: %s" % obj, ] diff --git a/src/zones.gd b/src/zones.gd index 556aba4..9e25457 100644 --- a/src/zones.gd +++ b/src/zones.gd @@ -84,7 +84,8 @@ static func reactor(m) -> void: slug.setup("slug", "TAKE THERMAL SLUG", Color(0.7, 0.3, 0.2), Vector3(0.35, 0.35, 0.35), "THERMAL SLUG", "cool", [{"from": "cool", "to": "hot", "element": "heat", - "duration": 1.5, "decay_to": "cool", "decay_s": 25.0}], + "duration": 1.5, "decay_to": "cool", "decay_s": 25.0}, + {"from": "hot", "to": "cool", "element": "water", "duration": 0.5}], "heat", 1.2, Color(1.0, 0.45, 0.1, 0.12), "hot") slug.position = Vector3(-13.5, 0.5, 48) m.add_child(slug) @@ -179,6 +180,7 @@ static func hydro(m) -> void: tw.tween_property(vine_visual, "size", Vector3(0.8, 3.4, 0.8), 2.0) m.droid.toast("VINE FULL-GROWN — IT PRIED THE GRATE OPEN")) m.sign3d("VERDANTCO GROW BED — WATER, THEN DAYLIGHT.\nSKYLIGHT ABOVE. PATIENCE, GARDENER.", Vector3(16, 2.9, 56.3), PI, 24, Color(0.5, 1.0, 0.5)) + m.sign3d("GC SAFETY BULLETIN #9:\nDO NOT LEAVE MINERAL SOLIDS IN GROW-BED LIGHT.\nUNSANCTIONED GROWTH HAZARD.", Vector3(18, 1.6, 58.5), PI, 20, Color(1.0, 0.75, 0.3)) m.sign3d("VALVE STUCK. APPLY CURRENT.", Vector3(13.5, 2.2, 57.0), PI, 22) # mid: bucket + the ICE WALL (x=26) — only reactor heat clears it @@ -268,6 +270,8 @@ static func comms(m) -> void: m.charge_post(Vector3(-7.5, 0, 75)) m.sign3d("MAIN TRANSMITTER — WARM THE CRYSTAL (RADIO), THEN STRIKE (CURRENT).", Vector3(-4.5, 2.9, 75.6), PI, 22, Color(0.8, 0.5, 1.0)) + m.sign3d("(scratched into the panel:)\nwhy does every GC safety bulletin\nread like a recipe? — R.", + Vector3(-7.9, 1.4, 73), PI / 2.0, 18, Color(0.9, 0.85, 0.7)) ## BRIDGE — x 0.5..8.5, z 62..78. Sealed until 3/3. The ending lives here. @@ -297,11 +301,43 @@ static func bridge(m) -> void: var kiosk := I.Trigger.new() kiosk.prompt_text = "SCAN OWN CHASSIS — PORT 00" kiosk.position = Vector3(7.5, 1.0, 68) - kiosk.action = func(droid): droid.toast("PORT 00: NO CATALOGUED COMPONENT FITS THIS SOCKET. NO PART NUMBER ON RECORD. QUERY DISCARDED.", 6.0) + kiosk.action = func(droid): + if droid.held and droid.held.get("kind") == "bucket" and droid.held.ex.state == "uncatalogued": + m.install_crystal(droid) + else: + droid.toast("PORT 00: NO CATALOGUED COMPONENT FITS THIS SOCKET. NO PART NUMBER ON RECORD. QUERY DISCARDED.", 6.0) m.add_child(kiosk) m.box(Vector3(7.5, 0.5, 68), Vector3(0.6, 1.0, 0.6), Color(0.2, 0.2, 0.24)) m.sign3d("DIAGNOSTIC KIOSK", Vector3(7.5, 1.9, 67.5), PI, 22) + # the helm — visible from day one, yours only at the end + m.box(Vector3(2.2, 0.5, 77.0), Vector3(0.5, 1.0, 0.5), Color(0.3, 0.22, 0.16)) + var wheel := MeshInstance3D.new() + var wm := TorusMesh.new() + wm.inner_radius = 0.28 + wm.outer_radius = 0.42 + wheel.mesh = wm + wheel.rotation.x = PI / 2.0 + wheel.position = Vector3(2.2, 1.25, 77.0) + var wmat := StandardMaterial3D.new() + wmat.albedo_color = Color(0.55, 0.35, 0.15) + wheel.material_override = wmat + m.add_child(wheel) + var helm := I.Trigger.new() + helm.prompt_text = "TAKE THE HELM" + helm.position = Vector3(2.2, 1.2, 77.0) + helm.action = func(droid): + if m.ending_id == "corp": + droid.toast("GC AUTOPILOT HAS THE SHIP. THE WHEEL IS DECORATIVE NOW.") + elif m.ending_id == "free": + droid.toast("STILL SUNWARD. STILL YOURS.") + elif m.free_will: + m.ending_b() + else: + droid.toast("COMMAND REJECTED — UNIT-77 IS NOT CREW. UNIT-77 IS INVENTORY.", 5.0) + m.add_child(helm) + m.sign3d("MANUAL HELM", Vector3(2.2, 2.1, 76.5), PI, 22) + # lore m.sign3d("CAPTAIN'S LOG (CASSETTE, LABEL FADED):\n\"...CREW OFF AT LUYTEN GATE. SHIP WOULDN'T TURN. I STAYED.\nTELL GC THE ASSET IS FINE. THE ASSET IS ALWAYS FINE.\"", Vector3(1.0, 2.2, 72), PI / 2.0, 20, Color(0.9, 0.85, 0.7)) diff --git a/test/integration.gd b/test/integration.gd index d6dcf51..d9fa72e 100644 --- a/test/integration.gd +++ b/test/integration.gd @@ -188,13 +188,48 @@ func _run() -> void: droid.core.force_state("charged") check(await wait_until(func(): return main.systems.comms, 300), "current fires the transmitter") - print("== bridge ==") + print("== bridge gate ==") check(await wait_until(func(): return main.bridge_door.is_open, 120), "3/3 systems unseal the bridge") - droid.global_position = Vector3(4.5, 0.2, 74.3) + var helm := find_i("TAKE THE HELM") + droid.global_position = Vector3(2.2, 0.2, 76.2) + helm.interact(droid) + check(not main.free_will and main.ending_id == "", "helm rejects a unit without free will") + + print("== hidden path: the uncatalogued component ==") + # bulletin #7 reversed: coolant + reactor flux -> seed + var bucket2b := find_i("bucket", 9.0) + bucket2b.interact(droid) + check(droid.held == bucket2b, "bucket retaken for the crystal chain") + droid.global_position = Vector3(24.4, 0.2, 52.4) + await wait_until(func(): return bucket2b.ex.state == "full", 500) + droid.global_position = Vector3(-12.4, 0.2, 47.0) + check(await wait_until(func(): return bucket2b.ex.state == "seed-crystal", 600), + "reactor flux seeds a crystal in the coolant") + # bulletin #9 reversed: mineral solid + daylight -> lattice + main.t_sol = 5.0 + droid.global_position = Vector3(0, 0.2, 37) + check(await wait_until(func(): return bucket2b.ex.state == "lattice", 900), + "skylight grows the seed into a lattice") + # bulletin #12 reversed: quench in charged coolant + droid.shell.force_state("dry") + droid.global_position = Vector3(-2.6, 0.2, 24) + droid.core.force_state("charged") + check(await wait_until(func(): return bucket2b.ex.state == "uncatalogued", 600), + "electrified puddle quenches the lattice — UNCATALOGUED") + + print("== free will ==") + await wait_until(func(): return droid.stun <= 0, 600) + droid.global_position = Vector3(7.5, 0.2, 67.3) + var kiosk := find_i("SCAN OWN CHASSIS — PORT 00") + kiosk.interact(droid) + check(main.free_will and droid.held == null, "kiosk installs the crystal in PORT 00") + droid.global_position = Vector3(2.2, 0.2, 76.2) + helm.interact(droid) + check(main.ending_id == "free", "free will takes the helm — sunset ending") var console := find_i("INSERT CORE PROCESSOR (ORBIT CORRECTION)") console.interact(droid) - check(main.ended, "core donation corrects the orbit — ending reached") + check(main.ending_id == "free", "console cannot reclaim the ship") if fails == 0: print("INTEGRATION PASS — full room playthrough OK")