Migrate Socket.IO server to ASGI, fix bugs, harden, add socket tests
Replace Flask-SocketIO + eventlet with python-socketio AsyncServer on an
ASGI app served by uvicorn (Python 3.14). The server is no longer started
as an import side-effect; `python -m app` runs uvicorn for dev and the
Docker image runs `uvicorn api:app`.
Bug fixes:
- create_game now mints a real uuid gid and returns it to the creator
(was hardcoded 'a').
- play_card resolves the player's hand and plays the selected Card (was
indexing a method and crashing).
Hardening:
- Identity binding: every action derives the seat from the connection
(sid -> {gid, order}); clients no longer pass a player number, closing
the hidden-cards cheat where any client could request any hand.
- Secure token-based reconnect (per-player secret token).
- disconnect handler marks players offline and drops empty games (no
more leaked games), notifying the room via player_connection.
- Guards for unknown gid, double start_game, and bad input; engine
exception messages are forwarded instead of swallowed.
- Lobby payload is public-only (no sids/tokens); game_status carries a
completed flag.
- /health endpoint via other_asgi_app; env-driven CORS and logging.
Infra:
- Dockerfile -> python:3.14-slim, uvicorn CMD, drop dead venv lines.
- requirements.txt -> python-socketio/engineio + uvicorn; drop eventlet,
Flask-SocketIO, Flask-Session.
- docker-compose: drop unused debugpy port and obsolete version key.
- Remove redundant start.py; gitignore /.venv.
Tests: test_socket.py drives the handlers (identity binding, lobby
privacy, reconnect, disconnect cleanup, error handling, play flow).
Full suite: 29 passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,4 +3,6 @@ __pycache__/
|
||||
/.vscode
|
||||
/logs
|
||||
/env-bridzik-dev
|
||||
/.venv
|
||||
*.png
|
||||
*.idea
|
||||
|
||||
+10
-18
@@ -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",
|
||||
EXPOSE 5000
|
||||
# Serve the ASGI Socket.IO app with uvicorn.
|
||||
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||
|
||||
+315
-63
@@ -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)
|
||||
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")
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
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:
|
||||
send_error('Prekroceny pocet hracov')
|
||||
|
||||
@socket_.on('reconnect_to_game')
|
||||
def reconnect_to_game():
|
||||
get_players_status()
|
||||
await sio.emit(
|
||||
"player_connection",
|
||||
{"order": player.order, "connected": False},
|
||||
room=game.gid,
|
||||
)
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@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)
|
||||
# --- lobby ----------------------------------------------------------------
|
||||
|
||||
@socket_.on('add_quess')
|
||||
def add_quess(message):
|
||||
print(message)
|
||||
app.logger.info('Message from user', message)
|
||||
@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)
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
from api import app
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
version: "3.0"
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "5678:5678"
|
||||
volumes:
|
||||
- ./:/app
|
||||
+8
-13
@@ -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.
|
||||
|
||||
+186
@@ -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()
|
||||
Reference in New Issue
Block a user