crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }