feat(call-to-play)!: coordinate game sessions across peers

Implement the launcher design as a production peer-to-peer feature. Call to
Play actions are immutable, validated events broadcast over the existing QUIC
control channel, deduplicated in a bounded in-memory history, and exchanged in
Hello/HelloAck so late joiners reconstruct current calls.

Add the Tauri bridge and modular launcher surfaces for play-now and scheduled
calls, check-in, readiness buffers, role-aware controls, chat, tickers, and
actual caller launch. A deterministic frontend reducer derives presentation
state from replicated history. Extend the JSONL peer harness with publish/list
commands and a three-peer live-delivery and late-join scenario.

This intentionally raises the only supported wire protocol from version 5 to
version 6; older builds are not supported. Document the transport architecture
and exclude generated peer-test state from Docker build contexts.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 20 tests
- `just build` -- passed
- `just peer-cli-tests S2 S48` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-07-21 22:30:11 +02:00
parent 8f151e38b4
commit 0f53bc4b78
31 changed files with 3032 additions and 21 deletions
@@ -0,0 +1,124 @@
import { CSSProperties } from 'react';
import { Icon } from '../Icon';
import {
avatarColor,
formatClock,
formatCountdown,
formatCountdownShort,
formatUntil,
isReady,
readyCountOf,
statusOf,
} from '../../lib/callToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
games: ReadonlyArray<Game>;
accent: string;
onOpen: (callId: string) => void;
}
const LABEL = {
scheduled: 'Scheduled',
call: 'Call to Play',
soon: 'Starting soon',
ready: 'Ready',
} as const;
const RANK = { ready: 0, soon: 1, call: 2, scheduled: 2, started: 3 } as const;
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
const entries = Object.entries(nomination.participants);
return (
<span className="ctp-ticker-bubbles">
{entries.slice(0, 6).map(([name, participant]) => {
const ready = isReady(participant, now);
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const remaining = (participant.readyAt ?? now) - now;
return (
<span
key={name}
className="ctp-mini"
data-state={state}
title={`${name}${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
style={{ background: avatarColor(name) }}
>
{initials}
{ready && <span className="ctp-mini-check"><Icon.check /></span>}
{state === 'pending' && (
<i className="ctp-mini-tag">{formatCountdownShort(remaining)}</i>
)}
</span>
);
})}
{entries.length > 6 && <span className="ctp-mini ctp-mini-more">+{entries.length - 6}</span>}
</span>
);
};
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
const now = Date.now();
const gameById = new Map(games.map(game => [game.id, game]));
const active = nominations
.filter(nomination => nomination.state !== 'started')
.sort((left, right) =>
RANK[statusOf(left, now)] - RANK[statusOf(right, now)]
|| left.deadline - right.deadline
);
if (active.length === 0) return null;
return (
<div className="ctp-ticker-stack">
{active.map(nomination => {
const game = gameById.get(nomination.gameId);
if (!game) return null;
const status = statusOf(nomination, now);
if (status === 'started') return null;
const ready = readyCountOf(nomination, now);
const total = Object.keys(nomination.participants).length;
const remaining = Math.max(0, nomination.deadline - now);
const last = nomination.messages[nomination.messages.length - 1];
const count = status === 'scheduled'
? `${total} in`
: `${ready}/${nomination.maxPlayers} ready`;
const time = status === 'ready'
? 'waiting to start'
: status === 'scheduled'
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
: nomination.scheduledFor !== null
? `starts ${formatClock(nomination.scheduledFor)} · ${formatCountdown(remaining)}`
: formatCountdown(remaining);
return (
<button
key={nomination.id}
className="ctp-ticker"
data-status={status}
style={{ '--accent': accent } as CSSProperties}
onClick={() => onOpen(nomination.id)}
>
<span className="ctp-ticker-dot" data-status={status} />
<span className="ctp-ticker-label" data-status={status}>{LABEL[status]}</span>
<span className="ctp-ticker-game">{game.name}</span>
<span className="ctp-ticker-by">by {nomination.creator}</span>
<span className="ctp-ticker-ready">{count}</span>
<span className="ctp-ticker-time">{time}</span>
{last ? (
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
<Icon.chat />
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
<span className="ctp-ticker-chat-text">{last.text}</span>
</span>
) : <span className="ctp-ticker-chat" />}
<MiniBubbles nomination={nomination} now={now} />
<span className="ctp-ticker-cta">
{status === 'soon' ? 'Check in' : 'View'}<Icon.chevron />
</span>
</button>
);
})}
</div>
);
};