feat(call-to-play)!: coordinate game sessions across peers

Implement the launcher design as a production peer-to-peer feature. Call to
Play actions are immutable, validated events broadcast over the existing QUIC
control channel, deduplicated in a bounded in-memory history, and exchanged in
Hello/HelloAck so late joiners reconstruct current calls.

Add the Tauri bridge and modular launcher surfaces for play-now and scheduled
calls, check-in, readiness buffers, role-aware controls, chat, tickers, and
actual caller launch. A deterministic frontend reducer derives presentation
state from replicated history. Extend the JSONL peer harness with publish/list
commands and a three-peer live-delivery and late-join scenario.

This intentionally raises the only supported wire protocol from version 5 to
version 6; older builds are not supported. Document the transport architecture
and exclude generated peer-test state from Docker build contexts.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 20 tests
- `just build` -- passed
- `just peer-cli-tests S2 S48` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-07-21 22:30:11 +02:00
parent 8f151e38b4
commit 0f53bc4b78
31 changed files with 3032 additions and 21 deletions
+1
View File
@@ -1,6 +1,7 @@
.git .git
.agents .agents
.codex .codex
.lanspread-peer-cli
target target
**/target **/target
**/node_modules **/node_modules
+9
View File
@@ -55,6 +55,7 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
| S45 | Sender disconnect during streamed install | A source serves large catalog-version `alienswarm`; after the client receives the first streamed chunk, the source container is killed. | The operation reaches a terminal failure/peers-gone event, emits no download/install success, clears active operations, and rolls back local/staging state. | | S45 | Sender disconnect during streamed install | A source serves large catalog-version `alienswarm`; after the client receives the first streamed chunk, the source container is killed. | The operation reaches a terminal failure/peers-gone event, emits no download/install success, clears active operations, and rolls back local/staging state. |
| S46 | Receiver cancel during streamed install | A client starts streaming large catalog-version `alienswarm`, receives the first chunk, then sends `cancel-download alienswarm`. | The receiver cancels without emitting download/install success or a user-visible download failure, clears active operations, and rolls back local/staging state. | | S46 | Receiver cancel during streamed install | A client starts streaming large catalog-version `alienswarm`, receives the first chunk, then sends `cancel-download alienswarm`. | The receiver cancels without emitting download/install success or a user-visible download failure, clears active operations, and rolls back local/staging state. |
| S47 | Multi-archive streamed install order | A source serves `fixture-multi/cnctw` with two root `.eti` archives named to require sorted processing. | Streamed chunk paths arrive in root archive sort order, both payloads install under `local/`, the receiver is local-only installed, and no root archives or sentinel are committed. | | S47 | Multi-archive streamed install order | A source serves `fixture-multi/cnctw` with two root `.eti` archives named to require sorted processing. | Streamed chunk paths arrive in root archive sort order, both payloads install under `local/`, the receiver is local-only installed, and no root archives or sentinel are committed. |
| S48 | Call to Play replication and late join | Alice and Bob connect; Alice publishes a call, then Bob publishes an RSVP and chat message. Charlie joins afterward and handshakes with Alice. | Alice and Bob receive the live events, while Charlie reconstructs the same three-event history during handshake with no duplicate event IDs. |
## Version-Skew Contract ## Version-Skew Contract
@@ -143,6 +144,14 @@ Use S39-S41 to pin down low-disk streamed installs:
## Run Log ## Run Log
### 2026-07-21 - Call to Play Transport (S48)
- Added JSONL commands to publish and inspect Call to Play events.
- S2 passed against the rebuilt image, preserving bidirectional library
exchange after the protocol version bump.
- S48 passed against the rebuilt image: create, RSVP, and chat propagated live,
then a late third peer received the same deduplicated history in handshake.
### 2026-06-21 - Test-Suite Integrity Audit And Hardening ### 2026-06-21 - Test-Suite Integrity Audit And Hardening
- An adversarial review of `run_extended_scenarios.py` found assertions that - An adversarial review of `run_extended_scenarios.py` found assertions that
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run the peer-cli scenarios S1-S47 through Docker.""" """Run the peer-cli scenarios S1-S48 through Docker."""
from __future__ import annotations from __future__ import annotations
@@ -242,6 +242,12 @@ class Peer:
def status(self) -> dict[str, Any]: def status(self) -> dict[str, Any]:
return self.send({"cmd": "status"})["data"] return self.send({"cmd": "status"})["data"]
def call_to_play_events(self) -> list[dict[str, Any]]:
return self.send({"cmd": "list-call-to-play"})["data"]["events"]
def publish_call_to_play(self, event: dict[str, Any]) -> None:
self.send({"cmd": "publish-call-to-play", "event": event})
def connect_to(self, other: "Peer") -> None: def connect_to(self, other: "Peer") -> None:
if other.ready_addr is None: if other.ready_addr is None:
raise ScenarioError(f"{other.name} is not ready") raise ScenarioError(f"{other.name} is not ready")
@@ -350,6 +356,7 @@ class Runner:
("S45", self.s45_sender_disconnect_mid_stream), ("S45", self.s45_sender_disconnect_mid_stream),
("S46", self.s46_receiver_cancel_mid_stream), ("S46", self.s46_receiver_cancel_mid_stream),
("S47", self.s47_multi_archive_streams_in_sorted_order), ("S47", self.s47_multi_archive_streams_in_sorted_order),
("S48", self.s48_call_to_play_replication_and_late_join),
] ]
for scenario_id, scenario in scenarios: for scenario_id, scenario in scenarios:
@@ -1757,6 +1764,63 @@ class Runner:
return f"multi-archive cnctw streamed in sorted order: {chunk_paths}" return f"multi-archive cnctw streamed in sorted order: {chunk_paths}"
def s48_call_to_play_replication_and_late_join(self) -> str:
alice = self.peer("s48-alice")
bob = self.peer("s48-bob")
bob.connect_to(alice)
now = int(time.time() * 1000)
create = {
"id": "s48-create",
"call_id": "s48-call",
"actor": "Alice",
"at": now,
"action": {
"Create": {
"game_id": "cnctw",
"max_players": 4,
"scheduled_for": None,
"deadline": now + 600_000,
}
},
}
rsvp = {
"id": "s48-rsvp",
"call_id": "s48-call",
"actor": "Bob",
"at": now + 1,
"action": "Rsvp",
}
message = {
"id": "s48-message-event",
"call_id": "s48-call",
"actor": "Bob",
"at": now + 2,
"action": {
"SendMessage": {
"message_id": "s48-message",
"text": "I am in",
}
},
}
alice.publish_call_to_play(create)
wait_call_to_play_events(bob, {"s48-create"})
bob.publish_call_to_play(rsvp)
bob.publish_call_to_play(message)
wait_call_to_play_events(alice, {"s48-create", "s48-rsvp", "s48-message-event"})
charlie = self.peer("s48-charlie")
charlie.connect_to(alice)
events = wait_call_to_play_events(
charlie,
{"s48-create", "s48-rsvp", "s48-message-event"},
)
if len(events) != 3:
raise ScenarioError(f"late join history contains duplicates: {events}")
return "live create/RSVP/chat replicated and a late joiner received deduplicated history"
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]: def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run( result = subprocess.run(
@@ -2034,6 +2098,24 @@ def wait_no_outbound_transfer(peer: Peer, game_id: str, timeout: float = 20) ->
) )
def wait_call_to_play_events(
peer: Peer,
expected_ids: set[str],
timeout: float = 20,
) -> list[dict[str, Any]]:
deadline = time.monotonic() + timeout
last_events: list[dict[str, Any]] = []
while time.monotonic() < deadline:
events = peer.call_to_play_events()
last_events = events
if expected_ids <= {event.get("id") for event in events}:
return events
time.sleep(0.2)
raise ScenarioError(
f"{peer.name} never received Call to Play events {expected_ids}: {last_events}"
)
def assert_game_state( def assert_game_state(
game: dict[str, Any], game: dict[str, Any],
*, *,
+31 -1
View File
@@ -9,7 +9,7 @@ use std::{
}; };
use eyre::{Context, OptionExt}; use eyre::{Context, OptionExt};
use lanspread_peer::{UnpackFuture, Unpacker}; use lanspread_peer::{CallToPlayEvent, UnpackFuture, Unpacker};
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -26,6 +26,10 @@ pub enum CliCommand {
Status, Status,
ListPeers, ListPeers,
ListGames, ListGames,
ListCallToPlay,
PublishCallToPlay {
event: CallToPlayEvent,
},
SetGameDir { SetGameDir {
path: PathBuf, path: PathBuf,
}, },
@@ -67,6 +71,8 @@ impl CliCommand {
Self::Status => "status", Self::Status => "status",
Self::ListPeers => "list-peers", Self::ListPeers => "list-peers",
Self::ListGames => "list-games", Self::ListGames => "list-games",
Self::ListCallToPlay => "list-call-to-play",
Self::PublishCallToPlay { .. } => "publish-call-to-play",
Self::SetGameDir { .. } => "set-game-dir", Self::SetGameDir { .. } => "set-game-dir",
Self::Download { .. } => "download", Self::Download { .. } => "download",
Self::StreamInstall { .. } => "stream-install", Self::StreamInstall { .. } => "stream-install",
@@ -102,6 +108,16 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
"status" => CliCommand::Status, "status" => CliCommand::Status,
"list-peers" => CliCommand::ListPeers, "list-peers" => CliCommand::ListPeers,
"list-games" => CliCommand::ListGames, "list-games" => CliCommand::ListGames,
"list-call-to-play" => CliCommand::ListCallToPlay,
"publish-call-to-play" => CliCommand::PublishCallToPlay {
event: serde_json::from_value(
object
.get("event")
.cloned()
.ok_or_eyre("publish-call-to-play must include event")?,
)
.wrap_err("invalid Call to Play event")?,
},
"set-game-dir" => CliCommand::SetGameDir { "set-game-dir" => CliCommand::SetGameDir {
path: PathBuf::from(required_str(object, "path")?), path: PathBuf::from(required_str(object, "path")?),
}, },
@@ -384,6 +400,20 @@ mod tests {
); );
} }
#[test]
fn parses_call_to_play_event_command() {
let parsed = parse_command_line(
r#"{"cmd":"publish-call-to-play","event":{"id":"event-1","call_id":"call-1","actor":"Alice","at":1000,"action":{"Create":{"game_id":"game-1","max_players":4,"scheduled_for":null,"deadline":61000}}}}"#,
)
.expect("command should parse");
let CliCommand::PublishCallToPlay { event } = parsed.command else {
panic!("expected PublishCallToPlay");
};
assert_eq!(event.id, "event-1");
assert_eq!(event.call_id, "call-1");
}
#[tokio::test] #[tokio::test]
async fn fixture_unpacker_creates_install_payload() { async fn fixture_unpacker_creates_install_payload() {
let temp = TempDir::new("lanspread-peer-cli-fixture"); let temp = TempDir::new("lanspread-peer-cli-fixture");
+39
View File
@@ -16,6 +16,7 @@ use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
use lanspread_peer::{ use lanspread_peer::{
ActiveOperation, ActiveOperation,
ActiveOperationKind, ActiveOperationKind,
CallToPlayEvent,
ExternalUnrarStreamProvider, ExternalUnrarStreamProvider,
NoopStreamInstallProvider, NoopStreamInstallProvider,
OutboundTransfers, OutboundTransfers,
@@ -101,6 +102,8 @@ struct CliState {
game_files: HashMap<String, Vec<GameFileDescription>>, game_files: HashMap<String, Vec<GameFileDescription>>,
unavailable_games: HashSet<String>, unavailable_games: HashSet<String>,
downloads: HashMap<String, DownloadMeasurement>, downloads: HashMap<String, DownloadMeasurement>,
call_to_play_events: Vec<CallToPlayEvent>,
call_to_play_generation: u64,
} }
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
@@ -243,6 +246,26 @@ async fn handle_command(
CliCommand::Status => status(shared).await, CliCommand::Status => status(shared).await,
CliCommand::ListPeers => list_peers(shared).await, CliCommand::ListPeers => list_peers(shared).await,
CliCommand::ListGames => list_games(shared).await, CliCommand::ListGames => list_games(shared).await,
CliCommand::ListCallToPlay => {
let generation = shared.state.read().await.call_to_play_generation;
sender.send(PeerCommand::GetCallToPlayEvents)?;
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if shared.state.read().await.call_to_play_generation > generation {
break;
}
shared.notify.notified().await;
}
})
.await
.wrap_err("timed out waiting for Call to Play history")?;
let events = shared.state.read().await.call_to_play_events.clone();
Ok(json!({ "events": events }))
}
CliCommand::PublishCallToPlay { event } => {
sender.send(PeerCommand::PublishCallToPlay(event.clone()))?;
Ok(json!({"queued": true, "event_id": event.id}))
}
CliCommand::SetGameDir { path } => { CliCommand::SetGameDir { path } => {
sender.send(PeerCommand::SetGameDir(path.clone()))?; sender.send(PeerCommand::SetGameDir(path.clone()))?;
Ok(json!({"queued": true, "path": path})) Ok(json!({"queued": true, "path": path}))
@@ -498,6 +521,22 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
json!({ "active_operations": active_operations_json(&active_operations) }), json!({ "active_operations": active_operations_json(&active_operations) }),
) )
} }
PeerEvent::CallToPlayEvents(events) => {
let mut state = shared.state.write().await;
let mut known = state
.call_to_play_events
.iter()
.map(|event| event.id.clone())
.collect::<HashSet<_>>();
state.call_to_play_events.extend(
events
.iter()
.filter(|event| known.insert(event.id.clone()))
.cloned(),
);
state.call_to_play_generation = state.call_to_play_generation.saturating_add(1);
("call-to-play-events", json!({ "events": events }))
}
PeerEvent::GotGameFiles { PeerEvent::GotGameFiles {
id, id,
file_descriptions, file_descriptions,
+15
View File
@@ -44,6 +44,21 @@ When a peer is discovered:
- Any message updates `last_seen`. - Any message updates `last_seen`.
- Pings run only when idle (or on a longer interval), not every 5 seconds. - Pings run only when idle (or on a longer interval), not every 5 seconds.
- Library updates are pushed as deltas, debounced and coalesced. - Library updates are pushed as deltas, debounced and coalesced.
- Call to Play actions are broadcast as immutable, uniquely identified events.
### Call to Play replication
Call to Play is transient peer-session state rather than database state. The
peer keeps a bounded event history, deduplicated by event ID. 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.
`Hello` and `HelloAck` include each side's event history. This lets peers that
join after a call was created reconstruct the same nominations, responses,
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
deterministically and derives deadlines and check-in phases from timestamps.
There is deliberately no compatibility path for older protocol versions.
### 4) Shutdown ### 4) Shutdown
+225
View File
@@ -0,0 +1,225 @@
//! Replicated event history for Call to Play coordination.
use std::collections::HashSet;
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
use tokio::sync::mpsc::UnboundedSender;
use crate::{PeerEvent, context::Ctx, events, network::send_call_to_play_events};
const MAX_EVENTS: usize = 4_096;
const MAX_ID_CHARS: usize = 128;
const MAX_GAME_ID_CHARS: usize = 256;
const MAX_USERNAME_CHARS: usize = 24;
const MAX_MESSAGE_CHARS: usize = 500;
#[derive(Debug, Default)]
pub(crate) struct CallToPlayStore {
events: Vec<CallToPlayEvent>,
event_ids: HashSet<String>,
}
impl CallToPlayStore {
pub(crate) fn snapshot(&self) -> Vec<CallToPlayEvent> {
self.events.clone()
}
pub(crate) fn insert(&mut self, event: CallToPlayEvent) -> Result<bool, &'static str> {
validate_event(&event)?;
if self.event_ids.contains(&event.id) {
return Ok(false);
}
if self.events.len() >= MAX_EVENTS {
return Err("Call to Play event history is full");
}
self.event_ids.insert(event.id.clone());
self.events.push(event);
Ok(true)
}
pub(crate) fn insert_all(&mut self, events: Vec<CallToPlayEvent>) -> Vec<CallToPlayEvent> {
let mut accepted = Vec::new();
for event in events {
match self.insert(event.clone()) {
Ok(true) => accepted.push(event),
Ok(false) => {}
Err(err) => log::warn!("Ignoring invalid Call to Play event {}: {err}", event.id),
}
}
accepted
}
}
pub(crate) async fn publish(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
event: CallToPlayEvent,
) {
match ctx.call_to_play.write().await.insert(event.clone()) {
Ok(false) => return,
Err(err) => {
log::warn!("Rejecting local Call to Play event {}: {err}", event.id);
return;
}
Ok(true) => {}
}
events::send(
tx_notify_ui,
PeerEvent::CallToPlayEvents(vec![event.clone()]),
);
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
ctx.task_tracker.spawn(async move {
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
let event = event.clone();
async move {
if let Err(err) = send_call_to_play_events(peer_addr, vec![event]).await {
log::warn!("Failed to send Call to Play event to {peer_addr}: {err}");
}
}
});
futures::future::join_all(deliveries).await;
});
}
fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> {
validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?;
validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?;
validate_nonempty(&event.actor, MAX_USERNAME_CHARS, "invalid actor")?;
if event.at <= 0 {
return Err("invalid event timestamp");
}
match &event.action {
CallToPlayAction::Create {
game_id,
max_players,
scheduled_for,
deadline,
} => {
validate_nonempty(game_id, MAX_GAME_ID_CHARS, "invalid game id")?;
if !(2..=64).contains(max_players) {
return Err("max players must be between 2 and 64");
}
if *deadline <= event.at {
return Err("deadline must be after creation");
}
if scheduled_for.is_some_and(|scheduled| scheduled != *deadline) {
return Err("scheduled call deadline must match its start time");
}
}
CallToPlayAction::Respond { ready_at } => {
if ready_at.is_some_and(|ready| ready < event.at) {
return Err("ready time cannot be before the response");
}
}
CallToPlayAction::SendMessage { message_id, text } => {
validate_nonempty(message_id, MAX_ID_CHARS, "invalid message id")?;
validate_nonempty(text, MAX_MESSAGE_CHARS, "invalid message")?;
}
CallToPlayAction::AddTime { deadline } => {
if *deadline <= event.at {
return Err("extended deadline must be in the future");
}
}
CallToPlayAction::Rsvp
| CallToPlayAction::Leave
| CallToPlayAction::Cancel
| CallToPlayAction::Start => {}
}
Ok(())
}
fn validate_nonempty(
value: &str,
max_chars: usize,
error: &'static str,
) -> Result<(), &'static str> {
if value.trim().is_empty() || value.chars().count() > max_chars {
return Err(error);
}
Ok(())
}
#[cfg(test)]
mod tests {
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
use super::CallToPlayStore;
fn create_event(id: &str) -> CallToPlayEvent {
CallToPlayEvent {
id: id.to_string(),
call_id: "call-1".to_string(),
actor: "Alice".to_string(),
at: 1_000,
action: CallToPlayAction::Create {
game_id: "game-1".to_string(),
max_players: 4,
scheduled_for: None,
deadline: 61_000,
},
}
}
#[test]
fn deduplicates_events_without_reordering_history() {
let mut store = CallToPlayStore::default();
assert!(
store
.insert(create_event("event-1"))
.expect("valid event should be inserted")
);
assert!(
!store
.insert(create_event("event-1"))
.expect("duplicate valid event should be accepted")
);
assert!(
store
.insert(create_event("event-2"))
.expect("valid event should be inserted")
);
let ids = store
.snapshot()
.into_iter()
.map(|event| event.id)
.collect::<Vec<_>>();
assert_eq!(ids, ["event-1", "event-2"]);
}
#[test]
fn rejects_malformed_events() {
let mut store = CallToPlayStore::default();
let mut event = create_event("event-1");
event.action = CallToPlayAction::SendMessage {
message_id: "message-1".to_string(),
text: " ".to_string(),
};
assert_eq!(store.insert(event), Err("invalid message"));
assert!(store.snapshot().is_empty());
}
#[test]
fn merge_returns_only_new_valid_events() {
let mut store = CallToPlayStore::default();
store
.insert(create_event("event-1"))
.expect("valid event should be inserted");
let mut invalid = create_event("invalid");
invalid.actor.clear();
let accepted = store.insert_all(vec![
create_event("event-1"),
invalid,
create_event("event-2"),
]);
assert_eq!(accepted, [create_event("event-2")]);
}
}
+5
View File
@@ -10,6 +10,7 @@ use crate::{
PeerEvent, PeerEvent,
StreamInstallProvider, StreamInstallProvider,
Unpacker, Unpacker,
call_to_play::CallToPlayStore,
events, events,
library::LocalLibraryState, library::LocalLibraryState,
peer_db::PeerGameDB, peer_db::PeerGameDB,
@@ -51,6 +52,7 @@ pub struct Ctx {
pub shutdown: CancellationToken, pub shutdown: CancellationToken,
pub task_tracker: TaskTracker, pub task_tracker: TaskTracker,
pub active_outbound_transfers: OutboundTransfers, pub active_outbound_transfers: OutboundTransfers,
pub call_to_play: Arc<RwLock<CallToPlayStore>>,
} }
/// Context for peer connection handling. /// Context for peer connection handling.
@@ -69,6 +71,7 @@ pub struct PeerCtx {
pub shutdown: CancellationToken, pub shutdown: CancellationToken,
pub task_tracker: TaskTracker, pub task_tracker: TaskTracker,
pub active_outbound_transfers: OutboundTransfers, pub active_outbound_transfers: OutboundTransfers,
pub call_to_play: Arc<RwLock<CallToPlayStore>>,
} }
impl std::fmt::Debug for PeerCtx { impl std::fmt::Debug for PeerCtx {
@@ -113,6 +116,7 @@ impl Ctx {
shutdown, shutdown,
task_tracker, task_tracker,
active_outbound_transfers, active_outbound_transfers,
call_to_play: Arc::new(RwLock::new(CallToPlayStore::default())),
} }
} }
@@ -135,6 +139,7 @@ impl Ctx {
shutdown: self.shutdown.clone(), shutdown: self.shutdown.clone(),
task_tracker: self.task_tracker.clone(), task_tracker: self.task_tracker.clone(),
active_outbound_transfers: self.active_outbound_transfers.clone(), active_outbound_transfers: self.active_outbound_transfers.clone(),
call_to_play: self.call_to_play.clone(),
} }
} }
} }
+2
View File
@@ -6,6 +6,7 @@ use crate::state_paths::peer_id_path;
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1"; pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1"; pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
pub const FEATURE_CALL_TO_PLAY: &str = "call-to-play-v1";
pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result<String> { pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result<String> {
let path = peer_id_path(state_dir); let path = peer_id_path(state_dir);
@@ -28,5 +29,6 @@ pub fn default_features() -> Vec<String> {
vec![ vec![
FEATURE_LIBRARY_DELTA.to_string(), FEATURE_LIBRARY_DELTA.to_string(),
FEATURE_LIBRARY_SNAPSHOT.to_string(), FEATURE_LIBRARY_SNAPSHOT.to_string(),
FEATURE_CALL_TO_PLAY.to_string(),
] ]
} }
+15
View File
@@ -12,6 +12,7 @@
// Module declarations // Module declarations
// ============================================================================= // =============================================================================
mod call_to_play;
mod config; mod config;
mod context; mod context;
mod download; mod download;
@@ -46,6 +47,7 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
pub use error::PeerError; pub use error::PeerError;
pub use install::{UnpackFuture, Unpacker}; pub use install::{UnpackFuture, Unpacker};
use lanspread_db::db::{Game, GameCatalog, GameFileDescription}; use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
pub use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
pub use migration::{MigrationReport, migrate_legacy_state}; pub use migration::{MigrationReport, migrate_legacy_state};
pub use peer_db::{ pub use peer_db::{
MajorityValidationResult, MajorityValidationResult,
@@ -159,6 +161,8 @@ pub enum PeerEvent {
ActiveOperationsChanged { ActiveOperationsChanged {
active_operations: Vec<ActiveOperation>, active_operations: Vec<ActiveOperation>,
}, },
/// New or requested Call to Play events in replication order.
CallToPlayEvents(Vec<CallToPlayEvent>),
/// A required peer runtime component failed. /// A required peer runtime component failed.
RuntimeFailed { RuntimeFailed {
component: PeerRuntimeComponent, component: PeerRuntimeComponent,
@@ -259,6 +263,10 @@ pub enum PeerCommand {
GetPeerCount, GetPeerCount,
/// Connect directly to a peer address without waiting for mDNS discovery. /// Connect directly to a peer address without waiting for mDNS discovery.
ConnectPeer(SocketAddr), ConnectPeer(SocketAddr),
/// Publish one local Call to Play action to this peer and the LAN.
PublishCallToPlay(CallToPlayEvent),
/// Request the complete in-memory Call to Play history.
GetCallToPlayEvents,
} }
/// Optional startup settings for non-GUI callers and tests. /// Optional startup settings for non-GUI callers and tests.
@@ -489,6 +497,13 @@ async fn handle_peer_commands(
PeerCommand::ConnectPeer(addr) => { PeerCommand::ConnectPeer(addr) => {
handle_connect_peer_command(ctx, tx_notify_ui, addr).await; handle_connect_peer_command(ctx, tx_notify_ui, addr).await;
} }
PeerCommand::PublishCallToPlay(event) => {
call_to_play::publish(ctx, tx_notify_ui, event).await;
}
PeerCommand::GetCallToPlayEvents => {
let events = ctx.call_to_play.read().await.snapshot();
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(events));
}
} }
} }
} }
+8 -1
View File
@@ -9,7 +9,7 @@ use bytes::BytesMut;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use if_addrs::{IfAddr, Interface, get_if_addrs}; use if_addrs::{IfAddr, Interface, get_if_addrs};
use lanspread_db::db::GameFileDescription; use lanspread_db::db::GameFileDescription;
use lanspread_proto::{Hello, HelloAck, LibraryDelta, Message, Request, Response}; use lanspread_proto::{CallToPlayEvent, Hello, HelloAck, LibraryDelta, Message, Request, Response};
use s2n_quic::{ use s2n_quic::{
Client as QuicClient, Client as QuicClient,
Connection, Connection,
@@ -173,6 +173,13 @@ pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Resul
send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await
} }
pub async fn send_call_to_play_events(
peer_addr: SocketAddr,
events: Vec<CallToPlayEvent>,
) -> eyre::Result<()> {
send_oneway_request(peer_addr, Request::CallToPlayEvents { events }).await
}
/// Requests game file details from a peer. /// Requests game file details from a peer.
pub async fn request_game_details_from_peer( pub async fn request_game_details_from_peer(
peer_addr: SocketAddr, peer_addr: SocketAddr,
+128 -5
View File
@@ -8,6 +8,7 @@ use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{ use crate::{
PeerEvent, PeerEvent,
call_to_play::CallToPlayStore,
context::{Ctx, PeerCtx}, context::{Ctx, PeerCtx},
events, events,
identity::default_features, identity::default_features,
@@ -24,6 +25,7 @@ pub(crate) struct HandshakeCtx {
peer_game_db: Arc<RwLock<PeerGameDB>>, peer_game_db: Arc<RwLock<PeerGameDB>>,
tx_notify_ui: UnboundedSender<PeerEvent>, tx_notify_ui: UnboundedSender<PeerEvent>,
catalog: Arc<RwLock<GameCatalog>>, catalog: Arc<RwLock<GameCatalog>>,
call_to_play: Arc<RwLock<CallToPlayStore>>,
} }
impl HandshakeCtx { impl HandshakeCtx {
@@ -35,6 +37,7 @@ impl HandshakeCtx {
peer_game_db: ctx.peer_game_db.clone(), peer_game_db: ctx.peer_game_db.clone(),
tx_notify_ui: tx_notify_ui.clone(), tx_notify_ui: tx_notify_ui.clone(),
catalog: ctx.catalog.clone(), catalog: ctx.catalog.clone(),
call_to_play: ctx.call_to_play.clone(),
} }
} }
@@ -46,6 +49,7 @@ impl HandshakeCtx {
peer_game_db: ctx.peer_game_db.clone(), peer_game_db: ctx.peer_game_db.clone(),
tx_notify_ui: ctx.tx_notify_ui.clone(), tx_notify_ui: ctx.tx_notify_ui.clone(),
catalog: ctx.catalog.clone(), catalog: ctx.catalog.clone(),
call_to_play: ctx.call_to_play.clone(),
} }
} }
} }
@@ -58,28 +62,36 @@ async fn required_listen_addr(
} }
pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result<HelloAck> { pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result<HelloAck> {
let library_guard = ctx.local_library.read().await;
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?; let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
let library = build_library_snapshot(&library_guard); let library = {
let library_guard = ctx.local_library.read().await;
build_library_snapshot(&library_guard)
};
let call_to_play_events = ctx.call_to_play.read().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,
listen_addr, listen_addr,
library, library,
features: default_features(), features: default_features(),
call_to_play_events,
}) })
} }
async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result<Hello> { async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result<Hello> {
let library_guard = ctx.local_library.read().await;
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?; let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
let library = build_library_snapshot(&library_guard); let library = {
let library_guard = ctx.local_library.read().await;
build_library_snapshot(&library_guard)
};
let call_to_play_events = ctx.call_to_play.read().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,
listen_addr, listen_addr,
library, library,
features: default_features(), features: default_features(),
call_to_play_events,
}) })
} }
@@ -114,6 +126,13 @@ pub(crate) async fn perform_handshake_with_peer(
let _ = ctx.peer_game_db.write().await.remove_peer(expected); let _ = ctx.peer_game_db.write().await.remove_peer(expected);
} }
merge_call_to_play_events(
&ctx.call_to_play,
&ctx.tx_notify_ui,
ack.call_to_play_events,
)
.await;
let record_addr = ack.listen_addr; let record_addr = ack.listen_addr;
let upsert = record_remote_library( let upsert = record_remote_library(
&ctx.peer_game_db, &ctx.peer_game_db,
@@ -149,6 +168,12 @@ pub(super) async fn accept_inbound_hello(
} }
let addr = hello.listen_addr; let addr = hello.listen_addr;
merge_call_to_play_events(
&ctx.call_to_play,
&ctx.tx_notify_ui,
hello.call_to_play_events,
)
.await;
let handshake_ctx = HandshakeCtx::from_peer_ctx(ctx); let handshake_ctx = HandshakeCtx::from_peer_ctx(ctx);
let upsert = record_remote_library( let upsert = record_remote_library(
&ctx.peer_game_db, &ctx.peer_game_db,
@@ -165,6 +190,17 @@ pub(super) async fn accept_inbound_hello(
build_hello_ack(ctx).await build_hello_ack(ctx).await
} }
async fn merge_call_to_play_events(
store: &Arc<RwLock<CallToPlayStore>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
incoming: Vec<lanspread_proto::CallToPlayEvent>,
) {
let accepted = store.write().await.insert_all(incoming);
if !accepted.is_empty() {
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(accepted));
}
}
pub(super) fn spawn_library_resync( pub(super) fn spawn_library_resync(
ctx: HandshakeCtx, ctx: HandshakeCtx,
peer_addr: SocketAddr, peer_addr: SocketAddr,
@@ -212,7 +248,15 @@ mod tests {
}; };
use lanspread_db::db::GameCatalog; use lanspread_db::db::GameCatalog;
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION}; use lanspread_proto::{
Availability,
CallToPlayAction,
CallToPlayEvent,
GameSummary,
Hello,
LibrarySnapshot,
PROTOCOL_VERSION,
};
use tokio::sync::{RwLock, mpsc}; use tokio::sync::{RwLock, mpsc};
use tokio_util::{sync::CancellationToken, task::TaskTracker}; use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -248,6 +292,7 @@ mod tests {
peer_game_db, peer_game_db,
tx_notify_ui, tx_notify_ui,
catalog: Arc::new(RwLock::new(GameCatalog::empty())), catalog: Arc::new(RwLock::new(GameCatalog::empty())),
call_to_play: Arc::new(RwLock::new(crate::call_to_play::CallToPlayStore::default())),
} }
} }
@@ -264,6 +309,21 @@ mod tests {
} }
} }
fn call_to_play_event() -> CallToPlayEvent {
CallToPlayEvent {
id: "event-1".to_string(),
call_id: "call-1".to_string(),
actor: "Alice".to_string(),
at: 1_000,
action: CallToPlayAction::Create {
game_id: "game".to_string(),
max_players: 4,
scheduled_for: None,
deadline: 61_000,
},
}
}
#[tokio::test] #[tokio::test]
async fn outbound_hello_requires_local_listener_addr() { async fn outbound_hello_requires_local_listener_addr() {
let ctx = test_handshake_ctx(None); let ctx = test_handshake_ctx(None);
@@ -304,6 +364,22 @@ mod tests {
assert_eq!(hello.library.games[0].id, "game"); assert_eq!(hello.library.games[0].id, "game");
} }
#[tokio::test]
async fn outbound_hello_carries_call_to_play_history() {
let ctx = test_handshake_ctx(Some(addr([10, 66, 0, 2], 40000)));
ctx.call_to_play
.write()
.await
.insert(call_to_play_event())
.expect("valid event should be inserted");
let hello = build_hello_from_state(&ctx)
.await
.expect("listener address is present");
assert_eq!(hello.call_to_play_events, [call_to_play_event()]);
}
#[tokio::test] #[tokio::test]
async fn inbound_hello_applies_remote_library_snapshot() { async fn inbound_hello_applies_remote_library_snapshot() {
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
@@ -335,6 +411,7 @@ mod tests {
games: vec![summary("remote-game")], games: vec![summary("remote-game")],
}, },
features: Vec::new(), features: Vec::new(),
call_to_play_events: Vec::new(),
}; };
let ack = accept_inbound_hello(&peer_ctx, None, hello) let ack = accept_inbound_hello(&peer_ctx, None, hello)
@@ -406,6 +483,7 @@ mod tests {
games: vec![summary("self-game")], games: vec![summary("self-game")],
}, },
features: Vec::new(), features: Vec::new(),
call_to_play_events: Vec::new(),
}; };
let ack = accept_inbound_hello(&peer_ctx, None, self_hello) let ack = accept_inbound_hello(&peer_ctx, None, self_hello)
@@ -422,4 +500,49 @@ mod tests {
"self hello must emit no peer discovery events" "self hello must emit no peer discovery events"
); );
} }
#[tokio::test]
async fn inbound_hello_merges_call_to_play_history_once() {
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
let ctx = Ctx::new(
peer_game_db,
"local-peer".to_string(),
PathBuf::new(),
PathBuf::new(),
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(GameCatalog::empty())),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
);
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
let (tx_notify_ui, mut rx_notify_ui) = mpsc::unbounded_channel();
let peer_ctx = ctx.to_peer_ctx(tx_notify_ui);
let remote_addr = addr([127, 0, 0, 1], 5000);
let hello = Hello {
peer_id: "remote-peer".to_string(),
proto_ver: PROTOCOL_VERSION,
listen_addr: remote_addr,
library: LibrarySnapshot {
library_rev: 0,
games: Vec::new(),
},
features: Vec::new(),
call_to_play_events: vec![call_to_play_event(), call_to_play_event()],
};
accept_inbound_hello(&peer_ctx, None, hello)
.await
.expect("current protocol hello should be accepted");
assert_eq!(
ctx.call_to_play.read().await.snapshot(),
[call_to_play_event()]
);
assert!(matches!(
rx_notify_ui.recv().await,
Some(PeerEvent::CallToPlayEvents(events)) if events == [call_to_play_event()]
));
}
} }
@@ -90,6 +90,16 @@ async fn dispatch_request(
handle_library_delta(ctx, peer_id, delta).await; handle_library_delta(ctx, peer_id, delta).await;
framed_tx framed_tx
} }
Request::CallToPlayEvents { events: incoming } => {
let accepted = ctx.call_to_play.write().await.insert_all(incoming);
if !accepted.is_empty() {
events::send(
&ctx.tx_notify_ui,
crate::PeerEvent::CallToPlayEvents(accepted),
);
}
framed_tx
}
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await, Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await, Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
Request::GetGameFileChunk { Request::GetGameFileChunk {
+39 -1
View File
@@ -4,7 +4,7 @@ use bytes::Bytes;
use lanspread_db::db::{Game, GameFileDescription}; use lanspread_db::db::{Game, GameFileDescription};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 5; pub const PROTOCOL_VERSION: u32 = 6;
pub use lanspread_db::db::Availability; pub use lanspread_db::db::Availability;
@@ -27,6 +27,7 @@ pub struct Hello {
pub listen_addr: SocketAddr, pub listen_addr: SocketAddr,
pub library: LibrarySnapshot, pub library: LibrarySnapshot,
pub features: Vec<String>, pub features: Vec<String>,
pub call_to_play_events: Vec<CallToPlayEvent>,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -36,6 +37,40 @@ pub struct HelloAck {
pub listen_addr: SocketAddr, pub listen_addr: SocketAddr,
pub library: LibrarySnapshot, pub library: LibrarySnapshot,
pub features: Vec<String>, pub features: Vec<String>,
pub call_to_play_events: Vec<CallToPlayEvent>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CallToPlayEvent {
pub id: String,
pub call_id: String,
pub actor: String,
pub at: i64,
pub action: CallToPlayAction,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum CallToPlayAction {
Create {
game_id: String,
max_players: u16,
scheduled_for: Option<i64>,
deadline: i64,
},
Respond {
ready_at: Option<i64>,
},
Rsvp,
SendMessage {
message_id: String,
text: String,
},
Leave,
Cancel,
Start,
AddTime {
deadline: i64,
},
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -75,6 +110,9 @@ pub enum Request {
peer_id: String, peer_id: String,
delta: LibraryDelta, delta: LibraryDelta,
}, },
CallToPlayEvents {
events: Vec<CallToPlayEvent>,
},
Goodbye { Goodbye {
peer_id: String, peer_id: String,
}, },
@@ -44,7 +44,7 @@ walkdir = { workspace = true }
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[target.'cfg(windows)'.dependencies] [target."cfg(windows)".dependencies]
windows = { workspace = true } windows = { workspace = true }
[lints.clippy] [lints.clippy]
@@ -18,6 +18,7 @@ use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescript
use lanspread_peer::{ use lanspread_peer::{
ActiveOperation, ActiveOperation,
ActiveOperationKind, ActiveOperationKind,
CallToPlayEvent,
ExternalUnrarStreamProvider, ExternalUnrarStreamProvider,
NoopStreamInstallProvider, NoopStreamInstallProvider,
PeerCommand, PeerCommand,
@@ -161,6 +162,7 @@ struct LauncherGame {
game: Game, game: Game,
can_host_server: bool, can_host_server: bool,
active_outbound_transfers: usize, active_outbound_transfers: usize,
installed_peer_count: u32,
} }
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
@@ -272,6 +274,35 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
Ok(()) Ok(())
} }
#[tauri::command]
async fn request_call_to_play_events(
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
Ok(peer_ctrl.send(PeerCommand::GetCallToPlayEvents).is_ok())
}
#[tauri::command]
async fn publish_call_to_play(
event: CallToPlayEvent,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
Ok(peer_ctrl
.send(PeerCommand::PublishCallToPlay(event))
.is_ok())
}
#[tauri::command] #[tauri::command]
async fn install_game( async fn install_game(
id: String, id: String,
@@ -1031,6 +1062,19 @@ fn clear_all_local_game_states(game_db: &mut GameDB) {
async fn emit_games_list(app_handle: &AppHandle) { async fn emit_games_list(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>(); let state = app_handle.state::<LanSpreadState>();
let installed_peer_counts = state
.peer_game_db
.read()
.await
.peer_snapshots()
.into_iter()
.flat_map(|peer| peer.games)
.filter(|game| game.installed)
.fold(HashMap::<String, u32>::new(), |mut counts, game| {
*counts.entry(game.id).or_default() += 1;
counts
});
let games_db_lock = state.games.clone(); let games_db_lock = state.games.clone();
let game_db = games_db_lock.read().await; let game_db = games_db_lock.read().await;
let games_folder = state.games_folder.read().await.clone(); let games_folder = state.games_folder.read().await.clone();
@@ -1051,6 +1095,7 @@ async fn emit_games_list(app_handle: &AppHandle) {
LauncherGame { LauncherGame {
can_host_server: game_can_host_server(&games_folder, &game), can_host_server: game_can_host_server(&games_folder, &game),
active_outbound_transfers, active_outbound_transfers,
installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0),
game, game,
} }
}) })
@@ -2178,6 +2223,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
} }
emit_games_list(app_handle).await; emit_games_list(app_handle).await;
} }
PeerEvent::CallToPlayEvents(events) => {
if let Err(err) = app_handle.emit("call-to-play-events", Some(events)) {
log::error!("Failed to emit call-to-play-events event: {err}");
}
}
PeerEvent::OutboundTransferCountChanged => { PeerEvent::OutboundTransferCountChanged => {
log::info!("PeerEvent::OutboundTransferCountChanged received"); log::info!("PeerEvent::OutboundTransferCountChanged received");
schedule_outbound_transfer_emit(app_handle).await; schedule_outbound_transfer_emit(app_handle).await;
@@ -2348,6 +2398,8 @@ pub fn run() {
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
request_games, request_games,
request_call_to_play_events,
publish_call_to_play,
install_game, install_game,
stream_install_game, stream_install_game,
run_game, run_game,
@@ -105,4 +105,35 @@ export const Icon = {
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" /> <path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
</svg> </svg>
), ),
flag: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.6} {...baseStroke} {...p}>
<path d="M3 14V2.5M3.5 3h8l-1.4 2.5L11.5 8h-8" />
</svg>
),
clock: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<circle cx="8" cy="8" r="5.5" />
<path d="M8 4.5V8l2.5 1.5" />
</svg>
),
chat: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="M2.5 3.5h11v8h-6L4 14v-2.5H2.5z" />
</svg>
),
send: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="m2 3 12 5-12 5 2-5zM4 8h6" />
</svg>
),
caretUp: (p: Props) => (
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
<path d="m4 10 4-4 4 4" />
</svg>
),
caretDown: (p: Props) => (
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
<path d="m4 6 4 4 4-4" />
</svg>
),
} satisfies Record<string, (p: Props) => JSX.Element>; } satisfies Record<string, (p: Props) => JSX.Element>;
@@ -0,0 +1,18 @@
import { Icon } from '../Icon';
import { Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
onClick: () => void;
}
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
const activeCount = nominations.filter(nomination => nomination.state !== 'started').length;
return (
<button className="ctp-btn" onClick={onClick}>
<Icon.flag />
<span>Call to Play</span>
{activeCount > 0 && <span className="ctp-badge">{activeCount}</span>}
</button>
);
};
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { Icon } from '../Icon';
import { Modal } from '../Modal';
import { CreateNominationForm } from './CreateNominationForm';
import { NominationCard } from './NominationCard';
import { CallToPlayActions } from '../../hooks/useCallToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
games: ReadonlyArray<Game>;
username: string;
actions: CallToPlayActions;
focusId: string | null;
transportReady: boolean;
error: string | null;
getThumbnail: (gameId: string) => string | null | undefined;
totalPeerCount: number;
onLaunch: (game: Game) => void;
onClose: () => void;
}
export const CallToPlayOverlay = ({
nominations,
games,
username,
actions,
focusId,
transportReady,
error,
getThumbnail,
totalPeerCount,
onLaunch,
onClose,
}: 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
);
return (
<Modal onClose={onClose} className="ctp-modal">
<button className="modal-close" onClick={onClose} aria-label="Close">
<Icon.close />
</button>
<div className="ctp-head">
<h2>Call to Play</h2>
<p className="ctp-head-sub">
Rally the LAN around a game and a time right now, or scheduled for later with
an Im in RSVP. The caller decides when it actually starts.
</p>
{!showCreate && (
<button
className="act-btn act-play ctp-head-new"
disabled={!transportReady}
onClick={() => setShowCreate(true)}
><Icon.flag /><span>Call a new match</span></button>
)}
{!transportReady && !error && (
<div className="ctp-transport-note">Connecting Call to Play to the LAN</div>
)}
{error && <div className="ctp-transport-note is-error">{error}</div>}
</div>
<div className="ctp-body">
{showCreate && (
<CreateNominationForm
games={games}
onCancel={() => setShowCreate(false)}
onCreate={(gameId, maxPlayers, duration, scheduledFor) => {
actions.createNomination(gameId, maxPlayers, duration, scheduledFor);
setShowCreate(false);
}}
/>
)}
{sorted.length === 0 && !showCreate && (
<div className="ctp-empty">
No active calls right now be the one to start something.
</div>
)}
{sorted.map(nomination => {
const game = gameById.get(nomination.gameId);
if (!game) return null;
return (
<NominationCard
key={nomination.id}
nomination={nomination}
game={game}
username={username}
actions={actions}
focused={nomination.id === focusId}
thumbnailUrl={getThumbnail(game.id)}
totalPeerCount={totalPeerCount}
onLaunch={onLaunch}
/>
);
})}
</div>
</Modal>
);
};
@@ -0,0 +1,124 @@
import { CSSProperties } from 'react';
import { Icon } from '../Icon';
import {
avatarColor,
formatClock,
formatCountdown,
formatCountdownShort,
formatUntil,
isReady,
readyCountOf,
statusOf,
} from '../../lib/callToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
games: ReadonlyArray<Game>;
accent: string;
onOpen: (callId: string) => void;
}
const LABEL = {
scheduled: 'Scheduled',
call: 'Call to Play',
soon: 'Starting soon',
ready: 'Ready',
} as const;
const RANK = { ready: 0, soon: 1, call: 2, scheduled: 2, started: 3 } as const;
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
const entries = Object.entries(nomination.participants);
return (
<span className="ctp-ticker-bubbles">
{entries.slice(0, 6).map(([name, participant]) => {
const ready = isReady(participant, now);
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const remaining = (participant.readyAt ?? now) - now;
return (
<span
key={name}
className="ctp-mini"
data-state={state}
title={`${name}${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
style={{ background: avatarColor(name) }}
>
{initials}
{ready && <span className="ctp-mini-check"><Icon.check /></span>}
{state === 'pending' && (
<i className="ctp-mini-tag">{formatCountdownShort(remaining)}</i>
)}
</span>
);
})}
{entries.length > 6 && <span className="ctp-mini ctp-mini-more">+{entries.length - 6}</span>}
</span>
);
};
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
const now = Date.now();
const gameById = new Map(games.map(game => [game.id, game]));
const active = nominations
.filter(nomination => nomination.state !== 'started')
.sort((left, right) =>
RANK[statusOf(left, now)] - RANK[statusOf(right, now)]
|| left.deadline - right.deadline
);
if (active.length === 0) return null;
return (
<div className="ctp-ticker-stack">
{active.map(nomination => {
const game = gameById.get(nomination.gameId);
if (!game) return null;
const status = statusOf(nomination, now);
if (status === 'started') return null;
const ready = readyCountOf(nomination, now);
const total = Object.keys(nomination.participants).length;
const remaining = Math.max(0, nomination.deadline - now);
const last = nomination.messages[nomination.messages.length - 1];
const count = status === 'scheduled'
? `${total} in`
: `${ready}/${nomination.maxPlayers} ready`;
const time = status === 'ready'
? 'waiting to start'
: status === 'scheduled'
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
: nomination.scheduledFor !== null
? `starts ${formatClock(nomination.scheduledFor)} · ${formatCountdown(remaining)}`
: formatCountdown(remaining);
return (
<button
key={nomination.id}
className="ctp-ticker"
data-status={status}
style={{ '--accent': accent } as CSSProperties}
onClick={() => onOpen(nomination.id)}
>
<span className="ctp-ticker-dot" data-status={status} />
<span className="ctp-ticker-label" data-status={status}>{LABEL[status]}</span>
<span className="ctp-ticker-game">{game.name}</span>
<span className="ctp-ticker-by">by {nomination.creator}</span>
<span className="ctp-ticker-ready">{count}</span>
<span className="ctp-ticker-time">{time}</span>
{last ? (
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
<Icon.chat />
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
<span className="ctp-ticker-chat-text">{last.text}</span>
</span>
) : <span className="ctp-ticker-chat" />}
<MiniBubbles nomination={nomination} now={now} />
<span className="ctp-ticker-cta">
{status === 'soon' ? 'Check in' : 'View'}<Icon.chevron />
</span>
</button>
);
})}
</div>
);
};
@@ -0,0 +1,278 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { Game } from '../../lib/types';
import { bumpTime, normalizeTimeInput, sanitizeTimeDraft } from '../../lib/callToPlay';
interface Props {
games: ReadonlyArray<Game>;
onCancel: () => void;
onCreate: (
gameId: string,
maxPlayers: number,
durationMinutes: number,
scheduledFor: number | null,
) => void;
}
const nextTimeSlot = (): { time: string; dayOffset: number } => {
const now = new Date();
const slot = new Date(now.getTime() + 40 * 60_000);
slot.setMinutes(slot.getMinutes() <= 30 ? 30 : 60, 0, 0);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const slotDay = new Date(slot.getFullYear(), slot.getMonth(), slot.getDate());
return {
time: `${String(slot.getHours()).padStart(2, '0')}:${String(slot.getMinutes()).padStart(2, '0')}`,
dayOffset: Math.round((slotDay.getTime() - today.getTime()) / 86_400_000),
};
};
const suggestedPlayers = (game: Game): number => Math.max(2, Math.min(64, game.max_players ?? 8));
const dayChips = (): ReadonlyArray<{ offset: number; label: string }> => {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return [0, 1, 2].map(offset => {
const date = new Date();
date.setDate(date.getDate() + offset);
return { offset, label: offset === 0 ? 'Today' : days[date.getDay()] };
});
};
export const CreateNominationForm = ({ games, onCancel, onCreate }: Props) => {
const [initialSchedule] = useState(nextTimeSlot);
const [query, setQuery] = useState('');
const [gameId, setGameId] = useState<string | null>(null);
const [maxPlayers, setMaxPlayers] = useState(8);
const [duration, setDuration] = useState(10);
const [when, setWhen] = useState<'now' | 'later'>('now');
const [dayOffset, setDayOffset] = useState(initialSchedule.dayOffset);
const [time, setTime] = useState(initialSchedule.time);
const [editingTime, setEditingTime] = useState(false);
const [timeDraft, setTimeDraft] = useState(time);
const timeInputRef = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
if (editingTime) {
timeInputRef.current?.focus();
timeInputRef.current?.select();
}
}, [editingTime]);
const selected = games.find(game => game.id === gameId);
const matches = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return [];
return games.filter(game => game.name.toLocaleLowerCase().includes(needle)).slice(0, 6);
}, [games, query]);
const days = dayChips();
const pickGame = (game: Game) => {
setGameId(game.id);
setMaxPlayers(suggestedPlayers(game));
setQuery(game.name);
};
const scheduledTimestamp = (): number => {
const [hours, minutes] = time.split(':').map(Number);
const date = new Date();
date.setDate(date.getDate() + dayOffset);
date.setHours(hours, minutes, 0, 0);
return date.getTime();
};
const scheduledFor = scheduledTimestamp();
const scheduledTimeIsFuture = scheduledFor >= Date.now() + 60_000;
const commitTimeDraft = () => {
const normalized = normalizeTimeInput(timeDraft);
if (normalized) setTime(normalized);
setEditingTime(false);
};
return (
<div className="ctp-create">
<div className="ctp-create-row">
<label className="ctp-create-label" htmlFor="ctp-game-search">Game</label>
<div className="search ctp-create-search">
<Icon.search />
<input
id="ctp-game-search"
type="text"
placeholder="Search the catalog…"
autoComplete="off"
value={query}
onChange={event => {
setQuery(event.target.value);
setGameId(null);
}}
/>
</div>
{!selected && query.trim() && (
<div className="ctp-create-matches">
{matches.map(game => (
<button key={game.id} onClick={() => pickGame(game)}>
<span>{game.name}</span>
<span className="ctp-create-match-meta">
up to {suggestedPlayers(game)} players
</span>
</button>
))}
{matches.length === 0 && (
<div className="ctp-create-nomatch">No games match {query}</div>
)}
</div>
)}
</div>
{selected && (
<>
<div className="ctp-create-row ctp-create-row-inline">
<label className="ctp-create-label" htmlFor="ctp-max-players">Max players</label>
<input
id="ctp-max-players"
type="number"
className="ctp-create-num"
min={2}
max={64}
value={maxPlayers}
onChange={event => setMaxPlayers(
Math.max(2, Math.min(64, Number(event.target.value) || 2)),
)}
/>
<span className="ctp-create-hint">
Suggested for {selected.name}: {suggestedPlayers(selected)}
</span>
</div>
<div className="ctp-create-row">
<label className="ctp-create-label">When</label>
<div className="ctp-duration-opts">
<button
className={`ctp-duration-btn ${when === 'now' ? 'is-active' : ''}`}
onClick={() => setWhen('now')}
>Now</button>
<button
className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
onClick={() => setWhen('later')}
>Schedule</button>
</div>
{when === 'later' && (
<>
<div className="ctp-timepick">
{editingTime ? (
<input
ref={timeInputRef}
className="ctp-time-editinput"
type="text"
inputMode="numeric"
maxLength={5}
placeholder="20:00"
value={timeDraft}
onChange={event => setTimeDraft(previous =>
sanitizeTimeDraft(event.target.value, previous)
)}
onBlur={commitTimeDraft}
onKeyDown={event => {
if (event.key === 'Enter') commitTimeDraft();
if (event.key === 'Escape') setEditingTime(false);
}}
/>
) : (
<>
<div className="ctp-timepick-field">
<button
type="button"
className="ctp-time-step"
aria-label="Hour up"
onClick={() => setTime(value => bumpTime(value, 'hours', 1))}
><Icon.caretUp /></button>
<span className="ctp-time-cell">{time.slice(0, 2)}</span>
<button
type="button"
className="ctp-time-step"
aria-label="Hour down"
onClick={() => setTime(value => bumpTime(value, 'hours', -1))}
><Icon.caretDown /></button>
</div>
<span className="ctp-time-colon">:</span>
<div className="ctp-timepick-field">
<button
type="button"
className="ctp-time-step"
aria-label="Minute up"
onClick={() => setTime(value => bumpTime(value, 'minutes', 15))}
><Icon.caretUp /></button>
<span className="ctp-time-cell">{time.slice(3)}</span>
<button
type="button"
className="ctp-time-step"
aria-label="Minute down"
onClick={() => setTime(value => bumpTime(value, 'minutes', -15))}
><Icon.caretDown /></button>
</div>
<button
type="button"
className="ctp-time-type"
onClick={() => {
setTimeDraft(time);
setEditingTime(true);
}}
>Type a time</button>
</>
)}
</div>
<div className="ctp-time-row ctp-day-row">
<span className="ctp-day-label">Day</span>
{days.map(day => (
<button
key={day.offset}
className={`ctp-duration-btn ctp-day-btn ${dayOffset === day.offset ? 'is-active' : ''}`}
onClick={() => setDayOffset(day.offset)}
>{day.label}</button>
))}
</div>
<span className="ctp-create-hint">
{scheduledTimeIsFuture
? '24-hour clock. Check-in to ready up opens 15 min before start.'
: 'Choose a time at least one minute from now.'}
</span>
</>
)}
</div>
{when === 'now' && (
<div className="ctp-create-row">
<label className="ctp-create-label">Give people</label>
<div className="ctp-duration-opts">
{[5, 10, 15, 30, 60].map(minutes => (
<button
key={minutes}
className={`ctp-duration-btn ${duration === minutes ? 'is-active' : ''}`}
onClick={() => setDuration(minutes)}
>{minutes}m</button>
))}
</div>
</div>
)}
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
<button
className="act-btn act-play"
disabled={when === 'later' && !scheduledTimeIsFuture}
onClick={() => onCreate(
selected.id,
maxPlayers,
duration,
when === 'later' ? scheduledFor : null,
)}
>
{when === 'later'
? `Schedule it — ${selected.name} · ${dayOffset > 0 ? `${days[dayOffset].label} ` : ''}${time}`
: `Call it — ${selected.name}`}
</button>
</div>
</>
)}
{!selected && (
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { avatarColor, formatClock } from '../../lib/callToPlay';
import { Nomination } from '../../lib/types';
interface Props {
nomination: Nomination;
username: string;
disabled: boolean;
onSend: (text: string) => void;
}
export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => {
const [open, setOpen] = useState(false);
const [seen, setSeen] = useState(nomination.messages.length);
const [draft, setDraft] = useState('');
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) setSeen(nomination.messages.length);
}, [open, nomination.messages.length]);
useEffect(() => {
if (open && listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
}, [open, nomination.messages.length]);
const unread = Math.max(0, nomination.messages.length - seen);
const last = nomination.messages[nomination.messages.length - 1];
const submit = () => {
const text = draft.trim();
if (!text) return;
onSend(text);
setDraft('');
};
return (
<div className={`ctp-chat ${open ? 'is-open' : ''}`}>
<button className="ctp-chat-toggle" onClick={() => setOpen(value => !value)}>
<Icon.chat />
<span>Chat</span>
{nomination.messages.length > 0 && (
<span className="ctp-chat-count">{nomination.messages.length}</span>
)}
{!open && unread > 0 && <span className="ctp-chat-unread">{unread}</span>}
{!open && last && (
<span className="ctp-chat-preview">
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b> {last.text}
</span>
)}
<span className="ctp-chat-chevron"><Icon.chevron /></span>
</button>
{open && (
<>
<div className="ctp-chat-list" ref={listRef}>
{nomination.messages.length === 0 && (
<div className="ctp-chat-empty">No messages yet say hi.</div>
)}
{nomination.messages.map(message => (
<div
key={message.id}
className={`ctp-chat-msg ${message.from === username ? 'is-me' : ''}`}
>
<b style={{ color: avatarColor(message.from) }}>{message.from}</b>
<span className="ctp-chat-time">{formatClock(message.at)}</span>
<span className="ctp-chat-text"> {message.text}</span>
</div>
))}
</div>
{!disabled && (
<div className="ctp-chat-form">
<input
type="text"
className="ctp-chat-input"
placeholder="Message the group…"
maxLength={500}
value={draft}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') submit();
}}
/>
<button className="ctp-chat-send" onClick={submit} aria-label="Send">
<Icon.send />
</button>
</div>
)}
</>
)}
</div>
);
};
@@ -0,0 +1,361 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { GameCover } from '../grid/GameCover';
import { CtpChat } from './CtpChat';
import { CallToPlayActions } from '../../hooks/useCallToPlay';
import {
avatarColor,
CHECKIN_LEAD_MS,
formatClock,
formatCountdown,
formatCountdownShort,
formatUntil,
inCountOf,
isReady,
phaseOf,
readyCountOf,
} from '../../lib/callToPlay';
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
interface Props {
nomination: Nomination;
game: Game;
username: string;
actions: CallToPlayActions;
focused: boolean;
thumbnailUrl?: string | null;
totalPeerCount: number;
onLaunch: (game: Game) => void;
}
const AvatarChip = ({
name,
participant,
now,
}: {
name: string;
participant: CallToPlayParticipant;
now: number;
}) => {
const ready = isReady(participant, now);
const isIn = !ready && participant.status === 'in';
const remaining = Math.max(0, (participant.readyAt ?? now) - now);
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
return (
<div
className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`}
title={`${name}${ready ? 'ready' : isIn ? 'in, not checked in' : `ready in ${formatCountdown(remaining)}`}`}
>
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
{ready
? <span className="ctp-avatar-check"><Icon.check /></span>
: isIn
? <span className="ctp-avatar-in">in</span>
: <span className="ctp-avatar-pending">{formatCountdownShort(remaining)}</span>}
</div>
);
};
export const NominationCard = ({
nomination,
game,
username,
actions,
focused,
thumbnailUrl,
totalPeerCount,
onLaunch,
}: Props) => {
const [confirmCancel, setConfirmCancel] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, [focused]);
const now = Date.now();
const entries = Object.entries(nomination.participants);
const readyCount = readyCountOf(nomination, now);
const inCount = inCountOf(nomination, now);
const myStatus = nomination.participants[username];
const isMe = myStatus !== undefined;
const isCreator = nomination.creator === username;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const phase = phaseOf(nomination, now);
const isScheduled = phase === 'scheduled' && !isDone && !isStarted;
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
const windowStart = nomination.scheduledFor === null
? nomination.createdAt
: nomination.scheduledFor - CHECKIN_LEAD_MS;
const remaining = Math.max(0, nomination.deadline - now);
const percentage = Math.max(
0,
Math.min(100, (remaining / Math.max(1, nomination.deadline - windowStart)) * 100),
);
const urgency = percentage < 20 ? 'high' : percentage < 50 ? 'mid' : 'low';
const installedCount = (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>
: isDone
? <div className="ctp-card-timer" data-urgency="off">Ready</div>
: isScheduled
? (
<div className="ctp-card-timer is-sched" data-urgency="off">
<span className="ctp-card-clock">{formatClock(nomination.scheduledFor!)}</span>
<span className="ctp-card-until">{formatUntil(nomination.scheduledFor! - now)}</span>
</div>
)
: isCheckin
? (
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
<span className="ctp-card-clock">{formatCountdown(remaining)}</span>
<span className="ctp-card-until">starts {formatClock(nomination.scheduledFor!)}</span>
</div>
)
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
const rosterLabel = isScheduled
? `${entries.length} in · up to ${nomination.maxPlayers} players`
: isCheckin && inCount > 0
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
: `${readyCount}/${nomination.maxPlayers} ready`;
return (
<div
ref={cardRef}
className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
>
<div className="ctp-card-top">
<div className="ctp-card-cover">
<GameCover game={game} aspect="square" thumbnailUrl={thumbnailUrl} />
</div>
<div className="ctp-card-info">
<div className="ctp-card-title">{game.name}</div>
<div className="ctp-card-sub">
{nomination.scheduledFor === null ? 'Called' : 'Scheduled'} by{' '}
<strong>{nomination.creator}</strong>
{nomination.scheduledFor !== null && ` · starts at ${formatClock(nomination.scheduledFor)}`}
{` · ${installedCount}/${lanCount} peers have it installed`}
</div>
</div>
{timer}
</div>
{isCheckin && (
<div className="ctp-checkin-note">
<Icon.clock />
<span>{isMe && myStatus.status === 'in'
? 'Starting soon — you said youre in. Check in below.'
: 'Starting soon — check-in is open.'}</span>
</div>
)}
{!isScheduled && (
<div className="ctp-progress">
<div
className="ctp-progress-fill"
style={{
width: `${isStarted || isDone ? 100 : percentage}%`,
background: isStarted || isDone ? 'var(--ok)' : 'var(--accent)',
}}
/>
</div>
)}
<div className="ctp-roster">
<div className="ctp-roster-count">{rosterLabel}</div>
<div className="ctp-avatars">
{entries.map(([name, participant]) => (
<AvatarChip key={name} name={name} participant={participant} now={now} />
))}
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
.map((_, index) => (
<div key={index} className="ctp-avatar ctp-avatar-empty" />
))}
</div>
</div>
<div className="ctp-actions">
<CardActions
nomination={nomination}
game={game}
username={username}
actions={actions}
onLaunch={onLaunch}
now={now}
/>
</div>
<CtpChat
nomination={nomination}
username={username}
disabled={isStarted}
onSend={text => actions.sendMessage(nomination.id, text)}
/>
{isCreator && !isStarted && (
confirmCancel
? (
<div className="ctp-cancel-confirm">
<span>Cancel this call for everyone?</span>
<div className="ctp-cancel-confirm-btns">
<button
className="ghost-btn ghost-danger"
onClick={() => actions.cancel(nomination.id)}
>Yes, cancel it</button>
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>
No, keep it
</button>
</div>
</div>
)
: (
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
<Icon.trash /><span>Cancel this call</span>
</button>
)
)}
</div>
);
};
interface CardActionsProps {
nomination: Nomination;
game: Game;
username: string;
actions: CallToPlayActions;
onLaunch: (game: Game) => void;
now: number;
}
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
nomination: Nomination;
actions: CallToPlayActions;
includeThirty?: boolean;
}) => (
<>
<button className="act-btn act-play" onClick={() => actions.respond(nomination.id, 'ready')}>
<Icon.play /><span>Ready now</span>
</button>
<div className="ctp-buffer-group">
{[5, 10, 15, ...(includeThirty ? [30] : [])].map(minutes => (
<button
key={minutes}
className="ctp-buffer-btn"
onClick={() => actions.respond(nomination.id, minutes)}
>+{minutes}m</button>
))}
</div>
</>
);
const CardActions = ({
nomination,
game,
username,
actions,
onLaunch,
now,
}: CardActionsProps) => {
const myStatus = nomination.participants[username];
const isMe = myStatus !== undefined;
const isCreator = nomination.creator === username;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
const readyCount = readyCountOf(nomination, now);
if (isStarted) {
return <div className="ctp-note ctp-note-launch">Launching {game.name}</div>;
}
if (scheduled) {
if (isCreator) {
return (
<div className="ctp-note">
Scheduled for {formatClock(nomination.scheduledFor!)} check-in opens 15 min before start.
</div>
);
}
if (isMe) {
return (
<>
<div className="ctp-me-status">Youre in well nudge you when check-in opens</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Cant make it
</button>
</>
);
}
return (
<>
<button className="act-btn act-play" onClick={() => actions.rsvp(nomination.id)}>
<Icon.check /><span>Im in</span>
</button>
<div className="ctp-note">Check-in opens 15 min before start</div>
</>
);
}
if (isCreator) {
if (isDone) {
return (
<>
<button
className="act-btn act-play"
onClick={() => void actions.startNow(nomination.id).then(accepted => {
if (accepted) onLaunch(game);
})}
><Icon.play /><span>Start now</span></button>
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
Add 5 more minutes
</button>
</>
);
}
if (myStatus?.status === 'in') {
return <ReadyButtons nomination={nomination} actions={actions} />;
}
return <div className="ctp-note">Waiting for players to ready up</div>;
}
if (isDone) {
return (
<div className="ctp-note">
{readyCount >= nomination.maxPlayers
? 'Everyones ready.'
: `Its time — waiting for ${nomination.creator} to start.`}
</div>
);
}
if (isMe) {
if (myStatus.status === 'in') {
return (
<>
<div className="ctp-me-status">You said youre in check in:</div>
<ReadyButtons nomination={nomination} actions={actions} />
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Leave
</button>
</>
);
}
const ready = isReady(myStatus, now);
return (
<>
<div className="ctp-me-status">
{ready ? 'Youre in — ready' : `You: ready in ${formatCountdown((myStatus.readyAt ?? now) - now)}`}
</div>
{!ready && (
<button className="ghost-btn" onClick={() => actions.respond(nomination.id, 'ready')}>
Im ready now
</button>
)}
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Leave
</button>
</>
);
}
return <ReadyButtons nomination={nomination} actions={actions} includeThirty />;
};
@@ -3,9 +3,10 @@ import { SegmentedFilters } from './SegmentedFilters';
import { SearchField } from './SearchField'; import { SearchField } from './SearchField';
import { SortMenu } from './SortMenu'; import { SortMenu } from './SortMenu';
import { KebabMenu, KebabItem } from './KebabMenu'; import { KebabMenu, KebabItem } from './KebabMenu';
import { CallToPlayButton } from '../calltoplay/CallToPlayButton';
import { FilterCounts } from '../../lib/gameState'; import { FilterCounts } from '../../lib/gameState';
import { GameFilter, GameSort } from '../../lib/types'; import { GameFilter, GameSort, Nomination } from '../../lib/types';
interface Props { interface Props {
accent: string; accent: string;
@@ -18,6 +19,8 @@ interface Props {
sort: GameSort; sort: GameSort;
setSort: (value: GameSort) => void; setSort: (value: GameSort) => void;
kebabItems: ReadonlyArray<KebabItem>; kebabItems: ReadonlyArray<KebabItem>;
nominations: ReadonlyArray<Nomination>;
onOpenCallToPlay: () => void;
} }
export const TopBar = ({ export const TopBar = ({
@@ -31,6 +34,8 @@ export const TopBar = ({
sort, sort,
setSort, setSort,
kebabItems, kebabItems,
nominations,
onOpenCallToPlay,
}: Props) => ( }: Props) => (
<header className="topbar"> <header className="topbar">
<div className="topbar-left"> <div className="topbar-left">
@@ -47,6 +52,7 @@ export const TopBar = ({
<SortMenu value={sort} onChange={setSort} /> <SortMenu value={sort} onChange={setSort} />
</div> </div>
<div className="topbar-right-tail"> <div className="topbar-right-tail">
<CallToPlayButton nominations={nominations} onClick={onOpenCallToPlay} />
<KebabMenu items={kebabItems} /> <KebabMenu items={kebabItems} />
</div> </div>
</div> </div>
@@ -0,0 +1,173 @@
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 { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
export interface CallToPlayActions {
createNomination: (
gameId: string,
maxPlayers: number,
durationMinutes: number,
scheduledFor: number | null,
) => void;
respond: (callId: string, choice: 'ready' | number) => void;
rsvp: (callId: string) => void;
sendMessage: (callId: string, text: string) => void;
leave: (callId: string) => void;
cancel: (callId: string) => void;
startNow: (callId: string) => Promise<boolean>;
addTime: (callId: string, minutes?: number) => void;
}
export interface UseCallToPlay {
nominations: Nomination[];
actions: CallToPlayActions;
username: string;
transportReady: boolean;
error: string | null;
}
const mergeEvents = (
previous: ReadonlyMap<string, CallToPlayEvent>,
incoming: ReadonlyArray<CallToPlayEvent>,
): Map<string, CallToPlayEvent> => {
const next = new Map(previous);
for (const event of incoming) next.set(event.id, event);
return next;
};
export const useCallToPlay = (username: string): UseCallToPlay => {
const actor = useMemo(() => {
const trimmed = username.trim();
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
}, [username]);
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
const [now, setNow] = useState(Date.now());
const [transportReady, setTransportReady] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let cancelled = false;
let unlisten: UnlistenFn | undefined;
let retry: number | undefined;
const requestSnapshot = async (): Promise<boolean> => {
try {
const ready = await invoke<boolean>('request_call_to_play_events');
if (cancelled) return false;
setTransportReady(ready);
if (ready && retry !== undefined) {
window.clearInterval(retry);
retry = undefined;
}
return ready;
} catch (err) {
if (!cancelled) {
setTransportReady(false);
console.error('request_call_to_play_events failed:', err);
}
return false;
}
};
const register = async () => {
try {
unlisten = await listen<CallToPlayEvent[]>('call-to-play-events', event => {
setEvents(previous => mergeEvents(previous, event.payload));
});
if (cancelled) {
unlisten();
return;
}
const ready = await requestSnapshot();
if (!cancelled && !ready) {
retry = window.setInterval(() => void requestSnapshot(), 2_000);
}
} catch (err) {
console.error('Failed to register Call to Play listener:', err);
if (!cancelled) setError('Call to Play networking is unavailable.');
}
};
void register();
return () => {
cancelled = true;
unlisten?.();
if (retry !== undefined) window.clearInterval(retry);
};
}, []);
const publish = useCallback(async (
callId: string,
action: CallToPlayAction,
): Promise<boolean> => {
const event = callToPlayEvent(callId, actor, action);
try {
const accepted = await invoke<boolean>('publish_call_to_play', { event });
if (!accepted) {
setTransportReady(false);
setError('Call to Play needs an active LAN peer. Choose a game folder first.');
return false;
}
setTransportReady(true);
setError(null);
return true;
} catch (err) {
console.error('publish_call_to_play failed:', err);
setError('Could not send this Call to Play update.');
return false;
}
}, [actor]);
const actions = useMemo<CallToPlayActions>(() => ({
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
const createdAt = Date.now();
const callId = globalThis.crypto.randomUUID();
const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000;
void publish(callId, {
Create: {
game_id: gameId,
max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))),
scheduled_for: scheduledFor,
deadline,
},
});
},
respond: (callId, choice) => void publish(callId, {
Respond: {
ready_at: choice === 'ready' ? null : Date.now() + choice * 60_000,
},
}),
rsvp: callId => void publish(callId, 'Rsvp'),
sendMessage: (callId, text) => {
const trimmed = text.trim().slice(0, 500);
if (!trimmed) return;
void publish(callId, {
SendMessage: {
message_id: globalThis.crypto.randomUUID(),
text: trimmed,
},
});
},
leave: callId => void publish(callId, 'Leave'),
cancel: callId => void publish(callId, 'Cancel'),
startNow: callId => publish(callId, 'Start'),
addTime: (callId, minutes = 5) => void publish(callId, {
AddTime: { deadline: Date.now() + minutes * 60_000 },
}),
}), [publish]);
const nominations = useMemo(
() => reduceCallToPlayEvents([...events.values()], now),
[events, now],
);
return { nominations, actions, username: actor, transportReady, error };
};
@@ -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')}`;
};
@@ -60,6 +60,7 @@ export interface Game {
peer_count: number; peer_count: number;
can_host_server?: boolean; can_host_server?: boolean;
active_outbound_transfers?: number; active_outbound_transfers?: number;
installed_peer_count?: number;
} }
export interface ActiveOperation { export interface ActiveOperation {
@@ -83,3 +84,50 @@ export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'bus
/** Two-character language code passed through to game scripts. */ /** Two-character language code passed through to game scripts. */
export type LauncherLanguage = 'en' | 'de'; export type LauncherLanguage = 'en' | 'de';
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
export interface CallToPlayParticipant {
status: CallToPlayParticipantStatus;
joinedAt: number;
readyAt?: number;
}
export interface CallToPlayMessage {
id: string;
from: string;
text: string;
at: number;
}
export interface Nomination {
id: string;
gameId: string;
creator: string;
maxPlayers: number;
createdAt: number;
scheduledFor: number | null;
deadline: number;
participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[];
state: 'open' | 'done' | 'started';
startedAt?: number;
}
export type CallToPlayAction =
| { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } }
| { Respond: { ready_at: number | null } }
| 'Rsvp'
| { SendMessage: { message_id: string; text: string } }
| 'Leave'
| 'Cancel'
| 'Start'
| { AddTime: { deadline: number } };
export interface CallToPlayEvent {
id: string;
call_id: string;
actor: string;
at: number;
action: CallToPlayAction;
}
@@ -1783,3 +1783,645 @@
background: var(--accent); background: var(--accent);
border-color: transparent; border-color: transparent;
} }
/* ═══════════════════════════════════════════════════════════════════
Call to Play — rally a game + a time
═══════════════════════════════════════════════════════════════════ */
/* ─── Top-bar entry button ─── */
.ctp-btn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background .15s, border-color .15s;
}
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
.ctp-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
}
.ctp-badge {
display: inline-grid; place-items: center;
min-width: 18px; height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 10.5px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* ─── Ticker strip ─── */
.ctp-ticker-stack {
display: flex; flex-direction: column; gap: 8px;
margin-bottom: 14px;
}
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
.ctp-ticker {
display: grid;
grid-template-columns:
10px /* LED */
98px /* status */
220px /* game */
130px /* by */
88px /* count */
170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */
82px; /* cta */
align-items: center;
column-gap: 12px;
width: 100%;
padding: 10px 16px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
border-radius: 10px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
cursor: pointer;
text-align: left;
transition: background .15s, border-color .15s;
}
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
.ctp-ticker-dot {
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
animation: ctp-tickerpulse 1.6s ease-out infinite;
}
@keyframes ctp-tickerpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-ticker-label {
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
font-size: 10.5px;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-sep { display: none; }
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-more {
color: var(--t-3);
font-size: 11.5px;
flex-shrink: 0;
}
.ctp-ticker-cta {
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
font-weight: 700;
color: var(--accent);
}
.ctp-ticker-cta svg { transform: rotate(-90deg); }
/* ─── Overlay modal ─── */
.ctp-modal { width: min(720px, 100%); }
.ctp-head-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
margin-right: 36px;
}
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
.ctp-body {
padding: 18px 24px 24px;
display: flex; flex-direction: column; gap: 14px;
max-height: 66vh;
overflow: auto;
}
.ctp-empty {
padding: 28px 8px;
text-align: center;
color: var(--t-3);
font-size: 13px;
}
/* ─── Nomination card ─── */
.ctp-card {
display: flex; flex-direction: column; gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1);
border-radius: 12px;
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-started { opacity: 0.6; }
.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); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
.ctp-card-cover {
position: relative;
width: 52px; height: 52px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.ctp-card-info { flex: 1; min-width: 0; }
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
.ctp-card-timer {
font-variant-numeric: tabular-nums;
font-size: 18px;
font-weight: 700;
color: var(--t-1);
flex-shrink: 0;
padding-top: 2px;
}
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
.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-cancel-link {
display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start;
margin-top: -2px;
padding: 6px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .15s;
}
.ctp-cancel-link:hover { color: #fca5a5; }
.ctp-cancel-confirm {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.35);
border-radius: 8px;
font-size: 12.5px;
color: #fca5a5;
font-weight: 600;
}
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
.ctp-progress {
height: 5px;
border-radius: 3px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
.ctp-avatar {
position: relative;
display: inline-flex;
}
.ctp-avatar-dot {
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 999px;
color: white;
font-size: 10.5px; font-weight: 800;
letter-spacing: 0.01em;
}
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
.ctp-avatar-check {
position: absolute; bottom: -2px; right: -2px;
width: 14px; height: 14px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 2px solid var(--bg-2);
}
.ctp-avatar-pending {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 9px; font-weight: 700;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ctp-avatar-empty {
width: 30px; height: 30px;
border-radius: 999px;
border: 1.5px dashed var(--bd-2);
}
.ctp-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
padding-top: 2px;
}
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
.ctp-note { font-size: 12.5px; color: var(--t-3); }
.ctp-note-launch { color: var(--ok); font-weight: 600; }
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
.ctp-buffer-group { display: inline-flex; gap: 6px; }
.ctp-buffer-btn {
height: 36px; padding: 0 11px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
/* ─── Create-nomination form ─── */
.ctp-create-cta {
display: flex; align-items: center; justify-content: center; gap: 8px;
height: 48px;
background: transparent;
border: 1.5px dashed var(--bd-3);
border-radius: 12px;
color: var(--t-2);
font: inherit; font-size: 13px; font-weight: 700;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
.ctp-create {
display: flex; flex-direction: column; gap: 14px;
padding: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-2);
border-radius: 12px;
}
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.ctp-create-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
}
.ctp-create-search { width: 100%; }
.ctp-create-matches {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
z-index: 20;
max-height: 220px;
overflow: auto;
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
}
.ctp-create-matches button {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
text-align: left;
cursor: pointer;
}
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
.ctp-create-num {
width: 70px; height: 34px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 13px; font-weight: 600;
text-align: center;
}
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.ctp-duration-btn {
height: 32px; padding: 0 13px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.ctp-duration-btn:hover { color: var(--t-1); }
.ctp-duration-btn.is-active { color: white; }
.ctp-create-foot {
display: flex; justify-content: flex-end; gap: 10px;
padding-top: 4px;
}
.ctp-create-foot .act-btn,
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
/* ─── Time stepper ─── */
.ctp-timepick {
display: flex; align-items: center; gap: 6px;
margin-top: 6px;
}
.ctp-timepick-field {
display: flex; flex-direction: column; align-items: center; gap: 3px;
}
.ctp-time-step {
width: 54px; height: 24px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
.ctp-time-cell {
width: 54px; height: 44px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 9px;
font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
letter-spacing: 0.02em;
}
.ctp-time-editinput {
width: 132px; height: 44px;
padding: 0;
background: var(--bg-3);
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
border-radius: 9px;
font: inherit; font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
text-align: center;
letter-spacing: 0.06em;
}
.ctp-time-editinput:focus { outline: none; }
.ctp-time-editinput::placeholder { color: var(--t-4); }
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
.ctp-time-type {
align-self: center;
margin-left: 8px;
height: 30px; padding: 0 12px;
background: transparent;
border: 1px dashed var(--bd-3);
border-radius: 7px;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
.ctp-day-row { gap: 5px; }
.ctp-day-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
margin-right: 4px;
}
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
/* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
STARTING SOON (amber, glowing) · READY (green, steady) ─── */
.ctp-ticker[data-status="scheduled"] {
background: var(--bg-2);
border-color: var(--bd-2);
}
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
.ctp-ticker[data-status="soon"] {
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
animation: ctp-soon-glow 2.4s ease-in-out infinite;
}
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
@keyframes ctp-soon-glow {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
}
.ctp-ticker[data-status="ready"] {
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
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-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); }
@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; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
min-width: 0;
display: inline-flex; align-items: center; gap: 5px;
color: var(--t-3);
font-size: 12px;
}
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
.ctp-ticker-chat-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--t-2);
}
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
.ctp-checkin-note {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
border-radius: 8px;
font-size: 12.5px; font-weight: 600;
color: var(--t-1);
}
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
.ctp-card-timer.is-sched {
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
}
.ctp-card-clock { font-size: 18px; line-height: 1; }
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
.ctp-avatar-in {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
}
/* ─── Ticker mini ready-bubbles ─── */
.ctp-ticker-bubbles {
display: inline-flex; align-items: center;
justify-self: end;
}
.ctp-mini {
position: relative;
width: 22px; height: 22px;
display: grid; place-items: center;
border-radius: 999px;
margin-left: -6px;
border: 2px solid var(--bg-2);
color: white;
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
}
.ctp-mini:first-child { margin-left: 0; }
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
.ctp-mini[data-state="pending"] { opacity: 0.75; }
.ctp-mini[data-state="in"] { opacity: 0.85; }
.ctp-mini-check {
position: absolute; bottom: -3px; right: -4px;
width: 11px; height: 11px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 1.5px solid var(--bg-2);
}
.ctp-mini-check svg { width: 7px; height: 7px; }
.ctp-mini-tag {
position: absolute; bottom: -6px; right: -7px;
font-style: normal;
font-size: 8px; font-weight: 700;
line-height: 11px;
padding: 0 3px;
border-radius: 999px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
color: var(--t-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
/* ─── Per-call chat ─── */
.ctp-chat {
display: flex; flex-direction: column;
border-top: 1px solid var(--bd-1);
margin-top: 2px;
padding-top: 8px;
}
.ctp-chat-toggle {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding: 4px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 700;
cursor: pointer;
transition: color .15s;
}
.ctp-chat-toggle:hover { color: var(--t-1); }
.ctp-chat-toggle svg { flex-shrink: 0; }
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
.ctp-chat-unread {
min-width: 16px; height: 16px;
display: grid; place-items: center;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 9.5px; font-weight: 800;
flex-shrink: 0;
}
.ctp-chat-preview {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 500;
color: var(--t-3);
text-align: left;
}
.ctp-chat-preview b { font-weight: 700; }
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
.ctp-chat-list {
display: flex; flex-direction: column; gap: 6px;
max-height: 168px;
overflow-y: auto;
margin: 8px 0;
padding: 10px 12px;
background: rgba(0,0,0,0.22);
border: 1px solid var(--bd-1);
border-radius: 8px;
}
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
.ctp-chat-msg b { font-weight: 700; }
.ctp-chat-text { color: var(--t-1); }
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
.ctp-chat-form { display: flex; gap: 6px; }
.ctp-chat-input {
flex: 1; height: 32px;
padding: 0 10px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px;
}
.ctp-chat-input::placeholder { color: var(--t-4); }
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
.ctp-chat-send {
width: 32px; height: 32px;
display: grid; place-items: center;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 7px;
color: var(--t-1);
cursor: pointer;
flex-shrink: 0;
transition: background .15s;
}
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }
.ctp-transport-note {
margin-top: 10px;
font-size: 11.5px;
color: var(--t-3);
}
.ctp-transport-note.is-error { color: var(--danger); }
@container launcher (max-width: 1280px) {
.ctp-ticker {
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content 70px;
}
.ctp-ticker-by,
.ctp-ticker-chat { display: none; }
}
@container launcher (max-width: 800px) {
.ctp-ticker {
grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px;
}
.ctp-ticker-time,
.ctp-ticker-bubbles { display: none; }
}
@@ -10,12 +10,15 @@ import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDo
import { SettingsDialog } from '../components/modals/SettingsDialog'; import { SettingsDialog } from '../components/modals/SettingsDialog';
import { NoDirectoryState } from '../components/empty/NoDirectoryState'; import { NoDirectoryState } from '../components/empty/NoDirectoryState';
import { EmptyResultsState } from '../components/empty/EmptyResultsState'; import { EmptyResultsState } from '../components/empty/EmptyResultsState';
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
import { useGameDirectory } from '../hooks/useGameDirectory'; import { useGameDirectory } from '../hooks/useGameDirectory';
import { useGames } from '../hooks/useGames'; import { useGames } from '../hooks/useGames';
import { useGameActions } from '../hooks/useGameActions'; import { useGameActions } from '../hooks/useGameActions';
import { useThumbnails } from '../hooks/useThumbnails'; import { useThumbnails } from '../hooks/useThumbnails';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { useCallToPlay } from '../hooks/useCallToPlay';
import { Game } from '../lib/types'; import { Game } from '../lib/types';
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState'; import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
@@ -72,10 +75,13 @@ export const MainWindow = () => {
const games = useGames(rescan); const games = useGames(rescan);
const actions = useGameActions(games, settings); const actions = useGameActions(games, settings);
const thumbnails = useThumbnails(); const thumbnails = useThumbnails();
const callToPlay = useCallToPlay(settings.username);
const [openGameId, setOpenGameId] = useState<string | null>(null); const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null); const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
const visibleGames = useMemo( const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [], () => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory], [games.games, hasGameDirectory],
@@ -154,8 +160,22 @@ export const MainWindow = () => {
sort={settings.sort} sort={settings.sort}
setSort={(v) => setSetting('sort', v)} setSort={(v) => setSetting('sort', v)}
kebabItems={kebabItems} kebabItems={kebabItems}
nominations={callToPlay.nominations}
onOpenCallToPlay={() => {
setFocusedCallId(null);
setCallToPlayOpen(true);
}}
/> />
<main className="grid-wrap"> <main className="grid-wrap">
<CallToPlayTicker
nominations={callToPlay.nominations}
games={games.games}
accent={settings.accent}
onOpen={(callId) => {
setFocusedCallId(callId);
setCallToPlayOpen(true);
}}
/>
{hasGameDirectory ? ( {hasGameDirectory ? (
<> <>
<ResultsBar shown={filteredGames.length} total={counts.all} /> <ResultsBar shown={filteredGames.length} total={counts.all} />
@@ -220,6 +240,25 @@ export const MainWindow = () => {
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
/> />
)} )}
{callToPlayOpen && (
<CallToPlayOverlay
nominations={callToPlay.nominations}
games={games.games}
username={callToPlay.username}
actions={callToPlay.actions}
focusId={focusedCallId}
transportReady={callToPlay.transportReady}
error={callToPlay.error}
getThumbnail={thumbnails.get}
totalPeerCount={games.totalPeerCount}
onLaunch={handlePrimary}
onClose={() => {
setCallToPlayOpen(false);
setFocusedCallId(null);
}}
/>
)}
</div> </div>
); );
}; };
@@ -0,0 +1,154 @@
import {
CHECKIN_LEAD_MS,
phaseOf,
bumpTime,
normalizeTimeInput,
readyCountOf,
reduceCallToPlayEvents,
statusOf,
} from '../src/lib/callToPlay.ts';
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
const NOW = 1_000_000;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
};
const event = (
id: string,
actor: string,
action: CallToPlayAction,
at = NOW,
): CallToPlayEvent => ({ id, call_id: 'call-1', actor, at, action });
const create = (
scheduledFor: number | null = null,
deadline = NOW + 30 * 60_000,
): CallToPlayEvent => event('create', 'Alice', {
Create: {
game_id: 'game-1',
max_players: 3,
scheduled_for: scheduledFor,
deadline,
},
});
Deno.test('play-now call starts with its creator ready', () => {
const [nomination] = reduceCallToPlayEvents([create()], NOW);
assert(nomination, 'call should exist');
assertEquals(nomination.creator, 'Alice', 'creator');
assertEquals(nomination.participants.Alice.status, 'ready', 'creator status');
assertEquals(phaseOf(nomination, NOW), 'now', 'phase');
assertEquals(statusOf(nomination, NOW), 'call', 'status outside starting-soon window');
});
Deno.test('scheduled RSVP becomes check-in and pending response becomes ready over time', () => {
const scheduledFor = NOW + 60 * 60_000;
const events = [
create(scheduledFor, scheduledFor),
event('rsvp', 'Bob', 'Rsvp', NOW + 1),
event('respond', 'Bob', { Respond: { ready_at: scheduledFor - 5 * 60_000 } }, NOW + 2),
];
const [far] = reduceCallToPlayEvents(events, NOW);
assertEquals(phaseOf(far, NOW), 'scheduled', 'far-out phase');
assertEquals(far.participants.Bob.status, 'pending', 'buffered response state');
const checkinNow = scheduledFor - CHECKIN_LEAD_MS;
const [checkin] = reduceCallToPlayEvents(events, checkinNow);
assertEquals(phaseOf(checkin, checkinNow), 'checkin', 'check-in phase');
assertEquals(readyCountOf(checkin, checkinNow), 0, 'nobody ready at check-in opening');
const [ready] = reduceCallToPlayEvents(events, scheduledFor - 4 * 60_000);
assertEquals(readyCountOf(ready, scheduledFor - 4 * 60_000), 1, 'elapsed buffer is ready');
});
Deno.test('call resolves when the roster fills or its deadline elapses', () => {
const full = [
create(),
event('bob', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('carol', 'Carol', { Respond: { ready_at: null } }, NOW + 2),
];
assertEquals(reduceCallToPlayEvents(full, NOW + 2)[0].state, 'done', 'full roster');
assertEquals(
reduceCallToPlayEvents([create()], NOW + 31 * 60_000)[0].state,
'done',
'elapsed deadline',
);
});
Deno.test('creator-only controls cannot be forged by another participant', () => {
const forged = [
create(),
event('cancel', 'Mallory', 'Cancel', NOW + 1),
event('start', 'Mallory', 'Start', NOW + 2),
event('extend', 'Mallory', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 3),
];
const [nomination] = reduceCallToPlayEvents(forged, NOW + 4);
assert(nomination, 'forged cancel should not remove call');
assertEquals(nomination.state, 'open', 'forged start ignored');
assertEquals(nomination.deadline, NOW + 30 * 60_000, 'forged extension ignored');
});
Deno.test('creator can extend, start, and cancel a call', () => {
const extended = reduceCallToPlayEvents([
create(),
event('extend', 'Alice', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 1),
], NOW + 40 * 60_000)[0];
assertEquals(extended.state, 'open', 'extension reopens an elapsed call');
const started = reduceCallToPlayEvents([
create(),
event('start', 'Alice', 'Start', NOW + 1),
], NOW + 2)[0];
assertEquals(started.state, 'started', 'creator start');
const cancelled = reduceCallToPlayEvents([
create(),
event('cancel', 'Alice', 'Cancel', NOW + 1),
], NOW + 2);
assertEquals(cancelled.length, 0, 'creator cancel removes call');
});
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
const message = event('message-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Ready?' },
}, NOW + 2);
const duplicateMessage = event('other-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'duplicate' },
}, NOW + 3);
const events = [message, create(), message, duplicateMessage];
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
assertEquals(nomination.messages.length, 1, 'unique message id');
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins');
});
Deno.test('actions timestamped before creation cannot mutate a call', () => {
const events = [
event('early-cancel', 'Alice', 'Cancel', NOW - 1),
event('early-response', 'Bob', { Respond: { ready_at: null } }, NOW - 1),
create(),
];
const [nomination] = reduceCallToPlayEvents(events, NOW + 1);
assert(nomination, 'call should survive a pre-creation cancel');
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
});
Deno.test('started calls expire from local presentation history', () => {
const events = [create(), event('start', 'Alice', 'Start', NOW + 1)];
assertEquals(reduceCallToPlayEvents(events, NOW + 2).length, 1, 'fresh started call remains');
assertEquals(reduceCallToPlayEvents(events, NOW + 5_000).length, 0, 'old started call expires');
});
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
assertEquals(normalizeTimeInput('20:00'), '20:00', 'colon format');
assertEquals(normalizeTimeInput('2000'), '20:00', 'compact format');
assertEquals(normalizeTimeInput('9:30'), '09:30', 'single-digit hour');
assertEquals(normalizeTimeInput('24:00'), null, 'invalid hour');
assertEquals(bumpTime('23:45', 'minutes', 15), '00:00', 'minute wrap');
assertEquals(bumpTime('00:00', 'hours', -1), '23:00', 'hour wrap');
});
+10 -10
View File
@@ -548,7 +548,7 @@ The `useNominations({ username, seed })` hook owns the list and exposes
`createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`, `createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`,
`startNow`, `addTime`. `startNow`, `addTime`.
### Mock vs production ### Design reference vs production
The mock **simulates other people** with a 1-second `setInterval` The mock **simulates other people** with a 1-second `setInterval`
(`tickNomination`): bots RSVP to scheduled calls, ready-up during check-in, walk (`tickNomination`): bots RSVP to scheduled calls, ready-up during check-in, walk
@@ -558,14 +558,14 @@ The mock also seeds a few representative calls on mount (a live call with chat,
a fresh call you started, a scheduled call whose check-in window just opened, a fresh call you started, a scheduled call whose check-in window just opened,
and one scheduled for later collecting RSVPs). and one scheduled for later collecting RSVPs).
**In production, replace the simulation with a real-time LAN transport:** the The production launcher uses the peer's existing QUIC control channel. Each
launcher instances broadcast nominations, responses, RSVPs, and chat messages to create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
each other (whatever the app uses — a small pub/sub over the LAN, a lightweight is an immutable, uniquely identified event. Connected peers receive new events
signaling server, or Tauri-side networking). Swap the guts of `useNominations` immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
(the `setInterval` + local `setNoms`) for your networking layer that pushes the history so a late joiner reconstructs the same calls. The frontend reducer turns
same state shape; the rendering components need nothing else. The `username` that that event history into the `Nomination` state above and derives time-based phase
identifies "you" comes from `settings.username` (the Profile setting), not a changes locally. The `username` that identifies "you" comes from
prop default. `settings.username` (the Profile setting), not a prop default.
--- ---
@@ -773,5 +773,5 @@ To preview the design in a browser:
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready. - **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal. - **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows. - **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
- **Call to Play real-time transport** — the feature is fully designed and interactive, but the mock fakes other players with a local `setInterval` simulation. Production needs a real LAN transport that broadcasts nominations / responses / RSVPs / chat between launcher instances and pushes the same state shape into `useNominations`. Notifications when a call you're in enters its check-in window (OS notification / tray) are also a follow-up. - **Call to Play notifications** — real-time LAN transport is implemented. OS / tray notifications when a call you're in enters its check-in window remain a follow-up.
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization. - **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.