From d7d036578766c17377da80a3893290eebe947d13 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Fri, 27 Mar 2026 12:51:47 +0800 Subject: [PATCH 1/5] Implement rollback timeline model --- docs/Rollback.md | 383 ++++++++++ docs/RollbackImplementationPlan.md | 703 ++++++++++++++++++ spec/system/actions/loadSaveSlot.spec.yaml | 122 +++ spec/system/actions/nextLine.spec.yaml | 147 ++++ .../actions/nextLineFromSystem.spec.yaml | 48 ++ .../system/actions/rollbackByOffset.spec.yaml | 136 ++++ spec/system/actions/saveSaveSlot.spec.yaml | 117 +++ .../actions/sectionTransition.spec.yaml | 14 +- spec/system/createInitialState.spec.yaml | 52 +- src/stores/system.store.js | 283 ++++++- 10 files changed, 1993 insertions(+), 12 deletions(-) create mode 100644 docs/Rollback.md create mode 100644 docs/RollbackImplementationPlan.md diff --git a/docs/Rollback.md b/docs/Rollback.md new file mode 100644 index 00000000..b934e844 --- /dev/null +++ b/docs/Rollback.md @@ -0,0 +1,383 @@ +# Rollback Design + +This document defines the intended product behavior and engine model for rollback in `route-engine`. + +It is a design document, not a guarantee that the current implementation already matches every rule below. + +## Purpose + +Rollback is a core reading control in a visual novel. + +Its job is to let the player move backward through prior line checkpoints and restore the corresponding story state so the game can be re-read or re-branch from that point. + +Rollback is not the same as dialogue history. + +- `Back` means rollback. +- `History` is a separate read-only feature and is out of scope for this document. + +## Product Summary + +The rollback model for `route-engine` is: + +- `Back` performs true rollback. +- Rollback is line-level. +- Rollback crosses section boundaries. +- Rollback policy is `free` for now. +- The model should be extensible to support additional policies later. +- Presentation is always reconstructed from story state after rollback. +- Seen-line tracking does not roll back. +- Persistent/global device/account variables do not roll back. +- Rollback stops auto mode and skip mode. +- If the user rolls back and then advances again, future rollback history after that point is discarded and replaced by the new branch. +- Rollback timeline is stored in save data and restored on load. + +## Terminology + +### Rollback + +Rollback means restoring a prior story checkpoint and making that checkpoint the current playable position. + +Rollback changes story state. + +### History + +History means showing previously displayed dialogue in a read-only UI. + +History does not change story state. + +History is a separate feature and should not be conflated with rollback. + +### Checkpoint + +A checkpoint is the unit the player can roll back to. + +In this design, checkpoints are line-level. + +Checkpoint state is defined as: + +- the state after rollbackable story actions for that line have been applied + +That means rollback reconstruction to a target checkpoint replays rollbackable story actions up to that target line inclusively. + +For v1, the authoritative replay source for a checkpoint is: + +- the project-data line identified by that checkpoint's `sectionId` and `lineId` +- filtered to rollbackable story mutations only + +The checkpoint entry itself does not need to duplicate the mutation payload as long as replay remains deterministic for the supported rollbackable action set. + +## Back Button Semantics + +The main `Back` action in reading UI should perform rollback, not history preview. + +Expected behavior: + +- If the player presses `Back`, the engine rolls back to the previous line checkpoint. +- This should work even if the current line is incomplete. +- `Back` should not first "complete the current line" and require a second press. + +Rationale: + +- Advance and rollback should have different semantics. +- Advancing is incremental and often reveal-aware. +- Rollback is an explicit undo/navigation action and should be decisive. + +## Rollback Policy + +Current product policy is: + +- `free` + +Meaning: + +- The player may roll back to an earlier checkpoint. +- The player may then advance again and make different decisions. +- The old future branch is discarded once the player diverges. + +Future policies should be possible without redesigning the entire model: + +- `fixed` + - player may roll back and inspect prior state + - prior decisions remain locked +- `blocked` + - player may not roll back before a specific checkpoint + +These policies are not part of the current behavior, but the internal checkpoint structure should leave room for them. + +## Checkpoint Granularity + +Checkpoints are line-level. + +That means: + +- every line that the player reaches becomes a rollback checkpoint +- rollback moves between lines, not between arbitrary internal action steps + +This is intentionally simple and predictable. + +For now, we do not introduce finer-grained checkpoint kinds. + +The checkpoint structure should remain extensible so future metadata can be attached if needed. + +## Cross-Section Rollback + +Rollback must cross section boundaries. + +If the player reached: + +- section A, line 10 +- then section B, line 1 + +pressing `Back` should allow rollback from section B back into section A. + +Rationale: + +- from the player's perspective, this is one continuous reading timeline +- restricting rollback to the current section feels arbitrary and broken + +This means rollback history must be modeled as a single ordered timeline per context, not only as isolated per-section history buckets. + +## Rollback Scope + +### State that rolls back + +Rollback restores: + +- current read pointer +- current section and line +- context-scoped story variables +- current branch position in the narrative + +### State that does not roll back + +Rollback does not restore: + +- seen-line registry +- seen-resource registry +- persistent/global device variables +- persistent/global account variables +- save slots +- external side effects outside engine state + +Rationale: + +- seen-state should be monotonic +- persistent/global variables are not story-local and should not behave like local branch state + +Consequence: + +- after rollback, previously seen content remains seen even if the player later diverges onto a different branch +- skip behavior may still treat previously seen branch content as seen + +This is intentional for v1. + +## Presentation Model + +Rollback must not restore stored presentation snapshots. + +Instead: + +- rollback restores rollbackable story state +- presentation is derived from that state +- render state is derived from presentation and system state + +This matches the core state-driven architecture of `route-engine`. + +Implications: + +- transient animation progress is not restored +- presentation after rollback is reconstructed, not resumed +- rollback correctness should be defined in terms of restored story state, not exact renderer internals + +## Divergence Rule + +If the player rolls back and then advances again from the rollback point: + +- all future checkpoints after that point are discarded +- a new future branch is created from the rollback point + +This is standard rollback behavior and keeps the model coherent. + +Without this rule, rollback history becomes ambiguous and harder to reason about. + +## Interaction with Auto and Skip + +Rollback should always stop: + +- auto mode +- skip mode + +Rationale: + +- rollback is an explicit navigation action +- continuing auto/skip after rollback is surprising and unsafe + +## Data Model Direction + +The data model should support the product rules above. + +### Required logical model + +Each context should have a single rollback timeline ordered by playthrough sequence. + +Conceptually: + +```js +rollback: { + currentIndex: 0, + isRestoring: false, + timeline: [ + { + sectionId: "section1", + lineId: "line1", + rollbackPolicy: "free", + }, + ], +} +``` + +The exact field names may differ, but the model should support: + +- ordered line checkpoints across section boundaries +- replaying rollbackable story actions from timeline history +- an array-index cursor for the current rollback position +- a temporary restore guard for rollback reconstruction +- future rollback policy expansion + +### Current checkpoint contents + +For now, checkpoint identity only needs: + +- `sectionId` +- `lineId` + +But the structure should be able to grow later with: + +- `rollbackPolicy` +- future interaction metadata +- future choice locking metadata + +## Restoration Strategy + +Rollbackable story state should be recomputed from full rollback history, not restored from per-checkpoint variable snapshots. + +The model is: + +- rollback timeline stores the visited line sequence +- each entry identifies a visited line by `sectionId` and `lineId` +- rollback restoration resets context-scoped story state to the context baseline +- the engine replays rollbackable story actions from the start of the timeline up to the target timeline index +- presentation is then reconstructed from the resulting story state + +This means: + +- presentation snapshots are never stored +- context variable snapshots are not the source of truth +- story state is derived from the rollback timeline + +For now, replayability is based on line sequence plus rollbackable action classification. + +Initially, the only required rollbackable story mutation type is: + +- `updateVariable` + +This can be expanded later if more rollbackable action types are introduced. + +## Restore Guard + +Rollback restoration requires an explicit temporary guard such as: + +```js +rollback.isRestoring = true; +``` + +This is an internal implementation flag, not a user-facing mode. + +Its purpose is to prevent the restore process from being mistaken for normal forward progression. + +During restore: + +- new rollback checkpoints must not be appended +- rollbackable story actions must not execute again as live gameplay actions +- seen-state must not be recomputed as fresh progression +- normal progression side effects must not be emitted + +Restore must use a dedicated rollback restore path. + +It must not reuse normal live line-action execution as if the player had just progressed forward. + +For v1, `jumpToLine` is excluded from rollback history. + +Rationale: + +- `jumpToLine` can be used for tooling, debugging, or non-player transport +- automatically recording it as player rollback history would pollute the rollback timeline + +Without this guard, rollback reconstruction can: + +- execute `updateVariable` more than once +- append duplicate timeline entries +- produce incorrect derived state + +## Non-Goals + +This document does not define: + +- dialogue history UI +- history retention rules +- fixed rollback behavior +- blocked rollback behavior +- roll-forward UX +- save/load UI behavior beyond normal state restoration + +## Save/Load + +Rollback timeline is part of saved runtime state. + +That means: + +- saves must serialize the rollback timeline +- saves must serialize the current rollback cursor +- loading a save must restore rollback ability from that saved point + +Compatibility behavior for older saves without rollback timeline should be defined during implementation. + +Recommended default: + +- if an older save does not contain rollback state, initialize a minimal rollback timeline at the loaded current pointer +- do not attempt to reconstruct older rollback history that was never saved + +Those can be defined separately later. + +## Implementation Notes + +The current engine already derives presentation from state, which is correct. + +However, a fully correct rollback system for this product model should move toward: + +- a per-context rollback timeline +- line-level checkpoints +- direct restoration of rollbackable story state +- future policy extensibility + +The current code may still contain older concepts such as: + +- history-mode pointers +- section-oriented history structures +- replay-based rollback logic + +Those should be treated as implementation details that can be replaced if they do not fit this design. + +## Review Checklist + +The design is correct if all of the following are true: + +- `Back` always means rollback +- rollback works on incomplete lines +- rollback crosses section boundaries +- rollback reconstructs presentation from story state +- rollback does not affect seen-state +- rollback does not affect persistent/global device/account variables +- rollback stops auto/skip +- re-advancing after rollback discards the old future branch +- the internal checkpoint model can later support `fixed` and `blocked` policies diff --git a/docs/RollbackImplementationPlan.md b/docs/RollbackImplementationPlan.md new file mode 100644 index 00000000..f04e984c --- /dev/null +++ b/docs/RollbackImplementationPlan.md @@ -0,0 +1,703 @@ +# Rollback Implementation Plan + +This document translates [Rollback.md](/home/han4wluc/repositories/RouteVN/route-engine/docs/Rollback.md) into an implementation plan for `route-engine`. + +It is intentionally technical. + +## Goal + +Replace the current mixed "history mode + section replay" rollback behavior with a true rollback system that: + +- uses line-level checkpoints +- supports cross-section rollback +- recomputes rollbackable story state from full rollback history +- reconstructs presentation after restoration +- remains extensible for future rollback policies + +## Scope + +In scope: + +- rollback model and engine actions +- checkpoint storage +- pointer restoration +- branch truncation after rollback +- stopping auto/skip on rollback +- tests and docs + +Out of scope: + +- dialogue history UI +- fixed rollback +- blocked rollback +- roll-forward +- save UI changes + +## Current State + +The current engine has two different backward-navigation concepts: + +1. `prevLine` +- moves a `history` pointer +- behaves like preview/history navigation +- does not represent the intended final `Back` semantics + +2. `rollbackToLine` / `rollbackByOffset` +- resets context variables to a section `initialState` +- replays recorded `updateVariable` actions +- is limited by the current section-oriented history structure + +The implementation currently depends on: + +- `contexts[*].historySequence` +- `pointers.read` +- `pointers.history` +- `currentPointerMode` +- event-sourced replay of context variable mutations inside a section + +This is not the right long-term model for: + +- cross-section rollback +- line-level checkpoints across the full playthrough +- future rollback policy expansion + +## Target Model + +Each context should own one ordered rollback timeline. + +Conceptually: + +```js +contexts: [ + { + pointers: { + read: { sectionId, lineId }, + }, + rollback: { + currentIndex: 12, + isRestoring: false, + timeline: [ + { + sectionId: "intro", + lineId: "line1", + rollbackPolicy: "free", + }, + ], + }, + }, +]; +``` + +### Required properties + +Each checkpoint should eventually be able to hold: + +- `sectionId` +- `lineId` +- `rollbackPolicy` + +For initial implementation: + +- the array index is the sequence identity +- `rollbackPolicy` can default to `"free"` + +No separate `sequenceId` is required as long as: + +- timeline order is preserved +- `currentIndex` is the rollback cursor + +## High-Level Approach + +Use the visited-line timeline as the source of truth for rollbackable story state. + +Specifically: + +- store visited lines in playthrough order +- do not snapshot presentation +- do not treat variable snapshots as the source of truth +- on rollback: + - reset context variables to the context baseline + - replay rollbackable story actions from the start of the timeline up to the target index + - move `read` pointer to the target checkpoint + - reconstruct presentation/render state from restored story state + +For now, the only required rollbackable story mutation type is: + +- `updateVariable` + +Implementation default for v1: + +- replay source is the project-data line actions for each visited checkpoint +- replay only the rollbackable subset of those actions +- do not duplicate mutation payloads into checkpoint entries yet + +Checkpoint semantics must be defined as: + +- checkpoint N represents state after rollbackable story actions for line N have been applied + +So rollback replay to target index is inclusive. + +This can be expanded later if more rollbackable action types are introduced. + +## Restore Guard + +Rollback restoration requires an explicit temporary guard: + +```js +rollback.isRestoring = true; +``` + +This is an internal implementation flag, not a user-facing mode. + +Its purpose is to prevent the restore process from being mistaken for normal forward progression. + +During restore: + +- do not append new rollback checkpoints +- do not execute normal live mutation side effects +- do not double-apply `updateVariable` +- do not perform fresh progression bookkeeping + +Without this guard, rollback reconstruction can: + +- execute story mutations more than once +- append duplicate timeline entries +- corrupt recomputed state + +Implementation rule: + +- rollback restore must use a dedicated restore path +- it must not rely on normal live line-action execution + +## Data Structure Migration + +### Phase 1: Introduce rollback timeline alongside current structures + +Add a new context-local structure: + +```js +rollback: { + currentIndex: 0, + isRestoring: false, + timeline: [], +} +``` + +Checkpoint shape: + +```js +{ + sectionId, + lineId, + rollbackPolicy: "free", +} +``` + +Implementation notes: + +- checkpoint creation should happen through one internal helper, not inline in many actions +- `rollbackPolicy` should be present or defaultable from one helper +- `currentIndex` should be the array-index cursor into `timeline` + +Current pointer rule: + +- `rollback.currentIndex` is the single source of truth for rollback position inside the timeline +- array order is the sequence identity + +### Phase 2: Keep old history structures temporarily + +Do not remove these immediately: + +- `historySequence` +- `pointers.history` +- `currentPointerMode` +- `prevLine` + +Reason: + +- they may still be needed by existing tests or non-rollback flows +- history/log is a separate feature and may still temporarily depend on some of them + +But rollback actions should stop depending on them as the new timeline becomes authoritative. + +Recommended deprecation stance: + +- `prevLine` and history pointers may remain temporarily for non-rollback features +- they should no longer be considered part of gameplay back semantics + +### Phase 3: Decommission old rollback dependencies + +Once rollback actions and tests are migrated: + +- remove rollback dependence on `historySequence` +- remove rollback dependence on `pointers.history` +- stop using section-oriented replay for rollback + +Possible later cleanup: + +- fully remove `currentPointerMode === "history"` if it is no longer used by any feature + +That decision should be made separately from rollback. + +## Checkpoint Creation Rules + +Checkpoints are line-level. + +A checkpoint should exist for each line the player reaches in read flow. + +### Required checkpoint creation points + +1. Engine init +- create initial checkpoint for the initial line + +2. `nextLine` +- after advancing to the new line + +3. `nextLineFromSystem` +- after advancing to the new line + +4. `sectionTransition` +- after landing on the destination section's first line + +5. `jumpToLine` +- excluded from rollback timeline for now + +Rationale: + +- `jumpToLine` can be used for tooling, debugging, or non-player transport +- automatically recording it as player rollback history would pollute the timeline + +### Rule for timing + +Checkpoint should represent the line the player is now on, not the line they left. + +That keeps rollback semantics intuitive: + +- back from line 10 goes to line 9 +- current line is always represented in the timeline + +### Deduplication rule + +Do not create duplicate adjacent checkpoints for the same: + +- `sectionId` +- `lineId` + +unless future policy metadata requires it. + +Use one helper like: + +```js +appendRollbackCheckpoint(state, { + sectionId, + lineId, + rollbackPolicy: "free", +}); +``` + +The helper should also: + +- truncate future timeline if `currentIndex` is not already at the end +- append the new checkpoint +- move `currentIndex` to the new last index + +## Replay Rules + +Rollback reconstruction should replay only rollbackable story actions. + +It must not replay arbitrary line actions as if they were live gameplay. + +### Initial rollbackable action set + +Start with: + +- `updateVariable` + +Future rule: + +- any new story-mutating action must explicitly declare whether it is rollbackable before it participates in rollback replay + +### Non-replayed actions during restore + +Do not replay as story mutations during restore: + +- dialogue presentation actions +- background/character/visual/layout presentation actions +- seen-state updates +- persistent/global variable writes +- save-related effects +- any other forward-progression bookkeeping + +This is why rollback restore must not call the normal live line-action path. + +Presentation should still appear correctly because it is derived from the restored story state and current pointer. + +## Rollback Action Design + +### Replace `prevLine` as gameplay back + +`Back` in gameplay should no longer use `prevLine`. + +Instead: + +- UI-facing back action should call `rollbackByOffset({ offset: -1 })` + +`prevLine` may remain temporarily for history/log work, but it should not be the primary gameplay back implementation. + +### `rollbackByOffset` + +Target behavior: + +1. validate offset is negative +2. find target checkpoint from `rollback.currentIndex + offset` +3. if out of bounds, no-op +4. delegate to one internal restore helper + +### `restoreRollbackCheckpoint` + +Introduce an internal restore helper: + +```js +restoreRollbackCheckpoint(state, checkpointIndex) +``` + +Responsibilities: + +1. stop auto mode +2. stop skip mode +3. set `rollback.isRestoring = true` +4. set `rollback.currentIndex` +5. reset context variables to the context baseline +6. replay rollbackable actions from `timeline[0..checkpointIndex]` +7. restore `pointers.read` +8. set `rollback.isRestoring = false` +9. clear any stale timers via pending effects +10. set line completion state appropriately +11. queue normal render path + +The target line must be rendered from reconstructed state, not by replaying normal live gameplay actions. + +### Line completion on rollback + +Policy from product spec: + +- rollback should go immediately to previous checkpoint +- no "complete current line first" behavior + +Recommended engine state after rollback: + +- `isLineCompleted = true` + +Reason: + +- the rolled-back line should be visible immediately +- the engine should not require reveal completion before another rollback +- presentation is reconstructed, not resumed + +If later you want rolled-back lines to reveal again, that should be an explicit separate product decision. + +## Branch Truncation + +When the user rolls back and then advances again: + +- all checkpoints after `rollback.currentIndex` must be discarded before appending the new forward checkpoint + +This should happen in the checkpoint append helper. + +Pseudo-logic: + +```js +if (rollback.currentIndex < rollback.timeline.length - 1) { + rollback.timeline = rollback.timeline.slice(0, rollback.currentIndex + 1); +} +``` + +Then append the new checkpoint and move `currentIndex` to the end. + +This is required for coherent free rollback. + +This also means: + +- after divergence, old future checkpoints are permanently discarded from the active rollback timeline + +## Variable Scope Rules + +Rollback restores: + +- context-scoped variables only, derived by replay from the timeline + +Rollback does not restore: + +- global-device variables +- global-account variables +- seen registries + +Implementation rule: + +- rollback replay should only affect context-scoped rollbackable story actions +- do not mix global persistent variables into rollback reconstruction + +Cross-branch consequence: + +- previously seen content remains seen even after divergence +- this is intentional and should not be treated as a rollback bug + +## Presentation and Rendering Flow + +Rollback must keep the existing architectural principle: + +- system state changes first +- presentation is derived +- render state is derived +- route-graphics renders the result + +So rollback should not attempt to store or restore: + +- presentation state +- render state +- animation progress + +Instead: + +1. reset story state to the context baseline +2. replay rollbackable timeline entries up to target index +3. restore pointer to target line +4. queue normal render effects +5. let the engine derive presentation/render state fresh + +Do not restore by executing arbitrary line actions as if the user had just progressed normally. + +Restore should use: + +- timeline replay for rollbackable story actions +- normal state-to-presentation derivation for rendering + +## Action Changes + +### `nextLine` + +Update to: + +- advance pointer +- reset line completion state as usual +- append checkpoint for destination line + +### `nextLineFromSystem` + +Update to: + +- advance pointer +- append checkpoint for destination line + +### `sectionTransition` + +Update to: + +- move pointer to target section first line +- append checkpoint for destination line + +### `jumpToLine` + +Do not append rollback checkpoints for `jumpToLine` in the initial implementation. + +Treat it as out of the player rollback flow unless it is later reclassified as true gameplay navigation. + +### `rollbackByOffset` + +Rewrite to operate on the rollback timeline, not `historySequence`. + +### `rollbackToLine` + +Either: + +- rewrite it as a lookup into the new timeline by line identity plus occurrence + +or: + +- deprecate it in favor of checkpoint-index-based restore helpers + +Recommendation: + +- keep public API if needed +- internally resolve to checkpoint index in the rollback timeline + +## Save/Load + +Rollback timeline should be saved and loaded as part of context state. + +That is necessary for: + +- consistent rollback after load +- preserving branch position correctly + +Implementation requirement: + +- the new `rollback` context structure must be serializable +- it must not contain renderer objects or non-serializable references +- loading a save must restore both: + - `rollback.timeline` + - `rollback.currentIndex` + +Compatibility requirement: + +- define explicit behavior for older saves that do not contain rollback state + +Recommended default: + +- initialize a minimal rollback timeline anchored at the loaded current pointer +- do not attempt to synthesize unsaved historical checkpoints + +## Future Policy Expansion + +Even though only `free` is implemented now, add a small abstraction boundary. + +Recommended helper: + +```js +const resolveRollbackPolicy = (checkpoint) => + checkpoint.rollbackPolicy ?? "free"; +``` + +Later: + +- `free` +- `fixed` +- `blocked` + +can be handled in one place without redesigning checkpoint storage. + +## Test Plan + +### Unit/system tests + +Add or migrate tests for: + +1. initial checkpoint exists at engine start +2. manual line advance appends checkpoint +3. auto/skip/system advance appends checkpoint +4. section transition appends checkpoint +5. rollback by offset moves to previous line in same section +6. rollback by offset crosses section boundary +7. rollback recomputes context variables from timeline replay +8. rollback does not restore persistent globals +9. rollback does not restore seen registry +10. rollback stops auto mode +11. rollback stops skip mode +12. rollback on incomplete line goes immediately to previous checkpoint +13. re-advance after rollback truncates old future branch +14. duplicate adjacent checkpoints are not appended +15. `rollback.isRestoring` prevents duplicate checkpoint append +16. `rollback.isRestoring` prevents duplicate `updateVariable` execution +17. old-save compatibility initializes a minimal rollback timeline correctly + +### Regression tests for divergence + +This is important enough to test directly: + +1. reach A -> B -> C +2. rollback to B +3. make a different choice / branch to D +4. assert old C checkpoint is gone +5. assert timeline is A -> B -> D + +### Serialization tests + +Add tests that: + +1. save a state with rollback timeline +2. load it +3. rollback still works correctly afterward + +## Migration Sequence + +Recommended order: + +1. add rollback timeline structure to context state +2. add `appendRollbackCheckpoint` helper +3. create checkpoints on init and forward navigation +4. add branch truncation logic +5. add `rollback.isRestoring` guard and restore helper +6. implement replay-based rollback reconstruction from timeline start +7. switch `rollbackByOffset` to new timeline +8. switch UI-facing back flows to true rollback +9. stop rollback logic from depending on `historySequence` +10. update docs and tests +11. evaluate whether `prevLine` / `history` pointer can be simplified or removed later + +## Risks + +### 1. Double checkpoint creation + +If both navigation action and line handling append checkpoints, timeline will drift. + +Mitigation: + +- centralize checkpoint appending in one helper +- write adjacency dedupe tests + +### 2. Hidden dependence on `historySequence` + +Some current selectors/tests may still depend on old structures. + +Mitigation: + +- migrate rollback tests first +- keep old structures temporarily for unrelated features + +### 3. Save compatibility + +Changing context shape may affect older saves. + +Mitigation: + +- decide whether to support a compatibility shim +- if not, document the save compatibility break clearly + +### 4. Branch truncation bugs + +If future checkpoints are not trimmed correctly, rollback semantics become incoherent. + +Mitigation: + +- add explicit branch-divergence tests + +### 5. Incorrect replay classification + +If non-rollbackable actions are replayed as story mutations, restore will corrupt state. + +Mitigation: + +- explicitly classify rollbackable actions +- start with `updateVariable` only +- add restore-guard regression tests + +### 6. Unbounded timeline growth + +Rollback history is intentionally unbounded for now. + +Mitigation: + +- accept this as a product tradeoff in v1 +- keep checkpoint entries minimal +- defer compaction/pruning work until there is evidence it is needed + +## Recommended First Implementation Slice + +The smallest valuable vertical slice is: + +1. add rollback timeline with array-index cursor +2. append checkpoints on init, `nextLine`, `nextLineFromSystem`, and `sectionTransition` +3. add `rollback.isRestoring` +4. implement replay-based `rollbackByOffset(-1)` on the new timeline +5. stop auto/skip on rollback +6. support cross-section rollback +7. add system tests for same-section and cross-section rollback + +Do not start with: + +- fixed rollback +- blocked rollback +- history UI +- roll-forward + +Those should come later. diff --git a/spec/system/actions/loadSaveSlot.spec.yaml b/spec/system/actions/loadSaveSlot.spec.yaml index 86ce6237..e65e4e54 100644 --- a/spec/system/actions/loadSaveSlot.spec.yaml +++ b/spec/system/actions/loadSaveSlot.spec.yaml @@ -41,6 +41,15 @@ out: contexts: - pointers: read: { sectionId: "sec2", lineId: "line2" } + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "sec2" + lineId: "line2" + rollbackPolicy: "free" --- case: load from non-existent slot in: @@ -172,3 +181,116 @@ out: playerName: "Alice" score: 150 soundEnabled: true + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 1 + baselineVariables: + playerName: "Alice" + score: 150 + soundEnabled: true + timeline: + - sectionId: "sec2" + lineId: "line2" + rollbackPolicy: "free" +--- +case: load preserves rollback timeline from save data +in: + - state: + global: + saveSlots: + "4": + slotKey: "4" + date: 1704110600 + image: "save4.png" + state: + viewedRegistry: + sections: + - sectionId: "sec2" + lastLineId: "line3" + resources: [] + contexts: + - pointers: + read: { sectionId: "sec2", lineId: "line3" } + variables: + score: 15 + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "sec1" + lineId: "line1" + rollbackPolicy: "free" + - sectionId: "sec1" + lineId: "line2" + rollbackPolicy: "free" + - sectionId: "sec2" + lineId: "line3" + rollbackPolicy: "free" + pendingEffects: [] + - slot: 4 +out: + global: + saveSlots: + "4": + slotKey: "4" + date: 1704110600 + image: "save4.png" + state: + viewedRegistry: + sections: + - sectionId: "sec2" + lastLineId: "line3" + resources: [] + contexts: + - pointers: + read: { sectionId: "sec2", lineId: "line3" } + variables: + score: 15 + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "sec1" + lineId: "line1" + rollbackPolicy: "free" + - sectionId: "sec1" + lineId: "line2" + rollbackPolicy: "free" + - sectionId: "sec2" + lineId: "line3" + rollbackPolicy: "free" + viewedRegistry: + sections: + - sectionId: "sec2" + lastLineId: "line3" + resources: [] + pendingEffects: + - name: "render" + contexts: + - pointers: + read: { sectionId: "sec2", lineId: "line3" } + variables: + score: 15 + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "sec1" + lineId: "line1" + rollbackPolicy: "free" + - sectionId: "sec1" + lineId: "line2" + rollbackPolicy: "free" + - sectionId: "sec2" + lineId: "line3" + rollbackPolicy: "free" diff --git a/spec/system/actions/nextLine.spec.yaml b/spec/system/actions/nextLine.spec.yaml index 3298f047..b185b947 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -180,6 +180,18 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- case: move to next line keeps fromStart scene auto running in: @@ -255,6 +267,18 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- case: move to next line resets singleLine applyMode config in: @@ -323,6 +347,117 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" +--- +case: move to next line truncates future rollback history after divergence +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: {} + - id: "2" + actions: {} + - id: "3" + actions: {} + - id: "4" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "4" + rollbackPolicy: "free" +out: + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + pendingEffects: + - name: "handleLineActions" + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "2" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: {} + - id: "2" + actions: {} + - id: "3" + actions: {} + - id: "4" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "3" + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" --- case: already at last line (no change) in: @@ -676,6 +811,18 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- case: complete line with scene mode (fromComplete) restarts scene timer in: diff --git a/spec/system/actions/nextLineFromSystem.spec.yaml b/spec/system/actions/nextLineFromSystem.spec.yaml index 9016d43e..322908fc 100644 --- a/spec/system/actions/nextLineFromSystem.spec.yaml +++ b/spec/system/actions/nextLineFromSystem.spec.yaml @@ -104,6 +104,18 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- case: advance to next line when auto is enabled with fromStart trigger (re-queues timer) in: @@ -167,6 +179,18 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- case: advance resets singleLine applyMode config and does not re-queue timer in: @@ -231,6 +255,18 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- case: advance to next line when auto is enabled with fromComplete trigger (does not re-queue timer) in: @@ -291,6 +327,18 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- case: at last line of section with auto disabled in: diff --git a/spec/system/actions/rollbackByOffset.spec.yaml b/spec/system/actions/rollbackByOffset.spec.yaml index 7c0d91cd..5737bf3e 100644 --- a/spec/system/actions/rollbackByOffset.spec.yaml +++ b/spec/system/actions/rollbackByOffset.spec.yaml @@ -375,3 +375,139 @@ out: updateVariableIds: - "act1" - id: "3" +--- +case: rolls back across sections using rollback timeline replay +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + - id: "2" + actions: + updateVariable: + id: "set-score-10" + operations: + - variableId: score + op: set + value: 10 + section2: + lines: + - id: "10" + actions: + updateVariable: + id: "add-score-5" + operations: + - variableId: score + op: add + value: 5 + - id: "11" + resources: + variables: + score: + type: number + scope: context + default: 0 + global: + isLineCompleted: false + pendingEffects: [] + contexts: + - variables: + score: 15 + currentPointerMode: "history" + pointers: + read: + sectionId: "section2" + lineId: "10" + history: + sectionId: "section2" + lineId: "11" + historySequenceIndex: 0 + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section2" + lineId: "10" + rollbackPolicy: "free" + - offset: -1 +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + - id: "2" + actions: + updateVariable: + id: "set-score-10" + operations: + - variableId: score + op: set + value: 10 + section2: + lines: + - id: "10" + actions: + updateVariable: + id: "add-score-5" + operations: + - variableId: score + op: add + value: 5 + - id: "11" + resources: + variables: + score: + type: number + scope: context + default: 0 + global: + isLineCompleted: true + pendingEffects: + - name: "clearNextLineConfigTimer" + - name: "render" + contexts: + - variables: + score: 10 + currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "2" + history: + sectionId: null + lineId: null + historySequenceIndex: null + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section2" + lineId: "10" + rollbackPolicy: "free" diff --git a/spec/system/actions/saveSaveSlot.spec.yaml b/spec/system/actions/saveSaveSlot.spec.yaml index 9c771cb2..fb3067af 100644 --- a/spec/system/actions/saveSaveSlot.spec.yaml +++ b/spec/system/actions/saveSaveSlot.spec.yaml @@ -263,3 +263,120 @@ out: playerName: "Alice" score: 150 soundEnabled: true +--- +case: save persists rollback timeline +in: + - state: + global: + saveSlots: {} + pendingEffects: [] + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec2", lineId: "line3" } + variables: + score: 15 + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "sec1" + lineId: "line1" + rollbackPolicy: "free" + - sectionId: "sec1" + lineId: "line2" + rollbackPolicy: "free" + - sectionId: "sec2" + lineId: "line3" + rollbackPolicy: "free" + - slot: 3 + thumbnailImage: null +out: + global: + saveSlots: + "3": + slotKey: "3" + date: 1700000000000 + image: null + state: + contexts: + - pointers: + read: { sectionId: "sec2", lineId: "line3" } + variables: + score: 15 + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "sec1" + lineId: "line1" + rollbackPolicy: "free" + - sectionId: "sec1" + lineId: "line2" + rollbackPolicy: "free" + - sectionId: "sec2" + lineId: "line3" + rollbackPolicy: "free" + viewedRegistry: __undefined__ + pendingEffects: + - name: "saveSlots" + payload: + saveSlots: + "3": + slotKey: "3" + date: 1700000000000 + image: null + state: + contexts: + - pointers: + read: { sectionId: "sec2", lineId: "line3" } + variables: + score: 15 + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "sec1" + lineId: "line1" + rollbackPolicy: "free" + - sectionId: "sec1" + lineId: "line2" + rollbackPolicy: "free" + - sectionId: "sec2" + lineId: "line3" + rollbackPolicy: "free" + viewedRegistry: __undefined__ + - name: "render" + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec2", lineId: "line3" } + variables: + score: 15 + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "sec1" + lineId: "line1" + rollbackPolicy: "free" + - sectionId: "sec1" + lineId: "line2" + rollbackPolicy: "free" + - sectionId: "sec2" + lineId: "line3" + rollbackPolicy: "free" diff --git a/spec/system/actions/sectionTransition.spec.yaml b/spec/system/actions/sectionTransition.spec.yaml index 55a03111..2651375e 100644 --- a/spec/system/actions/sectionTransition.spec.yaml +++ b/spec/system/actions/sectionTransition.spec.yaml @@ -70,6 +70,18 @@ out: read: sectionId: "section2" lineId: "10" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section2" + lineId: "10" + rollbackPolicy: "free" --- case: section not found - returns unchanged state in: @@ -177,4 +189,4 @@ out: pointers: read: sectionId: "section1" - lineId: "1" \ No newline at end of file + lineId: "1" diff --git a/spec/system/createInitialState.spec.yaml b/spec/system/createInitialState.spec.yaml index 7ded0fa2..d2f5423e 100644 --- a/spec/system/createInitialState.spec.yaml +++ b/spec/system/createInitialState.spec.yaml @@ -71,6 +71,15 @@ out: views: [] bgm: resourceId: __undefined__ + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "line1" + rollbackPolicy: "free" variables: {} --- case: initialize object type variables with defaults @@ -179,6 +188,19 @@ out: views: [] bgm: resourceId: __undefined__ + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + inventory: + items: + - id: sword + name: Steel Sword + timeline: + - sectionId: "section1" + lineId: "line1" + rollbackPolicy: "free" variables: inventory: items: @@ -262,6 +284,16 @@ out: views: [] bgm: resourceId: __undefined__ + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + settings: {} + timeline: + - sectionId: "section1" + lineId: "line1" + rollbackPolicy: "free" variables: settings: {} --- @@ -365,6 +397,15 @@ out: views: [] bgm: resourceId: __undefined__ + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "line1" + rollbackPolicy: "free" variables: {} --- case: initialize with specific initialLineId @@ -439,4 +480,13 @@ out: views: [] bgm: resourceId: __undefined__ - variables: {} \ No newline at end of file + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "line2" + rollbackPolicy: "free" + variables: {} diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 79a04970..c382f3ec 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -44,6 +44,190 @@ const resetNextLineConfigIfSingleLine = (state) => { } }; +const createRollbackCheckpoint = ({ sectionId, lineId, rollbackPolicy }) => ({ + sectionId, + lineId, + rollbackPolicy: rollbackPolicy ?? "free", +}); + +const createRollbackState = ({ + pointer, + baselineVariables, + replayStartIndex = 0, +}) => { + const hasInitialPointer = pointer?.sectionId && pointer?.lineId; + + return { + currentIndex: hasInitialPointer ? 0 : -1, + isRestoring: false, + replayStartIndex, + baselineVariables: structuredClone(baselineVariables ?? {}), + timeline: hasInitialPointer + ? [ + createRollbackCheckpoint({ + sectionId: pointer.sectionId, + lineId: pointer.lineId, + }), + ] + : [], + }; +}; + +const ensureRollbackState = (lastContext, options = {}) => { + if (lastContext?.rollback) { + if (!Array.isArray(lastContext.rollback.timeline)) { + lastContext.rollback.timeline = []; + } + if (typeof lastContext.rollback.currentIndex !== "number") { + lastContext.rollback.currentIndex = + lastContext.rollback.timeline.length > 0 + ? lastContext.rollback.timeline.length - 1 + : -1; + } + if (typeof lastContext.rollback.isRestoring !== "boolean") { + lastContext.rollback.isRestoring = false; + } + if (typeof lastContext.rollback.replayStartIndex !== "number") { + lastContext.rollback.replayStartIndex = 0; + } + if (lastContext.rollback.baselineVariables === undefined) { + lastContext.rollback.baselineVariables = structuredClone( + lastContext.variables ?? {}, + ); + } + return lastContext.rollback; + } + + const pointer = lastContext?.pointers?.read; + lastContext.rollback = createRollbackState({ + pointer, + baselineVariables: lastContext?.variables ?? {}, + replayStartIndex: options.compatibilityAnchor ? 1 : 0, + }); + return lastContext.rollback; +}; + +const appendRollbackCheckpoint = (state, payload) => { + const lastContext = state.contexts?.[state.contexts.length - 1]; + if (!lastContext) { + return; + } + + const rollback = ensureRollbackState(lastContext); + if (rollback.isRestoring) { + return; + } + + if (rollback.currentIndex < rollback.timeline.length - 1) { + rollback.timeline = rollback.timeline.slice(0, rollback.currentIndex + 1); + } + + const lastCheckpoint = rollback.timeline[rollback.timeline.length - 1]; + if ( + lastCheckpoint?.sectionId === payload.sectionId && + lastCheckpoint?.lineId === payload.lineId && + (lastCheckpoint?.rollbackPolicy ?? "free") === + (payload.rollbackPolicy ?? "free") + ) { + rollback.currentIndex = rollback.timeline.length - 1; + return; + } + + rollback.timeline.push(createRollbackCheckpoint(payload)); + rollback.currentIndex = rollback.timeline.length - 1; +}; + +const applyRollbackableLineActions = (state, payload) => { + const { sectionId, lineId } = payload; + const section = selectSection({ state }, { sectionId }); + const line = section?.lines?.find((item) => item.id === lineId); + const updateVariableAction = line?.actions?.updateVariable; + + if (!updateVariableAction) { + return; + } + + const lastContext = state.contexts?.[state.contexts.length - 1]; + if (!lastContext) { + return; + } + + const operations = updateVariableAction.operations || []; + for (const { variableId, op, value } of operations) { + const variableConfig = state.projectData.resources?.variables?.[variableId]; + const scope = variableConfig?.scope; + const type = variableConfig?.type; + + validateVariableScope(scope, variableId); + validateVariableOperation(type, op, variableId); + + if (scope === "context") { + lastContext.variables[variableId] = applyVariableOperation( + lastContext.variables[variableId], + op, + value, + ); + } + } +}; + +const restoreRollbackCheckpoint = (state, checkpointIndex) => { + const lastContext = state.contexts?.[state.contexts.length - 1]; + if (!lastContext) { + return state; + } + + const rollback = ensureRollbackState(lastContext); + const checkpoint = rollback.timeline[checkpointIndex]; + if (!checkpoint) { + return state; + } + + if (state.global.autoMode) { + stopAutoMode({ state }); + } + if (state.global.skipMode) { + stopSkipMode({ state }); + } + + state.global.pendingEffects.push({ + name: "clearNextLineConfigTimer", + }); + + rollback.isRestoring = true; + rollback.currentIndex = checkpointIndex; + + lastContext.variables = structuredClone(rollback.baselineVariables ?? {}); + + const replayStartIndex = rollback.replayStartIndex ?? 0; + for (let i = replayStartIndex; i <= checkpointIndex; i++) { + applyRollbackableLineActions(state, rollback.timeline[i]); + } + + lastContext.pointers.read = { + sectionId: checkpoint.sectionId, + lineId: checkpoint.lineId, + }; + + lastContext.currentPointerMode = "read"; + if (lastContext.pointers?.history) { + lastContext.pointers.history = { + sectionId: null, + lineId: null, + historySequenceIndex: null, + }; + } + + state.global.isLineCompleted = true; + rollback.isRestoring = false; + + state.global.pendingEffects.push({ + name: "render", + }); + + return state; +}; + export const createInitialState = (payload) => { const { projectData } = payload; const global = payload.global ?? {}; @@ -116,6 +300,10 @@ export const createInitialState = (payload) => { resourceId: undefined, }, variables: contextVariableDefaultValues, + rollback: createRollbackState({ + pointer: initialPointer, + baselineVariables: contextVariableDefaultValues, + }), }, ], }; @@ -941,8 +1129,8 @@ export const saveSaveSlot = ({ state }, payload) => { const slotKey = String(slot); const currentState = { - contexts: [...state.contexts], - viewedRegistry: state.global.viewedRegistry, + contexts: structuredClone(state.contexts), + viewedRegistry: structuredClone(state.global.viewedRegistry), }; const saveData = { @@ -979,7 +1167,10 @@ export const loadSaveSlot = ({ state }, payload) => { const slotData = state.global.saveSlots[slotKey]; if (slotData) { state.global.viewedRegistry = slotData.state.viewedRegistry; - state.contexts = slotData.state.contexts; + state.contexts = structuredClone(slotData.state.contexts); + state.contexts?.forEach((context) => { + ensureRollbackState(context, { compatibilityAnchor: !context.rollback }); + }); state.global.pendingEffects.push({ name: "render" }); } return state; @@ -1066,9 +1257,6 @@ export const jumpToLine = ({ state }, payload) => { // Reset line completion state state.global.isLineCompleted = false; - // Add line to history for rollback support - addLineToHistory({ state }, { lineId }); - // Add appropriate pending effects state.global.pendingEffects.push({ name: "handleLineActions", @@ -1195,6 +1383,7 @@ export const nextLine = ({ state }) => { const pointer = selectCurrentPointer({ state })?.pointer; const sectionId = pointer?.sectionId; const section = selectSection({ state }, { sectionId }); + const lastContext = state.contexts[state.contexts.length - 1]; const lines = section?.lines || []; const currentLineIndex = lines.findIndex( @@ -1223,7 +1412,9 @@ export const nextLine = ({ state }) => { } } - const lastContext = state.contexts[state.contexts.length - 1]; + if (lastContext && !lastContext.rollback) { + ensureRollbackState(lastContext, { compatibilityAnchor: true }); + } if (lastContext) { // Mark current line as viewed before moving @@ -1242,6 +1433,10 @@ export const nextLine = ({ state }) => { // Add line to history for rollback support addLineToHistory({ state }, { lineId: nextLine.id }); + appendRollbackCheckpoint(state, { + sectionId, + lineId: nextLine.id, + }); resetNextLineConfigIfSingleLine(state); state.global.pendingEffects.push({ @@ -1449,6 +1644,10 @@ export const sectionTransition = ({ state }, payload) => { // Update current pointer to new section's first line const lastContext = state.contexts[state.contexts.length - 1]; if (lastContext) { + if (!lastContext.rollback) { + ensureRollbackState(lastContext, { compatibilityAnchor: true }); + } + lastContext.pointers.read = { sectionId, lineId: firstLine.id, @@ -1459,6 +1658,10 @@ export const sectionTransition = ({ state }, payload) => { // Add first line to history addLineToHistory({ state }, { lineId: firstLine.id }); + appendRollbackCheckpoint(state, { + sectionId, + lineId: firstLine.id, + }); } // Reset line completion state @@ -1486,6 +1689,7 @@ export const nextLineFromSystem = ({ state }) => { const pointer = selectCurrentPointer({ state })?.pointer; const sectionId = pointer?.sectionId; const section = selectSection({ state }, { sectionId }); + const lastContext = state.contexts[state.contexts.length - 1]; const lines = section?.lines || []; const currentLineIndex = lines.findIndex( @@ -1514,7 +1718,9 @@ export const nextLineFromSystem = ({ state }) => { } } - const lastContext = state.contexts[state.contexts.length - 1]; + if (lastContext && !lastContext.rollback) { + ensureRollbackState(lastContext, { compatibilityAnchor: true }); + } if (lastContext) { const currentLineId = lastContext.pointers.read.lineId; @@ -1532,6 +1738,10 @@ export const nextLineFromSystem = ({ state }) => { // Add line to history for rollback support addLineToHistory({ state }, { lineId: nextLine.id }); + appendRollbackCheckpoint(state, { + sectionId, + lineId: nextLine.id, + }); resetNextLineConfigIfSingleLine(state); state.global.pendingEffects.push({ @@ -1573,6 +1783,9 @@ export const updateVariable = ({ state }, payload) => { } const lastContext = state.contexts[state.contexts.length - 1]; + if (lastContext?.rollback?.isRestoring) { + return state; + } // Track which scopes are modified let contextVariableModified = false; @@ -1735,6 +1948,23 @@ export const selectLineIdByOffset = ({ state }, payload) => { return null; } + const rollback = lastContext.rollback; + if ( + Array.isArray(rollback?.timeline) && + typeof rollback?.currentIndex === "number" + ) { + const targetIndex = rollback.currentIndex + offset; + if (targetIndex < 0 || targetIndex >= rollback.timeline.length) { + return null; + } + + const targetLine = rollback.timeline[targetIndex]; + return { + sectionId: targetLine.sectionId, + lineId: targetLine.lineId, + }; + } + // Get current position from read pointer const currentSectionId = lastContext.pointers.read?.sectionId; const currentLineId = lastContext.pointers.read?.lineId; @@ -1813,15 +2043,28 @@ export const rollbackByOffset = ({ state }, payload) => { throw new Error("rollbackByOffset requires a negative offset"); } + const lastContext = state.contexts?.[state.contexts.length - 1]; + const rollback = lastContext?.rollback; + + if ( + Array.isArray(rollback?.timeline) && + typeof rollback?.currentIndex === "number" + ) { + const targetIndex = rollback.currentIndex + offset; + if (targetIndex < 0 || targetIndex >= rollback.timeline.length) { + return state; + } + + return restoreRollbackCheckpoint(state, targetIndex); + } + // Get target using the selector const target = selectLineIdByOffset({ state }, { offset }); if (!target) { - // Out of bounds or invalid - do nothing return state; } - // Delegate to rollbackToLine for the actual rollback with variable reversion return rollbackToLine( { state }, { @@ -1858,6 +2101,26 @@ export const rollbackToLine = ({ state }, payload) => { throw new Error("No context available for rollbackToLine"); } + const rollback = lastContext.rollback; + if (Array.isArray(rollback?.timeline) && rollback.timeline.length > 0) { + const visibleTimeline = rollback.timeline.slice( + 0, + rollback.currentIndex + 1, + ); + const targetLineIndex = visibleTimeline.findLastIndex( + (checkpoint) => + checkpoint.sectionId === sectionId && checkpoint.lineId === lineId, + ); + + if (targetLineIndex === -1) { + throw new Error( + `Line ${lineId} not found in section ${sectionId} rollback timeline`, + ); + } + + return restoreRollbackCheckpoint(state, targetLineIndex); + } + // Find the section in history const historySequence = lastContext.historySequence; const sectionEntry = historySequence?.find( From 76f6f89dce2439282835562d0de44f50df8588e8 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Fri, 27 Mar 2026 22:57:08 +0800 Subject: [PATCH 2/5] Fix rollback draft safety and restore replay --- .../system/actions/rollbackByOffset.spec.yaml | 25 ++ spec/systemStore.rollbackDraftSafety.test.js | 282 ++++++++++++++++++ src/stores/system.store.js | 69 ++++- 3 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 spec/systemStore.rollbackDraftSafety.test.js diff --git a/spec/system/actions/rollbackByOffset.spec.yaml b/spec/system/actions/rollbackByOffset.spec.yaml index 5737bf3e..53ae5669 100644 --- a/spec/system/actions/rollbackByOffset.spec.yaml +++ b/spec/system/actions/rollbackByOffset.spec.yaml @@ -389,6 +389,10 @@ in: - id: "1" - id: "2" actions: + setNextLineConfig: + manual: + enabled: false + requireLineCompleted: true updateVariable: id: "set-score-10" operations: @@ -414,6 +418,13 @@ in: default: 0 global: isLineCompleted: false + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" pendingEffects: [] contexts: - variables: @@ -455,6 +466,10 @@ out: - id: "1" - id: "2" actions: + setNextLineConfig: + manual: + enabled: false + requireLineCompleted: true updateVariable: id: "set-score-10" operations: @@ -480,6 +495,16 @@ out: default: 0 global: isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: false + requireLineCompleted: true + auto: + enabled: false + applyMode: "persistent" pendingEffects: - name: "clearNextLineConfigTimer" - name: "render" diff --git a/spec/systemStore.rollbackDraftSafety.test.js b/spec/systemStore.rollbackDraftSafety.test.js new file mode 100644 index 00000000..3e3f329e --- /dev/null +++ b/spec/systemStore.rollbackDraftSafety.test.js @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from "vitest"; +import { produce } from "immer"; +import { + loadSaveSlot, + rollbackByOffset, + saveSaveSlot, +} from "../src/stores/system.store.js"; + +const createProjectData = () => ({ + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [ + { id: "1" }, + { + id: "2", + actions: { + setNextLineConfig: { + manual: { + enabled: false, + requireLineCompleted: true, + }, + }, + updateVariable: { + id: "set-score-10", + operations: [ + { + variableId: "score", + op: "set", + value: 10, + }, + ], + }, + }, + }, + { id: "3" }, + ], + }, + section2: { + lines: [{ id: "10" }], + }, + }, + }, + }, + }, + resources: { + variables: { + score: { + type: "number", + scope: "context", + default: 0, + }, + }, + }, +}); + +describe("system.store rollback/save draft safety", () => { + it("saveSaveSlot does not throw when cloning live draft state", () => { + vi.spyOn(Date, "now").mockReturnValue(1700000000000); + + const baseState = { + global: { + saveSlots: {}, + viewedRegistry: { + sections: [{ sectionId: "section1", lastLineId: "2" }], + resources: [], + }, + pendingEffects: [], + }, + projectData: createProjectData(), + contexts: [ + { + pointers: { + read: { sectionId: "section1", lineId: "2" }, + }, + variables: { + score: 10, + }, + rollback: { + currentIndex: 1, + isRestoring: false, + replayStartIndex: 0, + baselineVariables: { + score: 0, + }, + timeline: [ + { + sectionId: "section1", + lineId: "1", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "2", + rollbackPolicy: "free", + }, + ], + }, + }, + ], + }; + + const nextState = produce(baseState, (draft) => { + saveSaveSlot({ state: draft }, { slot: 1, thumbnailImage: null }); + }); + + expect(nextState.global.saveSlots["1"]?.state?.contexts?.[0]?.rollback) + .toEqual(baseState.contexts[0].rollback); + vi.restoreAllMocks(); + }); + + it("loadSaveSlot does not throw when restoring slot state from a live draft", () => { + const savedRollback = { + currentIndex: 2, + isRestoring: false, + replayStartIndex: 0, + baselineVariables: { + score: 0, + }, + timeline: [ + { + sectionId: "section1", + lineId: "1", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "2", + rollbackPolicy: "free", + }, + { + sectionId: "section2", + lineId: "10", + rollbackPolicy: "free", + }, + ], + }; + + const baseState = { + global: { + saveSlots: { + "1": { + slotKey: "1", + date: 1700000000000, + image: null, + state: { + viewedRegistry: { + sections: [{ sectionId: "section2", lastLineId: "10" }], + resources: [], + }, + contexts: [ + { + pointers: { + read: { sectionId: "section2", lineId: "10" }, + }, + variables: { + score: 15, + }, + rollback: savedRollback, + }, + ], + }, + }, + }, + viewedRegistry: { + sections: [], + resources: [], + }, + pendingEffects: [], + }, + contexts: [ + { + pointers: { + read: { sectionId: "section1", lineId: "1" }, + }, + variables: { + score: 0, + }, + }, + ], + }; + + const nextState = produce(baseState, (draft) => { + loadSaveSlot({ state: draft }, { slot: 1 }); + }); + + expect(nextState.contexts[0].rollback).toEqual(savedRollback); + expect(nextState.global.viewedRegistry.sections[0]).toEqual({ + sectionId: "section2", + lastLineId: "10", + }); + }); + + it("rollbackByOffset restores variables and current-line system state from a live draft", () => { + const baseState = { + projectData: createProjectData(), + global: { + isLineCompleted: false, + dialogueUIHidden: false, + isDialogueHistoryShowing: false, + nextLineConfig: { + manual: { + enabled: true, + requireLineCompleted: false, + }, + auto: { + enabled: false, + }, + applyMode: "persistent", + }, + layeredViews: [], + pendingEffects: [], + }, + contexts: [ + { + variables: { + score: 10, + }, + currentPointerMode: "history", + pointers: { + read: { sectionId: "section1", lineId: "3" }, + history: { + sectionId: "section2", + lineId: "10", + historySequenceIndex: 0, + }, + }, + rollback: { + currentIndex: 2, + isRestoring: false, + replayStartIndex: 0, + baselineVariables: { + score: 0, + }, + timeline: [ + { + sectionId: "section1", + lineId: "1", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "2", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "3", + rollbackPolicy: "free", + }, + ], + }, + }, + ], + }; + + const nextState = produce(baseState, (draft) => { + rollbackByOffset({ state: draft }, { offset: -1 }); + }); + + expect(nextState.contexts[0].variables.score).toBe(10); + expect(nextState.contexts[0].pointers.read).toEqual({ + sectionId: "section1", + lineId: "2", + }); + expect(nextState.contexts[0].currentPointerMode).toBe("read"); + expect(nextState.global.nextLineConfig).toEqual({ + manual: { + enabled: false, + requireLineCompleted: true, + }, + auto: { + enabled: false, + }, + applyMode: "persistent", + }); + }); +}); diff --git a/src/stores/system.store.js b/src/stores/system.store.js index c382f3ec..22ada45a 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1,3 +1,4 @@ +import { current, isDraft } from "immer"; import { createStore, getDefaultVariablesFromProjectData, @@ -44,6 +45,11 @@ const resetNextLineConfigIfSingleLine = (state) => { } }; +const cloneStateValue = (value) => { + const source = isDraft(value) ? current(value) : value; + return structuredClone(source); +}; + const createRollbackCheckpoint = ({ sectionId, lineId, rollbackPolicy }) => ({ sectionId, lineId, @@ -61,7 +67,7 @@ const createRollbackState = ({ currentIndex: hasInitialPointer ? 0 : -1, isRestoring: false, replayStartIndex, - baselineVariables: structuredClone(baselineVariables ?? {}), + baselineVariables: cloneStateValue(baselineVariables ?? {}), timeline: hasInitialPointer ? [ createRollbackCheckpoint({ @@ -91,7 +97,7 @@ const ensureRollbackState = (lastContext, options = {}) => { lastContext.rollback.replayStartIndex = 0; } if (lastContext.rollback.baselineVariables === undefined) { - lastContext.rollback.baselineVariables = structuredClone( + lastContext.rollback.baselineVariables = cloneStateValue( lastContext.variables ?? {}, ); } @@ -171,6 +177,39 @@ const applyRollbackableLineActions = (state, payload) => { } }; +const applyRollbackRestorableLineActions = (state, payload) => { + const { sectionId, lineId } = payload; + const section = selectSection({ state }, { sectionId }); + const line = section?.lines?.find((item) => item.id === lineId); + const actions = line?.actions; + + if (!actions) { + return; + } + + const restorableActions = { + showDialogueUI, + hideDialogueUI, + toggleDialogueUI, + showDialogueHistory, + hideDialogueHistory, + setNextLineConfig, + pushLayeredView, + popLayeredView, + replaceLastLayeredView, + clearLayeredViews, + }; + + Object.entries(actions).forEach(([actionType, actionPayload]) => { + const action = restorableActions[actionType]; + if (!action) { + return; + } + + action({ state }, actionPayload); + }); +}; + const restoreRollbackCheckpoint = (state, checkpointIndex) => { const lastContext = state.contexts?.[state.contexts.length - 1]; if (!lastContext) { @@ -197,11 +236,20 @@ const restoreRollbackCheckpoint = (state, checkpointIndex) => { rollback.isRestoring = true; rollback.currentIndex = checkpointIndex; - lastContext.variables = structuredClone(rollback.baselineVariables ?? {}); + lastContext.variables = cloneStateValue(rollback.baselineVariables ?? {}); + state.global.dialogueUIHidden = false; + state.global.isDialogueHistoryShowing = false; + state.global.nextLineConfig = cloneStateValue(DEFAULT_NEXT_LINE_CONFIG); + state.global.layeredViews = []; + state.global.isLineCompleted = true; const replayStartIndex = rollback.replayStartIndex ?? 0; for (let i = replayStartIndex; i <= checkpointIndex; i++) { + if (i > replayStartIndex) { + resetNextLineConfigIfSingleLine(state); + } applyRollbackableLineActions(state, rollback.timeline[i]); + applyRollbackRestorableLineActions(state, rollback.timeline[i]); } lastContext.pointers.read = { @@ -218,9 +266,12 @@ const restoreRollbackCheckpoint = (state, checkpointIndex) => { }; } - state.global.isLineCompleted = true; rollback.isRestoring = false; + state.global.pendingEffects = state.global.pendingEffects.filter( + (effect) => effect?.name !== "render", + ); + state.global.pendingEffects.push({ name: "render", }); @@ -1129,8 +1180,8 @@ export const saveSaveSlot = ({ state }, payload) => { const slotKey = String(slot); const currentState = { - contexts: structuredClone(state.contexts), - viewedRegistry: structuredClone(state.global.viewedRegistry), + contexts: cloneStateValue(state.contexts), + viewedRegistry: cloneStateValue(state.global.viewedRegistry), }; const saveData = { @@ -1166,8 +1217,10 @@ export const loadSaveSlot = ({ state }, payload) => { const slotKey = String(slot); const slotData = state.global.saveSlots[slotKey]; if (slotData) { - state.global.viewedRegistry = slotData.state.viewedRegistry; - state.contexts = structuredClone(slotData.state.contexts); + state.global.viewedRegistry = cloneStateValue( + slotData.state.viewedRegistry, + ); + state.contexts = cloneStateValue(slotData.state.contexts); state.contexts?.forEach((context) => { ensureRollbackState(context, { compatibilityAnchor: !context.rollback }); }); From cff5ec78fe6143caeab46bddc7b5b03309eee928 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Mon, 30 Mar 2026 20:32:01 +0800 Subject: [PATCH 3/5] Implement rollback timeline and expand VT coverage --- rettangoli.config.yaml | 2 + spec/RouteEngine.systemState.test.js | 128 +++++ .../system/actions/rollbackByOffset.spec.yaml | 358 ++++++++++--- spec/system/actions/rollbackToLine.spec.yaml | 474 ++++++++++------ .../actions/setNextLineConfig.spec.yaml | 34 ++ .../selectors/selectLineIdByOffset.spec.yaml | 73 ++- spec/systemStore.rollbackDraftSafety.test.js | 239 ++++++++- src/RouteEngine.js | 11 +- src/stores/constructRenderState.js | 1 - src/stores/system.store.js | 505 ++++++++---------- .../rollback/auto-suppressed--capture-01.webp | 3 + .../rollback/back-one-line--capture-01.webp | 3 + .../rollback/by-offset--capture-01.webp | 3 + .../rollback/choice-event--capture-01.webp | 3 + .../rollback/cross-section--capture-01.webp | 3 + .../layered-view-restore--capture-01.webp | 3 + .../rollback/to-line--capture-01.webp | 3 + .../variables/rollback-offset-test-01.webp | 3 - vt/reference/variables/rollback-test-01.webp | 3 - vt/specs/rollback/auto-suppressed.yaml | 137 +++++ vt/specs/rollback/back-one-line.yaml | 413 ++++++++++++++ vt/specs/rollback/by-offset.yaml | 223 ++++++++ vt/specs/rollback/choice-event.yaml | 225 ++++++++ vt/specs/rollback/cross-section.yaml | 184 +++++++ vt/specs/rollback/layered-view-restore.yaml | 219 ++++++++ .../to-line.yaml} | 162 +++--- vt/specs/variables/rollback-offset-test.yaml | 273 ---------- 27 files changed, 2798 insertions(+), 890 deletions(-) create mode 100644 vt/reference/rollback/auto-suppressed--capture-01.webp create mode 100644 vt/reference/rollback/back-one-line--capture-01.webp create mode 100644 vt/reference/rollback/by-offset--capture-01.webp create mode 100644 vt/reference/rollback/choice-event--capture-01.webp create mode 100644 vt/reference/rollback/cross-section--capture-01.webp create mode 100644 vt/reference/rollback/layered-view-restore--capture-01.webp create mode 100644 vt/reference/rollback/to-line--capture-01.webp delete mode 100644 vt/reference/variables/rollback-offset-test-01.webp delete mode 100644 vt/reference/variables/rollback-test-01.webp create mode 100644 vt/specs/rollback/auto-suppressed.yaml create mode 100644 vt/specs/rollback/back-one-line.yaml create mode 100644 vt/specs/rollback/by-offset.yaml create mode 100644 vt/specs/rollback/choice-event.yaml create mode 100644 vt/specs/rollback/cross-section.yaml create mode 100644 vt/specs/rollback/layered-view-restore.yaml rename vt/specs/{variables/rollback-test.yaml => rollback/to-line.yaml} (53%) delete mode 100644 vt/specs/variables/rollback-offset-test.yaml diff --git a/rettangoli.config.yaml b/rettangoli.config.yaml index 5c6a34bf..f342ea24 100644 --- a/rettangoli.config.yaml +++ b/rettangoli.config.yaml @@ -43,6 +43,8 @@ vt: files: "save" - title: "Keyboard" files: "keyboard" + - title: "Rollback" + files: "rollback" - title: "Variables" files: "variables" - title: "initialization" diff --git a/spec/RouteEngine.systemState.test.js b/spec/RouteEngine.systemState.test.js index 8b13de61..ca3e87c4 100644 --- a/spec/RouteEngine.systemState.test.js +++ b/spec/RouteEngine.systemState.test.js @@ -37,6 +37,74 @@ const createMinimalProjectData = () => ({ }, }); +const createRollbackChoiceProjectData = () => ({ + screen: { + width: 1920, + height: 1080, + backgroundColor: "#000000", + }, + resources: { + layouts: {}, + sounds: {}, + images: {}, + videos: {}, + sprites: {}, + characters: {}, + variables: { + score: { + type: "number", + scope: "context", + default: 0, + }, + }, + transforms: {}, + sectionTransitions: {}, + animations: {}, + fonts: {}, + colors: {}, + textStyles: {}, + }, + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "source", + sections: { + source: { + lines: [{ id: "line1", actions: {} }], + }, + result: { + lines: [ + { + id: "line1", + actions: { + dialogue: { + content: [{ text: "Result" }], + }, + }, + }, + ], + }, + }, + }, + }, + }, +}); + +const createRouteEngineWithInlineEffects = () => { + let engine; + const handlePendingEffects = (pendingEffects) => { + pendingEffects.forEach((effect) => { + if (effect.name === "handleLineActions") { + engine.handleLineActions(); + } + }); + }; + + engine = createRouteEngine({ handlePendingEffects }); + return engine; +}; + describe("RouteEngine selectSystemState", () => { it("returns a cloned system-state snapshot", () => { const engine = createRouteEngine({ @@ -72,4 +140,64 @@ describe("RouteEngine selectSystemState", () => { expect(engine.selectCurrentLine).toBeUndefined(); expect(engine.selectNextLineConfig).toBeUndefined(); }); + + it("records a mixed interaction batch on the checkpoint where the batch started", () => { + const engine = createRouteEngineWithInlineEffects(); + + engine.init({ + initialState: { + global: { + variables: {}, + }, + projectData: createRollbackChoiceProjectData(), + }, + }); + + engine.handleActions({ + sectionTransition: { + sectionId: "result", + }, + updateVariable: { + id: "chooseA15", + operations: [ + { + variableId: "score", + op: "set", + value: 15, + }, + ], + }, + }); + + let state = engine.selectSystemState(); + expect(state.contexts[0].variables.score).toBe(15); + expect(state.contexts[0].pointers.read).toEqual({ + sectionId: "result", + lineId: "line1", + }); + + engine.handleAction("rollbackByOffset", { offset: -1 }); + + state = engine.selectSystemState(); + expect(state.contexts[0].variables.score).toBe(15); + expect(state.contexts[0].pointers.read).toEqual({ + sectionId: "source", + lineId: "line1", + }); + expect(state.contexts[0].rollback.timeline[0].executedActions).toEqual([ + { + type: "updateVariable", + payload: { + id: "chooseA15", + operations: [ + { + variableId: "score", + op: "set", + value: 15, + }, + ], + }, + }, + ]); + }); }); diff --git a/spec/system/actions/rollbackByOffset.spec.yaml b/spec/system/actions/rollbackByOffset.spec.yaml index 53ae5669..6c5bd4fb 100644 --- a/spec/system/actions/rollbackByOffset.spec.yaml +++ b/spec/system/actions/rollbackByOffset.spec.yaml @@ -68,12 +68,18 @@ in: sectionId: "section1" lineId: "2" history: {} - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" - - id: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" - {} out: projectData: @@ -89,8 +95,19 @@ out: variables: {} global: isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" pendingEffects: - - name: "handleLineActions" + - name: "clearNextLineConfigTimer" + - name: "render" contexts: - variables: {} currentPointerMode: "read" @@ -102,13 +119,20 @@ out: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- -case: rolls back one line with offset -1 and reverts variables +case: rolls back one line with inclusive checkpoint state in: - state: projectData: @@ -135,7 +159,7 @@ in: scope: context default: 0 global: - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: @@ -146,16 +170,22 @@ in: sectionId: "section1" lineId: "3" history: {} - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "act1" - - id: "3" + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" - offset: -1 out: projectData: @@ -183,11 +213,22 @@ out: default: 0 global: isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" pendingEffects: - - name: "handleLineActions" + - name: "clearNextLineConfigTimer" + - name: "render" contexts: - variables: - score: 0 + score: 10 currentPointerMode: "read" pointers: read: @@ -197,13 +238,22 @@ out: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" --- case: does nothing when at first line with offset -1 in: @@ -234,12 +284,16 @@ in: sectionId: "section1" lineId: "1" history: {} - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" - offset: -1 out: projectData: @@ -268,14 +322,18 @@ out: sectionId: "section1" lineId: "1" history: {} - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" --- -case: handles duplicate line entries correctly (uses last occurrence) +case: handles duplicate checkpoints using rollback cursor in: - state: projectData: @@ -295,6 +353,13 @@ in: op: set value: 10 - id: "3" + actions: + updateVariable: + id: "act2" + operations: + - variableId: score + op: increment + value: 5 resources: variables: score: @@ -302,7 +367,7 @@ in: scope: context default: 0 global: - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: @@ -313,17 +378,25 @@ in: sectionId: "section1" lineId: "2" history: {} - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "act1" - - id: "3" - - id: "2" + rollback: + currentIndex: 3 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" - offset: -1 out: projectData: @@ -343,6 +416,13 @@ out: op: set value: 10 - id: "3" + actions: + updateVariable: + id: "act2" + operations: + - variableId: score + op: increment + value: 5 resources: variables: score: @@ -351,11 +431,22 @@ out: default: 0 global: isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" pendingEffects: - - name: "handleLineActions" + - name: "clearNextLineConfigTimer" + - name: "render" contexts: - variables: - score: 10 + score: 15 currentPointerMode: "read" pointers: read: @@ -365,16 +456,25 @@ out: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "act1" - - id: "3" + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- case: rolls back across sections using rollback timeline replay in: @@ -536,3 +636,121 @@ out: - sectionId: "section2" lineId: "10" rollbackPolicy: "free" +--- +case: restores nextLineConfig auto without restarting scene timer +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + - id: "2" + actions: + setNextLineConfig: + auto: + enabled: true + trigger: "fromComplete" + delay: 750 + - id: "3" + resources: + variables: {} + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" + pendingEffects: [] + contexts: + - variables: {} + currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "3" + history: {} + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" + - offset: -1 +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + - id: "2" + actions: + setNextLineConfig: + auto: + enabled: true + trigger: "fromComplete" + delay: 750 + - id: "3" + resources: + variables: {} + global: + isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: true + trigger: "fromComplete" + delay: 750 + applyMode: "persistent" + pendingEffects: + - name: "clearNextLineConfigTimer" + - name: "render" + contexts: + - variables: {} + currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "2" + history: + sectionId: null + lineId: null + historySequenceIndex: null + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" diff --git a/spec/system/actions/rollbackToLine.spec.yaml b/spec/system/actions/rollbackToLine.spec.yaml index 9e767c29..2ee0ab96 100644 --- a/spec/system/actions/rollbackToLine.spec.yaml +++ b/spec/system/actions/rollbackToLine.spec.yaml @@ -5,7 +5,7 @@ suites: [rollbackToLine] suite: rollbackToLine exportName: rollbackToLine --- -case: resets to initialState before replay +case: restores target checkpoint state inclusively in: - state: projectData: @@ -14,7 +14,25 @@ in: scene1: sections: section1: - lines: [] + lines: + - id: "1" + - id: "2" + actions: + updateVariable: + id: "act1" + operations: + - variableId: score + op: set + value: 15 + - id: "3" + actions: + updateVariable: + id: "act2" + operations: + - variableId: score + op: increment + value: 10 + - id: "4" resources: variables: score: @@ -22,11 +40,11 @@ in: scope: context default: 0 global: - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: - score: 999 + score: 25 currentPointerMode: "history" pointers: read: @@ -35,17 +53,27 @@ in: history: sectionId: "section1" lineId: "2" - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" - - id: "3" - - id: "4" + rollback: + currentIndex: 3 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "4" + rollbackPolicy: "free" - sectionId: "section1" - lineId: "1" + lineId: "3" out: projectData: story: @@ -53,7 +81,25 @@ out: scene1: sections: section1: - lines: [] + lines: + - id: "1" + - id: "2" + actions: + updateVariable: + id: "act1" + operations: + - variableId: score + op: set + value: 15 + - id: "3" + actions: + updateVariable: + id: "act2" + operations: + - variableId: score + op: increment + value: 10 + - id: "4" resources: variables: score: @@ -62,28 +108,52 @@ out: default: 0 global: isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" pendingEffects: - - name: "handleLineActions" + - name: "clearNextLineConfigTimer" + - name: "render" contexts: - variables: - score: 0 + score: 25 currentPointerMode: "read" pointers: read: sectionId: "section1" - lineId: "1" + lineId: "3" history: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "4" + rollbackPolicy: "free" --- -case: replays actions forward to line before target +case: rolls back directly across sections using rollback timeline in: - state: projectData: @@ -97,20 +167,21 @@ in: - id: "2" actions: updateVariable: - id: "act1" + id: "set-score-10" operations: - variableId: score op: set - value: 15 - - id: "3" + value: 10 + section2: + lines: + - id: "10" actions: updateVariable: - id: "act2" + id: "add-score-5" operations: - variableId: score - op: increment - value: 10 - - id: "4" + op: add + value: 5 resources: variables: score: @@ -118,34 +189,37 @@ in: scope: context default: 0 global: - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: - score: 25 + score: 15 currentPointerMode: "history" pointers: read: - sectionId: "section1" - lineId: "4" + sectionId: "section2" + lineId: "10" history: - sectionId: "section1" - lineId: "2" - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "act1" - - id: "3" - updateVariableIds: - - "act2" - - id: "4" + sectionId: "section2" + lineId: "10" + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section2" + lineId: "10" + rollbackPolicy: "free" - sectionId: "section1" - lineId: "3" + lineId: "2" out: projectData: story: @@ -158,20 +232,21 @@ out: - id: "2" actions: updateVariable: - id: "act1" + id: "set-score-10" operations: - variableId: score op: set - value: 15 - - id: "3" + value: 10 + section2: + lines: + - id: "10" actions: updateVariable: - id: "act2" + id: "add-score-5" operations: - variableId: score - op: increment - value: 10 - - id: "4" + op: add + value: 5 resources: variables: score: @@ -180,32 +255,49 @@ out: default: 0 global: isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" pendingEffects: - - name: "handleLineActions" + - name: "clearNextLineConfigTimer" + - name: "render" contexts: - variables: - score: 15 + score: 10 currentPointerMode: "read" pointers: read: sectionId: "section1" - lineId: "3" + lineId: "2" history: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "act1" - - id: "3" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section2" + lineId: "10" + rollbackPolicy: "free" --- -case: throws error for invalid lineId +case: throws error for invalid lineId in rollback timeline in: - state: projectData: @@ -222,21 +314,25 @@ in: pointers: read: sectionId: "section1" - lineId: "3" - history: - sectionId: "section1" - lineId: "1" - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" - - id: "2" + lineId: "2" + history: {} + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" - sectionId: "section1" lineId: "nonExistentLine" -throws: "Line nonExistentLine not found in section section1 history" +throws: "Line nonExistentLine not found in section section1 rollback timeline" --- -case: handles multiple operations in single action +case: handles multiple operations in a rollbackable action in: - state: projectData: @@ -266,31 +362,35 @@ in: scope: context default: 0 global: - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: - score: 100 + score: 15 currentPointerMode: "history" pointers: read: sectionId: "section1" lineId: "3" - history: - sectionId: "section1" - lineId: "1" - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "act1" - - id: "3" + history: {} + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" - sectionId: "section1" - lineId: "3" + lineId: "2" out: projectData: story: @@ -320,8 +420,19 @@ out: default: 0 global: isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" pendingEffects: - - name: "handleLineActions" + - name: "clearNextLineConfigTimer" + - name: "render" contexts: - variables: score: 15 @@ -329,23 +440,29 @@ out: pointers: read: sectionId: "section1" - lineId: "3" + lineId: "2" history: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "act1" - - id: "3" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" --- -case: rollback to first line resets to initialState only (no replay) +case: rollback to first line resets to baseline when first checkpoint has no rollbackable actions in: - state: projectData: @@ -371,7 +488,7 @@ in: scope: context default: 0 global: - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: @@ -381,18 +498,20 @@ in: read: sectionId: "section1" lineId: "2" - history: - sectionId: "section1" - lineId: "1" - historySequence: - - sectionId: "section1" - initialState: - score: 10 - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "act1" + history: {} + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 10 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" - sectionId: "section1" lineId: "1" out: @@ -420,8 +539,19 @@ out: default: 0 global: isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" pendingEffects: - - name: "handleLineActions" + - name: "clearNextLineConfigTimer" + - name: "render" contexts: - variables: score: 10 @@ -434,14 +564,21 @@ out: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: - score: 10 - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: + score: 10 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" --- -case: global variables are NOT replayed during rollback +case: global variables are not replayed during rollback in: - state: projectData: @@ -470,7 +607,7 @@ in: global: variables: volume: 100 - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: {} @@ -480,17 +617,23 @@ in: sectionId: "section1" lineId: "3" history: {} - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "globalAct" - - id: "3" + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" - sectionId: "section1" - lineId: "3" + lineId: "2" out: projectData: story: @@ -519,25 +662,42 @@ out: variables: volume: 100 isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" pendingEffects: - - name: "handleLineActions" + - name: "clearNextLineConfigTimer" + - name: "render" contexts: - variables: {} currentPointerMode: "read" pointers: read: sectionId: "section1" - lineId: "3" + lineId: "2" history: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "globalAct" - - id: "3" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" diff --git a/spec/system/actions/setNextLineConfig.spec.yaml b/spec/system/actions/setNextLineConfig.spec.yaml index 584b83f1..9026bd69 100644 --- a/spec/system/actions/setNextLineConfig.spec.yaml +++ b/spec/system/actions/setNextLineConfig.spec.yaml @@ -356,3 +356,37 @@ out: pendingEffects: - name: "clearNextLineConfigTimer" - name: "render" +--- +case: suppresses timer side effects during rollback restore +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: false + pendingEffects: [] + contexts: + - rollback: + isRestoring: true + - auto: + enabled: true + trigger: "fromComplete" + delay: 2000 +out: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromComplete" + delay: 2000 + pendingEffects: + - name: "render" + contexts: + - rollback: + isRestoring: true diff --git a/spec/system/selectors/selectLineIdByOffset.spec.yaml b/spec/system/selectors/selectLineIdByOffset.spec.yaml index 352e954a..084abac9 100644 --- a/spec/system/selectors/selectLineIdByOffset.spec.yaml +++ b/spec/system/selectors/selectLineIdByOffset.spec.yaml @@ -1,14 +1,11 @@ file: "../../../src/stores/system.store.js" group: systemStore selectors -suites: - [ - selectLineIdByOffset, - ] +suites: [selectLineIdByOffset] --- suite: selectLineIdByOffset exportName: selectLineIdByOffset --- -case: returns previous line with offset -1 +case: returns previous line from rollback timeline in: - state: global: {} @@ -18,19 +15,27 @@ in: read: sectionId: "section1" lineId: "3" - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" - - id: "2" - - id: "3" + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" - offset: -1 out: sectionId: "section1" lineId: "2" --- -case: returns null when at first line with offset -1 +case: returns null when at first rollback checkpoint with offset -1 in: - state: global: {} @@ -40,15 +45,19 @@ in: read: sectionId: "section1" lineId: "1" - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" - offset: -1 out: null --- -case: handles duplicate line entries after backtracking (findLastIndex behavior) +case: handles duplicate checkpoints after backtracking using rollback cursor in: - state: global: {} @@ -58,14 +67,24 @@ in: read: sectionId: "section1" lineId: "2" - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" - - id: "2" - - id: "3" - - id: "2" + rollback: + currentIndex: 3 + isRestoring: false + replayStartIndex: 0 + baselineVariables: {} + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" - offset: -1 out: sectionId: "section1" diff --git a/spec/systemStore.rollbackDraftSafety.test.js b/spec/systemStore.rollbackDraftSafety.test.js index 3e3f329e..4af94dbd 100644 --- a/spec/systemStore.rollbackDraftSafety.test.js +++ b/spec/systemStore.rollbackDraftSafety.test.js @@ -4,6 +4,8 @@ import { loadSaveSlot, rollbackByOffset, saveSaveSlot, + sectionTransition, + updateVariable, } from "../src/stores/system.store.js"; const createProjectData = () => ({ @@ -58,6 +60,76 @@ const createProjectData = () => ({ }, }); +const createInvalidRollbackProjectData = () => ({ + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [ + { id: "1" }, + { + id: "2", + actions: { + updateVariable: { + id: "invalid-score-toggle", + operations: [ + { + variableId: "score", + op: "toggle", + }, + ], + }, + }, + }, + { id: "3" }, + ], + }, + }, + }, + }, + }, + resources: { + variables: { + score: { + type: "number", + scope: "context", + default: 0, + }, + }, + }, +}); + +const createEventDrivenRollbackProjectData = () => ({ + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [{ id: "1" }, { id: "2" }], + }, + section2: { + lines: [{ id: "10" }], + }, + }, + }, + }, + }, + resources: { + variables: { + score: { + type: "number", + scope: "context", + default: 0, + }, + }, + }, +}); + describe("system.store rollback/save draft safety", () => { it("saveSaveSlot does not throw when cloning live draft state", () => { vi.spyOn(Date, "now").mockReturnValue(1700000000000); @@ -108,8 +180,9 @@ describe("system.store rollback/save draft safety", () => { saveSaveSlot({ state: draft }, { slot: 1, thumbnailImage: null }); }); - expect(nextState.global.saveSlots["1"]?.state?.contexts?.[0]?.rollback) - .toEqual(baseState.contexts[0].rollback); + expect( + nextState.global.saveSlots["1"]?.state?.contexts?.[0]?.rollback, + ).toEqual(baseState.contexts[0].rollback); vi.restoreAllMocks(); }); @@ -143,7 +216,7 @@ describe("system.store rollback/save draft safety", () => { const baseState = { global: { saveSlots: { - "1": { + 1: { slotKey: "1", date: 1700000000000, image: null, @@ -279,4 +352,164 @@ describe("system.store rollback/save draft safety", () => { applyMode: "persistent", }); }); + + it("rollbackByOffset replays event-driven checkpoint actions on the source line", () => { + const state = { + projectData: createEventDrivenRollbackProjectData(), + global: { + autoMode: false, + skipMode: false, + isLineCompleted: false, + dialogueUIHidden: false, + isDialogueHistoryShowing: false, + nextLineConfig: { + manual: { + enabled: true, + requireLineCompleted: false, + }, + auto: { + enabled: false, + }, + applyMode: "persistent", + }, + layeredViews: [], + pendingEffects: [], + variables: {}, + }, + contexts: [ + { + variables: { + score: 0, + }, + currentPointerMode: "read", + pointers: { + read: { sectionId: "section1", lineId: "2" }, + history: {}, + }, + rollback: { + currentIndex: 1, + isRestoring: false, + replayStartIndex: 0, + baselineVariables: { + score: 0, + }, + timeline: [ + { + sectionId: "section1", + lineId: "1", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "2", + rollbackPolicy: "free", + }, + ], + }, + }, + ], + }; + + updateVariable( + { state }, + { + id: "choiceScore15", + operations: [ + { + variableId: "score", + op: "set", + value: 15, + }, + ], + }, + ); + sectionTransition({ state }, { sectionId: "section2" }); + rollbackByOffset({ state }, { offset: -1 }); + + expect(state.contexts[0].variables.score).toBe(15); + expect(state.contexts[0].pointers.read).toEqual({ + sectionId: "section1", + lineId: "2", + }); + expect(state.contexts[0].rollback.timeline[1].executedActions).toEqual([ + { + type: "updateVariable", + payload: { + id: "choiceScore15", + operations: [ + { + variableId: "score", + op: "set", + value: 15, + }, + ], + }, + }, + ]); + }); + + it("rollbackByOffset clears the restoring guard when replay throws", () => { + const state = { + projectData: createInvalidRollbackProjectData(), + global: { + isLineCompleted: false, + dialogueUIHidden: false, + isDialogueHistoryShowing: false, + nextLineConfig: { + manual: { + enabled: true, + requireLineCompleted: false, + }, + auto: { + enabled: false, + }, + applyMode: "persistent", + }, + layeredViews: [], + pendingEffects: [], + }, + contexts: [ + { + variables: { + score: 0, + }, + currentPointerMode: "read", + pointers: { + read: { sectionId: "section1", lineId: "3" }, + history: {}, + }, + rollback: { + currentIndex: 2, + isRestoring: false, + replayStartIndex: 0, + baselineVariables: { + score: 0, + }, + timeline: [ + { + sectionId: "section1", + lineId: "1", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "2", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "3", + rollbackPolicy: "free", + }, + ], + }, + }, + ], + }; + + expect(() => rollbackByOffset({ state }, { offset: -1 })).toThrow( + 'Operation "toggle" is not valid for variable "score" of type "number". Valid operations: set, increment, decrement, multiply, divide', + ); + expect(state.contexts[0].rollback.isRestoring).toBe(false); + }); }); diff --git a/src/RouteEngine.js b/src/RouteEngine.js index 7d1bc544..7767fadd 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -91,9 +91,14 @@ export default function createRouteEngine(options) { const handleActions = (actions, eventContext) => { const context = buildActionTemplateContext(eventContext); const processedActions = processActionTemplates(actions, context); - Object.entries(processedActions).forEach(([actionType, payload]) => { - handleAction(actionType, payload); - }); + _systemStore.beginRollbackActionBatch({}); + try { + Object.entries(processedActions).forEach(([actionType, payload]) => { + handleAction(actionType, payload); + }); + } finally { + _systemStore.endRollbackActionBatch({}); + } }; const handleLineActions = () => { diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 45af997a..7e5e9189 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -1710,7 +1710,6 @@ export const addControl = ( skipMode, canRollback, saveSlots = [], - isLineCompleted, skipTransitionsAndAnimations, }, ) => { diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 22ada45a..417e9b0d 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -50,6 +50,8 @@ const cloneStateValue = (value) => { return structuredClone(source); }; +const rollbackActionBatchStack = []; + const createRollbackCheckpoint = ({ sectionId, lineId, rollbackPolicy }) => ({ sectionId, lineId, @@ -143,6 +145,146 @@ const appendRollbackCheckpoint = (state, payload) => { rollback.currentIndex = rollback.timeline.length - 1; }; +const getCurrentRollbackCheckpoint = (state) => { + const lastContext = state.contexts?.[state.contexts.length - 1]; + const rollback = lastContext?.rollback; + + if ( + !rollback || + rollback.isRestoring || + !Array.isArray(rollback.timeline) || + typeof rollback.currentIndex !== "number" + ) { + return null; + } + + const activeBatch = + rollbackActionBatchStack[rollbackActionBatchStack.length - 1]; + const checkpointIndex = + typeof activeBatch?.checkpointIndex === "number" + ? activeBatch.checkpointIndex + : rollback.currentIndex; + + if (checkpointIndex < 0 || checkpointIndex >= rollback.timeline.length) { + return null; + } + + if ( + typeof activeBatch?.checkpointIndex !== "number" && + checkpointIndex < rollback.timeline.length - 1 + ) { + rollback.timeline = rollback.timeline.slice(0, checkpointIndex + 1); + } + + return rollback.timeline[checkpointIndex] ?? null; +}; + +export const beginRollbackActionBatch = ({ state }) => { + const lastContext = state.contexts?.[state.contexts.length - 1]; + const rollback = lastContext?.rollback; + if ( + !rollback || + rollback.isRestoring || + !Array.isArray(rollback.timeline) || + typeof rollback.currentIndex !== "number" || + rollback.currentIndex < 0 || + rollback.currentIndex >= rollback.timeline.length + ) { + rollbackActionBatchStack.push({ checkpointIndex: null }); + return state; + } + + if (rollback.currentIndex < rollback.timeline.length - 1) { + rollback.timeline = rollback.timeline.slice(0, rollback.currentIndex + 1); + } + + rollbackActionBatchStack.push({ + checkpointIndex: rollback.currentIndex, + }); + return state; +}; + +export const endRollbackActionBatch = ({ state }) => { + rollbackActionBatchStack.pop(); + return state; +}; + +const recordRollbackAction = (state, actionType, payload) => { + const checkpoint = getCurrentRollbackCheckpoint(state); + if (!checkpoint) { + return; + } + + if (!Array.isArray(checkpoint.executedActions)) { + checkpoint.executedActions = []; + } + + checkpoint.executedActions.push({ + type: actionType, + payload: cloneStateValue(payload), + }); +}; + +const applyRollbackCheckpointUpdateVariable = (state, payload) => { + const lastContext = state.contexts?.[state.contexts.length - 1]; + if (!lastContext) { + return; + } + + const operations = payload?.operations ?? []; + for (const { variableId, op, value } of operations) { + const variableConfig = state.projectData.resources?.variables?.[variableId]; + const scope = variableConfig?.scope; + const type = variableConfig?.type; + + validateVariableScope(scope, variableId); + validateVariableOperation(type, op, variableId); + + if (scope === "context") { + lastContext.variables[variableId] = applyVariableOperation( + lastContext.variables[variableId], + op, + value, + ); + } + } +}; + +const replayRecordedRollbackActions = (state, checkpoint) => { + if (!Array.isArray(checkpoint?.executedActions)) { + return false; + } + + const restorableActions = { + showDialogueUI, + hideDialogueUI, + toggleDialogueUI, + showDialogueHistory, + hideDialogueHistory, + setNextLineConfig, + pushLayeredView, + popLayeredView, + replaceLastLayeredView, + clearLayeredViews, + }; + + checkpoint.executedActions.forEach(({ type, payload }) => { + if (type === "updateVariable") { + applyRollbackCheckpointUpdateVariable(state, payload); + return; + } + + const action = restorableActions[type]; + if (!action) { + return; + } + + action({ state }, payload); + }); + + return true; +}; + const applyRollbackableLineActions = (state, payload) => { const { sectionId, lineId } = payload; const section = selectSection({ state }, { sectionId }); @@ -236,45 +378,49 @@ const restoreRollbackCheckpoint = (state, checkpointIndex) => { rollback.isRestoring = true; rollback.currentIndex = checkpointIndex; - lastContext.variables = cloneStateValue(rollback.baselineVariables ?? {}); - state.global.dialogueUIHidden = false; - state.global.isDialogueHistoryShowing = false; - state.global.nextLineConfig = cloneStateValue(DEFAULT_NEXT_LINE_CONFIG); - state.global.layeredViews = []; - state.global.isLineCompleted = true; + try { + lastContext.variables = cloneStateValue(rollback.baselineVariables ?? {}); + state.global.dialogueUIHidden = false; + state.global.isDialogueHistoryShowing = false; + state.global.nextLineConfig = cloneStateValue(DEFAULT_NEXT_LINE_CONFIG); + state.global.layeredViews = []; + state.global.isLineCompleted = true; - const replayStartIndex = rollback.replayStartIndex ?? 0; - for (let i = replayStartIndex; i <= checkpointIndex; i++) { - if (i > replayStartIndex) { - resetNextLineConfigIfSingleLine(state); + const replayStartIndex = rollback.replayStartIndex ?? 0; + for (let i = replayStartIndex; i <= checkpointIndex; i++) { + if (i > replayStartIndex) { + resetNextLineConfigIfSingleLine(state); + } + if (!replayRecordedRollbackActions(state, rollback.timeline[i])) { + applyRollbackableLineActions(state, rollback.timeline[i]); + applyRollbackRestorableLineActions(state, rollback.timeline[i]); + } } - applyRollbackableLineActions(state, rollback.timeline[i]); - applyRollbackRestorableLineActions(state, rollback.timeline[i]); - } - - lastContext.pointers.read = { - sectionId: checkpoint.sectionId, - lineId: checkpoint.lineId, - }; - lastContext.currentPointerMode = "read"; - if (lastContext.pointers?.history) { - lastContext.pointers.history = { - sectionId: null, - lineId: null, - historySequenceIndex: null, + lastContext.pointers.read = { + sectionId: checkpoint.sectionId, + lineId: checkpoint.lineId, }; - } - rollback.isRestoring = false; + lastContext.currentPointerMode = "read"; + if (lastContext.pointers?.history) { + lastContext.pointers.history = { + sectionId: null, + lineId: null, + historySequenceIndex: null, + }; + } - state.global.pendingEffects = state.global.pendingEffects.filter( - (effect) => effect?.name !== "render", - ); + state.global.pendingEffects = state.global.pendingEffects.filter( + (effect) => effect?.name !== "render", + ); - state.global.pendingEffects.push({ - name: "render", - }); + state.global.pendingEffects.push({ + name: "render", + }); + } finally { + rollback.isRestoring = false; + } return state; }; @@ -838,12 +984,14 @@ export const selectRenderState = ({ state }) => { export const pushLayeredView = ({ state }, payload) => { state.global.layeredViews.push(payload); state.global.pendingEffects.push({ name: "render" }); + recordRollbackAction(state, "pushLayeredView", payload); return state; }; export const popLayeredView = ({ state }) => { state.global.layeredViews.pop(); state.global.pendingEffects.push({ name: "render" }); + recordRollbackAction(state, "popLayeredView", undefined); return state; }; @@ -852,12 +1000,14 @@ export const replaceLastLayeredView = ({ state }, payload) => { state.global.layeredViews[state.global.layeredViews.length - 1] = payload; } state.global.pendingEffects.push({ name: "render" }); + recordRollbackAction(state, "replaceLastLayeredView", payload); return state; }; export const clearLayeredViews = ({ state }) => { state.global.layeredViews = []; state.global.pendingEffects.push({ name: "render" }); + recordRollbackAction(state, "clearLayeredViews", undefined); return state; }; @@ -967,6 +1117,7 @@ export const showDialogueUI = ({ state }) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "showDialogueUI", undefined); return state; }; @@ -975,6 +1126,7 @@ export const hideDialogueUI = ({ state }) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "hideDialogueUI", undefined); return state; }; @@ -994,6 +1146,7 @@ export const showDialogueHistory = ({ state }) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "showDialogueHistory", undefined); return state; }; @@ -1002,6 +1155,7 @@ export const hideDialogueHistory = ({ state }) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "hideDialogueHistory", undefined); return state; }; @@ -1103,6 +1257,8 @@ export const setNextLineConfig = ({ state }, payload) => { const { manual, auto, applyMode } = payload; const previousAutoEnabled = state.global.nextLineConfig.auto?.enabled; const previousApplyMode = state.global.nextLineConfig?.applyMode; + const isRollbackRestoring = + state.contexts?.[state.contexts.length - 1]?.rollback?.isRestoring === true; // If both manual and auto are provided, do complete replacement if (manual && auto) { @@ -1137,7 +1293,11 @@ export const setNextLineConfig = ({ state }, payload) => { const currentAutoEnabled = state.global.nextLineConfig.auto?.enabled; // If auto.enabled state has changed, dispatch timer effects - if (currentAutoEnabled === true && !previousAutoEnabled) { + if ( + !isRollbackRestoring && + currentAutoEnabled === true && + !previousAutoEnabled + ) { const trigger = state.global.nextLineConfig.auto?.trigger; // Event-based: only start timer immediately if trigger is "fromStart" @@ -1155,7 +1315,11 @@ export const setNextLineConfig = ({ state }, payload) => { payload: { delay: state.global.nextLineConfig.auto.delay }, }); } - } else if (currentAutoEnabled === false && previousAutoEnabled) { + } else if ( + !isRollbackRestoring && + currentAutoEnabled === false && + previousAutoEnabled + ) { state.global.pendingEffects.push({ name: "clearNextLineConfigTimer", }); @@ -1164,6 +1328,7 @@ export const setNextLineConfig = ({ state }, payload) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "setNextLineConfig", payload); return state; }; @@ -1844,6 +2009,7 @@ export const updateVariable = ({ state }, payload) => { let contextVariableModified = false; let globalDeviceModified = false; let globalAccountModified = false; + const contextOperations = []; operations.forEach(({ variableId, op, value }) => { const variableConfig = state.projectData.resources?.variables?.[variableId]; @@ -1860,6 +2026,7 @@ export const updateVariable = ({ state }, payload) => { // Track which scope was modified if (scope === "context") { contextVariableModified = true; + contextOperations.push({ variableId, op, value }); } else if (scope === "global-device") { globalDeviceModified = true; } else if (scope === "global-account") { @@ -1873,6 +2040,11 @@ export const updateVariable = ({ state }, payload) => { // Log updateVariableId to current line's history entry (EVENT SOURCING) // Only log if context variables were modified (global variables not tracked for rollback) if (contextVariableModified) { + recordRollbackAction(state, "updateVariable", { + id, + operations: contextOperations, + }); + const historySequence = lastContext.historySequence; if (historySequence && historySequence.length > 0) { const currentSection = historySequence[historySequence.length - 1]; @@ -1926,57 +2098,7 @@ export const updateVariable = ({ state }, payload) => { }; /** - * Recursively traverses any object/array looking for updateVariable actions with matching ID. - * This is a generic approach that works regardless of where actions are defined. - * @param {any} obj - The object to search - * @param {string} updateVariableId - The ID to find - * @param {string} parentKey - The key that led to this object (used to detect updateVariable context) - * @returns {Object|undefined} The action definition or undefined - */ -const findUpdateVariableRecursive = (obj, updateVariableId, parentKey = "") => { - if (obj === null || obj === undefined) return undefined; - - // If this is an updateVariable object with matching ID, return it - if ( - parentKey === "updateVariable" && - typeof obj === "object" && - obj.id === updateVariableId && - Array.isArray(obj.operations) - ) { - return obj; - } - - // Recurse into arrays - if (Array.isArray(obj)) { - for (const item of obj) { - const found = findUpdateVariableRecursive( - item, - updateVariableId, - parentKey, - ); - if (found) return found; - } - return undefined; - } - - // Recurse into objects - if (typeof obj === "object") { - for (const key of Object.keys(obj)) { - const found = findUpdateVariableRecursive( - obj[key], - updateVariableId, - key, - ); - if (found) return found; - } - } - - return undefined; -}; - -/** - * Selects a line ID by relative offset from current position in history. - * Uses findLastIndex to handle duplicate line entries after rollback. + * Selects a line ID by relative offset from the active rollback timeline. * * @param {Object} state - Current state object * @param {Object} payload - Selector payload @@ -2003,66 +2125,26 @@ export const selectLineIdByOffset = ({ state }, payload) => { const rollback = lastContext.rollback; if ( - Array.isArray(rollback?.timeline) && - typeof rollback?.currentIndex === "number" + !Array.isArray(rollback?.timeline) || + typeof rollback?.currentIndex !== "number" ) { - const targetIndex = rollback.currentIndex + offset; - if (targetIndex < 0 || targetIndex >= rollback.timeline.length) { - return null; - } - - const targetLine = rollback.timeline[targetIndex]; - return { - sectionId: targetLine.sectionId, - lineId: targetLine.lineId, - }; - } - - // Get current position from read pointer - const currentSectionId = lastContext.pointers.read?.sectionId; - const currentLineId = lastContext.pointers.read?.lineId; - - if (!currentSectionId || !currentLineId) { - return null; - } - - // Find section in history - const historySequence = lastContext.historySequence; - const sectionEntry = historySequence?.find( - (entry) => entry.sectionId === currentSectionId, - ); - - if (!sectionEntry?.lines || sectionEntry.lines.length === 0) { - return null; - } - - // Use findLastIndex to handle duplicate entries after rollback - // When user rolls back and moves forward again, same lineIds may appear multiple times - const currentIndex = sectionEntry.lines.findLastIndex( - (line) => line.id === currentLineId, - ); - - if (currentIndex === -1) { return null; } - // Calculate target index - const targetIndex = currentIndex + offset; - - // Check bounds (within section only, as per user requirement) - if (targetIndex < 0 || targetIndex >= sectionEntry.lines.length) { + const targetIndex = rollback.currentIndex + offset; + if (targetIndex < 0 || targetIndex >= rollback.timeline.length) { return null; } - const targetLine = sectionEntry.lines[targetIndex]; + const targetLine = rollback.timeline[targetIndex]; return { - sectionId: currentSectionId, - lineId: targetLine.id, + sectionId: targetLine.sectionId, + lineId: targetLine.lineId, }; }; /** - * Checks if rollback is possible (not at first line of history). + * Checks if rollback is possible (not at the first rollback checkpoint). * Used for UI to conditionally enable/disable back button. * * @param {Object} state - Current state object @@ -2074,8 +2156,7 @@ export const selectCanRollback = ({ state }) => { }; /** - * Rolls back by a relative offset from current position. - * Convenience action that combines selectLineIdByOffset with rollbackToLine. + * Rolls back by a relative offset from the current rollback checkpoint. * * @param {Object} state - Current state object * @param {Object} payload - Action payload @@ -2097,48 +2178,23 @@ export const rollbackByOffset = ({ state }, payload) => { } const lastContext = state.contexts?.[state.contexts.length - 1]; - const rollback = lastContext?.rollback; - - if ( - Array.isArray(rollback?.timeline) && - typeof rollback?.currentIndex === "number" - ) { - const targetIndex = rollback.currentIndex + offset; - if (targetIndex < 0 || targetIndex >= rollback.timeline.length) { - return state; - } - - return restoreRollbackCheckpoint(state, targetIndex); + if (!lastContext) { + return state; } - // Get target using the selector - const target = selectLineIdByOffset({ state }, { offset }); - - if (!target) { + const rollback = ensureRollbackState(lastContext, { + compatibilityAnchor: !lastContext.rollback, + }); + const targetIndex = rollback.currentIndex + offset; + if (targetIndex < 0 || targetIndex >= rollback.timeline.length) { return state; } - return rollbackToLine( - { state }, - { - sectionId: target.sectionId, - lineId: target.lineId, - }, - ); + return restoreRollbackCheckpoint(state, targetIndex); }; /** - * Rolls back to a specific line using replay-forward algorithm. - * - * Algorithm: - * 1. Get initialState from current section - * 2. Reset context variables to initialState - * 3. For each line in history BEFORE targetLineId: - * - For each updateVariableId in line: - * - Look up action definition in project data - * - Execute the action (apply operations) - * 4. Set pointer to targetLineId - * 5. Switch to read mode + * Rolls back to a specific line using the active rollback timeline. * * @param {Object} state - Current state object * @param {Object} payload - Action payload @@ -2154,111 +2210,22 @@ export const rollbackToLine = ({ state }, payload) => { throw new Error("No context available for rollbackToLine"); } - const rollback = lastContext.rollback; - if (Array.isArray(rollback?.timeline) && rollback.timeline.length > 0) { - const visibleTimeline = rollback.timeline.slice( - 0, - rollback.currentIndex + 1, - ); - const targetLineIndex = visibleTimeline.findLastIndex( - (checkpoint) => - checkpoint.sectionId === sectionId && checkpoint.lineId === lineId, - ); - - if (targetLineIndex === -1) { - throw new Error( - `Line ${lineId} not found in section ${sectionId} rollback timeline`, - ); - } - - return restoreRollbackCheckpoint(state, targetLineIndex); - } - - // Find the section in history - const historySequence = lastContext.historySequence; - const sectionEntry = historySequence?.find( - (entry) => entry.sectionId === sectionId, + const rollback = ensureRollbackState(lastContext, { + compatibilityAnchor: !lastContext.rollback, + }); + const visibleTimeline = rollback.timeline.slice(0, rollback.currentIndex + 1); + const targetLineIndex = visibleTimeline.findLastIndex( + (checkpoint) => + checkpoint.sectionId === sectionId && checkpoint.lineId === lineId, ); - if (!sectionEntry?.lines) { + if (targetLineIndex === -1) { throw new Error( - `Section ${sectionId} not found in history or has no lines`, + `Line ${lineId} not found in section ${sectionId} rollback timeline`, ); } - // Find target line index by lineId - const targetLineIndex = sectionEntry.lines.findIndex( - (line) => line.id === lineId, - ); - if (targetLineIndex === -1) { - throw new Error(`Line ${lineId} not found in section ${sectionId} history`); - } - - // Step 1: Reset context variables to initialState - const initialState = sectionEntry.initialState || {}; - lastContext.variables = { ...initialState }; - - // Step 2: Replay all actions BEFORE the target line - // (We want state as it was BEFORE target line executed) - for (let i = 0; i < targetLineIndex; i++) { - const lineEntry = sectionEntry.lines[i]; - const updateVariableIds = lineEntry.updateVariableIds || []; - - for (const actionId of updateVariableIds) { - // Look up action definition in project data - const actionDef = findUpdateVariableRecursive( - state.projectData, - actionId, - ); - - if (!actionDef) { - throw new Error(`Action definition not found for ID: ${actionId}`); - } - - // Apply the action's operations - const operations = actionDef.operations || []; - for (const { variableId, op, value } of operations) { - const variableConfig = - state.projectData.resources?.variables?.[variableId]; - const scope = variableConfig?.scope; - - // Only apply context-scoped variables during rollback - if (scope === "context") { - lastContext.variables[variableId] = applyVariableOperation( - lastContext.variables[variableId], - op, - value, - ); - } - } - } - } - - // Step 3: Truncate history to target line position - // This removes entries at and after targetLineIndex - sectionEntry.lines = sectionEntry.lines.slice(0, targetLineIndex); - - // Step 4: Add target line to history (fresh entry for rollback support) - sectionEntry.lines.push({ id: lineId }); - - // Step 5: Update pointer to target line - lastContext.pointers.read = { sectionId, lineId }; - - // Step 6: Switch to read mode (makes choices interactive) - lastContext.currentPointerMode = "read"; - lastContext.pointers.history = { - sectionId: null, - lineId: null, - historySequenceIndex: null, - }; - - // Skip animations when rolling back - set to true to disable animation - state.global.isLineCompleted = true; - - // Queue render and line actions - state.global.pendingEffects.push({ name: "handleLineActions" }); - - return state; + return restoreRollbackCheckpoint(state, targetLineIndex); }; /************************** @@ -2309,6 +2276,8 @@ export const createSystemStore = (initialState) => { hideDialogueHistory, clearPendingEffects, appendPendingEffect, + beginRollbackActionBatch, + endRollbackActionBatch, addViewedLine, addViewedResource, setNextLineConfig, diff --git a/vt/reference/rollback/auto-suppressed--capture-01.webp b/vt/reference/rollback/auto-suppressed--capture-01.webp new file mode 100644 index 00000000..452721e0 --- /dev/null +++ b/vt/reference/rollback/auto-suppressed--capture-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3e1768be16fdec7f41c194ab943b2b8d61fc7d23032f28fbc2cdef2083f7e0e +size 3520 diff --git a/vt/reference/rollback/back-one-line--capture-01.webp b/vt/reference/rollback/back-one-line--capture-01.webp new file mode 100644 index 00000000..07b08b1e --- /dev/null +++ b/vt/reference/rollback/back-one-line--capture-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb0b83d4a84d3a58a21a9d49586ebe0e88c50f497728a52ef971a286012a159d +size 3786 diff --git a/vt/reference/rollback/by-offset--capture-01.webp b/vt/reference/rollback/by-offset--capture-01.webp new file mode 100644 index 00000000..76f9e509 --- /dev/null +++ b/vt/reference/rollback/by-offset--capture-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1be3f47b2f58af00aa715bc4365633c91ef4af86712934a5d21dc9af7f6a7e62 +size 2814 diff --git a/vt/reference/rollback/choice-event--capture-01.webp b/vt/reference/rollback/choice-event--capture-01.webp new file mode 100644 index 00000000..b1398e69 --- /dev/null +++ b/vt/reference/rollback/choice-event--capture-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc0d2e11be01f6cbd818a3da910cb80ad248b547a83acc69264045193701505a +size 4942 diff --git a/vt/reference/rollback/cross-section--capture-01.webp b/vt/reference/rollback/cross-section--capture-01.webp new file mode 100644 index 00000000..adf3f641 --- /dev/null +++ b/vt/reference/rollback/cross-section--capture-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:712feeef436a09701c02ea301c67ab001d2bcf5464b56065e4cc1ae6ffd2faba +size 3522 diff --git a/vt/reference/rollback/layered-view-restore--capture-01.webp b/vt/reference/rollback/layered-view-restore--capture-01.webp new file mode 100644 index 00000000..b421626c --- /dev/null +++ b/vt/reference/rollback/layered-view-restore--capture-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37e670357b4bdd3fe40cb9d82c3fb8e3ce128b6bd751b400f08d2ac7fd01455a +size 4580 diff --git a/vt/reference/rollback/to-line--capture-01.webp b/vt/reference/rollback/to-line--capture-01.webp new file mode 100644 index 00000000..c4404a13 --- /dev/null +++ b/vt/reference/rollback/to-line--capture-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:219f4acea930ae78e89e645b629d3eed254b51b2199199ddc15aea3d974a59d0 +size 4078 diff --git a/vt/reference/variables/rollback-offset-test-01.webp b/vt/reference/variables/rollback-offset-test-01.webp deleted file mode 100644 index 5923bfbc..00000000 --- a/vt/reference/variables/rollback-offset-test-01.webp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:64a6bb49936016b65f7fb8182d6471a6fadb6768a6fc1ef738a04a7c6d81c924 -size 5214 diff --git a/vt/reference/variables/rollback-test-01.webp b/vt/reference/variables/rollback-test-01.webp deleted file mode 100644 index b8752f13..00000000 --- a/vt/reference/variables/rollback-test-01.webp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96e92e3f5633aa2d65bbbe21896e670d095aad8f884da26d5a7aad34b7f9f39e -size 3714 diff --git a/vt/specs/rollback/auto-suppressed.yaml b/vt/specs/rollback/auto-suppressed.yaml new file mode 100644 index 00000000..207ec80a --- /dev/null +++ b/vt/specs/rollback/auto-suppressed.yaml @@ -0,0 +1,137 @@ +--- +title: Rollback Auto Suppression +description: Rolling back to a line with scene auto enabled should not restart the line auto timer +specs: + - scene auto advances from line 1 to line 2 on first load + - rollback returns to line 1 + - waiting longer than the auto delay does not advance again after rollback +skipInitialScreenshot: true +viewport: + id: capture + width: 960 + height: 540 +steps: + - action: wait + ms: 420 + - action: click + x: 195 + y: 360 + - action: wait + ms: 520 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" +resources: + characters: + narrator: + name: Guide + layouts: + baseLayout: + elements: + - id: bg + type: rect + width: 1920 + height: 1080 + click: + payload: + actions: + nextLine: {} + colorId: bg + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 100 + y: 760 + width: 1720 + height: 240 + colorId: panel + - id: dialogue-text + type: text + x: 140 + y: 835 + width: 1400 + content: ${dialogue.content[0].text} + textStyleId: textMain + - id: rollback-button + type: rect + x: 100 + y: 680 + width: 320 + height: 60 + colorId: button + click: + payload: + actions: + rollbackByOffset: + offset: -1 + - id: rollback-label + type: text + x: 130 + y: 700 + content: "[ROLLBACK]" + textStyleId: textButton + fonts: + fontDefault: + fileId: Arial + colors: + bg: + hex: "#000000" + panel: + hex: "#4D4D4D" + button: + hex: "#737373" + fg: + hex: "#FFFFFF" + textStyles: + textMain: + fontId: fontDefault + colorId: fg + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textButton: + fontId: fontDefault + colorId: fg + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 +story: + initialSceneId: main + scenes: + main: + initialSectionId: story + sections: + story: + lines: + - id: line1 + actions: + background: + resourceId: baseLayout + setNextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: fromStart + delay: 250 + applyMode: persistent + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1. After rollback, this line should stay visible." + characterId: narrator + - id: line2 + actions: + dialogue: + content: + - text: "Line 2. Roll back, then wait longer than the auto delay." + characterId: narrator diff --git a/vt/specs/rollback/back-one-line.yaml b/vt/specs/rollback/back-one-line.yaml new file mode 100644 index 00000000..47b22227 --- /dev/null +++ b/vt/specs/rollback/back-one-line.yaml @@ -0,0 +1,413 @@ +--- +title: Rollback Back One Line +description: A twenty-line rollback sandbox that crosses multiple sections and scenes while exposing only a single-step back action +specs: + - covers twenty visible checkpoints in total + - crosses four sections across two scenes + - boundary lines enter the next section or scene on a normal screen click + - uses only single-step rollback so you can back up one line at a time +skipInitialScreenshot: true +viewport: + id: capture + width: 960 + height: 540 +steps: + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 100 + - action: click + x: 480 + y: 270 + - action: wait + ms: 100 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 100 + - action: click + x: 480 + y: 270 + - action: wait + ms: 100 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 100 + - action: click + x: 480 + y: 270 + - action: wait + ms: 100 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 200 + y: 360 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" +resources: + characters: + narrator: + name: Guide + layouts: + baseLayout: + elements: + - id: bg + type: rect + width: 1920 + height: 1080 + click: + payload: + actions: + nextLine: {} + colorId: bg + toSectionA2Layout: + elements: + - id: bg + type: rect + width: 1920 + height: 1080 + click: + payload: + actions: + sectionTransition: + sectionId: sectionA2 + colorId: bg + toSectionB1Layout: + elements: + - id: bg + type: rect + width: 1920 + height: 1080 + click: + payload: + actions: + sectionTransition: + sectionId: sectionB1 + colorId: bg + toSectionB2Layout: + elements: + - id: bg + type: rect + width: 1920 + height: 1080 + click: + payload: + actions: + sectionTransition: + sectionId: sectionB2 + colorId: bg + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 100 + y: 760 + width: 1720 + height: 240 + colorId: panel + - id: dialogue-text + type: text + x: 140 + y: 835 + width: 1460 + content: ${dialogue.content[0].text} + textStyleId: textMain + - id: rollback-button + type: rect + x: 100 + y: 680 + width: 320 + height: 60 + colorId: button + click: + payload: + actions: + rollbackByOffset: + offset: -1 + - id: rollback-label + type: text + x: 130 + y: 700 + content: "[BACK 1 LINE]" + textStyleId: textButton + - id: hint-label + type: text + x: 1820 + y: 70 + anchorX: 1 + content: "Click screen to advance" + textStyleId: textHint + fonts: + fontDefault: + fileId: Arial + colors: + bg: + hex: "#000000" + panel: + hex: "#4D4D4D" + button: + hex: "#737373" + fg: + hex: "#FFFFFF" + fgMuted: + hex: "#D9D9D9" + textStyles: + textMain: + fontId: fontDefault + colorId: fg + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textButton: + fontId: fontDefault + colorId: fg + fontSize: 26 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textHint: + fontId: fontDefault + colorId: fgMuted + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 +story: + initialSceneId: sceneA + scenes: + sceneA: + initialSectionId: sectionA1 + sections: + sectionA1: + lines: + - id: line1 + actions: + background: + resourceId: baseLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "1/20. Scene A, section A1." + characterId: narrator + - id: line2 + actions: + dialogue: + content: + - text: "2/20. Scene A, section A1." + characterId: narrator + - id: line3 + actions: + dialogue: + content: + - text: "3/20. Scene A, section A1." + characterId: narrator + - id: line4 + actions: + dialogue: + content: + - text: "4/20. Scene A, section A1." + characterId: narrator + - id: line5 + actions: + background: + resourceId: toSectionA2Layout + dialogue: + content: + - text: "5/20. Boundary checkpoint. Click anywhere to enter scene A, section A2." + characterId: narrator + sectionA2: + lines: + - id: line1 + actions: + background: + resourceId: baseLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "6/20. Scene A, section A2." + characterId: narrator + - id: line2 + actions: + dialogue: + content: + - text: "7/20. Scene A, section A2." + characterId: narrator + - id: line3 + actions: + dialogue: + content: + - text: "8/20. Scene A, section A2." + characterId: narrator + - id: line4 + actions: + dialogue: + content: + - text: "9/20. Scene A, section A2." + characterId: narrator + - id: line5 + actions: + background: + resourceId: toSectionB1Layout + dialogue: + content: + - text: "10/20. Boundary checkpoint. Click anywhere to enter scene B, section B1." + characterId: narrator + sceneB: + initialSectionId: sectionB1 + sections: + sectionB1: + lines: + - id: line1 + actions: + background: + resourceId: baseLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "11/20. Scene B, section B1." + characterId: narrator + - id: line2 + actions: + dialogue: + content: + - text: "12/20. Scene B, section B1." + characterId: narrator + - id: line3 + actions: + dialogue: + content: + - text: "13/20. Scene B, section B1." + characterId: narrator + - id: line4 + actions: + dialogue: + content: + - text: "14/20. Scene B, section B1." + characterId: narrator + - id: line5 + actions: + background: + resourceId: toSectionB2Layout + dialogue: + content: + - text: "15/20. Boundary checkpoint. Click anywhere to enter scene B, section B2." + characterId: narrator + sectionB2: + lines: + - id: line1 + actions: + background: + resourceId: baseLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "16/20. Scene B, section B2." + characterId: narrator + - id: line2 + actions: + dialogue: + content: + - text: "17/20. Scene B, section B2." + characterId: narrator + - id: line3 + actions: + dialogue: + content: + - text: "18/20. Scene B, section B2." + characterId: narrator + - id: line4 + actions: + dialogue: + content: + - text: "19/20. Scene B, section B2." + characterId: narrator + - id: line5 + actions: + dialogue: + content: + - text: "20/20. Final checkpoint. Use BACK 1 LINE to step backward one line at a time." + characterId: narrator diff --git a/vt/specs/rollback/by-offset.yaml b/vt/specs/rollback/by-offset.yaml new file mode 100644 index 00000000..7094aa7b --- /dev/null +++ b/vt/specs/rollback/by-offset.yaml @@ -0,0 +1,223 @@ +--- +title: Rollback By Offset +description: rollbackByOffset should jump back multiple checkpoints and restore score and line state +specs: + - advances through five lines with cumulative score changes + - rolls back three checkpoints in one action + - restores the earlier line and its score total +skipInitialScreenshot: true +viewport: + id: capture + width: 960 + height: 540 +steps: + - action: click + x: 480 + y: 270 + - action: click + x: 480 + y: 270 + - action: click + x: 480 + y: 270 + - action: click + x: 480 + y: 270 + - action: wait + ms: 120 + - action: click + x: 420 + y: 360 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" +resources: + variables: + score: + type: number + scope: context + default: 0 + lineVisited: + type: number + scope: context + default: 1 + characters: + narrator: + name: Guide + layouts: + baseLayout: + elements: + - id: bg + type: rect + width: 1920 + height: 1080 + click: + payload: + actions: + nextLine: {} + colorId: bg + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 100 + y: 760 + width: 1720 + height: 240 + colorId: panel + - id: dialogue-text + type: text + x: 140 + y: 835 + width: 1300 + content: ${dialogue.content[0].text} + textStyleId: textMain + - id: score-display + type: text + x: 1780 + y: 70 + anchorX: 1 + content: "Score: ${variables.score}" + textStyleId: textScore + - id: rollback-three + type: rect + x: 520 + y: 680 + width: 360 + height: 60 + colorId: button + click: + payload: + actions: + rollbackByOffset: + offset: -3 + - id: rollback-three-label + type: text + x: 555 + y: 700 + content: "[BACK 3 LINES]" + textStyleId: textButton + fonts: + fontDefault: + fileId: Arial + colors: + bg: + hex: "#000000" + panel: + hex: "#4D4D4D" + button: + hex: "#737373" + fg: + hex: "#FFFFFF" + fgMuted: + hex: "#D9D9D9" + fgSoft: + hex: "#A6A6A6" + textStyles: + textMain: + fontId: fontDefault + colorId: fg + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textScore: + fontId: fontDefault + colorId: fgMuted + fontSize: 32 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textLine: + fontId: fontDefault + colorId: fgSoft + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textButton: + fontId: fontDefault + colorId: fg + fontSize: 26 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 +story: + initialSceneId: main + scenes: + main: + initialSectionId: story + sections: + story: + lines: + - id: line1 + actions: + background: + resourceId: baseLayout + updateVariable: + id: visit1 + operations: + - variableId: lineVisited + op: set + value: 1 + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1. Start at score 0." + characterId: narrator + - id: line2 + actions: + updateVariable: + id: visit2 + operations: + - variableId: score + op: increment + value: 5 + dialogue: + content: + - text: "Line 2. Score is 5." + characterId: narrator + - id: line3 + actions: + updateVariable: + id: visit3 + operations: + - variableId: score + op: increment + value: 10 + dialogue: + content: + - text: "Line 3. Score is 15." + characterId: narrator + - id: line4 + actions: + updateVariable: + id: visit4 + operations: + - variableId: score + op: increment + value: 15 + dialogue: + content: + - text: "Line 4. Score is 30." + characterId: narrator + - id: line5 + actions: + updateVariable: + id: visit5 + operations: + - variableId: score + op: increment + value: 20 + dialogue: + content: + - text: "Line 5. Score is 50. Roll back three lines." + characterId: narrator diff --git a/vt/specs/rollback/choice-event.yaml b/vt/specs/rollback/choice-event.yaml new file mode 100644 index 00000000..9cde3b48 --- /dev/null +++ b/vt/specs/rollback/choice-event.yaml @@ -0,0 +1,225 @@ +--- +title: Rollback Choice Event +description: Choice click actions should stay attached to the source checkpoint through rollback +specs: + - two branch choices are visible on the source line + - a choice click updates score and transitions to another section + - rollback returns to the source line instead of the result section + - the source line keeps the branch score from the executed choice action +skipInitialScreenshot: true +viewport: + id: capture + width: 960 + height: 540 +steps: + - action: click + x: 780 + y: 140 + - action: wait + ms: 150 + - action: click + x: 195 + y: 360 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" +resources: + variables: + score: + type: number + scope: context + default: 0 + characters: + narrator: + name: Guide + layouts: + baseLayout: + elements: + - id: bg + type: rect + width: 1920 + height: 1080 + colorId: bg + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 100 + y: 760 + width: 1720 + height: 240 + colorId: panel + - id: dialogue-text + type: text + x: 140 + y: 835 + width: 1200 + content: ${dialogue.content[0].text} + textStyleId: textMain + - id: score-display + type: text + x: 1780 + y: 70 + anchorX: 1 + content: "Score: ${variables.score}" + textStyleId: textScore + - id: rollback-button + type: rect + x: 100 + y: 680 + width: 320 + height: 60 + colorId: button + click: + payload: + actions: + rollbackByOffset: + offset: -1 + - id: rollback-label + type: text + x: 130 + y: 700 + content: "[ROLLBACK]" + textStyleId: textButton + choiceLayout: + elements: + - id: choice-a-button + type: rect + x: 260 + y: 220 + width: 1400 + height: 80 + colorId: button + click: + payload: + actions: + updateVariable: + id: chooseA15 + operations: + - variableId: score + op: set + value: 15 + sectionTransition: + sectionId: result + - id: choice-a-label + type: text + x: 320 + y: 245 + content: "Choose A" + textStyleId: textMain + - id: choice-b-button + type: rect + x: 260 + y: 330 + width: 1400 + height: 80 + colorId: button + click: + payload: + actions: + updateVariable: + id: chooseB5 + operations: + - variableId: score + op: set + value: 5 + sectionTransition: + sectionId: resultB + - id: choice-b-label + type: text + x: 320 + y: 355 + content: "Choose B" + textStyleId: textMain + fonts: + fontDefault: + fileId: Arial + colors: + bg: + hex: "#000000" + panel: + hex: "#4D4D4D" + button: + hex: "#737373" + fg: + hex: "#FFFFFF" + fgMuted: + hex: "#D9D9D9" + textStyles: + textMain: + fontId: fontDefault + colorId: fg + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textScore: + fontId: fontDefault + colorId: fgMuted + fontSize: 32 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textButton: + fontId: fontDefault + colorId: fg + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 +story: + initialSceneId: main + scenes: + main: + initialSectionId: source + sections: + source: + lines: + - id: line1 + actions: + background: + resourceId: baseLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Source line. Score should stay at 15 after rollback." + characterId: narrator + choice: + resourceId: choiceLayout + items: + - id: a + content: Choose A + - id: b + content: Choose B + result: + lines: + - id: line1 + actions: + choice: {} + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Result line after the choice click." + characterId: narrator + resultB: + lines: + - id: line1 + actions: + choice: {} + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Result line after choice B." + characterId: narrator diff --git a/vt/specs/rollback/cross-section.yaml b/vt/specs/rollback/cross-section.yaml new file mode 100644 index 00000000..3b4ec91b --- /dev/null +++ b/vt/specs/rollback/cross-section.yaml @@ -0,0 +1,184 @@ +--- +title: Rollback Cross Section +description: Rollback should replay to a checkpoint in a previous section and restore that section's state +specs: + - advances from a source section into a result section + - the result section adds more context state on arrival + - rollbackToLine returns directly to the source checkpoint and restores its score +skipInitialScreenshot: true +viewport: + id: capture + width: 960 + height: 540 +steps: + - action: click + x: 480 + y: 270 + - action: click + x: 480 + y: 270 + - action: wait + ms: 150 + - action: click + x: 200 + y: 360 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" +resources: + variables: + score: + type: number + scope: context + default: 0 + characters: + narrator: + name: Guide + layouts: + baseLayout: + elements: + - id: bg + type: rect + width: 1920 + height: 1080 + click: + payload: + actions: + nextLine: {} + colorId: bg + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 100 + y: 760 + width: 1720 + height: 240 + colorId: panel + - id: dialogue-text + type: text + x: 140 + y: 835 + width: 1320 + content: ${dialogue.content[0].text} + textStyleId: textMain + - id: score-display + type: text + x: 1780 + y: 70 + anchorX: 1 + content: "Score: ${variables.score}" + textStyleId: textScore + - id: rollback-button + type: rect + x: 100 + y: 680 + width: 320 + height: 60 + colorId: button + click: + payload: + actions: + rollbackToLine: + sectionId: source + lineId: line2 + - id: rollback-label + type: text + x: 130 + y: 700 + content: "[BACK TO SOURCE]" + textStyleId: textButton + fonts: + fontDefault: + fileId: Arial + colors: + bg: + hex: "#000000" + panel: + hex: "#4D4D4D" + button: + hex: "#737373" + fg: + hex: "#FFFFFF" + fgMuted: + hex: "#D9D9D9" + textStyles: + textMain: + fontId: fontDefault + colorId: fg + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textScore: + fontId: fontDefault + colorId: fgMuted + fontSize: 32 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textButton: + fontId: fontDefault + colorId: fg + fontSize: 25 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 +story: + initialSceneId: main + scenes: + main: + initialSectionId: source + sections: + source: + lines: + - id: line1 + actions: + background: + resourceId: baseLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Source line 1. Advance to the checkpoint line." + characterId: narrator + - id: line2 + actions: + updateVariable: + id: sourceScore10 + operations: + - variableId: score + op: set + value: 10 + dialogue: + content: + - text: "Source line 2. Score is 10 here." + characterId: narrator + - id: line3 + actions: + sectionTransition: + sectionId: result + result: + lines: + - id: line1 + actions: + updateVariable: + id: resultAdd5 + operations: + - variableId: score + op: increment + value: 5 + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Result line. Score is 15 before rollback." + characterId: narrator diff --git a/vt/specs/rollback/layered-view-restore.yaml b/vt/specs/rollback/layered-view-restore.yaml new file mode 100644 index 00000000..520b6f23 --- /dev/null +++ b/vt/specs/rollback/layered-view-restore.yaml @@ -0,0 +1,219 @@ +--- +title: Rollback Restores Layered View +description: Rolling back to a checkpoint should restore the current line layered view state +specs: + - line 2 opens a layered info panel + - line 3 clears the layered view + - rollback returns to line 2 and restores the info panel +skipInitialScreenshot: true +viewport: + id: capture + width: 960 + height: 540 +steps: + - action: click + x: 885 + y: 360 + - action: click + x: 885 + y: 360 + - action: wait + ms: 120 + - action: click + x: 195 + y: 360 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" +resources: + characters: + narrator: + name: Guide + layouts: + baseLayout: + elements: + - id: bg + type: rect + width: 1920 + height: 1080 + click: + payload: + actions: + nextLine: {} + colorId: bg + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 100 + y: 760 + width: 1720 + height: 240 + colorId: panel + - id: dialogue-text + type: text + x: 140 + y: 835 + width: 1400 + content: ${dialogue.content[0].text} + textStyleId: textMain + - id: rollback-button + type: rect + x: 100 + y: 680 + width: 320 + height: 60 + colorId: button + click: + payload: + actions: + rollbackByOffset: + offset: -1 + - id: rollback-label + type: text + x: 130 + y: 700 + content: "[ROLLBACK]" + textStyleId: textButton + - id: next-button + type: rect + x: 1540 + y: 680 + width: 280 + height: 60 + colorId: button + click: + payload: + actions: + nextLine: {} + - id: next-label + type: text + x: 1600 + y: 700 + content: "[NEXT]" + textStyleId: textButton + infoPanel: + elements: + - id: info-panel + type: rect + x: 500 + y: 180 + width: 920 + height: 220 + opacity: 0.92 + colorId: button + - id: info-title + type: text + x: 560 + y: 225 + content: "Overlay Restored" + textStyleId: textOverlayTitle + - id: info-body + type: text + x: 560 + y: 290 + width: 420 + content: "This panel exists only on line 2." + textStyleId: textOverlayBody + - id: info-rollback-button + type: rect + x: 1080 + y: 325 + width: 260 + height: 50 + colorId: panel + click: + payload: + actions: + rollbackByOffset: + offset: -1 + - id: info-rollback-label + type: text + x: 1125 + y: 340 + content: "[BACK]" + textStyleId: textOverlayBody + fonts: + fontDefault: + fileId: Arial + colors: + bg: + hex: "#000000" + panel: + hex: "#4D4D4D" + button: + hex: "#737373" + fg: + hex: "#FFFFFF" + fgMuted: + hex: "#D9D9D9" + textStyles: + textMain: + fontId: fontDefault + colorId: fg + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textButton: + fontId: fontDefault + colorId: fg + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textOverlayTitle: + fontId: fontDefault + colorId: fg + fontSize: 34 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textOverlayBody: + fontId: fontDefault + colorId: fgMuted + fontSize: 24 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 +story: + initialSceneId: main + scenes: + main: + initialSectionId: story + sections: + story: + lines: + - id: line1 + actions: + background: + resourceId: baseLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1. Move forward to the overlay line." + characterId: narrator + - id: line2 + actions: + pushLayeredView: + resourceId: infoPanel + resourceType: layout + dialogue: + content: + - text: "Line 2. Overlay restored." + characterId: narrator + - id: line3 + actions: + clearLayeredViews: {} + dialogue: + content: + - text: "Line 3. Overlay is cleared. Roll back one line." + characterId: narrator diff --git a/vt/specs/variables/rollback-test.yaml b/vt/specs/rollback/to-line.yaml similarity index 53% rename from vt/specs/variables/rollback-test.yaml rename to vt/specs/rollback/to-line.yaml index 6815af05..78ad3ffb 100644 --- a/vt/specs/variables/rollback-test.yaml +++ b/vt/specs/rollback/to-line.yaml @@ -1,6 +1,33 @@ --- -title: Rollback Test -description: Simple visual novel simulation to test variable rollback +title: Rollback To Line +description: Direct rollbackToLine should restore the source line and reset context score +specs: + - advances through score-changing lines + - uses rollbackToLine to return to the first line + - restores score and dialogue content for the target line +skipInitialScreenshot: true +viewport: + id: capture + width: 960 + height: 540 +steps: + - action: click + x: 480 + y: 270 + - action: click + x: 480 + y: 270 + - action: click + x: 480 + y: 270 + - action: wait + ms: 120 + - action: click + x: 200 + y: 360 + - action: wait + ms: 150 + - action: screenshot --- screen: width: 1920 @@ -26,104 +53,83 @@ resources: payload: actions: nextLine: {} - colorId: layoutColor1 - dialogueUI: - name: Dialogue UI + colorId: bg + dialogueLayout: mode: adv elements: - - id: dialogueBox + - id: dialogue-box type: rect x: 100 - y: 750 + y: 760 width: 1720 - height: 280 - opacity: 0.9 - click: - payload: - actions: - nextLine: {} - colorId: layoutColor2 - - id: charName - type: text - content: ${dialogue.character.name} - x: 150 - y: 770 - textStyleId: textStyle1 - - id: dialogueText + height: 240 + colorId: panel + - id: dialogue-text type: text + x: 140 + y: 835 + width: 1400 content: ${dialogue.content[0].text} - x: 150 - y: 820 - width: 1620 - textStyleId: textStyle2 - - id: scoreDisplay + textStyleId: textMain + - id: score-display type: text - content: "Score: ${variables.score}" - x: 1700 - y: 50 + x: 1780 + y: 70 anchorX: 1 - textStyleId: textStyle3 - - id: backBtn - type: text - content: "[BACK to Line 1]" + content: "Score: ${variables.score}" + textStyleId: textScore + - id: rollback-button + type: rect x: 100 - y: 700 - hover: - textStyleId: textStyle5 + y: 680 + width: 320 + height: 60 + colorId: button click: payload: actions: rollbackToLine: sectionId: story lineId: line1 - textStyleId: textStyle4 + - id: rollback-label + type: text + x: 130 + y: 700 + content: "[BACK TO LINE 1]" + textStyleId: textButton fonts: fontDefault: fileId: Arial colors: - color1: - hex: "#737373" - color2: - hex: "#D9D9D9" - color3: - hex: "#A6A6A6" - layoutColor1: + bg: hex: "#000000" - layoutColor2: + panel: hex: "#4D4D4D" + button: + hex: "#737373" + fg: + hex: "#FFFFFF" + fgMuted: + hex: "#D9D9D9" textStyles: - textStyle1: + textMain: fontId: fontDefault - colorId: color1 - fontSize: 28 - fontWeight: bold - fontStyle: normal - lineHeight: 1.2 - textStyle2: - fontId: fontDefault - colorId: color2 - fontSize: 24 + colorId: fg + fontSize: 30 fontWeight: "400" fontStyle: normal lineHeight: 1.2 - textStyle3: + textScore: fontId: fontDefault - colorId: color3 + colorId: fgMuted fontSize: 32 fontWeight: bold fontStyle: normal lineHeight: 1.2 - textStyle4: - fontId: fontDefault - colorId: color1 - fontSize: 28 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle5: + textButton: fontId: fontDefault - colorId: color3 - fontSize: 28 + colorId: fg + fontSize: 26 fontWeight: "400" fontStyle: normal lineHeight: 1.2 @@ -131,11 +137,9 @@ story: initialSceneId: main scenes: main: - name: Main Scene initialSectionId: story sections: story: - name: Story lines: - id: line1 actions: @@ -144,43 +148,37 @@ story: dialogue: mode: adv ui: - resourceId: dialogueUI + resourceId: dialogueLayout content: - - text: Welcome! Your current score is 0. Click to continue... + - text: "Line 1. After rollback, score should be back at 0." characterId: narrator - id: line2 actions: dialogue: content: - - text: This is the second line. Nothing changes here yet. + - text: "Line 2. Score is still 0." characterId: narrator - id: line3 actions: updateVariable: - id: uvAddScore + id: add10 operations: - variableId: score op: increment value: 10 dialogue: content: - - text: You gained +10 points! Watch the score in the corner. + - text: "Line 3. Score increased to 10." characterId: narrator - id: line4 actions: updateVariable: - id: uvAddMore + id: add20 operations: - variableId: score op: increment value: 20 dialogue: content: - - text: Another +20 points! Now try clicking [BACK] to return to line 1. - characterId: narrator - - id: line5 - actions: - dialogue: - content: - - text: If rollback works, your score should reset to 0 when you go back! + - text: "Line 4. Score is now 30. Use rollback." characterId: narrator diff --git a/vt/specs/variables/rollback-offset-test.yaml b/vt/specs/variables/rollback-offset-test.yaml deleted file mode 100644 index af28dcb2..00000000 --- a/vt/specs/variables/rollback-offset-test.yaml +++ /dev/null @@ -1,273 +0,0 @@ ---- -title: Rollback By Offset Test -description: Test rollbackByOffset action - go back N lines with variable reversion ---- -screen: - width: 1920 - height: 1080 - backgroundColor: "#000000" -resources: - variables: - score: - type: number - scope: context - default: 0 - lineVisited: - type: number - scope: context - default: 1 - characters: - narrator: - name: Guide - layouts: - baseLayout: - elements: - - id: bg - type: rect - width: 1920 - height: 1080 - click: - payload: - actions: - nextLine: {} - colorId: layoutColor1 - dialogueUI: - name: Dialogue UI - mode: adv - elements: - - id: dialogueBox - type: rect - x: 100 - y: 750 - width: 1720 - height: 280 - opacity: 0.9 - click: - payload: - actions: - nextLine: {} - colorId: color4 - - id: charName - type: text - content: ${dialogue.character.name} - x: 150 - y: 770 - textStyleId: textStyle1 - - id: dialogueText - type: text - content: ${dialogue.content[0].text} - x: 150 - y: 820 - width: 1620 - textStyleId: textStyle2 - - id: scoreDisplay - type: text - content: "Score: ${variables.score}" - x: 1700 - y: 50 - anchorX: 1 - textStyleId: textStyle3 - - id: lineIndicator - type: text - content: "Line visited: ${variables.lineVisited}" - x: 1700 - y: 100 - anchorX: 1 - textStyleId: textStyle4 - - id: backOneBtn - type: text - content: "[BACK 1 LINE]" - x: 100 - y: 680 - hover: - textStyleId: textStyle5 - click: - payload: - actions: - rollbackByOffset: - offset: -1 - textStyleId: textStyle4 - - id: backTwoBtn - type: text - content: "[BACK 2 LINES]" - x: 350 - y: 680 - hover: - textStyleId: textStyle4 - click: - payload: - actions: - rollbackByOffset: - offset: -2 - textStyleId: textStyle4 - - id: backThreeBtn - type: text - content: "[BACK 3 LINES]" - x: 620 - y: 680 - hover: - textStyleId: textStyle4 - click: - payload: - actions: - rollbackByOffset: - offset: -3 - textStyleId: textStyle6 - fonts: - fontDefault: - fileId: Arial - colors: - color1: - hex: "#737373" - color2: - hex: "#D9D9D9" - color3: - hex: "#A6A6A6" - color4: - hex: "#4D4D4D" - layoutColor1: - hex: "#000000" - textStyles: - textStyle1: - fontId: fontDefault - colorId: color1 - fontSize: 28 - fontWeight: bold - fontStyle: normal - lineHeight: 1.2 - textStyle2: - fontId: fontDefault - colorId: color2 - fontSize: 24 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle3: - fontId: fontDefault - colorId: color3 - fontSize: 32 - fontWeight: bold - fontStyle: normal - lineHeight: 1.2 - textStyle4: - fontId: fontDefault - colorId: color1 - fontSize: 24 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle5: - fontId: fontDefault - colorId: color3 - fontSize: 24 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle6: - fontId: fontDefault - colorId: color4 - fontSize: 24 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 -story: - initialSceneId: main - scenes: - main: - name: Main Scene - initialSectionId: story - sections: - story: - name: Story - lines: - - id: line1 - actions: - background: - resourceId: baseLayout - updateVariable: - id: uvLine1 - operations: - - variableId: lineVisited - op: set - value: 1 - dialogue: - mode: adv - ui: - resourceId: dialogueUI - content: - - text: "LINE 1: Score is 0. Use the back buttons above to test offset rollback!" - characterId: narrator - - id: line2 - actions: - updateVariable: - id: uvLine2Score - operations: - - variableId: score - op: increment - value: 5 - - variableId: lineVisited - op: set - value: 2 - dialogue: - content: - - text: "LINE 2: +5 points! (Total: 5)" - characterId: narrator - - id: line3 - actions: - updateVariable: - id: uvLine3Score - operations: - - variableId: score - op: increment - value: 10 - - variableId: lineVisited - op: set - value: 3 - dialogue: - content: - - text: "LINE 3: +10 points! (Total: 15)" - characterId: narrator - - id: line4 - actions: - updateVariable: - id: uvLine4Score - operations: - - variableId: score - op: increment - value: 15 - - variableId: lineVisited - op: set - value: 4 - dialogue: - content: - - text: "LINE 4: +15 points! (Total: 30)" - characterId: narrator - - id: line5 - actions: - updateVariable: - id: uvLine5Score - operations: - - variableId: score - op: increment - value: 20 - - variableId: lineVisited - op: set - value: 5 - dialogue: - content: - - text: "LINE 5: +20 points! (Total: 50) - Try the back buttons now!" - characterId: narrator - - id: line6 - actions: - updateVariable: - id: uvLine6 - operations: - - variableId: lineVisited - op: set - value: 6 - dialogue: - content: - - text: - "LINE 6: End of test. Expected behavior: [BACK 1] goes to line 5 (score - 30), [BACK 2] goes to line 4 (score 15), etc." - characterId: narrator From 6aac44b8c8d959b50c825492e5fb7c1098f6f626 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Mon, 30 Mar 2026 21:27:58 +0800 Subject: [PATCH 4/5] Derive rollback restore state from project defaults --- docs/Rollback.md | 5 +- docs/RollbackImplementationPlan.md | 26 ++++++--- spec/system/actions/loadSaveSlot.spec.yaml | 11 ---- spec/system/actions/nextLine.spec.yaml | 6 --- .../actions/nextLineFromSystem.spec.yaml | 4 -- .../system/actions/rollbackByOffset.spec.yaml | 20 ------- spec/system/actions/rollbackToLine.spec.yaml | 25 ++------- spec/system/actions/saveSaveSlot.spec.yaml | 8 --- .../actions/sectionTransition.spec.yaml | 1 - spec/system/createInitialState.spec.yaml | 10 ---- .../selectors/selectLineIdByOffset.spec.yaml | 3 -- spec/systemStore.rollbackDraftSafety.test.js | 53 ++++++++++++++----- src/stores/system.store.js | 44 +++++++++------ 13 files changed, 93 insertions(+), 123 deletions(-) diff --git a/docs/Rollback.md b/docs/Rollback.md index b934e844..aa3c0df4 100644 --- a/docs/Rollback.md +++ b/docs/Rollback.md @@ -241,6 +241,7 @@ The exact field names may differ, but the model should support: - ordered line checkpoints across section boundaries - replaying rollbackable story actions from timeline history - an array-index cursor for the current rollback position +- deriving rollback start state from project-defined context variable defaults - a temporary restore guard for rollback reconstruction - future rollback policy expansion @@ -265,7 +266,7 @@ The model is: - rollback timeline stores the visited line sequence - each entry identifies a visited line by `sectionId` and `lineId` -- rollback restoration resets context-scoped story state to the context baseline +- rollback restoration resets context-scoped story state to the default values of context-scoped variables from project data - the engine replays rollbackable story actions from the start of the timeline up to the target timeline index - presentation is then reconstructed from the resulting story state @@ -274,6 +275,7 @@ This means: - presentation snapshots are never stored - context variable snapshots are not the source of truth - story state is derived from the rollback timeline +- rollback state should not store a duplicate `baselineVariables` snapshot when the same start state can be derived from project data For now, replayability is based on line sequence plus rollbackable action classification. @@ -338,6 +340,7 @@ That means: - saves must serialize the rollback timeline - saves must serialize the current rollback cursor +- loads must recompute rollback start state from project-defined context variable defaults - loading a save must restore rollback ability from that saved point Compatibility behavior for older saves without rollback timeline should be defined during implementation. diff --git a/docs/RollbackImplementationPlan.md b/docs/RollbackImplementationPlan.md index f04e984c..2c838228 100644 --- a/docs/RollbackImplementationPlan.md +++ b/docs/RollbackImplementationPlan.md @@ -116,7 +116,7 @@ Specifically: - do not snapshot presentation - do not treat variable snapshots as the source of truth - on rollback: - - reset context variables to the context baseline + - reset context variables to the default values of context-scoped variables derived from project data - replay rollbackable story actions from the start of the timeline up to the target index - move `read` pointer to the target checkpoint - reconstruct presentation/render state from restored story state @@ -183,6 +183,11 @@ rollback: { } ``` +Target model rule: + +- rollback state should not store a duplicate `baselineVariables` snapshot +- restore start state should be derived on demand from project-defined defaults for context-scoped variables + Checkpoint shape: ```js @@ -367,7 +372,7 @@ Responsibilities: 2. stop skip mode 3. set `rollback.isRestoring = true` 4. set `rollback.currentIndex` -5. reset context variables to the context baseline +5. reset context variables to the default values of context-scoped variables derived from project data 6. replay rollbackable actions from `timeline[0..checkpointIndex]` 7. restore `pointers.read` 8. set `rollback.isRestoring = false` @@ -459,7 +464,7 @@ So rollback should not attempt to store or restore: Instead: -1. reset story state to the context baseline +1. reset story state to the default values of context-scoped variables derived from project data 2. replay rollbackable timeline entries up to target index 3. restore pointer to target line 4. queue normal render effects @@ -537,10 +542,13 @@ Implementation requirement: - loading a save must restore both: - `rollback.timeline` - `rollback.currentIndex` +- rollback save data should not store a duplicate `baselineVariables` snapshot +- restore start state should be recomputed from project data after load Compatibility requirement: - define explicit behavior for older saves that do not contain rollback state +- define whether older saves that still contain `baselineVariables` ignore that field or receive a one-time migration Recommended default: @@ -589,6 +597,7 @@ Add or migrate tests for: 15. `rollback.isRestoring` prevents duplicate checkpoint append 16. `rollback.isRestoring` prevents duplicate `updateVariable` execution 17. old-save compatibility initializes a minimal rollback timeline correctly +18. rollback restore start state is derived from project defaults, not serialized baseline snapshots ### Regression tests for divergence @@ -618,11 +627,12 @@ Recommended order: 4. add branch truncation logic 5. add `rollback.isRestoring` guard and restore helper 6. implement replay-based rollback reconstruction from timeline start -7. switch `rollbackByOffset` to new timeline -8. switch UI-facing back flows to true rollback -9. stop rollback logic from depending on `historySequence` -10. update docs and tests -11. evaluate whether `prevLine` / `history` pointer can be simplified or removed later +7. derive rollback restore start state from project-defined context defaults instead of stored baseline snapshots +8. switch `rollbackByOffset` to new timeline +9. switch UI-facing back flows to true rollback +10. stop rollback logic from depending on `historySequence` +11. update docs and tests +12. evaluate whether `prevLine` / `history` pointer can be simplified or removed later ## Risks diff --git a/spec/system/actions/loadSaveSlot.spec.yaml b/spec/system/actions/loadSaveSlot.spec.yaml index e65e4e54..67c313ee 100644 --- a/spec/system/actions/loadSaveSlot.spec.yaml +++ b/spec/system/actions/loadSaveSlot.spec.yaml @@ -45,7 +45,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "sec2" lineId: "line2" @@ -185,10 +184,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 1 - baselineVariables: - playerName: "Alice" - score: 150 - soundEnabled: true timeline: - sectionId: "sec2" lineId: "line2" @@ -218,8 +213,6 @@ in: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "sec1" lineId: "line1" @@ -254,8 +247,6 @@ out: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "sec1" lineId: "line1" @@ -282,8 +273,6 @@ out: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "sec1" lineId: "line1" diff --git a/spec/system/actions/nextLine.spec.yaml b/spec/system/actions/nextLine.spec.yaml index b185b947..1713cd16 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -184,7 +184,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -271,7 +270,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -351,7 +349,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -396,7 +393,6 @@ in: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -447,7 +443,6 @@ out: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -815,7 +810,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" diff --git a/spec/system/actions/nextLineFromSystem.spec.yaml b/spec/system/actions/nextLineFromSystem.spec.yaml index 322908fc..f87b8b71 100644 --- a/spec/system/actions/nextLineFromSystem.spec.yaml +++ b/spec/system/actions/nextLineFromSystem.spec.yaml @@ -108,7 +108,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -183,7 +182,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -259,7 +257,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -331,7 +328,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" diff --git a/spec/system/actions/rollbackByOffset.spec.yaml b/spec/system/actions/rollbackByOffset.spec.yaml index 6c5bd4fb..f66157f0 100644 --- a/spec/system/actions/rollbackByOffset.spec.yaml +++ b/spec/system/actions/rollbackByOffset.spec.yaml @@ -72,7 +72,6 @@ in: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -123,7 +122,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -174,8 +172,6 @@ in: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -242,8 +238,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -288,8 +282,6 @@ in: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -326,8 +318,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -382,8 +372,6 @@ in: currentIndex: 3 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -460,8 +448,6 @@ out: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -542,8 +528,6 @@ in: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -624,8 +608,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -680,7 +662,6 @@ in: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -743,7 +724,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" diff --git a/spec/system/actions/rollbackToLine.spec.yaml b/spec/system/actions/rollbackToLine.spec.yaml index 2ee0ab96..36d2ba52 100644 --- a/spec/system/actions/rollbackToLine.spec.yaml +++ b/spec/system/actions/rollbackToLine.spec.yaml @@ -57,8 +57,6 @@ in: currentIndex: 3 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -137,8 +135,6 @@ out: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -206,8 +202,6 @@ in: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -284,8 +278,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -320,7 +312,6 @@ in: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -377,8 +368,6 @@ in: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -449,8 +438,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "section1" lineId: "1" @@ -462,7 +449,7 @@ out: lineId: "3" rollbackPolicy: "free" --- -case: rollback to first line resets to baseline when first checkpoint has no rollbackable actions +case: rollback to first line resets to context defaults when first checkpoint has no rollbackable actions in: - state: projectData: @@ -486,7 +473,7 @@ in: score: type: number scope: context - default: 0 + default: 10 global: isLineCompleted: false pendingEffects: [] @@ -503,8 +490,6 @@ in: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 10 timeline: - sectionId: "section1" lineId: "1" @@ -536,7 +521,7 @@ out: score: type: number scope: context - default: 0 + default: 10 global: isLineCompleted: true dialogueUIHidden: false @@ -568,8 +553,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 10 timeline: - sectionId: "section1" lineId: "1" @@ -621,7 +604,6 @@ in: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -690,7 +672,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" diff --git a/spec/system/actions/saveSaveSlot.spec.yaml b/spec/system/actions/saveSaveSlot.spec.yaml index fb3067af..0fd0c945 100644 --- a/spec/system/actions/saveSaveSlot.spec.yaml +++ b/spec/system/actions/saveSaveSlot.spec.yaml @@ -281,8 +281,6 @@ in: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "sec1" lineId: "line1" @@ -312,8 +310,6 @@ out: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "sec1" lineId: "line1" @@ -343,8 +339,6 @@ out: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "sec1" lineId: "line1" @@ -368,8 +362,6 @@ out: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: - score: 0 timeline: - sectionId: "sec1" lineId: "line1" diff --git a/spec/system/actions/sectionTransition.spec.yaml b/spec/system/actions/sectionTransition.spec.yaml index 2651375e..a321e366 100644 --- a/spec/system/actions/sectionTransition.spec.yaml +++ b/spec/system/actions/sectionTransition.spec.yaml @@ -74,7 +74,6 @@ out: currentIndex: 1 isRestoring: false replayStartIndex: 1 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" diff --git a/spec/system/createInitialState.spec.yaml b/spec/system/createInitialState.spec.yaml index d2f5423e..46cdaf6d 100644 --- a/spec/system/createInitialState.spec.yaml +++ b/spec/system/createInitialState.spec.yaml @@ -75,7 +75,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "line1" @@ -192,11 +191,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: - inventory: - items: - - id: sword - name: Steel Sword timeline: - sectionId: "section1" lineId: "line1" @@ -288,8 +282,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: - settings: {} timeline: - sectionId: "section1" lineId: "line1" @@ -401,7 +393,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "line1" @@ -484,7 +475,6 @@ out: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "line2" diff --git a/spec/system/selectors/selectLineIdByOffset.spec.yaml b/spec/system/selectors/selectLineIdByOffset.spec.yaml index 084abac9..7b9dc2cc 100644 --- a/spec/system/selectors/selectLineIdByOffset.spec.yaml +++ b/spec/system/selectors/selectLineIdByOffset.spec.yaml @@ -19,7 +19,6 @@ in: currentIndex: 2 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -49,7 +48,6 @@ in: currentIndex: 0 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" @@ -71,7 +69,6 @@ in: currentIndex: 3 isRestoring: false replayStartIndex: 0 - baselineVariables: {} timeline: - sectionId: "section1" lineId: "1" diff --git a/spec/systemStore.rollbackDraftSafety.test.js b/spec/systemStore.rollbackDraftSafety.test.js index 4af94dbd..778e9ee8 100644 --- a/spec/systemStore.rollbackDraftSafety.test.js +++ b/spec/systemStore.rollbackDraftSafety.test.js @@ -156,9 +156,6 @@ describe("system.store rollback/save draft safety", () => { currentIndex: 1, isRestoring: false, replayStartIndex: 0, - baselineVariables: { - score: 0, - }, timeline: [ { sectionId: "section1", @@ -182,7 +179,23 @@ describe("system.store rollback/save draft safety", () => { expect( nextState.global.saveSlots["1"]?.state?.contexts?.[0]?.rollback, - ).toEqual(baseState.contexts[0].rollback); + ).toEqual({ + currentIndex: 1, + isRestoring: false, + replayStartIndex: 0, + timeline: [ + { + sectionId: "section1", + lineId: "1", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "2", + rollbackPolicy: "free", + }, + ], + }); vi.restoreAllMocks(); }); @@ -261,7 +274,28 @@ describe("system.store rollback/save draft safety", () => { loadSaveSlot({ state: draft }, { slot: 1 }); }); - expect(nextState.contexts[0].rollback).toEqual(savedRollback); + expect(nextState.contexts[0].rollback).toEqual({ + currentIndex: 2, + isRestoring: false, + replayStartIndex: 0, + timeline: [ + { + sectionId: "section1", + lineId: "1", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "2", + rollbackPolicy: "free", + }, + { + sectionId: "section2", + lineId: "10", + rollbackPolicy: "free", + }, + ], + }); expect(nextState.global.viewedRegistry.sections[0]).toEqual({ sectionId: "section2", lastLineId: "10", @@ -306,9 +340,6 @@ describe("system.store rollback/save draft safety", () => { currentIndex: 2, isRestoring: false, replayStartIndex: 0, - baselineVariables: { - score: 0, - }, timeline: [ { sectionId: "section1", @@ -390,9 +421,6 @@ describe("system.store rollback/save draft safety", () => { currentIndex: 1, isRestoring: false, replayStartIndex: 0, - baselineVariables: { - score: 0, - }, timeline: [ { sectionId: "section1", @@ -482,9 +510,6 @@ describe("system.store rollback/save draft safety", () => { currentIndex: 2, isRestoring: false, replayStartIndex: 0, - baselineVariables: { - score: 0, - }, timeline: [ { sectionId: "section1", diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 417e9b0d..9314c708 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -58,18 +58,28 @@ const createRollbackCheckpoint = ({ sectionId, lineId, rollbackPolicy }) => ({ rollbackPolicy: rollbackPolicy ?? "free", }); -const createRollbackState = ({ - pointer, - baselineVariables, - replayStartIndex = 0, -}) => { +const getRollbackContextVariableDefaults = (projectData) => { + const { contextVariableDefaultValues } = getDefaultVariablesFromProjectData( + projectData ?? {}, + ); + return cloneStateValue(contextVariableDefaultValues); +}; + +const removeLegacyRollbackBaseline = (rollback) => { + if (!rollback || !("baselineVariables" in rollback)) { + return; + } + + delete rollback.baselineVariables; +}; + +const createRollbackState = ({ pointer, replayStartIndex = 0 }) => { const hasInitialPointer = pointer?.sectionId && pointer?.lineId; return { currentIndex: hasInitialPointer ? 0 : -1, isRestoring: false, replayStartIndex, - baselineVariables: cloneStateValue(baselineVariables ?? {}), timeline: hasInitialPointer ? [ createRollbackCheckpoint({ @@ -83,6 +93,7 @@ const createRollbackState = ({ const ensureRollbackState = (lastContext, options = {}) => { if (lastContext?.rollback) { + removeLegacyRollbackBaseline(lastContext.rollback); if (!Array.isArray(lastContext.rollback.timeline)) { lastContext.rollback.timeline = []; } @@ -98,18 +109,12 @@ const ensureRollbackState = (lastContext, options = {}) => { if (typeof lastContext.rollback.replayStartIndex !== "number") { lastContext.rollback.replayStartIndex = 0; } - if (lastContext.rollback.baselineVariables === undefined) { - lastContext.rollback.baselineVariables = cloneStateValue( - lastContext.variables ?? {}, - ); - } return lastContext.rollback; } const pointer = lastContext?.pointers?.read; lastContext.rollback = createRollbackState({ pointer, - baselineVariables: lastContext?.variables ?? {}, replayStartIndex: options.compatibilityAnchor ? 1 : 0, }); return lastContext.rollback; @@ -379,7 +384,9 @@ const restoreRollbackCheckpoint = (state, checkpointIndex) => { rollback.currentIndex = checkpointIndex; try { - lastContext.variables = cloneStateValue(rollback.baselineVariables ?? {}); + lastContext.variables = getRollbackContextVariableDefaults( + state.projectData, + ); state.global.dialogueUIHidden = false; state.global.isDialogueHistoryShowing = false; state.global.nextLineConfig = cloneStateValue(DEFAULT_NEXT_LINE_CONFIG); @@ -499,7 +506,6 @@ export const createInitialState = (payload) => { variables: contextVariableDefaultValues, rollback: createRollbackState({ pointer: initialPointer, - baselineVariables: contextVariableDefaultValues, }), }, ], @@ -1343,9 +1349,13 @@ export const setNextLineConfig = ({ state }, payload) => { export const saveSaveSlot = ({ state }, payload) => { const { slot, thumbnailImage } = payload; const slotKey = String(slot); + const contexts = cloneStateValue(state.contexts); + contexts?.forEach((context) => { + removeLegacyRollbackBaseline(context.rollback); + }); const currentState = { - contexts: cloneStateValue(state.contexts), + contexts, viewedRegistry: cloneStateValue(state.global.viewedRegistry), }; @@ -2225,6 +2235,10 @@ export const rollbackToLine = ({ state }, payload) => { ); } + if (targetLineIndex === rollback.currentIndex) { + return state; + } + return restoreRollbackCheckpoint(state, targetLineIndex); }; From cc658a30d14aab3ce419b7631792490627fb0b1a Mon Sep 17 00:00:00 2001 From: han4wluc Date: Mon, 30 Mar 2026 21:59:50 +0800 Subject: [PATCH 5/5] Support NVL loop layouts and refresh VT baselines --- docs/vt-guidelines.md | 1 + package.json | 2 +- spec/system/renderState/addDialogue.spec.yaml | 42 ++++- src/stores/constructRenderState.js | 145 +++++++++++++++--- .../completion-text-animation-choice-01.webp | 4 +- .../rollback/auto-suppressed--capture-01.webp | 4 +- .../rollback/back-one-line--capture-01.webp | 4 +- .../rollback/choice-event--capture-01.webp | 4 +- .../rollback/cross-section--capture-01.webp | 4 +- .../layered-view-restore--capture-01.webp | 4 +- .../rollback/to-line--capture-01.webp | 4 +- 11 files changed, 180 insertions(+), 38 deletions(-) diff --git a/docs/vt-guidelines.md b/docs/vt-guidelines.md index 492d2b22..8e6567d5 100644 --- a/docs/vt-guidelines.md +++ b/docs/vt-guidelines.md @@ -28,6 +28,7 @@ Define one stable standard for VT authoring in this repo: - Accept an intentional visual change with `rtgl vt accept`. - If the sync step needs a different upstream build, point `VT_ROUTE_GRAPHICS_URL` at the desired CDN file. - Local VT defaults to `2` Docker workers and a `60000ms` timeout. +- Local VT reports default to a `0.8%` diff threshold to tolerate small Docker text raster drift. - VT is currently local-only. GitHub Actions VT is disabled again until the runner instability is fixed separately. ## Visual Standard diff --git a/package.json b/package.json index ee6e121e..52f4f8a1 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "vt:sync-route-graphics": "bun scripts/sync-vt-route-graphics.js", "vt:generate": "bun scripts/sync-vt-route-graphics.js && bun run esbuild.js && rtgl vt generate", "vt:docker": "bun scripts/sync-vt-route-graphics.js && bun run esbuild.js && docker run --rm ${VT_DOCKER_ARGS:-} --user $(id -u):$(id -g) -e RTGL_VT_DEBUG=true -v \"$PWD:/app\" -w /app docker.io/han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.10 rtgl vt screenshot --wait-event vt:ready --concurrency ${VT_DOCKER_CONCURRENCY:-2} --timeout ${VT_DOCKER_TIMEOUT:-60000}", - "vt:report": "bun run vt:docker && rtgl vt report", + "vt:report": "bun run vt:docker && rtgl vt report --diff-threshold ${VT_REPORT_DIFF_THRESHOLD:-0.8}", "vt:accept": "rtgl vt accept", "serve": "bun run esbuild.js && bunx serve -p 3004 .rettangoli/vt/_site" }, diff --git a/spec/system/renderState/addDialogue.spec.yaml b/spec/system/renderState/addDialogue.spec.yaml index 5e1bcccb..6b632fbb 100644 --- a/spec/system/renderState/addDialogue.spec.yaml +++ b/spec/system/renderState/addDialogue.spec.yaml @@ -94,7 +94,18 @@ in: elements: - id: "nvl-container" type: "container" - children: [] + children: + - $for line, i in dialogueLines: + - id: "line-${i}" + type: "container" + children: + - $if line.characterName: + - id: "name-${i}" + type: "text" + content: "${line.characterName}" + - id: "text-${i}" + type: "text" + content: "${line.content[0].text}" characters: narrator: name: "Narrator" @@ -114,7 +125,34 @@ out: children: - id: "nvl-container" type: "container" - children: [] + children: + - id: "line-0" + type: "container" + children: + - id: "name-0" + type: "text" + content: "Narrator" + - id: "text-0" + type: "text" + content: "First line" + - id: "line-1" + type: "container" + children: + - id: "name-1" + type: "text" + content: "Alice" + - id: "text-1" + type: "text" + content: "Second line" + - id: "line-2" + type: "container" + children: + - id: "name-2" + type: "text" + content: "Bob" + - id: "text-2" + type: "text" + content: "Third line" animations: [] --- case: resolves textStyleId in dialogue ui layout elements diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 7e5e9189..baa6b6f0 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -7,6 +7,99 @@ const jemplFunctions = { formatDate, }; +const LOOP_DIRECTIVE_RE = + /^\$for\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*,\s*([A-Za-z_][A-Za-z0-9_]*))?\s+in\s+(.+):$/; + +const resolveTemplatePath = (data, expression) => { + const normalized = String(expression || "").trim(); + if (!normalized) { + return undefined; + } + + const parts = normalized.match(/[A-Za-z_][A-Za-z0-9_]*|\[\d+\]/g) || []; + let current = data; + for (const part of parts) { + if (part.startsWith("[")) { + const index = Number(part.slice(1, -1)); + current = Array.isArray(current) ? current[index] : undefined; + continue; + } + current = current?.[part]; + } + return current; +}; + +const expandLoopTemplates = (node, templateData, options) => { + if (Array.isArray(node)) { + const expanded = []; + + for (const item of node) { + if ( + item && + typeof item === "object" && + !Array.isArray(item) && + Object.keys(item).length === 1 + ) { + const [maybeLoopKey] = Object.keys(item); + const loopMatch = LOOP_DIRECTIVE_RE.exec(maybeLoopKey); + if (loopMatch) { + const [, itemName, indexName, sourceExpression] = loopMatch; + const iterable = resolveTemplatePath(templateData, sourceExpression); + if (!Array.isArray(iterable)) { + continue; + } + + const loopTemplate = Array.isArray(item[maybeLoopKey]) + ? item[maybeLoopKey] + : []; + iterable.forEach((loopItem, loopIndex) => { + const loopData = { + ...templateData, + [itemName]: loopItem, + }; + if (indexName) { + loopData[indexName] = loopIndex; + } + + loopTemplate.forEach((loopNode) => { + const expandedLoopNode = expandLoopTemplates( + loopNode, + loopData, + options, + ); + const rendered = parseAndRender( + expandedLoopNode, + loopData, + options, + ); + if (Array.isArray(rendered)) { + expanded.push(...rendered); + } else { + expanded.push(rendered); + } + }); + }); + continue; + } + } + + expanded.push(expandLoopTemplates(item, templateData, options)); + } + + return expanded; + } + + if (!node || typeof node !== "object") { + return node; + } + + const expanded = {}; + Object.entries(node).forEach(([key, value]) => { + expanded[key] = expandLoopTemplates(value, templateData, options); + }); + return expanded; +}; + const LEGACY_ANIMATION_TYPE_MAP = { live: "update", replace: "transition", @@ -1504,6 +1597,24 @@ export const addDialogue = ( presentationState.dialogue.content, "dialogue.content", ); + const dialogueLines = (presentationState.dialogue?.lines || []).map( + (line, index) => { + const lineContent = ensureDialogueContentItems( + line.content, + `dialogue.lines[${index}].content`, + ); + + return { + content: lineContent.map((item) => ({ + ...item, + text: interpolateDialogueText(item.text, { variables }), + })), + characterName: line.characterId + ? resources.characters?.[line.characterId]?.name || "" + : "", + }; + }, + ); const templateData = { variables, @@ -1517,6 +1628,7 @@ export const addDialogue = ( ? 0 : (variables?._soundVolume ?? 500), textSpeed: variables?._textSpeed ?? 50, + dialogueLines, dialogue: { character: { name: character?.name || "", @@ -1525,30 +1637,21 @@ export const addDialogue = ( ...item, text: interpolateDialogueText(item.text, { variables }), })), - lines: (presentationState.dialogue?.lines || []).map( - (line, index) => { - const lineContent = ensureDialogueContentItems( - line.content, - `dialogue.lines[${index}].content`, - ); - - return { - content: lineContent.map((item) => ({ - ...item, - text: interpolateDialogueText(item.text, { variables }), - })), - characterName: line.characterId - ? resources.characters?.[line.characterId]?.name || "" - : "", - }; - }, - ), + lines: dialogueLines, }, }; - const result = parseAndRender(wrappedTemplate, templateData, { - functions: jemplFunctions, - }); + const renderOptions = { functions: jemplFunctions }; + const expandedTemplate = expandLoopTemplates( + wrappedTemplate, + templateData, + renderOptions, + ); + const result = parseAndRender( + expandedTemplate, + templateData, + renderOptions, + ); const uiElements = resolveLayoutResourceIds( settleTextRevealIfCompleted(result?.elements, { isLineCompleted, diff --git a/vt/reference/nextLineConfig/completion-text-animation-choice-01.webp b/vt/reference/nextLineConfig/completion-text-animation-choice-01.webp index 154369ef..f8e861d2 100644 --- a/vt/reference/nextLineConfig/completion-text-animation-choice-01.webp +++ b/vt/reference/nextLineConfig/completion-text-animation-choice-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed95041e7f6441c5588fe8d69a9b6ff1d858b58a6836efe75b0877e58ced3457 -size 1180 +oid sha256:bbb67c833286c416c4571b6e1f87a5291c0c7bbdce1cfd0a869d232b4b5d75bd +size 3352 diff --git a/vt/reference/rollback/auto-suppressed--capture-01.webp b/vt/reference/rollback/auto-suppressed--capture-01.webp index 452721e0..b10908ce 100644 --- a/vt/reference/rollback/auto-suppressed--capture-01.webp +++ b/vt/reference/rollback/auto-suppressed--capture-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b3e1768be16fdec7f41c194ab943b2b8d61fc7d23032f28fbc2cdef2083f7e0e -size 3520 +oid sha256:98f6e7fe69aed8a093c8f835da38d80afdf482798073ca137a2407887e8e40cd +size 3372 diff --git a/vt/reference/rollback/back-one-line--capture-01.webp b/vt/reference/rollback/back-one-line--capture-01.webp index 07b08b1e..4377a711 100644 --- a/vt/reference/rollback/back-one-line--capture-01.webp +++ b/vt/reference/rollback/back-one-line--capture-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb0b83d4a84d3a58a21a9d49586ebe0e88c50f497728a52ef971a286012a159d -size 3786 +oid sha256:c3c5605d5967f4965c1bfcde7aa81aba431db0110f7dd29688286c620df8e099 +size 3418 diff --git a/vt/reference/rollback/choice-event--capture-01.webp b/vt/reference/rollback/choice-event--capture-01.webp index b1398e69..89873639 100644 --- a/vt/reference/rollback/choice-event--capture-01.webp +++ b/vt/reference/rollback/choice-event--capture-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc0d2e11be01f6cbd818a3da910cb80ad248b547a83acc69264045193701505a -size 4942 +oid sha256:078ba2f7efbc0d2ef68f36ce805173d0c65f565de45ee20a608d2f78d0b5ea26 +size 4856 diff --git a/vt/reference/rollback/cross-section--capture-01.webp b/vt/reference/rollback/cross-section--capture-01.webp index adf3f641..1f708d9d 100644 --- a/vt/reference/rollback/cross-section--capture-01.webp +++ b/vt/reference/rollback/cross-section--capture-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:712feeef436a09701c02ea301c67ab001d2bcf5464b56065e4cc1ae6ffd2faba -size 3522 +oid sha256:19c616ddc384cb4dd97af0fb08dbca1c3f0a75b3cfb8e6ab459269fcc2cb02cb +size 3546 diff --git a/vt/reference/rollback/layered-view-restore--capture-01.webp b/vt/reference/rollback/layered-view-restore--capture-01.webp index b421626c..27f6e3a6 100644 --- a/vt/reference/rollback/layered-view-restore--capture-01.webp +++ b/vt/reference/rollback/layered-view-restore--capture-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37e670357b4bdd3fe40cb9d82c3fb8e3ce128b6bd751b400f08d2ac7fd01455a -size 4580 +oid sha256:f74748a609906cd112334f37f86bdfc93f9301eb12ea625c6301d05dea68e448 +size 4738 diff --git a/vt/reference/rollback/to-line--capture-01.webp b/vt/reference/rollback/to-line--capture-01.webp index c4404a13..a89cc84d 100644 --- a/vt/reference/rollback/to-line--capture-01.webp +++ b/vt/reference/rollback/to-line--capture-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:219f4acea930ae78e89e645b629d3eed254b51b2199199ddc15aea3d974a59d0 -size 4078 +oid sha256:7b66436b10e270ab3007e7a323522c93705afc6b260304d79f6231fbb68b6b3c +size 3876