diff --git a/.gitignore b/.gitignore index 6974b45..f4be8cb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ __pycache__/ /.vscode /logs /env-bridzik-dev +/.venv *.png +*.idea diff --git a/Dockerfile b/Dockerfile index 64cea64..11158ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,16 @@ -FROM python:3.8-slim-buster +FROM python:3.14-slim WORKDIR /app -RUN python3 -m venv venv -RUN . venv/bin/activate -COPY requirements.txt requirements.txt -RUN pip3 install -r requirements.txt -# Debug image reusing the base -# Install dev dependencies for debugging -RUN pip install debugpy -# Keeps Python from generating .pyc files in the container -ENV PYTHONDONTWRITEBYTECODE 1 -# Turns off buffering for easier container logging -ENV PYTHONUNBUFFERED 1 +# Keeps Python from generating .pyc files and turns off output buffering. +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt COPY . . -ENV FLASK_ENV=development -# ENTRYPOINT ["python3"] -CMD ["python3", "-m", "app"] -# For start Jakub version run script -# CMD ["python3", "-m", "flask", "run", "-h", "0.0.0.0", "-p", "5000" ] -# , "-m", "debugpy", "--listen", "0.0.0.0:5678", "-m", "app", "--wait-for-client", "--multiprocess", \ No newline at end of file +EXPOSE 5000 +# Serve the ASGI Socket.IO app with uvicorn. +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "5000"] diff --git a/api/__init__.py b/api/__init__.py index 3c345d4..5de3d9f 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,76 +1,328 @@ import json -import string -from flask_socketio import SocketIO, emit, join_room -from bridzik import Bridzik, BridzikException, Card, Card_colors, Card_values -from flask import Flask +import os +import uuid +from json import JSONEncoder -async_mode = None -app = Flask(__name__) -app.debug = True +import socketio -app.config['SECRET_KEY'] = 'secret!' -socket_ = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet', logger=True, engineio_logger=True) - -bridzikInstance = Bridzik() - -players = [] +from bridzik import Bridzik, BridzikException, Card -@socket_.on('register_player') -def register_player(name: string): - if(len(players) < 4): - players.append(name) - player_id = len(players) - 1 - join_room(player_id) - get_players_status() - sendPlayersNames() - emit('register_player',{ 'id_player': player_id}, room=player_id) - - else: - send_error('Prekroceny pocet hracov') - -@socket_.on('reconnect_to_game') -def reconnect_to_game(): - get_players_status() +def _env_bool(name: str, default: bool) -> bool: + val = os.environ.get(name) + if val is None: + return default + return val.lower() in ("1", "true", "yes", "on") -@socket_.on('game_status') -def get_players_status(): - for player_id in range(len(players)): - emit('game_status', {'data': 'game_status', 'playerName': players[player_id], - 'playerNumber': player_id, 'status': json.loads(json.dumps(bridzikInstance.get_status(player_id), cls=Card.JSONEncoder))}, room=player_id) +# --- configuration (env-driven, dev-friendly defaults) -------------------- +_cors = os.environ.get("CORS_ALLOWED_ORIGINS", "*") +CORS_ALLOWED_ORIGINS = "*" if _cors == "*" else [o.strip() for o in _cors.split(",")] +SIO_LOGGER = _env_bool("SOCKETIO_LOGGER", False) -@socket_.on('add_quess') -def add_quess(message): - print(message) - app.logger.info('Message from user', message) +LOBBY = "lobby" # room every connection joins to receive the public game list + +sio = socketio.AsyncServer( + async_mode="asgi", + cors_allowed_origins=CORS_ALLOWED_ORIGINS, + logger=SIO_LOGGER, + engineio_logger=SIO_LOGGER, +) + + +async def _health_app(scope, receive, send): + """Minimal ASGI handler for non-socket.io HTTP routes (liveness checks).""" + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + if scope["type"] == "http": + ok = scope.get("path", "") in ("/health", "/healthz") + status = 200 if ok else 404 + body = b"ok" if ok else b"not found" + await send({"type": "http.response.start", "status": status, + "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": body}) + + +# Run with: uvicorn api:app --host 0.0.0.0 --port 5000 +app = socketio.ASGIApp(sio, other_asgi_app=_health_app) + +# --- in-memory state ------------------------------------------------------ +# Single-process only. For multi-worker deployments this moves to Redis +# (socketio.AsyncRedisManager) plus a shared game store. +games: dict[str, "Game"] = {} +# Maps a live connection (sid) to the seat it controls: {"gid": str, "order": int}. +# This is the source of truth for "who is acting" — never trust a client-supplied +# player number. +sessions: dict[str, dict] = {} + + +class Game: + def __init__(self, gid: str, name: str): + self.gid = gid + self.name = name + self.players: list["Player"] = [] + self.started = False + self.bridzik_core: Bridzik | None = None + + def start(self): + self.bridzik_core = Bridzik() + self.started = True + + def player_by_token(self, token: str) -> "Player | None": + return next((p for p in self.players if p.token == token), None) + + def player_by_sid(self, sid: str) -> "Player | None": + return next((p for p in self.players if p.sid == sid), None) + + +class Player: + def __init__(self, sid: str, name: str, order: int): + self.sid = sid + self.name = name + self.order = order + self.token = str(uuid.uuid4()) # secret token used for secure reconnect + self.connected = True + + +class CardStatusEncoder(JSONEncoder): + """Serializes the engine status, which may contain Card objects.""" + + def default(self, obj): + if isinstance(obj, Card): + return {"color": obj.color.name, "value": obj.value.name} + return JSONEncoder.default(self, obj) + + +def public_games() -> list: + """Public lobby view — no sids, no reconnect tokens.""" + return [ + { + "gid": g.gid, + "name": g.name, + "started": g.started, + "players": [ + {"order": p.order, "name": p.name, "connected": p.connected} + for p in g.players + ], + } + for g in games.values() + ] + + +# --- emit helpers --------------------------------------------------------- + +async def broadcast_lobby(): + await sio.emit("get_games", {"games": public_games()}, room=LOBBY) + + +async def send_game_status(gid: str): + game = games[gid] + core = game.bridzik_core + await sio.emit( + "game_status", + { + "gid": gid, + "completed": core.is_completed(), + "status": json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder)), + }, + room=gid, + ) + + +async def send_player_cards(gid: str, order: int, to: str): + core = games[gid].bridzik_core + await sio.emit( + "player_cards", + {"cards": json.loads(json.dumps(core.get_player_cards(int(order)), cls=Card.JSONEncoder))}, + to=to, + ) + + +async def send_error(sid: str, message: str): + await sio.emit("error", {"error": message}, to=sid) + + +def _active_game(sid: str) -> "tuple[Game, dict] | None": + """Resolve the started game and seat for a connection, or None.""" + sess = sessions.get(sid) + if sess is None: + return None + game = games.get(sess["gid"]) + if game is None or not game.started: + return None + return game, sess + + +# --- connection lifecycle ------------------------------------------------- + +@sio.event +async def connect(sid, environ, auth=None): + await sio.enter_room(sid, LOBBY) + await sio.emit("get_games", {"games": public_games()}, to=sid) + + +@sio.event +async def disconnect(sid): + sessions.pop(sid, None) + game = next((g for g in games.values() if g.player_by_sid(sid)), None) + if game is not None: + player = game.player_by_sid(sid) + player.connected = False + if not any(p.connected for p in game.players): + # Everybody left — drop the game so it can't leak memory forever. + del games[game.gid] + else: + await sio.emit( + "player_connection", + {"order": player.order, "connected": False}, + room=game.gid, + ) + await broadcast_lobby() + + +# --- lobby ---------------------------------------------------------------- + +@sio.on("create_game") +async def create_game(sid, name): + gid = str(uuid.uuid4()) + games[gid] = Game(gid, name) + await sio.emit("create_game", {"gid": gid}, to=sid) + await broadcast_lobby() + + +@sio.on("get_games") +async def get_games(sid, *args): + await sio.emit("get_games", {"games": public_games()}, to=sid) + + +@sio.on("register_player") +async def register_player(sid, gid, player_name): + if sid in sessions: + return await send_error(sid, "Uz ste v hre.") + game = games.get(gid) + if game is None: + return await send_error(sid, "Hra neexistuje.") + if game.started: + return await send_error(sid, "Hra uz zacala.") + if len(game.players) >= 4: + return await send_error(sid, "Prekroceny pocet hracov.") + + order = len(game.players) + player = Player(sid, player_name, order) + game.players.append(player) + sessions[sid] = {"gid": gid, "order": order} + await sio.enter_room(sid, gid) + # The token is private to this player and required for a secure reconnect. + await sio.emit( + "register_player", + {"player": {"order": order, "name": player_name}, "token": player.token}, + to=sid, + ) + await broadcast_lobby() + + +@sio.on("start_game") +async def start_game(sid, gid): + game = games.get(gid) + if game is None: + return await send_error(sid, "Hra neexistuje.") + if game.started: + return await send_error(sid, "Hra uz zacala.") + if len(game.players) != 4: + return await send_error(sid, "Nedostatocny pocet hracov.") + + game.start() + await broadcast_lobby() + await send_game_status(gid) + for player in game.players: + await send_player_cards(gid, player.order, player.sid) + + +@sio.on("reconnect_to_game") +async def reconnect_to_game(sid, gid, token): + game = games.get(gid) + if game is None: + return await send_error(sid, "Hra neexistuje.") + player = game.player_by_token(token) + if player is None: + return await send_error(sid, "Neplatny token pre pripojenie.") + + player.sid = sid + player.connected = True + sessions[sid] = {"gid": gid, "order": player.order} + await sio.enter_room(sid, gid) + await sio.emit( + "register_player", + {"player": {"order": player.order, "name": player.name}, "token": player.token}, + to=sid, + ) + if game.started: + await send_game_status(gid) + await send_player_cards(gid, player.order, sid) + await sio.emit("player_connection", {"order": player.order, "connected": True}, room=gid) + await broadcast_lobby() + + +# --- in-game actions (seat derived from the connection, never the client) - + +@sio.on("game_status") +async def game_status(sid, *args): + resolved = _active_game(sid) + if resolved is None: + return await send_error(sid, "Nie ste v rozohratej hre.") + game, _ = resolved + await send_game_status(game.gid) + + +@sio.on("player_cards") +async def player_cards(sid, *args): + resolved = _active_game(sid) + if resolved is None: + return await send_error(sid, "Nie ste v rozohratej hre.") + game, sess = resolved + await send_player_cards(game.gid, sess["order"], sid) + + +@sio.on("add_guess") +async def add_guess(sid, guess): + resolved = _active_game(sid) + if resolved is None: + return await send_error(sid, "Nie ste v rozohratej hre.") + game, sess = resolved try: - bridzikInstance.add_player_guess(int(message['playerNumber']), int(message['quess'])) - get_players_status() - except BridzikException: - send_error('Nie je možné zadať tip.') - -@socket_.on('play_card') -def play_card(message): - color, value = message['card'].split('_') + value = int(guess) + except (TypeError, ValueError): + return await send_error(sid, "Neplatny tip.") try: - card = Card(Card_colors[color], Card_values[value]) - except KeyError: - send_error('Chyba. Opakuj pokus znovu.') + game.bridzik_core.add_player_guess(sess["order"], value) + except BridzikException as exc: + return await send_error(sid, str(exc)) + await send_game_status(game.gid) + + +@sio.on("play_card") +async def play_card(sid, card_key): + resolved = _active_game(sid) + if resolved is None: + return await send_error(sid, "Nie ste v rozohratej hre.") + game, sess = resolved + core = game.bridzik_core + hand = core.get_player_cards(sess["order"]) try: - print(message['playerNumber'], card) - bridzikInstance.play_card(message['playerNumber'], card) - get_players_status() - except BridzikException: - get_players_status() - send_error('Nie je možné zahrať kartu.') - -@socket_.on('all_names') -def sendPlayersNames(): - emit('all_names', {'data': 'players_names', 'players': players}, broadcast=True) - -@socket_.on('error') -def send_error(message: string): - emit('error',{'error': message}) - -socket_.run(app, debug=True, host="0.0.0.0", port="5000" ) + key = int(card_key) + except (TypeError, ValueError): + return await send_error(sid, "Neplatna karta.") + if key not in hand: + return await send_error(sid, "Neplatna karta.") + try: + core.play_card(sess["order"], hand[key]) + except BridzikException as exc: + return await send_error(sid, str(exc)) + await send_game_status(game.gid) + await send_player_cards(game.gid, sess["order"], sid) diff --git a/app.py b/app.py index 989e1c6..108e186 100644 --- a/app.py +++ b/app.py @@ -1 +1,6 @@ -from api import app \ No newline at end of file +import uvicorn + +if __name__ == "__main__": + # Local dev entrypoint: `python -m app` + # (production / Docker runs `uvicorn api:app` directly). + uvicorn.run("api:app", host="0.0.0.0", port=5000, reload=True) diff --git a/docker-compose.yaml b/docker-compose.yaml index f677264..8a74b3f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,7 @@ -version: "3.0" services: api: build: . ports: - "5000:5000" - - "5678:5678" volumes: - ./:/app \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9e2299f..7d6fbbf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,8 @@ -Flask==1.0.2 -Flask-Login==0.4.1 -Flask-Session==0.3.1 -Flask_SocketIO==4.0.0 -itsdangerous==1.1.0 -Jinja2==2.10 -MarkupSafe==1.1.0 -python-engineio==3.14.1 -python-socketio==4.4.0 -six==1.11.0 -Werkzeug==0.14.1 -Flask-Cors==3.0.7 -eventlet==0.19.0 +# Realtime Socket.IO server running on ASGI (uvicorn). +python-socketio>=5.11 +python-engineio>=4.9 +uvicorn>=0.30 + +# NOTE: the legacy Flask/Jinja HTTP UI (api/routes.py, api/forms.py, +# api/templates/) is dormant and its dependencies (Flask, Flask-WTF, etc.) +# were removed during the ASGI migration. Re-add them only if that UI is revived. diff --git a/start.py b/start.py deleted file mode 100644 index 989e1c6..0000000 --- a/start.py +++ /dev/null @@ -1 +0,0 @@ -from api import app \ No newline at end of file diff --git a/test_socket.py b/test_socket.py new file mode 100644 index 0000000..42bf78e --- /dev/null +++ b/test_socket.py @@ -0,0 +1,186 @@ +import unittest + +import api + + +class SocketLayerCase(unittest.IsolatedAsyncioTestCase): + """Drives the socket handlers directly with a fake emit, asserting the + identity-binding, privacy, lifecycle and error-handling guarantees.""" + + def setUp(self): + api.games.clear() + api.sessions.clear() + self.emits = [] + + async def fake_emit(event, data=None, room=None, to=None, **kw): + self.emits.append({"event": event, "data": data, "room": room, "to": to}) + + async def noop(*a, **kw): + pass + + # Patch the network-facing primitives; logic under test is untouched. + api.sio.emit = fake_emit + api.sio.enter_room = noop + api.sio.leave_room = noop + + # --- helpers ---------------------------------------------------------- + + def last(self, event): + return next(e for e in reversed(self.emits) if e["event"] == event) + + def events(self, event): + return [e for e in self.emits if e["event"] == event] + + async def make_game(self): + await api.create_game("host", "Game") + return list(api.games.keys())[0] + + async def make_started_game(self): + gid = await self.make_game() + for i in range(4): + await api.register_player(f"s{i}", gid, f"P{i}") + await api.start_game("s0", gid) + return gid + + # --- creation & lobby ------------------------------------------------- + + async def test_create_game_mints_uuid(self): + gid = await self.make_game() + self.assertNotEqual(gid, "a") + self.assertEqual(len(gid), 36) + self.assertEqual(self.last("create_game")["data"]["gid"], gid) + + async def test_lobby_payload_hides_sids_and_tokens(self): + gid = await self.make_game() + await api.register_player("s0", gid, "P0") + lobby = self.last("get_games")["data"]["games"] + blob = repr(lobby) + self.assertNotIn("sid", blob) + self.assertNotIn("token", blob) + self.assertEqual(lobby[0]["players"][0], {"order": 0, "name": "P0", "connected": True}) + + async def test_register_returns_private_token_to_caller_only(self): + gid = await self.make_game() + await api.register_player("s0", gid, "P0") + reg = self.last("register_player") + self.assertEqual(reg["to"], "s0") + self.assertIn("token", reg["data"]) + self.assertEqual(reg["data"]["player"], {"order": 0, "name": "P0"}) + + async def test_register_rejects_unknown_game_fifth_and_started(self): + await api.register_player("x", "nope", "P") # unknown gid + self.assertEqual(self.last("error")["data"]["error"], "Hra neexistuje.") + + gid = await self.make_game() + for i in range(4): + await api.register_player(f"s{i}", gid, f"P{i}") + await api.register_player("s4", gid, "P4") # fifth + self.assertEqual(self.last("error")["data"]["error"], "Prekroceny pocet hracov.") + + await api.start_game("s0", gid) + await api.register_player("s5", gid, "late") # already started + self.assertEqual(self.last("error")["data"]["error"], "Hra uz zacala.") + + # --- start lifecycle -------------------------------------------------- + + async def test_start_deals_and_emits_private_hands(self): + gid = await self.make_started_game() + self.assertTrue(api.games[gid].started) + self.assertEqual(self.last("game_status")["room"], gid) + hands = self.events("player_cards") + self.assertEqual(sorted(h["to"] for h in hands), ["s0", "s1", "s2", "s3"]) + + async def test_start_game_twice_is_rejected_and_does_not_reset(self): + gid = await self.make_started_game() + core_before = api.games[gid].bridzik_core + await api.start_game("s0", gid) + self.assertEqual(self.last("error")["data"]["error"], "Hra uz zacala.") + self.assertIs(api.games[gid].bridzik_core, core_before) + + # --- identity binding (the anti-cheat guarantee) ---------------------- + + async def test_player_cards_serves_only_your_own_seat(self): + gid = await self.make_started_game() + # The handler takes no player argument: a connection can only ever + # request the hand bound to its own session. + self.emits.clear() + await api.player_cards("s2") + sent = self.last("player_cards") + self.assertEqual(sent["to"], "s2") + expected = api.games[gid].bridzik_core.get_player_cards(2) + self.assertEqual(len(sent["data"]["cards"]), len(expected)) + + async def test_action_without_session_is_rejected(self): + await self.make_started_game() + await api.add_guess("stranger", 1) + self.assertEqual(self.last("error")["data"]["error"], "Nie ste v rozohratej hre.") + + # --- full play flow --------------------------------------------------- + + async def test_guess_and_play_flow(self): + gid = await self.make_started_game() + core = api.games[gid].bridzik_core + # Round 0 leads with seat 0; guess 0 all round (sum 0 != 8 tricks). + for i in range(4): + await api.add_guess(f"s{i}", 0) + rnd = core.series[-1].get_last_round() + self.assertTrue(rnd.is_guessing_completed()) + leader = rnd.get_active_player() + + before = len(core.get_player_cards(leader)) + self.emits.clear() + await api.play_card(f"s{leader}", 0) + after = len(core.get_player_cards(leader)) + self.assertEqual(after, before - 1) + status = self.last("game_status")["data"] + self.assertIn("completed", status) + self.assertIn("active_stash", status["status"]) + + async def test_bad_input_does_not_crash(self): + await self.make_started_game() + await api.add_guess("s0", "not-a-number") + self.assertEqual(self.last("error")["data"]["error"], "Neplatny tip.") + await api.play_card("s0", "xx") + self.assertEqual(self.last("error")["data"]["error"], "Neplatna karta.") + + async def test_engine_error_message_is_forwarded(self): + gid = await self.make_started_game() + # Seat 0 leads guessing; seat 1 acting out of turn -> engine rejects, + # and the real (Slovak) reason is forwarded, not a generic message. + await api.add_guess("s1", 1) + self.assertEqual(self.last("error")["data"]["error"], "Hrac nie je na tahu") + + # --- reconnect -------------------------------------------------------- + + async def test_secure_reconnect_with_token_rebinds_seat(self): + gid = await self.make_game() + await api.register_player("s0", gid, "P0") + token = self.last("register_player")["data"]["token"] + for i in range(1, 4): + await api.register_player(f"s{i}", gid, f"P{i}") + await api.start_game("s0", gid) + + await api.reconnect_to_game("s0-new", gid, token) + self.assertEqual(api.games[gid].player_by_token(token).sid, "s0-new") + self.assertEqual(api.sessions["s0-new"], {"gid": gid, "order": 0}) + + self.emits.clear() + await api.reconnect_to_game("zzz", gid, "wrong-token") + self.assertEqual(self.last("error")["data"]["error"], "Neplatny token pre pripojenie.") + + # --- disconnect & cleanup -------------------------------------------- + + async def test_disconnect_marks_player_then_drops_empty_game(self): + gid = await self.make_started_game() + await api.disconnect("s0") + self.assertFalse(api.games[gid].player_by_sid("s0").connected) + self.assertNotIn("s0", api.sessions) + self.assertIn(gid, api.games) # others still connected + + for i in range(1, 4): + await api.disconnect(f"s{i}") + self.assertNotIn(gid, api.games) # everyone gone -> game removed + + +if __name__ == "__main__": + unittest.main()