crossmate

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

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:
MCrossmate/Models/Puzzle.swift | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
MCrossmate/Services/NYTToXDConverter.swift | 15+++++++++------
MCrossmate/Views/CellView.swift | 9+++++----
MCrossmate/Views/GridView.swift | 7++++++-
MTests/Unit/NYTToXDConverterTests.swift | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
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) } }