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:
@@ -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],
|
||||
*,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user