The crystal chain lives entirely in the bucket's existing schema — bulletins #7/#9/#12 read in reverse: coolant + reactor flux -> seed, seed + grow-bed daylight -> lattice, lattice quenched in charged coolant -> UNCATALOGUED. Bridge kiosk installs it in Port 00; the helm (which rejects you as INVENTORY all game) accepts, and the ship flies into a permanent sunset. Console donation remains ending A. Red herrings: zapped coolant, water-quenched slug. New bulletin #9 in hydro + crew graffiti hint in comms. Integration test now plays the hidden path end to end — all checks passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
239 lines
9.3 KiB
GDScript
239 lines
9.3 KiB
GDScript
extends SceneTree
|
|
## Headless playthrough of the whole Phase 0 room.
|
|
## Run: godot --headless --path . -s test/integration.gd
|
|
|
|
var fails := 0
|
|
|
|
|
|
func check(cond: bool, msg: String) -> void:
|
|
if cond:
|
|
print(" ok: " + msg)
|
|
else:
|
|
fails += 1
|
|
printerr(" FAIL: " + msg)
|
|
|
|
|
|
# exposure accrues on physics ticks (real-time), so waits must count
|
|
# physics frames — process frames run uncapped-fast in headless
|
|
func wait(frames: int) -> void:
|
|
for i in frames:
|
|
await physics_frame
|
|
|
|
|
|
func wait_until(cond: Callable, max_frames: int) -> bool:
|
|
for i in max_frames:
|
|
if cond.call():
|
|
return true
|
|
await physics_frame
|
|
return cond.call()
|
|
|
|
|
|
func find_i(kind_or_prompt: String, min_x := -999.0) -> Node3D:
|
|
for n in get_nodes_in_group("interactable"):
|
|
if n.global_position.x < min_x:
|
|
continue
|
|
if str(n.get("kind")) == kind_or_prompt or str(n.get("prompt_text")) == kind_or_prompt:
|
|
return n
|
|
return null
|
|
|
|
|
|
func find_ex(dname: String) -> Node:
|
|
for n in get_nodes_in_group("exposable"):
|
|
if n.display_name == dname:
|
|
return n
|
|
return null
|
|
|
|
|
|
func _initialize() -> void:
|
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
|
_run()
|
|
|
|
|
|
func _run() -> void:
|
|
Engine.time_scale = 3.0 # ponytail: same sim, less wall-clock
|
|
await wait(10)
|
|
var main := root.get_node("Main")
|
|
var droid: Droid = main.droid
|
|
|
|
print("== donation ==")
|
|
var socket := find_i("DONATE WHEEL TO DOOR BUS")
|
|
socket.interact(droid)
|
|
check(main.door1.is_open and droid.wheels == 1, "wheel donated, door1 open, droid slow")
|
|
|
|
print("== be the wire ==")
|
|
droid.global_position = Vector3(0, 0.2, 15.3)
|
|
droid.core.force_state("charged")
|
|
check(await wait_until(func(): return main.door2.is_open, 120),
|
|
"charged droid in the bus gap closes the circuit")
|
|
|
|
var wheel := find_i("TAKE WHEEL")
|
|
wheel.interact(droid)
|
|
check(droid.wheels == 2, "replacement wheel reinstalled")
|
|
|
|
print("== electrified puddle ==")
|
|
droid.global_position = Vector3(-2.6, 0.2, 24)
|
|
droid.core.force_state("charged")
|
|
check(await wait_until(func(): return main.puddle_live.enabled, 120),
|
|
"puddle goes live from droid charge")
|
|
check(await wait_until(func(): return main.relay.state == "tripped", 300),
|
|
"live puddle trips sluice relay")
|
|
check(main.door3.is_open, "relay opens door3")
|
|
check(droid.shell.state == "wet" or droid.stun > 0, "droid got wet/shocked in the process")
|
|
droid.global_position = Vector3(0, 0.2, 25)
|
|
await wait_until(func(): return droid.stun <= 0 and main.puddle_live.enabled == false, 600)
|
|
|
|
print("== leaky bucket ==")
|
|
var bucket := find_i("bucket")
|
|
bucket.interact(droid)
|
|
check(droid.held == bucket, "bucket taken")
|
|
var tank := find_i("POUR COOLANT")
|
|
for trip in 2:
|
|
droid.global_position = Vector3(-2.7, 0.2, 28)
|
|
check(await wait_until(func(): return bucket.ex.state == "full", 400),
|
|
"bucket fills at valve (trip %d)" % (trip + 1))
|
|
droid.global_position = Vector3(2.4, 0.2, 32.0)
|
|
await wait(5)
|
|
tank.interact(droid)
|
|
check(tank.count == 2 and main.door4.is_open, "two pours open the sluice door")
|
|
|
|
print("== drum rotation ==")
|
|
# droid is wet from the valve; wet+charged self-shocks (by design) — dry off first
|
|
droid.shell.force_state("dry")
|
|
droid.global_position = Vector3(2.9, 0.2, 18.6)
|
|
droid.core.force_state("charged")
|
|
check(await wait_until(func(): return main.drum_turned, 180), "charged touch starts the drum")
|
|
await wait(200) # tween is 3s
|
|
var crate := find_i("crate")
|
|
check(crate != null and crate.global_position.y < 2.0 and crate.global_position.x < 6.5,
|
|
"rotation brought the crate within reach")
|
|
droid.global_position = Vector3(4.2, 0.2, 22)
|
|
crate.interact(droid)
|
|
check(droid.held == crate, "crate taken (bucket dropped)")
|
|
var plate := find_i("PLACE CARGO ON PLATE")
|
|
droid.global_position = Vector3(2.0, 0.2, 24.2)
|
|
plate.interact(droid)
|
|
check(main.bonus_door.is_open, "cargo plate opens bonus bay")
|
|
|
|
print("== solar exit ==")
|
|
main.t_sol = 5.0 # daytime
|
|
droid.global_position = Vector3(0, 0.2, 37)
|
|
check(await wait_until(func(): return main.exit_door.is_open, 120),
|
|
"daylight powers solar cell, exit opens")
|
|
main.t_sol = 45.0 # nightfall
|
|
check(await wait_until(func(): return not main.exit_door.is_open, 300),
|
|
"night kills solar cell, exit closes")
|
|
main.t_sol = 5.0
|
|
|
|
print("== hall done ==")
|
|
await wait_until(func(): return main.exit_door.is_open, 120)
|
|
droid.global_position = Vector3(0, 0.2, 42)
|
|
check(await wait_until(func(): return main.hall_done, 60), "crossing the exit completes the hall")
|
|
|
|
print("== reactor ==")
|
|
var slug := find_i("slug")
|
|
slug.interact(droid)
|
|
check(droid.held == slug, "thermal slug taken")
|
|
droid.global_position = Vector3(-12.4, 0.2, 47.0)
|
|
check(await wait_until(func(): return slug.ex.state == "hot", 300), "slug heats at the vent")
|
|
droid.global_position = Vector3(-20.9, 0.2, 53)
|
|
check(await wait_until(func(): return find_ex("WAX SEAL").state == "melted", 300),
|
|
"hot slug melts the wax seal")
|
|
droid.global_position = Vector3(-27.8, 0.2, 52.6)
|
|
check(await wait_until(func(): return find_ex("CORE IGNITER").state == "primed", 300),
|
|
"slug primes the igniter")
|
|
droid.shell.force_state("dry")
|
|
droid.core.force_state("charged")
|
|
check(await wait_until(func(): return main.systems.reactor, 300), "current ignites the primed core")
|
|
check(main.ice_wall == null and main.melt_pool.enabled, "reactor melts the hydro ice wall")
|
|
|
|
print("== hydroponics ==")
|
|
droid.global_position = Vector3(12.8, 0.2, 56.5)
|
|
droid.core.force_state("charged")
|
|
check(await wait_until(func(): return find_ex("SPRINKLER VALVE").state == "open", 300),
|
|
"current frees the sprinkler valve")
|
|
main.t_sol = 5.0
|
|
check(await wait_until(func(): return find_ex("GROWTH VINE").state == "sprouted", 600),
|
|
"sprinkler water sprouts the vine")
|
|
check(await wait_until(func(): return find_ex("GROWTH VINE").state == "grown", 600),
|
|
"daylight grows the vine (grate opens)")
|
|
var bucket2 := find_i("bucket", 9.0)
|
|
bucket2.interact(droid)
|
|
check(droid.held == bucket2, "hydro bucket taken")
|
|
var basin := find_i("POUR COOLANT", 9.0)
|
|
for trip in 2:
|
|
droid.global_position = Vector3(24.4, 0.2, 52.4)
|
|
check(await wait_until(func(): return bucket2.ex.state == "full", 500),
|
|
"bucket fills at the melt pool (trip %d)" % (trip + 1))
|
|
droid.global_position = Vector3(29.4, 0.2, 52.6)
|
|
await wait(5)
|
|
basin.interact(droid)
|
|
check(main.systems.hydro, "two pours pressurize the water mains")
|
|
|
|
print("== comms ==")
|
|
var beacon := find_i("beacon")
|
|
beacon.interact(droid)
|
|
check(droid.held == beacon, "radio beacon taken (bucket dropped)")
|
|
droid.global_position = Vector3(-1.2, 0.2, 63.2)
|
|
check(await wait_until(func(): return beacon.ex.state == "broadcasting", 300),
|
|
"beacon charges to broadcasting at the post")
|
|
droid.global_position = Vector3(-5.5, 0.2, 68.5)
|
|
check(await wait_until(func(): return find_ex("CRYSTAL RESONATOR (SEALED IN WALL)").state == "rung", 300),
|
|
"radio rings the resonator through the wall")
|
|
droid.global_position = Vector3(-7.2, 0.2, 74.2)
|
|
await wait_until(func(): return beacon.ex.state == "broadcasting", 300)
|
|
droid.global_position = Vector3(-4.4, 0.2, 75.4)
|
|
check(await wait_until(func(): return find_ex("MAIN TRANSMITTER").state == "warmed", 300),
|
|
"beacon warms the transmitter")
|
|
droid.shell.force_state("dry")
|
|
droid.core.force_state("charged")
|
|
check(await wait_until(func(): return main.systems.comms, 300), "current fires the transmitter")
|
|
|
|
print("== bridge gate ==")
|
|
check(await wait_until(func(): return main.bridge_door.is_open, 120),
|
|
"3/3 systems unseal the bridge")
|
|
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.ending_id == "free", "console cannot reclaim the ship")
|
|
|
|
if fails == 0:
|
|
print("INTEGRATION PASS — full room playthrough OK")
|
|
else:
|
|
printerr("INTEGRATION: %d FAILURES" % fails)
|
|
quit(1 if fails > 0 else 0)
|