crossmate

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

GameMutator.swift (16000B)


      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     /// Set to `true` when the owner has revoked the current user's access to
     43     /// a shared game. `emitMove` becomes a no-op and `PuzzleView` shows a
     44     /// read-only banner.
     45     var isAccessRevoked: Bool
     46 
     47     /// Set to `true` once the game is completed (won or resigned). A completed
     48     /// game is terminal and read-only: every mutating entry point below becomes
     49     /// a no-op, so the grid can't be edited or "re-solved" after the fact. Set
     50     /// at construction from `completedAt` and flipped live when completion
     51     /// latches mid-session. Unlike `isAccessRevoked` (which only suppresses the
     52     /// durable/sync emit) this also blocks the in-memory mutation, so no letter
     53     /// even appears. The gate keys off this latched fact, not the live
     54     /// `completionState`, so a grid that drifted after completion stays locked.
     55     var isCompleted: Bool
     56 
     57     init(
     58         game: Game,
     59         gameID: UUID,
     60         movesUpdater: MovesUpdater?,
     61         movesJournal: MovesJournal? = nil,
     62         authorIDProvider: (@MainActor () -> String?)? = nil,
     63         onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)? = nil,
     64         onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)? = nil,
     65         isOwned: Bool = true,
     66         isShared: Bool = false,
     67         isAccessRevoked: Bool = false,
     68         isCompleted: Bool = false
     69     ) {
     70         self.game = game
     71         self.gameID = gameID
     72         self.movesUpdater = movesUpdater
     73         self.movesJournal = movesJournal
     74         self.authorIDProvider = authorIDProvider
     75         self.onLocalCellEdit = onLocalCellEdit
     76         self.onLocalCellEditBatch = onLocalCellEditBatch
     77         self.isOwned = isOwned
     78         self.isShared = isShared
     79         self.isAccessRevoked = isAccessRevoked
     80         self.isCompleted = isCompleted
     81     }
     82 
     83     // MARK: - Single-cell mutations
     84 
     85     func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, direction: Puzzle.Direction? = nil) {
     86         guard !isCompleted else { return }
     87         let before = cellState(atRow: row, atCol: col)
     88         game.setLetter(letter, atRow: row, atCol: col, pencil: pencil, authorID: authorIDProvider?())
     89         emitMove(
     90             atRow: row,
     91             atCol: col,
     92             beforeState: before,
     93             journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col),
     94             direction: direction
     95         )
     96     }
     97 
     98     func clearLetter(atRow row: Int, atCol col: Int, direction: Puzzle.Direction? = nil) {
     99         guard !isCompleted else { return }
    100         let before = cellState(atRow: row, atCol: col)
    101         game.clearLetter(atRow: row, atCol: col)
    102         emitMove(
    103             atRow: row,
    104             atCol: col,
    105             beforeState: before,
    106             journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col),
    107             direction: direction
    108         )
    109     }
    110 
    111     // MARK: - Bulk mutations
    112 
    113     // Every gesture is journaled (so it can be replayed), but only `clear` is
    114     // undoable among these — `check` and `reveal` are help actions, recorded
    115     // but never offered as undo steps. Each bulk gesture is one undo/replay
    116     // step via a shared batch ID; cells that didn't actually change record no
    117     // entry.
    118 
    119     func checkCells(_ cells: [Puzzle.Cell]) {
    120         applyBulk(cells, kind: .check) { game.checkCells($0) }
    121     }
    122 
    123     func revealCells(_ cells: [Puzzle.Cell]) {
    124         applyBulk(cells, kind: .reveal) { game.revealCells($0) }
    125     }
    126 
    127     func clearCells(_ cells: [Puzzle.Cell]) {
    128         applyBulk(cells, kind: .clear) { game.clearCells($0) }
    129     }
    130 
    131     private func applyBulk(
    132         _ cells: [Puzzle.Cell],
    133         kind: JournalKind,
    134         _ mutate: ([Puzzle.Cell]) -> Void
    135     ) {
    136         // A completed game is read-only. `resignGame` reveals through a freshly
    137         // loaded mutator whose `isCompleted` is still false (it sets
    138         // `completedAt` only afterwards), so its reveal is unaffected.
    139         guard !isCompleted else { return }
    140         let applicable = cells.filter { !$0.isBlock }
    141         guard !applicable.isEmpty else { return }
    142         let before = applicable.map { cellState(atRow: $0.row, atCol: $0.col) }
    143         mutate(applicable)
    144         let batch = UUID()
    145         collectingBroadcast {
    146             for (cell, priorState) in zip(applicable, before) {
    147                 emitMove(
    148                     atRow: cell.row,
    149                     atCol: cell.col,
    150                     beforeState: priorState,
    151                     journalKind: self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col),
    152                     batchID: batch
    153                 )
    154             }
    155         }
    156     }
    157 
    158     // MARK: - Undo / redo
    159 
    160     /// Where the cursor should land after an undo/redo, and which way it should
    161     /// point. `direction` is the way the reversed letter was originally typed,
    162     /// or `nil` when that wasn't recorded (older entries) so the caller keeps
    163     /// its current orientation.
    164     struct CursorLanding {
    165         let position: GridPosition
    166         let direction: Puzzle.Direction?
    167     }
    168 
    169     /// `true` when there is a still-undoable move by this user. Reading these
    170     /// is cheap (a derivation pass over the in-memory journal) and drives the
    171     /// enabled state of the undo/redo controls.
    172     var canUndo: Bool {
    173         _ = journalRevision
    174         guard !isAccessRevoked, !isCompleted, let movesJournal else { return false }
    175         return movesJournal.canUndo(gameID: gameID)
    176     }
    177 
    178     var canRedo: Bool {
    179         _ = journalRevision
    180         guard !isAccessRevoked, !isCompleted, let movesJournal else { return false }
    181         return movesJournal.canRedo(gameID: gameID)
    182     }
    183 
    184     /// Reverts the most recent still-standing move. Each restored cell is
    185     /// applied as a fresh forward mutation (so it syncs like any edit) and
    186     /// recorded as an `undo` row. Cells a collaborator has changed since are
    187     /// skipped via the supersession guard; if a whole step was superseded it is
    188     /// passed over so undo lands on the next still-standing move.
    189     ///
    190     /// Returns where the cursor should follow to — the single cell of an
    191     /// `input` step, with the direction it was typed in — or `nil` for a bulk
    192     /// `clear` (no single target) or when nothing was undone.
    193     @discardableResult
    194     func undo() -> CursorLanding? {
    195         guard !isAccessRevoked, !isCompleted, let movesJournal else { return nil }
    196         while let plan = movesJournal.planUndo(gameID: gameID) {
    197             if applyRestores(plan.restores, kind: .undo) { return cursorTarget(for: plan) }
    198             movesJournal.markUndoConsumed(stepID: plan.stepID, gameID: gameID)
    199             journalRevision += 1
    200         }
    201         return nil
    202     }
    203 
    204     /// Re-applies the most recently undone move. Mirror of `undo()`.
    205     @discardableResult
    206     func redo() -> CursorLanding? {
    207         guard !isAccessRevoked, !isCompleted, let movesJournal else { return nil }
    208         while let plan = movesJournal.planRedo(gameID: gameID) {
    209             if applyRestores(plan.restores, kind: .redo) { return cursorTarget(for: plan) }
    210             movesJournal.markRedoConsumed(stepID: plan.stepID, gameID: gameID)
    211             journalRevision += 1
    212         }
    213         return nil
    214     }
    215 
    216     /// The cell the cursor should move to after applying `plan`: a single-cell
    217     /// `input` step focuses its cell (oriented to how it was typed), while a
    218     /// bulk `clear` leaves the cursor put.
    219     private func cursorTarget(for plan: JournalPlan) -> CursorLanding? {
    220         guard plan.kind == .input, let position = plan.restores.first?.position else { return nil }
    221         return CursorLanding(position: position, direction: plan.direction)
    222     }
    223 
    224     /// Applies the surviving cells of a plan under one batch, returning whether
    225     /// any cell was applied (a fully-superseded step applies nothing).
    226     private func applyRestores(_ restores: [JournalRestore], kind: JournalKind) -> Bool {
    227         let batch = UUID()
    228         var appliedAny = false
    229         collectingBroadcast {
    230             for restore in restores {
    231                 let row = restore.position.row
    232                 let col = restore.position.col
    233                 let square = game.squares[row][col]
    234                 let current = JournalCellState(
    235                     letter: square.entry,
    236                     mark: square.mark,
    237                     cellAuthorID: square.letterAuthorID
    238                 )
    239                 guard current.letterMatches(restore.expectedCurrent) else { continue }
    240                 game.applyCellState(
    241                     restore.restoreTo.letter,
    242                     mark: restore.restoreTo.mark,
    243                     authorID: restore.restoreTo.cellAuthorID,
    244                     atRow: row, atCol: col
    245                 )
    246                 emitMove(
    247                     atRow: row,
    248                     atCol: col,
    249                     beforeState: current,
    250                     journalKind: kind,
    251                     batchID: batch,
    252                     targetSeq: restore.targetSeq
    253                 )
    254                 appliedAny = true
    255             }
    256         }
    257         return appliedAny
    258     }
    259 
    260     /// The cell's current full state, for change detection and the
    261     /// supersession guard.
    262     private func cellState(atRow row: Int, atCol col: Int) -> JournalCellState {
    263         let square = game.squares[row][col]
    264         return JournalCellState(letter: square.entry, mark: square.mark, cellAuthorID: square.letterAuthorID)
    265     }
    266 
    267     /// Returns `kind` when the cell's state actually changed (letter or mark),
    268     /// `nil` otherwise — a same-letter rewrite, a no-op write to a revealed
    269     /// cell, or a check/clear that skipped a cell records nothing.
    270     private func kind(_ kind: JournalKind, ifChangedFrom before: JournalCellState, atRow row: Int, atCol col: Int) -> JournalKind? {
    271         let square = game.squares[row][col]
    272         let changed = square.entry != before.letter || square.mark != before.mark
    273         return changed ? kind : nil
    274     }
    275 
    276     // MARK: - Helpers
    277 
    278     /// Runs `body` with per-cell live broadcasts buffered, then flushes them as
    279     /// a single message. A one-cell result degrades to the legacy single-cell
    280     /// `onLocalCellEdit` path, so it stays wire-compatible with peers that
    281     /// don't understand batches; only genuinely multi-cell gestures send a
    282     /// batch. The durable Moves/journal writes in `emitMove` are unaffected —
    283     /// only the live overlay is coalesced.
    284     private func collectingBroadcast(_ body: () -> Void) {
    285         batchBroadcastBuffer = []
    286         body()
    287         let edits = batchBroadcastBuffer ?? []
    288         batchBroadcastBuffer = nil
    289         switch edits.count {
    290         case 0: break
    291         case 1: onLocalCellEdit?(edits[0])
    292         default: onLocalCellEditBatch?(edits)
    293         }
    294     }
    295 
    296     private func emitMove(
    297         atRow row: Int,
    298         atCol col: Int,
    299         beforeState: JournalCellState? = nil,
    300         journalKind: JournalKind? = nil,
    301         batchID: UUID? = nil,
    302         targetSeq: Int64? = nil,
    303         direction: Puzzle.Direction? = nil
    304     ) {
    305         guard !isAccessRevoked else { return }
    306         let square = game.squares[row][col]
    307         let mark = square.mark
    308         let id = gameID
    309         let letter = square.entry
    310         // The cell's `letterAuthorID` is the canonical author for the square —
    311         // it may differ from the acting user when a same-letter write or a
    312         // reveal-of-correct preserved the original author.
    313         let cellAuthorID = square.letterAuthorID
    314         let actingAuthorID = authorIDProvider?()
    315 
    316         // Only letter adds/deletes (and undo/redo of them) are journaled;
    317         // `journalKind == nil` means this move is not undoable (check/reveal,
    318         // or a no-op write). Recording is independent of whether sync is wired.
    319         if let journalKind {
    320             movesJournal?.record(
    321                 gameID: id,
    322                 position: GridPosition(row: row, col: col),
    323                 beforeState: beforeState,
    324                 state: JournalCellState(letter: letter, mark: square.mark, cellAuthorID: cellAuthorID),
    325                 actingAuthorID: actingAuthorID,
    326                 kind: journalKind,
    327                 targetSeq: targetSeq,
    328                 batchID: batchID,
    329                 direction: direction
    330             )
    331             journalRevision += 1
    332         }
    333 
    334         guard let movesUpdater else { return }
    335         // Stamp the flag on the MainActor *before* the Task hops to the
    336         // actor, atomically with the value the user just typed. While it's
    337         // set, `GameStore.restore` won't overwrite this square from a remote
    338         // refresh — closing the window where the buffered letter exists only
    339         // in the actor and a fetch could revert it. The same timestamp is
    340         // persisted as the cell's `updatedAt` on flush, which is how
    341         // `restore` later recognises the edit has landed and retires the flag.
    342         let enqueuedAt = Date()
    343         game.squares[row][col].enqueuedAt = enqueuedAt
    344         if let actingAuthorID, !actingAuthorID.isEmpty {
    345             let edit = RealtimeCellEdit(
    346                 gameID: id,
    347                 authorID: actingAuthorID,
    348                 deviceID: RecordSerializer.localDeviceID,
    349                 row: row,
    350                 col: col,
    351                 letter: letter,
    352                 mark: mark,
    353                 updatedAt: enqueuedAt,
    354                 cellAuthorID: cellAuthorID
    355             )
    356             if batchBroadcastBuffer != nil {
    357                 batchBroadcastBuffer?.append(edit)
    358             } else {
    359                 onLocalCellEdit?(edit)
    360             }
    361         }
    362         Task {
    363             await movesUpdater.enqueue(
    364                 gameID: id,
    365                 row: row, col: col,
    366                 letter: letter,
    367                 mark: mark,
    368                 authorID: cellAuthorID,
    369                 actingAuthorID: actingAuthorID,
    370                 enqueuedAt: enqueuedAt
    371             )
    372         }
    373     }
    374 }