crossmate

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

Moves.swift (7010B)


      1 import Foundation
      2 
      3 /// A row/column coordinate inside a puzzle grid. Also used as the dictionary
      4 /// key for `GridState` and `MovesValue.cells`.
      5 struct GridPosition: Hashable, Sendable, Codable {
      6     let row: Int
      7     let col: Int
      8 }
      9 
     10 /// Per-cell state in the merged grid — the result of `GridStateMerger.merge`.
     11 /// `authorID` is the *preserved* cell author from the winning entry.
     12 struct GridCell: Equatable, Sendable, Codable {
     13     var letter: String
     14     var mark: CellMark
     15     var authorID: String?
     16 }
     17 
     18 /// The merged grid for a single game: only cells that have ever been touched
     19 /// appear; untouched cells are absent rather than carrying empty values.
     20 typealias GridState = [GridPosition: GridCell]
     21 
     22 /// One device's contribution to a game: every cell this `(authorID, deviceID)`
     23 /// pair has ever touched, with the wall-clock timestamp of each touch. Merging
     24 /// across all `MovesValue`s for a game reconstructs the current grid via per-cell
     25 /// last-writer-wins on `TimestampedCell.updatedAt`.
     26 struct MovesValue: Equatable, Sendable {
     27     let gameID: UUID
     28     let authorID: String
     29     let deviceID: String
     30     var cells: [GridPosition: TimestampedCell]
     31     var updatedAt: Date
     32 }
     33 
     34 /// A single cell touch within a `MovesValue`. The cell-level `authorID` is the
     35 /// *preserved* author for the square — it can differ from the parent record's
     36 /// `authorID` (which is the iCloud user who wrote this row) when a reveal-of-
     37 /// correct or a same-letter rewrite preserves the original author of the
     38 /// letter. The merger uses cell-level `authorID` when populating `GridCell`.
     39 struct TimestampedCell: Equatable, Sendable {
     40     var letter: String
     41     var mark: CellMark
     42     var updatedAt: Date
     43     var authorID: String?
     44 }
     45 
     46 struct RealtimeCellEdit: Codable, Equatable, Sendable {
     47     var gameID: UUID
     48     var authorID: String
     49     var deviceID: String
     50     var row: Int
     51     var col: Int
     52     var letter: String
     53     var mark: CellMark
     54     var updatedAt: Date
     55     var cellAuthorID: String?
     56 }
     57 
     58 enum MovesCodec {
     59     /// Wire format for `MovesValue.cells`. Each entry's `authorID` is the
     60     /// preserved cell-level author — distinct from the parent record's
     61     /// authorID, which identifies the iCloud user who wrote the record.
     62     ///
     63     /// The mark is written as a single lossless `markCode` (`CellMark.code`).
     64     /// `decodeMark` is the **sole** reader that still understands the legacy
     65     /// `(markKind, checkedRight, checkedWrong)` triple that pre-cutover clients
     66     /// wrote; it lets records already at rest decode cleanly. Once every device
     67     /// has migrated, that fallback (and the legacy `CodingKeys`) is dead code.
     68     struct Payload: Codable, Equatable {
     69         struct Entry: Codable, Equatable {
     70             let row: Int
     71             let col: Int
     72             let letter: String
     73             let mark: CellMark
     74             let updatedAt: Date
     75             let authorID: String?
     76 
     77             enum CodingKeys: String, CodingKey {
     78                 case row, col, letter, markCode, updatedAt, authorID
     79                 // Legacy keys — read-only, for records written before cutover.
     80                 case markKind, checkedRight, checkedWrong
     81             }
     82 
     83             init(
     84                 row: Int,
     85                 col: Int,
     86                 letter: String,
     87                 mark: CellMark,
     88                 updatedAt: Date,
     89                 authorID: String?
     90             ) {
     91                 self.row = row
     92                 self.col = col
     93                 self.letter = letter
     94                 self.mark = mark
     95                 self.updatedAt = updatedAt
     96                 self.authorID = authorID
     97             }
     98 
     99             init(from decoder: Decoder) throws {
    100                 let c = try decoder.container(keyedBy: CodingKeys.self)
    101                 row = try c.decode(Int.self, forKey: .row)
    102                 col = try c.decode(Int.self, forKey: .col)
    103                 letter = try c.decode(String.self, forKey: .letter)
    104                 updatedAt = try c.decode(Date.self, forKey: .updatedAt)
    105                 authorID = try? c.decode(String.self, forKey: .authorID)
    106                 mark = try Self.decodeMark(from: c)
    107             }
    108 
    109             func encode(to encoder: Encoder) throws {
    110                 var c = encoder.container(keyedBy: CodingKeys.self)
    111                 try c.encode(row, forKey: .row)
    112                 try c.encode(col, forKey: .col)
    113                 try c.encode(letter, forKey: .letter)
    114                 try c.encode(mark.code, forKey: .markCode)
    115                 try c.encode(updatedAt, forKey: .updatedAt)
    116                 try c.encodeIfPresent(authorID, forKey: .authorID)
    117             }
    118 
    119             /// Prefers the single `markCode`; falls back to the legacy triple
    120             /// for records written before the single-code cutover. This is the
    121             /// only place the triple is still understood.
    122             private static func decodeMark(
    123                 from c: KeyedDecodingContainer<CodingKeys>
    124             ) throws -> CellMark {
    125                 if let code = try c.decodeIfPresent(Int16.self, forKey: .markCode) {
    126                     return CellMark(code: code)
    127                 }
    128                 let kind = (try? c.decode(Int16.self, forKey: .markKind)) ?? 0
    129                 let checkedWrong = (try? c.decode(Bool.self, forKey: .checkedWrong)) ?? false
    130                 let checkedRight = (try? c.decode(Bool.self, forKey: .checkedRight)) ?? false
    131                 let check: CheckResult? = checkedWrong ? .wrong : (checkedRight ? .right : nil)
    132                 switch kind {
    133                 case 1: return .pen(checked: check)
    134                 case 2: return .pencil(checked: check)
    135                 case 3: return .revealed
    136                 default: return .none
    137                 }
    138             }
    139         }
    140         let entries: [Entry]
    141     }
    142 
    143     static func encode(_ cells: [GridPosition: TimestampedCell]) throws -> Data {
    144         let entries = cells
    145             .map { position, cell in
    146                 Payload.Entry(
    147                     row: position.row,
    148                     col: position.col,
    149                     letter: cell.letter,
    150                     mark: cell.mark,
    151                     updatedAt: cell.updatedAt,
    152                     authorID: cell.authorID
    153                 )
    154             }
    155             .sorted { lhs, rhs in
    156                 lhs.row != rhs.row ? lhs.row < rhs.row : lhs.col < rhs.col
    157             }
    158         return try JSONEncoder().encode(Payload(entries: entries))
    159     }
    160 
    161     static func decode(_ data: Data) throws -> [GridPosition: TimestampedCell] {
    162         let payload = try JSONDecoder().decode(Payload.self, from: data)
    163         var cells: [GridPosition: TimestampedCell] = [:]
    164         for entry in payload.entries {
    165             let position = GridPosition(row: entry.row, col: entry.col)
    166             cells[position] = TimestampedCell(
    167                 letter: entry.letter,
    168                 mark: entry.mark,
    169                 updatedAt: entry.updatedAt,
    170                 authorID: entry.authorID
    171             )
    172         }
    173         return cells
    174     }
    175 }