Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 7 additions & 2 deletions examples/web_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
19 changes: 18 additions & 1 deletion src/ui/userInterfaceFactory.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from ui.enum.uiType import UIType
from ui.consoleUserInterface import ConsoleUserInterface
from prompt.prompt import Prompt
Expand Down Expand Up @@ -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}")
80 changes: 72 additions & 8 deletions tests/ui/test_userInterfaceFactory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
Expand All @@ -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
Expand All @@ -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
)
)