crossmate

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

NYTPuzzleUpgrader.swift (7679B)


      1 import Foundation
      2 
      3 /// Re-fetches a NYT puzzle and runs the current `NYTToXDConverter` over it.
      4 /// Used when `XD.currentConverterVersion` advances and an existing game's stored
      5 /// XD source predates a converter fix; the upgrader produces an XD string
      6 /// that can replace the persisted source provided it is structurally the
      7 /// same puzzle. The verifier guards puzzle identity, not fill validity:
      8 /// geometry and single-letter solution diffs refuse the upgrade, while rebus
      9 /// fills deliberately defer to the fresh conversion (see
     10 /// `structuralDivergence`).
     11 enum NYTPuzzleUpgrader {
     12     typealias PuzzleFetch = @Sendable (Date) async throws -> String
     13 
     14     enum Outcome {
     15         /// The fetched + re-converted XD is structurally identical to the old
     16         /// one; the persisted source can be replaced.
     17         case upgraded(newSource: String)
     18         /// The new XD's grid differs from the persisted one. Per the upgrade
     19         /// policy, the caller should advance the kept source's converter-version
     20         /// header but keep the old grid so the player's moves stay valid.
     21         /// `reason` describes the first divergence found so diagnostics can
     22         /// pinpoint the cell.
     23         case mismatched(reason: String)
     24         /// The fetch or a parse step failed. Caller should leave both the
     25         /// source and its converter version untouched so the upgrade is retried
     26         /// on the next launch.
     27         case failed(Error)
     28     }
     29 
     30     static func upgrade(
     31         date: Date,
     32         currentSource: String,
     33         fetch: PuzzleFetch
     34     ) async -> Outcome {
     35         let newSource: String
     36         do {
     37             newSource = try await fetch(date)
     38         } catch {
     39             return .failed(error)
     40         }
     41 
     42         let oldXD: XD
     43         let newXD: XD
     44         do {
     45             oldXD = try XD.parse(currentSource)
     46             newXD = try XD.parse(newSource)
     47         } catch {
     48             return .failed(error)
     49         }
     50 
     51         if let reason = structuralDivergence(oldXD, newXD) {
     52             return .mismatched(reason: reason)
     53         }
     54         return .upgraded(newSource: newSource)
     55     }
     56 
     57     /// Describes a deferred NYT re-conversion for an owned game whose stored
     58     /// `puzzleSource` predates `XD.currentConverterVersion`. Built up-front (before
     59     /// `loadGame`) so the caller can show an "Updating puzzle…" state during
     60     /// the network round-trip. The bundled-catalog upgrade in
     61     /// `GameStore.preparePuzzleForLoad` runs synchronously and doesn't need
     62     /// this.
     63     struct Plan: Sendable {
     64         let gameID: UUID
     65         let date: Date
     66         fileprivate let currentSource: String
     67     }
     68 
     69     /// Returns a plan when an opened game would benefit from a NYT re-
     70     /// conversion: CmVer mismatch, the local user owns the zone (so this
     71     /// device's write will sync to participants), the persisted XD identifies
     72     /// a NYT puzzle, and a publication date is present. Bundled puzzles and
     73     /// participant-side rows are skipped — the bundled-catalog path already
     74     /// handles the former, and only the owner should rewrite the canonical
     75     /// source.
     76     @MainActor
     77     static func plan(for id: UUID, store: GameStore) -> Plan? {
     78         guard let info = store.puzzleInfo(for: id),
     79               info.isOwned,
     80               let xd = try? XD.parse(info.source),
     81               xd.converterVersion != XD.currentConverterVersion,
     82               xd.publisher == "New York Times",
     83               let date = xd.date
     84         else { return nil }
     85         return Plan(gameID: info.gameID, date: date, currentSource: info.source)
     86     }
     87 
     88     /// Runs the upgrader and applies the result against `store`:
     89     /// `.upgraded` swaps in the new source via `replacePuzzleSource`;
     90     /// `.mismatched` advances the kept source's converter-version header via
     91     /// `stampConverterVersion` so `plan` stops re-fetching a permanently
     92     /// diverged puzzle every launch; `.failed` writes nothing, so the upgrade
     93     /// is re-attempted next time the game is opened (covers transient
     94     /// network / auth failures).
     95     @MainActor
     96     @discardableResult
     97     static func apply(
     98         plan: Plan,
     99         store: GameStore,
    100         fetch: PuzzleFetch
    101     ) async -> Outcome {
    102         let outcome = await upgrade(
    103             date: plan.date,
    104             currentSource: plan.currentSource,
    105             fetch: fetch
    106         )
    107         switch outcome {
    108         case .upgraded(let newSource):
    109             store.replacePuzzleSource(id: plan.gameID, with: newSource)
    110         case .mismatched:
    111             store.stampConverterVersion(for: plan.gameID)
    112         case .failed:
    113             break
    114         }
    115         return outcome
    116     }
    117 
    118     /// Two puzzles are structurally equivalent when their grids have the same
    119     /// dimensions and block layout, and the same solution at every open cell
    120     /// that isn't a rebus. The comparison guards puzzle *identity*, not fill
    121     /// validity: dimension or block changes would corrupt persisted move
    122     /// coordinates, and a single-letter solution diff can't plausibly come
    123     /// from a converter fix (that conversion is trivial), so it signals a
    124     /// different or edited puzzle and refuses the upgrade. Rebus cells are
    125     /// the opposite case — see `isRebusFill`. Special markers, accepted
    126     /// variants, clues, and headers may all differ.
    127     static func structurallyEquivalent(_ a: XD, _ b: XD) -> Bool {
    128         structuralDivergence(a, b) == nil
    129     }
    130 
    131     /// A rebus fill occupies a single square with a multi-character solution.
    132     /// Rebus representation is exactly where converter fixes land, so a diff
    133     /// here is presumed to be such a fix and defers to the fresh conversion —
    134     /// treating it as divergence would keep the broken conversion in place
    135     /// forever, the worse failure. The accepted cost: a genuine NYT content
    136     /// edit at a rebus square is indistinguishable in this diff, so it also
    137     /// replaces the answer under any existing fill, and a checked-right mark
    138     /// on that square stays stale until re-checked (marks live in synced move
    139     /// state, which `replacePuzzleSource` doesn't touch).
    140     private static func isRebusFill(_ solution: String?) -> Bool {
    141         (solution?.count ?? 0) > 1
    142     }
    143 
    144     /// Walks the grid in row-major order and returns a short description of
    145     /// the first cell that disagrees, or nil when the two are equivalent.
    146     /// Used to populate `.mismatched(reason:)` for diagnostic logging.
    147     static func structuralDivergence(_ a: XD, _ b: XD) -> String? {
    148         if a.width != b.width || a.height != b.height {
    149             return "dims old=\(a.width)x\(a.height) new=\(b.width)x\(b.height)"
    150         }
    151         for row in 0..<a.height {
    152             for col in 0..<a.width {
    153                 switch (a.cells[row][col], b.cells[row][col]) {
    154                 case (.block, .block):
    155                     continue
    156                 case let (.open(left, _, _), .open(right, _, _)):
    157                     // A rebus fill on either side is presumed to be a
    158                     // converter fix — exempt it (see `isRebusFill`). Compare
    159                     // only single-letter cells.
    160                     if isRebusFill(left) || isRebusFill(right) { continue }
    161                     if left != right {
    162                         return "cell(r=\(row),c=\(col)) old=\(left ?? "nil") new=\(right ?? "nil")"
    163                     }
    164                 case (.block, .open):
    165                     return "cell(r=\(row),c=\(col)) old=block new=open"
    166                 case (.open, .block):
    167                     return "cell(r=\(row),c=\(col)) old=open new=block"
    168                 }
    169             }
    170         }
    171         return nil
    172     }
    173 }