crossmate

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

PeerChangeLedger.swift (3090B)


      1 import Foundation
      2 
      3 /// One recorded letter change for a single grid cell: the cell's letter as we
      4 /// last observed it, who wrote that letter, and *when the letter last changed*.
      5 /// Persisted device-locally (one `PeerChangeEntity` row per touched cell) and
      6 /// updated incrementally from the inbound `Moves` snapshots we receive.
      7 ///
      8 /// The point of `changedAt` is to be a **letter**-change time, not a touch
      9 /// time. The synced `Moves` snapshot bumps a cell's `updatedAt` on any touch —
     10 /// including a check, which re-stamps every filled cell while leaving the
     11 /// letter untouched. Keying "changed while you were away" off that timestamp
     12 /// makes a peer's check sweep light up the whole board on rejoin. Here we only
     13 /// advance `changedAt` when the letter actually differs from what we recorded,
     14 /// so a check never moves it.
     15 struct PeerChange: Equatable, Sendable {
     16     let position: GridPosition
     17     let letter: String
     18     let authorID: String?
     19     let changedAt: Date
     20 }
     21 
     22 /// Pure logic for maintaining the per-cell letter-change ledger. Kept separate
     23 /// from Core Data so it runs off already-merged moves and is unit-testable in
     24 /// isolation.
     25 enum PeerChangeLedger {
     26     /// The rows to upsert given the current merged grid and the letters already
     27     /// recorded: one `PeerChange` per cell whose letter differs from what we
     28     /// hold (or that we've never recorded). Cells whose letter is unchanged —
     29     /// the common case for an inbound check sweep — produce nothing.
     30     ///
     31     /// Attribution mirrors `RecentChanges`' old rule: the letter's preserved
     32     /// author (survives a check), falling back to whoever wrote the winning
     33     /// move when a cleared cell carries no preserved author.
     34     ///
     35     /// `seeding` is set until the game records that its first build completed.
     36     /// Legacy rows also prove an older app completed that build, but otherwise
     37     /// the state cannot be inferred from row count: an empty grid is a valid
     38     /// silent baseline. Every current cell in that first build is recorded at
     39     /// `.distantPast`, so pre-existing content does not surface as "changed
     40     /// while you were away". Genuine later changes carry the move's real
     41     /// `updatedAt`.
     42     static func upserts(
     43         current: [GridPosition: GridStateMerger.Provenance],
     44         recorded: [GridPosition: PeerChange],
     45         seeding: Bool
     46     ) -> [PeerChange] {
     47         var result: [PeerChange] = []
     48         for (position, provenance) in current {
     49             let letter = provenance.cell.letter
     50             if let existing = recorded[position], existing.letter == letter { continue }
     51             if recorded[position] == nil, letter.isEmpty { continue }
     52             let writer = provenance.cell.authorID ?? provenance.writerAuthorID
     53             result.append(
     54                 PeerChange(
     55                     position: position,
     56                     letter: letter,
     57                     authorID: writer,
     58                     changedAt: seeding ? .distantPast : provenance.cell.updatedAt
     59                 )
     60             )
     61         }
     62         return result
     63     }
     64 }