crossmate

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

JournalReplay.swift (8233B)


      1 import Foundation
      2 
      3 /// Identifies one device's journal within a game: the `(authorID, deviceID)`
      4 /// pair that names a `Journal` (and `Moves`) record. Faithful replay needs one
      5 /// journal per device that ever wrote grid state.
      6 struct JournalDeviceKey: Hashable, Sendable {
      7     let authorID: String
      8     let deviceID: String
      9 }
     10 
     11 /// One device's decoded move log, tagged with the device it came from.
     12 struct DeviceJournal: Sendable {
     13     let key: JournalDeviceKey
     14     let entries: [JournalValue]
     15 }
     16 
     17 /// What `SyncEngine.fetchReplay` pulls from a finished game's zone: every
     18 /// device's uploaded journal, plus the set of devices that wrote grid state
     19 /// (derived from the `Moves` record names). Replay is only faithful once a
     20 /// journal is present for every expected device — see `ReplayAssembler`.
     21 struct JournalReplayFetch: Sendable {
     22     let journals: [DeviceJournal]
     23     let expectedDevices: Set<JournalDeviceKey>
     24 }
     25 
     26 /// The outcome of assembling a replay. `.waiting` means at least one
     27 /// contributing device hasn't uploaded its journal yet (the scrubber stays
     28 /// disabled until it does); `.unavailable` means the game's zone can't be
     29 /// reached (unknown locally, or access revoked).
     30 enum JournalReplayResult: Sendable, Equatable {
     31     case ready(ReplayTimeline)
     32     case waiting(missing: Int)
     33     case unavailable
     34 }
     35 
     36 /// A merged, chronological replay of a finished game, reconstructed from every
     37 /// device's journal. Forward replay needs only each entry's *after-state*, so
     38 /// the device-local `seq` / `prevSeqAtCell` links (which exist for undo
     39 /// derivation) are ignored here — undo, check, and reveal rows replay
     40 /// naturally because each already carries the cell state it produced and the
     41 /// wall-clock instant it happened. Pure and `Sendable`: no CloudKit, no Core
     42 /// Data, so it can be unit-tested directly.
     43 struct ReplayTimeline: Sendable, Equatable {
     44     /// Replay steps in order. Each step is one scrub increment: either a single
     45     /// cell touch, or every cell of one batched gesture grouped by `batchID` —
     46     /// a bulk reveal/check/clear, or an undo/redo op — so e.g. "reveal puzzle"
     47     /// rewinds as a single event rather than cell by cell.
     48     let steps: [[JournalValue]]
     49 
     50     var count: Int { steps.count }
     51 
     52     /// Every entry in replay order, flattened across steps — for inspection.
     53     var entries: [JournalValue] { steps.flatMap { $0 } }
     54 
     55     /// Merges each device's log into one timeline ordered by `timestamp`, then
     56     /// coalesces runs of same-`batchID` entries into one step. Ties break
     57     /// deterministically on `(actingAuthorID, seq)` so two devices that touch
     58     /// cells at the same instant always replay in the same order, regardless of
     59     /// the order the journals were fetched in.
     60     init(merging logs: [[JournalValue]]) {
     61         let sorted = logs.flatMap { $0 }.sorted(by: ReplayTimeline.precedes)
     62         var grouped: [[JournalValue]] = []
     63         for entry in sorted {
     64             if let batchID = entry.batchID, grouped.last?.first?.batchID == batchID {
     65                 grouped[grouped.count - 1].append(entry)
     66             } else {
     67                 grouped.append([entry])
     68             }
     69         }
     70         steps = grouped
     71     }
     72 
     73     private static func precedes(_ a: JournalValue, _ b: JournalValue) -> Bool {
     74         if a.timestamp != b.timestamp { return a.timestamp < b.timestamp }
     75         let lhs = a.actingAuthorID ?? ""
     76         let rhs = b.actingAuthorID ?? ""
     77         if lhs != rhs { return lhs < rhs }
     78         return a.seq < b.seq
     79     }
     80 
     81     /// The grid after applying the first `count` steps: each touched position
     82     /// mapped to the after-state of its most recent touch. Positions never
     83     /// touched are absent (render as blank). `count` is clamped to
     84     /// `0...self.count`, so `state(through: 0)` is the empty starting grid and
     85     /// `state(through: count)` is the finished puzzle.
     86     func state(through count: Int) -> [GridPosition: JournalCellState] {
     87         let upper = min(max(count, 0), steps.count)
     88         var grid: [GridPosition: JournalCellState] = [:]
     89         for index in 0..<upper {
     90             for entry in steps[index] {
     91                 grid[entry.position] = entry.state
     92             }
     93         }
     94         return grid
     95     }
     96 
     97     /// The single cell a step changed — the replay playhead. `nil` for a
     98     /// batched gesture, which has no single focus to highlight.
     99     func focus(ofStep index: Int) -> GridPosition? {
    100         guard steps.indices.contains(index) else { return nil }
    101         let step = steps[index]
    102         return step.count == 1 ? step.first?.position : nil
    103     }
    104 
    105     /// Who performed a single-cell step — pairs with `focus(ofStep:)` so the
    106     /// playhead can be tinted in the acting author's colour. `nil` for a
    107     /// batched gesture (no single playhead) or an entry that carries no author.
    108     func actingAuthor(ofStep index: Int) -> String? {
    109         guard steps.indices.contains(index) else { return nil }
    110         let step = steps[index]
    111         return step.count == 1 ? step.first?.actingAuthorID : nil
    112     }
    113 
    114     /// The cursor direction recorded with a single-cell step. Older decoded
    115     /// replay rows and non-input rows may not carry one.
    116     func direction(ofStep index: Int) -> Puzzle.Direction? {
    117         guard steps.indices.contains(index) else { return nil }
    118         let step = steps[index]
    119         return step.count == 1 ? step.first?.direction : nil
    120     }
    121 }
    122 
    123 /// Composes a `JournalReplayFetch` with this device's *live* journal into a
    124 /// gated result. Pure, so the completeness rule is unit-testable without
    125 /// CloudKit.
    126 ///
    127 /// Two reasons the live local log is overlaid rather than trusting the fetched
    128 /// copy of ourselves: the completion upload may not have round-tripped yet, and
    129 /// even once it has, the in-memory log is the session's authoritative copy.
    130 ///
    131 /// Strict completeness: a timeline is produced only when a journal is present
    132 /// for every expected device; otherwise the caller is told how many are still
    133 /// missing so the UI can wait.
    134 enum ReplayAssembler {
    135     static func assemble(
    136         fetch: JournalReplayFetch,
    137         localKey: JournalDeviceKey,
    138         localEntries: [JournalValue]
    139     ) -> JournalReplayResult {
    140         // Index fetched journals by device, then overlay this device's live
    141         // log (fresher than any uploaded copy of itself).
    142         var byDevice: [JournalDeviceKey: [JournalValue]] = [:]
    143         for journal in fetch.journals {
    144             byDevice[journal.key] = journal.entries
    145         }
    146         if !localEntries.isEmpty {
    147             byDevice[localKey] = localEntries
    148         }
    149 
    150         // Expected = every device that wrote grid state. The local device is
    151         // implicitly expected when it has a live log, in case its own Moves
    152         // record query raced the fetch.
    153         var expected = fetch.expectedDevices
    154         if !localEntries.isEmpty {
    155             expected.insert(localKey)
    156         }
    157 
    158         let present = Set(byDevice.keys)
    159         let missing = expected.subtracting(present)
    160         guard missing.isEmpty else {
    161             return .waiting(missing: missing.count)
    162         }
    163         return .ready(ReplayTimeline(merging: Array(byDevice.values)))
    164     }
    165 
    166     /// Gates assembly on a per-game memo so re-entry doesn't re-run the merge.
    167     /// A `cached` timeline short-circuits to `.ready` (a finished game's
    168     /// journals are frozen, so it can never go stale); otherwise `assemble`
    169     /// runs and *only* a `.ready` result is handed to `store` to cache —
    170     /// `.waiting`/`.unavailable` stay uncached so they remain retryable. Pure
    171     /// orchestration with its IO injected, so it's testable without
    172     /// `AppServices`. `onHit` is a logging-only side hook for the cache-hit path.
    173     @MainActor
    174     static func memoised(
    175         cached: ReplayTimeline?,
    176         onHit: (ReplayTimeline) -> Void = { _ in },
    177         store: (ReplayTimeline) -> Void,
    178         assemble: () async -> JournalReplayResult
    179     ) async -> JournalReplayResult {
    180         if let cached {
    181             onHit(cached)
    182             return .ready(cached)
    183         }
    184         let result = await assemble()
    185         if case .ready(let timeline) = result {
    186             store(timeline)
    187         }
    188         return result
    189     }
    190 }