From 5a34eabf412d5dd11fb815379181e4bbcd34e639 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 13 Jun 2026 07:49:43 -0600 Subject: [PATCH] Add a web (browser) front-end and an example web app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements WebUserInterface, a third BaseUserInterface front-end alongside the console and pygame ones, using only the Python standard library. 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 serves the page (GET /), the current screen (GET /state), and accepts the player's response (POST /input). - UIType.WEB + a lazy factory branch (so other modes don't start HTTP machinery). - FishE accepts an interfaceType (default unchanged) so a front-end can be chosen without editing the module constant. - examples/web_app.py runs the whole game in the browser. The entire game — save manager, fishing, shop, bank, tavern, NPC dialogue — works through the web UI with no new dependencies. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 10 ++ examples/web_app.py | 32 ++++ src/fishE.py | 4 +- src/ui/enum/uiType.py | 3 +- src/ui/userInterfaceFactory.py | 5 + src/ui/webUserInterface.py | 254 ++++++++++++++++++++++++++++++ tests/ui/test_webUserInterface.py | 145 +++++++++++++++++ 7 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 examples/web_app.py create mode 100644 src/ui/webUserInterface.py create mode 100644 tests/ui/test_webUserInterface.py diff --git a/README.md b/README.md index cefb507..174ce37 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ This game allows you to explore a fishing village and perform actions in it. ## Features +### Play in your browser (web interface) +FishE runs behind a single user-interface contract, so it supports multiple front-ends: the default text/console interface, a pygame window, and a browser-based **web interface**. To play in the browser, run the example web app and open the printed URL: + +```bash +python3 examples/web_app.py +# then open http://127.0.0.1:8000 +``` + +The entire game — save-file manager, fishing, shop, bank, tavern, and NPC dialogue — plays in the browser, with no extra dependencies (it uses only the Python standard library). Adding a new front-end means implementing `BaseUserInterface` and adding a `UIType` + factory branch. + ### Your Goal Build a fortune of **$10,000** in total wealth (cash on hand plus savings in the bank). Your progress toward the goal is shown in the status header, and reaching it earns a one-time victory — after which you're free to keep fishing or retire from the Home menu. diff --git a/examples/web_app.py b/examples/web_app.py new file mode 100644 index 0000000..ad6fa72 --- /dev/null +++ b/examples/web_app.py @@ -0,0 +1,32 @@ +"""Example: run FishE with the browser-based web front-end. + +This shows how to drive the existing game through the WebUserInterface — no game +logic changes, just a different UIType. Run it and open the printed URL: + + python3 examples/web_app.py + +The whole game (save-file manager, fishing, shop, bank, tavern, dialogue) then +plays in the browser. +""" + +import os +import sys + +# Make the game package importable when running this file directly. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from ui.enum.uiType import UIType # noqa: E402 +from fishE import FishE # noqa: E402 + + +def main(): + print("FishE web app is starting at http://127.0.0.1:8000") + print("Open that URL in your browser to play. Press Ctrl+C here to stop.") + # Building FishE starts the web server and then waits (in the save-file + # manager) for the browser to interact, so play happens entirely in-browser. + game = FishE(interfaceType=UIType.WEB) + game.play() + + +if __name__ == "__main__": + main() diff --git a/src/fishE.py b/src/fishE.py index 1b1d3f6..2354712 100644 --- a/src/fishE.py +++ b/src/fishE.py @@ -27,7 +27,7 @@ # @author Daniel McCoy Stephenson class FishE: - def __init__(self): + def __init__(self, interfaceType=INTERFACE_TYPE): self.running = True self.playerJsonReaderWriter = PlayerJsonReaderWriter() @@ -43,7 +43,7 @@ def __init__(self): self.timeService = TimeService(self.player, self.stats) self.prompt = Prompt("What would you like to do?") self.userInterface = UserInterfaceFactory.create_user_interface( - INTERFACE_TYPE, self.prompt, self.timeService, self.player + interfaceType, self.prompt, self.timeService, self.player ) # Migrate old save files to new format if they exist diff --git a/src/ui/enum/uiType.py b/src/ui/enum/uiType.py index cc2d9f5..3d5c871 100644 --- a/src/ui/enum/uiType.py +++ b/src/ui/enum/uiType.py @@ -4,4 +4,5 @@ # @author Daniel McCoy Stephenson class UIType(Enum): CONSOLE = "console" - PYGAME = "pygame" \ No newline at end of file + PYGAME = "pygame" + WEB = "web" \ No newline at end of file diff --git a/src/ui/userInterfaceFactory.py b/src/ui/userInterfaceFactory.py index dd1af6a..4fd72ea 100644 --- a/src/ui/userInterfaceFactory.py +++ b/src/ui/userInterfaceFactory.py @@ -29,5 +29,10 @@ def create_user_interface( from ui.pygameUserInterface import PygameUserInterface return PygameUserInterface(currentPrompt, timeService, player) + elif ui_type == UIType.WEB: + # Imported lazily so other modes don't start the HTTP machinery. + from ui.webUserInterface import WebUserInterface + + return WebUserInterface(currentPrompt, timeService, player) else: raise ValueError(f"Unsupported UI type: {ui_type}") diff --git a/src/ui/webUserInterface.py b/src/ui/webUserInterface.py new file mode 100644 index 0000000..dd6533b --- /dev/null +++ b/src/ui/webUserInterface.py @@ -0,0 +1,254 @@ +import json +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 + + +# The single-page client. 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. +HTML_PAGE = """ + + + +FishE + + + +

FishE

+
Connecting…
+ + + +""" + + +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", HTML_PAGE.encode("utf-8")) + elif self.path.startswith("/state"): + body = json.dumps(ui.get_state()).encode("utf-8") + self._send(200, "application/json", body) + 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).""" + + def __init__( + self, + currentPrompt: Prompt, + timeService: TimeService, + player: Player, + host="127.0.0.1", + port=8000, + start_server=True, + ): + super().__init__(currentPrompt, timeService, player) + self._lock = threading.Lock() + self._screen = {"type": "loading"} + self._version = 0 + self._inputQueue = queue.Queue() + self._server = None + if start_server: + self._server = ThreadingHTTPServer((host, port), _makeRequestHandler(self)) + self._server.daemon_threads = True + threading.Thread(target=self._server.serve_forever, daemon=True).start() + + @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 + + # --- 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 _present(self, screen): + with self._lock: + self._screen = screen + self._version += 1 + + 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, + "location": self.currentLocationName, + "goal": self.goalProgress, + } + + # --- 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): + self._present( + { + "type": "options", + "descriptor": descriptor, + "prompt": self.currentPrompt.text, + "options": list(optionList), + "header": self._header(), + } + ) + valid = {str(i + 1) for i in range(len(optionList))} + while True: + choice = str(self._inputQueue.get()) + if choice in valid: + return choice + # ignore anything that isn't a listed option and keep waiting + + def showDialogue(self, text): + self._present({"type": "dialogue", "text": text}) + self._inputQueue.get() + self.currentPrompt.text = "What would you like to do?" + + def promptForText(self, promptText): + self._present({"type": "prompt", "text": promptText}) + return str(self._inputQueue.get()) + + def timedKeyPress(self, message): + self._present({"type": "timed", "message": message}) + startTime = time.time() + self._inputQueue.get() + return time.time() - startTime + + def cleanup(self): + self._present({"type": "ended"}) + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None diff --git a/tests/ui/test_webUserInterface.py b/tests/ui/test_webUserInterface.py new file mode 100644 index 0000000..189ae45 --- /dev/null +++ b/tests/ui/test_webUserInterface.py @@ -0,0 +1,145 @@ +import sys +import os +import json +import time +import threading +import urllib.request + +# Use the bare `ui.*`/`player.*` import style (matching production) so class +# identities line up with the runtime MRO; pytest.ini exposes both `.` and `src`. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + +from ui.baseUserInterface import BaseUserInterface +from ui.webUserInterface import WebUserInterface +from player.player import Player +from prompt.prompt import Prompt +from stats.stats import Stats +from world.timeService import TimeService + + +def makeWebUI(start_server=False, port=0): + prompt = Prompt("What would you like to do?") + player = Player() + stats = Stats() + timeService = TimeService(player, stats) + return WebUserInterface( + prompt, timeService, player, port=port, start_server=start_server + ) + + +def runInThread(fn): + box = {} + thread = threading.Thread(target=lambda: box.__setitem__("result", fn())) + thread.start() + return thread, box + + +def waitForScreen(ui, screenType, timeout=2.0): + deadline = time.time() + timeout + while time.time() < deadline: + if ui.get_state()["screen"].get("type") == screenType: + return + time.sleep(0.01) + raise AssertionError("screen %r was never presented" % screenType) + + +def test_web_ui_implements_interface(): + # check - it is a BaseUserInterface and instantiable (all primitives present) + assert issubclass(WebUserInterface, BaseUserInterface) + ui = makeWebUI() + assert ui.get_state()["screen"]["type"] == "loading" + + +def test_showOptions_round_trips_a_choice(): + ui = makeWebUI() + thread, box = runInThread(lambda: ui.showOptions("Pick one", ["Apple", "Banana"])) + + waitForScreen(ui, "options") + screen = ui.get_state()["screen"] + assert screen["options"] == ["Apple", "Banana"] + assert "header" in screen and "day" in screen["header"] + + ui.submit_input("2") + thread.join(timeout=2) + assert box["result"] == "2" + + +def test_showOptions_ignores_invalid_then_accepts_valid(): + ui = makeWebUI() + thread, box = runInThread(lambda: ui.showOptions("Pick", ["Only"])) + waitForScreen(ui, "options") + + ui.submit_input("9") # not a listed option -> ignored + ui.submit_input("1") # valid + thread.join(timeout=2) + assert box["result"] == "1" + + +def test_promptForText_round_trips_text(): + ui = makeWebUI() + thread, box = runInThread(lambda: ui.promptForText("Your name?")) + waitForScreen(ui, "prompt") + + ui.submit_input("Gilbert") + thread.join(timeout=2) + assert box["result"] == "Gilbert" + + +def test_promptForNumber_uses_web_input(): + ui = makeWebUI() + thread, box = runInThread(lambda: ui.promptForNumber("How much?")) + waitForScreen(ui, "prompt") + + ui.submit_input("12.5") + thread.join(timeout=2) + assert box["result"] == 12.5 + + +def test_showDialogue_waits_then_resets_prompt(): + ui = makeWebUI() + ui.currentPrompt.text = "something" + thread, box = runInThread(lambda: ui.showDialogue("Hello there")) + waitForScreen(ui, "dialogue") + + assert ui.get_state()["screen"]["text"] == "Hello there" + ui.submit_input("") + thread.join(timeout=2) + assert ui.currentPrompt.text == "What would you like to do?" + + +def test_timedKeyPress_returns_elapsed_seconds(): + ui = makeWebUI() + thread, box = runInThread(lambda: ui.timedKeyPress("React!")) + waitForScreen(ui, "timed") + + ui.submit_input("") + thread.join(timeout=2) + assert box["result"] >= 0.0 + + +def test_http_server_serves_and_accepts_input(): + # Integration smoke test against a real ephemeral-port server. + ui = makeWebUI(start_server=True, port=0) + try: + host, port = ui.address + base = "http://127.0.0.1:%d" % port + + page = urllib.request.urlopen(base + "/", timeout=2).read().decode("utf-8") + assert "FishE" in page + + state = json.loads(urllib.request.urlopen(base + "/state", timeout=2).read()) + assert "version" in state and "screen" in state + + # Present an options screen, then submit a choice over HTTP. + thread, box = runInThread(lambda: ui.showOptions("Pick", ["A", "B"])) + waitForScreen(ui, "options") + request = urllib.request.Request( + base + "/input", + data=json.dumps({"value": "1"}).encode("utf-8"), + method="POST", + ) + urllib.request.urlopen(request, timeout=2).read() + thread.join(timeout=2) + assert box["result"] == "1" + finally: + ui.cleanup()