Show live player presence and Captain/Gat roster badges

This commit is contained in:
2026-09-04 19:32:22 -07:00
parent 8b67d78f25
commit fcbc617fe3
10 changed files with 162 additions and 18 deletions
+3 -2
View File
@@ -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>
+2 -1
View File
@@ -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 Obstacles 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.'] },
+11 -4
View File
@@ -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">