Journal.md (10580B)
1 # Move Journal — Design 2 3 A local, append-only log of every grid move, plus undo/redo built on top of it. 4 Phase 1 is entirely local (Core Data only, no CloudKit). Phase 2a uploads each 5 device's journal at game completion so the whole game can be replayed; it is 6 **built**. Phase 2b (the replay viewer that merges all devices' journals) and 7 the undo completion-lockout are still deferred — see "Remaining work" below. 8 9 ## Goals 10 11 1. **Undo / redo during play.** A player can walk back their own moves, with no 12 fixed depth limit. Undo applies a *forward* mutation through the normal 13 `GameMutator` path, so it syncs to collaborators like any other edit — it is 14 not a time machine. 15 2. **Replay after completion (Phase 2).** Merging every device's uploaded 16 journal by timestamp reconstructs the full game, including corrections. 17 18 **Every grid-changing move is recorded** (so replay is faithful), but only a 19 subset is **undoable**: letter `input` (adds/single-cell deletes) and `clear` 20 (the bulk clear gesture). `check` and `reveal` are recorded for replay but never 21 offered as undo steps — they're help actions, not edits you rewind. 22 23 ## Why a new structure (not the Moves record) 24 25 `MovesValue.cells` (`Sync/Moves.swift`) is a *compacted current-state* map — 26 `[GridPosition: TimestampedCell]`, only the latest touch per cell survives. It 27 carries no history, so neither undo nor replay can be reconstructed from it. 28 Snapshotting the whole Moves value per move is O(moves × cells) — tens of MB for 29 a single game — and is also collaboratively wrong (restoring a snapshot rewrites 30 every cell, clobbering peers). Instead we log **one entry per changed cell**, in 31 the Moves cell shape, and recover the "before" value via a back-pointer. 32 33 ## Data model — `JournalEntity` 34 35 Local Core Data entity, cascade-deleted with its `GameEntity`. Append-only; 36 rows are never mutated or deleted in normal operation. 37 38 | Field | Type | Purpose | 39 |---|---|---| 40 | `gameID` | UUID | game scope | 41 | `seq` | Int64 | monotonic per game; stable local ordering + undo walk | 42 | `timestamp` | Date | wall-clock; Phase 2 replay ordering + cross-device merge | 43 | `row`, `col` | Int16 | cell | 44 | `letter` | String | **after**-state letter | 45 | `markCode` | Int16 | after-state `CellMark` as one lossless code (see `CellMarkCodec.code`) | 46 | `cellAuthorID` | String? | preserved letter author (mirrors `emitMove`) | 47 | `actingAuthorID` | String? | iCloud user who made the move | 48 | `kind` | Int16 | 0 input / 1 check / 2 reveal / 3 clear / 4 undo / 5 redo (`input` + `clear` are undoable) | 49 | `targetSeq` | Int64? | for undo/redo, the entry it acts on | 50 | `batchID` | UUID? | groups one gesture (bulk check/reveal/clear, or one undo/redo op) into a single undo step | 51 | `prevSeqAtCell` | Int64? | seq of the previous entry at this `(row,col)`; `nil` = cell was empty before | 52 53 There is **no stored before-state**. To undo entry `E`, follow `E.prevSeqAtCell` 54 to one prior entry and apply *its* after-state (or empty if `nil`). O(1) keyed 55 lookup, no scan; after-states stay the single source of truth and the pointer 56 can never go stale because the log is immutable. 57 58 Adding the entity is an additive, automatic lightweight migration. Unlike the 59 synced Moves wire format (which flattens the mark into `markKind` + two check 60 bools), the journal stores the whole `CellMark` as one `markCode` — the 61 two-bool form is a wart we chose not to propagate into new local storage. The 62 shared `CellMarkCodec` now owns both encodings. 63 64 ## `MovesJournal` 65 66 `@MainActor` class, a shared singleton beside `MovesUpdater`. Holds the current 67 game's entries in memory (loaded lazily from Core Data on first use, so undo 68 survives relaunch), persists each new entry on a background context (the 69 in-memory list is authoritative for the session, so async persistence is fine). 70 71 - `record(...)` — assigns `seq` (= max+1), looks up & updates a 72 `[GridPosition: Int64]` last-seq map to set `prevSeqAtCell`, appends in memory, 73 persists. 74 - `planUndo(gameID:)` / `planRedo(gameID:)` — derive the next step and return 75 per-cell restore instructions; pure, no mutation of state. 76 - `canUndo` / `canRedo`. 77 78 ### Undo/redo derivation 79 80 One pass over the in-memory entries (seq order), grouping consecutive entries 81 that share a `batchID`/kind into *operations*, then a stack machine: 82 83 ``` 84 for op in operations: 85 userEdit -> redoStack = []; liveStack.append(op) // new edit cuts redo branch 86 undo -> if let s = liveStack.popLast() { redoStack.append(s) } 87 redo -> if let s = redoStack.popLast() { liveStack.append(s) } 88 ``` 89 90 `liveStack.last` is the next thing to undo; `redoStack.last` the next to redo. 91 Undone entries are never deleted — replay keeps the true history — the 92 derivation just stops offering them once a new edit cuts the branch. 93 94 ### The superseded guard 95 96 Each restore instruction carries the entry's after-state as `expectedCurrent`, 97 which `GameMutator` compares (by **letter only**) to the live `game` cell. If 98 the letter differs, a collaborator (or a later edit) changed the cell since, so 99 we **skip that cell** — this stops undo from ever clobbering someone else's 100 current letter. Only the letter is compared so that a peer's check/reveal (a 101 mark-only change) doesn't block undoing one's own letter. In a batch, superseded 102 cells are skipped individually; the step still counts as undone. 103 104 ## `GameMutator` / `Game` hooks 105 106 - `emitMove` gains `journalKind` + `batchID` + `targetSeq` and, alongside the 107 existing sync enqueue, calls `movesJournal.record(...)` with the after-state. 108 A move is recorded only when the cell's state actually changed. 109 - `setLetter`/`clearLetter` → `kind = .input`, `batchID = nil` (one step each). 110 `checkCells`/`revealCells`/`clearCells` → `kind = .check`/`.reveal`/`.clear`, 111 one shared `batchID`. Only `.input` and `.clear` are undoable; `.check`/ 112 `.reveal` are recorded for replay but inert to the stack machine. 113 - `undo()` / `redo()`: ask the journal for a plan, apply surviving instructions 114 via a new low-level `Game.applyCellState(...)` (sets entry + mark + author 115 directly; can override the reveal lock), emitting each as `kind = .undo`/ 116 `.redo` under one op `batchID`. 117 - `canUndo` / `canRedo` drive the UI control's enabled state. 118 119 ## Open questions / notes 120 121 - **Undo control UI.** Buttons in `PuzzleView` are deferred to follow-up. 122 - **`recordedEntries(gameID:)`** exposes the log read-only; it's a seam Phase 2b 123 replay can read locally (the cross-device merge reads uploaded assets). 124 125 ## Phase 2a — journal upload (built) 126 127 At game completion (win or resign, in `GameStore`), each device encodes its 128 whole local `JournalEntity` log via `JournalCodec` (JSON) and pushes it as a 129 `CKAsset` on a `Journal` record — one per `(game, authorID, deviceID)`, named 130 `journal-<gameID>-<authorID>-<deviceID>`, written into the game's existing zone. 131 It mirrors the `Moves` record path and is **write-once with no system-fields 132 archive** (like `Ping`/`Decision`): `buildRecord` reconstructs the asset from the 133 durable `JournalEntity` rows, so a pending upload survives an app kill, and 134 `MovesJournal.flush()` is awaited before enqueue to beat the journal's async 135 persistence. Inbound `Journal` records are deliberately ignored (`case 136 "Journal": break`) — 2b fetches them on demand instead of applying peers' logs. 137 138 `JournalCodec` carries the mark as the single lossless `markCode` (matching 139 `JournalEntity`), not the `Moves` two-bool flattening, and decodes optionals 140 leniently for forward-compat. Key files: `Persistence/Journal.swift`, 141 `Sync/RecordSerializer.swift`, `Sync/RecordBuilder.swift`, `Sync/SyncEngine.swift` 142 (`enqueueJournalUpload`), `Persistence/GameStore.swift`, `Services/AppServices.swift`. 143 144 CloudKit schema: the `Journal` record type (`authorID` String, `deviceID` 145 String, `updatedAt` Date/Time, `entries` Asset) was added manually and deployed 146 to Development and Production on 2026-05-30. No new zone. 147 148 ## Phase 2b — cross-device replay data layer (built) 149 150 The read side of replay. `SyncEngine.fetchReplay(forGameID:)` (in 151 `Sync/CloudQuery.swift`) runs two zone-scoped `CKQuery`s against the finished 152 game's zone — one for `Journal` records (decoding each `entries` asset via 153 `JournalCodec`) and one for `Moves` records (keys only) — and returns a 154 `JournalReplayFetch { journals, expectedDevices }`. It's a plain query, so it 155 never disturbs the sync engine's change token; inbound `Journal` records stay 156 ignored in the delegate path. 157 158 `Persistence/JournalReplay.swift` holds the pure, unit-tested core: 159 - `ReplayTimeline(merging:)` flattens every device's log and orders it by 160 `timestamp` (ties break on `actingAuthorID` then `seq`, so fetch order can't 161 change the result). `state(through:)` folds the first *n* steps last-write- 162 per-cell into `[GridPosition: JournalCellState]`. Forward replay needs only 163 each entry's after-state, so device-local `seq`/`prevSeqAtCell` are unused; 164 undo/check/reveal rows replay naturally as timestamped restores. 165 - `ReplayAssembler.assemble(fetch:localKey:localEntries:)` overlays this 166 device's *live* log over any uploaded copy of itself, then enforces **strict 167 completeness**: `.ready(timeline)` only when a journal is present for every 168 expected device, else `.waiting(missing:)`. `AppServices.loadReplay(gameID:)` 169 composes `fetchReplay` + `GameStore.localReplaySource` + the assembler and 170 maps an unreachable zone to `.unavailable`. 171 172 **Strict completeness needs late devices to upload.** Completion learned purely 173 via sync used to set `completedAt` without uploading the local journal, so a 174 device away at finish time would block replay forever. `applyGameRecord` now 175 signals a not-completed → completed transition (`onCompletedTransition`), and 176 both inbound apply paths enqueue this device's journal upload — so any 177 contributing device that ever syncs the finished game converges. Residual: a 178 device that wrote cells and is then permanently gone keeps replay `.waiting` 179 indefinitely (accepted trade-off; a "replay anyway" affordance could relax it). 180 181 ## Remaining work 182 183 - **Phase 3 — scrubber UI.** A scrubber inside the finish banner (above the 184 victory image + scoreboard in `SuccessPanel`), head starting hard-right at the 185 finished grid and dragging left to rewind. Drives the main `GridView` from 186 `ReplayTimeline.state(through:)`; disabled with a "waiting to sync history" 187 overlay while `loadReplay` returns `.waiting`. 188 - **Completion lockout.** Gate undo/redo off once the game's `completedAt` is 189 set, so a finished game can't be rewound.