Moves.swift (7010B)
1 import Foundation 2 3 /// A row/column coordinate inside a puzzle grid. Also used as the dictionary 4 /// key for `GridState` and `MovesValue.cells`. 5 struct GridPosition: Hashable, Sendable, Codable { 6 let row: Int 7 let col: Int 8 } 9 10 /// Per-cell state in the merged grid — the result of `GridStateMerger.merge`. 11 /// `authorID` is the *preserved* cell author from the winning entry. 12 struct GridCell: Equatable, Sendable, Codable { 13 var letter: String 14 var mark: CellMark 15 var authorID: String? 16 } 17 18 /// The merged grid for a single game: only cells that have ever been touched 19 /// appear; untouched cells are absent rather than carrying empty values. 20 typealias GridState = [GridPosition: GridCell] 21 22 /// One device's contribution to a game: every cell this `(authorID, deviceID)` 23 /// pair has ever touched, with the wall-clock timestamp of each touch. Merging 24 /// across all `MovesValue`s for a game reconstructs the current grid via per-cell 25 /// last-writer-wins on `TimestampedCell.updatedAt`. 26 struct MovesValue: Equatable, Sendable { 27 let gameID: UUID 28 let authorID: String 29 let deviceID: String 30 var cells: [GridPosition: TimestampedCell] 31 var updatedAt: Date 32 } 33 34 /// A single cell touch within a `MovesValue`. The cell-level `authorID` is the 35 /// *preserved* author for the square — it can differ from the parent record's 36 /// `authorID` (which is the iCloud user who wrote this row) when a reveal-of- 37 /// correct or a same-letter rewrite preserves the original author of the 38 /// letter. The merger uses cell-level `authorID` when populating `GridCell`. 39 struct TimestampedCell: Equatable, Sendable { 40 var letter: String 41 var mark: CellMark 42 var updatedAt: Date 43 var authorID: String? 44 } 45 46 struct RealtimeCellEdit: Codable, Equatable, Sendable { 47 var gameID: UUID 48 var authorID: String 49 var deviceID: String 50 var row: Int 51 var col: Int 52 var letter: String 53 var mark: CellMark 54 var updatedAt: Date 55 var cellAuthorID: String? 56 } 57 58 enum MovesCodec { 59 /// Wire format for `MovesValue.cells`. Each entry's `authorID` is the 60 /// preserved cell-level author — distinct from the parent record's 61 /// authorID, which identifies the iCloud user who wrote the record. 62 /// 63 /// The mark is written as a single lossless `markCode` (`CellMark.code`). 64 /// `decodeMark` is the **sole** reader that still understands the legacy 65 /// `(markKind, checkedRight, checkedWrong)` triple that pre-cutover clients 66 /// wrote; it lets records already at rest decode cleanly. Once every device 67 /// has migrated, that fallback (and the legacy `CodingKeys`) is dead code. 68 struct Payload: Codable, Equatable { 69 struct Entry: Codable, Equatable { 70 let row: Int 71 let col: Int 72 let letter: String 73 let mark: CellMark 74 let updatedAt: Date 75 let authorID: String? 76 77 enum CodingKeys: String, CodingKey { 78 case row, col, letter, markCode, updatedAt, authorID 79 // Legacy keys — read-only, for records written before cutover. 80 case markKind, checkedRight, checkedWrong 81 } 82 83 init( 84 row: Int, 85 col: Int, 86 letter: String, 87 mark: CellMark, 88 updatedAt: Date, 89 authorID: String? 90 ) { 91 self.row = row 92 self.col = col 93 self.letter = letter 94 self.mark = mark 95 self.updatedAt = updatedAt 96 self.authorID = authorID 97 } 98 99 init(from decoder: Decoder) throws { 100 let c = try decoder.container(keyedBy: CodingKeys.self) 101 row = try c.decode(Int.self, forKey: .row) 102 col = try c.decode(Int.self, forKey: .col) 103 letter = try c.decode(String.self, forKey: .letter) 104 updatedAt = try c.decode(Date.self, forKey: .updatedAt) 105 authorID = try? c.decode(String.self, forKey: .authorID) 106 mark = try Self.decodeMark(from: c) 107 } 108 109 func encode(to encoder: Encoder) throws { 110 var c = encoder.container(keyedBy: CodingKeys.self) 111 try c.encode(row, forKey: .row) 112 try c.encode(col, forKey: .col) 113 try c.encode(letter, forKey: .letter) 114 try c.encode(mark.code, forKey: .markCode) 115 try c.encode(updatedAt, forKey: .updatedAt) 116 try c.encodeIfPresent(authorID, forKey: .authorID) 117 } 118 119 /// Prefers the single `markCode`; falls back to the legacy triple 120 /// for records written before the single-code cutover. This is the 121 /// only place the triple is still understood. 122 private static func decodeMark( 123 from c: KeyedDecodingContainer<CodingKeys> 124 ) throws -> CellMark { 125 if let code = try c.decodeIfPresent(Int16.self, forKey: .markCode) { 126 return CellMark(code: code) 127 } 128 let kind = (try? c.decode(Int16.self, forKey: .markKind)) ?? 0 129 let checkedWrong = (try? c.decode(Bool.self, forKey: .checkedWrong)) ?? false 130 let checkedRight = (try? c.decode(Bool.self, forKey: .checkedRight)) ?? false 131 let check: CheckResult? = checkedWrong ? .wrong : (checkedRight ? .right : nil) 132 switch kind { 133 case 1: return .pen(checked: check) 134 case 2: return .pencil(checked: check) 135 case 3: return .revealed 136 default: return .none 137 } 138 } 139 } 140 let entries: [Entry] 141 } 142 143 static func encode(_ cells: [GridPosition: TimestampedCell]) throws -> Data { 144 let entries = cells 145 .map { position, cell in 146 Payload.Entry( 147 row: position.row, 148 col: position.col, 149 letter: cell.letter, 150 mark: cell.mark, 151 updatedAt: cell.updatedAt, 152 authorID: cell.authorID 153 ) 154 } 155 .sorted { lhs, rhs in 156 lhs.row != rhs.row ? lhs.row < rhs.row : lhs.col < rhs.col 157 } 158 return try JSONEncoder().encode(Payload(entries: entries)) 159 } 160 161 static func decode(_ data: Data) throws -> [GridPosition: TimestampedCell] { 162 let payload = try JSONDecoder().decode(Payload.self, from: data) 163 var cells: [GridPosition: TimestampedCell] = [:] 164 for entry in payload.entries { 165 let position = GridPosition(row: entry.row, col: entry.col) 166 cells[position] = TimestampedCell( 167 letter: entry.letter, 168 mark: entry.mark, 169 updatedAt: entry.updatedAt, 170 authorID: entry.authorID 171 ) 172 } 173 return cells 174 } 175 }