crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Models/Puzzle.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Models/XD.swift | 40+++++++++++++++++++++++++++++++++++++++-
MCrossmate/Services/NYTToXDConverter.swift | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/CellView.swift | 4++++
MCrossmate/Views/GridView.swift | 1+
ATests/Unit/NYTToXDConverterTests.swift | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } + } + } +}