crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Services/AppServices.swift | 11++++++++---
MCrossmate/Sync/RecordApplier.swift | 26+++++++++++++++++++++++---
MCrossmate/Sync/SyncEngine.swift | 20+++++++++++++++++---
ATests/Unit/Sync/PlayerRecordPresenceTests.swift | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + } +}