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 }