crossmate

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

commit ead08bca7c90d7e32e8c09e7e9336f5522b29210
parent e1d073ca8449828b99a5a1e25b787417028ddc03
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 17:02:00 +0900

Persist Cursor Track instead of Cursor Reticle

Cursor presence was publishing the selected square into the Player record on
each movement. That made ordinary within-answer movement particularly noisy.

PlayerSession now converts the local selection into a Cursor Track before
publishing: the start cell of the selected answer plus its direction. The local
Cursor Reticle remains unchanged for play, but CloudKit presence reuses the
existing selRow/selCol/selDir fields to store the coarser Cursor Track.

In conjunction with this, grid rendering now treats Player selections as Cursor
Tracks only. It tints the peer's selected answer but no longer draws a
focused-cell reticle from the persisted value.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 9+++++----
MCrossmate/Models/PlayerRoster.swift | 4++--
MCrossmate/Models/PlayerSelection.swift | 4++--
MCrossmate/Models/PlayerSession.swift | 13+++++++------
MCrossmate/Models/Puzzle.swift | 11+++++++++++
MCrossmate/Sync/PlayerSelectionPublisher.swift | 16++++++++--------
MCrossmate/Sync/RecordSerializer.swift | 7++++---
MCrossmate/Views/GridView.swift | 14+++++---------
MTests/Unit/PlayerSessionNavigationTests.swift | 29+++++++++++++++++++++++++++++
9 files changed, 73 insertions(+), 34 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -503,12 +503,13 @@ private struct PuzzleDisplayView: View { ) } } - let initial = PlayerSelection( - row: session.selectedRow, + if let initial = session.puzzle.cursorTrack( + atRow: session.selectedRow, col: session.selectedCol, direction: session.direction - ) - await selectionPublisher.publish(initial) + ) { + await selectionPublisher.publish(initial) + } } private func pollOpenSyncedPuzzle() async { diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -34,7 +34,7 @@ final class PlayerRoster { let updatedAt: Date } - /// Peer cursors keyed by `authorID`. Stale entries (older than the + /// Peer cursor tracks 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] = [:] @@ -275,7 +275,7 @@ final class PlayerRoster { ) entries = [localEntry] + remoteEntries - // Map raw selections to the resolved colour from the entry list, + // Map raw cursor tracks 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) } diff --git a/Crossmate/Models/PlayerSelection.swift b/Crossmate/Models/PlayerSelection.swift @@ -1,9 +1,9 @@ import Foundation -/// A peer player's current cursor: the cell they have focused and the +/// A peer player's current cursor track: the canonical start cell and /// 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. +/// selected slot without trying to sync every local cursor-reticle movement. struct PlayerSelection: Sendable, Equatable { let row: Int let col: Int diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -22,9 +22,9 @@ final class PlayerSession { } var isPencilMode: Bool = false - /// Optional sink fired whenever the cursor moves. Wired to - /// `PlayerSelectionPublisher` from the puzzle view so the local selection is - /// debounced + pushed to CloudKit, and the peer can render the outline. + /// Optional sink fired whenever the selected answer slot changes. The + /// local cursor reticle remains exact and local-only; the published value + /// is the coarser cursor track persisted to CloudKit for collaborators. /// Unset for solo (non-shared) games. var onSelectionChanged: ((PlayerSelection) -> Void)? @@ -84,11 +84,12 @@ final class PlayerSession { private func publishSelectionIfNeeded() { guard let onSelectionChanged else { return } - onSelectionChanged(PlayerSelection( - row: selectedRow, + guard let track = puzzle.cursorTrack( + atRow: selectedRow, col: selectedCol, direction: direction - )) + ) else { return } + onSelectionChanged(track) } func select(row: Int, col: Int) { diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -268,6 +268,17 @@ struct Puzzle: Sendable { return result.count > 1 ? result : [] } + /// Returns the canonical cursor track for a focused cell: the start cell + /// of the answer slot in `direction`, paired with that direction. This is + /// the low-frequency collaborative presence value persisted to CloudKit; + /// the exact focused square remains local cursor-reticle state. + func cursorTrack(atRow row: Int, col: Int, direction: Direction) -> PlayerSelection? { + guard let start = wordCells(atRow: row, col: col, direction: direction).first else { + return nil + } + return PlayerSelection(row: start.row, col: start.col, direction: direction) + } + /// Returns the cells of every clue cross-referenced from the focus /// word. Gated on direction: only fires when the focus cell's word in /// the *current* direction is itself one of the cross-referenced diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift @@ -1,11 +1,11 @@ import CoreData import Foundation -/// Debounced writer for the local player's cursor selection. Updates the +/// Debounced writer for the local player's cursor track. 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 `MovesUpdater` because they aren't cell edits — they live -/// on `PlayerEntity` with last-writer-wins semantics. +/// `selDir` and asks the sync engine to push the Player record. Cursor-track +/// edits don't go through `MovesUpdater` because they aren't cell edits — they +/// live on `PlayerEntity` with last-writer-wins semantics. actor PlayerSelectionPublisher { private let debounceInterval: Duration private let persistence: PersistenceController @@ -47,8 +47,8 @@ actor PlayerSelectionPublisher { debounceTask = nil } - /// Registers a new selection. Coalesces with any prior pending value and - /// schedules a trailing-edge flush. Repeated identical selections are + /// Registers a new cursor track. Coalesces with any prior pending value + /// and schedules a trailing-edge flush. Repeated identical tracks are /// dropped. func publish(_ selection: PlayerSelection) { guard gameID != nil, authorID != nil else { return } @@ -59,8 +59,8 @@ actor PlayerSelectionPublisher { scheduleDebounce() } - /// Records a "no selection" — used on puzzle teardown so the peer's - /// outline disappears promptly instead of waiting for staleness. + /// Records a "no cursor track" — used on puzzle teardown so the peer's + /// overlay disappears promptly instead of waiting for staleness. func clear() { guard gameID != nil, authorID != nil else { return } pending = nil diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -201,9 +201,10 @@ 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). + /// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. These + /// fields carry the peer's cursor track start, not their exact local + /// reticle. Returns `nil` if any field is missing — the peer either hasn't + /// published a track 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, diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -55,20 +55,16 @@ 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`. + /// Builds remote word-tint overlays from each peer's persisted cursor + /// track. The exact cursor reticle is intentionally local-only; CloudKit + /// presence records carry the selected answer slot, not the focused + /// square. private func remoteOverlays() -> ( outline: [GridPosition: Color], tint: [GridPosition: Color] ) { - 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 ) { @@ -78,7 +74,7 @@ struct GridView: View { } } } - return (outline.mapValues { $0.1 }, tint.mapValues { $0.1 }) + return ([:], tint.mapValues { $0.1 }) } } diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift @@ -6,6 +6,35 @@ import Testing @Suite("PlayerSession navigation", .serialized) @MainActor struct PlayerSessionNavigationTests { + @Test("Published selection is the cursor track start, not the local reticle") + func publishesCursorTrackStart() throws { + let session = try makeNavigationSession() + var published: [PlayerSelection] = [] + session.onSelectionChanged = { published.append($0) } + + session.select(row: 0, col: 2) + session.setDirection(.across) + + #expect(session.selectedRow == 0) + #expect(session.selectedCol == 2) + #expect(published.last == PlayerSelection(row: 0, col: 0, direction: .across)) + } + + @Test("Moving within one answer keeps publishing the same cursor track") + func movingWithinAnswerKeepsSameCursorTrack() throws { + let session = try makeNavigationSession() + var published: [PlayerSelection] = [] + session.onSelectionChanged = { published.append($0) } + + session.select(row: 0, col: 1) + session.select(row: 0, col: 2) + + #expect(!published.isEmpty) + #expect(published.allSatisfy { + $0 == PlayerSelection(row: 0, col: 0, direction: .across) + }) + } + @Test("Next clue from final across moves to first down") func nextClueFromFinalAcrossMovesToFirstDown() throws { let session = try makeNavigationSession()