crossmate

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

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:
MCrossmate/Models/XD.swift | 42+++++++++++++++++++++++++++++++++++++-----
MTests/Unit/XDAcceptTests.swift | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 = """