crossmate

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

PlayerSession.swift (15777B)


      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     var selectedRow: Int {
     15         didSet { publishSelectionIfNeeded() }
     16     }
     17     var selectedCol: Int {
     18         didSet { publishSelectionIfNeeded() }
     19     }
     20     var direction: Puzzle.Direction = .across {
     21         didSet { publishSelectionIfNeeded() }
     22     }
     23     var isPencilMode: Bool = false
     24 
     25     /// Optional sink fired whenever the cursor moves. Wired to
     26     /// `PlayerSelectionPublisher` from the puzzle view so the local selection is
     27     /// debounced + pushed to CloudKit, and the peer can render the outline.
     28     /// Unset for solo (non-shared) games.
     29     var onSelectionChanged: ((PlayerSelection) -> Void)?
     30 
     31     /// Optional sink fired after local mutations that can fill or solve the
     32     /// puzzle. This keeps completion UI out of SwiftUI's render-time body
     33     /// evaluation path.
     34     @ObservationIgnored
     35     var onCompletionStateChanged: ((Game.CompletionState) -> Void)?
     36 
     37     /// Optional sink fired for collaborator-visible events (check, reveal, win)
     38     /// so the puzzle screen can enqueue a CloudKit ping for other participants.
     39     /// Set only for shared games with iCloud sync enabled; nil for solo games.
     40     @ObservationIgnored
     41     var onPlayerEvent: ((PingKind, PingScope?) -> Void)?
     42 
     43     /// Last completion state we fired a `.win` ping for. Tracked so the win
     44     /// ping fires once on the `.solved` transition and not on every keystroke
     45     /// after the puzzle is already solved.
     46     @ObservationIgnored
     47     private var lastWinCompletionState: Game.CompletionState?
     48 
     49     /// Rebus mode lets the player type a multi-character value into a single
     50     /// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in
     51     /// `rebusBuffer` rather than going straight to `Game.squares`; on commit
     52     /// the buffer is written to the cell and the cursor advances.
     53     var isRebusActive: Bool = false
     54     var rebusBuffer: String = ""
     55 
     56     var puzzle: Puzzle { game.puzzle }
     57 
     58     init(game: Game, mutator: GameMutator) {
     59         self.game = game
     60         self.mutator = mutator
     61         self.lastWinCompletionState = game.completionState
     62         let puzzle = game.puzzle
     63 
     64         // Start at the first across clue. Fall back to the first down clue if
     65         // the puzzle has no across answers, then to (0, 0) for a degenerate
     66         // puzzle with no clues at all.
     67         if let first = puzzle.acrossClues.first,
     68            let cell = puzzle.cell(numbered: first.number) {
     69             self.selectedRow = cell.row
     70             self.selectedCol = cell.col
     71             self.direction = .across
     72         } else if let first = puzzle.downClues.first,
     73                   let cell = puzzle.cell(numbered: first.number) {
     74             self.selectedRow = cell.row
     75             self.selectedCol = cell.col
     76             self.direction = .down
     77         } else {
     78             self.selectedRow = 0
     79             self.selectedCol = 0
     80         }
     81     }
     82 
     83     // MARK: - Selection
     84 
     85     private func publishSelectionIfNeeded() {
     86         guard let onSelectionChanged else { return }
     87         onSelectionChanged(PlayerSelection(
     88             row: selectedRow,
     89             col: selectedCol,
     90             direction: direction
     91         ))
     92     }
     93 
     94     func select(row: Int, col: Int) {
     95         guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return }
     96         if row == selectedRow && col == selectedCol {
     97             direction = direction.opposite
     98             if !hasWord(at: row, col: col, direction: direction) {
     99                 direction = direction.opposite
    100             }
    101         } else {
    102             selectedRow = row
    103             selectedCol = col
    104             if !hasWord(at: row, col: col, direction: direction) {
    105                 direction = direction.opposite
    106             }
    107         }
    108     }
    109 
    110     func togglePencil() {
    111         // TODO: when wired up, letters typed in pencil mode should be stored
    112         // as tentative entries (rendered in a lighter weight) until confirmed.
    113         isPencilMode.toggle()
    114     }
    115 
    116     func toggleDirection() {
    117         let next = direction.opposite
    118         if hasWord(at: selectedRow, col: selectedCol, direction: next) {
    119             direction = next
    120         }
    121     }
    122 
    123     func setDirection(_ newDirection: Puzzle.Direction) {
    124         guard newDirection != direction else { return }
    125         if hasWord(at: selectedRow, col: selectedCol, direction: newDirection) {
    126             direction = newDirection
    127         }
    128     }
    129 
    130     func selectClue(direction: Puzzle.Direction, number: Int) {
    131         guard let cell = puzzle.cell(numbered: number) else { return }
    132         self.direction = direction
    133         selectedRow = cell.row
    134         selectedCol = cell.col
    135     }
    136 
    137     // MARK: - Clue navigation
    138 
    139     func goToNextClue() {
    140         moveClue(by: +1)
    141     }
    142 
    143     func goToPreviousClue() {
    144         moveClue(by: -1)
    145     }
    146 
    147     func goToNextWord() {
    148         moveWord(by: +1)
    149     }
    150 
    151     func goToPreviousWord() {
    152         moveWord(by: -1)
    153     }
    154 
    155     func goToNextLetter() {
    156         advance()
    157     }
    158 
    159     func goToPreviousLetter() {
    160         retreat()
    161     }
    162 
    163     private func moveClue(by offset: Int) {
    164         // Walk every clue in order: all acrosses, then all downs. Stepping past
    165         // the last across rolls into the first down (and vice versa going
    166         // backwards), which matches how most crossword apps behave.
    167         let ordered = orderedClues()
    168         guard !ordered.isEmpty else { return }
    169 
    170         let currentNumber = currentClueNumber()
    171         let currentIndex = ordered.firstIndex {
    172             $0.0 == direction && $0.1.number == currentNumber
    173         } ?? 0
    174         let count = ordered.count
    175         let nextIndex = ((currentIndex + offset) % count + count) % count
    176 
    177         let (newDirection, newClue) = ordered[nextIndex]
    178         direction = newDirection
    179         moveToClueStart(number: newClue.number)
    180     }
    181 
    182     private func orderedClues() -> [(Puzzle.Direction, Puzzle.Clue)] {
    183         puzzle.acrossClues.map { (.across, $0) }
    184             + puzzle.downClues.map { (.down, $0) }
    185     }
    186 
    187     private func moveWord(by offset: Int) {
    188         let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
    189         guard !clues.isEmpty else { return }
    190         let currentNumber = currentClueNumber()
    191         let currentIndex = clues.firstIndex { $0.number == currentNumber } ?? 0
    192         let count = clues.count
    193         let nextIndex = ((currentIndex + offset) % count + count) % count
    194         moveToClueStart(number: clues[nextIndex].number)
    195     }
    196 
    197     // MARK: - Check / Reveal / Clear
    198     //
    199     // These translate the player's cursor into a set of cells (or the whole
    200     // puzzle) and ask `Game` to apply the operation. The actual marking lives
    201     // on `Game` because checks and reveals are shared state.
    202 
    203     func checkSquare() {
    204         let cell = puzzle.cells[selectedRow][selectedCol]
    205         guard !cell.isBlock else { return }
    206         mutator.checkCells([cell])
    207         onPlayerEvent?(.check, .square)
    208     }
    209 
    210     func checkCurrentWord() {
    211         mutator.checkCells(currentWordCells())
    212         onPlayerEvent?(.check, .word)
    213     }
    214 
    215     func checkPuzzle() {
    216         mutator.checkCells(puzzle.cells.flatMap { $0 })
    217         onPlayerEvent?(.check, .puzzle)
    218     }
    219 
    220     func revealSquare() {
    221         let cell = puzzle.cells[selectedRow][selectedCol]
    222         guard !cell.isBlock else { return }
    223         mutator.revealCells([cell])
    224         onPlayerEvent?(.reveal, .square)
    225         publishTerminalCompletionState()
    226     }
    227 
    228     func revealCurrentWord() {
    229         mutator.revealCells(currentWordCells())
    230         onPlayerEvent?(.reveal, .word)
    231         publishTerminalCompletionState()
    232     }
    233 
    234     func revealPuzzle() {
    235         mutator.revealCells(puzzle.cells.flatMap { $0 })
    236         onPlayerEvent?(.reveal, .puzzle)
    237         publishTerminalCompletionState()
    238     }
    239 
    240     func clearCurrentWord() {
    241         mutator.clearCells(currentWordCells())
    242     }
    243 
    244     func clearPuzzle() {
    245         mutator.clearCells(puzzle.cells.flatMap { $0 })
    246     }
    247 
    248     private func currentClueNumber() -> Int? {
    249         let start = wordStart(row: selectedRow, col: selectedCol, direction: direction)
    250         return puzzle.cells[start.row][start.col].number
    251     }
    252 
    253     private func moveToClueStart(number: Int) {
    254         for r in 0..<puzzle.height {
    255             for c in 0..<puzzle.width where puzzle.cells[r][c].number == number {
    256                 selectedRow = r
    257                 selectedCol = c
    258                 return
    259             }
    260         }
    261     }
    262 
    263     private func moveToClueEnd(direction newDirection: Puzzle.Direction, number: Int) {
    264         guard let start = puzzle.cell(numbered: number) else { return }
    265         let (dr, dc) = step(for: newDirection)
    266         var row = start.row
    267         var col = start.col
    268         while isValid(row: row + dr, col: col + dc),
    269               !puzzle.cells[row + dr][col + dc].isBlock {
    270             row += dr
    271             col += dc
    272         }
    273         direction = newDirection
    274         selectedRow = row
    275         selectedCol = col
    276     }
    277 
    278     // MARK: - Input
    279 
    280     func enter(_ letter: String) {
    281         let inputRow = selectedRow
    282         let inputCol = selectedCol
    283         let cell = puzzle.cells[inputRow][inputCol]
    284         guard !cell.isBlock else { return }
    285         mutator.setLetter(letter, atRow: inputRow, atCol: inputCol, pencil: isPencilMode)
    286         let completionState = game.completionState
    287         publishTerminalCompletionState(completionState)
    288         if completionState != .solved {
    289             advance()
    290         }
    291     }
    292 
    293     func deleteBackward() {
    294         // If the cursor is on an empty cell or a revealed (locked) cell,
    295         // retreat first — revealed cells can't be cleared in place, so delete
    296         // tunnels past them to the previous editable cell. `clearLetter`
    297         // itself no-ops on revealed cells, so calling it unconditionally
    298         // after retreat is safe.
    299         let currentMark = game.squares[selectedRow][selectedCol].mark
    300         let currentEmpty = game.squares[selectedRow][selectedCol].entry.isEmpty
    301         if currentEmpty || currentMark.isRevealed {
    302             retreat()
    303         }
    304         mutator.clearLetter(atRow: selectedRow, atCol: selectedCol)
    305     }
    306 
    307     // MARK: - Rebus
    308 
    309     func startRebus() {
    310         let cell = puzzle.cells[selectedRow][selectedCol]
    311         guard !cell.isBlock else { return }
    312         rebusBuffer = game.squares[selectedRow][selectedCol].entry
    313         isRebusActive = true
    314     }
    315 
    316     func appendRebusLetter(_ letter: String) {
    317         rebusBuffer += letter.uppercased()
    318     }
    319 
    320     func deleteRebusLetter() {
    321         guard !rebusBuffer.isEmpty else { return }
    322         rebusBuffer.removeLast()
    323     }
    324 
    325     func commitRebus() {
    326         let value = rebusBuffer
    327         let inputRow = selectedRow
    328         let inputCol = selectedCol
    329         isRebusActive = false
    330         rebusBuffer = ""
    331         mutator.setLetter(value, atRow: inputRow, atCol: inputCol, pencil: isPencilMode)
    332         let completionState = game.completionState
    333         publishTerminalCompletionState(completionState)
    334         if completionState != .solved {
    335             advance()
    336         }
    337     }
    338 
    339     // MARK: - Word geometry
    340 
    341     func isInCurrentWord(row: Int, col: Int) -> Bool {
    342         currentWordCells().contains(where: { $0.row == row && $0.col == col })
    343     }
    344 
    345     func currentClue() -> Puzzle.Clue? {
    346         let start = wordStart(row: selectedRow, col: selectedCol, direction: direction)
    347         guard let number = puzzle.cells[start.row][start.col].number else { return nil }
    348         let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
    349         return clues.first { $0.number == number }
    350     }
    351 
    352     private func currentWordCells() -> [Puzzle.Cell] {
    353         let (dr, dc) = step(for: direction)
    354         let start = wordStart(row: selectedRow, col: selectedCol, direction: direction)
    355         var cells: [Puzzle.Cell] = []
    356         var r = start.row
    357         var c = start.col
    358         while isValid(row: r, col: c) && !puzzle.cells[r][c].isBlock {
    359             cells.append(puzzle.cells[r][c])
    360             r += dr
    361             c += dc
    362         }
    363         return cells
    364     }
    365 
    366     private func wordStart(row: Int, col: Int, direction: Puzzle.Direction) -> (row: Int, col: Int) {
    367         let (dr, dc) = step(for: direction)
    368         var r = row
    369         var c = col
    370         while isValid(row: r - dr, col: c - dc) && !puzzle.cells[r - dr][c - dc].isBlock {
    371             r -= dr
    372             c -= dc
    373         }
    374         return (r, c)
    375     }
    376 
    377     private func hasWord(at row: Int, col: Int, direction: Puzzle.Direction) -> Bool {
    378         let (dr, dc) = step(for: direction)
    379         let hasNext = isValid(row: row + dr, col: col + dc)
    380             && !puzzle.cells[row + dr][col + dc].isBlock
    381         let hasPrev = isValid(row: row - dr, col: col - dc)
    382             && !puzzle.cells[row - dr][col - dc].isBlock
    383         return hasNext || hasPrev
    384     }
    385 
    386     private func step(for direction: Puzzle.Direction) -> (Int, Int) {
    387         direction == .across ? (0, 1) : (1, 0)
    388     }
    389 
    390     private func publishTerminalCompletionState(_ state: Game.CompletionState? = nil) {
    391         let state = state ?? game.completionState
    392         guard state != .incomplete else { return }
    393         if state == .solved, lastWinCompletionState != .solved {
    394             onPlayerEvent?(.win, nil)
    395         }
    396         lastWinCompletionState = state
    397         onCompletionStateChanged?(state)
    398     }
    399 
    400     private func advance() {
    401         let (dr, dc) = step(for: direction)
    402         let r = selectedRow + dr
    403         let c = selectedCol + dc
    404         // If we're still inside the current word, step one cell. Otherwise
    405         // we've hit the end of the word, so jump to the next clue in the full
    406         // clue list. Without that, we'd fall through the block into a different
    407         // word in the same row/column — which for down clues is almost never
    408         // the next clue by number.
    409         if isValid(row: r, col: c) && !puzzle.cells[r][c].isBlock {
    410             selectedRow = r
    411             selectedCol = c
    412         } else {
    413             advanceToNextClue()
    414         }
    415     }
    416 
    417     private func advanceToNextClue() {
    418         moveClue(by: +1)
    419     }
    420 
    421     private func retreat() {
    422         let start = wordStart(row: selectedRow, col: selectedCol, direction: direction)
    423         if selectedRow == start.row && selectedCol == start.col {
    424             retreatToPreviousClueEnd()
    425             return
    426         }
    427 
    428         let (dr, dc) = step(for: direction)
    429         var r = selectedRow - dr
    430         var c = selectedCol - dc
    431         while isValid(row: r, col: c) && puzzle.cells[r][c].isBlock {
    432             r -= dr
    433             c -= dc
    434         }
    435         if isValid(row: r, col: c) {
    436             selectedRow = r
    437             selectedCol = c
    438         }
    439     }
    440 
    441     private func retreatToPreviousClueEnd() {
    442         let ordered = orderedClues()
    443         guard !ordered.isEmpty else { return }
    444 
    445         let currentNumber = currentClueNumber()
    446         let currentIndex = ordered.firstIndex {
    447             $0.0 == direction && $0.1.number == currentNumber
    448         } ?? 0
    449         let previousIndex = ((currentIndex - 1) % ordered.count + ordered.count) % ordered.count
    450         let (newDirection, newClue) = ordered[previousIndex]
    451         moveToClueEnd(direction: newDirection, number: newClue.number)
    452     }
    453 
    454     private func isValid(row: Int, col: Int) -> Bool {
    455         row >= 0 && row < puzzle.height && col >= 0 && col < puzzle.width
    456     }
    457 }