crossmate

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

commit 16bbcad048c21630c238862693219dc5d73f2103
parent cb8e7ee0e14c2f4cfcba8038bbc3ddd1fce7009a
Author: Michael Camilleri <[email protected]>
Date:   Thu,  7 May 2026 03:30:37 +0900

Fix XD accepted-answer rebus completion

Regeneration of the project files had been overlooked in prior commits and
tests were not running. After remedying this error, certain XD-related tests
were not passing.

This commit tightens XD accepted-answer handling for rebus cells whose clue
answer is represented by a single grid cell. When a clue’s declared answer
matches that cell’s solution, its ^Accept: metadata is now applied directly to
the cell, including normalized and escaped alternatives.

Completion counting now ignores cells without known solutions, which keeps
multi-cell rebus fixtures from requiring filler cells that are not part of the
declared answer while preserving normal grid completion behaviour.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Models/Game.swift | 12+++++++-----
MCrossmate/Models/XD.swift | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 117 insertions(+), 8 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; }; 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; }; 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; }; + 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */; }; 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; @@ -126,6 +127,7 @@ 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; }; 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcaster.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; + 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDAcceptTests.swift; sourceTree = "<group>"; }; 50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; }; 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBuffer.swift; sourceTree = "<group>"; }; 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLogTests.swift; sourceTree = "<group>"; }; @@ -237,6 +239,7 @@ C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */, 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, 2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */, + 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, ); name = Unit; @@ -499,6 +502,7 @@ BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, 6A0B4EF7A6E66D9689BF6790 /* SnapshotServiceTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, + 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift @@ -18,7 +18,7 @@ final class Game { init(puzzle: Puzzle) { self.puzzle = puzzle self.fillableCellCount = puzzle.cells.reduce(0) { count, row in - count + row.filter { !$0.isBlock }.count + count + row.filter { !$0.isBlock && $0.solution != nil }.count } self.squares = Array( repeating: Array(repeating: Square(), count: puzzle.width), @@ -140,9 +140,9 @@ final class Game { case solved } - /// `.incomplete` while any non-block cell is empty; once every cell has an - /// entry, `.solved` if every entry matches its solution (cells with no - /// known solution are treated as correct) and `.filledWithErrors` + /// `.incomplete` while any cell with a known solution is empty; once every + /// known-solution cell has an entry, `.solved` if every entry matches its + /// solution (cells with no known solution are ignored) and `.filledWithErrors` /// otherwise. var completionState: CompletionState { guard filledCellCount == fillableCellCount else { return .incomplete } @@ -157,7 +157,7 @@ final class Game { for r in 0..<puzzle.height { for c in 0..<puzzle.width { let cell = puzzle.cells[r][c] - guard !cell.isBlock else { continue } + guard !cell.isBlock, cell.solution != nil else { continue } let entry = squares[r][c].entry if !entry.isEmpty { filledCellCount += 1 } if isWrongEntry(entry, for: cell) { wrongCellCount += 1 } @@ -166,6 +166,8 @@ final class Game { } private func noteEntryChange(from oldEntry: String, to newEntry: String, for cell: Puzzle.Cell) { + guard cell.solution != nil else { return } + if oldEntry.isEmpty, !newEntry.isEmpty { filledCellCount += 1 } else if !oldEntry.isEmpty, newEntry.isEmpty { diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -413,10 +413,17 @@ struct XD: Sendable { var cells = cells let acrossByNumber = Dictionary(uniqueKeysWithValues: across.map { ($0.number, $0) }) let downByNumber = Dictionary(uniqueKeysWithValues: down.map { ($0.number, $0) }) + let positionsOutsideExactCellAnswers = positionsOutsideExactCellAnswers( + cells: cells, + across: acrossByNumber, + down: downByNumber + ) 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 } + let position = Position(row: r, col: c) + let effectiveSolution = positionsOutsideExactCellAnswers.contains(position) ? nil : solution var merged = acceptedSolutions if let accepted = acceptedCellValues( atRow: r, @@ -436,8 +443,8 @@ struct XD: Sendable { ) { merged.formUnion(accepted) } - if merged != acceptedSolutions { - cells[r][c] = .open(solution: solution, acceptedSolutions: merged, isSpecial: isSpecial) + if effectiveSolution != solution || merged != acceptedSolutions { + cells[r][c] = .open(solution: effectiveSolution, acceptedSolutions: merged, isSpecial: isSpecial) } } } @@ -445,6 +452,87 @@ struct XD: Sendable { return cells } + private struct Position: Hashable { + let row: Int + let col: Int + } + + private static func positionsOutsideExactCellAnswers( + cells: [[Cell]], + across: [Int: Clue], + down: [Int: Clue] + ) -> Set<Position> { + var positions: Set<Position> = [] + var seenWords: Set<WordKey> = [] + for r in cells.indices { + for c in cells[r].indices { + positions.formUnion( + positionsOutsideExactCellAnswer( + fromRow: r, + col: c, + direction: .across, + cells: cells, + cluesByNumber: across, + seenWords: &seenWords + ) + ) + positions.formUnion( + positionsOutsideExactCellAnswer( + fromRow: r, + col: c, + direction: .down, + cells: cells, + cluesByNumber: down, + seenWords: &seenWords + ) + ) + } + } + return positions + } + + private struct WordKey: Hashable { + let direction: Direction + let row: Int + let col: Int + } + + private static func positionsOutsideExactCellAnswer( + fromRow row: Int, + col: Int, + direction: Direction, + cells: [[Cell]], + cluesByNumber: [Int: Clue], + seenWords: inout Set<WordKey> + ) -> Set<Position> { + let word = wordCells(fromRow: row, col: col, direction: direction, cells: cells) + guard let first = word.first else { return [] } + let key = WordKey(direction: direction, row: first.row, col: first.col) + guard seenWords.insert(key).inserted, + word.count > 1, + let number = clueNumber(forWord: word, cells: cells), + let clue = cluesByNumber[number] + else { return [] } + + guard let answer = clue.answer else { return [] } + let solutions = word.compactMap { position -> String? in + guard case .open(let solution?, _, _) = cells[position.row][position.col] else { return nil } + return solution + } + guard solutions.count == word.count else { return [] } + + if normalizedAnswer(answer) == normalizedAnswer(solutions.joined()) { + return [] + } + + let matchingIndices = solutions.indices.filter { normalizedAnswer(answer) == normalizedAnswer(solutions[$0]) } + guard matchingIndices.count == 1, let matchingIndex = matchingIndices.first else { return [] } + + return Set(word.indices.compactMap { index in + index == matchingIndex ? nil : Position(row: word[index].row, col: word[index].col) + }) + } + private static func acceptedCellValues( atRow row: Int, col: Int, @@ -458,6 +546,12 @@ struct XD: Sendable { let clue = cluesByNumber[number], !clue.acceptedAnswers.isEmpty else { return nil } + if let solution = solution(atRow: row, col: col, cells: cells), + let answer = clue.answer, + normalizedAnswer(answer) == normalizedAnswer(solution) { + return Set(clue.acceptedAnswers) + } + if word.count == 1 { return Set(clue.acceptedAnswers) } @@ -481,6 +575,15 @@ struct XD: Sendable { return accepted } + private static func solution(atRow row: Int, col: Int, cells: [[Cell]]) -> String? { + guard case .open(let solution?, _, _) = cells[row][col] else { return nil } + return solution + } + + private static func normalizedAnswer(_ value: String) -> String { + value.precomposedStringWithCanonicalMapping.uppercased() + } + private static func segmentAcceptedAnswer( _ acceptedAnswer: String, canonicalSegments: [String] @@ -510,7 +613,7 @@ struct XD: Sendable { return nil } - private enum Direction { + private enum Direction: Hashable { case across case down