Journal.swift (25256B)
1 import CoreData 2 import Foundation 3 4 /// Why a journal entry exists. Every grid-changing move is recorded so the 5 /// whole game can be replayed (Phase 2). Letter `input` (adds/deletes) and 6 /// `clear` (the bulk clear gesture) are undoable; `check`/`reveal` are recorded 7 /// for replay but the undo/redo machine never offers them as steps — checking 8 /// and revealing are "help" actions, not edits you rewind. `undo`/`redo` rows 9 /// are the forward mutations produced by reversing or re-applying an undoable 10 /// step; they are real grid changes (so they sync) but the stack machine treats 11 /// them as stack operations. 12 enum JournalKind: Int16, Sendable { 13 case input = 0 14 case check = 1 15 case reveal = 2 16 case clear = 3 17 case undo = 4 18 case redo = 5 19 20 /// Whether this kind is an undoable step (as opposed to a recorded-only 21 /// help gesture or a stack operation). 22 var isUndoable: Bool { self == .input || self == .clear } 23 } 24 25 /// The after-state of a single cell touch — what the cell became, not what it 26 /// was. The "before" value is never stored; it is recovered by following 27 /// `JournalValue.prevSeqAtCell`. 28 struct JournalCellState: Equatable, Sendable { 29 var letter: String 30 var mark: CellMark 31 var cellAuthorID: String? 32 33 static let empty = JournalCellState(letter: "", mark: .none, cellAuthorID: nil) 34 35 /// Letter match for the supersession guard: undo only fires when the cell 36 /// still holds the letter the move produced. Only the letter is compared — 37 /// the journal logs letter adds/deletes, so a peer's check/reveal (a mark 38 /// change) shouldn't block undoing one's own letter, while a peer changing 39 /// the letter itself should. 40 func letterMatches(_ other: JournalCellState) -> Bool { 41 letter == other.letter 42 } 43 } 44 45 /// One recorded cell touch. Append-only and immutable once written. 46 /// `prevSeqAtCell` points at the previous entry for the same `(row, col)` 47 /// (any kind), or `nil` when the cell was empty before — that pointer is how a 48 /// before-state is recovered in O(1) without scanning the log. 49 struct JournalValue: Equatable, Sendable { 50 let seq: Int64 51 let timestamp: Date 52 let position: GridPosition 53 /// The cell state observed immediately before this local touch. New local 54 /// rows carry this so undo can restore a collaborator's pre-existing 55 /// letter even though that letter is not in this device's local journal. 56 /// Older rows and replay-upload rows may be nil and fall back to 57 /// `prevSeqAtCell`. 58 let beforeState: JournalCellState? 59 let state: JournalCellState 60 let actingAuthorID: String? 61 let kind: JournalKind 62 let targetSeq: Int64? 63 let batchID: UUID? 64 let prevSeqAtCell: Int64? 65 /// The cursor direction at the moment of input, so undo/redo can land the 66 /// cursor pointing the way the letter was originally typed. Only meaningful 67 /// for `.input` entries; `nil` for clears, help gestures, and undo/redo 68 /// rows (whose cursor direction comes from the input op they reverse). 69 let direction: Puzzle.Direction? 70 71 init( 72 seq: Int64, 73 timestamp: Date, 74 position: GridPosition, 75 beforeState: JournalCellState? = nil, 76 state: JournalCellState, 77 actingAuthorID: String?, 78 kind: JournalKind, 79 targetSeq: Int64?, 80 batchID: UUID?, 81 prevSeqAtCell: Int64?, 82 direction: Puzzle.Direction? = nil 83 ) { 84 self.seq = seq 85 self.timestamp = timestamp 86 self.position = position 87 self.beforeState = beforeState 88 self.state = state 89 self.actingAuthorID = actingAuthorID 90 self.kind = kind 91 self.targetSeq = targetSeq 92 self.batchID = batchID 93 self.prevSeqAtCell = prevSeqAtCell 94 self.direction = direction 95 } 96 } 97 98 /// One cell to rewrite as part of an undo or redo, with the guard value the 99 /// caller checks against the live grid before applying. 100 struct JournalRestore: Equatable, Sendable { 101 let position: GridPosition 102 /// The value to write into the cell. 103 let restoreTo: JournalCellState 104 /// Skip this cell if the live grid no longer shows this — it means a 105 /// collaborator (or a later edit) changed it since. 106 let expectedCurrent: JournalCellState 107 /// The undoable entry being reversed / re-applied. 108 let targetSeq: Int64 109 } 110 111 /// The cells to restore for one undo/redo step, plus the step's identity so the 112 /// caller can mark it consumed if every cell turned out to be superseded. 113 struct JournalPlan: Equatable, Sendable { 114 let restores: [JournalRestore] 115 let stepID: Int64 116 /// The kind of the undoable step being reversed/re-applied (`.input` or 117 /// `.clear`). Lets the caller decide where to put the cursor — onto a 118 /// single-cell input, but not after a bulk clear. 119 let kind: JournalKind 120 /// The direction the reversed/re-applied input step was typed in, so the 121 /// caller can orient the cursor to match. `nil` for `.clear` (no single 122 /// direction) and for older entries recorded before direction was tracked. 123 let direction: Puzzle.Direction? 124 } 125 126 /// Local, append-only log of every grid move, and the undo/redo derivation 127 /// built on top of it. Local only — never synced in Phase 1. The current 128 /// game's entries are held in memory (loaded lazily, so undo survives a 129 /// relaunch) and each new entry is persisted on a background context; the 130 /// in-memory list is authoritative for the session. 131 @MainActor 132 final class MovesJournal { 133 private let persistence: PersistenceController 134 private let backgroundContext: NSManagedObjectContext 135 136 private var loadedGameID: UUID? 137 private var entries: [JournalValue] = [] 138 private var bySeq: [Int64: JournalValue] = [:] 139 private var lastSeqAtCell: [GridPosition: Int64] = [:] 140 private var nextSeq: Int64 = 0 141 142 /// Steps whose every cell was found superseded when the caller tried to 143 /// apply them, so they should be passed over. Transient (session-only): a 144 /// fully-superseded step has no grid effect to record, so without this the 145 /// stack would keep re-offering it and undo would be stuck. Cleared on load. 146 private var consumedUndoSteps: Set<Int64> = [] 147 private var consumedRedoSteps: Set<Int64> = [] 148 149 init(persistence: PersistenceController) { 150 self.persistence = persistence 151 self.backgroundContext = persistence.container.newBackgroundContext() 152 // Each appended entry links its `GameEntity` (for cascade-delete), 153 // which touches that row's inverse relationship. Meanwhile the grid 154 // writer and summary backfills bump the same `GameEntity` constantly, 155 // so this context's snapshot of it goes stale between saves. With the 156 // default `NSErrorMergePolicy` that surfaces as a 133020 merge conflict 157 // and the *entire* save — including the new journal row — is rolled 158 // back, silently dropping the entry from disk while it lingers in the 159 // in-memory list. Undo then works for the session but the row is gone 160 // on relaunch, leaving the journal behind the grid. Store-trump keeps 161 // the store's authoritative `GameEntity` and applies only our 162 // relationship insert, so the journal write always lands. 163 self.backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyStoreTrump 164 } 165 166 // MARK: - Recording 167 168 /// Appends a cell touch and returns the recorded value. Assigns the next 169 /// `seq`, links `prevSeqAtCell`, and persists asynchronously. 170 @discardableResult 171 func record( 172 gameID: UUID, 173 position: GridPosition, 174 beforeState: JournalCellState? = nil, 175 state: JournalCellState, 176 actingAuthorID: String?, 177 kind: JournalKind, 178 targetSeq: Int64?, 179 batchID: UUID?, 180 direction: Puzzle.Direction? = nil 181 ) -> JournalValue { 182 ensureLoaded(gameID) 183 let value = JournalValue( 184 seq: nextSeq, 185 timestamp: Date(), 186 position: position, 187 beforeState: beforeState, 188 state: state, 189 actingAuthorID: actingAuthorID, 190 kind: kind, 191 targetSeq: targetSeq, 192 batchID: batchID, 193 prevSeqAtCell: lastSeqAtCell[position], 194 direction: direction 195 ) 196 nextSeq += 1 197 entries.append(value) 198 bySeq[value.seq] = value 199 lastSeqAtCell[position] = value.seq 200 persist(value, gameID: gameID) 201 return value 202 } 203 204 // MARK: - Replay 205 206 /// Read-only snapshot of the recorded log for a game, in seq order — 207 /// every move including checks and reveals. The basis for Phase 2 replay. 208 func recordedEntries(gameID: UUID) -> [JournalValue] { 209 ensureLoaded(gameID) 210 return entries 211 } 212 213 // MARK: - Undo / redo queries 214 215 func canUndo(gameID: UUID) -> Bool { 216 ensureLoaded(gameID) 217 return stacks().live.contains { !consumedUndoSteps.contains(stepID($0)) } 218 } 219 220 func canRedo(gameID: UUID) -> Bool { 221 ensureLoaded(gameID) 222 return stacks().redo.contains { !consumedRedoSteps.contains(stepID($0)) } 223 } 224 225 /// The cells to restore to undo the most recent still-undoable step, or 226 /// `nil` when there is nothing to undo. Does not mutate journal state — the 227 /// `undo` rows the caller records via `record` drive the stack forward. 228 func planUndo(gameID: UUID) -> JournalPlan? { 229 ensureLoaded(gameID) 230 guard let op = stacks().live.last(where: { !consumedUndoSteps.contains(stepID($0)) }) 231 else { return nil } 232 let restores = op.entries.map { entry in 233 JournalRestore( 234 position: entry.position, 235 restoreTo: observedBeforeState(of: entry), 236 expectedCurrent: entry.state, 237 targetSeq: entry.seq 238 ) 239 } 240 return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind, direction: op.entries.first?.direction) 241 } 242 243 /// The cells to restore to redo the most recently undone step. 244 func planRedo(gameID: UUID) -> JournalPlan? { 245 ensureLoaded(gameID) 246 guard let op = stacks().redo.last(where: { !consumedRedoSteps.contains(stepID($0)) }) 247 else { return nil } 248 let restores = op.entries.map { entry in 249 JournalRestore( 250 position: entry.position, 251 restoreTo: entry.state, 252 expectedCurrent: observedBeforeState(of: entry), 253 targetSeq: entry.seq 254 ) 255 } 256 return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind, direction: op.entries.first?.direction) 257 } 258 259 /// Records that a planned undo/redo step had no surviving cells (all 260 /// superseded), so the next plan skips past it instead of re-offering it. 261 func markUndoConsumed(stepID: Int64, gameID: UUID) { 262 ensureLoaded(gameID) 263 consumedUndoSteps.insert(stepID) 264 } 265 266 func markRedoConsumed(stepID: Int64, gameID: UUID) { 267 ensureLoaded(gameID) 268 consumedRedoSteps.insert(stepID) 269 } 270 271 // MARK: - Derivation 272 273 private struct Operation { 274 let kind: JournalKind 275 let entries: [JournalValue] 276 } 277 278 /// Stable identity for an operation — the seq of its first entry, which is 279 /// unique and immutable across re-derivations of the same log. 280 private func stepID(_ op: Operation) -> Int64 { 281 op.entries.first?.seq ?? -1 282 } 283 284 /// The before-state of an entry: the after-state of the previous touch at 285 /// the same cell, or empty if there was none. 286 private func beforeState(of entry: JournalValue) -> JournalCellState { 287 guard let prev = entry.prevSeqAtCell, let value = bySeq[prev] else { 288 return .empty 289 } 290 return value.state 291 } 292 293 private func observedBeforeState(of entry: JournalValue) -> JournalCellState { 294 entry.beforeState ?? beforeState(of: entry) 295 } 296 297 /// Groups the log into operations (a bulk gesture or one undo/redo op is a 298 /// single operation; each single-cell edit is its own), then runs the stack 299 /// machine. `live` holds applied undoable operations newest-last; `redo` 300 /// holds undone ones available to re-apply. `check`/`reveal` ops are 301 /// recorded but inert here. 302 /// 303 /// `undo`/`redo` ops are matched to the undoable op they act on by 304 /// `targetSeq`, not by stack position: the supersession guard can skip a 305 /// superseded top step and undo an earlier one, so the op being reversed is 306 /// not necessarily on top. 307 private func stacks() -> (live: [Operation], redo: [Operation]) { 308 var operations: [Operation] = [] 309 var i = 0 310 while i < entries.count { 311 let head = entries[i] 312 var run = [head] 313 var j = i + 1 314 if let batch = head.batchID { 315 while j < entries.count, 316 entries[j].batchID == batch, 317 entries[j].kind == head.kind { 318 run.append(entries[j]) 319 j += 1 320 } 321 } 322 operations.append(Operation(kind: head.kind, entries: run)) 323 i = j 324 } 325 326 // Each entry's seq maps to the op that owns it, so an undo/redo op can 327 // find the exact undoable op its `targetSeq` refers to. 328 var opIndexBySeq: [Int64: Int] = [:] 329 for (index, op) in operations.enumerated() { 330 for entry in op.entries { opIndexBySeq[entry.seq] = index } 331 } 332 333 var live: [Int] = [] 334 var redo: [Int] = [] 335 for (index, op) in operations.enumerated() { 336 switch op.kind { 337 case .undo: 338 guard let target = op.entries.first?.targetSeq, 339 let targetIndex = opIndexBySeq[target], 340 let pos = live.firstIndex(of: targetIndex) else { continue } 341 live.remove(at: pos) 342 redo.append(targetIndex) 343 case .redo: 344 guard let target = op.entries.first?.targetSeq, 345 let targetIndex = opIndexBySeq[target], 346 let pos = redo.firstIndex(of: targetIndex) else { continue } 347 redo.remove(at: pos) 348 live.append(targetIndex) 349 case .input, .clear: 350 redo.removeAll() // a new undoable edit cuts the redo branch 351 live.append(index) 352 case .check, .reveal: 353 break // recorded for replay, inert to undo/redo 354 } 355 } 356 return (live.map { operations[$0] }, redo.map { operations[$0] }) 357 } 358 359 // MARK: - Loading & persistence 360 361 private func ensureLoaded(_ gameID: UUID) { 362 guard loadedGameID != gameID else { return } 363 load(gameID) 364 } 365 366 private func load(_ gameID: UUID) { 367 entries.removeAll() 368 bySeq.removeAll() 369 lastSeqAtCell.removeAll() 370 consumedUndoSteps.removeAll() 371 consumedRedoSteps.removeAll() 372 nextSeq = 0 373 loadedGameID = gameID 374 375 let ctx = backgroundContext 376 let loaded: [JournalValue] = ctx.performAndWait { 377 let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") 378 // `sourceDeviceID == nil` keeps this to *this device's* log: rows 379 // cached from other devices for replay (see GameStore's replay 380 // cache) carry a source key and must not enter the undo/redo model. 381 req.predicate = NSPredicate(format: "gameID == %@ AND sourceDeviceID == nil", gameID as CVarArg) 382 req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] 383 let rows = (try? ctx.fetch(req)) ?? [] 384 return rows.map(Self.value(from:)) 385 } 386 for value in loaded { 387 entries.append(value) 388 bySeq[value.seq] = value 389 lastSeqAtCell[value.position] = value.seq 390 nextSeq = max(nextSeq, value.seq + 1) 391 } 392 } 393 394 /// Awaits the background persistence queue so every recorded entry has 395 /// landed in the store. `record(...)` persists asynchronously and the 396 /// in-memory list is authoritative for play, but the Phase 2 upload 397 /// reconstructs the asset from Core Data on a *separate* background 398 /// context — call this first (e.g. at completion) so that context sees the 399 /// final entries rather than racing the in-flight saves. 400 func flush() async { 401 await backgroundContext.perform { } 402 } 403 404 private func persist(_ value: JournalValue, gameID: UUID) { 405 let ctx = backgroundContext 406 ctx.perform { 407 let entity = JournalEntity(context: ctx) 408 // Local log: leave the source key nil (this device's own row). 409 Self.assign(value, to: entity, gameID: gameID) 410 411 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 412 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 413 gameReq.fetchLimit = 1 414 entity.game = try? ctx.fetch(gameReq).first 415 416 do { 417 try ctx.save() 418 } catch { 419 print("MovesJournal: failed to persist entry: \(error)") 420 ctx.rollback() 421 } 422 } 423 } 424 425 /// Writes a `JournalValue`'s fields onto a `JournalEntity`, leaving the 426 /// `game` relationship and the `sourceAuthorID`/`sourceDeviceID` key to the 427 /// caller. Shared by the local-log `persist` and the replay cache (which 428 /// also stamps the source device), so the value→row mapping lives in one 429 /// place. `nonisolated` so the cache can call it on its own context. 430 nonisolated static func assign(_ value: JournalValue, to entity: JournalEntity, gameID: UUID) { 431 entity.gameID = gameID 432 entity.seq = value.seq 433 entity.timestamp = value.timestamp 434 entity.row = Int16(value.position.row) 435 entity.col = Int16(value.position.col) 436 entity.beforeLetter = value.beforeState?.letter 437 entity.beforeMarkCode = value.beforeState.map { NSNumber(value: $0.mark.code) } 438 entity.beforeCellAuthorID = value.beforeState?.cellAuthorID 439 entity.letter = value.state.letter 440 entity.markCode = value.state.mark.code 441 entity.cellAuthorID = value.state.cellAuthorID 442 entity.actingAuthorID = value.actingAuthorID 443 entity.kind = value.kind.rawValue 444 entity.targetSeq = value.targetSeq.map { NSNumber(value: $0) } 445 entity.batchID = value.batchID 446 entity.prevSeqAtCell = value.prevSeqAtCell.map { NSNumber(value: $0) } 447 entity.dir = value.direction.map { NSNumber(value: $0 == .down ? 1 : 0) } 448 } 449 450 /// Maps a persisted row to its in-memory value. `internal` (not `private`) 451 /// so `RecordBuilder` can reconstruct the upload asset straight from Core 452 /// Data on its own background context. 453 nonisolated static func value(from entity: JournalEntity) -> JournalValue { 454 JournalValue( 455 seq: entity.seq, 456 timestamp: entity.timestamp ?? .distantPast, 457 position: GridPosition(row: Int(entity.row), col: Int(entity.col)), 458 beforeState: entity.beforeLetter.map { 459 JournalCellState( 460 letter: $0, 461 mark: CellMark(code: entity.beforeMarkCode?.int16Value ?? 0), 462 cellAuthorID: entity.beforeCellAuthorID 463 ) 464 }, 465 state: JournalCellState( 466 letter: entity.letter ?? "", 467 mark: CellMark(code: entity.markCode), 468 cellAuthorID: entity.cellAuthorID 469 ), 470 actingAuthorID: entity.actingAuthorID, 471 kind: JournalKind(rawValue: entity.kind) ?? .input, 472 targetSeq: entity.targetSeq?.int64Value, 473 batchID: entity.batchID, 474 prevSeqAtCell: entity.prevSeqAtCell?.int64Value, 475 direction: entity.dir.map { $0.int16Value == 1 ? Puzzle.Direction.down : .across } 476 ) 477 } 478 } 479 480 /// Wire format for a device's journal, encoded once at game completion and 481 /// uploaded as the `Journal` record's `entries` asset (Phase 2). A faithful 482 /// dump of `[JournalValue]` in `seq` order — merging every device's decoded 483 /// dump by `timestamp` reconstructs the whole game for replay. The mark is 484 /// carried as the single lossless `markCode` (`CellMark.code`). 485 enum JournalCodec { 486 struct Payload: Codable, Equatable { 487 struct Entry: Codable, Equatable { 488 let seq: Int64 489 let timestamp: Date 490 let row: Int 491 let col: Int 492 let letter: String 493 let markCode: Int16 494 let cellAuthorID: String? 495 let actingAuthorID: String? 496 let kind: Int16 497 let targetSeq: Int64? 498 let batchID: UUID? 499 let prevSeqAtCell: Int64? 500 let dir: Int? 501 502 init( 503 seq: Int64, 504 timestamp: Date, 505 row: Int, 506 col: Int, 507 letter: String, 508 markCode: Int16, 509 cellAuthorID: String?, 510 actingAuthorID: String?, 511 kind: Int16, 512 targetSeq: Int64?, 513 batchID: UUID?, 514 prevSeqAtCell: Int64?, 515 dir: Int? 516 ) { 517 self.seq = seq 518 self.timestamp = timestamp 519 self.row = row 520 self.col = col 521 self.letter = letter 522 self.markCode = markCode 523 self.cellAuthorID = cellAuthorID 524 self.actingAuthorID = actingAuthorID 525 self.kind = kind 526 self.targetSeq = targetSeq 527 self.batchID = batchID 528 self.prevSeqAtCell = prevSeqAtCell 529 self.dir = dir 530 } 531 532 // Optionals are decoded leniently so a record written by a newer 533 // client that added fields still decodes cleanly on an older one 534 // (same forward-compat stance as `MovesCodec.Payload.Entry`). 535 init(from decoder: Decoder) throws { 536 let c = try decoder.container(keyedBy: CodingKeys.self) 537 seq = try c.decode(Int64.self, forKey: .seq) 538 timestamp = try c.decode(Date.self, forKey: .timestamp) 539 row = try c.decode(Int.self, forKey: .row) 540 col = try c.decode(Int.self, forKey: .col) 541 letter = try c.decode(String.self, forKey: .letter) 542 markCode = try c.decode(Int16.self, forKey: .markCode) 543 kind = try c.decode(Int16.self, forKey: .kind) 544 cellAuthorID = try? c.decode(String.self, forKey: .cellAuthorID) 545 actingAuthorID = try? c.decode(String.self, forKey: .actingAuthorID) 546 targetSeq = try? c.decode(Int64.self, forKey: .targetSeq) 547 batchID = try? c.decode(UUID.self, forKey: .batchID) 548 prevSeqAtCell = try? c.decode(Int64.self, forKey: .prevSeqAtCell) 549 dir = try? c.decode(Int.self, forKey: .dir) 550 } 551 } 552 let entries: [Entry] 553 } 554 555 static func encode(_ values: [JournalValue]) throws -> Data { 556 let entries = values 557 .sorted { $0.seq < $1.seq } 558 .map { value in 559 Payload.Entry( 560 seq: value.seq, 561 timestamp: value.timestamp, 562 row: value.position.row, 563 col: value.position.col, 564 letter: value.state.letter, 565 markCode: value.state.mark.code, 566 cellAuthorID: value.state.cellAuthorID, 567 actingAuthorID: value.actingAuthorID, 568 kind: value.kind.rawValue, 569 targetSeq: value.targetSeq, 570 batchID: value.batchID, 571 prevSeqAtCell: value.prevSeqAtCell, 572 dir: value.direction?.rawValue 573 ) 574 } 575 return try JSONEncoder().encode(Payload(entries: entries)) 576 } 577 578 static func decode(_ data: Data) throws -> [JournalValue] { 579 let payload = try JSONDecoder().decode(Payload.self, from: data) 580 return payload.entries.map { entry in 581 JournalValue( 582 seq: entry.seq, 583 timestamp: entry.timestamp, 584 position: GridPosition(row: entry.row, col: entry.col), 585 beforeState: nil, 586 state: JournalCellState( 587 letter: entry.letter, 588 mark: CellMark(code: entry.markCode), 589 cellAuthorID: entry.cellAuthorID 590 ), 591 actingAuthorID: entry.actingAuthorID, 592 kind: JournalKind(rawValue: entry.kind) ?? .input, 593 targetSeq: entry.targetSeq, 594 batchID: entry.batchID, 595 prevSeqAtCell: entry.prevSeqAtCell, 596 direction: entry.dir.flatMap(Puzzle.Direction.init(rawValue:)) 597 ) 598 } 599 } 600 }