commit 31758bb8b0807a5c998a3e41e15e3a960845c8bc
parent d759c61b1c46ead344bb7e20c4635fde362cd127
Author: Michael Camilleri <[email protected]>
Date: Sat, 16 May 2026 06:45:06 +0900
Persist the local cursor per game
Opening a puzzle, returning to the Game List, then reopening the same puzzle
resets the cursor to the first across clue, because PuzzleDisplayView builds a
fresh PlayerSession on every navigation and PlayerSession.init always seeded
the position from the puzzle's clues.
This commit adds GameCursorStore, a UserDefaults-backed per-game store keyed by
gameID, modeled on GamePlayerColorStore. The cursor is device-local by design
and never synced to CloudKit — it is distinct from the coarser collaborator
"cursor track" PlayerSession already publishes. PlayerSession.init now restores
the saved (row, col, direction) when one exists and still points at an editable
cell, and the selectedRow/selectedCol/direction observers write through to the
store on every move, so the position survives both navigation and a cold launch
without relying on a clean shutdown. A non-block cell always belongs to at
least one word, so a restored direction with no word flips to the valid one; an
out-of-bounds or block-cell entry falls back to the default start.
The store is wired through AppServices alongside colorStore and cleared in
makeOnGameDeleted when a game is deleted. The cursorStore parameters on
PlayerSession.init and makeOnGameDeleted default to nil so solo/test paths and
the existing colour-cleanup test compile unchanged.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
6 files changed, 294 insertions(+), 19 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -13,6 +13,7 @@
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; };
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; };
0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; };
+ 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.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 */; };
@@ -49,6 +50,7 @@
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; };
8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; };
849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; };
+ 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */; };
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; };
8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; };
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; };
@@ -142,6 +144,7 @@
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>"; };
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>"; };
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>"; };
@@ -158,6 +161,7 @@
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; };
86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; };
87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; };
+ 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; };
927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; };
93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; };
9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -245,6 +249,7 @@
212DB6FCF46C41F81C41D232 /* Unit */ = {
isa = PBXGroup;
children = (
+ 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */,
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */,
D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */,
@@ -275,6 +280,7 @@
children = (
B135C285570F91181595B405 /* CellMark.swift */,
465F2BB469EFE84CF3733398 /* Game.swift */,
+ 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */,
666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */,
DB55FC337CF72C650373210A /* PlayerColor.swift */,
46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */,
@@ -504,6 +510,7 @@
files = (
A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */,
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */,
+ 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */,
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */,
453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */,
@@ -550,6 +557,7 @@
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */,
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */,
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */,
+ 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */,
818B1F2693962832BE14578E /* GameListView.swift in Sources */,
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */,
DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -384,7 +384,11 @@ private struct PuzzleDisplayView: View {
Task { await services.dismissDeliveredNotifications(for: gameID) }
do {
let (game, mutator) = try store.loadGame(id: gameID)
- let newSession = PlayerSession(game: game, mutator: mutator)
+ let newSession = PlayerSession(
+ game: game,
+ mutator: mutator,
+ cursorStore: services.cursorStore
+ )
let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences)
roster = newRoster
session = newSession
diff --git a/Crossmate/Models/GameCursorStore.swift b/Crossmate/Models/GameCursorStore.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+/// Persists the local cursor (row, column, direction) per game in
+/// `UserDefaults`.
+///
+/// The cursor is device-local by design — it is never synced to CloudKit. The
+/// coarser collaborator-visible "cursor track" is published separately by
+/// `PlayerSession`; this store only remembers where *this* player left off so
+/// reopening a puzzle (even after a cold launch) restores their position.
+///
+/// Layout under the `"gameCursors"` key:
+/// `[gameID.uuidString: ["row": Int, "col": Int, "dir": Int]]`
+/// where `dir` is `0` for across and `1` for down.
+@MainActor
+final class GameCursorStore {
+ struct Cursor: Equatable {
+ var row: Int
+ var col: Int
+ var direction: Puzzle.Direction
+ }
+
+ private let defaults: UserDefaults
+ private let defaultsKey = "gameCursors"
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ }
+
+ // MARK: - Read
+
+ func cursor(forGame gameID: UUID) -> Cursor? {
+ guard let entry = rawStore[gameID.uuidString],
+ let row = entry["row"],
+ let col = entry["col"],
+ let dir = entry["dir"] else { return nil }
+ return Cursor(row: row, col: col, direction: dir == 1 ? .down : .across)
+ }
+
+ // MARK: - Write
+
+ func setCursor(_ cursor: Cursor, forGame gameID: UUID) {
+ if self.cursor(forGame: gameID) == cursor { return }
+ var s = rawStore
+ s[gameID.uuidString] = [
+ "row": cursor.row,
+ "col": cursor.col,
+ "dir": cursor.direction == .down ? 1 : 0
+ ]
+ rawStore = s
+ }
+
+ func clearCursor(forGame gameID: UUID) {
+ var s = rawStore
+ guard s[gameID.uuidString] != nil else { return }
+ s.removeValue(forKey: gameID.uuidString)
+ rawStore = s
+ }
+
+ // MARK: - Private
+
+ private var rawStore: [String: [String: Int]] {
+ get { defaults.dictionary(forKey: defaultsKey) as? [String: [String: Int]] ?? [:] }
+ set { defaults.set(newValue, forKey: defaultsKey) }
+ }
+}
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -11,14 +11,22 @@ import Observation
final class PlayerSession {
let game: Game
let mutator: GameMutator
+
+ /// Device-local store for this player's last cursor position, keyed by
+ /// game. Restored on open and rewritten as the cursor moves so reopening a
+ /// puzzle — even after a cold launch — returns to where they left off.
+ /// `nil` for solo/test sessions that don't persist.
+ @ObservationIgnored
+ private let cursorStore: GameCursorStore?
+
var selectedRow: Int {
- didSet { publishSelectionIfNeeded() }
+ didSet { selectionDidChange() }
}
var selectedCol: Int {
- didSet { publishSelectionIfNeeded() }
+ didSet { selectionDidChange() }
}
var direction: Puzzle.Direction = .across {
- didSet { publishSelectionIfNeeded() }
+ didSet { selectionDidChange() }
}
var isPencilMode: Bool = false
@@ -55,33 +63,66 @@ final class PlayerSession {
var puzzle: Puzzle { game.puzzle }
- init(game: Game, mutator: GameMutator) {
+ init(game: Game, mutator: GameMutator, cursorStore: GameCursorStore? = nil) {
self.game = game
self.mutator = mutator
+ self.cursorStore = cursorStore
self.lastWinCompletionState = game.completionState
let puzzle = game.puzzle
- // Start at the first across clue. Fall back to the first down clue if
- // the puzzle has no across answers, then to (0, 0) for a degenerate
- // puzzle with no clues at all.
+ // Default: start at the first across clue. Fall back to the first down
+ // clue if the puzzle has no across answers, then to (0, 0) for a
+ // degenerate puzzle with no clues at all.
+ var startRow = 0
+ var startCol = 0
+ var startDirection: Puzzle.Direction = .across
if let first = puzzle.acrossClues.first,
let cell = puzzle.cell(numbered: first.number) {
- self.selectedRow = cell.row
- self.selectedCol = cell.col
- self.direction = .across
+ startRow = cell.row
+ startCol = cell.col
+ startDirection = .across
} else if let first = puzzle.downClues.first,
let cell = puzzle.cell(numbered: first.number) {
- self.selectedRow = cell.row
- self.selectedCol = cell.col
- self.direction = .down
- } else {
- self.selectedRow = 0
- self.selectedCol = 0
+ startRow = cell.row
+ startCol = cell.col
+ startDirection = .down
+ }
+
+ // Prefer this player's last position for the game when one was
+ // persisted and still points at an editable cell. The grid never
+ // changes for a given game, so a stored cursor stays valid; the
+ // bounds/block check is purely defensive.
+ if let saved = cursorStore?.cursor(forGame: mutator.gameID),
+ saved.row >= 0, saved.row < puzzle.height,
+ saved.col >= 0, saved.col < puzzle.width,
+ !puzzle.cells[saved.row][saved.col].isBlock {
+ startRow = saved.row
+ startCol = saved.col
+ startDirection = saved.direction
+ }
+
+ self.selectedRow = startRow
+ self.selectedCol = startCol
+ self.direction = startDirection
+
+ // A non-block cell always belongs to at least one word; flip if the
+ // restored direction has none so the cursor never lands directionless.
+ if !hasWord(at: selectedRow, col: selectedCol, direction: direction),
+ hasWord(at: selectedRow, col: selectedCol, direction: direction.opposite) {
+ direction = direction.opposite
}
}
// MARK: - Selection
+ private func selectionDidChange() {
+ publishSelectionIfNeeded()
+ cursorStore?.setCursor(
+ .init(row: selectedRow, col: selectedCol, direction: direction),
+ forGame: mutator.gameID
+ )
+ }
+
private func publishSelectionIfNeeded() {
guard let onSelectionChanged else { return }
guard let track = puzzle.cursorTrack(
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -36,6 +36,7 @@ final class AppServices {
let identity: AuthorIdentity
let shareController: ShareController
let colorStore: GamePlayerColorStore
+ let cursorStore: GameCursorStore
let cloudService: CloudService
let importService: ImportService
@@ -128,9 +129,12 @@ final class AppServices {
let colorStore = GamePlayerColorStore()
self.colorStore = colorStore
+ let cursorStore = GameCursorStore()
+ self.cursorStore = cursorStore
let onGameDeletedHandler = Self.makeOnGameDeleted(
syncEngine: syncEngine,
- colorStore: colorStore
+ colorStore: colorStore,
+ cursorStore: cursorStore
)
let store = GameStore(
@@ -1193,10 +1197,12 @@ final class AppServices {
/// colour-cleanup branch from drifting silently.
static func makeOnGameDeleted(
syncEngine: SyncEngine,
- colorStore: GamePlayerColorStore
+ colorStore: GamePlayerColorStore,
+ cursorStore: GameCursorStore? = nil
) -> (GameCloudDeletion) -> Void {
{ deletion in
colorStore.clearColors(forGame: deletion.gameID)
+ cursorStore?.clearCursor(forGame: deletion.gameID)
Task { await syncEngine.enqueueDeleteGame(deletion) }
}
}
diff --git a/Tests/Unit/GameCursorStoreTests.swift b/Tests/Unit/GameCursorStoreTests.swift
@@ -0,0 +1,151 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("GameCursorStore")
+@MainActor
+struct GameCursorStoreTests {
+
+ private func makeStore() -> GameCursorStore {
+ // Use a fresh UserDefaults suite per test to avoid cross-test pollution.
+ let suiteName = "test-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ return GameCursorStore(defaults: defaults)
+ }
+
+ /// 3×3 grid with a block at (1, 1):
+ /// row 0: A B C (A1 across, D1/D2/… downs start here)
+ /// row 1: D # E
+ /// row 2: F G H
+ /// (1, 0) = 'D' is part of the D1 down answer only — it has no across word.
+ private func makeGame() throws -> Game {
+ let source = """
+ Title: Cursor Test
+ Author: Test
+
+
+ ABC
+ D#E
+ FGH
+
+
+ A1. First across ~ ABC
+ A3. Final across ~ FGH
+ D1. First down ~ ADF
+ D2. Final down ~ CEH
+ """
+ let puzzle = Puzzle(xd: try XD.parse(source))
+ return Game(puzzle: puzzle)
+ }
+
+ private func makeSession(
+ game: Game,
+ gameID: UUID,
+ store: GameCursorStore?
+ ) -> PlayerSession {
+ let mutator = GameMutator(game: game, gameID: gameID, movesUpdater: nil)
+ return PlayerSession(game: game, mutator: mutator, cursorStore: store)
+ }
+
+ // MARK: - Store round-trip
+
+ @Test("Cursor round-trips through the store")
+ func cursorRoundTrips() {
+ let store = makeStore()
+ let gameID = UUID()
+ #expect(store.cursor(forGame: gameID) == nil)
+
+ store.setCursor(.init(row: 2, col: 1, direction: .down), forGame: gameID)
+ #expect(store.cursor(forGame: gameID) == .init(row: 2, col: 1, direction: .down))
+
+ store.clearCursor(forGame: gameID)
+ #expect(store.cursor(forGame: gameID) == nil)
+ }
+
+ @Test("Cursors are isolated per game")
+ func cursorsIsolatedPerGame() {
+ let store = makeStore()
+ let a = UUID()
+ let b = UUID()
+ store.setCursor(.init(row: 0, col: 1, direction: .across), forGame: a)
+ store.setCursor(.init(row: 2, col: 2, direction: .down), forGame: b)
+ #expect(store.cursor(forGame: a) == .init(row: 0, col: 1, direction: .across))
+ #expect(store.cursor(forGame: b) == .init(row: 2, col: 2, direction: .down))
+ }
+
+ // MARK: - PlayerSession persistence + restore
+
+ @Test("Moving the cursor persists it to the store")
+ func movingCursorPersists() throws {
+ let store = makeStore()
+ let gameID = UUID()
+ let session = makeSession(game: try makeGame(), gameID: gameID, store: store)
+
+ session.select(row: 2, col: 2)
+
+ let saved = try #require(store.cursor(forGame: gameID))
+ #expect(saved.row == 2)
+ #expect(saved.col == 2)
+ }
+
+ @Test("A new session restores the persisted cursor")
+ func sessionRestoresPersistedCursor() throws {
+ let store = makeStore()
+ let gameID = UUID()
+
+ let first = makeSession(game: try makeGame(), gameID: gameID, store: store)
+ first.select(row: 2, col: 0)
+ first.setDirection(.down)
+
+ let reopened = makeSession(game: try makeGame(), gameID: gameID, store: store)
+ #expect(reopened.selectedRow == 2)
+ #expect(reopened.selectedCol == 0)
+ #expect(reopened.direction == .down)
+ }
+
+ @Test("Without a store the session uses the default start position")
+ func defaultsWithoutStore() throws {
+ let session = makeSession(game: try makeGame(), gameID: UUID(), store: nil)
+ #expect(session.selectedRow == 0)
+ #expect(session.selectedCol == 0)
+ #expect(session.direction == .across)
+ }
+
+ @Test("An out-of-bounds saved cursor falls back to the default start")
+ func invalidSavedCursorFallsBack() throws {
+ let store = makeStore()
+ let gameID = UUID()
+ store.setCursor(.init(row: 9, col: 9, direction: .across), forGame: gameID)
+
+ let session = makeSession(game: try makeGame(), gameID: gameID, store: store)
+ #expect(session.selectedRow == 0)
+ #expect(session.selectedCol == 0)
+ #expect(session.direction == .across)
+ }
+
+ @Test("A saved cursor on a block cell falls back to the default start")
+ func blockSavedCursorFallsBack() throws {
+ let store = makeStore()
+ let gameID = UUID()
+ store.setCursor(.init(row: 1, col: 1, direction: .across), forGame: gameID)
+
+ let session = makeSession(game: try makeGame(), gameID: gameID, store: store)
+ #expect(session.selectedRow == 0)
+ #expect(session.selectedCol == 0)
+ }
+
+ @Test("Restore flips direction when the saved one has no word")
+ func restoreFlipsDirectionWithoutWord() throws {
+ let store = makeStore()
+ let gameID = UUID()
+ // (1, 0) = 'D' belongs only to the D1 down answer; there is no across
+ // word through it, so a saved `.across` must flip to `.down`.
+ store.setCursor(.init(row: 1, col: 0, direction: .across), forGame: gameID)
+
+ let session = makeSession(game: try makeGame(), gameID: gameID, store: store)
+ #expect(session.selectedRow == 1)
+ #expect(session.selectedCol == 0)
+ #expect(session.direction == .down)
+ }
+}