SessionPushPlanner.swift (9043B)
1 import Foundation 2 3 /// Pure decision logic for session presence pushes, factored out of 4 /// `AppServices` so it can be unit-tested without standing up the full service 5 /// stack. `AppServices` still owns the state and the network call; these types 6 /// only decide *whether* and *what* to send. 7 8 /// Everything a sender-side push helper needs to know about a game in 9 /// one Core Data round-trip: the roster authors to notify (each with the 10 /// read/notified watermarks the pause path windows its per-recipient diff 11 /// on), the puzzle's display title, and the gating flags callers consult 12 /// before emitting. 13 struct PushRecipient: Sendable, Equatable { 14 let authorID: String 15 /// The recipient's read watermark (`Player.readThrough`): the latest 16 /// other-author move time they've actually seen. The session-end tally 17 /// windows on this — never the forward-dated presence lease — so a peer 18 /// who was "present" (leased) but backgrounded before our moves still 19 /// gets a summary for what they missed. `nil` when they've recorded no 20 /// read yet, which tallies their whole backlog. 21 let readThrough: Date? 22 /// Sender-local watermark: the latest authored move we've already told 23 /// this recipient about via a previous pause. The session-end tally 24 /// windows on the later of this and `readAt`, so we never re-report a 25 /// move the recipient has already seen *or* already been notified of. 26 /// `nil` when we've never paused to them. Never synced — see 27 /// `PlayerEntity.notifiedThrough`. 28 let notifiedThrough: Date? 29 /// The recipient's per-(account, game) push capability, read off their 30 /// Player record. `nil` when they haven't published one yet (older 31 /// build, or not-yet-synced) — such a recipient can't be addressed and 32 /// is dropped from the push. 33 let pushAddress: String? 34 } 35 36 enum SessionPushPlanner { 37 /// Builds the per-recipient addressees for a session-end push. A recipient 38 /// is addressed only when this session changed letters *they* haven't seen 39 /// — net `fills`, `clears`, or `reveals` since their watermark. A session 40 /// that only moved the cursor or ran checks reaches no one: the summary is 41 /// about grid letters, not presence (the begin push that used to carry the 42 /// "is solving" presence signal is gone). Recipients with no published push 43 /// capability are dropped, as are caught-up recipients and check-only 44 /// recipients. 45 /// 46 /// `journalEntries` is the sender's own local journal for the game (this 47 /// device's recorded moves, in seq order). Counts are derived from it — 48 /// not the merged grid — so the summary can name *gestures*: net letter 49 /// `fills` / `clears` plus the number of `check` / `reveal` gestures, each 50 /// windowed to entries newer than the recipient's `readThrough`. A check 51 /// gesture rides the body when it accompanies a letter change but never on 52 /// its own. The journal is this device's only, so a session run on another 53 /// of the author's devices is described by *that* device's own pause; 54 /// "eventual consistency is OK" covers the gap. 55 static func sessionEndAddressees( 56 recipients: [PushRecipient], 57 journalEntries: [JournalValue], 58 selfAuthorID: String?, 59 playerName: String, 60 puzzleTitle: String, 61 diagnostics: PushPayload.Diagnostics? = nil 62 ) -> [PushClient.Addressee] { 63 // Per-cell history in seq order, computed once and reused per recipient. 64 var history: [GridPosition: [JournalValue]] = [:] 65 for entry in journalEntries { 66 history[entry.position, default: []].append(entry) 67 } 68 for key in history.keys { 69 history[key]?.sort { $0.seq < $1.seq } 70 } 71 72 return recipients.compactMap { recipient -> PushClient.Addressee? in 73 guard let address = recipient.pushAddress else { return nil } 74 // Window on the later of what the recipient has actually read 75 // (`readThrough`) and what we've already notified them about 76 // (`notifiedThrough`). `readThrough` alone misses a recipient who 77 // stayed backgrounded across two of our pauses — it never advances, 78 // so the second pause would re-tally the same moves. The 79 // notified-through watermark closes that: a session that added 80 // nothing new tallies to zero ("stopped solving") rather than 81 // repeating. 82 let cutoff = [recipient.readThrough, recipient.notifiedThrough] 83 .compactMap { $0 } 84 .max() 85 let counts = tally( 86 history: history, 87 since: cutoff ?? .distantPast, 88 selfAuthorID: selfAuthorID 89 ) 90 // Only letter changes the recipient hasn't seen warrant a push. 91 // Checks alter marks, not letters, so a check-only session — or a 92 // pure cursor session that tallies to nothing — addresses no one. 93 guard counts.fills + counts.clears + counts.reveals > 0 else { return nil } 94 let body = PuzzleNotificationText.pauseBody( 95 playerName: playerName, 96 puzzleTitle: puzzleTitle, 97 fills: counts.fills, 98 clears: counts.clears, 99 checks: counts.checks, 100 reveals: counts.reveals 101 ) 102 // Stamp the exact cutoff this recipient's diff used. `nil` (no 103 // cursor and never notified) is preserved as nil — itself 104 // diagnostic, since it means the count covered the author's 105 // entire history. 106 var perRecipient = diagnostics 107 perRecipient?.recipientReadAt = cutoff 108 return PushClient.Addressee( 109 address: address, 110 body: body, 111 payload: PushPayload( 112 event: .pause( 113 fills: counts.fills, 114 clears: counts.clears, 115 checks: counts.checks, 116 reveals: counts.reveals 117 ), 118 // Carried so the receiver's NSE can name this sender in a 119 // coalesced multi-sender summary when it holds no private 120 // nickname for them. 121 playerName: playerName, 122 diagnostics: perRecipient 123 ) 124 ) 125 } 126 } 127 128 private struct SessionCounts { 129 var fills = 0 130 var clears = 0 131 var checks = 0 132 var reveals = 0 133 } 134 135 /// Tallies what the author did after `cutoff`, given the per-cell journal 136 /// history. Letter `fills` / `clears` are net-per-cell (the cell's final 137 /// letter vs. the letter the recipient last saw at `cutoff`); `checks` / 138 /// `reveals` are gesture counts (distinct batches). A reveal-touched cell is 139 /// attributed to `reveals`, never the letter counts. 140 /// 141 /// A check carries the cell's letter (so a checked pencil entry inks) but 142 /// preserves the letter's *original* author in `cellAuthorID`. So a fill is 143 /// only this author's when the winning letter's `cellAuthorID` is 144 /// `selfAuthorID`: checking a peer's letter mustn't read as this author 145 /// filling it, while checking one's own letter still counts. 146 private static func tally( 147 history: [GridPosition: [JournalValue]], 148 since cutoff: Date, 149 selfAuthorID: String? 150 ) -> SessionCounts { 151 var counts = SessionCounts() 152 var checkBatches: Set<String> = [] 153 var revealBatches: Set<String> = [] 154 155 for (_, entries) in history { 156 guard entries.contains(where: { $0.timestamp > cutoff }) else { continue } 157 158 for entry in entries where entry.timestamp > cutoff { 159 let key = entry.batchID?.uuidString ?? "seq-\(entry.seq)" 160 switch entry.kind { 161 case .check: checkBatches.insert(key) 162 case .reveal: revealBatches.insert(key) 163 default: break 164 } 165 } 166 167 // A revealed cell is locked, so its final letter is the reveal's — 168 // count it under reveals, not as a fill. 169 if entries.contains(where: { $0.timestamp > cutoff && $0.kind == .reveal }) { 170 continue 171 } 172 173 let before = entries.last(where: { $0.timestamp <= cutoff })?.state.letter ?? "" 174 let afterEntry = entries.last 175 let after = afterEntry?.state.letter ?? "" 176 let authoredBySelf = afterEntry?.state.cellAuthorID == selfAuthorID 177 if !after.isEmpty, after != before, authoredBySelf { 178 counts.fills += 1 179 } else if after.isEmpty, !before.isEmpty { 180 counts.clears += 1 181 } 182 } 183 184 counts.checks = checkBatches.count 185 counts.reveals = revealBatches.count 186 return counts 187 } 188 }