Compare commits

...
7 Commits
Author SHA1 Message Date
ddidderr 2c204ac258 fix(call-to-play): explain peer startup state
Treat an unavailable actor ID or an explicit not-ready result as normal peer
startup and tell the user that LAN connection is still in progress. Clear that
message when snapshot registration succeeds.

Map obsolete, missing-history, and active-history-limit store failures to
distinct guidance without marking a healthy transport unavailable. Preserve a
generic message for unexpected publish failures.

Test Plan:
- just frontend-test
- just build
- git diff --cached --check
2026-07-23 18:07:08 +02:00
ddidderr 8d3affe19c fix(call-to-play): extend from the current deadline
Pass the effective nomination deadline into the Add time action and extend
from whichever is later: that deadline or the current time. This preserves
remaining time when a call becomes ready early while still giving an overdue
call a fresh five-minute window.

Test Plan:
- just fmt
- just frontend-test
- just build
- git diff --cached --check
2026-07-23 18:05:21 +02:00
ddidderr 9c34efa705 feat(call-to-play): retain terminal outcomes
Keep complete running and cancelled histories visible for fifteen minutes so
peers retain the roster, chat, and outcome long enough to understand what
happened. Compact them to terminal tombstones afterward without charging
settled calls against the active-history limit.

Model running and cancelled as durable read-only frontend states, exclude
them from active badges, prune retired raw events, and document the lifecycle.
Add peer scenario S49 to prove a late joiner reconstructs a terminal call with
its roster and chat intact.

Test Plan:
- just fmt
- just clippy
- just test
- just frontend-test
- just build
- just peer-cli-tests S48 S49
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- git diff --cached --check
2026-07-23 18:03:57 +02:00
ddidderr e5d70ae56f fix(call-to-play): acknowledge live replication
Raise the wire protocol to version 7 and add explicit Call to Play delivery
outcomes. Live requests now wait for an application acknowledgement, allowing
the sender to distinguish applied, duplicate, obsolete, incomplete, and
rejected updates instead of treating a successful write as acceptance.

Remove source-IP equality from actor verification. The receiver now requires
the envelope peer ID to be present in its known roster and requires every live
event actor to match that envelope. This matches the cooperative-LAN trust
model without misrepresenting the shared TLS identity as per-peer
authentication.

Transport failures, malformed responses, NeedHandshake, and NeedHistory each
trigger one asynchronous Hello/HelloAck resync. Rejections are logged without
retry, and local publication remains independent of remote availability.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `git diff --cached --check` -- passed
2026-07-23 17:49:01 +02:00
ddidderr be7ad2e560 refactor(call-to-play): merge histories atomically
Replace per-event insertion with a transactional batch merge. The store now
validates and deduplicates an entire history before committing it, rejects
conflicting event IDs without partial mutation, evaluates retention after all
batch events are present, and reports applied, duplicate, obsolete, and
missing-root outcomes explicitly.

Derive event identity from retained history instead of preserving an unbounded
ID set. Expired histories can therefore be restored by a complete Create plus
AddTime batch, while orphan actions request history and terminal tombstones
continue to reject stale resurrection. Capacity applies only to unresolved
history, allowing Start and Cancel to settle a full call.

Only retained events reach the UI or live broadcast path. Handshake and live
merge callers log invalid or incomplete histories without publishing events
that compaction discarded.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `git diff --cached --check` -- passed
2026-07-23 17:44:03 +02:00
ddidderr 872692e3f4 plan 2026-07-23 17:07:32 +02:00
ddidderr e141229805 fable5findings 2026-07-23 13:06:21 +02:00
20 changed files with 1655 additions and 420 deletions
+152
View File
@@ -0,0 +1,152 @@
# Finish Call to Plays replication seam
## Summary
Keep the existing architecture: immutable events, deterministic reduction, live delivery, and handshake history are appropriate for a LAN party. Do not replace it with owner-authoritative state, consensus, persistent storage, or cryptographic peer identities.
The focused redesign is the delivery/merge/store seam:
- Merge histories atomically.
- Acknowledge live deliveries.
- Preserve complete histories while users can still see a call.
- Make terminal outcomes understandable.
- Never report an action as accepted when it was immediately discarded.
## User-visible lifecycle
| State | Meaning | Visibility | Available actions |
|---|---|---:|---|
| Open / Ready | Call is still coordinating players | Until deadline | Existing participation, chat, creator controls |
| Times up | Deadline elapsed without a Start or Cancel | 5 minutes | Creator may Start, Add time, or Cancel; history remains complete |
| Running | Creator emitted Start; this is a successful outcome | 15 minutes after Start | Read-only card, roster, and complete chat |
| Cancelled | Creator emitted Cancel | 15 minutes after Cancel | Read-only card, roster, and complete chat |
| Retired | Display period ended | Hidden | None |
“Times up” and “Running” are meaningfully different: a timed-out call is unresolved and recoverable, while Running is a final success receipt. Deadline passage alone never means the game started.
Running and Cancelled rows remain in the ticker and overlay, sorted after actionable calls. They do not increase the top-bar badge. Their chat history remains readable, but all composers and controls are disabled.
## Interface and invariant changes
- Raise `PROTOCOL_VERSION` from 6 to 7; no compatibility path.
- Add `Response::CallToPlayAck` with outcomes equivalent to:
- `Applied`
- `Duplicate`
- `NeedHandshake`
- `NeedHistory`
- `Obsolete`
- `Rejected(reason)`
- Replace per-event insertion with one atomic `merge_batch(events, now)` operation returning retained UI events, duplicate/obsolete counts, and missing-history information.
- Change frontend nomination state to represent `running` and `cancelled`, with a `terminalAt` timestamp.
- Change `addTime` to receive the current effective deadline and calculate `max(now, deadline) + duration`.
- Preserve these invariants:
- Every visible call has its entire event and chat history.
- Handshake batches are evaluated as a whole, regardless of event order.
- Missing-root actions are not retained alone; they request history.
- Event IDs correspond only to retained events.
- Terminal tombstones prevent stale histories from resurrecting finished calls.
- Local acceptance is immediate user success; remote delivery is acknowledged and healed asynchronously.
## Commit sequence
1. `refactor(call-to-play): merge histories atomically`
- Validate and deduplicate the complete incoming batch before changing the store.
- Treat an existing ID with different contents as a conflict and reject the batch.
- Evaluate compaction once after all batch events are present, fixing the quadratic handshake path.
- Commit the candidate store only when capacity and validation succeed.
- Rebuild event IDs from retained events instead of preserving every historical ID.
- Return `NeedHistory` without storing an action when neither the store nor batch contains its Create event.
- Permit a full `Create + AddTime` history to revive a call atomically.
- Mark an event as applied only when it survives compaction; obsolete events are neither broadcast nor emitted to the UI.
- Keep the 4,096-event safety cap for unresolved histories, but always permit Start and Cancel. Recently terminal histories and compact tombstones must not prevent new active calls.
- Preserve full open/ready/Times-up histories; never evict individual chat or participant events.
2. `fix(call-to-play): acknowledge live replication`
- Turn the live Call to Play request into a request/response exchange returning `CallToPlayAck`.
- Remove source-IP-versus-advertised-IP equality checks.
- Under the selected trusted-LAN model, require:
- the envelope peer ID to exist in the known peer roster;
- every live events actor ID to match that envelope peer ID;
- local peer-core publication to continue stamping its own actor ID.
- Return `NeedHandshake` for unknown peers and `NeedHistory` for missing call roots.
- On transport failure, malformed response, `NeedHandshake`, or `NeedHistory`, perform one full Hello/HelloAck resync.
- Treat Applied and Duplicate as delivered, Obsolete as finished, and Rejected as a logged non-retriable error.
- Keep publication locally successful without waiting for every peer, so an offline machine cannot block a LAN-party action.
- Document that shared TLS plus stable peer IDs prevent accidental identity mixing but are not hostile-peer authentication.
3. `feat(call-to-play): retain terminal outcomes`
- Preserve complete Running and Cancelled histories in backend snapshots for 15 minutes so late joiners receive the card, roster, and chat.
- After 15 minutes, compact each terminal call to its Start or Cancel tombstone for the remainder of the peer session.
- Continue deleting unresolved Times-up histories after their separate five-minute recovery window, including their event IDs.
- Derive and render Running and Cancelled frontend states instead of immediately removing them.
- Show their ticker/card status, retain unknown-game degraded rendering, sort them last, and exclude them from the badge.
- Prune the frontends raw event map after the corresponding display window so a long GUI session does not accumulate invisible history.
- Update the feature specification and architecture documentation with these lifecycle and clock-skew assumptions.
4. `fix(call-to-play): extend from the current deadline`
- Calculate extensions as `max(Date.now(), nomination.deadline) + five minutes`.
- Preserve the existing behavior that an overdue call gets five minutes from now.
- Ensure extending a call that became Ready early adds time instead of shortening its remaining deadline.
- Keep terminal Running and Cancelled calls non-extendable.
5. `fix(call-to-play): explain peer startup state`
- Replace the game-folder advice shown while `actorId` is unavailable with: “Call to Play is still connecting to the LAN. Try again in a moment.”
- Do not mark transport unavailable for store-level errors.
- Surface distinct messages for:
- expired/obsolete call;
- full active history;
- peer startup;
- unexpected update failure.
## Test and acceptance plan
- Store unit tests:
- `Create + AddTime` succeeds in every input order.
- An expired receiver returns NeedHistory for orphan AddTime, then revives after receiving full history.
- IDs removed with expired histories do not block revival.
- Stale events against terminal tombstones remain obsolete.
- Accepted results contain only retained events.
- Invalid/conflicting batches leave the store unchanged.
- Batch merge compacts once rather than once per event.
- Start and Cancel remain possible at the active-history cap.
- Visible calls always retain every chat event.
- Transport tests:
- A known peer is accepted when transport and advertised IPs differ.
- Unknown peer IDs and mismatched actor IDs receive explicit acknowledgements.
- NeedHistory, NeedHandshake, and lost acknowledgements trigger idempotent handshake healing.
- Duplicate delivery is harmless.
- Frontend tests:
- Ready, Times up, Running, Cancelled, and retired transitions.
- Full terminal history remains for 15 minutes and then disappears.
- Terminal calls appear in ticker/overlay but not the badge.
- Terminal controls and chat composer are disabled.
- Both Add-time deadline cases.
- Correct startup and store-error messages.
- Peer CLI:
- Keep S48 as the active-call/full-history late-join acceptance test.
- Add S49 covering a terminal call whose roster and chat are reconstructed by a late joiner.
- Fix any snapshot waiting through the direct reply path rather than observing unrelated generations.
- Final verification:
- `just fmt`
- `just clippy`
- `just test`
- `just frontend-test`
- `just build`
- `just peer-cli-tests S48 S49`
- `git diff --check`
## Assumptions
- The LAN is cooperative; deliberate peer-ID impersonation is outside scope.
- Wall clocks are assumed reasonably close. Atomic history revival tolerates boundary skew but does not attempt clock synchronization.
- Call to Play state remains transient and disappears when the peer process/session ends.
- The existing `FABLE_5_FINDINGS.md` commit remains untouched; all implementation work is added as focused forward commits.
+107
View File
@@ -0,0 +1,107 @@
# Fable 5 findings
Review of the issues Fable found after the Call to Play follow-up fixes. This
document records the assessment only; it is not an implementation plan.
## 1. IP-based actor verification can reject legitimate peers
**Assessment: real and must-fix.**
`handle_call_to_play_events` requires the QUIC connection's source IP to match
the peer's advertised listener IP. That assumption is unreliable for a
multi-homed LAN machine: routing may select Ethernet, Wi-Fi, a VPN, or a bridge
address different from the advertised address. The receiver silently drops the
event, while the one-way sender sees a successful write and does not invoke its
resync fallback.
The check is not strong authentication either. Two peers on one host share an
IP, and the project currently uses a shared application TLS certificate rather
than a cryptographic identity unique to each `peer_id`. A normal client still
stamps its own peer ID, so same-host impersonation requires a modified client,
but the IP comparison should not be presented as an authenticated identity
boundary.
A sound correction must either bind a peer ID to a connection through a
same-connection protocol exchange, return an application-level
acknowledgement/rejection that can trigger resync, or deliberately rely on the
documented trusted-LAN model without the unreliable IP equality test.
## 2. Evicted event IDs can prevent a revived call from healing
**Assessment: real and must-fix together with finding 5.**
Expiry removes call events from `events` but leaves their IDs in `event_ids`.
If one peer expires a call and later receives an orphan `AddTime`, a subsequent
handshake cannot restore the original `Create`: it is rejected forever as a
duplicate. The call can therefore be alive on its creator while remaining
invisible on the other peer. The ID set also grows without a bound for the
session.
Pruning expired IDs alone is not sufficient with the current `insert_all`
behavior. A handshake commonly supplies `Create` followed by later actions;
per-event compaction can expire and remove `Create` before the merge reaches
the extending `AddTime`. Healing requires atomic batch semantics: validate and
deduplicate the batch, merge it with retained events, then compact once using
the complete history.
Retained IDs should correspond to retained events and deliberate terminal
tombstones, not every event ever observed.
## 3. `insert` can accept an event that compaction immediately removes
**Assessment: correct, lower-severity correctness/semantics issue.**
An action targeting a call that expired more than five minutes ago can be
inserted, removed by compaction in the same operation, and still return
`Ok(true)`. It is then emitted to the local UI and broadcast to peers even
though it has no durable effect.
The presentation reducer normally hides the result, but the accepted signal is
misleading and the work is wasted. Store results should distinguish retained,
duplicate, rejected, and immediately obsolete events, and only retained events
should be emitted or broadcast.
## 4. "Add 5 more minutes" can shorten the deadline
**Assessment: real user-visible bug.**
The action currently sets the deadline to `now + 5 minutes`. If a roster fills
early while the call still has more than five minutes remaining, the button
shortens the call despite saying that it adds time.
The intended calculation is `max(now, current deadline) + 5 minutes`. That also
behaves naturally for an already expired call.
## 5. Handshake history merge is quadratic
**Assessment: correct, and part of the correctness fix for finding 2 rather
than merely a performance nit.**
`insert_all` calls `insert` for every incoming event, and each insertion rebuilds
several maps over the growing store while holding its write lock. Merging a
large handshake history is therefore O(n²).
Compacting once after an atomic batch merge removes that cost and is also what
allows `Create` plus a later `AddTime` to revive consistently. Findings 2 and 5
should be treated as one store-semantics problem.
## 6. The startup error points users at the wrong cause
**Assessment: correct UX issue.**
When `actorId` is still unavailable, the peer may simply be starting. Telling
the user to choose a game folder is misleading unless the application actually
knows that the folder is missing. The normal startup state should say that Call
to Play is connecting and ask the user to try again shortly; folder guidance
should be reserved for a known missing-folder condition.
## Priority
1. Replace the unreliable IP identity check or make delivery acknowledged.
2. Correct batch merge, compaction, and event-ID retention as one change.
3. Correct the deadline-extension calculation.
4. Stop reporting and broadcasting immediately obsolete events.
5. Correct the startup message.
The existing QUIC connection must not be described as carrying an authenticated
per-peer identity until the protocol actually establishes one.
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Run the peer-cli scenarios S1-S48 through Docker."""
"""Run the peer-cli scenarios S1-S49 through Docker."""
from __future__ import annotations
@@ -357,6 +357,7 @@ class Runner:
("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),
("S49", self.s49_terminal_call_to_play_late_join),
]
for scenario_id, scenario in scenarios:
@@ -1824,6 +1825,79 @@ class Runner:
return "live create/RSVP/chat replicated and a late joiner received deduplicated history"
def s49_terminal_call_to_play_late_join(self) -> str:
alice = self.peer("s49-alice")
bob = self.peer("s49-bob")
bob.connect_to(alice)
now = int(time.time() * 1000)
create = {
"id": "s49-create",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Alice",
"at": now,
"action": {
"Create": {
"game_id": "cnctw",
"max_players": 4,
"scheduled_for": None,
"deadline": now + 600_000,
}
},
}
ready = {
"id": "s49-ready",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Bob",
"at": now + 1,
"action": {"Respond": {"ready_at": None}},
}
message = {
"id": "s49-message-event",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Bob",
"at": now + 2,
"action": {
"SendMessage": {
"message_id": "s49-message",
"text": "Ready to launch",
}
},
}
start = {
"id": "s49-start",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Alice",
"at": now + 3,
"action": "Start",
}
alice.publish_call_to_play(create)
wait_call_to_play_events(bob, {"s49-create"})
bob.publish_call_to_play(ready)
bob.publish_call_to_play(message)
wait_call_to_play_events(alice, {"s49-create", "s49-ready", "s49-message-event"})
alice.publish_call_to_play(start)
wait_call_to_play_events(
bob,
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
)
charlie = self.peer("s49-charlie")
charlie.connect_to(alice)
events = wait_call_to_play_events(
charlie,
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
)
if len(events) != 4:
raise ScenarioError(f"terminal late-join history is incomplete: {events}")
return "late joiner reconstructed terminal call outcome, roster, and chat"
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
+23 -16
View File
@@ -52,28 +52,35 @@ 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 call whose deadline
elapses remains available for five minutes so the creator can start or extend
it, then its history is evicted as a unit. A
or `Cancel` makes the call terminal and read-only, but its complete roster and
chat history remain in snapshots for 15 minutes so late joiners can see the
outcome. After that display window the history compacts to the Start or Cancel
tombstone for the rest of the peer session. Active and recently terminal 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;
terminal histories and tombstones do not consume that active-history capacity.
A call whose deadline elapses remains available for five minutes so the creator
can start or extend it, then its unresolved history is evicted as a unit. 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.
If a live Call to Play delivery fails, the sender immediately falls back to a
normal `Hello` / `HelloAck` exchange with that peer. The handshake carries the
full active history in both directions, so a transient request failure heals
without waiting for mDNS rediscovery or a later reconnect.
Live Call to Play delivery is acknowledged by the receiver. Applied, duplicate,
and obsolete events need no follow-up. An unknown envelope peer, missing call
root, transport failure, or malformed acknowledgement makes the sender perform
one normal `Hello` / `HelloAck` exchange with that peer. The handshake carries
the full retained history in both directions, so a transient request failure
heals without waiting for mDNS rediscovery or a later reconnect. A rejected
event is logged without retry. Local publication remains successful while this
healing happens asynchronously, so an offline peer cannot block an action.
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.
origin peer overwrites the actor ID on local actions. A live-event envelope must
name a peer already in the receiver's roster, and every enclosed actor ID must
match that envelope. This prevents accidental identity mixing and protects
creator controls from other normal clients. It is not authentication against a
hostile LAN peer: all peers use the shared application TLS identity, and stable
peer IDs are self-asserted under the project's trusted-LAN 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,
+669 -200
View File
@@ -1,11 +1,12 @@
//! Replicated event history for Call to Play coordination.
use std::{
collections::{HashMap, HashSet},
collections::{BTreeSet, HashMap, HashSet},
fmt,
time::{SystemTime, UNIX_EPOCH},
};
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
use tokio::sync::mpsc::UnboundedSender;
use crate::{
@@ -22,11 +23,44 @@ const MAX_GAME_ID_CHARS: usize = 256;
const MAX_USERNAME_CHARS: usize = 24;
const MAX_MESSAGE_CHARS: usize = 500;
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
const TERMINAL_RETENTION_MS: i64 = 15 * 60_000;
#[derive(Debug, Default)]
pub(crate) struct CallToPlayStore {
events: Vec<CallToPlayEvent>,
event_ids: HashSet<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct BatchMerge {
pub(crate) applied: Vec<CallToPlayEvent>,
pub(crate) duplicates: usize,
pub(crate) obsolete: usize,
pub(crate) missing_call_ids: Vec<String>,
}
impl BatchMerge {
pub(crate) fn needs_history(&self) -> bool {
!self.missing_call_ids.is_empty()
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum MergeError {
Invalid(&'static str),
ConflictingEvent(String),
HistoryFull,
}
impl fmt::Display for MergeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Invalid(reason) => formatter.write_str(reason),
Self::ConflictingEvent(event_id) => {
write!(formatter, "conflicting Call to Play event ID {event_id}")
}
Self::HistoryFull => formatter.write_str("Call to Play event history is full"),
}
}
}
impl CallToPlayStore {
@@ -35,121 +69,320 @@ impl CallToPlayStore {
}
fn snapshot_at(&mut self, now: i64) -> Vec<CallToPlayEvent> {
self.compact_inactive_calls(now);
compact_history(&mut self.events, now);
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);
pub(crate) fn merge_batch(
&mut self,
incoming: Vec<CallToPlayEvent>,
) -> Result<BatchMerge, MergeError> {
self.merge_batch_at(incoming, now_ms())
}
let event_id = event.id.clone();
self.event_ids.insert(event_id.clone());
self.events.push(event);
self.compact_inactive_calls(now_ms());
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)
fn merge_batch_at(
&mut self,
incoming: Vec<CallToPlayEvent>,
now: i64,
) -> Result<BatchMerge, MergeError> {
for event in &incoming {
validate_event(event).map_err(MergeError::Invalid)?;
}
pub(crate) fn insert_all(&mut self, events: Vec<CallToPlayEvent>) -> Vec<CallToPlayEvent> {
let mut accepted = Vec::new();
let (incoming, mut duplicates) = deduplicate_batch(incoming)?;
// Expiry is evaluated before accepting new actions. An action cannot keep
// an already-retired root alive by itself; the sender must provide the
// complete history in the same batch.
let mut retained = self.events.clone();
compact_history(&mut retained, now);
let retained_by_id = retained
.iter()
.map(|event| (event.id.as_str(), event))
.collect::<HashMap<_, _>>();
let mut new_events = Vec::with_capacity(incoming.len());
for event in incoming {
if let Some(existing) = retained_by_id.get(event.id.as_str()) {
if *existing != &event {
return Err(MergeError::ConflictingEvent(event.id));
}
duplicates += 1;
} else {
new_events.push(event);
}
}
drop(retained_by_id);
let rooted_calls = retained
.iter()
.chain(&new_events)
.filter(|event| matches!(event.action, CallToPlayAction::Create { .. }))
.map(|event| event.call_id.clone())
.collect::<HashSet<_>>();
let retained_tombstones = terminal_tombstone_call_ids(&retained);
let retained_history = HistoryIndex::build(&retained);
let mut applicable = Vec::with_capacity(new_events.len());
let mut missing_call_ids = BTreeSet::new();
let mut obsolete = 0;
for event in new_events {
if retained_tombstones.contains(event.call_id.as_str()) {
obsolete += 1;
continue;
}
if let Some(terminal) = retained_history.terminal_events.get(&event.call_id)
&& (matches!(event.action, CallToPlayAction::Create { .. })
|| (event.at, event.id.as_str()) > terminal.order_key())
{
obsolete += 1;
continue;
}
if !matches!(event.action, CallToPlayAction::Create { .. })
&& !rooted_calls.contains(event.call_id.as_str())
{
missing_call_ids.insert(event.call_id);
continue;
}
applicable.push(event);
}
let applicable_ids = applicable
.iter()
.map(|event| event.id.clone())
.collect::<HashSet<_>>();
retained.extend(applicable);
compact_history(&mut retained, now);
if unresolved_event_count(&retained) > MAX_EVENTS {
return Err(MergeError::HistoryFull);
}
let retained_ids = retained
.iter()
.map(|event| event.id.clone())
.collect::<HashSet<_>>();
let applied = retained
.iter()
.filter(|event| applicable_ids.contains(&event.id))
.cloned()
.collect::<Vec<_>>();
obsolete += applicable_ids
.iter()
.filter(|event_id| !retained_ids.contains(*event_id))
.count();
self.events = retained;
Ok(BatchMerge {
applied,
duplicates,
obsolete,
missing_call_ids: missing_call_ids.into_iter().collect(),
})
}
}
fn deduplicate_batch(
incoming: Vec<CallToPlayEvent>,
) -> Result<(Vec<CallToPlayEvent>, usize), MergeError> {
let mut unique = Vec::<CallToPlayEvent>::with_capacity(incoming.len());
let mut indexes = HashMap::<String, usize>::with_capacity(incoming.len());
let mut duplicates = 0;
for event in incoming {
if let Some(index) = indexes.get(&event.id).copied() {
if unique[index] != event {
return Err(MergeError::ConflictingEvent(event.id));
}
duplicates += 1;
} else {
indexes.insert(event.id.clone(), unique.len());
unique.push(event);
}
}
Ok((unique, duplicates))
}
#[derive(Debug)]
struct HistoryIndex {
creators: HashMap<String, CreateRecord>,
terminal_events: HashMap<String, EventRecord>,
extensions: HashMap<String, ExtensionRecord>,
}
#[derive(Clone, Debug)]
struct CreateRecord {
at: i64,
event_id: String,
actor_id: String,
deadline: i64,
}
impl CreateRecord {
fn order_key(&self) -> (i64, &str) {
(self.at, &self.event_id)
}
}
#[derive(Clone, Debug)]
struct EventRecord {
at: i64,
event_id: String,
}
impl EventRecord {
fn order_key(&self) -> (i64, &str) {
(self.at, &self.event_id)
}
}
#[derive(Clone, Debug)]
struct ExtensionRecord {
event: EventRecord,
deadline: i64,
}
impl HistoryIndex {
fn build(events: &[CallToPlayEvent]) -> Self {
let mut creators = HashMap::<String, CreateRecord>::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 compact_inactive_calls(&mut self, now: i64) {
let mut creators = HashMap::<String, (i64, String, String, i64)>::new();
for event in &self.events {
let CallToPlayAction::Create { deadline, .. } = event.action else {
continue;
};
let candidate = (event.at, event.id.clone(), event.actor_id.clone(), deadline);
let candidate = CreateRecord {
at: event.at,
event_id: event.id.clone(),
actor_id: event.actor_id.clone(),
deadline,
};
creators
.entry(event.call_id.clone())
.and_modify(|current| {
if (candidate.0, &candidate.1) < (current.0, &current.1) {
if candidate.order_key() < current.order_key() {
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 {
let mut terminal_events = HashMap::<String, EventRecord>::new();
let mut extensions = HashMap::<String, ExtensionRecord>::new();
for event in events {
let Some(creator) = 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)
if event.actor_id != creator.actor_id
|| (event.at, event.id.as_str()) <= creator.order_key()
{
continue;
}
let candidate = (event.at, event.id.clone());
if matches!(
event.action,
CallToPlayAction::Cancel | CallToPlayAction::Start
) {
let candidate = EventRecord {
at: event.at,
event_id: event.id.clone(),
};
terminal_events
.entry(event.call_id.clone())
.and_modify(|current| {
if (candidate.0, &candidate.1) < (current.0, &current.1) {
if candidate.order_key() < current.order_key() {
current.clone_from(&candidate);
}
})
.or_insert(candidate);
}
let mut extensions = HashMap::<String, (i64, String, i64)>::new();
for event in &self.events {
let CallToPlayAction::AddTime { deadline } = event.action else {
continue;
} else if let CallToPlayAction::AddTime { deadline } = event.action {
let candidate = ExtensionRecord {
event: EventRecord {
at: event.at,
event_id: event.id.clone(),
},
deadline,
};
let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else {
continue;
};
if event.actor_id != *creator_id || (event.at, &event.id) <= (*created_at, create_id) {
continue;
}
let candidate = (event.at, event.id.clone(), deadline);
extensions
.entry(event.call_id.clone())
.and_modify(|current| {
if (candidate.0, &candidate.1) > (current.0, &current.1) {
if candidate.event.order_key() > current.event.order_key() {
current.clone_from(&candidate);
}
})
.or_insert(candidate);
}
let expired_calls = creators
.iter()
.filter_map(|(call_id, (_, _, _, original_deadline))| {
let deadline = extensions
.get(call_id)
.map_or(*original_deadline, |(_, _, deadline)| *deadline);
(now - deadline > EXPIRED_RETENTION_MS).then(|| call_id.clone())
})
.collect::<HashSet<_>>();
}
self.events.retain(|event| {
if expired_calls.contains(&event.call_id) {
Self {
creators,
terminal_events,
extensions,
}
}
fn expired_call_ids(&self, now: i64) -> HashSet<&str> {
self.creators
.iter()
.filter_map(|(call_id, creator)| {
if self.terminal_events.contains_key(call_id) {
return None;
}
let deadline = self
.extensions
.get(call_id)
.map_or(creator.deadline, |extension| extension.deadline);
(now - deadline > EXPIRED_RETENTION_MS).then_some(call_id.as_str())
})
.collect()
}
}
fn compact_history(events: &mut Vec<CallToPlayEvent>, now: i64) {
let index = HistoryIndex::build(events);
let expired_calls = index.expired_call_ids(now);
events.retain(|event| {
if expired_calls.contains(event.call_id.as_str()) {
return false;
}
terminal_events
.get(&event.call_id)
.is_none_or(|(_, terminal_id)| event.id == *terminal_id)
});
let Some(terminal) = index.terminal_events.get(&event.call_id) else {
return true;
};
if (event.at, event.id.as_str()) > terminal.order_key() {
return false;
}
now - terminal.at <= TERMINAL_RETENTION_MS || event.id == terminal.event_id
});
}
fn terminal_tombstone_call_ids(events: &[CallToPlayEvent]) -> HashSet<String> {
let rooted_calls = events
.iter()
.filter(|event| matches!(event.action, CallToPlayAction::Create { .. }))
.map(|event| event.call_id.clone())
.collect::<HashSet<_>>();
events
.iter()
.filter(|event| {
matches!(
event.action,
CallToPlayAction::Cancel | CallToPlayAction::Start
) && !rooted_calls.contains(event.call_id.as_str())
})
.map(|event| event.call_id.clone())
.collect()
}
fn unresolved_event_count(events: &[CallToPlayEvent]) -> usize {
let index = HistoryIndex::build(events);
events
.iter()
.filter(|event| {
index.creators.contains_key(&event.call_id)
&& !index.terminal_events.contains_key(&event.call_id)
})
.count()
}
fn now_ms() -> i64 {
@@ -166,21 +399,25 @@ pub(crate) async fn publish(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
mut event: CallToPlayEvent,
) -> Result<(), &'static str> {
) -> Result<(), String> {
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);
let merged = ctx
.call_to_play
.write()
.await
.merge_batch(vec![event.clone()])
.map_err(|err| err.to_string())?;
if merged.needs_history() {
return Err("Call to Play history is missing".to_string());
}
Ok(true) => {}
if merged.applied.is_empty() {
if merged.obsolete > 0 {
return Err("Call to Play event is obsolete".to_string());
}
return Ok(());
}
events::send(
tx_notify_ui,
PeerEvent::CallToPlayEvents(vec![event.clone()]),
);
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied));
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
let peer_id = ctx.peer_id.clone();
@@ -191,18 +428,7 @@ pub(crate) async fn publish(
let peer_id = peer_id.clone();
let handshake_ctx = handshake_ctx.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}");
if let Err(resync_err) =
perform_handshake_with_peer(handshake_ctx, peer_addr, None).await
{
log::warn!(
"Failed to resync Call to Play history with {peer_addr}: {resync_err}"
);
}
}
deliver_to_peer(handshake_ctx, peer_addr, peer_id.as_ref(), event).await;
}
});
futures::future::join_all(deliveries).await;
@@ -210,6 +436,53 @@ pub(crate) async fn publish(
Ok(())
}
async fn deliver_to_peer(
handshake_ctx: HandshakeCtx,
peer_addr: std::net::SocketAddr,
peer_id: &str,
event: CallToPlayEvent,
) {
let delivery = send_call_to_play_events(peer_addr, peer_id, vec![event]).await;
match &delivery {
Ok(CallToPlayAck::Rejected { reason }) => {
log::warn!("Peer {peer_addr} rejected a Call to Play event: {reason}");
}
Ok(CallToPlayAck::Obsolete) => {
log::debug!("Peer {peer_addr} already retired the Call to Play event");
}
Err(err) => {
log::warn!("Failed to deliver a Call to Play event to {peer_addr}: {err}");
}
Ok(
CallToPlayAck::Applied
| CallToPlayAck::Duplicate
| CallToPlayAck::NeedHandshake
| CallToPlayAck::NeedHistory,
) => {}
}
let Some(reason) = delivery_resync_reason(delivery.as_ref().map_err(|_| ())) else {
return;
};
if let Err(err) = perform_handshake_with_peer(handshake_ctx, peer_addr, None).await {
log::warn!("Failed to {reason} with {peer_addr}: {err}");
}
}
fn delivery_resync_reason(delivery: Result<&CallToPlayAck, ()>) -> Option<&'static str> {
match delivery {
Err(()) => Some("heal a failed Call to Play delivery"),
Ok(CallToPlayAck::NeedHandshake) => Some("complete a requested Call to Play handshake"),
Ok(CallToPlayAck::NeedHistory) => Some("restore missing Call to Play history"),
Ok(
CallToPlayAck::Applied
| CallToPlayAck::Duplicate
| CallToPlayAck::Obsolete
| CallToPlayAck::Rejected { .. },
) => None,
}
}
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")?;
@@ -273,16 +546,26 @@ fn validate_nonempty(
#[cfg(test)]
mod tests {
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
use super::{CallToPlayStore, MAX_EVENTS};
use super::{
CallToPlayStore,
MAX_EVENTS,
MergeError,
TERMINAL_RETENTION_MS,
delivery_resync_reason,
};
const TEST_NOW: i64 = 8_000_000_000_000;
fn create_event(id: &str) -> CallToPlayEvent {
create_event_for("call-1", id)
}
fn create_event_for(call_id: &str, id: &str) -> CallToPlayEvent {
CallToPlayEvent {
id: id.to_string(),
call_id: "call-1".to_string(),
call_id: call_id.to_string(),
actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(),
at: TEST_NOW,
@@ -307,138 +590,324 @@ mod tests {
}
#[test]
fn deduplicates_events_without_reordering_history() {
fn deduplicates_events_without_reordering_new_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 first = create_event("event-1");
let second = create_event("event-2");
let merged = store
.merge_batch_at(vec![first.clone(), first.clone(), second.clone()], TEST_NOW)
.expect("valid batch should merge");
let ids = store
.snapshot()
.into_iter()
.map(|event| event.id)
.collect::<Vec<_>>();
assert_eq!(merged.applied, [first, second]);
assert_eq!(merged.duplicates, 1);
assert_eq!(merged.obsolete, 0);
assert!(!merged.needs_history());
let duplicate = store
.merge_batch_at(vec![create_event("event-1")], TEST_NOW)
.expect("stored duplicate should be harmless");
assert!(duplicate.applied.is_empty());
assert_eq!(duplicate.duplicates, 1);
let ids = event_ids(store.snapshot_at(TEST_NOW));
assert_eq!(ids, ["event-1", "event-2"]);
}
#[test]
fn rejects_malformed_events() {
fn invalid_batch_leaves_store_unchanged() {
let mut store = CallToPlayStore::default();
let mut event = create_event("event-1");
event.action = CallToPlayAction::SendMessage {
store
.merge_batch_at(vec![create_event("create")], TEST_NOW)
.expect("create should fit");
let before = store.snapshot_at(TEST_NOW);
let mut invalid = action_event(
"invalid",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-1".to_string(),
text: "valid before corruption".to_string(),
},
);
invalid.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"),
assert_eq!(
store.merge_batch_at(
vec![
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
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")
],
TEST_NOW,
),
Err(MergeError::Invalid("invalid message"))
);
let snapshot = store.snapshot();
assert_eq!(snapshot.len(), 1);
assert_eq!(snapshot[0].id, "start");
assert_eq!(store.snapshot_at(TEST_NOW), before);
}
#[test]
fn terminal_action_keeps_unrelated_active_calls() {
fn conflicting_event_id_leaves_store_unchanged() {
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");
.merge_batch_at(vec![create_event("create")], TEST_NOW)
.expect("create should fit");
let before = store.snapshot_at(TEST_NOW);
let mut conflicting = create_event("create");
conflicting.actor_name = "Mallory".to_string();
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"));
assert_eq!(
store.merge_batch_at(vec![conflicting], TEST_NOW),
Err(MergeError::ConflictingEvent("create".to_string()))
);
assert_eq!(store.snapshot_at(TEST_NOW), before);
}
#[test]
fn create_and_extension_revive_expired_history_in_any_batch_order() {
let extended_deadline = TEST_NOW + 20 * 60_000;
let extension = action_event(
"extend",
"call-1",
CallToPlayAction::AddTime {
deadline: extended_deadline,
},
);
let after_recovery_window = TEST_NOW + 6 * 60_000 + 1;
for history in [
vec![create_event("create"), extension.clone()],
vec![extension.clone(), create_event("create")],
] {
let mut store = CallToPlayStore::default();
store
.merge_batch_at(vec![create_event("create")], TEST_NOW)
.expect("initial create should fit");
let missing = store
.merge_batch_at(vec![extension.clone()], after_recovery_window)
.expect("missing history is an acknowledged merge outcome");
assert!(missing.applied.is_empty());
assert_eq!(missing.missing_call_ids, ["call-1"]);
assert!(store.snapshot_at(after_recovery_window).is_empty());
let revived = store
.merge_batch_at(history, after_recovery_window)
.expect("complete history should revive the call atomically");
assert_eq!(revived.applied.len(), 2);
assert_eq!(
event_ids(store.snapshot_at(after_recovery_window)),
event_ids(revived.applied)
);
}
}
#[test]
fn immediately_obsolete_event_is_not_applied() {
let mut store = CallToPlayStore::default();
let after_recovery_window = TEST_NOW + 6 * 60_000 + 1;
let merged = store
.merge_batch_at(vec![create_event("create")], after_recovery_window)
.expect("obsolete is an acknowledged merge outcome");
assert!(merged.applied.is_empty());
assert_eq!(merged.obsolete, 1);
assert!(store.snapshot_at(after_recovery_window).is_empty());
}
#[test]
fn terminal_tombstone_prevents_stale_history_resurrection() {
let mut store = CallToPlayStore::default();
let start = action_event("start", "call-1", CallToPlayAction::Start);
store
.merge_batch_at(vec![create_event("create"), start.clone()], TEST_NOW)
.expect("terminal history should merge");
let after_terminal_retention = start.at + TERMINAL_RETENTION_MS + 1;
assert_eq!(
store.snapshot_at(after_terminal_retention).as_slice(),
std::slice::from_ref(&start)
);
let stale = store
.merge_batch_at(
vec![
create_event("create"),
action_event(
"extend",
"call-1",
CallToPlayAction::AddTime {
deadline: TEST_NOW + 20 * 60_000,
},
),
],
after_terminal_retention,
)
.expect("stale history is an obsolete merge outcome");
assert!(stale.applied.is_empty());
assert_eq!(stale.obsolete, 2);
assert_eq!(store.snapshot_at(after_terminal_retention), [start]);
}
#[test]
fn terminal_actions_succeed_at_the_active_history_cap() {
for terminal_action in [CallToPlayAction::Start, CallToPlayAction::Cancel] {
let mut store = full_active_store();
let terminal = action_event("terminal", "call-1", terminal_action);
let merged = store
.merge_batch_at(vec![terminal.clone()], TEST_NOW)
.expect("terminal action should settle the full call");
assert_eq!(merged.applied.as_slice(), std::slice::from_ref(&terminal));
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 1);
assert_eq!(
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
[terminal]
);
}
}
#[test]
fn terminal_histories_do_not_consume_active_history_capacity() {
let mut store = full_active_store();
let mut terminal = action_event("call-1-start", "call-1", CallToPlayAction::Start);
terminal.at = TEST_NOW + 2_000;
store
.merge_batch_at(vec![terminal], TEST_NOW)
.expect("start should settle active history");
let created = create_event_for("call-2", "call-2-create");
let merged = store
.merge_batch_at(vec![created.clone()], TEST_NOW)
.expect("terminal history must not block a new active call");
assert_eq!(merged.applied, [created]);
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 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");
}
let mut store = full_active_store();
let before = store.snapshot_at(TEST_NOW);
assert_eq!(
store.insert(action_event("overflow", "call-1", CallToPlayAction::Rsvp)),
Err("Call to Play event history is full")
store.merge_batch_at(
vec![action_event("overflow", "call-1", CallToPlayAction::Rsvp,)],
TEST_NOW,
),
Err(MergeError::HistoryFull)
);
assert_eq!(store.snapshot().len(), MAX_EVENTS);
assert_eq!(store.snapshot_at(TEST_NOW), before);
}
#[test]
fn expired_call_history_is_evicted_as_a_unit() {
fn visible_call_retains_complete_chat_history() {
let mut store = CallToPlayStore::default();
let message = action_event(
"message-event",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-1".to_string(),
text: "Ready when you are".to_string(),
},
);
let merged = store
.merge_batch_at(
vec![
create_event("create"),
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
message.clone(),
],
TEST_NOW,
)
.expect("visible history should merge");
assert_eq!(merged.applied.len(), 3);
assert!(store.snapshot_at(TEST_NOW).contains(&message));
}
#[test]
fn terminal_history_preserves_roster_and_chat_for_display_window() {
let mut store = CallToPlayStore::default();
let message = action_event(
"message",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-1".to_string(),
text: "Launching now".to_string(),
},
);
let terminal = action_event("terminal", "call-1", CallToPlayAction::Start);
let history = vec![
create_event("create"),
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
message,
terminal.clone(),
];
store
.merge_batch_at(history.clone(), TEST_NOW)
.expect("terminal history should merge");
let mut post_terminal = action_event(
"post-terminal",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-2".to_string(),
text: "Too late".to_string(),
},
);
post_terminal.at = terminal.at + 1;
let obsolete = store
.merge_batch_at(
vec![create_event("different-create"), post_terminal],
TEST_NOW,
)
.expect("terminal updates should be obsolete");
assert!(obsolete.applied.is_empty());
assert_eq!(obsolete.obsolete, 2);
assert_eq!(
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS),
history
);
assert_eq!(
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
[terminal]
);
}
#[test]
fn only_failed_or_incomplete_deliveries_request_resync() {
assert!(delivery_resync_reason(Err(())).is_some());
assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHandshake)).is_some());
assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHistory)).is_some());
for ack in [
CallToPlayAck::Applied,
CallToPlayAck::Duplicate,
CallToPlayAck::Obsolete,
CallToPlayAck::Rejected {
reason: "invalid".to_string(),
},
] {
assert!(delivery_resync_reason(Ok(&ack)).is_none());
}
}
fn full_active_store() -> CallToPlayStore {
let mut history = Vec::with_capacity(MAX_EVENTS);
history.push(create_event("create"));
history.extend((1..MAX_EVENTS).map(|index| {
action_event(&format!("event-{index}"), "call-1", CallToPlayAction::Rsvp)
}));
let mut store = CallToPlayStore::default();
store
.insert(create_event("create"))
.expect("active call should fit");
.merge_batch_at(history, TEST_NOW)
.expect("active history should fit through the cap");
store
}
assert!(store.snapshot_at(TEST_NOW + 5 * 60_000 + 60_001).is_empty());
fn event_ids(events: Vec<CallToPlayEvent>) -> Vec<String> {
events.into_iter().map(|event| event.id).collect()
}
}
+1 -3
View File
@@ -504,9 +504,7 @@ async fn handle_peer_commands(
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 result = call_to_play::publish(ctx, tx_notify_ui, event).await;
let _ = reply.send(result);
}
PeerCommand::GetCallToPlayEvents { reply } => {
+30 -13
View File
@@ -9,7 +9,16 @@ use bytes::BytesMut;
use futures::{SinkExt, StreamExt};
use if_addrs::{IfAddr, Interface, get_if_addrs};
use lanspread_db::db::GameFileDescription;
use lanspread_proto::{CallToPlayEvent, Hello, HelloAck, LibraryDelta, Message, Request, Response};
use lanspread_proto::{
CallToPlayAck,
CallToPlayEvent,
Hello,
HelloAck,
LibraryDelta,
Message,
Request,
Response,
};
use s2n_quic::{
Client as QuicClient,
Connection,
@@ -132,6 +141,14 @@ pub async fn send_oneway_request(peer_addr: SocketAddr, request: Request) -> eyr
/// Performs a hello/ack handshake with a peer.
pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result<HelloAck> {
let response = exchange_request(peer_addr, Request::Hello(hello)).await?;
match response {
Response::HelloAck(ack) => Ok(ack),
other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"),
}
}
async fn exchange_request(peer_addr: SocketAddr, request: Request) -> eyre::Result<Response> {
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
@@ -139,19 +156,15 @@ pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx.send(Request::Hello(hello).encode()).await?;
let _ = framed_tx.close().await;
framed_tx.send(request.encode()).await?;
framed_tx.close().await?;
let mut data = BytesMut::new();
while let Some(Ok(bytes)) = framed_rx.next().await {
data.extend_from_slice(&bytes);
while let Some(frame) = framed_rx.next().await {
data.extend_from_slice(&frame?);
}
let response = Response::decode(data.freeze());
match response {
Response::HelloAck(ack) => Ok(ack),
other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"),
}
Ok(Response::decode(data.freeze()))
}
pub async fn send_library_delta(
@@ -177,15 +190,19 @@ pub async fn send_call_to_play_events(
peer_addr: SocketAddr,
peer_id: &str,
events: Vec<CallToPlayEvent>,
) -> eyre::Result<()> {
send_oneway_request(
) -> eyre::Result<CallToPlayAck> {
let response = exchange_request(
peer_addr,
Request::CallToPlayEvents {
peer_id: peer_id.to_string(),
events,
},
)
.await
.await?;
match response {
Response::CallToPlayAck(ack) => Ok(ack),
other => eyre::bail!("Unexpected Call to Play response from peer {peer_addr}: {other:?}"),
}
}
/// Requests game file details from a peer.
@@ -195,9 +195,21 @@ async fn merge_call_to_play_events(
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));
match store.write().await.merge_batch(incoming) {
Ok(merged) => {
if merged.needs_history() {
log::warn!(
"Call to Play handshake omitted roots for calls: {}",
merged.missing_call_ids.join(", ")
);
}
if !merged.applied.is_empty() {
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied));
}
}
Err(err) => {
log::warn!("Rejecting Call to Play handshake history: {err}");
}
}
}
@@ -371,8 +383,8 @@ mod tests {
ctx.call_to_play
.write()
.await
.insert(call_to_play_event())
.expect("valid event should be inserted");
.merge_batch(vec![call_to_play_event()])
.expect("valid event should be merged");
let hello = build_hello_from_state(&ctx)
.await
+144 -22
View File
@@ -4,7 +4,7 @@ use std::net::SocketAddr;
use futures::{SinkExt, StreamExt};
use lanspread_db::db::{Game, GameFileDescription};
use lanspread_proto::{LibraryDelta, Message, Request, Response};
use lanspread_proto::{CallToPlayAck, LibraryDelta, Message, Request, Response};
use s2n_quic::stream::{BidirectionalStream, SendStream};
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
@@ -94,8 +94,8 @@ async fn dispatch_request(
peer_id,
events: incoming,
} => {
handle_call_to_play_events(ctx, remote_addr, &peer_id, incoming).await;
framed_tx
let ack = handle_call_to_play_events(ctx, &peer_id, incoming).await;
send_response(framed_tx, Response::CallToPlayAck(ack), "CallToPlayAck").await
}
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
@@ -123,36 +123,56 @@ 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>,
) {
) -> CallToPlayAck {
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 ctx.peer_game_db.read().await.peer_addr(&peer_id).is_none() {
log::debug!("Requesting a handshake before accepting Call to Play events from {peer_id}");
return CallToPlayAck::NeedHandshake;
}
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 reason = format!("event actor does not match envelope peer {peer_id}");
log::warn!("Rejecting Call to Play events: {reason}");
return CallToPlayAck::Rejected { reason };
}
let accepted = ctx.call_to_play.write().await.insert_all(incoming);
if !accepted.is_empty() {
match ctx.call_to_play.write().await.merge_batch(incoming) {
Ok(merged) => {
let ack = if merged.needs_history() {
CallToPlayAck::NeedHistory
} else if !merged.applied.is_empty() {
CallToPlayAck::Applied
} else if merged.obsolete > 0 {
CallToPlayAck::Obsolete
} else if merged.duplicates > 0 {
CallToPlayAck::Duplicate
} else {
CallToPlayAck::Rejected {
reason: "empty Call to Play event batch".to_string(),
}
};
if merged.needs_history() {
log::warn!(
"Ignoring Call to Play actions without history from {peer_id}: {}",
merged.missing_call_ids.join(", ")
);
}
if !merged.applied.is_empty() {
events::send(
&ctx.tx_notify_ui,
crate::PeerEvent::CallToPlayEvents(accepted),
crate::PeerEvent::CallToPlayEvents(merged.applied),
);
}
ack
}
Err(err) => {
log::warn!("Rejecting Call to Play events from {peer_id}: {err}");
CallToPlayAck::Rejected {
reason: err.to_string(),
}
}
}
}
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
@@ -491,6 +511,7 @@ mod tests {
};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
use tokio::sync::{RwLock, mpsc};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -536,6 +557,29 @@ mod tests {
.to_peer_ctx(tx_notify_ui)
}
fn call_to_play_event(actor_id: &str, action: CallToPlayAction) -> CallToPlayEvent {
CallToPlayEvent {
id: "event-1".to_string(),
call_id: "call-1".to_string(),
actor_id: actor_id.to_string(),
actor_name: "Alice".to_string(),
at: 8_000_000_000_000,
action,
}
}
fn call_to_play_create(actor_id: &str) -> CallToPlayEvent {
call_to_play_event(
actor_id,
CallToPlayAction::Create {
game_id: "game".to_string(),
max_players: 4,
scheduled_for: None,
deadline: 8_000_000_060_000,
},
)
}
#[test]
fn local_relative_paths_are_never_transferable() {
assert!(path_points_inside_local("game", "game/local/save.dat"));
@@ -558,6 +602,84 @@ mod tests {
));
}
#[tokio::test]
async fn known_peer_id_accepts_live_events_without_transport_ip_matching() {
let temp = TempDir::new("lanspread-call-to-play-known-peer");
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
ctx.peer_game_db.write().await.upsert_peer(
"peer-alice".to_string(),
SocketAddr::from(([10, 66, 0, 2], 40000)),
);
let ack =
handle_call_to_play_events(&ctx, "peer-alice", vec![call_to_play_create("peer-alice")])
.await;
assert_eq!(ack, CallToPlayAck::Applied);
assert_eq!(ctx.call_to_play.write().await.snapshot().len(), 1);
}
#[tokio::test]
async fn unknown_peer_and_mismatched_actor_receive_explicit_acks() {
let temp = TempDir::new("lanspread-call-to-play-identity");
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
assert_eq!(
handle_call_to_play_events(
&ctx,
"peer-alice",
vec![call_to_play_create("peer-alice")],
)
.await,
CallToPlayAck::NeedHandshake
);
ctx.peer_game_db.write().await.upsert_peer(
"peer-alice".to_string(),
SocketAddr::from(([10, 66, 0, 2], 40000)),
);
assert!(matches!(
handle_call_to_play_events(
&ctx,
"peer-alice",
vec![call_to_play_create("peer-mallory")],
)
.await,
CallToPlayAck::Rejected { reason }
if reason.contains("does not match envelope peer")
));
}
#[tokio::test]
async fn live_event_ack_reports_missing_history_and_duplicates() {
let temp = TempDir::new("lanspread-call-to-play-outcomes");
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
ctx.peer_game_db.write().await.upsert_peer(
"peer-alice".to_string(),
SocketAddr::from(([10, 66, 0, 2], 40000)),
);
let orphan = call_to_play_event(
"peer-alice",
CallToPlayAction::AddTime {
deadline: 8_000_000_600_000,
},
);
assert_eq!(
handle_call_to_play_events(&ctx, "peer-alice", vec![orphan]).await,
CallToPlayAck::NeedHistory
);
let create = call_to_play_create("peer-alice");
assert_eq!(
handle_call_to_play_events(&ctx, "peer-alice", vec![create.clone()]).await,
CallToPlayAck::Applied
);
assert_eq!(
handle_call_to_play_events(&ctx, "peer-alice", vec![create]).await,
CallToPlayAck::Duplicate
);
}
#[tokio::test]
async fn get_game_response_respects_serve_gates() {
let temp = TempDir::new("lanspread-stream");
+12 -1
View File
@@ -4,7 +4,7 @@ use bytes::Bytes;
use lanspread_db::db::{Game, GameFileDescription};
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 6;
pub const PROTOCOL_VERSION: u32 = 7;
pub use lanspread_db::db::Availability;
@@ -74,6 +74,16 @@ pub enum CallToPlayAction {
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum CallToPlayAck {
Applied,
Duplicate,
NeedHandshake,
NeedHistory,
Obsolete,
Rejected { reason: String },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LibrarySnapshot {
pub library_rev: u64,
@@ -130,6 +140,7 @@ pub enum Response {
file_descriptions: Vec<GameFileDescription>,
},
HelloAck(HelloAck),
CallToPlayAck(CallToPlayAck),
GameNotFound(String),
InvalidRequest(Bytes, String),
EncodingError(String),
@@ -1,4 +1,5 @@
import { Icon } from '../Icon';
import { activeCallCount } from '../../lib/callToPlay';
import { Nomination } from '../../lib/types';
interface Props {
@@ -7,7 +8,7 @@ interface Props {
}
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
const activeCount = nominations.filter(nomination => nomination.state !== 'started').length;
const activeCount = activeCallCount(nominations);
return (
<button className="ctp-btn" onClick={onClick}>
<Icon.flag />
@@ -5,6 +5,7 @@ import { Modal } from '../Modal';
import { CreateNominationForm } from './CreateNominationForm';
import { NominationCard } from './NominationCard';
import { CallToPlayActions } from '../../hooks/useCallToPlay';
import { sortNominations } from '../../lib/callToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
@@ -36,10 +37,7 @@ export const CallToPlayOverlay = ({
}: 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
);
const sorted = sortNominations(nominations);
return (
<Modal onClose={onClose} className="ctp-modal">
@@ -22,6 +22,8 @@ interface Props {
}
const LABEL = {
running: 'Running',
cancelled: 'Cancelled',
scheduled: 'Scheduled',
call: 'Call to Play',
soon: 'Starting soon',
@@ -29,7 +31,7 @@ const LABEL = {
expired: 'Times up',
} as const;
type TickerStatus = Exclude<CallToPlayStatus, 'started'>;
type TickerStatus = CallToPlayStatus;
const RANK: Record<TickerStatus, number> = {
expired: 0,
@@ -37,12 +39,12 @@ const RANK: Record<TickerStatus, number> = {
soon: 2,
call: 3,
scheduled: 3,
running: 4,
cancelled: 4,
};
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus | null => {
const status = statusOf(nomination, now);
return status === 'started' ? null : status;
};
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus =>
statusOf(nomination, now);
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
const entries = Object.entries(nomination.participants);
@@ -78,27 +80,37 @@ const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number
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.flatMap(nomination => {
const status = tickerStatusOf(nomination, now);
return status === null ? [] : [{ nomination, status }];
}).sort((left, right) =>
const rows = nominations.map(nomination => ({
nomination,
status: tickerStatusOf(nomination, now),
})).sort((left, right) =>
RANK[left.status] - RANK[right.status]
|| left.nomination.deadline - right.nomination.deadline
);
if (active.length === 0) return null;
if (rows.length === 0) return null;
return (
<div className="ctp-ticker-stack">
{active.map(({ nomination, status }) => {
{rows.map(({ nomination, status }) => {
const game = gameById.get(nomination.gameId);
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'
const count = status === 'running' || status === 'cancelled'
? `${total} players`
: status === 'scheduled'
? `${total} in`
: `${ready}/${nomination.maxPlayers} ready`;
const time = status === 'ready'
const terminalElapsed = now - (nomination.terminalAt ?? now);
const terminalAge = terminalElapsed < 1_000
? 'just now'
: `${formatCountdownShort(terminalElapsed)} ago`;
const time = status === 'running'
? `started ${terminalAge}`
: status === 'cancelled'
? `cancelled ${terminalAge}`
: status === 'ready'
? 'waiting to start'
: status === 'expired'
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
@@ -13,6 +13,7 @@ import {
formatUntil,
inCountOf,
isReady,
isTerminal,
phaseOf,
readyCountOf,
statusOf,
@@ -82,11 +83,13 @@ export const NominationCard = ({
const isMe = myStatus !== undefined;
const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const terminal = isTerminal(nomination);
const isRunning = nomination.state === 'running';
const isCancelled = nomination.state === 'cancelled';
const isExpired = statusOf(nomination, now) === 'expired';
const phase = phaseOf(nomination, now);
const isScheduled = phase === 'scheduled' && !isDone && !isStarted && !isExpired;
const isCheckin = phase === 'checkin' && !isDone && !isStarted && !isExpired;
const isScheduled = phase === 'scheduled' && !isDone && !terminal && !isExpired;
const isCheckin = phase === 'checkin' && !isDone && !terminal && !isExpired;
const windowStart = nomination.scheduledFor === null
? nomination.createdAt
: nomination.scheduledFor - CHECKIN_LEAD_MS;
@@ -101,8 +104,10 @@ export const NominationCard = ({
: (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>
const timer = isRunning
? <div className="ctp-card-timer" data-urgency="off">Running</div>
: isCancelled
? <div className="ctp-card-timer" data-urgency="off">Cancelled</div>
: isExpired
? <div className="ctp-card-timer" data-urgency="high">Times up</div>
: isDone
@@ -123,7 +128,9 @@ export const NominationCard = ({
)
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
const rosterLabel = isScheduled
const rosterLabel = terminal
? `${entries.length} players`
: isScheduled
? `${entries.length} in · up to ${nomination.maxPlayers} players`
: isCheckin && inCount > 0
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
@@ -132,7 +139,7 @@ export const NominationCard = ({
return (
<div
ref={cardRef}
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${terminal ? 'is-terminal' : ''} ${isRunning ? 'is-running' : ''} ${isCancelled ? 'is-cancelled' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
>
<div className="ctp-card-top">
<div className="ctp-card-cover">
@@ -168,10 +175,10 @@ export const NominationCard = ({
<div
className="ctp-progress-fill"
style={{
width: `${isStarted || isDone ? 100 : percentage}%`,
background: isExpired
width: `${terminal || isDone ? 100 : percentage}%`,
background: isExpired || isCancelled
? 'var(--danger)'
: isStarted || isDone
: terminal || isDone
? 'var(--ok)'
: 'var(--accent)',
}}
@@ -211,11 +218,11 @@ export const NominationCard = ({
<CtpChat
nomination={nomination}
actorId={actorId}
disabled={isStarted}
disabled={terminal}
onSend={text => actions.sendMessage(nomination.id, text)}
/>
{isCreator && !isStarted && (
{isCreator && !terminal && (
confirmCancel
? (
<div className="ctp-cancel-confirm">
@@ -283,15 +290,19 @@ const CardActions = ({
const isMe = myStatus !== undefined;
const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const terminal = isTerminal(nomination);
const isExpired = statusOf(nomination, now) === 'expired';
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
const readyCount = readyCountOf(nomination, now);
if (isStarted) {
if (terminal) {
return (
<div className="ctp-note ctp-note-launch">
{game ? `Launching ${game.name}` : 'The match is starting.'}
<div className="ctp-note">
{nomination.state === 'running'
? game
? `${game.name} is running.`
: 'The match is running.'
: 'This call was cancelled.'}
</div>
);
}
@@ -331,8 +342,11 @@ const CardActions = ({
onClick={() => void actions.startNow(nomination.id).then(accepted => {
if (accepted && game) onLaunch(game);
})}
><Icon.play /><span>{game ? 'Start now' : 'Mark as started'}</span></button>
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
<button
className="ghost-btn"
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
>
Add 5 more minutes
</button>
</>
@@ -2,7 +2,14 @@ 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 {
CALL_TO_PLAY_CONNECTING_MESSAGE,
callToPlayEvent,
callToPlayPublishErrorMessage,
extendDeadline,
pruneCallToPlayEvents,
reduceCallToPlayEvents,
} from '../lib/callToPlay';
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
export interface CallToPlayActions {
@@ -18,7 +25,7 @@ export interface CallToPlayActions {
leave: (callId: string) => void;
cancel: (callId: string) => void;
startNow: (callId: string) => Promise<boolean>;
addTime: (callId: string, minutes?: number) => void;
addTime: (callId: string, currentDeadline: number, minutes?: number) => void;
}
export interface UseCallToPlay {
@@ -50,7 +57,11 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
const timer = window.setInterval(() => {
const current = Date.now();
setNow(current);
setEvents(previous => pruneCallToPlayEvents(previous, current));
}, 1_000);
return () => window.clearInterval(timer);
}, []);
@@ -66,6 +77,11 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
const ready = peerId !== null;
setActorId(peerId);
setTransportReady(ready);
if (ready) {
setError(current =>
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
);
}
if (ready && retry !== undefined) {
window.clearInterval(retry);
retry = undefined;
@@ -95,7 +111,10 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
}
} catch (err) {
console.error('Failed to register Call to Play listener:', err);
if (!cancelled) setError('Call to Play networking is unavailable.');
if (!cancelled) {
setTransportReady(false);
setError('Call to Play networking is unavailable.');
}
}
};
@@ -113,7 +132,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
): Promise<boolean> => {
if (actorId === null) {
setTransportReady(false);
setError('Call to Play needs an active LAN peer. Choose a game folder first.');
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
return false;
}
const event = callToPlayEvent(callId, actorId, actor, action);
@@ -121,7 +140,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
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.');
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
return false;
}
setTransportReady(true);
@@ -129,7 +148,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
return true;
} catch (err) {
console.error('publish_call_to_play failed:', err);
setError('Could not send this Call to Play update.');
setError(callToPlayPublishErrorMessage(err));
return false;
}
}, [actor, actorId]);
@@ -167,8 +186,8 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
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 },
addTime: (callId, currentDeadline, minutes = 5) => void publish(callId, {
AddTime: { deadline: extendDeadline(Date.now(), currentDeadline, minutes) },
}),
}), [publish]);
@@ -6,14 +6,41 @@ import {
} from './types';
export const CHECKIN_LEAD_MS = 15 * 60_000;
export const STARTED_RETENTION_MS = 3_000;
export const EXPIRED_RETENTION_MS = 5 * 60_000;
export const TERMINAL_RETENTION_MS = 15 * 60_000;
export const CALL_TO_PLAY_CONNECTING_MESSAGE =
'Call to Play is still connecting to the LAN. Try again in a moment.';
export const callToPlayPublishErrorMessage = (error: unknown): string => {
const detail = error instanceof Error ? error.message : String(error);
if (detail.includes('Call to Play event is obsolete')
|| detail.includes('Call to Play history is missing')
) {
return 'This Call to Play has expired or already finished.';
}
if (detail.includes('Call to Play event history is full')) {
return 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.';
}
return 'Could not send this Call to Play update.';
};
export const extendDeadline = (
now: number,
currentDeadline: number,
durationMinutes = 5,
): number => Math.max(now, currentDeadline) + durationMinutes * 60_000;
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
export type CallToPlayStatus = 'started' | 'expired' | 'ready' | 'soon' | 'scheduled' | 'call';
export type CallToPlayStatus =
| 'running'
| 'cancelled'
| 'expired'
| 'ready'
| 'soon'
| 'scheduled'
| 'call';
interface MutableNomination extends Nomination {
cancelled: boolean;
messageIds: Set<string>;
}
@@ -57,8 +84,30 @@ export const inCountOf = (nomination: Nomination, now: number): number =>
!isReady(participant, now) && participant.status === 'in'
).length;
export const isTerminal = (nomination: Nomination): boolean =>
nomination.state === 'running' || nomination.state === 'cancelled';
export const activeCallCount = (nominations: ReadonlyArray<Nomination>): number =>
nominations.filter(nomination => !isTerminal(nomination)).length;
export const sortNominations = (
nominations: ReadonlyArray<Nomination>,
): Nomination[] => [...nominations].sort((left, right) => {
const terminalRank = Number(isTerminal(left)) - Number(isTerminal(right));
if (terminalRank !== 0) return terminalRank;
if (isTerminal(left) && isTerminal(right)) {
return (right.terminalAt ?? 0) - (left.terminalAt ?? 0)
|| left.id.localeCompare(right.id);
}
return left.deadline - right.deadline
|| right.createdAt - left.createdAt
|| left.id.localeCompare(right.id);
});
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
if (nomination.state === 'started') return 'started';
if (nomination.state === 'running' || nomination.state === 'cancelled') {
return nomination.state;
}
if (now >= nomination.deadline) return 'expired';
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
return 'ready';
@@ -71,6 +120,39 @@ export const reduceCallToPlayEvents = (
input: ReadonlyArray<CallToPlayEvent>,
now: number,
): Nomination[] => {
const nominations = [...groupEvents(input).values()]
.map(events => deriveNomination(events, now))
.filter((nomination): nomination is Nomination => nomination !== null);
return sortNominations(nominations);
};
export const pruneCallToPlayEvents = (
previous: ReadonlyMap<string, CallToPlayEvent>,
now: number,
): ReadonlyMap<string, CallToPlayEvent> => {
const retiredEventIds = new Set<string>();
for (const events of groupEvents([...previous.values()]).values()) {
if (deriveNomination(events, now) !== null) continue;
const hasCreate = events.some(event => createPayload(event.action) !== null);
const expiredTombstone = events.some(event =>
(event.action === 'Start' || event.action === 'Cancel')
&& now - event.at > TERMINAL_RETENTION_MS
);
if (hasCreate || expiredTombstone) {
for (const event of events) retiredEventIds.add(event.id);
}
}
if (retiredEventIds.size === 0) return previous;
const next = new Map(previous);
for (const eventId of retiredEventIds) next.delete(eventId);
return next;
};
const groupEvents = (
input: ReadonlyArray<CallToPlayEvent>,
): Map<string, CallToPlayEvent[]> => {
const unique = new Map(input.map(event => [event.id, event]));
const byCall = new Map<string, CallToPlayEvent[]>();
for (const event of unique.values()) {
@@ -78,14 +160,18 @@ export const reduceCallToPlayEvents = (
events.push(event);
byCall.set(event.call_id, events);
}
return byCall;
};
const nominations: Nomination[] = [];
for (const events of byCall.values()) {
const deriveNomination = (
events: CallToPlayEvent[],
now: number,
): Nomination | null => {
events.sort(compareEvents);
const create = events.find(event => createPayload(event.action) !== null);
if (!create) continue;
if (!create) return null;
const payload = createPayload(create.action);
if (!payload) continue;
if (!payload) return null;
const nomination: MutableNomination = {
id: create.call_id,
@@ -105,40 +191,32 @@ export const reduceCallToPlayEvents = (
},
messages: [],
state: 'open',
cancelled: false,
terminalAt: null,
messageIds: new Set(),
};
for (const event of events) {
if (compareEvents(event, create) <= 0 || nomination.cancelled) continue;
applyEvent(nomination, event);
if (compareEvents(event, create) > 0) 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;
}
if (nomination.state !== 'started'
&& now - nomination.deadline > EXPIRED_RETENTION_MS
) {
continue;
if (isTerminal(nomination)) {
if (now - (nomination.terminalAt ?? now) > TERMINAL_RETENTION_MS) return null;
} else if (now - nomination.deadline > EXPIRED_RETENTION_MS) {
return null;
}
const { cancelled: _, messageIds: __, ...result } = nomination;
nominations.push(result);
}
return nominations.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
const { messageIds: _, ...result } = nomination;
return result;
};
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
if (isTerminal(nomination)) return;
const action = event.action;
if (typeof action === 'string') {
applyUnitAction(nomination, event, action);
@@ -147,7 +225,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
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,
@@ -159,7 +236,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
}
const message = messagePayload(action);
if (message && nomination.state !== 'started' && !nomination.messageIds.has(message.message_id)) {
if (message && !nomination.messageIds.has(message.message_id)) {
nomination.messageIds.add(message.message_id);
nomination.messages.push({
id: message.message_id,
@@ -175,7 +252,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const extension = addTimePayload(action);
if (extension
&& event.actor_id === nomination.creatorId
&& nomination.state !== 'started'
) {
nomination.deadline = extension.deadline;
nomination.state = 'open';
@@ -189,7 +265,6 @@ const applyUnitAction = (
): 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,
@@ -199,19 +274,20 @@ const applyUnitAction = (
break;
}
case 'Leave':
if (event.actor_id !== nomination.creatorId && nomination.state !== 'started') {
if (event.actor_id !== nomination.creatorId) {
delete nomination.participants[event.actor_id];
}
break;
case 'Cancel':
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
nomination.cancelled = true;
if (event.actor_id === nomination.creatorId) {
nomination.state = 'cancelled';
nomination.terminalAt = event.at;
}
break;
case 'Start':
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
nomination.state = 'started';
nomination.startedAt = event.at;
if (event.actor_id === nomination.creatorId) {
nomination.state = 'running';
nomination.terminalAt = event.at;
}
break;
}
@@ -113,8 +113,8 @@ export interface Nomination {
deadline: number;
participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[];
state: 'open' | 'done' | 'started';
startedAt?: number;
state: 'open' | 'done' | 'running' | 'cancelled';
terminalAt: number | null;
}
export type CallToPlayAction =
@@ -1920,7 +1920,9 @@
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); }
.ctp-card.is-started { opacity: 0.6; }
.ctp-card.is-terminal { opacity: 0.72; }
.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); }
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); }
.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); }
@@ -2213,7 +2215,7 @@
/* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
STARTING SOON (amber, glowing) · READY (green, steady) ·
TIME'S UP (red, steady) ─── */
TIME'S UP (red, steady) · RUNNING / CANCELLED (muted receipts) ─── */
.ctp-ticker[data-status="scheduled"] {
background: var(--bg-2);
border-color: var(--bd-2);
@@ -2240,10 +2242,22 @@
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
}
.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); }
.ctp-ticker[data-status="running"] {
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
}
.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); }
.ctp-ticker[data-status="cancelled"] {
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
}
.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, 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); }
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; }
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; }
@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; }
@@ -2253,9 +2267,13 @@
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
.ctp-ticker-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); }
.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); }
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
@@ -1,11 +1,18 @@
import {
CALL_TO_PLAY_CONNECTING_MESSAGE,
CHECKIN_LEAD_MS,
EXPIRED_RETENTION_MS,
TERMINAL_RETENTION_MS,
activeCallCount,
callToPlayPublishErrorMessage,
extendDeadline,
phaseOf,
bumpTime,
normalizeTimeInput,
pruneCallToPlayEvents,
readyCountOf,
reduceCallToPlayEvents,
sortNominations,
statusOf,
} from '../src/lib/callToPlay.ts';
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
@@ -148,13 +155,57 @@ Deno.test('creator can extend, start, and cancel a call', () => {
create(),
event('start', 'Alice', 'Start', NOW + 1),
], NOW + 2)[0];
assertEquals(started.state, 'started', 'creator start');
assertEquals(started.state, 'running', 'creator start');
assertEquals(started.terminalAt, NOW + 1, 'running timestamp');
const cancelled = reduceCallToPlayEvents([
const [cancelled] = reduceCallToPlayEvents([
create(),
event('cancel', 'Alice', 'Cancel', NOW + 1),
], NOW + 2);
assertEquals(cancelled.length, 0, 'creator cancel removes call');
assertEquals(cancelled.state, 'cancelled', 'creator cancel');
assertEquals(cancelled.terminalAt, NOW + 1, 'cancel timestamp');
});
Deno.test('adding time extends from the current deadline or the current time', () => {
const futureDeadline = NOW + 30 * 60_000;
assertEquals(
extendDeadline(NOW, futureDeadline),
futureDeadline + 5 * 60_000,
'ready-early call keeps its remaining time',
);
assertEquals(
extendDeadline(NOW, NOW - 60_000),
NOW + 5 * 60_000,
'overdue call gets five minutes from now',
);
});
Deno.test('publish failures distinguish startup and store outcomes', () => {
assertEquals(
CALL_TO_PLAY_CONNECTING_MESSAGE,
'Call to Play is still connecting to the LAN. Try again in a moment.',
'peer startup message',
);
assertEquals(
callToPlayPublishErrorMessage('Call to Play event is obsolete'),
'This Call to Play has expired or already finished.',
'obsolete call message',
);
assertEquals(
callToPlayPublishErrorMessage('Call to Play history is missing'),
'This Call to Play has expired or already finished.',
'missing expired history message',
);
assertEquals(
callToPlayPublishErrorMessage(new Error('Call to Play event history is full')),
'Call to Play has reached its active update limit. Start or cancel an active call, then try again.',
'active history limit message',
);
assertEquals(
callToPlayPublishErrorMessage('channel closed'),
'Could not send this Call to Play update.',
'unexpected failure message',
);
});
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
@@ -181,10 +232,75 @@ Deno.test('actions timestamped before creation cannot mutate a call', () => {
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('terminal calls retain complete read-only history for fifteen minutes', () => {
const events = [
create(),
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('message', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Launching' },
}, NOW + 2),
event('start', 'Alice', 'Start', NOW + 3),
event('late-message', 'Bob', {
SendMessage: { message_id: 'message-2', text: 'Too late' },
}, NOW + 4),
];
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
assertEquals(running.state, 'running', 'running receipt remains');
assertEquals(Object.keys(running.participants).length, 2, 'terminal roster retained');
assertEquals(running.messages.length, 1, 'pre-terminal chat retained');
assertEquals(
reduceCallToPlayEvents(events, NOW + 3 + TERMINAL_RETENTION_MS + 1).length,
0,
'terminal receipt retires after display window',
);
});
Deno.test('terminal calls sort last and do not contribute to the badge', () => {
const active = reduceCallToPlayEvents([create()], NOW)[0];
const running = reduceCallToPlayEvents([
create(),
event('start', 'Alice', 'Start', NOW + 1),
], NOW + 2)[0];
const cancelled = reduceCallToPlayEvents([
create(),
event('cancel', 'Alice', 'Cancel', NOW + 2),
], NOW + 3)[0];
const sorted = sortNominations([running, cancelled, active]);
assertEquals(sorted[0].state, 'open', 'actionable call sorts first');
assertEquals(activeCallCount(sorted), 1, 'terminal calls excluded from badge');
assertEquals(statusOf(running, NOW + 2), 'running', 'running ticker status');
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
});
Deno.test('retired calls are pruned from the frontend raw event map', () => {
const terminalEvents = [
create(),
event('start', 'Alice', 'Start', NOW + 1),
];
const map = new Map(terminalEvents.map(item => [item.id, item]));
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
2,
'visible terminal history stays cached',
);
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
0,
'retired terminal history is pruned',
);
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
assertEquals(
pruneCallToPlayEvents(
tombstoneMap,
NOW + 1 + TERMINAL_RETENTION_MS + 1,
).size,
0,
'backend tombstone is pruned too',
);
});
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
+29 -17
View File
@@ -98,7 +98,7 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
- **Right zone (col 3, flex space-between with two sub-groups):**
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active (non-started) calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active, non-terminal calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
@@ -466,7 +466,7 @@ the call and its history expire as a unit.
### Three surfaces
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
an accent badge counting active (non-`started`) calls. Opens the overlay.
an accent badge counting active, non-terminal calls. Opens the overlay.
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
stack rendered at the top of the grid area, **one row per active call**.
Sorted **ready → starting-soon → the rest**, ties broken by whichever
@@ -474,7 +474,8 @@ the call and its history expire as a unit.
then by `deadline`). Clicking a row opens the overlay focused on that call.
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
the other dialogs) with a header, a **Call a new match** button, the create
form, and a list of **nomination cards** (started calls sink to the bottom).
form, and a list of **nomination cards** (Running and Cancelled calls sink
to the bottom).
### Status model
@@ -482,14 +483,16 @@ Two derived values drive everything (`calltoplay.jsx`):
- `phaseOf(call)``'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
`'checkin'` (within the 15-min lead).
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'expired'`
(deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or state `done`) ·
`'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a clock time) ·
`'call'` (a plain play-now call).
- `statusOf(call)` (quick-bar/label status) → `'running'` · `'cancelled'` ·
`'expired'` (deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or
state `done`) · `'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a
clock time) · `'call'` (a plain play-now call).
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
SOON**, **READY**, **TIME'S UP** (`TICKER_LABEL`), each with its own dot color via
`.ctp-ticker-dot[data-status]`.
SOON**, **READY**, **TIME'S UP**, **RUNNING**, **CANCELLED** (`TICKER_LABEL`),
each with its own dot color via `.ctp-ticker-dot[data-status]`. Running and
Cancelled receipts remain for 15 minutes, sort after actionable calls, and do
not contribute to the top-bar badge.
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
@@ -502,7 +505,7 @@ as larger `AvatarChip`s in the nomination card roster.
Top to bottom:
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, or `Launching…`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, `Running`, or `Cancelled`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
4. **Roster**`readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
@@ -510,6 +513,10 @@ Top to bottom:
6. **Chat** — the collapsible per-call chat panel.
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
Running and Cancelled cards are read-only: the complete roster and chat remain
visible, but participant controls, creator controls, chat composition, and
cancel actions are disabled.
### Create form (`CreateNominationForm`)
Game search (typeahead over the catalog) → on pick, max-players defaults to the
@@ -546,8 +553,8 @@ type Nomination = {
readyAt?: number; // ms epoch a 'pending' buffer elapses
}>;
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
state: 'open' | 'done' | 'started';
startedAt?: number;
state: 'open' | 'done' | 'running' | 'cancelled';
terminalAt: number | null;
};
```
@@ -570,11 +577,16 @@ 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 every event and chat message for active
calls. Started and cancelled calls compact to terminal tombstones; expired
calls are removed after the five-minute grace period. The frontend reducer
turns that event history into the `Nomination` state above and derives
time-based phase changes locally. Stable peer IDs identify actors and enforce
creator controls; `settings.username` is only the display name.
calls. Running and Cancelled calls retain their complete history for 15 minutes
so late joiners can see the outcome, roster, and chat, then compact to a Start
or Cancel tombstone for the rest of the peer session. Unresolved calls are
removed after the five-minute post-deadline recovery period. The frontend
reducer turns that event history into the `Nomination` state above, derives
time-based phase changes locally, and prunes retired raw events. Stable peer IDs
identify actors and enforce creator controls; `settings.username` is only the
display name. These deadlines use event wall-clock timestamps, so LAN clocks
are assumed to be reasonably close; no clock-synchronization protocol is
attempted.
---