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
This commit is contained in:
2026-07-21 22:30:11 +02:00
parent 8f151e38b4
commit 0f53bc4b78
31 changed files with 3032 additions and 21 deletions
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Run the peer-cli scenarios S1-S47 through Docker."""
"""Run the peer-cli scenarios S1-S48 through Docker."""
from __future__ import annotations
@@ -242,6 +242,12 @@ class Peer:
def status(self) -> dict[str, Any]:
return self.send({"cmd": "status"})["data"]
def call_to_play_events(self) -> list[dict[str, Any]]:
return self.send({"cmd": "list-call-to-play"})["data"]["events"]
def publish_call_to_play(self, event: dict[str, Any]) -> None:
self.send({"cmd": "publish-call-to-play", "event": event})
def connect_to(self, other: "Peer") -> None:
if other.ready_addr is None:
raise ScenarioError(f"{other.name} is not ready")
@@ -350,6 +356,7 @@ class Runner:
("S45", self.s45_sender_disconnect_mid_stream),
("S46", self.s46_receiver_cancel_mid_stream),
("S47", self.s47_multi_archive_streams_in_sorted_order),
("S48", self.s48_call_to_play_replication_and_late_join),
]
for scenario_id, scenario in scenarios:
@@ -1757,6 +1764,63 @@ 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": "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": "Bob",
"at": now + 1,
"action": "Rsvp",
}
message = {
"id": "s48-message-event",
"call_id": "s48-call",
"actor": "Bob",
"at": now + 2,
"action": {
"SendMessage": {
"message_id": "s48-message",
"text": "I am in",
}
},
}
alice.publish_call_to_play(create)
wait_call_to_play_events(bob, {"s48-create"})
bob.publish_call_to_play(rsvp)
bob.publish_call_to_play(message)
wait_call_to_play_events(alice, {"s48-create", "s48-rsvp", "s48-message-event"})
charlie = self.peer("s48-charlie")
charlie.connect_to(alice)
events = wait_call_to_play_events(
charlie,
{"s48-create", "s48-rsvp", "s48-message-event"},
)
if len(events) != 3:
raise ScenarioError(f"late join history contains duplicates: {events}")
return "live create/RSVP/chat replicated and a late joiner received deduplicated history"
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
@@ -2034,6 +2098,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],
*,
+31 -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,20 @@ 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":"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");
}
#[tokio::test]
async fn fixture_unpacker_creates_install_payload() {
let temp = TempDir::new("lanspread-peer-cli-fixture");
+39
View File
@@ -16,6 +16,7 @@ use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
CallToPlayEvent,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
OutboundTransfers,
@@ -101,6 +102,8 @@ struct CliState {
game_files: HashMap<String, Vec<GameFileDescription>>,
unavailable_games: HashSet<String>,
downloads: HashMap<String, DownloadMeasurement>,
call_to_play_events: Vec<CallToPlayEvent>,
call_to_play_generation: u64,
}
#[derive(Clone, serde::Serialize)]
@@ -243,6 +246,26 @@ async fn handle_command(
CliCommand::Status => status(shared).await,
CliCommand::ListPeers => list_peers(shared).await,
CliCommand::ListGames => list_games(shared).await,
CliCommand::ListCallToPlay => {
let generation = shared.state.read().await.call_to_play_generation;
sender.send(PeerCommand::GetCallToPlayEvents)?;
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if shared.state.read().await.call_to_play_generation > generation {
break;
}
shared.notify.notified().await;
}
})
.await
.wrap_err("timed out waiting for Call to Play history")?;
let events = shared.state.read().await.call_to_play_events.clone();
Ok(json!({ "events": events }))
}
CliCommand::PublishCallToPlay { event } => {
sender.send(PeerCommand::PublishCallToPlay(event.clone()))?;
Ok(json!({"queued": true, "event_id": event.id}))
}
CliCommand::SetGameDir { path } => {
sender.send(PeerCommand::SetGameDir(path.clone()))?;
Ok(json!({"queued": true, "path": path}))
@@ -498,6 +521,22 @@ 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(),
);
state.call_to_play_generation = state.call_to_play_generation.saturating_add(1);
("call-to-play-events", json!({ "events": events }))
}
PeerEvent::GotGameFiles {
id,
file_descriptions,