crossmate

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

Game.swift (7767B)


      1 import Foundation
      2 import Observation
      3 
      4 /// The shared, eventually-synced state of a single crossword game. Everything
      5 /// on `Game` is content that both players see — the puzzle itself, the
      6 /// letters that have been entered, and the per-cell marks for pencil / check
      7 /// / reveal state. Anything that's local to a single player (cursor position,
      8 /// pencil-mode toggle, chosen colour) lives on `PlayerSession`, not here.
      9 @MainActor
     10 @Observable
     11 final class Game {
     12     let puzzle: Puzzle
     13     var squares: [[Square]]
     14     @ObservationIgnored private let fillableCellCount: Int
     15     private var filledCellCount: Int = 0
     16     private var wrongCellCount: Int = 0
     17 
     18     init(puzzle: Puzzle) {
     19         self.puzzle = puzzle
     20         self.fillableCellCount = puzzle.cells.reduce(0) { count, row in
     21             count + row.filter { !$0.isBlock && $0.solution != nil }.count
     22         }
     23         self.squares = Array(
     24             repeating: Array(repeating: Square(), count: puzzle.width),
     25             count: puzzle.height
     26         )
     27     }
     28 
     29     // MARK: - Letter writes
     30 
     31     /// Writes `letter` into `(row, col)`. If `pencil` is true the cell is
     32     /// marked `.pencil(checkedWrong: false)`; otherwise the mark is cleared
     33     /// to `.none`. Revealed cells are locked — writes are silently ignored.
     34     /// When the new letter matches the existing entry, `letterAuthorID` is
     35     /// preserved — typing over an existing answer (common when filling a
     36     /// crossing word) shouldn't reattribute the square to the second typist.
     37     func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, authorID: String? = nil) {
     38         let cell = puzzle.cells[row][col]
     39         guard !cell.isBlock else { return }
     40         guard !squares[row][col].mark.isRevealed else { return }
     41         let oldEntry = squares[row][col].entry
     42         let newEntry = letter.uppercased()
     43         squares[row][col].entry = newEntry
     44         squares[row][col].mark = pencil ? .pencil(checkedWrong: false) : .none
     45         if newEntry != oldEntry {
     46             squares[row][col].letterAuthorID = authorID
     47         }
     48         noteEntryChange(from: oldEntry, to: newEntry, for: cell)
     49     }
     50 
     51     /// Clears the entry and mark at `(row, col)`. No-op on revealed cells.
     52     func clearLetter(atRow row: Int, atCol col: Int) {
     53         let cell = puzzle.cells[row][col]
     54         guard !cell.isBlock else { return }
     55         guard !squares[row][col].mark.isRevealed else { return }
     56         let oldEntry = squares[row][col].entry
     57         squares[row][col].entry = ""
     58         squares[row][col].mark = .none
     59         squares[row][col].letterAuthorID = nil
     60         noteEntryChange(from: oldEntry, to: "", for: cell)
     61     }
     62 
     63     // MARK: - Check / Reveal / Clear
     64 
     65     /// For each non-empty, non-revealed target cell, compares the current
     66     /// entry against the solution. Wrong entries get `checkedWrong = true`
     67     /// (preserving pen-vs-pencil style); correct entries have the wrong mark
     68     /// cleared. Empty cells and cells without a known solution are skipped.
     69     func checkCells(_ cells: [Puzzle.Cell]) {
     70         for cell in cells {
     71             guard !cell.isBlock else { continue }
     72             guard cell.solution != nil else { continue }
     73             let entry = squares[cell.row][cell.col].entry
     74             guard !entry.isEmpty else { continue }
     75             guard !squares[cell.row][cell.col].mark.isRevealed else { continue }
     76 
     77             let isWrong = !cell.accepts(entry)
     78             switch squares[cell.row][cell.col].mark {
     79             case .pencil:
     80                 squares[cell.row][cell.col].mark = .pencil(checkedWrong: isWrong)
     81             case .none, .pen:
     82                 squares[cell.row][cell.col].mark = isWrong ? .pen(checkedWrong: true) : .none
     83             case .revealed:
     84                 break // unreachable; guarded above
     85             }
     86         }
     87     }
     88 
     89     func checkPuzzle() {
     90         checkCells(puzzle.cells.flatMap { $0 })
     91     }
     92 
     93     /// For each target cell, writes the solution into the entry and sets the
     94     /// mark to `.revealed` (which locks the cell against future edits). Cells
     95     /// whose entry already matches the solution are left untouched — the user
     96     /// got it right, so there's no reason to overwrite their mark or lock the
     97     /// cell.
     98     func revealCells(_ cells: [Puzzle.Cell]) {
     99         for cell in cells {
    100             guard !cell.isBlock else { continue }
    101             guard let solution = cell.solution else { continue }
    102             let expected = solution.uppercased()
    103             let oldEntry = squares[cell.row][cell.col].entry
    104             if cell.accepts(oldEntry) { continue }
    105             squares[cell.row][cell.col].entry = expected
    106             squares[cell.row][cell.col].mark = .revealed
    107             squares[cell.row][cell.col].letterAuthorID = nil
    108             noteEntryChange(from: oldEntry, to: expected, for: cell)
    109         }
    110     }
    111 
    112     func revealPuzzle() {
    113         revealCells(puzzle.cells.flatMap { $0 })
    114     }
    115 
    116     /// Clears the entry and mark for each non-revealed target cell. Revealed
    117     /// cells are left untouched — once revealed, a cell's contents are
    118     /// permanent for the rest of the game.
    119     func clearCells(_ cells: [Puzzle.Cell]) {
    120         for cell in cells {
    121             guard !cell.isBlock else { continue }
    122             guard !squares[cell.row][cell.col].mark.isRevealed else { continue }
    123             let oldEntry = squares[cell.row][cell.col].entry
    124             squares[cell.row][cell.col].entry = ""
    125             squares[cell.row][cell.col].mark = .none
    126             squares[cell.row][cell.col].letterAuthorID = nil
    127             noteEntryChange(from: oldEntry, to: "", for: cell)
    128         }
    129     }
    130 
    131     func clearPuzzle() {
    132         clearCells(puzzle.cells.flatMap { $0 })
    133     }
    134 
    135     // MARK: - Completion
    136 
    137     enum CompletionState: Sendable, Equatable {
    138         case incomplete
    139         case filledWithErrors
    140         case solved
    141     }
    142 
    143     /// `.incomplete` while any cell with a known solution is empty; once every
    144     /// known-solution cell has an entry, `.solved` if every entry matches its
    145     /// solution (cells with no known solution are ignored) and `.filledWithErrors`
    146     /// otherwise.
    147     var completionState: CompletionState {
    148         guard filledCellCount == fillableCellCount else { return .incomplete }
    149         return wrongCellCount > 0 ? .filledWithErrors : .solved
    150     }
    151 
    152     /// Rebuilds the O(1) completion counters after bulk state restoration
    153     /// paths that write directly into `squares`.
    154     func recomputeCompletionCache() {
    155         filledCellCount = 0
    156         wrongCellCount = 0
    157         for r in 0..<puzzle.height {
    158             for c in 0..<puzzle.width {
    159                 let cell = puzzle.cells[r][c]
    160                 guard !cell.isBlock, cell.solution != nil else { continue }
    161                 let entry = squares[r][c].entry
    162                 if !entry.isEmpty { filledCellCount += 1 }
    163                 if isWrongEntry(entry, for: cell) { wrongCellCount += 1 }
    164             }
    165         }
    166     }
    167 
    168     private func noteEntryChange(from oldEntry: String, to newEntry: String, for cell: Puzzle.Cell) {
    169         guard cell.solution != nil else { return }
    170 
    171         if oldEntry.isEmpty, !newEntry.isEmpty {
    172             filledCellCount += 1
    173         } else if !oldEntry.isEmpty, newEntry.isEmpty {
    174             filledCellCount -= 1
    175         }
    176 
    177         let wasWrong = isWrongEntry(oldEntry, for: cell)
    178         let isWrong = isWrongEntry(newEntry, for: cell)
    179         if wasWrong, !isWrong {
    180             wrongCellCount -= 1
    181         } else if !wasWrong, isWrong {
    182             wrongCellCount += 1
    183         }
    184     }
    185 
    186     private func isWrongEntry(_ entry: String, for cell: Puzzle.Cell) -> Bool {
    187         guard !entry.isEmpty, cell.solution != nil else { return false }
    188         return !cell.accepts(entry)
    189     }
    190 }