diff --git a/docs/Rollback.md b/docs/Rollback.md new file mode 100644 index 0000000..aa3c0df --- /dev/null +++ b/docs/Rollback.md @@ -0,0 +1,386 @@ +# 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 +- deriving rollback start state from project-defined context variable defaults +- 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 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 + +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. + +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 +- 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. + +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 0000000..2c83822 --- /dev/null +++ b/docs/RollbackImplementationPlan.md @@ -0,0 +1,713 @@ +# 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 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 + +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: [], +} +``` + +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 +{ + 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 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` +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 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 +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` +- 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: + +- 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 +18. rollback restore start state is derived from project defaults, not serialized baseline snapshots + +### 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. 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 + +### 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/docs/vt-guidelines.md b/docs/vt-guidelines.md index 492d2b2..8e6567d 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 ee6e121..52f4f8a 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/rettangoli.config.yaml b/rettangoli.config.yaml index 5c6a34b..f342ea2 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 8b13de6..ca3e87c 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/loadSaveSlot.spec.yaml b/spec/system/actions/loadSaveSlot.spec.yaml index 86ce623..67c313e 100644 --- a/spec/system/actions/loadSaveSlot.spec.yaml +++ b/spec/system/actions/loadSaveSlot.spec.yaml @@ -41,6 +41,14 @@ out: contexts: - pointers: read: { sectionId: "sec2", lineId: "line2" } + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 1 + timeline: + - sectionId: "sec2" + lineId: "line2" + rollbackPolicy: "free" --- case: load from non-existent slot in: @@ -172,3 +180,106 @@ out: playerName: "Alice" score: 150 soundEnabled: true + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 1 + 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 + 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 + 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 + 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 3298f04..1713cd1 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -180,6 +180,17 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + 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 +266,17 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + 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 +345,114 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + 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 + 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 + 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 +806,17 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + 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 9016d43..f87b8b7 100644 --- a/spec/system/actions/nextLineFromSystem.spec.yaml +++ b/spec/system/actions/nextLineFromSystem.spec.yaml @@ -104,6 +104,17 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + 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 +178,17 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + 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 +253,17 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + 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 +324,17 @@ out: read: sectionId: "section1" lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + 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 7c0d91c..f66157f 100644 --- a/spec/system/actions/rollbackByOffset.spec.yaml +++ b/spec/system/actions/rollbackByOffset.spec.yaml @@ -68,12 +68,17 @@ in: sectionId: "section1" lineId: "2" history: {} - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" - - id: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" - {} out: projectData: @@ -89,8 +94,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 +118,19 @@ out: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + 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 +157,7 @@ in: scope: context default: 0 global: - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: @@ -146,16 +168,20 @@ 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 + 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 +209,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 +234,20 @@ 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 + 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 +278,14 @@ in: sectionId: "section1" lineId: "1" history: {} - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" - offset: -1 out: projectData: @@ -268,14 +314,16 @@ out: sectionId: "section1" lineId: "1" history: {} - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 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 +343,13 @@ in: op: set value: 10 - id: "3" + actions: + updateVariable: + id: "act2" + operations: + - variableId: score + op: increment + value: 5 resources: variables: score: @@ -302,7 +357,7 @@ in: scope: context default: 0 global: - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: @@ -313,17 +368,176 @@ in: sectionId: "section1" lineId: "2" history: {} - historySequence: - - sectionId: "section1" - initialState: - score: 0 + rollback: + currentIndex: 3 + isRestoring: false + replayStartIndex: 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: + story: + scenes: + scene1: + sections: + section1: lines: - id: "1" - id: "2" - updateVariableIds: - - "act1" + actions: + updateVariable: + id: "act1" + operations: + - variableId: score + op: set + value: 10 - id: "3" - - id: "2" + actions: + updateVariable: + id: "act2" + operations: + - variableId: score + op: increment + value: 5 + resources: + variables: + score: + type: number + scope: context + default: 0 + global: + isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" + pendingEffects: + - name: "clearNextLineConfigTimer" + - name: "render" + contexts: + - variables: + score: 15 + currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "3" + history: + sectionId: null + lineId: null + historySequenceIndex: null + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 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: + - state: + projectData: + story: + scenes: + scene1: + 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 + 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 + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" + 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 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section2" + lineId: "10" + rollbackPolicy: "free" - offset: -1 out: projectData: @@ -336,13 +550,27 @@ out: - id: "1" - id: "2" actions: + setNextLineConfig: + manual: + enabled: false + requireLineCompleted: true updateVariable: - id: "act1" + id: "set-score-10" operations: - variableId: score op: set value: 10 - - id: "3" + section2: + lines: + - id: "10" + actions: + updateVariable: + id: "add-score-5" + operations: + - variableId: score + op: add + value: 5 + - id: "11" resources: variables: score: @@ -351,8 +579,19 @@ out: default: 0 global: isLineCompleted: true + dialogueUIHidden: false + isDialogueHistoryShowing: false + layeredViews: [] + nextLineConfig: + manual: + enabled: false + requireLineCompleted: true + auto: + enabled: false + applyMode: "persistent" pendingEffects: - - name: "handleLineActions" + - name: "clearNextLineConfigTimer" + - name: "render" contexts: - variables: score: 10 @@ -360,18 +599,138 @@ out: pointers: read: sectionId: "section1" - lineId: "3" + lineId: "2" + history: + sectionId: null + lineId: null + historySequenceIndex: null + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - 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 + 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 - historySequence: - - sectionId: "section1" - initialState: - score: 0 - lines: - - id: "1" - - id: "2" - updateVariableIds: - - "act1" - - id: "3" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + 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 9e767c2..36d2ba5 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,25 @@ 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 + 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 +79,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 +106,50 @@ 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 + 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 +163,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 +185,35 @@ 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 + 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 +226,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 +249,47 @@ 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 + 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 +306,24 @@ 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 + 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 +353,33 @@ 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 + 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 +409,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 +429,27 @@ 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 + 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 context defaults when first checkpoint has no rollbackable actions in: - state: projectData: @@ -369,9 +473,9 @@ in: score: type: number scope: context - default: 0 + default: 10 global: - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: @@ -381,18 +485,18 @@ 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 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" - sectionId: "section1" lineId: "1" out: @@ -417,11 +521,22 @@ out: score: type: number scope: context - default: 0 + default: 10 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 +549,19 @@ out: sectionId: null lineId: null historySequenceIndex: null - historySequence: - - sectionId: "section1" - initialState: - score: 10 - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + 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 +590,7 @@ in: global: variables: volume: 100 - isLineCompleted: true + isLineCompleted: false pendingEffects: [] contexts: - variables: {} @@ -480,17 +600,22 @@ 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 + 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 +644,41 @@ 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 + 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/saveSaveSlot.spec.yaml b/spec/system/actions/saveSaveSlot.spec.yaml index 9c771cb..0fd0c94 100644 --- a/spec/system/actions/saveSaveSlot.spec.yaml +++ b/spec/system/actions/saveSaveSlot.spec.yaml @@ -263,3 +263,112 @@ 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 + 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 + 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 + 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 + 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 55a0311..a321e36 100644 --- a/spec/system/actions/sectionTransition.spec.yaml +++ b/spec/system/actions/sectionTransition.spec.yaml @@ -70,6 +70,17 @@ out: read: sectionId: "section2" lineId: "10" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section2" + lineId: "10" + rollbackPolicy: "free" --- case: section not found - returns unchanged state in: @@ -177,4 +188,4 @@ out: pointers: read: sectionId: "section1" - lineId: "1" \ No newline at end of file + lineId: "1" diff --git a/spec/system/actions/setNextLineConfig.spec.yaml b/spec/system/actions/setNextLineConfig.spec.yaml index 584b83f..9026bd6 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/createInitialState.spec.yaml b/spec/system/createInitialState.spec.yaml index 7ded0fa..46cdaf6 100644 --- a/spec/system/createInitialState.spec.yaml +++ b/spec/system/createInitialState.spec.yaml @@ -71,6 +71,14 @@ out: views: [] bgm: resourceId: __undefined__ + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: "section1" + lineId: "line1" + rollbackPolicy: "free" variables: {} --- case: initialize object type variables with defaults @@ -179,6 +187,14 @@ out: views: [] bgm: resourceId: __undefined__ + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: "section1" + lineId: "line1" + rollbackPolicy: "free" variables: inventory: items: @@ -262,6 +278,14 @@ out: views: [] bgm: resourceId: __undefined__ + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: "section1" + lineId: "line1" + rollbackPolicy: "free" variables: settings: {} --- @@ -365,6 +389,14 @@ out: views: [] bgm: resourceId: __undefined__ + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: "section1" + lineId: "line1" + rollbackPolicy: "free" variables: {} --- case: initialize with specific initialLineId @@ -439,4 +471,12 @@ out: views: [] bgm: resourceId: __undefined__ - variables: {} \ No newline at end of file + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: "section1" + lineId: "line2" + rollbackPolicy: "free" + variables: {} diff --git a/spec/system/renderState/addDialogue.spec.yaml b/spec/system/renderState/addDialogue.spec.yaml index 5e1bccc..6b632fb 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/spec/system/selectors/selectLineIdByOffset.spec.yaml b/spec/system/selectors/selectLineIdByOffset.spec.yaml index 352e954..7b9dc2c 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,26 @@ in: read: sectionId: "section1" lineId: "3" - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" - - id: "2" - - id: "3" + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + 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 +44,18 @@ in: read: sectionId: "section1" lineId: "1" - historySequence: - - sectionId: "section1" - initialState: {} - lines: - - id: "1" + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + 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 +65,23 @@ 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 + 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 new file mode 100644 index 0000000..778e9ee --- /dev/null +++ b/spec/systemStore.rollbackDraftSafety.test.js @@ -0,0 +1,540 @@ +import { describe, expect, it, vi } from "vitest"; +import { produce } from "immer"; +import { + loadSaveSlot, + rollbackByOffset, + saveSaveSlot, + sectionTransition, + updateVariable, +} 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, + }, + }, + }, +}); + +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); + + 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, + 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({ + currentIndex: 1, + isRestoring: false, + replayStartIndex: 0, + timeline: [ + { + sectionId: "section1", + lineId: "1", + rollbackPolicy: "free", + }, + { + sectionId: "section1", + lineId: "2", + rollbackPolicy: "free", + }, + ], + }); + 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({ + 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", + }); + }); + + 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, + 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", + }); + }); + + 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, + 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, + 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 7d1bc54..7767fad 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 45af997..baa6b6f 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, @@ -1710,7 +1813,6 @@ export const addControl = ( skipMode, canRollback, saveSlots = [], - isLineCompleted, skipTransitionsAndAnimations, }, ) => { diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 79a0497..9314c70 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,393 @@ const resetNextLineConfigIfSingleLine = (state) => { } }; +const cloneStateValue = (value) => { + const source = isDraft(value) ? current(value) : value; + return structuredClone(source); +}; + +const rollbackActionBatchStack = []; + +const createRollbackCheckpoint = ({ sectionId, lineId, rollbackPolicy }) => ({ + sectionId, + lineId, + rollbackPolicy: rollbackPolicy ?? "free", +}); + +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, + timeline: hasInitialPointer + ? [ + createRollbackCheckpoint({ + sectionId: pointer.sectionId, + lineId: pointer.lineId, + }), + ] + : [], + }; +}; + +const ensureRollbackState = (lastContext, options = {}) => { + if (lastContext?.rollback) { + removeLegacyRollbackBaseline(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; + } + return lastContext.rollback; + } + + const pointer = lastContext?.pointers?.read; + lastContext.rollback = createRollbackState({ + pointer, + 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 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 }); + 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 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) { + 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; + + try { + lastContext.variables = getRollbackContextVariableDefaults( + state.projectData, + ); + 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); + } + if (!replayRecordedRollbackActions(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, + }; + } + + state.global.pendingEffects = state.global.pendingEffects.filter( + (effect) => effect?.name !== "render", + ); + + state.global.pendingEffects.push({ + name: "render", + }); + } finally { + rollback.isRestoring = false; + } + + return state; +}; + export const createInitialState = (payload) => { const { projectData } = payload; const global = payload.global ?? {}; @@ -116,6 +504,9 @@ export const createInitialState = (payload) => { resourceId: undefined, }, variables: contextVariableDefaultValues, + rollback: createRollbackState({ + pointer: initialPointer, + }), }, ], }; @@ -599,12 +990,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; }; @@ -613,12 +1006,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; }; @@ -728,6 +1123,7 @@ export const showDialogueUI = ({ state }) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "showDialogueUI", undefined); return state; }; @@ -736,6 +1132,7 @@ export const hideDialogueUI = ({ state }) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "hideDialogueUI", undefined); return state; }; @@ -755,6 +1152,7 @@ export const showDialogueHistory = ({ state }) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "showDialogueHistory", undefined); return state; }; @@ -763,6 +1161,7 @@ export const hideDialogueHistory = ({ state }) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "hideDialogueHistory", undefined); return state; }; @@ -864,6 +1263,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) { @@ -898,7 +1299,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" @@ -916,7 +1321,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", }); @@ -925,6 +1334,7 @@ export const setNextLineConfig = ({ state }, payload) => { state.global.pendingEffects.push({ name: "render", }); + recordRollbackAction(state, "setNextLineConfig", payload); return state; }; @@ -939,10 +1349,14 @@ 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: [...state.contexts], - viewedRegistry: state.global.viewedRegistry, + contexts, + viewedRegistry: cloneStateValue(state.global.viewedRegistry), }; const saveData = { @@ -978,8 +1392,13 @@ 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 = 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 }); + }); state.global.pendingEffects.push({ name: "render" }); } return state; @@ -1066,9 +1485,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 +1611,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 +1640,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 +1661,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 +1872,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 +1886,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 +1917,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 +1946,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 +1966,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,11 +2011,15 @@ 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; let globalDeviceModified = false; let globalAccountModified = false; + const contextOperations = []; operations.forEach(({ variableId, op, value }) => { const variableConfig = state.projectData.resources?.variables?.[variableId]; @@ -1594,6 +2036,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") { @@ -1607,6 +2050,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]; @@ -1660,57 +2108,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 @@ -1735,51 +2133,28 @@ export const selectLineIdByOffset = ({ state }, payload) => { return null; } - // 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) { + const rollback = lastContext.rollback; + if ( + !Array.isArray(rollback?.timeline) || + typeof rollback?.currentIndex !== "number" + ) { 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 @@ -1791,8 +2166,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 @@ -1813,36 +2187,24 @@ export const rollbackByOffset = ({ state }, payload) => { throw new Error("rollbackByOffset requires a negative offset"); } - // Get target using the selector - const target = selectLineIdByOffset({ state }, { offset }); + const lastContext = state.contexts?.[state.contexts.length - 1]; + if (!lastContext) { + return state; + } - if (!target) { - // Out of bounds or invalid - do nothing + const rollback = ensureRollbackState(lastContext, { + compatibilityAnchor: !lastContext.rollback, + }); + const targetIndex = rollback.currentIndex + offset; + if (targetIndex < 0 || targetIndex >= rollback.timeline.length) { return state; } - // Delegate to rollbackToLine for the actual rollback with variable reversion - 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 @@ -1858,91 +2220,26 @@ export const rollbackToLine = ({ state }, payload) => { throw new Error("No context available for rollbackToLine"); } - // 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, - ); - } - } - } + if (targetLineIndex === rollback.currentIndex) { + return state; } - // 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); }; /************************** @@ -1993,6 +2290,8 @@ export const createSystemStore = (initialState) => { hideDialogueHistory, clearPendingEffects, appendPendingEffect, + beginRollbackActionBatch, + endRollbackActionBatch, addViewedLine, addViewedResource, setNextLineConfig, diff --git a/vt/reference/nextLineConfig/completion-text-animation-choice-01.webp b/vt/reference/nextLineConfig/completion-text-animation-choice-01.webp index 154369e..f8e861d 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 new file mode 100644 index 0000000..b10908c --- /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: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 new file mode 100644 index 0000000..4377a71 --- /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:c3c5605d5967f4965c1bfcde7aa81aba431db0110f7dd29688286c620df8e099 +size 3418 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 0000000..76f9e50 --- /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 0000000..8987363 --- /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:078ba2f7efbc0d2ef68f36ce805173d0c65f565de45ee20a608d2f78d0b5ea26 +size 4856 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 0000000..1f708d9 --- /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: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 new file mode 100644 index 0000000..27f6e3a --- /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:f74748a609906cd112334f37f86bdfc93f9301eb12ea625c6301d05dea68e448 +size 4738 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 0000000..a89cc84 --- /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:7b66436b10e270ab3007e7a323522c93705afc6b260304d79f6231fbb68b6b3c +size 3876 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 5923bfb..0000000 --- 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 b8752f1..0000000 --- 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 0000000..207ec80 --- /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 0000000..47b2222 --- /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 0000000..7094aa7 --- /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 0000000..9cde3b4 --- /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 0000000..3b4ec91 --- /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 0000000..520b6f2 --- /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 6815af0..78ad3ff 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 af28dcb..0000000 --- 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