feat(call-to-play): retain terminal outcomes

Keep complete running and cancelled histories visible for fifteen minutes so
peers retain the roster, chat, and outcome long enough to understand what
happened. Compact them to terminal tombstones afterward without charging
settled calls against the active-history limit.

Model running and cancelled as durable read-only frontend states, exclude
them from active badges, prune retired raw events, and document the lifecycle.
Add peer scenario S49 to prove a late joiner reconstructs a terminal call with
its roster and chat intact.

Test Plan:
- just fmt
- just clippy
- just test
- just frontend-test
- just build
- just peer-cli-tests S48 S49
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- git diff --cached --check
This commit is contained in:
2026-07-23 18:03:57 +02:00
parent e5d70ae56f
commit 9c34efa705
13 changed files with 494 additions and 161 deletions
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Run the peer-cli scenarios S1-S48 through Docker."""
"""Run the peer-cli scenarios S1-S49 through Docker."""
from __future__ import annotations
@@ -357,6 +357,7 @@ class Runner:
("S46", self.s46_receiver_cancel_mid_stream),
("S47", self.s47_multi_archive_streams_in_sorted_order),
("S48", self.s48_call_to_play_replication_and_late_join),
("S49", self.s49_terminal_call_to_play_late_join),
]
for scenario_id, scenario in scenarios:
@@ -1824,6 +1825,79 @@ class Runner:
return "live create/RSVP/chat replicated and a late joiner received deduplicated history"
def s49_terminal_call_to_play_late_join(self) -> str:
alice = self.peer("s49-alice")
bob = self.peer("s49-bob")
bob.connect_to(alice)
now = int(time.time() * 1000)
create = {
"id": "s49-create",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Alice",
"at": now,
"action": {
"Create": {
"game_id": "cnctw",
"max_players": 4,
"scheduled_for": None,
"deadline": now + 600_000,
}
},
}
ready = {
"id": "s49-ready",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Bob",
"at": now + 1,
"action": {"Respond": {"ready_at": None}},
}
message = {
"id": "s49-message-event",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Bob",
"at": now + 2,
"action": {
"SendMessage": {
"message_id": "s49-message",
"text": "Ready to launch",
}
},
}
start = {
"id": "s49-start",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Alice",
"at": now + 3,
"action": "Start",
}
alice.publish_call_to_play(create)
wait_call_to_play_events(bob, {"s49-create"})
bob.publish_call_to_play(ready)
bob.publish_call_to_play(message)
wait_call_to_play_events(alice, {"s49-create", "s49-ready", "s49-message-event"})
alice.publish_call_to_play(start)
wait_call_to_play_events(
bob,
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
)
charlie = self.peer("s49-charlie")
charlie.connect_to(alice)
events = wait_call_to_play_events(
charlie,
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
)
if len(events) != 4:
raise ScenarioError(f"terminal late-join history is incomplete: {events}")
return "late joiner reconstructed terminal call outcome, roster, and chat"
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
+9 -7
View File
@@ -52,13 +52,15 @@ Call to Play is transient peer-session state rather than database state. The
peer keeps a bounded event history, deduplicated by event ID. Every event and
chat message remains in the snapshot for the full lifetime of an active call,
so a peer joining mid-call receives the complete context. A creator's `Start`
or `Cancel` event replaces that terminal call with one small tombstone; this
lets a peer that missed the live action heal on its next handshake without
retaining the inactive call's full history. Active calls are never partially
trimmed. If genuinely active history reaches the bound, local publishes return
an error to the caller instead of appearing to succeed. A call whose deadline
elapses remains available for five minutes so the creator can start or extend
it, then its history is evicted as a unit. A
or `Cancel` makes the call terminal and read-only, but its complete roster and
chat history remain in snapshots for 15 minutes so late joiners can see the
outcome. After that display window the history compacts to the Start or Cancel
tombstone for the rest of the peer session. Active and recently terminal calls
are never partially trimmed. If genuinely active history reaches the bound,
local publishes return an error to the caller instead of appearing to succeed;
terminal histories and tombstones do not consume that active-history capacity.
A call whose deadline elapses remains available for five minutes so the creator
can start or extend it, then its unresolved history is evicted as a unit. A
local action is applied to that history, sent to the UI, and broadcast to every
currently known peer. An incoming live event is applied once and sent to the UI
without being rebroadcast, which prevents forwarding loops.
+92 -21
View File
@@ -23,6 +23,7 @@ const MAX_GAME_ID_CHARS: usize = 256;
const MAX_USERNAME_CHARS: usize = 24;
const MAX_MESSAGE_CHARS: usize = 500;
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
const TERMINAL_RETENTION_MS: i64 = 15 * 60_000;
#[derive(Debug, Default)]
pub(crate) struct CallToPlayStore {
@@ -120,6 +121,7 @@ impl CallToPlayStore {
.map(|event| event.call_id.clone())
.collect::<HashSet<_>>();
let retained_tombstones = terminal_tombstone_call_ids(&retained);
let retained_history = HistoryIndex::build(&retained);
let mut applicable = Vec::with_capacity(new_events.len());
let mut missing_call_ids = BTreeSet::new();
let mut obsolete = 0;
@@ -129,6 +131,13 @@ impl CallToPlayStore {
obsolete += 1;
continue;
}
if let Some(terminal) = retained_history.terminal_events.get(&event.call_id)
&& (matches!(event.action, CallToPlayAction::Create { .. })
|| (event.at, event.id.as_str()) > terminal.order_key())
{
obsolete += 1;
continue;
}
if !matches!(event.action, CallToPlayAction::Create { .. })
&& !rooted_calls.contains(event.call_id.as_str())
{
@@ -336,10 +345,13 @@ fn compact_history(events: &mut Vec<CallToPlayEvent>, now: i64) {
if expired_calls.contains(event.call_id.as_str()) {
return false;
}
index
.terminal_events
.get(&event.call_id)
.is_none_or(|terminal| event.id == terminal.event_id)
let Some(terminal) = index.terminal_events.get(&event.call_id) else {
return true;
};
if (event.at, event.id.as_str()) > terminal.order_key() {
return false;
}
now - terminal.at <= TERMINAL_RETENTION_MS || event.id == terminal.event_id
});
}
@@ -536,7 +548,13 @@ fn validate_nonempty(
mod tests {
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
use super::{CallToPlayStore, MAX_EVENTS, MergeError, delivery_resync_reason};
use super::{
CallToPlayStore,
MAX_EVENTS,
MergeError,
TERMINAL_RETENTION_MS,
delivery_resync_reason,
};
const TEST_NOW: i64 = 8_000_000_000_000;
@@ -705,6 +723,11 @@ mod tests {
store
.merge_batch_at(vec![create_event("create"), start.clone()], TEST_NOW)
.expect("terminal history should merge");
let after_terminal_retention = start.at + TERMINAL_RETENTION_MS + 1;
assert_eq!(
store.snapshot_at(after_terminal_retention).as_slice(),
std::slice::from_ref(&start)
);
let stale = store
.merge_batch_at(
@@ -718,13 +741,13 @@ mod tests {
},
),
],
TEST_NOW,
after_terminal_retention,
)
.expect("stale history is an obsolete merge outcome");
assert!(stale.applied.is_empty());
assert_eq!(stale.obsolete, 2);
assert_eq!(store.snapshot_at(TEST_NOW), [start]);
assert_eq!(store.snapshot_at(after_terminal_retention), [start]);
}
#[test]
@@ -735,32 +758,31 @@ mod tests {
let merged = store
.merge_batch_at(vec![terminal.clone()], TEST_NOW)
.expect("terminal action should compact the full call");
.expect("terminal action should settle the full call");
assert_eq!(merged.applied.as_slice(), std::slice::from_ref(&terminal));
assert_eq!(store.snapshot_at(TEST_NOW), [terminal]);
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 1);
assert_eq!(
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
[terminal]
);
}
}
#[test]
fn terminal_tombstones_do_not_consume_active_history_capacity() {
fn terminal_histories_do_not_consume_active_history_capacity() {
let mut store = full_active_store();
let mut terminal = action_event("call-1-start", "call-1", CallToPlayAction::Start);
terminal.at = TEST_NOW + 2_000;
store
.merge_batch_at(
vec![action_event(
"call-1-start",
"call-1",
CallToPlayAction::Start,
)],
TEST_NOW,
)
.expect("start should compact active history");
.merge_batch_at(vec![terminal], TEST_NOW)
.expect("start should settle active history");
let created = create_event_for("call-2", "call-2-create");
let merged = store
.merge_batch_at(vec![created.clone()], TEST_NOW)
.expect("terminal tombstone must not block a new active call");
.expect("terminal history must not block a new active call");
assert_eq!(merged.applied, [created]);
assert_eq!(store.snapshot_at(TEST_NOW).len(), 2);
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 2);
}
#[test]
@@ -804,6 +826,55 @@ mod tests {
assert!(store.snapshot_at(TEST_NOW).contains(&message));
}
#[test]
fn terminal_history_preserves_roster_and_chat_for_display_window() {
let mut store = CallToPlayStore::default();
let message = action_event(
"message",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-1".to_string(),
text: "Launching now".to_string(),
},
);
let terminal = action_event("terminal", "call-1", CallToPlayAction::Start);
let history = vec![
create_event("create"),
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
message,
terminal.clone(),
];
store
.merge_batch_at(history.clone(), TEST_NOW)
.expect("terminal history should merge");
let mut post_terminal = action_event(
"post-terminal",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-2".to_string(),
text: "Too late".to_string(),
},
);
post_terminal.at = terminal.at + 1;
let obsolete = store
.merge_batch_at(
vec![create_event("different-create"), post_terminal],
TEST_NOW,
)
.expect("terminal updates should be obsolete");
assert!(obsolete.applied.is_empty());
assert_eq!(obsolete.obsolete, 2);
assert_eq!(
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS),
history
);
assert_eq!(
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
[terminal]
);
}
#[test]
fn only_failed_or_incomplete_deliveries_request_resync() {
assert!(delivery_resync_reason(Err(())).is_some());
@@ -1,4 +1,5 @@
import { Icon } from '../Icon';
import { activeCallCount } from '../../lib/callToPlay';
import { Nomination } from '../../lib/types';
interface Props {
@@ -7,7 +8,7 @@ interface Props {
}
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
const activeCount = nominations.filter(nomination => nomination.state !== 'started').length;
const activeCount = activeCallCount(nominations);
return (
<button className="ctp-btn" onClick={onClick}>
<Icon.flag />
@@ -5,6 +5,7 @@ import { Modal } from '../Modal';
import { CreateNominationForm } from './CreateNominationForm';
import { NominationCard } from './NominationCard';
import { CallToPlayActions } from '../../hooks/useCallToPlay';
import { sortNominations } from '../../lib/callToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
@@ -36,10 +37,7 @@ export const CallToPlayOverlay = ({
}: 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
);
const sorted = sortNominations(nominations);
return (
<Modal onClose={onClose} className="ctp-modal">
@@ -22,6 +22,8 @@ interface Props {
}
const LABEL = {
running: 'Running',
cancelled: 'Cancelled',
scheduled: 'Scheduled',
call: 'Call to Play',
soon: 'Starting soon',
@@ -29,7 +31,7 @@ const LABEL = {
expired: 'Times up',
} as const;
type TickerStatus = Exclude<CallToPlayStatus, 'started'>;
type TickerStatus = CallToPlayStatus;
const RANK: Record<TickerStatus, number> = {
expired: 0,
@@ -37,12 +39,12 @@ const RANK: Record<TickerStatus, number> = {
soon: 2,
call: 3,
scheduled: 3,
running: 4,
cancelled: 4,
};
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus | null => {
const status = statusOf(nomination, now);
return status === 'started' ? null : status;
};
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus =>
statusOf(nomination, now);
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
const entries = Object.entries(nomination.participants);
@@ -78,27 +80,37 @@ const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number
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.flatMap(nomination => {
const status = tickerStatusOf(nomination, now);
return status === null ? [] : [{ nomination, status }];
}).sort((left, right) =>
const rows = nominations.map(nomination => ({
nomination,
status: tickerStatusOf(nomination, now),
})).sort((left, right) =>
RANK[left.status] - RANK[right.status]
|| left.nomination.deadline - right.nomination.deadline
);
if (active.length === 0) return null;
if (rows.length === 0) return null;
return (
<div className="ctp-ticker-stack">
{active.map(({ nomination, status }) => {
{rows.map(({ nomination, status }) => {
const game = gameById.get(nomination.gameId);
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'
const count = status === 'running' || status === 'cancelled'
? `${total} players`
: status === 'scheduled'
? `${total} in`
: `${ready}/${nomination.maxPlayers} ready`;
const time = status === 'ready'
const terminalElapsed = now - (nomination.terminalAt ?? now);
const terminalAge = terminalElapsed < 1_000
? 'just now'
: `${formatCountdownShort(terminalElapsed)} ago`;
const time = status === 'running'
? `started ${terminalAge}`
: status === 'cancelled'
? `cancelled ${terminalAge}`
: status === 'ready'
? 'waiting to start'
: status === 'expired'
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
@@ -13,6 +13,7 @@ import {
formatUntil,
inCountOf,
isReady,
isTerminal,
phaseOf,
readyCountOf,
statusOf,
@@ -82,11 +83,13 @@ export const NominationCard = ({
const isMe = myStatus !== undefined;
const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const terminal = isTerminal(nomination);
const isRunning = nomination.state === 'running';
const isCancelled = nomination.state === 'cancelled';
const isExpired = statusOf(nomination, now) === 'expired';
const phase = phaseOf(nomination, now);
const isScheduled = phase === 'scheduled' && !isDone && !isStarted && !isExpired;
const isCheckin = phase === 'checkin' && !isDone && !isStarted && !isExpired;
const isScheduled = phase === 'scheduled' && !isDone && !terminal && !isExpired;
const isCheckin = phase === 'checkin' && !isDone && !terminal && !isExpired;
const windowStart = nomination.scheduledFor === null
? nomination.createdAt
: nomination.scheduledFor - CHECKIN_LEAD_MS;
@@ -101,8 +104,10 @@ export const NominationCard = ({
: (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
const lanCount = Math.max(totalPeerCount + 1, installedCount);
const timer = isStarted
? <div className="ctp-card-timer" data-urgency="off">Launching</div>
const timer = isRunning
? <div className="ctp-card-timer" data-urgency="off">Running</div>
: isCancelled
? <div className="ctp-card-timer" data-urgency="off">Cancelled</div>
: isExpired
? <div className="ctp-card-timer" data-urgency="high">Times up</div>
: isDone
@@ -123,7 +128,9 @@ export const NominationCard = ({
)
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
const rosterLabel = isScheduled
const rosterLabel = terminal
? `${entries.length} players`
: isScheduled
? `${entries.length} in · up to ${nomination.maxPlayers} players`
: isCheckin && inCount > 0
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
@@ -132,7 +139,7 @@ export const NominationCard = ({
return (
<div
ref={cardRef}
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${terminal ? 'is-terminal' : ''} ${isRunning ? 'is-running' : ''} ${isCancelled ? 'is-cancelled' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
>
<div className="ctp-card-top">
<div className="ctp-card-cover">
@@ -168,10 +175,10 @@ export const NominationCard = ({
<div
className="ctp-progress-fill"
style={{
width: `${isStarted || isDone ? 100 : percentage}%`,
background: isExpired
width: `${terminal || isDone ? 100 : percentage}%`,
background: isExpired || isCancelled
? 'var(--danger)'
: isStarted || isDone
: terminal || isDone
? 'var(--ok)'
: 'var(--accent)',
}}
@@ -211,11 +218,11 @@ export const NominationCard = ({
<CtpChat
nomination={nomination}
actorId={actorId}
disabled={isStarted}
disabled={terminal}
onSend={text => actions.sendMessage(nomination.id, text)}
/>
{isCreator && !isStarted && (
{isCreator && !terminal && (
confirmCancel
? (
<div className="ctp-cancel-confirm">
@@ -283,15 +290,19 @@ const CardActions = ({
const isMe = myStatus !== undefined;
const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const terminal = isTerminal(nomination);
const isExpired = statusOf(nomination, now) === 'expired';
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
const readyCount = readyCountOf(nomination, now);
if (isStarted) {
if (terminal) {
return (
<div className="ctp-note ctp-note-launch">
{game ? `Launching ${game.name}` : 'The match is starting.'}
<div className="ctp-note">
{nomination.state === 'running'
? game
? `${game.name} is running.`
: 'The match is running.'
: 'This call was cancelled.'}
</div>
);
}
@@ -331,7 +342,7 @@ const CardActions = ({
onClick={() => void actions.startNow(nomination.id).then(accepted => {
if (accepted && game) onLaunch(game);
})}
><Icon.play /><span>{game ? 'Start now' : 'Mark as started'}</span></button>
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
Add 5 more minutes
</button>
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { callToPlayEvent, reduceCallToPlayEvents } from '../lib/callToPlay';
import {
callToPlayEvent,
pruneCallToPlayEvents,
reduceCallToPlayEvents,
} from '../lib/callToPlay';
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
export interface CallToPlayActions {
@@ -50,7 +54,11 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
const timer = window.setInterval(() => {
const current = Date.now();
setNow(current);
setEvents(previous => pruneCallToPlayEvents(previous, current));
}, 1_000);
return () => window.clearInterval(timer);
}, []);
@@ -6,14 +6,20 @@ import {
} from './types';
export const CHECKIN_LEAD_MS = 15 * 60_000;
export const STARTED_RETENTION_MS = 3_000;
export const EXPIRED_RETENTION_MS = 5 * 60_000;
export const TERMINAL_RETENTION_MS = 15 * 60_000;
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
export type CallToPlayStatus = 'started' | 'expired' | 'ready' | 'soon' | 'scheduled' | 'call';
export type CallToPlayStatus =
| 'running'
| 'cancelled'
| 'expired'
| 'ready'
| 'soon'
| 'scheduled'
| 'call';
interface MutableNomination extends Nomination {
cancelled: boolean;
messageIds: Set<string>;
}
@@ -57,8 +63,30 @@ export const inCountOf = (nomination: Nomination, now: number): number =>
!isReady(participant, now) && participant.status === 'in'
).length;
export const isTerminal = (nomination: Nomination): boolean =>
nomination.state === 'running' || nomination.state === 'cancelled';
export const activeCallCount = (nominations: ReadonlyArray<Nomination>): number =>
nominations.filter(nomination => !isTerminal(nomination)).length;
export const sortNominations = (
nominations: ReadonlyArray<Nomination>,
): Nomination[] => [...nominations].sort((left, right) => {
const terminalRank = Number(isTerminal(left)) - Number(isTerminal(right));
if (terminalRank !== 0) return terminalRank;
if (isTerminal(left) && isTerminal(right)) {
return (right.terminalAt ?? 0) - (left.terminalAt ?? 0)
|| left.id.localeCompare(right.id);
}
return left.deadline - right.deadline
|| right.createdAt - left.createdAt
|| left.id.localeCompare(right.id);
});
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
if (nomination.state === 'started') return 'started';
if (nomination.state === 'running' || nomination.state === 'cancelled') {
return nomination.state;
}
if (now >= nomination.deadline) return 'expired';
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
return 'ready';
@@ -71,6 +99,39 @@ export const reduceCallToPlayEvents = (
input: ReadonlyArray<CallToPlayEvent>,
now: number,
): Nomination[] => {
const nominations = [...groupEvents(input).values()]
.map(events => deriveNomination(events, now))
.filter((nomination): nomination is Nomination => nomination !== null);
return sortNominations(nominations);
};
export const pruneCallToPlayEvents = (
previous: ReadonlyMap<string, CallToPlayEvent>,
now: number,
): ReadonlyMap<string, CallToPlayEvent> => {
const retiredEventIds = new Set<string>();
for (const events of groupEvents([...previous.values()]).values()) {
if (deriveNomination(events, now) !== null) continue;
const hasCreate = events.some(event => createPayload(event.action) !== null);
const expiredTombstone = events.some(event =>
(event.action === 'Start' || event.action === 'Cancel')
&& now - event.at > TERMINAL_RETENTION_MS
);
if (hasCreate || expiredTombstone) {
for (const event of events) retiredEventIds.add(event.id);
}
}
if (retiredEventIds.size === 0) return previous;
const next = new Map(previous);
for (const eventId of retiredEventIds) next.delete(eventId);
return next;
};
const groupEvents = (
input: ReadonlyArray<CallToPlayEvent>,
): Map<string, CallToPlayEvent[]> => {
const unique = new Map(input.map(event => [event.id, event]));
const byCall = new Map<string, CallToPlayEvent[]>();
for (const event of unique.values()) {
@@ -78,67 +139,63 @@ export const reduceCallToPlayEvents = (
events.push(event);
byCall.set(event.call_id, events);
}
return byCall;
};
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 deriveNomination = (
events: CallToPlayEvent[],
now: number,
): Nomination | null => {
events.sort(compareEvents);
const create = events.find(event => createPayload(event.action) !== null);
if (!create) return null;
const payload = createPayload(create.action);
if (!payload) return null;
const nomination: MutableNomination = {
id: create.call_id,
gameId: payload.game_id,
creatorId: create.actor_id,
creator: create.actor_name,
maxPlayers: payload.max_players,
createdAt: create.at,
scheduledFor: payload.scheduled_for,
deadline: payload.deadline,
participants: {
[create.actor_id]: {
name: create.actor_name,
status: payload.scheduled_for === null ? 'ready' : 'in',
joinedAt: create.at,
},
const nomination: MutableNomination = {
id: create.call_id,
gameId: payload.game_id,
creatorId: create.actor_id,
creator: create.actor_name,
maxPlayers: payload.max_players,
createdAt: create.at,
scheduledFor: payload.scheduled_for,
deadline: payload.deadline,
participants: {
[create.actor_id]: {
name: create.actor_name,
status: payload.scheduled_for === null ? 'ready' : 'in',
joinedAt: create.at,
},
messages: [],
state: 'open',
cancelled: false,
messageIds: new Set(),
};
},
messages: [],
state: 'open',
terminalAt: null,
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;
}
if (nomination.state !== 'started'
&& now - nomination.deadline > EXPIRED_RETENTION_MS
) {
continue;
}
const { cancelled: _, messageIds: __, ...result } = nomination;
nominations.push(result);
for (const event of events) {
if (compareEvents(event, create) > 0) applyEvent(nomination, event);
}
return nominations.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
if (nomination.state === 'open'
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
) {
nomination.state = 'done';
}
if (isTerminal(nomination)) {
if (now - (nomination.terminalAt ?? now) > TERMINAL_RETENTION_MS) return null;
} else if (now - nomination.deadline > EXPIRED_RETENTION_MS) {
return null;
}
const { messageIds: _, ...result } = nomination;
return result;
};
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
if (isTerminal(nomination)) return;
const action = event.action;
if (typeof action === 'string') {
applyUnitAction(nomination, event, action);
@@ -147,7 +204,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const response = respondPayload(action);
if (response) {
if (nomination.state === 'started') return;
const existing = nomination.participants[event.actor_id];
nomination.participants[event.actor_id] = {
name: event.actor_name,
@@ -159,7 +215,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
}
const message = messagePayload(action);
if (message && nomination.state !== 'started' && !nomination.messageIds.has(message.message_id)) {
if (message && !nomination.messageIds.has(message.message_id)) {
nomination.messageIds.add(message.message_id);
nomination.messages.push({
id: message.message_id,
@@ -175,7 +231,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const extension = addTimePayload(action);
if (extension
&& event.actor_id === nomination.creatorId
&& nomination.state !== 'started'
) {
nomination.deadline = extension.deadline;
nomination.state = 'open';
@@ -189,7 +244,6 @@ const applyUnitAction = (
): void => {
switch (action) {
case 'Rsvp': {
if (nomination.state === 'started') return;
const existing = nomination.participants[event.actor_id];
nomination.participants[event.actor_id] = {
name: event.actor_name,
@@ -199,19 +253,20 @@ const applyUnitAction = (
break;
}
case 'Leave':
if (event.actor_id !== nomination.creatorId && nomination.state !== 'started') {
if (event.actor_id !== nomination.creatorId) {
delete nomination.participants[event.actor_id];
}
break;
case 'Cancel':
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
nomination.cancelled = true;
if (event.actor_id === nomination.creatorId) {
nomination.state = 'cancelled';
nomination.terminalAt = event.at;
}
break;
case 'Start':
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
nomination.state = 'started';
nomination.startedAt = event.at;
if (event.actor_id === nomination.creatorId) {
nomination.state = 'running';
nomination.terminalAt = event.at;
}
break;
}
@@ -113,8 +113,8 @@ export interface Nomination {
deadline: number;
participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[];
state: 'open' | 'done' | 'started';
startedAt?: number;
state: 'open' | 'done' | 'running' | 'cancelled';
terminalAt: number | null;
}
export type CallToPlayAction =
@@ -1920,7 +1920,9 @@
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); }
.ctp-card.is-started { opacity: 0.6; }
.ctp-card.is-terminal { opacity: 0.72; }
.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); }
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); }
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
@keyframes ctp-cardflash {
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
@@ -2213,7 +2215,7 @@
/* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
STARTING SOON (amber, glowing) · READY (green, steady) ·
TIME'S UP (red, steady) ─── */
TIME'S UP (red, steady) · RUNNING / CANCELLED (muted receipts) ─── */
.ctp-ticker[data-status="scheduled"] {
background: var(--bg-2);
border-color: var(--bd-2);
@@ -2240,10 +2242,22 @@
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
}
.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); }
.ctp-ticker[data-status="running"] {
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
}
.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); }
.ctp-ticker[data-status="cancelled"] {
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
}
.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); }
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; }
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; }
@keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
@@ -2253,9 +2267,13 @@
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
.ctp-ticker-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); }
.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); }
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
@@ -1,11 +1,15 @@
import {
CHECKIN_LEAD_MS,
EXPIRED_RETENTION_MS,
TERMINAL_RETENTION_MS,
activeCallCount,
phaseOf,
bumpTime,
normalizeTimeInput,
pruneCallToPlayEvents,
readyCountOf,
reduceCallToPlayEvents,
sortNominations,
statusOf,
} from '../src/lib/callToPlay.ts';
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
@@ -148,13 +152,15 @@ Deno.test('creator can extend, start, and cancel a call', () => {
create(),
event('start', 'Alice', 'Start', NOW + 1),
], NOW + 2)[0];
assertEquals(started.state, 'started', 'creator start');
assertEquals(started.state, 'running', 'creator start');
assertEquals(started.terminalAt, NOW + 1, 'running timestamp');
const cancelled = reduceCallToPlayEvents([
const [cancelled] = reduceCallToPlayEvents([
create(),
event('cancel', 'Alice', 'Cancel', NOW + 1),
], NOW + 2);
assertEquals(cancelled.length, 0, 'creator cancel removes call');
assertEquals(cancelled.state, 'cancelled', 'creator cancel');
assertEquals(cancelled.terminalAt, NOW + 1, 'cancel timestamp');
});
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
@@ -181,10 +187,75 @@ Deno.test('actions timestamped before creation cannot mutate a call', () => {
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('terminal calls retain complete read-only history for fifteen minutes', () => {
const events = [
create(),
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('message', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Launching' },
}, NOW + 2),
event('start', 'Alice', 'Start', NOW + 3),
event('late-message', 'Bob', {
SendMessage: { message_id: 'message-2', text: 'Too late' },
}, NOW + 4),
];
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
assertEquals(running.state, 'running', 'running receipt remains');
assertEquals(Object.keys(running.participants).length, 2, 'terminal roster retained');
assertEquals(running.messages.length, 1, 'pre-terminal chat retained');
assertEquals(
reduceCallToPlayEvents(events, NOW + 3 + TERMINAL_RETENTION_MS + 1).length,
0,
'terminal receipt retires after display window',
);
});
Deno.test('terminal calls sort last and do not contribute to the badge', () => {
const active = reduceCallToPlayEvents([create()], NOW)[0];
const running = reduceCallToPlayEvents([
create(),
event('start', 'Alice', 'Start', NOW + 1),
], NOW + 2)[0];
const cancelled = reduceCallToPlayEvents([
create(),
event('cancel', 'Alice', 'Cancel', NOW + 2),
], NOW + 3)[0];
const sorted = sortNominations([running, cancelled, active]);
assertEquals(sorted[0].state, 'open', 'actionable call sorts first');
assertEquals(activeCallCount(sorted), 1, 'terminal calls excluded from badge');
assertEquals(statusOf(running, NOW + 2), 'running', 'running ticker status');
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
});
Deno.test('retired calls are pruned from the frontend raw event map', () => {
const terminalEvents = [
create(),
event('start', 'Alice', 'Start', NOW + 1),
];
const map = new Map(terminalEvents.map(item => [item.id, item]));
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
2,
'visible terminal history stays cached',
);
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
0,
'retired terminal history is pruned',
);
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
assertEquals(
pruneCallToPlayEvents(
tombstoneMap,
NOW + 1 + TERMINAL_RETENTION_MS + 1,
).size,
0,
'backend tombstone is pruned too',
);
});
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
+29 -17
View File
@@ -98,7 +98,7 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
- **Right zone (col 3, flex space-between with two sub-groups):**
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active (non-started) calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active, non-terminal calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
@@ -466,7 +466,7 @@ the call and its history expire as a unit.
### Three surfaces
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
an accent badge counting active (non-`started`) calls. Opens the overlay.
an accent badge counting active, non-terminal calls. Opens the overlay.
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
stack rendered at the top of the grid area, **one row per active call**.
Sorted **ready → starting-soon → the rest**, ties broken by whichever
@@ -474,7 +474,8 @@ the call and its history expire as a unit.
then by `deadline`). Clicking a row opens the overlay focused on that call.
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
the other dialogs) with a header, a **Call a new match** button, the create
form, and a list of **nomination cards** (started calls sink to the bottom).
form, and a list of **nomination cards** (Running and Cancelled calls sink
to the bottom).
### Status model
@@ -482,14 +483,16 @@ Two derived values drive everything (`calltoplay.jsx`):
- `phaseOf(call)``'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
`'checkin'` (within the 15-min lead).
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'expired'`
(deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or state `done`) ·
`'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a clock time) ·
`'call'` (a plain play-now call).
- `statusOf(call)` (quick-bar/label status) → `'running'` · `'cancelled'` ·
`'expired'` (deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or
state `done`) · `'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a
clock time) · `'call'` (a plain play-now call).
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
SOON**, **READY**, **TIME'S UP** (`TICKER_LABEL`), each with its own dot color via
`.ctp-ticker-dot[data-status]`.
SOON**, **READY**, **TIME'S UP**, **RUNNING**, **CANCELLED** (`TICKER_LABEL`),
each with its own dot color via `.ctp-ticker-dot[data-status]`. Running and
Cancelled receipts remain for 15 minutes, sort after actionable calls, and do
not contribute to the top-bar badge.
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
@@ -502,7 +505,7 @@ as larger `AvatarChip`s in the nomination card roster.
Top to bottom:
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, or `Launching…`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, `Running`, or `Cancelled`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
4. **Roster**`readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
@@ -510,6 +513,10 @@ Top to bottom:
6. **Chat** — the collapsible per-call chat panel.
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
Running and Cancelled cards are read-only: the complete roster and chat remain
visible, but participant controls, creator controls, chat composition, and
cancel actions are disabled.
### Create form (`CreateNominationForm`)
Game search (typeahead over the catalog) → on pick, max-players defaults to the
@@ -546,8 +553,8 @@ type Nomination = {
readyAt?: number; // ms epoch a 'pending' buffer elapses
}>;
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
state: 'open' | 'done' | 'started';
startedAt?: number;
state: 'open' | 'done' | 'running' | 'cancelled';
terminalAt: number | null;
};
```
@@ -570,11 +577,16 @@ create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
is an immutable, uniquely identified event. Connected peers receive new events
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
history so a late joiner reconstructs every event and chat message for active
calls. Started and cancelled calls compact to terminal tombstones; expired
calls are removed after the five-minute grace period. The frontend reducer
turns that event history into the `Nomination` state above and derives
time-based phase changes locally. Stable peer IDs identify actors and enforce
creator controls; `settings.username` is only the display name.
calls. Running and Cancelled calls retain their complete history for 15 minutes
so late joiners can see the outcome, roster, and chat, then compact to a Start
or Cancel tombstone for the rest of the peer session. Unresolved calls are
removed after the five-minute post-deadline recovery period. The frontend
reducer turns that event history into the `Nomination` state above, derives
time-based phase changes locally, and prunes retired raw events. Stable peer IDs
identify actors and enforce creator controls; `settings.username` is only the
display name. These deadlines use event wall-clock timestamps, so LAN clocks
are assumed to be reasonably close; no clock-synchronization protocol is
attempted.
---