crossmate

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

Moves.swift (9199B)


      1 import Foundation
      2 
      3 /// Per-cell state in the merged grid — the result of `GridStateMerger.merge`.
      4 /// `authorID` is the *preserved* cell author from the winning entry.
      5 struct GridCell: Equatable, Sendable, Codable {
      6     var letter: String
      7     var mark: CellMark
      8     var authorID: String?
      9 }
     10 
     11 /// The merged grid for a single game: only cells that have ever been touched
     12 /// appear; untouched cells are absent rather than carrying empty values.
     13 typealias GridState = [GridPosition: GridCell]
     14 
     15 /// One device's contribution to a game: every cell this `(authorID, deviceID)`
     16 /// pair has ever touched, with its logical revision and display/event time.
     17 /// Merging every `MovesValue` reconstructs the grid by per-cell revision.
     18 struct MovesValue: Equatable, Sendable {
     19     let gameID: UUID
     20     let authorID: String
     21     let deviceID: String
     22     var cells: [GridPosition: TimestampedCell]
     23     var updatedAt: Date
     24 }
     25 
     26 /// A single cell touch within a `MovesValue`. The cell-level `authorID` is the
     27 /// *preserved* author for the square — it can differ from the parent record's
     28 /// `authorID` (which is the iCloud user who wrote this row) when a reveal-of-
     29 /// correct or a same-letter rewrite preserves the original author of the
     30 /// letter. The merger uses cell-level `authorID` when populating `GridCell`.
     31 struct TimestampedCell: Equatable, Sendable {
     32     var letter: String
     33     var mark: CellMark
     34     var updatedAt: Date
     35     var authorID: String?
     36     /// Lamport-style per-game counter. Absent on records written by clients
     37     /// before sync version 2; those timestamps are projected into the same
     38     /// ordering space so mixed-version games remain best-effort playable.
     39     var tick: Int64?
     40 
     41     init(
     42         letter: String,
     43         mark: CellMark,
     44         updatedAt: Date,
     45         authorID: String? = nil,
     46         tick: Int64? = nil
     47     ) {
     48         self.letter = letter
     49         self.mark = mark
     50         self.updatedAt = updatedAt
     51         self.authorID = authorID
     52         self.tick = tick.flatMap { $0 > 0 ? $0 : nil }
     53     }
     54 
     55     /// Value used to seed a modern writer's next logical tick. Milliseconds
     56     /// preserve the ordering scale used by a legacy wall-clock write without
     57     /// making modern-to-modern comparisons depend on either device's clock.
     58     var logicalValue: Int64 {
     59         if let tick { return tick }
     60         let milliseconds = updatedAt.timeIntervalSince1970 * 1_000
     61         guard milliseconds.isFinite else { return 0 }
     62         if milliseconds >= Double(Int64.max) { return Int64.max }
     63         if milliseconds <= 0 { return 0 }
     64         return Int64(milliseconds.rounded(.down))
     65     }
     66 
     67     /// Compares cell revisions. Two legacy cells retain their full Date
     68     /// precision; any comparison involving a modern cell uses the shared
     69     /// logical value.
     70     func compareRevision(to other: TimestampedCell) -> ComparisonResult {
     71         if tick == nil, other.tick == nil {
     72             if updatedAt < other.updatedAt { return .orderedAscending }
     73             if updatedAt > other.updatedAt { return .orderedDescending }
     74             return .orderedSame
     75         }
     76         if logicalValue < other.logicalValue { return .orderedAscending }
     77         if logicalValue > other.logicalValue { return .orderedDescending }
     78         return .orderedSame
     79     }
     80 }
     81 
     82 struct RealtimeCellEdit: Codable, Equatable, Sendable {
     83     var gameID: UUID
     84     var authorID: String
     85     var deviceID: String
     86     var row: Int
     87     var col: Int
     88     var letter: String
     89     var mark: CellMark
     90     var updatedAt: Date
     91     var cellAuthorID: String?
     92     var tick: Int64? = nil
     93 }
     94 
     95 enum MovesCodec {
     96     /// Wire format for `MovesValue.cells`. Each entry's `authorID` is the
     97     /// preserved cell-level author — distinct from the parent record's
     98     /// authorID, which identifies the iCloud user who wrote the record.
     99     ///
    100     /// The mark is written as a single lossless `markCode` (`CellMark.code`).
    101     /// `decodeMark` is the **sole** reader that still understands the legacy
    102     /// `(markKind, checkedRight, checkedWrong)` triple that pre-cutover clients
    103     /// wrote; it lets records already at rest decode cleanly. Once every device
    104     /// has migrated, that fallback (and the legacy `CodingKeys`) is dead code.
    105     struct Payload: Codable, Equatable {
    106         struct Entry: Codable, Equatable {
    107             let row: Int
    108             let col: Int
    109             let letter: String
    110             let mark: CellMark
    111             let updatedAt: Date
    112             let authorID: String?
    113             let tick: Int64?
    114 
    115             enum CodingKeys: String, CodingKey {
    116                 case row, col, letter, markCode, updatedAt, authorID, tick
    117                 // Legacy keys — read-only, for records written before cutover.
    118                 case markKind, checkedRight, checkedWrong
    119             }
    120 
    121             init(
    122                 row: Int,
    123                 col: Int,
    124                 letter: String,
    125                 mark: CellMark,
    126                 updatedAt: Date,
    127                 authorID: String?,
    128                 tick: Int64?
    129             ) {
    130                 self.row = row
    131                 self.col = col
    132                 self.letter = letter
    133                 self.mark = mark
    134                 self.updatedAt = updatedAt
    135                 self.authorID = authorID
    136                 self.tick = tick
    137             }
    138 
    139             init(from decoder: Decoder) throws {
    140                 let c = try decoder.container(keyedBy: CodingKeys.self)
    141                 row = try c.decode(Int.self, forKey: .row)
    142                 col = try c.decode(Int.self, forKey: .col)
    143                 letter = try c.decode(String.self, forKey: .letter)
    144                 updatedAt = try c.decode(Date.self, forKey: .updatedAt)
    145                 authorID = try? c.decode(String.self, forKey: .authorID)
    146                 tick = (try? c.decode(Int64.self, forKey: .tick)).flatMap { $0 > 0 ? $0 : nil }
    147                 mark = try Self.decodeMark(from: c)
    148             }
    149 
    150             func encode(to encoder: Encoder) throws {
    151                 var c = encoder.container(keyedBy: CodingKeys.self)
    152                 try c.encode(row, forKey: .row)
    153                 try c.encode(col, forKey: .col)
    154                 try c.encode(letter, forKey: .letter)
    155                 try c.encode(mark.code, forKey: .markCode)
    156                 try c.encode(updatedAt, forKey: .updatedAt)
    157                 try c.encodeIfPresent(authorID, forKey: .authorID)
    158                 try c.encodeIfPresent(tick, forKey: .tick)
    159             }
    160 
    161             /// Prefers the single `markCode`; falls back to the legacy triple
    162             /// for records written before the single-code cutover. This is the
    163             /// only place the triple is still understood.
    164             private static func decodeMark(
    165                 from c: KeyedDecodingContainer<CodingKeys>
    166             ) throws -> CellMark {
    167                 if let code = try c.decodeIfPresent(Int16.self, forKey: .markCode) {
    168                     return CellMark(code: code)
    169                 }
    170                 let kind = (try? c.decode(Int16.self, forKey: .markKind)) ?? 0
    171                 let checkedWrong = (try? c.decode(Bool.self, forKey: .checkedWrong)) ?? false
    172                 let checkedRight = (try? c.decode(Bool.self, forKey: .checkedRight)) ?? false
    173                 let check: CheckResult? = checkedWrong ? .wrong : (checkedRight ? .right : nil)
    174                 switch kind {
    175                 case 1: return .pen(checked: check)
    176                 case 2: return .pencil(checked: check)
    177                 case 3: return .revealed
    178                 default: return .none
    179                 }
    180             }
    181         }
    182         let entries: [Entry]
    183     }
    184 
    185     static func encode(_ cells: [GridPosition: TimestampedCell]) throws -> Data {
    186         let entries = cells
    187             .map { position, cell in
    188                 Payload.Entry(
    189                     row: position.row,
    190                     col: position.col,
    191                     letter: cell.letter,
    192                     mark: cell.mark,
    193                     updatedAt: cell.updatedAt,
    194                     authorID: cell.authorID,
    195                     tick: cell.tick
    196                 )
    197             }
    198             .sorted { lhs, rhs in
    199                 lhs.row != rhs.row ? lhs.row < rhs.row : lhs.col < rhs.col
    200             }
    201         return try JSONEncoder().encode(Payload(entries: entries))
    202     }
    203 
    204     static func decode(_ data: Data) throws -> [GridPosition: TimestampedCell] {
    205         let payload = try JSONDecoder().decode(Payload.self, from: data)
    206         var cells: [GridPosition: TimestampedCell] = [:]
    207         for entry in payload.entries {
    208             let position = GridPosition(row: entry.row, col: entry.col)
    209             // A remote payload's coordinates are attacker-controlled; entries
    210             // that can't survive the sinks' Int16 conversions are dropped here
    211             // so they never reach persistence or cache replay.
    212             guard position.isInt16Representable else { continue }
    213             cells[position] = TimestampedCell(
    214                 letter: entry.letter,
    215                 mark: entry.mark,
    216                 updatedAt: entry.updatedAt,
    217                 authorID: entry.authorID,
    218                 tick: entry.tick
    219             )
    220         }
    221         return cells
    222     }
    223 }