crossmate

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

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 }