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:
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)