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,290 @@
|
||||
import {
|
||||
CallToPlayAction,
|
||||
CallToPlayEvent,
|
||||
CallToPlayParticipant,
|
||||
Nomination,
|
||||
} from './types';
|
||||
|
||||
export const CHECKIN_LEAD_MS = 15 * 60_000;
|
||||
export const STARTED_RETENTION_MS = 3_000;
|
||||
|
||||
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
|
||||
export type CallToPlayStatus = 'started' | 'ready' | 'soon' | 'scheduled' | 'call';
|
||||
|
||||
interface MutableNomination extends Nomination {
|
||||
cancelled: boolean;
|
||||
messageIds: Set<string>;
|
||||
}
|
||||
|
||||
const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number =>
|
||||
a.at - b.at || a.id.localeCompare(b.id);
|
||||
|
||||
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
|
||||
type RespondPayload = Extract<CallToPlayAction, { Respond: unknown }>['Respond'];
|
||||
type MessagePayload = Extract<CallToPlayAction, { SendMessage: unknown }>['SendMessage'];
|
||||
type AddTimePayload = Extract<CallToPlayAction, { AddTime: unknown }>['AddTime'];
|
||||
|
||||
const createPayload = (action: CallToPlayAction): CreatePayload | null =>
|
||||
typeof action === 'object' && 'Create' in action ? action.Create : null;
|
||||
const respondPayload = (action: CallToPlayAction): RespondPayload | null =>
|
||||
typeof action === 'object' && 'Respond' in action ? action.Respond : null;
|
||||
const messagePayload = (action: CallToPlayAction): MessagePayload | null =>
|
||||
typeof action === 'object' && 'SendMessage' in action ? action.SendMessage : null;
|
||||
const addTimePayload = (action: CallToPlayAction): AddTimePayload | null =>
|
||||
typeof action === 'object' && 'AddTime' in action ? action.AddTime : null;
|
||||
|
||||
export const phaseOf = (nomination: Nomination, now: number): CallToPlayPhase => {
|
||||
if (nomination.scheduledFor === null) return 'now';
|
||||
return now < nomination.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
|
||||
};
|
||||
|
||||
export const isReady = (
|
||||
participant: CallToPlayParticipant | undefined,
|
||||
now: number,
|
||||
): boolean => Boolean(
|
||||
participant && (
|
||||
participant.status === 'ready'
|
||||
|| (participant.readyAt !== undefined && now >= participant.readyAt)
|
||||
),
|
||||
);
|
||||
|
||||
export const readyCountOf = (nomination: Nomination, now: number): number =>
|
||||
Object.values(nomination.participants).filter(participant => isReady(participant, now)).length;
|
||||
|
||||
export const inCountOf = (nomination: Nomination, now: number): number =>
|
||||
Object.values(nomination.participants).filter(participant =>
|
||||
!isReady(participant, now) && participant.status === 'in'
|
||||
).length;
|
||||
|
||||
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
|
||||
if (nomination.state === 'started') return 'started';
|
||||
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
|
||||
return 'ready';
|
||||
}
|
||||
if (nomination.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
|
||||
return nomination.scheduledFor === null ? 'call' : 'scheduled';
|
||||
};
|
||||
|
||||
export const reduceCallToPlayEvents = (
|
||||
input: ReadonlyArray<CallToPlayEvent>,
|
||||
now: number,
|
||||
): Nomination[] => {
|
||||
const unique = new Map(input.map(event => [event.id, event]));
|
||||
const byCall = new Map<string, CallToPlayEvent[]>();
|
||||
for (const event of unique.values()) {
|
||||
const events = byCall.get(event.call_id) ?? [];
|
||||
events.push(event);
|
||||
byCall.set(event.call_id, events);
|
||||
}
|
||||
|
||||
const nominations: Nomination[] = [];
|
||||
for (const events of byCall.values()) {
|
||||
events.sort(compareEvents);
|
||||
const create = events.find(event => createPayload(event.action) !== null);
|
||||
if (!create) continue;
|
||||
const payload = createPayload(create.action);
|
||||
if (!payload) continue;
|
||||
|
||||
const nomination: MutableNomination = {
|
||||
id: create.call_id,
|
||||
gameId: payload.game_id,
|
||||
creator: create.actor,
|
||||
maxPlayers: payload.max_players,
|
||||
createdAt: create.at,
|
||||
scheduledFor: payload.scheduled_for,
|
||||
deadline: payload.deadline,
|
||||
participants: {
|
||||
[create.actor]: {
|
||||
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||
joinedAt: create.at,
|
||||
},
|
||||
},
|
||||
messages: [],
|
||||
state: 'open',
|
||||
cancelled: false,
|
||||
messageIds: new Set(),
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
if (compareEvents(event, create) <= 0 || nomination.cancelled) continue;
|
||||
applyEvent(nomination, event);
|
||||
}
|
||||
|
||||
if (nomination.cancelled) continue;
|
||||
if (nomination.state === 'open'
|
||||
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
||||
) {
|
||||
nomination.state = 'done';
|
||||
}
|
||||
if (nomination.state === 'started'
|
||||
&& now - (nomination.startedAt ?? now) > STARTED_RETENTION_MS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { cancelled: _, messageIds: __, ...result } = nomination;
|
||||
nominations.push(result);
|
||||
}
|
||||
|
||||
return nominations.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
|
||||
};
|
||||
|
||||
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
|
||||
const action = event.action;
|
||||
if (typeof action === 'string') {
|
||||
applyUnitAction(nomination, event, action);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = respondPayload(action);
|
||||
if (response) {
|
||||
if (nomination.state === 'started') return;
|
||||
const existing = nomination.participants[event.actor];
|
||||
nomination.participants[event.actor] = {
|
||||
status: response.ready_at === null ? 'ready' : 'pending',
|
||||
joinedAt: existing?.joinedAt ?? event.at,
|
||||
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const message = messagePayload(action);
|
||||
if (message && nomination.state !== 'started' && !nomination.messageIds.has(message.message_id)) {
|
||||
nomination.messageIds.add(message.message_id);
|
||||
nomination.messages.push({
|
||||
id: message.message_id,
|
||||
from: event.actor,
|
||||
text: message.text,
|
||||
at: event.at,
|
||||
});
|
||||
nomination.messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = addTimePayload(action);
|
||||
if (extension && event.actor === nomination.creator && nomination.state !== 'started') {
|
||||
nomination.deadline = extension.deadline;
|
||||
nomination.state = 'open';
|
||||
}
|
||||
};
|
||||
|
||||
const applyUnitAction = (
|
||||
nomination: MutableNomination,
|
||||
event: CallToPlayEvent,
|
||||
action: Extract<CallToPlayAction, string>,
|
||||
): void => {
|
||||
switch (action) {
|
||||
case 'Rsvp': {
|
||||
if (nomination.state === 'started') return;
|
||||
const existing = nomination.participants[event.actor];
|
||||
nomination.participants[event.actor] = {
|
||||
status: 'in',
|
||||
joinedAt: existing?.joinedAt ?? event.at,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'Leave':
|
||||
if (event.actor !== nomination.creator && nomination.state !== 'started') {
|
||||
delete nomination.participants[event.actor];
|
||||
}
|
||||
break;
|
||||
case 'Cancel':
|
||||
if (event.actor === nomination.creator && nomination.state !== 'started') {
|
||||
nomination.cancelled = true;
|
||||
}
|
||||
break;
|
||||
case 'Start':
|
||||
if (event.actor === nomination.creator && nomination.state !== 'started') {
|
||||
nomination.state = 'started';
|
||||
nomination.startedAt = event.at;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const callToPlayEvent = (
|
||||
callId: string,
|
||||
actor: string,
|
||||
action: CallToPlayAction,
|
||||
at = Date.now(),
|
||||
): CallToPlayEvent => ({
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
call_id: callId,
|
||||
actor,
|
||||
at,
|
||||
action,
|
||||
});
|
||||
|
||||
export const formatClock = (timestamp: number): string => {
|
||||
const date = new Date(timestamp);
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const formatCountdown = (milliseconds: number): string => {
|
||||
if (milliseconds <= 0) return '0:00';
|
||||
const total = Math.ceil(milliseconds / 1_000);
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const formatCountdownShort = (milliseconds: number): string => {
|
||||
if (milliseconds <= 0) return 'now';
|
||||
const total = Math.ceil(milliseconds / 1_000);
|
||||
return total < 60 ? `${total}s` : `${Math.ceil(total / 60)}m`;
|
||||
};
|
||||
|
||||
export const formatUntil = (milliseconds: number): string => {
|
||||
if (milliseconds <= 60_000) return 'in <1 min';
|
||||
const minutes = Math.round(milliseconds / 60_000);
|
||||
if (minutes < 60) return `in ${minutes} min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainder = minutes % 60;
|
||||
return remainder ? `in ${hours}h ${remainder}m` : `in ${hours}h`;
|
||||
};
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'#60a5fa', '#34d399', '#c084fc', '#fbbf24',
|
||||
'#f472b6', '#38bdf8', '#a3e635', '#fb7185',
|
||||
];
|
||||
|
||||
export const avatarColor = (name: string): string => {
|
||||
const hash = Array.from(name).reduce((sum, character) => sum + character.charCodeAt(0), 0);
|
||||
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
|
||||
};
|
||||
|
||||
export const sanitizeTimeDraft = (raw: string, previous: string): string => {
|
||||
const value = raw.replace(/[^\d:]/g, '');
|
||||
if ((value.match(/:/g) ?? []).length > 1) return previous;
|
||||
const colon = value.indexOf(':');
|
||||
if (colon !== -1) {
|
||||
if (value.slice(0, colon).length > 2 || value.slice(colon + 1).length > 2) {
|
||||
return previous;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return value.length > 4 ? previous : value;
|
||||
};
|
||||
|
||||
export const normalizeTimeInput = (raw: string): string | null => {
|
||||
const match = raw.trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2] ?? 0);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const bumpTime = (
|
||||
time: string,
|
||||
field: 'hours' | 'minutes',
|
||||
delta: number,
|
||||
): string => {
|
||||
let [hours, minutes] = time.split(':').map(Number);
|
||||
if (field === 'hours') {
|
||||
hours = (hours + delta + 24) % 24;
|
||||
} else {
|
||||
const total = ((hours * 60 + minutes + delta) % 1_440 + 1_440) % 1_440;
|
||||
hours = Math.floor(total / 60);
|
||||
minutes = total % 60;
|
||||
}
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||
};
|
||||
Reference in New Issue
Block a user