Compare commits
3
Commits
640d81d919
...
d58307c328
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58307c328
|
||
|
|
383e8f4855
|
||
|
|
cc7dacf6c3
|
@@ -56,11 +56,18 @@ 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
|
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
|
retaining the inactive call's full history. Active calls are never partially
|
||||||
trimmed. If genuinely active history reaches the bound, local publishes return
|
trimmed. If genuinely active history reaches the bound, local publishes return
|
||||||
an error to the caller instead of appearing to succeed. A
|
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
|
||||||
local action is applied to that history, sent to the UI, and broadcast to every
|
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
|
currently known peer. An incoming live event is applied once and sent to the UI
|
||||||
without being rebroadcast, which prevents forwarding loops.
|
without being rebroadcast, which prevents forwarding loops.
|
||||||
|
|
||||||
|
If a live Call to Play delivery fails, the sender immediately falls back to a
|
||||||
|
normal `Hello` / `HelloAck` exchange with that peer. The handshake carries the
|
||||||
|
full active history in both directions, so a transient request failure heals
|
||||||
|
without waiting for mDNS rediscovery or a later reconnect.
|
||||||
|
|
||||||
Actors are keyed by the peer's stable ID and carry a separate display name. The
|
Actors are keyed by the peer's stable ID and carry a separate display name. The
|
||||||
origin peer overwrites the actor ID on local actions, and live-event envelopes
|
origin peer overwrites the actor ID on local actions, and live-event envelopes
|
||||||
must match the known sending peer. This prevents duplicate default usernames
|
must match the known sending peer. This prevents duplicate default usernames
|
||||||
@@ -72,6 +79,10 @@ uses the project's trusted-LAN identity model.
|
|||||||
join after a call was created reconstruct the same nominations, responses,
|
join after a call was created reconstruct the same nominations, responses,
|
||||||
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
|
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
|
||||||
deterministically and derives deadlines and check-in phases from timestamps.
|
deterministically and derives deadlines and check-in phases from timestamps.
|
||||||
|
Those phases compare creator-supplied wall-clock timestamps with each viewer's
|
||||||
|
local wall clock, so LAN machines are assumed to be synchronized closely enough
|
||||||
|
for human-scale minute countdowns; clock skew shifts the displayed boundary by
|
||||||
|
the same amount.
|
||||||
There is deliberately no compatibility path for older protocol versions.
|
There is deliberately no compatibility path for older protocol versions.
|
||||||
|
|
||||||
### 4) Shutdown
|
### 4) Shutdown
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
//! Replicated event history for Call to Play coordination.
|
//! Replicated event history for Call to Play coordination.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
use crate::{PeerEvent, context::Ctx, events, network::send_call_to_play_events};
|
use crate::{
|
||||||
|
PeerEvent,
|
||||||
|
context::Ctx,
|
||||||
|
events,
|
||||||
|
network::send_call_to_play_events,
|
||||||
|
services::{HandshakeCtx, perform_handshake_with_peer},
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_EVENTS: usize = 4_096;
|
const MAX_EVENTS: usize = 4_096;
|
||||||
const MAX_ID_CHARS: usize = 128;
|
const MAX_ID_CHARS: usize = 128;
|
||||||
const MAX_GAME_ID_CHARS: usize = 256;
|
const MAX_GAME_ID_CHARS: usize = 256;
|
||||||
const MAX_USERNAME_CHARS: usize = 24;
|
const MAX_USERNAME_CHARS: usize = 24;
|
||||||
const MAX_MESSAGE_CHARS: usize = 500;
|
const MAX_MESSAGE_CHARS: usize = 500;
|
||||||
|
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub(crate) struct CallToPlayStore {
|
pub(crate) struct CallToPlayStore {
|
||||||
@@ -20,7 +30,12 @@ pub(crate) struct CallToPlayStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CallToPlayStore {
|
impl CallToPlayStore {
|
||||||
pub(crate) fn snapshot(&self) -> Vec<CallToPlayEvent> {
|
pub(crate) fn snapshot(&mut self) -> Vec<CallToPlayEvent> {
|
||||||
|
self.snapshot_at(now_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_at(&mut self, now: i64) -> Vec<CallToPlayEvent> {
|
||||||
|
self.compact_inactive_calls(now);
|
||||||
self.events.clone()
|
self.events.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +48,7 @@ impl CallToPlayStore {
|
|||||||
let event_id = event.id.clone();
|
let event_id = event.id.clone();
|
||||||
self.event_ids.insert(event_id.clone());
|
self.event_ids.insert(event_id.clone());
|
||||||
self.events.push(event);
|
self.events.push(event);
|
||||||
self.evict_terminal_calls();
|
self.compact_inactive_calls(now_ms());
|
||||||
if self.events.len() > MAX_EVENTS {
|
if self.events.len() > MAX_EVENTS {
|
||||||
self.events.retain(|event| event.id != event_id);
|
self.events.retain(|event| event.id != event_id);
|
||||||
self.event_ids.remove(&event_id);
|
self.event_ids.remove(&event_id);
|
||||||
@@ -54,13 +69,13 @@ impl CallToPlayStore {
|
|||||||
accepted
|
accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evict_terminal_calls(&mut self) {
|
fn compact_inactive_calls(&mut self, now: i64) {
|
||||||
let mut creators = HashMap::<String, (i64, String, String)>::new();
|
let mut creators = HashMap::<String, (i64, String, String, i64)>::new();
|
||||||
for event in &self.events {
|
for event in &self.events {
|
||||||
if !matches!(event.action, CallToPlayAction::Create { .. }) {
|
let CallToPlayAction::Create { deadline, .. } = event.action else {
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let candidate = (event.at, event.id.clone(), event.actor_id.clone());
|
let candidate = (event.at, event.id.clone(), event.actor_id.clone(), deadline);
|
||||||
creators
|
creators
|
||||||
.entry(event.call_id.clone())
|
.entry(event.call_id.clone())
|
||||||
.and_modify(|current| {
|
.and_modify(|current| {
|
||||||
@@ -73,7 +88,7 @@ impl CallToPlayStore {
|
|||||||
|
|
||||||
let mut terminal_events = HashMap::<String, (i64, String)>::new();
|
let mut terminal_events = HashMap::<String, (i64, String)>::new();
|
||||||
for event in &self.events {
|
for event in &self.events {
|
||||||
let Some((created_at, create_id, creator_id)) = creators.get(&event.call_id) else {
|
let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !matches!(
|
if !matches!(
|
||||||
@@ -94,7 +109,42 @@ impl CallToPlayStore {
|
|||||||
})
|
})
|
||||||
.or_insert(candidate);
|
.or_insert(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut extensions = HashMap::<String, (i64, String, i64)>::new();
|
||||||
|
for event in &self.events {
|
||||||
|
let CallToPlayAction::AddTime { deadline } = event.action else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if event.actor_id != *creator_id || (event.at, &event.id) <= (*created_at, create_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let candidate = (event.at, event.id.clone(), deadline);
|
||||||
|
extensions
|
||||||
|
.entry(event.call_id.clone())
|
||||||
|
.and_modify(|current| {
|
||||||
|
if (candidate.0, &candidate.1) > (current.0, ¤t.1) {
|
||||||
|
current.clone_from(&candidate);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(candidate);
|
||||||
|
}
|
||||||
|
let expired_calls = creators
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(call_id, (_, _, _, original_deadline))| {
|
||||||
|
let deadline = extensions
|
||||||
|
.get(call_id)
|
||||||
|
.map_or(*original_deadline, |(_, _, deadline)| *deadline);
|
||||||
|
(now - deadline > EXPIRED_RETENTION_MS).then(|| call_id.clone())
|
||||||
|
})
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
|
||||||
self.events.retain(|event| {
|
self.events.retain(|event| {
|
||||||
|
if expired_calls.contains(&event.call_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
terminal_events
|
terminal_events
|
||||||
.get(&event.call_id)
|
.get(&event.call_id)
|
||||||
.is_none_or(|(_, terminal_id)| event.id == *terminal_id)
|
.is_none_or(|(_, terminal_id)| event.id == *terminal_id)
|
||||||
@@ -102,6 +152,16 @@ impl CallToPlayStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
i64::try_from(
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis(),
|
||||||
|
)
|
||||||
|
.unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn publish(
|
pub(crate) async fn publish(
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
@@ -124,15 +184,24 @@ pub(crate) async fn publish(
|
|||||||
|
|
||||||
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
|
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
|
||||||
let peer_id = ctx.peer_id.clone();
|
let peer_id = ctx.peer_id.clone();
|
||||||
|
let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui);
|
||||||
ctx.task_tracker.spawn(async move {
|
ctx.task_tracker.spawn(async move {
|
||||||
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
|
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
|
||||||
let event = event.clone();
|
let event = event.clone();
|
||||||
let peer_id = peer_id.clone();
|
let peer_id = peer_id.clone();
|
||||||
|
let handshake_ctx = handshake_ctx.clone();
|
||||||
async move {
|
async move {
|
||||||
if let Err(err) =
|
if let Err(err) =
|
||||||
send_call_to_play_events(peer_addr, peer_id.as_ref(), vec![event]).await
|
send_call_to_play_events(peer_addr, peer_id.as_ref(), vec![event]).await
|
||||||
{
|
{
|
||||||
log::warn!("Failed to send Call to Play event to {peer_addr}: {err}");
|
log::warn!("Failed to send Call to Play event to {peer_addr}: {err}");
|
||||||
|
if let Err(resync_err) =
|
||||||
|
perform_handshake_with_peer(handshake_ctx, peer_addr, None).await
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Failed to resync Call to Play history with {peer_addr}: {resync_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -208,18 +277,20 @@ mod tests {
|
|||||||
|
|
||||||
use super::{CallToPlayStore, MAX_EVENTS};
|
use super::{CallToPlayStore, MAX_EVENTS};
|
||||||
|
|
||||||
|
const TEST_NOW: i64 = 8_000_000_000_000;
|
||||||
|
|
||||||
fn create_event(id: &str) -> CallToPlayEvent {
|
fn create_event(id: &str) -> CallToPlayEvent {
|
||||||
CallToPlayEvent {
|
CallToPlayEvent {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
call_id: "call-1".to_string(),
|
call_id: "call-1".to_string(),
|
||||||
actor_id: "peer-alice".to_string(),
|
actor_id: "peer-alice".to_string(),
|
||||||
actor_name: "Alice".to_string(),
|
actor_name: "Alice".to_string(),
|
||||||
at: 1_000,
|
at: TEST_NOW,
|
||||||
action: CallToPlayAction::Create {
|
action: CallToPlayAction::Create {
|
||||||
game_id: "game-1".to_string(),
|
game_id: "game-1".to_string(),
|
||||||
max_players: 4,
|
max_players: 4,
|
||||||
scheduled_for: None,
|
scheduled_for: None,
|
||||||
deadline: 61_000,
|
deadline: TEST_NOW + 60_000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +301,7 @@ mod tests {
|
|||||||
call_id: call_id.to_string(),
|
call_id: call_id.to_string(),
|
||||||
actor_id: "peer-alice".to_string(),
|
actor_id: "peer-alice".to_string(),
|
||||||
actor_name: "Alice".to_string(),
|
actor_name: "Alice".to_string(),
|
||||||
at: 2_000,
|
at: TEST_NOW + 1_000,
|
||||||
action,
|
action,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,4 +431,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(store.snapshot().len(), MAX_EVENTS);
|
assert_eq!(store.snapshot().len(), MAX_EVENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_call_history_is_evicted_as_a_unit() {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
store
|
||||||
|
.insert(create_event("create"))
|
||||||
|
.expect("active call should fit");
|
||||||
|
|
||||||
|
assert!(store.snapshot_at(TEST_NOW + 5 * 60_000 + 60_001).is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ async fn handle_peer_commands(
|
|||||||
let _ = reply.send(result);
|
let _ = reply.send(result);
|
||||||
}
|
}
|
||||||
PeerCommand::GetCallToPlayEvents { reply } => {
|
PeerCommand::GetCallToPlayEvents { reply } => {
|
||||||
let events = ctx.call_to_play.read().await.snapshot();
|
let events = ctx.call_to_play.write().await.snapshot();
|
||||||
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(events.clone()));
|
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(events.clone()));
|
||||||
if let Some(reply) = reply {
|
if let Some(reply) = reply {
|
||||||
let _ = reply.send(events);
|
let _ = reply.send(events);
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result<HelloAck> {
|
|||||||
let library_guard = ctx.local_library.read().await;
|
let library_guard = ctx.local_library.read().await;
|
||||||
build_library_snapshot(&library_guard)
|
build_library_snapshot(&library_guard)
|
||||||
};
|
};
|
||||||
let call_to_play_events = ctx.call_to_play.read().await.snapshot();
|
let call_to_play_events = ctx.call_to_play.write().await.snapshot();
|
||||||
Ok(HelloAck {
|
Ok(HelloAck {
|
||||||
peer_id: ctx.peer_id.as_ref().clone(),
|
peer_id: ctx.peer_id.as_ref().clone(),
|
||||||
proto_ver: PROTOCOL_VERSION,
|
proto_ver: PROTOCOL_VERSION,
|
||||||
@@ -84,7 +84,7 @@ async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result<Hello> {
|
|||||||
let library_guard = ctx.local_library.read().await;
|
let library_guard = ctx.local_library.read().await;
|
||||||
build_library_snapshot(&library_guard)
|
build_library_snapshot(&library_guard)
|
||||||
};
|
};
|
||||||
let call_to_play_events = ctx.call_to_play.read().await.snapshot();
|
let call_to_play_events = ctx.call_to_play.write().await.snapshot();
|
||||||
Ok(Hello {
|
Ok(Hello {
|
||||||
peer_id: ctx.peer_id.as_ref().clone(),
|
peer_id: ctx.peer_id.as_ref().clone(),
|
||||||
proto_ver: PROTOCOL_VERSION,
|
proto_ver: PROTOCOL_VERSION,
|
||||||
@@ -315,12 +315,12 @@ mod tests {
|
|||||||
call_id: "call-1".to_string(),
|
call_id: "call-1".to_string(),
|
||||||
actor_id: "peer-alice".to_string(),
|
actor_id: "peer-alice".to_string(),
|
||||||
actor_name: "Alice".to_string(),
|
actor_name: "Alice".to_string(),
|
||||||
at: 1_000,
|
at: 8_000_000_000_000,
|
||||||
action: CallToPlayAction::Create {
|
action: CallToPlayAction::Create {
|
||||||
game_id: "game".to_string(),
|
game_id: "game".to_string(),
|
||||||
max_players: 4,
|
max_players: 4,
|
||||||
scheduled_for: None,
|
scheduled_for: None,
|
||||||
deadline: 61_000,
|
deadline: 8_000_000_060_000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,7 +538,7 @@ mod tests {
|
|||||||
.expect("current protocol hello should be accepted");
|
.expect("current protocol hello should be accepted");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ctx.call_to_play.read().await.snapshot(),
|
ctx.call_to_play.write().await.snapshot(),
|
||||||
[call_to_play_event()]
|
[call_to_play_event()]
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
isReady,
|
isReady,
|
||||||
readyCountOf,
|
readyCountOf,
|
||||||
statusOf,
|
statusOf,
|
||||||
|
type CallToPlayStatus,
|
||||||
} from '../../lib/callToPlay';
|
} from '../../lib/callToPlay';
|
||||||
import { Game, Nomination } from '../../lib/types';
|
import { Game, Nomination } from '../../lib/types';
|
||||||
|
|
||||||
@@ -25,9 +26,23 @@ const LABEL = {
|
|||||||
call: 'Call to Play',
|
call: 'Call to Play',
|
||||||
soon: 'Starting soon',
|
soon: 'Starting soon',
|
||||||
ready: 'Ready',
|
ready: 'Ready',
|
||||||
|
expired: 'Time’s up',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const RANK = { ready: 0, soon: 1, call: 2, scheduled: 2, started: 3 } as const;
|
type TickerStatus = Exclude<CallToPlayStatus, 'started'>;
|
||||||
|
|
||||||
|
const RANK: Record<TickerStatus, number> = {
|
||||||
|
expired: 0,
|
||||||
|
ready: 1,
|
||||||
|
soon: 2,
|
||||||
|
call: 3,
|
||||||
|
scheduled: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus | null => {
|
||||||
|
const status = statusOf(nomination, now);
|
||||||
|
return status === 'started' ? null : status;
|
||||||
|
};
|
||||||
|
|
||||||
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
||||||
const entries = Object.entries(nomination.participants);
|
const entries = Object.entries(nomination.participants);
|
||||||
@@ -63,20 +78,19 @@ const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number
|
|||||||
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
|
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const gameById = new Map(games.map(game => [game.id, game]));
|
const gameById = new Map(games.map(game => [game.id, game]));
|
||||||
const active = nominations
|
const active = nominations.flatMap(nomination => {
|
||||||
.filter(nomination => nomination.state !== 'started')
|
const status = tickerStatusOf(nomination, now);
|
||||||
.sort((left, right) =>
|
return status === null ? [] : [{ nomination, status }];
|
||||||
RANK[statusOf(left, now)] - RANK[statusOf(right, now)]
|
}).sort((left, right) =>
|
||||||
|| left.deadline - right.deadline
|
RANK[left.status] - RANK[right.status]
|
||||||
);
|
|| left.nomination.deadline - right.nomination.deadline
|
||||||
|
);
|
||||||
if (active.length === 0) return null;
|
if (active.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ctp-ticker-stack">
|
<div className="ctp-ticker-stack">
|
||||||
{active.map(nomination => {
|
{active.map(({ nomination, status }) => {
|
||||||
const game = gameById.get(nomination.gameId);
|
const game = gameById.get(nomination.gameId);
|
||||||
const status = statusOf(nomination, now);
|
|
||||||
if (status === 'started') return null;
|
|
||||||
const ready = readyCountOf(nomination, now);
|
const ready = readyCountOf(nomination, now);
|
||||||
const total = Object.keys(nomination.participants).length;
|
const total = Object.keys(nomination.participants).length;
|
||||||
const remaining = Math.max(0, nomination.deadline - now);
|
const remaining = Math.max(0, nomination.deadline - now);
|
||||||
@@ -86,6 +100,8 @@ export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props)
|
|||||||
: `${ready}/${nomination.maxPlayers} ready`;
|
: `${ready}/${nomination.maxPlayers} ready`;
|
||||||
const time = status === 'ready'
|
const time = status === 'ready'
|
||||||
? 'waiting to start'
|
? 'waiting to start'
|
||||||
|
: status === 'expired'
|
||||||
|
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
|
||||||
: status === 'scheduled'
|
: status === 'scheduled'
|
||||||
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
|
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
|
||||||
: nomination.scheduledFor !== null
|
: nomination.scheduledFor !== null
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
isReady,
|
isReady,
|
||||||
phaseOf,
|
phaseOf,
|
||||||
readyCountOf,
|
readyCountOf,
|
||||||
|
statusOf,
|
||||||
} from '../../lib/callToPlay';
|
} from '../../lib/callToPlay';
|
||||||
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
|
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
|
||||||
|
|
||||||
@@ -82,9 +83,10 @@ export const NominationCard = ({
|
|||||||
const isCreator = nomination.creatorId === actorId;
|
const isCreator = nomination.creatorId === actorId;
|
||||||
const isDone = nomination.state === 'done';
|
const isDone = nomination.state === 'done';
|
||||||
const isStarted = nomination.state === 'started';
|
const isStarted = nomination.state === 'started';
|
||||||
|
const isExpired = statusOf(nomination, now) === 'expired';
|
||||||
const phase = phaseOf(nomination, now);
|
const phase = phaseOf(nomination, now);
|
||||||
const isScheduled = phase === 'scheduled' && !isDone && !isStarted;
|
const isScheduled = phase === 'scheduled' && !isDone && !isStarted && !isExpired;
|
||||||
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
|
const isCheckin = phase === 'checkin' && !isDone && !isStarted && !isExpired;
|
||||||
const windowStart = nomination.scheduledFor === null
|
const windowStart = nomination.scheduledFor === null
|
||||||
? nomination.createdAt
|
? nomination.createdAt
|
||||||
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
||||||
@@ -101,6 +103,8 @@ export const NominationCard = ({
|
|||||||
|
|
||||||
const timer = isStarted
|
const timer = isStarted
|
||||||
? <div className="ctp-card-timer" data-urgency="off">Launching…</div>
|
? <div className="ctp-card-timer" data-urgency="off">Launching…</div>
|
||||||
|
: isExpired
|
||||||
|
? <div className="ctp-card-timer" data-urgency="high">Time’s up</div>
|
||||||
: isDone
|
: isDone
|
||||||
? <div className="ctp-card-timer" data-urgency="off">Ready</div>
|
? <div className="ctp-card-timer" data-urgency="off">Ready</div>
|
||||||
: isScheduled
|
: isScheduled
|
||||||
@@ -128,7 +132,7 @@ export const NominationCard = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
|
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="ctp-card-top">
|
<div className="ctp-card-top">
|
||||||
<div className="ctp-card-cover">
|
<div className="ctp-card-cover">
|
||||||
@@ -165,7 +169,11 @@ export const NominationCard = ({
|
|||||||
className="ctp-progress-fill"
|
className="ctp-progress-fill"
|
||||||
style={{
|
style={{
|
||||||
width: `${isStarted || isDone ? 100 : percentage}%`,
|
width: `${isStarted || isDone ? 100 : percentage}%`,
|
||||||
background: isStarted || isDone ? 'var(--ok)' : 'var(--accent)',
|
background: isExpired
|
||||||
|
? 'var(--danger)'
|
||||||
|
: isStarted || isDone
|
||||||
|
? 'var(--ok)'
|
||||||
|
: 'var(--accent)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,6 +284,7 @@ const CardActions = ({
|
|||||||
const isCreator = nomination.creatorId === actorId;
|
const isCreator = nomination.creatorId === actorId;
|
||||||
const isDone = nomination.state === 'done';
|
const isDone = nomination.state === 'done';
|
||||||
const isStarted = nomination.state === 'started';
|
const isStarted = nomination.state === 'started';
|
||||||
|
const isExpired = statusOf(nomination, now) === 'expired';
|
||||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
|
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
|
||||||
const readyCount = readyCountOf(nomination, now);
|
const readyCount = readyCountOf(nomination, now);
|
||||||
|
|
||||||
@@ -337,7 +346,9 @@ const CardActions = ({
|
|||||||
if (isDone) {
|
if (isDone) {
|
||||||
return (
|
return (
|
||||||
<div className="ctp-note">
|
<div className="ctp-note">
|
||||||
{readyCount >= nomination.maxPlayers
|
{isExpired
|
||||||
|
? `Time’s up — waiting for ${nomination.creator} to start or extend the call.`
|
||||||
|
: readyCount >= nomination.maxPlayers
|
||||||
? 'Everyone’s ready.'
|
? 'Everyone’s ready.'
|
||||||
: `It’s time — waiting for ${nomination.creator} to start.`}
|
: `It’s time — waiting for ${nomination.creator} to start.`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
|
|
||||||
export const CHECKIN_LEAD_MS = 15 * 60_000;
|
export const CHECKIN_LEAD_MS = 15 * 60_000;
|
||||||
export const STARTED_RETENTION_MS = 3_000;
|
export const STARTED_RETENTION_MS = 3_000;
|
||||||
|
export const EXPIRED_RETENTION_MS = 5 * 60_000;
|
||||||
|
|
||||||
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
|
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
|
||||||
export type CallToPlayStatus = 'started' | 'ready' | 'soon' | 'scheduled' | 'call';
|
export type CallToPlayStatus = 'started' | 'expired' | 'ready' | 'soon' | 'scheduled' | 'call';
|
||||||
|
|
||||||
interface MutableNomination extends Nomination {
|
interface MutableNomination extends Nomination {
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
@@ -58,6 +59,7 @@ export const inCountOf = (nomination: Nomination, now: number): number =>
|
|||||||
|
|
||||||
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
|
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
|
||||||
if (nomination.state === 'started') return 'started';
|
if (nomination.state === 'started') return 'started';
|
||||||
|
if (now >= nomination.deadline) return 'expired';
|
||||||
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
|
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
|
||||||
return 'ready';
|
return 'ready';
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,11 @@ export const reduceCallToPlayEvents = (
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (nomination.state !== 'started'
|
||||||
|
&& now - nomination.deadline > EXPIRED_RETENTION_MS
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { cancelled: _, messageIds: __, ...result } = nomination;
|
const { cancelled: _, messageIds: __, ...result } = nomination;
|
||||||
nominations.push(result);
|
nominations.push(result);
|
||||||
|
|||||||
@@ -1919,6 +1919,7 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
|
.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-started { opacity: 0.6; }
|
||||||
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
|
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
|
||||||
@keyframes ctp-cardflash {
|
@keyframes ctp-cardflash {
|
||||||
@@ -1956,6 +1957,7 @@
|
|||||||
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
|
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
|
||||||
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
|
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
|
||||||
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
|
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.ctp-card.is-expired .ctp-card-timer { color: var(--danger); }
|
||||||
.ctp-cancel-link {
|
.ctp-cancel-link {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@@ -2210,7 +2212,8 @@
|
|||||||
|
|
||||||
/* ─── Quick-bar status variants — LED + label + row tint per status:
|
/* ─── Quick-bar status variants — LED + label + row tint per status:
|
||||||
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
||||||
STARTING SOON (amber, glowing) · READY (green, steady) ─── */
|
STARTING SOON (amber, glowing) · READY (green, steady) ·
|
||||||
|
TIME'S UP (red, steady) ─── */
|
||||||
.ctp-ticker[data-status="scheduled"] {
|
.ctp-ticker[data-status="scheduled"] {
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
border-color: var(--bd-2);
|
border-color: var(--bd-2);
|
||||||
@@ -2232,9 +2235,15 @@
|
|||||||
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
|
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
|
||||||
}
|
}
|
||||||
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
|
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
|
||||||
|
.ctp-ticker[data-status="expired"] {
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||||
|
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-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
|
.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="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="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; }
|
||||||
@keyframes ctp-tickerpulse-warn {
|
@keyframes ctp-tickerpulse-warn {
|
||||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
|
||||||
70% { box-shadow: 0 0 0 6px transparent; }
|
70% { box-shadow: 0 0 0 6px transparent; }
|
||||||
@@ -2243,8 +2252,10 @@
|
|||||||
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
|
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
|
||||||
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
|
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
|
||||||
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
|
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
|
||||||
|
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
|
||||||
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
|
.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="ready"] .ctp-ticker-cta { color: var(--ok); }
|
||||||
|
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
|
||||||
|
|
||||||
/* ─── Quick-bar inline chat preview ─── */
|
/* ─── Quick-bar inline chat preview ─── */
|
||||||
.ctp-ticker-chat {
|
.ctp-ticker-chat {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CHECKIN_LEAD_MS,
|
CHECKIN_LEAD_MS,
|
||||||
|
EXPIRED_RETENTION_MS,
|
||||||
phaseOf,
|
phaseOf,
|
||||||
bumpTime,
|
bumpTime,
|
||||||
normalizeTimeInput,
|
normalizeTimeInput,
|
||||||
@@ -89,6 +90,19 @@ Deno.test('call resolves when the roster fills or its deadline elapses', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('elapsed calls show as expired briefly and then disappear', () => {
|
||||||
|
const deadline = NOW + 30 * 60_000;
|
||||||
|
const events = [create(null, deadline)];
|
||||||
|
const [expired] = reduceCallToPlayEvents(events, deadline + 1);
|
||||||
|
assert(expired, 'freshly expired call remains visible');
|
||||||
|
assertEquals(statusOf(expired, deadline + 1), 'expired', 'elapsed status');
|
||||||
|
assertEquals(
|
||||||
|
reduceCallToPlayEvents(events, deadline + EXPIRED_RETENTION_MS + 1).length,
|
||||||
|
0,
|
||||||
|
'expired call retention',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test('creator-only controls cannot be forged by another participant', () => {
|
Deno.test('creator-only controls cannot be forged by another participant', () => {
|
||||||
const forged = [
|
const forged = [
|
||||||
create(),
|
create(),
|
||||||
|
|||||||
+16
-9
@@ -457,6 +457,10 @@ flips into the `checkin` phase: it lights up everywhere and everyone who said
|
|||||||
"I'm in" is nudged to answer with the same `Ready now` / `+N minutes` states as
|
"I'm in" is nudged to answer with the same `Ready now` / `+N minutes` states as
|
||||||
a play-now call. A play-now call is effectively "always in its check-in window."
|
a play-now call. A play-now call is effectively "always in its check-in window."
|
||||||
|
|
||||||
|
When the deadline passes, the call is labeled **Time's up** rather than Ready.
|
||||||
|
It remains visible for five minutes so the caller can start or extend it, then
|
||||||
|
the call and its history expire as a unit.
|
||||||
|
|
||||||
**Every call carries a small group chat** (see "Per-call chat" below).
|
**Every call carries a small group chat** (see "Per-call chat" below).
|
||||||
|
|
||||||
### Three surfaces
|
### Three surfaces
|
||||||
@@ -478,12 +482,13 @@ Two derived values drive everything (`calltoplay.jsx`):
|
|||||||
|
|
||||||
- `phaseOf(call)` → `'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
|
- `phaseOf(call)` → `'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
|
||||||
`'checkin'` (within the 15-min lead).
|
`'checkin'` (within the 15-min lead).
|
||||||
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'ready'`
|
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'expired'`
|
||||||
(`readyCount >= maxPlayers`, or state `done`) · `'soon'` (`deadline - now ≤
|
(deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or state `done`) ·
|
||||||
15 min`) · `'scheduled'` (has a clock time) · `'call'` (a plain play-now call).
|
`'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
|
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
|
||||||
SOON**, **READY** (`TICKER_LABEL`), each with its own dot color via
|
SOON**, **READY**, **TIME'S UP** (`TICKER_LABEL`), each with its own dot color via
|
||||||
`.ctp-ticker-dot[data-status]`.
|
`.ctp-ticker-dot[data-status]`.
|
||||||
|
|
||||||
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
|
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
|
||||||
@@ -497,7 +502,7 @@ as larger `AvatarChip`s in the nomination card roster.
|
|||||||
|
|
||||||
Top to bottom:
|
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`, or `Launching…`. 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`, 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.
|
||||||
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).
|
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.
|
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`.
|
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`.
|
||||||
@@ -564,10 +569,12 @@ The production launcher uses the peer's existing QUIC control channel. Each
|
|||||||
create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
|
create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
|
||||||
is an immutable, uniquely identified event. Connected peers receive new events
|
is an immutable, uniquely identified event. Connected peers receive new events
|
||||||
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
|
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
|
||||||
history so a late joiner reconstructs the same calls. The frontend reducer turns
|
history so a late joiner reconstructs every event and chat message for active
|
||||||
that event history into the `Nomination` state above and derives time-based phase
|
calls. Started and cancelled calls compact to terminal tombstones; expired
|
||||||
changes locally. The `username` that identifies "you" comes from
|
calls are removed after the five-minute grace period. The frontend reducer
|
||||||
`settings.username` (the Profile setting), not a prop default.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user