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 }