crossmate

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

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 }