commit f095843f49522fd702ed5ba8b3df20f1f34dc396
parent d08ec8bc081c088ac8f10995332f5a894c1a2928
Author: Michael Camilleri <[email protected]>
Date: Wed, 20 May 2026 14:45:34 +0900
Deduplicate cursor track publishing
PlayerSession now remembers the last published cursor track and suppresses
selection updates that stay within the same word and direction. The initial
shared-game selection publish also goes through the same PlayerSession path so
the dedupe state is initialized consistently.
This keeps cursor track updates for word or direction changes, but avoids
writing Player records for square-level cursor movement that collaborators do
not currently display.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 18 insertions(+), 16 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -591,13 +591,7 @@ private struct PuzzleDisplayView: View {
)
}
}
- if let initial = session.puzzle.cursorTrack(
- atRow: session.selectedRow,
- col: session.selectedCol,
- direction: session.direction
- ) {
- await selectionPublisher.publish(initial)
- }
+ session.publishCurrentSelection()
}
private func pollOpenSyncedPuzzle() async {
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -34,7 +34,16 @@ final class PlayerSession {
/// 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)?
+ var onSelectionChanged: ((PlayerSelection) -> Void)? {
+ didSet {
+ if onSelectionChanged == nil {
+ lastPublishedCursorTrack = nil
+ }
+ }
+ }
+
+ @ObservationIgnored
+ private var lastPublishedCursorTrack: PlayerSelection?
/// Optional sink fired after local mutations that can fill or solve the
/// puzzle. This keeps completion UI out of SwiftUI's render-time body
@@ -109,20 +118,22 @@ final class PlayerSession {
// MARK: - Selection
private func selectionDidChange() {
- publishSelectionIfNeeded()
+ publishCurrentSelection()
cursorStore?.setCursor(
.init(row: selectedRow, col: selectedCol, direction: direction),
forGame: mutator.gameID
)
}
- private func publishSelectionIfNeeded() {
+ func publishCurrentSelection() {
guard let onSelectionChanged else { return }
guard let track = puzzle.cursorTrack(
atRow: selectedRow,
col: selectedCol,
direction: direction
) else { return }
+ guard track != lastPublishedCursorTrack else { return }
+ lastPublishedCursorTrack = track
onSelectionChanged(track)
}
diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift
@@ -20,8 +20,8 @@ struct PlayerSessionNavigationTests {
#expect(published.last == PlayerSelection(row: 0, col: 0, direction: .across))
}
- @Test("Moving within one answer keeps publishing the same cursor track")
- func movingWithinAnswerKeepsSameCursorTrack() throws {
+ @Test("Moving within one answer publishes the cursor track once")
+ func movingWithinAnswerPublishesCursorTrackOnce() throws {
let session = try makeNavigationSession()
var published: [PlayerSelection] = []
session.onSelectionChanged = { published.append($0) }
@@ -29,10 +29,7 @@ struct PlayerSessionNavigationTests {
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)
- })
+ #expect(published == [PlayerSelection(row: 0, col: 0, direction: .across)])
}
@Test("Next clue from final across moves to first down")