Journal.swift (28604B)
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 private var loadingGameID: UUID? 142 private var loadGeneration = 0 143 144 /// Steps whose every cell was found superseded when the caller tried to 145 /// apply them, so they should be passed over. Transient (session-only): a 146 /// fully-superseded step has no grid effect to record, so without this the 147 /// stack would keep re-offering it and undo would be stuck. Cleared on load. 148 private var consumedUndoSteps: Set<Int64> = [] 149 private var consumedRedoSteps: Set<Int64> = [] 150 151 init(persistence: PersistenceController) { 152 self.persistence = persistence 153 self.backgroundContext = persistence.container.newBackgroundContext() 154 // Each appended entry links its `GameEntity` (for cascade-delete), 155 // which touches that row's inverse relationship. Meanwhile the grid 156 // writer and summary backfills bump the same `GameEntity` constantly, 157 // so this context's snapshot of it goes stale between saves. With the 158 // default `NSErrorMergePolicy` that surfaces as a 133020 merge conflict 159 // and the *entire* save — including the new journal row — is rolled 160 // back, silently dropping the entry from disk while it lingers in the 161 // in-memory list. Undo then works for the session but the row is gone 162 // on relaunch, leaving the journal behind the grid. Store-trump keeps 163 // the store's authoritative `GameEntity` and applies only our 164 // relationship insert, so the journal write always lands. 165 self.backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyStoreTrump 166 } 167 168 // MARK: - Recording 169 170 /// Appends a cell touch and returns the recorded value. Assigns the next 171 /// `seq`, links `prevSeqAtCell`, and persists asynchronously. 172 @discardableResult 173 func record( 174 gameID: UUID, 175 position: GridPosition, 176 beforeState: JournalCellState? = nil, 177 state: JournalCellState, 178 actingAuthorID: String?, 179 kind: JournalKind, 180 targetSeq: Int64?, 181 batchID: UUID?, 182 direction: Puzzle.Direction? = nil 183 ) -> JournalValue { 184 ensureLoaded(gameID) 185 let value = JournalValue( 186 seq: nextSeq, 187 timestamp: Date(), 188 position: position, 189 beforeState: beforeState, 190 state: state, 191 actingAuthorID: actingAuthorID, 192 kind: kind, 193 targetSeq: targetSeq, 194 batchID: batchID, 195 prevSeqAtCell: lastSeqAtCell[position], 196 direction: direction 197 ) 198 nextSeq += 1 199 entries.append(value) 200 bySeq[value.seq] = value 201 lastSeqAtCell[position] = value.seq 202 persist(value, gameID: gameID) 203 return value 204 } 205 206 // MARK: - Replay 207 208 /// Read-only snapshot of the recorded log for a game, in seq order — 209 /// every move including checks and reveals. The basis for Phase 2 replay. 210 func recordedEntries(gameID: UUID) -> [JournalValue] { 211 ensureLoaded(gameID) 212 return entries 213 } 214 215 // MARK: - Undo / redo queries 216 217 func canUndo(gameID: UUID) -> Bool { 218 guard loadedGameID == gameID else { return false } 219 return stacks().live.contains { !consumedUndoSteps.contains(stepID($0)) } 220 } 221 222 func canRedo(gameID: UUID) -> Bool { 223 guard loadedGameID == gameID else { return false } 224 return stacks().redo.contains { !consumedRedoSteps.contains(stepID($0)) } 225 } 226 227 /// The cells to restore to undo the most recent still-undoable step, or 228 /// `nil` when there is nothing to undo. Does not mutate journal state — the 229 /// `undo` rows the caller records via `record` drive the stack forward. 230 func planUndo(gameID: UUID) -> JournalPlan? { 231 ensureLoaded(gameID) 232 guard let op = stacks().live.last(where: { !consumedUndoSteps.contains(stepID($0)) }) 233 else { return nil } 234 let restores = op.entries.map { entry in 235 JournalRestore( 236 position: entry.position, 237 restoreTo: observedBeforeState(of: entry), 238 expectedCurrent: entry.state, 239 targetSeq: entry.seq 240 ) 241 } 242 return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind, direction: op.entries.first?.direction) 243 } 244 245 /// The cells to restore to redo the most recently undone step. 246 func planRedo(gameID: UUID) -> JournalPlan? { 247 ensureLoaded(gameID) 248 guard let op = stacks().redo.last(where: { !consumedRedoSteps.contains(stepID($0)) }) 249 else { return nil } 250 let restores = op.entries.map { entry in 251 JournalRestore( 252 position: entry.position, 253 restoreTo: entry.state, 254 expectedCurrent: observedBeforeState(of: entry), 255 targetSeq: entry.seq 256 ) 257 } 258 return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind, direction: op.entries.first?.direction) 259 } 260 261 /// Records that a planned undo/redo step had no surviving cells (all 262 /// superseded), so the next plan skips past it instead of re-offering it. 263 func markUndoConsumed(stepID: Int64, gameID: UUID) { 264 ensureLoaded(gameID) 265 consumedUndoSteps.insert(stepID) 266 } 267 268 func markRedoConsumed(stepID: Int64, gameID: UUID) { 269 ensureLoaded(gameID) 270 consumedRedoSteps.insert(stepID) 271 } 272 273 // MARK: - Derivation 274 275 private struct Operation { 276 let kind: JournalKind 277 let entries: [JournalValue] 278 } 279 280 /// Stable identity for an operation — the seq of its first entry, which is 281 /// unique and immutable across re-derivations of the same log. 282 private func stepID(_ op: Operation) -> Int64 { 283 op.entries.first?.seq ?? -1 284 } 285 286 /// The before-state of an entry: the after-state of the previous touch at 287 /// the same cell, or empty if there was none. 288 private func beforeState(of entry: JournalValue) -> JournalCellState { 289 guard let prev = entry.prevSeqAtCell, let value = bySeq[prev] else { 290 return .empty 291 } 292 return value.state 293 } 294 295 private func observedBeforeState(of entry: JournalValue) -> JournalCellState { 296 entry.beforeState ?? beforeState(of: entry) 297 } 298 299 /// Groups the log into operations (a bulk gesture or one undo/redo op is a 300 /// single operation; each single-cell edit is its own), then runs the stack 301 /// machine. `live` holds applied undoable operations newest-last; `redo` 302 /// holds undone ones available to re-apply. `check`/`reveal` ops are 303 /// recorded but inert here. 304 /// 305 /// `undo`/`redo` ops are matched to the undoable op they act on by 306 /// `targetSeq`, not by stack position: the supersession guard can skip a 307 /// superseded top step and undo an earlier one, so the op being reversed is 308 /// not necessarily on top. 309 private func stacks() -> (live: [Operation], redo: [Operation]) { 310 var operations: [Operation] = [] 311 var i = 0 312 while i < entries.count { 313 let head = entries[i] 314 var run = [head] 315 var j = i + 1 316 if let batch = head.batchID { 317 while j < entries.count, 318 entries[j].batchID == batch, 319 entries[j].kind == head.kind { 320 run.append(entries[j]) 321 j += 1 322 } 323 } 324 operations.append(Operation(kind: head.kind, entries: run)) 325 i = j 326 } 327 328 // Each entry's seq maps to the op that owns it, so an undo/redo op can 329 // find the exact undoable op its `targetSeq` refers to. 330 var opIndexBySeq: [Int64: Int] = [:] 331 for (index, op) in operations.enumerated() { 332 for entry in op.entries { opIndexBySeq[entry.seq] = index } 333 } 334 335 var live: [Int] = [] 336 var redo: [Int] = [] 337 for (index, op) in operations.enumerated() { 338 switch op.kind { 339 case .undo: 340 guard let target = op.entries.first?.targetSeq, 341 let targetIndex = opIndexBySeq[target], 342 let pos = live.firstIndex(of: targetIndex) else { continue } 343 live.remove(at: pos) 344 redo.append(targetIndex) 345 case .redo: 346 guard let target = op.entries.first?.targetSeq, 347 let targetIndex = opIndexBySeq[target], 348 let pos = redo.firstIndex(of: targetIndex) else { continue } 349 redo.remove(at: pos) 350 live.append(targetIndex) 351 case .input, .clear: 352 redo.removeAll() // a new undoable edit cuts the redo branch 353 live.append(index) 354 case .check, .reveal: 355 break // recorded for replay, inert to undo/redo 356 } 357 } 358 return (live.map { operations[$0] }, redo.map { operations[$0] }) 359 } 360 361 // MARK: - Loading & persistence 362 363 func preload(gameID: UUID) async { 364 guard loadedGameID != gameID else { return } 365 loadGeneration += 1 366 let generation = loadGeneration 367 loadingGameID = gameID 368 let loaded = await loadValues(gameID) 369 guard generation == loadGeneration else { return } 370 applyLoaded(loaded, for: gameID) 371 loadingGameID = nil 372 } 373 374 private func ensureLoaded(_ gameID: UUID) { 375 guard loadedGameID != gameID else { return } 376 loadGeneration += 1 377 loadingGameID = nil 378 applyLoaded(loadValuesSynchronously(gameID), for: gameID) 379 } 380 381 private func applyLoaded(_ loaded: [JournalValue], for gameID: UUID) { 382 entries.removeAll() 383 bySeq.removeAll() 384 lastSeqAtCell.removeAll() 385 consumedUndoSteps.removeAll() 386 consumedRedoSteps.removeAll() 387 nextSeq = 0 388 loadedGameID = gameID 389 for value in loaded { 390 entries.append(value) 391 bySeq[value.seq] = value 392 lastSeqAtCell[value.position] = value.seq 393 nextSeq = max(nextSeq, value.seq + 1) 394 } 395 } 396 397 private func loadValues(_ gameID: UUID) async -> [JournalValue] { 398 let ctx = backgroundContext 399 return await ctx.perform { 400 Self.fetchValues(for: gameID, in: ctx) 401 } 402 } 403 404 private func loadValuesSynchronously(_ gameID: UUID) -> [JournalValue] { 405 let ctx = backgroundContext 406 return ctx.performAndWait { 407 Self.fetchValues(for: gameID, in: ctx) 408 } 409 } 410 411 private nonisolated static func fetchValues(for gameID: UUID, in ctx: NSManagedObjectContext) -> [JournalValue] { 412 let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") 413 // `sourceDeviceID == nil` keeps this to *this device's* log: rows cached 414 // from other devices for replay carry a source key and must not enter 415 // the undo/redo model. 416 req.predicate = NSPredicate(format: "gameID == %@ AND sourceDeviceID == nil", gameID as CVarArg) 417 req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] 418 let rows = (try? ctx.fetch(req)) ?? [] 419 return rows.map(Self.value(from:)) 420 } 421 422 /// Awaits the background persistence queue so every recorded entry has 423 /// landed in the store. `record(...)` persists asynchronously and the 424 /// in-memory list is authoritative for play, but the Phase 2 upload 425 /// reconstructs the asset from Core Data on a *separate* background 426 /// context — call this first (e.g. at completion) so that context sees the 427 /// final entries rather than racing the in-flight saves. 428 func flush() async { 429 await backgroundContext.perform { } 430 } 431 432 private func persist(_ value: JournalValue, gameID: UUID) { 433 let ctx = backgroundContext 434 let eventLog = persistence.eventLog 435 ctx.perform { 436 let entity = JournalEntity(context: ctx) 437 // Local log: leave the source key nil (this device's own row). 438 Self.assign(value, to: entity, gameID: gameID) 439 440 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 441 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 442 gameReq.fetchLimit = 1 443 entity.game = try? ctx.fetch(gameReq).first 444 445 do { 446 try ctx.save() 447 } catch { 448 let message = "MovesJournal: failed to persist entry: \(error)" 449 Task { @MainActor in 450 eventLog?.note(message, level: "error") 451 } 452 ctx.rollback() 453 } 454 } 455 } 456 457 /// Writes a `JournalValue`'s fields onto a `JournalEntity`, leaving the 458 /// `game` relationship and the `sourceAuthorID`/`sourceDeviceID` key to the 459 /// caller. Shared by the local-log `persist` and the replay cache (which 460 /// also stamps the source device), so the value→row mapping lives in one 461 /// place. `nonisolated` so the cache can call it on its own context. 462 nonisolated static func assign(_ value: JournalValue, to entity: JournalEntity, gameID: UUID) { 463 entity.gameID = gameID 464 entity.seq = value.seq 465 entity.timestamp = value.timestamp 466 entity.row = Int16(value.position.row) 467 entity.col = Int16(value.position.col) 468 entity.beforeLetter = value.beforeState?.letter 469 entity.beforeMarkCode = value.beforeState.map { NSNumber(value: $0.mark.code) } 470 entity.beforeCellAuthorID = value.beforeState?.cellAuthorID 471 entity.letter = value.state.letter 472 entity.markCode = value.state.mark.code 473 entity.cellAuthorID = value.state.cellAuthorID 474 entity.actingAuthorID = value.actingAuthorID 475 entity.kind = value.kind.rawValue 476 entity.targetSeq = value.targetSeq.map { NSNumber(value: $0) } 477 entity.batchID = value.batchID 478 entity.prevSeqAtCell = value.prevSeqAtCell.map { NSNumber(value: $0) } 479 entity.dir = value.direction.map { NSNumber(value: $0 == .down ? 1 : 0) } 480 } 481 482 /// Maps a persisted row to its in-memory value. `internal` (not `private`) 483 /// so `RecordBuilder` can reconstruct the upload asset straight from Core 484 /// Data on its own background context. 485 nonisolated static func value(from entity: JournalEntity) -> JournalValue { 486 JournalValue( 487 seq: entity.seq, 488 timestamp: entity.timestamp ?? .distantPast, 489 position: GridPosition(row: Int(entity.row), col: Int(entity.col)), 490 beforeState: entity.beforeLetter.map { 491 JournalCellState( 492 letter: $0, 493 mark: CellMark(code: entity.beforeMarkCode?.int16Value ?? 0), 494 cellAuthorID: entity.beforeCellAuthorID 495 ) 496 }, 497 state: JournalCellState( 498 letter: entity.letter ?? "", 499 mark: CellMark(code: entity.markCode), 500 cellAuthorID: entity.cellAuthorID 501 ), 502 actingAuthorID: entity.actingAuthorID, 503 kind: JournalKind(rawValue: entity.kind) ?? .input, 504 targetSeq: entity.targetSeq?.int64Value, 505 batchID: entity.batchID, 506 prevSeqAtCell: entity.prevSeqAtCell?.int64Value, 507 direction: entity.dir.map { $0.int16Value == 1 ? Puzzle.Direction.down : .across } 508 ) 509 } 510 } 511 512 /// Wire format for a device's journal, encoded once at game completion and 513 /// uploaded as the `Journal` record's `entries` asset (Phase 2). A faithful 514 /// dump of `[JournalValue]` in `seq` order — merging every device's decoded 515 /// dump by `timestamp` reconstructs the whole game for replay. The mark is 516 /// carried as the single lossless `markCode` (`CellMark.code`). 517 enum JournalCodec { 518 /// Upper bound on one device's encoded `entries` blob. Each entry is a 519 /// single keystroke at ~200 bytes of JSON, so 4 MiB is over 20,000 moves — 520 /// far beyond any real solve — while keeping a peer-controlled asset from 521 /// forcing an arbitrarily large read and decode. Callers that read the 522 /// blob from a `CKAsset` file must also gate on the on-disk size *before* 523 /// loading it (`RecordSerializer.boundedAssetData`); this bound caps the 524 /// decode work itself. 525 static let maxAssetBytes = 4_194_304 526 527 /// Upper bound on decoded entries per device journal. Redundant with the 528 /// byte cap for honest JSON, but bounds the `JournalValue` allocation and 529 /// every later per-entry merge/replay pass independently of encoding 530 /// tricks. 531 static let maxEntryCount = 20_000 532 533 /// A journal blob that exceeds the decode bounds. The whole blob is 534 /// rejected (not truncated): a partial journal would silently corrupt the 535 /// merged replay timeline, and the caller already treats a missing device 536 /// journal as "replay unavailable". 537 enum LimitError: Error, CustomStringConvertible { 538 case oversized(bytes: Int) 539 case tooManyEntries(count: Int) 540 541 var description: String { 542 switch self { 543 case .oversized(let bytes): 544 return "journal blob exceeds \(maxAssetBytes) bytes (\(bytes))" 545 case .tooManyEntries(let count): 546 return "journal blob exceeds \(maxEntryCount) entries (\(count))" 547 } 548 } 549 } 550 551 struct Payload: Codable, Equatable { 552 struct Entry: Codable, Equatable { 553 let seq: Int64 554 let timestamp: Date 555 let row: Int 556 let col: Int 557 let letter: String 558 let markCode: Int16 559 let cellAuthorID: String? 560 let actingAuthorID: String? 561 let kind: Int16 562 let targetSeq: Int64? 563 let batchID: UUID? 564 let prevSeqAtCell: Int64? 565 let dir: Int? 566 567 init( 568 seq: Int64, 569 timestamp: Date, 570 row: Int, 571 col: Int, 572 letter: String, 573 markCode: Int16, 574 cellAuthorID: String?, 575 actingAuthorID: String?, 576 kind: Int16, 577 targetSeq: Int64?, 578 batchID: UUID?, 579 prevSeqAtCell: Int64?, 580 dir: Int? 581 ) { 582 self.seq = seq 583 self.timestamp = timestamp 584 self.row = row 585 self.col = col 586 self.letter = letter 587 self.markCode = markCode 588 self.cellAuthorID = cellAuthorID 589 self.actingAuthorID = actingAuthorID 590 self.kind = kind 591 self.targetSeq = targetSeq 592 self.batchID = batchID 593 self.prevSeqAtCell = prevSeqAtCell 594 self.dir = dir 595 } 596 597 // Optionals are decoded leniently so a record written by a newer 598 // client that added fields still decodes cleanly on an older one 599 // (same forward-compat stance as `MovesCodec.Payload.Entry`). 600 init(from decoder: Decoder) throws { 601 let c = try decoder.container(keyedBy: CodingKeys.self) 602 seq = try c.decode(Int64.self, forKey: .seq) 603 timestamp = try c.decode(Date.self, forKey: .timestamp) 604 row = try c.decode(Int.self, forKey: .row) 605 col = try c.decode(Int.self, forKey: .col) 606 letter = try c.decode(String.self, forKey: .letter) 607 markCode = try c.decode(Int16.self, forKey: .markCode) 608 kind = try c.decode(Int16.self, forKey: .kind) 609 cellAuthorID = try? c.decode(String.self, forKey: .cellAuthorID) 610 actingAuthorID = try? c.decode(String.self, forKey: .actingAuthorID) 611 targetSeq = try? c.decode(Int64.self, forKey: .targetSeq) 612 batchID = try? c.decode(UUID.self, forKey: .batchID) 613 prevSeqAtCell = try? c.decode(Int64.self, forKey: .prevSeqAtCell) 614 dir = try? c.decode(Int.self, forKey: .dir) 615 } 616 } 617 let entries: [Entry] 618 } 619 620 static func encode(_ values: [JournalValue]) throws -> Data { 621 let entries = values 622 .sorted { $0.seq < $1.seq } 623 .map { value in 624 Payload.Entry( 625 seq: value.seq, 626 timestamp: value.timestamp, 627 row: value.position.row, 628 col: value.position.col, 629 letter: value.state.letter, 630 markCode: value.state.mark.code, 631 cellAuthorID: value.state.cellAuthorID, 632 actingAuthorID: value.actingAuthorID, 633 kind: value.kind.rawValue, 634 targetSeq: value.targetSeq, 635 batchID: value.batchID, 636 prevSeqAtCell: value.prevSeqAtCell, 637 dir: value.direction?.rawValue 638 ) 639 } 640 return try JSONEncoder().encode(Payload(entries: entries)) 641 } 642 643 static func decode(_ data: Data) throws -> [JournalValue] { 644 guard data.count <= maxAssetBytes else { 645 throw LimitError.oversized(bytes: data.count) 646 } 647 let payload = try JSONDecoder().decode(Payload.self, from: data) 648 guard payload.entries.count <= maxEntryCount else { 649 throw LimitError.tooManyEntries(count: payload.entries.count) 650 } 651 return payload.entries.compactMap { entry in 652 let position = GridPosition(row: entry.row, col: entry.col) 653 // A remote journal's coordinates are attacker-controlled; entries 654 // that can't survive `assign`'s Int16 conversions are dropped here 655 // so they never reach the replay cache. 656 guard position.isInt16Representable else { return nil } 657 return JournalValue( 658 seq: entry.seq, 659 timestamp: entry.timestamp, 660 position: position, 661 beforeState: nil, 662 state: JournalCellState( 663 letter: entry.letter, 664 mark: CellMark(code: entry.markCode), 665 cellAuthorID: entry.cellAuthorID 666 ), 667 actingAuthorID: entry.actingAuthorID, 668 kind: JournalKind(rawValue: entry.kind) ?? .input, 669 targetSeq: entry.targetSeq, 670 batchID: entry.batchID, 671 prevSeqAtCell: entry.prevSeqAtCell, 672 direction: entry.dir.flatMap(Puzzle.Direction.init(rawValue:)) 673 ) 674 } 675 } 676 }