Compare commits
7
Commits
d58307c328
...
2c204ac258
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c204ac258
|
||
|
|
8d3affe19c
|
||
|
|
9c34efa705
|
||
|
|
e5d70ae56f
|
||
|
|
be7ad2e560
|
||
|
|
872692e3f4
|
||
|
|
e141229805
|
@@ -0,0 +1,152 @@
|
||||
# Finish Call to Play’s 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 |
|
||||
| Time’s 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 |
|
||||
|
||||
“Time’s 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/Time’s-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 event’s 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 Time’s-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 frontend’s 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, Time’s 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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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)
|
||||
pub(crate) fn merge_batch(
|
||||
&mut self,
|
||||
incoming: Vec<CallToPlayEvent>,
|
||||
) -> Result<BatchMerge, MergeError> {
|
||||
self.merge_batch_at(incoming, now_ms())
|
||||
}
|
||||
|
||||
pub(crate) fn insert_all(&mut self, events: Vec<CallToPlayEvent>) -> Vec<CallToPlayEvent> {
|
||||
let mut accepted = Vec::new();
|
||||
for event in events {
|
||||
match self.insert(event.clone()) {
|
||||
Ok(true) => accepted.push(event),
|
||||
Ok(false) => {}
|
||||
Err(err) => log::warn!("Ignoring invalid Call to Play event {}: {err}", event.id),
|
||||
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)?;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
accepted
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_inactive_calls(&mut self, now: i64) {
|
||||
let mut creators = HashMap::<String, (i64, String, String, i64)>::new();
|
||||
for event in &self.events {
|
||||
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 {
|
||||
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, ¤t.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());
|
||||
terminal_events
|
||||
.entry(event.call_id.clone())
|
||||
.and_modify(|current| {
|
||||
if (candidate.0, &candidate.1) < (current.0, ¤t.1) {
|
||||
current.clone_from(&candidate);
|
||||
}
|
||||
})
|
||||
.or_insert(candidate);
|
||||
|
||||
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.order_key() < current.order_key() {
|
||||
current.clone_from(&candidate);
|
||||
}
|
||||
})
|
||||
.or_insert(candidate);
|
||||
} else if let CallToPlayAction::AddTime { deadline } = event.action {
|
||||
let candidate = ExtensionRecord {
|
||||
event: EventRecord {
|
||||
at: event.at,
|
||||
event_id: event.id.clone(),
|
||||
},
|
||||
deadline,
|
||||
};
|
||||
extensions
|
||||
.entry(event.call_id.clone())
|
||||
.and_modify(|current| {
|
||||
if candidate.event.order_key() > current.event.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;
|
||||
};
|
||||
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, ¤t.1) {
|
||||
current.clone_from(&candidate);
|
||||
}
|
||||
})
|
||||
.or_insert(candidate);
|
||||
Self {
|
||||
creators,
|
||||
terminal_events,
|
||||
extensions,
|
||||
}
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
terminal_events
|
||||
.get(&event.call_id)
|
||||
.is_none_or(|(_, terminal_id)| event.id == *terminal_id)
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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());
|
||||
}
|
||||
if merged.applied.is_empty() {
|
||||
if merged.obsolete > 0 {
|
||||
return Err("Call to Play event is obsolete".to_string());
|
||||
}
|
||||
Ok(true) => {}
|
||||
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"),
|
||||
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")
|
||||
assert_eq!(
|
||||
store.merge_batch_at(
|
||||
vec![
|
||||
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
|
||||
invalid,
|
||||
],
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,35 +123,55 @@ 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() {
|
||||
events::send(
|
||||
&ctx.tx_notify_ui,
|
||||
crate::PeerEvent::CallToPlayEvents(accepted),
|
||||
);
|
||||
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(merged.applied),
|
||||
);
|
||||
}
|
||||
ack
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Rejecting Call to Play events from {peer_id}: {err}");
|
||||
CallToPlayAck::Rejected {
|
||||
reason: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: 'Time’s 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">Time’s 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,67 +160,63 @@ export const reduceCallToPlayEvents = (
|
||||
events.push(event);
|
||||
byCall.set(event.call_id, events);
|
||||
}
|
||||
return byCall;
|
||||
};
|
||||
|
||||
const nominations: Nomination[] = [];
|
||||
for (const events of byCall.values()) {
|
||||
events.sort(compareEvents);
|
||||
const create = events.find(event => createPayload(event.action) !== null);
|
||||
if (!create) continue;
|
||||
const payload = createPayload(create.action);
|
||||
if (!payload) continue;
|
||||
const deriveNomination = (
|
||||
events: CallToPlayEvent[],
|
||||
now: number,
|
||||
): Nomination | null => {
|
||||
events.sort(compareEvents);
|
||||
const create = events.find(event => createPayload(event.action) !== null);
|
||||
if (!create) return null;
|
||||
const payload = createPayload(create.action);
|
||||
if (!payload) return null;
|
||||
|
||||
const nomination: MutableNomination = {
|
||||
id: create.call_id,
|
||||
gameId: payload.game_id,
|
||||
creatorId: create.actor_id,
|
||||
creator: create.actor_name,
|
||||
maxPlayers: payload.max_players,
|
||||
createdAt: create.at,
|
||||
scheduledFor: payload.scheduled_for,
|
||||
deadline: payload.deadline,
|
||||
participants: {
|
||||
[create.actor_id]: {
|
||||
name: create.actor_name,
|
||||
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||
joinedAt: create.at,
|
||||
},
|
||||
const nomination: MutableNomination = {
|
||||
id: create.call_id,
|
||||
gameId: payload.game_id,
|
||||
creatorId: create.actor_id,
|
||||
creator: create.actor_name,
|
||||
maxPlayers: payload.max_players,
|
||||
createdAt: create.at,
|
||||
scheduledFor: payload.scheduled_for,
|
||||
deadline: payload.deadline,
|
||||
participants: {
|
||||
[create.actor_id]: {
|
||||
name: create.actor_name,
|
||||
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||
joinedAt: create.at,
|
||||
},
|
||||
messages: [],
|
||||
state: 'open',
|
||||
cancelled: false,
|
||||
messageIds: new Set(),
|
||||
};
|
||||
},
|
||||
messages: [],
|
||||
state: 'open',
|
||||
terminalAt: null,
|
||||
messageIds: new Set(),
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
if (compareEvents(event, create) <= 0 || nomination.cancelled) continue;
|
||||
applyEvent(nomination, event);
|
||||
}
|
||||
|
||||
if (nomination.cancelled) continue;
|
||||
if (nomination.state === 'open'
|
||||
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
||||
) {
|
||||
nomination.state = 'done';
|
||||
}
|
||||
if (nomination.state === 'started'
|
||||
&& now - (nomination.startedAt ?? now) > STARTED_RETENTION_MS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (nomination.state !== 'started'
|
||||
&& now - nomination.deadline > EXPIRED_RETENTION_MS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { cancelled: _, messageIds: __, ...result } = nomination;
|
||||
nominations.push(result);
|
||||
for (const event of events) {
|
||||
if (compareEvents(event, create) > 0) applyEvent(nomination, event);
|
||||
}
|
||||
|
||||
return nominations.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
|
||||
if (nomination.state === 'open'
|
||||
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
||||
) {
|
||||
nomination.state = 'done';
|
||||
}
|
||||
if (isTerminal(nomination)) {
|
||||
if (now - (nomination.terminalAt ?? now) > TERMINAL_RETENTION_MS) return null;
|
||||
} else if (now - nomination.deadline > EXPIRED_RETENTION_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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
@@ -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 (A–Z)`, `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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user