PlayerSession.swift (23182B)
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 15 /// Device-local store for this player's last cursor position, keyed by 16 /// game. Restored on open and rewritten as the cursor moves so reopening a 17 /// puzzle — even after a cold launch — returns to where they left off. 18 /// `nil` for solo/test sessions that don't persist. 19 @ObservationIgnored 20 private let cursorStore: GameCursorStore? 21 22 var selectedRow: Int { 23 didSet { selectionDidChange() } 24 } 25 var selectedCol: Int { 26 didSet { selectionDidChange() } 27 } 28 var direction: Puzzle.Direction = .across { 29 didSet { selectionDidChange() } 30 } 31 var isPencilMode: Bool = false 32 33 /// Optional sink fired whenever the selected answer slot changes. The 34 /// local cursor reticle remains exact and local-only; the published value 35 /// is the coarser cursor track persisted to CloudKit for collaborators. 36 /// Unset for solo (non-shared) games. 37 var onSelectionChanged: ((PlayerSelection) -> Void)? { 38 didSet { 39 if onSelectionChanged == nil { 40 lastPublishedCursorTrack = nil 41 } 42 } 43 } 44 45 @ObservationIgnored 46 private var lastPublishedCursorTrack: PlayerSelection? 47 48 /// The single completion signal the UI reacts to, carrying both *what* 49 /// completion state the grid reached and *who* drove it there. Unlike 50 /// `Game.completionState` — derived state that flips for local and remote 51 /// edits alike — this is an event: repeated failed attempts on an 52 /// already-full wrong grid still get a fresh sequence, and a local solve is 53 /// distinguishable from a collaborator's. `PlayerSession` is the sole owner: 54 /// local input emits `.local` inline, while a remote merge into the shared 55 /// `Game` is reconciled into `.observed` (see `observeRemoteCompletion`). A 56 /// single owner means the view has one handler and no ordering coupling. 57 struct CompletionEvent: Equatable { 58 let sequence: Int 59 let state: Game.CompletionState 60 let origin: Origin 61 62 /// Who drove the grid to its completion state: this player's own input, 63 /// or a merge of a collaborator's edits. 64 enum Origin: Equatable { 65 case local 66 case observed 67 } 68 } 69 70 var completionEvent: CompletionEvent? 71 72 @ObservationIgnored 73 private var completionEventSequence = 0 74 75 /// Rebus mode lets the player type a multi-character value into a single 76 /// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in 77 /// `rebusBuffer` rather than going straight to `Game.squares`; on commit 78 /// the buffer is written to the cell and the cursor advances. 79 var isRebusActive: Bool = false 80 var rebusBuffer: String = "" 81 82 var puzzle: Puzzle { game.puzzle } 83 84 /// Cells a peer filled or cleared since this player last viewed the puzzle, 85 /// each mapped to the author who wrote the change. Captured once on the 86 /// open "arm" beat (see `PuzzleView`) and rendered as fading author-coloured 87 /// borders by `GridView`; cleared on the player's first interaction, which 88 /// is the acknowledgement. Empty outside that window. 89 var recentChanges: [GridPosition: String] = [:] 90 91 /// Fired when `recentChanges` is acknowledged (cleared) by an interaction, 92 /// so the owner can advance this game's last-viewed timestamp. Unset for 93 /// solo/test sessions. 94 @ObservationIgnored 95 var onRecentChangesAcknowledged: (() -> Void)? 96 97 init(game: Game, mutator: GameMutator, cursorStore: GameCursorStore? = nil) { 98 self.game = game 99 self.mutator = mutator 100 self.cursorStore = cursorStore 101 let puzzle = game.puzzle 102 103 // Default: start at the first across clue. Fall back to the first down 104 // clue if the puzzle has no across answers, then to (0, 0) for a 105 // degenerate puzzle with no clues at all. 106 var startRow = 0 107 var startCol = 0 108 var startDirection: Puzzle.Direction = .across 109 if let first = puzzle.acrossClues.first, 110 let cell = puzzle.cell(numbered: first.number) { 111 startRow = cell.row 112 startCol = cell.col 113 startDirection = .across 114 } else if let first = puzzle.downClues.first, 115 let cell = puzzle.cell(numbered: first.number) { 116 startRow = cell.row 117 startCol = cell.col 118 startDirection = .down 119 } 120 121 // Prefer this player's last position for the game when one was 122 // persisted and still points at an editable cell. The grid never 123 // changes for a given game, so a stored cursor stays valid; the 124 // bounds/block check is purely defensive. 125 if let saved = cursorStore?.cursor(forGame: mutator.gameID), 126 saved.row >= 0, saved.row < puzzle.height, 127 saved.col >= 0, saved.col < puzzle.width, 128 !puzzle.cells[saved.row][saved.col].isBlock { 129 startRow = saved.row 130 startCol = saved.col 131 startDirection = saved.direction 132 } 133 134 self.selectedRow = startRow 135 self.selectedCol = startCol 136 self.direction = startDirection 137 138 // A non-block cell always belongs to at least one word; flip if the 139 // restored direction has none so the cursor never lands directionless. 140 if !hasWord(at: selectedRow, col: selectedCol, direction: direction), 141 hasWord(at: selectedRow, col: selectedCol, direction: direction.opposite) { 142 direction = direction.opposite 143 } 144 145 observeRemoteCompletion() 146 } 147 148 // MARK: - Selection 149 150 private func selectionDidChange() { 151 acknowledgeRecentChangesIfNeeded() 152 publishCurrentSelection() 153 cursorStore?.setCursor( 154 .init(row: selectedRow, col: selectedCol, direction: direction), 155 forGame: mutator.gameID 156 ) 157 } 158 159 /// Clears the "changed while you were away" borders on the first selection 160 /// change after they were captured — moving the cursor or typing (which 161 /// advances it) both route through here — and notifies the owner so the 162 /// last-viewed timestamp advances. A no-op while `recentChanges` is empty. 163 private func acknowledgeRecentChangesIfNeeded() { 164 guard !recentChanges.isEmpty else { return } 165 recentChanges = [:] 166 onRecentChangesAcknowledged?() 167 } 168 169 func publishCurrentSelection() { 170 guard let onSelectionChanged else { return } 171 guard let track = currentCursorTrack else { return } 172 guard track != lastPublishedCursorTrack else { return } 173 lastPublishedCursorTrack = track 174 onSelectionChanged(track) 175 } 176 177 /// Cursor track for the active selection, or `nil` if the selected 178 /// cell has no answer slot in the current direction. Exposed so the 179 /// puzzle-open path can ship the initial track in the open burst 180 /// without routing it through `onSelectionChanged`'s debounced sink. 181 var currentCursorTrack: PlayerSelection? { 182 puzzle.cursorTrack( 183 atRow: selectedRow, 184 col: selectedCol, 185 direction: direction 186 ) 187 } 188 189 func select(row: Int, col: Int) { 190 guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return } 191 if row == selectedRow && col == selectedCol { 192 direction = direction.opposite 193 if !hasWord(at: row, col: col, direction: direction) { 194 direction = direction.opposite 195 } 196 } else { 197 selectedRow = row 198 selectedCol = col 199 if !hasWord(at: row, col: col, direction: direction) { 200 direction = direction.opposite 201 } 202 } 203 } 204 205 func togglePencil() { 206 // TODO: when wired up, letters typed in pencil mode should be stored 207 // as tentative entries (rendered in a lighter weight) until confirmed. 208 isPencilMode.toggle() 209 } 210 211 func toggleDirection() { 212 let next = direction.opposite 213 if hasWord(at: selectedRow, col: selectedCol, direction: next) { 214 direction = next 215 } 216 } 217 218 func setDirection(_ newDirection: Puzzle.Direction) { 219 guard newDirection != direction else { return } 220 if hasWord(at: selectedRow, col: selectedCol, direction: newDirection) { 221 direction = newDirection 222 } 223 } 224 225 func selectClue(direction: Puzzle.Direction, number: Int) { 226 guard let cell = puzzle.cell(numbered: number) else { return } 227 self.direction = direction 228 selectedRow = cell.row 229 selectedCol = cell.col 230 } 231 232 // MARK: - Clue navigation 233 234 func goToNextClue() { 235 moveClue(by: +1) 236 } 237 238 func goToPreviousClue() { 239 moveClue(by: -1) 240 } 241 242 func goToNextWord() { 243 moveWord(by: +1) 244 } 245 246 func goToPreviousWord() { 247 moveWord(by: -1) 248 } 249 250 func goToNextLetter() { 251 advance() 252 } 253 254 func goToPreviousLetter() { 255 retreat() 256 } 257 258 private func moveClue(by offset: Int) { 259 // Walk every clue in order: all acrosses, then all downs. Stepping past 260 // the last across rolls into the first down (and vice versa going 261 // backwards), which matches how most crossword apps behave. 262 let ordered = orderedClues() 263 guard !ordered.isEmpty else { return } 264 265 let currentNumber = currentClueNumber() 266 let currentIndex = ordered.firstIndex { 267 $0.0 == direction && $0.1.number == currentNumber 268 } ?? 0 269 let count = ordered.count 270 let nextIndex = ((currentIndex + offset) % count + count) % count 271 272 let (newDirection, newClue) = ordered[nextIndex] 273 direction = newDirection 274 moveToClueStart(number: newClue.number) 275 } 276 277 private func orderedClues() -> [(Puzzle.Direction, Puzzle.Clue)] { 278 puzzle.acrossClues.map { (.across, $0) } 279 + puzzle.downClues.map { (.down, $0) } 280 } 281 282 private func moveWord(by offset: Int) { 283 let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues 284 guard !clues.isEmpty else { return } 285 let currentNumber = currentClueNumber() 286 let currentIndex = clues.firstIndex { $0.number == currentNumber } ?? 0 287 let count = clues.count 288 let nextIndex = ((currentIndex + offset) % count + count) % count 289 moveToClueStart(number: clues[nextIndex].number) 290 } 291 292 // MARK: - Check / Reveal / Clear 293 // 294 // These translate the player's cursor into a set of cells (or the whole 295 // puzzle) and ask `Game` to apply the operation. The actual marking lives 296 // on `Game` because checks and reveals are shared state. 297 298 func checkSquare() { 299 let cell = puzzle.cells[selectedRow][selectedCol] 300 guard !cell.isBlock else { return } 301 mutator.checkCells([cell]) 302 } 303 304 func checkCurrentWord() { 305 mutator.checkCells(currentWordCells()) 306 } 307 308 func checkPuzzle() { 309 mutator.checkCells(puzzle.cells.flatMap { $0 }) 310 } 311 312 func revealSquare() { 313 let cell = puzzle.cells[selectedRow][selectedCol] 314 guard !cell.isBlock else { return } 315 mutator.revealCells([cell]) 316 publishLocalCompletion() 317 } 318 319 func revealCurrentWord() { 320 mutator.revealCells(currentWordCells()) 321 publishLocalCompletion() 322 } 323 324 func revealPuzzle() { 325 mutator.revealCells(puzzle.cells.flatMap { $0 }) 326 publishLocalCompletion() 327 } 328 329 func clearCurrentWord() { 330 mutator.clearCells(currentWordCells()) 331 } 332 333 func clearPuzzle() { 334 mutator.clearCells(puzzle.cells.flatMap { $0 }) 335 } 336 337 // MARK: - Undo / redo 338 339 var canUndo: Bool { mutator.canUndo } 340 var canRedo: Bool { mutator.canRedo } 341 342 /// Undoes the most recent move and follows the cursor to the cell it 343 /// touched, so reversing a typed letter lands back on that letter. A bulk 344 /// clear returns no target, so the cursor stays where it is. 345 func undo() { 346 if let landing = mutator.undo() { 347 placeCursor(atRow: landing.position.row, atCol: landing.position.col, preferring: landing.direction) 348 } 349 } 350 351 func redo() { 352 if let landing = mutator.redo() { 353 placeCursor(atRow: landing.position.row, atCol: landing.position.col, preferring: landing.direction) 354 } 355 } 356 357 /// Moves the cursor onto `(row, col)` without the same-cell direction flip 358 /// `select(row:col:)` applies. When `preferred` is given and that direction 359 /// has a word at the destination, the cursor adopts it — so undoing a letter 360 /// restores the orientation it was typed in. Otherwise the current direction 361 /// is kept, flipped only if it has no word here, so the cursor never lands 362 /// directionless. 363 private func placeCursor(atRow row: Int, atCol col: Int, preferring preferred: Puzzle.Direction? = nil) { 364 guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return } 365 selectedRow = row 366 selectedCol = col 367 if let preferred, hasWord(at: row, col: col, direction: preferred) { 368 direction = preferred 369 return 370 } 371 if !hasWord(at: row, col: col, direction: direction), 372 hasWord(at: row, col: col, direction: direction.opposite) { 373 direction = direction.opposite 374 } 375 } 376 377 private func currentClueNumber() -> Int? { 378 let start = wordStart(row: selectedRow, col: selectedCol, direction: direction) 379 return puzzle.cells[start.row][start.col].number 380 } 381 382 private func moveToClueStart(number: Int) { 383 for r in 0..<puzzle.height { 384 for c in 0..<puzzle.width where puzzle.cells[r][c].number == number { 385 selectedRow = r 386 selectedCol = c 387 return 388 } 389 } 390 } 391 392 private func moveToClueEnd(direction newDirection: Puzzle.Direction, number: Int) { 393 guard let start = puzzle.cell(numbered: number) else { return } 394 let (dr, dc) = step(for: newDirection) 395 var row = start.row 396 var col = start.col 397 while isValid(row: row + dr, col: col + dc), 398 !puzzle.cells[row + dr][col + dc].isBlock { 399 row += dr 400 col += dc 401 } 402 direction = newDirection 403 selectedRow = row 404 selectedCol = col 405 } 406 407 // MARK: - Input 408 409 func enter(_ letter: String) { 410 let inputRow = selectedRow 411 let inputCol = selectedCol 412 let cell = puzzle.cells[inputRow][inputCol] 413 guard !cell.isBlock else { return } 414 mutator.setLetter(letter, atRow: inputRow, atCol: inputCol, pencil: isPencilMode, direction: direction) 415 let completionState = game.completionState 416 publishLocalCompletion(completionState) 417 if completionState != .solved { 418 advance() 419 } 420 } 421 422 func deleteBackward() { 423 // If the cursor is on an empty cell or a revealed (locked) cell, 424 // retreat first — revealed cells can't be cleared in place, so delete 425 // tunnels past them to the previous editable cell. `clearLetter` 426 // itself no-ops on revealed cells, so calling it unconditionally 427 // after retreat is safe. 428 let currentMark = game.squares[selectedRow][selectedCol].mark 429 let currentEmpty = game.squares[selectedRow][selectedCol].entry.isEmpty 430 if currentEmpty || currentMark.isRevealed { 431 retreat() 432 } 433 mutator.clearLetter(atRow: selectedRow, atCol: selectedCol, direction: direction) 434 } 435 436 // MARK: - Rebus 437 438 func startRebus() { 439 let cell = puzzle.cells[selectedRow][selectedCol] 440 guard !cell.isBlock else { return } 441 rebusBuffer = game.squares[selectedRow][selectedCol].entry 442 isRebusActive = true 443 } 444 445 func appendRebusLetter(_ letter: String) { 446 rebusBuffer += letter.uppercased() 447 } 448 449 func deleteRebusLetter() { 450 guard !rebusBuffer.isEmpty else { return } 451 rebusBuffer.removeLast() 452 } 453 454 func commitRebus() { 455 let value = committedRebusValue(from: rebusBuffer) 456 let inputRow = selectedRow 457 let inputCol = selectedCol 458 isRebusActive = false 459 rebusBuffer = "" 460 mutator.setLetter(value, atRow: inputRow, atCol: inputCol, pencil: isPencilMode, direction: direction) 461 let completionState = game.completionState 462 publishLocalCompletion(completionState) 463 if completionState != .solved { 464 advance() 465 } 466 } 467 468 private func committedRebusValue(from buffer: String) -> String { 469 guard buffer.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.inverted) != nil else { 470 return buffer 471 } 472 return buffer.trimmingCharacters(in: .whitespacesAndNewlines) 473 } 474 475 // MARK: - Word geometry 476 477 func isInCurrentWord(row: Int, col: Int) -> Bool { 478 currentWordCells().contains(where: { $0.row == row && $0.col == col }) 479 } 480 481 func currentClue() -> Puzzle.Clue? { 482 let start = wordStart(row: selectedRow, col: selectedCol, direction: direction) 483 guard let number = puzzle.cells[start.row][start.col].number else { return nil } 484 let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues 485 return clues.first { $0.number == number } 486 } 487 488 private func currentWordCells() -> [Puzzle.Cell] { 489 let (dr, dc) = step(for: direction) 490 let start = wordStart(row: selectedRow, col: selectedCol, direction: direction) 491 var cells: [Puzzle.Cell] = [] 492 var r = start.row 493 var c = start.col 494 while isValid(row: r, col: c) && !puzzle.cells[r][c].isBlock { 495 cells.append(puzzle.cells[r][c]) 496 r += dr 497 c += dc 498 } 499 return cells 500 } 501 502 private func wordStart(row: Int, col: Int, direction: Puzzle.Direction) -> (row: Int, col: Int) { 503 let (dr, dc) = step(for: direction) 504 var r = row 505 var c = col 506 while isValid(row: r - dr, col: c - dc) && !puzzle.cells[r - dr][c - dc].isBlock { 507 r -= dr 508 c -= dc 509 } 510 return (r, c) 511 } 512 513 private func hasWord(at row: Int, col: Int, direction: Puzzle.Direction) -> Bool { 514 let (dr, dc) = step(for: direction) 515 let hasNext = isValid(row: row + dr, col: col + dc) 516 && !puzzle.cells[row + dr][col + dc].isBlock 517 let hasPrev = isValid(row: row - dr, col: col - dc) 518 && !puzzle.cells[row - dr][col - dc].isBlock 519 return hasNext || hasPrev 520 } 521 522 private func step(for direction: Puzzle.Direction) -> (Int, Int) { 523 direction == .across ? (0, 1) : (1, 0) 524 } 525 526 private func publishLocalCompletion(_ state: Game.CompletionState? = nil) { 527 let state = state ?? game.completionState 528 guard state != .incomplete else { return } 529 emitCompletion(state, origin: .local) 530 } 531 532 private func emitCompletion(_ state: Game.CompletionState, origin: CompletionEvent.Origin) { 533 completionEventSequence += 1 534 completionEvent = CompletionEvent( 535 sequence: completionEventSequence, 536 state: state, 537 origin: origin 538 ) 539 } 540 541 /// Watches the shared `Game`'s completion state for a transition this 542 /// player didn't drive — a collaborator's merged edits completing the grid 543 /// — and reconciles it into a single `.observed` completion event. Armed 544 /// once at the end of `init` (so the game's already-restored initial state 545 /// never fires) and re-armed after every change, since 546 /// `withObservationTracking` is one-shot. Only a remote *solve* surfaces: a 547 /// collaborator's wrong fill must not interrupt the local solver, and a 548 /// solve the local input path already announced is not re-emitted. 549 private func observeRemoteCompletion() { 550 withObservationTracking { 551 _ = game.completionState 552 } onChange: { [weak self] in 553 // `onChange` fires synchronously at willSet, before the new value is 554 // readable; hop to the main actor to read the settled state and re-arm. 555 Task { @MainActor [weak self] in 556 guard let self else { return } 557 self.reconcileRemoteCompletion() 558 self.observeRemoteCompletion() 559 } 560 } 561 } 562 563 private func reconcileRemoteCompletion() { 564 let state = game.completionState 565 guard state == .solved else { return } 566 guard completionEvent?.state != .solved else { return } 567 emitCompletion(state, origin: .observed) 568 } 569 570 private func advance() { 571 let (dr, dc) = step(for: direction) 572 let r = selectedRow + dr 573 let c = selectedCol + dc 574 // If we're still inside the current word, step one cell. Otherwise 575 // we've hit the end of the word, so jump to the next clue in the full 576 // clue list. Without that, we'd fall through the block into a different 577 // word in the same row/column — which for down clues is almost never 578 // the next clue by number. 579 if isValid(row: r, col: c) && !puzzle.cells[r][c].isBlock { 580 selectedRow = r 581 selectedCol = c 582 } else { 583 advanceToNextClue() 584 } 585 } 586 587 private func advanceToNextClue() { 588 moveClue(by: +1) 589 } 590 591 private func retreat() { 592 let start = wordStart(row: selectedRow, col: selectedCol, direction: direction) 593 if selectedRow == start.row && selectedCol == start.col { 594 retreatToPreviousClueEnd() 595 return 596 } 597 598 let (dr, dc) = step(for: direction) 599 var r = selectedRow - dr 600 var c = selectedCol - dc 601 while isValid(row: r, col: c) && puzzle.cells[r][c].isBlock { 602 r -= dr 603 c -= dc 604 } 605 if isValid(row: r, col: c) { 606 selectedRow = r 607 selectedCol = c 608 } 609 } 610 611 private func retreatToPreviousClueEnd() { 612 let ordered = orderedClues() 613 guard !ordered.isEmpty else { return } 614 615 let currentNumber = currentClueNumber() 616 let currentIndex = ordered.firstIndex { 617 $0.0 == direction && $0.1.number == currentNumber 618 } ?? 0 619 let previousIndex = ((currentIndex - 1) % ordered.count + ordered.count) % ordered.count 620 let (newDirection, newClue) = ordered[previousIndex] 621 moveToClueEnd(direction: newDirection, number: newClue.number) 622 } 623 624 private func isValid(row: Int, col: Int) -> Bool { 625 row >= 0 && row < puzzle.height && col >= 0 && col < puzzle.width 626 } 627 }