crossmate

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

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.