commit fdf1e2aec6ca54b274312346c5861d21cb8941b2
parent bdddd25f07bddb25faa815023f7a44322af2c022
Author: Michael Camilleri <[email protected]>
Date: Wed, 27 May 2026 15:27:33 +0900
Wake engagement on existing player presence updates
Known collaborators can already have Player records before either side opens a
shared puzzle. In that case later selection/read-cursor updates refresh the
roster, but do not re-run the engagement presence check because the callback
only fires when a PlayerEntity is first created.
This commit splits the inbound Player callbacks so first-time Player discovery
still drives friendship bootstrap, while accepting remote presence changes from
existing Player records that wake the selection publisher and engagement
coordinator. This lets live co-solving send the .hail room bootstrap when an
existing collaborator opens or moves in the puzzle.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
5 files changed, 221 insertions(+), 9 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -56,6 +56,7 @@
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; };
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; };
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; };
+ 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; };
7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; };
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; };
818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; };
@@ -185,6 +186,7 @@
56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; };
5ABB557BA10CBE9909056882 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; };
5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; };
+ 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRecordPresenceTests.swift; sourceTree = "<group>"; };
5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; };
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStoreTests.swift; sourceTree = "<group>"; };
61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementBanner.swift; sourceTree = "<group>"; };
@@ -458,6 +460,7 @@
EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */,
BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */,
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */,
+ 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */,
F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */,
68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */,
A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */,
@@ -623,6 +626,7 @@
8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */,
014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */,
CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */,
+ 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */,
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */,
309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */,
00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */,
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -364,9 +364,14 @@ final class AppServices {
// not on moves and not on their later name / cursor updates.
await syncEngine.setOnRemotePlayersUpdated { [weak self] gameIDs in
await self?.reconcileFriendships(forGameIDs: gameIDs)
- // An inbound Player record may have updated a peer's cursor track;
- // nudge the selection publisher and engagement coordinator to
- // re-evaluate peer presence.
+ }
+
+ // An inbound Player record may have updated a peer's cursor track;
+ // nudge the selection publisher and engagement coordinator to
+ // re-evaluate peer presence. This must fire for existing Player
+ // records too: known collaborators opening a puzzle are the common
+ // live co-solving path.
+ await syncEngine.setOnRemotePlayerPresenceChanged { [weak self] gameIDs in
await self?.playerSelectionPublisher.peerPresenceMayHaveChanged(gameIDs: gameIDs)
await self?.engagementCoordinator.peerPresenceMayHaveChanged(gameIDs: gameIDs)
}
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -26,11 +26,12 @@ extension SyncEngine {
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let localAuthorID = await currentLocalAuthorID()
- let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, readCursors, authorDeltas):
- (Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait {
+ let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, playerPresenceChangedGameIDs, readCursors, authorDeltas):
+ (Set<UUID>, Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait {
var movesUpdated = Set<UUID>()
var affected = Set<UUID>()
var playersUpdated = Set<UUID>()
+ var playerPresenceChanged = Set<UUID>()
var read: [(UUID, Date)] = []
// Pre-pass: snapshot the merged grid for every game that has a
// Moves record in this batch, so the post-apply diff can attribute
@@ -65,6 +66,7 @@ extension SyncEngine {
in: ctx,
localAuthorID: localAuthorID,
onFirstTime: { playersUpdated.insert($0) },
+ onPresenceChange: { playerPresenceChanged.insert($0) },
onReadCursor: { read.append(($0, $1)) }
)
affected.insert(gameID)
@@ -104,7 +106,7 @@ extension SyncEngine {
)
}
}
- return (movesUpdated, affected, playersUpdated, read, deltas)
+ return (movesUpdated, affected, playersUpdated, playerPresenceChanged, read, deltas)
}
if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty {
@@ -116,6 +118,9 @@ extension SyncEngine {
if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty {
await onRemotePlayersUpdated(playersUpdatedGameIDs)
}
+ if let onRemotePlayerPresenceChanged, !playerPresenceChangedGameIDs.isEmpty {
+ await onRemotePlayerPresenceChanged(playerPresenceChangedGameIDs)
+ }
if let onIncomingReadCursor, !readCursors.isEmpty {
await onIncomingReadCursor(readCursors)
}
@@ -140,6 +145,7 @@ extension SyncEngine {
in ctx: NSManagedObjectContext,
localAuthorID: String?,
onFirstTime: (UUID) -> Void,
+ onPresenceChange: (UUID) -> Void,
onReadCursor: (UUID, Date) -> Void
) {
let ckName = record.recordID.recordName
@@ -181,6 +187,10 @@ extension SyncEngine {
return
}
+ let oldSelection = (entity.selRow, entity.selCol, entity.selDir)
+ let hadSelection = oldSelection.0 != nil && oldSelection.1 != nil && oldSelection.2 != nil
+ let oldUpdatedAt = entity.updatedAt
+
// Adopt the server's system fields — that's etag tracking and is
// independent of which side has the freshest data. The value fields,
// however, are only adopted when the incoming record is at least as
@@ -214,6 +224,16 @@ extension SyncEngine {
if authorID == localAuthorID, let readAt = incomingReadAt {
onReadCursor(gameID, readAt)
}
+ let isRemoteAuthor = authorID != localAuthorID && authorID != CKCurrentUserDefaultName
+ let hasSelection = entity.selRow != nil && entity.selCol != nil && entity.selDir != nil
+ if isRemoteAuthor,
+ (hadSelection || hasSelection),
+ oldUpdatedAt != entity.updatedAt ||
+ oldSelection.0 != entity.selRow ||
+ oldSelection.1 != entity.selCol ||
+ oldSelection.2 != entity.selDir {
+ onPresenceChange(gameID)
+ }
if !foundExisting {
onFirstTime(gameID)
}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -84,6 +84,11 @@ actor SyncEngine {
/// friendship bootstrap keys off this so a collaborator becomes a friend
/// once, as soon as their identity syncs, without waiting for a move.
var onRemotePlayersUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)?
+ /// Fires when a remote collaborator's `Player` record updates active
+ /// presence state (selection set/cleared or refreshed). Unlike
+ /// `onRemotePlayersUpdated`, this fires for existing Player records too,
+ /// so live engagement can start when a known collaborator opens a puzzle.
+ var onRemotePlayerPresenceChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)?
var onPings: (@MainActor @Sendable ([Ping]) async -> Void)?
private var onAccountChange: (@MainActor @Sendable () async -> Void)?
private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
@@ -152,6 +157,10 @@ actor SyncEngine {
onRemotePlayersUpdated = cb
}
+ func setOnRemotePlayerPresenceChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
+ onRemotePlayerPresenceChanged = cb
+ }
+
func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) {
onPings = cb
}
@@ -1159,12 +1168,13 @@ actor SyncEngine {
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let localAuthorID = await currentLocalAuthorID()
- let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, removedGameIDs, readCursors, authorDeltas):
- (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait {
+ let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, playerPresenceChangedGameIDs, removedGameIDs, readCursors, authorDeltas):
+ (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait {
var movesUpdated = Set<UUID>()
var affected = Set<UUID>()
var pings: [Ping] = []
var playersUpdated = Set<UUID>()
+ var playerPresenceChanged = Set<UUID>()
var removed = Set<UUID>()
var read: [(UUID, Date)] = []
// Pre-pass: snapshot the merged grid for every game that has a
@@ -1201,6 +1211,7 @@ actor SyncEngine {
in: ctx,
localAuthorID: localAuthorID,
onFirstTime: { playersUpdated.insert($0) },
+ onPresenceChange: { playerPresenceChanged.insert($0) },
onReadCursor: { read.append(($0, $1)) }
)
affected.insert(gameID)
@@ -1266,7 +1277,7 @@ actor SyncEngine {
)
}
}
- return (movesUpdated, affected, pings, playersUpdated, removed, read, deltas)
+ return (movesUpdated, affected, pings, playersUpdated, playerPresenceChanged, removed, read, deltas)
}
if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty {
@@ -1278,6 +1289,9 @@ actor SyncEngine {
if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty {
await onRemotePlayersUpdated(playersUpdatedGameIDs)
}
+ if let onRemotePlayerPresenceChanged, !playerPresenceChangedGameIDs.isEmpty {
+ await onRemotePlayerPresenceChanged(playerPresenceChangedGameIDs)
+ }
if let onIncomingReadCursor, !readCursors.isEmpty {
await onIncomingReadCursor(readCursors)
}
diff --git a/Tests/Unit/Sync/PlayerRecordPresenceTests.swift b/Tests/Unit/Sync/PlayerRecordPresenceTests.swift
@@ -0,0 +1,169 @@
+import CloudKit
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Pins down the inbound Player-record callback split: first sight of a
+/// collaborator still drives friendship bootstrap, while later presence
+/// updates from known collaborators wake live engagement.
+@Suite("Player record presence")
+@MainActor
+struct PlayerRecordPresenceTests {
+
+ private let gameID = UUID(uuidString: "ABCD1234-0000-0000-0000-111122223333")!
+ private let localAuthorID = "local-author"
+ private let remoteAuthorID = "remote-author"
+
+ private var zoneID: CKRecordZone.ID {
+ RecordSerializer.zoneID(for: gameID)
+ }
+
+ private func makeEngine(persistence: PersistenceController) -> SyncEngine {
+ SyncEngine(
+ container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"),
+ persistence: persistence
+ )
+ }
+
+ private func makeGame(in ctx: NSManagedObjectContext) throws -> GameEntity {
+ let game = GameEntity(context: ctx)
+ game.id = gameID
+ game.title = "Presence Test"
+ game.puzzleSource = ""
+ game.createdAt = Date(timeIntervalSince1970: 1)
+ game.updatedAt = Date(timeIntervalSince1970: 1)
+ game.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
+ game.ckZoneName = zoneID.zoneName
+ game.databaseScope = 0
+ try ctx.save()
+ return game
+ }
+
+ private func makeExistingRemotePlayer(
+ in ctx: NSManagedObjectContext,
+ game: GameEntity,
+ updatedAt: Date,
+ selection: PlayerSelection?
+ ) throws {
+ let player = PlayerEntity(context: ctx)
+ player.game = game
+ player.ckRecordName = RecordSerializer.recordName(
+ forPlayerInGame: gameID,
+ authorID: remoteAuthorID
+ )
+ player.authorID = remoteAuthorID
+ player.name = "Remote"
+ player.updatedAt = updatedAt
+ if let selection {
+ player.selRow = NSNumber(value: selection.row)
+ player.selCol = NSNumber(value: selection.col)
+ player.selDir = NSNumber(value: selection.direction.rawValue)
+ }
+ try ctx.save()
+ }
+
+ private func remotePlayerRecord(
+ updatedAt: Date,
+ selection: PlayerSelection?
+ ) -> CKRecord {
+ RecordSerializer.playerRecord(
+ gameID: gameID,
+ authorID: remoteAuthorID,
+ name: "Remote",
+ updatedAt: updatedAt,
+ selection: selection,
+ zone: zoneID,
+ systemFields: nil
+ )
+ }
+
+ @Test("Existing remote selection move fires presence change only")
+ func existingRemoteSelectionMoveFiresPresenceChangeOnly() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let game = try makeGame(in: ctx)
+ try makeExistingRemotePlayer(
+ in: ctx,
+ game: game,
+ updatedAt: Date(timeIntervalSince1970: 10),
+ selection: PlayerSelection(row: 1, col: 2, direction: .across)
+ )
+ let engine = makeEngine(persistence: persistence)
+
+ var firstTimeGameIDs: [UUID] = []
+ var presenceGameIDs: [UUID] = []
+ let record = remotePlayerRecord(
+ updatedAt: Date(timeIntervalSince1970: 20),
+ selection: PlayerSelection(row: 2, col: 2, direction: .down)
+ )
+
+ engine.applyPlayerRecord(
+ record,
+ in: ctx,
+ localAuthorID: localAuthorID,
+ onFirstTime: { firstTimeGameIDs.append($0) },
+ onPresenceChange: { presenceGameIDs.append($0) },
+ onReadCursor: { _, _ in }
+ )
+
+ #expect(firstTimeGameIDs.isEmpty)
+ #expect(presenceGameIDs == [gameID])
+
+ let fetched = try fetchRemotePlayer(in: ctx)
+ let row = try #require(fetched)
+ #expect(row.selRow?.intValue == 2)
+ #expect(row.selCol?.intValue == 2)
+ #expect(row.selDir?.intValue == Puzzle.Direction.down.rawValue)
+ }
+
+ @Test("Existing remote selection clear fires presence change only")
+ func existingRemoteSelectionClearFiresPresenceChangeOnly() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let game = try makeGame(in: ctx)
+ try makeExistingRemotePlayer(
+ in: ctx,
+ game: game,
+ updatedAt: Date(timeIntervalSince1970: 10),
+ selection: PlayerSelection(row: 1, col: 2, direction: .across)
+ )
+ let engine = makeEngine(persistence: persistence)
+
+ var firstTimeGameIDs: [UUID] = []
+ var presenceGameIDs: [UUID] = []
+ let record = remotePlayerRecord(
+ updatedAt: Date(timeIntervalSince1970: 20),
+ selection: nil
+ )
+
+ engine.applyPlayerRecord(
+ record,
+ in: ctx,
+ localAuthorID: localAuthorID,
+ onFirstTime: { firstTimeGameIDs.append($0) },
+ onPresenceChange: { presenceGameIDs.append($0) },
+ onReadCursor: { _, _ in }
+ )
+
+ #expect(firstTimeGameIDs.isEmpty)
+ #expect(presenceGameIDs == [gameID])
+
+ let fetched = try fetchRemotePlayer(in: ctx)
+ let row = try #require(fetched)
+ #expect(row.selRow == nil)
+ #expect(row.selCol == nil)
+ #expect(row.selDir == nil)
+ }
+
+ private func fetchRemotePlayer(in ctx: NSManagedObjectContext) throws -> PlayerEntity? {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(
+ format: "ckRecordName == %@",
+ RecordSerializer.recordName(forPlayerInGame: gameID, authorID: remoteAuthorID)
+ )
+ req.fetchLimit = 1
+ return try ctx.fetch(req).first
+ }
+}