| """Fixed lobby board: sandbox, 1v1, 4v4. Seat claims are server-authoritative.""" |
|
|
| from __future__ import annotations |
|
|
| import string |
| import time |
| from typing import Any, Optional |
|
|
| |
| _LETTERS = string.ascii_uppercase |
| SANDBOX_IDS = [f"0{ch}" for ch in _LETTERS[:10]] |
| DUEL_IDS = [f"1{ch}" for ch in _LETTERS[:8]] |
| SQUAD_IDS = [f"4{ch}" for ch in _LETTERS[:8]] |
|
|
| SANDBOX_CAP = 1 |
| DUEL_CAP = 2 |
| SQUAD_CAP = 8 |
|
|
| |
| SQUAD_IDLE_START_SEC = 60.0 |
| SEAT_STALE_SEC = 8.0 |
| |
| STARTING_TIMEOUT_SEC = 40.0 |
| LIVE_STALE_SEC = 40.0 |
|
|
|
|
| def _now() -> float: |
| return time.time() |
|
|
|
|
| def _empty_sandbox(lobby_id: str) -> dict[str, Any]: |
| return { |
| "id": lobby_id, |
| "mode": "sandbox", |
| "seats": {f"S-{i}": None for i in range(1, SANDBOX_CAP + 1)}, |
| "status": "open", |
| "last_change": _now(), |
| "match_id": None, |
| } |
|
|
|
|
| def _empty_duel(lobby_id: str) -> dict[str, Any]: |
| return { |
| "id": lobby_id, |
| "mode": "1v1", |
| "seats": {"X-1": None, "Y-1": None}, |
| "status": "open", |
| "last_change": _now(), |
| "match_id": None, |
| } |
|
|
|
|
| def _empty_squad(lobby_id: str) -> dict[str, Any]: |
| seats: dict[str, Optional[str]] = {} |
| for side in ("X", "Y"): |
| for i in range(1, 5): |
| seats[f"{side}-{i}"] = None |
| return { |
| "id": lobby_id, |
| "mode": "4v4", |
| "seats": seats, |
| "status": "open", |
| "last_change": _now(), |
| "match_id": None, |
| } |
|
|
|
|
| class LobbyBoard: |
| def __init__(self) -> None: |
| self.lobbies: dict[str, dict[str, Any]] = {} |
| for lid in SANDBOX_IDS: |
| self.lobbies[lid] = _empty_sandbox(lid) |
| for lid in DUEL_IDS: |
| self.lobbies[lid] = _empty_duel(lid) |
| for lid in SQUAD_IDS: |
| self.lobbies[lid] = _empty_squad(lid) |
| |
| self.by_user: dict[str, tuple[str, str]] = {} |
|
|
| def snapshot(self) -> dict[str, Any]: |
| self._tick_stale_and_start() |
| return { |
| "sandbox": [self._public(self.lobbies[lid]) for lid in SANDBOX_IDS], |
| "duel": [self._public(self.lobbies[lid]) for lid in DUEL_IDS], |
| "squad": [self._public(self.lobbies[lid]) for lid in SQUAD_IDS], |
| "serverTime": _now(), |
| } |
|
|
| def _public(self, lobby: dict[str, Any]) -> dict[str, Any]: |
| return { |
| "id": lobby["id"], |
| "mode": lobby["mode"], |
| "seats": dict(lobby["seats"]), |
| "status": lobby["status"], |
| "lastChange": lobby["last_change"], |
| "matchId": lobby["match_id"], |
| "filled": sum(1 for v in lobby["seats"].values() if v), |
| "capacity": len(lobby["seats"]), |
| } |
|
|
| def _clear_user(self, username: str) -> None: |
| prev = self.by_user.pop(username, None) |
| if not prev: |
| return |
| lid, seat = prev |
| lobby = self.lobbies.get(lid) |
| if lobby and lobby["seats"].get(seat) == username: |
| lobby["seats"][seat] = None |
| lobby.get("_avatars", {}).pop(username, None) |
| lobby.get("_hb", {}).pop(username, None) |
| lobby["last_change"] = _now() |
| |
| |
| if lobby["status"] in ("live", "starting") and not any(lobby["seats"].values()): |
| self._reset_lobby(lobby) |
|
|
| def _reset_lobby(self, lobby: dict[str, Any]) -> None: |
| mode = lobby["mode"] |
| lid = lobby["id"] |
| if mode == "sandbox": |
| self.lobbies[lid] = _empty_sandbox(lid) |
| elif mode == "1v1": |
| self.lobbies[lid] = _empty_duel(lid) |
| else: |
| self.lobbies[lid] = _empty_squad(lid) |
|
|
| def leave(self, username: str) -> dict[str, Any]: |
| self._clear_user(username) |
| return {"ok": True} |
|
|
| def claim( |
| self, |
| mode: str, |
| lobby_id: str, |
| username: str, |
| seat: str, |
| avatar_url: str | None = None, |
| ) -> dict[str, Any]: |
| username = (username or "").strip() |
| if not username: |
| return {"ok": False, "error": "username required"} |
| lobby = self.lobbies.get(lobby_id) |
| if not lobby or lobby["mode"] != mode: |
| return {"ok": False, "error": "lobby not found"} |
| if seat not in lobby["seats"]: |
| return {"ok": False, "error": "invalid seat"} |
| if lobby["status"] in ("live", "starting"): |
| return {"ok": False, "error": "match already starting"} |
| if lobby["seats"][seat] is not None: |
| return {"ok": False, "error": "seat taken"} |
|
|
| |
| self._clear_user(username) |
| lobby["seats"][seat] = username |
| lobby["last_change"] = _now() |
| lobby.setdefault("_hb", {})[username] = _now() |
| avatars = lobby.setdefault("_avatars", {}) |
| if avatar_url and str(avatar_url).strip(): |
| avatars[username] = str(avatar_url).strip() |
| elif username not in avatars: |
| avatars[username] = f"https://huggingface.co/avatars/{username}" |
| self.by_user[username] = (lobby_id, seat) |
| started = self._maybe_start(lobby) |
| return { |
| "ok": True, |
| "lobby": self._public(lobby), |
| "seat": seat, |
| "started": started, |
| "matchId": lobby["match_id"], |
| } |
|
|
| def heartbeat(self, username: str) -> None: |
| prev = self.by_user.get(username) |
| if not prev: |
| return |
| lid, seat = prev |
| lobby = self.lobbies.get(lid) |
| if not lobby: |
| return |
| |
| lobby.setdefault("_hb", {})[username] = _now() |
|
|
| def _tick_stale_and_start(self) -> None: |
| now = _now() |
| stale_users: list[str] = [] |
| for username, (lid, seat) in list(self.by_user.items()): |
| lobby = self.lobbies.get(lid) |
| if not lobby: |
| stale_users.append(username) |
| continue |
| hb = lobby.get("_hb", {}).get(username, lobby["last_change"]) |
| status = lobby["status"] |
| if status == "open" and now - hb > SEAT_STALE_SEC: |
| stale_users.append(username) |
| elif status == "live" and now - hb > LIVE_STALE_SEC: |
| |
| stale_users.append(username) |
| elif status == "starting" and now - lobby["last_change"] > STARTING_TIMEOUT_SEC: |
| stale_users.append(username) |
| for u in stale_users: |
| self._clear_user(u) |
|
|
| for lobby in list(self.lobbies.values()): |
| if lobby["status"] == "starting" and now - lobby["last_change"] > STARTING_TIMEOUT_SEC: |
| self._reset_lobby(lobby) |
| elif lobby["status"] == "live": |
| seated = [u for u in lobby["seats"].values() if u] |
| if seated and all( |
| now - lobby.get("_hb", {}).get(u, 0) > LIVE_STALE_SEC for u in seated |
| ): |
| self._reset_lobby(lobby) |
| elif lobby["status"] == "open": |
| self._maybe_start(lobby) |
|
|
| def _maybe_start(self, lobby: dict[str, Any]) -> bool: |
| if lobby["status"] != "open": |
| return False |
| mode = lobby["mode"] |
| seats = lobby["seats"] |
| filled = [s for s, u in seats.items() if u] |
|
|
| if mode == "sandbox": |
| |
| if filled: |
| return self._mark_starting(lobby) |
| return False |
|
|
| if mode == "1v1": |
| if seats.get("X-1") and seats.get("Y-1"): |
| return self._mark_starting(lobby) |
| return False |
|
|
| |
| team_x = [s for s, u in seats.items() if u and s.startswith("X-")] |
| team_y = [s for s, u in seats.items() if u and s.startswith("Y-")] |
| if len(filled) >= SQUAD_CAP: |
| return self._mark_starting(lobby) |
| if team_x and team_y and (_now() - lobby["last_change"]) >= SQUAD_IDLE_START_SEC: |
| return self._mark_starting(lobby) |
| return False |
|
|
| def _mark_starting(self, lobby: dict[str, Any]) -> bool: |
| import uuid |
|
|
| lobby["status"] = "starting" |
| lobby["match_id"] = str(uuid.uuid4()) |
| lobby["last_change"] = _now() |
| return True |
|
|
| def mark_live(self, lobby_id: str) -> None: |
| lobby = self.lobbies.get(lobby_id) |
| if lobby and lobby["status"] == "starting": |
| lobby["status"] = "live" |
|
|
| def players_in(self, lobby_id: str) -> list[dict[str, str]]: |
| lobby = self.lobbies.get(lobby_id) |
| if not lobby: |
| return [] |
| avatars = lobby.get("_avatars") or {} |
| out = [] |
| for seat, user in lobby["seats"].items(): |
| if user: |
| side = "ffa" |
| if seat.startswith("X-"): |
| side = "X" |
| elif seat.startswith("Y-"): |
| side = "Y" |
| out.append( |
| { |
| "username": user, |
| "seat": seat, |
| "side": side, |
| "avatarUrl": avatars.get(user) |
| or f"https://huggingface.co/avatars/{user}", |
| } |
| ) |
| return out |
|
|
| def get(self, lobby_id: str) -> Optional[dict[str, Any]]: |
| return self.lobbies.get(lobby_id) |
|
|
|
|
| board = LobbyBoard() |
|
|