Show live player presence and Captain/Gat roster badges
This commit is contained in:
@@ -28,6 +28,10 @@ When you run into a repeatable problem during testing (e.g. port assignment coll
|
||||
|
||||
- Rollback tests must fetch Player/Obstacle/Challenge/Vote rows again by ID after `apply_game_state`; it deletes and recreates those rows, so old ORM instances cannot be refreshed.
|
||||
|
||||
- The project `.venv` uses Python 3.9. Use `Optional[T]` (or postponed annotations) instead of evaluated `T | None` annotations, which fail during import.
|
||||
|
||||
- Multi-socket TestClient tests must use `with TestClient(app) as client` so sockets share one event loop; separate portals can hang on cross-socket broadcasts. Close sockets explicitly before asserting disconnect messages. Override lifespan when using an isolated test database.
|
||||
|
||||
## Versioning & changelog
|
||||
|
||||
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
## Features
|
||||
|
||||
- [ ] Add a note taking area to store game state between sessions
|
||||
- [ ] Add an online indicator that displays if a player is connected
|
||||
- [x] Add an online indicator that displays if a player is connected
|
||||
- [ ] PvP challenges should linger in the UI after completion so that players can see the result
|
||||
- [ ] Add hat/gun icons next to player names on the player list to indicate if they're the captain and/or have a gat
|
||||
- [x] Add hat/gun icons next to player names on the player list to indicate if they're the captain and/or have a gat
|
||||
- [ ] Replace red/green borders of successful/failed cards with checks and crosses in the upper corner of the card.
|
||||
- [ ] Add a note taking area to store game state between sessions
|
||||
|
||||
## Polish
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import PlayerBadges from './PlayerBadges.svelte';
|
||||
import { displayName, crewLabel } from '../lib/cards';
|
||||
import CharacterSheet from './scene/CharacterSheet.svelte';
|
||||
|
||||
@@ -70,7 +71,7 @@
|
||||
class:is-you={p.id === state.player.id}
|
||||
>
|
||||
<span class="bubble-icon">{iconFor(p)}</span>
|
||||
<span class="bubble-name">{displayName(p)}</span>
|
||||
<span class="bubble-name">{displayName(p)} <PlayerBadges player={p} {state} /></span>
|
||||
<span class="bubble-meta">{statusFor(p)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -87,7 +88,7 @@
|
||||
<span class="vote-status" class:done={voteStatus(p).done} title={voteStatus(p).label} aria-label={voteStatus(p).label}>{voteStatus(p).icon}</span>
|
||||
{/if}
|
||||
<span class="bubble-icon">{iconFor(p)}</span>
|
||||
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||
<span class="bubble-name">{crewLabel(p, captainId)} <PlayerBadges player={p} {state} /></span>
|
||||
<span class="bubble-meta">{statusFor(p)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script>
|
||||
export let player;
|
||||
export let state;
|
||||
$: online = state.onlinePlayerIds?.includes(player.id);
|
||||
$: connectionLabel = state.onlinePlayerIds == null ? 'Connection unknown' : online ? 'Online' : 'Offline';
|
||||
</script>
|
||||
|
||||
<span class="player-badges">
|
||||
<span class="presence" class:online title={connectionLabel} aria-label={connectionLabel}>{online ? '●' : '○'}</span>
|
||||
{#if player.id === state.game.captain_player_id}<span title="Captain" aria-label="Captain">🎩</span>{/if}
|
||||
{#if player.completed_personal_1}<span title="Has a Gat" aria-label="Has a Gat">🔫</span>{/if}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.player-badges { display: inline-flex; align-items: center; gap: 0.2rem; white-space: nowrap; }
|
||||
.presence { color: var(--text-muted); font-size: 0.85rem; }
|
||||
.presence.online { color: var(--success); }
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import PlayerBadges from '../PlayerBadges.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { crewLabel } from '../../lib/cards';
|
||||
@@ -73,7 +74,7 @@
|
||||
>{hasBeenChallenged(p) ? '✓' : '○'}</span>
|
||||
{/if}
|
||||
<span class="bubble-icon">{roleIcon(p)}</span>
|
||||
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||
<span class="bubble-name">{crewLabel(p, captainId)} <PlayerBadges player={p} {state} /></span>
|
||||
<span class="bubble-meta">
|
||||
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
|
||||
</span>
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
||||
|
||||
export const VERSION = 37;
|
||||
export const VERSION = 38;
|
||||
|
||||
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||
export const CHANGELOG = [
|
||||
{ version: 38, date: '2026-09-04', changes: ['The crew roster shows who is online, with hat and gun icons for the Captain and Pi-Rats who have a Gat.'] },
|
||||
{ version: 37, date: '2026-09-04', changes: ['Color-match replacement cards arrive after the Deep resolves a Challenge. Each applied Obstacle accepts one card per Challenge, and each assistant can contribute one card.'] },
|
||||
{ version: 36, date: '2026-09-04', changes: ['Duel instructions clarify that only the defender draws a replacement for matching the Obstacle’s color, even when the defense fails.'] },
|
||||
{ version: 35, date: '2026-09-04', changes: ['Personal objectives now require a group vote. Propose the next objective from a character sheet, then vote Yes or No at the table. A majority approves; the Captain breaks tied votes.'] },
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
let playerId = params.pid;
|
||||
|
||||
let state = null;
|
||||
let onlinePlayerIds = null;
|
||||
$: rosterState = state ? { ...state, onlinePlayerIds } : null;
|
||||
let error = '';
|
||||
let intervalId;
|
||||
let ws = null;
|
||||
@@ -71,13 +73,18 @@
|
||||
// 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 = new WebSocket(`${proto}://${location.host}/api/game/${gameId}/ws?player_id=${encodeURIComponent(playerId)}`);
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = 1000;
|
||||
fetchState(); // catch up on anything missed while disconnected
|
||||
};
|
||||
ws.onmessage = () => fetchState();
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'presence') onlinePlayerIds = message.player_ids;
|
||||
else if (message.type === 'state_changed') fetchState();
|
||||
};
|
||||
ws.onclose = () => {
|
||||
onlinePlayerIds = null;
|
||||
if (destroyed || staleSession) return;
|
||||
reconnectTimer = setTimeout(connectSocket, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
||||
@@ -224,10 +231,10 @@
|
||||
{#if state}
|
||||
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
||||
{#if state.game.phase === 'scene'}
|
||||
<ScenePhase {state} />
|
||||
<ScenePhase state={rosterState} />
|
||||
{:else}
|
||||
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
|
||||
<CrewSidebar {state} />
|
||||
<CrewSidebar state={rosterState} />
|
||||
<main class="phase-main-column">
|
||||
{#if ['scene_setup', 'recruit_creation'].includes(state.game.phase) && (state.game.last_rank_up_player_id || state.game.current_scene_number > 1)}
|
||||
<div class="notice-banner" role="status">
|
||||
|
||||
+14
-2
@@ -220,8 +220,18 @@ async def broadcast_state_changes(request, call_next):
|
||||
app.add_middleware(LimitRequestBodyMiddleware, max_bytes=max_body_bytes())
|
||||
|
||||
@app.websocket("/api/game/{game_id}/ws")
|
||||
async def game_websocket(websocket: WebSocket, game_id: str):
|
||||
await manager.connect(game_id, websocket)
|
||||
async def game_websocket(
|
||||
websocket: WebSocket, game_id: str, player_id: Optional[str] = None,
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if player_id is not None:
|
||||
player = crud.get_player(db, player_id)
|
||||
if not player or player.game_id != game_id:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
# Release the read transaction before waiting on a long-lived socket.
|
||||
db.close()
|
||||
await manager.connect(game_id, websocket, player_id)
|
||||
try:
|
||||
while True:
|
||||
# Clients don't send anything meaningful; this just detects disconnects.
|
||||
@@ -230,6 +240,8 @@ async def game_websocket(websocket: WebSocket, game_id: str):
|
||||
pass
|
||||
finally:
|
||||
manager.disconnect(game_id, websocket)
|
||||
if player_id is not None:
|
||||
await manager.broadcast_presence(game_id)
|
||||
|
||||
# --- API Core Route Handlers ---
|
||||
|
||||
|
||||
+19
-5
@@ -5,29 +5,43 @@ 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 typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class GameConnectionManager:
|
||||
def __init__(self):
|
||||
self._connections: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
self._connections: dict[str, dict[WebSocket, Optional[str]]] = defaultdict(dict)
|
||||
|
||||
async def connect(self, game_id: str, websocket: WebSocket):
|
||||
async def connect(self, game_id: str, websocket: WebSocket, player_id: Optional[str] = None):
|
||||
await websocket.accept()
|
||||
self._connections[game_id].add(websocket)
|
||||
self._connections[game_id][websocket] = player_id
|
||||
if player_id is not None:
|
||||
await self.broadcast_presence(game_id)
|
||||
|
||||
def disconnect(self, game_id: str, websocket: WebSocket):
|
||||
self._connections[game_id].discard(websocket)
|
||||
if not self._connections[game_id]:
|
||||
connections = self._connections.get(game_id)
|
||||
if connections is None:
|
||||
return
|
||||
connections.pop(websocket, None)
|
||||
if not connections:
|
||||
del self._connections[game_id]
|
||||
|
||||
async def broadcast_presence(self, game_id: str):
|
||||
player_ids = sorted({pid for pid in self._connections.get(game_id, {}).values() if pid})
|
||||
await self.broadcast(game_id, {"type": "presence", "player_ids": player_ids})
|
||||
|
||||
async def broadcast(self, game_id: str, message: dict):
|
||||
disconnected = False
|
||||
for ws in list(self._connections.get(game_id, ())):
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
self.disconnect(game_id, ws)
|
||||
disconnected = True
|
||||
if disconnected:
|
||||
await self.broadcast_presence(game_id)
|
||||
|
||||
|
||||
manager = GameConnectionManager()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Live presence tracks connections rather than persistent character state."""
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
from sqlmodel.pool import StaticPool
|
||||
|
||||
from pirats.database import get_session
|
||||
from pirats.main import app
|
||||
from pirats.ws import GameConnectionManager
|
||||
|
||||
|
||||
def test_presence_tracks_multiple_tabs_and_rejects_other_games():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
def session_override():
|
||||
with Session(engine) as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_session] = session_override
|
||||
original_lifespan = app.router.lifespan_context
|
||||
@asynccontextmanager
|
||||
async def test_lifespan(app):
|
||||
yield
|
||||
app.router.lifespan_context = test_lifespan
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
gid = client.post('/api/game', data={'crew_name': 'Presence'}).json()['id']
|
||||
pid = client.post(f'/api/game/{gid}/join', data={'name': 'Rat'}).json()['id']
|
||||
other = client.post('/api/game', data={'crew_name': 'Other'}).json()['id']
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
import pytest
|
||||
with pytest.raises(WebSocketDisconnect) as rejected:
|
||||
with client.websocket_connect(f'/api/game/{other}/ws?player_id={pid}'):
|
||||
pass
|
||||
assert rejected.value.code == 1008
|
||||
|
||||
with client.websocket_connect(f'/api/game/{gid}/ws') as observer:
|
||||
with client.websocket_connect(f'/api/game/{gid}/ws?player_id={pid}') as first:
|
||||
expected = {'type': 'presence', 'player_ids': [pid]}
|
||||
assert first.receive_json() == expected
|
||||
assert observer.receive_json() == expected
|
||||
with client.websocket_connect(f'/api/game/{gid}/ws?player_id={pid}') as second:
|
||||
assert second.receive_json() == expected
|
||||
assert first.receive_json() == expected
|
||||
assert observer.receive_json() == expected
|
||||
second.close()
|
||||
assert first.receive_json() == expected
|
||||
assert observer.receive_json() == expected
|
||||
first.close()
|
||||
assert observer.receive_json() == {'type': 'presence', 'player_ids': []}
|
||||
finally:
|
||||
app.router.lifespan_context = original_lifespan
|
||||
app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_failed_socket_is_removed_from_presence():
|
||||
class Socket:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
self.failed = False
|
||||
|
||||
async def accept(self):
|
||||
pass
|
||||
|
||||
async def send_json(self, message):
|
||||
if self.failed:
|
||||
raise RuntimeError('Connection lost')
|
||||
self.messages.append(message)
|
||||
|
||||
async def scenario():
|
||||
manager = GameConnectionManager()
|
||||
first, second = Socket(), Socket()
|
||||
await manager.connect('game', first, 'a')
|
||||
await manager.connect('game', second, 'b')
|
||||
first.failed = True
|
||||
await manager.broadcast('game', {'type': 'state_changed'})
|
||||
assert second.messages[-1] == {'type': 'presence', 'player_ids': ['b']}
|
||||
manager.disconnect('game', first) # Endpoint cleanup remains idempotent.
|
||||
manager.disconnect('game', second)
|
||||
assert not manager._connections
|
||||
|
||||
asyncio.run(scenario())
|
||||
Reference in New Issue
Block a user