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 }