commit 6cfa469467c4937df01dc14a4cef87cbdecc8fa5
parent 5e3014a92bbbec090ce446cea6606b0ee847beff
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 13:11:46 +0900
Keep the rebus fill when a converted cell is also circled
This commit fixes a precedence collision in the .puz and JSON grid
writers. Both emitted the circle (or shade) marker before checking for a
rebus placeholder, so a cell that was both circled and rebus wrote `@`
to the grid — which parses as a solution-less circled cell. The
multi-character fill vanished from the grid while the `Rebus:` header
still referenced a placeholder key that never appeared and the clue
answer still carried the full fill, leaving the converted puzzle
internally inconsistent.
The placeholder now wins in both converters. A grid character can only
say one thing, and the XD dialect cannot express circled-and-rebus in a
single cell — placeholders are digits and symbols, and the parser
attaches specials only to `@`, `*` and lowercase letters — so the
correct fill takes precedence and only that cell's circle is lost.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
4 files changed, 75 insertions(+), 9 deletions(-)
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -161,6 +161,16 @@ enum NYTToXDConverter {
line += "#"
continue
}
+ // A cell can be both special and rebus, but a grid character
+ // can only say one thing: `@`/`*` parse as solution-less
+ // special cells, so emitting them here would drop the
+ // multi-char fill and leave the `Rebus:` header referencing a
+ // key absent from the grid. The fill wins; the special marker
+ // on that one cell is lost.
+ if needsRebusEncoding(answer) {
+ line += String(rebusLookup[answer]!)
+ continue
+ }
if specialCells.circled.contains(index) {
line += "@"
continue
@@ -169,11 +179,7 @@ enum NYTToXDConverter {
line += "*"
continue
}
- if needsRebusEncoding(answer) {
- line += String(rebusLookup[answer]!)
- } else {
- line += answer.uppercased()
- }
+ line += answer.uppercased()
}
gridLines.append(line)
}
diff --git a/Crossmate/Services/PUZToXDConverter.swift b/Crossmate/Services/PUZToXDConverter.swift
@@ -124,14 +124,19 @@ enum PUZToXDConverter {
line += "#"
continue
}
- if circledCells.contains(index) {
- line += "@"
- continue
- }
+ // A cell can be both circled and rebus, but a grid character
+ // can only say one thing: `@` parses as a solution-less circled
+ // cell, so emitting it here would drop the multi-char fill and
+ // leave the `Rebus:` header referencing a key absent from the
+ // grid. The fill wins; the circle on that one cell is lost.
if let key = rebusKeys[index] {
line.append(key)
continue
}
+ if circledCells.contains(index) {
+ line += "@"
+ continue
+ }
line += String(UnicodeScalar(byte)).uppercased()
}
return line
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -380,6 +380,26 @@ struct NYTToXDConverterTests {
#expect(puzzle.cells[1][1].special == .shaded)
}
+ @Test("A cell both circled and rebus keeps its fill over the circle")
+ func circledRebusCellKeepsRebusFill() throws {
+ // The grid can't say both: '@' parses as a solution-less circled cell,
+ // so letting the circle win (the old precedence) dropped the rebus
+ // placeholder from the grid while the Rebus header still referenced it.
+ // The fill must win; only that cell's circle is sacrificed.
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ letters: ["A", "B", "C", "D", "HEART", "F", "G", "H", "I"],
+ cellTypes: [0: 2, 4: 2]
+ )
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+
+ #expect(header("Rebus", in: xd) == "1=HEART")
+ #expect(xd.contains("\n@BC\nD1F\nGHI\n"))
+ let puzzle = Puzzle(xd: try XD.parse(xd))
+ #expect(puzzle.cells[1][1].solution == "HEART")
+ #expect(puzzle.cells[0][0].special == .circled)
+ }
+
@Test("Revealer with ≥2 relatives produces a group")
func revealerGroup() throws {
// 1A (index 0) references 4A (1) and 5A (2).
diff --git a/Tests/Unit/PUZToXDConverterTests.swift b/Tests/Unit/PUZToXDConverterTests.swift
@@ -206,6 +206,41 @@ struct PUZToXDConverterTests {
#expect(puzzle.cells[1][1].solution == "HEART")
}
+ @Test("A cell both circled and rebus keeps its fill over the circle")
+ func circledRebusCellKeepsRebusFill() throws {
+ // The grid can't say both: '@' parses as a solution-less circled cell,
+ // so letting the circle win (the old precedence) dropped the rebus
+ // placeholder from the grid while the Rebus header still referenced it.
+ // The fill must win; only that cell's circle is sacrificed.
+ let data = try puzData(
+ width: 3,
+ height: 3,
+ solution: "ABCDEFGHI",
+ title: "Both",
+ author: "",
+ copyright: "",
+ clues: [
+ "Across 1",
+ "Down 1",
+ "Down 2",
+ "Down 3",
+ "Across 4",
+ "Across 5"
+ ],
+ circledCells: [0, 4],
+ rebusGrid: [4: 0],
+ rebusTable: [0: "HEART"]
+ )
+
+ let source = try PUZToXDConverter.convert(puzData: data)
+ #expect(header("Rebus", in: source) == "1=HEART")
+ #expect(source.contains("\n@BC\nD1F\nGHI\n"))
+
+ let puzzle = Puzzle(xd: try XD.parse(source))
+ #expect(puzzle.cells[1][1].solution == "HEART")
+ #expect(puzzle.cells[0][0].special == .circled)
+ }
+
@Test("Many distinct rebus fills never collide with reserved grid syntax")
func manyRebusFillsAvoidReservedKeys() throws {
// Keying placeholders by cell index used to reach '=' (the Rebus header