diff --git a/src/ui/baseUserInterface.py b/src/ui/baseUserInterface.py index d9805ac..9603185 100644 --- a/src/ui/baseUserInterface.py +++ b/src/ui/baseUserInterface.py @@ -27,8 +27,12 @@ class BaseUserInterface(KitBaseUserInterface): - def __init__(self, currentPrompt: Prompt, timeService: TimeService, player: Player): - super().__init__(currentPrompt, header=self._buildHeader) + def __init__( + self, currentPrompt: Prompt, timeService: TimeService, player: Player, **kwargs + ): + # kwargs travel on to the next kit class in the MRO: the web front-end + # takes its title, address and server switches through here. + super().__init__(currentPrompt, header=self._buildHeader, **kwargs) self.timeService = timeService self.player = player diff --git a/src/ui/pyodideUserInterface.py b/src/ui/pyodideUserInterface.py index 11c6949..de6b161 100644 --- a/src/ui/pyodideUserInterface.py +++ b/src/ui/pyodideUserInterface.py @@ -1,128 +1,21 @@ # @author Daniel McCoy Stephenson -"""FishE's browser-native front-end: the game itself runs in the player's tab. - -WebUserInterface puts the game on a server and the browser polls it, which means -one server-side game and one set of server-side save files shared by everyone -who opens the page. This front-end instead runs the whole Python game inside a -Web Worker under Pyodide, so every tab has its own game and its own saves — the -latter kept in the browser's IndexedDB (see browserSaveSync and -web/game-worker.js). - -Only the transport differs from WebUserInterface, which this class subclasses: -every screen the player sees is still built by WebUserInterface's primitives, so -a new screen type is written once and appears in both. Here a screen is posted -to the main thread as a JSON string, and the player's response is read from the -SharedArrayBuffer ring buffer that web/index.html writes into. - -Why a ring buffer rather than Worker messages: the game loop is synchronous, so -it blocks the Worker while waiting for input, and a blocked Worker can never run -an onmessage handler. Shared memory is readable without the event loop, so the -main thread can hand input to a blocked game. +"""The browser-native front-end: the whole game runs in the player's tab under +Pyodide, with saves in that browser's IndexedDB. The Worker bridge, the input +ring and the screens now live in tak (tak.ui.pyodide); this module is FishE's +adapter with the (currentPrompt, timeService, player) constructor. """ -import json -import time - -from browserSaveSync import getJsModule -from ui.webUserInterface import WebUserInterface - - -# How long to sleep between checks of the input ring while waiting on the -# player. Short enough to feel instant, long enough that a player reading a -# screen isn't spun on. time.sleep() in Pyodide yields via Atomics.wait, so -# this genuinely idles the Worker rather than burning the tab's CPU. -INPUT_POLL_INTERVAL_SECONDS = 0.02 - -_MESSAGE_TERMINATOR = 10 # b"\n" — the ring's message boundary - - -class SharedArrayBufferBridge: - """Talks to the JavaScript side of the Pyodide front-end. - - web/game-worker.js installs the globals used here before starting the game: - ``sendToMain`` (post a message to the main thread), and ``sabMeta`` / - ``sabData`` / ``sabRingSize`` / ``Atomics`` for the input ring buffer. - """ +from tak.ui import pyodide as kitPyodide +from tak.ui.pyodide import ( # noqa: F401 - re-exported + INPUT_POLL_INTERVAL_SECONDS, + SharedArrayBufferBridge, +) - def __init__(self, js=None): - if js is None: - js = getJsModule() - if js is None: - raise RuntimeError( - "The Pyodide front-end can only run inside a browser: the 'js' " - "module it needs exists only under Pyodide. To play in a " - "browser, serve the game with 'python3 web/serve.py' and open " - "the page it prints. To play from a terminal or a desktop " - "window instead, use UIType.CONSOLE or UIType.PYGAME." - ) - missing = [ - name - for name in ("sendToMain", "Atomics", "sabMeta", "sabData", "sabRingSize") - if getattr(js, name, None) is None - ] - if missing: - raise RuntimeError( - "The Pyodide front-end is missing the JavaScript globals its " - f"Worker is supposed to install: {', '.join(missing)}. This " - "happens when the game is started by something other than " - "web/game-worker.js — start it from that Worker (web/index.html " - "does), or use UIType.CONSOLE outside a browser." - ) - self._js = js - self._ringSize = int(js.sabRingSize) +from ui.baseUserInterface import BaseUserInterface +from ui.webUserInterface import TAGLINE, TIP, TITLE - def postScreen(self, screen): - """Send a screen to the main thread for rendering. - - Sent as a JSON string rather than a converted JS object: the screen is - a plain nested dict/list structure, and a string crosses the Worker - boundary without any Pyodide FFI conversion to get wrong. - """ - self._js.sendToMain(json.dumps({"type": "screen", "screen": screen})) - - def readInput(self): - """Return the player's next response, or None if none has arrived yet.""" - line = self._readLine() - if not line: - return None - try: - message = json.loads(line) - except ValueError: - # Not something this front-end sent; ignoring it keeps a stray - # write from ending the wait with a bogus response. - return None - if message.get("type") != "input": - return None - return message.get("value", "") - - def _readLine(self): - """Pull one newline-terminated message out of the ring buffer.""" - Atomics = self._js.Atomics - meta = self._js.sabMeta - data = self._js.sabData - - writeIndex = int(Atomics.load(meta, 0)) - readIndex = int(Atomics.load(meta, 1)) - if writeIndex == readIndex: - return "" - - raw = bytearray() - while readIndex != writeIndex: - byte = int(data[readIndex % self._ringSize]) - readIndex += 1 - if byte == _MESSAGE_TERMINATOR: - break - raw.append(byte) - Atomics.store(meta, 1, readIndex) - # Player-entered text (a business name, say) can be any UTF-8; a partial - # sequence is dropped rather than raising in the middle of the game. - return raw.decode("utf-8", errors="ignore") - - -# @author Daniel McCoy Stephenson -class PyodideUserInterface(WebUserInterface): - """WebUserInterface's screens, delivered over the Pyodide Worker bridge.""" +class PyodideUserInterface(BaseUserInterface, kitPyodide.PyodideUserInterface): def __init__( self, currentPrompt, @@ -131,28 +24,13 @@ def __init__( bridge=None, pollIntervalSeconds=INPUT_POLL_INTERVAL_SECONDS, ): - # start_server=False: there is no server here, and no sockets to bind - # in the browser. get_state()/submit_input() still work, which keeps the - # inherited screen bookkeeping (and its tests) intact. - super().__init__(currentPrompt, timeService, player, start_server=False) - self._bridge = bridge if bridge is not None else SharedArrayBufferBridge() - self._pollIntervalSeconds = pollIntervalSeconds - - def _present(self, screen): - # Record it the way WebUserInterface does (version bookkeeping, and so a - # late-attaching reader can still ask for the current screen), then push - # it to the browser instead of waiting to be polled for it. - super()._present(screen) - self._bridge.postScreen(screen) - - def _awaitInput(self): - # The queue is still honoured so submit_input() works for tests and for - # anything driving the interface directly; the ring buffer is what the - # browser actually writes to. - while True: - if not self._inputQueue.empty(): - return self._inputQueue.get() - value = self._bridge.readInput() - if value is not None: - return value - time.sleep(self._pollIntervalSeconds) + super().__init__( + currentPrompt, + timeService, + player, + title=TITLE, + tagline=TAGLINE, + tip=TIP, + bridge=bridge, + pollIntervalSeconds=pollIntervalSeconds, + ) diff --git a/src/ui/webUserInterface.py b/src/ui/webUserInterface.py index ab923ba..b1e83cd 100644 --- a/src/ui/webUserInterface.py +++ b/src/ui/webUserInterface.py @@ -1,428 +1,62 @@ -import errno -import json -import os -import queue -import threading -import time -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -from ui.baseUserInterface import BaseUserInterface -from prompt.prompt import Prompt -from player.player import Player -from world.timeService import TimeService -from housing import housing - - -# The browser client (renderer + styles) is shared verbatim with the Pyodide -# front-end, which serves the same two files from web/. Keeping one copy is what -# stops a screen type from being handled in one web front-end but not the other. -# They are inlined into the page below rather than served as separate routes so -# the server stays a two-route affair and the page needs a single request. -WEB_ASSET_DIRECTORY = os.path.normpath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "web") -) - -# How long cleanup() gives the browser to collect the ended screen before it -# closes the server, and how often it checks whether that has happened. -# -# The client only discovers a new screen on its next poll, so shutting the -# socket the instant the ended screen is published closes it before the screen -# can be fetched: a player who had just retired was shown "Lost connection" -# instead of the end of their run. The wait ends as soon as the screen has -# actually gone out (typically within one poll interval), so the timeout is -# only ever paid when nobody is listening - a closed tab, or a game driven by -# something other than a browser. -ENDED_SCREEN_DELIVERY_TIMEOUT_SECONDS = 2.0 -ENDED_SCREEN_DELIVERY_POLL_SECONDS = 0.02 - +# @author Daniel McCoy Stephenson +"""The server-backed browser front-end: the game runs here, the browser is a +terminal for it. The server, the rendezvous and every screen now live in tak +(tak.ui.web.WebUserInterface); this module is FishE's adapter - the +(currentPrompt, timeService, player) constructor, FishE's page copy, and the +FISHE_WEB_HOST / FISHE_WEB_PORT variables. +""" -def _readWebAsset(name): - """Read a shared browser-client file from web/, or explain why it could not.""" - path = os.path.join(WEB_ASSET_DIRECTORY, name) - try: - with open(path, "r", encoding="utf-8") as assetFile: - return assetFile.read() - except OSError as e: - raise RuntimeError( - f"FishE's web front-end could not read its browser client " - f"'{name}' (looked in {path}): {e}. That file ships in the " - f"repository's web/ directory, alongside src/. Run the game from a " - f"complete checkout, or — if this is a container image — make sure " - f"the image copies web/ in as well as src/." - ) from e +from tak.ui import web as kitWeb +from tak.ui.web import DEFAULT_HOST, DEFAULT_PORT # noqa: F401 - re-exported +from tak.ui.web import ENDED_SCREEN_DELIVERY_TIMEOUT_SECONDS +from ui.baseUserInterface import BaseUserInterface -# Read on first use rather than at import, and cached after. This module is -# imported by the Pyodide front-end too (PyodideUserInterface subclasses the -# class below), and there these files are NOT on the filesystem: the browser -# fetches them over HTTP, so only the server-backed front-end has any reason to -# read them. Reading them at import time made merely importing this module fail -# inside the Worker. -_clientAssetCache = {} -_pageCache = {} +TITLE = "FishE" +TAGLINE = "fish a seaside village and build a fortune of $10,000" +TIP = "Tip: click an option or press its number key (1-9). Enter or Space continues." +ENV_PREFIX = "FISHE" -def _clientAsset(name): - if name not in _clientAssetCache: - _clientAssetCache[name] = _readWebAsset(name) - return _clientAssetCache[name] +def resolveAddressFromEnvironment(): + """The (host, port) to serve on, from FISHE_WEB_HOST / FISHE_WEB_PORT, or a + ValueError naming the variable that is wrong.""" + return kitWeb.resolveAddressFromEnvironment(ENV_PREFIX) def htmlPage(): - """The single-page client, built on first use. - - It polls /state and renders whatever screen the game is currently waiting - on, posting the player's response to /input. Served as-is (no templating); - it talks to the server via relative URLs. - """ - if "page" in _pageCache: - return _pageCache["page"] - page = ( - """ - - - - -FishE - - - -

FishE — fish a seaside village and build a fortune of $10,000

-
Connecting…
-

Tip: click an option or press its number key (1-9). Enter or Space continues.

- - - -""" - ) - _pageCache["page"] = page - return page + """The single-page client with FishE's heading, built on first use.""" + return kitWeb.htmlPage(TITLE, TAGLINE, TIP) def __getattr__(name): - """Keep HTML_PAGE readable as a module attribute; the page is built lazily - so a missing web/ directory doesn't break import.""" + """Keep HTML_PAGE readable as a module attribute.""" if name == "HTML_PAGE": return htmlPage() raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -# The address this front-end serves on when nothing says otherwise. Stated -# here, beside the server that binds it, so the factory branch that reads the -# environment and the constructor below share one copy of the default rather -# than each carrying their own - a copy elsewhere would keep announcing the old -# value if this one ever moved, with nothing failing to say so. (web/serve.py -# deliberately keeps a different default of its own; see the comment there.) -DEFAULT_HOST = "127.0.0.1" -DEFAULT_PORT = 8000 - - -def resolveAddressFromEnvironment(): - """The (host, port) to serve on, or a ValueError naming the variable that is - wrong. - - FISHE_WEB_HOST/FISHE_WEB_PORT are how a player moves the game off the - default loopback address - e.g. FISHE_WEB_HOST=0.0.0.0 so a container's - port mapping or reverse proxy can reach it.""" - host = os.environ.get("FISHE_WEB_HOST", DEFAULT_HOST) - portText = os.environ.get("FISHE_WEB_PORT", str(DEFAULT_PORT)) - try: - port = int(portText) - except ValueError: - raise ValueError(f"FISHE_WEB_PORT must be an integer, got: {portText!r}") - return host, port - - -def _bindServer(host, port, handler): - """Start the game's HTTP server, turning a refused address into a sentence. - - The front-end does not exist yet at this point, so there is no showDialogue - to say it through: a port that is already listening - a second copy of the - game, most often - would otherwise reach the player as an errno raised from - inside http.server, naming neither FishE nor FISHE_WEB_PORT, which is what - they would have to change.""" - try: - return ThreadingHTTPServer((host, port), handler) - except OSError as e: - if e.errno == errno.EADDRINUSE: - reason = "something else is already listening there" - elif e.errno == errno.EACCES: - reason = "this process is not allowed to use that port" - else: - reason = str(e) - raise OSError( - f"FishE's web front-end could not be served at http://{host}:{port}/: " - f"{reason}. Set FISHE_WEB_PORT to a free port (or FISHE_WEB_HOST to " - f"an address this machine can bind) and start the game again." - ) from e - - -def _makeRequestHandler(ui): - """Build a request handler bound to a specific WebUserInterface instance.""" - - class _Handler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path in ("/", "/index.html"): - self._send(200, "text/html; charset=utf-8", htmlPage().encode("utf-8")) - elif self.path.startswith("/state"): - state = ui.get_state() - self._send(200, "application/json", json.dumps(state).encode("utf-8")) - # Recorded only once the bytes are on the wire, so cleanup() - # cannot close the server out from under a response it is - # still writing (see record_state_delivered). - ui.record_state_delivered(state["version"]) - else: - self._send(404, "text/plain", b"Not found") - - def do_POST(self): - if self.path.startswith("/input"): - length = int(self.headers.get("Content-Length", 0)) - raw = self.rfile.read(length) if length else b"{}" - try: - value = json.loads(raw or b"{}").get("value", "") - except (ValueError, TypeError): - value = "" - ui.submit_input(value) - self._send(200, "application/json", b"{}") - else: - self._send(404, "text/plain", b"Not found") - - def _send(self, status, contentType, body): - self.send_response(status) - self.send_header("Content-Type", contentType) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass # keep the game's stdout clean - - return _Handler - - -# @author Daniel McCoy Stephenson -class WebUserInterface(BaseUserInterface): - """A browser-based front-end for FishE. - - The synchronous game loop is unchanged: each input primitive publishes the - current screen and blocks until the browser submits a response, coordinated - through a thread-safe rendezvous. A small stdlib HTTP server (run in a daemon - thread) serves the screen state (GET /state) and the page (GET /), and - accepts the player's response (POST /input).""" - +class WebUserInterface(BaseUserInterface, kitWeb.WebUserInterface): def __init__( self, - currentPrompt: Prompt, - timeService: TimeService, - player: Player, + currentPrompt, + timeService, + player, host=DEFAULT_HOST, port=DEFAULT_PORT, start_server=True, endedScreenTimeoutSeconds=ENDED_SCREEN_DELIVERY_TIMEOUT_SECONDS, ): - super().__init__(currentPrompt, timeService, player) - self._lock = threading.Lock() - self._screen = {"type": "loading"} - self._version = 0 - # The newest screen version the browser has been handed. Starts below - # the first version so "nothing has been collected yet" is a state - # cleanup() can tell apart from "the current screen has been seen". - self._deliveredVersion = -1 - self._endedScreenTimeoutSeconds = endedScreenTimeoutSeconds - self._inputQueue = queue.Queue() - self._server = None - if start_server: - self._server = _bindServer(host, port, _makeRequestHandler(self)) - self._server.daemon_threads = True - threading.Thread(target=self._server.serve_forever, daemon=True).start() - self._announceAddress() - - @property - def address(self): - """The (host, port) the server is bound to, or None if not started.""" - return self._server.server_address if self._server else None - - def _announceAddress(self): - """Say where the game can be played, once it is actually being served. - - Said from here rather than from the entry point that started the game - (examples/web_app.py) because building FishE starts this server and - then blocks in the save-file manager, so nothing gets control back - there to announce a bound address - and announcing one beforehand names - an address that a misspelled or already-taken port means will never - answer. The address named is the socket's own, so a caller that asked - for port 0 is told the port it actually got.""" - boundHost, boundPort = self._server.server_address[:2] - print(f"FishE is being served at http://{boundHost}:{boundPort}/") - print("Open that URL in your browser to play. Press Ctrl+C here to stop.") - - # --- web rendezvous --------------------------------------------------- - def get_state(self): - """Snapshot of the current screen for the browser to render.""" - with self._lock: - return {"version": self._version, "screen": self._screen} - - def submit_input(self, value): - """Deliver the player's browser response to the waiting game thread.""" - self._inputQueue.put(value) - - def record_state_delivered(self, version): - """Note that the browser has been sent the screen at this version. - - Only cleanup() reads this, to know the ended screen reached the page - before the server is closed. Kept as the highest version seen so an - overlapping poll that finishes late cannot walk it backwards.""" - with self._lock: - self._deliveredVersion = max(self._deliveredVersion, version) - - def _awaitScreenDelivery(self, timeout): - """Block until the current screen has been sent to the browser. - - Returns True if it went out, False if the timeout ran out first - - which is the ordinary outcome when no page is polling.""" - deadline = time.monotonic() + timeout - while True: - with self._lock: - if self._deliveredVersion >= self._version: - return True - if time.monotonic() >= deadline: - return False - time.sleep(ENDED_SCREEN_DELIVERY_POLL_SECONDS) - - # --- transport seams --------------------------------------------------- - # Every screen below is built once and shared by both web front-ends; only - # how a screen reaches the browser (_present) and how the response comes - # back (_awaitInput) differ. PyodideUserInterface overrides just these two, - # so a new screen type never has to be written twice. - def _present(self, screen): - with self._lock: - self._screen = screen - self._version += 1 - - def _awaitInput(self): - """Block until the browser submits a response, and return it.""" - return self._inputQueue.get() - - def _header(self): - return { - "day": self.timeService.day, - "time": self.times[self.timeService.time], - "money": float(self.player.money), - "fish": self.player.fishCount, - "energy": self.player.energy, - "maxEnergy": housing.maxEnergy(self.player), - "location": self.currentLocationName, - "goal": self.goalProgress, - "operator": self.player.operatorMode, - } - - # --- BaseUserInterface primitives ------------------------------------ - def lotsOfSpace(self): - # The browser renders a fresh screen each time; nothing to clear. - pass - - def divider(self): - pass - - def showOptions(self, descriptor, optionList, unavailableOptions=None): - # "unavailable" is a list parallel to "options" - the reason each one - # can't be picked, or null. The browser greys those buttons out and - # shows the reason on the row; sending the reason as data rather than - # baked into the label is what lets it be styled apart from the option. - reasons = self.unavailableReasons(optionList, unavailableOptions) - self._present( - { - "type": "options", - "descriptor": descriptor, - "prompt": self.currentPrompt.text, - "options": list(optionList), - "unavailable": reasons, - "header": self._header(), - } + super().__init__( + currentPrompt, + timeService, + player, + title=TITLE, + tagline=TAGLINE, + tip=TIP, + envPrefix=ENV_PREFIX, + host=host, + port=port, + start_server=start_server, + endedScreenTimeoutSeconds=endedScreenTimeoutSeconds, ) - valid = self.selectableNumbers(reasons) - while True: - choice = str(self._awaitInput()) - if choice in valid: - return choice - # ignore anything that isn't a selectable option and keep waiting - - def showDialogue(self, text): - self._present({"type": "dialogue", "text": text}) - self._awaitInput() - self.currentPrompt.text = "What would you like to do?" - - def promptForText(self, promptText): - self._present({"type": "prompt", "text": promptText}) - return str(self._awaitInput()) - - def promptForNumber(self, promptText): - # Flag the prompt as numeric so the browser can offer a numeric keyboard - # and block submission of non-numbers (the base default can't say so). - self._present({"type": "prompt", "text": promptText, "numeric": True}) - try: - return float(self._awaitInput()) - except (ValueError, TypeError): - return None - - def showBusy(self, message, seconds=1.0): - # Published as its own screen type so the browser shows the message - # instead of sitting on the previous screen. No input is consumed — - # whatever the game presents next supersedes it. - self._present({"type": "busy", "message": message}) - time.sleep(seconds) - - def timedKeyPress(self, message): - self._present({"type": "timed", "message": message}) - startTime = time.time() - self._awaitInput() - return time.time() - startTime - - def cleanup(self): - self._present({"type": "ended"}) - if self._server is not None: - # Hold the server open until the page has the ended screen; - # otherwise the run's last screen is never fetched and the browser - # reports a lost connection instead. - self._awaitScreenDelivery(self._endedScreenTimeoutSeconds) - self._server.shutdown() - self._server.server_close() - self._server = None diff --git a/tests/ui/test_pyodideUserInterface.py b/tests/ui/test_pyodideUserInterface.py index 2c78bc7..6e23e5e 100644 --- a/tests/ui/test_pyodideUserInterface.py +++ b/tests/ui/test_pyodideUserInterface.py @@ -122,9 +122,12 @@ def lastScreen(js): def test_pyodide_ui_implements_interface(fakeJs): ui = makePyodideUI(fakeJs) assert isinstance(ui, BaseUserInterface) - # It shares WebUserInterface's screens rather than redefining them, which - # is what keeps the two web front-ends from drifting apart. - assert isinstance(ui, WebUserInterface) + # It shares the kit's WebUserInterface screens rather than redefining + # them, which is what keeps the two web front-ends from drifting apart. + # (FishE's WebUserInterface is a sibling adapter, not an ancestor.) + from tak.ui.web import WebUserInterface as KitWebUserInterface + + assert isinstance(ui, KitWebUserInterface) def test_no_http_server_is_started(fakeJs): @@ -154,8 +157,9 @@ def test_posted_screens_carry_the_shared_header(fakeJs): ui.showOptions("The Docks", ["Fish"]) header = lastScreen(fakeJs)["header"] - assert header["day"] == ui.timeService.day - assert header["maxEnergy"] >= header["energy"] + assert header["chips"][0]["text"] == "Day %d" % ui.timeService.day + assert any(chip["text"].startswith("Energy: ") for chip in header["chips"]) + assert header["title"].startswith("FishE - Day") def test_present_still_updates_the_state_snapshot(fakeJs): @@ -242,39 +246,23 @@ def test_ring_wraps_around_without_losing_a_message(fakeJs): # --- independence from the server-backed front-end's files --------------- -def test_the_front_end_works_with_no_web_directory_on_disk(fakeJs, monkeypatch): - """Regression: the Worker's filesystem has src/ but not web/client.*. - - web/client.js and web/client.css reach the browser over HTTP, so they are - not on the filesystem the Python game sees. WebUserInterface reads them to - build its own page, and PyodideUserInterface subclasses it — so reading - them at import time made the game fail to start in a real browser with - FileNotFoundError on /game/web/client.css. Nothing on this front-end's path - may touch them. - """ - from ui import webUserInterface +def test_the_front_end_never_builds_the_server_backed_page(fakeJs, monkeypatch): + """Regression: the Worker's filesystem has the game but not the page + assets (the browser fetches those over HTTP from /tak/). Nothing on this + front-end's path may read them - the kit guards the read itself; here we + pin that FishE's adapter does not trigger it.""" + import tak.web + from tak.ui import web as kitWeb - monkeypatch.setattr(webUserInterface, "WEB_ASSET_DIRECTORY", "/no/such/directory") - monkeypatch.setattr(webUserInterface, "_clientAssetCache", {}) - monkeypatch.setattr(webUserInterface, "_pageCache", {}) + monkeypatch.setattr(tak.web, "ASSET_DIRECTORY", "/no/such/directory") + monkeypatch.setattr(kitWeb, "_clientAssetCache", {}) ui = makePyodideUI(fakeJs) fakeJs.writePlayerInput("1") assert ui.showOptions("The Docks", ["Fish", "Leave"]) == "1" assert lastScreen(fakeJs)["options"] == ["Fish", "Leave"] - - -def test_the_server_backed_page_is_not_built_unless_it_is_asked_for(fakeJs): - # The page is what needs those files; building it eagerly is what put the - # read on every importer's path. - from ui import webUserInterface - - webUserInterface._pageCache.clear() - - makePyodideUI(fakeJs) - - assert webUserInterface._pageCache == {} + assert ui._pageCache is None # --- construction errors ------------------------------------------------- diff --git a/tests/ui/test_webUserInterface.py b/tests/ui/test_webUserInterface.py index b276315..a7d8590 100644 --- a/tests/ui/test_webUserInterface.py +++ b/tests/ui/test_webUserInterface.py @@ -42,7 +42,9 @@ def makeWebUI(start_server=False, port=0, endedScreenTimeoutSeconds=0.1): def runInThread(fn): box = {} - thread = threading.Thread(target=lambda: box.__setitem__("result", fn())) + thread = threading.Thread( + target=lambda: box.__setitem__("result", fn()), daemon=True + ) thread.start() return thread, box @@ -64,12 +66,19 @@ def test_web_ui_implements_interface(): def test_header_includes_max_energy(): - # check - the header exposes the current tier's cap alongside the raw - # energy value, so the client can always show "X/Y" instead of just "X" + # check - the header shows the current tier's cap alongside the raw + # energy value, so the client can always show "X/Y" instead of just "X". + # The header is chips now (tak's contract): text the console prints, plus + # a class the browser styles - "low" once the player is too tired to fish. ui = makeWebUI() - header = ui._header() - assert header["energy"] == ui.player.energy - assert header["maxEnergy"] == housing.maxEnergy(ui.player) + chips = ui.header()["chips"] + energy = "Energy: %d/%d" % (ui.player.energy, housing.maxEnergy(ui.player)) + assert {"text": energy, "class": ""} in chips + ui.player.energy = 3 + assert { + "text": "Energy: 3/%d" % housing.maxEnergy(ui.player), + "class": "low", + } in ui.header()["chips"] def test_showOptions_round_trips_a_choice(): @@ -79,7 +88,7 @@ def test_showOptions_round_trips_a_choice(): waitForScreen(ui, "options") screen = ui.get_state()["screen"] assert screen["options"] == ["Apple", "Banana"] - assert "header" in screen and "day" in screen["header"] + assert screen["header"]["chips"][0]["text"] == "Day %d" % ui.timeService.day ui.submit_input("2") thread.join(timeout=2) diff --git a/tests/web/test_buildZip.py b/tests/web/test_buildZip.py index 082a4ca..525a987 100644 --- a/tests/web/test_buildZip.py +++ b/tests/web/test_buildZip.py @@ -1,70 +1,62 @@ +"""web/build_zip.py is a thin call into tak.web.bundle; these tests pin what +FishE puts in the bundle.""" + import os import zipfile -from web.build_zip import build +from tak.web.bundle import build -REPOSITORY_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +from web.build_zip import REPOSITORY_ROOT -def buildBundle(tmp_path, monkeypatch): - # The bundle stores repo-relative paths, so it has to be built from the - # repository root - that is what makes the unpacked tree in the browser - # match a checkout. - monkeypatch.chdir(REPOSITORY_ROOT) +def buildBundle(tmp_path): outputPath = tmp_path / "game.zip" - build(outputPath=str(outputPath)) + build( + REPOSITORY_ROOT, + outputPath=str(outputPath), + extraFiles=("version.txt", "web/pyodide_main.py"), + ) with zipfile.ZipFile(outputPath) as bundle: - return bundle.namelist() - - -def test_bundle_carries_the_game(tmp_path, monkeypatch): - names = buildBundle(tmp_path, monkeypatch) + return set(bundle.namelist()) - assert os.path.join("src", "fishE.py") in names - assert os.path.join("src", "ui", "pyodideUserInterface.py") in names - assert os.path.join("src", "browserSaveSync.py") in names +def test_bundle_carries_the_game(tmp_path): + names = buildBundle(tmp_path) + assert "src/fishE.py" in names + assert "src/location/docks.py" in names -def test_bundle_carries_the_usage_reporting_modules(tmp_path, monkeypatch): - # fishE.py imports both at module level, so a bundle without them fails - # to start at all - even though the browser build never reports. - names = buildBundle(tmp_path, monkeypatch) - - assert os.path.join("src", "usageReporting.py") in names - assert os.path.join("src", "trace_client.py") in names - assert "version.txt" in names +def test_bundle_carries_the_usage_reporting_modules(tmp_path): + names = buildBundle(tmp_path) + assert "src/usageReporting.py" in names + assert "src/trace_client.py" in names -def test_bundle_carries_the_schemas_the_save_readers_validate_against( - tmp_path, monkeypatch -): - # The *JsonReaderWriter modules resolve these paths relative to the cwd the - # Worker chdir's into, so a bundle without them fails every save load. - names = buildBundle(tmp_path, monkeypatch) +def test_bundle_carries_the_schemas_the_save_readers_validate_against(tmp_path): + names = buildBundle(tmp_path) for schema in ("player.json", "stats.json", "timeService.json"): - assert os.path.join("schemas", schema) in names + assert "schemas/" + schema in names, schema -def test_bundle_carries_the_worker_entry_point(tmp_path, monkeypatch): - names = buildBundle(tmp_path, monkeypatch) - - assert os.path.join("web", "pyodide_main.py") in names - - -def test_bundle_carries_the_shared_browser_client(tmp_path, monkeypatch): - # The page fetches these over HTTP, so the browser does not need them from - # the bundle - but webUserInterface reads them from the filesystem, and the - # Pyodide front-end subclasses it. Shipping them means that read can never - # be the thing that fails inside the Worker. - names = buildBundle(tmp_path, monkeypatch) +def test_bundle_carries_the_worker_entry_point_and_version(tmp_path): + names = buildBundle(tmp_path) + assert "web/pyodide_main.py" in names + assert "version.txt" in names - assert os.path.join("web", "client.js") in names - assert os.path.join("web", "client.css") in names +def test_bundle_carries_the_kit_under_src(tmp_path): + # One sys.path entry (/game/src) covers the game and the kit it imports. + names = buildBundle(tmp_path) + for module in ( + "__init__.py", + "ui/pyodide.py", + "saves/manager.py", + "progression.py", + ): + assert "src/tak/" + module in names, module -def test_bundle_excludes_build_artifacts(tmp_path, monkeypatch): - names = buildBundle(tmp_path, monkeypatch) - assert not [name for name in names if "__pycache__" in name] - assert not [name for name in names if name.endswith(".pyc")] +def test_bundle_excludes_build_artifacts(tmp_path): + names = buildBundle(tmp_path) + assert not any(name.endswith(".pyc") or "__pycache__" in name for name in names) + assert "web/game.zip" not in names diff --git a/tests/web/test_clientParity.py b/tests/web/test_clientParity.py deleted file mode 100644 index 524af82..0000000 --- a/tests/web/test_clientParity.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Both web front-ends must render from the same browser client. - -FishE has three front-ends behind one contract, and the two browser ones are -the easiest pair to let drift: a screen type handled in the server-backed page -but not in the Pyodide one looks fine in local testing and blank in production. -Neither front-end owning its own copy of the renderer is what prevents that, so -these tests assert the sharing itself rather than any particular screen. -""" - -import os -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) - -from ui import webUserInterface # noqa: E402 - -REPOSITORY_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) -WEB_DIRECTORY = os.path.join(REPOSITORY_ROOT, "web") - - -def readWebFile(name): - with open(os.path.join(WEB_DIRECTORY, name), encoding="utf-8") as webFile: - return webFile.read() - - -def test_server_backed_page_inlines_the_shared_client(): - page = webUserInterface.HTML_PAGE - - assert readWebFile("client.js") in page - assert readWebFile("client.css") in page - - -def test_pyodide_page_links_the_shared_client(): - page = readWebFile("index.html") - - assert "/web/client.js" in page - assert "/web/client.css" in page - - -def test_both_front_ends_hand_the_client_a_transport(): - # FisheClient.init(sendFn) is the seam: the renderer is shared, how a - # response gets back to the game is not. - assert "FisheClient.init(" in webUserInterface.HTML_PAGE - assert "FisheClient.init(" in readWebFile("index.html") - - -def test_a_missing_client_file_explains_where_it_should_be(): - try: - webUserInterface._readWebAsset("no-such-client.js") - raised = None - except RuntimeError as e: - raised = e - - assert raised is not None - message = str(raised) - assert "web/" in message - assert "no-such-client.js" in message - - -def test_the_shared_client_consumes_the_unavailable_contract(): - # WebUserInterface.showOptions publishes a reason per option; a contract - # the server sends and the client ignores would render every option as - # pickable, which is exactly what the greying-out is for. Both browser - # front-ends load this one file, so checking it covers both. - client = readWebFile("client.js") - - assert "screen.unavailable" in client - assert "disabled = true" in client - assert "unavailable" in readWebFile("client.css") diff --git a/tests/web/test_serve.py b/tests/web/test_serve.py index d471a69..280e0e1 100644 --- a/tests/web/test_serve.py +++ b/tests/web/test_serve.py @@ -1,141 +1,97 @@ +"""web/serve.py is a thin call into tak.web.serve; these tests pin what FishE +asks of it - its page, its title in errors, its variables, its port default - +and that the kit's assets reach the browser from this repository's checkout. +The server's own behaviour is tested in tak.""" + +import os +import socket import threading import urllib.error import urllib.request import pytest -from web.serve import _bindServer, _Handler, _resolvePort, _Server - - -def startServer(): - server = _Server(("127.0.0.1", 0), _Handler) - threading.Thread(target=server.serve_forever, daemon=True).start() - host, port = server.server_address[0], server.server_address[1] - return server, f"http://{host}:{port}" - +from tak.web import serve as kitServe -def get(url): - return urllib.request.urlopen(url, timeout=5) +from web.serve import REPOSITORY_ROOT -def test_serves_the_game_page(): - server, base = startServer() +@pytest.fixture +def server(): + httpd = kitServe.bindServer(REPOSITORY_ROOT, "FishE", "FISHE", "127.0.0.1", 0) + threading.Thread(target=httpd.serve_forever, daemon=True).start() try: - response = get(base + "/") - body = response.read().decode("utf-8") + yield "http://127.0.0.1:%d" % httpd.server_address[1] finally: - server.shutdown() - server.server_close() + httpd.shutdown() + httpd.server_close() - assert response.status == 200 - assert "game-worker.js" in body +def get(base, path): + return urllib.request.urlopen(base + path, timeout=5) -def test_cross_origin_isolation_headers_are_present(): - # Without these the page is not cross-origin isolated, SharedArrayBuffer is - # undefined, and the player's input can never reach the blocked game Worker. - server, base = startServer() - try: - response = get(base + "/") - finally: - server.shutdown() - server.server_close() +@pytest.mark.parametrize("path", ["/", "/play", "/index.html"]) +def test_serves_fishes_page_with_isolation_headers(server, path): + response = get(server, path) + body = response.read().decode("utf-8") + assert "FishE" in body + assert "fish a seaside village" in body assert response.headers["Cross-Origin-Opener-Policy"] == "same-origin" assert response.headers["Cross-Origin-Embedder-Policy"] == "require-corp" -def test_serves_the_shared_browser_client(): - server, base = startServer() - try: - body = get(base + "/web/client.js").read().decode("utf-8") - finally: - server.shutdown() - server.server_close() - - assert "FisheClient" in body +def test_the_page_keeps_the_saves_database_name(): + # Existing players' browser saves live under this IndexedDB name; the + # kit's boot config is where it is now set, and it must not move. + with open( + os.path.join(REPOSITORY_ROOT, "web", "index.html"), encoding="utf-8" + ) as page: + html = page.read() + assert 'idbName: "fishe-saves"' in html + assert 'saveDirEnv: "FISHE_SAVE_DIR"' in html + assert 'entry: "web/pyodide_main.py"' in html -def test_play_path_serves_the_game_page(): - server, base = startServer() - try: - body = get(base + "/play").read().decode("utf-8") - finally: - server.shutdown() - server.server_close() +def test_serves_the_kits_client_and_boot_script(server): + assert b"window.TakClient" in get(server, "/tak/client.js").read() + assert b"window.TakBoot" in get(server, "/tak/boot.js").read() + assert b"loadPyodide" in get(server, "/tak/game-worker.js").read() - assert "game-worker.js" in body - -def test_does_not_serve_the_rest_of_the_repository(): - # SimpleHTTPRequestHandler is rooted at the repository, so anything outside - # web/ has to be refused explicitly. - server, base = startServer() - try: - try: - get(base + "/src/fishE.py") - served = True - except urllib.error.HTTPError as e: - served = False - status = e.code - finally: - server.shutdown() - server.server_close() - - assert not served - assert status == 404 +def test_does_not_serve_the_rest_of_the_repository(server): + for path in ("/src/fishE.py", "/README.md", "/tak/../serve.py"): + with pytest.raises(urllib.error.HTTPError) as error: + get(server, path) + assert error.value.code == 404, path def test_port_defaults_to_the_one_the_dockerfile_sets(monkeypatch): monkeypatch.delenv("FISHE_WEB_PORT", raising=False) - - assert _resolvePort() == 8080 + assert kitServe.resolvePort("FISHE") == 8080 def test_port_is_read_from_the_environment(monkeypatch): - monkeypatch.setenv("FISHE_WEB_PORT", "9001") - - assert _resolvePort() == 9001 + monkeypatch.setenv("FISHE_WEB_PORT", "9090") + assert kitServe.resolvePort("FISHE") == 9090 def test_misspelled_port_names_the_variable(monkeypatch): - # This entry point is the one the Dockerfile runs, so its port is the one - # most likely to arrive from outside - and a bare int() conversion error - # names neither the variable nor what it should hold. - monkeypatch.setenv("FISHE_WEB_PORT", "80801x") - - with pytest.raises(ValueError, match="FISHE_WEB_PORT"): - _resolvePort() - - with pytest.raises(ValueError, match="80801x"): - _resolvePort() - - -def test_free_port_is_bound_and_handed_back(): - server = _bindServer("127.0.0.1", 0) - try: - boundHost, boundPort = server.server_address[0], server.server_address[1] - finally: - server.server_close() - - assert boundHost == "127.0.0.1" - assert boundPort != 0 + monkeypatch.setenv("FISHE_WEB_PORT", "eighty") + with pytest.raises(ValueError) as error: + kitServe.resolvePort("FISHE") + assert "FISHE_WEB_PORT" in str(error.value) def test_taken_port_is_explained_rather_than_traced(): - # "It is already running in another terminal" is the ordinary failure, and - # allow_reuse_address does not cover it: that only reopens a socket left in - # TIME_WAIT, while a live listener still refuses the bind. - server, _ = startServer() - host, port = server.server_address[0], server.server_address[1] + blocker = socket.socket() + blocker.bind(("127.0.0.1", 0)) + blocker.listen(1) try: - with pytest.raises(OSError) as raised: - _bindServer(host, port) + with pytest.raises(OSError) as error: + kitServe.bindServer( + REPOSITORY_ROOT, "FishE", "FISHE", "127.0.0.1", blocker.getsockname()[1] + ) finally: - server.shutdown() - server.server_close() - - message = str(raised.value) - assert "FISHE_WEB_PORT" in message - assert "already listening" in message - assert str(port) in message + blocker.close() + assert "FishE" in str(error.value) and "FISHE_WEB_PORT" in str(error.value) diff --git a/web/build_zip.py b/web/build_zip.py index 5882162..a3b6cb7 100644 --- a/web/build_zip.py +++ b/web/build_zip.py @@ -1,67 +1,25 @@ #!/usr/bin/env python3 # @author Daniel McCoy Stephenson -"""Build web/game.zip — the bundle the browser's Pyodide Worker downloads. - -Everything the game needs to run in a tab goes in: the Python source tree, the -JSON Schemas the save readers validate against, the Worker's entry point, and -the tak package the game imports from - placed under src/tak so the Worker's -single sys.path entry (/game/src) covers both. -Run from the repository root (the Dockerfile does this at build time): +"""Build web/game.zip - the bundle the browser's Pyodide Worker downloads. python3 web/build_zip.py + +Puts src/ and schemas/ in, plus version.txt and the Worker's entry point, and +the tak package itself under src/tak (see tak.web.bundle). The browser client +is no longer bundled: the page fetches it from the kit's assets at /tak/. """ import os -import zipfile - -import tak -OUTPUT_PATH = "web/game.zip" +from tak.web.bundle import build -# Paths are stored repo-relative so the Worker can unpack into /game and get a -# tree that matches a checkout — which is what makes the cwd-relative schema -# paths in the *JsonReaderWriter modules resolve there. -SOURCE_DIRECTORIES = ("src", "schemas") -# client.js/client.css are fetched over HTTP by the page, so the browser does -# not need them from the bundle — they are included anyway because -# webUserInterface reads them from the filesystem, and PyodideUserInterface -# subclasses it. Belt and braces: the read is lazy so it never happens in the -# browser, and if a future change makes it happen, the files are there. -EXTRA_FILES = ( - "version.txt", - "web/pyodide_main.py", - "web/client.js", - "web/client.css", +REPOSITORY_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") ) - - -def build(outputPath=OUTPUT_PATH): - with zipfile.ZipFile(outputPath, "w", zipfile.ZIP_DEFLATED) as bundle: - for directory in SOURCE_DIRECTORIES: - for root, dirs, files in os.walk(directory): - dirs[:] = [d for d in dirs if d != "__pycache__"] - for name in files: - if name.endswith(".pyc"): - continue - path = os.path.join(root, name) - bundle.write(path, path) - for path in EXTRA_FILES: - if os.path.exists(path): - bundle.write(path, path) - # The installed tak package, so the browser runs exactly the kit - # version this checkout was built against (requirements.txt's pin). - takDirectory = os.path.dirname(os.path.abspath(tak.__file__)) - for root, dirs, files in os.walk(takDirectory): - dirs[:] = [d for d in dirs if d != "__pycache__"] - for name in files: - if name.endswith(".pyc"): - continue - path = os.path.join(root, name) - archiveName = os.path.join( - "src", "tak", os.path.relpath(path, takDirectory) - ).replace(os.sep, "/") - bundle.write(path, archiveName) - print(f"Built {outputPath}") - +OUTPUT_PATH = os.path.join("web", "game.zip") if __name__ == "__main__": - build() + build( + REPOSITORY_ROOT, + outputPath=OUTPUT_PATH, + extraFiles=("version.txt", "web/pyodide_main.py"), + ) diff --git a/web/client.css b/web/client.css deleted file mode 100644 index b0f9f4f..0000000 --- a/web/client.css +++ /dev/null @@ -1,61 +0,0 @@ - /* text-size-adjust keeps Safari from inflating text of its own accord once - the viewport is device-width (most visible in landscape). */ - html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } - body { font-family: monospace; background: #0b1d2a; color: #e0f0ff; - max-width: 680px; margin: 2rem auto; padding: 0 1rem; - /* The game can emit long unbroken strings; break them rather than - letting them scroll the page sideways. Inherited by the screens. */ - overflow-wrap: break-word; } - .header { color: #7fb0d0; border-bottom: 1px solid #2a4a5a; - padding-bottom: .5rem; margin-bottom: 1rem; - display: flex; flex-wrap: wrap; gap: .15rem 1.1rem; } - .descriptor { margin: 1rem 0; font-size: 1.1rem; } - .prompt { color: #9fd0ff; margin: 1rem 0; } - .dialogue { white-space: pre-wrap; overflow-wrap: break-word; - margin: 1rem 0; line-height: 1.5; } - button { display: block; width: 100%; text-align: left; margin: .3rem 0; - padding: .6rem; background: #163345; color: #e0f0ff; - border: 1px solid #2a4a5a; border-radius: 4px; cursor: pointer; - font-family: monospace; font-size: 1rem; - overflow-wrap: break-word; - touch-action: manipulation; } /* no double-tap-to-zoom delay */ - button:hover { background: #1f4a63; } - button.danger { background: #4a1620; border-color: #7a2a35; } - button.danger:hover { background: #63202c; } - button:disabled { opacity: .45; cursor: not-allowed; } - button:disabled:hover { background: #163345; } - /* A menu option the game would refuse right now. Greyed with its own muted - palette rather than the dimming above, because the reason printed on the - row has to stay readable - the point is that the player can see why. */ - button.unavailable { opacity: 1; background: #10202a; border-color: #24343d; - color: #7d94a3; } - button.unavailable:hover { background: #10202a; } - button .reason { color: #d8a866; font-style: italic; } - button.action { width: auto; text-align: center; padding: .6rem 1.4rem; - background: #1d5a7a; border-color: #2f7ba0; } - button.action:hover { background: #246a90; } - input { width: 100%; padding: .6rem; font-family: monospace; font-size: 1rem; - background: #163345; color: #e0f0ff; border: 1px solid #2a4a5a; - border-radius: 4px; } - .tagline { font-size: .8rem; font-weight: normal; color: #7fb0d0; } - .controls { font-size: .8rem; color: #6a8aa0; border-top: 1px solid #2a4a5a; - margin-top: 1.5rem; padding-top: .5rem; } - .low { color: #ff8a8a; font-weight: bold; } - .operator { color: #ffcf8f; font-weight: bold; } - .notice { color: #9fd0ff; margin-top: 1rem; } - .notice.warning { color: #ffcf8f; border-left: 3px solid #c77b2a; padding-left: .6rem; } - /* Phone-sized screens only: the desktop layout above is left as-is. Note that - font sizes are deliberately not reduced here — 1rem inputs (16px) are what - keeps iOS Safari from zooming the page in when a field is focused. */ - @media (max-width: 600px) { - /* 2rem of vertical margin is a lot of a short screen to spend on nothing. */ - body { margin: 1rem auto; padding: 0 .75rem; } - /* A wrapped header was stacking its rows almost on top of each other. */ - .header { gap: .35rem 1rem; } - /* .6rem padding puts a button at ~40px tall, under the ~44px that is - comfortable to tap; .75rem clears it. Wider gaps between them, too. */ - button { padding: .75rem .6rem; margin: .45rem 0; } - /* Continue/Submit/React! are the only thing to press on their screens — - give them the full width instead of a small target off to one side. */ - button.action { width: 100%; padding: .75rem .6rem; } - } diff --git a/web/client.js b/web/client.js deleted file mode 100644 index 556bf5d..0000000 --- a/web/client.js +++ /dev/null @@ -1,173 +0,0 @@ -// The FishE browser client: renders the screen JSON that every web front-end -// publishes, and hands the player's response back to whatever is driving it. -// -// Two front-ends share this file, so the game looks and behaves identically in -// both and a screen type only ever has to be implemented once: -// -// - WebUserInterface (src/ui/webUserInterface.py) — the game runs on a -// server; the page polls GET /state and POSTs to /input. -// - PyodideUserInterface (src/ui/pyodideUserInterface.py) — the game runs in -// this browser under Pyodide; screens arrive as Worker messages and input -// goes back over a SharedArrayBuffer ring. -// -// A host wires itself up with FisheClient.init(sendFn) and then calls -// FisheClient.render(screen) for each screen the game publishes. The screen -// JSON contract is BaseUserInterface's primitives — see WebUserInterface. - -window.FisheClient = (function () { - let currentScreen = null; - let sendToGame = () => {}; - - function app() { - return document.getElementById("app"); - } - - function el(tag, props, ...kids) { - const e = document.createElement(tag); - Object.assign(e, props || {}); - for (const k of kids) e.append(k); - return e; - } - - // The reason option i can't be picked, or null. Older screens (and any - // front-end that never marks anything unavailable) simply omit the list. - function unavailableReason(screen, i) { - return (screen.unavailable || [])[i] || null; - } - - function renderNotice(text, className) { - const a = app(); - a.innerHTML = ""; - a.append(el("div", { className: className || "notice", textContent: text })); - } - - function renderDisconnected() { - currentScreen = null; - renderNotice("Lost connection to the game — is it still running? Retrying…", "notice warning"); - } - - // Nothing is on screen between sending a response and the next screen - // arriving, so drop currentScreen to make stray keypresses inert. - function send(value) { - currentScreen = null; - app().innerHTML = "…"; - sendToGame(value); - } - - function render(screen) { - const a = app(); - a.innerHTML = ""; - currentScreen = screen; // let keyboard shortcuts act on what's on screen - if (!screen || screen.type === "loading") { renderNotice("Waiting for the game…"); return; } - if (screen.type === "ended") { renderNotice("The game has ended. You can close this tab."); return; } - if (screen.header) { - const h = screen.header; - const header = el("div", { className: "header" }); - // Each stat is its own chip; the flex-wrap row spaces them with whitespace - // and wraps cleanly on narrow screens instead of running off one long line. - const addPart = (content) => { - header.append(content instanceof Node ? content : el("span", { textContent: content })); - }; - addPart(`Day ${h.day}`); - addPart(h.time); - addPart(`$${h.money.toFixed(2)}`); - addPart(`Fish: ${h.fish}`); - // Below the fishing threshold (10) the player is too tired to fish — flag it. - const energy = el("span", { textContent: `Energy: ${h.energy}/${h.maxEnergy}` }); - if (h.energy < 10) energy.className = "low"; - addPart(energy); - if (h.location) addPart(h.location); - if (h.goal) addPart(`Goal: ${h.goal}`); - if (h.operator) addPart(el("span", { textContent: "OPERATOR MODE", className: "operator" })); - a.append(header); - document.title = `FishE — Day ${h.day}, $${h.money.toFixed(2)}`; - } - if (screen.descriptor) a.append(el("div", { className: "descriptor", textContent: screen.descriptor })); - if (screen.prompt) a.append(el("div", { className: "prompt", textContent: screen.prompt })); - if (screen.type === "options") { - screen.options.forEach((opt, i) => { - // An option the game would refuse right now is still listed, so the - // menu doesn't shuffle under the player, but it is greyed out and - // carries the reason - the button itself says why it can't be used. - const reason = unavailableReason(screen, i); - const b = el("button", { - className: reason ? "unavailable" : (/delete/i.test(opt) ? "danger" : ""), - }); - b.append(`[${i + 1}] ${opt}`); - if (reason) { - b.append(el("span", { className: "reason", textContent: ` — ${reason}` })); - b.disabled = true; - b.title = reason; - } else { - b.onclick = () => send(String(i + 1)); - } - a.append(b); - }); - } else if (screen.type === "dialogue") { - a.append(el("div", { className: "dialogue", textContent: screen.text })); - const b = el("button", { textContent: "Continue", className: "action" }); - b.onclick = () => send(""); - a.append(b); - } else if (screen.type === "prompt") { - a.append(el("div", { className: "descriptor", textContent: screen.text })); - const inp = el("input", { type: "text" }); - const b = el("button", { textContent: "Submit", className: "action" }); - const valid = () => !screen.numeric || - (inp.value.trim() !== "" && !isNaN(Number(inp.value))); - const submit = () => { if (valid()) send(inp.value); }; - if (screen.numeric) { - inp.inputMode = "decimal"; - inp.placeholder = "Enter a number"; - inp.oninput = () => { b.disabled = !valid(); }; - b.disabled = true; // nothing valid typed yet - } - inp.onkeydown = (e) => { if (e.key === "Enter") submit(); }; - b.onclick = submit; - a.append(inp); a.append(b); inp.focus(); - } else if (screen.type === "busy") { - // A pause the game takes on its own — shown, but with nothing to click; - // the next screen replaces it when the pause is over. - a.append(el("div", { className: "descriptor", textContent: screen.message })); - } else if (screen.type === "timed") { - a.append(el("div", { className: "descriptor", textContent: screen.message })); - const b = el("button", { textContent: "React!", className: "action" }); - b.onclick = () => send(""); - a.append(b); - } - } - - // Keyboard control, matching the console/pygame front-ends: number keys pick - // an option; Enter or Space advances a dialogue or the timed prompt. Typing - // in the text field is left to the field itself. - function onKeyDown(e) { - const s = currentScreen; - if (!s) return; - if (e.target && e.target.tagName === "INPUT") return; - if (s.type === "options") { - if (e.key >= "1" && e.key <= "9") { - const n = parseInt(e.key, 10); - // A greyed-out option is no more pickable by its number key than by - // its button; the game would ignore the response either way. - if (n <= s.options.length && !unavailableReason(s, n - 1)) { - e.preventDefault(); - send(String(n)); - } - } - } else if (s.type === "dialogue" || s.type === "timed") { - if (e.key === "Enter" || e.key === " ") { e.preventDefault(); send(""); } - } - } - - function init(sendFn) { - sendToGame = sendFn; - document.addEventListener("keydown", onKeyDown); - } - - return { - init: init, - render: render, - renderNotice: renderNotice, - renderDisconnected: renderDisconnected, - getCurrentScreen: () => currentScreen, - }; -})(); diff --git a/web/game-worker.js b/web/game-worker.js deleted file mode 100644 index fae2cc9..0000000 --- a/web/game-worker.js +++ /dev/null @@ -1,195 +0,0 @@ -// Web Worker: loads Pyodide, unpacks the game bundle, and runs FishE's Python -// game loop in the player's own browser. -// -// Communication with the main thread: -// main → worker: { type: 'init', sab: SharedArrayBuffer } -// worker → main: { type: 'status', msg: string } -// { type: 'ready' } -// { type: 'error', msg: string } -// { type: 'save', files: { path: content, ... } } -// string — a JSON {"type":"screen","screen":{...}} frame -// posted by PyodideUserInterface -// -// The player's responses arrive the other way, through the SharedArrayBuffer -// ring buffer the main thread writes into (see web/index.html). They cannot -// come over postMessage: FishE's game loop is synchronous, so it blocks the -// Worker while waiting for input, and a blocked Worker never runs onmessage. -// -// ── Why IndexedDB writes live on the main thread ───────────────────────────── -// Pyodide's build uses Atomics.wait() for time.sleep() when SharedArrayBuffer -// is available, which blocks the Worker's JS event loop entirely — so IDB -// callbacks (macrotasks) can never fire while Python is running. Instead the -// Worker walks /saves synchronously and postMessages the file map to the main -// thread, which writes to IDB from its own, unblocked, event loop. The initial -// restore still happens here in the Worker because it runs before Python -// starts, when nothing is blocking the event loop yet. - -importScripts('https://cdn.jsdelivr.net/pyodide/v0.26.0/full/pyodide.js'); - -const SAVE_DIRECTORY = '/saves'; - -const IDB_NAME = 'fishe-saves'; -const IDB_STORE = 'files'; -const IDB_VERSION = 1; - -function idbOpen() { - return new Promise((resolve, reject) => { - const req = indexedDB.open(IDB_NAME, IDB_VERSION); - req.onupgradeneeded = (e) => { - const db = e.target.result; - if (!db.objectStoreNames.contains(IDB_STORE)) { - db.createObjectStore(IDB_STORE); - } - }; - req.onsuccess = (e) => resolve(e.target.result); - req.onerror = (e) => reject(e.target.error); - }); -} - -// ── Save restore: read IndexedDB into /saves before Python starts ──────────── -// Always resolves, never rejects: a browser that won't hand back stored data -// should start the player on a fresh save file, not refuse to load the game. -async function loadSavesFromIDB(pyodide) { - try { - const db = await Promise.race([ - idbOpen(), - new Promise((_, rej) => - setTimeout(() => rej(new Error('IndexedDB open timed out')), 5000) - ), - ]); - - const entries = await new Promise((resolve, reject) => { - const result = []; - let tx, cursorReq; - try { - tx = db.transaction(IDB_STORE, 'readonly'); - cursorReq = tx.objectStore(IDB_STORE).openCursor(); - } catch (e) { reject(e); return; } - cursorReq.onsuccess = (ev) => { - const c = ev.target.result; - if (c) { result.push({ path: c.key, content: c.value }); c.continue(); } - else resolve(result); - }; - cursorReq.onerror = () => reject(cursorReq.error); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(new Error('IndexedDB transaction aborted')); - }); - - let restored = 0; - for (const { path, content } of entries) { - // Save slots are directories (/saves/slot_1/player.json), so the - // parents have to exist before the file can be written. - const parts = path.split('/').filter(Boolean); - let dir = ''; - for (let i = 0; i < parts.length - 1; i++) { - dir += '/' + parts[i]; - try { pyodide.FS.mkdir(dir); } catch {} // already there: fine - } - try { - pyodide.FS.writeFile(path, content, { encoding: 'utf8' }); - restored++; - } catch (e) { - console.warn('[fishe] could not restore save file', path, e); - } - } - if (restored > 0) console.log(`[fishe] ${restored} save file(s) restored`); - try { db.close(); } catch {} - - } catch (err) { - console.warn('[fishe] save restore skipped (starting fresh):', err); - } -} - -// ── Save flush: collect /saves and hand it to the main thread ──────────────── -// Installed as globalThis.syncSaves, which src/browserSaveSync.py calls after -// every write or delete. Walking pyodide.FS is pure JavaScript and needs no -// event loop, and postMessage is synchronous from the Worker's side — so this -// works even though Python is mid-call and the Worker is otherwise blocked. - -function makeSyncSaves(pyodide) { - return () => { - const files = {}; - function walk(path) { - let entries; - try { entries = pyodide.FS.readdir(path); } catch { return; } - for (const name of entries) { - if (name === '.' || name === '..') continue; - const full = `${path}/${name}`; - let stat; - try { stat = pyodide.FS.stat(full); } catch { continue; } - const isDirectory = (stat.mode & 0o170000) === 0o040000; - if (isDirectory) { - walk(full); - } else { - // FishE's saves are JSON, so UTF-8 is the whole story here. - try { - files[full] = pyodide.FS.readFile(full, { encoding: 'utf8' }); - } catch (e) { - console.warn('[fishe] could not read save file', full, e); - } - } - } - } - walk(SAVE_DIRECTORY); - self.postMessage({ type: 'save', files }); - }; -} - -// ── Worker entry point ─────────────────────────────────────────────────────── - -self.onmessage = async (e) => { - if (e.data.type !== 'init') return; - - const { sab } = e.data; - // [0] = write index, [1] = read index, both monotonically increasing and - // taken modulo the ring size when indexing into the data region. - globalThis.sabMeta = new Int32Array(sab, 0, 2); - globalThis.sabData = new Uint8Array(sab, 8, e.data.ringSize); - globalThis.sabRingSize = e.data.ringSize; - globalThis.sendToMain = (data) => self.postMessage(data); - - try { - self.postMessage({ type: 'status', msg: 'Loading Python runtime…' }); - - const pyodide = await loadPyodide({ - stdout: (msg) => console.log('[fishe]', msg), - stderr: (msg) => console.warn('[fishe]', msg), - }); - - self.postMessage({ type: 'status', msg: 'Restoring saved games…' }); - - pyodide.FS.mkdir(SAVE_DIRECTORY); - await loadSavesFromIDB(pyodide); // before Python: IDB callbacks still fire - globalThis.syncSaves = makeSyncSaves(pyodide); - - self.postMessage({ type: 'status', msg: 'Installing Python packages…' }); - - // jsonschema is a real runtime dependency: the save readers validate - // against schemas/*.json on every load (see requirements.txt). - await pyodide.loadPackage(['jsonschema']); - - self.postMessage({ type: 'status', msg: 'Downloading the village…' }); - - const resp = await fetch('/web/game.zip'); - if (!resp.ok) throw new Error(`game.zip fetch failed: ${resp.status}`); - const buf = await resp.arrayBuffer(); - pyodide.FS.mkdir('/game'); - pyodide.unpackArchive(new Uint8Array(buf), 'zip', { extractDir: '/game' }); - - self.postMessage({ type: 'status', msg: 'Casting off…' }); - self.postMessage({ type: 'ready' }); - - // chdir to /game so the cwd-relative schema paths in the save readers - // resolve; FISHE_SAVE_DIR points Config at the IndexedDB-backed dir. - await pyodide.runPythonAsync(` -import os, sys -sys.path.insert(0, '/game/src') -os.chdir('/game') -os.environ['FISHE_SAVE_DIR'] = '${SAVE_DIRECTORY}' -exec(open('/game/web/pyodide_main.py').read()) -`); - - } catch (err) { - self.postMessage({ type: 'error', msg: String(err) }); - } -}; diff --git a/web/index.html b/web/index.html index 5938ab7..648f2b9 100644 --- a/web/index.html +++ b/web/index.html @@ -4,14 +4,13 @@ FishE - - + + @@ -21,135 +20,17 @@

FishE — fish a seaside village and build a fortune o
Starting…

Tip: click an option or press its number key (1-9). Enter or Space continues. Your saved games live in this browser.

- + + diff --git a/web/pyodide_main.py b/web/pyodide_main.py index 972ce3a..6512e88 100644 --- a/web/pyodide_main.py +++ b/web/pyodide_main.py @@ -1,8 +1,8 @@ # @author Daniel McCoy Stephenson """Pyodide entry point — runs inside the Web Worker once game.zip is unpacked. -By the time this file is exec()'d, web/game-worker.js has already: - - put /game/src on sys.path and chdir'd to /game, so the schema paths the +By the time this file is exec()'d, tak's game-worker.js has already: + - put /game/src on sys.path (the bundle carries tak there too) and chdir'd to /game, so the schema paths the save readers validate against ("schemas/player.json") resolve - created /saves, restored it from IndexedDB, and pointed FISHE_SAVE_DIR at it - installed the JavaScript globals the front-end needs: sendToMain, Atomics, diff --git a/web/serve.py b/web/serve.py index 6839548..c2761a4 100644 --- a/web/serve.py +++ b/web/serve.py @@ -1,137 +1,24 @@ # @author Daniel McCoy Stephenson """Static file server for the browser-native (Pyodide) build of FishE. -Unlike examples/web_app.py, this server does not run the game — it only hands -the browser the files it needs, and the game then runs in the player's own tab -with its saves in that browser's IndexedDB. Every visitor gets their own game -and their own save slots, and the server keeps no state at all. - python3 web/serve.py # then open the URL it prints -Routes: - / /play /play/ /index.html → web/index.html - /web/... → the web/ directory (game.zip, worker, client) - everything else → 404 - -The Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy headers below are -not optional: without them the page is not cross-origin isolated, and -SharedArrayBuffer — which is how the player's input reaches the blocked game -Worker — is not available at all. web/index.html says so on screen if they are -missing, which is the usual symptom of a proxy in front of this server dropping -them. +The server itself is tak's (tak.web.serve): it never runs the game, only hands +the browser web/index.html, web/game.zip and the kit's assets at /tak/, with +the Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy headers that +SharedArrayBuffer - and therefore all player input - depends on. Any proxy in +front of it must preserve them. FISHE_WEB_HOST / FISHE_WEB_PORT move it; the +port defaults to 8080 (what the Dockerfile sets), distinct from the +server-backed front-end's 8000 so both can run at once. """ -import errno -import http.server import os -from urllib.parse import unquote, urlparse - -REPOSITORY_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -WEB_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) - -INDEX_PATHS = ("/", "/play", "/play/", "/index.html") - -# FISHE_WEB_PORT is read here and by UserInterfaceFactory's WEB branch, with a -# different default in each: 8080 for this server (what the Dockerfile sets), -# 8000 for the server-backed front-end behind examples/web_app.py. They are -# separate programs that can be run at the same time, so the defaults are -# deliberately not shared - the check on the value is, so a misspelled port -# names itself whichever one the player started. -DEFAULT_PORT = "8080" - - -class _Handler(http.server.SimpleHTTPRequestHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, directory=REPOSITORY_ROOT, **kwargs) - - def do_GET(self): - path = unquote(urlparse(self.path).path) - if path in INDEX_PATHS: - self._sendIndex() - return - if not path.startswith("/web/"): - self.send_error(404, "Not found") - return - super().do_GET() - - def _sendIndex(self): - indexPath = os.path.join(WEB_DIRECTORY, "index.html") - try: - with open(indexPath, "rb") as indexFile: - body = indexFile.read() - except OSError as e: - self.send_error( - 500, - "FishE's page is missing", - f"Could not read {indexPath}: {e}. That file ships in the " - f"repository's web/ directory — serve the game from a complete " - f"checkout.", - ) - return - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def end_headers(self): - # Required for SharedArrayBuffer, which the Pyodide front-end uses to - # deliver input to the (blocked) game Worker. - self.send_header("Cross-Origin-Opener-Policy", "same-origin") - self.send_header("Cross-Origin-Embedder-Policy", "require-corp") - self.send_header("Cross-Origin-Resource-Policy", "same-origin") - super().end_headers() - - def log_message(self, *args): - pass # keep the container's logs to what the game itself says - - -class _Server(http.server.ThreadingHTTPServer): - allow_reuse_address = True - daemon_threads = True - - -def _resolvePort(): - """The port to serve on, or a ValueError naming the variable that is wrong.""" - portText = os.environ.get("FISHE_WEB_PORT", DEFAULT_PORT) - try: - return int(portText) - except ValueError: - raise ValueError(f"FISHE_WEB_PORT must be an integer, got: {portText!r}") - - -def _bindServer(host, port): - """Bind the server, turning a refused address into a sentence. - - A port that is already listening - a second copy of the game, most often - - otherwise surfaces as an errno raised from inside http.server, naming - neither FishE nor the variable the player would have to change.""" - try: - return _Server((host, port), _Handler) - except OSError as e: - if e.errno == errno.EADDRINUSE: - reason = "something else is already listening there" - elif e.errno == errno.EACCES: - reason = "this process is not allowed to use that port" - else: - reason = str(e) - raise OSError( - f"FishE could not be served at http://{host}:{port}/: {reason}. " - f"Set FISHE_WEB_PORT to a free port (or FISHE_WEB_HOST to an " - f"address this machine can bind) and start it again." - ) from e - -def main(): - host = os.environ.get("FISHE_WEB_HOST", "127.0.0.1") - port = _resolvePort() - # Bound before the URL is announced, so a failure is never preceded by an - # address that was never served. - server = _bindServer(host, port) - print(f"FishE is being served at http://{host}:{port}/") - print("Open that URL to play. Press Ctrl+C here to stop.") - server.serve_forever() +from tak.web.serve import main +REPOSITORY_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +) if __name__ == "__main__": - main() + main(REPOSITORY_ROOT, title="FishE", envPrefix="FISHE")