diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e9db62e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.12-slim + +# No third-party deps: the game (and its web front-end) is stdlib-only — +# pygame is only imported lazily by the pygame front-end, which this image +# never selects, so it's deliberately not installed here. Matches the +# gateway-dashboard/fleet-dashboard services' "no wheel supply chain to keep +# patched" approach. +WORKDIR /app +COPY src/ ./src/ +COPY examples/ ./examples/ + +# Create the data/ directory (see saveFileManager.py's default +# data_directory="data", resolved relative to the process cwd -> /app/data +# here) and hand it to a non-root user before switching to it, so a named +# volume mounted over /app/data on first run inherits writable ownership. +RUN useradd --system --no-create-home fishe \ + && mkdir -p /app/data \ + && chown -R fishe:fishe /app/data +USER fishe + +# WebUserInterface itself defaults to 127.0.0.1:8000 (unreachable from +# outside its own network namespace); UserInterfaceFactory's WEB branch reads +# these to override the bind address/port. 0.0.0.0 is required for Traefik +# (a different container on the same bridge network) to reach this server. +ENV FISHE_WEB_HOST=0.0.0.0 +ENV FISHE_WEB_PORT=8000 +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8000 +CMD ["python3", "examples/web_app.py"] diff --git a/examples/web_app.py b/examples/web_app.py index ad6fa72..4f385b6 100644 --- a/examples/web_app.py +++ b/examples/web_app.py @@ -6,7 +6,10 @@ python3 examples/web_app.py The whole game (save-file manager, fishing, shop, bank, tavern, dialogue) then -plays in the browser. +plays in the browser. The server binds 127.0.0.1:8000 by default; set +FISHE_WEB_HOST/FISHE_WEB_PORT (read by UserInterfaceFactory's WEB branch) to +change that — e.g. FISHE_WEB_HOST=0.0.0.0 so it's reachable from outside its +own host, such as from inside a container. """ import os @@ -20,7 +23,9 @@ def main(): - print("FishE web app is starting at http://127.0.0.1:8000") + host = os.environ.get("FISHE_WEB_HOST", "127.0.0.1") + port = os.environ.get("FISHE_WEB_PORT", "8000") + print(f"FishE web app is starting at http://{host}:{port}") 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. diff --git a/src/ui/userInterfaceFactory.py b/src/ui/userInterfaceFactory.py index 4fd72ea..fe57543 100644 --- a/src/ui/userInterfaceFactory.py +++ b/src/ui/userInterfaceFactory.py @@ -1,3 +1,5 @@ +import os + from ui.enum.uiType import UIType from ui.consoleUserInterface import ConsoleUserInterface from prompt.prompt import Prompt @@ -33,6 +35,21 @@ def create_user_interface( # Imported lazily so other modes don't start the HTTP machinery. from ui.webUserInterface import WebUserInterface - return WebUserInterface(currentPrompt, timeService, player) + # WebUserInterface itself defaults to 127.0.0.1:8000 (unreachable + # from outside its own host/container). Let FISHE_WEB_HOST/ + # FISHE_WEB_PORT override that — e.g. FISHE_WEB_HOST=0.0.0.0 so a + # container's port mapping/reverse proxy can actually reach it — + # while leaving the default unchanged for anyone not setting them. + host = os.environ.get("FISHE_WEB_HOST", "127.0.0.1") + port_str = os.environ.get("FISHE_WEB_PORT", "8000") + try: + port = int(port_str) + except ValueError: + raise ValueError( + f"FISHE_WEB_PORT must be an integer, got: {port_str!r}" + ) + return WebUserInterface( + currentPrompt, timeService, player, host=host, port=port + ) else: raise ValueError(f"Unsupported UI type: {ui_type}") diff --git a/tests/ui/test_userInterfaceFactory.py b/tests/ui/test_userInterfaceFactory.py index 9ca14ad..56ca8d0 100644 --- a/tests/ui/test_userInterfaceFactory.py +++ b/tests/ui/test_userInterfaceFactory.py @@ -3,12 +3,13 @@ import os # Add src to the path so imports work correctly -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) from ui.enum.uiType import UIType from ui.userInterfaceFactory import UserInterfaceFactory from ui.consoleUserInterface import ConsoleUserInterface from ui.pygameUserInterface import PygameUserInterface +from ui.webUserInterface import WebUserInterface from player.player import Player from prompt.prompt import Prompt from stats.stats import Stats @@ -19,6 +20,7 @@ def test_ui_type_enum(): """Test UI type enum values""" assert UIType.CONSOLE.value == "console" assert UIType.PYGAME.value == "pygame" + assert UIType.WEB.value == "web" def test_factory_create_console_ui(): @@ -28,12 +30,12 @@ def test_factory_create_console_ui(): player = Player() stats = Stats() timeService = TimeService(player, stats) - + # call ui = UserInterfaceFactory.create_user_interface( UIType.CONSOLE, currentPrompt, timeService, player ) - + # check assert isinstance(ui, ConsoleUserInterface) assert ui.currentPrompt == currentPrompt @@ -48,30 +50,92 @@ def test_factory_create_pygame_ui(): player = Player() stats = Stats() timeService = TimeService(player, stats) - + # call ui = UserInterfaceFactory.create_user_interface( UIType.PYGAME, currentPrompt, timeService, player ) - + # check assert isinstance(ui, PygameUserInterface) assert ui.currentPrompt == currentPrompt assert ui.timeService == timeService assert ui.player == player - + # cleanup ui.cleanup() +def test_factory_create_web_ui_default_bind(monkeypatch): + """Test factory creates a web UI bound to 127.0.0.1 by default when + FISHE_WEB_HOST is not set. FISHE_WEB_PORT is overridden to 0 (ephemeral) + to avoid conflicts with any real listener on port 8000; only the host + default is asserted here.""" + monkeypatch.delenv("FISHE_WEB_HOST", raising=False) + # Use an ephemeral port (0) so this test never fights a real listener on + # 8000, but assert the *host* the factory chose defaults correctly. + monkeypatch.setenv("FISHE_WEB_PORT", "0") + currentPrompt = Prompt("What would you like to do?") + player = Player() + stats = Stats() + timeService = TimeService(player, stats) + + ui = UserInterfaceFactory.create_user_interface( + UIType.WEB, currentPrompt, timeService, player + ) + try: + assert isinstance(ui, WebUserInterface) + assert ui.address[0] == "127.0.0.1" + assert ui.currentPrompt == currentPrompt + assert ui.timeService == timeService + assert ui.player == player + finally: + ui.cleanup() + + +def test_factory_create_web_ui_honors_env_overrides(monkeypatch): + """Test FISHE_WEB_HOST/FISHE_WEB_PORT override the web UI's bind address — + the mechanism a container needs to make the server reachable from outside + its own network namespace (e.g. FISHE_WEB_HOST=0.0.0.0).""" + monkeypatch.setenv("FISHE_WEB_HOST", "0.0.0.0") + monkeypatch.setenv("FISHE_WEB_PORT", "0") + currentPrompt = Prompt("What would you like to do?") + player = Player() + stats = Stats() + timeService = TimeService(player, stats) + + ui = UserInterfaceFactory.create_user_interface( + UIType.WEB, currentPrompt, timeService, player + ) + try: + assert ui.address[0] == "0.0.0.0" + finally: + ui.cleanup() + + +def test_factory_create_web_ui_invalid_port(monkeypatch): + """Test factory raises a descriptive ValueError when FISHE_WEB_PORT is + not a valid integer, rather than a bare conversion error.""" + monkeypatch.setenv("FISHE_WEB_PORT", "not-a-number") + currentPrompt = Prompt("What would you like to do?") + player = Player() + stats = Stats() + timeService = TimeService(player, stats) + + with pytest.raises(ValueError, match="FISHE_WEB_PORT"): + UserInterfaceFactory.create_user_interface( + UIType.WEB, currentPrompt, timeService, player + ) + + def test_factory_invalid_ui_type(): """Test factory raises error for invalid UI type""" currentPrompt = Prompt("What would you like to do?") player = Player() stats = Stats() timeService = TimeService(player, stats) - + with pytest.raises(ValueError): UserInterfaceFactory.create_user_interface( "invalid", currentPrompt, timeService, player - ) \ No newline at end of file + )