crossmate

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

PlayerSession.swift (23182B)


      1 import Foundation
      2 import Observation
      3 
      4 /// Local, per-player state for a single crossword game. Holds everything that
      5 /// belongs to one player and is *not* shared with the other side of a
      6 /// collaborative session: cursor position, current direction, pencil mode,
      7 /// and (eventually) chosen colour. All shared mutations are routed through
      8 /// the underlying `Game`.
      9 @MainActor
     10 @Observable
     11 final class PlayerSession {
     12     let game: Game
     13     let mutator: GameMutator
     14 
     15     /// Device-local store for this player's last cursor position, keyed by
     16     /// game. Restored on open and rewritten as the cursor moves so reopening a
     17     /// puzzle — even after a cold launch — returns to where they left off.
     18     /// `nil` for solo/test sessions that don't persist.
     19     @ObservationIgnored
     20     private let cursorStore: GameCursorStore?
     21 
     22     var selectedRow: Int {
     23         didSet { selectionDidChange() }
     24     }
     25     var selectedCol: Int {
     26         didSet { selectionDidChange() }
     27     }
     28     var direction: Puzzle.Direction = .across {
     29         didSet { selectionDidChange() }
     30     }
     31     var isPencilMode: Bool = false
     32 
     33     /// Optional sink fired whenever the selected answer slot changes. The
     34     /// local cursor reticle remains exact and local-only; the published value
     35     /// is the coarser cursor track persisted to CloudKit for collaborators.
     36     /// Unset for solo (non-shared) games.
     37     var onSelectionChanged: ((PlayerSelection) -> Void)? {
     38         didSet {
     39             if onSelectionChanged == nil {
     40                 lastPublishedCursorTrack = nil
     41             }
     42         }
     43     }
     44 
     45     @ObservationIgnored
     46     private var lastPublishedCursorTrack: PlayerSelection?
     47 
     48     /// The single completion signal the UI reacts to, carrying both *what*
     49     /// completion state the grid reached and *who* drove it there. Unlike
     50     /// `Game.completionState` — derived state that flips for local and remote
     51     /// edits alike — this is an event: repeated failed attempts on an
     52     /// already-full wrong grid still get a fresh sequence, and a local solve is
     53     /// distinguishable from a collaborator's. `PlayerSession` is the sole owner:
     54     /// local input emits `.local` inline, while a remote merge into the shared
     55     /// `Game` is reconciled into `.observed` (see `observeRemoteCompletion`). A
     56     /// single owner means the view has one handler and no ordering coupling.
     57     struct CompletionEvent: Equatable {
     58         let sequence: Int
     59         let state: Game.CompletionState
     60         let origin: Origin
     61 
     62         /// Who drove the grid to its completion state: this player's own input,
     63         /// or a merge of a collaborator's edits.
     64         enum Origin: Equatable {
     65             case local
     66             case observed
     67         }
     68     }
     69 
     70     var completionEvent: CompletionEvent?
     71 
     72     @ObservationIgnored
     73     private var completionEventSequence = 0
     74 
     75     /// Rebus mode lets the player type a multi-character value into a single
     76     /// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in
     77     /// `rebusBuffer` rather than going straight to `Game.squares`; on commit
     78     /// the buffer is written to the cell and the cursor advances.
     79     var isRebusActive: Bool = false
     80     var rebusBuffer: String = ""
     81 
     82     var puzzle: Puzzle { game.puzzle }
     83 
     84     /// Cells a peer filled or cleared since this player last viewed the puzzle,
     85     /// each mapped to the author who wrote the change. Captured once on the
     86     /// open "arm" beat (see `PuzzleView`) and rendered as fading author-coloured
     87     /// borders by `GridView`; cleared on the player's first interaction, which
     88     /// is the acknowledgement. Empty outside that window.
     89     var recentChanges: [GridPosition: String] = [:]
     90 
     91     /// Fired when `recentChanges` is acknowledged (cleared) by an interaction,
     92     /// so the owner can advance this game's last-viewed timestamp. Unset for
     93     /// solo/test sessions.
     94     @ObservationIgnored
     95     var onRecentChangesAcknowledged: (() -> Void)?
     96 
     97     init(game: Game, mutator: GameMutator, cursorStore: GameCursorStore? = nil) {
     98         self.game = game
     99         self.mutator = mutator
    100         self.cursorStore = cursorStore
    101         let puzzle = game.puzzle
    102 
    103         // Default: start at the first across clue. Fall back to the first down
    104         // clue if the puzzle has no across answers, then to (0, 0) for a
    105         // degenerate puzzle with no clues at all.
    106         var startRow = 0
    107         var startCol = 0
    108         var startDirection: Puzzle.Direction = .across
    109         if let first = puzzle.acrossClues.first,
    110            let cell = puzzle.cell(numbered: first.number) {
    111             startRow = cell.row
    112             startCol = cell.col
    113             startDirection = .across
    114         } else if let first = puzzle.downClues.first,
    115                   let cell = puzzle.cell(numbered: first.number) {
    116             startRow = cell.row
    117             startCol = cell.col
    118             startDirection = .down
    119         }
    120 
    121         // Prefer this player's last position for the game when one was
    122         // persisted and still points at an editable cell. The grid never
    123         // changes for a given game, so a stored cursor stays valid; the
    124         // bounds/block check is purely defensive.
    125         if let saved = cursorStore?.cursor(forGame: mutator.gameID),
    126            saved.row >= 0, saved.row < puzzle.height,
    127            saved.col >= 0, saved.col < puzzle.width,
    128            !puzzle.cells[saved.row][saved.col].isBlock {
    129             startRow = saved.row
    130             startCol = saved.col
    131             startDirection = saved.direction
    132         }
    133 
    134         self.selectedRow = startRow
    135         self.selectedCol = startCol
    136         self.direction = startDirection
    137 
    138         // A non-block cell always belongs to at least one word; flip if the
    139         // restored direction has none so the cursor never lands directionless.
    140         if !hasWord(at: selectedRow, col: selectedCol, direction: direction),
    141            hasWord(at: selectedRow, col: selectedCol, direction: direction.opposite) {
    142             direction = direction.opposite
    143         }
    144 
    145         observeRemoteCompletion()
    146     }
    147 
    148     // MARK: - Selection
    149 
    150     private func selectionDidChange() {
    151         acknowledgeRecentChangesIfNeeded()
    152         publishCurrentSelection()
    153         cursorStore?.setCursor(
    154             .init(row: selectedRow, col: selectedCol, direction: direction),
    155             forGame: mutator.gameID
    156         )
    157     }
    158 
    159     /// Clears the "changed while you were away" borders on the first selection
    160     /// change after they were captured — moving the cursor or typing (which
    161     /// advances it) both route through here — and notifies the owner so the
    162     /// last-viewed timestamp advances. A no-op while `recentChanges` is empty.
    163     private func acknowledgeRecentChangesIfNeeded() {
    164         guard !recentChanges.isEmpty else { return }
    165         recentChanges = [:]
    166         onRecentChangesAcknowledged?()
    167     }
    168 
    169     func publishCurrentSelection() {
    170         guard let onSelectionChanged else { return }
    171         guard let track = currentCursorTrack else { return }
    172         guard track != lastPublishedCursorTrack else { return }
    173         lastPublishedCursorTrack = track
    174         onSelectionChanged(track)
    175     }
    176 
    177     /// Cursor track for the active selection, or `nil` if the selected
    178     /// cell has no answer slot in the current direction. Exposed so the
    179     /// puzzle-open path can ship the initial track in the open burst
    180     /// without routing it through `onSelectionChanged`'s debounced sink.
    181     var currentCursorTrack: PlayerSelection? {
    182         puzzle.cursorTrack(
    183             atRow: selectedRow,
    184             col: selectedCol,
    185             direction: direction
    186         )
    187     }
    188 
    189     func select(row: Int, col: Int) {
    190         guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return }
    191         if row == selectedRow && col == selectedCol {
    192             direction = direction.opposite
    193             if !hasWord(at: row, col: col, direction: direction) {
    194                 direction = direction.opposite
    195             }
    196         } else {
    197             selectedRow = row
    198             selectedCol = col
    199             if !hasWord(at: row, col: col, direction: direction) {
    200                 direction = direction.opposite
    201             }
    202         }
    203     }
    204 
    205     func togglePencil() {
    206         // TODO: when wired up, letters typed in pencil mode should be stored
    207         // as tentative entries (rendered in a lighter weight) until confirmed.
    208         isPencilMode.toggle()
    209     }
    210 
    211     func toggleDirection() {
    212         let next = direction.opposite
    213         if hasWord(at: selectedRow, col: selectedCol, direction: next) {
    214             direction = next
    215         }
    216     }
    217 
    218     func setDirection(_ newDirection: Puzzle.Direction) {
    219         guard newDirection != direction else { return }
    220         if hasWord(at: selectedRow, col: selectedCol, direction: newDirection) {
    221             direction = newDirection
    222         }
    223     }
    224 
    225     func selectClue(direction: Puzzle.Direction, number: Int) {
    226         guard let cell = puzzle.cell(numbered: number) else { return }
    227         self.direction = direction
    228         selectedRow = cell.row
    229         selectedCol = cell.col
    230     }
    231 
    232     // MARK: - Clue navigation
    233 
    234     func goToNextClue() {
    235         moveClue(by: +1)
    236     }
    237 
    238     func goToPreviousClue() {
    239         moveClue(by: -1)
    240     }
    241 
    242     func goToNextWord() {
    243         moveWord(by: +1)
    244     }
    245 
    246     func goToPreviousWord() {
    247         moveWord(by: -1)
    248     }
    249 
    250     func goToNextLetter() {
    251         advance()
    252     }
    253 
    254     func goToPreviousLetter() {
    255         retreat()
    256     }
    257 
    258     private func moveClue(by offset: Int) {
    259         // Walk every clue in order: all acrosses, then all downs. Stepping past
    260         // the last across rolls into the first down (and vice versa going
    261         // backwards), which matches how most crossword apps behave.
    262         let ordered = orderedClues()
    263         guard !ordered.isEmpty else { return }
    264 
    265         let currentNumber = currentClueNumber()
    266         let currentIndex = ordered.firstIndex {
    267             $0.0 == direction && $0.1.number == currentNumber
    268         } ?? 0
    269         let count = ordered.count
    270         let nextIndex = ((currentIndex + offset) % count + count) % count
    271 
    272         let (newDirection, newClue) = ordered[nextIndex]
    273         direction = newDirection
    274         moveToClueStart(number: newClue.number)
    275     }
    276 
    277     private func orderedClues() -> [(Puzzle.Direction, Puzzle.Clue)] {
    278         puzzle.acrossClues.map { (.across, $0) }
    279             + puzzle.downClues.map { (.down, $0) }
    280     }
    281 
    282     private func moveWord(by offset: Int) {
    283         let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
    284         guard !clues.isEmpty else { return }
    285         let currentNumber = currentClueNumber()
    286         let currentIndex = clues.firstIndex { $0.number == currentNumber } ?? 0
    287         let count = clues.count
    288         let nextIndex = ((currentIndex + offset) % count + count) % count
    289         moveToClueStart(number: clues[nextIndex].number)
    290     }
    291 
    292     // MARK: - Check / Reveal / Clear
    293     //
    294     // These translate the player's cursor into a set of cells (or the whole
    295     // puzzle) and ask `Game` to apply the operation. The actual marking lives
    296     // on `Game` because checks and reveals are shared state.
    297 
    298     func checkSquare() {
    299         let cell = puzzle.cells[selectedRow][selectedCol]
    300         guard !cell.isBlock else { return }
    301         mutator.checkCells([cell])
    302     }
    303 
    304     func checkCurrentWord() {
    305         mutator.checkCells(currentWordCells())
    306     }
    307 
    308     func checkPuzzle() {
    309         mutator.checkCells(puzzle.cells.flatMap { $0 })
    310     }
    311 
    312     func revealSquare() {
    313         let cell = puzzle.cells[selectedRow][selectedCol]
    314         guard !cell.isBlock else { return }
    315         mutator.revealCells([cell])
    316         publishLocalCompletion()
    317     }
    318 
    319     func revealCurrentWord() {
    320         mutator.revealCells(currentWordCells())
    321         publishLocalCompletion()
    322     }
    323 
    324     func revealPuzzle() {
    325         mutator.revealCells(puzzle.cells.flatMap { $0 })
    326         publishLocalCompletion()
    327     }
    328 
    329     func clearCurrentWord() {
    330         mutator.clearCells(currentWordCells())
    331     }
    332 
    333     func clearPuzzle() {
    334         mutator.clearCells(puzzle.cells.flatMap { $0 })
    335     }
    336 
    337     // MARK: - Undo / redo
    338 
    339     var canUndo: Bool { mutator.canUndo }
    340     var canRedo: Bool { mutator.canRedo }
    341 
    342     /// Undoes the most recent move and follows the cursor to the cell it
    343     /// touched, so reversing a typed letter lands back on that letter. A bulk
    344     /// clear returns no target, so the cursor stays where it is.
    345     func undo() {
    346         if let landing = mutator.undo() {
    347             placeCursor(atRow: landing.position.row, atCol: landing.position.col, preferring: landing.direction)
    348         }
    349     }
    350 
    351     func redo() {
    352         if let landing = mutator.redo() {
    353             placeCursor(atRow: landing.position.row, atCol: landing.position.col, preferring: landing.direction)
    354         }
    355     }
    356 
    357     /// Moves the cursor onto `(row, col)` without the same-cell direction flip
    358     /// `select(row:col:)` applies. When `preferred` is given and that direction
    359     /// has a word at the destination, the cursor adopts it — so undoing a letter
    360     /// restores the orientation it was typed in. Otherwise the current direction
    361     /// is kept, flipped only if it has no word here, so the cursor never lands
    362     /// directionless.
    363     private func placeCursor(atRow row: Int, atCol col: Int, preferring preferred: Puzzle.Direction? = nil) {
    364         guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return }
    365         selectedRow = row
    366         selectedCol = col
    367         if let preferred, hasWord(at: row, col: col, direction: preferred) {
    368             direction = preferred
    369             return
    370         }
    371         if !hasWord(at: row, col: col, direction: direction),
    372            hasWord(at: row, col: col, direction: direction.opposite) {
    373             direction = direction.opposite
    374         }
    375     }
    376 
    377     private func currentClueNumber() -> Int? {
    378         let start = wordStart(row: selectedRow, col: selectedCol, direction: direction)
    379         return puzzle.cells[start.row][start.col].number
    380     }
    381 
    382     private func moveToClueStart(number: Int) {
    383         for r in 0..<puzzle.height {
    384             for c in 0..<puzzle.width where puzzle.cells[r][c].number == number {
    385                 selectedRow = r
    386                 selectedCol = c
    387                 return
    388             }
    389         }
    390     }
    391 
    392     private func moveToClueEnd(direction newDirection: Puzzle.Direction, number: Int) {
    393         guard let start = puzzle.cell(numbered: number) else { return }
    394         let (dr, dc) = step(for: newDirection)
    395         var row = start.row
    396         var col = start.col
    397         while isValid(row: row + dr, col: col + dc),
    398               !puzzle.cells[row + dr][col + dc].isBlock {
    399             row += dr
    400             col += dc
    401         }
    402         direction = newDirection
    403         selectedRow = row
    404         selectedCol = col
    405     }
    406 
    407     // MARK: - Input
    408 
    409     func enter(_ letter: String) {
    410         let inputRow = selectedRow
    411         let inputCol = selectedCol
    412         let cell = puzzle.cells[inputRow][inputCol]
    413         guard !cell.isBlock else { return }
    414         mutator.setLetter(letter, atRow: inputRow, atCol: inputCol, pencil: isPencilMode, direction: direction)
    415         let completionState = game.completionState
    416         publishLocalCompletion(completionState)
    417         if completionState != .solved {
    418             advance()
    419         }
    420     }
    421 
    422     func deleteBackward() {
    423         // If the cursor is on an empty cell or a revealed (locked) cell,
    424         // retreat first — revealed cells can't be cleared in place, so delete
    425         // tunnels past them to the previous editable cell. `clearLetter`
    426         // itself no-ops on revealed cells, so calling it unconditionally
    427         // after retreat is safe.
    428         let currentMark = game.squares[selectedRow][selectedCol].mark
    429         let currentEmpty = game.squares[selectedRow][selectedCol].entry.isEmpty
    430         if currentEmpty || currentMark.isRevealed {
    431             retreat()
    432         }
    433         mutator.clearLetter(atRow: selectedRow, atCol: selectedCol, direction: direction)
    434     }
    435 
    436     // MARK: - Rebus
    437 
    438     func startRebus() {
    439         let cell = puzzle.cells[selectedRow][selectedCol]
    440         guard !cell.isBlock else { return }
    441         rebusBuffer = game.squares[selectedRow][selectedCol].entry
    442         isRebusActive = true
    443     }
    444 
    445     func appendRebusLetter(_ letter: String) {
    446         rebusBuffer += letter.uppercased()
    447     }
    448 
    449     func deleteRebusLetter() {
    450         guard !rebusBuffer.isEmpty else { return }
    451         rebusBuffer.removeLast()
    452     }
    453 
    454     func commitRebus() {
    455         let value = committedRebusValue(from: rebusBuffer)
    456         let inputRow = selectedRow
    457         let inputCol = selectedCol
    458         isRebusActive = false
    459         rebusBuffer = ""
    460         mutator.setLetter(value, atRow: inputRow, atCol: inputCol, pencil: isPencilMode, direction: direction)
    461         let completionState = game.completionState
    462         publishLocalCompletion(completionState)
    463         if completionState != .solved {
    464             advance()
    465         }
    466     }
    467 
    468     private func committedRebusValue(from buffer: String) -> String {
    469         guard buffer.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.inverted) != nil else {
    470             return buffer
    471         }
    472         return buffer.trimmingCharacters(in: .whitespacesAndNewlines)
    473     }
    474 
    475     // MARK: - Word geometry
    476 
    477     func isInCurrentWord(row: Int, col: Int) -> Bool {
    478         currentWordCells().contains(where: { $0.row == row && $0.col == col })
    479     }
    480 
    481     func currentClue() -> Puzzle.Clue? {
    482         let start = wordStart(row: selectedRow, col: selectedCol, direction: direction)
    483         guard let number = puzzle.cells[start.row][start.col].number else { return nil }
    484         let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
    485         return clues.first { $0.number == number }
    486     }
    487 
    488     private func currentWordCells() -> [Puzzle.Cell] {
    489         let (dr, dc) = step(for: direction)
    490         let start = wordStart(row: selectedRow, col: selectedCol, direction: direction)
    491         var cells: [Puzzle.Cell] = []
    492         var r = start.row
    493         var c = start.col
    494         while isValid(row: r, col: c) && !puzzle.cells[r][c].isBlock {
    495             cells.append(puzzle.cells[r][c])
    496             r += dr
    497             c += dc
    498         }
    499         return cells
    500     }
    501 
    502     private func wordStart(row: Int, col: Int, direction: Puzzle.Direction) -> (row: Int, col: Int) {
    503         let (dr, dc) = step(for: direction)
    504         var r = row
    505         var c = col
    506         while isValid(row: r - dr, col: c - dc) && !puzzle.cells[r - dr][c - dc].isBlock {
    507             r -= dr
    508             c -= dc
    509         }
    510         return (r, c)
    511     }
    512 
    513     private func hasWord(at row: Int, col: Int, direction: Puzzle.Direction) -> Bool {
    514         let (dr, dc) = step(for: direction)
    515         let hasNext = isValid(row: row + dr, col: col + dc)
    516             && !puzzle.cells[row + dr][col + dc].isBlock
    517         let hasPrev = isValid(row: row - dr, col: col - dc)
    518             && !puzzle.cells[row - dr][col - dc].isBlock
    519         return hasNext || hasPrev
    520     }
    521 
    522     private func step(for direction: Puzzle.Direction) -> (Int, Int) {
    523         direction == .across ? (0, 1) : (1, 0)
    524     }
    525 
    526     private func publishLocalCompletion(_ state: Game.CompletionState? = nil) {
    527         let state = state ?? game.completionState
    528         guard state != .incomplete else { return }
    529         emitCompletion(state, origin: .local)
    530     }
    531 
    532     private func emitCompletion(_ state: Game.CompletionState, origin: CompletionEvent.Origin) {
    533         completionEventSequence += 1
    534         completionEvent = CompletionEvent(
    535             sequence: completionEventSequence,
    536             state: state,
    537             origin: origin
    538         )
    539     }
    540 
    541     /// Watches the shared `Game`'s completion state for a transition this
    542     /// player didn't drive — a collaborator's merged edits completing the grid
    543     /// — and reconciles it into a single `.observed` completion event. Armed
    544     /// once at the end of `init` (so the game's already-restored initial state
    545     /// never fires) and re-armed after every change, since
    546     /// `withObservationTracking` is one-shot. Only a remote *solve* surfaces: a
    547     /// collaborator's wrong fill must not interrupt the local solver, and a
    548     /// solve the local input path already announced is not re-emitted.
    549     private func observeRemoteCompletion() {
    550         withObservationTracking {
    551             _ = game.completionState
    552         } onChange: { [weak self] in
    553             // `onChange` fires synchronously at willSet, before the new value is
    554             // readable; hop to the main actor to read the settled state and re-arm.
    555             Task { @MainActor [weak self] in
    556                 guard let self else { return }
    557                 self.reconcileRemoteCompletion()
    558                 self.observeRemoteCompletion()
    559             }
    560         }
    561     }
    562 
    563     private func reconcileRemoteCompletion() {
    564         let state = game.completionState
    565         guard state == .solved else { return }
    566         guard completionEvent?.state != .solved else { return }
    567         emitCompletion(state, origin: .observed)
    568     }
    569 
    570     private func advance() {
    571         let (dr, dc) = step(for: direction)
    572         let r = selectedRow + dr
    573         let c = selectedCol + dc
    574         // If we're still inside the current word, step one cell. Otherwise
    575         // we've hit the end of the word, so jump to the next clue in the full
    576         // clue list. Without that, we'd fall through the block into a different
    577         // word in the same row/column — which for down clues is almost never
    578         // the next clue by number.
    579         if isValid(row: r, col: c) && !puzzle.cells[r][c].isBlock {
    580             selectedRow = r
    581             selectedCol = c
    582         } else {
    583             advanceToNextClue()
    584         }
    585     }
    586 
    587     private func advanceToNextClue() {
    588         moveClue(by: +1)
    589     }
    590 
    591     private func retreat() {
    592         let start = wordStart(row: selectedRow, col: selectedCol, direction: direction)
    593         if selectedRow == start.row && selectedCol == start.col {
    594             retreatToPreviousClueEnd()
    595             return
    596         }
    597 
    598         let (dr, dc) = step(for: direction)
    599         var r = selectedRow - dr
    600         var c = selectedCol - dc
    601         while isValid(row: r, col: c) && puzzle.cells[r][c].isBlock {
    602             r -= dr
    603             c -= dc
    604         }
    605         if isValid(row: r, col: c) {
    606             selectedRow = r
    607             selectedCol = c
    608         }
    609     }
    610 
    611     private func retreatToPreviousClueEnd() {
    612         let ordered = orderedClues()
    613         guard !ordered.isEmpty else { return }
    614 
    615         let currentNumber = currentClueNumber()
    616         let currentIndex = ordered.firstIndex {
    617             $0.0 == direction && $0.1.number == currentNumber
    618         } ?? 0
    619         let previousIndex = ((currentIndex - 1) % ordered.count + ordered.count) % ordered.count
    620         let (newDirection, newClue) = ordered[previousIndex]
    621         moveToClueEnd(direction: newDirection, number: newClue.number)
    622     }
    623 
    624     private func isValid(row: Int, col: Int) -> Bool {
    625         row >= 0 && row < puzzle.height && col >= 0 && col < puzzle.width
    626     }
    627 }