crossmate

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

PeerChangeLedger.swift (2917B)


      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 when the ledger has no rows for this game yet (a first
     36     /// build, e.g. just after upgrade). Every current cell is then recorded at
     37     /// `.distantPast`, establishing a silent baseline so the first build never
     38     /// surfaces pre-existing content as "changed while you were away". Genuine
     39     /// changes after the seed carry the move's real `updatedAt`.
     40     static func upserts(
     41         current: [GridPosition: GridStateMerger.Provenance],
     42         recorded: [GridPosition: PeerChange],
     43         seeding: Bool
     44     ) -> [PeerChange] {
     45         var result: [PeerChange] = []
     46         for (position, provenance) in current {
     47             let letter = provenance.cell.letter
     48             if let existing = recorded[position], existing.letter == letter { continue }
     49             let writer = provenance.cell.authorID ?? provenance.writerAuthorID
     50             result.append(
     51                 PeerChange(
     52                     position: position,
     53                     letter: letter,
     54                     authorID: writer,
     55                     changedAt: seeding ? .distantPast : provenance.cell.updatedAt
     56                 )
     57             )
     58         }
     59         return result
     60     }
     61 }