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:
@@ -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 “I’m 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user