Compare commits

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

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

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

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

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

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

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

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

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

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

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

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `git diff --cached --check` -- passed
2026-07-23 17:44:03 +02:00
ddidderr 872692e3f4 plan 2026-07-23 17:07:32 +02:00
ddidderr e141229805 fable5findings 2026-07-23 13:06:21 +02:00
ddidderr d58307c328 fix(call-to-play): compact expired snapshots
Expired call histories were removed on the next store insertion, so an otherwise
idle peer could continue carrying stale payload through handshakes after the
five-minute UI retention ended.

Run the same inactive-call compaction before local and handshake snapshots. This
makes the expiry boundary exact without trimming any event from an active call.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `git diff --cached --check` -- passed
2026-07-21 22:59:48 +02:00
ddidderr 383e8f4855 fix(call-to-play): resync after live delivery failure
A failed fire-and-forget event left one peer's active call state divergent until
some later discovery or reconnect happened. During a LAN party that could leave
different players looking at different rosters or chat.

Fall back to the existing bidirectional handshake whenever a live Call to Play
send fails. Its active-history exchange heals both sides immediately when the
failure was transient. Document the related wall-clock synchronization
assumption for deadline and countdown presentation.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
2026-07-21 22:58:19 +02:00
ddidderr cc7dacf6c3 fix(call-to-play): expire elapsed calls clearly
Deadline completion previously shared the green Ready presentation with a full
roster and remained visible forever. An abandoned call therefore looked ready
to launch and required its creator to return and cancel it.

Give elapsed calls a distinct Time's up state and a five-minute grace period in
which the creator can start or extend them. After that, both the reducer and
peer store remove the call as a unit. Filled calls remain ready until their
deadline, and active calls continue to retain complete history for late joiners.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `just frontend-test` -- passed, 22 tests
- `just build` -- passed
- `git diff --cached --check` -- passed
2026-07-21 22:56:02 +02:00
ddidderr 640d81d919 fix(call-to-play): explain unavailable game calls
Calls for a game missing from the local catalog still contributed to the badge
but were discarded by both the ticker and overlay. Opening the badge could
therefore show a blank modal with no explanation.

Render those calls with an unavailable-game label, the sender and game ID, the
normal roster and chat, and safe coordination actions. The creator can mark the
match started, but automatic launch remains disabled until catalog data exists.

Test Plan:
- `just fmt` -- passed
- `just frontend-test` -- passed, 21 tests
- `just build` -- passed
- `git diff --cached --check` -- passed
2026-07-21 22:50:09 +02:00
ddidderr 4b7725db16 fix(call-to-play): compact terminal histories
The 4,096-event store retained every completed call forever and local commands
only reported that they reached the queue. Once the bound was reached, GUI
actions could therefore fail with no user-visible result. The CLI snapshot wait
could also be satisfied by an unrelated live event.

Keep the complete event and chat history for every active call so late joiners
receive full context. When the creator starts or cancels a call, replace its
history with a single terminal tombstone; this bounds retained payload while
still healing peers that missed the live terminal action. Publish commands now
reply with the actual store result, and CLI snapshots use a direct reply.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, including full-cap terminal compaction
- `just build` -- passed
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
2026-07-21 22:48:19 +02:00
ddidderr 29eacabcc0 fix(call-to-play): key actors by stable peer identity
Participant maps and creator authorization previously used display names, so
two peers left at the default Commander name collapsed into one participant
and could exercise each other's creator controls through the normal client.

Carry a stable actor_id separately from actor_name. The peer overwrites actor_id
on every local publish, and live event envelopes are accepted only when the
known peer, source, and event actor match. The frontend keys participants and
authorization by actor_id while retaining actor_name for display. This follows
the trusted-LAN model and is not cryptographic authentication against a hostile
peer.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 21 tests
- `just build` -- passed
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
2026-07-21 22:40:59 +02:00
ddidderr 0f53bc4b78 feat(call-to-play)!: coordinate game sessions across peers
Implement the launcher design as a production peer-to-peer feature. Call to
Play actions are immutable, validated events broadcast over the existing QUIC
control channel, deduplicated in a bounded in-memory history, and exchanged in
Hello/HelloAck so late joiners reconstruct current calls.

Add the Tauri bridge and modular launcher surfaces for play-now and scheduled
calls, check-in, readiness buffers, role-aware controls, chat, tickers, and
actual caller launch. A deterministic frontend reducer derives presentation
state from replicated history. Extend the JSONL peer harness with publish/list
commands and a three-peer live-delivery and late-join scenario.

This intentionally raises the only supported wire protocol from version 5 to
version 6; older builds are not supported. Document the transport architecture
and exclude generated peer-test state from Docker build contexts.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 20 tests
- `just build` -- passed
- `just peer-cli-tests S2 S48` -- passed
- `git diff --cached --check` -- passed
2026-07-21 22:30:11 +02:00
33 changed files with 4753 additions and 47 deletions
+1
View File
@@ -1,6 +1,7 @@
.git
.agents
.codex
.lanspread-peer-cli
target
**/target
**/node_modules
+152
View File
@@ -0,0 +1,152 @@
# Finish Call to Plays replication seam
## Summary
Keep the existing architecture: immutable events, deterministic reduction, live delivery, and handshake history are appropriate for a LAN party. Do not replace it with owner-authoritative state, consensus, persistent storage, or cryptographic peer identities.
The focused redesign is the delivery/merge/store seam:
- Merge histories atomically.
- Acknowledge live deliveries.
- Preserve complete histories while users can still see a call.
- Make terminal outcomes understandable.
- Never report an action as accepted when it was immediately discarded.
## User-visible lifecycle
| State | Meaning | Visibility | Available actions |
|---|---|---:|---|
| Open / Ready | Call is still coordinating players | Until deadline | Existing participation, chat, creator controls |
| Times up | Deadline elapsed without a Start or Cancel | 5 minutes | Creator may Start, Add time, or Cancel; history remains complete |
| Running | Creator emitted Start; this is a successful outcome | 15 minutes after Start | Read-only card, roster, and complete chat |
| Cancelled | Creator emitted Cancel | 15 minutes after Cancel | Read-only card, roster, and complete chat |
| Retired | Display period ended | Hidden | None |
“Times up” and “Running” are meaningfully different: a timed-out call is unresolved and recoverable, while Running is a final success receipt. Deadline passage alone never means the game started.
Running and Cancelled rows remain in the ticker and overlay, sorted after actionable calls. They do not increase the top-bar badge. Their chat history remains readable, but all composers and controls are disabled.
## Interface and invariant changes
- Raise `PROTOCOL_VERSION` from 6 to 7; no compatibility path.
- Add `Response::CallToPlayAck` with outcomes equivalent to:
- `Applied`
- `Duplicate`
- `NeedHandshake`
- `NeedHistory`
- `Obsolete`
- `Rejected(reason)`
- Replace per-event insertion with one atomic `merge_batch(events, now)` operation returning retained UI events, duplicate/obsolete counts, and missing-history information.
- Change frontend nomination state to represent `running` and `cancelled`, with a `terminalAt` timestamp.
- Change `addTime` to receive the current effective deadline and calculate `max(now, deadline) + duration`.
- Preserve these invariants:
- Every visible call has its entire event and chat history.
- Handshake batches are evaluated as a whole, regardless of event order.
- Missing-root actions are not retained alone; they request history.
- Event IDs correspond only to retained events.
- Terminal tombstones prevent stale histories from resurrecting finished calls.
- Local acceptance is immediate user success; remote delivery is acknowledged and healed asynchronously.
## Commit sequence
1. `refactor(call-to-play): merge histories atomically`
- Validate and deduplicate the complete incoming batch before changing the store.
- Treat an existing ID with different contents as a conflict and reject the batch.
- Evaluate compaction once after all batch events are present, fixing the quadratic handshake path.
- Commit the candidate store only when capacity and validation succeed.
- Rebuild event IDs from retained events instead of preserving every historical ID.
- Return `NeedHistory` without storing an action when neither the store nor batch contains its Create event.
- Permit a full `Create + AddTime` history to revive a call atomically.
- Mark an event as applied only when it survives compaction; obsolete events are neither broadcast nor emitted to the UI.
- Keep the 4,096-event safety cap for unresolved histories, but always permit Start and Cancel. Recently terminal histories and compact tombstones must not prevent new active calls.
- Preserve full open/ready/Times-up histories; never evict individual chat or participant events.
2. `fix(call-to-play): acknowledge live replication`
- Turn the live Call to Play request into a request/response exchange returning `CallToPlayAck`.
- Remove source-IP-versus-advertised-IP equality checks.
- Under the selected trusted-LAN model, require:
- the envelope peer ID to exist in the known peer roster;
- every live events actor ID to match that envelope peer ID;
- local peer-core publication to continue stamping its own actor ID.
- Return `NeedHandshake` for unknown peers and `NeedHistory` for missing call roots.
- On transport failure, malformed response, `NeedHandshake`, or `NeedHistory`, perform one full Hello/HelloAck resync.
- Treat Applied and Duplicate as delivered, Obsolete as finished, and Rejected as a logged non-retriable error.
- Keep publication locally successful without waiting for every peer, so an offline machine cannot block a LAN-party action.
- Document that shared TLS plus stable peer IDs prevent accidental identity mixing but are not hostile-peer authentication.
3. `feat(call-to-play): retain terminal outcomes`
- Preserve complete Running and Cancelled histories in backend snapshots for 15 minutes so late joiners receive the card, roster, and chat.
- After 15 minutes, compact each terminal call to its Start or Cancel tombstone for the remainder of the peer session.
- Continue deleting unresolved Times-up histories after their separate five-minute recovery window, including their event IDs.
- Derive and render Running and Cancelled frontend states instead of immediately removing them.
- Show their ticker/card status, retain unknown-game degraded rendering, sort them last, and exclude them from the badge.
- Prune the frontends raw event map after the corresponding display window so a long GUI session does not accumulate invisible history.
- Update the feature specification and architecture documentation with these lifecycle and clock-skew assumptions.
4. `fix(call-to-play): extend from the current deadline`
- Calculate extensions as `max(Date.now(), nomination.deadline) + five minutes`.
- Preserve the existing behavior that an overdue call gets five minutes from now.
- Ensure extending a call that became Ready early adds time instead of shortening its remaining deadline.
- Keep terminal Running and Cancelled calls non-extendable.
5. `fix(call-to-play): explain peer startup state`
- Replace the game-folder advice shown while `actorId` is unavailable with: “Call to Play is still connecting to the LAN. Try again in a moment.”
- Do not mark transport unavailable for store-level errors.
- Surface distinct messages for:
- expired/obsolete call;
- full active history;
- peer startup;
- unexpected update failure.
## Test and acceptance plan
- Store unit tests:
- `Create + AddTime` succeeds in every input order.
- An expired receiver returns NeedHistory for orphan AddTime, then revives after receiving full history.
- IDs removed with expired histories do not block revival.
- Stale events against terminal tombstones remain obsolete.
- Accepted results contain only retained events.
- Invalid/conflicting batches leave the store unchanged.
- Batch merge compacts once rather than once per event.
- Start and Cancel remain possible at the active-history cap.
- Visible calls always retain every chat event.
- Transport tests:
- A known peer is accepted when transport and advertised IPs differ.
- Unknown peer IDs and mismatched actor IDs receive explicit acknowledgements.
- NeedHistory, NeedHandshake, and lost acknowledgements trigger idempotent handshake healing.
- Duplicate delivery is harmless.
- Frontend tests:
- Ready, Times up, Running, Cancelled, and retired transitions.
- Full terminal history remains for 15 minutes and then disappears.
- Terminal calls appear in ticker/overlay but not the badge.
- Terminal controls and chat composer are disabled.
- Both Add-time deadline cases.
- Correct startup and store-error messages.
- Peer CLI:
- Keep S48 as the active-call/full-history late-join acceptance test.
- Add S49 covering a terminal call whose roster and chat are reconstructed by a late joiner.
- Fix any snapshot waiting through the direct reply path rather than observing unrelated generations.
- Final verification:
- `just fmt`
- `just clippy`
- `just test`
- `just frontend-test`
- `just build`
- `just peer-cli-tests S48 S49`
- `git diff --check`
## Assumptions
- The LAN is cooperative; deliberate peer-ID impersonation is outside scope.
- Wall clocks are assumed reasonably close. Atomic history revival tolerates boundary skew but does not attempt clock synchronization.
- Call to Play state remains transient and disappears when the peer process/session ends.
- The existing `FABLE_5_FINDINGS.md` commit remains untouched; all implementation work is added as focused forward commits.
+107
View File
@@ -0,0 +1,107 @@
# Fable 5 findings
Review of the issues Fable found after the Call to Play follow-up fixes. This
document records the assessment only; it is not an implementation plan.
## 1. IP-based actor verification can reject legitimate peers
**Assessment: real and must-fix.**
`handle_call_to_play_events` requires the QUIC connection's source IP to match
the peer's advertised listener IP. That assumption is unreliable for a
multi-homed LAN machine: routing may select Ethernet, Wi-Fi, a VPN, or a bridge
address different from the advertised address. The receiver silently drops the
event, while the one-way sender sees a successful write and does not invoke its
resync fallback.
The check is not strong authentication either. Two peers on one host share an
IP, and the project currently uses a shared application TLS certificate rather
than a cryptographic identity unique to each `peer_id`. A normal client still
stamps its own peer ID, so same-host impersonation requires a modified client,
but the IP comparison should not be presented as an authenticated identity
boundary.
A sound correction must either bind a peer ID to a connection through a
same-connection protocol exchange, return an application-level
acknowledgement/rejection that can trigger resync, or deliberately rely on the
documented trusted-LAN model without the unreliable IP equality test.
## 2. Evicted event IDs can prevent a revived call from healing
**Assessment: real and must-fix together with finding 5.**
Expiry removes call events from `events` but leaves their IDs in `event_ids`.
If one peer expires a call and later receives an orphan `AddTime`, a subsequent
handshake cannot restore the original `Create`: it is rejected forever as a
duplicate. The call can therefore be alive on its creator while remaining
invisible on the other peer. The ID set also grows without a bound for the
session.
Pruning expired IDs alone is not sufficient with the current `insert_all`
behavior. A handshake commonly supplies `Create` followed by later actions;
per-event compaction can expire and remove `Create` before the merge reaches
the extending `AddTime`. Healing requires atomic batch semantics: validate and
deduplicate the batch, merge it with retained events, then compact once using
the complete history.
Retained IDs should correspond to retained events and deliberate terminal
tombstones, not every event ever observed.
## 3. `insert` can accept an event that compaction immediately removes
**Assessment: correct, lower-severity correctness/semantics issue.**
An action targeting a call that expired more than five minutes ago can be
inserted, removed by compaction in the same operation, and still return
`Ok(true)`. It is then emitted to the local UI and broadcast to peers even
though it has no durable effect.
The presentation reducer normally hides the result, but the accepted signal is
misleading and the work is wasted. Store results should distinguish retained,
duplicate, rejected, and immediately obsolete events, and only retained events
should be emitted or broadcast.
## 4. "Add 5 more minutes" can shorten the deadline
**Assessment: real user-visible bug.**
The action currently sets the deadline to `now + 5 minutes`. If a roster fills
early while the call still has more than five minutes remaining, the button
shortens the call despite saying that it adds time.
The intended calculation is `max(now, current deadline) + 5 minutes`. That also
behaves naturally for an already expired call.
## 5. Handshake history merge is quadratic
**Assessment: correct, and part of the correctness fix for finding 2 rather
than merely a performance nit.**
`insert_all` calls `insert` for every incoming event, and each insertion rebuilds
several maps over the growing store while holding its write lock. Merging a
large handshake history is therefore O(n²).
Compacting once after an atomic batch merge removes that cost and is also what
allows `Create` plus a later `AddTime` to revive consistently. Findings 2 and 5
should be treated as one store-semantics problem.
## 6. The startup error points users at the wrong cause
**Assessment: correct UX issue.**
When `actorId` is still unavailable, the peer may simply be starting. Telling
the user to choose a game folder is misleading unless the application actually
knows that the folder is missing. The normal startup state should say that Call
to Play is connecting and ask the user to try again shortly; folder guidance
should be reserved for a known missing-folder condition.
## Priority
1. Replace the unreliable IP identity check or make delivery acknowledged.
2. Correct batch merge, compaction, and event-ID retention as one change.
3. Correct the deadline-extension calculation.
4. Stop reporting and broadcasting immediately obsolete events.
5. Correct the startup message.
The existing QUIC connection must not be described as carrying an authenticated
per-peer identity until the protocol actually establishes one.
+9
View File
@@ -55,6 +55,7 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
| S45 | Sender disconnect during streamed install | A source serves large catalog-version `alienswarm`; after the client receives the first streamed chunk, the source container is killed. | The operation reaches a terminal failure/peers-gone event, emits no download/install success, clears active operations, and rolls back local/staging state. |
| S46 | Receiver cancel during streamed install | A client starts streaming large catalog-version `alienswarm`, receives the first chunk, then sends `cancel-download alienswarm`. | The receiver cancels without emitting download/install success or a user-visible download failure, clears active operations, and rolls back local/staging state. |
| S47 | Multi-archive streamed install order | A source serves `fixture-multi/cnctw` with two root `.eti` archives named to require sorted processing. | Streamed chunk paths arrive in root archive sort order, both payloads install under `local/`, the receiver is local-only installed, and no root archives or sentinel are committed. |
| S48 | Call to Play replication and late join | Alice and Bob connect; Alice publishes a call, then Bob publishes an RSVP and chat message. Charlie joins afterward and handshakes with Alice. | Alice and Bob receive the live events, while Charlie reconstructs the same three-event history during handshake with no duplicate event IDs. |
## Version-Skew Contract
@@ -143,6 +144,14 @@ Use S39-S41 to pin down low-disk streamed installs:
## Run Log
### 2026-07-21 - Call to Play Transport (S48)
- Added JSONL commands to publish and inspect Call to Play events.
- S2 passed against the rebuilt image, preserving bidirectional library
exchange after the protocol version bump.
- S48 passed against the rebuilt image: create, RSVP, and chat propagated live,
then a late third peer received the same deduplicated history in handshake.
### 2026-06-21 - Test-Suite Integrity Audit And Hardening
- An adversarial review of `run_extended_scenarios.py` found assertions that
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Run the peer-cli scenarios S1-S47 through Docker."""
"""Run the peer-cli scenarios S1-S49 through Docker."""
from __future__ import annotations
@@ -242,6 +242,12 @@ class Peer:
def status(self) -> dict[str, Any]:
return self.send({"cmd": "status"})["data"]
def call_to_play_events(self) -> list[dict[str, Any]]:
return self.send({"cmd": "list-call-to-play"})["data"]["events"]
def publish_call_to_play(self, event: dict[str, Any]) -> None:
self.send({"cmd": "publish-call-to-play", "event": event})
def connect_to(self, other: "Peer") -> None:
if other.ready_addr is None:
raise ScenarioError(f"{other.name} is not ready")
@@ -350,6 +356,8 @@ class Runner:
("S45", self.s45_sender_disconnect_mid_stream),
("S46", self.s46_receiver_cancel_mid_stream),
("S47", self.s47_multi_archive_streams_in_sorted_order),
("S48", self.s48_call_to_play_replication_and_late_join),
("S49", self.s49_terminal_call_to_play_late_join),
]
for scenario_id, scenario in scenarios:
@@ -1757,6 +1765,139 @@ class Runner:
return f"multi-archive cnctw streamed in sorted order: {chunk_paths}"
def s48_call_to_play_replication_and_late_join(self) -> str:
alice = self.peer("s48-alice")
bob = self.peer("s48-bob")
bob.connect_to(alice)
now = int(time.time() * 1000)
create = {
"id": "s48-create",
"call_id": "s48-call",
"actor_id": "",
"actor_name": "Alice",
"at": now,
"action": {
"Create": {
"game_id": "cnctw",
"max_players": 4,
"scheduled_for": None,
"deadline": now + 600_000,
}
},
}
rsvp = {
"id": "s48-rsvp",
"call_id": "s48-call",
"actor_id": "",
"actor_name": "Bob",
"at": now + 1,
"action": "Rsvp",
}
message = {
"id": "s48-message-event",
"call_id": "s48-call",
"actor_id": "",
"actor_name": "Bob",
"at": now + 2,
"action": {
"SendMessage": {
"message_id": "s48-message",
"text": "I am in",
}
},
}
alice.publish_call_to_play(create)
wait_call_to_play_events(bob, {"s48-create"})
bob.publish_call_to_play(rsvp)
bob.publish_call_to_play(message)
wait_call_to_play_events(alice, {"s48-create", "s48-rsvp", "s48-message-event"})
charlie = self.peer("s48-charlie")
charlie.connect_to(alice)
events = wait_call_to_play_events(
charlie,
{"s48-create", "s48-rsvp", "s48-message-event"},
)
if len(events) != 3:
raise ScenarioError(f"late join history contains duplicates: {events}")
return "live create/RSVP/chat replicated and a late joiner received deduplicated history"
def s49_terminal_call_to_play_late_join(self) -> str:
alice = self.peer("s49-alice")
bob = self.peer("s49-bob")
bob.connect_to(alice)
now = int(time.time() * 1000)
create = {
"id": "s49-create",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Alice",
"at": now,
"action": {
"Create": {
"game_id": "cnctw",
"max_players": 4,
"scheduled_for": None,
"deadline": now + 600_000,
}
},
}
ready = {
"id": "s49-ready",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Bob",
"at": now + 1,
"action": {"Respond": {"ready_at": None}},
}
message = {
"id": "s49-message-event",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Bob",
"at": now + 2,
"action": {
"SendMessage": {
"message_id": "s49-message",
"text": "Ready to launch",
}
},
}
start = {
"id": "s49-start",
"call_id": "s49-call",
"actor_id": "",
"actor_name": "Alice",
"at": now + 3,
"action": "Start",
}
alice.publish_call_to_play(create)
wait_call_to_play_events(bob, {"s49-create"})
bob.publish_call_to_play(ready)
bob.publish_call_to_play(message)
wait_call_to_play_events(alice, {"s49-create", "s49-ready", "s49-message-event"})
alice.publish_call_to_play(start)
wait_call_to_play_events(
bob,
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
)
charlie = self.peer("s49-charlie")
charlie.connect_to(alice)
events = wait_call_to_play_events(
charlie,
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
)
if len(events) != 4:
raise ScenarioError(f"terminal late-join history is incomplete: {events}")
return "late joiner reconstructed terminal call outcome, roster, and chat"
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
@@ -2034,6 +2175,24 @@ def wait_no_outbound_transfer(peer: Peer, game_id: str, timeout: float = 20) ->
)
def wait_call_to_play_events(
peer: Peer,
expected_ids: set[str],
timeout: float = 20,
) -> list[dict[str, Any]]:
deadline = time.monotonic() + timeout
last_events: list[dict[str, Any]] = []
while time.monotonic() < deadline:
events = peer.call_to_play_events()
last_events = events
if expected_ids <= {event.get("id") for event in events}:
return events
time.sleep(0.2)
raise ScenarioError(
f"{peer.name} never received Call to Play events {expected_ids}: {last_events}"
)
def assert_game_state(
game: dict[str, Any],
*,
+32 -1
View File
@@ -9,7 +9,7 @@ use std::{
};
use eyre::{Context, OptionExt};
use lanspread_peer::{UnpackFuture, Unpacker};
use lanspread_peer::{CallToPlayEvent, UnpackFuture, Unpacker};
use serde::Serialize;
use serde_json::{Value, json};
@@ -26,6 +26,10 @@ pub enum CliCommand {
Status,
ListPeers,
ListGames,
ListCallToPlay,
PublishCallToPlay {
event: CallToPlayEvent,
},
SetGameDir {
path: PathBuf,
},
@@ -67,6 +71,8 @@ impl CliCommand {
Self::Status => "status",
Self::ListPeers => "list-peers",
Self::ListGames => "list-games",
Self::ListCallToPlay => "list-call-to-play",
Self::PublishCallToPlay { .. } => "publish-call-to-play",
Self::SetGameDir { .. } => "set-game-dir",
Self::Download { .. } => "download",
Self::StreamInstall { .. } => "stream-install",
@@ -102,6 +108,16 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
"status" => CliCommand::Status,
"list-peers" => CliCommand::ListPeers,
"list-games" => CliCommand::ListGames,
"list-call-to-play" => CliCommand::ListCallToPlay,
"publish-call-to-play" => CliCommand::PublishCallToPlay {
event: serde_json::from_value(
object
.get("event")
.cloned()
.ok_or_eyre("publish-call-to-play must include event")?,
)
.wrap_err("invalid Call to Play event")?,
},
"set-game-dir" => CliCommand::SetGameDir {
path: PathBuf::from(required_str(object, "path")?),
},
@@ -384,6 +400,21 @@ mod tests {
);
}
#[test]
fn parses_call_to_play_event_command() {
let parsed = parse_command_line(
r#"{"cmd":"publish-call-to-play","event":{"id":"event-1","call_id":"call-1","actor_id":"","actor_name":"Alice","at":1000,"action":{"Create":{"game_id":"game-1","max_players":4,"scheduled_for":null,"deadline":61000}}}}"#,
)
.expect("command should parse");
let CliCommand::PublishCallToPlay { event } = parsed.command else {
panic!("expected PublishCallToPlay");
};
assert_eq!(event.id, "event-1");
assert_eq!(event.call_id, "call-1");
assert_eq!(event.actor_name, "Alice");
}
#[tokio::test]
async fn fixture_unpacker_creates_install_payload() {
let temp = TempDir::new("lanspread-peer-cli-fixture");
+39 -1
View File
@@ -16,6 +16,7 @@ use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
CallToPlayEvent,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
OutboundTransfers,
@@ -46,7 +47,7 @@ use lanspread_peer_cli::{
use serde_json::{Value, json};
use tokio::{
io::{AsyncBufReadExt, BufReader},
sync::{Notify, RwLock, mpsc},
sync::{Notify, RwLock, mpsc, oneshot},
};
#[derive(Debug)]
@@ -101,6 +102,7 @@ struct CliState {
game_files: HashMap<String, Vec<GameFileDescription>>,
unavailable_games: HashSet<String>,
downloads: HashMap<String, DownloadMeasurement>,
call_to_play_events: Vec<CallToPlayEvent>,
}
#[derive(Clone, serde::Serialize)]
@@ -243,6 +245,27 @@ async fn handle_command(
CliCommand::Status => status(shared).await,
CliCommand::ListPeers => list_peers(shared).await,
CliCommand::ListGames => list_games(shared).await,
CliCommand::ListCallToPlay => {
let (reply, result) = oneshot::channel();
sender.send(PeerCommand::GetCallToPlayEvents { reply: Some(reply) })?;
let events = tokio::time::timeout(Duration::from_secs(1), result)
.await
.wrap_err("timed out waiting for Call to Play history")?
.wrap_err("peer stopped before returning Call to Play history")?;
Ok(json!({ "events": events }))
}
CliCommand::PublishCallToPlay { event } => {
let (reply, result) = oneshot::channel();
sender.send(PeerCommand::PublishCallToPlay {
event: event.clone(),
reply,
})?;
result
.await
.wrap_err("peer stopped before publishing Call to Play event")?
.map_err(eyre::Report::msg)?;
Ok(json!({"published": true, "event_id": event.id}))
}
CliCommand::SetGameDir { path } => {
sender.send(PeerCommand::SetGameDir(path.clone()))?;
Ok(json!({"queued": true, "path": path}))
@@ -498,6 +521,21 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
json!({ "active_operations": active_operations_json(&active_operations) }),
)
}
PeerEvent::CallToPlayEvents(events) => {
let mut state = shared.state.write().await;
let mut known = state
.call_to_play_events
.iter()
.map(|event| event.id.clone())
.collect::<HashSet<_>>();
state.call_to_play_events.extend(
events
.iter()
.filter(|event| known.insert(event.id.clone()))
.cloned(),
);
("call-to-play-events", json!({ "events": events }))
}
PeerEvent::GotGameFiles {
id,
file_descriptions,
+47
View File
@@ -44,6 +44,53 @@ When a peer is discovered:
- Any message updates `last_seen`.
- Pings run only when idle (or on a longer interval), not every 5 seconds.
- Library updates are pushed as deltas, debounced and coalesced.
- Call to Play actions are broadcast as immutable, uniquely identified events.
### Call to Play replication
Call to Play is transient peer-session state rather than database state. The
peer keeps a bounded event history, deduplicated by event ID. Every event and
chat message remains in the snapshot for the full lifetime of an active call,
so a peer joining mid-call receives the complete context. A creator's `Start`
or `Cancel` makes the call terminal and read-only, but its complete roster and
chat history remain in snapshots for 15 minutes so late joiners can see the
outcome. After that display window the history compacts to the Start or Cancel
tombstone for the rest of the peer session. Active and recently terminal calls
are never partially trimmed. If genuinely active history reaches the bound,
local publishes return an error to the caller instead of appearing to succeed;
terminal histories and tombstones do not consume that active-history capacity.
A call whose deadline elapses remains available for five minutes so the creator
can start or extend it, then its unresolved history is evicted as a unit. A
local action is applied to that history, sent to the UI, and broadcast to every
currently known peer. An incoming live event is applied once and sent to the UI
without being rebroadcast, which prevents forwarding loops.
Live Call to Play delivery is acknowledged by the receiver. Applied, duplicate,
and obsolete events need no follow-up. An unknown envelope peer, missing call
root, transport failure, or malformed acknowledgement makes the sender perform
one normal `Hello` / `HelloAck` exchange with that peer. The handshake carries
the full retained history in both directions, so a transient request failure
heals without waiting for mDNS rediscovery or a later reconnect. A rejected
event is logged without retry. Local publication remains successful while this
healing happens asynchronously, so an offline peer cannot block an action.
Actors are keyed by the peer's stable ID and carry a separate display name. The
origin peer overwrites the actor ID on local actions. A live-event envelope must
name a peer already in the receiver's roster, and every enclosed actor ID must
match that envelope. This prevents accidental identity mixing and protects
creator controls from other normal clients. It is not authentication against a
hostile LAN peer: all peers use the shared application TLS identity, and stable
peer IDs are self-asserted under the project's trusted-LAN model.
`Hello` and `HelloAck` include each side's event history. This lets peers that
join after a call was created reconstruct the same nominations, responses,
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
deterministically and derives deadlines and check-in phases from timestamps.
Those phases compare creator-supplied wall-clock timestamps with each viewer's
local wall clock, so LAN machines are assumed to be synchronized closely enough
for human-scale minute countdowns; clock skew shifts the displayed boundary by
the same amount.
There is deliberately no compatibility path for older protocol versions.
### 4) Shutdown
+913
View File
@@ -0,0 +1,913 @@
//! Replicated event history for Call to Play coordination.
use std::{
collections::{BTreeSet, HashMap, HashSet},
fmt,
time::{SystemTime, UNIX_EPOCH},
};
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
use tokio::sync::mpsc::UnboundedSender;
use crate::{
PeerEvent,
context::Ctx,
events,
network::send_call_to_play_events,
services::{HandshakeCtx, perform_handshake_with_peer},
};
const MAX_EVENTS: usize = 4_096;
const MAX_ID_CHARS: usize = 128;
const MAX_GAME_ID_CHARS: usize = 256;
const MAX_USERNAME_CHARS: usize = 24;
const MAX_MESSAGE_CHARS: usize = 500;
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
const TERMINAL_RETENTION_MS: i64 = 15 * 60_000;
#[derive(Debug, Default)]
pub(crate) struct CallToPlayStore {
events: Vec<CallToPlayEvent>,
}
#[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 {
pub(crate) fn snapshot(&mut self) -> Vec<CallToPlayEvent> {
self.snapshot_at(now_ms())
}
fn snapshot_at(&mut self, now: i64) -> Vec<CallToPlayEvent> {
compact_history(&mut self.events, now);
self.events.clone()
}
pub(crate) fn merge_batch(
&mut self,
incoming: Vec<CallToPlayEvent>,
) -> Result<BatchMerge, MergeError> {
self.merge_batch_at(incoming, now_ms())
}
fn merge_batch_at(
&mut self,
incoming: Vec<CallToPlayEvent>,
now: i64,
) -> Result<BatchMerge, MergeError> {
for event in &incoming {
validate_event(event).map_err(MergeError::Invalid)?;
}
let (incoming, mut duplicates) = deduplicate_batch(incoming)?;
// Expiry is evaluated before accepting new actions. An action cannot keep
// an already-retired root alive by itself; the sender must provide the
// complete history in the same batch.
let mut retained = self.events.clone();
compact_history(&mut retained, now);
let retained_by_id = retained
.iter()
.map(|event| (event.id.as_str(), event))
.collect::<HashMap<_, _>>();
let mut new_events = Vec::with_capacity(incoming.len());
for event in incoming {
if let Some(existing) = retained_by_id.get(event.id.as_str()) {
if *existing != &event {
return Err(MergeError::ConflictingEvent(event.id));
}
duplicates += 1;
} else {
new_events.push(event);
}
}
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 {
let CallToPlayAction::Create { deadline, .. } = event.action else {
continue;
};
let candidate = CreateRecord {
at: event.at,
event_id: event.id.clone(),
actor_id: event.actor_id.clone(),
deadline,
};
creators
.entry(event.call_id.clone())
.and_modify(|current| {
if candidate.order_key() < current.order_key() {
current.clone_from(&candidate);
}
})
.or_insert(candidate);
}
let mut terminal_events = HashMap::<String, EventRecord>::new();
let mut extensions = HashMap::<String, ExtensionRecord>::new();
for event in events {
let Some(creator) = creators.get(&event.call_id) else {
continue;
};
if event.actor_id != creator.actor_id
|| (event.at, event.id.as_str()) <= creator.order_key()
{
continue;
}
if matches!(
event.action,
CallToPlayAction::Cancel | CallToPlayAction::Start
) {
let candidate = EventRecord {
at: event.at,
event_id: event.id.clone(),
};
terminal_events
.entry(event.call_id.clone())
.and_modify(|current| {
if candidate.order_key() < current.order_key() {
current.clone_from(&candidate);
}
})
.or_insert(candidate);
} else if let CallToPlayAction::AddTime { deadline } = event.action {
let candidate = ExtensionRecord {
event: EventRecord {
at: event.at,
event_id: event.id.clone(),
},
deadline,
};
extensions
.entry(event.call_id.clone())
.and_modify(|current| {
if candidate.event.order_key() > current.event.order_key() {
current.clone_from(&candidate);
}
})
.or_insert(candidate);
}
}
Self {
creators,
terminal_events,
extensions,
}
}
fn expired_call_ids(&self, now: i64) -> HashSet<&str> {
self.creators
.iter()
.filter_map(|(call_id, creator)| {
if self.terminal_events.contains_key(call_id) {
return None;
}
let deadline = self
.extensions
.get(call_id)
.map_or(creator.deadline, |extension| extension.deadline);
(now - deadline > EXPIRED_RETENTION_MS).then_some(call_id.as_str())
})
.collect()
}
}
fn compact_history(events: &mut Vec<CallToPlayEvent>, now: i64) {
let index = HistoryIndex::build(events);
let expired_calls = index.expired_call_ids(now);
events.retain(|event| {
if expired_calls.contains(event.call_id.as_str()) {
return false;
}
let Some(terminal) = index.terminal_events.get(&event.call_id) else {
return true;
};
if (event.at, event.id.as_str()) > terminal.order_key() {
return false;
}
now - terminal.at <= TERMINAL_RETENTION_MS || event.id == terminal.event_id
});
}
fn terminal_tombstone_call_ids(events: &[CallToPlayEvent]) -> HashSet<String> {
let rooted_calls = events
.iter()
.filter(|event| matches!(event.action, CallToPlayAction::Create { .. }))
.map(|event| event.call_id.clone())
.collect::<HashSet<_>>();
events
.iter()
.filter(|event| {
matches!(
event.action,
CallToPlayAction::Cancel | CallToPlayAction::Start
) && !rooted_calls.contains(event.call_id.as_str())
})
.map(|event| event.call_id.clone())
.collect()
}
fn unresolved_event_count(events: &[CallToPlayEvent]) -> usize {
let index = HistoryIndex::build(events);
events
.iter()
.filter(|event| {
index.creators.contains_key(&event.call_id)
&& !index.terminal_events.contains_key(&event.call_id)
})
.count()
}
fn now_ms() -> i64 {
i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
)
.unwrap_or(i64::MAX)
}
pub(crate) async fn publish(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
mut event: CallToPlayEvent,
) -> Result<(), String> {
event.actor_id.clone_from(ctx.peer_id.as_ref());
let merged = ctx
.call_to_play
.write()
.await
.merge_batch(vec![event.clone()])
.map_err(|err| err.to_string())?;
if merged.needs_history() {
return Err("Call to Play history is missing".to_string());
}
if merged.applied.is_empty() {
if merged.obsolete > 0 {
return Err("Call to Play event is obsolete".to_string());
}
return Ok(());
}
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied));
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
let peer_id = ctx.peer_id.clone();
let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui);
ctx.task_tracker.spawn(async move {
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
let event = event.clone();
let peer_id = peer_id.clone();
let handshake_ctx = handshake_ctx.clone();
async move {
deliver_to_peer(handshake_ctx, peer_addr, peer_id.as_ref(), event).await;
}
});
futures::future::join_all(deliveries).await;
});
Ok(())
}
async fn deliver_to_peer(
handshake_ctx: HandshakeCtx,
peer_addr: std::net::SocketAddr,
peer_id: &str,
event: CallToPlayEvent,
) {
let delivery = send_call_to_play_events(peer_addr, peer_id, vec![event]).await;
match &delivery {
Ok(CallToPlayAck::Rejected { reason }) => {
log::warn!("Peer {peer_addr} rejected a Call to Play event: {reason}");
}
Ok(CallToPlayAck::Obsolete) => {
log::debug!("Peer {peer_addr} already retired the Call to Play event");
}
Err(err) => {
log::warn!("Failed to deliver a Call to Play event to {peer_addr}: {err}");
}
Ok(
CallToPlayAck::Applied
| CallToPlayAck::Duplicate
| CallToPlayAck::NeedHandshake
| CallToPlayAck::NeedHistory,
) => {}
}
let Some(reason) = delivery_resync_reason(delivery.as_ref().map_err(|_| ())) else {
return;
};
if let Err(err) = perform_handshake_with_peer(handshake_ctx, peer_addr, None).await {
log::warn!("Failed to {reason} with {peer_addr}: {err}");
}
}
fn delivery_resync_reason(delivery: Result<&CallToPlayAck, ()>) -> Option<&'static str> {
match delivery {
Err(()) => Some("heal a failed Call to Play delivery"),
Ok(CallToPlayAck::NeedHandshake) => Some("complete a requested Call to Play handshake"),
Ok(CallToPlayAck::NeedHistory) => Some("restore missing Call to Play history"),
Ok(
CallToPlayAck::Applied
| CallToPlayAck::Duplicate
| CallToPlayAck::Obsolete
| CallToPlayAck::Rejected { .. },
) => None,
}
}
fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> {
validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?;
validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?;
validate_nonempty(&event.actor_id, MAX_ID_CHARS, "invalid actor id")?;
validate_nonempty(&event.actor_name, MAX_USERNAME_CHARS, "invalid actor name")?;
if event.at <= 0 {
return Err("invalid event timestamp");
}
match &event.action {
CallToPlayAction::Create {
game_id,
max_players,
scheduled_for,
deadline,
} => {
validate_nonempty(game_id, MAX_GAME_ID_CHARS, "invalid game id")?;
if !(2..=64).contains(max_players) {
return Err("max players must be between 2 and 64");
}
if *deadline <= event.at {
return Err("deadline must be after creation");
}
if scheduled_for.is_some_and(|scheduled| scheduled != *deadline) {
return Err("scheduled call deadline must match its start time");
}
}
CallToPlayAction::Respond { ready_at } => {
if ready_at.is_some_and(|ready| ready < event.at) {
return Err("ready time cannot be before the response");
}
}
CallToPlayAction::SendMessage { message_id, text } => {
validate_nonempty(message_id, MAX_ID_CHARS, "invalid message id")?;
validate_nonempty(text, MAX_MESSAGE_CHARS, "invalid message")?;
}
CallToPlayAction::AddTime { deadline } => {
if *deadline <= event.at {
return Err("extended deadline must be in the future");
}
}
CallToPlayAction::Rsvp
| CallToPlayAction::Leave
| CallToPlayAction::Cancel
| CallToPlayAction::Start => {}
}
Ok(())
}
fn validate_nonempty(
value: &str,
max_chars: usize,
error: &'static str,
) -> Result<(), &'static str> {
if value.trim().is_empty() || value.chars().count() > max_chars {
return Err(error);
}
Ok(())
}
#[cfg(test)]
mod tests {
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
use super::{
CallToPlayStore,
MAX_EVENTS,
MergeError,
TERMINAL_RETENTION_MS,
delivery_resync_reason,
};
const TEST_NOW: i64 = 8_000_000_000_000;
fn create_event(id: &str) -> CallToPlayEvent {
create_event_for("call-1", id)
}
fn create_event_for(call_id: &str, id: &str) -> CallToPlayEvent {
CallToPlayEvent {
id: id.to_string(),
call_id: call_id.to_string(),
actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(),
at: TEST_NOW,
action: CallToPlayAction::Create {
game_id: "game-1".to_string(),
max_players: 4,
scheduled_for: None,
deadline: TEST_NOW + 60_000,
},
}
}
fn action_event(id: &str, call_id: &str, action: CallToPlayAction) -> CallToPlayEvent {
CallToPlayEvent {
id: id.to_string(),
call_id: call_id.to_string(),
actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(),
at: TEST_NOW + 1_000,
action,
}
}
#[test]
fn deduplicates_events_without_reordering_new_history() {
let mut store = CallToPlayStore::default();
let first = create_event("event-1");
let second = create_event("event-2");
let merged = store
.merge_batch_at(vec![first.clone(), first.clone(), second.clone()], TEST_NOW)
.expect("valid batch should merge");
assert_eq!(merged.applied, [first, second]);
assert_eq!(merged.duplicates, 1);
assert_eq!(merged.obsolete, 0);
assert!(!merged.needs_history());
let duplicate = store
.merge_batch_at(vec![create_event("event-1")], TEST_NOW)
.expect("stored duplicate should be harmless");
assert!(duplicate.applied.is_empty());
assert_eq!(duplicate.duplicates, 1);
let ids = event_ids(store.snapshot_at(TEST_NOW));
assert_eq!(ids, ["event-1", "event-2"]);
}
#[test]
fn invalid_batch_leaves_store_unchanged() {
let mut store = CallToPlayStore::default();
store
.merge_batch_at(vec![create_event("create")], TEST_NOW)
.expect("create should fit");
let before = store.snapshot_at(TEST_NOW);
let mut invalid = action_event(
"invalid",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-1".to_string(),
text: "valid before corruption".to_string(),
},
);
invalid.action = CallToPlayAction::SendMessage {
message_id: "message-1".to_string(),
text: " ".to_string(),
};
assert_eq!(
store.merge_batch_at(
vec![
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
invalid,
],
TEST_NOW,
),
Err(MergeError::Invalid("invalid message"))
);
assert_eq!(store.snapshot_at(TEST_NOW), before);
}
#[test]
fn conflicting_event_id_leaves_store_unchanged() {
let mut store = CallToPlayStore::default();
store
.merge_batch_at(vec![create_event("create")], TEST_NOW)
.expect("create should fit");
let before = store.snapshot_at(TEST_NOW);
let mut conflicting = create_event("create");
conflicting.actor_name = "Mallory".to_string();
assert_eq!(
store.merge_batch_at(vec![conflicting], TEST_NOW),
Err(MergeError::ConflictingEvent("create".to_string()))
);
assert_eq!(store.snapshot_at(TEST_NOW), before);
}
#[test]
fn create_and_extension_revive_expired_history_in_any_batch_order() {
let extended_deadline = TEST_NOW + 20 * 60_000;
let extension = action_event(
"extend",
"call-1",
CallToPlayAction::AddTime {
deadline: extended_deadline,
},
);
let after_recovery_window = TEST_NOW + 6 * 60_000 + 1;
for history in [
vec![create_event("create"), extension.clone()],
vec![extension.clone(), create_event("create")],
] {
let mut store = CallToPlayStore::default();
store
.merge_batch_at(vec![create_event("create")], TEST_NOW)
.expect("initial create should fit");
let missing = store
.merge_batch_at(vec![extension.clone()], after_recovery_window)
.expect("missing history is an acknowledged merge outcome");
assert!(missing.applied.is_empty());
assert_eq!(missing.missing_call_ids, ["call-1"]);
assert!(store.snapshot_at(after_recovery_window).is_empty());
let revived = store
.merge_batch_at(history, after_recovery_window)
.expect("complete history should revive the call atomically");
assert_eq!(revived.applied.len(), 2);
assert_eq!(
event_ids(store.snapshot_at(after_recovery_window)),
event_ids(revived.applied)
);
}
}
#[test]
fn immediately_obsolete_event_is_not_applied() {
let mut store = CallToPlayStore::default();
let after_recovery_window = TEST_NOW + 6 * 60_000 + 1;
let merged = store
.merge_batch_at(vec![create_event("create")], after_recovery_window)
.expect("obsolete is an acknowledged merge outcome");
assert!(merged.applied.is_empty());
assert_eq!(merged.obsolete, 1);
assert!(store.snapshot_at(after_recovery_window).is_empty());
}
#[test]
fn terminal_tombstone_prevents_stale_history_resurrection() {
let mut store = CallToPlayStore::default();
let start = action_event("start", "call-1", CallToPlayAction::Start);
store
.merge_batch_at(vec![create_event("create"), start.clone()], TEST_NOW)
.expect("terminal history should merge");
let after_terminal_retention = start.at + TERMINAL_RETENTION_MS + 1;
assert_eq!(
store.snapshot_at(after_terminal_retention).as_slice(),
std::slice::from_ref(&start)
);
let stale = store
.merge_batch_at(
vec![
create_event("create"),
action_event(
"extend",
"call-1",
CallToPlayAction::AddTime {
deadline: TEST_NOW + 20 * 60_000,
},
),
],
after_terminal_retention,
)
.expect("stale history is an obsolete merge outcome");
assert!(stale.applied.is_empty());
assert_eq!(stale.obsolete, 2);
assert_eq!(store.snapshot_at(after_terminal_retention), [start]);
}
#[test]
fn terminal_actions_succeed_at_the_active_history_cap() {
for terminal_action in [CallToPlayAction::Start, CallToPlayAction::Cancel] {
let mut store = full_active_store();
let terminal = action_event("terminal", "call-1", terminal_action);
let merged = store
.merge_batch_at(vec![terminal.clone()], TEST_NOW)
.expect("terminal action should settle the full call");
assert_eq!(merged.applied.as_slice(), std::slice::from_ref(&terminal));
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 1);
assert_eq!(
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
[terminal]
);
}
}
#[test]
fn terminal_histories_do_not_consume_active_history_capacity() {
let mut store = full_active_store();
let mut terminal = action_event("call-1-start", "call-1", CallToPlayAction::Start);
terminal.at = TEST_NOW + 2_000;
store
.merge_batch_at(vec![terminal], TEST_NOW)
.expect("start should settle active history");
let created = create_event_for("call-2", "call-2-create");
let merged = store
.merge_batch_at(vec![created.clone()], TEST_NOW)
.expect("terminal history must not block a new active call");
assert_eq!(merged.applied, [created]);
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 2);
}
#[test]
fn full_active_history_returns_an_error() {
let mut store = full_active_store();
let before = store.snapshot_at(TEST_NOW);
assert_eq!(
store.merge_batch_at(
vec![action_event("overflow", "call-1", CallToPlayAction::Rsvp,)],
TEST_NOW,
),
Err(MergeError::HistoryFull)
);
assert_eq!(store.snapshot_at(TEST_NOW), before);
}
#[test]
fn visible_call_retains_complete_chat_history() {
let mut store = CallToPlayStore::default();
let message = action_event(
"message-event",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-1".to_string(),
text: "Ready when you are".to_string(),
},
);
let merged = store
.merge_batch_at(
vec![
create_event("create"),
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
message.clone(),
],
TEST_NOW,
)
.expect("visible history should merge");
assert_eq!(merged.applied.len(), 3);
assert!(store.snapshot_at(TEST_NOW).contains(&message));
}
#[test]
fn terminal_history_preserves_roster_and_chat_for_display_window() {
let mut store = CallToPlayStore::default();
let message = action_event(
"message",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-1".to_string(),
text: "Launching now".to_string(),
},
);
let terminal = action_event("terminal", "call-1", CallToPlayAction::Start);
let history = vec![
create_event("create"),
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
message,
terminal.clone(),
];
store
.merge_batch_at(history.clone(), TEST_NOW)
.expect("terminal history should merge");
let mut post_terminal = action_event(
"post-terminal",
"call-1",
CallToPlayAction::SendMessage {
message_id: "message-2".to_string(),
text: "Too late".to_string(),
},
);
post_terminal.at = terminal.at + 1;
let obsolete = store
.merge_batch_at(
vec![create_event("different-create"), post_terminal],
TEST_NOW,
)
.expect("terminal updates should be obsolete");
assert!(obsolete.applied.is_empty());
assert_eq!(obsolete.obsolete, 2);
assert_eq!(
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS),
history
);
assert_eq!(
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
[terminal]
);
}
#[test]
fn only_failed_or_incomplete_deliveries_request_resync() {
assert!(delivery_resync_reason(Err(())).is_some());
assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHandshake)).is_some());
assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHistory)).is_some());
for ack in [
CallToPlayAck::Applied,
CallToPlayAck::Duplicate,
CallToPlayAck::Obsolete,
CallToPlayAck::Rejected {
reason: "invalid".to_string(),
},
] {
assert!(delivery_resync_reason(Ok(&ack)).is_none());
}
}
fn full_active_store() -> CallToPlayStore {
let mut history = Vec::with_capacity(MAX_EVENTS);
history.push(create_event("create"));
history.extend((1..MAX_EVENTS).map(|index| {
action_event(&format!("event-{index}"), "call-1", CallToPlayAction::Rsvp)
}));
let mut store = CallToPlayStore::default();
store
.merge_batch_at(history, TEST_NOW)
.expect("active history should fit through the cap");
store
}
fn event_ids(events: Vec<CallToPlayEvent>) -> Vec<String> {
events.into_iter().map(|event| event.id).collect()
}
}
+5
View File
@@ -10,6 +10,7 @@ use crate::{
PeerEvent,
StreamInstallProvider,
Unpacker,
call_to_play::CallToPlayStore,
events,
library::LocalLibraryState,
peer_db::PeerGameDB,
@@ -51,6 +52,7 @@ pub struct Ctx {
pub shutdown: CancellationToken,
pub task_tracker: TaskTracker,
pub active_outbound_transfers: OutboundTransfers,
pub call_to_play: Arc<RwLock<CallToPlayStore>>,
}
/// Context for peer connection handling.
@@ -69,6 +71,7 @@ pub struct PeerCtx {
pub shutdown: CancellationToken,
pub task_tracker: TaskTracker,
pub active_outbound_transfers: OutboundTransfers,
pub call_to_play: Arc<RwLock<CallToPlayStore>>,
}
impl std::fmt::Debug for PeerCtx {
@@ -113,6 +116,7 @@ impl Ctx {
shutdown,
task_tracker,
active_outbound_transfers,
call_to_play: Arc::new(RwLock::new(CallToPlayStore::default())),
}
}
@@ -135,6 +139,7 @@ impl Ctx {
shutdown: self.shutdown.clone(),
task_tracker: self.task_tracker.clone(),
active_outbound_transfers: self.active_outbound_transfers.clone(),
call_to_play: self.call_to_play.clone(),
}
}
}
+2
View File
@@ -6,6 +6,7 @@ use crate::state_paths::peer_id_path;
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
pub const FEATURE_CALL_TO_PLAY: &str = "call-to-play-v1";
pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result<String> {
let path = peer_id_path(state_dir);
@@ -28,5 +29,6 @@ pub fn default_features() -> Vec<String> {
vec![
FEATURE_LIBRARY_DELTA.to_string(),
FEATURE_LIBRARY_SNAPSHOT.to_string(),
FEATURE_CALL_TO_PLAY.to_string(),
]
}
+26 -1
View File
@@ -12,6 +12,7 @@
// Module declarations
// =============================================================================
mod call_to_play;
mod config;
mod context;
mod download;
@@ -46,6 +47,7 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
pub use error::PeerError;
pub use install::{UnpackFuture, Unpacker};
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
pub use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
pub use migration::{MigrationReport, migrate_legacy_state};
pub use peer_db::{
MajorityValidationResult,
@@ -58,6 +60,7 @@ pub use peer_db::{
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
oneshot,
};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -159,6 +162,8 @@ pub enum PeerEvent {
ActiveOperationsChanged {
active_operations: Vec<ActiveOperation>,
},
/// New or requested Call to Play events in replication order.
CallToPlayEvents(Vec<CallToPlayEvent>),
/// A required peer runtime component failed.
RuntimeFailed {
component: PeerRuntimeComponent,
@@ -224,7 +229,7 @@ pub enum ActiveOperationKind {
}
/// Commands sent to the peer system from the UI.
#[derive(Clone, Debug)]
#[derive(Debug)]
pub enum PeerCommand {
/// Request a list of all available games.
ListGames,
@@ -259,6 +264,15 @@ pub enum PeerCommand {
GetPeerCount,
/// Connect directly to a peer address without waiting for mDNS discovery.
ConnectPeer(SocketAddr),
/// Publish one local Call to Play action to this peer and the LAN.
PublishCallToPlay {
event: CallToPlayEvent,
reply: oneshot::Sender<Result<(), String>>,
},
/// Request the complete in-memory Call to Play history.
GetCallToPlayEvents {
reply: Option<oneshot::Sender<Vec<CallToPlayEvent>>>,
},
}
/// Optional startup settings for non-GUI callers and tests.
@@ -489,6 +503,17 @@ async fn handle_peer_commands(
PeerCommand::ConnectPeer(addr) => {
handle_connect_peer_command(ctx, tx_notify_ui, addr).await;
}
PeerCommand::PublishCallToPlay { event, reply } => {
let result = call_to_play::publish(ctx, tx_notify_ui, event).await;
let _ = reply.send(result);
}
PeerCommand::GetCallToPlayEvents { reply } => {
let events = ctx.call_to_play.write().await.snapshot();
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(events.clone()));
if let Some(reply) = reply {
let _ = reply.send(events);
}
}
}
}
}
+42 -10
View File
@@ -9,7 +9,16 @@ use bytes::BytesMut;
use futures::{SinkExt, StreamExt};
use if_addrs::{IfAddr, Interface, get_if_addrs};
use lanspread_db::db::GameFileDescription;
use lanspread_proto::{Hello, HelloAck, LibraryDelta, Message, Request, Response};
use lanspread_proto::{
CallToPlayAck,
CallToPlayEvent,
Hello,
HelloAck,
LibraryDelta,
Message,
Request,
Response,
};
use s2n_quic::{
Client as QuicClient,
Connection,
@@ -132,6 +141,14 @@ pub async fn send_oneway_request(peer_addr: SocketAddr, request: Request) -> eyr
/// Performs a hello/ack handshake with a peer.
pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result<HelloAck> {
let response = exchange_request(peer_addr, Request::Hello(hello)).await?;
match response {
Response::HelloAck(ack) => Ok(ack),
other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"),
}
}
async fn exchange_request(peer_addr: SocketAddr, request: Request) -> eyre::Result<Response> {
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
@@ -139,19 +156,15 @@ pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx.send(Request::Hello(hello).encode()).await?;
let _ = framed_tx.close().await;
framed_tx.send(request.encode()).await?;
framed_tx.close().await?;
let mut data = BytesMut::new();
while let Some(Ok(bytes)) = framed_rx.next().await {
data.extend_from_slice(&bytes);
while let Some(frame) = framed_rx.next().await {
data.extend_from_slice(&frame?);
}
let response = Response::decode(data.freeze());
match response {
Response::HelloAck(ack) => Ok(ack),
other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"),
}
Ok(Response::decode(data.freeze()))
}
pub async fn send_library_delta(
@@ -173,6 +186,25 @@ pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Resul
send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await
}
pub async fn send_call_to_play_events(
peer_addr: SocketAddr,
peer_id: &str,
events: Vec<CallToPlayEvent>,
) -> eyre::Result<CallToPlayAck> {
let response = exchange_request(
peer_addr,
Request::CallToPlayEvents {
peer_id: peer_id.to_string(),
events,
},
)
.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.
pub async fn request_game_details_from_peer(
peer_addr: SocketAddr,
+141 -5
View File
@@ -8,6 +8,7 @@ use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{
PeerEvent,
call_to_play::CallToPlayStore,
context::{Ctx, PeerCtx},
events,
identity::default_features,
@@ -24,6 +25,7 @@ pub(crate) struct HandshakeCtx {
peer_game_db: Arc<RwLock<PeerGameDB>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
catalog: Arc<RwLock<GameCatalog>>,
call_to_play: Arc<RwLock<CallToPlayStore>>,
}
impl HandshakeCtx {
@@ -35,6 +37,7 @@ impl HandshakeCtx {
peer_game_db: ctx.peer_game_db.clone(),
tx_notify_ui: tx_notify_ui.clone(),
catalog: ctx.catalog.clone(),
call_to_play: ctx.call_to_play.clone(),
}
}
@@ -46,6 +49,7 @@ impl HandshakeCtx {
peer_game_db: ctx.peer_game_db.clone(),
tx_notify_ui: ctx.tx_notify_ui.clone(),
catalog: ctx.catalog.clone(),
call_to_play: ctx.call_to_play.clone(),
}
}
}
@@ -58,28 +62,36 @@ async fn required_listen_addr(
}
pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result<HelloAck> {
let library_guard = ctx.local_library.read().await;
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
let library = build_library_snapshot(&library_guard);
let library = {
let library_guard = ctx.local_library.read().await;
build_library_snapshot(&library_guard)
};
let call_to_play_events = ctx.call_to_play.write().await.snapshot();
Ok(HelloAck {
peer_id: ctx.peer_id.as_ref().clone(),
proto_ver: PROTOCOL_VERSION,
listen_addr,
library,
features: default_features(),
call_to_play_events,
})
}
async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result<Hello> {
let library_guard = ctx.local_library.read().await;
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
let library = build_library_snapshot(&library_guard);
let library = {
let library_guard = ctx.local_library.read().await;
build_library_snapshot(&library_guard)
};
let call_to_play_events = ctx.call_to_play.write().await.snapshot();
Ok(Hello {
peer_id: ctx.peer_id.as_ref().clone(),
proto_ver: PROTOCOL_VERSION,
listen_addr,
library,
features: default_features(),
call_to_play_events,
})
}
@@ -114,6 +126,13 @@ pub(crate) async fn perform_handshake_with_peer(
let _ = ctx.peer_game_db.write().await.remove_peer(expected);
}
merge_call_to_play_events(
&ctx.call_to_play,
&ctx.tx_notify_ui,
ack.call_to_play_events,
)
.await;
let record_addr = ack.listen_addr;
let upsert = record_remote_library(
&ctx.peer_game_db,
@@ -149,6 +168,12 @@ pub(super) async fn accept_inbound_hello(
}
let addr = hello.listen_addr;
merge_call_to_play_events(
&ctx.call_to_play,
&ctx.tx_notify_ui,
hello.call_to_play_events,
)
.await;
let handshake_ctx = HandshakeCtx::from_peer_ctx(ctx);
let upsert = record_remote_library(
&ctx.peer_game_db,
@@ -165,6 +190,29 @@ pub(super) async fn accept_inbound_hello(
build_hello_ack(ctx).await
}
async fn merge_call_to_play_events(
store: &Arc<RwLock<CallToPlayStore>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
incoming: Vec<lanspread_proto::CallToPlayEvent>,
) {
match store.write().await.merge_batch(incoming) {
Ok(merged) => {
if merged.needs_history() {
log::warn!(
"Call to Play handshake omitted roots for calls: {}",
merged.missing_call_ids.join(", ")
);
}
if !merged.applied.is_empty() {
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied));
}
}
Err(err) => {
log::warn!("Rejecting Call to Play handshake history: {err}");
}
}
}
pub(super) fn spawn_library_resync(
ctx: HandshakeCtx,
peer_addr: SocketAddr,
@@ -212,7 +260,15 @@ mod tests {
};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
use lanspread_proto::{
Availability,
CallToPlayAction,
CallToPlayEvent,
GameSummary,
Hello,
LibrarySnapshot,
PROTOCOL_VERSION,
};
use tokio::sync::{RwLock, mpsc};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -248,6 +304,7 @@ mod tests {
peer_game_db,
tx_notify_ui,
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
call_to_play: Arc::new(RwLock::new(crate::call_to_play::CallToPlayStore::default())),
}
}
@@ -264,6 +321,22 @@ mod tests {
}
}
fn call_to_play_event() -> CallToPlayEvent {
CallToPlayEvent {
id: "event-1".to_string(),
call_id: "call-1".to_string(),
actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(),
at: 8_000_000_000_000,
action: CallToPlayAction::Create {
game_id: "game".to_string(),
max_players: 4,
scheduled_for: None,
deadline: 8_000_000_060_000,
},
}
}
#[tokio::test]
async fn outbound_hello_requires_local_listener_addr() {
let ctx = test_handshake_ctx(None);
@@ -304,6 +377,22 @@ mod tests {
assert_eq!(hello.library.games[0].id, "game");
}
#[tokio::test]
async fn outbound_hello_carries_call_to_play_history() {
let ctx = test_handshake_ctx(Some(addr([10, 66, 0, 2], 40000)));
ctx.call_to_play
.write()
.await
.merge_batch(vec![call_to_play_event()])
.expect("valid event should be merged");
let hello = build_hello_from_state(&ctx)
.await
.expect("listener address is present");
assert_eq!(hello.call_to_play_events, [call_to_play_event()]);
}
#[tokio::test]
async fn inbound_hello_applies_remote_library_snapshot() {
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
@@ -335,6 +424,7 @@ mod tests {
games: vec![summary("remote-game")],
},
features: Vec::new(),
call_to_play_events: Vec::new(),
};
let ack = accept_inbound_hello(&peer_ctx, None, hello)
@@ -406,6 +496,7 @@ mod tests {
games: vec![summary("self-game")],
},
features: Vec::new(),
call_to_play_events: Vec::new(),
};
let ack = accept_inbound_hello(&peer_ctx, None, self_hello)
@@ -422,4 +513,49 @@ mod tests {
"self hello must emit no peer discovery events"
);
}
#[tokio::test]
async fn inbound_hello_merges_call_to_play_history_once() {
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
let ctx = Ctx::new(
peer_game_db,
"local-peer".to_string(),
PathBuf::new(),
PathBuf::new(),
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(GameCatalog::empty())),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
);
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
let (tx_notify_ui, mut rx_notify_ui) = mpsc::unbounded_channel();
let peer_ctx = ctx.to_peer_ctx(tx_notify_ui);
let remote_addr = addr([127, 0, 0, 1], 5000);
let hello = Hello {
peer_id: "remote-peer".to_string(),
proto_ver: PROTOCOL_VERSION,
listen_addr: remote_addr,
library: LibrarySnapshot {
library_rev: 0,
games: Vec::new(),
},
features: Vec::new(),
call_to_play_events: vec![call_to_play_event(), call_to_play_event()],
};
accept_inbound_hello(&peer_ctx, None, hello)
.await
.expect("current protocol hello should be accepted");
assert_eq!(
ctx.call_to_play.write().await.snapshot(),
[call_to_play_event()]
);
assert!(matches!(
rx_notify_ui.recv().await,
Some(PeerEvent::CallToPlayEvents(events)) if events == [call_to_play_event()]
));
}
}
+164 -1
View File
@@ -4,7 +4,7 @@ use std::net::SocketAddr;
use futures::{SinkExt, StreamExt};
use lanspread_db::db::{Game, GameFileDescription};
use lanspread_proto::{LibraryDelta, Message, Request, Response};
use lanspread_proto::{CallToPlayAck, LibraryDelta, Message, Request, Response};
use s2n_quic::stream::{BidirectionalStream, SendStream};
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
@@ -90,6 +90,13 @@ async fn dispatch_request(
handle_library_delta(ctx, peer_id, delta).await;
framed_tx
}
Request::CallToPlayEvents {
peer_id,
events: incoming,
} => {
let ack = handle_call_to_play_events(ctx, &peer_id, incoming).await;
send_response(framed_tx, Response::CallToPlayAck(ack), "CallToPlayAck").await
}
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
Request::GetGameFileChunk {
@@ -114,6 +121,60 @@ async fn dispatch_request(
}
}
async fn handle_call_to_play_events(
ctx: &PeerCtx,
peer_id: &str,
incoming: Vec<lanspread_proto::CallToPlayEvent>,
) -> CallToPlayAck {
let peer_id = peer_id.to_string();
if ctx.peer_game_db.read().await.peer_addr(&peer_id).is_none() {
log::debug!("Requesting a handshake before accepting Call to Play events from {peer_id}");
return CallToPlayAck::NeedHandshake;
}
if incoming.iter().any(|event| event.actor_id != peer_id) {
let reason = format!("event actor does not match envelope peer {peer_id}");
log::warn!("Rejecting Call to Play events: {reason}");
return CallToPlayAck::Rejected { reason };
}
match ctx.call_to_play.write().await.merge_batch(incoming) {
Ok(merged) => {
let ack = if merged.needs_history() {
CallToPlayAck::NeedHistory
} else if !merged.applied.is_empty() {
CallToPlayAck::Applied
} else if merged.obsolete > 0 {
CallToPlayAck::Obsolete
} else if merged.duplicates > 0 {
CallToPlayAck::Duplicate
} else {
CallToPlayAck::Rejected {
reason: "empty Call to Play event batch".to_string(),
}
};
if merged.needs_history() {
log::warn!(
"Ignoring Call to Play actions without history from {peer_id}: {}",
merged.missing_call_ids.join(", ")
);
}
if !merged.applied.is_empty() {
events::send(
&ctx.tx_notify_ui,
crate::PeerEvent::CallToPlayEvents(merged.applied),
);
}
ack
}
Err(err) => {
log::warn!("Rejecting Call to Play events from {peer_id}: {err}");
CallToPlayAck::Rejected {
reason: err.to_string(),
}
}
}
}
async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option<SocketAddr>) {
if let Some(addr) = remote_addr {
ctx.peer_game_db
@@ -450,6 +511,7 @@ mod tests {
};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
use tokio::sync::{RwLock, mpsc};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -495,6 +557,29 @@ mod tests {
.to_peer_ctx(tx_notify_ui)
}
fn call_to_play_event(actor_id: &str, action: CallToPlayAction) -> CallToPlayEvent {
CallToPlayEvent {
id: "event-1".to_string(),
call_id: "call-1".to_string(),
actor_id: actor_id.to_string(),
actor_name: "Alice".to_string(),
at: 8_000_000_000_000,
action,
}
}
fn call_to_play_create(actor_id: &str) -> CallToPlayEvent {
call_to_play_event(
actor_id,
CallToPlayAction::Create {
game_id: "game".to_string(),
max_players: 4,
scheduled_for: None,
deadline: 8_000_000_060_000,
},
)
}
#[test]
fn local_relative_paths_are_never_transferable() {
assert!(path_points_inside_local("game", "game/local/save.dat"));
@@ -517,6 +602,84 @@ mod tests {
));
}
#[tokio::test]
async fn known_peer_id_accepts_live_events_without_transport_ip_matching() {
let temp = TempDir::new("lanspread-call-to-play-known-peer");
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
ctx.peer_game_db.write().await.upsert_peer(
"peer-alice".to_string(),
SocketAddr::from(([10, 66, 0, 2], 40000)),
);
let ack =
handle_call_to_play_events(&ctx, "peer-alice", vec![call_to_play_create("peer-alice")])
.await;
assert_eq!(ack, CallToPlayAck::Applied);
assert_eq!(ctx.call_to_play.write().await.snapshot().len(), 1);
}
#[tokio::test]
async fn unknown_peer_and_mismatched_actor_receive_explicit_acks() {
let temp = TempDir::new("lanspread-call-to-play-identity");
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
assert_eq!(
handle_call_to_play_events(
&ctx,
"peer-alice",
vec![call_to_play_create("peer-alice")],
)
.await,
CallToPlayAck::NeedHandshake
);
ctx.peer_game_db.write().await.upsert_peer(
"peer-alice".to_string(),
SocketAddr::from(([10, 66, 0, 2], 40000)),
);
assert!(matches!(
handle_call_to_play_events(
&ctx,
"peer-alice",
vec![call_to_play_create("peer-mallory")],
)
.await,
CallToPlayAck::Rejected { reason }
if reason.contains("does not match envelope peer")
));
}
#[tokio::test]
async fn live_event_ack_reports_missing_history_and_duplicates() {
let temp = TempDir::new("lanspread-call-to-play-outcomes");
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
ctx.peer_game_db.write().await.upsert_peer(
"peer-alice".to_string(),
SocketAddr::from(([10, 66, 0, 2], 40000)),
);
let orphan = call_to_play_event(
"peer-alice",
CallToPlayAction::AddTime {
deadline: 8_000_000_600_000,
},
);
assert_eq!(
handle_call_to_play_events(&ctx, "peer-alice", vec![orphan]).await,
CallToPlayAck::NeedHistory
);
let create = call_to_play_create("peer-alice");
assert_eq!(
handle_call_to_play_events(&ctx, "peer-alice", vec![create.clone()]).await,
CallToPlayAck::Applied
);
assert_eq!(
handle_call_to_play_events(&ctx, "peer-alice", vec![create]).await,
CallToPlayAck::Duplicate
);
}
#[tokio::test]
async fn get_game_response_respects_serve_gates() {
let temp = TempDir::new("lanspread-stream");
+52 -1
View File
@@ -4,7 +4,7 @@ use bytes::Bytes;
use lanspread_db::db::{Game, GameFileDescription};
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 5;
pub const PROTOCOL_VERSION: u32 = 7;
pub use lanspread_db::db::Availability;
@@ -27,6 +27,7 @@ pub struct Hello {
pub listen_addr: SocketAddr,
pub library: LibrarySnapshot,
pub features: Vec<String>,
pub call_to_play_events: Vec<CallToPlayEvent>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -36,6 +37,51 @@ pub struct HelloAck {
pub listen_addr: SocketAddr,
pub library: LibrarySnapshot,
pub features: Vec<String>,
pub call_to_play_events: Vec<CallToPlayEvent>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CallToPlayEvent {
pub id: String,
pub call_id: String,
pub actor_id: String,
pub actor_name: String,
pub at: i64,
pub action: CallToPlayAction,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum CallToPlayAction {
Create {
game_id: String,
max_players: u16,
scheduled_for: Option<i64>,
deadline: i64,
},
Respond {
ready_at: Option<i64>,
},
Rsvp,
SendMessage {
message_id: String,
text: String,
},
Leave,
Cancel,
Start,
AddTime {
deadline: i64,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum CallToPlayAck {
Applied,
Duplicate,
NeedHandshake,
NeedHistory,
Obsolete,
Rejected { reason: String },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -75,6 +121,10 @@ pub enum Request {
peer_id: String,
delta: LibraryDelta,
},
CallToPlayEvents {
peer_id: String,
events: Vec<CallToPlayEvent>,
},
Goodbye {
peer_id: String,
},
@@ -90,6 +140,7 @@ pub enum Response {
file_descriptions: Vec<GameFileDescription>,
},
HelloAck(HelloAck),
CallToPlayAck(CallToPlayAck),
GameNotFound(String),
InvalidRequest(Bytes, String),
EncodingError(String),
@@ -44,7 +44,7 @@ walkdir = { workspace = true }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[target.'cfg(windows)'.dependencies]
[target."cfg(windows)".dependencies]
windows = { workspace = true }
[lints.clippy]
@@ -18,6 +18,7 @@ use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescript
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
CallToPlayEvent,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
PeerCommand,
@@ -39,6 +40,7 @@ use tauri_plugin_shell::{
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
oneshot,
};
use tracing::{Event, Level, Metadata, Subscriber, field::Visit};
use tracing_subscriber::{
@@ -90,6 +92,7 @@ impl OutboundTransferEmitState {
struct LanSpreadState {
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
local_peer_id: Arc<RwLock<Option<String>>>,
games: Arc<RwLock<GameDB>>,
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
games_folder: Arc<RwLock<String>>,
@@ -161,6 +164,7 @@ struct LauncherGame {
game: Game,
can_host_server: bool,
active_outbound_transfers: usize,
installed_peer_count: u32,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
@@ -272,6 +276,43 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
Ok(())
}
#[tauri::command]
async fn request_call_to_play_events(
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<Option<String>> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(None);
};
if peer_ctrl
.send(PeerCommand::GetCallToPlayEvents { reply: None })
.is_err()
{
return Ok(None);
}
Ok(state.inner().local_peer_id.read().await.clone())
}
#[tauri::command]
async fn publish_call_to_play(
event: CallToPlayEvent,
state: tauri::State<'_, LanSpreadState>,
) -> Result<bool, String> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
let (reply, result) = oneshot::channel();
peer_ctrl
.send(PeerCommand::PublishCallToPlay { event, reply })
.map_err(|err| err.to_string())?;
result.await.map_err(|err| err.to_string())?.map(|()| true)
}
#[tauri::command]
async fn install_game(
id: String,
@@ -1031,6 +1072,19 @@ fn clear_all_local_game_states(game_db: &mut GameDB) {
async fn emit_games_list(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let installed_peer_counts = state
.peer_game_db
.read()
.await
.peer_snapshots()
.into_iter()
.flat_map(|peer| peer.games)
.filter(|game| game.installed)
.fold(HashMap::<String, u32>::new(), |mut counts, game| {
*counts.entry(game.id).or_default() += 1;
counts
});
let games_db_lock = state.games.clone();
let game_db = games_db_lock.read().await;
let games_folder = state.games_folder.read().await.clone();
@@ -1051,6 +1105,7 @@ async fn emit_games_list(app_handle: &AppHandle) {
LauncherGame {
can_host_server: game_can_host_server(&games_folder, &game),
active_outbound_transfers,
installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0),
game,
}
})
@@ -2160,6 +2215,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
match event {
PeerEvent::LocalPeerReady { peer_id, addr } => {
log::info!("Local peer ready: {peer_id} at {addr}");
*app_handle
.state::<LanSpreadState>()
.local_peer_id
.write()
.await = Some(peer_id);
}
PeerEvent::ListGames(games) => {
log::info!("PeerEvent::ListGames received");
@@ -2178,6 +2238,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
emit_games_list(app_handle).await;
}
PeerEvent::CallToPlayEvents(events) => {
if let Err(err) = app_handle.emit("call-to-play-events", Some(events)) {
log::error!("Failed to emit call-to-play-events event: {err}");
}
}
PeerEvent::OutboundTransferCountChanged => {
log::info!("PeerEvent::OutboundTransferCountChanged received");
schedule_outbound_transfer_emit(app_handle).await;
@@ -2348,6 +2413,8 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
request_games,
request_call_to_play_events,
publish_call_to_play,
install_game,
stream_install_game,
run_game,
@@ -105,4 +105,35 @@ export const Icon = {
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
</svg>
),
flag: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.6} {...baseStroke} {...p}>
<path d="M3 14V2.5M3.5 3h8l-1.4 2.5L11.5 8h-8" />
</svg>
),
clock: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<circle cx="8" cy="8" r="5.5" />
<path d="M8 4.5V8l2.5 1.5" />
</svg>
),
chat: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="M2.5 3.5h11v8h-6L4 14v-2.5H2.5z" />
</svg>
),
send: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="m2 3 12 5-12 5 2-5zM4 8h6" />
</svg>
),
caretUp: (p: Props) => (
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
<path d="m4 10 4-4 4 4" />
</svg>
),
caretDown: (p: Props) => (
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
<path d="m4 6 4 4 4-4" />
</svg>
),
} satisfies Record<string, (p: Props) => JSX.Element>;
@@ -0,0 +1,19 @@
import { Icon } from '../Icon';
import { activeCallCount } from '../../lib/callToPlay';
import { Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
onClick: () => void;
}
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
const activeCount = activeCallCount(nominations);
return (
<button className="ctp-btn" onClick={onClick}>
<Icon.flag />
<span>Call to Play</span>
{activeCount > 0 && <span className="ctp-badge">{activeCount}</span>}
</button>
);
};
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { Icon } from '../Icon';
import { Modal } from '../Modal';
import { CreateNominationForm } from './CreateNominationForm';
import { NominationCard } from './NominationCard';
import { CallToPlayActions } from '../../hooks/useCallToPlay';
import { sortNominations } from '../../lib/callToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
games: ReadonlyArray<Game>;
actorId: string | null;
actions: CallToPlayActions;
focusId: string | null;
transportReady: boolean;
error: string | null;
getThumbnail: (gameId: string) => string | null | undefined;
totalPeerCount: number;
onLaunch: (game: Game) => void;
onClose: () => void;
}
export const CallToPlayOverlay = ({
nominations,
games,
actorId,
actions,
focusId,
transportReady,
error,
getThumbnail,
totalPeerCount,
onLaunch,
onClose,
}: Props) => {
const [showCreate, setShowCreate] = useState(false);
const gameById = new Map(games.map(game => [game.id, game]));
const sorted = sortNominations(nominations);
return (
<Modal onClose={onClose} className="ctp-modal">
<button className="modal-close" onClick={onClose} aria-label="Close">
<Icon.close />
</button>
<div className="ctp-head">
<h2>Call to Play</h2>
<p className="ctp-head-sub">
Rally the LAN around a game and a time right now, or scheduled for later with
an Im in RSVP. The caller decides when it actually starts.
</p>
{!showCreate && (
<button
className="act-btn act-play ctp-head-new"
disabled={!transportReady}
onClick={() => setShowCreate(true)}
><Icon.flag /><span>Call a new match</span></button>
)}
{!transportReady && !error && (
<div className="ctp-transport-note">Connecting Call to Play to the LAN</div>
)}
{error && <div className="ctp-transport-note is-error">{error}</div>}
</div>
<div className="ctp-body">
{showCreate && (
<CreateNominationForm
games={games}
onCancel={() => setShowCreate(false)}
onCreate={(gameId, maxPlayers, duration, scheduledFor) => {
actions.createNomination(gameId, maxPlayers, duration, scheduledFor);
setShowCreate(false);
}}
/>
)}
{sorted.length === 0 && !showCreate && (
<div className="ctp-empty">
No active calls right now be the one to start something.
</div>
)}
{sorted.map(nomination => {
const game = gameById.get(nomination.gameId) ?? null;
return (
<NominationCard
key={nomination.id}
nomination={nomination}
game={game}
actorId={actorId}
actions={actions}
focused={nomination.id === focusId}
thumbnailUrl={game ? getThumbnail(game.id) : null}
totalPeerCount={totalPeerCount}
onLaunch={onLaunch}
/>
);
})}
</div>
</Modal>
);
};
@@ -0,0 +1,155 @@
import { CSSProperties } from 'react';
import { Icon } from '../Icon';
import {
avatarColor,
formatClock,
formatCountdown,
formatCountdownShort,
formatUntil,
isReady,
readyCountOf,
statusOf,
type CallToPlayStatus,
} from '../../lib/callToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
games: ReadonlyArray<Game>;
accent: string;
onOpen: (callId: string) => void;
}
const LABEL = {
running: 'Running',
cancelled: 'Cancelled',
scheduled: 'Scheduled',
call: 'Call to Play',
soon: 'Starting soon',
ready: 'Ready',
expired: 'Times up',
} as const;
type TickerStatus = CallToPlayStatus;
const RANK: Record<TickerStatus, number> = {
expired: 0,
ready: 1,
soon: 2,
call: 3,
scheduled: 3,
running: 4,
cancelled: 4,
};
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus =>
statusOf(nomination, now);
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
const entries = Object.entries(nomination.participants);
return (
<span className="ctp-ticker-bubbles">
{entries.slice(0, 6).map(([participantId, participant]) => {
const name = participant.name;
const ready = isReady(participant, now);
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const remaining = (participant.readyAt ?? now) - now;
return (
<span
key={participantId}
className="ctp-mini"
data-state={state}
title={`${name}${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
style={{ background: avatarColor(name) }}
>
{initials}
{ready && <span className="ctp-mini-check"><Icon.check /></span>}
{state === 'pending' && (
<i className="ctp-mini-tag">{formatCountdownShort(remaining)}</i>
)}
</span>
);
})}
{entries.length > 6 && <span className="ctp-mini ctp-mini-more">+{entries.length - 6}</span>}
</span>
);
};
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
const now = Date.now();
const gameById = new Map(games.map(game => [game.id, game]));
const rows = nominations.map(nomination => ({
nomination,
status: tickerStatusOf(nomination, now),
})).sort((left, right) =>
RANK[left.status] - RANK[right.status]
|| left.nomination.deadline - right.nomination.deadline
);
if (rows.length === 0) return null;
return (
<div className="ctp-ticker-stack">
{rows.map(({ nomination, status }) => {
const game = gameById.get(nomination.gameId);
const ready = readyCountOf(nomination, now);
const total = Object.keys(nomination.participants).length;
const remaining = Math.max(0, nomination.deadline - now);
const last = nomination.messages[nomination.messages.length - 1];
const count = status === 'running' || status === 'cancelled'
? `${total} players`
: status === 'scheduled'
? `${total} in`
: `${ready}/${nomination.maxPlayers} ready`;
const terminalElapsed = now - (nomination.terminalAt ?? now);
const terminalAge = terminalElapsed < 1_000
? 'just now'
: `${formatCountdownShort(terminalElapsed)} ago`;
const time = status === 'running'
? `started ${terminalAge}`
: status === 'cancelled'
? `cancelled ${terminalAge}`
: status === 'ready'
? 'waiting to start'
: status === 'expired'
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
: status === 'scheduled'
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
: nomination.scheduledFor !== null
? `starts ${formatClock(nomination.scheduledFor)} · ${formatCountdown(remaining)}`
: formatCountdown(remaining);
return (
<button
key={nomination.id}
className="ctp-ticker"
data-status={status}
style={{ '--accent': accent } as CSSProperties}
onClick={() => onOpen(nomination.id)}
>
<span className="ctp-ticker-dot" data-status={status} />
<span className="ctp-ticker-label" data-status={status}>{LABEL[status]}</span>
<span
className="ctp-ticker-game"
title={game ? undefined : `Game ID: ${nomination.gameId}`}
>{game?.name ?? 'Game unavailable here'}</span>
<span className="ctp-ticker-by">by {nomination.creator}</span>
<span className="ctp-ticker-ready">{count}</span>
<span className="ctp-ticker-time">{time}</span>
{last ? (
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
<Icon.chat />
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
<span className="ctp-ticker-chat-text">{last.text}</span>
</span>
) : <span className="ctp-ticker-chat" />}
<MiniBubbles nomination={nomination} now={now} />
<span className="ctp-ticker-cta">
{status === 'soon' ? 'Check in' : 'View'}<Icon.chevron />
</span>
</button>
);
})}
</div>
);
};
@@ -0,0 +1,278 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { Game } from '../../lib/types';
import { bumpTime, normalizeTimeInput, sanitizeTimeDraft } from '../../lib/callToPlay';
interface Props {
games: ReadonlyArray<Game>;
onCancel: () => void;
onCreate: (
gameId: string,
maxPlayers: number,
durationMinutes: number,
scheduledFor: number | null,
) => void;
}
const nextTimeSlot = (): { time: string; dayOffset: number } => {
const now = new Date();
const slot = new Date(now.getTime() + 40 * 60_000);
slot.setMinutes(slot.getMinutes() <= 30 ? 30 : 60, 0, 0);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const slotDay = new Date(slot.getFullYear(), slot.getMonth(), slot.getDate());
return {
time: `${String(slot.getHours()).padStart(2, '0')}:${String(slot.getMinutes()).padStart(2, '0')}`,
dayOffset: Math.round((slotDay.getTime() - today.getTime()) / 86_400_000),
};
};
const suggestedPlayers = (game: Game): number => Math.max(2, Math.min(64, game.max_players ?? 8));
const dayChips = (): ReadonlyArray<{ offset: number; label: string }> => {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return [0, 1, 2].map(offset => {
const date = new Date();
date.setDate(date.getDate() + offset);
return { offset, label: offset === 0 ? 'Today' : days[date.getDay()] };
});
};
export const CreateNominationForm = ({ games, onCancel, onCreate }: Props) => {
const [initialSchedule] = useState(nextTimeSlot);
const [query, setQuery] = useState('');
const [gameId, setGameId] = useState<string | null>(null);
const [maxPlayers, setMaxPlayers] = useState(8);
const [duration, setDuration] = useState(10);
const [when, setWhen] = useState<'now' | 'later'>('now');
const [dayOffset, setDayOffset] = useState(initialSchedule.dayOffset);
const [time, setTime] = useState(initialSchedule.time);
const [editingTime, setEditingTime] = useState(false);
const [timeDraft, setTimeDraft] = useState(time);
const timeInputRef = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
if (editingTime) {
timeInputRef.current?.focus();
timeInputRef.current?.select();
}
}, [editingTime]);
const selected = games.find(game => game.id === gameId);
const matches = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return [];
return games.filter(game => game.name.toLocaleLowerCase().includes(needle)).slice(0, 6);
}, [games, query]);
const days = dayChips();
const pickGame = (game: Game) => {
setGameId(game.id);
setMaxPlayers(suggestedPlayers(game));
setQuery(game.name);
};
const scheduledTimestamp = (): number => {
const [hours, minutes] = time.split(':').map(Number);
const date = new Date();
date.setDate(date.getDate() + dayOffset);
date.setHours(hours, minutes, 0, 0);
return date.getTime();
};
const scheduledFor = scheduledTimestamp();
const scheduledTimeIsFuture = scheduledFor >= Date.now() + 60_000;
const commitTimeDraft = () => {
const normalized = normalizeTimeInput(timeDraft);
if (normalized) setTime(normalized);
setEditingTime(false);
};
return (
<div className="ctp-create">
<div className="ctp-create-row">
<label className="ctp-create-label" htmlFor="ctp-game-search">Game</label>
<div className="search ctp-create-search">
<Icon.search />
<input
id="ctp-game-search"
type="text"
placeholder="Search the catalog…"
autoComplete="off"
value={query}
onChange={event => {
setQuery(event.target.value);
setGameId(null);
}}
/>
</div>
{!selected && query.trim() && (
<div className="ctp-create-matches">
{matches.map(game => (
<button key={game.id} onClick={() => pickGame(game)}>
<span>{game.name}</span>
<span className="ctp-create-match-meta">
up to {suggestedPlayers(game)} players
</span>
</button>
))}
{matches.length === 0 && (
<div className="ctp-create-nomatch">No games match {query}</div>
)}
</div>
)}
</div>
{selected && (
<>
<div className="ctp-create-row ctp-create-row-inline">
<label className="ctp-create-label" htmlFor="ctp-max-players">Max players</label>
<input
id="ctp-max-players"
type="number"
className="ctp-create-num"
min={2}
max={64}
value={maxPlayers}
onChange={event => setMaxPlayers(
Math.max(2, Math.min(64, Number(event.target.value) || 2)),
)}
/>
<span className="ctp-create-hint">
Suggested for {selected.name}: {suggestedPlayers(selected)}
</span>
</div>
<div className="ctp-create-row">
<label className="ctp-create-label">When</label>
<div className="ctp-duration-opts">
<button
className={`ctp-duration-btn ${when === 'now' ? 'is-active' : ''}`}
onClick={() => setWhen('now')}
>Now</button>
<button
className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
onClick={() => setWhen('later')}
>Schedule</button>
</div>
{when === 'later' && (
<>
<div className="ctp-timepick">
{editingTime ? (
<input
ref={timeInputRef}
className="ctp-time-editinput"
type="text"
inputMode="numeric"
maxLength={5}
placeholder="20:00"
value={timeDraft}
onChange={event => setTimeDraft(previous =>
sanitizeTimeDraft(event.target.value, previous)
)}
onBlur={commitTimeDraft}
onKeyDown={event => {
if (event.key === 'Enter') commitTimeDraft();
if (event.key === 'Escape') setEditingTime(false);
}}
/>
) : (
<>
<div className="ctp-timepick-field">
<button
type="button"
className="ctp-time-step"
aria-label="Hour up"
onClick={() => setTime(value => bumpTime(value, 'hours', 1))}
><Icon.caretUp /></button>
<span className="ctp-time-cell">{time.slice(0, 2)}</span>
<button
type="button"
className="ctp-time-step"
aria-label="Hour down"
onClick={() => setTime(value => bumpTime(value, 'hours', -1))}
><Icon.caretDown /></button>
</div>
<span className="ctp-time-colon">:</span>
<div className="ctp-timepick-field">
<button
type="button"
className="ctp-time-step"
aria-label="Minute up"
onClick={() => setTime(value => bumpTime(value, 'minutes', 15))}
><Icon.caretUp /></button>
<span className="ctp-time-cell">{time.slice(3)}</span>
<button
type="button"
className="ctp-time-step"
aria-label="Minute down"
onClick={() => setTime(value => bumpTime(value, 'minutes', -15))}
><Icon.caretDown /></button>
</div>
<button
type="button"
className="ctp-time-type"
onClick={() => {
setTimeDraft(time);
setEditingTime(true);
}}
>Type a time</button>
</>
)}
</div>
<div className="ctp-time-row ctp-day-row">
<span className="ctp-day-label">Day</span>
{days.map(day => (
<button
key={day.offset}
className={`ctp-duration-btn ctp-day-btn ${dayOffset === day.offset ? 'is-active' : ''}`}
onClick={() => setDayOffset(day.offset)}
>{day.label}</button>
))}
</div>
<span className="ctp-create-hint">
{scheduledTimeIsFuture
? '24-hour clock. Check-in to ready up opens 15 min before start.'
: 'Choose a time at least one minute from now.'}
</span>
</>
)}
</div>
{when === 'now' && (
<div className="ctp-create-row">
<label className="ctp-create-label">Give people</label>
<div className="ctp-duration-opts">
{[5, 10, 15, 30, 60].map(minutes => (
<button
key={minutes}
className={`ctp-duration-btn ${duration === minutes ? 'is-active' : ''}`}
onClick={() => setDuration(minutes)}
>{minutes}m</button>
))}
</div>
</div>
)}
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
<button
className="act-btn act-play"
disabled={when === 'later' && !scheduledTimeIsFuture}
onClick={() => onCreate(
selected.id,
maxPlayers,
duration,
when === 'later' ? scheduledFor : null,
)}
>
{when === 'later'
? `Schedule it — ${selected.name} · ${dayOffset > 0 ? `${days[dayOffset].label} ` : ''}${time}`
: `Call it — ${selected.name}`}
</button>
</div>
</>
)}
{!selected && (
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { avatarColor, formatClock } from '../../lib/callToPlay';
import { Nomination } from '../../lib/types';
interface Props {
nomination: Nomination;
actorId: string | null;
disabled: boolean;
onSend: (text: string) => void;
}
export const CtpChat = ({ nomination, actorId, disabled, onSend }: Props) => {
const [open, setOpen] = useState(false);
const [seen, setSeen] = useState(nomination.messages.length);
const [draft, setDraft] = useState('');
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) setSeen(nomination.messages.length);
}, [open, nomination.messages.length]);
useEffect(() => {
if (open && listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
}, [open, nomination.messages.length]);
const unread = Math.max(0, nomination.messages.length - seen);
const last = nomination.messages[nomination.messages.length - 1];
const submit = () => {
const text = draft.trim();
if (!text) return;
onSend(text);
setDraft('');
};
return (
<div className={`ctp-chat ${open ? 'is-open' : ''}`}>
<button className="ctp-chat-toggle" onClick={() => setOpen(value => !value)}>
<Icon.chat />
<span>Chat</span>
{nomination.messages.length > 0 && (
<span className="ctp-chat-count">{nomination.messages.length}</span>
)}
{!open && unread > 0 && <span className="ctp-chat-unread">{unread}</span>}
{!open && last && (
<span className="ctp-chat-preview">
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b> {last.text}
</span>
)}
<span className="ctp-chat-chevron"><Icon.chevron /></span>
</button>
{open && (
<>
<div className="ctp-chat-list" ref={listRef}>
{nomination.messages.length === 0 && (
<div className="ctp-chat-empty">No messages yet say hi.</div>
)}
{nomination.messages.map(message => (
<div
key={message.id}
className={`ctp-chat-msg ${message.fromId === actorId ? 'is-me' : ''}`}
>
<b style={{ color: avatarColor(message.from) }}>{message.from}</b>
<span className="ctp-chat-time">{formatClock(message.at)}</span>
<span className="ctp-chat-text"> {message.text}</span>
</div>
))}
</div>
{!disabled && (
<div className="ctp-chat-form">
<input
type="text"
className="ctp-chat-input"
placeholder="Message the group…"
maxLength={500}
value={draft}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') submit();
}}
/>
<button className="ctp-chat-send" onClick={submit} aria-label="Send">
<Icon.send />
</button>
</div>
)}
</>
)}
</div>
);
};
@@ -0,0 +1,401 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { GameCover } from '../grid/GameCover';
import { CtpChat } from './CtpChat';
import { CallToPlayActions } from '../../hooks/useCallToPlay';
import {
avatarColor,
CHECKIN_LEAD_MS,
formatClock,
formatCountdown,
formatCountdownShort,
formatUntil,
inCountOf,
isReady,
isTerminal,
phaseOf,
readyCountOf,
statusOf,
} from '../../lib/callToPlay';
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
interface Props {
nomination: Nomination;
game: Game | null;
actorId: string | null;
actions: CallToPlayActions;
focused: boolean;
thumbnailUrl?: string | null;
totalPeerCount: number;
onLaunch: (game: Game) => void;
}
const AvatarChip = ({
name,
participant,
now,
}: {
name: string;
participant: CallToPlayParticipant;
now: number;
}) => {
const ready = isReady(participant, now);
const isIn = !ready && participant.status === 'in';
const remaining = Math.max(0, (participant.readyAt ?? now) - now);
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
return (
<div
className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`}
title={`${name}${ready ? 'ready' : isIn ? 'in, not checked in' : `ready in ${formatCountdown(remaining)}`}`}
>
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
{ready
? <span className="ctp-avatar-check"><Icon.check /></span>
: isIn
? <span className="ctp-avatar-in">in</span>
: <span className="ctp-avatar-pending">{formatCountdownShort(remaining)}</span>}
</div>
);
};
export const NominationCard = ({
nomination,
game,
actorId,
actions,
focused,
thumbnailUrl,
totalPeerCount,
onLaunch,
}: Props) => {
const [confirmCancel, setConfirmCancel] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, [focused]);
const now = Date.now();
const entries = Object.entries(nomination.participants);
const readyCount = readyCountOf(nomination, now);
const inCount = inCountOf(nomination, now);
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
const isMe = myStatus !== undefined;
const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done';
const terminal = isTerminal(nomination);
const isRunning = nomination.state === 'running';
const isCancelled = nomination.state === 'cancelled';
const isExpired = statusOf(nomination, now) === 'expired';
const phase = phaseOf(nomination, now);
const isScheduled = phase === 'scheduled' && !isDone && !terminal && !isExpired;
const isCheckin = phase === 'checkin' && !isDone && !terminal && !isExpired;
const windowStart = nomination.scheduledFor === null
? nomination.createdAt
: nomination.scheduledFor - CHECKIN_LEAD_MS;
const remaining = Math.max(0, nomination.deadline - now);
const percentage = Math.max(
0,
Math.min(100, (remaining / Math.max(1, nomination.deadline - windowStart)) * 100),
);
const urgency = percentage < 20 ? 'high' : percentage < 50 ? 'mid' : 'low';
const installedCount = game === null
? 0
: (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
const lanCount = Math.max(totalPeerCount + 1, installedCount);
const timer = isRunning
? <div className="ctp-card-timer" data-urgency="off">Running</div>
: isCancelled
? <div className="ctp-card-timer" data-urgency="off">Cancelled</div>
: isExpired
? <div className="ctp-card-timer" data-urgency="high">Times up</div>
: isDone
? <div className="ctp-card-timer" data-urgency="off">Ready</div>
: isScheduled
? (
<div className="ctp-card-timer is-sched" data-urgency="off">
<span className="ctp-card-clock">{formatClock(nomination.scheduledFor!)}</span>
<span className="ctp-card-until">{formatUntil(nomination.scheduledFor! - now)}</span>
</div>
)
: isCheckin
? (
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
<span className="ctp-card-clock">{formatCountdown(remaining)}</span>
<span className="ctp-card-until">starts {formatClock(nomination.scheduledFor!)}</span>
</div>
)
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
const rosterLabel = terminal
? `${entries.length} players`
: isScheduled
? `${entries.length} in · up to ${nomination.maxPlayers} players`
: isCheckin && inCount > 0
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
: `${readyCount}/${nomination.maxPlayers} ready`;
return (
<div
ref={cardRef}
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${terminal ? 'is-terminal' : ''} ${isRunning ? 'is-running' : ''} ${isCancelled ? 'is-cancelled' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
>
<div className="ctp-card-top">
<div className="ctp-card-cover">
{game
? <GameCover game={game} aspect="square" thumbnailUrl={thumbnailUrl} />
: <div className="ctp-card-cover-missing"><Icon.flag /></div>}
</div>
<div className="ctp-card-info">
<div className="ctp-card-title">{game?.name ?? 'Game unavailable here'}</div>
<div className="ctp-card-sub">
{nomination.scheduledFor === null ? 'Called' : 'Scheduled'} by{' '}
<strong>{nomination.creator}</strong>
{nomination.scheduledFor !== null && ` · starts at ${formatClock(nomination.scheduledFor)}`}
{game
? ` · ${installedCount}/${lanCount} peers have it installed`
: ` · this game is not in your current library (ID: ${nomination.gameId})`}
</div>
</div>
{timer}
</div>
{isCheckin && (
<div className="ctp-checkin-note">
<Icon.clock />
<span>{isMe && myStatus.status === 'in'
? 'Starting soon — you said youre in. Check in below.'
: 'Starting soon — check-in is open.'}</span>
</div>
)}
{!isScheduled && (
<div className="ctp-progress">
<div
className="ctp-progress-fill"
style={{
width: `${terminal || isDone ? 100 : percentage}%`,
background: isExpired || isCancelled
? 'var(--danger)'
: terminal || isDone
? 'var(--ok)'
: 'var(--accent)',
}}
/>
</div>
)}
<div className="ctp-roster">
<div className="ctp-roster-count">{rosterLabel}</div>
<div className="ctp-avatars">
{entries.map(([participantId, participant]) => (
<AvatarChip
key={participantId}
name={participant.name}
participant={participant}
now={now}
/>
))}
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
.map((_, index) => (
<div key={index} className="ctp-avatar ctp-avatar-empty" />
))}
</div>
</div>
<div className="ctp-actions">
<CardActions
nomination={nomination}
game={game}
actorId={actorId}
actions={actions}
onLaunch={onLaunch}
now={now}
/>
</div>
<CtpChat
nomination={nomination}
actorId={actorId}
disabled={terminal}
onSend={text => actions.sendMessage(nomination.id, text)}
/>
{isCreator && !terminal && (
confirmCancel
? (
<div className="ctp-cancel-confirm">
<span>Cancel this call for everyone?</span>
<div className="ctp-cancel-confirm-btns">
<button
className="ghost-btn ghost-danger"
onClick={() => actions.cancel(nomination.id)}
>Yes, cancel it</button>
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>
No, keep it
</button>
</div>
</div>
)
: (
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
<Icon.trash /><span>Cancel this call</span>
</button>
)
)}
</div>
);
};
interface CardActionsProps {
nomination: Nomination;
game: Game | null;
actorId: string | null;
actions: CallToPlayActions;
onLaunch: (game: Game) => void;
now: number;
}
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
nomination: Nomination;
actions: CallToPlayActions;
includeThirty?: boolean;
}) => (
<>
<button className="act-btn act-play" onClick={() => actions.respond(nomination.id, 'ready')}>
<Icon.play /><span>Ready now</span>
</button>
<div className="ctp-buffer-group">
{[5, 10, 15, ...(includeThirty ? [30] : [])].map(minutes => (
<button
key={minutes}
className="ctp-buffer-btn"
onClick={() => actions.respond(nomination.id, minutes)}
>+{minutes}m</button>
))}
</div>
</>
);
const CardActions = ({
nomination,
game,
actorId,
actions,
onLaunch,
now,
}: CardActionsProps) => {
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
const isMe = myStatus !== undefined;
const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done';
const terminal = isTerminal(nomination);
const isExpired = statusOf(nomination, now) === 'expired';
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
const readyCount = readyCountOf(nomination, now);
if (terminal) {
return (
<div className="ctp-note">
{nomination.state === 'running'
? game
? `${game.name} is running.`
: 'The match is running.'
: 'This call was cancelled.'}
</div>
);
}
if (scheduled) {
if (isCreator) {
return (
<div className="ctp-note">
Scheduled for {formatClock(nomination.scheduledFor!)} check-in opens 15 min before start.
</div>
);
}
if (isMe) {
return (
<>
<div className="ctp-me-status">Youre in well nudge you when check-in opens</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Cant make it
</button>
</>
);
}
return (
<>
<button className="act-btn act-play" onClick={() => actions.rsvp(nomination.id)}>
<Icon.check /><span>Im in</span>
</button>
<div className="ctp-note">Check-in opens 15 min before start</div>
</>
);
}
if (isCreator) {
if (isDone) {
return (
<>
<button
className="act-btn act-play"
onClick={() => void actions.startNow(nomination.id).then(accepted => {
if (accepted && game) onLaunch(game);
})}
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
<button
className="ghost-btn"
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
>
Add 5 more minutes
</button>
</>
);
}
if (myStatus?.status === 'in') {
return <ReadyButtons nomination={nomination} actions={actions} />;
}
return <div className="ctp-note">Waiting for players to ready up</div>;
}
if (isDone) {
return (
<div className="ctp-note">
{isExpired
? `Times up — waiting for ${nomination.creator} to start or extend the call.`
: readyCount >= nomination.maxPlayers
? 'Everyones ready.'
: `Its time — waiting for ${nomination.creator} to start.`}
</div>
);
}
if (isMe) {
if (myStatus.status === 'in') {
return (
<>
<div className="ctp-me-status">You said youre in check in:</div>
<ReadyButtons nomination={nomination} actions={actions} />
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Leave
</button>
</>
);
}
const ready = isReady(myStatus, now);
return (
<>
<div className="ctp-me-status">
{ready ? 'Youre in — ready' : `You: ready in ${formatCountdown((myStatus.readyAt ?? now) - now)}`}
</div>
{!ready && (
<button className="ghost-btn" onClick={() => actions.respond(nomination.id, 'ready')}>
Im ready now
</button>
)}
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Leave
</button>
</>
);
}
return <ReadyButtons nomination={nomination} actions={actions} includeThirty />;
};
@@ -3,9 +3,10 @@ import { SegmentedFilters } from './SegmentedFilters';
import { SearchField } from './SearchField';
import { SortMenu } from './SortMenu';
import { KebabMenu, KebabItem } from './KebabMenu';
import { CallToPlayButton } from '../calltoplay/CallToPlayButton';
import { FilterCounts } from '../../lib/gameState';
import { GameFilter, GameSort } from '../../lib/types';
import { GameFilter, GameSort, Nomination } from '../../lib/types';
interface Props {
accent: string;
@@ -18,6 +19,8 @@ interface Props {
sort: GameSort;
setSort: (value: GameSort) => void;
kebabItems: ReadonlyArray<KebabItem>;
nominations: ReadonlyArray<Nomination>;
onOpenCallToPlay: () => void;
}
export const TopBar = ({
@@ -31,6 +34,8 @@ export const TopBar = ({
sort,
setSort,
kebabItems,
nominations,
onOpenCallToPlay,
}: Props) => (
<header className="topbar">
<div className="topbar-left">
@@ -47,6 +52,7 @@ export const TopBar = ({
<SortMenu value={sort} onChange={setSort} />
</div>
<div className="topbar-right-tail">
<CallToPlayButton nominations={nominations} onClick={onOpenCallToPlay} />
<KebabMenu items={kebabItems} />
</div>
</div>
@@ -0,0 +1,200 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import {
CALL_TO_PLAY_CONNECTING_MESSAGE,
callToPlayEvent,
callToPlayPublishErrorMessage,
extendDeadline,
pruneCallToPlayEvents,
reduceCallToPlayEvents,
} from '../lib/callToPlay';
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
export interface CallToPlayActions {
createNomination: (
gameId: string,
maxPlayers: number,
durationMinutes: number,
scheduledFor: number | null,
) => void;
respond: (callId: string, choice: 'ready' | number) => void;
rsvp: (callId: string) => void;
sendMessage: (callId: string, text: string) => void;
leave: (callId: string) => void;
cancel: (callId: string) => void;
startNow: (callId: string) => Promise<boolean>;
addTime: (callId: string, currentDeadline: number, minutes?: number) => void;
}
export interface UseCallToPlay {
nominations: Nomination[];
actions: CallToPlayActions;
actorId: string | null;
transportReady: boolean;
error: string | null;
}
const mergeEvents = (
previous: ReadonlyMap<string, CallToPlayEvent>,
incoming: ReadonlyArray<CallToPlayEvent>,
): Map<string, CallToPlayEvent> => {
const next = new Map(previous);
for (const event of incoming) next.set(event.id, event);
return next;
};
export const useCallToPlay = (username: string): UseCallToPlay => {
const actor = useMemo(() => {
const trimmed = username.trim();
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
}, [username]);
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
const [actorId, setActorId] = useState<string | null>(null);
const [now, setNow] = useState(Date.now());
const [transportReady, setTransportReady] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const timer = window.setInterval(() => {
const current = Date.now();
setNow(current);
setEvents(previous => pruneCallToPlayEvents(previous, current));
}, 1_000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let cancelled = false;
let unlisten: UnlistenFn | undefined;
let retry: number | undefined;
const requestSnapshot = async (): Promise<boolean> => {
try {
const peerId = await invoke<string | null>('request_call_to_play_events');
if (cancelled) return false;
const ready = peerId !== null;
setActorId(peerId);
setTransportReady(ready);
if (ready) {
setError(current =>
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
);
}
if (ready && retry !== undefined) {
window.clearInterval(retry);
retry = undefined;
}
return ready;
} catch (err) {
if (!cancelled) {
setTransportReady(false);
console.error('request_call_to_play_events failed:', err);
}
return false;
}
};
const register = async () => {
try {
unlisten = await listen<CallToPlayEvent[]>('call-to-play-events', event => {
setEvents(previous => mergeEvents(previous, event.payload));
});
if (cancelled) {
unlisten();
return;
}
const ready = await requestSnapshot();
if (!cancelled && !ready) {
retry = window.setInterval(() => void requestSnapshot(), 2_000);
}
} catch (err) {
console.error('Failed to register Call to Play listener:', err);
if (!cancelled) {
setTransportReady(false);
setError('Call to Play networking is unavailable.');
}
}
};
void register();
return () => {
cancelled = true;
unlisten?.();
if (retry !== undefined) window.clearInterval(retry);
};
}, []);
const publish = useCallback(async (
callId: string,
action: CallToPlayAction,
): Promise<boolean> => {
if (actorId === null) {
setTransportReady(false);
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
return false;
}
const event = callToPlayEvent(callId, actorId, actor, action);
try {
const accepted = await invoke<boolean>('publish_call_to_play', { event });
if (!accepted) {
setTransportReady(false);
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
return false;
}
setTransportReady(true);
setError(null);
return true;
} catch (err) {
console.error('publish_call_to_play failed:', err);
setError(callToPlayPublishErrorMessage(err));
return false;
}
}, [actor, actorId]);
const actions = useMemo<CallToPlayActions>(() => ({
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
const createdAt = Date.now();
const callId = globalThis.crypto.randomUUID();
const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000;
void publish(callId, {
Create: {
game_id: gameId,
max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))),
scheduled_for: scheduledFor,
deadline,
},
});
},
respond: (callId, choice) => void publish(callId, {
Respond: {
ready_at: choice === 'ready' ? null : Date.now() + choice * 60_000,
},
}),
rsvp: callId => void publish(callId, 'Rsvp'),
sendMessage: (callId, text) => {
const trimmed = text.trim().slice(0, 500);
if (!trimmed) return;
void publish(callId, {
SendMessage: {
message_id: globalThis.crypto.randomUUID(),
text: trimmed,
},
});
},
leave: callId => void publish(callId, 'Leave'),
cancel: callId => void publish(callId, 'Cancel'),
startNow: callId => publish(callId, 'Start'),
addTime: (callId, currentDeadline, minutes = 5) => void publish(callId, {
AddTime: { deadline: extendDeadline(Date.now(), currentDeadline, minutes) },
}),
}), [publish]);
const nominations = useMemo(
() => reduceCallToPlayEvents([...events.values()], now),
[events, now],
);
return { nominations, actions, actorId, transportReady, error };
};
@@ -0,0 +1,383 @@
import {
CallToPlayAction,
CallToPlayEvent,
CallToPlayParticipant,
Nomination,
} from './types';
export const CHECKIN_LEAD_MS = 15 * 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 CallToPlayStatus =
| 'running'
| 'cancelled'
| 'expired'
| 'ready'
| 'soon'
| 'scheduled'
| 'call';
interface MutableNomination extends Nomination {
messageIds: Set<string>;
}
const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number =>
a.at - b.at || a.id.localeCompare(b.id);
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
type RespondPayload = Extract<CallToPlayAction, { Respond: unknown }>['Respond'];
type MessagePayload = Extract<CallToPlayAction, { SendMessage: unknown }>['SendMessage'];
type AddTimePayload = Extract<CallToPlayAction, { AddTime: unknown }>['AddTime'];
const createPayload = (action: CallToPlayAction): CreatePayload | null =>
typeof action === 'object' && 'Create' in action ? action.Create : null;
const respondPayload = (action: CallToPlayAction): RespondPayload | null =>
typeof action === 'object' && 'Respond' in action ? action.Respond : null;
const messagePayload = (action: CallToPlayAction): MessagePayload | null =>
typeof action === 'object' && 'SendMessage' in action ? action.SendMessage : null;
const addTimePayload = (action: CallToPlayAction): AddTimePayload | null =>
typeof action === 'object' && 'AddTime' in action ? action.AddTime : null;
export const phaseOf = (nomination: Nomination, now: number): CallToPlayPhase => {
if (nomination.scheduledFor === null) return 'now';
return now < nomination.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
};
export const isReady = (
participant: CallToPlayParticipant | undefined,
now: number,
): boolean => Boolean(
participant && (
participant.status === 'ready'
|| (participant.readyAt !== undefined && now >= participant.readyAt)
),
);
export const readyCountOf = (nomination: Nomination, now: number): number =>
Object.values(nomination.participants).filter(participant => isReady(participant, now)).length;
export const inCountOf = (nomination: Nomination, now: number): number =>
Object.values(nomination.participants).filter(participant =>
!isReady(participant, now) && participant.status === 'in'
).length;
export const isTerminal = (nomination: Nomination): boolean =>
nomination.state === 'running' || nomination.state === 'cancelled';
export const activeCallCount = (nominations: ReadonlyArray<Nomination>): number =>
nominations.filter(nomination => !isTerminal(nomination)).length;
export const sortNominations = (
nominations: ReadonlyArray<Nomination>,
): Nomination[] => [...nominations].sort((left, right) => {
const terminalRank = Number(isTerminal(left)) - Number(isTerminal(right));
if (terminalRank !== 0) return terminalRank;
if (isTerminal(left) && isTerminal(right)) {
return (right.terminalAt ?? 0) - (left.terminalAt ?? 0)
|| left.id.localeCompare(right.id);
}
return left.deadline - right.deadline
|| right.createdAt - left.createdAt
|| left.id.localeCompare(right.id);
});
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
if (nomination.state === 'running' || nomination.state === 'cancelled') {
return nomination.state;
}
if (now >= nomination.deadline) return 'expired';
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
return 'ready';
}
if (nomination.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
return nomination.scheduledFor === null ? 'call' : 'scheduled';
};
export const reduceCallToPlayEvents = (
input: ReadonlyArray<CallToPlayEvent>,
now: number,
): Nomination[] => {
const nominations = [...groupEvents(input).values()]
.map(events => deriveNomination(events, now))
.filter((nomination): nomination is Nomination => nomination !== null);
return sortNominations(nominations);
};
export const pruneCallToPlayEvents = (
previous: ReadonlyMap<string, CallToPlayEvent>,
now: number,
): ReadonlyMap<string, CallToPlayEvent> => {
const retiredEventIds = new Set<string>();
for (const events of groupEvents([...previous.values()]).values()) {
if (deriveNomination(events, now) !== null) continue;
const hasCreate = events.some(event => createPayload(event.action) !== null);
const expiredTombstone = events.some(event =>
(event.action === 'Start' || event.action === 'Cancel')
&& now - event.at > TERMINAL_RETENTION_MS
);
if (hasCreate || expiredTombstone) {
for (const event of events) retiredEventIds.add(event.id);
}
}
if (retiredEventIds.size === 0) return previous;
const next = new Map(previous);
for (const eventId of retiredEventIds) next.delete(eventId);
return next;
};
const groupEvents = (
input: ReadonlyArray<CallToPlayEvent>,
): Map<string, CallToPlayEvent[]> => {
const unique = new Map(input.map(event => [event.id, event]));
const byCall = new Map<string, CallToPlayEvent[]>();
for (const event of unique.values()) {
const events = byCall.get(event.call_id) ?? [];
events.push(event);
byCall.set(event.call_id, events);
}
return byCall;
};
const deriveNomination = (
events: CallToPlayEvent[],
now: number,
): Nomination | null => {
events.sort(compareEvents);
const create = events.find(event => createPayload(event.action) !== null);
if (!create) return null;
const payload = createPayload(create.action);
if (!payload) return null;
const nomination: MutableNomination = {
id: create.call_id,
gameId: payload.game_id,
creatorId: create.actor_id,
creator: create.actor_name,
maxPlayers: payload.max_players,
createdAt: create.at,
scheduledFor: payload.scheduled_for,
deadline: payload.deadline,
participants: {
[create.actor_id]: {
name: create.actor_name,
status: payload.scheduled_for === null ? 'ready' : 'in',
joinedAt: create.at,
},
},
messages: [],
state: 'open',
terminalAt: null,
messageIds: new Set(),
};
for (const event of events) {
if (compareEvents(event, create) > 0) applyEvent(nomination, event);
}
if (nomination.state === 'open'
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
) {
nomination.state = 'done';
}
if (isTerminal(nomination)) {
if (now - (nomination.terminalAt ?? now) > TERMINAL_RETENTION_MS) return null;
} else if (now - nomination.deadline > EXPIRED_RETENTION_MS) {
return null;
}
const { messageIds: _, ...result } = nomination;
return result;
};
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
if (isTerminal(nomination)) return;
const action = event.action;
if (typeof action === 'string') {
applyUnitAction(nomination, event, action);
return;
}
const response = respondPayload(action);
if (response) {
const existing = nomination.participants[event.actor_id];
nomination.participants[event.actor_id] = {
name: event.actor_name,
status: response.ready_at === null ? 'ready' : 'pending',
joinedAt: existing?.joinedAt ?? event.at,
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
};
return;
}
const message = messagePayload(action);
if (message && !nomination.messageIds.has(message.message_id)) {
nomination.messageIds.add(message.message_id);
nomination.messages.push({
id: message.message_id,
fromId: event.actor_id,
from: event.actor_name,
text: message.text,
at: event.at,
});
nomination.messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
return;
}
const extension = addTimePayload(action);
if (extension
&& event.actor_id === nomination.creatorId
) {
nomination.deadline = extension.deadline;
nomination.state = 'open';
}
};
const applyUnitAction = (
nomination: MutableNomination,
event: CallToPlayEvent,
action: Extract<CallToPlayAction, string>,
): void => {
switch (action) {
case 'Rsvp': {
const existing = nomination.participants[event.actor_id];
nomination.participants[event.actor_id] = {
name: event.actor_name,
status: 'in',
joinedAt: existing?.joinedAt ?? event.at,
};
break;
}
case 'Leave':
if (event.actor_id !== nomination.creatorId) {
delete nomination.participants[event.actor_id];
}
break;
case 'Cancel':
if (event.actor_id === nomination.creatorId) {
nomination.state = 'cancelled';
nomination.terminalAt = event.at;
}
break;
case 'Start':
if (event.actor_id === nomination.creatorId) {
nomination.state = 'running';
nomination.terminalAt = event.at;
}
break;
}
};
export const callToPlayEvent = (
callId: string,
actorId: string,
actorName: string,
action: CallToPlayAction,
at = Date.now(),
): CallToPlayEvent => ({
id: globalThis.crypto.randomUUID(),
call_id: callId,
actor_id: actorId,
actor_name: actorName,
at,
action,
});
export const formatClock = (timestamp: number): string => {
const date = new Date(timestamp);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
};
export const formatCountdown = (milliseconds: number): string => {
if (milliseconds <= 0) return '0:00';
const total = Math.ceil(milliseconds / 1_000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
};
export const formatCountdownShort = (milliseconds: number): string => {
if (milliseconds <= 0) return 'now';
const total = Math.ceil(milliseconds / 1_000);
return total < 60 ? `${total}s` : `${Math.ceil(total / 60)}m`;
};
export const formatUntil = (milliseconds: number): string => {
if (milliseconds <= 60_000) return 'in <1 min';
const minutes = Math.round(milliseconds / 60_000);
if (minutes < 60) return `in ${minutes} min`;
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return remainder ? `in ${hours}h ${remainder}m` : `in ${hours}h`;
};
const AVATAR_COLORS = [
'#60a5fa', '#34d399', '#c084fc', '#fbbf24',
'#f472b6', '#38bdf8', '#a3e635', '#fb7185',
];
export const avatarColor = (name: string): string => {
const hash = Array.from(name).reduce((sum, character) => sum + character.charCodeAt(0), 0);
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
};
export const sanitizeTimeDraft = (raw: string, previous: string): string => {
const value = raw.replace(/[^\d:]/g, '');
if ((value.match(/:/g) ?? []).length > 1) return previous;
const colon = value.indexOf(':');
if (colon !== -1) {
if (value.slice(0, colon).length > 2 || value.slice(colon + 1).length > 2) {
return previous;
}
return value;
}
return value.length > 4 ? previous : value;
};
export const normalizeTimeInput = (raw: string): string | null => {
const match = raw.trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2] ?? 0);
if (hours > 23 || minutes > 59) return null;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
};
export const bumpTime = (
time: string,
field: 'hours' | 'minutes',
delta: number,
): string => {
let [hours, minutes] = time.split(':').map(Number);
if (field === 'hours') {
hours = (hours + delta + 24) % 24;
} else {
const total = ((hours * 60 + minutes + delta) % 1_440 + 1_440) % 1_440;
hours = Math.floor(total / 60);
minutes = total % 60;
}
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
};
@@ -60,6 +60,7 @@ export interface Game {
peer_count: number;
can_host_server?: boolean;
active_outbound_transfers?: number;
installed_peer_count?: number;
}
export interface ActiveOperation {
@@ -83,3 +84,54 @@ export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'bus
/** Two-character language code passed through to game scripts. */
export type LauncherLanguage = 'en' | 'de';
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
export interface CallToPlayParticipant {
name: string;
status: CallToPlayParticipantStatus;
joinedAt: number;
readyAt?: number;
}
export interface CallToPlayMessage {
id: string;
fromId: string;
from: string;
text: string;
at: number;
}
export interface Nomination {
id: string;
gameId: string;
creatorId: string;
creator: string;
maxPlayers: number;
createdAt: number;
scheduledFor: number | null;
deadline: number;
participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[];
state: 'open' | 'done' | 'running' | 'cancelled';
terminalAt: number | null;
}
export type CallToPlayAction =
| { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } }
| { Respond: { ready_at: number | null } }
| 'Rsvp'
| { SendMessage: { message_id: string; text: string } }
| 'Leave'
| 'Cancel'
| 'Start'
| { AddTime: { deadline: number } };
export interface CallToPlayEvent {
id: string;
call_id: string;
actor_id: string;
actor_name: string;
at: number;
action: CallToPlayAction;
}
@@ -1783,3 +1783,682 @@
background: var(--accent);
border-color: transparent;
}
/* ═══════════════════════════════════════════════════════════════════
Call to Play — rally a game + a time
═══════════════════════════════════════════════════════════════════ */
/* ─── Top-bar entry button ─── */
.ctp-btn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background .15s, border-color .15s;
}
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
.ctp-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
}
.ctp-badge {
display: inline-grid; place-items: center;
min-width: 18px; height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 10.5px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* ─── Ticker strip ─── */
.ctp-ticker-stack {
display: flex; flex-direction: column; gap: 8px;
margin-bottom: 14px;
}
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
.ctp-ticker {
display: grid;
grid-template-columns:
10px /* LED */
98px /* status */
220px /* game */
130px /* by */
88px /* count */
170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */
82px; /* cta */
align-items: center;
column-gap: 12px;
width: 100%;
padding: 10px 16px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
border-radius: 10px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
cursor: pointer;
text-align: left;
transition: background .15s, border-color .15s;
}
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
.ctp-ticker-dot {
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
animation: ctp-tickerpulse 1.6s ease-out infinite;
}
@keyframes ctp-tickerpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-ticker-label {
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
font-size: 10.5px;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-sep { display: none; }
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-more {
color: var(--t-3);
font-size: 11.5px;
flex-shrink: 0;
}
.ctp-ticker-cta {
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
font-weight: 700;
color: var(--accent);
}
.ctp-ticker-cta svg { transform: rotate(-90deg); }
/* ─── Overlay modal ─── */
.ctp-modal { width: min(720px, 100%); }
.ctp-head-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
margin-right: 36px;
}
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
.ctp-body {
padding: 18px 24px 24px;
display: flex; flex-direction: column; gap: 14px;
max-height: 66vh;
overflow: auto;
}
.ctp-empty {
padding: 28px 8px;
text-align: center;
color: var(--t-3);
font-size: 13px;
}
/* ─── Nomination card ─── */
.ctp-card {
display: flex; flex-direction: column; gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1);
border-radius: 12px;
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); }
.ctp-card.is-terminal { opacity: 0.72; }
.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); }
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); }
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
@keyframes ctp-cardflash {
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
.ctp-card-cover {
position: relative;
width: 52px; height: 52px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.ctp-card-cover-missing {
width: 100%; height: 100%;
display: grid; place-items: center;
color: var(--t-3);
background: color-mix(in srgb, var(--bd-2) 55%, transparent);
border: 1px dashed var(--bd-2);
}
.ctp-card-cover-missing svg { width: 22px; height: 22px; }
.ctp-card-info { flex: 1; min-width: 0; }
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
.ctp-card-timer {
font-variant-numeric: tabular-nums;
font-size: 18px;
font-weight: 700;
color: var(--t-1);
flex-shrink: 0;
padding-top: 2px;
}
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
.ctp-card.is-expired .ctp-card-timer { color: var(--danger); }
.ctp-cancel-link {
display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start;
margin-top: -2px;
padding: 6px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .15s;
}
.ctp-cancel-link:hover { color: #fca5a5; }
.ctp-cancel-confirm {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.35);
border-radius: 8px;
font-size: 12.5px;
color: #fca5a5;
font-weight: 600;
}
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
.ctp-progress {
height: 5px;
border-radius: 3px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
.ctp-avatar {
position: relative;
display: inline-flex;
}
.ctp-avatar-dot {
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 999px;
color: white;
font-size: 10.5px; font-weight: 800;
letter-spacing: 0.01em;
}
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
.ctp-avatar-check {
position: absolute; bottom: -2px; right: -2px;
width: 14px; height: 14px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 2px solid var(--bg-2);
}
.ctp-avatar-pending {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 9px; font-weight: 700;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ctp-avatar-empty {
width: 30px; height: 30px;
border-radius: 999px;
border: 1.5px dashed var(--bd-2);
}
.ctp-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
padding-top: 2px;
}
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
.ctp-note { font-size: 12.5px; color: var(--t-3); }
.ctp-note-launch { color: var(--ok); font-weight: 600; }
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
.ctp-buffer-group { display: inline-flex; gap: 6px; }
.ctp-buffer-btn {
height: 36px; padding: 0 11px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
/* ─── Create-nomination form ─── */
.ctp-create-cta {
display: flex; align-items: center; justify-content: center; gap: 8px;
height: 48px;
background: transparent;
border: 1.5px dashed var(--bd-3);
border-radius: 12px;
color: var(--t-2);
font: inherit; font-size: 13px; font-weight: 700;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
.ctp-create {
display: flex; flex-direction: column; gap: 14px;
padding: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-2);
border-radius: 12px;
}
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.ctp-create-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
}
.ctp-create-search { width: 100%; }
.ctp-create-matches {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
z-index: 20;
max-height: 220px;
overflow: auto;
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
}
.ctp-create-matches button {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
text-align: left;
cursor: pointer;
}
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
.ctp-create-num {
width: 70px; height: 34px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 13px; font-weight: 600;
text-align: center;
}
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.ctp-duration-btn {
height: 32px; padding: 0 13px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.ctp-duration-btn:hover { color: var(--t-1); }
.ctp-duration-btn.is-active { color: white; }
.ctp-create-foot {
display: flex; justify-content: flex-end; gap: 10px;
padding-top: 4px;
}
.ctp-create-foot .act-btn,
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
/* ─── Time stepper ─── */
.ctp-timepick {
display: flex; align-items: center; gap: 6px;
margin-top: 6px;
}
.ctp-timepick-field {
display: flex; flex-direction: column; align-items: center; gap: 3px;
}
.ctp-time-step {
width: 54px; height: 24px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
.ctp-time-cell {
width: 54px; height: 44px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 9px;
font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
letter-spacing: 0.02em;
}
.ctp-time-editinput {
width: 132px; height: 44px;
padding: 0;
background: var(--bg-3);
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
border-radius: 9px;
font: inherit; font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
text-align: center;
letter-spacing: 0.06em;
}
.ctp-time-editinput:focus { outline: none; }
.ctp-time-editinput::placeholder { color: var(--t-4); }
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
.ctp-time-type {
align-self: center;
margin-left: 8px;
height: 30px; padding: 0 12px;
background: transparent;
border: 1px dashed var(--bd-3);
border-radius: 7px;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
.ctp-day-row { gap: 5px; }
.ctp-day-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
margin-right: 4px;
}
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
/* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
STARTING SOON (amber, glowing) · READY (green, steady) ·
TIME'S UP (red, steady) · RUNNING / CANCELLED (muted receipts) ─── */
.ctp-ticker[data-status="scheduled"] {
background: var(--bg-2);
border-color: var(--bd-2);
}
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
.ctp-ticker[data-status="soon"] {
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
animation: ctp-soon-glow 2.4s ease-in-out infinite;
}
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
@keyframes ctp-soon-glow {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
}
.ctp-ticker[data-status="ready"] {
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
}
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
.ctp-ticker[data-status="expired"] {
background: color-mix(in srgb, var(--danger) 8%, var(--bg-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="running"] {
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
}
.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); }
.ctp-ticker[data-status="cancelled"] {
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
}
.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); }
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; }
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; }
@keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
.ctp-ticker-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); }
.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); }
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
min-width: 0;
display: inline-flex; align-items: center; gap: 5px;
color: var(--t-3);
font-size: 12px;
}
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
.ctp-ticker-chat-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--t-2);
}
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
.ctp-checkin-note {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
border-radius: 8px;
font-size: 12.5px; font-weight: 600;
color: var(--t-1);
}
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
.ctp-card-timer.is-sched {
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
}
.ctp-card-clock { font-size: 18px; line-height: 1; }
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
.ctp-avatar-in {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
}
/* ─── Ticker mini ready-bubbles ─── */
.ctp-ticker-bubbles {
display: inline-flex; align-items: center;
justify-self: end;
}
.ctp-mini {
position: relative;
width: 22px; height: 22px;
display: grid; place-items: center;
border-radius: 999px;
margin-left: -6px;
border: 2px solid var(--bg-2);
color: white;
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
}
.ctp-mini:first-child { margin-left: 0; }
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
.ctp-mini[data-state="pending"] { opacity: 0.75; }
.ctp-mini[data-state="in"] { opacity: 0.85; }
.ctp-mini-check {
position: absolute; bottom: -3px; right: -4px;
width: 11px; height: 11px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 1.5px solid var(--bg-2);
}
.ctp-mini-check svg { width: 7px; height: 7px; }
.ctp-mini-tag {
position: absolute; bottom: -6px; right: -7px;
font-style: normal;
font-size: 8px; font-weight: 700;
line-height: 11px;
padding: 0 3px;
border-radius: 999px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
color: var(--t-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
/* ─── Per-call chat ─── */
.ctp-chat {
display: flex; flex-direction: column;
border-top: 1px solid var(--bd-1);
margin-top: 2px;
padding-top: 8px;
}
.ctp-chat-toggle {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding: 4px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 700;
cursor: pointer;
transition: color .15s;
}
.ctp-chat-toggle:hover { color: var(--t-1); }
.ctp-chat-toggle svg { flex-shrink: 0; }
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
.ctp-chat-unread {
min-width: 16px; height: 16px;
display: grid; place-items: center;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 9.5px; font-weight: 800;
flex-shrink: 0;
}
.ctp-chat-preview {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 500;
color: var(--t-3);
text-align: left;
}
.ctp-chat-preview b { font-weight: 700; }
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
.ctp-chat-list {
display: flex; flex-direction: column; gap: 6px;
max-height: 168px;
overflow-y: auto;
margin: 8px 0;
padding: 10px 12px;
background: rgba(0,0,0,0.22);
border: 1px solid var(--bd-1);
border-radius: 8px;
}
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
.ctp-chat-msg b { font-weight: 700; }
.ctp-chat-text { color: var(--t-1); }
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
.ctp-chat-form { display: flex; gap: 6px; }
.ctp-chat-input {
flex: 1; height: 32px;
padding: 0 10px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px;
}
.ctp-chat-input::placeholder { color: var(--t-4); }
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
.ctp-chat-send {
width: 32px; height: 32px;
display: grid; place-items: center;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 7px;
color: var(--t-1);
cursor: pointer;
flex-shrink: 0;
transition: background .15s;
}
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }
.ctp-transport-note {
margin-top: 10px;
font-size: 11.5px;
color: var(--t-3);
}
.ctp-transport-note.is-error { color: var(--danger); }
@container launcher (max-width: 1280px) {
.ctp-ticker {
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content 70px;
}
.ctp-ticker-by,
.ctp-ticker-chat { display: none; }
}
@container launcher (max-width: 800px) {
.ctp-ticker {
grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px;
}
.ctp-ticker-time,
.ctp-ticker-bubbles { display: none; }
}
@@ -10,12 +10,15 @@ import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDo
import { SettingsDialog } from '../components/modals/SettingsDialog';
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
import { useGameDirectory } from '../hooks/useGameDirectory';
import { useGames } from '../hooks/useGames';
import { useGameActions } from '../hooks/useGameActions';
import { useThumbnails } from '../hooks/useThumbnails';
import { useSettings } from '../hooks/useSettings';
import { useCallToPlay } from '../hooks/useCallToPlay';
import { Game } from '../lib/types';
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
@@ -72,10 +75,13 @@ export const MainWindow = () => {
const games = useGames(rescan);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails();
const callToPlay = useCallToPlay(settings.username);
const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory],
@@ -154,8 +160,22 @@ export const MainWindow = () => {
sort={settings.sort}
setSort={(v) => setSetting('sort', v)}
kebabItems={kebabItems}
nominations={callToPlay.nominations}
onOpenCallToPlay={() => {
setFocusedCallId(null);
setCallToPlayOpen(true);
}}
/>
<main className="grid-wrap">
<CallToPlayTicker
nominations={callToPlay.nominations}
games={games.games}
accent={settings.accent}
onOpen={(callId) => {
setFocusedCallId(callId);
setCallToPlayOpen(true);
}}
/>
{hasGameDirectory ? (
<>
<ResultsBar shown={filteredGames.length} total={counts.all} />
@@ -220,6 +240,25 @@ export const MainWindow = () => {
onClose={() => setSettingsOpen(false)}
/>
)}
{callToPlayOpen && (
<CallToPlayOverlay
nominations={callToPlay.nominations}
games={games.games}
actorId={callToPlay.actorId}
actions={callToPlay.actions}
focusId={focusedCallId}
transportReady={callToPlay.transportReady}
error={callToPlay.error}
getThumbnail={thumbnails.get}
totalPeerCount={games.totalPeerCount}
onLaunch={handlePrimary}
onClose={() => {
setCallToPlayOpen(false);
setFocusedCallId(null);
}}
/>
)}
</div>
);
};
@@ -0,0 +1,313 @@
import {
CALL_TO_PLAY_CONNECTING_MESSAGE,
CHECKIN_LEAD_MS,
EXPIRED_RETENTION_MS,
TERMINAL_RETENTION_MS,
activeCallCount,
callToPlayPublishErrorMessage,
extendDeadline,
phaseOf,
bumpTime,
normalizeTimeInput,
pruneCallToPlayEvents,
readyCountOf,
reduceCallToPlayEvents,
sortNominations,
statusOf,
} from '../src/lib/callToPlay.ts';
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
const NOW = 1_000_000;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
};
const event = (
id: string,
actorId: string,
action: CallToPlayAction,
at = NOW,
actorName = actorId,
): CallToPlayEvent => ({
id,
call_id: 'call-1',
actor_id: actorId,
actor_name: actorName,
at,
action,
});
const create = (
scheduledFor: number | null = null,
deadline = NOW + 30 * 60_000,
): CallToPlayEvent => event('create', 'Alice', {
Create: {
game_id: 'game-1',
max_players: 3,
scheduled_for: scheduledFor,
deadline,
},
});
Deno.test('play-now call starts with its creator ready', () => {
const [nomination] = reduceCallToPlayEvents([create()], NOW);
assert(nomination, 'call should exist');
assertEquals(nomination.creator, 'Alice', 'creator');
assertEquals(nomination.participants.Alice.status, 'ready', 'creator status');
assertEquals(phaseOf(nomination, NOW), 'now', 'phase');
assertEquals(statusOf(nomination, NOW), 'call', 'status outside starting-soon window');
});
Deno.test('scheduled RSVP becomes check-in and pending response becomes ready over time', () => {
const scheduledFor = NOW + 60 * 60_000;
const events = [
create(scheduledFor, scheduledFor),
event('rsvp', 'Bob', 'Rsvp', NOW + 1),
event('respond', 'Bob', { Respond: { ready_at: scheduledFor - 5 * 60_000 } }, NOW + 2),
];
const [far] = reduceCallToPlayEvents(events, NOW);
assertEquals(phaseOf(far, NOW), 'scheduled', 'far-out phase');
assertEquals(far.participants.Bob.status, 'pending', 'buffered response state');
const checkinNow = scheduledFor - CHECKIN_LEAD_MS;
const [checkin] = reduceCallToPlayEvents(events, checkinNow);
assertEquals(phaseOf(checkin, checkinNow), 'checkin', 'check-in phase');
assertEquals(readyCountOf(checkin, checkinNow), 0, 'nobody ready at check-in opening');
const [ready] = reduceCallToPlayEvents(events, scheduledFor - 4 * 60_000);
assertEquals(readyCountOf(ready, scheduledFor - 4 * 60_000), 1, 'elapsed buffer is ready');
});
Deno.test('call resolves when the roster fills or its deadline elapses', () => {
const full = [
create(),
event('bob', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('carol', 'Carol', { Respond: { ready_at: null } }, NOW + 2),
];
assertEquals(reduceCallToPlayEvents(full, NOW + 2)[0].state, 'done', 'full roster');
assertEquals(
reduceCallToPlayEvents([create()], NOW + 31 * 60_000)[0].state,
'done',
'elapsed deadline',
);
});
Deno.test('elapsed calls show as expired briefly and then disappear', () => {
const deadline = NOW + 30 * 60_000;
const events = [create(null, deadline)];
const [expired] = reduceCallToPlayEvents(events, deadline + 1);
assert(expired, 'freshly expired call remains visible');
assertEquals(statusOf(expired, deadline + 1), 'expired', 'elapsed status');
assertEquals(
reduceCallToPlayEvents(events, deadline + EXPIRED_RETENTION_MS + 1).length,
0,
'expired call retention',
);
});
Deno.test('creator-only controls cannot be forged by another participant', () => {
const forged = [
create(),
event('cancel', 'Mallory', 'Cancel', NOW + 1),
event('start', 'Mallory', 'Start', NOW + 2),
event('extend', 'Mallory', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 3),
];
const [nomination] = reduceCallToPlayEvents(forged, NOW + 4);
assert(nomination, 'forged cancel should not remove call');
assertEquals(nomination.state, 'open', 'forged start ignored');
assertEquals(nomination.deadline, NOW + 30 * 60_000, 'forged extension ignored');
});
Deno.test('stable peer ids keep duplicate display names distinct', () => {
const createByCommander = event('create', 'peer-a', {
Create: {
game_id: 'game-1',
max_players: 3,
scheduled_for: null,
deadline: NOW + 30 * 60_000,
},
}, NOW, 'Commander');
const events = [
createByCommander,
event('join', 'peer-b', { Respond: { ready_at: null } }, NOW + 1, 'Commander'),
event('forged-cancel', 'peer-b', 'Cancel', NOW + 2, 'Commander'),
];
const [nomination] = reduceCallToPlayEvents(events, NOW + 3);
assert(nomination, 'same-name participant must not cancel the call');
assertEquals(nomination.creatorId, 'peer-a', 'creator identity');
assertEquals(Object.keys(nomination.participants).length, 2, 'distinct peer participants');
});
Deno.test('creator can extend, start, and cancel a call', () => {
const extended = reduceCallToPlayEvents([
create(),
event('extend', 'Alice', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 1),
], NOW + 40 * 60_000)[0];
assertEquals(extended.state, 'open', 'extension reopens an elapsed call');
const started = reduceCallToPlayEvents([
create(),
event('start', 'Alice', 'Start', NOW + 1),
], NOW + 2)[0];
assertEquals(started.state, 'running', 'creator start');
assertEquals(started.terminalAt, NOW + 1, 'running timestamp');
const [cancelled] = reduceCallToPlayEvents([
create(),
event('cancel', 'Alice', 'Cancel', NOW + 1),
], NOW + 2);
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', () => {
const message = event('message-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Ready?' },
}, NOW + 2);
const duplicateMessage = event('other-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'duplicate' },
}, NOW + 3);
const events = [message, create(), message, duplicateMessage];
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
assertEquals(nomination.messages.length, 1, 'unique message id');
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins');
});
Deno.test('actions timestamped before creation cannot mutate a call', () => {
const events = [
event('early-cancel', 'Alice', 'Cancel', NOW - 1),
event('early-response', 'Bob', { Respond: { ready_at: null } }, NOW - 1),
create(),
];
const [nomination] = reduceCallToPlayEvents(events, NOW + 1);
assert(nomination, 'call should survive a pre-creation cancel');
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
});
Deno.test('terminal calls retain complete read-only history for fifteen minutes', () => {
const events = [
create(),
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('message', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Launching' },
}, NOW + 2),
event('start', 'Alice', 'Start', NOW + 3),
event('late-message', 'Bob', {
SendMessage: { message_id: 'message-2', text: 'Too late' },
}, NOW + 4),
];
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
assertEquals(running.state, 'running', 'running receipt remains');
assertEquals(Object.keys(running.participants).length, 2, 'terminal roster retained');
assertEquals(running.messages.length, 1, 'pre-terminal chat retained');
assertEquals(
reduceCallToPlayEvents(events, NOW + 3 + TERMINAL_RETENTION_MS + 1).length,
0,
'terminal receipt retires after display window',
);
});
Deno.test('terminal calls sort last and do not contribute to the badge', () => {
const active = reduceCallToPlayEvents([create()], NOW)[0];
const running = reduceCallToPlayEvents([
create(),
event('start', 'Alice', 'Start', NOW + 1),
], NOW + 2)[0];
const cancelled = reduceCallToPlayEvents([
create(),
event('cancel', 'Alice', 'Cancel', NOW + 2),
], NOW + 3)[0];
const sorted = sortNominations([running, cancelled, active]);
assertEquals(sorted[0].state, 'open', 'actionable call sorts first');
assertEquals(activeCallCount(sorted), 1, 'terminal calls excluded from badge');
assertEquals(statusOf(running, NOW + 2), 'running', 'running ticker status');
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
});
Deno.test('retired calls are pruned from the frontend raw event map', () => {
const terminalEvents = [
create(),
event('start', 'Alice', 'Start', NOW + 1),
];
const map = new Map(terminalEvents.map(item => [item.id, item]));
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
2,
'visible terminal history stays cached',
);
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
0,
'retired terminal history is pruned',
);
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
assertEquals(
pruneCallToPlayEvents(
tombstoneMap,
NOW + 1 + TERMINAL_RETENTION_MS + 1,
).size,
0,
'backend tombstone is pruned too',
);
});
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
assertEquals(normalizeTimeInput('20:00'), '20:00', 'colon format');
assertEquals(normalizeTimeInput('2000'), '20:00', 'compact format');
assertEquals(normalizeTimeInput('9:30'), '09:30', 'single-digit hour');
assertEquals(normalizeTimeInput('24:00'), null, 'invalid hour');
assertEquals(bumpTime('23:45', 'minutes', 15), '00:00', 'minute wrap');
assertEquals(bumpTime('00:00', 'hours', -1), '23:00', 'hour wrap');
});
+45 -24
View File
@@ -98,7 +98,7 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
- **Right zone (col 3, flex space-between with two sub-groups):**
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active (non-started) calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active, non-terminal calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
@@ -457,12 +457,16 @@ flips into the `checkin` phase: it lights up everywhere and everyone who said
"I'm in" is nudged to answer with the same `Ready now` / `+N minutes` states as
a play-now call. A play-now call is effectively "always in its check-in window."
When the deadline passes, the call is labeled **Time's up** rather than Ready.
It remains visible for five minutes so the caller can start or extend it, then
the call and its history expire as a unit.
**Every call carries a small group chat** (see "Per-call chat" below).
### Three surfaces
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
an accent badge counting active (non-`started`) calls. Opens the overlay.
an accent badge counting active, non-terminal calls. Opens the overlay.
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
stack rendered at the top of the grid area, **one row per active call**.
Sorted **ready → starting-soon → the rest**, ties broken by whichever
@@ -470,7 +474,8 @@ a play-now call. A play-now call is effectively "always in its check-in window."
then by `deadline`). Clicking a row opens the overlay focused on that call.
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
the other dialogs) with a header, a **Call a new match** button, the create
form, and a list of **nomination cards** (started calls sink to the bottom).
form, and a list of **nomination cards** (Running and Cancelled calls sink
to the bottom).
### Status model
@@ -478,13 +483,16 @@ Two derived values drive everything (`calltoplay.jsx`):
- `phaseOf(call)``'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
`'checkin'` (within the 15-min lead).
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'ready'`
(`readyCount >= maxPlayers`, or state `done`) · `'soon'` (`deadline - now ≤
15 min`) · `'scheduled'` (has a clock time) · `'call'` (a plain play-now call).
- `statusOf(call)` (quick-bar/label status) → `'running'` · `'cancelled'` ·
`'expired'` (deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or
state `done`) · `'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a
clock time) · `'call'` (a plain play-now call).
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
SOON**, **READY** (`TICKER_LABEL`), each with its own dot color via
`.ctp-ticker-dot[data-status]`.
SOON**, **READY**, **TIME'S UP**, **RUNNING**, **CANCELLED** (`TICKER_LABEL`),
each with its own dot color via `.ctp-ticker-dot[data-status]`. Running and
Cancelled receipts remain for 15 minutes, sort after actionable calls, and do
not contribute to the top-bar badge.
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
@@ -497,7 +505,7 @@ as larger `AvatarChip`s in the nomination card roster.
Top to bottom:
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, or `Launching…`. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, `Running`, or `Cancelled`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
4. **Roster**`readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
@@ -505,6 +513,10 @@ Top to bottom:
6. **Chat** — the collapsible per-call chat panel.
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
Running and Cancelled cards are read-only: the complete roster and chat remain
visible, but participant controls, creator controls, chat composition, and
cancel actions are disabled.
### Create form (`CreateNominationForm`)
Game search (typeahead over the catalog) → on pick, max-players defaults to the
@@ -528,19 +540,21 @@ card. Usernames are colored deterministically by a hash of the name.
type Nomination = {
id: string;
gameId: string;
creator: string; // username of the caller
creatorId: string; // stable peer ID of the caller
creator: string; // display name of the caller
maxPlayers: number;
createdAt: number; // ms epoch
scheduledFor: number | null; // ms epoch clock time; null = play-now
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
participants: Record<string, { // keyed by username
participants: Record<string, { // keyed by stable peer ID
name: string; // current display name
status: 'ready' | 'in' | 'pending';
joinedAt: number;
readyAt?: number; // ms epoch a 'pending' buffer elapses
}>;
messages: { id: string; from: string; text: string; at: number }[];
state: 'open' | 'done' | 'started';
startedAt?: number;
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
state: 'open' | 'done' | 'running' | 'cancelled';
terminalAt: number | null;
};
```
@@ -548,7 +562,7 @@ The `useNominations({ username, seed })` hook owns the list and exposes
`createNomination`, `respond`, `rsvp`, `sendMessage`, `leave`, `cancel`,
`startNow`, `addTime`.
### Mock vs production
### Design reference vs production
The mock **simulates other people** with a 1-second `setInterval`
(`tickNomination`): bots RSVP to scheduled calls, ready-up during check-in, walk
@@ -558,14 +572,21 @@ The mock also seeds a few representative calls on mount (a live call with chat,
a fresh call you started, a scheduled call whose check-in window just opened,
and one scheduled for later collecting RSVPs).
**In production, replace the simulation with a real-time LAN transport:** the
launcher instances broadcast nominations, responses, RSVPs, and chat messages to
each other (whatever the app uses — a small pub/sub over the LAN, a lightweight
signaling server, or Tauri-side networking). Swap the guts of `useNominations`
(the `setInterval` + local `setNoms`) for your networking layer that pushes the
same state shape; the rendering components need nothing else. The `username` that
identifies "you" comes from `settings.username` (the Profile setting), not a
prop default.
The production launcher uses the peer's existing QUIC control channel. Each
create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
is an immutable, uniquely identified event. Connected peers receive new events
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
history so a late joiner reconstructs every event and chat message for active
calls. Running and Cancelled calls retain their complete history for 15 minutes
so late joiners can see the outcome, roster, and chat, then compact to a Start
or Cancel tombstone for the rest of the peer session. Unresolved calls are
removed after the five-minute post-deadline recovery period. The frontend
reducer turns that event history into the `Nomination` state above, derives
time-based phase changes locally, and prunes retired raw events. Stable peer IDs
identify actors and enforce creator controls; `settings.username` is only the
display name. These deadlines use event wall-clock timestamps, so LAN clocks
are assumed to be reasonably close; no clock-synchronization protocol is
attempted.
---
@@ -773,5 +794,5 @@ To preview the design in a browser:
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
- **Call to Play real-time transport** — the feature is fully designed and interactive, but the mock fakes other players with a local `setInterval` simulation. Production needs a real LAN transport that broadcasts nominations / responses / RSVPs / chat between launcher instances and pushes the same state shape into `useNominations`. Notifications when a call you're in enters its check-in window (OS notification / tray) are also a follow-up.
- **Call to Play notifications** — real-time LAN transport is implemented. OS / tray notifications when a call you're in enters its check-in window remain a follow-up.
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.