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()