crossmate

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

GameMutator.swift (19392B)


      1 import Foundation
      2 
      3 /// Unified mutation processor that sits between `PlayerSession` and `Game`.
      4 /// Every mutation flows through here so that the in-memory `Game` stays
      5 /// up-to-date for immediate UI feedback, and a corresponding cell update is
      6 /// emitted to `MovesUpdater` for durable persistence and CloudKit sync.
      7 ///
      8 /// Remote changes no longer flow through here — they arrive via replay from
      9 /// the sync engine, which writes directly to `CellEntity` and notifies the
     10 /// store to refresh the in-memory game.
     11 ///
     12 /// All methods are `@MainActor` because `Game` is `@MainActor`.
     13 @MainActor
     14 @Observable
     15 final class GameMutator {
     16     private let game: Game
     17     let gameID: UUID
     18     private let movesUpdater: MovesUpdater?
     19     private let movesJournal: MovesJournal?
     20     private let authorIDProvider: (@MainActor () -> String?)?
     21     private let onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)?
     22     private let onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)?
     23     /// Observation token for undo/redo availability. The journal itself is not
     24     /// observable, so `canUndo`/`canRedo` read this value and journal mutations
     25     /// bump it to invalidate SwiftUI menu state.
     26     private var journalRevision = 0
     27 
     28     /// While non-nil, `emitMove` parks its live broadcast here instead of
     29     /// firing `onLocalCellEdit` per cell. A bulk gesture (check/clear/undo of a
     30     /// batch) thus ships one engagement message rather than one per cell, so
     31     /// the peer's grid lights up in a single frame instead of trickling.
     32     private var batchBroadcastBuffer: [RealtimeCellEdit]?
     33 
     34     /// `true` when the current user owns the CloudKit zone for this game.
     35     let isOwned: Bool
     36     /// `true` when the game is shared — either the owner has an active share
     37     /// or the current user joined via one. Mutable so the store can flip it
     38     /// when a share is created mid-session, which lets `PuzzleDisplayView`
     39     /// react and build a roster without requiring the user to re-open.
     40     var isShared: Bool
     41 
     42     /// Whether persisted cell authorship should be rendered with participant
     43     /// colours. A Chronicle can retain collaborative history after its live
     44     /// share has been retired, so this is deliberately separate from
     45     /// `isShared`, which continues to control network and share actions.
     46     var showsPlayerAttribution: Bool {
     47         isShared || hasArchivedPlayerAttribution
     48     }
     49     private let hasArchivedPlayerAttribution: Bool
     50 
     51     /// `true` for the local read-only projection of a Chronicle. It retains
     52     /// participant display and replay data, but must never enter live-game
     53     /// clock, presence, engagement, cursor, or CloudKit write paths.
     54     let isArchived: Bool
     55 
     56     /// Set to `true` when the owner has revoked the current user's access to
     57     /// a shared game. Revocation makes the game read-only: every mutating
     58     /// entry point below is a no-op (via `isEditable`) and `PuzzleView` shows
     59     /// a read-only banner.
     60     var isAccessRevoked: Bool
     61 
     62     /// Whether this app implements the sync protocol selected by the game.
     63     /// Unsupported games remain viewable but every mutation is blocked.
     64     var isSyncSupported: Bool
     65 
     66     /// Version 2 allocates a new Lamport tick for every emitted cell mutation.
     67     /// `logicalTick` tracks the largest value observed anywhere in the game,
     68     /// including timestamp-derived values from legacy clients.
     69     private var usesLogicalTicks: Bool
     70     private var logicalTick: Int64
     71 
     72     /// Set to `true` once the game is completed (won or resigned). A completed
     73     /// game is terminal and read-only: every mutating entry point below becomes
     74     /// a no-op, so the grid can't be edited or "re-solved" after the fact. Set
     75     /// at construction from `completedAt` and flipped live when completion
     76     /// latches mid-session. The gate keys off this latched fact, not the live
     77     /// `completionState`, so a grid that drifted after completion stays locked.
     78     var isCompleted: Bool
     79 
     80     /// The single read-only predicate: local mutations are accepted only while
     81     /// the game is not terminal, the local user still has access, and this app
     82     /// understands the game's sync protocol. Every mutating entry point below
     83     /// guards on this — blocking the in-memory mutation, not just the sync
     84     /// emit — and the editing UI (keyboard, toolbar menus, hardware keys,
     85     /// VoiceOver actions) disables itself from the same flag, so no surface
     86     /// can mutate a game the model would refuse.
     87     var isEditable: Bool { !isCompleted && !isAccessRevoked && isSyncSupported }
     88 
     89     init(
     90         game: Game,
     91         gameID: UUID,
     92         movesUpdater: MovesUpdater?,
     93         movesJournal: MovesJournal? = nil,
     94         authorIDProvider: (@MainActor () -> String?)? = nil,
     95         onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)? = nil,
     96         onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)? = nil,
     97         isOwned: Bool = true,
     98         isShared: Bool = false,
     99         showsPlayerAttribution: Bool? = nil,
    100         isArchived: Bool = false,
    101         isAccessRevoked: Bool = false,
    102         isSyncSupported: Bool = true,
    103         syncVersion: Int64 = GameSyncVersion.current,
    104         initialLogicalTick: Int64 = 0,
    105         isCompleted: Bool = false
    106     ) {
    107         self.game = game
    108         self.gameID = gameID
    109         self.movesUpdater = movesUpdater
    110         self.movesJournal = movesJournal
    111         self.authorIDProvider = authorIDProvider
    112         self.onLocalCellEdit = onLocalCellEdit
    113         self.onLocalCellEditBatch = onLocalCellEditBatch
    114         self.isOwned = isOwned
    115         self.isShared = isShared
    116         self.hasArchivedPlayerAttribution = showsPlayerAttribution ?? isShared
    117         self.isArchived = isArchived
    118         self.isAccessRevoked = isAccessRevoked
    119         self.isSyncSupported = isSyncSupported
    120         self.usesLogicalTicks = GameSyncVersion.normalized(syncVersion) >= GameSyncVersion.logicalTicks
    121         self.logicalTick = max(0, initialLogicalTick)
    122         self.isCompleted = isCompleted
    123         prefetchJournal()
    124     }
    125 
    126     /// Refreshes protocol state after an inbound Game/Moves record. A game can
    127     /// advance from legacy to logical ticks while it is open, and every remote
    128     /// revision must raise the local Lamport floor before the next edit.
    129     func updateSyncVersion(_ version: Int64, observedLogicalTick: Int64) {
    130         isSyncSupported = GameSyncVersion.supports(version)
    131         usesLogicalTicks = GameSyncVersion.normalized(version) >= GameSyncVersion.logicalTicks
    132         logicalTick = max(logicalTick, observedLogicalTick)
    133     }
    134 
    135     // MARK: - Single-cell mutations
    136 
    137     func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, direction: Puzzle.Direction? = nil) {
    138         guard isEditable else { return }
    139         let before = cellState(atRow: row, atCol: col)
    140         game.setLetter(letter, atRow: row, atCol: col, pencil: pencil, authorID: authorIDProvider?())
    141         emitMove(
    142             atRow: row,
    143             atCol: col,
    144             beforeState: before,
    145             journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col),
    146             direction: direction
    147         )
    148     }
    149 
    150     func clearLetter(atRow row: Int, atCol col: Int, direction: Puzzle.Direction? = nil) {
    151         guard isEditable else { return }
    152         let before = cellState(atRow: row, atCol: col)
    153         game.clearLetter(atRow: row, atCol: col)
    154         emitMove(
    155             atRow: row,
    156             atCol: col,
    157             beforeState: before,
    158             journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col),
    159             direction: direction
    160         )
    161     }
    162 
    163     // MARK: - Bulk mutations
    164 
    165     // Every gesture is journaled (so it can be replayed), but only `clear` is
    166     // undoable among these — `check` and `reveal` are help actions, recorded
    167     // but never offered as undo steps. Each bulk gesture is one undo/replay
    168     // step via a shared batch ID; cells that didn't actually change record no
    169     // entry.
    170 
    171     func checkCells(_ cells: [Puzzle.Cell]) {
    172         applyBulk(cells, kind: .check) { game.checkCells($0) }
    173     }
    174 
    175     func revealCells(_ cells: [Puzzle.Cell]) {
    176         applyBulk(cells, kind: .reveal) { game.revealCells($0) }
    177     }
    178 
    179     func clearCells(_ cells: [Puzzle.Cell]) {
    180         applyBulk(cells, kind: .clear) { game.clearCells($0) }
    181     }
    182 
    183     private func applyBulk(
    184         _ cells: [Puzzle.Cell],
    185         kind: JournalKind,
    186         _ mutate: ([Puzzle.Cell]) -> Void
    187     ) {
    188         // A completed or revoked game is read-only. `resignGame` reveals
    189         // through a freshly loaded mutator whose `isCompleted` is still false
    190         // (it sets `completedAt` only afterwards), so its reveal is unaffected.
    191         guard isEditable else { return }
    192         let applicable = cells.filter { !$0.isBlock }
    193         guard !applicable.isEmpty else { return }
    194         let before = applicable.map { cellState(atRow: $0.row, atCol: $0.col) }
    195         mutate(applicable)
    196         let batch = UUID()
    197         collectingBroadcast {
    198             for (cell, priorState) in zip(applicable, before) {
    199                 let journalKind = self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col)
    200                 if journalKind == nil, kind != .reveal { continue }
    201                 emitMove(
    202                     atRow: cell.row,
    203                     atCol: cell.col,
    204                     beforeState: priorState,
    205                     journalKind: journalKind,
    206                     batchID: batch
    207                 )
    208             }
    209         }
    210     }
    211 
    212     // MARK: - Undo / redo
    213 
    214     /// Where the cursor should land after an undo/redo, and which way it should
    215     /// point. `direction` is the way the reversed letter was originally typed,
    216     /// or `nil` when that wasn't recorded (older entries) so the caller keeps
    217     /// its current orientation.
    218     struct CursorLanding {
    219         let position: GridPosition
    220         let direction: Puzzle.Direction?
    221     }
    222 
    223     /// `true` when there is a still-undoable move by this user. Reading these
    224     /// is cheap (a derivation pass over the in-memory journal) and drives the
    225     /// enabled state of the undo/redo controls.
    226     var canUndo: Bool {
    227         _ = journalRevision
    228         guard isEditable, let movesJournal else { return false }
    229         return movesJournal.canUndo(gameID: gameID)
    230     }
    231 
    232     var canRedo: Bool {
    233         _ = journalRevision
    234         guard isEditable, let movesJournal else { return false }
    235         return movesJournal.canRedo(gameID: gameID)
    236     }
    237 
    238     func prefetchJournal() {
    239         guard let movesJournal else { return }
    240         Task { @MainActor [weak self, movesJournal, gameID] in
    241             await movesJournal.preload(gameID: gameID)
    242             guard let self, self.gameID == gameID else { return }
    243             self.journalRevision += 1
    244         }
    245     }
    246 
    247     /// Reverts the most recent still-standing move. Each restored cell is
    248     /// applied as a fresh forward mutation (so it syncs like any edit) and
    249     /// recorded as an `undo` row. Cells a collaborator has changed since are
    250     /// skipped via the supersession guard; if a whole step was superseded it is
    251     /// passed over so undo lands on the next still-standing move.
    252     ///
    253     /// Returns where the cursor should follow to — the single cell of an
    254     /// `input` step, with the direction it was typed in — or `nil` for a bulk
    255     /// `clear` (no single target) or when nothing was undone.
    256     @discardableResult
    257     func undo() -> CursorLanding? {
    258         guard isEditable, let movesJournal else { return nil }
    259         while let plan = movesJournal.planUndo(gameID: gameID) {
    260             if applyRestores(plan.restores, kind: .undo) { return cursorTarget(for: plan) }
    261             movesJournal.markUndoConsumed(stepID: plan.stepID, gameID: gameID)
    262             journalRevision += 1
    263         }
    264         return nil
    265     }
    266 
    267     /// Re-applies the most recently undone move. Mirror of `undo()`.
    268     @discardableResult
    269     func redo() -> CursorLanding? {
    270         guard isEditable, let movesJournal else { return nil }
    271         while let plan = movesJournal.planRedo(gameID: gameID) {
    272             if applyRestores(plan.restores, kind: .redo) { return cursorTarget(for: plan) }
    273             movesJournal.markRedoConsumed(stepID: plan.stepID, gameID: gameID)
    274             journalRevision += 1
    275         }
    276         return nil
    277     }
    278 
    279     /// The cell the cursor should move to after applying `plan`: a single-cell
    280     /// `input` step focuses its cell (oriented to how it was typed), while a
    281     /// bulk `clear` leaves the cursor put.
    282     private func cursorTarget(for plan: JournalPlan) -> CursorLanding? {
    283         guard plan.kind == .input, let position = plan.restores.first?.position else { return nil }
    284         return CursorLanding(position: position, direction: plan.direction)
    285     }
    286 
    287     /// Applies the surviving cells of a plan under one batch, returning whether
    288     /// any cell was applied (a fully-superseded step applies nothing).
    289     private func applyRestores(_ restores: [JournalRestore], kind: JournalKind) -> Bool {
    290         let batch = UUID()
    291         var appliedAny = false
    292         collectingBroadcast {
    293             for restore in restores {
    294                 let row = restore.position.row
    295                 let col = restore.position.col
    296                 let square = game.squares[row][col]
    297                 let current = JournalCellState(
    298                     letter: square.entry,
    299                     mark: square.mark,
    300                     cellAuthorID: square.letterAuthorID
    301                 )
    302                 guard current.letterMatches(restore.expectedCurrent) else { continue }
    303                 game.applyCellState(
    304                     restore.restoreTo.letter,
    305                     mark: restore.restoreTo.mark,
    306                     authorID: restore.restoreTo.cellAuthorID,
    307                     atRow: row, atCol: col
    308                 )
    309                 emitMove(
    310                     atRow: row,
    311                     atCol: col,
    312                     beforeState: current,
    313                     journalKind: kind,
    314                     batchID: batch,
    315                     targetSeq: restore.targetSeq
    316                 )
    317                 appliedAny = true
    318             }
    319         }
    320         return appliedAny
    321     }
    322 
    323     /// The cell's current full state, for change detection and the
    324     /// supersession guard.
    325     private func cellState(atRow row: Int, atCol col: Int) -> JournalCellState {
    326         let square = game.squares[row][col]
    327         return JournalCellState(letter: square.entry, mark: square.mark, cellAuthorID: square.letterAuthorID)
    328     }
    329 
    330     /// Returns `kind` when the cell's state actually changed (letter or mark),
    331     /// `nil` otherwise — a same-letter rewrite, a no-op write to a revealed
    332     /// cell, or a check/clear that skipped a cell records nothing.
    333     private func kind(_ kind: JournalKind, ifChangedFrom before: JournalCellState, atRow row: Int, atCol col: Int) -> JournalKind? {
    334         let square = game.squares[row][col]
    335         let changed = square.entry != before.letter || square.mark != before.mark
    336         return changed ? kind : nil
    337     }
    338 
    339     // MARK: - Helpers
    340 
    341     /// Runs `body` with per-cell live broadcasts buffered, then flushes them as
    342     /// a single message. A one-cell result degrades to the legacy single-cell
    343     /// `onLocalCellEdit` path, so it stays wire-compatible with peers that
    344     /// don't understand batches; only genuinely multi-cell gestures send a
    345     /// batch. The durable Moves/journal writes in `emitMove` are unaffected —
    346     /// only the live overlay is coalesced.
    347     private func collectingBroadcast(_ body: () -> Void) {
    348         batchBroadcastBuffer = []
    349         body()
    350         let edits = batchBroadcastBuffer ?? []
    351         batchBroadcastBuffer = nil
    352         switch edits.count {
    353         case 0: break
    354         case 1: onLocalCellEdit?(edits[0])
    355         default: onLocalCellEditBatch?(edits)
    356         }
    357     }
    358 
    359     private func emitMove(
    360         atRow row: Int,
    361         atCol col: Int,
    362         beforeState: JournalCellState? = nil,
    363         journalKind: JournalKind? = nil,
    364         batchID: UUID? = nil,
    365         targetSeq: Int64? = nil,
    366         direction: Puzzle.Direction? = nil
    367     ) {
    368         guard !isAccessRevoked, isSyncSupported else { return }
    369         let square = game.squares[row][col]
    370         let mark = square.mark
    371         let id = gameID
    372         let letter = square.entry
    373         // The cell's `letterAuthorID` is the canonical author for the square —
    374         // it may differ from the acting user when a same-letter write or a
    375         // reveal-of-correct preserved the original author.
    376         let cellAuthorID = square.letterAuthorID
    377         let actingAuthorID = authorIDProvider?()
    378 
    379         // Only letter adds/deletes (and undo/redo of them) are journaled;
    380         // `journalKind == nil` means this move is not undoable (check/reveal,
    381         // or a no-op write). Recording is independent of whether sync is wired.
    382         if let journalKind {
    383             movesJournal?.record(
    384                 gameID: id,
    385                 position: GridPosition(row: row, col: col),
    386                 beforeState: beforeState,
    387                 state: JournalCellState(letter: letter, mark: square.mark, cellAuthorID: cellAuthorID),
    388                 actingAuthorID: actingAuthorID,
    389                 kind: journalKind,
    390                 targetSeq: targetSeq,
    391                 batchID: batchID,
    392                 direction: direction
    393             )
    394             journalRevision += 1
    395         }
    396 
    397         guard let movesUpdater else { return }
    398         // Stamp the flag on the MainActor *before* the Task hops to the
    399         // actor, atomically with the value the user just typed. While it's
    400         // set, `GameStore.restore` won't overwrite this square from a remote
    401         // refresh — closing the window where the buffered letter exists only
    402         // in the actor and a fetch could revert it. The same timestamp is
    403         // persisted as the cell's `updatedAt` on flush, which is how
    404         // `restore` later recognises the edit has landed and retires the flag.
    405         let enqueuedAt = Date()
    406         let tick = nextLogicalTick()
    407         game.squares[row][col].enqueuedAt = enqueuedAt
    408         if let actingAuthorID, !actingAuthorID.isEmpty {
    409             let edit = RealtimeCellEdit(
    410                 gameID: id,
    411                 authorID: actingAuthorID,
    412                 deviceID: RecordSerializer.localDeviceID,
    413                 row: row,
    414                 col: col,
    415                 letter: letter,
    416                 mark: mark,
    417                 updatedAt: enqueuedAt,
    418                 cellAuthorID: cellAuthorID,
    419                 tick: tick
    420             )
    421             if batchBroadcastBuffer != nil {
    422                 batchBroadcastBuffer?.append(edit)
    423             } else {
    424                 onLocalCellEdit?(edit)
    425             }
    426         }
    427         Task {
    428             await movesUpdater.enqueue(
    429                 gameID: id,
    430                 row: row, col: col,
    431                 letter: letter,
    432                 mark: mark,
    433                 authorID: cellAuthorID,
    434                 enqueuedAt: enqueuedAt,
    435                 tick: tick
    436             )
    437         }
    438     }
    439 
    440     private func nextLogicalTick() -> Int64? {
    441         guard usesLogicalTicks else { return nil }
    442         if logicalTick < Int64.max { logicalTick += 1 }
    443         return logicalTick
    444     }
    445 }