GameMutator.swift (4704B)
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 authorIDProvider: (@MainActor () -> String?)? 20 21 /// `true` when the current user owns the CloudKit zone for this game. 22 let isOwned: Bool 23 /// `true` when the game is shared — either the owner has an active share 24 /// or the current user joined via one. Mutable so the store can flip it 25 /// when a share is created mid-session, which lets `PuzzleDisplayView` 26 /// react and build a roster without requiring the user to re-open. 27 var isShared: Bool 28 29 /// Set to `true` when the owner has revoked the current user's access to 30 /// a shared game. `emitMove` becomes a no-op and `PuzzleView` shows a 31 /// read-only banner. 32 var isAccessRevoked: Bool 33 34 init( 35 game: Game, 36 gameID: UUID, 37 movesUpdater: MovesUpdater?, 38 authorIDProvider: (@MainActor () -> String?)? = nil, 39 isOwned: Bool = true, 40 isShared: Bool = false, 41 isAccessRevoked: Bool = false 42 ) { 43 self.game = game 44 self.gameID = gameID 45 self.movesUpdater = movesUpdater 46 self.authorIDProvider = authorIDProvider 47 self.isOwned = isOwned 48 self.isShared = isShared 49 self.isAccessRevoked = isAccessRevoked 50 } 51 52 // MARK: - Single-cell mutations 53 54 func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool) { 55 game.setLetter(letter, atRow: row, atCol: col, pencil: pencil, authorID: authorIDProvider?()) 56 emitMove(atRow: row, atCol: col) 57 } 58 59 func clearLetter(atRow row: Int, atCol col: Int) { 60 game.clearLetter(atRow: row, atCol: col) 61 emitMove(atRow: row, atCol: col) 62 } 63 64 // MARK: - Bulk mutations 65 66 func checkCells(_ cells: [Puzzle.Cell]) { 67 let applicable = cells.filter { !$0.isBlock } 68 guard !applicable.isEmpty else { return } 69 game.checkCells(applicable) 70 for cell in applicable { 71 emitMove(atRow: cell.row, atCol: cell.col) 72 } 73 } 74 75 func revealCells(_ cells: [Puzzle.Cell]) { 76 let applicable = cells.filter { !$0.isBlock } 77 guard !applicable.isEmpty else { return } 78 game.revealCells(applicable) 79 for cell in applicable { 80 emitMove(atRow: cell.row, atCol: cell.col) 81 } 82 } 83 84 func clearCells(_ cells: [Puzzle.Cell]) { 85 let applicable = cells.filter { !$0.isBlock } 86 guard !applicable.isEmpty else { return } 87 game.clearCells(applicable) 88 for cell in applicable { 89 emitMove(atRow: cell.row, atCol: cell.col) 90 } 91 } 92 93 // MARK: - Helpers 94 95 private func emitMove(atRow row: Int, atCol col: Int) { 96 guard let movesUpdater, !isAccessRevoked else { return } 97 let square = game.squares[row][col] 98 let (markKind, checkedWrong) = encodeMark(square.mark) 99 let id = gameID 100 let letter = square.entry 101 // The cell's `letterAuthorID` is the canonical author for the square — 102 // it may differ from the acting user when a same-letter write or a 103 // reveal-of-correct preserved the original author. The acting user is 104 // still passed separately so MovesUpdater can fire session pings. 105 let cellAuthorID = square.letterAuthorID 106 let actingAuthorID = authorIDProvider?() 107 Task { 108 await movesUpdater.enqueue( 109 gameID: id, 110 row: row, col: col, 111 letter: letter, 112 markKind: markKind, 113 checkedWrong: checkedWrong, 114 authorID: cellAuthorID, 115 actingAuthorID: actingAuthorID 116 ) 117 } 118 } 119 120 private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedWrong: Bool) { 121 switch mark { 122 case .none: 123 return (0, false) 124 case .pen(let wrong): 125 return (1, wrong) 126 case .pencil(let wrong): 127 return (2, wrong) 128 case .revealed: 129 return (3, false) 130 } 131 } 132 }