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