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
|
#!/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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -357,6 +357,7 @@ class Runner:
|
|||||||
("S46", self.s46_receiver_cancel_mid_stream),
|
("S46", self.s46_receiver_cancel_mid_stream),
|
||||||
("S47", self.s47_multi_archive_streams_in_sorted_order),
|
("S47", self.s47_multi_archive_streams_in_sorted_order),
|
||||||
("S48", self.s48_call_to_play_replication_and_late_join),
|
("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:
|
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"
|
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]:
|
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
|
||||||
result = subprocess.run(
|
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
|
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,
|
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`
|
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
|
or `Cancel` makes the call terminal and read-only, but its complete roster and
|
||||||
lets a peer that missed the live action heal on its next handshake without
|
chat history remain in snapshots for 15 minutes so late joiners can see the
|
||||||
retaining the inactive call's full history. Active calls are never partially
|
outcome. After that display window the history compacts to the Start or Cancel
|
||||||
trimmed. If genuinely active history reaches the bound, local publishes return
|
tombstone for the rest of the peer session. Active and recently terminal calls
|
||||||
an error to the caller instead of appearing to succeed. A call whose deadline
|
are never partially trimmed. If genuinely active history reaches the bound,
|
||||||
elapses remains available for five minutes so the creator can start or extend
|
local publishes return an error to the caller instead of appearing to succeed;
|
||||||
it, then its history is evicted as a unit. A
|
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
|
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
|
currently known peer. An incoming live event is applied once and sent to the UI
|
||||||
without being rebroadcast, which prevents forwarding loops.
|
without being rebroadcast, which prevents forwarding loops.
|
||||||
|
|
||||||
If a live Call to Play delivery fails, the sender immediately falls back to a
|
Live Call to Play delivery is acknowledged by the receiver. Applied, duplicate,
|
||||||
normal `Hello` / `HelloAck` exchange with that peer. The handshake carries the
|
and obsolete events need no follow-up. An unknown envelope peer, missing call
|
||||||
full active history in both directions, so a transient request failure heals
|
root, transport failure, or malformed acknowledgement makes the sender perform
|
||||||
without waiting for mDNS rediscovery or a later reconnect.
|
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
|
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
|
origin peer overwrites the actor ID on local actions. A live-event envelope must
|
||||||
must match the known sending peer. This prevents duplicate default usernames
|
name a peer already in the receiver's roster, and every enclosed actor ID must
|
||||||
from merging participants and protects creator controls from other normal
|
match that envelope. This prevents accidental identity mixing and protects
|
||||||
clients. It is not authentication against a hostile LAN peer; the QUIC setup
|
creator controls from other normal clients. It is not authentication against a
|
||||||
uses the project's trusted-LAN identity model.
|
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
|
`Hello` and `HelloAck` include each side's event history. This lets peers that
|
||||||
join after a call was created reconstruct the same nominations, responses,
|
join after a call was created reconstruct the same nominations, responses,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
//! Replicated event history for Call to Play coordination.
|
//! Replicated event history for Call to Play coordination.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{BTreeSet, HashMap, HashSet},
|
||||||
|
fmt,
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -22,11 +23,44 @@ const MAX_GAME_ID_CHARS: usize = 256;
|
|||||||
const MAX_USERNAME_CHARS: usize = 24;
|
const MAX_USERNAME_CHARS: usize = 24;
|
||||||
const MAX_MESSAGE_CHARS: usize = 500;
|
const MAX_MESSAGE_CHARS: usize = 500;
|
||||||
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
|
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
|
||||||
|
const TERMINAL_RETENTION_MS: i64 = 15 * 60_000;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub(crate) struct CallToPlayStore {
|
pub(crate) struct CallToPlayStore {
|
||||||
events: Vec<CallToPlayEvent>,
|
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 {
|
impl CallToPlayStore {
|
||||||
@@ -35,121 +69,320 @@ impl CallToPlayStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot_at(&mut self, now: i64) -> Vec<CallToPlayEvent> {
|
fn snapshot_at(&mut self, now: i64) -> Vec<CallToPlayEvent> {
|
||||||
self.compact_inactive_calls(now);
|
compact_history(&mut self.events, now);
|
||||||
self.events.clone()
|
self.events.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn insert(&mut self, event: CallToPlayEvent) -> Result<bool, &'static str> {
|
pub(crate) fn merge_batch(
|
||||||
validate_event(&event)?;
|
&mut self,
|
||||||
if self.event_ids.contains(&event.id) {
|
incoming: Vec<CallToPlayEvent>,
|
||||||
return Ok(false);
|
) -> Result<BatchMerge, MergeError> {
|
||||||
|
self.merge_batch_at(incoming, now_ms())
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_id = event.id.clone();
|
fn merge_batch_at(
|
||||||
self.event_ids.insert(event_id.clone());
|
&mut self,
|
||||||
self.events.push(event);
|
incoming: Vec<CallToPlayEvent>,
|
||||||
self.compact_inactive_calls(now_ms());
|
now: i64,
|
||||||
if self.events.len() > MAX_EVENTS {
|
) -> Result<BatchMerge, MergeError> {
|
||||||
self.events.retain(|event| event.id != event_id);
|
for event in &incoming {
|
||||||
self.event_ids.remove(&event_id);
|
validate_event(event).map_err(MergeError::Invalid)?;
|
||||||
return Err("Call to Play event history is full");
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn insert_all(&mut self, events: Vec<CallToPlayEvent>) -> Vec<CallToPlayEvent> {
|
let (incoming, mut duplicates) = deduplicate_batch(incoming)?;
|
||||||
let mut accepted = Vec::new();
|
|
||||||
|
// Expiry is evaluated before accepting new actions. An action cannot keep
|
||||||
|
// an already-retired root alive by itself; the sender must provide the
|
||||||
|
// complete history in the same batch.
|
||||||
|
let mut retained = self.events.clone();
|
||||||
|
compact_history(&mut retained, now);
|
||||||
|
|
||||||
|
let retained_by_id = retained
|
||||||
|
.iter()
|
||||||
|
.map(|event| (event.id.as_str(), event))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let mut new_events = Vec::with_capacity(incoming.len());
|
||||||
|
for event in incoming {
|
||||||
|
if let Some(existing) = retained_by_id.get(event.id.as_str()) {
|
||||||
|
if *existing != &event {
|
||||||
|
return Err(MergeError::ConflictingEvent(event.id));
|
||||||
|
}
|
||||||
|
duplicates += 1;
|
||||||
|
} else {
|
||||||
|
new_events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(retained_by_id);
|
||||||
|
|
||||||
|
let rooted_calls = retained
|
||||||
|
.iter()
|
||||||
|
.chain(&new_events)
|
||||||
|
.filter(|event| matches!(event.action, CallToPlayAction::Create { .. }))
|
||||||
|
.map(|event| event.call_id.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let retained_tombstones = terminal_tombstone_call_ids(&retained);
|
||||||
|
let retained_history = HistoryIndex::build(&retained);
|
||||||
|
let mut applicable = Vec::with_capacity(new_events.len());
|
||||||
|
let mut missing_call_ids = BTreeSet::new();
|
||||||
|
let mut obsolete = 0;
|
||||||
|
|
||||||
|
for event in new_events {
|
||||||
|
if retained_tombstones.contains(event.call_id.as_str()) {
|
||||||
|
obsolete += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(terminal) = retained_history.terminal_events.get(&event.call_id)
|
||||||
|
&& (matches!(event.action, CallToPlayAction::Create { .. })
|
||||||
|
|| (event.at, event.id.as_str()) > terminal.order_key())
|
||||||
|
{
|
||||||
|
obsolete += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !matches!(event.action, CallToPlayAction::Create { .. })
|
||||||
|
&& !rooted_calls.contains(event.call_id.as_str())
|
||||||
|
{
|
||||||
|
missing_call_ids.insert(event.call_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
applicable.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let applicable_ids = applicable
|
||||||
|
.iter()
|
||||||
|
.map(|event| event.id.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
retained.extend(applicable);
|
||||||
|
compact_history(&mut retained, now);
|
||||||
|
|
||||||
|
if unresolved_event_count(&retained) > MAX_EVENTS {
|
||||||
|
return Err(MergeError::HistoryFull);
|
||||||
|
}
|
||||||
|
|
||||||
|
let retained_ids = retained
|
||||||
|
.iter()
|
||||||
|
.map(|event| event.id.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let applied = retained
|
||||||
|
.iter()
|
||||||
|
.filter(|event| applicable_ids.contains(&event.id))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
obsolete += applicable_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|event_id| !retained_ids.contains(*event_id))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
self.events = retained;
|
||||||
|
Ok(BatchMerge {
|
||||||
|
applied,
|
||||||
|
duplicates,
|
||||||
|
obsolete,
|
||||||
|
missing_call_ids: missing_call_ids.into_iter().collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deduplicate_batch(
|
||||||
|
incoming: Vec<CallToPlayEvent>,
|
||||||
|
) -> Result<(Vec<CallToPlayEvent>, usize), MergeError> {
|
||||||
|
let mut unique = Vec::<CallToPlayEvent>::with_capacity(incoming.len());
|
||||||
|
let mut indexes = HashMap::<String, usize>::with_capacity(incoming.len());
|
||||||
|
let mut duplicates = 0;
|
||||||
|
|
||||||
|
for event in incoming {
|
||||||
|
if let Some(index) = indexes.get(&event.id).copied() {
|
||||||
|
if unique[index] != event {
|
||||||
|
return Err(MergeError::ConflictingEvent(event.id));
|
||||||
|
}
|
||||||
|
duplicates += 1;
|
||||||
|
} else {
|
||||||
|
indexes.insert(event.id.clone(), unique.len());
|
||||||
|
unique.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((unique, duplicates))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct HistoryIndex {
|
||||||
|
creators: HashMap<String, CreateRecord>,
|
||||||
|
terminal_events: HashMap<String, EventRecord>,
|
||||||
|
extensions: HashMap<String, ExtensionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct CreateRecord {
|
||||||
|
at: i64,
|
||||||
|
event_id: String,
|
||||||
|
actor_id: String,
|
||||||
|
deadline: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateRecord {
|
||||||
|
fn order_key(&self) -> (i64, &str) {
|
||||||
|
(self.at, &self.event_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct EventRecord {
|
||||||
|
at: i64,
|
||||||
|
event_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventRecord {
|
||||||
|
fn order_key(&self) -> (i64, &str) {
|
||||||
|
(self.at, &self.event_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ExtensionRecord {
|
||||||
|
event: EventRecord,
|
||||||
|
deadline: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryIndex {
|
||||||
|
fn build(events: &[CallToPlayEvent]) -> Self {
|
||||||
|
let mut creators = HashMap::<String, CreateRecord>::new();
|
||||||
for event in events {
|
for event in events {
|
||||||
match self.insert(event.clone()) {
|
|
||||||
Ok(true) => accepted.push(event),
|
|
||||||
Ok(false) => {}
|
|
||||||
Err(err) => log::warn!("Ignoring invalid Call to Play event {}: {err}", event.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
accepted
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compact_inactive_calls(&mut self, now: i64) {
|
|
||||||
let mut creators = HashMap::<String, (i64, String, String, i64)>::new();
|
|
||||||
for event in &self.events {
|
|
||||||
let CallToPlayAction::Create { deadline, .. } = event.action else {
|
let CallToPlayAction::Create { deadline, .. } = event.action else {
|
||||||
continue;
|
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
|
creators
|
||||||
.entry(event.call_id.clone())
|
.entry(event.call_id.clone())
|
||||||
.and_modify(|current| {
|
.and_modify(|current| {
|
||||||
if (candidate.0, &candidate.1) < (current.0, ¤t.1) {
|
if candidate.order_key() < current.order_key() {
|
||||||
current.clone_from(&candidate);
|
current.clone_from(&candidate);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.or_insert(candidate);
|
.or_insert(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut terminal_events = HashMap::<String, (i64, String)>::new();
|
let mut terminal_events = HashMap::<String, EventRecord>::new();
|
||||||
for event in &self.events {
|
let mut extensions = HashMap::<String, ExtensionRecord>::new();
|
||||||
let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else {
|
for event in events {
|
||||||
|
let Some(creator) = creators.get(&event.call_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !matches!(
|
if event.actor_id != creator.actor_id
|
||||||
event.action,
|
|| (event.at, event.id.as_str()) <= creator.order_key()
|
||||||
CallToPlayAction::Cancel | CallToPlayAction::Start
|
|
||||||
) || event.actor_id != *creator_id
|
|
||||||
|| (event.at, &event.id) <= (*created_at, create_id)
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let candidate = (event.at, event.id.clone());
|
|
||||||
|
if matches!(
|
||||||
|
event.action,
|
||||||
|
CallToPlayAction::Cancel | CallToPlayAction::Start
|
||||||
|
) {
|
||||||
|
let candidate = EventRecord {
|
||||||
|
at: event.at,
|
||||||
|
event_id: event.id.clone(),
|
||||||
|
};
|
||||||
terminal_events
|
terminal_events
|
||||||
.entry(event.call_id.clone())
|
.entry(event.call_id.clone())
|
||||||
.and_modify(|current| {
|
.and_modify(|current| {
|
||||||
if (candidate.0, &candidate.1) < (current.0, ¤t.1) {
|
if candidate.order_key() < current.order_key() {
|
||||||
current.clone_from(&candidate);
|
current.clone_from(&candidate);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.or_insert(candidate);
|
.or_insert(candidate);
|
||||||
}
|
} else if let CallToPlayAction::AddTime { deadline } = event.action {
|
||||||
|
let candidate = ExtensionRecord {
|
||||||
let mut extensions = HashMap::<String, (i64, String, i64)>::new();
|
event: EventRecord {
|
||||||
for event in &self.events {
|
at: event.at,
|
||||||
let CallToPlayAction::AddTime { deadline } = event.action else {
|
event_id: event.id.clone(),
|
||||||
continue;
|
},
|
||||||
|
deadline,
|
||||||
};
|
};
|
||||||
let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if event.actor_id != *creator_id || (event.at, &event.id) <= (*created_at, create_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let candidate = (event.at, event.id.clone(), deadline);
|
|
||||||
extensions
|
extensions
|
||||||
.entry(event.call_id.clone())
|
.entry(event.call_id.clone())
|
||||||
.and_modify(|current| {
|
.and_modify(|current| {
|
||||||
if (candidate.0, &candidate.1) > (current.0, ¤t.1) {
|
if candidate.event.order_key() > current.event.order_key() {
|
||||||
current.clone_from(&candidate);
|
current.clone_from(&candidate);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.or_insert(candidate);
|
.or_insert(candidate);
|
||||||
}
|
}
|
||||||
let expired_calls = creators
|
}
|
||||||
.iter()
|
|
||||||
.filter_map(|(call_id, (_, _, _, original_deadline))| {
|
|
||||||
let deadline = extensions
|
|
||||||
.get(call_id)
|
|
||||||
.map_or(*original_deadline, |(_, _, deadline)| *deadline);
|
|
||||||
(now - deadline > EXPIRED_RETENTION_MS).then(|| call_id.clone())
|
|
||||||
})
|
|
||||||
.collect::<HashSet<_>>();
|
|
||||||
|
|
||||||
self.events.retain(|event| {
|
Self {
|
||||||
if expired_calls.contains(&event.call_id) {
|
creators,
|
||||||
|
terminal_events,
|
||||||
|
extensions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expired_call_ids(&self, now: i64) -> HashSet<&str> {
|
||||||
|
self.creators
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(call_id, creator)| {
|
||||||
|
if self.terminal_events.contains_key(call_id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let deadline = self
|
||||||
|
.extensions
|
||||||
|
.get(call_id)
|
||||||
|
.map_or(creator.deadline, |extension| extension.deadline);
|
||||||
|
(now - deadline > EXPIRED_RETENTION_MS).then_some(call_id.as_str())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_history(events: &mut Vec<CallToPlayEvent>, now: i64) {
|
||||||
|
let index = HistoryIndex::build(events);
|
||||||
|
let expired_calls = index.expired_call_ids(now);
|
||||||
|
|
||||||
|
events.retain(|event| {
|
||||||
|
if expired_calls.contains(event.call_id.as_str()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
terminal_events
|
let Some(terminal) = index.terminal_events.get(&event.call_id) else {
|
||||||
.get(&event.call_id)
|
return true;
|
||||||
.is_none_or(|(_, terminal_id)| event.id == *terminal_id)
|
};
|
||||||
|
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 {
|
fn now_ms() -> i64 {
|
||||||
@@ -166,21 +399,25 @@ pub(crate) async fn publish(
|
|||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
mut event: CallToPlayEvent,
|
mut event: CallToPlayEvent,
|
||||||
) -> Result<(), &'static str> {
|
) -> Result<(), String> {
|
||||||
event.actor_id.clone_from(ctx.peer_id.as_ref());
|
event.actor_id.clone_from(ctx.peer_id.as_ref());
|
||||||
match ctx.call_to_play.write().await.insert(event.clone()) {
|
let merged = ctx
|
||||||
Ok(false) => return Ok(()),
|
.call_to_play
|
||||||
Err(err) => {
|
.write()
|
||||||
log::warn!("Rejecting local Call to Play event {}: {err}", event.id);
|
.await
|
||||||
return Err(err);
|
.merge_batch(vec![event.clone()])
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
if merged.needs_history() {
|
||||||
|
return Err("Call to Play history is missing".to_string());
|
||||||
}
|
}
|
||||||
Ok(true) => {}
|
if merged.applied.is_empty() {
|
||||||
|
if merged.obsolete > 0 {
|
||||||
|
return Err("Call to Play event is obsolete".to_string());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
events::send(
|
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied));
|
||||||
tx_notify_ui,
|
|
||||||
PeerEvent::CallToPlayEvents(vec![event.clone()]),
|
|
||||||
);
|
|
||||||
|
|
||||||
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
|
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
|
||||||
let peer_id = ctx.peer_id.clone();
|
let peer_id = ctx.peer_id.clone();
|
||||||
@@ -191,18 +428,7 @@ pub(crate) async fn publish(
|
|||||||
let peer_id = peer_id.clone();
|
let peer_id = peer_id.clone();
|
||||||
let handshake_ctx = handshake_ctx.clone();
|
let handshake_ctx = handshake_ctx.clone();
|
||||||
async move {
|
async move {
|
||||||
if let Err(err) =
|
deliver_to_peer(handshake_ctx, peer_addr, peer_id.as_ref(), event).await;
|
||||||
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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
futures::future::join_all(deliveries).await;
|
futures::future::join_all(deliveries).await;
|
||||||
@@ -210,6 +436,53 @@ pub(crate) async fn publish(
|
|||||||
Ok(())
|
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> {
|
fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> {
|
||||||
validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?;
|
validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?;
|
||||||
validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?;
|
validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?;
|
||||||
@@ -273,16 +546,26 @@ fn validate_nonempty(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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;
|
const TEST_NOW: i64 = 8_000_000_000_000;
|
||||||
|
|
||||||
fn create_event(id: &str) -> CallToPlayEvent {
|
fn create_event(id: &str) -> CallToPlayEvent {
|
||||||
|
create_event_for("call-1", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_event_for(call_id: &str, id: &str) -> CallToPlayEvent {
|
||||||
CallToPlayEvent {
|
CallToPlayEvent {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
call_id: "call-1".to_string(),
|
call_id: call_id.to_string(),
|
||||||
actor_id: "peer-alice".to_string(),
|
actor_id: "peer-alice".to_string(),
|
||||||
actor_name: "Alice".to_string(),
|
actor_name: "Alice".to_string(),
|
||||||
at: TEST_NOW,
|
at: TEST_NOW,
|
||||||
@@ -307,138 +590,324 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deduplicates_events_without_reordering_history() {
|
fn deduplicates_events_without_reordering_new_history() {
|
||||||
let mut store = CallToPlayStore::default();
|
let mut store = CallToPlayStore::default();
|
||||||
assert!(
|
let first = create_event("event-1");
|
||||||
store
|
let second = create_event("event-2");
|
||||||
.insert(create_event("event-1"))
|
let merged = store
|
||||||
.expect("valid event should be inserted")
|
.merge_batch_at(vec![first.clone(), first.clone(), second.clone()], TEST_NOW)
|
||||||
);
|
.expect("valid batch should merge");
|
||||||
assert!(
|
|
||||||
!store
|
|
||||||
.insert(create_event("event-1"))
|
|
||||||
.expect("duplicate valid event should be accepted")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.insert(create_event("event-2"))
|
|
||||||
.expect("valid event should be inserted")
|
|
||||||
);
|
|
||||||
|
|
||||||
let ids = store
|
assert_eq!(merged.applied, [first, second]);
|
||||||
.snapshot()
|
assert_eq!(merged.duplicates, 1);
|
||||||
.into_iter()
|
assert_eq!(merged.obsolete, 0);
|
||||||
.map(|event| event.id)
|
assert!(!merged.needs_history());
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
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"]);
|
assert_eq!(ids, ["event-1", "event-2"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_malformed_events() {
|
fn invalid_batch_leaves_store_unchanged() {
|
||||||
let mut store = CallToPlayStore::default();
|
let mut store = CallToPlayStore::default();
|
||||||
let mut event = create_event("event-1");
|
store
|
||||||
event.action = CallToPlayAction::SendMessage {
|
.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(),
|
message_id: "message-1".to_string(),
|
||||||
text: " ".to_string(),
|
text: " ".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(store.insert(event), Err("invalid message"));
|
assert_eq!(
|
||||||
assert!(store.snapshot().is_empty());
|
store.merge_batch_at(
|
||||||
}
|
vec![
|
||||||
|
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
|
||||||
#[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,
|
invalid,
|
||||||
create_event("event-2"),
|
],
|
||||||
]);
|
TEST_NOW,
|
||||||
|
),
|
||||||
assert_eq!(accepted, [create_event("event-2")]);
|
Err(MergeError::Invalid("invalid message"))
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn terminal_action_evicts_the_whole_call_even_at_capacity() {
|
|
||||||
let mut store = CallToPlayStore::default();
|
|
||||||
store
|
|
||||||
.insert(create_event("create"))
|
|
||||||
.expect("create should fit");
|
|
||||||
for index in 1..MAX_EVENTS {
|
|
||||||
store
|
|
||||||
.insert(action_event(
|
|
||||||
&format!("event-{index}"),
|
|
||||||
"call-1",
|
|
||||||
CallToPlayAction::Rsvp,
|
|
||||||
))
|
|
||||||
.expect("active history should fit through the cap");
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.insert(action_event("start", "call-1", CallToPlayAction::Start))
|
|
||||||
.expect("terminal action should compact the full call")
|
|
||||||
);
|
);
|
||||||
let snapshot = store.snapshot();
|
assert_eq!(store.snapshot_at(TEST_NOW), before);
|
||||||
assert_eq!(snapshot.len(), 1);
|
|
||||||
assert_eq!(snapshot[0].id, "start");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_action_keeps_unrelated_active_calls() {
|
fn conflicting_event_id_leaves_store_unchanged() {
|
||||||
let mut store = CallToPlayStore::default();
|
let mut store = CallToPlayStore::default();
|
||||||
store
|
store
|
||||||
.insert(create_event("call-1-create"))
|
.merge_batch_at(vec![create_event("create")], TEST_NOW)
|
||||||
.expect("first call should fit");
|
.expect("create should fit");
|
||||||
let mut other_create = create_event("call-2-create");
|
let before = store.snapshot_at(TEST_NOW);
|
||||||
other_create.call_id = "call-2".to_string();
|
let mut conflicting = create_event("create");
|
||||||
store.insert(other_create).expect("second call should fit");
|
conflicting.actor_name = "Mallory".to_string();
|
||||||
store
|
|
||||||
.insert(action_event("cancel", "call-1", CallToPlayAction::Cancel))
|
|
||||||
.expect("cancel should compact the first call");
|
|
||||||
|
|
||||||
let snapshot = store.snapshot();
|
assert_eq!(
|
||||||
assert_eq!(snapshot.len(), 2);
|
store.merge_batch_at(vec![conflicting], TEST_NOW),
|
||||||
assert!(snapshot.iter().any(|event| event.id == "cancel"));
|
Err(MergeError::ConflictingEvent("create".to_string()))
|
||||||
assert!(snapshot.iter().any(|event| event.call_id == "call-2"));
|
);
|
||||||
|
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]
|
#[test]
|
||||||
fn full_active_history_returns_an_error() {
|
fn full_active_history_returns_an_error() {
|
||||||
let mut store = CallToPlayStore::default();
|
let mut store = full_active_store();
|
||||||
store
|
let before = store.snapshot_at(TEST_NOW);
|
||||||
.insert(create_event("create"))
|
|
||||||
.expect("create should fit");
|
|
||||||
for index in 1..MAX_EVENTS {
|
|
||||||
store
|
|
||||||
.insert(action_event(
|
|
||||||
&format!("event-{index}"),
|
|
||||||
"call-1",
|
|
||||||
CallToPlayAction::Rsvp,
|
|
||||||
))
|
|
||||||
.expect("active history should fit through the cap");
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.insert(action_event("overflow", "call-1", CallToPlayAction::Rsvp)),
|
store.merge_batch_at(
|
||||||
Err("Call to Play event history is full")
|
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]
|
#[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();
|
let mut store = CallToPlayStore::default();
|
||||||
store
|
store
|
||||||
.insert(create_event("create"))
|
.merge_batch_at(history, TEST_NOW)
|
||||||
.expect("active call should fit");
|
.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;
|
handle_connect_peer_command(ctx, tx_notify_ui, addr).await;
|
||||||
}
|
}
|
||||||
PeerCommand::PublishCallToPlay { event, reply } => {
|
PeerCommand::PublishCallToPlay { event, reply } => {
|
||||||
let result = call_to_play::publish(ctx, tx_notify_ui, event)
|
let result = call_to_play::publish(ctx, tx_notify_ui, event).await;
|
||||||
.await
|
|
||||||
.map_err(str::to_owned);
|
|
||||||
let _ = reply.send(result);
|
let _ = reply.send(result);
|
||||||
}
|
}
|
||||||
PeerCommand::GetCallToPlayEvents { reply } => {
|
PeerCommand::GetCallToPlayEvents { reply } => {
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ use bytes::BytesMut;
|
|||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use if_addrs::{IfAddr, Interface, get_if_addrs};
|
use if_addrs::{IfAddr, Interface, get_if_addrs};
|
||||||
use lanspread_db::db::GameFileDescription;
|
use lanspread_db::db::GameFileDescription;
|
||||||
use lanspread_proto::{CallToPlayEvent, Hello, HelloAck, LibraryDelta, Message, Request, Response};
|
use lanspread_proto::{
|
||||||
|
CallToPlayAck,
|
||||||
|
CallToPlayEvent,
|
||||||
|
Hello,
|
||||||
|
HelloAck,
|
||||||
|
LibraryDelta,
|
||||||
|
Message,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
};
|
||||||
use s2n_quic::{
|
use s2n_quic::{
|
||||||
Client as QuicClient,
|
Client as QuicClient,
|
||||||
Connection,
|
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.
|
/// Performs a hello/ack handshake with a peer.
|
||||||
pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result<HelloAck> {
|
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 mut conn = connect_to_peer(peer_addr).await?;
|
||||||
|
|
||||||
let stream = conn.open_bidirectional_stream().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_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
|
||||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||||
|
|
||||||
framed_tx.send(Request::Hello(hello).encode()).await?;
|
framed_tx.send(request.encode()).await?;
|
||||||
let _ = framed_tx.close().await;
|
framed_tx.close().await?;
|
||||||
|
|
||||||
let mut data = BytesMut::new();
|
let mut data = BytesMut::new();
|
||||||
while let Some(Ok(bytes)) = framed_rx.next().await {
|
while let Some(frame) = framed_rx.next().await {
|
||||||
data.extend_from_slice(&bytes);
|
data.extend_from_slice(&frame?);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = Response::decode(data.freeze());
|
Ok(Response::decode(data.freeze()))
|
||||||
match response {
|
|
||||||
Response::HelloAck(ack) => Ok(ack),
|
|
||||||
other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_library_delta(
|
pub async fn send_library_delta(
|
||||||
@@ -177,15 +190,19 @@ pub async fn send_call_to_play_events(
|
|||||||
peer_addr: SocketAddr,
|
peer_addr: SocketAddr,
|
||||||
peer_id: &str,
|
peer_id: &str,
|
||||||
events: Vec<CallToPlayEvent>,
|
events: Vec<CallToPlayEvent>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<CallToPlayAck> {
|
||||||
send_oneway_request(
|
let response = exchange_request(
|
||||||
peer_addr,
|
peer_addr,
|
||||||
Request::CallToPlayEvents {
|
Request::CallToPlayEvents {
|
||||||
peer_id: peer_id.to_string(),
|
peer_id: peer_id.to_string(),
|
||||||
events,
|
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.
|
/// Requests game file details from a peer.
|
||||||
|
|||||||
@@ -195,9 +195,21 @@ async fn merge_call_to_play_events(
|
|||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
||||||
) {
|
) {
|
||||||
let accepted = store.write().await.insert_all(incoming);
|
match store.write().await.merge_batch(incoming) {
|
||||||
if !accepted.is_empty() {
|
Ok(merged) => {
|
||||||
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(accepted));
|
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
|
ctx.call_to_play
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.insert(call_to_play_event())
|
.merge_batch(vec![call_to_play_event()])
|
||||||
.expect("valid event should be inserted");
|
.expect("valid event should be merged");
|
||||||
|
|
||||||
let hello = build_hello_from_state(&ctx)
|
let hello = build_hello_from_state(&ctx)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::net::SocketAddr;
|
|||||||
|
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use lanspread_db::db::{Game, GameFileDescription};
|
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 s2n_quic::stream::{BidirectionalStream, SendStream};
|
||||||
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
|
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
|
||||||
|
|
||||||
@@ -94,8 +94,8 @@ async fn dispatch_request(
|
|||||||
peer_id,
|
peer_id,
|
||||||
events: incoming,
|
events: incoming,
|
||||||
} => {
|
} => {
|
||||||
handle_call_to_play_events(ctx, remote_addr, &peer_id, incoming).await;
|
let ack = handle_call_to_play_events(ctx, &peer_id, incoming).await;
|
||||||
framed_tx
|
send_response(framed_tx, Response::CallToPlayAck(ack), "CallToPlayAck").await
|
||||||
}
|
}
|
||||||
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
|
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
|
||||||
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
|
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
|
||||||
@@ -123,36 +123,56 @@ async fn dispatch_request(
|
|||||||
|
|
||||||
async fn handle_call_to_play_events(
|
async fn handle_call_to_play_events(
|
||||||
ctx: &PeerCtx,
|
ctx: &PeerCtx,
|
||||||
remote_addr: Option<SocketAddr>,
|
|
||||||
peer_id: &str,
|
peer_id: &str,
|
||||||
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
||||||
) {
|
) -> CallToPlayAck {
|
||||||
let peer_id = peer_id.to_string();
|
let peer_id = peer_id.to_string();
|
||||||
let sender_matches = if let Some(remote_addr) = remote_addr {
|
if ctx.peer_game_db.read().await.peer_addr(&peer_id).is_none() {
|
||||||
ctx.peer_game_db
|
log::debug!("Requesting a handshake before accepting Call to Play events from {peer_id}");
|
||||||
.read()
|
return CallToPlayAck::NeedHandshake;
|
||||||
.await
|
|
||||||
.peer_addr(&peer_id)
|
|
||||||
.is_some_and(|listen_addr| listen_addr.ip() == remote_addr.ip())
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
if !sender_matches {
|
|
||||||
log::warn!("Ignoring Call to Play events from unverified peer {peer_id}");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if incoming.iter().any(|event| event.actor_id != peer_id) {
|
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}");
|
let reason = format!("event actor does not match envelope peer {peer_id}");
|
||||||
return;
|
log::warn!("Rejecting Call to Play events: {reason}");
|
||||||
|
return CallToPlayAck::Rejected { reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
let accepted = ctx.call_to_play.write().await.insert_all(incoming);
|
match ctx.call_to_play.write().await.merge_batch(incoming) {
|
||||||
if !accepted.is_empty() {
|
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(
|
events::send(
|
||||||
&ctx.tx_notify_ui,
|
&ctx.tx_notify_ui,
|
||||||
crate::PeerEvent::CallToPlayEvents(accepted),
|
crate::PeerEvent::CallToPlayEvents(merged.applied),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
ack
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Rejecting Call to Play events from {peer_id}: {err}");
|
||||||
|
CallToPlayAck::Rejected {
|
||||||
|
reason: err.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
|
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
|
||||||
@@ -491,6 +511,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use lanspread_db::db::GameCatalog;
|
use lanspread_db::db::GameCatalog;
|
||||||
|
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
||||||
use tokio::sync::{RwLock, mpsc};
|
use tokio::sync::{RwLock, mpsc};
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
|
|
||||||
@@ -536,6 +557,29 @@ mod tests {
|
|||||||
.to_peer_ctx(tx_notify_ui)
|
.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]
|
#[test]
|
||||||
fn local_relative_paths_are_never_transferable() {
|
fn local_relative_paths_are_never_transferable() {
|
||||||
assert!(path_points_inside_local("game", "game/local/save.dat"));
|
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]
|
#[tokio::test]
|
||||||
async fn get_game_response_respects_serve_gates() {
|
async fn get_game_response_respects_serve_gates() {
|
||||||
let temp = TempDir::new("lanspread-stream");
|
let temp = TempDir::new("lanspread-stream");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use bytes::Bytes;
|
|||||||
use lanspread_db::db::{Game, GameFileDescription};
|
use lanspread_db::db::{Game, GameFileDescription};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const PROTOCOL_VERSION: u32 = 6;
|
pub const PROTOCOL_VERSION: u32 = 7;
|
||||||
|
|
||||||
pub use lanspread_db::db::Availability;
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct LibrarySnapshot {
|
pub struct LibrarySnapshot {
|
||||||
pub library_rev: u64,
|
pub library_rev: u64,
|
||||||
@@ -130,6 +140,7 @@ pub enum Response {
|
|||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
},
|
},
|
||||||
HelloAck(HelloAck),
|
HelloAck(HelloAck),
|
||||||
|
CallToPlayAck(CallToPlayAck),
|
||||||
GameNotFound(String),
|
GameNotFound(String),
|
||||||
InvalidRequest(Bytes, String),
|
InvalidRequest(Bytes, String),
|
||||||
EncodingError(String),
|
EncodingError(String),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Icon } from '../Icon';
|
import { Icon } from '../Icon';
|
||||||
|
import { activeCallCount } from '../../lib/callToPlay';
|
||||||
import { Nomination } from '../../lib/types';
|
import { Nomination } from '../../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -7,7 +8,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
|
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
|
||||||
const activeCount = nominations.filter(nomination => nomination.state !== 'started').length;
|
const activeCount = activeCallCount(nominations);
|
||||||
return (
|
return (
|
||||||
<button className="ctp-btn" onClick={onClick}>
|
<button className="ctp-btn" onClick={onClick}>
|
||||||
<Icon.flag />
|
<Icon.flag />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Modal } from '../Modal';
|
|||||||
import { CreateNominationForm } from './CreateNominationForm';
|
import { CreateNominationForm } from './CreateNominationForm';
|
||||||
import { NominationCard } from './NominationCard';
|
import { NominationCard } from './NominationCard';
|
||||||
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
||||||
|
import { sortNominations } from '../../lib/callToPlay';
|
||||||
import { Game, Nomination } from '../../lib/types';
|
import { Game, Nomination } from '../../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -36,10 +37,7 @@ export const CallToPlayOverlay = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const gameById = new Map(games.map(game => [game.id, game]));
|
const gameById = new Map(games.map(game => [game.id, game]));
|
||||||
const sorted = [...nominations].sort((left, right) =>
|
const sorted = sortNominations(nominations);
|
||||||
Number(left.state === 'started') - Number(right.state === 'started')
|
|
||||||
|| left.deadline - right.deadline
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose} className="ctp-modal">
|
<Modal onClose={onClose} className="ctp-modal">
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LABEL = {
|
const LABEL = {
|
||||||
|
running: 'Running',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
scheduled: 'Scheduled',
|
scheduled: 'Scheduled',
|
||||||
call: 'Call to Play',
|
call: 'Call to Play',
|
||||||
soon: 'Starting soon',
|
soon: 'Starting soon',
|
||||||
@@ -29,7 +31,7 @@ const LABEL = {
|
|||||||
expired: 'Time’s up',
|
expired: 'Time’s up',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type TickerStatus = Exclude<CallToPlayStatus, 'started'>;
|
type TickerStatus = CallToPlayStatus;
|
||||||
|
|
||||||
const RANK: Record<TickerStatus, number> = {
|
const RANK: Record<TickerStatus, number> = {
|
||||||
expired: 0,
|
expired: 0,
|
||||||
@@ -37,12 +39,12 @@ const RANK: Record<TickerStatus, number> = {
|
|||||||
soon: 2,
|
soon: 2,
|
||||||
call: 3,
|
call: 3,
|
||||||
scheduled: 3,
|
scheduled: 3,
|
||||||
|
running: 4,
|
||||||
|
cancelled: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus | null => {
|
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus =>
|
||||||
const status = statusOf(nomination, now);
|
statusOf(nomination, now);
|
||||||
return status === 'started' ? null : status;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
||||||
const entries = Object.entries(nomination.participants);
|
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) => {
|
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const gameById = new Map(games.map(game => [game.id, game]));
|
const gameById = new Map(games.map(game => [game.id, game]));
|
||||||
const active = nominations.flatMap(nomination => {
|
const rows = nominations.map(nomination => ({
|
||||||
const status = tickerStatusOf(nomination, now);
|
nomination,
|
||||||
return status === null ? [] : [{ nomination, status }];
|
status: tickerStatusOf(nomination, now),
|
||||||
}).sort((left, right) =>
|
})).sort((left, right) =>
|
||||||
RANK[left.status] - RANK[right.status]
|
RANK[left.status] - RANK[right.status]
|
||||||
|| left.nomination.deadline - right.nomination.deadline
|
|| left.nomination.deadline - right.nomination.deadline
|
||||||
);
|
);
|
||||||
if (active.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ctp-ticker-stack">
|
<div className="ctp-ticker-stack">
|
||||||
{active.map(({ nomination, status }) => {
|
{rows.map(({ nomination, status }) => {
|
||||||
const game = gameById.get(nomination.gameId);
|
const game = gameById.get(nomination.gameId);
|
||||||
const ready = readyCountOf(nomination, now);
|
const ready = readyCountOf(nomination, now);
|
||||||
const total = Object.keys(nomination.participants).length;
|
const total = Object.keys(nomination.participants).length;
|
||||||
const remaining = Math.max(0, nomination.deadline - now);
|
const remaining = Math.max(0, nomination.deadline - now);
|
||||||
const last = nomination.messages[nomination.messages.length - 1];
|
const last = nomination.messages[nomination.messages.length - 1];
|
||||||
const count = status === 'scheduled'
|
const count = status === 'running' || status === 'cancelled'
|
||||||
|
? `${total} players`
|
||||||
|
: status === 'scheduled'
|
||||||
? `${total} in`
|
? `${total} in`
|
||||||
: `${ready}/${nomination.maxPlayers} ready`;
|
: `${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'
|
? 'waiting to start'
|
||||||
: status === 'expired'
|
: status === 'expired'
|
||||||
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
|
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
formatUntil,
|
formatUntil,
|
||||||
inCountOf,
|
inCountOf,
|
||||||
isReady,
|
isReady,
|
||||||
|
isTerminal,
|
||||||
phaseOf,
|
phaseOf,
|
||||||
readyCountOf,
|
readyCountOf,
|
||||||
statusOf,
|
statusOf,
|
||||||
@@ -82,11 +83,13 @@ export const NominationCard = ({
|
|||||||
const isMe = myStatus !== undefined;
|
const isMe = myStatus !== undefined;
|
||||||
const isCreator = nomination.creatorId === actorId;
|
const isCreator = nomination.creatorId === actorId;
|
||||||
const isDone = nomination.state === 'done';
|
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 isExpired = statusOf(nomination, now) === 'expired';
|
||||||
const phase = phaseOf(nomination, now);
|
const phase = phaseOf(nomination, now);
|
||||||
const isScheduled = phase === 'scheduled' && !isDone && !isStarted && !isExpired;
|
const isScheduled = phase === 'scheduled' && !isDone && !terminal && !isExpired;
|
||||||
const isCheckin = phase === 'checkin' && !isDone && !isStarted && !isExpired;
|
const isCheckin = phase === 'checkin' && !isDone && !terminal && !isExpired;
|
||||||
const windowStart = nomination.scheduledFor === null
|
const windowStart = nomination.scheduledFor === null
|
||||||
? nomination.createdAt
|
? nomination.createdAt
|
||||||
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
||||||
@@ -101,8 +104,10 @@ export const NominationCard = ({
|
|||||||
: (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
|
: (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
|
||||||
const lanCount = Math.max(totalPeerCount + 1, installedCount);
|
const lanCount = Math.max(totalPeerCount + 1, installedCount);
|
||||||
|
|
||||||
const timer = isStarted
|
const timer = isRunning
|
||||||
? <div className="ctp-card-timer" data-urgency="off">Launching…</div>
|
? <div className="ctp-card-timer" data-urgency="off">Running</div>
|
||||||
|
: isCancelled
|
||||||
|
? <div className="ctp-card-timer" data-urgency="off">Cancelled</div>
|
||||||
: isExpired
|
: isExpired
|
||||||
? <div className="ctp-card-timer" data-urgency="high">Time’s up</div>
|
? <div className="ctp-card-timer" data-urgency="high">Time’s up</div>
|
||||||
: isDone
|
: isDone
|
||||||
@@ -123,7 +128,9 @@ export const NominationCard = ({
|
|||||||
)
|
)
|
||||||
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
|
: <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`
|
? `${entries.length} in · up to ${nomination.maxPlayers} players`
|
||||||
: isCheckin && inCount > 0
|
: isCheckin && inCount > 0
|
||||||
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
|
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
|
||||||
@@ -132,7 +139,7 @@ export const NominationCard = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
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-top">
|
||||||
<div className="ctp-card-cover">
|
<div className="ctp-card-cover">
|
||||||
@@ -168,10 +175,10 @@ export const NominationCard = ({
|
|||||||
<div
|
<div
|
||||||
className="ctp-progress-fill"
|
className="ctp-progress-fill"
|
||||||
style={{
|
style={{
|
||||||
width: `${isStarted || isDone ? 100 : percentage}%`,
|
width: `${terminal || isDone ? 100 : percentage}%`,
|
||||||
background: isExpired
|
background: isExpired || isCancelled
|
||||||
? 'var(--danger)'
|
? 'var(--danger)'
|
||||||
: isStarted || isDone
|
: terminal || isDone
|
||||||
? 'var(--ok)'
|
? 'var(--ok)'
|
||||||
: 'var(--accent)',
|
: 'var(--accent)',
|
||||||
}}
|
}}
|
||||||
@@ -211,11 +218,11 @@ export const NominationCard = ({
|
|||||||
<CtpChat
|
<CtpChat
|
||||||
nomination={nomination}
|
nomination={nomination}
|
||||||
actorId={actorId}
|
actorId={actorId}
|
||||||
disabled={isStarted}
|
disabled={terminal}
|
||||||
onSend={text => actions.sendMessage(nomination.id, text)}
|
onSend={text => actions.sendMessage(nomination.id, text)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isCreator && !isStarted && (
|
{isCreator && !terminal && (
|
||||||
confirmCancel
|
confirmCancel
|
||||||
? (
|
? (
|
||||||
<div className="ctp-cancel-confirm">
|
<div className="ctp-cancel-confirm">
|
||||||
@@ -283,15 +290,19 @@ const CardActions = ({
|
|||||||
const isMe = myStatus !== undefined;
|
const isMe = myStatus !== undefined;
|
||||||
const isCreator = nomination.creatorId === actorId;
|
const isCreator = nomination.creatorId === actorId;
|
||||||
const isDone = nomination.state === 'done';
|
const isDone = nomination.state === 'done';
|
||||||
const isStarted = nomination.state === 'started';
|
const terminal = isTerminal(nomination);
|
||||||
const isExpired = statusOf(nomination, now) === 'expired';
|
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);
|
const readyCount = readyCountOf(nomination, now);
|
||||||
|
|
||||||
if (isStarted) {
|
if (terminal) {
|
||||||
return (
|
return (
|
||||||
<div className="ctp-note ctp-note-launch">
|
<div className="ctp-note">
|
||||||
{game ? `Launching ${game.name}…` : 'The match is starting.'}
|
{nomination.state === 'running'
|
||||||
|
? game
|
||||||
|
? `${game.name} is running.`
|
||||||
|
: 'The match is running.'
|
||||||
|
: 'This call was cancelled.'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -331,8 +342,11 @@ const CardActions = ({
|
|||||||
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
||||||
if (accepted && game) onLaunch(game);
|
if (accepted && game) onLaunch(game);
|
||||||
})}
|
})}
|
||||||
><Icon.play /><span>{game ? 'Start now' : 'Mark as started'}</span></button>
|
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
||||||
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
|
<button
|
||||||
|
className="ghost-btn"
|
||||||
|
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
|
||||||
|
>
|
||||||
Add 5 more minutes
|
Add 5 more minutes
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
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';
|
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
|
||||||
|
|
||||||
export interface CallToPlayActions {
|
export interface CallToPlayActions {
|
||||||
@@ -18,7 +25,7 @@ export interface CallToPlayActions {
|
|||||||
leave: (callId: string) => void;
|
leave: (callId: string) => void;
|
||||||
cancel: (callId: string) => void;
|
cancel: (callId: string) => void;
|
||||||
startNow: (callId: string) => Promise<boolean>;
|
startNow: (callId: string) => Promise<boolean>;
|
||||||
addTime: (callId: string, minutes?: number) => void;
|
addTime: (callId: string, currentDeadline: number, minutes?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseCallToPlay {
|
export interface UseCallToPlay {
|
||||||
@@ -50,7 +57,11 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -66,6 +77,11 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
const ready = peerId !== null;
|
const ready = peerId !== null;
|
||||||
setActorId(peerId);
|
setActorId(peerId);
|
||||||
setTransportReady(ready);
|
setTransportReady(ready);
|
||||||
|
if (ready) {
|
||||||
|
setError(current =>
|
||||||
|
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
|
||||||
|
);
|
||||||
|
}
|
||||||
if (ready && retry !== undefined) {
|
if (ready && retry !== undefined) {
|
||||||
window.clearInterval(retry);
|
window.clearInterval(retry);
|
||||||
retry = undefined;
|
retry = undefined;
|
||||||
@@ -95,7 +111,10 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to register Call to Play listener:', 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> => {
|
): Promise<boolean> => {
|
||||||
if (actorId === null) {
|
if (actorId === null) {
|
||||||
setTransportReady(false);
|
setTransportReady(false);
|
||||||
setError('Call to Play needs an active LAN peer. Choose a game folder first.');
|
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const event = callToPlayEvent(callId, actorId, actor, action);
|
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 });
|
const accepted = await invoke<boolean>('publish_call_to_play', { event });
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
setTransportReady(false);
|
setTransportReady(false);
|
||||||
setError('Call to Play needs an active LAN peer. Choose a game folder first.');
|
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setTransportReady(true);
|
setTransportReady(true);
|
||||||
@@ -129,7 +148,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('publish_call_to_play failed:', err);
|
console.error('publish_call_to_play failed:', err);
|
||||||
setError('Could not send this Call to Play update.');
|
setError(callToPlayPublishErrorMessage(err));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [actor, actorId]);
|
}, [actor, actorId]);
|
||||||
@@ -167,8 +186,8 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
leave: callId => void publish(callId, 'Leave'),
|
leave: callId => void publish(callId, 'Leave'),
|
||||||
cancel: callId => void publish(callId, 'Cancel'),
|
cancel: callId => void publish(callId, 'Cancel'),
|
||||||
startNow: callId => publish(callId, 'Start'),
|
startNow: callId => publish(callId, 'Start'),
|
||||||
addTime: (callId, minutes = 5) => void publish(callId, {
|
addTime: (callId, currentDeadline, minutes = 5) => void publish(callId, {
|
||||||
AddTime: { deadline: Date.now() + minutes * 60_000 },
|
AddTime: { deadline: extendDeadline(Date.now(), currentDeadline, minutes) },
|
||||||
}),
|
}),
|
||||||
}), [publish]);
|
}), [publish]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,41 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const CHECKIN_LEAD_MS = 15 * 60_000;
|
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 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 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 {
|
interface MutableNomination extends Nomination {
|
||||||
cancelled: boolean;
|
|
||||||
messageIds: Set<string>;
|
messageIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +84,30 @@ export const inCountOf = (nomination: Nomination, now: number): number =>
|
|||||||
!isReady(participant, now) && participant.status === 'in'
|
!isReady(participant, now) && participant.status === 'in'
|
||||||
).length;
|
).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 => {
|
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 (now >= nomination.deadline) return 'expired';
|
||||||
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
|
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
|
||||||
return 'ready';
|
return 'ready';
|
||||||
@@ -71,6 +120,39 @@ export const reduceCallToPlayEvents = (
|
|||||||
input: ReadonlyArray<CallToPlayEvent>,
|
input: ReadonlyArray<CallToPlayEvent>,
|
||||||
now: number,
|
now: number,
|
||||||
): Nomination[] => {
|
): 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 unique = new Map(input.map(event => [event.id, event]));
|
||||||
const byCall = new Map<string, CallToPlayEvent[]>();
|
const byCall = new Map<string, CallToPlayEvent[]>();
|
||||||
for (const event of unique.values()) {
|
for (const event of unique.values()) {
|
||||||
@@ -78,14 +160,18 @@ export const reduceCallToPlayEvents = (
|
|||||||
events.push(event);
|
events.push(event);
|
||||||
byCall.set(event.call_id, events);
|
byCall.set(event.call_id, events);
|
||||||
}
|
}
|
||||||
|
return byCall;
|
||||||
|
};
|
||||||
|
|
||||||
const nominations: Nomination[] = [];
|
const deriveNomination = (
|
||||||
for (const events of byCall.values()) {
|
events: CallToPlayEvent[],
|
||||||
|
now: number,
|
||||||
|
): Nomination | null => {
|
||||||
events.sort(compareEvents);
|
events.sort(compareEvents);
|
||||||
const create = events.find(event => createPayload(event.action) !== null);
|
const create = events.find(event => createPayload(event.action) !== null);
|
||||||
if (!create) continue;
|
if (!create) return null;
|
||||||
const payload = createPayload(create.action);
|
const payload = createPayload(create.action);
|
||||||
if (!payload) continue;
|
if (!payload) return null;
|
||||||
|
|
||||||
const nomination: MutableNomination = {
|
const nomination: MutableNomination = {
|
||||||
id: create.call_id,
|
id: create.call_id,
|
||||||
@@ -105,40 +191,32 @@ export const reduceCallToPlayEvents = (
|
|||||||
},
|
},
|
||||||
messages: [],
|
messages: [],
|
||||||
state: 'open',
|
state: 'open',
|
||||||
cancelled: false,
|
terminalAt: null,
|
||||||
messageIds: new Set(),
|
messageIds: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (compareEvents(event, create) <= 0 || nomination.cancelled) continue;
|
if (compareEvents(event, create) > 0) applyEvent(nomination, event);
|
||||||
applyEvent(nomination, event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nomination.cancelled) continue;
|
|
||||||
if (nomination.state === 'open'
|
if (nomination.state === 'open'
|
||||||
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
||||||
) {
|
) {
|
||||||
nomination.state = 'done';
|
nomination.state = 'done';
|
||||||
}
|
}
|
||||||
if (nomination.state === 'started'
|
if (isTerminal(nomination)) {
|
||||||
&& now - (nomination.startedAt ?? now) > STARTED_RETENTION_MS
|
if (now - (nomination.terminalAt ?? now) > TERMINAL_RETENTION_MS) return null;
|
||||||
) {
|
} else if (now - nomination.deadline > EXPIRED_RETENTION_MS) {
|
||||||
continue;
|
return null;
|
||||||
}
|
|
||||||
if (nomination.state !== 'started'
|
|
||||||
&& now - nomination.deadline > EXPIRED_RETENTION_MS
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cancelled: _, messageIds: __, ...result } = nomination;
|
const { messageIds: _, ...result } = nomination;
|
||||||
nominations.push(result);
|
return result;
|
||||||
}
|
|
||||||
|
|
||||||
return nominations.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
|
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
|
||||||
|
if (isTerminal(nomination)) return;
|
||||||
|
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
if (typeof action === 'string') {
|
if (typeof action === 'string') {
|
||||||
applyUnitAction(nomination, event, action);
|
applyUnitAction(nomination, event, action);
|
||||||
@@ -147,7 +225,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
|
|
||||||
const response = respondPayload(action);
|
const response = respondPayload(action);
|
||||||
if (response) {
|
if (response) {
|
||||||
if (nomination.state === 'started') return;
|
|
||||||
const existing = nomination.participants[event.actor_id];
|
const existing = nomination.participants[event.actor_id];
|
||||||
nomination.participants[event.actor_id] = {
|
nomination.participants[event.actor_id] = {
|
||||||
name: event.actor_name,
|
name: event.actor_name,
|
||||||
@@ -159,7 +236,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = messagePayload(action);
|
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.messageIds.add(message.message_id);
|
||||||
nomination.messages.push({
|
nomination.messages.push({
|
||||||
id: message.message_id,
|
id: message.message_id,
|
||||||
@@ -175,7 +252,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
const extension = addTimePayload(action);
|
const extension = addTimePayload(action);
|
||||||
if (extension
|
if (extension
|
||||||
&& event.actor_id === nomination.creatorId
|
&& event.actor_id === nomination.creatorId
|
||||||
&& nomination.state !== 'started'
|
|
||||||
) {
|
) {
|
||||||
nomination.deadline = extension.deadline;
|
nomination.deadline = extension.deadline;
|
||||||
nomination.state = 'open';
|
nomination.state = 'open';
|
||||||
@@ -189,7 +265,6 @@ const applyUnitAction = (
|
|||||||
): void => {
|
): void => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'Rsvp': {
|
case 'Rsvp': {
|
||||||
if (nomination.state === 'started') return;
|
|
||||||
const existing = nomination.participants[event.actor_id];
|
const existing = nomination.participants[event.actor_id];
|
||||||
nomination.participants[event.actor_id] = {
|
nomination.participants[event.actor_id] = {
|
||||||
name: event.actor_name,
|
name: event.actor_name,
|
||||||
@@ -199,19 +274,20 @@ const applyUnitAction = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'Leave':
|
case 'Leave':
|
||||||
if (event.actor_id !== nomination.creatorId && nomination.state !== 'started') {
|
if (event.actor_id !== nomination.creatorId) {
|
||||||
delete nomination.participants[event.actor_id];
|
delete nomination.participants[event.actor_id];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Cancel':
|
case 'Cancel':
|
||||||
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
if (event.actor_id === nomination.creatorId) {
|
||||||
nomination.cancelled = true;
|
nomination.state = 'cancelled';
|
||||||
|
nomination.terminalAt = event.at;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Start':
|
case 'Start':
|
||||||
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
if (event.actor_id === nomination.creatorId) {
|
||||||
nomination.state = 'started';
|
nomination.state = 'running';
|
||||||
nomination.startedAt = event.at;
|
nomination.terminalAt = event.at;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ export interface Nomination {
|
|||||||
deadline: number;
|
deadline: number;
|
||||||
participants: Record<string, CallToPlayParticipant>;
|
participants: Record<string, CallToPlayParticipant>;
|
||||||
messages: CallToPlayMessage[];
|
messages: CallToPlayMessage[];
|
||||||
state: 'open' | 'done' | 'started';
|
state: 'open' | 'done' | 'running' | 'cancelled';
|
||||||
startedAt?: number;
|
terminalAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallToPlayAction =
|
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-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-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; }
|
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
|
||||||
@keyframes ctp-cardflash {
|
@keyframes ctp-cardflash {
|
||||||
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
|
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:
|
/* ─── Quick-bar status variants — LED + label + row tint per status:
|
||||||
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
||||||
STARTING SOON (amber, glowing) · READY (green, steady) ·
|
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"] {
|
.ctp-ticker[data-status="scheduled"] {
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
border-color: var(--bd-2);
|
border-color: var(--bd-2);
|
||||||
@@ -2240,10 +2242,22 @@
|
|||||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
|
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="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="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="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="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="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 {
|
@keyframes ctp-tickerpulse-warn {
|
||||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
|
||||||
70% { box-shadow: 0 0 0 6px 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="soon"] { color: var(--warn); }
|
||||||
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
|
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
|
||||||
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
|
.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="soon"] .ctp-ticker-cta { color: var(--warn); }
|
||||||
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
|
.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="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 ─── */
|
/* ─── Quick-bar inline chat preview ─── */
|
||||||
.ctp-ticker-chat {
|
.ctp-ticker-chat {
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
|
CALL_TO_PLAY_CONNECTING_MESSAGE,
|
||||||
CHECKIN_LEAD_MS,
|
CHECKIN_LEAD_MS,
|
||||||
EXPIRED_RETENTION_MS,
|
EXPIRED_RETENTION_MS,
|
||||||
|
TERMINAL_RETENTION_MS,
|
||||||
|
activeCallCount,
|
||||||
|
callToPlayPublishErrorMessage,
|
||||||
|
extendDeadline,
|
||||||
phaseOf,
|
phaseOf,
|
||||||
bumpTime,
|
bumpTime,
|
||||||
normalizeTimeInput,
|
normalizeTimeInput,
|
||||||
|
pruneCallToPlayEvents,
|
||||||
readyCountOf,
|
readyCountOf,
|
||||||
reduceCallToPlayEvents,
|
reduceCallToPlayEvents,
|
||||||
|
sortNominations,
|
||||||
statusOf,
|
statusOf,
|
||||||
} from '../src/lib/callToPlay.ts';
|
} from '../src/lib/callToPlay.ts';
|
||||||
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.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(),
|
create(),
|
||||||
event('start', 'Alice', 'Start', NOW + 1),
|
event('start', 'Alice', 'Start', NOW + 1),
|
||||||
], NOW + 2)[0];
|
], 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(),
|
create(),
|
||||||
event('cancel', 'Alice', 'Cancel', NOW + 1),
|
event('cancel', 'Alice', 'Cancel', NOW + 1),
|
||||||
], NOW + 2);
|
], 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', () => {
|
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');
|
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('started calls expire from local presentation history', () => {
|
Deno.test('terminal calls retain complete read-only history for fifteen minutes', () => {
|
||||||
const events = [create(), event('start', 'Alice', 'Start', NOW + 1)];
|
const events = [
|
||||||
assertEquals(reduceCallToPlayEvents(events, NOW + 2).length, 1, 'fresh started call remains');
|
create(),
|
||||||
assertEquals(reduceCallToPlayEvents(events, NOW + 5_000).length, 0, 'old started call expires');
|
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', () => {
|
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):**
|
- **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.
|
- **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.
|
- **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.
|
**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
|
### Three surfaces
|
||||||
|
|
||||||
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
|
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
|
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
|
||||||
stack rendered at the top of the grid area, **one row per active call**.
|
stack rendered at the top of the grid area, **one row per active call**.
|
||||||
Sorted **ready → starting-soon → the rest**, ties broken by whichever
|
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.
|
then by `deadline`). Clicking a row opens the overlay focused on that call.
|
||||||
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
|
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
|
||||||
the other dialogs) with a header, a **Call a new match** button, the create
|
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
|
### Status model
|
||||||
|
|
||||||
@@ -482,14 +483,16 @@ Two derived values drive everything (`calltoplay.jsx`):
|
|||||||
|
|
||||||
- `phaseOf(call)` → `'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
|
- `phaseOf(call)` → `'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
|
||||||
`'checkin'` (within the 15-min lead).
|
`'checkin'` (within the 15-min lead).
|
||||||
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'expired'`
|
- `statusOf(call)` (quick-bar/label status) → `'running'` · `'cancelled'` ·
|
||||||
(deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or state `done`) ·
|
`'expired'` (deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or
|
||||||
`'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a clock time) ·
|
state `done`) · `'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a
|
||||||
`'call'` (a plain play-now call).
|
clock time) · `'call'` (a plain play-now call).
|
||||||
|
|
||||||
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
|
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
|
||||||
SOON**, **READY**, **TIME'S UP** (`TICKER_LABEL`), each with its own dot color via
|
SOON**, **READY**, **TIME'S UP**, **RUNNING**, **CANCELLED** (`TICKER_LABEL`),
|
||||||
`.ctp-ticker-dot[data-status]`.
|
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`
|
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
|
||||||
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
|
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:
|
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).
|
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.
|
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`.
|
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.
|
6. **Chat** — the collapsible per-call chat panel.
|
||||||
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
|
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`)
|
### Create form (`CreateNominationForm`)
|
||||||
|
|
||||||
Game search (typeahead over the catalog) → on pick, max-players defaults to the
|
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
|
readyAt?: number; // ms epoch a 'pending' buffer elapses
|
||||||
}>;
|
}>;
|
||||||
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
|
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
|
||||||
state: 'open' | 'done' | 'started';
|
state: 'open' | 'done' | 'running' | 'cancelled';
|
||||||
startedAt?: number;
|
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
|
is an immutable, uniquely identified event. Connected peers receive new events
|
||||||
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
|
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
|
||||||
history so a late joiner reconstructs every event and chat message for active
|
history so a late joiner reconstructs every event and chat message for active
|
||||||
calls. Started and cancelled calls compact to terminal tombstones; expired
|
calls. Running and Cancelled calls retain their complete history for 15 minutes
|
||||||
calls are removed after the five-minute grace period. The frontend reducer
|
so late joiners can see the outcome, roster, and chat, then compact to a Start
|
||||||
turns that event history into the `Nomination` state above and derives
|
or Cancel tombstone for the rest of the peer session. Unresolved calls are
|
||||||
time-based phase changes locally. Stable peer IDs identify actors and enforce
|
removed after the five-minute post-deadline recovery period. The frontend
|
||||||
creator controls; `settings.username` is only the display name.
|
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