155 lines
6.7 KiB
Python
155 lines
6.7 KiB
Python
"""Owns an isolated room broker/controller for a full simulated run and trace replay."""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
import uuid
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
BASE = "http://127.0.0.1:18776"
|
|
PORTS = (14232, 18776, 18777)
|
|
|
|
def api(path, body=None):
|
|
req = urllib.request.Request(BASE + path,
|
|
data=None if body is None else json.dumps(body).encode(),
|
|
headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=10) as response:
|
|
return json.load(response)
|
|
|
|
def action(name, entity="", value=None):
|
|
state = api("/room/state")["state"]
|
|
payload = {"command_id":str(uuid.uuid4()),"run_id":state["run_id"],
|
|
"expected_revision":state["revision"],"expires_at_ms":int(time.time()*1000)+10000,
|
|
"input":{"action":name,"entity":entity,"value":value}}
|
|
result = api("/room/command", payload)
|
|
assert result["accepted"], (name, result["message"])
|
|
return result["state"]
|
|
|
|
def golden():
|
|
action("prepare", value={"profile":"standard","party":6,"seed":42})
|
|
action("start_run")
|
|
action("briefing_done")
|
|
for puzzle, token in [("P01",None),("P02A","T01"),("P02B","T02"),("P03","T03"),("P04","T04"),("P05","T05"),("P06","T06")]:
|
|
action("solve", puzzle)
|
|
if token: action("deliver", token)
|
|
for i in range(1,7):
|
|
cabinet=f"cabinet_{i:02}"
|
|
action("token", cabinet, {"token":f"T{i:02}","age_ms":0})
|
|
action("start", cabinet)
|
|
state=action("advance", value=30000)
|
|
for station in state["stations"]:
|
|
action("win", station["id"], {"attempt":station["attempt"],"progress":station["target"]})
|
|
action("boss_start")
|
|
state=action("boss_win")
|
|
effect=next(e for e in state["effects"] if e["kind"]=="passage.release")
|
|
assert not action("ack", effect["id"])["passage_open"]
|
|
action("passage", value=True)
|
|
state=action("advance", value=300)
|
|
for station in state["stations"]:
|
|
action("bay", station["id"])
|
|
action("cell","BAY"+station["cell"][4:],{"cell":station["cell"],"present":False})
|
|
action("cell",station["socket"],{"cell":station["cell"],"present":True})
|
|
state=action("advance", value=2500)
|
|
while state["phase"] in ("CALIBRATION_TUTORIAL","CALIBRATION"):
|
|
wave=state["wave"]
|
|
for station,target in wave["targets"].items():
|
|
action("ready",station,{"challenge":wave["id"],"selection":target,"stable_ms":2000})
|
|
state=action("execute", value=wave["id"])
|
|
assert state["phase"]=="LAUNCH_ARM"
|
|
epoch=state["launch_epoch"]
|
|
for station in state["stations"]:
|
|
action("arm",station["id"],epoch)
|
|
for group in range(state["operators"]):
|
|
action("group_ready",value={"epoch":epoch,"group":group})
|
|
action("commit",value=epoch)
|
|
state=action("advance",value=5000)
|
|
assert state["lifecycle"]=="WON" and state["launches"]==1
|
|
assert all(s["passes"]==2 for s in state["stations"])
|
|
return state
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--preview",action="store_true",help="After tests, serve GM UI for up to 10 minutes; create qa.stop to stop early")
|
|
args=parser.parse_args()
|
|
for port in PORTS:
|
|
with socket.socket() as sock: sock.bind(("127.0.0.1",port))
|
|
nats=shutil.which("nats-server")
|
|
binary=ROOT/"target/debug"/("glitch-odin-lab.exe" if os.name=="nt" else "glitch-odin-lab")
|
|
if not nats or not binary.is_file(): raise RuntimeError("Install NATS and build the Rust lab first")
|
|
children=[]
|
|
output=ROOT/"evidence"
|
|
output.mkdir(exist_ok=True)
|
|
with tempfile.TemporaryFile(mode="w+b") as log:
|
|
def spawn(argv):
|
|
child=subprocess.Popen(argv,cwd=ROOT,stdout=log,stderr=log,
|
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name=="nt" else 0)
|
|
children.append(child)
|
|
return child
|
|
try:
|
|
spawn([nats,"-c",str(ROOT/"room-nats.conf")])
|
|
deadline=time.monotonic()+10
|
|
while True:
|
|
try:
|
|
with socket.create_connection(("127.0.0.1",14232),timeout=.2): break
|
|
except OSError:
|
|
if time.monotonic()>deadline: raise
|
|
time.sleep(.1)
|
|
spawn([str(binary),"room"])
|
|
deadline=time.monotonic()+20
|
|
while True:
|
|
try:
|
|
api("/room/state")
|
|
break
|
|
except OSError:
|
|
if time.monotonic()>deadline: raise
|
|
time.sleep(.1)
|
|
state=golden()
|
|
trace=api("/room/trace")
|
|
trace_path=output/"room-golden-trace.json"
|
|
trace_path.write_text(json.dumps(trace,indent=2),encoding="utf-8")
|
|
subprocess.run([str(binary),"replay-room",str(trace_path)],check=True)
|
|
(output/"room-result.json").write_text(json.dumps({
|
|
"status":"passed","transport":"HTTP -> Rust -> NATS -> Rust room reducer",
|
|
"lifecycle":state["lifecycle"],"launches":state["launches"],
|
|
"passes":[s["passes"] for s in state["stations"]],
|
|
"final_hash":trace["final_hash"],"trace_events":len(trace["trace"])
|
|
},indent=2),encoding="utf-8")
|
|
print("PASS: complete seven-cabinet room, puzzles, cells, practices, calibration, launch, and offline trace replay",flush=True)
|
|
if args.preview:
|
|
action("reset",value={"confirmed":True})
|
|
action("start_run")
|
|
action("briefing_done")
|
|
action("solve","P01")
|
|
action("solve","P02A")
|
|
action("deliver","T01")
|
|
action("token","cabinet_01",{"token":"T01","age_ms":0})
|
|
action("start","cabinet_01")
|
|
action("advance",value=10000)
|
|
spawn([sys.executable,str(ROOT/"room_web.py")])
|
|
stop=ROOT/"qa.stop"
|
|
if stop.exists(): stop.unlink()
|
|
print("PREVIEW: http://127.0.0.1:18777/?room=1",flush=True)
|
|
deadline=time.monotonic()+600
|
|
while time.monotonic()<deadline and not stop.exists():
|
|
time.sleep(.2)
|
|
if stop.exists(): stop.unlink()
|
|
finally:
|
|
for p in reversed(children):
|
|
if p.poll() is None: p.terminate()
|
|
for p in children:
|
|
try: p.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
p.kill();p.wait()
|
|
log.seek(0)
|
|
text=log.read().decode("utf-8",errors="replace")
|
|
(output/"room-runtime.log").write_text(text,encoding="utf-8")
|
|
if __name__=="__main__":
|
|
main()
|