commit ccfb9287beb5bb0c1b6bd3b0757efac786389305
parent c0cbf61d65e8ca7fe7dae9b581fbd631dfafe02b
Author: Michael Camilleri <[email protected]>
Date: Sun, 17 May 2026 21:53:44 +0900
Share the clue cell-walk behind a built-once start index
The cross-reference feature in c0cbf61 left two copies of the same run-walk —
'from a clue's numbered start cell, advance in its direction until a block or
the grid edge'. One lived in the focus-gated relatedCells, the other in
buildCrossReferenceGroupByCell; keeping a passive marker and the active
highlight in agreement depended on those two loops staying byte-identical.
relatedCells also resolved each linked clue's origin through cell(numbered:), a
full-grid scan, on every render of the focused word — and cell(numbered:)
itself scanned the grid for its four PlayerSession navigation callers.
The run-walk is now a single Self.runCells(from:direction:cells:) that both
relatedCells and the cross-reference index call, so the two can no longer drift
apart. Numbered start cells are indexed once at init into a new numberStarts
([Int: GridPosition]) stored property; relatedCells, the cross-reference
builder, and cell(numbered:) all resolve a clue's origin by O(1) lookup instead
of re-scanning the grid. cell(numbered:) keeps its signature and semantics —
there is exactly one numbered cell per number, so the dictionary is a faithful
replacement — so its navigation callers are unaffected. The only added cost is
one extra O(width·height) pass over the grid at load to populate numberStarts,
paid once; the per-render scan in the active highlight path is gone and no
asymptotic cost was added anywhere.
The map's awkward crossReferenceGroupByCell / buildCrossReferenceGroupByCell
pair is renamed to cellGroups / buildCellGroups, dropping the ByCell suffix no
other dictionary here carries (numberStarts is likewise just keyed by number)
and following the build<Property> convention the file already uses. GridView's
local, previously the inconsistent crossRefGroupByCell, is unified to
cellGroups too.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 74 insertions(+), 42 deletions(-)
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -37,6 +37,12 @@ struct Puzzle: Sendable {
/// the solver works them out.
let crossReferenceGroups: [Set<ClueRef>]
+ /// Maps each clue number to the position of its numbered start cell.
+ /// Built once so callers (`cell(numbered:)`, the per-render
+ /// `relatedCells`, the cross-reference walk) resolve a clue's origin
+ /// by O(1) lookup instead of re-scanning the grid every time.
+ let numberStarts: [Int: GridPosition]
+
/// Maps each cell that belongs to a cross-referenced clue to the
/// index of its group within `crossReferenceGroups`. Unlike
/// `relatedCells`, this is focus-independent: callers mark these
@@ -44,7 +50,7 @@ struct Puzzle: Sendable {
/// distinct cross-reference set carry its own visual pattern. A cell
/// shared by clues in two groups keeps the first group encountered.
/// Built once at init.
- let crossReferenceGroupByCell: [GridPosition: Int]
+ let cellGroups: [GridPosition: Int]
struct ClueRef: Hashable, Sendable {
let number: Int
@@ -136,46 +142,78 @@ struct Puzzle: Sendable {
down: downClues
)
self.crossReferenceGroups = groups
- self.crossReferenceGroupByCell = Self.buildCrossReferenceGroupByCell(
+ let numberStarts = Self.buildNumberStarts(cells)
+ self.numberStarts = numberStarts
+ self.cellGroups = Self.buildCellGroups(
groups: groups,
+ starts: numberStarts,
cells: cells
)
}
+ /// Indexes every numbered start cell by its clue number. There is
+ /// exactly one numbered cell per number, so a plain dictionary is a
+ /// faithful, scan-free replacement for `cell(numbered:)`.
+ private static func buildNumberStarts(
+ _ cells: [[Cell]]
+ ) -> [Int: GridPosition] {
+ var starts: [Int: GridPosition] = [:]
+ for row in cells {
+ for cell in row {
+ if let number = cell.number {
+ starts[number] = GridPosition(row: cell.row, col: cell.col)
+ }
+ }
+ }
+ return starts
+ }
+
+ /// Walks the run of cells for a clue, starting at `start` and
+ /// advancing in `direction` until a block or the grid edge. The
+ /// single source of truth for "which cells does this clue occupy",
+ /// shared by `relatedCells` and the cross-reference index so the two
+ /// can't drift apart.
+ private static func runCells(
+ from start: GridPosition,
+ direction: Direction,
+ cells: [[Cell]]
+ ) -> [GridPosition] {
+ let height = cells.count
+ let width = cells.first?.count ?? 0
+ var positions: [GridPosition] = []
+ var r = start.row
+ var c = start.col
+ while r >= 0, r < height, c >= 0, c < width, !cells[r][c].isBlock {
+ positions.append(GridPosition(row: r, col: c))
+ switch direction {
+ case .across: c += 1
+ case .down: r += 1
+ }
+ }
+ return positions
+ }
+
/// Walks every clue in every cross-reference group from its numbered
- /// start cell to the next block/edge, tagging each visited cell with
- /// its group's index. Mirrors the run-walk in `relatedCells` but has
+ /// start cell, tagging each visited cell with its group's index. Has
/// no focus gate, so the result is stable for the whole puzzle. Group
/// order follows `crossReferenceGroups`; the first group to claim a
/// shared cell wins, keeping the mapping deterministic.
- private static func buildCrossReferenceGroupByCell(
+ private static func buildCellGroups(
groups: [Set<ClueRef>],
+ starts: [Int: GridPosition],
cells: [[Cell]]
) -> [GridPosition: Int] {
guard !groups.isEmpty else { return [:] }
- let height = cells.count
- let width = cells.first?.count ?? 0
- var starts: [Int: (row: Int, col: Int)] = [:]
- for row in cells {
- for cell in row {
- if let number = cell.number {
- starts[number] = (cell.row, cell.col)
- }
- }
- }
var result: [GridPosition: Int] = [:]
for (index, group) in groups.enumerated() {
for clue in group {
guard let start = starts[clue.number] else { continue }
- var r = start.row
- var c = start.col
- while r >= 0, r < height, c >= 0, c < width, !cells[r][c].isBlock {
- let pos = GridPosition(row: r, col: c)
- if result[pos] == nil { result[pos] = index }
- switch clue.direction {
- case .across: c += 1
- case .down: r += 1
- }
+ for pos in runCells(
+ from: start,
+ direction: clue.direction,
+ cells: cells
+ ) where result[pos] == nil {
+ result[pos] = index
}
}
}
@@ -283,13 +321,11 @@ struct Puzzle: Sendable {
}
/// Returns the cell labelled with the given clue number, if any.
+ /// Resolved via the prebuilt `numberStarts` index, so this is an O(1)
+ /// lookup rather than a grid scan.
func cell(numbered number: Int) -> Cell? {
- for row in cells {
- for cell in row where cell.number == number {
- return cell
- }
- }
- return nil
+ guard let pos = numberStarts[number] else { return nil }
+ return cells[pos.row][pos.col]
}
/// Returns every open cell that belongs to the word containing
@@ -342,16 +378,12 @@ struct Puzzle: Sendable {
var related: Set<GridPosition> = []
for group in crossReferenceGroups where group.contains(focusClue) {
for clue in group where clue != focusClue {
- guard let startCell = cell(numbered: clue.number) else { continue }
- var r = startCell.row
- var c = startCell.col
- while r >= 0, r < height, c >= 0, c < width, !cells[r][c].isBlock {
- related.insert(GridPosition(row: r, col: c))
- switch clue.direction {
- case .across: c += 1
- case .down: r += 1
- }
- }
+ guard let start = numberStarts[clue.number] else { continue }
+ related.formUnion(Self.runCells(
+ from: start,
+ direction: clue.direction,
+ cells: cells
+ ))
}
}
return related
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -22,7 +22,7 @@ struct GridView: View {
direction: session.direction
)
let patternPalette = CrossRefPattern.allCases
- let crossRefGroupByCell = session.puzzle.crossReferenceGroupByCell
+ let cellGroups = session.puzzle.cellGroups
let currentWordCells = Set(session.puzzle.wordCells(
atRow: session.selectedRow,
col: session.selectedCol,
@@ -40,7 +40,7 @@ struct GridView: View {
mark: square.mark,
isSelected: session.selectedRow == r && session.selectedCol == c,
isHighlighted: currentWordCells.contains(pos),
- crossRefPattern: crossRefGroupByCell[pos].map {
+ crossRefPattern: cellGroups[pos].map {
patternPalette[$0 % patternPalette.count]
},
isRelatedToFocus: relatedCells.contains(pos),