"""PyScript adapter: the only file in this project that knows a DOM exists. Everything below is glue. The game itself lives in engine.py, which imports nothing from the browser and is tested under plain CPython. """ import asyncio import sys from pyscript import document, window from pyscript.ffi import create_proxy import content import lab_iso import labview from engine import DIM, HEAD, OK, OUT, Engine, boot_lines ENGINE = Engine() HISTORY_POS = [0] # boxed so the key handler can mutate it BOOTED = [False] # -------------------------------------------------------------------------- # tiny DOM helpers # -------------------------------------------------------------------------- def el(node_id): """document.getElementById, normalised to a real None. PyScript returns a JsNull for a missing id, and JsNull is not None, so the usual `is None` guard does not hold.""" node = document.getElementById(node_id) return node if node else None def make(tag, cls=None, text=None): node = document.createElement(tag) if cls: node.className = cls if text is not None: node.textContent = text return node def screen_bottom(): scr = el("screen") scr.scrollTop = scr.scrollHeight def write(text, cls=OUT): line = make("div", cls, text if text else " ") el("screen").appendChild(line) screen_bottom() return line def echo(command): row = make("div", "echo") row.appendChild(make("span", None, ENGINE.prompt + " ")) strong = make("b", None, command) row.appendChild(strong) el("screen").appendChild(row) # -------------------------------------------------------------------------- # sidebar # -------------------------------------------------------------------------- def render_objective(): """The one line a first-time visitor cannot miss: what to do, and the exact command that does it. Clicking the command runs it.""" bar = el("objective") if bar is None: return bar.innerHTML = "" current = ENGINE.current_objective if current is None: bar.className = "objective done" bar.appendChild(make("span", "o-tag", "complete")) bar.appendChild(make("span", "o-title", "Assessment filed — all six cleared")) link = make("button", "o-cmd", content.OWNER["email"]) link.setAttribute("data-cmd", "contact") link.addEventListener("click", create_proxy(on_chip)) bar.appendChild(link) return index, _key, title, why, command = current bar.className = "objective" bar.appendChild(make("span", "o-tag", "{}/{}".format(index + 1, len(content.MISSION)))) bar.appendChild(make("span", "o-title", title)) bar.appendChild(make("span", "o-why", why)) for step in command: run = make("button", "o-cmd", step) run.setAttribute("data-cmd", step) run.setAttribute("title", "Run: " + step) run.addEventListener("click", create_proxy(on_chip)) bar.appendChild(run) def render_mission_list(): node = el("misslist") if node is None: return node.innerHTML = "" for index, (_key, title, _why, _cmd, _payoff) in enumerate(content.MISSION): item = make("li") if index < ENGINE.objective: item.className = "got" item.appendChild(make("span", "mark", "✓")) elif index == ENGINE.objective: item.className = "now" item.appendChild(make("span", "mark", "▸")) else: item.appendChild(make("span", "mark", "·")) item.appendChild(make("span", None, title)) node.appendChild(item) def refresh_sidebar(): render_objective() render_mission_list() el("meter-fill").style.width = "{}%".format(ENGINE.percent) el("stat-pct").textContent = "{}%".format(ENGINE.percent) el("stat-hosts").textContent = "{} / {}".format(len(ENGINE.discovered), ENGINE.host_total) el("stat-files").textContent = "{} / {}".format(len(ENGINE.read), ENGINE.total_files) hosts = el("hostlist") hosts.innerHTML = "" if len(ENGINE.discovered) <= 1: hosts.appendChild(make("li", None, "run `scan` to populate")) else: for h in content.HOSTS: if h["id"] not in ENGINE.discovered: continue n = sum(1 for (hid, _f) in ENGINE.read if hid == h["id"]) done = n == len(h["files"]) li = make("li", "on done" if done else "on") li.appendChild(make("span", "ip", h["ip"].rsplit(".", 1)[-1].rjust(3))) li.appendChild(make("span", None, h["id"])) li.appendChild(make("span", "ip", "{}/{}".format(n, len(h["files"])))) li.addEventListener("click", create_proxy(_host_clicker(h["id"]))) hosts.appendChild(li) render_minimap() achs = el("achlist") achs.innerHTML = "" for key, title, _desc, _pts in content.ACHIEVEMENTS: got = key in ENGINE.unlocked li = make("li", "got" if got else None) li.appendChild(make("span", "mark", "✓" if got else "·")) li.appendChild(make("span", None, title)) achs.appendChild(li) # -------------------------------------------------------------------------- # radar minimap # -------------------------------------------------------------------------- CX, CY, RADIUS = 130.0, 92.0, 55.0 RADAR = {"yaw": 0.35, "task": None, "drag": None} def _lattice_positions(): """Hosts as points in space rather than on a circle: the gateway at the origin, everything it routes to on a ring around it, and the unadvertised host off the ring entirely.""" import math leaves = [h for h in content.HOSTS if not h["hidden"] and h["id"] != "gateway"] spots = {"gateway": (0.0, 0.0, 0.0)} for i, host in enumerate(leaves): angle = 2 * math.pi * i / len(leaves) lift = 15.0 if i % 2 == 0 else -15.0 spots[host["id"]] = (RADIUS * math.cos(angle), lift, RADIUS * math.sin(angle)) for host in content.HOSTS: if host["hidden"]: spots[host["id"]] = (-30.0, 46.0, -62.0) return spots def _project_radar(point): """Isometric, using the same helper the 3D demos use.""" return lab_iso.project(point, RADAR["yaw"], 1.0, (CX, CY), 1) def render_minimap(): spots = _lattice_positions() parts = [] for radius in (24, 42, 58): parts.append(''.format( CX, CY, radius, radius * 0.5)) parts.append(''.format( CX, CY, CX, CY - 70, 70, 70, CX + 50, CY - 49)) placed = {} for host in content.HOSTS: placed[host["id"]] = _project_radar(spots[host["id"]]) gx, gy, _gd = placed["gateway"] for host in content.HOSTS: if host["id"] == "gateway": continue x, y, _d = placed[host["id"]] found = host["id"] in ENGINE.discovered parts.append(''.format( "on" if found else "off", gx, gy, x, y)) # nearer hosts drawn last and larger, which is the only thing that reads as # depth in a flat projection order = sorted(content.HOSTS, key=lambda h: placed[h["id"]][2]) for host in order: x, y, depth = placed[host["id"]] found = host["id"] in ENGINE.discovered read = sum(1 for (hid, _f) in ENGINE.read if hid == host["id"]) cls = "node" if not found: cls += " ghost" elif read == len(host["files"]): cls += " full" if host["id"] == "gateway": cls += " gw" near = (depth + 80.0) / 160.0 radius = (7.5 if host["id"] == "gateway" else 4.6) * (0.75 + 0.5 * max(0.0, min(1.0, near))) label = host["id"] if found else "unknown" parts.append( '' '{} — {}'.format( cls, host["id"], x, y, radius, label, host["ip"] if found else "not discovered")) el("radar").innerHTML = "".join(parts) for group in document.querySelectorAll("#radar g.node:not(.ghost)"): group.addEventListener("click", create_proxy( _host_clicker(group.getAttribute("data-host")))) async def spin_radar(): """A slow drift, so the map reads as a volume rather than a diagram. Stops while a demo is open, because the sidebar is hidden then anyway.""" while True: await asyncio.sleep(0.12) if RADAR["drag"] is None and el("lab") is not None and el("lab").hidden: RADAR["yaw"] += 0.014 render_minimap() def _radar_down(event): RADAR["drag"] = event.clientX def _radar_move(event): if RADAR["drag"] is not None: RADAR["yaw"] += (event.clientX - RADAR["drag"]) * 0.012 RADAR["drag"] = event.clientX render_minimap() def _radar_up(_event=None): RADAR["drag"] = None def _host_clicker(host_id): def handler(_event): submit("connect " + host_id) el("cmdline").focus({"preventScroll": True}) return handler # -------------------------------------------------------------------------- # toasts # -------------------------------------------------------------------------- def toast(title, desc, points): node = make("div", "toast") node.appendChild(make("div", "t-k", "achievement unlocked +{}".format(points))) node.appendChild(make("div", "t-t", title)) node.appendChild(make("div", "t-d", desc)) el("toasts").appendChild(node) asyncio.ensure_future(_expire(node)) async def _expire(node): await asyncio.sleep(6.5) node.style.transition = "opacity .4s" node.style.opacity = "0" await asyncio.sleep(0.45) node.remove() # -------------------------------------------------------------------------- # running commands # -------------------------------------------------------------------------- def apply(action): kind = action.get("kind") if kind == "clear": el("screen").innerHTML = "" elif kind == "achievement": toast(action["title"], action["desc"], action["points"]) elif kind == "theme": document.documentElement.setAttribute("data-theme", action["value"]) try: window.localStorage.setItem("terminal-phosphor", action["value"]) except Exception: pass elif kind == "lab": labview.open_lab(action["app"]) elif kind in ("open", "mailto"): window.open(action["url"], "_blank") # "progress" and "host" only need the sidebar/prompt refresh below def submit(command): echo(command) response = ENGINE.execute(command) for text, cls in response.lines: write(text, cls) for action in response.actions: apply(action) el("prompt").textContent = ENGINE.prompt refresh_sidebar() screen_bottom() HISTORY_POS[0] = len(ENGINE.history) # -------------------------------------------------------------------------- # input handling # -------------------------------------------------------------------------- def on_key(event): if not BOOTED[0]: return box = el("cmdline") key = event.key if key == "Escape": labview.close_lab() return if key == "Enter": value = box.value box.value = "" if value.strip(): submit(value) return if key == "ArrowUp": event.preventDefault() if ENGINE.history: HISTORY_POS[0] = max(0, HISTORY_POS[0] - 1) box.value = ENGINE.history[HISTORY_POS[0]] return if key == "ArrowDown": event.preventDefault() if ENGINE.history: HISTORY_POS[0] = min(len(ENGINE.history), HISTORY_POS[0] + 1) box.value = "" if HISTORY_POS[0] >= len(ENGINE.history) \ else ENGINE.history[HISTORY_POS[0]] return if key == "Tab": event.preventDefault() options = ENGINE.completions(box.value) if len(options) == 1: box.value = options[0] + " " elif options: write(" ".join(options), DIM) return if key == "l" and (event.ctrlKey or event.metaKey): event.preventDefault() el("screen").innerHTML = "" PHOSPHORS = ("green", "amber", "ice") def on_chip(event): command = event.currentTarget.getAttribute("data-cmd") scroll_to_console() submit(command) el("cmdline").focus({"preventScroll": True}) def scroll_to_console(): """Cards live further down the page than the console they drive.""" document.getElementById("console").scrollIntoView({"behavior": "smooth", "block": "start"}) def toggle_appearance(_event=None): """Flip the page between light and dark for this visit. Deliberately not persisted. Restoring a stored choice would mean either an inline blocking