commit e7db7db51c539719ffc93e2bb546d23e5ae1398e
parent 7dadc6878c760a90617ec8dd5493382706144c54
Author: Michael Camilleri <[email protected]>
Date: Wed, 22 Apr 2026 05:52:52 +0900
Support relative relationships between clues
Certain crossword puzzles provide relationships between different clues
(perhaps to indicate a theme). This commit adds code to extract that
information and store it as metadata in the XD format.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
7 files changed, 364 insertions(+), 1 deletion(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -36,6 +36,7 @@
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; };
AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */; };
+ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; };
AA992F67F509EC8EFDDFC7CB /* morning.xd in Resources */ = {isa = PBXBuildFile; fileRef = 0B73A791FD061430AE286E11 /* morning.xd */; };
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; };
B42454D72FAA219D60DEA334 /* garden.xd in Resources */ = {isa = PBXBuildFile; fileRef = 50992CDA4082429EBB17F65C /* garden.xd */; };
@@ -112,6 +113,7 @@
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; };
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; };
C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; };
+ C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; };
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -164,6 +166,7 @@
isa = PBXGroup;
children = (
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
+ C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */,
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
);
@@ -380,6 +383,7 @@
buildActionMask = 2147483647;
files = (
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
+ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */,
83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */,
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -27,6 +27,10 @@ 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]]
struct Cell: Sendable, Hashable {
let row: Int
@@ -91,6 +95,50 @@ struct Puzzle: Sendable {
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
+ )
+ }
+
+ /// 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
+ }
+ }
+ }
+ }
+ return mask
+ }
+
+ private static func findCell(in cells: [[Cell]], numbered number: Int) -> Cell? {
+ for row in cells {
+ for cell in row where cell.number == number {
+ return cell
+ }
+ }
+ return nil
}
/// Returns the cell labelled with the given clue number, if any.
diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift
@@ -14,6 +14,15 @@ struct XD: Sendable {
let cells: [[Cell]]
let acrossClues: [Clue]
let downClues: [Clue]
+ let relatives: [[ClueRef]]
+
+ /// A single clue identified by its number and direction. Used to describe
+ /// groups of mutually-related clues (a theme's revealer plus the answers
+ /// it references, or a simple "See 14-Across" pair).
+ struct ClueRef: Sendable, Hashable {
+ let number: Int
+ let direction: Puzzle.Direction
+ }
/// A single grid cell as it appears in the .xd source. Open cells carry
/// an optional solution string which may be 1+ characters long once any
@@ -61,6 +70,7 @@ struct XD: Sendable {
let metadata = parseMetadata(sections[0])
let rebus = parseRebusHeader(metadata["Rebus"])
let specialKind = parseSpecialHeader(metadata["Special"])
+ let relatives = parseRelativesHeader(metadata["Relatives"])
let (cells, width, height) = try parseGrid(sections[1], rebus: rebus)
let (across, down) = try parseClues(sections[2])
@@ -74,7 +84,8 @@ struct XD: Sendable {
height: height,
cells: cells,
acrossClues: across,
- downClues: down
+ downClues: down,
+ relatives: relatives
)
}
@@ -180,6 +191,33 @@ struct XD: Sendable {
return Calendar(identifier: .gregorian).date(from: comps)
}
+ /// Parses a `Relatives:` header value into groups of cross-referenced
+ /// clues. Groups are separated by `;`, tokens within a group by `,`, and
+ /// each token is `{number}{A|D}` (e.g. `17A`, `57A`). This is a Crossmate
+ /// extension to `.xd`; unknown/invalid tokens are silently dropped and
+ /// groups with fewer than two valid tokens are discarded.
+ private static func parseRelativesHeader(_ value: String?) -> [[ClueRef]] {
+ guard let value, !value.isEmpty else { return [] }
+ var groups: [[ClueRef]] = []
+ for groupSlice in value.split(separator: ";") {
+ var refs: [ClueRef] = []
+ for tokenSlice in groupSlice.split(separator: ",") {
+ let token = tokenSlice.trimmingCharacters(in: .whitespaces)
+ guard let last = token.last else { continue }
+ let direction: Puzzle.Direction
+ switch last {
+ case "A", "a": direction = .across
+ case "D", "d": direction = .down
+ default: continue
+ }
+ guard let number = Int(token.dropLast()), number > 0 else { continue }
+ refs.append(ClueRef(number: number, direction: direction))
+ }
+ if refs.count >= 2 { groups.append(refs) }
+ }
+ return groups
+ }
+
/// Parses a `Special:` header value into a `Puzzle.Special` kind. The
/// .xd spec recognises `shaded` and `circle` as the two values; we accept
/// either case-insensitively and treat anything else as no special kind.
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -170,6 +170,14 @@ enum NYTToXDConverter {
metadata.append("Rebus: \(rebusStr)")
}
+ let relatives = buildRelatives(clues: clues)
+ if !relatives.isEmpty {
+ let joined = relatives
+ .map { $0.joined(separator: ",") }
+ .joined(separator: "; ")
+ metadata.append("Relatives: \(joined)")
+ }
+
sections.append(metadata.joined(separator: "\n"))
// Grid section
@@ -184,6 +192,78 @@ enum NYTToXDConverter {
return sections.joined(separator: "\n\n\n")
}
+ /// Builds groups of cross-referenced clues from the v6 per-clue
+ /// `relatives` arrays. Two rules admit a group, everything else is
+ /// discarded:
+ ///
+ /// 1. **Revealer** — a clue with ≥2 relatives defines a group consisting
+ /// of itself plus every clue it references. The revealer's list is
+ /// treated as canonical.
+ /// 2. **Mutual pair** — two clues that each list the other as their sole
+ /// relative form a group of two (the classic "See 14-Across" pattern).
+ ///
+ /// Single-direction 1-relative edges (where A references B but B does
+ /// not reference A back) are dropped. This guards against NYT data
+ /// errors where a leaf clue points at the wrong revealer.
+ private static func buildRelatives(clues: [[String: Any]]) -> [[String]] {
+ // Extract each clue's (label, direction) and relatives array.
+ var tokens: [String?] = []
+ var relativeIndices: [[Int]] = []
+ tokens.reserveCapacity(clues.count)
+ relativeIndices.reserveCapacity(clues.count)
+ for clue in clues {
+ let direction = clue["direction"] as? String ?? ""
+ let label = intValue(clue["label"]) ?? 0
+ if label > 0, direction == "Across" || direction == "Down" {
+ tokens.append("\(label)\(direction == "Across" ? "A" : "D")")
+ } else {
+ tokens.append(nil)
+ }
+ let raw = clue["relatives"] as? [Int] ?? []
+ let cleaned = Array(Set(raw.filter { $0 >= 0 && $0 < clues.count }))
+ relativeIndices.append(cleaned)
+ }
+
+ var groups: [[String]] = []
+ var seen = Set<Set<Int>>()
+
+ func emit(_ members: Set<Int>) {
+ guard members.count >= 2, !seen.contains(members) else { return }
+ seen.insert(members)
+ let sorted = members.sorted { a, b in
+ // Order by (number, direction-is-across-first). Extract from
+ // the stored token; fallback to index if a token is missing.
+ guard let ta = tokens[a], let tb = tokens[b] else { return a < b }
+ let (na, da) = (Int(ta.dropLast()) ?? 0, ta.last!)
+ let (nb, db) = (Int(tb.dropLast()) ?? 0, tb.last!)
+ if na != nb { return na < nb }
+ return da == "A" && db == "D"
+ }
+ let toks = sorted.compactMap { tokens[$0] }
+ if toks.count >= 2 { groups.append(toks) }
+ }
+
+ // Rule 1: revealers.
+ for (i, refs) in relativeIndices.enumerated() where refs.count >= 2 {
+ var members = Set<Int>()
+ members.insert(i)
+ for r in refs { members.insert(r) }
+ emit(members)
+ }
+
+ // Rule 2: mutual pairs. Only consider clues with exactly one relative
+ // — revealer-formed groups already cover the multi-relative cases.
+ for (i, refs) in relativeIndices.enumerated() where refs.count == 1 {
+ let j = refs[0]
+ guard j != i, relativeIndices.indices.contains(j) else { continue }
+ if relativeIndices[j] == [i] {
+ emit(Set([i, j]))
+ }
+ }
+
+ return groups
+ }
+
/// Extracts an Int from a JSON value that may be NSNumber, Int, or Double.
private static func intValue(_ value: Any?) -> Int? {
if let n = value as? Int { return n }
diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift
@@ -6,6 +6,7 @@ struct CellView: View {
let mark: CellMark
let isSelected: Bool
let isHighlighted: Bool
+ let isThematic: Bool
let specialKind: Puzzle.Special?
@Environment(PlayerPreferences.self) private var preferences
@@ -73,6 +74,9 @@ struct CellView: View {
} else {
ZStack {
Color.white
+ if isThematic {
+ Color.yellow.opacity(0.22)
+ }
if cell.isSpecial && specialKind == .shaded {
Color.black.opacity(0.18)
}
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -18,6 +18,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],
specialKind: session.puzzle.specialKind
)
.onTapGesture {
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -0,0 +1,188 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("NYTToXDConverter")
+struct NYTToXDConverterTests {
+
+ // MARK: - Fixtures
+
+ /// Builds minimal NYT v6-shaped JSON for a 3×3 all-open puzzle. The grid
+ /// admits six clues in a fixed order: 1-Across, 4-Across, 5-Across,
+ /// 1-Down, 2-Down, 3-Down (indices 0…5 in the flat `clues` array). Each
+ /// tuple supplies that clue's `relatives` array, or nil to omit the key
+ /// entirely.
+ private func puzzleJSON(
+ relatives: [[Int]?]
+ ) throws -> Data {
+ precondition(relatives.count == 6, "Expected 6 clues for a 3×3 open grid")
+ let defs: [(label: Int, direction: String, cells: [Int])] = [
+ (1, "Across", [0, 1, 2]),
+ (4, "Across", [3, 4, 5]),
+ (5, "Across", [6, 7, 8]),
+ (1, "Down", [0, 3, 6]),
+ (2, "Down", [1, 4, 7]),
+ (3, "Down", [2, 5, 8])
+ ]
+ var clueDicts: [[String: Any]] = []
+ for (i, def) in defs.enumerated() {
+ var dict: [String: Any] = [
+ "label": "\(def.label)",
+ "direction": def.direction,
+ "cells": def.cells,
+ "text": [["plain": "clue \(i)"]]
+ ]
+ if let rels = relatives[i] {
+ dict["relatives"] = rels
+ }
+ clueDicts.append(dict)
+ }
+ let letters = ["A", "B", "C", "D", "E", "F", "G", "H", "I"]
+ let root: [String: Any] = [
+ "publicationDate": "2025-01-01",
+ "constructors": ["Tester"],
+ "body": [[
+ "dimensions": ["width": 3, "height": 3],
+ "cells": letters.map { ["answer": $0] },
+ "clues": clueDicts
+ ]]
+ ]
+ return try JSONSerialization.data(withJSONObject: root)
+ }
+
+ /// Extracts the value after `Relatives: ` in an `.xd` source, or nil if
+ /// the header isn't present.
+ private func relativesHeader(in xd: String) -> String? {
+ for line in xd.split(separator: "\n") {
+ if line.hasPrefix("Relatives: ") {
+ return String(line.dropFirst("Relatives: ".count))
+ }
+ }
+ return nil
+ }
+
+ // MARK: - Header emission
+
+ @Test("Revealer with ≥2 relatives produces a group")
+ func revealerGroup() throws {
+ // 1A (index 0) references 4A (1) and 5A (2).
+ let data = try puzzleJSON(relatives: [[1, 2], nil, nil, nil, nil, nil])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(relativesHeader(in: xd) == "1A,4A,5A")
+ }
+
+ @Test("Mutual 1-relative pair produces a group")
+ func mutualPair() throws {
+ // 1D (index 3) ↔ 2D (index 4).
+ let data = try puzzleJSON(relatives: [nil, nil, nil, [4], [3], nil])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(relativesHeader(in: xd) == "1D,2D")
+ }
+
+ @Test("Asymmetric 1-relative edge is discarded")
+ func asymmetricEdgeDropped() throws {
+ // 3D (index 5) points at 1A (0); 1A does not point back. No group.
+ let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, [0]])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(relativesHeader(in: xd) == nil)
+ }
+
+ @Test("No relatives anywhere means no Relatives header")
+ func noRelatives() throws {
+ let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, nil])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(relativesHeader(in: xd) == nil)
+ }
+
+ @Test("Multiple groups are semicolon-joined on a single Relatives line")
+ func multipleGroups() throws {
+ // Revealer group {1A, 4A, 5A} + mutual pair {1D, 2D}.
+ let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ let header = try #require(relativesHeader(in: xd))
+ #expect(header.contains("1A,4A,5A"))
+ #expect(header.contains("1D,2D"))
+ #expect(header.contains("; "))
+ }
+
+ @Test("Revealer list wins over a buggy leaf back-reference")
+ func revealerWinsOverBadLeaf() throws {
+ // Mirrors the real-world NYT data bug: 4A (index 1, a leaf) back-
+ // references 3D (index 5) instead of the revealer. The asymmetric
+ // edge must be dropped so 3D is not dragged into the group.
+ let data = try puzzleJSON(relatives: [[1, 2], [5], nil, nil, nil, nil])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ let header = try #require(relativesHeader(in: xd))
+ #expect(header == "1A,4A,5A")
+ #expect(!header.contains("3D"))
+ }
+
+ @Test("Duplicate indices in relatives are deduplicated")
+ func duplicateRelativesDeduped() throws {
+ // Real NYT data occasionally repeats a clue in the relatives array
+ // (we saw 57A include index 23 twice). The emitted group must not
+ // repeat the token.
+ let data = try puzzleJSON(relatives: [[1, 2, 2, 1], nil, nil, nil, nil, nil])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(relativesHeader(in: xd) == "1A,4A,5A")
+ }
+
+ @Test("Self-reference in relatives is ignored")
+ func selfReferenceIgnored() throws {
+ // A revealer that lists itself as a relative plus one real reference
+ // still produces a valid 2-member group (itself + the reference).
+ let data = try puzzleJSON(relatives: [[0, 1], nil, nil, nil, nil, nil])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(relativesHeader(in: xd) == "1A,4A")
+ }
+
+ // MARK: - Round-trip through XD.parse and Puzzle
+
+ @Test("Emitted Relatives header round-trips through XD.parse")
+ func roundTripThroughXD() throws {
+ let data = try puzzleJSON(relatives: [[1, 2], nil, nil, [4], [3], nil])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ let parsed = try XD.parse(xd)
+ #expect(parsed.relatives.count == 2)
+ let groupSets = parsed.relatives.map { Set($0) }
+ let expectedRevealer: Set<XD.ClueRef> = [
+ XD.ClueRef(number: 1, direction: .across),
+ XD.ClueRef(number: 4, direction: .across),
+ XD.ClueRef(number: 5, direction: .across)
+ ]
+ let expectedPair: Set<XD.ClueRef> = [
+ XD.ClueRef(number: 1, direction: .down),
+ XD.ClueRef(number: 2, direction: .down)
+ ]
+ #expect(groupSets.contains(expectedRevealer))
+ #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])
+ 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")
+ }
+ }
+ }
+
+ @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])
+ 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)
+ }
+ }
+ }
+}