crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit e0ed3c7443727a385fec80727a292a7a89eaf385
parent 7e2135a5d3e2604a0e49bd6ea80b40e4e4a2d8e4
Author: Michael Camilleri <[email protected]>
Date:   Mon, 25 May 2026 18:56:44 +0900

Fix manual engagement offers targeting self

Manual engagement offers were always addressed to the local author ID, which
worked only for same-account multi-device testing. In real shared games this
made the offer ping self-addressed, so the sender fetched its own hail, ignored
it as 'own hail' and the remote participant never received a usable offer.

This commit makes the manual offer path consult the current present-peer map
for the game and address the offer to the first present peer when one exists.
It preserves the local-author fallback for same-account device testing when no
remote peer is present.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Sync/EngagementCoordinator.swift | 4+++-
MTests/Unit/Sync/EngagementCoordinatorTests.swift | 33+++++++++++++++++++++++++++++++--
2 files changed, 34 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift @@ -222,7 +222,9 @@ actor EngagementCoordinator { await log("engagement: manual offer skipped for \(gameID.uuidString), state is not idle") return } - await createOffer(gameID: gameID, peerAuthorID: localAuthorID) + let peers = await presentPeers([gameID]) + let peerAuthorID = peers[gameID]?.sorted().first ?? localAuthorID + await createOffer(gameID: gameID, peerAuthorID: peerAuthorID) } /// Demotes any `.offered`/`.handshaking` state that has been waiting diff --git a/Tests/Unit/Sync/EngagementCoordinatorTests.swift b/Tests/Unit/Sync/EngagementCoordinatorTests.swift @@ -116,9 +116,9 @@ struct EngagementCoordinatorTests { #expect(payload?.signal == host.offerSignal) } - @Test("manual offer addresses the local author for same-account device testing") + @Test("manual offer addresses a present peer when one exists") @MainActor - func manualOfferAddressesLocalAuthor() async throws { + func manualOfferAddressesPresentPeer() async throws { let gameID = UUID(uuidString: "66666666-6666-6666-6666-666666666666")! let host = MockEngagementHost() let sink = EngagementCoordinatorTestSink() @@ -126,6 +126,35 @@ struct EngagementCoordinatorTests { host: host, localAuthorID: { "alice" }, localDeviceID: "deviceA", + presentPeers: { _ in [gameID: ["bob"]] }, + sendHail: { gameID, payload, addressee in + await sink.send(gameID: gameID, payload: payload, addressee: addressee) + }, + deletePing: { recordName, gameID in + await sink.delete(recordName: recordName, gameID: gameID) + } + ) + + await coordinator.offerEngagement(gameID: gameID) + + #expect(host.createdOffers.count == 1) + let sent = await sink.sentHails() + #expect(sent.count == 1) + #expect(sent.first?.gameID == gameID) + #expect(sent.first?.addressee == "bob") + #expect(EngagementHailPayload.decode(sent.first?.payload)?.role == .offer) + } + + @Test("manual offer falls back to the local author for same-account device testing") + @MainActor + func manualOfferFallsBackToLocalAuthor() async throws { + let gameID = UUID(uuidString: "77777777-7777-7777-7777-777777777777")! + let host = MockEngagementHost() + let sink = EngagementCoordinatorTestSink() + let coordinator = EngagementCoordinator( + host: host, + localAuthorID: { "alice" }, + localDeviceID: "deviceA", presentPeers: { _ in [:] }, sendHail: { gameID, payload, addressee in await sink.send(gameID: gameID, payload: payload, addressee: addressee)