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:
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