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