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