From ba6bf35579647ab6eb810aa2232361d07074c60f Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Fri, 12 Jun 2026 06:19:22 -0700 Subject: [PATCH] Websockets instead of polling --- TODO.md | 6 +---- flake.nix | 1 + frontend/src/pages/Dashboard.svelte | 29 +++++++++++++++++++++-- frontend/vite.config.js | 1 + pyproject.toml | 1 + src/pirats/main.py | 29 ++++++++++++++++++++++- src/pirats/ws.py | 33 ++++++++++++++++++++++++++ tests/test_game.py | 36 +++++++++++++++++++++++++++++ 8 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 src/pirats/ws.py diff --git a/TODO.md b/TODO.md index c7e6fc3..a8f37da 100644 --- a/TODO.md +++ b/TODO.md @@ -1,16 +1,12 @@ ## Major Improvements -- [ ] **Replace polling in the frontend with a websocket connection** I believe the 2s polling interval is an artifact of the previous HTMX-based frontend implementation, and the app might feel more responsive if it were websocket-based or otherwise event driven. - - [ ] **Allow Deep players to roll back game events.** This may require some updates to how game state is handled. Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current architecture at all. - [ ] **Dead Pi-Rat re-roll flow.** I suspect this is badly broken, as it was implemented in a rush by an older model. In particular, other players will need to contribute likes/hates and techniques, which I believe are not currently implemented. This flow should also be used for new players who join after initial character creation, and should take place as a between-scenes phase after Deep Upkeep. ## Missing Features -- [x] **Incomplete admin page.**: The Admin page should include links for players to re-join the game. -- [x] **Less intrusive link copied notification.** When you copy a join link from the lobby page, it pops up an alert() to let you know the link was copied. I'd prefer something more subtle, and more importantly not modal. -- [x] **Rules endpoint** Make a nicely formatted HTML version of the rulebook. It should probably also be linked from a help menu somewhere that's always available, rather than only being surfaced on the splash page of the site (which joining players don't even see). Work from "Rats with Gats beta.pdf" rather than "RULEBOOK.md", which is a low fidelity AI summary of the former. +- [ ] The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing. ## Cosmetic diff --git a/flake.nix b/flake.nix index 7f7341d..12bc94b 100644 --- a/flake.nix +++ b/flake.nix @@ -48,6 +48,7 @@ fastapi sqlmodel uvicorn + websockets python-multipart httpx ]; diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index bfdc10c..ef763a9 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -18,6 +18,10 @@ let state = null; let error = ''; let intervalId; + let ws = null; + let reconnectTimer = null; + let reconnectDelay = 1000; + let destroyed = false; async function fetchState() { try { @@ -29,10 +33,28 @@ } } + // The server pushes a ping over this socket whenever the game changes; + // each ping triggers a refetch of the state blob. + function connectSocket() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + ws = new WebSocket(`${proto}://${location.host}/api/game/${gameId}/ws`); + ws.onopen = () => { + reconnectDelay = 1000; + fetchState(); // catch up on anything missed while disconnected + }; + ws.onmessage = () => fetchState(); + ws.onclose = () => { + if (destroyed) return; + reconnectTimer = setTimeout(connectSocket, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, 10000); + }; + } + onMount(() => { fetchState(); - // Poll every 2 seconds - intervalId = setInterval(fetchState, 2000); + connectSocket(); + // Slow fallback poll in case a push is ever missed + intervalId = setInterval(fetchState, 30000); }); // Surface the creator-only Admin link in the corner menu @@ -43,7 +65,10 @@ ); onDestroy(() => { + destroyed = true; if (intervalId) clearInterval(intervalId); + if (reconnectTimer) clearTimeout(reconnectTimer); + if (ws) ws.close(); extraMenuLinks.set([]); }); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 50f8725..b05c9ae 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -9,6 +9,7 @@ export default defineConfig({ '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true, + ws: true, }, '/rules': { target: 'http://127.0.0.1:8000', diff --git a/pyproject.toml b/pyproject.toml index 883fbdd..c26e2fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "fastapi", "sqlmodel", "uvicorn", + "websockets", "python-multipart", ] diff --git a/src/pirats/main.py b/src/pirats/main.py index ea5ab44..0b39de7 100644 --- a/src/pirats/main.py +++ b/src/pirats/main.py @@ -1,15 +1,17 @@ from contextlib import asynccontextmanager from typing import Optional -from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter +from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from sqlmodel import Session +import re import uvicorn import os from pathlib import Path from . import crud from .database import create_db_and_tables, get_session +from .ws import manager EVENT_PAGE_SIZE = 50 @@ -23,6 +25,31 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan) api = APIRouter() +# Every state mutation is a POST under /api/game/{game_id}/..., so one +# middleware can notify a game's websocket clients after any successful write. +GAME_PATH_RE = re.compile(r"^/api/game/([^/]+)/") + +@app.middleware("http") +async def broadcast_state_changes(request, call_next): + response = await call_next(request) + if request.method == "POST" and response.status_code < 400: + match = GAME_PATH_RE.match(request.url.path) + if match: + await manager.broadcast(match.group(1), {"type": "state_changed"}) + return response + +@app.websocket("/api/game/{game_id}/ws") +async def game_websocket(websocket: WebSocket, game_id: str): + await manager.connect(game_id, websocket) + try: + while True: + # Clients don't send anything meaningful; this just detects disconnects. + await websocket.receive_text() + except WebSocketDisconnect: + pass + finally: + manager.disconnect(game_id, websocket) + # --- API Core Route Handlers --- @api.post("/game") diff --git a/src/pirats/ws.py b/src/pirats/ws.py new file mode 100644 index 0000000..4d6e2c4 --- /dev/null +++ b/src/pirats/ws.py @@ -0,0 +1,33 @@ +"""In-process websocket hub: one connection set per game, used to push +"state changed" pings so clients refetch instead of polling. + +Single-process only (uvicorn runs one worker); a multi-worker deploy would +need an external pub/sub instead of this dict. +""" +from collections import defaultdict + +from fastapi import WebSocket + + +class GameConnectionManager: + def __init__(self): + self._connections: dict[str, set[WebSocket]] = defaultdict(set) + + async def connect(self, game_id: str, websocket: WebSocket): + await websocket.accept() + self._connections[game_id].add(websocket) + + def disconnect(self, game_id: str, websocket: WebSocket): + self._connections[game_id].discard(websocket) + if not self._connections[game_id]: + del self._connections[game_id] + + async def broadcast(self, game_id: str, message: dict): + for ws in list(self._connections.get(game_id, ())): + try: + await ws.send_json(message) + except Exception: + self.disconnect(game_id, ws) + + +manager = GameConnectionManager() diff --git a/tests/test_game.py b/tests/test_game.py index f8132c3..e52a64b 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1210,3 +1210,39 @@ def test_event_log_pagination(session): exact, have_more = crud.get_events_page(session, game.id, before=1020.0, limit=20) assert not have_more assert [e.message for e in exact] == [f"event {i}" for i in range(20)] + +def test_websocket_state_push(): + """A successful POST under /api/game/{gid}/ pings that game's websockets.""" + from fastapi.testclient import TestClient + from sqlmodel.pool import StaticPool + from pirats.main import app + from pirats.database import get_session + + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + + def override_session(): + with Session(engine) as s: + yield s + + app.dependency_overrides[get_session] = override_session + try: + client = TestClient(app) + gid = client.post("/api/game", data={"crew_name": "Testers"}).json()["id"] + + with client.websocket_connect(f"/api/game/{gid}/ws") as ws: + res = client.post(f"/api/game/{gid}/join", data={"name": "Carlos"}) + assert res.status_code == 200 + assert ws.receive_json() == {"type": "state_changed"} + + # A POST for a different game must not ping this socket + other_gid = client.post("/api/game", data={"crew_name": "Others"}).json()["id"] + client.post(f"/api/game/{other_gid}/join", data={"name": "Barry"}) + client.post(f"/api/game/{gid}/join", data={"name": "Jaspar"}) + assert ws.receive_json() == {"type": "state_changed"} + finally: + app.dependency_overrides.clear()