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,154 @@
import {
CHECKIN_LEAD_MS,
phaseOf,
bumpTime,
normalizeTimeInput,
readyCountOf,
reduceCallToPlayEvents,
statusOf,
} from '../src/lib/callToPlay.ts';
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
const NOW = 1_000_000;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
};
const event = (
id: string,
actor: string,
action: CallToPlayAction,
at = NOW,
): CallToPlayEvent => ({ id, call_id: 'call-1', actor, at, action });
const create = (
scheduledFor: number | null = null,
deadline = NOW + 30 * 60_000,
): CallToPlayEvent => event('create', 'Alice', {
Create: {
game_id: 'game-1',
max_players: 3,
scheduled_for: scheduledFor,
deadline,
},
});
Deno.test('play-now call starts with its creator ready', () => {
const [nomination] = reduceCallToPlayEvents([create()], NOW);
assert(nomination, 'call should exist');
assertEquals(nomination.creator, 'Alice', 'creator');
assertEquals(nomination.participants.Alice.status, 'ready', 'creator status');
assertEquals(phaseOf(nomination, NOW), 'now', 'phase');
assertEquals(statusOf(nomination, NOW), 'call', 'status outside starting-soon window');
});
Deno.test('scheduled RSVP becomes check-in and pending response becomes ready over time', () => {
const scheduledFor = NOW + 60 * 60_000;
const events = [
create(scheduledFor, scheduledFor),
event('rsvp', 'Bob', 'Rsvp', NOW + 1),
event('respond', 'Bob', { Respond: { ready_at: scheduledFor - 5 * 60_000 } }, NOW + 2),
];
const [far] = reduceCallToPlayEvents(events, NOW);
assertEquals(phaseOf(far, NOW), 'scheduled', 'far-out phase');
assertEquals(far.participants.Bob.status, 'pending', 'buffered response state');
const checkinNow = scheduledFor - CHECKIN_LEAD_MS;
const [checkin] = reduceCallToPlayEvents(events, checkinNow);
assertEquals(phaseOf(checkin, checkinNow), 'checkin', 'check-in phase');
assertEquals(readyCountOf(checkin, checkinNow), 0, 'nobody ready at check-in opening');
const [ready] = reduceCallToPlayEvents(events, scheduledFor - 4 * 60_000);
assertEquals(readyCountOf(ready, scheduledFor - 4 * 60_000), 1, 'elapsed buffer is ready');
});
Deno.test('call resolves when the roster fills or its deadline elapses', () => {
const full = [
create(),
event('bob', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('carol', 'Carol', { Respond: { ready_at: null } }, NOW + 2),
];
assertEquals(reduceCallToPlayEvents(full, NOW + 2)[0].state, 'done', 'full roster');
assertEquals(
reduceCallToPlayEvents([create()], NOW + 31 * 60_000)[0].state,
'done',
'elapsed deadline',
);
});
Deno.test('creator-only controls cannot be forged by another participant', () => {
const forged = [
create(),
event('cancel', 'Mallory', 'Cancel', NOW + 1),
event('start', 'Mallory', 'Start', NOW + 2),
event('extend', 'Mallory', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 3),
];
const [nomination] = reduceCallToPlayEvents(forged, NOW + 4);
assert(nomination, 'forged cancel should not remove call');
assertEquals(nomination.state, 'open', 'forged start ignored');
assertEquals(nomination.deadline, NOW + 30 * 60_000, 'forged extension ignored');
});
Deno.test('creator can extend, start, and cancel a call', () => {
const extended = reduceCallToPlayEvents([
create(),
event('extend', 'Alice', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 1),
], NOW + 40 * 60_000)[0];
assertEquals(extended.state, 'open', 'extension reopens an elapsed call');
const started = reduceCallToPlayEvents([
create(),
event('start', 'Alice', 'Start', NOW + 1),
], NOW + 2)[0];
assertEquals(started.state, 'started', 'creator start');
const cancelled = reduceCallToPlayEvents([
create(),
event('cancel', 'Alice', 'Cancel', NOW + 1),
], NOW + 2);
assertEquals(cancelled.length, 0, 'creator cancel removes call');
});
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
const message = event('message-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Ready?' },
}, NOW + 2);
const duplicateMessage = event('other-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'duplicate' },
}, NOW + 3);
const events = [message, create(), message, duplicateMessage];
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
assertEquals(nomination.messages.length, 1, 'unique message id');
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins');
});
Deno.test('actions timestamped before creation cannot mutate a call', () => {
const events = [
event('early-cancel', 'Alice', 'Cancel', NOW - 1),
event('early-response', 'Bob', { Respond: { ready_at: null } }, NOW - 1),
create(),
];
const [nomination] = reduceCallToPlayEvents(events, NOW + 1);
assert(nomination, 'call should survive a pre-creation cancel');
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
});
Deno.test('started calls expire from local presentation history', () => {
const events = [create(), event('start', 'Alice', 'Start', NOW + 1)];
assertEquals(reduceCallToPlayEvents(events, NOW + 2).length, 1, 'fresh started call remains');
assertEquals(reduceCallToPlayEvents(events, NOW + 5_000).length, 0, 'old started call expires');
});
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
assertEquals(normalizeTimeInput('20:00'), '20:00', 'colon format');
assertEquals(normalizeTimeInput('2000'), '20:00', 'compact format');
assertEquals(normalizeTimeInput('9:30'), '09:30', 'single-digit hour');
assertEquals(normalizeTimeInput('24:00'), null, 'invalid hour');
assertEquals(bumpTime('23:45', 'minutes', 15), '00:00', 'minute wrap');
assertEquals(bumpTime('00:00', 'hours', -1), '23:00', 'hour wrap');
});