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 }