Moves.swift (9199B)
1 import Foundation 2 3 /// Per-cell state in the merged grid — the result of `GridStateMerger.merge`. 4 /// `authorID` is the *preserved* cell author from the winning entry. 5 struct GridCell: Equatable, Sendable, Codable { 6 var letter: String 7 var mark: CellMark 8 var authorID: String? 9 } 10 11 /// The merged grid for a single game: only cells that have ever been touched 12 /// appear; untouched cells are absent rather than carrying empty values. 13 typealias GridState = [GridPosition: GridCell] 14 15 /// One device's contribution to a game: every cell this `(authorID, deviceID)` 16 /// pair has ever touched, with its logical revision and display/event time. 17 /// Merging every `MovesValue` reconstructs the grid by per-cell revision. 18 struct MovesValue: Equatable, Sendable { 19 let gameID: UUID 20 let authorID: String 21 let deviceID: String 22 var cells: [GridPosition: TimestampedCell] 23 var updatedAt: Date 24 } 25 26 /// A single cell touch within a `MovesValue`. The cell-level `authorID` is the 27 /// *preserved* author for the square — it can differ from the parent record's 28 /// `authorID` (which is the iCloud user who wrote this row) when a reveal-of- 29 /// correct or a same-letter rewrite preserves the original author of the 30 /// letter. The merger uses cell-level `authorID` when populating `GridCell`. 31 struct TimestampedCell: Equatable, Sendable { 32 var letter: String 33 var mark: CellMark 34 var updatedAt: Date 35 var authorID: String? 36 /// Lamport-style per-game counter. Absent on records written by clients 37 /// before sync version 2; those timestamps are projected into the same 38 /// ordering space so mixed-version games remain best-effort playable. 39 var tick: Int64? 40 41 init( 42 letter: String, 43 mark: CellMark, 44 updatedAt: Date, 45 authorID: String? = nil, 46 tick: Int64? = nil 47 ) { 48 self.letter = letter 49 self.mark = mark 50 self.updatedAt = updatedAt 51 self.authorID = authorID 52 self.tick = tick.flatMap { $0 > 0 ? $0 : nil } 53 } 54 55 /// Value used to seed a modern writer's next logical tick. Milliseconds 56 /// preserve the ordering scale used by a legacy wall-clock write without 57 /// making modern-to-modern comparisons depend on either device's clock. 58 var logicalValue: Int64 { 59 if let tick { return tick } 60 let milliseconds = updatedAt.timeIntervalSince1970 * 1_000 61 guard milliseconds.isFinite else { return 0 } 62 if milliseconds >= Double(Int64.max) { return Int64.max } 63 if milliseconds <= 0 { return 0 } 64 return Int64(milliseconds.rounded(.down)) 65 } 66 67 /// Compares cell revisions. Two legacy cells retain their full Date 68 /// precision; any comparison involving a modern cell uses the shared 69 /// logical value. 70 func compareRevision(to other: TimestampedCell) -> ComparisonResult { 71 if tick == nil, other.tick == nil { 72 if updatedAt < other.updatedAt { return .orderedAscending } 73 if updatedAt > other.updatedAt { return .orderedDescending } 74 return .orderedSame 75 } 76 if logicalValue < other.logicalValue { return .orderedAscending } 77 if logicalValue > other.logicalValue { return .orderedDescending } 78 return .orderedSame 79 } 80 } 81 82 struct RealtimeCellEdit: Codable, Equatable, Sendable { 83 var gameID: UUID 84 var authorID: String 85 var deviceID: String 86 var row: Int 87 var col: Int 88 var letter: String 89 var mark: CellMark 90 var updatedAt: Date 91 var cellAuthorID: String? 92 var tick: Int64? = nil 93 } 94 95 enum MovesCodec { 96 /// Wire format for `MovesValue.cells`. Each entry's `authorID` is the 97 /// preserved cell-level author — distinct from the parent record's 98 /// authorID, which identifies the iCloud user who wrote the record. 99 /// 100 /// The mark is written as a single lossless `markCode` (`CellMark.code`). 101 /// `decodeMark` is the **sole** reader that still understands the legacy 102 /// `(markKind, checkedRight, checkedWrong)` triple that pre-cutover clients 103 /// wrote; it lets records already at rest decode cleanly. Once every device 104 /// has migrated, that fallback (and the legacy `CodingKeys`) is dead code. 105 struct Payload: Codable, Equatable { 106 struct Entry: Codable, Equatable { 107 let row: Int 108 let col: Int 109 let letter: String 110 let mark: CellMark 111 let updatedAt: Date 112 let authorID: String? 113 let tick: Int64? 114 115 enum CodingKeys: String, CodingKey { 116 case row, col, letter, markCode, updatedAt, authorID, tick 117 // Legacy keys — read-only, for records written before cutover. 118 case markKind, checkedRight, checkedWrong 119 } 120 121 init( 122 row: Int, 123 col: Int, 124 letter: String, 125 mark: CellMark, 126 updatedAt: Date, 127 authorID: String?, 128 tick: Int64? 129 ) { 130 self.row = row 131 self.col = col 132 self.letter = letter 133 self.mark = mark 134 self.updatedAt = updatedAt 135 self.authorID = authorID 136 self.tick = tick 137 } 138 139 init(from decoder: Decoder) throws { 140 let c = try decoder.container(keyedBy: CodingKeys.self) 141 row = try c.decode(Int.self, forKey: .row) 142 col = try c.decode(Int.self, forKey: .col) 143 letter = try c.decode(String.self, forKey: .letter) 144 updatedAt = try c.decode(Date.self, forKey: .updatedAt) 145 authorID = try? c.decode(String.self, forKey: .authorID) 146 tick = (try? c.decode(Int64.self, forKey: .tick)).flatMap { $0 > 0 ? $0 : nil } 147 mark = try Self.decodeMark(from: c) 148 } 149 150 func encode(to encoder: Encoder) throws { 151 var c = encoder.container(keyedBy: CodingKeys.self) 152 try c.encode(row, forKey: .row) 153 try c.encode(col, forKey: .col) 154 try c.encode(letter, forKey: .letter) 155 try c.encode(mark.code, forKey: .markCode) 156 try c.encode(updatedAt, forKey: .updatedAt) 157 try c.encodeIfPresent(authorID, forKey: .authorID) 158 try c.encodeIfPresent(tick, forKey: .tick) 159 } 160 161 /// Prefers the single `markCode`; falls back to the legacy triple 162 /// for records written before the single-code cutover. This is the 163 /// only place the triple is still understood. 164 private static func decodeMark( 165 from c: KeyedDecodingContainer<CodingKeys> 166 ) throws -> CellMark { 167 if let code = try c.decodeIfPresent(Int16.self, forKey: .markCode) { 168 return CellMark(code: code) 169 } 170 let kind = (try? c.decode(Int16.self, forKey: .markKind)) ?? 0 171 let checkedWrong = (try? c.decode(Bool.self, forKey: .checkedWrong)) ?? false 172 let checkedRight = (try? c.decode(Bool.self, forKey: .checkedRight)) ?? false 173 let check: CheckResult? = checkedWrong ? .wrong : (checkedRight ? .right : nil) 174 switch kind { 175 case 1: return .pen(checked: check) 176 case 2: return .pencil(checked: check) 177 case 3: return .revealed 178 default: return .none 179 } 180 } 181 } 182 let entries: [Entry] 183 } 184 185 static func encode(_ cells: [GridPosition: TimestampedCell]) throws -> Data { 186 let entries = cells 187 .map { position, cell in 188 Payload.Entry( 189 row: position.row, 190 col: position.col, 191 letter: cell.letter, 192 mark: cell.mark, 193 updatedAt: cell.updatedAt, 194 authorID: cell.authorID, 195 tick: cell.tick 196 ) 197 } 198 .sorted { lhs, rhs in 199 lhs.row != rhs.row ? lhs.row < rhs.row : lhs.col < rhs.col 200 } 201 return try JSONEncoder().encode(Payload(entries: entries)) 202 } 203 204 static func decode(_ data: Data) throws -> [GridPosition: TimestampedCell] { 205 let payload = try JSONDecoder().decode(Payload.self, from: data) 206 var cells: [GridPosition: TimestampedCell] = [:] 207 for entry in payload.entries { 208 let position = GridPosition(row: entry.row, col: entry.col) 209 // A remote payload's coordinates are attacker-controlled; entries 210 // that can't survive the sinks' Int16 conversions are dropped here 211 // so they never reach persistence or cache replay. 212 guard position.isInt16Representable else { continue } 213 cells[position] = TimestampedCell( 214 letter: entry.letter, 215 mark: entry.mark, 216 updatedAt: entry.updatedAt, 217 authorID: entry.authorID, 218 tick: entry.tick 219 ) 220 } 221 return cells 222 } 223 }