crossmate

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

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 }