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 }