MoveLog.swift (4796B)
1 import Foundation 2 3 /// A single cell-state mutation in the move log. Append-only: a move is 4 /// never rewritten, only superseded by a later move at the same position. 5 struct Move: Equatable, Sendable { 6 let gameID: UUID 7 let lamport: Int64 8 let row: Int 9 let col: Int 10 let letter: String 11 let markKind: Int16 12 let checkedWrong: Bool 13 let authorID: String? 14 let createdAt: Date 15 } 16 17 /// A compacted grid state at a lamport boundary. Every move with 18 /// `lamport <= upToLamport` is already folded into `grid`, so those moves 19 /// can be deleted once the snapshot has been durably stored. 20 struct Snapshot: Equatable, Sendable { 21 let gameID: UUID 22 let upToLamport: Int64 23 let grid: GridState 24 let createdAt: Date 25 } 26 27 /// A touched cell in the grid. Cells the user has never entered a letter 28 /// or mark into are absent from `GridState` entirely. 29 struct GridCell: Equatable, Sendable, Codable { 30 var letter: String 31 var markKind: Int16 32 var checkedWrong: Bool 33 var authorID: String? 34 } 35 36 struct GridPosition: Hashable, Sendable, Codable { 37 let row: Int 38 let col: Int 39 } 40 41 typealias GridState = [GridPosition: GridCell] 42 43 enum MoveLog { 44 /// Deterministic replay: fold `moves` on top of an optional base 45 /// `snapshot` to produce the current grid state. Moves with 46 /// `lamport <= snapshot.upToLamport` are skipped — they are already 47 /// folded into the snapshot. Input order doesn't matter; the function 48 /// sorts by lamport internally. 49 static func replay(snapshot: Snapshot?, moves: [Move]) -> GridState { 50 var grid: GridState = snapshot?.grid ?? [:] 51 let cutoff: Int64 = snapshot?.upToLamport ?? 0 52 let ordered = moves 53 .filter { $0.lamport > cutoff } 54 .sorted { 55 if $0.lamport != $1.lamport { return $0.lamport < $1.lamport } 56 if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt } 57 return ($0.authorID ?? "") < ($1.authorID ?? "") 58 } 59 60 for move in ordered { 61 let position = GridPosition(row: move.row, col: move.col) 62 grid[position] = GridCell( 63 letter: move.letter, 64 markKind: move.markKind, 65 checkedWrong: move.checkedWrong, 66 authorID: move.authorID 67 ) 68 } 69 return grid 70 } 71 72 /// Returns the most recent snapshot by `upToLamport`, or `nil` if the 73 /// input is empty. Ties are broken by `createdAt` so two snapshots at 74 /// the same lamport don't produce a nondeterministic winner. 75 static func latestSnapshot(from snapshots: [Snapshot]) -> Snapshot? { 76 snapshots.max { lhs, rhs in 77 if lhs.upToLamport != rhs.upToLamport { 78 return lhs.upToLamport < rhs.upToLamport 79 } 80 return lhs.createdAt < rhs.createdAt 81 } 82 } 83 84 /// Wire format for `Snapshot.grid`. A flat array of entries keeps the 85 /// encoding `JSONEncoder`-friendly (dicts keyed by `GridPosition` 86 /// would need a custom encoder) and round-trips cleanly. 87 struct GridStatePayload: Codable, Equatable { 88 struct Entry: Codable, Equatable { 89 let row: Int 90 let col: Int 91 let letter: String 92 let markKind: Int16 93 let checkedWrong: Bool 94 let authorID: String? 95 } 96 let entries: [Entry] 97 } 98 99 static func encodeGridState(_ grid: GridState) throws -> Data { 100 let entries = grid 101 .map { position, cell in 102 GridStatePayload.Entry( 103 row: position.row, 104 col: position.col, 105 letter: cell.letter, 106 markKind: cell.markKind, 107 checkedWrong: cell.checkedWrong, 108 authorID: cell.authorID 109 ) 110 } 111 // Sort for determinism so identical grids encode to identical 112 // bytes — matters for tests and for diffing snapshots. 113 .sorted { lhs, rhs in 114 lhs.row != rhs.row ? lhs.row < rhs.row : lhs.col < rhs.col 115 } 116 return try JSONEncoder().encode(GridStatePayload(entries: entries)) 117 } 118 119 static func decodeGridState(_ data: Data) throws -> GridState { 120 let payload = try JSONDecoder().decode(GridStatePayload.self, from: data) 121 var grid: GridState = [:] 122 for entry in payload.entries { 123 let position = GridPosition(row: entry.row, col: entry.col) 124 grid[position] = GridCell( 125 letter: entry.letter, 126 markKind: entry.markKind, 127 checkedWrong: entry.checkedWrong, 128 authorID: entry.authorID 129 ) 130 } 131 return grid 132 } 133 }