crossmate

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

NYTPuzzleUpgrader.swift (6011B)


      1 import Foundation
      2 
      3 /// Re-fetches a NYT puzzle and runs the current `NYTToXDConverter` over it.
      4 /// Used when `XD.currentCmVersion` 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 the puzzle's grid hasn't
      7 /// changed underneath us. Only clue text, accepted variants, specials, and
      8 /// metadata are allowed to differ — the structural verifier rejects anything
      9 /// that would invalidate the player's in-progress moves.
     10 enum NYTPuzzleUpgrader {
     11     typealias PuzzleFetch = @Sendable (Date) async throws -> String
     12 
     13     enum Outcome {
     14         /// The fetched + re-converted XD is structurally identical to the old
     15         /// one; the persisted source can be replaced.
     16         case upgraded(newSource: String)
     17         /// The new XD's grid differs from the persisted one. Per the upgrade
     18         /// policy, the caller should stamp the new CmVer but keep the old
     19         /// source so the player's moves stay valid. `reason` describes the
     20         /// first divergence found so diagnostics can pinpoint the cell.
     21         case mismatched(reason: String)
     22         /// The fetch or a parse step failed. Caller should leave both the
     23         /// source and the CmVer untouched so the upgrade is retried on the
     24         /// next launch.
     25         case failed(Error)
     26     }
     27 
     28     static func upgrade(
     29         date: Date,
     30         currentSource: String,
     31         fetch: PuzzleFetch
     32     ) async -> Outcome {
     33         let newSource: String
     34         do {
     35             newSource = try await fetch(date)
     36         } catch {
     37             return .failed(error)
     38         }
     39 
     40         let oldXD: XD
     41         let newXD: XD
     42         do {
     43             oldXD = try XD.parse(currentSource)
     44             newXD = try XD.parse(newSource)
     45         } catch {
     46             return .failed(error)
     47         }
     48 
     49         if let reason = structuralDivergence(oldXD, newXD) {
     50             return .mismatched(reason: reason)
     51         }
     52         return .upgraded(newSource: newSource)
     53     }
     54 
     55     /// Describes a deferred NYT re-conversion for an owned game whose stored
     56     /// `puzzleSource` predates `XD.currentCmVersion`. Built up-front (before
     57     /// `loadGame`) so the caller can show an "Updating puzzle…" state during
     58     /// the network round-trip. The bundled-catalog upgrade in
     59     /// `GameStore.preparePuzzleForLoad` runs synchronously and doesn't need
     60     /// this.
     61     struct Plan: Sendable {
     62         let gameID: UUID
     63         let date: Date
     64         fileprivate let currentSource: String
     65     }
     66 
     67     /// Returns a plan when an opened game would benefit from a NYT re-
     68     /// conversion: CmVer mismatch, the local user owns the zone (so this
     69     /// device's write will sync to participants), the persisted XD identifies
     70     /// a NYT puzzle, and a publication date is present. Bundled puzzles and
     71     /// participant-side rows are skipped — the bundled-catalog path already
     72     /// handles the former, and only the owner should rewrite the canonical
     73     /// source.
     74     @MainActor
     75     static func plan(for id: UUID, store: GameStore) -> Plan? {
     76         guard let info = store.puzzleInfo(for: id),
     77               info.isOwned,
     78               let xd = try? XD.parse(info.source),
     79               xd.cmVersion != XD.currentCmVersion,
     80               xd.publisher == "New York Times",
     81               let date = xd.date
     82         else { return nil }
     83         return Plan(gameID: info.gameID, date: date, currentSource: info.source)
     84     }
     85 
     86     /// Runs the upgrader and applies the result against `store`:
     87     /// `.upgraded` swaps in the new source via `replacePuzzleSource`;
     88     /// `.mismatched` stamps the new CmVer via `bumpPuzzleCmVersion` so we
     89     /// don't retry every launch; `.failed` writes nothing, so the upgrade
     90     /// is re-attempted next time the game is opened (covers transient
     91     /// network / auth failures).
     92     @MainActor
     93     @discardableResult
     94     static func apply(
     95         plan: Plan,
     96         store: GameStore,
     97         fetch: PuzzleFetch
     98     ) async -> Outcome {
     99         let outcome = await upgrade(
    100             date: plan.date,
    101             currentSource: plan.currentSource,
    102             fetch: fetch
    103         )
    104         switch outcome {
    105         case .upgraded(let newSource):
    106             store.replacePuzzleSource(id: plan.gameID, with: newSource)
    107         case .mismatched:
    108             store.bumpPuzzleCmVersion(for: plan.gameID)
    109         case .failed:
    110             break
    111         }
    112         return outcome
    113     }
    114 
    115     /// Two puzzles are structurally equivalent when their grids have the same
    116     /// dimensions, the same block layout, and the same solution at every open
    117     /// cell. Special markers, accepted variants, clues, and headers may all
    118     /// differ.
    119     static func structurallyEquivalent(_ a: XD, _ b: XD) -> Bool {
    120         structuralDivergence(a, b) == nil
    121     }
    122 
    123     /// Walks the grid in row-major order and returns a short description of
    124     /// the first cell that disagrees, or nil when the two are equivalent.
    125     /// Used to populate `.mismatched(reason:)` for diagnostic logging.
    126     static func structuralDivergence(_ a: XD, _ b: XD) -> String? {
    127         if a.width != b.width || a.height != b.height {
    128             return "dims old=\(a.width)x\(a.height) new=\(b.width)x\(b.height)"
    129         }
    130         for row in 0..<a.height {
    131             for col in 0..<a.width {
    132                 switch (a.cells[row][col], b.cells[row][col]) {
    133                 case (.block, .block):
    134                     continue
    135                 case let (.open(left, _, _), .open(right, _, _)):
    136                     if left != right {
    137                         return "cell(r=\(row),c=\(col)) old=\(left ?? "nil") new=\(right ?? "nil")"
    138                     }
    139                 case (.block, .open):
    140                     return "cell(r=\(row),c=\(col)) old=block new=open"
    141                 case (.open, .block):
    142                     return "cell(r=\(row),c=\(col)) old=open new=block"
    143                 }
    144             }
    145         }
    146         return nil
    147     }
    148 }