crossmate

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

Game.swift (9483B)


      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         // Gap cells (solution is a literal blank) are excluded: they need no
     21         // user fill — their correct state is empty — so counting them would
     22         // leave the puzzle permanently `.incomplete`.
     23         self.fillableCellCount = puzzle.cells.reduce(0) { count, row in
     24             count + row.filter { !$0.isBlock && $0.solution != nil && !$0.expectsBlank }.count
     25         }
     26         self.squares = Array(
     27             repeating: Array(repeating: Square(), count: puzzle.width),
     28             count: puzzle.height
     29         )
     30     }
     31 
     32     // MARK: - Letter writes
     33 
     34     /// Writes `letter` into `(row, col)`. If `pencil` is true the cell is
     35     /// marked `.pencil` with both check flags cleared; otherwise the mark is
     36     /// cleared to `.none`. Revealed cells are locked — writes are silently
     37     /// ignored. When the new letter matches the existing entry,
     38     /// `letterAuthorID` is preserved — typing over an existing answer
     39     /// (common when filling a crossing word) shouldn't reattribute the
     40     /// square to the second typist.
     41     func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, authorID: String? = nil) {
     42         let cell = puzzle.cells[row][col]
     43         guard !cell.isBlock else { return }
     44         guard !squares[row][col].mark.isRevealed else { return }
     45         let oldEntry = squares[row][col].entry
     46         let newEntry = letter.uppercased()
     47         squares[row][col].entry = newEntry
     48         squares[row][col].mark = pencil ? .pencil(checked: nil) : .none
     49         if newEntry != oldEntry {
     50             squares[row][col].letterAuthorID = authorID
     51         }
     52         noteEntryChange(from: oldEntry, to: newEntry, for: cell)
     53     }
     54 
     55     /// Clears the entry and mark at `(row, col)`. No-op on revealed cells.
     56     func clearLetter(atRow row: Int, atCol col: Int) {
     57         let cell = puzzle.cells[row][col]
     58         guard !cell.isBlock else { return }
     59         guard !squares[row][col].mark.isRevealed else { return }
     60         let oldEntry = squares[row][col].entry
     61         squares[row][col].entry = ""
     62         squares[row][col].mark = .none
     63         squares[row][col].letterAuthorID = nil
     64         noteEntryChange(from: oldEntry, to: "", for: cell)
     65     }
     66 
     67     /// Directly writes a cell's full state — entry, mark, and letter author —
     68     /// used by undo/redo to restore a previously recorded value. Unlike
     69     /// `setLetter`/`clearLetter` this deliberately has no revealed-cell lock:
     70     /// reversing a reveal is a legitimate undo. Block cells are still rejected.
     71     func applyCellState(_ letter: String, mark: CellMark, authorID: String?, atRow row: Int, atCol col: Int) {
     72         let cell = puzzle.cells[row][col]
     73         guard !cell.isBlock else { return }
     74         let oldEntry = squares[row][col].entry
     75         let newEntry = letter.uppercased()
     76         squares[row][col].entry = newEntry
     77         squares[row][col].mark = mark
     78         squares[row][col].letterAuthorID = authorID
     79         noteEntryChange(from: oldEntry, to: newEntry, for: cell)
     80     }
     81 
     82     // MARK: - Check / Reveal / Clear
     83 
     84     /// For each non-empty, non-revealed target cell, compares the current
     85     /// entry against the solution. Wrong entries get `checkedWrong = true`;
     86     /// correct entries get `checkedRight = true` (was previously cleared to
     87     /// `.none` — the new flag carries the verification through so peers can
     88     /// see what their partner has already checked in a co-solving session).
     89     /// Correct pencil entries are promoted to ink; wrong pencil entries keep
     90     /// their pencil style so the tentative entry remains visually distinct.
     91     /// Empty cells and cells without a known solution are skipped.
     92     func checkCells(_ cells: [Puzzle.Cell]) {
     93         for cell in cells {
     94             guard !cell.isBlock else { continue }
     95             guard cell.solution != nil else { continue }
     96             let entry = squares[cell.row][cell.col].entry
     97             guard !entry.isEmpty else { continue }
     98             guard !squares[cell.row][cell.col].mark.isRevealed else { continue }
     99 
    100             let result: CheckResult = cell.accepts(entry) ? .right : .wrong
    101             switch squares[cell.row][cell.col].mark {
    102             case .pencil:
    103                 squares[cell.row][cell.col].mark = result == .right
    104                     ? .pen(checked: .right)
    105                     : .pencil(checked: .wrong)
    106             case .none, .pen:
    107                 squares[cell.row][cell.col].mark = .pen(checked: result)
    108             case .revealed:
    109                 break // unreachable; guarded above
    110             }
    111         }
    112     }
    113 
    114     func checkPuzzle() {
    115         checkCells(puzzle.cells.flatMap { $0 })
    116     }
    117 
    118     /// For each target cell, writes the solution into the entry and sets the
    119     /// mark to `.revealed` (which locks the cell against future edits). Cells
    120     /// whose entry already matches the solution are left untouched — the user
    121     /// got it right, so there's no reason to overwrite their mark or lock the
    122     /// cell.
    123     func revealCells(_ cells: [Puzzle.Cell]) {
    124         for cell in cells {
    125             guard !cell.isBlock else { continue }
    126             guard let solution = cell.solution else { continue }
    127             let expected = solution.uppercased()
    128             let oldEntry = squares[cell.row][cell.col].entry
    129             if cell.accepts(oldEntry) { continue }
    130             squares[cell.row][cell.col].entry = expected
    131             squares[cell.row][cell.col].mark = .revealed
    132             squares[cell.row][cell.col].letterAuthorID = nil
    133             noteEntryChange(from: oldEntry, to: expected, for: cell)
    134         }
    135     }
    136 
    137     func revealPuzzle() {
    138         revealCells(puzzle.cells.flatMap { $0 })
    139     }
    140 
    141     /// Clears the entry and mark for each non-revealed target cell. Revealed
    142     /// cells are left untouched — once revealed, a cell's contents are
    143     /// permanent for the rest of the game.
    144     func clearCells(_ cells: [Puzzle.Cell]) {
    145         for cell in cells {
    146             guard !cell.isBlock else { continue }
    147             guard !squares[cell.row][cell.col].mark.isRevealed else { continue }
    148             let oldEntry = squares[cell.row][cell.col].entry
    149             squares[cell.row][cell.col].entry = ""
    150             squares[cell.row][cell.col].mark = .none
    151             squares[cell.row][cell.col].letterAuthorID = nil
    152             noteEntryChange(from: oldEntry, to: "", for: cell)
    153         }
    154     }
    155 
    156     func clearPuzzle() {
    157         clearCells(puzzle.cells.flatMap { $0 })
    158     }
    159 
    160     // MARK: - Completion
    161 
    162     enum CompletionState: Sendable, Equatable {
    163         case incomplete
    164         case filledWithErrors
    165         case solved
    166     }
    167 
    168     /// `.incomplete` while any cell with a known solution is empty; once every
    169     /// known-solution cell has an entry, `.solved` if every entry matches its
    170     /// solution (cells with no known solution are ignored) and `.filledWithErrors`
    171     /// otherwise.
    172     var completionState: CompletionState {
    173         guard filledCellCount == fillableCellCount else { return .incomplete }
    174         return wrongCellCount > 0 ? .filledWithErrors : .solved
    175     }
    176 
    177     /// Rebuilds the O(1) completion counters after bulk state restoration
    178     /// paths that write directly into `squares`.
    179     func recomputeCompletionCache() {
    180         filledCellCount = 0
    181         wrongCellCount = 0
    182         for r in 0..<puzzle.height {
    183             for c in 0..<puzzle.width {
    184                 let cell = puzzle.cells[r][c]
    185                 guard !cell.isBlock, cell.solution != nil else { continue }
    186                 let entry = squares[r][c].entry
    187                 if !entry.isEmpty, !cell.expectsBlank { filledCellCount += 1 }
    188                 if isWrongEntry(entry, for: cell) { wrongCellCount += 1 }
    189             }
    190         }
    191     }
    192 
    193     private func noteEntryChange(from oldEntry: String, to newEntry: String, for cell: Puzzle.Cell) {
    194         guard cell.solution != nil else { return }
    195 
    196         // A gap cell is never part of the fill count (see `fillableCellCount`),
    197         // so a stray entry in one must not inflate it — only its wrongness,
    198         // tracked below, matters.
    199         if !cell.expectsBlank {
    200             if oldEntry.isEmpty, !newEntry.isEmpty {
    201                 filledCellCount += 1
    202             } else if !oldEntry.isEmpty, newEntry.isEmpty {
    203                 filledCellCount -= 1
    204             }
    205         }
    206 
    207         let wasWrong = isWrongEntry(oldEntry, for: cell)
    208         let isWrong = isWrongEntry(newEntry, for: cell)
    209         if wasWrong, !isWrong {
    210             wrongCellCount -= 1
    211         } else if !wasWrong, isWrong {
    212             wrongCellCount += 1
    213         }
    214     }
    215 
    216     private func isWrongEntry(_ entry: String, for cell: Puzzle.Cell) -> Bool {
    217         guard !entry.isEmpty, cell.solution != nil else { return false }
    218         return !cell.accepts(entry)
    219     }
    220 }