crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 817e0fda448ddfdd3d06e49f84f3a6e059a4ba00
parent 5584d392904122d419c77aac0e7985fc5904c957
Author: Michael Camilleri <[email protected]>
Date:   Sat,  2 May 2026 07:41:24 +0900

Reduce puzzle input work

This commit avoids recomputing the current word for every grid cell during
rendering and keeps puzzle completion state cached as letters are entered.
Restored games rebuild the cache after replaying persisted moves so remote
updates stay correct.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Models/Game.swift | 56++++++++++++++++++++++++++++++++++++++++++++++++--------
MCrossmate/Persistence/GameStore.swift | 1+
MCrossmate/Views/GridView.swift | 7++++++-
MTests/Unit/GameMutatorTests.swift | 37+++++++++++++++++++++++++++++++++++++
4 files changed, 92 insertions(+), 9 deletions(-)

diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift @@ -11,9 +11,15 @@ import Observation final class Game { let puzzle: Puzzle var squares: [[Square]] + @ObservationIgnored private let fillableCellCount: Int + @ObservationIgnored private var filledCellCount: Int = 0 + @ObservationIgnored private var wrongCellCount: Int = 0 init(puzzle: Puzzle) { self.puzzle = puzzle + self.fillableCellCount = puzzle.cells.reduce(0) { count, row in + count + row.filter { !$0.isBlock }.count + } self.squares = Array( repeating: Array(repeating: Square(), count: puzzle.width), count: puzzle.height @@ -29,8 +35,10 @@ final class Game { let cell = puzzle.cells[row][col] guard !cell.isBlock else { return } guard !squares[row][col].mark.isRevealed else { return } + let oldEntry = squares[row][col].entry squares[row][col].entry = letter.uppercased() squares[row][col].mark = pencil ? .pencil(checkedWrong: false) : .none + noteEntryChange(from: oldEntry, to: squares[row][col].entry, for: cell) } /// Clears the entry and mark at `(row, col)`. No-op on revealed cells. @@ -38,8 +46,10 @@ final class Game { let cell = puzzle.cells[row][col] guard !cell.isBlock else { return } guard !squares[row][col].mark.isRevealed else { return } + let oldEntry = squares[row][col].entry squares[row][col].entry = "" squares[row][col].mark = .none + noteEntryChange(from: oldEntry, to: "", for: cell) } // MARK: - Check / Reveal / Clear @@ -82,9 +92,11 @@ final class Game { guard !cell.isBlock else { continue } guard let solution = cell.solution else { continue } let expected = solution.uppercased() - if squares[cell.row][cell.col].entry == expected { continue } + let oldEntry = squares[cell.row][cell.col].entry + if oldEntry == expected { continue } squares[cell.row][cell.col].entry = expected squares[cell.row][cell.col].mark = .revealed + noteEntryChange(from: oldEntry, to: expected, for: cell) } } @@ -99,8 +111,10 @@ final class Game { for cell in cells { guard !cell.isBlock else { continue } guard !squares[cell.row][cell.col].mark.isRevealed else { continue } + let oldEntry = squares[cell.row][cell.col].entry squares[cell.row][cell.col].entry = "" squares[cell.row][cell.col].mark = .none + noteEntryChange(from: oldEntry, to: "", for: cell) } } @@ -121,18 +135,44 @@ final class Game { /// known solution are treated as correct) and `.filledWithErrors` /// otherwise. var completionState: CompletionState { - var hasErrors = false + guard filledCellCount == fillableCellCount else { return .incomplete } + return wrongCellCount > 0 ? .filledWithErrors : .solved + } + + /// Rebuilds the O(1) completion counters after bulk state restoration + /// paths that write directly into `squares`. + func recomputeCompletionCache() { + filledCellCount = 0 + wrongCellCount = 0 for r in 0..<puzzle.height { for c in 0..<puzzle.width { let cell = puzzle.cells[r][c] - if cell.isBlock { continue } + guard !cell.isBlock else { continue } let entry = squares[r][c].entry - if entry.isEmpty { return .incomplete } - if let solution = cell.solution, entry != solution.uppercased() { - hasErrors = true - } + if !entry.isEmpty { filledCellCount += 1 } + if isWrongEntry(entry, for: cell) { wrongCellCount += 1 } } } - return hasErrors ? .filledWithErrors : .solved + } + + private func noteEntryChange(from oldEntry: String, to newEntry: String, for cell: Puzzle.Cell) { + if oldEntry.isEmpty, !newEntry.isEmpty { + filledCellCount += 1 + } else if !oldEntry.isEmpty, newEntry.isEmpty { + filledCellCount -= 1 + } + + let wasWrong = isWrongEntry(oldEntry, for: cell) + let isWrong = isWrongEntry(newEntry, for: cell) + if wasWrong, !isWrong { + wrongCellCount -= 1 + } else if !wasWrong, isWrong { + wrongCellCount += 1 + } + } + + private func isWrongEntry(_ entry: String, for cell: Puzzle.Cell) -> Bool { + guard !entry.isEmpty, let solution = cell.solution else { return false } + return entry != solution.uppercased() } } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -627,6 +627,7 @@ final class GameStore { game.squares[r][c].mark = decodeMark(kind: cell.markKind, checkedWrong: cell.checkedWrong) game.squares[r][c].letterAuthorID = cell.authorID } + game.recomputeCompletionCache() updateCellCache(for: entity, from: grid) } diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -18,6 +18,11 @@ struct GridView: View { col: session.selectedCol, direction: session.direction ) + let currentWordCells = Set(session.puzzle.wordCells( + atRow: session.selectedRow, + col: session.selectedCol, + direction: session.direction + ).map { GridPosition(row: $0.row, col: $0.col) }) PuzzleGridLayout(columns: width, rows: height, spacing: spacing) { ForEach(0..<(width * height), id: \.self) { index in let r = index / width @@ -28,7 +33,7 @@ struct GridView: View { entry: session.game.squares[r][c].entry, mark: session.game.squares[r][c].mark, isSelected: session.selectedRow == r && session.selectedCol == c, - isHighlighted: session.isInCurrentWord(row: r, col: c), + isHighlighted: currentWordCells.contains(pos), isRelatedToFocus: relatedCells.contains(pos), specialKind: session.puzzle.specialKind, remoteWordTint: tintByCell[pos], diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift @@ -109,4 +109,41 @@ struct GameMutatorTests { #expect(game.squares[0][0].entry == "") #expect(game.squares[0][0].mark == .none) } + + // MARK: - Completion + + @Test("completion state updates incrementally as entries change") + func completionStateUpdatesIncrementally() throws { + let (game, mutator, _, _) = try makeTestGame() + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) + mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false) + mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false) + mutator.setLetter("D", atRow: 1, atCol: 0, pencil: false) + mutator.setLetter("E", atRow: 1, atCol: 2, pencil: false) + mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false) + mutator.setLetter("G", atRow: 2, atCol: 1, pencil: false) + + #expect(game.completionState == .incomplete) + + mutator.setLetter("Z", atRow: 2, atCol: 2, pencil: false) + #expect(game.completionState == .filledWithErrors) + + mutator.setLetter("H", atRow: 2, atCol: 2, pencil: false) + #expect(game.completionState == .solved) + + mutator.clearLetter(atRow: 0, atCol: 0) + #expect(game.completionState == .incomplete) + } + + @Test("reveal and clear keep completion cache in sync") + func revealAndClearKeepCompletionCacheInSync() throws { + let (game, mutator, _, _) = try makeTestGame() + + mutator.revealCells(game.puzzle.cells.flatMap { $0 }) + #expect(game.completionState == .solved) + + mutator.clearCells(game.puzzle.cells.flatMap { $0 }) + #expect(game.completionState == .solved) + } }