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 }