commit f614d20ea12f729656d8177321ae43bae8dd4098
parent e0ed3c7443727a385fec80727a292a7a89eaf385
Author: Michael Camilleri <[email protected]>
Date: Mon, 25 May 2026 19:19:19 +0900
Use cursor tracks for engagement presence
Engagement peer discovery was keyed off Player.readAt > now, but the visible
co-solving signal is the cursor track: if we can render another player’s
cursor, we have enough evidence to try an engagement, even if their read lease
is missing, stale, or from an older build. This commit switches both engagement
peer discovery and the cursor-publishing presence gate to look for non-local
selRow/selCol/selDir fields instead of an active readAt lease, so manual and
automatic offers target the peer the user can actually see in the grid.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 127 insertions(+), 26 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -340,9 +340,9 @@ 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 readAt
- // lease; nudge the selection publisher to re-evaluate its gate
- // and ship any selection it was holding for an absent audience.
+ // An inbound Player record may have updated a peer's cursor track;
+ // nudge the selection publisher and engagement coordinator to
+ // re-evaluate peer presence.
await self?.playerSelectionPublisher.peerPresenceMayHaveChanged(gameIDs: gameIDs)
await self?.engagementCoordinator.peerPresenceMayHaveChanged(gameIDs: gameIDs)
}
@@ -1910,10 +1910,10 @@ final class AppServices {
}
}
- /// True iff some non-local participant in `gameID` has an unexpired
- /// `Player.readAt` lease — i.e. a peer is currently in the puzzle. Gates
- /// the cursor-track publish path so we don't spend CloudKit writes
- /// broadcasting presence to nobody.
+ /// True iff some non-local participant in `gameID` has a cursor track.
+ /// Cursor fields are the user-visible presence signal; stale cursors are
+ /// still good enough to justify trying an engagement or publishing our
+ /// own cursor back.
static func hasPresentPeer(
persistence: PersistenceController,
gameID: UUID,
@@ -1923,24 +1923,18 @@ final class AppServices {
return await withCheckedContinuation { continuation in
context.perform {
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- let now = Date() as NSDate
- if let localAuthorID, !localAuthorID.isEmpty {
- req.predicate = NSPredicate(
- format: "game.id == %@ AND authorID != %@ AND readAt != nil AND readAt > %@",
- gameID as CVarArg,
- localAuthorID,
- now
- )
- } else {
- req.predicate = NSPredicate(
- format: "game.id == %@ AND readAt != nil AND readAt > %@",
- gameID as CVarArg,
- now
- )
+ req.predicate = NSPredicate(
+ format: "game.id == %@ AND selRow != nil AND selCol != nil AND selDir != nil",
+ gameID as CVarArg
+ )
+ let players = (try? context.fetch(req)) ?? []
+ let hasPeer = players.contains { player in
+ guard let authorID = player.authorID, !authorID.isEmpty else { return false }
+ if authorID == CKCurrentUserDefaultName { return false }
+ if let localAuthorID, !localAuthorID.isEmpty, authorID == localAuthorID { return false }
+ return true
}
- req.fetchLimit = 1
- let count = (try? context.count(for: req)) ?? 0
- continuation.resume(returning: count > 0)
+ continuation.resume(returning: hasPeer)
}
}
}
@@ -1955,7 +1949,7 @@ final class AppServices {
context.perform {
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
var predicates = [
- NSPredicate(format: "readAt != nil AND readAt > %@", Date() as NSDate)
+ NSPredicate(format: "selRow != nil AND selCol != nil AND selDir != nil")
]
if let gameIDs, !gameIDs.isEmpty {
predicates.append(NSPredicate(format: "game.id IN %@", Array(gameIDs)))
@@ -1969,7 +1963,8 @@ final class AppServices {
for player in (try? context.fetch(req)) ?? [] {
guard let gameID = player.game?.id,
let authorID = player.authorID,
- !authorID.isEmpty else { continue }
+ !authorID.isEmpty,
+ authorID != CKCurrentUserDefaultName else { continue }
result[gameID, default: []].insert(authorID)
}
continuation.resume(returning: result.mapValues { Array($0) })
diff --git a/Tests/Unit/Sync/AppServicesAnnouncementTests.swift b/Tests/Unit/Sync/AppServicesAnnouncementTests.swift
@@ -1,3 +1,4 @@
+import CoreData
import Foundation
import Testing
@@ -50,3 +51,108 @@ struct AppServicesAnnouncementTests {
#expect(body == "A player added 3 letters")
}
}
+
+@Suite("AppServices peer presence", .serialized)
+@MainActor
+struct AppServicesPeerPresenceTests {
+
+ @Test("cursor track counts as a present peer without a read lease")
+ func cursorTrackCountsAsPresentPeer() async throws {
+ let (persistence, gameID) = try makePersistence(authorID: "alice")
+ try addPlayer(
+ gameID: gameID,
+ authorID: "bob",
+ selection: PlayerSelection(row: 1, col: 2, direction: .down),
+ readAt: nil,
+ persistence: persistence
+ )
+
+ let hasPeer = await AppServices.hasPresentPeer(
+ persistence: persistence,
+ gameID: gameID,
+ localAuthorID: "alice"
+ )
+ let peers = await AppServices.presentPeers(
+ persistence: persistence,
+ gameIDs: [gameID],
+ localAuthorID: "alice"
+ )
+
+ #expect(hasPeer)
+ #expect(peers[gameID] == ["bob"])
+ }
+
+ @Test("read lease alone does not count as engagement presence")
+ func readLeaseWithoutCursorDoesNotCountAsPresentPeer() async throws {
+ let (persistence, gameID) = try makePersistence(authorID: "alice")
+ try addPlayer(
+ gameID: gameID,
+ authorID: "bob",
+ selection: nil,
+ readAt: Date().addingTimeInterval(60),
+ persistence: persistence
+ )
+
+ let hasPeer = await AppServices.hasPresentPeer(
+ persistence: persistence,
+ gameID: gameID,
+ localAuthorID: "alice"
+ )
+ let peers = await AppServices.presentPeers(
+ persistence: persistence,
+ gameIDs: [gameID],
+ localAuthorID: "alice"
+ )
+
+ #expect(!hasPeer)
+ #expect(peers[gameID] == nil)
+ }
+
+ private func makePersistence(authorID: String) throws -> (PersistenceController, UUID) {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+ let gameID = UUID()
+ let game = GameEntity(context: context)
+ game.id = gameID
+ game.title = "Test"
+ game.puzzleSource = ""
+ game.createdAt = Date()
+ game.updatedAt = Date()
+ game.ckRecordName = "game-\(gameID.uuidString)"
+ try addPlayer(
+ gameID: gameID,
+ authorID: authorID,
+ selection: PlayerSelection(row: 0, col: 0, direction: .across),
+ readAt: nil,
+ persistence: persistence
+ )
+ return (persistence, gameID)
+ }
+
+ private func addPlayer(
+ gameID: UUID,
+ authorID: String,
+ selection: PlayerSelection?,
+ readAt: Date?,
+ persistence: PersistenceController
+ ) throws {
+ let context = persistence.viewContext
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ let game = try #require(try context.fetch(gameReq).first)
+ let player = PlayerEntity(context: context)
+ player.game = game
+ player.authorID = authorID
+ player.name = authorID
+ player.updatedAt = Date()
+ player.ckRecordName = "player-\(gameID.uuidString)-\(authorID)"
+ player.readAt = readAt
+ if let selection {
+ player.selRow = NSNumber(value: selection.row)
+ player.selCol = NSNumber(value: selection.col)
+ player.selDir = NSNumber(value: selection.direction.rawValue)
+ }
+ try context.save()
+ }
+}