commit 962939c5302d3633e8386d910f2af5a78d693983
parent 309cf825a7e5cdc3825ac4bdb042c5d6b46d33ef
Author: Michael Camilleri <[email protected]>
Date: Mon, 22 Jun 2026 10:06:28 +0900
Accept slash-separated answers on .xd clues
Some .xd puzzles declare a Schrödinger clue by listing its alternate
readings after the tilde, separated by ` / ` — for instance `A37. Cigar,
to a Freudian ~ CIGAR / PENIS`. The parser read the whole field as one
literal answer, so the alternate reading became an unsolvable solution
string and a player who entered it was marked wrong.
This commit updates parseClues to split the tilde field on ` / `: the
first reading stays the canonical answer and the rest are recorded on a
new Clue.alternativeAnswers, which acceptedAnswers now folds in
alongside the existing ^Accept metadata. The readings then ride the same
acceptedCellValues path that already distributes accepted answers onto
the cells where they differ. A plain `~ ANSWER` field carries no slash
and so behaves exactly as before.
The distribution itself was widened to reach the whole-word case.
segmentAcceptedAnswer previously aligned an alternate only when a single
cell differed; it now mirrors the canonical cell lengths whenever the
alternate is the same length, so a reading in which every square is dual
— CIGAR against PENIS — drops each letter onto its own cell. The
length-asymmetric single-cell rebus alternate still falls through to the
original search, so its handling is unchanged.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 92 insertions(+), 5 deletions(-)
diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift
@@ -40,10 +40,14 @@ struct XD: Sendable {
let number: Int
let text: String
let answer: String?
+ /// Alternative full-word answers from a slash-separated `~` field
+ /// (`~ CIGAR / PENIS`): the first reading becomes `answer`, the rest
+ /// are recorded here as Schrödinger alternatives.
+ let alternativeAnswers: [String]
let metadata: [String: [String]]
var acceptedAnswers: [String] {
- metadata["Accept", default: []].flatMap(Self.parseEscapedTokens)
+ alternativeAnswers + metadata["Accept", default: []].flatMap(Self.parseEscapedTokens)
}
private static func parseEscapedTokens(_ source: String) -> [String] {
@@ -450,6 +454,7 @@ struct XD: Sendable {
var clueTexts: [ClueKey: String] = [:]
var clueAnswers: [ClueKey: String] = [:]
+ var clueAlternatives: [ClueKey: [String]] = [:]
var clueOrder: [ClueKey] = []
var metadataByClue: [ClueKey: [String: [String]]] = [:]
@@ -484,10 +489,18 @@ struct XD: Sendable {
.trimmingCharacters(in: .whitespaces)
if let tilde = afterDot.range(of: " ~ ", options: .backwards) {
- let answer = afterDot[tilde.upperBound...]
- .trimmingCharacters(in: .whitespaces)
- if !answer.isEmpty {
- clueAnswers[key] = answer
+ // A slash-separated answer field (`~ CIGAR / PENIS`) declares a
+ // Schrödinger clue: the first reading is canonical, the rest are
+ // accepted alternatives. A plain field stays a single answer.
+ let readings = afterDot[tilde.upperBound...]
+ .components(separatedBy: " / ")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ if let canonical = readings.first {
+ clueAnswers[key] = canonical
+ if readings.count > 1 {
+ clueAlternatives[key] = Array(readings.dropFirst())
+ }
}
afterDot = String(afterDot[..<tilde.lowerBound])
.trimmingCharacters(in: .whitespaces)
@@ -506,6 +519,7 @@ struct XD: Sendable {
number: key.number,
text: clueTexts[key] ?? "",
answer: clueAnswers[key],
+ alternativeAnswers: clueAlternatives[key] ?? [],
metadata: metadataByClue[key] ?? [:]
)
if key.direction == "A" {
@@ -819,6 +833,24 @@ struct XD: Sendable {
let accepted = Array(acceptedAnswer)
let canonical = canonicalSegments.map(Array.init)
+ // Equal-length alternative: mirror the canonical cell segmentation
+ // exactly. This covers whole-word Schrödinger answers where several
+ // cells differ at once (e.g. CIGAR / PENIS over single-letter cells),
+ // which the single-replacement search below cannot align.
+ let canonicalLength = canonical.reduce(0) { $0 + $1.count }
+ if accepted.count == canonicalLength {
+ var segments: [String] = []
+ var offset = 0
+ var differs = false
+ for cell in canonical {
+ let slice = accepted[offset..<offset + cell.count]
+ if !slice.elementsEqual(cell) { differs = true }
+ segments.append(String(slice))
+ offset += cell.count
+ }
+ return differs ? segments : nil
+ }
+
for replacedIndex in canonical.indices {
let prefixLength = canonical[..<replacedIndex].reduce(0) { $0 + $1.count }
let suffixLength = canonical[canonical.index(after: replacedIndex)...].reduce(0) { $0 + $1.count }
diff --git a/Tests/Unit/XDAcceptTests.swift b/Tests/Unit/XDAcceptTests.swift
@@ -301,6 +301,61 @@ struct XDAcceptTests {
#expect(!puzzle.cells[0][0].accepts("PHI"))
}
+ @Test("Slash ~ field accepts a single magic-square alternate")
+ func slashFieldAcceptsMagicSquare() throws {
+ let source = """
+ Title: Magic Square
+ CmVer: 3
+
+
+ ABC
+
+
+ A1. Three letters ~ ABC / ADC
+ """
+
+ let xd = try XD.parse(source)
+ let clue = try #require(xd.acrossClues.first)
+
+ #expect(clue.answer == "ABC")
+ #expect(clue.alternativeAnswers == ["ADC"])
+ #expect(clue.acceptedAnswers == ["ADC"])
+
+ let puzzle = Puzzle(xd: xd)
+ #expect(puzzle.cells[0][1].solution == "B")
+ #expect(puzzle.cells[0][1].accepts("D"))
+ #expect(!puzzle.cells[0][0].accepts("D"))
+ #expect(!puzzle.cells[0][2].accepts("D"))
+ }
+
+ @Test("Slash ~ field accepts a whole-word alternate where every cell differs")
+ func slashFieldAcceptsWholeWordAlternate() throws {
+ let source = """
+ Title: Whole Word
+ CmVer: 3
+
+
+ CIGAR
+
+
+ A1. Cigar, to a Freudian ~ CIGAR / PENIS
+ """
+
+ let xd = try XD.parse(source)
+ let clue = try #require(xd.acrossClues.first)
+
+ #expect(clue.answer == "CIGAR")
+ #expect(clue.alternativeAnswers == ["PENIS"])
+
+ let puzzle = Puzzle(xd: xd)
+ let canonical = Array("CIGAR")
+ let alternate = Array("PENIS")
+ for column in 0..<5 {
+ #expect(puzzle.cells[0][column].solution == String(canonical[column]))
+ #expect(puzzle.cells[0][column].accepts(String(alternate[column])))
+ }
+ }
+
@Test("Check accepts alternate entries")
func checkAcceptsAlternateEntries() throws {
let source = """