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],
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user