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,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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user