commit 73b3b587c1924aeb7ea72092d9ba9d5f91d16272
parent d135a009cb51a784b6fb9ffbbd94b968da463184
Author: Michael Camilleri <[email protected]>
Date: Wed, 8 Jul 2026 07:37:57 +0900
Preserve the slash in Schrödinger square fills
Some puzzles encode a 'choice' square — one correct as either of two
words, such as 'HIT/MISS' — with the slash as part of the answer itself.
The converter collapsed that to 'HITMISS', so a revealed square showed a
run-together fill that matched neither reading and diverged from the
official answer. The slash was dropped only because the old custom
keyboard could not enter one.
Rebus entry now runs through the system keyboard, so a slash is directly
typeable, and the individual sides and single crossing letters already
ride along as accepted alternates. This commit keeps a puzzle's slash
form verbatim as the rebus fill — it is ASCII and needs no header
escaping. Raising currentCmVersion also lets the upgrader re-convert
existing games on next open, so a revealed square reads 'HIT/MISS' and
the corrected fill syncs to co-players.
The conversion script also failed to compile: its XD stub mirrored only
currentCmVersion, while the converter now references rebusPlaceholders.
It now extracts that definition from XD.swift as well, keeping the stub
in lockstep with the source.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 46 insertions(+), 44 deletions(-)
diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift
@@ -4,7 +4,7 @@ import Foundation
/// full specification. Supports just enough of the format to parse our
/// bundled puzzles: metadata, grid (with rebus), and across/down clues.
struct XD: Sendable {
- static let currentCmVersion = 7
+ static let currentCmVersion = 8
/// Upper bound on a `.xd` source handed to `parse`. The largest puzzle we
/// ship is ~3 KB, so this is ~80× real content — no genuine puzzle is ever
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -81,18 +81,16 @@ enum NYTToXDConverter {
continue
}
- let rawAnswer = dict["answer"] as? String ?? ""
+ let answer = dict["answer"] as? String ?? ""
// NYT encodes a "Schrödinger" square — one correct as either of two
- // letters — as a slash-joined answer like "L/W", with every
- // acceptable keystroke enumerated in moreAnswers.valid. The slash
- // is NYT notation, not a grid character (and isn't on our
- // keyboard), so collapse it: the letters together ("LW") become the
- // canonical rebus fill, and each single letter rides along as an
- // accepted alternate via the moreAnswers handling below.
- let isSchrodinger = rawAnswer.contains("/")
- let answer = isSchrodinger
- ? rawAnswer.replacingOccurrences(of: "/", with: "")
- : rawAnswer
+ // fills — as a slash-joined answer like "HIT/MISS", with every
+ // acceptable entry enumerated in moreAnswers.valid. We keep the
+ // slash form verbatim as the canonical fill: it's ASCII, needs no
+ // rebus-header escaping, matches NYT's official answer, and reveals
+ // as "HIT/MISS". The individual sides ("HIT", "MISS") and the single
+ // crossing letters ("H", "M") ride along as accepted alternates
+ // below — that's what a player actually enters, since the keyboard
+ // can't produce a slash.
if answer.isEmpty {
// A playable cell (NYT type 1) with no answer is an intentional
// blank: the "gap" in themers like the 2006-07-06 "THE GAP"
@@ -108,19 +106,13 @@ enum NYTToXDConverter {
if let moreAnswers = dict["moreAnswers"] as? [String: Any],
let valid = moreAnswers["valid"] as? [String] {
- // For Schrödinger cells, strip the slash from each alternate too
- // so only keyboard-enterable letter forms survive, then
- // dedupe/sort for stable output. Other cells pass their
- // alternates through unchanged so arbitrary accepted strings
- // still round-trip.
- let candidates = isSchrodinger
- ? valid.map { $0.replacingOccurrences(of: "/", with: "") }
- : valid
- let cleaned = candidates.filter { !$0.isEmpty && $0 != answer }
+ // Every acceptable entry NYT lists passes through unchanged — the
+ // individual sides of a Schrödinger square, single crossing
+ // letters, and any other accepted string all round-trip into the
+ // Accept metadata.
+ let cleaned = valid.filter { !$0.isEmpty && $0 != answer }
if !cleaned.isEmpty {
- acceptedAnswersByCellIndex[index] = isSchrodinger
- ? Array(Set(cleaned)).sorted()
- : cleaned
+ acceptedAnswersByCellIndex[index] = cleaned
}
}
}
diff --git a/Scripts/nyt-to-xd.sh b/Scripts/nyt-to-xd.sh
@@ -14,15 +14,23 @@ converter="${repo_root}/Crossmate/Services/NYTToXDConverter.swift"
xd_source="${repo_root}/Crossmate/Models/XD.swift"
fetch_script="${repo_root}/Scripts/fetch-nyt.sh"
-# The converter pulls XD.currentCmVersion from the app target. Mirror just
-# that constant from XD.swift so we don't need to compile the full app.
-# Reading from source keeps the stub in lockstep with the real value.
+# The converter pulls a couple of constants from the app target (XD). Mirror
+# just those from XD.swift so we don't need to compile the full app. Reading
+# from source keeps the stub in lockstep with the real values.
cm_version="$(sed -n 's/^[[:space:]]*static let currentCmVersion = \([0-9][0-9]*\).*/\1/p' "$xd_source" | head -n1)"
if [[ -z "$cm_version" ]]; then
echo "error: could not read currentCmVersion from $xd_source" >&2
exit 1
fi
+# rebusPlaceholders is a self-contained computed constant; extract its whole
+# definition (from the declaration through the closing `}()`) verbatim.
+rebus_placeholders="$(awk '/static let rebusPlaceholders/{f=1} f{print} f&&/\}\(\)/{exit}' "$xd_source")"
+if [[ -z "$rebus_placeholders" ]]; then
+ echo "error: could not read rebusPlaceholders from $xd_source" >&2
+ exit 1
+fi
+
json_path=""
date_arg=""
output_path=""
@@ -66,11 +74,12 @@ driver="${tmp_dir}/main.swift"
cat > "$driver" <<SWIFT
import Foundation
-// Stub for the one symbol the converter pulls from the app target. The value
-// is read out of Crossmate/Models/XD.swift at script-start time so this stays
-// in sync with the canonical definition.
+// Stub for the symbols the converter pulls from the app target. The values
+// are read out of Crossmate/Models/XD.swift at script-start time so this stays
+// in sync with the canonical definitions.
enum XD {
static let currentCmVersion = ${cm_version}
+ ${rebus_placeholders}
}
guard CommandLine.arguments.count >= 2 else {
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -219,9 +219,10 @@ struct NYTToXDConverterTests {
#expect(cell.accepts("BACK\\SLASH"))
}
- @Test("Schrödinger slash cell becomes a letters-only rebus that round-trips")
- func schrodingerCellStripsSlash() throws {
- // Center cell is correct as L or W; the across answer crams both in.
+ @Test("Schrödinger slash cell keeps NYT's slash form and round-trips")
+ func schrodingerCellKeepsSlash() throws {
+ // Center cell is correct as L or W; NYT's canonical answer is the
+ // slash-joined "L/W".
let data = try puzzleJSON(
relatives: [nil, nil, nil, nil, nil, nil],
letters: ["A", "B", "C", "D", "L/W", "F", "G", "H", "I"],
@@ -229,18 +230,17 @@ struct NYTToXDConverterTests {
)
let xd = try NYTToXDConverter.convert(jsonData: data)
- // The canonical rebus fill is the letters with the slash removed, and
- // the slash never reaches the grid, the header, or the Accept metadata.
- #expect(header("Rebus", in: xd) == "1=LW")
- #expect(!xd.contains("/"))
+ // The slash form rides through verbatim as the canonical rebus fill —
+ // it's ASCII and needs no header escaping — and reveals as "L/W".
+ #expect(header("Rebus", in: xd) == "1=L/W")
let puzzle = Puzzle(xd: try XD.parse(xd))
let cell = puzzle.cells[1][1]
- #expect(cell.solution == "LW")
- #expect(cell.accepts("LW")) // both letters (across)
- #expect(cell.accepts("L")) // either single letter (down)
+ #expect(cell.solution == "L/W")
+ #expect(cell.accepts("L/W")) // NYT's canonical slash form
+ #expect(cell.accepts("LW")) // both letters, no slash (across)
+ #expect(cell.accepts("L")) // either single letter (down)
#expect(cell.accepts("W"))
- #expect(!cell.accepts("L/W")) // the slash itself is not a valid entry
}
@Test("Many distinct rebus fills never collide with reserved grid syntax")
@@ -261,10 +261,11 @@ struct NYTToXDConverterTests {
}
// The whole puzzle must now parse rather than throwing, with every
- // distinct fill landing as a multi-letter rebus solution.
+ // distinct fill landing as a multi-character rebus solution (NYT's
+ // slash form preserved verbatim).
let puzzle = Puzzle(xd: try XD.parse(xd))
- #expect(puzzle.cells[0][0].solution == "LW")
- #expect(puzzle.cells[0][12].solution == "HE")
+ #expect(puzzle.cells[0][0].solution == "L/W")
+ #expect(puzzle.cells[0][12].solution == "H/E")
}
@Test("Single-character digit fills are encoded as rebus placeholders and round-trip")