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 }