Websockets instead of polling
This commit is contained in:
6
TODO.md
6
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
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
fastapi
|
||||
sqlmodel
|
||||
uvicorn
|
||||
websockets
|
||||
python-multipart
|
||||
httpx
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"fastapi",
|
||||
"sqlmodel",
|
||||
"uvicorn",
|
||||
"websockets",
|
||||
"python-multipart",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
33
src/pirats/ws.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user