Compare commits
3
Commits
8f151e38b4
...
4b7725db16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7725db16
|
||
|
|
29eacabcc0
|
||
|
|
0f53bc4b78
|
@@ -1,6 +1,7 @@
|
||||
.git
|
||||
.agents
|
||||
.codex
|
||||
.lanspread-peer-cli
|
||||
target
|
||||
**/target
|
||||
**/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. |
|
||||
| S46 | Receiver cancel during streamed install | A client starts streaming large catalog-version `alienswarm`, receives the first chunk, then sends `cancel-download alienswarm`. | The receiver cancels without emitting download/install success or a user-visible download failure, clears active operations, and rolls back local/staging state. |
|
||||
| S47 | Multi-archive streamed install order | A source serves `fixture-multi/cnctw` with two root `.eti` archives named to require sorted processing. | Streamed chunk paths arrive in root archive sort order, both payloads install under `local/`, the receiver is local-only installed, and no root archives or sentinel are committed. |
|
||||
| S48 | Call to Play replication and late join | Alice and Bob connect; Alice publishes a call, then Bob publishes an RSVP and chat message. Charlie joins afterward and handshakes with Alice. | Alice and Bob receive the live events, while Charlie reconstructs the same three-event history during handshake with no duplicate event IDs. |
|
||||
|
||||
## Version-Skew Contract
|
||||
|
||||
@@ -143,6 +144,14 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
|
||||
## Run Log
|
||||
|
||||
### 2026-07-21 - Call to Play Transport (S48)
|
||||
|
||||
- Added JSONL commands to publish and inspect Call to Play events.
|
||||
- S2 passed against the rebuilt image, preserving bidirectional library
|
||||
exchange after the protocol version bump.
|
||||
- S48 passed against the rebuilt image: create, RSVP, and chat propagated live,
|
||||
then a late third peer received the same deduplicated history in handshake.
|
||||
|
||||
### 2026-06-21 - Test-Suite Integrity Audit And Hardening
|
||||
|
||||
- An adversarial review of `run_extended_scenarios.py` found assertions that
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the peer-cli scenarios S1-S47 through Docker."""
|
||||
"""Run the peer-cli scenarios S1-S48 through Docker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -242,6 +242,12 @@ class Peer:
|
||||
def status(self) -> dict[str, Any]:
|
||||
return self.send({"cmd": "status"})["data"]
|
||||
|
||||
def call_to_play_events(self) -> list[dict[str, Any]]:
|
||||
return self.send({"cmd": "list-call-to-play"})["data"]["events"]
|
||||
|
||||
def publish_call_to_play(self, event: dict[str, Any]) -> None:
|
||||
self.send({"cmd": "publish-call-to-play", "event": event})
|
||||
|
||||
def connect_to(self, other: "Peer") -> None:
|
||||
if other.ready_addr is None:
|
||||
raise ScenarioError(f"{other.name} is not ready")
|
||||
@@ -350,6 +356,7 @@ class Runner:
|
||||
("S45", self.s45_sender_disconnect_mid_stream),
|
||||
("S46", self.s46_receiver_cancel_mid_stream),
|
||||
("S47", self.s47_multi_archive_streams_in_sorted_order),
|
||||
("S48", self.s48_call_to_play_replication_and_late_join),
|
||||
]
|
||||
|
||||
for scenario_id, scenario in scenarios:
|
||||
@@ -1757,6 +1764,66 @@ class Runner:
|
||||
|
||||
return f"multi-archive cnctw streamed in sorted order: {chunk_paths}"
|
||||
|
||||
def s48_call_to_play_replication_and_late_join(self) -> str:
|
||||
alice = self.peer("s48-alice")
|
||||
bob = self.peer("s48-bob")
|
||||
bob.connect_to(alice)
|
||||
|
||||
now = int(time.time() * 1000)
|
||||
create = {
|
||||
"id": "s48-create",
|
||||
"call_id": "s48-call",
|
||||
"actor_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]:
|
||||
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(
|
||||
game: dict[str, Any],
|
||||
*,
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::{
|
||||
};
|
||||
|
||||
use eyre::{Context, OptionExt};
|
||||
use lanspread_peer::{UnpackFuture, Unpacker};
|
||||
use lanspread_peer::{CallToPlayEvent, UnpackFuture, Unpacker};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -26,6 +26,10 @@ pub enum CliCommand {
|
||||
Status,
|
||||
ListPeers,
|
||||
ListGames,
|
||||
ListCallToPlay,
|
||||
PublishCallToPlay {
|
||||
event: CallToPlayEvent,
|
||||
},
|
||||
SetGameDir {
|
||||
path: PathBuf,
|
||||
},
|
||||
@@ -67,6 +71,8 @@ impl CliCommand {
|
||||
Self::Status => "status",
|
||||
Self::ListPeers => "list-peers",
|
||||
Self::ListGames => "list-games",
|
||||
Self::ListCallToPlay => "list-call-to-play",
|
||||
Self::PublishCallToPlay { .. } => "publish-call-to-play",
|
||||
Self::SetGameDir { .. } => "set-game-dir",
|
||||
Self::Download { .. } => "download",
|
||||
Self::StreamInstall { .. } => "stream-install",
|
||||
@@ -102,6 +108,16 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
||||
"status" => CliCommand::Status,
|
||||
"list-peers" => CliCommand::ListPeers,
|
||||
"list-games" => CliCommand::ListGames,
|
||||
"list-call-to-play" => CliCommand::ListCallToPlay,
|
||||
"publish-call-to-play" => CliCommand::PublishCallToPlay {
|
||||
event: serde_json::from_value(
|
||||
object
|
||||
.get("event")
|
||||
.cloned()
|
||||
.ok_or_eyre("publish-call-to-play must include event")?,
|
||||
)
|
||||
.wrap_err("invalid Call to Play event")?,
|
||||
},
|
||||
"set-game-dir" => CliCommand::SetGameDir {
|
||||
path: PathBuf::from(required_str(object, "path")?),
|
||||
},
|
||||
@@ -384,6 +400,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]
|
||||
async fn fixture_unpacker_creates_install_payload() {
|
||||
let temp = TempDir::new("lanspread-peer-cli-fixture");
|
||||
|
||||
@@ -16,6 +16,7 @@ use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
CallToPlayEvent,
|
||||
ExternalUnrarStreamProvider,
|
||||
NoopStreamInstallProvider,
|
||||
OutboundTransfers,
|
||||
@@ -46,7 +47,7 @@ use lanspread_peer_cli::{
|
||||
use serde_json::{Value, json};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader},
|
||||
sync::{Notify, RwLock, mpsc},
|
||||
sync::{Notify, RwLock, mpsc, oneshot},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -101,6 +102,7 @@ struct CliState {
|
||||
game_files: HashMap<String, Vec<GameFileDescription>>,
|
||||
unavailable_games: HashSet<String>,
|
||||
downloads: HashMap<String, DownloadMeasurement>,
|
||||
call_to_play_events: Vec<CallToPlayEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
@@ -243,6 +245,27 @@ async fn handle_command(
|
||||
CliCommand::Status => status(shared).await,
|
||||
CliCommand::ListPeers => list_peers(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 } => {
|
||||
sender.send(PeerCommand::SetGameDir(path.clone()))?;
|
||||
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) }),
|
||||
)
|
||||
}
|
||||
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 {
|
||||
id,
|
||||
file_descriptions,
|
||||
|
||||
@@ -44,6 +44,35 @@ When a peer is discovered:
|
||||
- Any message updates `last_seen`.
|
||||
- Pings run only when idle (or on a longer interval), not every 5 seconds.
|
||||
- Library updates are pushed as deltas, debounced and coalesced.
|
||||
- Call to Play actions are broadcast as immutable, uniquely identified events.
|
||||
|
||||
### Call to Play replication
|
||||
|
||||
Call to Play is transient peer-session state rather than database state. The
|
||||
peer keeps a bounded event history, deduplicated by event ID. 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
StreamInstallProvider,
|
||||
Unpacker,
|
||||
call_to_play::CallToPlayStore,
|
||||
events,
|
||||
library::LocalLibraryState,
|
||||
peer_db::PeerGameDB,
|
||||
@@ -51,6 +52,7 @@ pub struct Ctx {
|
||||
pub shutdown: CancellationToken,
|
||||
pub task_tracker: TaskTracker,
|
||||
pub active_outbound_transfers: OutboundTransfers,
|
||||
pub call_to_play: Arc<RwLock<CallToPlayStore>>,
|
||||
}
|
||||
|
||||
/// Context for peer connection handling.
|
||||
@@ -69,6 +71,7 @@ pub struct PeerCtx {
|
||||
pub shutdown: CancellationToken,
|
||||
pub task_tracker: TaskTracker,
|
||||
pub active_outbound_transfers: OutboundTransfers,
|
||||
pub call_to_play: Arc<RwLock<CallToPlayStore>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PeerCtx {
|
||||
@@ -113,6 +116,7 @@ impl Ctx {
|
||||
shutdown,
|
||||
task_tracker,
|
||||
active_outbound_transfers,
|
||||
call_to_play: Arc::new(RwLock::new(CallToPlayStore::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +139,7 @@ impl Ctx {
|
||||
shutdown: self.shutdown.clone(),
|
||||
task_tracker: self.task_tracker.clone(),
|
||||
active_outbound_transfers: self.active_outbound_transfers.clone(),
|
||||
call_to_play: self.call_to_play.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::state_paths::peer_id_path;
|
||||
|
||||
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
|
||||
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
|
||||
pub const FEATURE_CALL_TO_PLAY: &str = "call-to-play-v1";
|
||||
|
||||
pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result<String> {
|
||||
let path = peer_id_path(state_dir);
|
||||
@@ -28,5 +29,6 @@ pub fn default_features() -> Vec<String> {
|
||||
vec![
|
||||
FEATURE_LIBRARY_DELTA.to_string(),
|
||||
FEATURE_LIBRARY_SNAPSHOT.to_string(),
|
||||
FEATURE_CALL_TO_PLAY.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// Module declarations
|
||||
// =============================================================================
|
||||
|
||||
mod call_to_play;
|
||||
mod config;
|
||||
mod context;
|
||||
mod download;
|
||||
@@ -46,6 +47,7 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
|
||||
pub use error::PeerError;
|
||||
pub use install::{UnpackFuture, Unpacker};
|
||||
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||
pub use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
||||
pub use migration::{MigrationReport, migrate_legacy_state};
|
||||
pub use peer_db::{
|
||||
MajorityValidationResult,
|
||||
@@ -58,6 +60,7 @@ pub use peer_db::{
|
||||
use tokio::sync::{
|
||||
RwLock,
|
||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
oneshot,
|
||||
};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
@@ -159,6 +162,8 @@ pub enum PeerEvent {
|
||||
ActiveOperationsChanged {
|
||||
active_operations: Vec<ActiveOperation>,
|
||||
},
|
||||
/// New or requested Call to Play events in replication order.
|
||||
CallToPlayEvents(Vec<CallToPlayEvent>),
|
||||
/// A required peer runtime component failed.
|
||||
RuntimeFailed {
|
||||
component: PeerRuntimeComponent,
|
||||
@@ -224,7 +229,7 @@ pub enum ActiveOperationKind {
|
||||
}
|
||||
|
||||
/// Commands sent to the peer system from the UI.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug)]
|
||||
pub enum PeerCommand {
|
||||
/// Request a list of all available games.
|
||||
ListGames,
|
||||
@@ -259,6 +264,15 @@ pub enum PeerCommand {
|
||||
GetPeerCount,
|
||||
/// Connect directly to a peer address without waiting for mDNS discovery.
|
||||
ConnectPeer(SocketAddr),
|
||||
/// Publish one local Call to Play action to this peer and the LAN.
|
||||
PublishCallToPlay {
|
||||
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.
|
||||
@@ -489,6 +503,19 @@ async fn handle_peer_commands(
|
||||
PeerCommand::ConnectPeer(addr) => {
|
||||
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 if_addrs::{IfAddr, Interface, get_if_addrs};
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
use lanspread_proto::{Hello, HelloAck, LibraryDelta, Message, Request, Response};
|
||||
use lanspread_proto::{CallToPlayEvent, Hello, HelloAck, LibraryDelta, Message, Request, Response};
|
||||
use s2n_quic::{
|
||||
Client as QuicClient,
|
||||
Connection,
|
||||
@@ -173,6 +173,21 @@ pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Resul
|
||||
send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await
|
||||
}
|
||||
|
||||
pub async fn send_call_to_play_events(
|
||||
peer_addr: SocketAddr,
|
||||
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.
|
||||
pub async fn request_game_details_from_peer(
|
||||
peer_addr: SocketAddr,
|
||||
|
||||
@@ -8,6 +8,7 @@ use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
use crate::{
|
||||
PeerEvent,
|
||||
call_to_play::CallToPlayStore,
|
||||
context::{Ctx, PeerCtx},
|
||||
events,
|
||||
identity::default_features,
|
||||
@@ -24,6 +25,7 @@ pub(crate) struct HandshakeCtx {
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
call_to_play: Arc<RwLock<CallToPlayStore>>,
|
||||
}
|
||||
|
||||
impl HandshakeCtx {
|
||||
@@ -35,6 +37,7 @@ impl HandshakeCtx {
|
||||
peer_game_db: ctx.peer_game_db.clone(),
|
||||
tx_notify_ui: tx_notify_ui.clone(),
|
||||
catalog: ctx.catalog.clone(),
|
||||
call_to_play: ctx.call_to_play.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +49,7 @@ impl HandshakeCtx {
|
||||
peer_game_db: ctx.peer_game_db.clone(),
|
||||
tx_notify_ui: ctx.tx_notify_ui.clone(),
|
||||
catalog: ctx.catalog.clone(),
|
||||
call_to_play: ctx.call_to_play.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,28 +62,36 @@ async fn required_listen_addr(
|
||||
}
|
||||
|
||||
pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result<HelloAck> {
|
||||
let library_guard = ctx.local_library.read().await;
|
||||
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
|
||||
let library = build_library_snapshot(&library_guard);
|
||||
let library = {
|
||||
let library_guard = ctx.local_library.read().await;
|
||||
build_library_snapshot(&library_guard)
|
||||
};
|
||||
let call_to_play_events = ctx.call_to_play.read().await.snapshot();
|
||||
Ok(HelloAck {
|
||||
peer_id: ctx.peer_id.as_ref().clone(),
|
||||
proto_ver: PROTOCOL_VERSION,
|
||||
listen_addr,
|
||||
library,
|
||||
features: default_features(),
|
||||
call_to_play_events,
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result<Hello> {
|
||||
let library_guard = ctx.local_library.read().await;
|
||||
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
|
||||
let library = build_library_snapshot(&library_guard);
|
||||
let library = {
|
||||
let library_guard = ctx.local_library.read().await;
|
||||
build_library_snapshot(&library_guard)
|
||||
};
|
||||
let call_to_play_events = ctx.call_to_play.read().await.snapshot();
|
||||
Ok(Hello {
|
||||
peer_id: ctx.peer_id.as_ref().clone(),
|
||||
proto_ver: PROTOCOL_VERSION,
|
||||
listen_addr,
|
||||
library,
|
||||
features: default_features(),
|
||||
call_to_play_events,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,6 +126,13 @@ pub(crate) async fn perform_handshake_with_peer(
|
||||
let _ = ctx.peer_game_db.write().await.remove_peer(expected);
|
||||
}
|
||||
|
||||
merge_call_to_play_events(
|
||||
&ctx.call_to_play,
|
||||
&ctx.tx_notify_ui,
|
||||
ack.call_to_play_events,
|
||||
)
|
||||
.await;
|
||||
|
||||
let record_addr = ack.listen_addr;
|
||||
let upsert = record_remote_library(
|
||||
&ctx.peer_game_db,
|
||||
@@ -149,6 +168,12 @@ pub(super) async fn accept_inbound_hello(
|
||||
}
|
||||
|
||||
let addr = hello.listen_addr;
|
||||
merge_call_to_play_events(
|
||||
&ctx.call_to_play,
|
||||
&ctx.tx_notify_ui,
|
||||
hello.call_to_play_events,
|
||||
)
|
||||
.await;
|
||||
let handshake_ctx = HandshakeCtx::from_peer_ctx(ctx);
|
||||
let upsert = record_remote_library(
|
||||
&ctx.peer_game_db,
|
||||
@@ -165,6 +190,17 @@ pub(super) async fn accept_inbound_hello(
|
||||
build_hello_ack(ctx).await
|
||||
}
|
||||
|
||||
async fn merge_call_to_play_events(
|
||||
store: &Arc<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(
|
||||
ctx: HandshakeCtx,
|
||||
peer_addr: SocketAddr,
|
||||
@@ -212,7 +248,15 @@ mod tests {
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
|
||||
use lanspread_proto::{
|
||||
Availability,
|
||||
CallToPlayAction,
|
||||
CallToPlayEvent,
|
||||
GameSummary,
|
||||
Hello,
|
||||
LibrarySnapshot,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
@@ -248,6 +292,7 @@ mod tests {
|
||||
peer_game_db,
|
||||
tx_notify_ui,
|
||||
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
|
||||
call_to_play: Arc::new(RwLock::new(crate::call_to_play::CallToPlayStore::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +309,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]
|
||||
async fn outbound_hello_requires_local_listener_addr() {
|
||||
let ctx = test_handshake_ctx(None);
|
||||
@@ -304,6 +365,22 @@ mod tests {
|
||||
assert_eq!(hello.library.games[0].id, "game");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_hello_carries_call_to_play_history() {
|
||||
let ctx = test_handshake_ctx(Some(addr([10, 66, 0, 2], 40000)));
|
||||
ctx.call_to_play
|
||||
.write()
|
||||
.await
|
||||
.insert(call_to_play_event())
|
||||
.expect("valid event should be inserted");
|
||||
|
||||
let hello = build_hello_from_state(&ctx)
|
||||
.await
|
||||
.expect("listener address is present");
|
||||
|
||||
assert_eq!(hello.call_to_play_events, [call_to_play_event()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inbound_hello_applies_remote_library_snapshot() {
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
@@ -335,6 +412,7 @@ mod tests {
|
||||
games: vec![summary("remote-game")],
|
||||
},
|
||||
features: Vec::new(),
|
||||
call_to_play_events: Vec::new(),
|
||||
};
|
||||
|
||||
let ack = accept_inbound_hello(&peer_ctx, None, hello)
|
||||
@@ -406,6 +484,7 @@ mod tests {
|
||||
games: vec![summary("self-game")],
|
||||
},
|
||||
features: Vec::new(),
|
||||
call_to_play_events: Vec::new(),
|
||||
};
|
||||
|
||||
let ack = accept_inbound_hello(&peer_ctx, None, self_hello)
|
||||
@@ -422,4 +501,49 @@ mod tests {
|
||||
"self hello must emit no peer discovery events"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inbound_hello_merges_call_to_play_history_once() {
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
let ctx = Ctx::new(
|
||||
peer_game_db,
|
||||
"local-peer".to_string(),
|
||||
PathBuf::new(),
|
||||
PathBuf::new(),
|
||||
Arc::new(NoopUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(GameCatalog::empty())),
|
||||
Arc::new(RwLock::new(HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
);
|
||||
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
|
||||
let (tx_notify_ui, mut rx_notify_ui) = mpsc::unbounded_channel();
|
||||
let peer_ctx = ctx.to_peer_ctx(tx_notify_ui);
|
||||
let remote_addr = addr([127, 0, 0, 1], 5000);
|
||||
let hello = Hello {
|
||||
peer_id: "remote-peer".to_string(),
|
||||
proto_ver: PROTOCOL_VERSION,
|
||||
listen_addr: remote_addr,
|
||||
library: LibrarySnapshot {
|
||||
library_rev: 0,
|
||||
games: Vec::new(),
|
||||
},
|
||||
features: Vec::new(),
|
||||
call_to_play_events: vec![call_to_play_event(), call_to_play_event()],
|
||||
};
|
||||
|
||||
accept_inbound_hello(&peer_ctx, None, hello)
|
||||
.await
|
||||
.expect("current protocol hello should be accepted");
|
||||
|
||||
assert_eq!(
|
||||
ctx.call_to_play.read().await.snapshot(),
|
||||
[call_to_play_event()]
|
||||
);
|
||||
assert!(matches!(
|
||||
rx_notify_ui.recv().await,
|
||||
Some(PeerEvent::CallToPlayEvents(events)) if events == [call_to_play_event()]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,13 @@ async fn dispatch_request(
|
||||
handle_library_delta(ctx, peer_id, delta).await;
|
||||
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::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
|
||||
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>) {
|
||||
if let Some(addr) = remote_addr {
|
||||
ctx.peer_game_db
|
||||
|
||||
@@ -4,7 +4,7 @@ use bytes::Bytes;
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 5;
|
||||
pub const PROTOCOL_VERSION: u32 = 6;
|
||||
|
||||
pub use lanspread_db::db::Availability;
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct Hello {
|
||||
pub listen_addr: SocketAddr,
|
||||
pub library: LibrarySnapshot,
|
||||
pub features: Vec<String>,
|
||||
pub call_to_play_events: Vec<CallToPlayEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -36,6 +37,41 @@ pub struct HelloAck {
|
||||
pub listen_addr: SocketAddr,
|
||||
pub library: LibrarySnapshot,
|
||||
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)]
|
||||
@@ -75,6 +111,10 @@ pub enum Request {
|
||||
peer_id: String,
|
||||
delta: LibraryDelta,
|
||||
},
|
||||
CallToPlayEvents {
|
||||
peer_id: String,
|
||||
events: Vec<CallToPlayEvent>,
|
||||
},
|
||||
Goodbye {
|
||||
peer_id: String,
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ walkdir = { workspace = true }
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
[target."cfg(windows)".dependencies]
|
||||
windows = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
|
||||
@@ -18,6 +18,7 @@ use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescript
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
CallToPlayEvent,
|
||||
ExternalUnrarStreamProvider,
|
||||
NoopStreamInstallProvider,
|
||||
PeerCommand,
|
||||
@@ -39,6 +40,7 @@ use tauri_plugin_shell::{
|
||||
use tokio::sync::{
|
||||
RwLock,
|
||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
oneshot,
|
||||
};
|
||||
use tracing::{Event, Level, Metadata, Subscriber, field::Visit};
|
||||
use tracing_subscriber::{
|
||||
@@ -90,6 +92,7 @@ impl OutboundTransferEmitState {
|
||||
struct LanSpreadState {
|
||||
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
|
||||
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
|
||||
local_peer_id: Arc<RwLock<Option<String>>>,
|
||||
games: Arc<RwLock<GameDB>>,
|
||||
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
||||
games_folder: Arc<RwLock<String>>,
|
||||
@@ -161,6 +164,7 @@ struct LauncherGame {
|
||||
game: Game,
|
||||
can_host_server: bool,
|
||||
active_outbound_transfers: usize,
|
||||
installed_peer_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
@@ -272,6 +276,43 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn request_call_to_play_events(
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<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]
|
||||
async fn install_game(
|
||||
id: String,
|
||||
@@ -1031,6 +1072,19 @@ fn clear_all_local_game_states(game_db: &mut GameDB) {
|
||||
async fn emit_games_list(app_handle: &AppHandle) {
|
||||
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 game_db = games_db_lock.read().await;
|
||||
let games_folder = state.games_folder.read().await.clone();
|
||||
@@ -1051,6 +1105,7 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
LauncherGame {
|
||||
can_host_server: game_can_host_server(&games_folder, &game),
|
||||
active_outbound_transfers,
|
||||
installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0),
|
||||
game,
|
||||
}
|
||||
})
|
||||
@@ -2160,6 +2215,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
match event {
|
||||
PeerEvent::LocalPeerReady { peer_id, 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) => {
|
||||
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;
|
||||
}
|
||||
PeerEvent::CallToPlayEvents(events) => {
|
||||
if let Err(err) = app_handle.emit("call-to-play-events", Some(events)) {
|
||||
log::error!("Failed to emit call-to-play-events event: {err}");
|
||||
}
|
||||
}
|
||||
PeerEvent::OutboundTransferCountChanged => {
|
||||
log::info!("PeerEvent::OutboundTransferCountChanged received");
|
||||
schedule_outbound_transfer_emit(app_handle).await;
|
||||
@@ -2348,6 +2413,8 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
request_games,
|
||||
request_call_to_play_events,
|
||||
publish_call_to_play,
|
||||
install_game,
|
||||
stream_install_game,
|
||||
run_game,
|
||||
|
||||
@@ -105,4 +105,35 @@ export const Icon = {
|
||||
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
|
||||
</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>;
|
||||
|
||||
@@ -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 { SortMenu } from './SortMenu';
|
||||
import { KebabMenu, KebabItem } from './KebabMenu';
|
||||
import { CallToPlayButton } from '../calltoplay/CallToPlayButton';
|
||||
|
||||
import { FilterCounts } from '../../lib/gameState';
|
||||
import { GameFilter, GameSort } from '../../lib/types';
|
||||
import { GameFilter, GameSort, Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
accent: string;
|
||||
@@ -18,6 +19,8 @@ interface Props {
|
||||
sort: GameSort;
|
||||
setSort: (value: GameSort) => void;
|
||||
kebabItems: ReadonlyArray<KebabItem>;
|
||||
nominations: ReadonlyArray<Nomination>;
|
||||
onOpenCallToPlay: () => void;
|
||||
}
|
||||
|
||||
export const TopBar = ({
|
||||
@@ -31,6 +34,8 @@ export const TopBar = ({
|
||||
sort,
|
||||
setSort,
|
||||
kebabItems,
|
||||
nominations,
|
||||
onOpenCallToPlay,
|
||||
}: Props) => (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
@@ -47,6 +52,7 @@ export const TopBar = ({
|
||||
<SortMenu value={sort} onChange={setSort} />
|
||||
</div>
|
||||
<div className="topbar-right-tail">
|
||||
<CallToPlayButton nominations={nominations} onClick={onOpenCallToPlay} />
|
||||
<KebabMenu items={kebabItems} />
|
||||
</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;
|
||||
can_host_server?: boolean;
|
||||
active_outbound_transfers?: number;
|
||||
installed_peer_count?: number;
|
||||
}
|
||||
|
||||
export interface ActiveOperation {
|
||||
@@ -83,3 +84,54 @@ export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'bus
|
||||
|
||||
/** Two-character language code passed through to game scripts. */
|
||||
export type LauncherLanguage = 'en' | 'de';
|
||||
|
||||
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
|
||||
|
||||
export interface CallToPlayParticipant {
|
||||
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);
|
||||
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 { NoDirectoryState } from '../components/empty/NoDirectoryState';
|
||||
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
|
||||
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
|
||||
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
|
||||
|
||||
import { useGameDirectory } from '../hooks/useGameDirectory';
|
||||
import { useGames } from '../hooks/useGames';
|
||||
import { useGameActions } from '../hooks/useGameActions';
|
||||
import { useThumbnails } from '../hooks/useThumbnails';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useCallToPlay } from '../hooks/useCallToPlay';
|
||||
|
||||
import { Game } from '../lib/types';
|
||||
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
|
||||
@@ -72,10 +75,13 @@ export const MainWindow = () => {
|
||||
const games = useGames(rescan);
|
||||
const actions = useGameActions(games, settings);
|
||||
const thumbnails = useThumbnails();
|
||||
const callToPlay = useCallToPlay(settings.username);
|
||||
|
||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
|
||||
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
|
||||
const visibleGames = useMemo(
|
||||
() => hasGameDirectory ? games.games : [],
|
||||
[games.games, hasGameDirectory],
|
||||
@@ -154,8 +160,22 @@ export const MainWindow = () => {
|
||||
sort={settings.sort}
|
||||
setSort={(v) => setSetting('sort', v)}
|
||||
kebabItems={kebabItems}
|
||||
nominations={callToPlay.nominations}
|
||||
onOpenCallToPlay={() => {
|
||||
setFocusedCallId(null);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
<main className="grid-wrap">
|
||||
<CallToPlayTicker
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
accent={settings.accent}
|
||||
onOpen={(callId) => {
|
||||
setFocusedCallId(callId);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
{hasGameDirectory ? (
|
||||
<>
|
||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||
@@ -220,6 +240,25 @@ export const MainWindow = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
id: 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;
|
||||
createdAt: number; // ms epoch
|
||||
scheduledFor: number | null; // ms epoch clock time; null = play-now
|
||||
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';
|
||||
joinedAt: number;
|
||||
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';
|
||||
startedAt?: number;
|
||||
};
|
||||
@@ -548,7 +550,7 @@ The `useNominations({ username, seed })` hook owns the list and exposes
|
||||
`createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`,
|
||||
`startNow`, `addTime`.
|
||||
|
||||
### Mock vs production
|
||||
### Design reference vs production
|
||||
|
||||
The mock **simulates other people** with a 1-second `setInterval`
|
||||
(`tickNomination`): bots RSVP to scheduled calls, ready-up during check-in, walk
|
||||
@@ -558,14 +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,
|
||||
and one scheduled for later collecting RSVPs).
|
||||
|
||||
**In production, replace the simulation with a real-time LAN transport:** the
|
||||
launcher instances broadcast nominations, responses, RSVPs, and chat messages to
|
||||
each other (whatever the app uses — a small pub/sub over the LAN, a lightweight
|
||||
signaling server, or Tauri-side networking). Swap the guts of `useNominations`
|
||||
(the `setInterval` + local `setNoms`) for your networking layer that pushes the
|
||||
same state shape; the rendering components need nothing else. The `username` that
|
||||
identifies "you" comes from `settings.username` (the Profile setting), not a
|
||||
prop default.
|
||||
The production launcher uses the peer's existing QUIC control channel. Each
|
||||
create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
|
||||
is an immutable, uniquely identified event. Connected peers receive new events
|
||||
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
|
||||
history so a late joiner reconstructs the same calls. The frontend reducer turns
|
||||
that event history into the `Nomination` state above and derives time-based phase
|
||||
changes locally. The `username` that identifies "you" comes from
|
||||
`settings.username` (the Profile setting), not a prop default.
|
||||
|
||||
---
|
||||
|
||||
@@ -773,5 +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.
|
||||
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
|
||||
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
|
||||
- **Call to Play real-time transport** — the feature is fully designed and interactive, but the mock fakes other players with a local `setInterval` simulation. Production needs a real LAN transport that broadcasts nominations / responses / RSVPs / chat between launcher instances and pushes the same state shape into `useNominations`. Notifications when a call you're in enters its check-in window (OS notification / tray) are also a follow-up.
|
||||
- **Call to Play notifications** — real-time LAN transport is implemented. OS / tray notifications when a call you're in enters its check-in window remain a follow-up.
|
||||
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
|
||||
|
||||
Reference in New Issue
Block a user