commit 2df79942d9cf4f9ad750337bc6c1aa828f95593a
parent 9ab07c1abed00fc9f31880eb7cf8683bdcc5f964
Author: Michael Camilleri <[email protected]>
Date: Wed, 29 Apr 2026 08:24:50 +0900
Show peer cursor in shared puzzles
This commit lets each player see where their partner is working in a shared
game. The peer's focused cell is drawn with a coloured outline and the rest of
their current word is tinted in the same colour, using the colour the local
roster has assigned to that participant.
Selection state is carried on the existing Player record. PlayerEntity gains
optional selRow/selCol/selDir attributes, RecordSerializer reads and writes
them as Int64 fields on the CKRecord (selDir = 0 across, 1 down), and
SyncEngine.applyPlayerRecord populates the entity on inbound. The three new
fields were added to the Player record type in CloudKit Production before this
commit landed.
A new PresencePublisher actor drives outbound updates. PlayerSession publishes
via a didSet hook on selectedRow/selectedCol/direction; the publisher debounces
by 300ms, dedupes against the last published value, and calls
SyncEngine.enqueuePlayerRecord — the same path NameBroadcaster already uses.
PuzzleDisplayView wires the session into the publisher only for shared games,
calls begin on appear, and calls clear on disappear so a peer's outline
disappears promptly when they leave the puzzle.
PlayerRoster exposes a remoteSelections map keyed by authorID, populated from
PlayerEntity rows during refresh and filtered to drop the local player and
entries older than 60s. GridView fans these out into per-cell outline and
word-tint maps using a new Puzzle.wordCells helper, with most-recent-wins on
conflicts.
PresencePublisherTests covers begin/publish/flush, dedupe of identical
selections, debounce coalescing, clear semantics, no-op clear, publish before
begin, in-place entity update, and republish after clear.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
15 files changed, 718 insertions(+), 15 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -10,8 +10,10 @@
014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; };
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; };
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; };
+ 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */; };
0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; };
170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */; };
+ 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; };
197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; };
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
@@ -65,6 +67,7 @@
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; };
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
+ D74E9307FC03801137BE2083 /* PresencePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */; };
DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */; };
DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; };
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; };
@@ -94,6 +97,7 @@
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; };
14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; };
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; };
+ 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisher.swift; sourceTree = "<group>"; };
1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; };
1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreSnapshotPruningTests.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
@@ -120,6 +124,7 @@
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStore.swift; sourceTree = "<group>"; };
68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareRoutingTests.swift; sourceTree = "<group>"; };
+ 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelection.swift; sourceTree = "<group>"; };
70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@@ -136,6 +141,7 @@
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; };
AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMonitor.swift; sourceTree = "<group>"; };
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
+ ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisherTests.swift; sourceTree = "<group>"; };
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; };
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; };
B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; };
@@ -179,6 +185,7 @@
B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */,
52B8E26067849A63758DDEA4 /* MoveBuffer.swift */,
F7422F19AA1F1692A98E3602 /* MoveLog.swift */,
+ 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */,
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */,
5C74683332956B0D1CA37589 /* ShareController.swift */,
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */,
@@ -208,6 +215,7 @@
9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */,
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */,
+ ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */,
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
ABB371EF2574E95782CB05FD /* Sync */,
);
@@ -224,6 +232,7 @@
DB55FC337CF72C650373210A /* PlayerColor.swift */,
46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */,
3292748EAE27B608C769D393 /* PlayerRoster.swift */,
+ 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */,
20B331CC55827FEF3420ABCE /* PlayerSession.swift */,
64C8064F04FC6177D987ACA2 /* Puzzle.swift */,
4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */,
@@ -448,6 +457,7 @@
7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */,
014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */,
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */,
+ 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */,
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */,
@@ -495,7 +505,9 @@
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */,
F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */,
0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */,
+ 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */,
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */,
+ D74E9307FC03801137BE2083 /* PresencePublisher.swift in Sources */,
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */,
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */,
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -214,14 +214,32 @@ private struct PuzzleDisplayView: View {
.task(id: gameID) {
do {
let (game, mutator) = try store.loadGame(id: gameID)
- session = PlayerSession(game: game, mutator: mutator)
+ let newSession = PlayerSession(game: game, mutator: mutator)
if mutator.isShared {
roster = services.makePlayerRoster(for: gameID, preferences: preferences)
+ if let authorID = services.identity.currentID {
+ let presence = services.presencePublisher
+ await presence.begin(gameID: gameID, authorID: authorID)
+ newSession.onSelectionChanged = { selection in
+ Task { await presence.publish(selection) }
+ }
+ let initial = PlayerSelection(
+ row: newSession.selectedRow,
+ col: newSession.selectedCol,
+ direction: newSession.direction
+ )
+ await presence.publish(initial)
+ }
}
+ session = newSession
} catch {
loadError = String(describing: error)
}
}
+ .onDisappear {
+ let presence = services.presencePublisher
+ Task { await presence.clear() }
+ }
}
private func pollOpenSharedPuzzle() async {
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -26,6 +26,9 @@
<attribute name="ckRecordName" attributeType="String"/>
<attribute name="ckSystemFields" optional="YES" attributeType="Binary"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
+ <attribute name="selCol" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
+ <attribute name="selDir" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
+ <attribute name="selRow" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="players" inverseEntity="GameEntity"/>
<fetchIndex name="byGameAndAuthor">
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -17,6 +17,26 @@ final class PlayerRoster {
var id: String { authorID }
}
+ struct RemoteSelection: Equatable {
+ let authorID: String
+ let row: Int
+ let col: Int
+ let direction: Puzzle.Direction
+ let color: PlayerColor
+ let updatedAt: Date
+ }
+
+ /// Peer cursors keyed by `authorID`. Stale entries (older than the
+ /// freshness window) are dropped on each refresh. The local player is
+ /// never present in this map.
+ private(set) var remoteSelections: [String: RemoteSelection] = [:]
+
+ /// Selections older than this are treated as stale and hidden — covers
+ /// the case where the peer crashed or lost connectivity without writing
+ /// a "cleared" record. The polling interval is 5s plus push, so 60s is
+ /// generous enough to ride out a brief network hiccup.
+ private let selectionFreshnessWindow: TimeInterval = 60
+
private(set) var entries: [Entry] = []
private let gameID: UUID
@@ -97,25 +117,51 @@ final class PlayerRoster {
func refresh() async {
let localAuthorID = authorIdentity.currentID ?? ""
+ // Raw selection tuple — colour is resolved on the main actor below
+ // so we don't have to thread the colour store through Core Data
+ // closures.
+ struct RawSelection {
+ let authorID: String
+ let row: Int
+ let col: Int
+ let direction: Puzzle.Direction
+ let updatedAt: Date
+ }
+
// Pull Core Data fields off a background context.
let ctx = persistence.container.newBackgroundContext()
- let (databaseScope, ckShareRecordName, ckZoneName, ckZoneOwnerName, namesMap, moveAuthorIDs) =
- ctx.performAndWait { () -> (Int16, String?, String?, String?, [String: String], [String]) in
+ let (databaseScope, ckShareRecordName, ckZoneName, ckZoneOwnerName, namesMap, moveAuthorIDs, rawSelections) =
+ ctx.performAndWait { () -> (Int16, String?, String?, String?, [String: String], [String], [RawSelection]) in
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
req.fetchLimit = 1
guard let entity = try? ctx.fetch(req).first else {
- return (0, nil, nil, nil, [:], [])
+ return (0, nil, nil, nil, [:], [], [])
}
let nameReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
nameReq.predicate = NSPredicate(format: "game == %@", entity)
let nameEntities = (try? ctx.fetch(nameReq)) ?? []
var namesMap: [String: String] = [:]
+ var selections: [RawSelection] = []
for nr in nameEntities {
- guard let aid = nr.authorID, !aid.isEmpty,
- let name = nr.name, !name.isEmpty
- else { continue }
- namesMap[aid] = name
+ guard let aid = nr.authorID, !aid.isEmpty else { continue }
+ if let name = nr.name, !name.isEmpty {
+ namesMap[aid] = name
+ }
+ if aid == localAuthorID { continue }
+ if let row = nr.selRow,
+ let col = nr.selCol,
+ let dir = nr.selDir,
+ let direction = Puzzle.Direction(rawValue: dir.intValue),
+ let updatedAt = nr.updatedAt {
+ selections.append(RawSelection(
+ authorID: aid,
+ row: row.intValue,
+ col: col.intValue,
+ direction: direction,
+ updatedAt: updatedAt
+ ))
+ }
}
let moveReq = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
moveReq.predicate = NSPredicate(format: "game == %@", entity)
@@ -130,7 +176,8 @@ final class PlayerRoster {
entity.ckZoneName,
entity.ckZoneOwnerName,
namesMap,
- authorIDs
+ authorIDs,
+ selections
)
}
@@ -189,6 +236,29 @@ final class PlayerRoster {
isLocal: true
)
entries = [localEntry] + remoteEntries
+
+ // Map raw selections to the resolved colour from the entry list,
+ // dropping anything stale or with no matching entry.
+ let colorByAuthor = Dictionary(
+ uniqueKeysWithValues: remoteEntries.map { ($0.authorID, $0.color) }
+ )
+ let now = Date()
+ var fresh: [String: RemoteSelection] = [:]
+ for raw in rawSelections {
+ guard let color = colorByAuthor[raw.authorID] else { continue }
+ guard now.timeIntervalSince(raw.updatedAt) < selectionFreshnessWindow else {
+ continue
+ }
+ fresh[raw.authorID] = RemoteSelection(
+ authorID: raw.authorID,
+ row: raw.row,
+ col: raw.col,
+ direction: raw.direction,
+ color: color,
+ updatedAt: raw.updatedAt
+ )
+ }
+ remoteSelections = fresh
}
// MARK: - Collision resolution
diff --git a/Crossmate/Models/PlayerSelection.swift b/Crossmate/Models/PlayerSelection.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// A peer player's current cursor: the cell they have focused and the
+/// direction (across/down) of the word they're working on. Carried inside
+/// `Player` records on CloudKit so the local client can render the peer's
+/// selection as an outline on the grid.
+struct PlayerSelection: Sendable, Equatable {
+ let row: Int
+ let col: Int
+ let direction: Puzzle.Direction
+}
+
+extension Puzzle.Direction {
+ /// Wire-format raw value used in the `selDir` field on `Player` records.
+ /// Stable: do not renumber.
+ var rawValue: Int {
+ switch self {
+ case .across: return 0
+ case .down: return 1
+ }
+ }
+
+ init?(rawValue: Int) {
+ switch rawValue {
+ case 0: self = .across
+ case 1: self = .down
+ default: return nil
+ }
+ }
+}
+
+extension PlayerSelection {
+ typealias Direction = Puzzle.Direction
+}
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -11,11 +11,23 @@ import Observation
final class PlayerSession {
let game: Game
let mutator: GameMutator
- var selectedRow: Int
- var selectedCol: Int
- var direction: Puzzle.Direction = .across
+ var selectedRow: Int {
+ didSet { publishSelectionIfNeeded() }
+ }
+ var selectedCol: Int {
+ didSet { publishSelectionIfNeeded() }
+ }
+ var direction: Puzzle.Direction = .across {
+ didSet { publishSelectionIfNeeded() }
+ }
var isPencilMode: Bool = false
+ /// Optional sink fired whenever the cursor moves. Wired to
+ /// `PresencePublisher` from the puzzle view so the local selection is
+ /// debounced + pushed to CloudKit, and the peer can render the outline.
+ /// Unset for solo (non-shared) games.
+ var onSelectionChanged: ((PlayerSelection) -> Void)?
+
/// Rebus mode lets the player type a multi-character value into a single
/// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in
/// `rebusBuffer` rather than going straight to `Game.squares`; on commit
@@ -51,6 +63,15 @@ final class PlayerSession {
// MARK: - Selection
+ private func publishSelectionIfNeeded() {
+ guard let onSelectionChanged else { return }
+ onSelectionChanged(PlayerSelection(
+ row: selectedRow,
+ col: selectedCol,
+ direction: direction
+ ))
+ }
+
func select(row: Int, col: Int) {
guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return }
if row == selectedRow && col == selectedCol {
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -4,7 +4,7 @@ import Foundation
/// source format so the rest of the app doesn't have to know how it was
/// loaded.
struct Puzzle: Sendable {
- enum Direction: Sendable {
+ enum Direction: Sendable, Equatable {
case across
case down
@@ -153,6 +153,33 @@ struct Puzzle: Sendable {
return nil
}
+ /// Returns every open cell that belongs to the word containing
+ /// `(row, col)` in the given direction. Empty if the starting cell is a
+ /// block, off-grid, or has no neighbour in that direction (a "word" of
+ /// length 1 isn't really a word).
+ func wordCells(atRow row: Int, col: Int, direction: Direction) -> [Cell] {
+ guard row >= 0, row < height, col >= 0, col < width else { return [] }
+ guard !cells[row][col].isBlock else { return [] }
+ let (dr, dc): (Int, Int) = direction == .across ? (0, 1) : (1, 0)
+ var startRow = row
+ var startCol = col
+ while startRow - dr >= 0, startRow - dr < height,
+ startCol - dc >= 0, startCol - dc < width,
+ !cells[startRow - dr][startCol - dc].isBlock {
+ startRow -= dr
+ startCol -= dc
+ }
+ var result: [Cell] = []
+ var r = startRow
+ var c = startCol
+ while r >= 0, r < height, c >= 0, c < width, !cells[r][c].isBlock {
+ result.append(cells[r][c])
+ r += dr
+ c += dc
+ }
+ return result.count > 1 ? result : []
+ }
+
private static func isBlock(_ cells: [[XD.Cell]], _ row: Int, _ col: Int) -> Bool {
if case .block = cells[row][col] { return true }
return false
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -11,6 +11,7 @@ final class AppServices {
let driveMonitor: DriveMonitor
let nytFetcher: NYTPuzzleFetcher
let moveBuffer: MoveBuffer
+ let presencePublisher: PresencePublisher
let identity: AuthorIdentity
let shareController: ShareController
let colorStore: GamePlayerColorStore
@@ -59,6 +60,12 @@ final class AppServices {
)
self.moveBuffer = moveBuffer
store.moveBuffer = moveBuffer
+ self.presencePublisher = PresencePublisher(
+ persistence: persistence,
+ sink: { gameID, authorID in
+ await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID)
+ }
+ )
store.authorIDProvider = { identity.currentID }
store.onGameCreated = { [syncEngine] ckRecordName in
Task { await syncEngine.enqueueGame(ckRecordName: ckRecordName) }
diff --git a/Crossmate/Sync/PresencePublisher.swift b/Crossmate/Sync/PresencePublisher.swift
@@ -0,0 +1,143 @@
+import CoreData
+import Foundation
+
+/// Debounced writer for the local player's cursor selection. Updates the
+/// `PlayerEntity` row for `(gameID, authorID)` with the new `selRow`/`selCol`/
+/// `selDir` and asks the sync engine to push the Player record. Cursor edits
+/// don't go through `MoveBuffer` because they aren't moves — there's no
+/// Lamport clock, no replay, and last-writer-wins is the right semantics.
+actor PresencePublisher {
+ private let debounceInterval: Duration
+ private let persistence: PersistenceController
+ private let sink: @Sendable (UUID, String) async -> Void
+
+ private var pending: PlayerSelection?
+ private var lastPublished: PlayerSelection?
+ private var debounceTask: Task<Void, Never>?
+ private var gameID: UUID?
+ private var authorID: String?
+
+ init(
+ debounceInterval: Duration = .milliseconds(300),
+ persistence: PersistenceController,
+ sink: @escaping @Sendable (UUID, String) async -> Void
+ ) {
+ self.debounceInterval = debounceInterval
+ self.persistence = persistence
+ self.sink = sink
+ }
+
+ /// Starts publishing for a new puzzle session. Resets dedupe state so the
+ /// first selection from the new session always flushes.
+ func begin(gameID: UUID, authorID: String) {
+ self.gameID = gameID
+ self.authorID = authorID
+ pending = nil
+ lastPublished = nil
+ debounceTask?.cancel()
+ debounceTask = nil
+ }
+
+ /// Registers a new selection. Coalesces with any prior pending value and
+ /// schedules a trailing-edge flush. Repeated identical selections are
+ /// dropped.
+ func publish(_ selection: PlayerSelection) {
+ guard gameID != nil, authorID != nil else { return }
+ if pending == selection || (pending == nil && lastPublished == selection) {
+ return
+ }
+ pending = selection
+ scheduleDebounce()
+ }
+
+ /// Records a "no selection" — used on puzzle teardown so the peer's
+ /// outline disappears promptly instead of waiting for staleness.
+ func clear() {
+ guard gameID != nil, authorID != nil else { return }
+ pending = nil
+ debounceTask?.cancel()
+ debounceTask = nil
+ Task { await self.flushClear() }
+ }
+
+ /// Flushes any pending selection immediately and cancels the debounce.
+ func flush() async {
+ debounceTask?.cancel()
+ debounceTask = nil
+ await performFlush()
+ }
+
+ private func scheduleDebounce() {
+ debounceTask?.cancel()
+ let interval = debounceInterval
+ debounceTask = Task { [weak self] in
+ try? await Task.sleep(for: interval)
+ if Task.isCancelled { return }
+ await self?.debouncedFlush()
+ }
+ }
+
+ private func debouncedFlush() async {
+ debounceTask = nil
+ await performFlush()
+ }
+
+ private func performFlush() async {
+ guard let gameID, let authorID, let selection = pending else { return }
+ if selection == lastPublished { return }
+ pending = nil
+ lastPublished = selection
+ await write(gameID: gameID, authorID: authorID, selection: selection)
+ await sink(gameID, authorID)
+ }
+
+ private func flushClear() async {
+ guard let gameID, let authorID else { return }
+ if lastPublished == nil { return }
+ lastPublished = nil
+ await write(gameID: gameID, authorID: authorID, selection: nil)
+ await sink(gameID, authorID)
+ }
+
+ private func write(
+ gameID: UUID,
+ authorID: String,
+ selection: PlayerSelection?
+ ) async {
+ let context = persistence.container.newBackgroundContext()
+ let now = Date()
+ context.performAndWait {
+ let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ req.fetchLimit = 1
+ let entity: PlayerEntity
+ if let existing = try? context.fetch(req).first {
+ entity = existing
+ } else {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ guard let game = try? context.fetch(gameReq).first else { return }
+ entity = PlayerEntity(context: context)
+ entity.game = game
+ entity.ckRecordName = recordName
+ entity.authorID = authorID
+ entity.name = ""
+ }
+ entity.updatedAt = now
+ if let selection {
+ entity.selRow = NSNumber(value: Int64(selection.row))
+ entity.selCol = NSNumber(value: Int64(selection.col))
+ entity.selDir = NSNumber(value: Int64(selection.direction.rawValue))
+ } else {
+ entity.selRow = nil
+ entity.selCol = nil
+ entity.selDir = nil
+ }
+ if context.hasChanges {
+ try? context.save()
+ }
+ }
+ }
+}
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -156,6 +156,7 @@ enum RecordSerializer {
authorID: String,
name: String,
updatedAt: Date,
+ selection: PlayerSelection?,
zone: CKRecordZone.ID,
systemFields: Data?
) -> CKRecord {
@@ -170,6 +171,15 @@ enum RecordSerializer {
record["authorID"] = authorID as CKRecordValue
record["name"] = name as CKRecordValue
record["updatedAt"] = updatedAt as CKRecordValue
+ if let selection {
+ record["selRow"] = Int64(selection.row) as CKRecordValue
+ record["selCol"] = Int64(selection.col) as CKRecordValue
+ record["selDir"] = Int64(selection.direction.rawValue) as CKRecordValue
+ } else {
+ record["selRow"] = nil
+ record["selCol"] = nil
+ record["selDir"] = nil
+ }
let gameName = self.recordName(forGameID: gameID)
let parentID = CKRecord.ID(recordName: gameName, zoneID: zone)
@@ -177,6 +187,18 @@ enum RecordSerializer {
return record
}
+ /// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. Returns
+ /// `nil` if any field is missing — the peer either hasn't published a
+ /// selection yet or has cleared theirs (e.g. left the puzzle view).
+ static func parsePlayerSelection(from record: CKRecord) -> PlayerSelection? {
+ guard let row = record["selRow"] as? Int64,
+ let col = record["selCol"] as? Int64,
+ let dirRaw = record["selDir"] as? Int64,
+ let direction = PlayerSelection.Direction(rawValue: Int(dirRaw))
+ else { return nil }
+ return PlayerSelection(row: Int(row), col: Int(col), direction: direction)
+ }
+
/// Parses an incoming `Player` record name back into its `(gameID,
/// authorID)` components. Returns `nil` if the name doesn't match the
/// `player-<UUID>-<authorID>` shape.
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -421,11 +421,25 @@ actor SyncEngine {
let renderedName = entity.name,
let updatedAt = entity.updatedAt
else { return nil }
+ let selection: PlayerSelection?
+ if let row = entity.selRow,
+ let col = entity.selCol,
+ let dir = entity.selDir,
+ let direction = Puzzle.Direction(rawValue: dir.intValue) {
+ selection = PlayerSelection(
+ row: row.intValue,
+ col: col.intValue,
+ direction: direction
+ )
+ } else {
+ selection = nil
+ }
return RecordSerializer.playerRecord(
gameID: gameID,
authorID: authorID,
name: renderedName,
updatedAt: updatedAt,
+ selection: selection,
zone: zoneID,
systemFields: entity.ckSystemFields
)
@@ -532,6 +546,15 @@ actor SyncEngine {
entity.authorID = authorID
entity.name = renderedName
entity.updatedAt = updatedAt
+ if let selection = RecordSerializer.parsePlayerSelection(from: record) {
+ entity.selRow = NSNumber(value: Int64(selection.row))
+ entity.selCol = NSNumber(value: Int64(selection.col))
+ entity.selDir = NSNumber(value: Int64(selection.direction.rawValue))
+ } else {
+ entity.selRow = nil
+ entity.selCol = nil
+ entity.selDir = nil
+ }
}
private nonisolated func applySnapshotRecord(
diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift
@@ -8,6 +8,8 @@ struct CellView: View {
let isHighlighted: Bool
let isThematic: Bool
let specialKind: Puzzle.Special?
+ var remoteWordTint: Color? = nil
+ var remoteOutline: Color? = nil
@Environment(PlayerPreferences.self) private var preferences
private var playerColor: PlayerColor { preferences.color }
@@ -37,6 +39,10 @@ struct CellView: View {
CornerTriangle()
.fill(triangleColor)
}
+ if let remoteOutline {
+ Rectangle()
+ .strokeBorder(remoteOutline, lineWidth: 2)
+ }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -80,6 +86,11 @@ struct CellView: View {
if cell.isSpecial && specialKind == .shaded {
Color.black.opacity(0.18)
}
+ // Peer word tint sits beneath self highlight/selection so the
+ // local cursor always reads as the dominant focus.
+ if let remoteWordTint {
+ remoteWordTint
+ }
if isSelected {
playerColor.selectionFill
} else if isHighlighted {
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -2,16 +2,22 @@ import SwiftUI
struct GridView: View {
@Bindable var session: PlayerSession
+ var roster: PlayerRoster? = nil
private let spacing: CGFloat = 1
var body: some View {
let width = session.puzzle.width
let height = session.puzzle.height
+ // Index remote selections by cell so each CellView only receives the
+ // value it needs. Multiple peers landing on the same cell collapse
+ // to the most recent.
+ let (outlineByCell, tintByCell) = remoteOverlays()
PuzzleGridLayout(columns: width, rows: height, spacing: spacing) {
ForEach(0..<(width * height), id: \.self) { index in
let r = index / width
let c = index % width
+ let pos = GridPosition(row: r, col: c)
CellView(
cell: session.puzzle.cells[r][c],
entry: session.game.squares[r][c].entry,
@@ -19,7 +25,9 @@ struct GridView: View {
isSelected: session.selectedRow == r && session.selectedCol == c,
isHighlighted: session.isInCurrentWord(row: r, col: c),
isThematic: session.puzzle.thematicMask[r][c],
- specialKind: session.puzzle.specialKind
+ specialKind: session.puzzle.specialKind,
+ remoteWordTint: tintByCell[pos],
+ remoteOutline: outlineByCell[pos]
)
.onTapGesture {
session.select(row: r, col: c)
@@ -28,6 +36,33 @@ struct GridView: View {
}
.background(Color.black)
}
+
+ /// Builds the focused-cell outline map and the word-tint map from each
+ /// peer's selection. Conflicts (two peers on the same cell or word) are
+ /// resolved by keeping the most recent `updatedAt`.
+ private func remoteOverlays() -> (
+ outline: [GridPosition: Color],
+ tint: [GridPosition: Color]
+ ) {
+ guard let roster else { return ([:], [:]) }
+ var outline: [GridPosition: (Date, Color)] = [:]
+ var tint: [GridPosition: (Date, Color)] = [:]
+ for (_, sel) in roster.remoteSelections {
+ let focused = GridPosition(row: sel.row, col: sel.col)
+ if outline[focused].map({ $0.0 < sel.updatedAt }) ?? true {
+ outline[focused] = (sel.updatedAt, sel.color.selectionFill)
+ }
+ for cell in session.puzzle.wordCells(
+ atRow: sel.row, col: sel.col, direction: sel.direction
+ ) {
+ let pos = GridPosition(row: cell.row, col: cell.col)
+ if tint[pos].map({ $0.0 < sel.updatedAt }) ?? true {
+ tint[pos] = (sel.updatedAt, sel.color.highlightFill)
+ }
+ }
+ }
+ return (outline.mapValues { $0.1 }, tint.mapValues { $0.1 })
+ }
}
// MARK: - Layout
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -60,7 +60,7 @@ struct PuzzleView: View {
.frame(maxWidth: .infinity)
.padding(.horizontal)
.padding(.bottom, 12)
- GridView(session: session)
+ GridView(session: session, roster: roster)
Spacer(minLength: 12)
}
diff --git a/Tests/Unit/PresencePublisherTests.swift b/Tests/Unit/PresencePublisherTests.swift
@@ -0,0 +1,277 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("PresencePublisher", .serialized)
+@MainActor
+struct PresencePublisherTests {
+
+ /// Captures the `(gameID, authorID)` sink fan-outs.
+ actor Capture {
+ private(set) var notifications: [(UUID, String)] = []
+ var count: Int { notifications.count }
+ func append(_ gameID: UUID, _ authorID: String) {
+ notifications.append((gameID, authorID))
+ }
+ }
+
+ private func makePersistenceWithGame() throws -> (PersistenceController, UUID) {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+ let gameID = UUID()
+ let entity = GameEntity(context: context)
+ entity.id = gameID
+ entity.title = "Test"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+ try context.save()
+ return (persistence, gameID)
+ }
+
+ @Test("Publish + flush writes the PlayerEntity row and notifies the sink")
+ func publishWritesEntity() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PresencePublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice")
+ await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down))
+ await publisher.flush()
+
+ let count = await capture.count
+ #expect(count == 1)
+ let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
+ #expect(values?.selRow == 3)
+ #expect(values?.selCol == 4)
+ #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
+ #expect(values?.ckRecordName == "player-\(gameID.uuidString)-alice")
+ }
+
+ @Test("Repeated identical selections don't refire the sink")
+ func dedupesIdenticalSelections() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PresencePublisher(
+ debounceInterval: .milliseconds(40),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice")
+ let selection = PlayerSelection(row: 0, col: 0, direction: .across)
+ await publisher.publish(selection)
+ try await Task.sleep(for: .milliseconds(120))
+ await publisher.publish(selection)
+ await publisher.publish(selection)
+ try await Task.sleep(for: .milliseconds(120))
+
+ let count = await capture.count
+ #expect(count == 1)
+ }
+
+ @Test("Rapid distinct selections coalesce into a single trailing flush")
+ func debounceCoalescesRapidPublishes() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PresencePublisher(
+ debounceInterval: .milliseconds(80),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice")
+ for col in 0...4 {
+ await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across))
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ try await Task.sleep(for: .milliseconds(250))
+
+ let count = await capture.count
+ #expect(count == 1)
+ let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
+ #expect(values?.selCol == 4)
+ }
+
+ @Test("Clear nils the selection fields and notifies the sink")
+ func clearWritesNilSelection() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PresencePublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice")
+ await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down))
+ await publisher.flush()
+ await publisher.clear()
+ // clear() spawns a Task internally — give it a moment to land.
+ try await Task.sleep(for: .milliseconds(50))
+
+ let count = await capture.count
+ #expect(count == 2)
+ let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
+ #expect(values != nil)
+ #expect(values?.selRow == nil)
+ #expect(values?.selCol == nil)
+ #expect(values?.selDir == nil)
+ }
+
+ @Test("Clear is a no-op when nothing has been published yet")
+ func clearWithoutPriorPublishIsNoOp() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PresencePublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice")
+ await publisher.clear()
+ try await Task.sleep(for: .milliseconds(50))
+
+ let count = await capture.count
+ #expect(count == 0)
+ }
+
+ @Test("Publish without begin is silently ignored")
+ func publishBeforeBeginIsDropped() async throws {
+ let (persistence, _) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PresencePublisher(
+ debounceInterval: .milliseconds(40),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across))
+ await publisher.flush()
+
+ let count = await capture.count
+ #expect(count == 0)
+ }
+
+ @Test("Updates an existing PlayerEntity rather than creating a duplicate")
+ func updatesExistingEntity() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ // Pre-seed a PlayerEntity (e.g. from a name broadcast or remote sync).
+ let context = persistence.viewContext
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ let game = try #require(try context.fetch(gameReq).first)
+ let preexisting = PlayerEntity(context: context)
+ preexisting.game = game
+ preexisting.authorID = "alice"
+ preexisting.name = "Alice"
+ preexisting.updatedAt = Date()
+ preexisting.ckRecordName = "player-\(gameID.uuidString)-alice"
+ try context.save()
+
+ let publisher = PresencePublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { _, _ in }
+ )
+ await publisher.begin(gameID: gameID, authorID: "alice")
+ await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across))
+ await publisher.flush()
+
+ let rows = fetchAllPlayers(gameID: gameID, persistence: persistence)
+ #expect(rows.count == 1)
+ #expect(rows.first?.name == "Alice") // pre-existing field preserved
+ #expect(rows.first?.selRow == 5)
+ #expect(rows.first?.selCol == 6)
+ }
+
+ @Test("Publishing again after clear writes the new selection")
+ func publishAfterClear() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PresencePublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice")
+ await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across))
+ await publisher.flush()
+ await publisher.clear()
+ try await Task.sleep(for: .milliseconds(50))
+ await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down))
+ await publisher.flush()
+
+ let count = await capture.count
+ #expect(count == 3)
+ let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
+ #expect(values?.selRow == 2)
+ #expect(values?.selCol == 2)
+ #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
+ }
+
+ // MARK: - Helpers
+
+ struct PlayerValues {
+ let name: String?
+ let selRow: Int64?
+ let selCol: Int64?
+ let selDir: Int64?
+ let ckRecordName: String?
+ }
+
+ private func fetchPlayer(
+ gameID: UUID,
+ authorID: String,
+ persistence: PersistenceController
+ ) -> PlayerValues? {
+ let context = persistence.container.newBackgroundContext()
+ return context.performAndWait {
+ let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ request.predicate = NSPredicate(
+ format: "game.id == %@ AND authorID == %@",
+ gameID as CVarArg,
+ authorID
+ )
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first else { return nil }
+ return PlayerValues(
+ name: entity.name,
+ selRow: entity.selRow?.int64Value,
+ selCol: entity.selCol?.int64Value,
+ selDir: entity.selDir?.int64Value,
+ ckRecordName: entity.ckRecordName
+ )
+ }
+ }
+
+ private func fetchAllPlayers(
+ gameID: UUID,
+ persistence: PersistenceController
+ ) -> [PlayerValues] {
+ let context = persistence.container.newBackgroundContext()
+ return context.performAndWait {
+ let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
+ guard let entities = try? context.fetch(request) else { return [] }
+ return entities.map {
+ PlayerValues(
+ name: $0.name,
+ selRow: $0.selRow?.int64Value,
+ selCol: $0.selCol?.int64Value,
+ selDir: $0.selDir?.int64Value,
+ ckRecordName: $0.ckRecordName
+ )
+ }
+ }
+ }
+}