crossmate

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

commit 98a82ded559cc63e190ae12176cc0757be9ca45e
parent 52847a9fa10d2a16f5c1bfcc8f60ce0d83738ac9
Author: Michael Camilleri <[email protected]>
Date:   Sat, 18 Jul 2026 10:09:55 +0900

Clarify comments regarding puzzle upgrades

Diffstat:
MCrossmate/Services/NYTPuzzleUpgrader.swift | 39+++++++++++++++++++++++----------------
MTests/Unit/NYTPuzzleUpgraderTests.swift | 7++++---
2 files changed, 27 insertions(+), 19 deletions(-)

diff --git a/Crossmate/Services/NYTPuzzleUpgrader.swift b/Crossmate/Services/NYTPuzzleUpgrader.swift @@ -3,10 +3,11 @@ import Foundation /// Re-fetches a NYT puzzle and runs the current `NYTToXDConverter` over it. /// Used when `XD.currentConverterVersion` advances and an existing game's stored /// XD source predates a converter fix; the upgrader produces an XD string -/// that can replace the persisted source provided the puzzle's grid hasn't -/// changed underneath us. Only clue text, accepted variants, specials, and -/// metadata are allowed to differ — the structural verifier rejects anything -/// that would invalidate the player's in-progress moves. +/// that can replace the persisted source provided it is structurally the +/// same puzzle. The verifier guards puzzle identity, not fill validity: +/// geometry and single-letter solution diffs refuse the upgrade, while rebus +/// fills deliberately defer to the fresh conversion (see +/// `structuralDivergence`). enum NYTPuzzleUpgrader { typealias PuzzleFetch = @Sendable (Date) async throws -> String @@ -116,21 +117,26 @@ enum NYTPuzzleUpgrader { /// Two puzzles are structurally equivalent when their grids have the same /// dimensions and block layout, and the same solution at every open cell - /// that isn't a rebus. A rebus (multi-character) fill is the converter's - /// representational choice for one square — 'HITMISS' vs 'HIT/MISS', or any - /// re-representation of the same fill — and lives in that one cell, so a - /// change there never relocates a move; it's exempt from the comparison. - /// Single-letter cells are the puzzle's answer grid, so a change there is - /// treated as divergence. Special markers, accepted variants, clues, and - /// headers may all differ. + /// that isn't a rebus. The comparison guards puzzle *identity*, not fill + /// validity: dimension or block changes would corrupt persisted move + /// coordinates, and a single-letter solution diff can't plausibly come + /// from a converter fix (that conversion is trivial), so it signals a + /// different or edited puzzle and refuses the upgrade. Rebus cells are + /// the opposite case — see `isRebusFill`. Special markers, accepted + /// variants, clues, and headers may all differ. static func structurallyEquivalent(_ a: XD, _ b: XD) -> Bool { structuralDivergence(a, b) == nil } /// A rebus fill occupies a single square with a multi-character solution. - /// Such a fill is presentational — the converter's choice of how to spell an - /// either/or or multi-letter answer in one cell — so a change to it doesn't - /// move any square and isn't a structural divergence. + /// Rebus representation is exactly where converter fixes land, so a diff + /// here is presumed to be such a fix and defers to the fresh conversion — + /// treating it as divergence would keep the broken conversion in place + /// forever, the worse failure. The accepted cost: a genuine NYT content + /// edit at a rebus square is indistinguishable in this diff, so it also + /// replaces the answer under any existing fill, and a checked-right mark + /// on that square stays stale until re-checked (marks live in synced move + /// state, which `replacePuzzleSource` doesn't touch). private static func isRebusFill(_ solution: String?) -> Bool { (solution?.count ?? 0) > 1 } @@ -148,8 +154,9 @@ enum NYTPuzzleUpgrader { case (.block, .block): continue case let (.open(left, _, _), .open(right, _, _)): - // A rebus fill on either side is representational, not - // geometry — exempt it. Compare only single-letter cells. + // A rebus fill on either side is presumed to be a + // converter fix — exempt it (see `isRebusFill`). Compare + // only single-letter cells. if isRebusFill(left) || isRebusFill(right) { continue } if left != right { return "cell(r=\(row),c=\(col)) old=\(left ?? "nil") new=\(right ?? "nil")" diff --git a/Tests/Unit/NYTPuzzleUpgraderTests.swift b/Tests/Unit/NYTPuzzleUpgraderTests.swift @@ -130,9 +130,10 @@ struct NYTPuzzleUpgraderTests { @Test("Any rebus fill change is .upgraded — slash re-representation or otherwise") func rebusFillChangeIsUpgraded() async { - // A rebus fill lives in one square, so changing it never relocates a - // move — whether it's a slash re-representation or a wholly different - // fill. Neither blocks the upgrade. + // Rebus diffs are presumed to be converter fixes and defer to the + // fresh conversion — refusing them would keep a broken conversion in + // place forever. Even a wholly different fill replaces the source; + // a stale existing fill at that square is the accepted cost. for (old, new) in [("HITMISS", "HIT/MISS"), ("LOVE", "HATE")] { let outcome = await NYTPuzzleUpgrader.upgrade( date: Date(),