commit eb8b0256bd2cc1bfb1f9482e823eee861e372edb parent 5c9571d6bcd460844133c536f07218558685b15d Author: Michael Camilleri <[email protected]> Date: Fri, 15 May 2026 20:12:59 +0900 Represent XD specials with grid symbols This commit changes Crossmate's XD extension for special cells to use a 'Specials' key associated with a symbol map (e.g. @=circle *=shaded) with those symbols appearing directly in the grid. This is contrast to XD's 'Special' key. The parser infers the symbol cells' solutions from clue answers, and rejects puzzles where crossings conflict, inference is ambiguous or a symbol cell cannot be filled. NYT and PUZ conversion now emit symbol-based specials instead of lowercase grid letters or numeric masks. The XD parser still supports standard XD's 'Special' header for lowercase circle/shaded cells. The PUZ extension parser also now indexes Data slices correctly when reading GEXT and GRBS payloads, avoiding traps from slice-local offset assumptions. Co-Authored-By: Codex GPT 5.5 <[email protected]> Diffstat:
22 files changed, 529 insertions(+), 149 deletions(-)
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -11,8 +11,7 @@ struct Puzzle: Sendable { var opposite: Direction { self == .across ? .down : .across } } - /// How "special" cells (lowercase letters in `.xd`) should be drawn. - /// A puzzle can use circles or shading, but not both at once. + /// How a special cell should be drawn. enum Special: Sendable, Hashable { case circled case shaded @@ -22,7 +21,6 @@ struct Puzzle: Sendable { let publisher: String? let author: String? let date: Date? - let specialKind: Special? let width: Int let height: Int let cells: [[Cell]] @@ -48,7 +46,7 @@ struct Puzzle: Sendable { let row: Int let col: Int let isBlock: Bool - let isSpecial: Bool + let special: Special? let number: Int? let solution: String? let acceptedSolutions: Set<String> @@ -82,7 +80,6 @@ struct Puzzle: Sendable { self.publisher = xd.publisher self.author = xd.author self.date = xd.date - self.specialKind = xd.specialKind self.width = xd.width self.height = xd.height @@ -100,8 +97,8 @@ struct Puzzle: Sendable { for c in 0..<xd.width { switch xd.cells[r][c] { case .block: - rowCells.append(Cell(row: r, col: c, isBlock: true, isSpecial: false, number: nil, solution: nil, acceptedSolutions: [])) - case .open(let solution, let acceptedSolutions, let isSpecial): + rowCells.append(Cell(row: r, col: c, isBlock: true, special: nil, number: nil, solution: nil, acceptedSolutions: [])) + case .open(let solution, let acceptedSolutions, let special): let leftBlock = c == 0 || Self.isBlock(xd.cells, r, c - 1) let rightOpen = c + 1 < xd.width && !Self.isBlock(xd.cells, r, c + 1) let topBlock = r == 0 || Self.isBlock(xd.cells, r - 1, c) @@ -115,7 +112,7 @@ struct Puzzle: Sendable { number = nil } let normalizedAccepted = Set(acceptedSolutions.map { Cell.normalizedAnswer($0) }) - rowCells.append(Cell(row: r, col: c, isBlock: false, isSpecial: isSpecial, number: number, solution: solution, acceptedSolutions: normalizedAccepted)) + rowCells.append(Cell(row: r, col: c, isBlock: false, special: special, number: number, solution: solution, acceptedSolutions: normalizedAccepted)) } } cells.append(rowCells) 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 = 2 + static let currentCmVersion = 3 let title: String? let publisher: String? @@ -12,7 +12,6 @@ struct XD: Sendable { let copyright: String? let date: Date? let cmVersion: Int - let specialKind: Puzzle.Special? let width: Int let height: Int let cells: [[Cell]] @@ -30,12 +29,11 @@ struct XD: Sendable { /// A single grid cell as it appears in the .xd source. Open cells carry /// an optional solution string which may be 1+ characters long once any - /// `Rebus:` mapping has been applied, plus a flag indicating whether the - /// cell is "special" (circled or shaded — the kind is per-puzzle and - /// lives on `XD.specialKind`). + /// `Rebus:` mapping has been applied, plus an optional per-cell special + /// marker. enum Cell: Sendable, Equatable { case block - case open(solution: String?, acceptedSolutions: Set<String>, isSpecial: Bool) + case open(solution: String?, acceptedSolutions: Set<String>, special: Puzzle.Special?) } struct Clue: Sendable, Equatable { @@ -85,6 +83,9 @@ struct XD: Sendable { case raggedGrid case malformedClue(String) case unknownGridCharacter(Character) + case clueAnswerMismatch(String) + case ambiguousClueAnswer(String) + case missingInferredSolution(row: Int, col: Int) var description: String { switch self { @@ -98,6 +99,12 @@ struct XD: Sendable { return "malformed .xd clue: \(line)" case .unknownGridCharacter(let ch): return "unknown .xd grid character: \(ch)" + case .clueAnswerMismatch(let clue): + return ".xd clue answer does not match grid: \(clue)" + case .ambiguousClueAnswer(let clue): + return ".xd clue answer cannot be unambiguously projected onto grid: \(clue)" + case .missingInferredSolution(let row, let col): + return ".xd grid cell at row \(row + 1), column \(col + 1) has no inferred solution" } } } @@ -108,21 +115,27 @@ struct XD: Sendable { guard sections.count >= 3 else { throw ParseError.missingClues } let metadata = parseMetadata(sections[0]) - let rebus = parseRebusHeader(metadata["Rebus"]) - let specialKind = parseSpecialHeader(metadata["Special"]) - let relatives = parseRelativesHeader(metadata["Relatives"]) - let (rawCells, width, height) = try parseGrid(sections[1], rebus: rebus) + let cmVersion = parseCmVersionHeader(metadata.first("CmVer")) + let rebus = parseRebusHeader(metadata.first("Rebus")) + let standardSpecial = parseStandardSpecialHeader(metadata.first("Special")) + let relatives = parseRelativesHeader(metadata.first("Relatives")) + let (rawCells, width, height) = try parseGrid( + sections[1], + rebus: rebus, + standardSpecial: standardSpecial, + specialsHeader: metadata.first("Specials") + ) let (across, down) = try parseClues(sections[2]) - let cells = applyAcceptedAnswers(cells: rawCells, across: across, down: down) + let solvedCells = try applyClueAnswers(cells: rawCells, across: across, down: down) + let cells = applyAcceptedAnswers(cells: solvedCells, across: across, down: down) return XD( - title: metadata["Title"], - publisher: metadata["Publisher"], - author: metadata["Author"], - copyright: metadata["Copyright"], - date: parseDateHeader(metadata["Date"]), - cmVersion: parseCmVersionHeader(metadata["CmVer"]), - specialKind: specialKind, + title: metadata.first("Title"), + publisher: metadata.first("Publisher"), + author: metadata.first("Author"), + copyright: metadata.first("Copyright"), + date: parseDateHeader(metadata.first("Date")), + cmVersion: cmVersion, width: width, height: height, cells: cells, @@ -189,17 +202,29 @@ struct XD: Sendable { // MARK: - Metadata - private static func parseMetadata(_ lines: [String]) -> [String: String] { - var dict: [String: String] = [:] + private struct Metadata { + var entries: [String: [String]] = [:] + + func first(_ key: String) -> String? { + entries[key]?.first + } + + func values(for key: String) -> [String] { + entries[key, default: []] + } + } + + private static func parseMetadata(_ lines: [String]) -> Metadata { + var metadata = Metadata() for line in lines { guard let colon = line.firstIndex(of: ":") else { continue } let key = line[..<colon].trimmingCharacters(in: .whitespaces) let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) if !key.isEmpty { - dict[key] = value + metadata.entries[key, default: []].append(value) } } - return dict + return metadata } /// Parses a `Rebus:` header value such as `1=ONE 2=TWO 3=THREE` into a @@ -234,8 +259,8 @@ struct XD: Sendable { return Calendar(identifier: .gregorian).date(from: comps) } - /// Parses a Crossmate `CmVer:` header. Missing versions are version 1 - /// so legacy sources establish the baseline content version. + /// Parses a Crossmate `CmVer:` header. Missing versions are version 1 so + /// older local fixtures remain identifiable as stale content. private static func parseCmVersionHeader(_ value: String?) -> Int { guard let value else { return 1 } let trimmed = value.trimmingCharacters(in: .whitespaces) @@ -271,14 +296,30 @@ struct XD: Sendable { return groups } - /// Parses a `Special:` header value into a `Puzzle.Special` kind. The - /// .xd spec recognises `shaded` and `circle` as the two values; we accept - /// either case-insensitively and treat anything else as no special kind. - private static func parseSpecialHeader(_ value: String?) -> Puzzle.Special? { + private static func parseSpecialsHeader(_ value: String?) -> [Character: Puzzle.Special] { + guard let value, !value.isEmpty else { return [:] } + var specials: [Character: Puzzle.Special] = [:] + for assignment in value.split(whereSeparator: { $0.isWhitespace }) { + guard let equals = assignment.firstIndex(of: "=") else { continue } + let symbolPart = assignment[..<equals] + let kindPart = assignment[assignment.index(after: equals)...] + guard symbolPart.count == 1, let symbol = symbolPart.first else { continue } + let kind: Puzzle.Special + switch kindPart.lowercased() { + case "circle", "circled": kind = .circled + case "shaded": kind = .shaded + default: continue + } + specials[symbol] = kind + } + return specials + } + + private static func parseStandardSpecialHeader(_ value: String?) -> Puzzle.Special? { guard let value else { return nil } switch value.trimmingCharacters(in: .whitespaces).lowercased() { - case "shaded": return .shaded case "circle", "circled": return .circled + case "shaded": return .shaded default: return nil } } @@ -287,54 +328,68 @@ struct XD: Sendable { private static func parseGrid( _ lines: [String], - rebus: [Character: String] + rebus: [Character: String], + standardSpecial: Puzzle.Special?, + specialsHeader: String? ) throws -> (cells: [[Cell]], width: Int, height: Int) { - var rows: [[Cell]] = [] + var gridLines: [String] = [] var width: Int? = nil for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { continue } - var row: [Cell] = [] - for ch in trimmed { - row.append(try gridCell(for: ch, rebus: rebus)) + if let w = width, trimmed.count != w { + throw ParseError.raggedGrid } + width = trimmed.count + gridLines.append(trimmed) + } - if let w = width, row.count != w { - throw ParseError.raggedGrid + guard let w = width, !gridLines.isEmpty else { throw ParseError.missingGrid } + + let specialSymbols = parseSpecialsHeader(specialsHeader) + var rows: [[Cell]] = [] + for line in gridLines { + var row: [Cell] = [] + for ch in line { + row.append(try gridCell( + for: ch, + rebus: rebus, + standardSpecial: standardSpecial, + specialSymbols: specialSymbols + )) } - width = row.count rows.append(row) } - guard let w = width, !rows.isEmpty else { throw ParseError.missingGrid } return (rows, w, rows.count) } private static func gridCell( for ch: Character, - rebus: [Character: String] + rebus: [Character: String], + standardSpecial: Puzzle.Special?, + specialSymbols: [Character: Puzzle.Special] ) throws -> Cell { // Blocks: '#' is a normal block; '_' marks a non-existing cell on the // edge of an irregularly-shaped grid. Both render as non-playable. if ch == "#" || ch == "_" { return .block } + if let special = specialSymbols[ch] { + return .open(solution: nil, acceptedSolutions: [], special: special) + } // '.' is an open cell with no known solution. if ch == "." { - return .open(solution: nil, acceptedSolutions: [], isSpecial: false) + return .open(solution: nil, acceptedSolutions: [], special: nil) } - // Per the .xd spec, lowercase letters always indicate a special cell - // (circled or shaded — the kind is in the `Special:` header). They - // may *also* appear in the Rebus header, in which case the solution - // is the rebus expansion; otherwise it's the uppercased letter. - let isLowercaseLetter = ch.isLetter && ch.isLowercase + let lowercaseSpecial = ch.isLetter && ch.isLowercase ? standardSpecial : nil if let expansion = rebus[ch] { - return .open(solution: expansion.uppercased(), acceptedSolutions: [], isSpecial: isLowercaseLetter) + return .open(solution: expansion.uppercased(), acceptedSolutions: [], special: lowercaseSpecial) } if ch.isLetter { - return .open(solution: String(ch).uppercased(), acceptedSolutions: [], isSpecial: isLowercaseLetter) + return .open(solution: String(ch).uppercased(), acceptedSolutions: [], special: lowercaseSpecial) } throw ParseError.unknownGridCharacter(ch) } @@ -434,7 +489,7 @@ struct XD: Sendable { for r in cells.indices { for c in cells[r].indices { - guard case .open(let solution, let acceptedSolutions, let isSpecial) = cells[r][c] else { continue } + guard case .open(let solution, let acceptedSolutions, let special) = cells[r][c] else { continue } let position = Position(row: r, col: c) let effectiveSolution = positionsOutsideExactCellAnswers.contains(position) ? nil : solution var merged = acceptedSolutions @@ -457,7 +512,7 @@ struct XD: Sendable { merged.formUnion(accepted) } if effectiveSolution != solution || merged != acceptedSolutions { - cells[r][c] = .open(solution: effectiveSolution, acceptedSolutions: merged, isSpecial: isSpecial) + cells[r][c] = .open(solution: effectiveSolution, acceptedSolutions: merged, special: special) } } } @@ -465,6 +520,122 @@ struct XD: Sendable { return cells } + private static func applyClueAnswers( + cells: [[Cell]], + across: [Clue], + down: [Clue] + ) throws -> [[Cell]] { + var cells = cells + for clue in across { + try applyClueAnswer(clue, direction: .across, cells: &cells) + } + for clue in down { + try applyClueAnswer(clue, direction: .down, cells: &cells) + } + + for r in cells.indices { + for c in cells[r].indices { + if case .open(nil, _, _) = cells[r][c] { + throw ParseError.missingInferredSolution(row: r, col: c) + } + } + } + return cells + } + + private static func applyClueAnswer( + _ clue: Clue, + direction: Direction, + cells: inout [[Cell]] + ) throws { + guard let answer = clue.answer, + let word = wordCells(forClueNumber: clue.number, direction: direction, cells: cells) + else { return } + guard wordNeedsAnswerProjection(word, cells: cells) else { return } + + let clueID = "\(direction == .across ? "A" : "D")\(clue.number)" + let segmentations = segment(answer: answer, over: word, cells: cells, maxResults: 2) + guard !segmentations.isEmpty else { + throw ParseError.clueAnswerMismatch(clueID) + } + guard segmentations.count == 1, let segments = segmentations.first else { + throw ParseError.ambiguousClueAnswer(clueID) + } + + for (position, segment) in zip(word, segments) { + guard case .open(let solution, let acceptedSolutions, let special) = cells[position.row][position.col] else { continue } + let normalizedSegment = normalizedAnswer(segment) + if let solution { + guard normalizedAnswer(solution) == normalizedSegment else { + throw ParseError.clueAnswerMismatch(clueID) + } + } else { + cells[position.row][position.col] = .open( + solution: normalizedSegment, + acceptedSolutions: acceptedSolutions, + special: special + ) + } + } + } + + private static func wordNeedsAnswerProjection( + _ word: [(row: Int, col: Int)], + cells: [[Cell]] + ) -> Bool { + word.contains { position in + guard case .open(let solution, _, let special) = cells[position.row][position.col] else { + return false + } + return solution == nil || special != nil + } + } + + private static func segment( + answer: String, + over word: [(row: Int, col: Int)], + cells: [[Cell]], + maxResults: Int + ) -> [[String]] { + let chars = Array(answer) + var results: [[String]] = [] + + func recurse(cellIndex: Int, answerIndex: Int, current: [String]) { + guard results.count < maxResults else { return } + if cellIndex == word.count { + if answerIndex == chars.count { + results.append(current) + } + return + } + guard answerIndex < chars.count else { return } + + let position = word[cellIndex] + guard case .open(let solution, _, _) = cells[position.row][position.col] else { return } + let remainingCells = word.count - cellIndex - 1 + + if let solution { + let solutionLength = Array(solution).count + let endIndex = answerIndex + solutionLength + guard endIndex <= chars.count else { return } + let segment = String(chars[answerIndex..<endIndex]) + guard normalizedAnswer(segment) == normalizedAnswer(solution) else { return } + recurse(cellIndex: cellIndex + 1, answerIndex: endIndex, current: current + [segment]) + } else { + let maxLength = chars.count - answerIndex - remainingCells + guard maxLength >= 1 else { return } + for length in 1...maxLength { + let endIndex = answerIndex + length + let segment = String(chars[answerIndex..<endIndex]) + recurse(cellIndex: cellIndex + 1, answerIndex: endIndex, current: current + [segment]) + } + } + } + + recurse(cellIndex: 0, answerIndex: 0, current: []) + return results + } + private struct Position: Hashable { let row: Int let col: Int @@ -643,6 +814,35 @@ struct XD: Sendable { return clueNumber(forWord: word, cells: cells) } + private static func wordCells( + forClueNumber number: Int, + direction: Direction, + cells: [[Cell]] + ) -> [(row: Int, col: Int)]? { + var counter = 1 + for r in cells.indices { + for c in cells[r].indices { + guard isOpen(cells, r, c) else { continue } + let startsAcross = !isOpen(cells, r, c - 1) && isOpen(cells, r, c + 1) + let startsDown = !isOpen(cells, r - 1, c) && isOpen(cells, r + 1, c) + guard startsAcross || startsDown else { continue } + + if counter == number { + switch direction { + case .across where startsAcross: + return wordCells(fromRow: r, col: c, direction: direction, cells: cells) + case .down where startsDown: + return wordCells(fromRow: r, col: c, direction: direction, cells: cells) + default: + return nil + } + } + counter += 1 + } + } + return nil + } + private static func clueNumber(forWord word: [(row: Int, col: Int)], cells: [[Cell]]) -> Int? { guard let first = word.first else { return nil } return computedNumber(atRow: first.row, col: first.col, cells: cells) diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -89,9 +89,9 @@ enum NYTToXDConverter { } } - // -- Find special (shaded/circled) cells from the SVG -- + // -- Find special (shaded/circled) cells from NYT cell data -- - let (specialCells, specialKind) = specialCellInfo(body: body) + let specialCells = specialCellInfo(body: body) // -- Build grid lines -- @@ -104,15 +104,18 @@ enum NYTToXDConverter { line += "#" continue } - let isSpecial = specialCells.contains(index) + if specialCells.circled.contains(index) { + line += "@" + continue + } + if specialCells.shaded.contains(index) { + line += "*" + continue + } if answer.count > 1 { - // Rebus: use the placeholder character. Lowercase marking - // only applies to single-letter cells per the .xd spec, so - // shading on a rebus cell would be lost here — no puzzle - // we've seen combines the two. line += String(rebusLookup[answer]!) } else { - line += isSpecial ? answer.lowercased() : answer + line += answer.uppercased() } } gridLines.append(line) @@ -203,8 +206,9 @@ enum NYTToXDConverter { metadata.append("Rebus: \(rebusStr)") } - if !specialCells.isEmpty, let specialKind { - metadata.append("Special: \(specialKind)") + let specialMappings = specialMappings(circled: specialCells.circled, shaded: specialCells.shaded) + if !specialMappings.isEmpty { + metadata.append("Specials: \(specialMappings)") } let relatives = buildRelativeGroups(clues: clues) @@ -413,67 +417,34 @@ enum NYTToXDConverter { return "\(label)\(direction == "Across" ? "A" : "D")" } - /// Walks the structured SVG tree under `body.SVG` to find cells that - /// carry a theme marker. The cells group is a `<g data-group="cells">` - /// whose children are per-cell `<g>` nodes in `body.cells` order — so the - /// child's array index is the cell index. Two markers are recognised: - /// - /// * Shaded cells — the cell's `<rect>` background has `fill="lightgray"`. - /// * Circled cells — the cell group contains a `<circle>` element. - /// - /// The `.xd` format only supports one `Special:` kind per puzzle. If a - /// grid mixes both (not seen in the wild yet), circles take precedence - /// since they're the more visually distinct marker. - private static func specialCellInfo(body: [String: Any]) -> (indices: Set<Int>, kind: String?) { - guard let svg = body["SVG"] as? [String: Any], - let topChildren = svg["children"] as? [[String: Any]] else { - return ([], nil) - } - - let cellsGroup = topChildren.first { node in - guard (node["name"] as? String) == "g", - let attrs = node["attributes"] as? [[String: Any]] else { - return false - } - return attrs.contains { attr in - (attr["name"] as? String) == "data-group" - && (attr["value"] as? String) == "cells" - } - } - - guard let cellsGroup, - let cellGroups = cellsGroup["children"] as? [[String: Any]] else { - return ([], nil) - } - - var shaded: Set<Int> = [] + private static func specialCellInfo(body: [String: Any]) -> (circled: Set<Int>, shaded: Set<Int>) { + guard let cells = body["cells"] as? [Any] else { return ([], []) } var circled: Set<Int> = [] - for (index, cellGroup) in cellGroups.enumerated() { - guard let inner = cellGroup["children"] as? [[String: Any]] else { - continue - } - if let rect = inner.first, - (rect["name"] as? String) == "rect", - let attrs = rect["attributes"] as? [[String: Any]] { - let fill = attrs.first { - ($0["name"] as? String) == "fill" - }?["value"] as? String - if fill == "lightgray" { - shaded.insert(index) - } - } - if inner.contains(where: { ($0["name"] as? String) == "circle" }) { + var shaded: Set<Int> = [] + for (index, cell) in cells.enumerated() { + guard let dict = cell as? [String: Any] else { continue } + switch intValue(dict["type"]) { + case 2: circled.insert(index) + case 3: + shaded.insert(index) + default: + continue } } + return (circled, shaded) + } + + private static func specialMappings(circled: Set<Int>, shaded: Set<Int>) -> String { + var parts: [String] = [] if !circled.isEmpty { - return (circled.union(shaded), "circle") + parts.append("@=circle") } if !shaded.isEmpty { - return (shaded, "shaded") + parts.append("*=shaded") } - return ([], nil) + return parts.joined(separator: " ") } /// Extracts an Int from a JSON value that may be NSNumber, Int, or Double. diff --git a/Crossmate/Services/PUZToXDConverter.swift b/Crossmate/Services/PUZToXDConverter.swift @@ -96,7 +96,7 @@ enum PUZToXDConverter { metadata.append("Rebus: \(header)") } if !circledCells.isEmpty { - metadata.append("Special: circle") + metadata.append("Specials: @=circle") } let gridLines = (0..<height).map { row -> String in @@ -108,12 +108,15 @@ enum PUZToXDConverter { line += "#" continue } + if circledCells.contains(index) { + line += "@" + continue + } if let key = rebusKeys[index] { line.append(key) continue } - let letter = String(UnicodeScalar(byte)) - line += circledCells.contains(index) ? letter.lowercased() : letter.uppercased() + line += String(UnicodeScalar(byte)).uppercased() } return line } @@ -285,8 +288,11 @@ enum PUZToXDConverter { ) -> Set<Int> { guard let data = extensions["GEXT"], data.count >= cellCount else { return [] } var cells: Set<Int> = [] - for index in 0..<cellCount where data[index] & 0x80 != 0 { - cells.insert(index) + for offset in 0..<cellCount { + let dataIndex = data.index(data.startIndex, offsetBy: offset) + if data[dataIndex] & 0x80 != 0 { + cells.insert(offset) + } } return cells } @@ -298,10 +304,11 @@ enum PUZToXDConverter { guard let grid = extensions["GRBS"], grid.count >= cellCount else { return [:] } let table = parseRebusTable(extensions["RTBL"]) var rebus: [Int: String] = [:] - for index in 0..<cellCount { - let key = Int(grid[index]) + for offset in 0..<cellCount { + let gridIndex = grid.index(grid.startIndex, offsetBy: offset) + let key = Int(grid[gridIndex]) guard key > 0, let value = table[key] else { continue } - rebus[index] = value + rebus[offset] = value } return rebus } diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift @@ -7,7 +7,6 @@ struct CellView: View, Equatable { let isSelected: Bool let isHighlighted: Bool var isRelatedToFocus: Bool = false - let specialKind: Puzzle.Special? var remoteWordTint: Color? = nil var authorTint: Color? = nil @@ -21,7 +20,6 @@ struct CellView: View, Equatable { && lhs.isSelected == rhs.isSelected && lhs.isHighlighted == rhs.isHighlighted && lhs.isRelatedToFocus == rhs.isRelatedToFocus - && lhs.specialKind == rhs.specialKind && lhs.remoteWordTint == rhs.remoteWordTint && lhs.authorTint == rhs.authorTint } @@ -30,7 +28,7 @@ struct CellView: View, Equatable { ZStack(alignment: .topLeading) { background if !cell.isBlock { - if cell.isSpecial && specialKind == .circled { + if cell.special == .circled { Circle() .stroke(Color.black.opacity(0.55), lineWidth: 1) .padding(1.5) @@ -92,7 +90,7 @@ struct CellView: View, Equatable { } else { ZStack { Color.white - if cell.isSpecial && specialKind == .shaded { + if cell.special == .shaded { Color.black.opacity(0.22) } // Faint background tint identifying who entered this letter diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -39,7 +39,6 @@ struct GridView: View { isSelected: session.selectedRow == r && session.selectedCol == c, isHighlighted: currentWordCells.contains(pos), isRelatedToFocus: relatedCells.contains(pos), - specialKind: session.puzzle.specialKind, remoteWordTint: tintByCell[pos], authorTint: square.entry.isEmpty ? nil diff --git a/Puzzles/Bundled/Crossmate-0001.xd b/Puzzles/Bundled/Crossmate-0001.xd @@ -1,5 +1,5 @@ Title: Crossmake #1 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0002.xd b/Puzzles/Bundled/Crossmate-0002.xd @@ -1,5 +1,5 @@ Title: Crossmake #2 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0003.xd b/Puzzles/Bundled/Crossmate-0003.xd @@ -1,5 +1,5 @@ Title: Crossmake #3 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0004.xd b/Puzzles/Bundled/Crossmate-0004.xd @@ -1,5 +1,5 @@ Title: Crossmake #4 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0005.xd b/Puzzles/Bundled/Crossmate-0005.xd @@ -1,5 +1,5 @@ Title: Crossmake #5 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0006.xd b/Puzzles/Bundled/Crossmate-0006.xd @@ -1,5 +1,5 @@ Title: Crossmake #6 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0007.xd b/Puzzles/Bundled/Crossmate-0007.xd @@ -1,5 +1,5 @@ Title: Crossmake #7 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0008.xd b/Puzzles/Bundled/Crossmate-0008.xd @@ -1,5 +1,5 @@ Title: Crossmake #8 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0009.xd b/Puzzles/Bundled/Crossmate-0009.xd @@ -1,5 +1,5 @@ Title: Crossmake #9 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0010.xd b/Puzzles/Bundled/Crossmate-0010.xd @@ -1,5 +1,5 @@ Title: Crossmake #10 -CmVer: 2 +CmVer: 3 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Debug/garden.xd b/Puzzles/Debug/garden.xd @@ -1,5 +1,5 @@ Title: Garden Party -CmVer: 2 +CmVer: 3 Author: Crossmate Copyright: Public domain test puzzle diff --git a/Puzzles/Debug/morning.xd b/Puzzles/Debug/morning.xd @@ -1,5 +1,5 @@ Title: Morning Routine -CmVer: 2 +CmVer: 3 Author: Crossmate Copyright: Public domain test puzzle diff --git a/Puzzles/Debug/sample.xd b/Puzzles/Debug/sample.xd @@ -1,5 +1,5 @@ Title: Crossmate Demo -CmVer: 2 +CmVer: 3 Author: Crossmate Copyright: Public domain test puzzle diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -18,7 +18,8 @@ struct NYTToXDConverterTests { formattedClueIndices: Set<Int> = [], clueTexts: [Int: String] = [:], letters: [String] = ["A", "B", "C", "D", "E", "F", "G", "H", "I"], - moreAnswersByCell: [Int: [String]] = [:] + moreAnswersByCell: [Int: [String]] = [:], + cellTypes: [Int: Int] = [:] ) throws -> Data { precondition(relatives.count == 6, "Expected 6 clues for a 3×3 open grid") precondition(letters.count == 9, "Expected 9 cell answers for a 3×3 open grid") @@ -50,6 +51,9 @@ struct NYTToXDConverterTests { } let cells = letters.enumerated().map { index, answer -> [String: Any] in var cell: [String: Any] = ["answer": answer] + if let type = cellTypes[index] { + cell["type"] = type + } if let moreAnswers = moreAnswersByCell[index] { cell["moreAnswers"] = ["valid": moreAnswers] } @@ -74,13 +78,18 @@ struct NYTToXDConverterTests { } private func header(_ name: String, in xd: String) -> String? { + headers(name, in: xd).first + } + + private func headers(_ name: String, in xd: String) -> [String] { let prefix = "\(name): " + var values: [String] = [] for line in xd.split(separator: "\n") { if line.hasPrefix(prefix) { - return String(line.dropFirst(prefix.count)) + values.append(String(line.dropFirst(prefix.count))) } } - return nil + return values } // MARK: - Header emission @@ -120,6 +129,53 @@ struct NYTToXDConverterTests { #expect(cell.accepts("BACK\\SLASH")) } + @Test("NYT type 2 cells emit circled specials") + func typeTwoCellsEmitCircledSpecials() throws { + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + cellTypes: [0: 2, 4: 2] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + + #expect(header("Specials", in: xd) == "@=circle") + #expect(xd.contains("\n@BC\nD@F\nGHI\n")) + let puzzle = Puzzle(xd: try XD.parse(xd)) + #expect(puzzle.cells[0][0].special == .circled) + #expect(puzzle.cells[1][1].special == .circled) + #expect(puzzle.cells[0][0].solution == "A") + #expect(puzzle.cells[1][1].solution == "E") + } + + @Test("NYT type 3 cells emit shaded specials") + func typeThreeCellsEmitShadedSpecials() throws { + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + cellTypes: [0: 3, 4: 3] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + + #expect(header("Specials", in: xd) == "*=shaded") + #expect(xd.contains("\n*BC\nD*F\nGHI\n")) + let puzzle = Puzzle(xd: try XD.parse(xd)) + #expect(puzzle.cells[0][0].special == .shaded) + #expect(puzzle.cells[1][1].special == .shaded) + } + + @Test("Mixed type 2 and type 3 cells emit separate special masks") + func mixedSpecialTypesEmitSeparateMasks() throws { + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + cellTypes: [0: 2, 4: 3] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + + #expect(headers("Specials", in: xd) == ["@=circle *=shaded"]) + #expect(xd.contains("\n@BC\nD*F\nGHI\n")) + let puzzle = Puzzle(xd: try XD.parse(xd)) + #expect(puzzle.cells[0][0].special == .circled) + #expect(puzzle.cells[1][1].special == .shaded) + } + @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 @@ -109,6 +109,37 @@ struct PUZToXDConverterTests { #expect(puzzle.title == "Mini PUZ") } + @Test("Circled cells emit explicit special mask") + func circledCellsEmitExplicitSpecialMask() throws { + let data = try puzData( + width: 3, + height: 3, + solution: "ABCDEFGHI", + title: "Circles", + author: "", + copyright: "", + clues: [ + "Across 1", + "Down 1", + "Down 2", + "Down 3", + "Across 4", + "Across 5" + ], + circledCells: [0, 4] + ) + + let source = try PUZToXDConverter.convert(puzData: data) + #expect(source.contains("Specials: @=circle")) + #expect(source.contains("\n@BC\nD@F\nGHI\n")) + + let puzzle = Puzzle(xd: try XD.parse(source)) + #expect(puzzle.cells[0][0].special == .circled) + #expect(puzzle.cells[1][1].special == .circled) + #expect(puzzle.cells[0][0].solution == "A") + #expect(puzzle.cells[1][1].solution == "E") + } + private func puzData( width: UInt8, height: UInt8, @@ -117,7 +148,8 @@ struct PUZToXDConverterTests { author: String, copyright: String, clues: [String], - notes: String = "" + notes: String = "", + circledCells: Set<Int> = [] ) throws -> Data { let cellCount = Int(width) * Int(height) let solutionBytes = Array(solution.utf8) @@ -142,6 +174,19 @@ struct PUZToXDConverterTests { data.append(try cp1252Data(string)) data.append(0) } + if !circledCells.isEmpty { + var payload = Data(repeating: 0, count: cellCount) + for index in circledCells where index >= 0 && index < cellCount { + payload[index] = 0x80 + } + data.append(contentsOf: "GEXT".utf8) + data.append(UInt8(cellCount & 0xFF)) + data.append(UInt8((cellCount >> 8) & 0xFF)) + data.append(0) + data.append(0) + data.append(contentsOf: payload) + data.append(0) + } return data } diff --git a/Tests/Unit/XDAcceptTests.swift b/Tests/Unit/XDAcceptTests.swift @@ -39,6 +39,113 @@ struct XDAcceptTests { #expect(xd.cmVersion == 3) } + @Test("Special symbols parse per cell") + func specialSymbolsParsePerCell() throws { + let puzzle = Puzzle(xd: try XD.parse(""" + Title: Specials + CmVer: 3 + Specials: @=circle *=shaded + + + @B@ + D** + + + A1. Row 1 ~ ABC + A4. Row 2 ~ DEF + D1. Col 1 ~ AD + D2. Col 2 ~ BE + D3. Col 3 ~ CF + """)) + + #expect(puzzle.cells[0][0].special == .circled) + #expect(puzzle.cells[0][2].special == .circled) + #expect(puzzle.cells[1][1].special == .shaded) + #expect(puzzle.cells[1][2].special == .shaded) + #expect(puzzle.cells[0][1].special == nil) + #expect(puzzle.cells[0][0].solution == "A") + #expect(puzzle.cells[1][1].solution == "E") + } + + @Test("Conflicting inferred special symbols fail parsing") + func conflictingInferredSpecialSymbolsFailParsing() throws { + #expect(throws: XD.ParseError.self) { + try XD.parse(""" + Title: Conflicting Specials + CmVer: 3 + Specials: @=circle + + + @BC + DEF + + + A1. Row 1 ~ ABC + A4. Row 2 ~ DEF + D1. Col 1 ~ ZD + D2. Col 2 ~ BE + D3. Col 3 ~ CF + """) + } + } + + @Test("Unfilled special symbols fail parsing") + func unfilledSpecialSymbolsFailParsing() throws { + #expect(throws: XD.ParseError.self) { + try XD.parse(""" + Title: Unfilled Specials + CmVer: 3 + Specials: @=circle + + + @ + + + """) + } + } + + @Test("Ambiguous inferred special symbols fail parsing") + func ambiguousInferredSpecialSymbolsFailParsing() throws { + #expect(throws: XD.ParseError.self) { + try XD.parse(""" + Title: Ambiguous Specials + CmVer: 3 + Specials: @=circle + + + @B@ + + + A1. Row 1 ~ ABBBC + D1. Col 1 ~ A + D2. Col 2 ~ B + D3. Col 3 ~ C + """) + } + } + + @Test("Standard XD Special header marks lowercase cells") + func standardXDSpecialHeaderMarksLowercaseCells() throws { + let puzzle = Puzzle(xd: try XD.parse(""" + Title: Standard Special + CmVer: 3 + Special: circle + + + aBC + + + A1. Row ~ ABC + D1. Col 1 ~ A + D2. Col 2 ~ B + D3. Col 3 ~ C + """)) + + #expect(puzzle.cells[0][0].special == .circled) + #expect(puzzle.cells[0][1].special == nil) + } + @Test("Clue metadata is parsed generically") func clueMetadataParsesGenerically() throws { let source = """