"""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