crossmate

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

PlayerSession.swift (27834B)


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