crossmate

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

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 }