Websockets instead of polling

This commit is contained in:
2026-06-12 06:19:22 -07:00
parent b72aea9747
commit ba6bf35579
8 changed files with 128 additions and 8 deletions

View File

@@ -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

View File

@@ -48,6 +48,7 @@
fastapi
sqlmodel
uvicorn
websockets
python-multipart
httpx
];

View File

@@ -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([]);
});
</script>

View File

@@ -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',

View File

@@ -12,6 +12,7 @@ dependencies = [
"fastapi",
"sqlmodel",
"uvicorn",
"websockets",
"python-multipart",
]

View File

@@ -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")

33
src/pirats/ws.py Normal file
View File

@@ -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()

View File

@@ -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()