crossmate

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

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 }