crossmate

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

PlayerSelectionPublisher.swift (11535B)


      1 import CoreData
      2 import Foundation
      3 
      4 /// Debounced writer for the local player's cursor track. Updates the
      5 /// `PlayerEntity` row for `(gameID, authorID)` with the new `selRow`/`selCol`/
      6 /// `selDir` and asks the sync engine to push the Player record. Cursor-track
      7 /// edits don't go through `MovesUpdater` because they aren't cell edits — they
      8 /// live on `PlayerEntity` with last-writer-wins semantics.
      9 actor PlayerSelectionPublisher {
     10     /// How long to wait before flushing the durable cursor write for `gameID`.
     11     /// Injected (rather than a fixed `Duration`) so the caller can lengthen it
     12     /// while an engagement room is live: the live websocket already carries the
     13     /// cursor to the peer, so the durable PlayerEntity/CloudKit write is only a
     14     /// post-disconnect fallback and can lag well behind. A longer interval there
     15     /// cuts the bulk of the Player-record traffic during co-solving, while the
     16     /// trailing flush still lands a resting position on each pause so the
     17     /// fallback stays usable. Defaults to a flat 500 ms so tests and
     18     /// non-collaborative contexts keep the prior single-interval behaviour.
     19     /// `@MainActor` so the provider can read main-actor state (the engagement
     20     /// liveness flag) directly; the `await` at the call site does the hop.
     21     private let debounceInterval: @MainActor @Sendable (UUID) async -> Duration
     22     private let persistence: PersistenceController
     23     /// Pushes the local Player record. The `Bool` is `drain`: `true` forces an
     24     /// immediate CKSyncEngine send (live cursor updates), `false` enqueues
     25     /// durably and lets the engine ship on its own schedule — used by the
     26     /// leave-path clear, which mustn't race the suspension budget.
     27     private let sink: @Sendable (UUID, String, Bool) async -> Void
     28     /// Returns `true` if any non-local peer currently has a fresh cursor track.
     29     /// When the predicate returns `false` the publisher still writes the local
     30     /// PlayerEntity row but skips the CloudKit enqueue — the cursor track is
     31     /// presence data nobody is watching for. Defaults to "always present" so
     32     /// callers that don't care about the gate (tests, contexts without peer
     33     /// info) get the pre-gate behaviour.
     34     private let peerPresent: @Sendable (UUID) async -> Bool
     35     /// Sleep primitive used by the debounce timer. Injected so tests can
     36     /// drive flushes deterministically instead of racing against wall-clock
     37     /// `Task.sleep` from the actor's own task queue. Mirrors the pattern in
     38     /// `MovesUpdater`.
     39     private let sleep: @Sendable (Duration) async throws -> Void
     40 
     41     private var pending: PlayerSelection?
     42     private var lastPublished: PlayerSelection?
     43     private var debounceTask: Task<Void, Never>?
     44     private var gameID: UUID?
     45     private var authorID: String?
     46     /// The local user's display name at session start. Used as a fallback
     47     /// when `write` has to insert a fresh `PlayerEntity` row before
     48     /// `PlayerNamePublisher` has fanned out — without it the row would have no
     49     /// name and the SyncEngine would refuse to build a CKRecord, dropping
     50     /// the cursor on the floor.
     51     private var fallbackName: String = ""
     52 
     53     init(
     54         debounceInterval: @escaping @MainActor @Sendable (UUID) async -> Duration = { _ in .milliseconds(500) },
     55         persistence: PersistenceController,
     56         sink: @escaping @Sendable (UUID, String, Bool) async -> Void,
     57         peerPresent: @escaping @Sendable (UUID) async -> Bool = { _ in true },
     58         sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
     59     ) {
     60         self.debounceInterval = debounceInterval
     61         self.persistence = persistence
     62         self.sink = sink
     63         self.peerPresent = peerPresent
     64         self.sleep = sleep
     65     }
     66 
     67     /// Starts publishing for a new puzzle session. Resets dedupe state so the
     68     /// first selection from the new session always flushes. `currentName` is
     69     /// the local user's display name at the time the puzzle was opened — used
     70     /// only when no PlayerEntity row exists yet for this (game, author).
     71     func begin(gameID: UUID, authorID: String, currentName: String) {
     72         self.gameID = gameID
     73         self.authorID = authorID
     74         fallbackName = currentName
     75         pending = nil
     76         lastPublished = nil
     77         debounceTask?.cancel()
     78         debounceTask = nil
     79     }
     80 
     81     /// Registers a new cursor track. Coalesces with any prior pending value
     82     /// and schedules a trailing-edge flush. Repeated identical tracks are
     83     /// dropped.
     84     func publish(_ selection: PlayerSelection) {
     85         guard gameID != nil, authorID != nil else { return }
     86         if pending == selection || (pending == nil && lastPublished == selection) {
     87             return
     88         }
     89         pending = selection
     90         scheduleDebounce()
     91     }
     92 
     93     /// Records a "no cursor track" — used on puzzle teardown so the peer's
     94     /// overlay disappears promptly instead of waiting for staleness. Awaits
     95     /// the flush so callers can sequence work that depends on the cleared
     96     /// state being visible to the sink (and so tests don't have to race a
     97     /// detached Task).
     98     func clear() async {
     99         guard gameID != nil, authorID != nil else { return }
    100         pending = nil
    101         debounceTask?.cancel()
    102         debounceTask = nil
    103         await flushClear()
    104     }
    105 
    106     /// Flushes any pending selection immediately and cancels the debounce.
    107     func flush() async {
    108         debounceTask?.cancel()
    109         debounceTask = nil
    110         await performFlush()
    111     }
    112 
    113     /// Publishes `selection` synchronously, skipping the trailing-edge
    114     /// debounce and discarding any pending debounce. Used by the
    115     /// puzzle-open path so the initial cursor track ships in the same
    116     /// CKSyncEngine drain as the read cursor and name-open enqueues
    117     /// instead of arriving ~500 ms later.
    118     func publishImmediately(_ selection: PlayerSelection) async {
    119         guard let gameID, let authorID else { return }
    120         if selection == lastPublished { return }
    121         debounceTask?.cancel()
    122         debounceTask = nil
    123         pending = nil
    124         lastPublished = selection
    125         await write(gameID: gameID, authorID: authorID, selection: selection)
    126         await sink(gameID, authorID, true)
    127     }
    128 
    129     private func scheduleDebounce() {
    130         debounceTask?.cancel()
    131         guard let gameID else { return }
    132         let resolveInterval = debounceInterval
    133         let sleep = self.sleep
    134         debounceTask = Task { [weak self] in
    135             let interval = await resolveInterval(gameID)
    136             if Task.isCancelled { return }
    137             try? await sleep(interval)
    138             if Task.isCancelled { return }
    139             await self?.debouncedFlush()
    140         }
    141     }
    142 
    143     private func debouncedFlush() async {
    144         debounceTask = nil
    145         await performFlush()
    146     }
    147 
    148     private func performFlush() async {
    149         guard let gameID, let authorID, let selection = pending else { return }
    150         if selection == lastPublished { return }
    151         // Always keep the local PlayerEntity row in sync — that's the
    152         // source of truth for the eventual outbound CKRecord, so the row
    153         // must reflect the latest selection whether or not we're shipping
    154         // it right now.
    155         await write(gameID: gameID, authorID: authorID, selection: selection)
    156         // If no peer is currently in the puzzle, hold the send. Keep
    157         // `pending` set (and don't advance `lastPublished`) so that a peer
    158         // joining later triggers a flush of the latest value.
    159         if !(await peerPresent(gameID)) { return }
    160         pending = nil
    161         lastPublished = selection
    162         await sink(gameID, authorID, true)
    163     }
    164 
    165     /// Called when peer-presence state may have changed (e.g. an inbound
    166     /// Player record updated a peer's cursor track). Re-evaluates the gate
    167     /// and ships any held `pending` selection. `gameIDs`, if provided, scopes
    168     /// the call to player records in those games — a no-op if the active
    169     /// session isn't one of them.
    170     ///
    171     /// Only resumes when no debounce is currently scheduled — i.e. the
    172     /// pending selection is being held by the gate, not by the normal
    173     /// trailing-edge debounce. Otherwise a co-solving partner whose own
    174     /// cursor updates pre-empt every one of our debounce windows would
    175     /// pressure us into one CloudKit write per partner-move instead of the
    176     /// 500 ms-throttled cadence.
    177     func peerPresenceMayHaveChanged(gameIDs: Set<UUID>? = nil) async {
    178         guard let gameID, pending != nil, debounceTask == nil else { return }
    179         if let gameIDs, !gameIDs.contains(gameID) { return }
    180         await performFlush()
    181     }
    182 
    183     private func flushClear() async {
    184         guard let gameID, let authorID else { return }
    185         if lastPublished == nil { return }
    186         lastPublished = nil
    187         await write(gameID: gameID, authorID: authorID, selection: nil)
    188         // Leave-path clear: enqueue durably but don't force a drain that would
    189         // race the suspension budget. The peer's live overlay is already gone
    190         // via the socket tear-down; this is the durable cleanup.
    191         await sink(gameID, authorID, false)
    192     }
    193 
    194     private func write(
    195         gameID: UUID,
    196         authorID: String,
    197         selection: PlayerSelection?
    198     ) async {
    199         let context = persistence.container.newBackgroundContext()
    200         context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
    201         let now = Date()
    202         let fallbackName = self.fallbackName
    203         context.performAndWait {
    204             let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
    205             let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    206             req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
    207             req.fetchLimit = 1
    208             let entity: PlayerEntity
    209             if let existing = try? context.fetch(req).first {
    210                 entity = existing
    211                 // Don't overwrite a name that PlayerNamePublisher has set; only
    212                 // backfill if it's missing or empty so the outgoing record is
    213                 // never `name=""`.
    214                 if (entity.name ?? "").isEmpty, !fallbackName.isEmpty {
    215                     entity.name = fallbackName
    216                 }
    217             } else {
    218                 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    219                 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    220                 gameReq.fetchLimit = 1
    221                 guard let game = try? context.fetch(gameReq).first else { return }
    222                 entity = PlayerEntity(context: context)
    223                 entity.game = game
    224                 entity.ckRecordName = recordName
    225                 entity.authorID = authorID
    226                 if !fallbackName.isEmpty {
    227                     entity.name = fallbackName
    228                 }
    229             }
    230             entity.updatedAt = now
    231             if let selection {
    232                 entity.selRow = NSNumber(value: Int64(selection.row))
    233                 entity.selCol = NSNumber(value: Int64(selection.col))
    234                 entity.selDir = NSNumber(value: Int64(selection.direction.rawValue))
    235             } else {
    236                 entity.selRow = nil
    237                 entity.selCol = nil
    238                 entity.selDir = nil
    239             }
    240             if context.hasChanges {
    241                 try? context.save()
    242             }
    243         }
    244     }
    245 }