From 29eacabcc06f9d0d99ac2761f4040a9d5bf8cb9f Mon Sep 17 00:00:00 2001 From: ddidderr Date: Tue, 21 Jul 2026 22:40:59 +0200 Subject: [PATCH] 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 --- .../scripts/run_extended_scenarios.py | 9 ++-- crates/lanspread-peer-cli/src/lib.rs | 3 +- crates/lanspread-peer/ARCHITECTURE.md | 7 +++ crates/lanspread-peer/src/call_to_play.rs | 17 +++++-- crates/lanspread-peer/src/network.rs | 10 +++- .../lanspread-peer/src/services/handshake.rs | 3 +- crates/lanspread-peer/src/services/stream.rs | 47 +++++++++++++++---- crates/lanspread-proto/src/lib.rs | 4 +- .../src-tauri/src/lib.rs | 15 ++++-- .../calltoplay/CallToPlayOverlay.tsx | 6 +-- .../calltoplay/CallToPlayTicker.tsx | 5 +- .../src/components/calltoplay/CtpChat.tsx | 6 +-- .../components/calltoplay/NominationCard.tsx | 29 +++++++----- .../src/hooks/useCallToPlay.ts | 18 +++++-- .../src/lib/callToPlay.ts | 38 +++++++++------ .../lanspread-tauri-deno-ts/src/lib/types.ts | 6 ++- .../src/windows/MainWindow.tsx | 2 +- .../tests/callToPlay.test.ts | 33 ++++++++++++- design/launcher/SPEC.md | 8 ++-- 19 files changed, 197 insertions(+), 69 deletions(-) diff --git a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py index 528c7a2..f064a22 100644 --- a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py +++ b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py @@ -1773,7 +1773,8 @@ class Runner: create = { "id": "s48-create", "call_id": "s48-call", - "actor": "Alice", + "actor_id": "", + "actor_name": "Alice", "at": now, "action": { "Create": { @@ -1787,14 +1788,16 @@ class Runner: rsvp = { "id": "s48-rsvp", "call_id": "s48-call", - "actor": "Bob", + "actor_id": "", + "actor_name": "Bob", "at": now + 1, "action": "Rsvp", } message = { "id": "s48-message-event", "call_id": "s48-call", - "actor": "Bob", + "actor_id": "", + "actor_name": "Bob", "at": now + 2, "action": { "SendMessage": { diff --git a/crates/lanspread-peer-cli/src/lib.rs b/crates/lanspread-peer-cli/src/lib.rs index 0f9d454..798774a 100644 --- a/crates/lanspread-peer-cli/src/lib.rs +++ b/crates/lanspread-peer-cli/src/lib.rs @@ -403,7 +403,7 @@ 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}}}}"#, + 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"); @@ -412,6 +412,7 @@ mod tests { }; assert_eq!(event.id, "event-1"); assert_eq!(event.call_id, "call-1"); + assert_eq!(event.actor_name, "Alice"); } #[tokio::test] diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index 4927eae..6bb9e5f 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -54,6 +54,13 @@ 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. +Actors are keyed by the peer's stable ID and carry a separate display name. The +origin peer overwrites the actor ID on local actions, and live-event envelopes +must match the known sending peer. This prevents duplicate default usernames +from merging participants and protects creator controls from other normal +clients. It is not authentication against a hostile LAN peer; the QUIC setup +uses the project's trusted-LAN identity 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 diff --git a/crates/lanspread-peer/src/call_to_play.rs b/crates/lanspread-peer/src/call_to_play.rs index 7fefbfb..5c102d5 100644 --- a/crates/lanspread-peer/src/call_to_play.rs +++ b/crates/lanspread-peer/src/call_to_play.rs @@ -54,8 +54,9 @@ impl CallToPlayStore { pub(crate) async fn publish( ctx: &Ctx, tx_notify_ui: &UnboundedSender, - event: CallToPlayEvent, + mut event: CallToPlayEvent, ) { + event.actor_id.clone_from(ctx.peer_id.as_ref()); match ctx.call_to_play.write().await.insert(event.clone()) { Ok(false) => return, Err(err) => { @@ -71,11 +72,15 @@ pub(crate) async fn publish( ); let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses(); + let peer_id = ctx.peer_id.clone(); 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(); async move { - if let Err(err) = send_call_to_play_events(peer_addr, vec![event]).await { + if let Err(err) = + send_call_to_play_events(peer_addr, peer_id.as_ref(), vec![event]).await + { log::warn!("Failed to send Call to Play event to {peer_addr}: {err}"); } } @@ -87,7 +92,8 @@ pub(crate) async fn publish( 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, MAX_USERNAME_CHARS, "invalid actor")?; + 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"); } @@ -154,7 +160,8 @@ mod tests { CallToPlayEvent { id: id.to_string(), call_id: "call-1".to_string(), - actor: "Alice".to_string(), + actor_id: "peer-alice".to_string(), + actor_name: "Alice".to_string(), at: 1_000, action: CallToPlayAction::Create { game_id: "game-1".to_string(), @@ -212,7 +219,7 @@ mod tests { .insert(create_event("event-1")) .expect("valid event should be inserted"); let mut invalid = create_event("invalid"); - invalid.actor.clear(); + invalid.actor_name.clear(); let accepted = store.insert_all(vec![ create_event("event-1"), diff --git a/crates/lanspread-peer/src/network.rs b/crates/lanspread-peer/src/network.rs index 7d08e20..c491fa1 100644 --- a/crates/lanspread-peer/src/network.rs +++ b/crates/lanspread-peer/src/network.rs @@ -175,9 +175,17 @@ pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Resul pub async fn send_call_to_play_events( peer_addr: SocketAddr, + peer_id: &str, events: Vec, ) -> eyre::Result<()> { - send_oneway_request(peer_addr, Request::CallToPlayEvents { events }).await + send_oneway_request( + peer_addr, + Request::CallToPlayEvents { + peer_id: peer_id.to_string(), + events, + }, + ) + .await } /// Requests game file details from a peer. diff --git a/crates/lanspread-peer/src/services/handshake.rs b/crates/lanspread-peer/src/services/handshake.rs index 005e6ef..96f09d2 100644 --- a/crates/lanspread-peer/src/services/handshake.rs +++ b/crates/lanspread-peer/src/services/handshake.rs @@ -313,7 +313,8 @@ mod tests { CallToPlayEvent { id: "event-1".to_string(), call_id: "call-1".to_string(), - actor: "Alice".to_string(), + actor_id: "peer-alice".to_string(), + actor_name: "Alice".to_string(), at: 1_000, action: CallToPlayAction::Create { game_id: "game".to_string(), diff --git a/crates/lanspread-peer/src/services/stream.rs b/crates/lanspread-peer/src/services/stream.rs index a8e4ede..7dac869 100644 --- a/crates/lanspread-peer/src/services/stream.rs +++ b/crates/lanspread-peer/src/services/stream.rs @@ -90,14 +90,11 @@ async fn dispatch_request( handle_library_delta(ctx, peer_id, delta).await; framed_tx } - Request::CallToPlayEvents { events: incoming } => { - let accepted = ctx.call_to_play.write().await.insert_all(incoming); - if !accepted.is_empty() { - events::send( - &ctx.tx_notify_ui, - crate::PeerEvent::CallToPlayEvents(accepted), - ); - } + Request::CallToPlayEvents { + peer_id, + events: incoming, + } => { + handle_call_to_play_events(ctx, remote_addr, &peer_id, incoming).await; framed_tx } Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await, @@ -124,6 +121,40 @@ async fn dispatch_request( } } +async fn handle_call_to_play_events( + ctx: &PeerCtx, + remote_addr: Option, + peer_id: &str, + incoming: Vec, +) { + let peer_id = peer_id.to_string(); + let sender_matches = if let Some(remote_addr) = remote_addr { + ctx.peer_game_db + .read() + .await + .peer_addr(&peer_id) + .is_some_and(|listen_addr| listen_addr.ip() == remote_addr.ip()) + } else { + false + }; + if !sender_matches { + log::warn!("Ignoring Call to Play events from unverified peer {peer_id}"); + return; + } + if incoming.iter().any(|event| event.actor_id != peer_id) { + log::warn!("Ignoring Call to Play events with an actor that does not match {peer_id}"); + return; + } + + let accepted = ctx.call_to_play.write().await.insert_all(incoming); + if !accepted.is_empty() { + events::send( + &ctx.tx_notify_ui, + crate::PeerEvent::CallToPlayEvents(accepted), + ); + } +} + async fn note_peer_activity(ctx: &PeerCtx, remote_addr: Option) { if let Some(addr) = remote_addr { ctx.peer_game_db diff --git a/crates/lanspread-proto/src/lib.rs b/crates/lanspread-proto/src/lib.rs index c32fcf1..412a40f 100644 --- a/crates/lanspread-proto/src/lib.rs +++ b/crates/lanspread-proto/src/lib.rs @@ -44,7 +44,8 @@ pub struct HelloAck { pub struct CallToPlayEvent { pub id: String, pub call_id: String, - pub actor: String, + pub actor_id: String, + pub actor_name: String, pub at: i64, pub action: CallToPlayAction, } @@ -111,6 +112,7 @@ pub enum Request { delta: LibraryDelta, }, CallToPlayEvents { + peer_id: String, events: Vec, }, Goodbye { diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 310c93a..8d756c5 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -91,6 +91,7 @@ impl OutboundTransferEmitState { struct LanSpreadState { peer_ctrl: Arc>>>, peer_runtime: Arc>>, + local_peer_id: Arc>>, games: Arc>, active_operations: Arc>>, games_folder: Arc>, @@ -277,14 +278,17 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result #[tauri::command] async fn request_call_to_play_events( state: tauri::State<'_, LanSpreadState>, -) -> tauri::Result { +) -> tauri::Result> { 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); + return Ok(None); }; - Ok(peer_ctrl.send(PeerCommand::GetCallToPlayEvents).is_ok()) + if peer_ctrl.send(PeerCommand::GetCallToPlayEvents).is_err() { + return Ok(None); + } + Ok(state.inner().local_peer_id.read().await.clone()) } #[tauri::command] @@ -2205,6 +2209,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::() + .local_peer_id + .write() + .await = Some(peer_id); } PeerEvent::ListGames(games) => { log::info!("PeerEvent::ListGames received"); diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayOverlay.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayOverlay.tsx index 6440ab6..4af3c06 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayOverlay.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayOverlay.tsx @@ -10,7 +10,7 @@ import { Game, Nomination } from '../../lib/types'; interface Props { nominations: ReadonlyArray; games: ReadonlyArray; - username: string; + actorId: string | null; actions: CallToPlayActions; focusId: string | null; transportReady: boolean; @@ -24,7 +24,7 @@ interface Props { export const CallToPlayOverlay = ({ nominations, games, - username, + actorId, actions, focusId, transportReady, @@ -88,7 +88,7 @@ export const CallToPlayOverlay = ({ key={nomination.id} nomination={nomination} game={game} - username={username} + actorId={actorId} actions={actions} focused={nomination.id === focusId} thumbnailUrl={getThumbnail(game.id)} diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx index cc4aa29..288817d 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx @@ -33,14 +33,15 @@ const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number const entries = Object.entries(nomination.participants); return ( - {entries.slice(0, 6).map(([name, participant]) => { + {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 ( void; } -export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => { +export const CtpChat = ({ nomination, actorId, disabled, onSend }: Props) => { const [open, setOpen] = useState(false); const [seen, setSeen] = useState(nomination.messages.length); const [draft, setDraft] = useState(''); @@ -58,7 +58,7 @@ export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => { {nomination.messages.map(message => (
{message.from} {formatClock(message.at)} diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx index 32a9217..ec890b9 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx @@ -21,7 +21,7 @@ import { CallToPlayParticipant, Game, Nomination } from '../../lib/types'; interface Props { nomination: Nomination; game: Game; - username: string; + actorId: string | null; actions: CallToPlayActions; focused: boolean; thumbnailUrl?: string | null; @@ -60,7 +60,7 @@ const AvatarChip = ({ export const NominationCard = ({ nomination, game, - username, + actorId, actions, focused, thumbnailUrl, @@ -77,9 +77,9 @@ export const NominationCard = ({ const entries = Object.entries(nomination.participants); const readyCount = readyCountOf(nomination, now); const inCount = inCountOf(nomination, now); - const myStatus = nomination.participants[username]; + const myStatus = actorId === null ? undefined : nomination.participants[actorId]; const isMe = myStatus !== undefined; - const isCreator = nomination.creator === username; + const isCreator = nomination.creatorId === actorId; const isDone = nomination.state === 'done'; const isStarted = nomination.state === 'started'; const phase = phaseOf(nomination, now); @@ -168,8 +168,13 @@ export const NominationCard = ({
{rosterLabel}
- {entries.map(([name, participant]) => ( - + {entries.map(([participantId, participant]) => ( + ))} {Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) }) .map((_, index) => ( @@ -182,7 +187,7 @@ export const NominationCard = ({ actions.sendMessage(nomination.id, text)} /> @@ -225,7 +230,7 @@ export const NominationCard = ({ interface CardActionsProps { nomination: Nomination; game: Game; - username: string; + actorId: string | null; actions: CallToPlayActions; onLaunch: (game: Game) => void; now: number; @@ -255,14 +260,14 @@ const ReadyButtons = ({ nomination, actions, includeThirty = false }: { const CardActions = ({ nomination, game, - username, + actorId, actions, onLaunch, now, }: CardActionsProps) => { - const myStatus = nomination.participants[username]; + const myStatus = actorId === null ? undefined : nomination.participants[actorId]; const isMe = myStatus !== undefined; - const isCreator = nomination.creator === username; + const isCreator = nomination.creatorId === actorId; const isDone = nomination.state === 'done'; const isStarted = nomination.state === 'started'; const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts index 83be4c2..15fb175 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts @@ -24,7 +24,7 @@ export interface CallToPlayActions { export interface UseCallToPlay { nominations: Nomination[]; actions: CallToPlayActions; - username: string; + actorId: string | null; transportReady: boolean; error: string | null; } @@ -44,6 +44,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => { return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander'; }, [username]); const [events, setEvents] = useState>(() => new Map()); + const [actorId, setActorId] = useState(null); const [now, setNow] = useState(Date.now()); const [transportReady, setTransportReady] = useState(false); const [error, setError] = useState(null); @@ -60,8 +61,10 @@ export const useCallToPlay = (username: string): UseCallToPlay => { const requestSnapshot = async (): Promise => { try { - const ready = await invoke('request_call_to_play_events'); + const peerId = await invoke('request_call_to_play_events'); if (cancelled) return false; + const ready = peerId !== null; + setActorId(peerId); setTransportReady(ready); if (ready && retry !== undefined) { window.clearInterval(retry); @@ -108,7 +111,12 @@ export const useCallToPlay = (username: string): UseCallToPlay => { callId: string, action: CallToPlayAction, ): Promise => { - const event = callToPlayEvent(callId, actor, action); + if (actorId === null) { + setTransportReady(false); + setError('Call to Play needs an active LAN peer. Choose a game folder first.'); + return false; + } + const event = callToPlayEvent(callId, actorId, actor, action); try { const accepted = await invoke('publish_call_to_play', { event }); if (!accepted) { @@ -124,7 +132,7 @@ export const useCallToPlay = (username: string): UseCallToPlay => { setError('Could not send this Call to Play update.'); return false; } - }, [actor]); + }, [actor, actorId]); const actions = useMemo(() => ({ createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => { @@ -169,5 +177,5 @@ export const useCallToPlay = (username: string): UseCallToPlay => { [events, now], ); - return { nominations, actions, username: actor, transportReady, error }; + return { nominations, actions, actorId, transportReady, error }; }; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts index 0b3925d..4411a75 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts @@ -88,13 +88,15 @@ export const reduceCallToPlayEvents = ( const nomination: MutableNomination = { id: create.call_id, gameId: payload.game_id, - creator: create.actor, + 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]: { + [create.actor_id]: { + name: create.actor_name, status: payload.scheduled_for === null ? 'ready' : 'in', joinedAt: create.at, }, @@ -139,8 +141,9 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void const response = respondPayload(action); if (response) { if (nomination.state === 'started') return; - const existing = nomination.participants[event.actor]; - nomination.participants[event.actor] = { + 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 }), @@ -153,7 +156,8 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void nomination.messageIds.add(message.message_id); nomination.messages.push({ id: message.message_id, - from: event.actor, + fromId: event.actor_id, + from: event.actor_name, text: message.text, at: event.at, }); @@ -162,7 +166,10 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void } const extension = addTimePayload(action); - if (extension && event.actor === nomination.creator && nomination.state !== 'started') { + if (extension + && event.actor_id === nomination.creatorId + && nomination.state !== 'started' + ) { nomination.deadline = extension.deadline; nomination.state = 'open'; } @@ -176,25 +183,26 @@ const applyUnitAction = ( switch (action) { case 'Rsvp': { if (nomination.state === 'started') return; - const existing = nomination.participants[event.actor]; - nomination.participants[event.actor] = { + 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 !== nomination.creator && nomination.state !== 'started') { - delete nomination.participants[event.actor]; + if (event.actor_id !== nomination.creatorId && nomination.state !== 'started') { + delete nomination.participants[event.actor_id]; } break; case 'Cancel': - if (event.actor === nomination.creator && nomination.state !== 'started') { + if (event.actor_id === nomination.creatorId && nomination.state !== 'started') { nomination.cancelled = true; } break; case 'Start': - if (event.actor === nomination.creator && nomination.state !== 'started') { + if (event.actor_id === nomination.creatorId && nomination.state !== 'started') { nomination.state = 'started'; nomination.startedAt = event.at; } @@ -204,13 +212,15 @@ const applyUnitAction = ( export const callToPlayEvent = ( callId: string, - actor: string, + actorId: string, + actorName: string, action: CallToPlayAction, at = Date.now(), ): CallToPlayEvent => ({ id: globalThis.crypto.randomUUID(), call_id: callId, - actor, + actor_id: actorId, + actor_name: actorName, at, action, }); diff --git a/crates/lanspread-tauri-deno-ts/src/lib/types.ts b/crates/lanspread-tauri-deno-ts/src/lib/types.ts index 83f95e6..89c0c43 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/types.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/types.ts @@ -88,6 +88,7 @@ export type LauncherLanguage = 'en' | 'de'; export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending'; export interface CallToPlayParticipant { + name: string; status: CallToPlayParticipantStatus; joinedAt: number; readyAt?: number; @@ -95,6 +96,7 @@ export interface CallToPlayParticipant { export interface CallToPlayMessage { id: string; + fromId: string; from: string; text: string; at: number; @@ -103,6 +105,7 @@ export interface CallToPlayMessage { export interface Nomination { id: string; gameId: string; + creatorId: string; creator: string; maxPlayers: number; createdAt: number; @@ -127,7 +130,8 @@ export type CallToPlayAction = export interface CallToPlayEvent { id: string; call_id: string; - actor: string; + actor_id: string; + actor_name: string; at: number; action: CallToPlayAction; } diff --git a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx index 911446d..6a1d091 100644 --- a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx +++ b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx @@ -245,7 +245,7 @@ export const MainWindow = () => { (actual: T, expected: T, message: string) => { const event = ( id: string, - actor: string, + actorId: string, action: CallToPlayAction, at = NOW, -): CallToPlayEvent => ({ id, call_id: 'call-1', actor, at, action }); + actorName = actorId, +): CallToPlayEvent => ({ + id, + call_id: 'call-1', + actor_id: actorId, + actor_name: actorName, + at, + action, +}); const create = ( scheduledFor: number | null = null, @@ -94,6 +102,27 @@ Deno.test('creator-only controls cannot be forged by another participant', () => 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(), diff --git a/design/launcher/SPEC.md b/design/launcher/SPEC.md index c8ce251..21bf1e8 100644 --- a/design/launcher/SPEC.md +++ b/design/launcher/SPEC.md @@ -528,17 +528,19 @@ 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; - messages: { id: string; from: string; text: string; at: number }[]; + messages: { id: string; fromId: string; from: string; text: string; at: number }[]; state: 'open' | 'done' | 'started'; startedAt?: number; };