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,103 @@
import { useState } from 'react';
import { Icon } from '../Icon';
import { Modal } from '../Modal';
import { CreateNominationForm } from './CreateNominationForm';
import { NominationCard } from './NominationCard';
import { CallToPlayActions } from '../../hooks/useCallToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
games: ReadonlyArray<Game>;
username: string;
actions: CallToPlayActions;
focusId: string | null;
transportReady: boolean;
error: string | null;
getThumbnail: (gameId: string) => string | null | undefined;
totalPeerCount: number;
onLaunch: (game: Game) => void;
onClose: () => void;
}
export const CallToPlayOverlay = ({
nominations,
games,
username,
actions,
focusId,
transportReady,
error,
getThumbnail,
totalPeerCount,
onLaunch,
onClose,
}: Props) => {
const [showCreate, setShowCreate] = useState(false);
const gameById = new Map(games.map(game => [game.id, game]));
const sorted = [...nominations].sort((left, right) =>
Number(left.state === 'started') - Number(right.state === 'started')
|| left.deadline - right.deadline
);
return (
<Modal onClose={onClose} className="ctp-modal">
<button className="modal-close" onClick={onClose} aria-label="Close">
<Icon.close />
</button>
<div className="ctp-head">
<h2>Call to Play</h2>
<p className="ctp-head-sub">
Rally the LAN around a game and a time right now, or scheduled for later with
an Im in RSVP. The caller decides when it actually starts.
</p>
{!showCreate && (
<button
className="act-btn act-play ctp-head-new"
disabled={!transportReady}
onClick={() => setShowCreate(true)}
><Icon.flag /><span>Call a new match</span></button>
)}
{!transportReady && !error && (
<div className="ctp-transport-note">Connecting Call to Play to the LAN</div>
)}
{error && <div className="ctp-transport-note is-error">{error}</div>}
</div>
<div className="ctp-body">
{showCreate && (
<CreateNominationForm
games={games}
onCancel={() => setShowCreate(false)}
onCreate={(gameId, maxPlayers, duration, scheduledFor) => {
actions.createNomination(gameId, maxPlayers, duration, scheduledFor);
setShowCreate(false);
}}
/>
)}
{sorted.length === 0 && !showCreate && (
<div className="ctp-empty">
No active calls right now be the one to start something.
</div>
)}
{sorted.map(nomination => {
const game = gameById.get(nomination.gameId);
if (!game) return null;
return (
<NominationCard
key={nomination.id}
nomination={nomination}
game={game}
username={username}
actions={actions}
focused={nomination.id === focusId}
thumbnailUrl={getThumbnail(game.id)}
totalPeerCount={totalPeerCount}
onLaunch={onLaunch}
/>
);
})}
</div>
</Modal>
);
};