From 7fb0c9f867f48dc79e471caadf2f5b6667f586af Mon Sep 17 00:00:00 2001 From: han4wluc Date: Wed, 15 Apr 2026 14:58:18 +0800 Subject: [PATCH 1/4] fix: keep transient UI out of rollback state --- spec/RouteEngine.rollbackRenderState.test.js | 9 +- spec/RouteEngine.systemState.test.js | 142 ++++++++-- spec/system/actions/loadSlot.spec.yaml | 137 +++++++++ src/RouteEngine.js | 10 +- src/stores/system.store.js | 202 +++++++------ .../layered-view-restore--capture-01.webp | 4 +- ...ollback-transient-overlay--capture-01.webp | 3 + vt/specs/rollback/layered-view-restore.yaml | 8 +- .../save/load-rollback-transient-overlay.yaml | 265 ++++++++++++++++++ 9 files changed, 642 insertions(+), 138 deletions(-) create mode 100644 vt/reference/save/load-rollback-transient-overlay--capture-01.webp create mode 100644 vt/specs/save/load-rollback-transient-overlay.yaml diff --git a/spec/RouteEngine.rollbackRenderState.test.js b/spec/RouteEngine.rollbackRenderState.test.js index c8ee286f..b947b5ea 100644 --- a/spec/RouteEngine.rollbackRenderState.test.js +++ b/spec/RouteEngine.rollbackRenderState.test.js @@ -151,7 +151,7 @@ const createProjectData = () => ({ }); describe("RouteEngine rollback render state", () => { - it("restores rollbacked lines directly in their settled end state", () => { + it("restores rollbacked lines directly in their settled end state without transient layered views", () => { const routeGraphics = { render: vi.fn(), }; @@ -192,12 +192,7 @@ describe("RouteEngine rollback render state", () => { revealEffect: "none", }, ); - expect(findElementById(rollbackRender.elements, "panel-text")).toMatchObject( - { - type: "text", - content: "Layered panel", - }, - ); + expect(findElementById(rollbackRender.elements, "panel-text")).toBeNull(); expect(rollbackRender.animations).toEqual([]); }); diff --git a/spec/RouteEngine.systemState.test.js b/spec/RouteEngine.systemState.test.js index 25e3bda3..d77bb625 100644 --- a/spec/RouteEngine.systemState.test.js +++ b/spec/RouteEngine.systemState.test.js @@ -168,6 +168,59 @@ const createResetStoryAtSectionProjectData = () => ({ }, }); +const createSaveLoadRollbackOverlayProjectData = () => ({ + screen: { + width: 1920, + height: 1080, + backgroundColor: "#000000", + }, + resources: { + layouts: { + saveMenuLayout: { + elements: [], + }, + }, + sounds: {}, + images: {}, + videos: {}, + sprites: {}, + characters: {}, + variables: {}, + transforms: {}, + sectionTransitions: {}, + animations: {}, + fonts: {}, + colors: {}, + textStyles: {}, + }, + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "entry", + sections: { + entry: { + lines: [ + { + id: "line1", + actions: {}, + }, + ], + }, + afterSave: { + lines: [ + { + id: "line2", + actions: {}, + }, + ], + }, + }, + }, + }, + }, +}); + const createRouteEngineWithInlineEffects = () => { let engine; const handlePendingEffects = (pendingEffects) => { @@ -337,21 +390,6 @@ describe("RouteEngine selectSystemState", () => { sectionId: "gameStart", lineId: "gameLine", rollbackPolicy: "free", - executedActions: [ - { - type: "updateVariable", - payload: { - id: "seedGameScore", - operations: [ - { - variableId: "score", - op: "increment", - value: 1, - }, - ], - }, - }, - ], }, ], }); @@ -382,22 +420,66 @@ describe("RouteEngine selectSystemState", () => { sectionId: "title", lineId: "titleLine", rollbackPolicy: "free", - executedActions: [ - { - type: "updateVariable", - payload: { - id: "seedTitleScore", - operations: [ - { - variableId: "score", - op: "set", - value: 7, - }, - ], - }, - }, - ], }, ]); }); + + it("does not reopen transient layered views when rolling back after load", () => { + const engine = createRouteEngineWithInlineEffects(); + + engine.init({ + initialState: { + projectData: createSaveLoadRollbackOverlayProjectData(), + }, + }); + + engine.handleActions({ + pushLayeredView: { + resourceId: "saveMenuLayout", + resourceType: "layout", + }, + }); + engine.handleActions({ + sectionTransition: { + sectionId: "afterSave", + }, + saveSlot: { + slotId: 1, + }, + }); + + let state = engine.selectSystemState(); + expect(state.global.saveSlots["1"].state.contexts[0].rollback.timeline).toEqual( + [ + { + sectionId: "entry", + lineId: "line1", + rollbackPolicy: "free", + }, + { + sectionId: "afterSave", + lineId: "line2", + rollbackPolicy: "free", + }, + ], + ); + + engine.handleAction("loadSlot", { slotId: 1 }); + + state = engine.selectSystemState(); + expect(state.contexts[0].pointers.read).toMatchObject({ + sectionId: "afterSave", + lineId: "line2", + }); + expect(state.global.layeredViews).toEqual([]); + + engine.handleAction("rollbackByOffset", { offset: -1 }); + + state = engine.selectSystemState(); + expect(state.contexts[0].pointers.read).toEqual({ + sectionId: "entry", + lineId: "line1", + }); + expect(state.global.layeredViews).toEqual([]); + }); }); diff --git a/spec/system/actions/loadSlot.spec.yaml b/spec/system/actions/loadSlot.spec.yaml index 22a16cc4..fe956740 100644 --- a/spec/system/actions/loadSlot.spec.yaml +++ b/spec/system/actions/loadSlot.spec.yaml @@ -618,3 +618,140 @@ out: - sectionId: sec2 lineId: line3 rollbackPolicy: free +--- +case: load strips transient rollback executedActions from legacy slot data +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + sec1: + lines: + - id: line1 + actions: {} + sec2: + lines: + - id: line2 + actions: {} + global: + saveSlots: + "5": + formatVersion: 1 + slotId: 5 + savedAt: 1704110700 + image: save5.png + state: + contexts: + - pointers: + read: + sectionId: sec2 + lineId: line2 + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: sec1 + lineId: line1 + rollbackPolicy: free + executedActions: + - type: pushLayeredView + payload: + resourceId: saveMenuLayout + resourceType: layout + - sectionId: sec2 + lineId: line2 + rollbackPolicy: free + pendingEffects: [] + - slotId: 5 +out: + projectData: + story: + scenes: + scene1: + sections: + sec1: + lines: + - id: line1 + actions: {} + sec2: + lines: + - id: line2 + actions: {} + global: + autoMode: false + skipMode: false + dialogueUIHidden: false + layeredViews: [] + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: persistent + saveSlots: + "5": + formatVersion: 1 + slotId: 5 + savedAt: 1704110700 + image: save5.png + state: + contexts: + - pointers: + read: + sectionId: sec2 + lineId: line2 + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: sec1 + lineId: line1 + rollbackPolicy: free + executedActions: + - type: pushLayeredView + payload: + resourceId: saveMenuLayout + resourceType: layout + - sectionId: sec2 + lineId: line2 + rollbackPolicy: free + viewedRegistry: + sections: [] + resources: [] + pendingEffects: + - name: clearAutoNextTimer + - name: clearSkipNextTimer + - name: clearNextLineConfigTimer + - name: render + contexts: + - currentPointerMode: read + pointers: + read: + sceneId: scene1 + sectionId: sec2 + lineId: line2 + history: + sectionId: __undefined__ + lineId: __undefined__ + configuration: {} + views: [] + bgm: + resourceId: __undefined__ + variables: {} + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: sec1 + lineId: line1 + rollbackPolicy: free + - sectionId: sec2 + lineId: line2 + rollbackPolicy: free diff --git a/src/RouteEngine.js b/src/RouteEngine.js index aef7a59c..dc5b3b59 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -131,10 +131,12 @@ export default function createRouteEngine(options) { }; }; - const handleActions = (actions, eventContext) => { + const handleActions = (actions, eventContext, options = {}) => { const context = buildActionTemplateContext(eventContext); const processedActions = processActionTemplates(actions, context); - _systemStore.beginRollbackActionBatch({}); + _systemStore.beginRollbackActionBatch({ + source: options.rollbackSource, + }); try { Object.entries(processedActions).forEach(([actionType, payload]) => { handleAction(actionType, payload); @@ -147,7 +149,9 @@ export default function createRouteEngine(options) { const handleLineActions = () => { const line = _systemStore.selectCurrentLine(); if (line?.actions) { - handleActions(line.actions); + handleActions(line.actions, undefined, { + rollbackSource: "line", + }); return true; } diff --git a/src/stores/system.store.js b/src/stores/system.store.js index f67af12c..a6ee566f 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -209,6 +209,43 @@ const normalizeStoredSaveSlots = (saveSlots = {}) => { ); }; +const sanitizePersistedRollbackExecutedActions = (executedActions) => { + if (!Array.isArray(executedActions)) { + return undefined; + } + + const sanitizedExecutedActions = executedActions.filter(({ type }) => + shouldPersistRollbackActionType(type), + ); + + return sanitizedExecutedActions.length > 0 + ? sanitizedExecutedActions + : undefined; +}; + +const sanitizePersistedRollback = (rollback) => { + if (!isRecord(rollback) || !Array.isArray(rollback.timeline)) { + return; + } + + rollback.timeline.forEach((checkpoint) => { + if (!isRecord(checkpoint)) { + return; + } + + const sanitizedExecutedActions = sanitizePersistedRollbackExecutedActions( + checkpoint.executedActions, + ); + + if (sanitizedExecutedActions) { + checkpoint.executedActions = sanitizedExecutedActions; + return; + } + + delete checkpoint.executedActions; + }); +}; + const normalizeLoadedViewedRegistryEntry = (entry, type, index) => { const keyName = type === "sections" ? "sectionId" : "resourceId"; @@ -400,10 +437,11 @@ const normalizeLoadedRollback = (rollback, readPointer, projectData) => { rollbackPolicy: checkpoint.rollbackPolicy, }); - if (Array.isArray(checkpoint.executedActions)) { - normalizedCheckpoint.executedActions = cloneStateValue( - checkpoint.executedActions, - ); + const sanitizedExecutedActions = sanitizePersistedRollbackExecutedActions( + cloneStateValue(checkpoint.executedActions), + ); + if (sanitizedExecutedActions) { + normalizedCheckpoint.executedActions = sanitizedExecutedActions; } return [normalizedCheckpoint]; @@ -629,6 +667,8 @@ const clearConfirmDialog = (state) => { }; const rollbackActionBatchStack = []; +const ROLLBACK_ACTION_SOURCE_LINE = "line"; +const ROLLBACK_ACTION_SOURCE_INTERACTION = "interaction"; const createRollbackCheckpoint = ({ sectionId, lineId, rollbackPolicy }) => ({ sectionId, @@ -971,7 +1011,11 @@ const getCurrentRollbackCheckpoint = (state) => { return rollback.timeline[checkpointIndex] ?? null; }; -export const beginRollbackActionBatch = ({ state }) => { +export const beginRollbackActionBatch = ({ state }, payload = {}) => { + const source = + payload?.source === ROLLBACK_ACTION_SOURCE_LINE + ? ROLLBACK_ACTION_SOURCE_LINE + : ROLLBACK_ACTION_SOURCE_INTERACTION; const lastContext = state.contexts?.[state.contexts.length - 1]; const rollback = lastContext?.rollback; if ( @@ -982,7 +1026,7 @@ export const beginRollbackActionBatch = ({ state }) => { rollback.currentIndex < 0 || rollback.currentIndex >= rollback.timeline.length ) { - rollbackActionBatchStack.push({ checkpointIndex: null }); + rollbackActionBatchStack.push({ checkpointIndex: null, source }); return state; } @@ -992,6 +1036,7 @@ export const beginRollbackActionBatch = ({ state }) => { rollbackActionBatchStack.push({ checkpointIndex: rollback.currentIndex, + source, }); return state; }; @@ -1002,6 +1047,13 @@ export const endRollbackActionBatch = ({ state }) => { }; const recordRollbackAction = (state, actionType, payload) => { + const activeBatch = + rollbackActionBatchStack[rollbackActionBatchStack.length - 1]; + const source = activeBatch?.source ?? ROLLBACK_ACTION_SOURCE_INTERACTION; + if (!shouldRecordRollbackActionType(actionType, source)) { + return; + } + const checkpoint = getCurrentRollbackCheckpoint(state); if (!checkpoint) { return; @@ -1044,77 +1096,15 @@ const applyRollbackCheckpointUpdateVariable = (state, payload) => { const replayRecordedRollbackActions = (state, checkpoint) => { if (!Array.isArray(checkpoint?.executedActions)) { - return false; + return; } - const restorableActions = { - showDialogueUI, - hideDialogueUI, - toggleDialogueUI, - setNextLineConfig, - pushLayeredView, - popLayeredView, - replaceLastLayeredView, - clearLayeredViews, - setSaveLoadPagination, - incrementSaveLoadPagination, - decrementSaveLoadPagination, - setMenuPage, - setMenuEntryPoint, - }; - checkpoint.executedActions.forEach(({ type, payload }) => { - if (type === "updateVariable") { - applyRollbackCheckpointUpdateVariable(state, payload); - return; - } - - const action = restorableActions[type]; - if (!action) { - return; - } - - action({ state }, payload); + replayRecordedRollbackAction(state, type, 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 replayRollbackLineActions = (state, payload) => { const { sectionId, lineId } = payload; const section = selectSection({ state }, { sectionId }); const line = section?.lines?.find((item) => item.id === lineId); @@ -1124,24 +1114,8 @@ const applyRollbackRestorableLineActions = (state, payload) => { return; } - const restorableActions = { - showDialogueUI, - hideDialogueUI, - toggleDialogueUI, - setNextLineConfig, - pushLayeredView, - popLayeredView, - replaceLastLayeredView, - clearLayeredViews, - }; - Object.entries(actions).forEach(([actionType, actionPayload]) => { - const action = restorableActions[actionType]; - if (!action) { - return; - } - - action({ state }, actionPayload); + replayRollbackLineAction(state, actionType, actionPayload); }); }; @@ -1188,10 +1162,8 @@ const restoreRollbackCheckpoint = (state, checkpointIndex) => { if (i > replayStartIndex) { resetNextLineConfigIfSingleLine(state); } - if (!replayRecordedRollbackActions(state, rollback.timeline[i])) { - applyRollbackableLineActions(state, rollback.timeline[i]); - applyRollbackRestorableLineActions(state, rollback.timeline[i]); - } + replayRollbackLineActions(state, rollback.timeline[i]); + replayRecordedRollbackActions(state, rollback.timeline[i]); } lastContext.pointers.read = { @@ -2382,6 +2354,7 @@ export const saveSlot = ({ state }, payload) => { const contexts = cloneStateValue(state.contexts); contexts?.forEach((context) => { removeLegacyRollbackBaseline(context.rollback); + sanitizePersistedRollback(context.rollback); }); const currentState = { @@ -3019,6 +2992,51 @@ export const updateVariable = ({ state }, payload) => { return state; }; +const replayStoreActionForRollback = + (action) => + (state, payload) => + action({ state }, payload); + +const ROLLBACK_ACTION_DEFINITIONS = { + updateVariable: { + recordSources: [ROLLBACK_ACTION_SOURCE_INTERACTION], + replayLine: applyRollbackCheckpointUpdateVariable, + replayRecorded: applyRollbackCheckpointUpdateVariable, + persistInSaveSlot: true, + }, + showDialogueUI: { + replayLine: replayStoreActionForRollback(showDialogueUI), + }, + hideDialogueUI: { + replayLine: replayStoreActionForRollback(hideDialogueUI), + }, + toggleDialogueUI: { + replayLine: replayStoreActionForRollback(toggleDialogueUI), + }, + setNextLineConfig: { + replayLine: replayStoreActionForRollback(setNextLineConfig), + }, +}; + +const getRollbackActionDefinition = (actionType) => + ROLLBACK_ACTION_DEFINITIONS[actionType] ?? null; + +const shouldPersistRollbackActionType = (actionType) => + getRollbackActionDefinition(actionType)?.persistInSaveSlot === true; + +const shouldRecordRollbackActionType = (actionType, source) => { + const recordSources = getRollbackActionDefinition(actionType)?.recordSources; + return Array.isArray(recordSources) && recordSources.includes(source); +}; + +const replayRecordedRollbackAction = (state, actionType, payload) => { + getRollbackActionDefinition(actionType)?.replayRecorded?.(state, payload); +}; + +const replayRollbackLineAction = (state, actionType, payload) => { + getRollbackActionDefinition(actionType)?.replayLine?.(state, payload); +}; + /** * Selects a line ID by relative offset from the active rollback timeline. * diff --git a/vt/reference/rollback/layered-view-restore--capture-01.webp b/vt/reference/rollback/layered-view-restore--capture-01.webp index 27f6e3a6..5d089e1f 100644 --- a/vt/reference/rollback/layered-view-restore--capture-01.webp +++ b/vt/reference/rollback/layered-view-restore--capture-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f74748a609906cd112334f37f86bdfc93f9301eb12ea625c6301d05dea68e448 -size 4738 +oid sha256:0998c0e9758449644a167a185e276a0aa87646377b27b7ea0490c07c9ecbc75d +size 4908 diff --git a/vt/reference/save/load-rollback-transient-overlay--capture-01.webp b/vt/reference/save/load-rollback-transient-overlay--capture-01.webp new file mode 100644 index 00000000..1d6a7a6b --- /dev/null +++ b/vt/reference/save/load-rollback-transient-overlay--capture-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca7e59151a5905d2ae4c3ce45953acb6bf7bc2d754f7d9f35aa9021ba16b322e +size 3416 diff --git a/vt/specs/rollback/layered-view-restore.yaml b/vt/specs/rollback/layered-view-restore.yaml index 520b6f23..73e3cf02 100644 --- a/vt/specs/rollback/layered-view-restore.yaml +++ b/vt/specs/rollback/layered-view-restore.yaml @@ -1,10 +1,10 @@ --- -title: Rollback Restores Layered View -description: Rolling back to a checkpoint should restore the current line layered view state +title: Rollback Keeps Transient Layered View Cleared +description: Rolling back to a checkpoint should not restore a transient layered view specs: - line 2 opens a layered info panel - line 3 clears the layered view - - rollback returns to line 2 and restores the info panel + - rollback returns to line 2 without restoring the transient panel skipInitialScreenshot: true viewport: id: capture @@ -208,7 +208,7 @@ story: resourceType: layout dialogue: content: - - text: "Line 2. Overlay restored." + - text: "Line 2. Overlay is transient." characterId: narrator - id: line3 actions: diff --git a/vt/specs/save/load-rollback-transient-overlay.yaml b/vt/specs/save/load-rollback-transient-overlay.yaml new file mode 100644 index 00000000..f27d413b --- /dev/null +++ b/vt/specs/save/load-rollback-transient-overlay.yaml @@ -0,0 +1,265 @@ +--- +title: Load Rollback Does Not Reopen Save Overlay +description: Loading a slot should not let rollback reopen a transient save layered view captured in slot history +specs: + - a save overlay is opened on the earlier checkpoint + - the overlay saves after transitioning into a later section + - loading that slot and rolling back should not restore the transient overlay +skipInitialScreenshot: true +viewport: + id: capture + width: 960 + height: 540 +steps: + - action: click + x: 832 + y: 44 + - action: wait + ms: 90 + - action: click + x: 477 + y: 192 + - action: wait + ms: 120 + - action: click + x: 478 + y: 257 + - action: wait + ms: 150 + - action: click + x: 140 + y: 350 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" +resources: + characters: + narrator: + name: Guide + layouts: + entryLayout: + elements: + - id: entry-bg + type: rect + width: 1920 + height: 1080 + colorId: bg + - id: entry-card + type: rect + x: 120 + y: 120 + width: 1240 + height: 240 + colorId: panel + - id: entry-heading + type: text + x: 170 + y: 176 + content: "Entry checkpoint" + textStyleId: textHeading + - id: save-button + type: rect + x: 1500 + y: 60 + width: 300 + height: 72 + colorId: button + click: + payload: + actions: + pushLayeredView: + resourceId: saveMenuLayout + resourceType: layout + - id: save-button-label + type: text + x: 1602 + y: 84 + content: "[SAVE]" + textStyleId: textButton + afterSaveLayout: + elements: + - id: after-save-bg + type: rect + width: 1920 + height: 1080 + colorId: bg + - id: after-save-card + type: rect + x: 120 + y: 120 + width: 1240 + height: 240 + colorId: panel + - id: after-save-heading + type: text + x: 170 + y: 176 + content: "Saved checkpoint" + textStyleId: textHeading + - id: rollback-button + type: rect + x: 100 + y: 680 + width: 340 + height: 64 + colorId: button + click: + payload: + actions: + rollbackByOffset: + offset: -1 + - id: rollback-label + type: text + x: 140 + y: 700 + content: "[BACK]" + textStyleId: textButton + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 100 + y: 780 + width: 1720 + height: 220 + colorId: panel + - id: dialogue-text + type: text + x: 150 + y: 844 + width: 1460 + content: ${dialogue.content[0].text} + textStyleId: textMain + saveMenuLayout: + elements: + - id: save-overlay + type: rect + width: 1920 + height: 1080 + opacity: 0.78 + colorId: bg + - id: save-panel + type: rect + x: 460 + y: 220 + width: 1000 + height: 420 + colorId: button + - id: save-title + type: text + x: 660 + y: 284 + content: "Transient Save Menu" + textStyleId: textHeading + - id: save-and-go-button + type: rect + x: 600 + y: 360 + width: 720 + height: 92 + colorId: panel + click: + payload: + actions: + sectionTransition: + sectionId: afterSave + saveSlot: + slotId: 1 + - id: save-and-go-label + type: text + x: 780 + y: 388 + content: "[SAVE AND GO]" + textStyleId: textButton + - id: load-slot-button + type: rect + x: 600 + y: 490 + width: 720 + height: 92 + colorId: panel + click: + payload: + actions: + loadSlot: + slotId: 1 + - id: load-slot-label + type: text + x: 816 + y: 518 + content: "[LOAD SLOT]" + 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 + textButton: + fontId: fontDefault + colorId: fg + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textHeading: + fontId: fontDefault + colorId: fgMuted + fontSize: 36 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 +story: + initialSceneId: main + scenes: + main: + initialSectionId: entry + sections: + entry: + lines: + - id: line1 + actions: + background: + resourceId: entryLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Entry checkpoint. Load then back should stay clean." + characterId: narrator + afterSave: + lines: + - id: line2 + actions: + background: + resourceId: afterSaveLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Loaded checkpoint. Back returns to entry." + characterId: narrator From 2ace9b0d1aff030b7aa106badc1db9dd837b4223 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Wed, 15 Apr 2026 14:58:42 +0800 Subject: [PATCH 2/4] style: format rollback store changes --- src/stores/system.store.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/stores/system.store.js b/src/stores/system.store.js index a6ee566f..8e2f131c 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -2992,10 +2992,8 @@ export const updateVariable = ({ state }, payload) => { return state; }; -const replayStoreActionForRollback = - (action) => - (state, payload) => - action({ state }, payload); +const replayStoreActionForRollback = (action) => (state, payload) => + action({ state }, payload); const ROLLBACK_ACTION_DEFINITIONS = { updateVariable: { From db793c468f8edcfad636dc52b80d4e1c74e9a68f Mon Sep 17 00:00:00 2001 From: han4wluc Date: Wed, 15 Apr 2026 15:55:27 +0800 Subject: [PATCH 3/4] refactor: stop replaying dialogue UI on rollback --- spec/RouteEngine.systemState.test.js | 79 ++++++++++ spec/system/actions/loadSlot.spec.yaml | 137 ------------------ .../system/actions/rollbackByOffset.spec.yaml | 97 +++++++++++++ src/stores/system.store.js | 9 -- 4 files changed, 176 insertions(+), 146 deletions(-) diff --git a/spec/RouteEngine.systemState.test.js b/spec/RouteEngine.systemState.test.js index d77bb625..d0b108a6 100644 --- a/spec/RouteEngine.systemState.test.js +++ b/spec/RouteEngine.systemState.test.js @@ -221,6 +221,53 @@ const createSaveLoadRollbackOverlayProjectData = () => ({ }, }); +const createDialogueUIRollbackProjectData = () => ({ + screen: { + width: 1920, + height: 1080, + backgroundColor: "#000000", + }, + resources: { + layouts: {}, + sounds: {}, + images: {}, + videos: {}, + sprites: {}, + characters: {}, + variables: {}, + transforms: {}, + sectionTransitions: {}, + animations: {}, + fonts: {}, + colors: {}, + textStyles: {}, + }, + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [ + { + id: "line1", + actions: { + hideDialogueUI: {}, + }, + }, + { + id: "line2", + actions: {}, + }, + ], + }, + }, + }, + }, + }, +}); + const createRouteEngineWithInlineEffects = () => { let engine; const handlePendingEffects = (pendingEffects) => { @@ -482,4 +529,36 @@ describe("RouteEngine selectSystemState", () => { }); expect(state.global.layeredViews).toEqual([]); }); + + it("does not restore dialogue UI visibility changes authored on the rollback target line", () => { + const engine = createRouteEngineWithInlineEffects(); + + engine.init({ + initialState: { + projectData: createDialogueUIRollbackProjectData(), + }, + }); + + expect(engine.selectSystemState().global.dialogueUIHidden).toBe(true); + + engine.handleAction("markLineCompleted", {}); + engine.handleAction("nextLine", {}); + engine.handleAction("nextLine", {}); + + let state = engine.selectSystemState(); + expect(state.contexts[0].pointers.read).toEqual({ + sectionId: "section1", + lineId: "line2", + }); + expect(state.global.dialogueUIHidden).toBe(false); + + engine.handleAction("rollbackByOffset", { offset: -1 }); + + state = engine.selectSystemState(); + expect(state.contexts[0].pointers.read).toEqual({ + sectionId: "section1", + lineId: "line1", + }); + expect(state.global.dialogueUIHidden).toBe(false); + }); }); diff --git a/spec/system/actions/loadSlot.spec.yaml b/spec/system/actions/loadSlot.spec.yaml index fe956740..22a16cc4 100644 --- a/spec/system/actions/loadSlot.spec.yaml +++ b/spec/system/actions/loadSlot.spec.yaml @@ -618,140 +618,3 @@ out: - sectionId: sec2 lineId: line3 rollbackPolicy: free ---- -case: load strips transient rollback executedActions from legacy slot data -in: - - state: - projectData: - story: - scenes: - scene1: - sections: - sec1: - lines: - - id: line1 - actions: {} - sec2: - lines: - - id: line2 - actions: {} - global: - saveSlots: - "5": - formatVersion: 1 - slotId: 5 - savedAt: 1704110700 - image: save5.png - state: - contexts: - - pointers: - read: - sectionId: sec2 - lineId: line2 - rollback: - currentIndex: 1 - isRestoring: false - replayStartIndex: 0 - timeline: - - sectionId: sec1 - lineId: line1 - rollbackPolicy: free - executedActions: - - type: pushLayeredView - payload: - resourceId: saveMenuLayout - resourceType: layout - - sectionId: sec2 - lineId: line2 - rollbackPolicy: free - pendingEffects: [] - - slotId: 5 -out: - projectData: - story: - scenes: - scene1: - sections: - sec1: - lines: - - id: line1 - actions: {} - sec2: - lines: - - id: line2 - actions: {} - global: - autoMode: false - skipMode: false - dialogueUIHidden: false - layeredViews: [] - isLineCompleted: true - nextLineConfig: - manual: - enabled: true - requireLineCompleted: false - auto: - enabled: false - applyMode: persistent - saveSlots: - "5": - formatVersion: 1 - slotId: 5 - savedAt: 1704110700 - image: save5.png - state: - contexts: - - pointers: - read: - sectionId: sec2 - lineId: line2 - rollback: - currentIndex: 1 - isRestoring: false - replayStartIndex: 0 - timeline: - - sectionId: sec1 - lineId: line1 - rollbackPolicy: free - executedActions: - - type: pushLayeredView - payload: - resourceId: saveMenuLayout - resourceType: layout - - sectionId: sec2 - lineId: line2 - rollbackPolicy: free - viewedRegistry: - sections: [] - resources: [] - pendingEffects: - - name: clearAutoNextTimer - - name: clearSkipNextTimer - - name: clearNextLineConfigTimer - - name: render - contexts: - - currentPointerMode: read - pointers: - read: - sceneId: scene1 - sectionId: sec2 - lineId: line2 - history: - sectionId: __undefined__ - lineId: __undefined__ - configuration: {} - views: [] - bgm: - resourceId: __undefined__ - variables: {} - rollback: - currentIndex: 1 - isRestoring: false - replayStartIndex: 0 - timeline: - - sectionId: sec1 - lineId: line1 - rollbackPolicy: free - - sectionId: sec2 - lineId: line2 - rollbackPolicy: free diff --git a/spec/system/actions/rollbackByOffset.spec.yaml b/spec/system/actions/rollbackByOffset.spec.yaml index c9fe8f80..20255a6a 100644 --- a/spec/system/actions/rollbackByOffset.spec.yaml +++ b/spec/system/actions/rollbackByOffset.spec.yaml @@ -804,3 +804,100 @@ out: - sectionId: "section1" lineId: "3" rollbackPolicy: "free" +--- +case: keeps dialogue UI visible when rolling back to a line that hid it on entry +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + hideDialogueUI: {} + - id: "2" + resources: + variables: {} + global: + isLineCompleted: false + dialogueUIHidden: false + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" + pendingEffects: [] + contexts: + - variables: {} + 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" + - offset: -1 +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + hideDialogueUI: {} + - id: "2" + resources: + variables: {} + global: + isLineCompleted: true + dialogueUIHidden: false + layeredViews: [] + nextLineConfig: + manual: + enabled: true + requireLineCompleted: false + auto: + enabled: false + applyMode: "persistent" + pendingEffects: + - name: "clearNextLineConfigTimer" + - name: "render" + contexts: + - variables: {} + currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + history: + sectionId: null + lineId: null + rollback: + currentIndex: 0 + isRestoring: false + replayStartIndex: 0 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 8e2f131c..c0cc5d6d 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -3002,15 +3002,6 @@ const ROLLBACK_ACTION_DEFINITIONS = { replayRecorded: applyRollbackCheckpointUpdateVariable, persistInSaveSlot: true, }, - showDialogueUI: { - replayLine: replayStoreActionForRollback(showDialogueUI), - }, - hideDialogueUI: { - replayLine: replayStoreActionForRollback(hideDialogueUI), - }, - toggleDialogueUI: { - replayLine: replayStoreActionForRollback(toggleDialogueUI), - }, setNextLineConfig: { replayLine: replayStoreActionForRollback(setNextLineConfig), }, From 8e30b89d16a2f3547fb13751c7f7635a22cf8ea7 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Wed, 15 Apr 2026 16:01:49 +0800 Subject: [PATCH 4/4] chore: bump version to 1.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c9ca7928..76a4a725 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "1.1.0", + "version": "1.2.0", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git",