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 }