commit 4e6dbaf517c34f8428aeebae4600d04f41f351d7
parent 4e5f802ae14f7e4908b05da4948bcb8d5bb56a55
Author: Michael Camilleri <[email protected]>
Date: Fri, 21 Aug 2026 09:00:17 +0900
Highlight asterisked theme clues as cross-references
Crossword constructors often mark a theme's entries with a leading
asterisk on every clue and name the set in the revealer. Crossmate drew
no connection between them, so the asterisks in the clue text were the
only cue a solver had — and on iPhone, where the clue list cannot be
scanned at a glance, the marked entries gave no sign of belonging
together.
This commit teaches buildCrossReferenceGroups to read a leading asterisk
as a second cross-reference signal alongside prose such as 'See
11-Down'. Two or more asterisked clues form a group, and a revealer that
names the set — 'each asterisked clue' — joins them even when it carries
no asterisk of its own; a single asterisk is left alone, being as likely
literal prose as a theme marker. Because the group joins
crossReferenceGroups, the theme squares carry the passive hatching from
the moment the puzzle opens as well as the outline that appears on
focus.
The asterisked set is appended as a group of its own rather than added
as edges to the prose graph, whose groups are connected components. A
clue that was both asterisked and carried a prose reference would
otherwise pull every link in the puzzle into a single component.
Appending it last also lets a prose group keep any cell the two share
when cellGroups assigns patterns.
Co-Authored-By: Claude Opus 5 <[email protected]>
Diffstat:
2 files changed, 151 insertions(+), 12 deletions(-)
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -63,15 +63,16 @@ struct Puzzle: Sendable {
let cells: [[Cell]]
let acrossClues: [Clue]
let downClues: [Clue]
- /// 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.
+ /// Cross-reference clue groups, sourced from the clue text: prose like
+ /// "See 11-Down" / "With X- and Y-Down", plus the theme set a constructor
+ /// marks with a leading `*` on every member clue. Both are connections the
+ /// constructor explicitly surfaced, 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.
+ /// Themer/revealer links from `XD.relatives` are still not represented
+ /// here: those are the connections clue text leaves unmarked, so drawing
+ /// them would reveal the trick before the solver works it out.
let crossReferenceGroups: [Set<ClueRef>]
/// Maps each clue number to the position of its numbered start cell.
@@ -306,12 +307,14 @@ struct Puzzle: Sendable {
return result
}
- /// 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.
+ /// Derives cross-reference groups from the clue text itself. Two signals
+ /// are trusted, both of them connections the constructor put in front of
+ /// the solver, so highlighting neither is a spoiler: NYT-style prose like
+ /// `See 11-Down` or `With 31- and 43-Down, …`, and the leading `*` that
+ /// marks each member of a theme set. Prose groups are connected
+ /// components — any clues mentioned together (transitively) land in the
+ /// same set as `(number, direction)` identifiers — and the starred set is
+ /// appended as one further group.
private static func buildCrossReferenceGroups(
across: [Clue],
down: [Clue]
@@ -355,9 +358,37 @@ struct Puzzle: Sendable {
}
if component.count >= 2 { groups.append(component) }
}
+
+ // The starred theme set, kept as a group of its own rather than fed
+ // in as edges above: a starred clue can also carry prose refs, and
+ // merging the two would pull every link in the puzzle into a single
+ // component. Appended last, so a prose group wins any cell the two
+ // share when `cellGroups` assigns patterns.
+ var starred: Set<ClueRef> = []
+ for entry in entries where entry.text.drop(while: \.isWhitespace).first == "*" {
+ starred.insert(entry.ref)
+ }
+ // One stray literal asterisk isn't a theme; two marked clues are.
+ if starred.count >= 2 {
+ // A revealer that names the set ("each asterisked clue") but
+ // carries no asterisk of its own still belongs with its themers.
+ for entry in entries where namesStarredSet(entry.text) {
+ starred.insert(entry.ref)
+ }
+ groups.append(starred)
+ }
return groups
}
+ /// Whether a clue's prose names the starred set — "asterisked" or
+ /// "starred" immediately followed by "clue" or "answer", as in "a literal
+ /// description of the answer to each asterisked clue". This is the only
+ /// signal binding an unmarked revealer to the themers it points at;
+ /// `NYTToXDConverter` applies the same rule to italicised sets.
+ private static func namesStarredSet(_ text: String) -> Bool {
+ text.lowercased().contains(/(asterisked|starred)\s+(clue|answer)/)
+ }
+
/// Pulls `(number, direction)` pairs out of `See …-Down`,
/// `With X- and Y-Down`, revealer-style `X-, Y- or Z-Across`,
/// and mixed-direction prose like `X-Across and Y-Down`.
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -845,6 +845,114 @@ struct NYTToXDConverterTests {
}
}
+ @Test("Starred theme clues drive Puzzle.relatedCells")
+ func starredCluesDrivePuzzle() throws {
+ // 1A and 4A are both marked with a leading asterisk. The asterisk is
+ // the constructor's own "these go together", so focusing 1A should
+ // outline 4A even though neither clue names the other.
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "*Theme answer one",
+ 1: "*Theme answer two"
+ ]
+ )
+ 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)
+ 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("Starred theme cells carry a passive cross-reference group")
+ func starredCluesCarryCellGroup() throws {
+ // The group has to reach `cellGroups` too: that is what hatches the
+ // theme squares the moment the puzzle opens, before the solver has
+ // focused any clue — the only cue on a phone, where the clue list
+ // can't be scanned for asterisks.
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "*Theme answer one",
+ 1: "*Theme answer two"
+ ]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ for c in 0..<3 {
+ #expect(puzzle.cellGroups[GridPosition(row: 0, col: c)] != nil)
+ #expect(puzzle.cellGroups[GridPosition(row: 1, col: c)] != nil)
+ #expect(puzzle.cellGroups[GridPosition(row: 2, col: c)] == nil)
+ }
+ }
+
+ @Test("An unstarred revealer naming the set joins its themers")
+ func unstarredRevealerJoinsStarredSet() throws {
+ // 1A and 4A are starred; 5A is the revealer and carries no asterisk,
+ // so its prose ("each asterisked clue") is the only thing tying it to
+ // them. Focusing it should still outline both themers.
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "*Theme answer one",
+ 1: "*Theme answer two",
+ 2: "A literal description of each asterisked clue"
+ ]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ let related = puzzle.relatedCells(atRow: 2, 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("A lone starred clue forms no group")
+ func loneStarredClueFormsNoGroup() throws {
+ // A single leading asterisk is as likely to be literal prose as a
+ // theme marker, and one clue is not a set either way.
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [0: "*Not a theme, just an asterisk"]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ #expect(puzzle.crossReferenceGroups.isEmpty)
+ #expect(puzzle.relatedCells(atRow: 0, col: 0, direction: .across).isEmpty)
+ }
+
+ @Test("Starred themers stay separate from a prose cross-reference")
+ func starredSetDoesNotMergeWithProseGroup() throws {
+ // 1A is starred and also points at 3-Down. The prose link and the
+ // theme set are different relations, so they stay two groups rather
+ // than collapsing into one component through their shared clue.
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "*Theme answer one, with 3-Down",
+ 1: "*Theme answer two"
+ ]
+ )
+ let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data))
+ let puzzle = Puzzle(xd: xd)
+ #expect(puzzle.crossReferenceGroups.count == 2)
+ let prose: Set<Puzzle.ClueRef> = [
+ Puzzle.ClueRef(number: 1, direction: .across),
+ Puzzle.ClueRef(number: 3, direction: .down)
+ ]
+ let theme: Set<Puzzle.ClueRef> = [
+ Puzzle.ClueRef(number: 1, direction: .across),
+ Puzzle.ClueRef(number: 4, direction: .across)
+ ]
+ #expect(puzzle.crossReferenceGroups.contains(prose))
+ #expect(puzzle.crossReferenceGroups.contains(theme))
+ }
+
// MARK: - Overlay assets
private func overlayJSON(