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 }