GridStateMerger.swift (3611B)
1 import Foundation 2 3 /// Reduces every `(author, device)` `MovesValue` for a single game into one 4 /// `GridState`. Per-cell last-writer-wins on wall-clock `updatedAt`; ties are 5 /// broken first by the writing user's `authorID` (lex-min wins), then by 6 /// `deviceID`, so the output is deterministic regardless of input order. The 7 /// merged `GridCell.authorID` is the *cell-level* preserved author from the 8 /// winning entry — not the parent record's author — so reveal-of-correct and 9 /// same-letter rewrites can hand off authorship without losing it. 10 enum GridStateMerger { 11 12 /// `notAfter` caps which writes are eligible: cells stamped later than the 13 /// cutoff are ignored. Used to freeze a completed game at its winning 14 /// instant — edits that predate the latch still merge in (a collaborator's 15 /// letter typed just before the win that reaches us afterward), but nothing 16 /// stamped after completion can reopen or rewrite the finished grid. 17 static func merge(_ moves: [MovesValue], notAfter cutoff: Date? = nil) -> GridState { 18 var grid: GridState = [:] 19 for (position, winner) in winners(moves, notAfter: cutoff) { 20 grid[position] = GridCell( 21 letter: winner.cell.letter, 22 mark: winner.cell.mark, 23 authorID: winner.cell.authorID 24 ) 25 } 26 return grid 27 } 28 29 /// Merge variant that preserves the *writer* (the iCloud user whose 30 /// `MovesValue` won LWW for each cell) and the raw `TimestampedCell`, 31 /// including `updatedAt`. Cells whose only writes are empty letters are 32 /// retained — SessionMonitor needs them to detect clears against a 33 /// before-snapshot. 34 static func mergeWithProvenance(_ moves: [MovesValue]) -> [GridPosition: Provenance] { 35 var result: [GridPosition: Provenance] = [:] 36 for (position, winner) in winners(moves) { 37 result[position] = Provenance( 38 cell: winner.cell, 39 writerAuthorID: winner.writerAuthorID 40 ) 41 } 42 return result 43 } 44 45 struct Provenance: Equatable { 46 var cell: TimestampedCell 47 var writerAuthorID: String 48 } 49 50 private static func winners(_ moves: [MovesValue], notAfter cutoff: Date? = nil) -> [GridPosition: Winner] { 51 var winners: [GridPosition: Winner] = [:] 52 for view in moves { 53 for (position, cell) in view.cells { 54 if let cutoff, cell.updatedAt > cutoff { continue } 55 let candidate = Winner( 56 cell: cell, 57 writerAuthorID: view.authorID, 58 deviceID: view.deviceID 59 ) 60 if let current = winners[position] { 61 if shouldReplace(current: current, with: candidate) { 62 winners[position] = candidate 63 } 64 } else { 65 winners[position] = candidate 66 } 67 } 68 } 69 return winners 70 } 71 72 private struct Winner { 73 var cell: TimestampedCell 74 var writerAuthorID: String 75 var deviceID: String 76 } 77 78 private static func shouldReplace(current: Winner, with candidate: Winner) -> Bool { 79 if candidate.cell.updatedAt != current.cell.updatedAt { 80 return candidate.cell.updatedAt > current.cell.updatedAt 81 } 82 if candidate.writerAuthorID != current.writerAuthorID { 83 return candidate.writerAuthorID < current.writerAuthorID 84 } 85 return candidate.deviceID < current.deviceID 86 } 87 }