From 0f53bc4b78e4cc307c4be207a6b53c4e8dae1cd8 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Tue, 21 Jul 2026 22:30:11 +0200 Subject: [PATCH] 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 --- .dockerignore | 1 + PEER_CLI_SCENARIOS.md | 9 + .../scripts/run_extended_scenarios.py | 84 ++- crates/lanspread-peer-cli/src/lib.rs | 32 +- crates/lanspread-peer-cli/src/main.rs | 39 ++ crates/lanspread-peer/ARCHITECTURE.md | 15 + crates/lanspread-peer/src/call_to_play.rs | 225 ++++++ crates/lanspread-peer/src/context.rs | 5 + crates/lanspread-peer/src/identity.rs | 2 + crates/lanspread-peer/src/lib.rs | 15 + crates/lanspread-peer/src/network.rs | 9 +- .../lanspread-peer/src/services/handshake.rs | 133 +++- crates/lanspread-peer/src/services/stream.rs | 10 + crates/lanspread-proto/src/lib.rs | 40 +- .../src-tauri/Cargo.toml | 2 +- .../src-tauri/src/lib.rs | 52 ++ .../src/components/Icon.tsx | 31 + .../calltoplay/CallToPlayButton.tsx | 18 + .../calltoplay/CallToPlayOverlay.tsx | 103 +++ .../calltoplay/CallToPlayTicker.tsx | 124 ++++ .../calltoplay/CreateNominationForm.tsx | 278 ++++++++ .../src/components/calltoplay/CtpChat.tsx | 91 +++ .../components/calltoplay/NominationCard.tsx | 361 ++++++++++ .../src/components/topbar/TopBar.tsx | 8 +- .../src/hooks/useCallToPlay.ts | 173 +++++ .../src/lib/callToPlay.ts | 290 ++++++++ .../lanspread-tauri-deno-ts/src/lib/types.ts | 48 ++ .../src/styles/launcher.css | 642 ++++++++++++++++++ .../src/windows/MainWindow.tsx | 39 ++ .../tests/callToPlay.test.ts | 154 +++++ design/launcher/SPEC.md | 20 +- 31 files changed, 3032 insertions(+), 21 deletions(-) create mode 100644 crates/lanspread-peer/src/call_to_play.rs create mode 100644 crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayButton.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayOverlay.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/calltoplay/CreateNominationForm.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/calltoplay/CtpChat.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts create mode 100644 crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts diff --git a/.dockerignore b/.dockerignore index 1b90caa..3f028a7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ .git .agents .codex +.lanspread-peer-cli target **/target **/node_modules diff --git a/PEER_CLI_SCENARIOS.md b/PEER_CLI_SCENARIOS.md index abc35f9..44fe559 100644 --- a/PEER_CLI_SCENARIOS.md +++ b/PEER_CLI_SCENARIOS.md @@ -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. | | 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. | +| 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 @@ -143,6 +144,14 @@ Use S39-S41 to pin down low-disk streamed installs: ## 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 - An adversarial review of `run_extended_scenarios.py` found assertions that diff --git a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py index b46f933..528c7a2 100644 --- a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py +++ b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py @@ -1,5 +1,5 @@ #!/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 @@ -242,6 +242,12 @@ class Peer: def status(self) -> dict[str, Any]: 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: if other.ready_addr is None: raise ScenarioError(f"{other.name} is not ready") @@ -350,6 +356,7 @@ class Runner: ("S45", self.s45_sender_disconnect_mid_stream), ("S46", self.s46_receiver_cancel_mid_stream), ("S47", self.s47_multi_archive_streams_in_sorted_order), + ("S48", self.s48_call_to_play_replication_and_late_join), ] for scenario_id, scenario in scenarios: @@ -1757,6 +1764,63 @@ class Runner: 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]: 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( game: dict[str, Any], *, diff --git a/crates/lanspread-peer-cli/src/lib.rs b/crates/lanspread-peer-cli/src/lib.rs index 7ed9f35..0f9d454 100644 --- a/crates/lanspread-peer-cli/src/lib.rs +++ b/crates/lanspread-peer-cli/src/lib.rs @@ -9,7 +9,7 @@ use std::{ }; use eyre::{Context, OptionExt}; -use lanspread_peer::{UnpackFuture, Unpacker}; +use lanspread_peer::{CallToPlayEvent, UnpackFuture, Unpacker}; use serde::Serialize; use serde_json::{Value, json}; @@ -26,6 +26,10 @@ pub enum CliCommand { Status, ListPeers, ListGames, + ListCallToPlay, + PublishCallToPlay { + event: CallToPlayEvent, + }, SetGameDir { path: PathBuf, }, @@ -67,6 +71,8 @@ impl CliCommand { Self::Status => "status", Self::ListPeers => "list-peers", Self::ListGames => "list-games", + Self::ListCallToPlay => "list-call-to-play", + Self::PublishCallToPlay { .. } => "publish-call-to-play", Self::SetGameDir { .. } => "set-game-dir", Self::Download { .. } => "download", Self::StreamInstall { .. } => "stream-install", @@ -102,6 +108,16 @@ pub fn parse_command_value(value: &Value) -> eyre::Result { "status" => CliCommand::Status, "list-peers" => CliCommand::ListPeers, "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 { 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] async fn fixture_unpacker_creates_install_payload() { let temp = TempDir::new("lanspread-peer-cli-fixture"); diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index 9e014f7..52b268d 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -16,6 +16,7 @@ use lanspread_db::db::{Game, GameCatalog, GameFileDescription}; use lanspread_peer::{ ActiveOperation, ActiveOperationKind, + CallToPlayEvent, ExternalUnrarStreamProvider, NoopStreamInstallProvider, OutboundTransfers, @@ -101,6 +102,8 @@ struct CliState { game_files: HashMap>, unavailable_games: HashSet, downloads: HashMap, + call_to_play_events: Vec, + call_to_play_generation: u64, } #[derive(Clone, serde::Serialize)] @@ -243,6 +246,26 @@ async fn handle_command( CliCommand::Status => status(shared).await, CliCommand::ListPeers => list_peers(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 } => { sender.send(PeerCommand::SetGameDir(path.clone()))?; 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) }), ) } + 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::>(); + 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 { id, file_descriptions, diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index a42e6e0..4927eae 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -44,6 +44,21 @@ When a peer is discovered: - Any message updates `last_seen`. - Pings run only when idle (or on a longer interval), not every 5 seconds. - 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 diff --git a/crates/lanspread-peer/src/call_to_play.rs b/crates/lanspread-peer/src/call_to_play.rs new file mode 100644 index 0000000..7fefbfb --- /dev/null +++ b/crates/lanspread-peer/src/call_to_play.rs @@ -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, + event_ids: HashSet, +} + +impl CallToPlayStore { + pub(crate) fn snapshot(&self) -> Vec { + self.events.clone() + } + + pub(crate) fn insert(&mut self, event: CallToPlayEvent) -> Result { + 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) -> Vec { + 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, + 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::>(); + 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")]); + } +} diff --git a/crates/lanspread-peer/src/context.rs b/crates/lanspread-peer/src/context.rs index db47b93..2d2c8b1 100644 --- a/crates/lanspread-peer/src/context.rs +++ b/crates/lanspread-peer/src/context.rs @@ -10,6 +10,7 @@ use crate::{ PeerEvent, StreamInstallProvider, Unpacker, + call_to_play::CallToPlayStore, events, library::LocalLibraryState, peer_db::PeerGameDB, @@ -51,6 +52,7 @@ pub struct Ctx { pub shutdown: CancellationToken, pub task_tracker: TaskTracker, pub active_outbound_transfers: OutboundTransfers, + pub call_to_play: Arc>, } /// Context for peer connection handling. @@ -69,6 +71,7 @@ pub struct PeerCtx { pub shutdown: CancellationToken, pub task_tracker: TaskTracker, pub active_outbound_transfers: OutboundTransfers, + pub call_to_play: Arc>, } impl std::fmt::Debug for PeerCtx { @@ -113,6 +116,7 @@ impl Ctx { shutdown, task_tracker, active_outbound_transfers, + call_to_play: Arc::new(RwLock::new(CallToPlayStore::default())), } } @@ -135,6 +139,7 @@ impl Ctx { shutdown: self.shutdown.clone(), task_tracker: self.task_tracker.clone(), active_outbound_transfers: self.active_outbound_transfers.clone(), + call_to_play: self.call_to_play.clone(), } } } diff --git a/crates/lanspread-peer/src/identity.rs b/crates/lanspread-peer/src/identity.rs index d7632a2..5a14522 100644 --- a/crates/lanspread-peer/src/identity.rs +++ b/crates/lanspread-peer/src/identity.rs @@ -6,6 +6,7 @@ use crate::state_paths::peer_id_path; pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-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 { let path = peer_id_path(state_dir); @@ -28,5 +29,6 @@ pub fn default_features() -> Vec { vec![ FEATURE_LIBRARY_DELTA.to_string(), FEATURE_LIBRARY_SNAPSHOT.to_string(), + FEATURE_CALL_TO_PLAY.to_string(), ] } diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index fa5b765..9061ff0 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -12,6 +12,7 @@ // Module declarations // ============================================================================= +mod call_to_play; mod config; mod context; mod download; @@ -46,6 +47,7 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT}; pub use error::PeerError; pub use install::{UnpackFuture, Unpacker}; use lanspread_db::db::{Game, GameCatalog, GameFileDescription}; +pub use lanspread_proto::{CallToPlayAction, CallToPlayEvent}; pub use migration::{MigrationReport, migrate_legacy_state}; pub use peer_db::{ MajorityValidationResult, @@ -159,6 +161,8 @@ pub enum PeerEvent { ActiveOperationsChanged { active_operations: Vec, }, + /// New or requested Call to Play events in replication order. + CallToPlayEvents(Vec), /// A required peer runtime component failed. RuntimeFailed { component: PeerRuntimeComponent, @@ -259,6 +263,10 @@ pub enum PeerCommand { GetPeerCount, /// Connect directly to a peer address without waiting for mDNS discovery. 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. @@ -489,6 +497,13 @@ async fn handle_peer_commands( PeerCommand::ConnectPeer(addr) => { 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)); + } } } } diff --git a/crates/lanspread-peer/src/network.rs b/crates/lanspread-peer/src/network.rs index 7609902..7d08e20 100644 --- a/crates/lanspread-peer/src/network.rs +++ b/crates/lanspread-peer/src/network.rs @@ -9,7 +9,7 @@ use bytes::BytesMut; use futures::{SinkExt, StreamExt}; use if_addrs::{IfAddr, Interface, get_if_addrs}; 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::{ Client as QuicClient, 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 } +pub async fn send_call_to_play_events( + peer_addr: SocketAddr, + events: Vec, +) -> eyre::Result<()> { + send_oneway_request(peer_addr, Request::CallToPlayEvents { events }).await +} + /// Requests game file details from a peer. pub async fn request_game_details_from_peer( peer_addr: SocketAddr, diff --git a/crates/lanspread-peer/src/services/handshake.rs b/crates/lanspread-peer/src/services/handshake.rs index 8680179..005e6ef 100644 --- a/crates/lanspread-peer/src/services/handshake.rs +++ b/crates/lanspread-peer/src/services/handshake.rs @@ -8,6 +8,7 @@ use tokio::sync::{RwLock, mpsc::UnboundedSender}; use crate::{ PeerEvent, + call_to_play::CallToPlayStore, context::{Ctx, PeerCtx}, events, identity::default_features, @@ -24,6 +25,7 @@ pub(crate) struct HandshakeCtx { peer_game_db: Arc>, tx_notify_ui: UnboundedSender, catalog: Arc>, + call_to_play: Arc>, } impl HandshakeCtx { @@ -35,6 +37,7 @@ impl HandshakeCtx { peer_game_db: ctx.peer_game_db.clone(), tx_notify_ui: tx_notify_ui.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(), tx_notify_ui: ctx.tx_notify_ui.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 { - let library_guard = ctx.local_library.read().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 { peer_id: ctx.peer_id.as_ref().clone(), proto_ver: PROTOCOL_VERSION, listen_addr, library, features: default_features(), + call_to_play_events, }) } async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result { - let library_guard = ctx.local_library.read().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 { peer_id: ctx.peer_id.as_ref().clone(), proto_ver: PROTOCOL_VERSION, listen_addr, library, 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); } + 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 upsert = record_remote_library( &ctx.peer_game_db, @@ -149,6 +168,12 @@ pub(super) async fn accept_inbound_hello( } 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 upsert = record_remote_library( &ctx.peer_game_db, @@ -165,6 +190,17 @@ pub(super) async fn accept_inbound_hello( build_hello_ack(ctx).await } +async fn merge_call_to_play_events( + store: &Arc>, + tx_notify_ui: &UnboundedSender, + incoming: Vec, +) { + 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( ctx: HandshakeCtx, peer_addr: SocketAddr, @@ -212,7 +248,15 @@ mod tests { }; 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_util::{sync::CancellationToken, task::TaskTracker}; @@ -248,6 +292,7 @@ mod tests { peer_game_db, tx_notify_ui, 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] async fn outbound_hello_requires_local_listener_addr() { let ctx = test_handshake_ctx(None); @@ -304,6 +364,22 @@ mod tests { 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] async fn inbound_hello_applies_remote_library_snapshot() { let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); @@ -335,6 +411,7 @@ mod tests { games: vec![summary("remote-game")], }, features: Vec::new(), + call_to_play_events: Vec::new(), }; let ack = accept_inbound_hello(&peer_ctx, None, hello) @@ -406,6 +483,7 @@ mod tests { games: vec![summary("self-game")], }, features: Vec::new(), + call_to_play_events: Vec::new(), }; let ack = accept_inbound_hello(&peer_ctx, None, self_hello) @@ -422,4 +500,49 @@ mod tests { "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()] + )); + } } diff --git a/crates/lanspread-peer/src/services/stream.rs b/crates/lanspread-peer/src/services/stream.rs index 5e906fe..a8e4ede 100644 --- a/crates/lanspread-peer/src/services/stream.rs +++ b/crates/lanspread-peer/src/services/stream.rs @@ -90,6 +90,16 @@ async fn dispatch_request( handle_library_delta(ctx, peer_id, delta).await; 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::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await, Request::GetGameFileChunk { diff --git a/crates/lanspread-proto/src/lib.rs b/crates/lanspread-proto/src/lib.rs index 3aad2dc..c32fcf1 100644 --- a/crates/lanspread-proto/src/lib.rs +++ b/crates/lanspread-proto/src/lib.rs @@ -4,7 +4,7 @@ use bytes::Bytes; use lanspread_db::db::{Game, GameFileDescription}; use serde::{Deserialize, Serialize}; -pub const PROTOCOL_VERSION: u32 = 5; +pub const PROTOCOL_VERSION: u32 = 6; pub use lanspread_db::db::Availability; @@ -27,6 +27,7 @@ pub struct Hello { pub listen_addr: SocketAddr, pub library: LibrarySnapshot, pub features: Vec, + pub call_to_play_events: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -36,6 +37,40 @@ pub struct HelloAck { pub listen_addr: SocketAddr, pub library: LibrarySnapshot, pub features: Vec, + pub call_to_play_events: Vec, +} + +#[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, + deadline: i64, + }, + Respond { + ready_at: Option, + }, + Rsvp, + SendMessage { + message_id: String, + text: String, + }, + Leave, + Cancel, + Start, + AddTime { + deadline: i64, + }, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -75,6 +110,9 @@ pub enum Request { peer_id: String, delta: LibraryDelta, }, + CallToPlayEvents { + events: Vec, + }, Goodbye { peer_id: String, }, diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml index e48222a..ea3496a 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml +++ b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml @@ -44,7 +44,7 @@ walkdir = { workspace = true } [build-dependencies] tauri-build = { version = "2", features = [] } -[target.'cfg(windows)'.dependencies] +[target."cfg(windows)".dependencies] windows = { workspace = true } [lints.clippy] diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 4115bcd..310c93a 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescript use lanspread_peer::{ ActiveOperation, ActiveOperationKind, + CallToPlayEvent, ExternalUnrarStreamProvider, NoopStreamInstallProvider, PeerCommand, @@ -161,6 +162,7 @@ struct LauncherGame { game: Game, can_host_server: bool, active_outbound_transfers: usize, + installed_peer_count: u32, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] @@ -272,6 +274,35 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result Ok(()) } +#[tauri::command] +async fn request_call_to_play_events( + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result { + 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 { + 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] async fn install_game( id: String, @@ -1031,6 +1062,19 @@ fn clear_all_local_game_states(game_db: &mut GameDB) { async fn emit_games_list(app_handle: &AppHandle) { let state = app_handle.state::(); + 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::::new(), |mut counts, game| { + *counts.entry(game.id).or_default() += 1; + counts + }); + let games_db_lock = state.games.clone(); let game_db = games_db_lock.read().await; let games_folder = state.games_folder.read().await.clone(); @@ -1051,6 +1095,7 @@ async fn emit_games_list(app_handle: &AppHandle) { LauncherGame { can_host_server: game_can_host_server(&games_folder, &game), active_outbound_transfers, + installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0), game, } }) @@ -2178,6 +2223,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { } 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 => { log::info!("PeerEvent::OutboundTransferCountChanged received"); schedule_outbound_transfer_emit(app_handle).await; @@ -2348,6 +2398,8 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ request_games, + request_call_to_play_events, + publish_call_to_play, install_game, stream_install_game, run_game, diff --git a/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx b/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx index 7231bea..989be54 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx @@ -105,4 +105,35 @@ export const Icon = { ), + flag: (p: Props) => ( + + + + ), + clock: (p: Props) => ( + + + + + ), + chat: (p: Props) => ( + + + + ), + send: (p: Props) => ( + + + + ), + caretUp: (p: Props) => ( + + + + ), + caretDown: (p: Props) => ( + + + + ), } satisfies Record JSX.Element>; diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayButton.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayButton.tsx new file mode 100644 index 0000000..95240d8 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayButton.tsx @@ -0,0 +1,18 @@ +import { Icon } from '../Icon'; +import { Nomination } from '../../lib/types'; + +interface Props { + nominations: ReadonlyArray; + onClick: () => void; +} + +export const CallToPlayButton = ({ nominations, onClick }: Props) => { + const activeCount = nominations.filter(nomination => nomination.state !== 'started').length; + return ( + + ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayOverlay.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayOverlay.tsx new file mode 100644 index 0000000..6440ab6 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayOverlay.tsx @@ -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; + games: ReadonlyArray; + 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 ( + + +
+

Call to Play

+

+ Rally the LAN around a game and a time — right now, or scheduled for later with + an “I’m in” RSVP. The caller decides when it actually starts. +

+ {!showCreate && ( + + )} + {!transportReady && !error && ( +
Connecting Call to Play to the LAN…
+ )} + {error &&
{error}
} +
+
+ {showCreate && ( + setShowCreate(false)} + onCreate={(gameId, maxPlayers, duration, scheduledFor) => { + actions.createNomination(gameId, maxPlayers, duration, scheduledFor); + setShowCreate(false); + }} + /> + )} + {sorted.length === 0 && !showCreate && ( +
+ No active calls right now — be the one to start something. +
+ )} + {sorted.map(nomination => { + const game = gameById.get(nomination.gameId); + if (!game) return null; + return ( + + ); + })} +
+
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx new file mode 100644 index 0000000..cc4aa29 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx @@ -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; + games: ReadonlyArray; + 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 ( + + {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 ( + + {initials} + {ready && } + {state === 'pending' && ( + {formatCountdownShort(remaining)} + )} + + ); + })} + {entries.length > 6 && +{entries.length - 6}} + + ); +}; + +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 ( +
+ {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 ( + + ); + })} +
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CreateNominationForm.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CreateNominationForm.tsx new file mode 100644 index 0000000..b3036f9 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CreateNominationForm.tsx @@ -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; + 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(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(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 ( +
+
+ +
+ + { + setQuery(event.target.value); + setGameId(null); + }} + /> +
+ {!selected && query.trim() && ( +
+ {matches.map(game => ( + + ))} + {matches.length === 0 && ( +
No games match “{query}”
+ )} +
+ )} +
+ + {selected && ( + <> +
+ + setMaxPlayers( + Math.max(2, Math.min(64, Number(event.target.value) || 2)), + )} + /> + + Suggested for {selected.name}: {suggestedPlayers(selected)} + +
+
+ +
+ + +
+ {when === 'later' && ( + <> +
+ {editingTime ? ( + setTimeDraft(previous => + sanitizeTimeDraft(event.target.value, previous) + )} + onBlur={commitTimeDraft} + onKeyDown={event => { + if (event.key === 'Enter') commitTimeDraft(); + if (event.key === 'Escape') setEditingTime(false); + }} + /> + ) : ( + <> +
+ + {time.slice(0, 2)} + +
+ : +
+ + {time.slice(3)} + +
+ + + )} +
+
+ Day + {days.map(day => ( + + ))} +
+ + {scheduledTimeIsFuture + ? '24-hour clock. Check-in to ready up opens 15 min before start.' + : 'Choose a time at least one minute from now.'} + + + )} +
+ {when === 'now' && ( +
+ +
+ {[5, 10, 15, 30, 60].map(minutes => ( + + ))} +
+
+ )} +
+ + +
+ + )} + {!selected && ( +
+ +
+ )} +
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CtpChat.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CtpChat.tsx new file mode 100644 index 0000000..69ed8e0 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CtpChat.tsx @@ -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(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 ( +
+ + {open && ( + <> +
+ {nomination.messages.length === 0 && ( +
No messages yet — say hi.
+ )} + {nomination.messages.map(message => ( +
+ {message.from} + {formatClock(message.at)} + {message.text} +
+ ))} +
+ {!disabled && ( +
+ setDraft(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') submit(); + }} + /> + +
+ )} + + )} +
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx new file mode 100644 index 0000000..32a9217 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx @@ -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 ( +
+ {initials} + {ready + ? + : isIn + ? in + : {formatCountdownShort(remaining)}} +
+ ); +}; + +export const NominationCard = ({ + nomination, + game, + username, + actions, + focused, + thumbnailUrl, + totalPeerCount, + onLaunch, +}: Props) => { + const [confirmCancel, setConfirmCancel] = useState(false); + const cardRef = useRef(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 + ?
Launching…
+ : isDone + ?
Ready
+ : isScheduled + ? ( +
+ {formatClock(nomination.scheduledFor!)} + {formatUntil(nomination.scheduledFor! - now)} +
+ ) + : isCheckin + ? ( +
+ {formatCountdown(remaining)} + starts {formatClock(nomination.scheduledFor!)} +
+ ) + :
{formatCountdown(remaining)}
; + + 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 ( +
+
+
+ +
+
+
{game.name}
+
+ {nomination.scheduledFor === null ? 'Called' : 'Scheduled'} by{' '} + {nomination.creator} + {nomination.scheduledFor !== null && ` · starts at ${formatClock(nomination.scheduledFor)}`} + {` · ${installedCount}/${lanCount} peers have it installed`} +
+
+ {timer} +
+ + {isCheckin && ( +
+ + {isMe && myStatus.status === 'in' + ? 'Starting soon — you said you’re in. Check in below.' + : 'Starting soon — check-in is open.'} +
+ )} + + {!isScheduled && ( +
+
+
+ )} + +
+
{rosterLabel}
+
+ {entries.map(([name, participant]) => ( + + ))} + {Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) }) + .map((_, index) => ( +
+ ))} +
+
+ +
+ +
+ + actions.sendMessage(nomination.id, text)} + /> + + {isCreator && !isStarted && ( + confirmCancel + ? ( +
+ Cancel this call for everyone? +
+ + +
+
+ ) + : ( + + ) + )} +
+ ); +}; + +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; +}) => ( + <> + +
+ {[5, 10, 15, ...(includeThirty ? [30] : [])].map(minutes => ( + + ))} +
+ +); + +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
Launching {game.name}…
; + } + if (scheduled) { + if (isCreator) { + return ( +
+ Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start. +
+ ); + } + if (isMe) { + return ( + <> +
You’re in — we’ll nudge you when check-in opens
+ + + ); + } + return ( + <> + +
Check-in opens 15 min before start
+ + ); + } + if (isCreator) { + if (isDone) { + return ( + <> + + + + ); + } + if (myStatus?.status === 'in') { + return ; + } + return
Waiting for players to ready up…
; + } + if (isDone) { + return ( +
+ {readyCount >= nomination.maxPlayers + ? 'Everyone’s ready.' + : `It’s time — waiting for ${nomination.creator} to start.`} +
+ ); + } + if (isMe) { + if (myStatus.status === 'in') { + return ( + <> +
You said you’re in — check in:
+ + + + ); + } + const ready = isReady(myStatus, now); + return ( + <> +
+ {ready ? 'You’re in — ready' : `You: ready in ${formatCountdown((myStatus.readyAt ?? now) - now)}`} +
+ {!ready && ( + + )} + + + ); + } + return ; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/topbar/TopBar.tsx b/crates/lanspread-tauri-deno-ts/src/components/topbar/TopBar.tsx index 3cec4f4..f96b117 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/topbar/TopBar.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/topbar/TopBar.tsx @@ -3,9 +3,10 @@ import { SegmentedFilters } from './SegmentedFilters'; import { SearchField } from './SearchField'; import { SortMenu } from './SortMenu'; import { KebabMenu, KebabItem } from './KebabMenu'; +import { CallToPlayButton } from '../calltoplay/CallToPlayButton'; import { FilterCounts } from '../../lib/gameState'; -import { GameFilter, GameSort } from '../../lib/types'; +import { GameFilter, GameSort, Nomination } from '../../lib/types'; interface Props { accent: string; @@ -18,6 +19,8 @@ interface Props { sort: GameSort; setSort: (value: GameSort) => void; kebabItems: ReadonlyArray; + nominations: ReadonlyArray; + onOpenCallToPlay: () => void; } export const TopBar = ({ @@ -31,6 +34,8 @@ export const TopBar = ({ sort, setSort, kebabItems, + nominations, + onOpenCallToPlay, }: Props) => (
@@ -47,6 +52,7 @@ export const TopBar = ({
+
diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts new file mode 100644 index 0000000..83be4c2 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts @@ -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; + addTime: (callId: string, minutes?: number) => void; +} + +export interface UseCallToPlay { + nominations: Nomination[]; + actions: CallToPlayActions; + username: string; + transportReady: boolean; + error: string | null; +} + +const mergeEvents = ( + previous: ReadonlyMap, + incoming: ReadonlyArray, +): Map => { + 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>(() => new Map()); + const [now, setNow] = useState(Date.now()); + const [transportReady, setTransportReady] = useState(false); + const [error, setError] = useState(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 => { + try { + const ready = await invoke('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('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 => { + const event = callToPlayEvent(callId, actor, action); + try { + const accepted = await invoke('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(() => ({ + 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 }; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts new file mode 100644 index 0000000..0b3925d --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts @@ -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; +} + +const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number => + a.at - b.at || a.id.localeCompare(b.id); + +type CreatePayload = Extract['Create']; +type RespondPayload = Extract['Respond']; +type MessagePayload = Extract['SendMessage']; +type AddTimePayload = Extract['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, + now: number, +): Nomination[] => { + const unique = new Map(input.map(event => [event.id, event])); + const byCall = new Map(); + 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, +): 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')}`; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/types.ts b/crates/lanspread-tauri-deno-ts/src/lib/types.ts index fd9ec4f..83f95e6 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/types.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/types.ts @@ -60,6 +60,7 @@ export interface Game { peer_count: number; can_host_server?: boolean; active_outbound_transfers?: number; + installed_peer_count?: number; } export interface ActiveOperation { @@ -83,3 +84,50 @@ export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'bus /** Two-character language code passed through to game scripts. */ 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; + 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; +} diff --git a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css index e5c6947..6093935 100644 --- a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css +++ b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css @@ -1783,3 +1783,645 @@ background: var(--accent); 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; } +} diff --git a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx index ef53220..911446d 100644 --- a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx +++ b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx @@ -10,12 +10,15 @@ import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDo import { SettingsDialog } from '../components/modals/SettingsDialog'; import { NoDirectoryState } from '../components/empty/NoDirectoryState'; import { EmptyResultsState } from '../components/empty/EmptyResultsState'; +import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker'; +import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay'; import { useGameDirectory } from '../hooks/useGameDirectory'; import { useGames } from '../hooks/useGames'; import { useGameActions } from '../hooks/useGameActions'; import { useThumbnails } from '../hooks/useThumbnails'; import { useSettings } from '../hooks/useSettings'; +import { useCallToPlay } from '../hooks/useCallToPlay'; import { Game } from '../lib/types'; import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState'; @@ -72,10 +75,13 @@ export const MainWindow = () => { const games = useGames(rescan); const actions = useGameActions(games, settings); const thumbnails = useThumbnails(); + const callToPlay = useCallToPlay(settings.username); const [openGameId, setOpenGameId] = useState(null); const [removeGameId, setRemoveGameId] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); + const [callToPlayOpen, setCallToPlayOpen] = useState(false); + const [focusedCallId, setFocusedCallId] = useState(null); const visibleGames = useMemo( () => hasGameDirectory ? games.games : [], [games.games, hasGameDirectory], @@ -154,8 +160,22 @@ export const MainWindow = () => { sort={settings.sort} setSort={(v) => setSetting('sort', v)} kebabItems={kebabItems} + nominations={callToPlay.nominations} + onOpenCallToPlay={() => { + setFocusedCallId(null); + setCallToPlayOpen(true); + }} />
+ { + setFocusedCallId(callId); + setCallToPlayOpen(true); + }} + /> {hasGameDirectory ? ( <> @@ -220,6 +240,25 @@ export const MainWindow = () => { onClose={() => setSettingsOpen(false)} /> )} + + {callToPlayOpen && ( + { + setCallToPlayOpen(false); + setFocusedCallId(null); + }} + /> + )}
); }; diff --git a/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts new file mode 100644 index 0000000..6bfbfd5 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts @@ -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 = (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'); +}); diff --git a/design/launcher/SPEC.md b/design/launcher/SPEC.md index 9e02f99..c8ce251 100644 --- a/design/launcher/SPEC.md +++ b/design/launcher/SPEC.md @@ -548,7 +548,7 @@ The `useNominations({ username, seed })` hook owns the list and exposes `createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`, `startNow`, `addTime`. -### Mock vs production +### Design reference vs production The mock **simulates other people** with a 1-second `setInterval` (`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, and one scheduled for later collecting RSVPs). -**In production, replace the simulation with a real-time LAN transport:** the -launcher instances broadcast nominations, responses, RSVPs, and chat messages to -each other (whatever the app uses — a small pub/sub over the LAN, a lightweight -signaling server, or Tauri-side networking). Swap the guts of `useNominations` -(the `setInterval` + local `setNoms`) for your networking layer that pushes the -same state shape; the rendering components need nothing else. The `username` that -identifies "you" comes from `settings.username` (the Profile setting), not a -prop default. +The production launcher uses the peer's existing QUIC control channel. Each +create, response, RSVP, chat, leave, cancel, start, or deadline-extension action +is an immutable, uniquely identified event. Connected peers receive new events +immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event +history so a late joiner reconstructs the same calls. The frontend reducer turns +that event history into the `Nomination` state above and derives time-based phase +changes locally. The `username` that identifies "you" comes from +`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. - **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. -- **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.