crossmate

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

commit c906cd45cbedd4309786b653c0b0c34e0e873141
parent 3ddfcd3cf7ffed79ce6de41a74cd7b51b4b26273
Author: Michael Camilleri <[email protected]>
Date:   Fri, 29 May 2026 07:29:12 +0900

Load Schrödinger puzzles as rebuses

A 'Schrödinger grid' is a puzzle in which a square can take multiple
letters. The XD converter was failing to parse 'Schrödinger cells' that
were identified with a '/'. Crossmate should model this as a rebus with
the alternate solutions being the individual letters.

A cell whose answer contains '/' now has the slash stripped: the letters
together (e.g. 'AZ') become the canonical rebus fill so the marquee
across answer reveals correctly, and the single letters ride through
moreAnswers → ^Accept → per-cell acceptedSolutions. Cells whose answer
has no slash are untouched, so arbitrary accepted strings still
round-trip.

The placeholder collision is fixed at the source. rebusPlaceholders is
no longer a hand-picked grab-bag of punctuation but is derived by the
.xd spec's rule — 'digits, most symbols, and printable unicode
characters (if needed)' — as digits first, then every printable ASCII
symbol except the seven the grid/header parser reserves (#, _, ., @, *,
=, space). The assignment loop throws a clear error rather than silently
corrupting output if a puzzle ever exhausts the pool; real grids use a
handful of distinct fills, nowhere near the ceiling.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Services/NYTToXDConverter.swift | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
MTests/Unit/NYTToXDConverterTests.swift | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 125 insertions(+), 8 deletions(-)

diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -7,6 +7,28 @@ enum NYTToXDConverter { var errorDescription: String? { message } } + /// Single-character grid placeholders for multi-letter (rebus) fills. The + /// `.xd` grid is one character per cell, so a cell whose fill is longer than + /// one letter shows one of these in the grid and is expanded via the + /// `Rebus:` header (the `1` in `1=LW`). These are *not* NYT data — they're + /// our `.xd` serialization. The `.xd` spec allows "digits, most symbols, + /// and printable unicode characters (if needed)" here; we take digits first + /// (the conventional, readable encoding) then the symbols, where "most" + /// means every printable ASCII symbol *except* the ones the grid/header + /// parser already reserves — letters (grid fill), `#`/`_`/`.` (block/empty), + /// `@`/`*` (special markers), and `=`/space (`Rebus:` `key=value` syntax). + /// We stop at ASCII rather than walking into unicode and throw past the + /// ceiling; real puzzles use a handful of distinct fills, nowhere near it. + /// (Walking ASCII naïvely from `'1'` overflows into `=` by the 13th key, + /// which produced an unparseable `==VALUE` entry and a rejected grid char.) + private static let rebusPlaceholders: [Character] = { + let reserved: Set<Character> = ["#", "_", ".", "@", "*", "=", " "] + let symbols = (UInt8(33)...UInt8(126)) + .map { Character(UnicodeScalar($0)) } + .filter { !$0.isLetter && !$0.isNumber && !reserved.contains($0) } + return Array("123456789") + symbols + }() + /// Converts raw JSON data from the NYT puzzle endpoint to an `.xd` source string. static func convert(jsonData: Data) throws -> String { guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { @@ -48,7 +70,6 @@ enum NYTToXDConverter { // Each cell is either an empty dict (block) or a dict with "answer", "type", etc. var answers: [String?] = [] // nil = block, String = answer var acceptedAnswersByCellIndex: [Int: [String]] = [:] - var nextRebusKey: Character = "1" for (index, cell) in cells.enumerated() { guard let dict = cell as? [String: Any], !dict.isEmpty else { @@ -56,7 +77,18 @@ enum NYTToXDConverter { continue } - let answer = dict["answer"] as? String ?? "" + let rawAnswer = 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 if answer.isEmpty { answers.append(nil) } else { @@ -65,27 +97,41 @@ enum NYTToXDConverter { if let moreAnswers = dict["moreAnswers"] as? [String: Any], let valid = moreAnswers["valid"] as? [String] { - let cleaned = valid.filter { !$0.isEmpty && $0 != answer } + // 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 } if !cleaned.isEmpty { - acceptedAnswersByCellIndex[index] = cleaned + acceptedAnswersByCellIndex[index] = isSchrodinger + ? Array(Set(cleaned)).sorted() + : cleaned } } } // -- Build rebus header if needed -- - // Check for multi-character answers. + // Check for multi-character answers. Each distinct multi-letter fill + // claims the next grid placeholder from `rebusPlaceholders`. var rebusEntries: [(key: Character, value: String)] = [] var rebusLookup: [String: Character] = [:] for answer in answers { guard let answer, answer.count > 1 else { continue } if rebusLookup[answer] == nil { - let key = nextRebusKey + guard rebusLookup.count < rebusPlaceholders.count else { + throw ConversionError( + message: "Too many distinct rebus fills (\(rebusLookup.count + 1)); ran out of grid placeholders." + ) + } + let key = rebusPlaceholders[rebusLookup.count] rebusLookup[answer] = key rebusEntries.append((key: key, value: answer)) - // Advance to next digit/letter for rebus key - nextRebusKey = Character(UnicodeScalar(nextRebusKey.asciiValue! + 1)) } } diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -71,6 +71,29 @@ struct NYTToXDConverterTests { return try JSONSerialization.data(withJSONObject: root) } + /// Builds NYT v6-shaped JSON for a single open row of `answers.count` + /// cells — one Across clue, no Down words. Handy for exercising cell-level + /// behaviour (rebus placeholders, Schrödinger fills) at a chosen volume + /// without the 3×3 grid's six-clue topology. + private func singleRowPuzzleJSON(answers: [String]) throws -> Data { + let cells = answers.map { ["answer": $0] as [String: Any] } + let clue: [String: Any] = [ + "label": "1", + "direction": "Across", + "cells": Array(0..<answers.count), + "text": [["plain": "across"]] + ] + let root: [String: Any] = [ + "publicationDate": "2025-02-02", + "body": [[ + "dimensions": ["width": answers.count, "height": 1], + "cells": cells, + "clues": [clue] + ]] + ] + return try JSONSerialization.data(withJSONObject: root) + } + /// Extracts the value after `Relatives: ` in an `.xd` source, or nil if /// the header isn't present. private func relativesHeader(in xd: String) -> String? { @@ -129,6 +152,54 @@ 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. + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + letters: ["A", "B", "C", "D", "L/W", "F", "G", "H", "I"], + moreAnswersByCell: [4: ["W/L", "LW", "WL", "L", "W"]] + ) + 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("/")) + + 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.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") + func manyRebusFillsAvoidReservedKeys() throws { + // The 13 Schrödinger fills from the 2 Feb 2025 Sunday puzzle. Walking + // ASCII from '1' reaches '=' (the Rebus header delimiter) on the 13th, + // which used to yield an unparseable "==HE" entry and a grid character + // the parser rejected with unknownGridCharacter. + let pairs = ["L/W", "Q/B", "Z/M", "C/V", "U/I", "D/N", "R/Y", + "G/T", "S/F", "X/K", "J/P", "O/A", "H/E"] + let data = try singleRowPuzzleJSON(answers: pairs) + let xd = try NYTToXDConverter.convert(jsonData: data) + + let rebus = try #require(header("Rebus", in: xd)) + #expect(!rebus.contains("==")) // no entry has '=' as its key + for entry in rebus.split(separator: " ") { + #expect(entry.first != "=") + } + + // The whole puzzle must now parse rather than throwing, with every + // distinct fill landing as a multi-letter rebus solution. + let puzzle = Puzzle(xd: try XD.parse(xd)) + #expect(puzzle.cells[0][0].solution == "LW") + #expect(puzzle.cells[0][12].solution == "HE") + } + @Test("NYT type 2 cells emit circled specials") func typeTwoCellsEmitCircledSpecials() throws { let data = try puzzleJSON(