commit 98a82ded559cc63e190ae12176cc0757be9ca45e
parent 52847a9fa10d2a16f5c1bfcc8f60ce0d83738ac9
Author: Michael Camilleri <[email protected]>
Date: Sat, 18 Jul 2026 10:09:55 +0900
Clarify comments regarding puzzle upgrades
Diffstat:
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(),