commit 86f3bee7034f96697b99ad06d1a6c9e38cb06c50
parent 39d8330276812bfd818aec11a7cc30de50035a97
Author: Michael Camilleri <[email protected]>
Date: Fri, 1 May 2026 06:52:54 +0900
Change display of cross-referenced clues
This commit changes the way that cross-referenced clues are displayed. Now,
when the cursor landed on a clue whose text cross-referenced another ("See
11-Down", "With 31- and 43-Down, …"), Crossmate draws a player-colored outline
around the cells of each referenced clue. The outline is gated on direction.
Cross-references are derived from clue text at puzzle-load time rather than
persisted in XD. The grey thematic background is dropped--it surfaced theme
structure that the constructor deliberately left for the solver to discover.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
5 files changed, 218 insertions(+), 73 deletions(-)
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -28,10 +28,21 @@ struct Puzzle: Sendable {
let cells: [[Cell]]
let acrossClues: [Clue]
let downClues: [Clue]
- /// Per-cell flag marking cells that belong to a cross-referenced clue
- /// group (a theme's revealer and its referenced answers, or a simple
- /// "See 14-Across" pair). Indexed `[row][col]` like `cells`.
- let thematicMask: [[Bool]]
+ /// Cross-reference clue groups, sourced from prose like "See 11-Down" /
+ /// "With X- and Y-Down" — connections the constructor explicitly
+ /// surfaced in the clue text, safe to highlight as a navigation aid.
+ /// Stored as clue identifiers (not cell positions) so `relatedCells` can
+ /// gate on the cursor's reading direction: only when the focus cell's
+ /// current-direction word is itself one of the group's clues.
+ /// Themer/revealer links from `XD.relatives` are intentionally not
+ /// represented here so the UI doesn't reveal trick relationships before
+ /// the solver works them out.
+ let crossReferenceGroups: [Set<ClueRef>]
+
+ struct ClueRef: Hashable, Sendable {
+ let number: Int
+ let direction: Direction
+ }
struct Cell: Sendable, Hashable {
let row: Int
@@ -95,43 +106,78 @@ struct Puzzle: Sendable {
cells.append(rowCells)
}
self.cells = cells
- self.acrossClues = xd.acrossClues.map { Clue(number: $0.number, text: $0.text) }
- self.downClues = xd.downClues.map { Clue(number: $0.number, text: $0.text) }
- self.thematicMask = Self.buildThematicMask(
- groups: xd.relatives,
- cells: cells,
- width: xd.width,
- height: xd.height
+ let acrossClues = xd.acrossClues.map { Clue(number: $0.number, text: $0.text) }
+ let downClues = xd.downClues.map { Clue(number: $0.number, text: $0.text) }
+ self.acrossClues = acrossClues
+ self.downClues = downClues
+ self.crossReferenceGroups = Self.buildCrossReferenceGroups(
+ across: acrossClues,
+ down: downClues
)
}
- /// Walks each clue-ref in every group from its starting numbered cell
- /// along the group's direction, marking each open cell visited until a
- /// block (or edge) terminates the word. Empty groups or refs pointing at
- /// non-existent clue numbers contribute nothing.
- private static func buildThematicMask(
- groups: [[XD.ClueRef]],
- cells: [[Cell]],
- width: Int,
- height: Int
- ) -> [[Bool]] {
- var mask = Array(repeating: Array(repeating: false, count: width), count: height)
- guard !groups.isEmpty else { return mask }
- for group in groups {
- for ref in group {
- guard let start = findCell(in: cells, numbered: ref.number) else { continue }
- var r = start.row
- var c = start.col
- while r >= 0, r < height, c >= 0, c < width, !cells[r][c].isBlock {
- mask[r][c] = true
- switch ref.direction {
- case .across: c += 1
- case .down: r += 1
- }
+ /// Derives cross-reference groups from the clue text itself. NYT-style
+ /// prose like `See 11-Down` or `With 31- and 43-Down, …` is the only
+ /// signal we trust — the constructor explicitly pointed the solver at
+ /// these connections, so highlighting them isn't a spoiler. Groups are
+ /// connected components: any clues mentioned together (transitively)
+ /// land in the same set as `(number, direction)` identifiers.
+ private static func buildCrossReferenceGroups(
+ across: [Clue],
+ down: [Clue]
+ ) -> [Set<ClueRef>] {
+ struct Entry { let ref: ClueRef; let text: String }
+ var entries: [Entry] = []
+ var indexByRef: [ClueRef: Int] = [:]
+ for clue in across {
+ let ref = ClueRef(number: clue.number, direction: .across)
+ indexByRef[ref] = entries.count
+ entries.append(Entry(ref: ref, text: clue.text))
+ }
+ for clue in down {
+ let ref = ClueRef(number: clue.number, direction: .down)
+ indexByRef[ref] = entries.count
+ entries.append(Entry(ref: ref, text: clue.text))
+ }
+
+ var adjacency: [Int: Set<Int>] = [:]
+ for (i, entry) in entries.enumerated() {
+ guard let refs = parseCrossReferences(in: entry.text) else { continue }
+ for ref in refs {
+ guard let j = indexByRef[ref], j != i else { continue }
+ adjacency[i, default: []].insert(j)
+ adjacency[j, default: []].insert(i)
+ }
+ }
+
+ var visited: Set<Int> = []
+ var groups: [Set<ClueRef>] = []
+ for start in adjacency.keys.sorted() {
+ guard !visited.contains(start) else { continue }
+ var component: Set<ClueRef> = []
+ var stack = [start]
+ while let node = stack.popLast() {
+ guard visited.insert(node).inserted else { continue }
+ component.insert(entries[node].ref)
+ for n in adjacency[node, default: []] where !visited.contains(n) {
+ stack.append(n)
}
}
+ if component.count >= 2 { groups.append(component) }
}
- return mask
+ return groups
+ }
+
+ /// Pulls `(number, direction)` pairs out of `See …-Down` /
+ /// `With X- and Y-Down` style prose. The trailing `Across`/`Down`
+ /// applies to every number in the list, matching NYT's convention.
+ private static func parseCrossReferences(in text: String) -> [ClueRef]? {
+ let pattern = /\b(?:See|With)\s+([\d\s,\-&]+?(?:and\s+[\d\s,\-&]+?)?)(Across|Down)\b/
+ guard let match = text.firstMatch(of: pattern) else { return nil }
+ let direction: Direction = String(match.2) == "Across" ? .across : .down
+ let numbers = String(match.1).matches(of: /\d+/).compactMap { Int($0.0) }
+ guard !numbers.isEmpty else { return nil }
+ return numbers.map { ClueRef(number: $0, direction: direction) }
}
private static func findCell(in cells: [[Cell]], numbered number: Int) -> Cell? {
@@ -180,6 +226,33 @@ struct Puzzle: Sendable {
return result.count > 1 ? result : []
}
+ /// Returns the cells of every clue cross-referenced from the focus
+ /// word. Gated on direction: only fires when the focus cell's word in
+ /// the *current* direction is itself one of the cross-referenced
+ /// clues. Reading the same cell in the opposite direction (where it
+ /// belongs to a different word) returns nothing.
+ func relatedCells(atRow row: Int, col: Int, direction: Direction) -> Set<GridPosition> {
+ let focusWord = wordCells(atRow: row, col: col, direction: direction)
+ guard let start = focusWord.first, let number = start.number else { return [] }
+ let focusClue = ClueRef(number: number, direction: direction)
+ 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
+ }
+ }
+ }
+ }
+ return related
+ }
+
private static func isBlock(_ cells: [[XD.Cell]], _ row: Int, _ col: Int) -> Bool {
if case .block = cells[row][col] { return true }
return false
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -183,7 +183,7 @@ enum NYTToXDConverter {
metadata.append("Special: shaded")
}
- let relatives = buildThematicGroups(clues: clues)
+ let relatives = buildRelativeGroups(clues: clues)
if !relatives.isEmpty {
let joined = relatives
.map { $0.joined(separator: ",") }
@@ -237,13 +237,16 @@ enum NYTToXDConverter {
return calendar.date(from: comps)
}
- /// Builds groups of theme-related clues from the v6 data. NYT uses
- /// `relatives` for explicit cross-references, but some theme entries are
- /// only marked by formatted clue text (for example italicized clues).
- private static func buildThematicGroups(clues: [[String: Any]]) -> [[String]] {
+ /// Themer/revealer groups: the structured `relatives` field plus
+ /// italics-flagged theme answers. These are the connections the
+ /// constructor did *not* surface in clue text — typically the trick
+ /// underlying a theme — so they're suitable for catalog/analysis but
+ /// should not drive any in-grid highlighting that would spoil the solve.
+ /// Cross-references that live in clue prose ("See 11-Down") are derived
+ /// at puzzle-load time in `Puzzle.init` instead.
+ private static func buildRelativeGroups(clues: [[String: Any]]) -> [[String]] {
var groups = buildRelatives(clues: clues)
groups.append(contentsOf: buildFormattedClueGroups(clues: clues))
-
var seen = Set<Set<String>>()
return groups.filter { group in
let key = Set(group)
diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift
@@ -6,7 +6,7 @@ struct CellView: View {
let mark: CellMark
let isSelected: Bool
let isHighlighted: Bool
- let isThematic: Bool
+ var isRelatedToFocus: Bool = false
let specialKind: Puzzle.Special?
var remoteWordTint: Color? = nil
var remoteOutline: Color? = nil
@@ -39,6 +39,10 @@ struct CellView: View {
CornerTriangle()
.fill(triangleColor)
}
+ if isRelatedToFocus {
+ Rectangle()
+ .strokeBorder(playerColor.highlightFill, lineWidth: 3)
+ }
if let remoteOutline {
Rectangle()
.strokeBorder(remoteOutline, lineWidth: 2)
@@ -80,9 +84,6 @@ struct CellView: View {
} else {
ZStack {
Color.white
- if isThematic {
- Color.gray.opacity(0.33)
- }
if cell.isSpecial && specialKind == .shaded {
Color.black.opacity(0.22)
}
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -13,6 +13,11 @@ struct GridView: View {
// value it needs. Multiple peers landing on the same cell collapse
// to the most recent.
let (outlineByCell, tintByCell) = remoteOverlays()
+ let relatedCells = session.puzzle.relatedCells(
+ atRow: session.selectedRow,
+ col: session.selectedCol,
+ direction: session.direction
+ )
PuzzleGridLayout(columns: width, rows: height, spacing: spacing) {
ForEach(0..<(width * height), id: \.self) { index in
let r = index / width
@@ -24,7 +29,7 @@ struct GridView: View {
mark: session.game.squares[r][c].mark,
isSelected: session.selectedRow == r && session.selectedCol == c,
isHighlighted: session.isInCurrentWord(row: r, col: c),
- isThematic: session.puzzle.thematicMask[r][c],
+ isRelatedToFocus: relatedCells.contains(pos),
specialKind: session.puzzle.specialKind,
remoteWordTint: tintByCell[pos],
remoteOutline: outlineByCell[pos]
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -15,7 +15,8 @@ struct NYTToXDConverterTests {
/// entirely.
private func puzzleJSON(
relatives: [[Int]?],
- formattedClueIndices: Set<Int> = []
+ formattedClueIndices: Set<Int> = [],
+ clueTexts: [Int: String] = [:]
) throws -> Data {
precondition(relatives.count == 6, "Expected 6 clues for a 3×3 open grid")
let defs: [(label: Int, direction: String, cells: [Int])] = [
@@ -28,7 +29,7 @@ struct NYTToXDConverterTests {
]
var clueDicts: [[String: Any]] = []
for (i, def) in defs.enumerated() {
- var text: [String: Any] = ["plain": "clue \(i)"]
+ var text: [String: Any] = ["plain": clueTexts[i] ?? "clue \(i)"]
if formattedClueIndices.contains(i) {
text["formatted"] = "<i>clue \(i)</i>"
}
@@ -176,6 +177,36 @@ struct NYTToXDConverterTests {
#expect(relativesHeader(in: xd) == "1A,4A,5A")
}
+ @Test("Cross-references in clue text never appear in the Relatives header")
+ func crossRefsDoNotPolluteRelatives() throws {
+ // Text-only "See N-Down" / "With X- and Y-Down" mentions belong to
+ // navigation and are derived in Puzzle.init, not by the converter —
+ // so the Relatives header sees only structured-relatives groups.
+ let data = try puzzleJSON(
+ relatives: [[1, 2], nil, nil, nil, nil, nil],
+ clueTexts: [
+ 3: "With 2-Down, a phrase",
+ 4: "See 1-Down"
+ ]
+ )
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(relativesHeader(in: xd) == "1A,4A,5A")
+ }
+
+ @Test("Clue-text refs without structured relatives produce no Relatives header")
+ func clueTextRefsAloneEmitNothing() throws {
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 3: "With 2- and 3-Down, an environmentalist motto",
+ 4: "See 1-Down",
+ 5: "See 1-Down"
+ ]
+ )
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(relativesHeader(in: xd) == nil)
+ }
+
@Test("Self-reference in relatives is ignored")
func selfReferenceIgnored() throws {
// A revealer that lists itself as a relative plus one real reference
@@ -207,46 +238,78 @@ struct NYTToXDConverterTests {
#expect(groupSets.contains(expectedPair))
}
- @Test("Puzzle.thematicMask marks exactly the cells of referenced answers")
- func thematicMaskCoversGroupCells() throws {
- // Revealer 1A → {4A, 5A}. In a 3×3 open grid, 1A spans row 0, 4A
- // spans row 1, 5A spans row 2. The mask should therefore be every
- // cell (all three rows thematic). No down clues participate.
- let data = try puzzleJSON(relatives: [[1, 2], nil, nil, nil, nil, nil])
+ @Test("Clue-text cross-references drive Puzzle.relatedCells")
+ func clueTextCrossRefsDrivePuzzle() throws {
+ // 1A is a revealer that points at 4A and 5A in its clue text. In a
+ // 3×3 open grid, 1A is row 0, 4A is row 1, 5A is row 2. With the
+ // cursor on 1A going Across, every cell of 4A and 5A should appear
+ // in relatedCells (and none of 1A's own row).
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [0: "With 4- and 5-Across, a phrase"]
+ )
let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
let puzzle = Puzzle(xd: xd)
- for r in 0..<3 {
- for c in 0..<3 {
- #expect(puzzle.thematicMask[r][c], "cell (\(r),\(c)) should be thematic")
- }
+ let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
+ for c in 0..<3 {
+ #expect(!related.contains(GridPosition(row: 0, col: c)))
+ #expect(related.contains(GridPosition(row: 1, col: c)))
+ #expect(related.contains(GridPosition(row: 2, col: c)))
}
}
- @Test("Puzzle.thematicMask is all-false when no relatives are present")
- func thematicMaskEmptyByDefault() throws {
- let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, nil])
+ @Test("Connected components: See / With chains form a single group")
+ func clueTextChainConnectedComponents() throws {
+ // 1D references 2D and 3D via "With"; 2D and 3D both point back via
+ // "See". Should resolve to a single connected component {1D,2D,3D}.
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 3: "With 2- and 3-Down, an environmentalist motto",
+ 4: "See 1-Down",
+ 5: "See 1-Down"
+ ]
+ )
let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
let puzzle = Puzzle(xd: xd)
- for row in puzzle.thematicMask {
- for flag in row {
- #expect(!flag)
- }
+ // Cursor on 1D (col 0, going down) — 2D and 3D should be related.
+ let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .down)
+ for r in 0..<3 {
+ #expect(related.contains(GridPosition(row: r, col: 1)))
+ #expect(related.contains(GridPosition(row: r, col: 2)))
+ #expect(!related.contains(GridPosition(row: r, col: 0)))
}
}
- @Test("Single formatted clue marks its answer cells thematic")
- func singleFormattedClueMarksAnswerCells() throws {
+ @Test("Cross-reference outlines only fire when the focus direction matches")
+ func crossRefsGatedByFocusDirection() throws {
+ // 1A points at 4A and 5A. Cell (0,0) is the start of both 1A
+ // (Across) and 1D (Down). On Across, the cursor's focus clue is
+ // 1A — the revealer — so 4A/5A light up. Switching to Down on the
+ // same cell makes the focus clue 1D, which isn't in any group, so
+ // the outlines disappear.
let data = try puzzleJSON(
relatives: [nil, nil, nil, nil, nil, nil],
- formattedClueIndices: [4]
+ clueTexts: [0: "With 4- and 5-Across, a phrase"]
)
let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
let puzzle = Puzzle(xd: xd)
+ #expect(!puzzle.relatedCells(atRow: 0, col: 0, direction: .across).isEmpty)
+ #expect(puzzle.relatedCells(atRow: 0, col: 0, direction: .down).isEmpty)
+ }
- for r in 0..<3 {
- for c in 0..<3 {
- #expect(puzzle.thematicMask[r][c] == (c == 1))
- }
- }
+ @Test("Bare clue mentions without See/With anchor do not link clues")
+ func bareClueMentionsDoNotLink() throws {
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "Compare with 5-Across, sort of",
+ 2: "Reminiscent of 1-Across"
+ ]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ let related = puzzle.relatedCells(atRow: 0, col: 0, direction: .across)
+ #expect(related.isEmpty)
}
}