From e2b63f6a3b63e8f2fdf1d35e887a2f9ea05fa2a0 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 12:18:05 +0800 Subject: [PATCH 01/11] Guard choice interactions without overlay --- spec/RouteEngine.lineCompletion.test.js | 343 ++++++++++++++---- ...forceChoiceVisibilityConstraints.spec.yaml | 130 +++++++ .../actions/markLineCompleted.spec.yaml | 74 ++++ spec/system/actions/nextLine.spec.yaml | 137 +++++++ .../actions/setNextLineConfig.spec.yaml | 64 ++++ spec/system/actions/startAutoMode.spec.yaml | 53 +++ spec/system/actions/startSkipMode.spec.yaml | 51 ++- spec/system/actions/toggleAutoMode.spec.yaml | 53 +++ spec/system/actions/toggleSkipMode.spec.yaml | 51 ++- spec/system/renderState/addChoices.spec.yaml | 138 +++---- .../selectors/selectIsChoiceVisible.spec.yaml | 103 ++++++ src/RouteEngine.js | 1 + src/stores/constructRenderState.js | 94 +++-- src/stores/system.store.js | 77 +++- vt/specs/choice/interaction-guards.yaml | 214 +++++++++++ 15 files changed, 1407 insertions(+), 176 deletions(-) create mode 100644 spec/system/actions/enforceChoiceVisibilityConstraints.spec.yaml create mode 100644 spec/system/selectors/selectIsChoiceVisible.spec.yaml create mode 100644 vt/specs/choice/interaction-guards.yaml diff --git a/spec/RouteEngine.lineCompletion.test.js b/spec/RouteEngine.lineCompletion.test.js index 78757140..306c1409 100644 --- a/spec/RouteEngine.lineCompletion.test.js +++ b/spec/RouteEngine.lineCompletion.test.js @@ -135,6 +135,159 @@ const createProjectData = () => ({ }, }); +const createChoiceBlockingProjectData = () => ({ + screen: { + width: 1920, + height: 1080, + backgroundColor: "#000000", + }, + resources: { + layouts: { + staticDialogue: { + mode: "adv", + elements: [ + { + id: "dialogue-text", + type: "text", + content: "${dialogue.content[0].text}", + textStyleId: "body", + }, + ], + }, + choiceLayout: { + elements: [ + { + id: "choice-button", + type: "button", + text: "Continue", + click: { + payload: { + actions: { + nextLine: {}, + }, + }, + }, + }, + ], + }, + }, + sounds: {}, + images: {}, + videos: {}, + sprites: {}, + characters: {}, + variables: {}, + transforms: {}, + sectionTransitions: {}, + animations: {}, + fonts: { + bodyFont: { + fileId: "Arial", + }, + }, + colors: { + bodyColor: { + hex: "#FFFFFF", + }, + }, + textStyles: { + body: { + fontId: "bodyFont", + colorId: "bodyColor", + fontSize: 24, + fontWeight: "400", + fontStyle: "normal", + lineHeight: 1.2, + }, + }, + controls: {}, + }, + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [ + { + id: "line1", + actions: { + startAutoMode: {}, + setNextLineConfig: { + manual: { + enabled: true, + }, + auto: { + enabled: true, + trigger: "fromStart", + delay: 900, + }, + applyMode: "persistent", + }, + dialogue: { + mode: "adv", + ui: { + resourceId: "staticDialogue", + }, + content: [ + { + text: "Line 1", + }, + ], + }, + }, + }, + { + id: "line2", + actions: { + dialogue: { + mode: "adv", + ui: { + resourceId: "staticDialogue", + }, + content: [ + { + text: "Make a choice", + }, + ], + }, + choice: { + resourceId: "choiceLayout", + items: [ + { + id: "choice-a", + content: "Continue", + }, + ], + }, + }, + }, + { + id: "line3", + actions: { + choice: {}, + dialogue: { + mode: "adv", + ui: { + resourceId: "staticDialogue", + }, + content: [ + { + text: "After choice", + }, + ], + }, + }, + }, + ], + }, + }, + }, + }, + }, +}); + const findElementById = (elements, id) => { for (const element of elements || []) { if (element?.id === id) { @@ -178,24 +331,24 @@ describe("RouteEngine line completion flow", () => { }); const initialRender = getRenderState(routeGraphics, 0); - expect(findElementById(initialRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "typewriter", - }, - ); + expect( + findElementById(initialRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "typewriter", + }); engine.handleActions({ nextLine: {}, }); const completedRender = getRenderState(routeGraphics, 1); - expect(findElementById(completedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "none", - }, - ); + expect( + findElementById(completedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "none", + }); engine.handleActions({ nextLine: {}, @@ -203,20 +356,20 @@ describe("RouteEngine line completion flow", () => { const advancedRender = getLastRenderState(routeGraphics); expect(routeGraphics.render).toHaveBeenCalledTimes(3); - expect(engine.selectSystemState().contexts.at(-1).pointers.read.lineId).toBe( - "line2", - ); - expect(findElementById(advancedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "typewriter", - content: [ - { - text: "Line 2 should be the next reveal line after the second click.", - }, - ], - }, - ); + expect( + engine.selectSystemState().contexts.at(-1).pointers.read.lineId, + ).toBe("line2"); + expect( + findElementById(advancedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "typewriter", + content: [ + { + text: "Line 2 should be the next reveal line after the second click.", + }, + ], + }); }); it("advances immediately after a natural renderComplete marks the line complete", () => { @@ -249,32 +402,32 @@ describe("RouteEngine line completion flow", () => { ).toBe(true); const completedRender = getRenderState(routeGraphics, 1); - expect(findElementById(completedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "none", - }, - ); + expect( + findElementById(completedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "none", + }); engine.handleActions({ nextLine: {}, }); const advancedRender = getLastRenderState(routeGraphics); - expect(engine.selectSystemState().contexts.at(-1).pointers.read.lineId).toBe( - "line2", - ); - expect(findElementById(advancedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "typewriter", - content: [ - { - text: "Line 2 should be the next reveal line after the second click.", - }, - ], - }, - ); + expect( + engine.selectSystemState().contexts.at(-1).pointers.read.lineId, + ).toBe("line2"); + expect( + findElementById(advancedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "typewriter", + content: [ + { + text: "Line 2 should be the next reveal line after the second click.", + }, + ], + }); expect( effectsHandler.handleRouteGraphicsEvent("renderComplete", { @@ -284,31 +437,91 @@ describe("RouteEngine line completion flow", () => { ).toBe(true); const line2CompletedRender = getLastRenderState(routeGraphics); - expect(findElementById(line2CompletedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "none", - }, - ); + expect( + findElementById(line2CompletedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "none", + }); engine.handleActions({ nextLine: {}, }); const line3Render = getLastRenderState(routeGraphics); - expect(engine.selectSystemState().contexts.at(-1).pointers.read.lineId).toBe( - "line3", - ); - expect(findElementById(line3Render.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "typewriter", - content: [ - { - text: "Line 3 should be reached with one click after line 2 completes naturally.", - }, - ], + expect( + engine.selectSystemState().contexts.at(-1).pointers.read.lineId, + ).toBe("line3"); + expect( + findElementById(line3Render.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "typewriter", + content: [ + { + text: "Line 3 should be reached with one click after line 2 completes naturally.", + }, + ], + }); + }); + + it("stops active playback on a choice line and still allows choice-origin nextLine", () => { + const routeGraphics = { + render: vi.fn(), + }; + let engine; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics, + ticker: createTicker(), + }); + engine = createRouteEngine({ + handlePendingEffects: effectsHandler, + }); + + engine.init({ + initialState: { + projectData: createChoiceBlockingProjectData(), + }, + }); + + expect(engine.selectSystemState().global.autoMode).toBe(true); + expect(engine.selectSystemState().global.nextLineConfig.auto).toEqual({ + enabled: true, + trigger: "fromStart", + delay: 900, + }); + + engine.handleAction("markLineCompleted", {}); + engine.handleActions({ + nextLine: {}, + }); + + let state = engine.selectSystemState(); + expect(state.contexts.at(-1).pointers.read.lineId).toBe("line2"); + expect(state.global.autoMode).toBe(false); + expect(state.global.skipMode).toBe(false); + expect(state.global.nextLineConfig.auto).toEqual({ + enabled: true, + trigger: "fromStart", + delay: 900, + }); + + engine.handleActions({ + nextLine: {}, + }); + + state = engine.selectSystemState(); + expect(state.contexts.at(-1).pointers.read.lineId).toBe("line2"); + + engine.handleAction("markLineCompleted", {}); + engine.handleActions({ + nextLine: { + _interactionSource: "choice", }, - ); + }); + + state = engine.selectSystemState(); + expect(state.contexts.at(-1).pointers.read.lineId).toBe("line3"); }); }); diff --git a/spec/system/actions/enforceChoiceVisibilityConstraints.spec.yaml b/spec/system/actions/enforceChoiceVisibilityConstraints.spec.yaml new file mode 100644 index 00000000..78d826da --- /dev/null +++ b/spec/system/actions/enforceChoiceVisibilityConstraints.spec.yaml @@ -0,0 +1,130 @@ +file: "../../../src/stores/system.store.js" +group: systemStore.enforceChoiceVisibilityConstraints +suites: [enforceChoiceVisibilityConstraints] +--- +suite: enforceChoiceVisibilityConstraints +exportName: enforceChoiceVisibilityConstraints +--- +case: stops active playback and clears timers when a choice is visible +in: + - state: + global: + autoMode: true + skipMode: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + global: + autoMode: false + skipMode: false + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: + - name: "clearAutoNextTimer" + - name: "clearSkipNextTimer" + - name: "clearNextLineConfigTimer" + - name: "render" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +--- +case: no-op when no choice is visible +in: + - state: + global: + autoMode: true + skipMode: false + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + global: + autoMode: true + skipMode: false + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" diff --git a/spec/system/actions/markLineCompleted.spec.yaml b/spec/system/actions/markLineCompleted.spec.yaml index c0733a21..bd94bb07 100644 --- a/spec/system/actions/markLineCompleted.spec.yaml +++ b/spec/system/actions/markLineCompleted.spec.yaml @@ -211,3 +211,77 @@ out: delay: 1500 pendingEffects: - name: render +--- +case: mark line as completed with a visible choice does not start playback timers +in: + - state: + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + isLineCompleted: false + autoMode: true + variables: + _autoForwardTime: 1000 + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromComplete" + delay: 2000 + viewedRegistry: + sections: [] + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] +out: + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + global: + isLineCompleted: true + autoMode: true + variables: + _autoForwardTime: 1000 + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromComplete" + delay: 2000 + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" + pendingEffects: + - name: render diff --git a/spec/system/actions/nextLine.spec.yaml b/spec/system/actions/nextLine.spec.yaml index 1713cd16..ec9b6501 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -506,6 +506,143 @@ out: sectionId: "section1" lineId: "2" --- +case: do not complete or advance when a choice is visible from non-choice input +in: + - state: + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +--- +case: allow next line when the interaction comes from a choice button +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + - _interactionSource: "choice" +out: + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + pendingEffects: + - name: "handleLineActions" + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + 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: no section found in: - state: diff --git a/spec/system/actions/setNextLineConfig.spec.yaml b/spec/system/actions/setNextLineConfig.spec.yaml index 97e6d94c..25ddd9bd 100644 --- a/spec/system/actions/setNextLineConfig.spec.yaml +++ b/spec/system/actions/setNextLineConfig.spec.yaml @@ -330,6 +330,70 @@ out: delay: 2000 - name: "render" --- +case: enable auto does not start timer when a choice is visible +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: false + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + - auto: + enabled: true + trigger: "fromComplete" + delay: 2000 +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromComplete" + delay: 2000 + pendingEffects: + - name: "render" +--- case: disable auto clears timer in: - state: diff --git a/spec/system/actions/startAutoMode.spec.yaml b/spec/system/actions/startAutoMode.spec.yaml index c7ec0c61..a6ac1c61 100644 --- a/spec/system/actions/startAutoMode.spec.yaml +++ b/spec/system/actions/startAutoMode.spec.yaml @@ -106,3 +106,56 @@ out: payload: delay: 1000 - name: render +--- +case: do not start auto mode when a choice is visible +in: + - state: + global: + autoMode: false + isLineCompleted: true + pendingEffects: [] + variables: + _autoForwardTime: 1000 + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + autoMode: false + isLineCompleted: true + variables: + _autoForwardTime: 1000 + pendingEffects: [] diff --git a/spec/system/actions/startSkipMode.spec.yaml b/spec/system/actions/startSkipMode.spec.yaml index c2e602f6..c9983407 100644 --- a/spec/system/actions/startSkipMode.spec.yaml +++ b/spec/system/actions/startSkipMode.spec.yaml @@ -91,4 +91,53 @@ out: - name: clearAutoNextTimer - name: clearSkipNextTimer - name: startSkipNextTimer - - name: render \ No newline at end of file + - name: render +--- +case: do not start skip mode when a choice is visible +in: + - state: + global: + skipMode: false + autoMode: false + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + skipMode: false + autoMode: false + pendingEffects: [] diff --git a/spec/system/actions/toggleAutoMode.spec.yaml b/spec/system/actions/toggleAutoMode.spec.yaml index 1d6348c5..8a437315 100644 --- a/spec/system/actions/toggleAutoMode.spec.yaml +++ b/spec/system/actions/toggleAutoMode.spec.yaml @@ -195,3 +195,56 @@ out: payload: delay: 1000 - name: render +--- +case: do not toggle auto mode on when a choice is visible +in: + - state: + global: + autoMode: false + isLineCompleted: true + pendingEffects: [] + variables: + _autoForwardTime: 1000 + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + autoMode: false + isLineCompleted: true + variables: + _autoForwardTime: 1000 + pendingEffects: [] diff --git a/spec/system/actions/toggleSkipMode.spec.yaml b/spec/system/actions/toggleSkipMode.spec.yaml index 603721da..f564eed2 100644 --- a/spec/system/actions/toggleSkipMode.spec.yaml +++ b/spec/system/actions/toggleSkipMode.spec.yaml @@ -105,4 +105,53 @@ out: - name: existingEffect data: test - name: clearSkipNextTimer - - name: render \ No newline at end of file + - name: render +--- +case: do not toggle skip mode on when a choice is visible +in: + - state: + global: + skipMode: false + autoMode: false + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + skipMode: false + autoMode: false + pendingEffects: [] diff --git a/spec/system/renderState/addChoices.spec.yaml b/spec/system/renderState/addChoices.spec.yaml index b46d562d..731ece96 100644 --- a/spec/system/renderState/addChoices.spec.yaml +++ b/spec/system/renderState/addChoices.spec.yaml @@ -39,21 +39,65 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - type: "button" id: "choice-button" text: "Select an option" animations: [] --- +case: tags nextLine actions from choice buttons and exposes choice visibility template data +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - presentationState: + choice: + resourceId: "choiceLayout" + items: + - id: "choice1" + text: "Option 1" + resources: + layouts: + choiceLayout: + elements: + - id: "choice-copy" + type: "text" + content: + $if isChoiceVisible: "Choice shown" + $else: "Choice hidden" + - id: "choice-button" + type: "button" + text: "Continue" + click: + payload: + actions: + nextLine: {} + screen: + width: 1920 + height: 1080 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "choice-copy" + type: "text" + content: "Choice shown" + - id: "choice-button" + type: "button" + text: "Continue" + click: + payload: + actions: + nextLine: + _interactionSource: "choice" + animations: [] +--- case: multiple choice layout elements in: - elements: @@ -94,16 +138,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - type: "container" id: "choices" children: @@ -185,16 +219,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-label" type: "text" content: "Select an option" @@ -242,16 +266,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-panel" type: "rect" width: 500 @@ -294,16 +308,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-image" type: "sprite" src: "choice-frame.png" @@ -367,17 +371,7 @@ out: type: "container" x: 0 y: 0 - children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} + children: [] animations: [] --- case: layout has no elements - return unchanged state @@ -408,17 +402,7 @@ out: type: "container" x: 0 y: 0 - children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} + children: [] animations: [] --- case: no choice items - still add layout elements @@ -450,16 +434,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - type: "text" content: "No choices available" animations: [] diff --git a/spec/system/selectors/selectIsChoiceVisible.spec.yaml b/spec/system/selectors/selectIsChoiceVisible.spec.yaml new file mode 100644 index 00000000..f0f09f27 --- /dev/null +++ b/spec/system/selectors/selectIsChoiceVisible.spec.yaml @@ -0,0 +1,103 @@ +file: "../../../src/stores/system.store.js" +group: systemStore selectors +suites: [selectIsChoiceVisible] +--- +suite: selectIsChoiceVisible +exportName: selectIsChoiceVisible +--- +case: false when no choice is active +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line1" +out: false +--- +case: true when current line shows a choice +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line1" +out: true +--- +case: false when the choice is explicitly cleared on the current line +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "line2" + actions: + choice: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line2" +out: false +--- +case: true when current line only updates choice animations +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "line2" + actions: + choice: + animations: + resourceId: "fade" + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line2" +out: true diff --git a/src/RouteEngine.js b/src/RouteEngine.js index 5539b3dc..5b612480 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -131,6 +131,7 @@ export default function createRouteEngine(options) { const line = _systemStore.selectCurrentLine(); if (line?.actions) { handleActions(line.actions); + _systemStore.enforceChoiceVisibilityConstraints({}); return true; } diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index d430fdd2..c217224e 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -844,6 +844,7 @@ const createLayoutTemplateData = ({ isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, confirmDialog, historyDialogue = [], @@ -855,6 +856,7 @@ const createLayoutTemplateData = ({ isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, confirmDialog, historyDialogue, @@ -956,6 +958,50 @@ const createFullscreenClickBlocker = ({ }, }); +const tagChoiceInteractionSource = (node) => { + if (Array.isArray(node)) { + return node.map((item) => tagChoiceInteractionSource(item)); + } + + if (!node || typeof node !== "object") { + return node; + } + + const taggedNode = { ...node }; + + Object.keys(taggedNode).forEach((key) => { + taggedNode[key] = tagChoiceInteractionSource(taggedNode[key]); + }); + + const clickPayload = taggedNode.click?.payload; + const actions = clickPayload?.actions; + if ( + !actions || + typeof actions !== "object" || + Array.isArray(actions) || + !Object.prototype.hasOwnProperty.call(actions, "nextLine") + ) { + return taggedNode; + } + + return { + ...taggedNode, + click: { + ...taggedNode.click, + payload: { + ...clickPayload, + actions: { + ...actions, + nextLine: { + ...(actions.nextLine || {}), + _interactionSource: "choice", + }, + }, + }, + }, + }; +}; + const createHistoryDialogueTemplateData = ( dialogueHistory = [], characters = {}, @@ -1213,6 +1259,7 @@ export const addBackgroundOrCg = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], }, @@ -1275,6 +1322,7 @@ export const addBackgroundOrCg = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, }), { functions: jemplFunctions }, @@ -1472,6 +1520,7 @@ export const addVisuals = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], }, @@ -1582,6 +1631,7 @@ export const addVisuals = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, }), { functions: jemplFunctions }, @@ -1639,6 +1689,7 @@ export const addDialogue = ( dialogueUIHidden, autoMode, skipMode, + isChoiceVisible, canRollback, skipOnlyViewedLines, isLineCompleted, @@ -1714,6 +1765,7 @@ export const addDialogue = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, skipOnlyViewedLines, isLineCompleted, @@ -1803,10 +1855,10 @@ export const addChoices = ( resources, isLineCompleted, skipTransitionsAndAnimations, - screen, variables, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], }, @@ -1817,21 +1869,6 @@ export const addChoices = ( const storyContainer = getStoryContainer(elements); if (!storyContainer) return state; - storyContainer.children.push({ - id: "choice-blocker", - type: "rect", - fill: "transparent", - width: screen.width, - height: screen.height, - x: 0, - y: 0, - click: { - payload: { - actions: {}, - }, - }, - }); - const layout = resources?.layouts[presentationState.choice.resourceId]; if (layout && layout.elements) { const wrappedTemplate = { elements: layout.elements }; @@ -1844,6 +1881,7 @@ export const addChoices = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible: isChoiceVisible ?? !!presentationState.choice, canRollback, }), choice: { @@ -1854,13 +1892,15 @@ export const addChoices = ( functions: jemplFunctions, }, ); - const choiceElements = resolveLayoutResourceIds( - settleTextRevealIfCompleted(result?.elements, { - isLineCompleted, - skipMode, - skipTransitionsAndAnimations, - }), - resources, + const choiceElements = tagChoiceInteractionSource( + resolveLayoutResourceIds( + settleTextRevealIfCompleted(result?.elements, { + isLineCompleted, + skipMode, + skipTransitionsAndAnimations, + }), + resources, + ), ); if (Array.isArray(choiceElements)) { @@ -1907,6 +1947,7 @@ export const addControl = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], skipTransitionsAndAnimations, @@ -1953,6 +1994,7 @@ export const addControl = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, }), isLineCompleted, @@ -2048,6 +2090,7 @@ export const addLayout = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], isLineCompleted, @@ -2097,6 +2140,7 @@ export const addLayout = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, }), isLineCompleted, @@ -2142,6 +2186,7 @@ export const addLayeredViews = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, layeredViews = [], dialogueHistory = [], @@ -2199,6 +2244,7 @@ export const addLayeredViews = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, historyDialogue: historyDialogueWithNames, characters: resources.characters || {}, @@ -2247,6 +2293,7 @@ export const addConfirmDialog = ( saveSlots = [], autoMode, skipMode, + isChoiceVisible, canRollback, confirmDialog, dialogueHistory = [], @@ -2303,6 +2350,7 @@ export const addConfirmDialog = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, confirmDialog, historyDialogue: historyDialogueWithNames, diff --git a/src/stores/system.store.js b/src/stores/system.store.js index f7a58abd..1b7e9e6b 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1225,6 +1225,10 @@ export const selectNextLineConfig = ({ state }) => { return state.global.nextLineConfig; }; +export const selectIsChoiceVisible = ({ state }) => { + return !!selectVisibleChoiceResourceId({ state }); +}; + export const selectSystemState = ({ state }) => { return structuredClone(state); }; @@ -1576,6 +1580,7 @@ export const selectRenderState = ({ state }) => { dialogueUIHidden: state.global.dialogueUIHidden, autoMode: state.global.autoMode, skipMode: state.global.skipMode, + isChoiceVisible: selectIsChoiceVisible({ state }), canRollback: selectCanRollback({ state }), skipOnlyViewedLines: !allVariables._skipUnseenText, isLineCompleted: state.global.isLineCompleted, @@ -1641,7 +1646,49 @@ export const clearLayeredViews = ({ state }) => { return state; }; +export const enforceChoiceVisibilityConstraints = ({ state }) => { + if (!selectIsChoiceVisible({ state })) { + return state; + } + + let needsRender = false; + + if (state.global.autoMode) { + state.global.autoMode = false; + state.global.pendingEffects.push({ + name: "clearAutoNextTimer", + }); + needsRender = true; + } + + if (state.global.skipMode) { + state.global.skipMode = false; + state.global.pendingEffects.push({ + name: "clearSkipNextTimer", + }); + needsRender = true; + } + + if (state.global.nextLineConfig?.auto?.enabled) { + state.global.pendingEffects.push({ + name: "clearNextLineConfigTimer", + }); + } + + if (needsRender) { + state.global.pendingEffects.push({ + name: "render", + }); + } + + return state; +}; + export const startAutoMode = ({ state }) => { + if (selectIsChoiceVisible({ state })) { + return state; + } + if (state.global.skipMode) { state.global.skipMode = false; state.global.pendingEffects.push({ @@ -1682,6 +1729,10 @@ export const stopAutoMode = ({ state }) => { export const toggleAutoMode = ({ state }) => { const autoMode = state.global.autoMode; + if (selectIsChoiceVisible({ state }) && !autoMode) { + return state; + } + if (autoMode) { stopAutoMode({ state }); } else { @@ -1695,6 +1746,10 @@ export const startSkipMode = ({ state }) => { // return state; // } + if (selectIsChoiceVisible({ state })) { + return state; + } + if (state.global.autoMode) { state.global.autoMode = false; state.global.pendingEffects.push({ @@ -1734,6 +1789,10 @@ export const toggleSkipMode = ({ state }) => { // } const skipMode = selectSkipMode({ state }); + if (selectIsChoiceVisible({ state }) && !skipMode) { + return state; + } + if (skipMode) { stopSkipMode({ state }); } else { @@ -1922,10 +1981,12 @@ export const setNextLineConfig = ({ state }, payload) => { } const currentAutoEnabled = state.global.nextLineConfig.auto?.enabled; + const isChoiceVisible = selectIsChoiceVisible({ state }); // If auto.enabled state has changed, dispatch timer effects if ( !isRollbackRestoring && + !isChoiceVisible && currentAutoEnabled === true && !previousAutoEnabled ) { @@ -2147,7 +2208,7 @@ export const jumpToLine = ({ state }, payload) => { return state; }; -export const nextLine = ({ state }) => { +export const nextLine = ({ state }, payload) => { //const isAutoOrSkip = state.global.autoMode || state.global.skipMode; if (!state.global.nextLineConfig.manual.enabled) { @@ -2159,6 +2220,11 @@ export const nextLine = ({ state }) => { return state; } + const isChoiceInteraction = payload?._interactionSource === "choice"; + if (selectIsChoiceVisible({ state }) && !isChoiceInteraction) { + return state; + } + // If line is not completed, complete it instantly instead of advancing if (!state.global.isLineCompleted) { state.global.isLineCompleted = true; @@ -2295,9 +2361,10 @@ export const markLineCompleted = ({ state }) => { return state; } state.global.isLineCompleted = true; + const isChoiceVisible = selectIsChoiceVisible({ state }); // If auto mode is on, start the delay timer to advance after completion - if (state.global.autoMode) { + if (state.global.autoMode && !isChoiceVisible) { const autoForwardTime = state.global.variables._autoForwardTime ?? 1000; state.global.pendingEffects.push({ name: "startAutoNextTimer", @@ -2314,7 +2381,7 @@ export const markLineCompleted = ({ state }) => { // If nextLineConfig.auto is enabled with fromComplete trigger, start the timer const nextLineConfig = state.global.nextLineConfig; - if (nextLineConfig?.auto?.enabled) { + if (nextLineConfig?.auto?.enabled && !isChoiceVisible) { const trigger = nextLineConfig.auto.trigger; // Default trigger is "fromComplete", so start timer if not explicitly "fromStart" if (trigger !== "fromStart") { @@ -2500,7 +2567,7 @@ export const nextLineFromSystem = ({ state }) => { } // Auto/skip/scene timers should pause when an interactive choice is visible. - if (selectVisibleChoiceResourceId({ state })) { + if (selectIsChoiceVisible({ state })) { return state; } @@ -2827,6 +2894,7 @@ export const createSystemStore = (initialState) => { selectPendingEffects, selectSkipMode, selectAutoMode, + selectIsChoiceVisible, selectDialogueUIHidden, selectDialogueHistory, selectConfirmDialog, @@ -2885,6 +2953,7 @@ export const createSystemStore = (initialState) => { popLayeredView, replaceLastLayeredView, clearLayeredViews, + enforceChoiceVisibilityConstraints, updateVariable, nextLineFromSystem, }; diff --git a/vt/specs/choice/interaction-guards.yaml b/vt/specs/choice/interaction-guards.yaml new file mode 100644 index 00000000..bc42bd53 --- /dev/null +++ b/vt/specs/choice/interaction-guards.yaml @@ -0,0 +1,214 @@ +--- +title: Choice Interaction Guards +description: Choice visibility stops active playback, blocks background/control advance, and still allows choice-origin nextLine. +--- +screen: + width: 1280 + height: 720 + backgroundColor: "#101010" +resources: + controls: + guardControls: + elements: + - id: bg-hit + type: rect + x: 0 + y: 0 + width: 1280 + height: 720 + click: + payload: + actions: + nextLine: {} + colorId: backgroundColor + - id: auto-button + type: rect + x: 40 + y: 40 + width: 220 + height: 68 + click: + payload: + actions: + toggleAutoMode: {} + colorId: buttonColor + - id: auto-label + type: text + x: 96 + y: 62 + content: AUTO + textStyleId: statusText + - id: skip-button + type: rect + x: 40 + y: 126 + width: 220 + height: 68 + click: + payload: + actions: + toggleSkipMode: {} + colorId: buttonColor + - id: skip-label + type: text + x: 100 + y: 148 + content: SKIP + textStyleId: statusText + - id: auto-status-on + $when: autoMode + type: text + x: 300 + y: 52 + content: "AUTO: ON" + textStyleId: statusText + - id: auto-status-off + $when: "!autoMode" + type: text + x: 300 + y: 52 + content: "AUTO: OFF" + textStyleId: statusText + - id: skip-status-on + $when: skipMode + type: text + x: 300 + y: 138 + content: "SKIP: ON" + textStyleId: statusText + - id: skip-status-off + $when: "!skipMode" + type: text + x: 300 + y: 138 + content: "SKIP: OFF" + textStyleId: statusText + - id: choice-status-on + $when: isChoiceVisible + type: text + x: 300 + y: 224 + content: "CHOICE: ON" + textStyleId: statusText + - id: choice-status-off + $when: "!isChoiceVisible" + type: text + x: 300 + y: 224 + content: "CHOICE: OFF" + textStyleId: statusText + - id: helper-copy + type: text + x: 40 + y: 300 + width: 520 + content: Background click advances when allowed. Auto and skip buttons must do nothing while a choice is visible. + textStyleId: helperText + layouts: + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 40 + y: 520 + width: 1200 + height: 150 + colorId: dialogueColor + - id: dialogue-text + type: text + x: 72 + y: 566 + width: 1140 + content: ${dialogue.content[0].text} + textStyleId: dialogueText + choiceLayout: + elements: + - id: choice-button-bg + type: rect + x: 680 + y: 180 + width: 520 + height: 84 + click: + payload: + actions: + nextLine: {} + colorId: choiceColor + - id: choice-button-text + type: text + x: 720 + y: 208 + content: ${choice.items[0].content} + textStyleId: statusText + fonts: + fontDefault: + fileId: Arial + colors: + textColor: + hex: "#F4F1E8" + backgroundColor: + hex: "#171717" + buttonColor: + hex: "#264653" + dialogueColor: + hex: "#2A2A2A" + choiceColor: + hex: "#9C6644" + textStyles: + statusText: + fontId: fontDefault + colorId: textColor + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + helperText: + fontId: fontDefault + colorId: textColor + fontSize: 22 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.35 + dialogueText: + fontId: fontDefault + colorId: textColor + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.25 +story: + initialSceneId: choiceGuards + scenes: + choiceGuards: + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + control: + resourceId: guardControls + startAutoMode: {} + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1: auto mode starts here, then the engine should stop it when the choice line appears." + - id: line2 + actions: + dialogue: + content: + - text: "Line 2: choice is visible. Background advance is blocked, auto and skip cannot be turned on, and the choice button should still continue." + choice: + resourceId: choiceLayout + items: + - id: continue + content: Continue from choice + - id: line3 + actions: + choice: {} + dialogue: + content: + - text: "Line 3: reached from the choice button." From e474e2ca3c0559d95b4aacd83b8970748aa1bca1 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 12:46:13 +0800 Subject: [PATCH 02/11] Strengthen choice VT coverage --- vt/specs/choice/interaction-guards.yaml | 49 +++++ vt/specs/choice/skip-stops-on-choice.yaml | 202 ++++++++++++++++++ .../nextLineConfig/choice-stops-auto.yaml | 22 ++ 3 files changed, 273 insertions(+) create mode 100644 vt/specs/choice/skip-stops-on-choice.yaml diff --git a/vt/specs/choice/interaction-guards.yaml b/vt/specs/choice/interaction-guards.yaml index bc42bd53..e16d8fb4 100644 --- a/vt/specs/choice/interaction-guards.yaml +++ b/vt/specs/choice/interaction-guards.yaml @@ -1,12 +1,61 @@ --- title: Choice Interaction Guards description: Choice visibility stops active playback, blocks background/control advance, and still allows choice-origin nextLine. +specs: + - auto mode is visibly on before the choice line is entered + - entering the choice line forces auto mode off and exposes the choice state + - auto, skip, and background clicks do not change the state while the choice is visible + - the choice button still advances to the next line +skipInitialScreenshot: true +viewport: + id: capture + width: 1280 + height: 720 +steps: + - action: wait + ms: 200 + - action: screenshot + - action: click + x: 150 + y: 400 + - action: wait + ms: 150 + - action: screenshot + - action: click + x: 150 + y: 74 + - action: wait + ms: 120 + - action: screenshot + - action: click + x: 150 + y: 160 + - action: wait + ms: 120 + - action: screenshot + - action: click + x: 150 + y: 400 + - action: wait + ms: 120 + - action: screenshot + - action: click + x: 940 + y: 222 + - action: wait + ms: 150 + - action: screenshot --- screen: width: 1280 height: 720 backgroundColor: "#101010" resources: + variables: + _autoForwardTime: + type: number + scope: global-device + default: 5000 controls: guardControls: elements: diff --git a/vt/specs/choice/skip-stops-on-choice.yaml b/vt/specs/choice/skip-stops-on-choice.yaml new file mode 100644 index 00000000..2fb0087c --- /dev/null +++ b/vt/specs/choice/skip-stops-on-choice.yaml @@ -0,0 +1,202 @@ +--- +title: Choice Stops Active Skip +description: Skip mode should be turned off as soon as a choice becomes visible, and the choice should remain interactive. +specs: + - skip mode is visibly on before the choice line is entered + - entering the choice line forces skip mode off + - waiting on the choice line does not re-enable skip or advance the story + - the choice button still advances after skip has been stopped +skipInitialScreenshot: true +viewport: + id: capture + width: 1280 + height: 720 +steps: + - action: wait + ms: 200 + - action: click + x: 150 + y: 74 + - action: wait + ms: 40 + - action: screenshot + - action: wait + ms: 220 + - action: screenshot + - action: wait + ms: 220 + - action: screenshot + - action: click + x: 940 + y: 222 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1280 + height: 720 + backgroundColor: "#101010" +resources: + variables: + _skipUnseenText: + type: boolean + scope: global-device + default: true + controls: + guardControls: + elements: + - id: bg-hit + type: rect + x: 0 + y: 0 + width: 1280 + height: 720 + click: + payload: + actions: + nextLine: {} + colorId: backgroundColor + - id: skip-button + type: rect + x: 40 + y: 40 + width: 220 + height: 68 + click: + payload: + actions: + toggleSkipMode: {} + colorId: choiceColor + - id: skip-label + type: text + x: 100 + y: 62 + content: SKIP + textStyleId: statusText + - id: skip-status-on + $when: skipMode + type: text + x: 300 + y: 56 + content: "SKIP: ON" + textStyleId: statusText + - id: skip-status-off + $when: "!skipMode" + type: text + x: 300 + y: 56 + content: "SKIP: OFF" + textStyleId: statusText + - id: choice-status-on + $when: isChoiceVisible + type: text + x: 300 + y: 102 + content: "CHOICE: ON" + textStyleId: statusText + - id: choice-status-off + $when: "!isChoiceVisible" + type: text + x: 300 + y: 102 + content: "CHOICE: OFF" + textStyleId: statusText + layouts: + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 40 + y: 520 + width: 1200 + height: 150 + colorId: dialogueColor + - id: dialogue-text + type: text + x: 72 + y: 566 + width: 1140 + content: ${dialogue.content[0].text} + textStyleId: dialogueText + choiceLayout: + elements: + - id: choice-button-bg + type: rect + x: 680 + y: 180 + width: 520 + height: 84 + click: + payload: + actions: + nextLine: {} + colorId: choiceColor + - id: choice-button-text + type: text + x: 720 + y: 208 + content: ${choice.items[0].content} + textStyleId: statusText + fonts: + fontDefault: + fileId: Arial + colors: + textColor: + hex: "#F4F1E8" + backgroundColor: + hex: "#171717" + dialogueColor: + hex: "#2A2A2A" + choiceColor: + hex: "#9C6644" + textStyles: + statusText: + fontId: fontDefault + colorId: textColor + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + dialogueText: + fontId: fontDefault + colorId: textColor + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.25 +story: + initialSceneId: choiceGuards + scenes: + choiceGuards: + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + control: + resourceId: guardControls + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1: turn on skip here, then the next choice line should stop it." + - id: line2 + actions: + dialogue: + content: + - text: "Line 2: the choice is visible and skip must already be off." + choice: + resourceId: choiceLayout + items: + - id: continue + content: Continue from choice + - id: line3 + actions: + choice: {} + dialogue: + content: + - text: "Line 3: reached from the choice after skip was stopped." diff --git a/vt/specs/nextLineConfig/choice-stops-auto.yaml b/vt/specs/nextLineConfig/choice-stops-auto.yaml index 0e9d2264..3040137b 100644 --- a/vt/specs/nextLineConfig/choice-stops-auto.yaml +++ b/vt/specs/nextLineConfig/choice-stops-auto.yaml @@ -1,6 +1,28 @@ --- title: nextLineConfig with Choice description: Scene auto (fromStart) should pause when a choice is visible +specs: + - scene auto advances from line 1 to line 3 without manual input + - once the choice is visible on line 3, waiting longer than the configured delay does not advance to line 4 + - the first choice button still advances to line 4 when clicked +skipInitialScreenshot: true +viewport: + id: capture + width: 1920 + height: 1080 +steps: + - action: wait + ms: 2300 + - action: screenshot + - action: wait + ms: 1300 + - action: screenshot + - action: click + x: 960 + y: 260 + - action: wait + ms: 150 + - action: screenshot --- screen: width: 1920 From 587e9c13b2487183d32197c3bb19eaf03ef239c2 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 17:01:21 +0800 Subject: [PATCH 03/11] Fix next-line after choice without explicit clear --- spec/RouteEngine.lineCompletion.test.js | 26 +++++- spec/system/actions/nextLine.spec.yaml | 85 +++++++++++++++++++ .../selectors/selectIsChoiceVisible.spec.yaml | 28 ++++++ src/stores/system.store.js | 44 ++-------- vt/specs/choice/interaction-guards.yaml | 13 ++- 5 files changed, 155 insertions(+), 41 deletions(-) diff --git a/spec/RouteEngine.lineCompletion.test.js b/spec/RouteEngine.lineCompletion.test.js index 306c1409..53cae0a5 100644 --- a/spec/RouteEngine.lineCompletion.test.js +++ b/spec/RouteEngine.lineCompletion.test.js @@ -266,7 +266,6 @@ const createChoiceBlockingProjectData = () => ({ { id: "line3", actions: { - choice: {}, dialogue: { mode: "adv", ui: { @@ -280,6 +279,22 @@ const createChoiceBlockingProjectData = () => ({ }, }, }, + { + id: "line4", + actions: { + dialogue: { + mode: "adv", + ui: { + resourceId: "staticDialogue", + }, + content: [ + { + text: "After line 3", + }, + ], + }, + }, + }, ], }, }, @@ -523,5 +538,14 @@ describe("RouteEngine line completion flow", () => { state = engine.selectSystemState(); expect(state.contexts.at(-1).pointers.read.lineId).toBe("line3"); + expect(engine.selectPresentationState().choice).toBeUndefined(); + + engine.handleAction("markLineCompleted", {}); + engine.handleActions({ + nextLine: {}, + }); + + state = engine.selectSystemState(); + expect(state.contexts.at(-1).pointers.read.lineId).toBe("line4"); }); }); diff --git a/spec/system/actions/nextLine.spec.yaml b/spec/system/actions/nextLine.spec.yaml index ec9b6501..b304dca9 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -643,6 +643,91 @@ out: lineId: "2" rollbackPolicy: "free" --- +case: allow next line on a later line when the previous choice is no longer in presentation +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: + dialogue: + content: + - text: "after choice" + - id: "3" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "2" +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: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: + dialogue: + content: + - text: "after choice" + - id: "3" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "3" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + timeline: + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" +--- case: no section found in: - state: diff --git a/spec/system/selectors/selectIsChoiceVisible.spec.yaml b/spec/system/selectors/selectIsChoiceVisible.spec.yaml index f0f09f27..c00483f9 100644 --- a/spec/system/selectors/selectIsChoiceVisible.spec.yaml +++ b/spec/system/selectors/selectIsChoiceVisible.spec.yaml @@ -74,6 +74,34 @@ in: lineId: "line2" out: false --- +case: false when a later line omits choice after an earlier choice line +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "line2" + actions: + dialogue: + content: + - text: "after choice" + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line2" +out: false +--- case: true when current line only updates choice animations in: - state: diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 1b7e9e6b..9398424b 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1226,7 +1226,11 @@ export const selectNextLineConfig = ({ state }) => { }; export const selectIsChoiceVisible = ({ state }) => { - return !!selectVisibleChoiceResourceId({ state }); + if (!selectCurrentPointer({ state })?.pointer) { + return false; + } + + return !!selectPresentationState({ state })?.choice; }; export const selectSystemState = ({ state }) => { @@ -1307,44 +1311,6 @@ export const selectCurrentLine = ({ state }) => { return section.lines.find((line) => line.id === lineId); }; -const selectVisibleChoiceResourceId = ({ state }) => { - const pointer = selectCurrentPointer({ state })?.pointer; - const sectionId = pointer?.sectionId; - const lineId = pointer?.lineId; - const section = selectSection({ state }, { sectionId }); - const lines = section?.lines || []; - const currentLineIndex = lines.findIndex((line) => line.id === lineId); - - if (currentLineIndex < 0) { - return undefined; - } - - let visibleChoiceResourceId; - const currentLines = lines.slice(0, currentLineIndex + 1); - currentLines.forEach((line) => { - if ( - !line?.actions || - !Object.prototype.hasOwnProperty.call(line.actions, "choice") - ) { - return; - } - - const choice = line.actions.choice; - if (!choice?.resourceId) { - // `choice: {}` clears the currently visible choice. - // Choice animations without resourceId should not change visibility. - if (!choice?.animations) { - visibleChoiceResourceId = undefined; - } - return; - } - - visibleChoiceResourceId = choice.resourceId; - }); - - return visibleChoiceResourceId; -}; - export const selectPresentationState = ({ state }) => { const { sectionId, lineId } = selectCurrentPointer({ state }).pointer; const section = selectSection({ state }, { sectionId }); diff --git a/vt/specs/choice/interaction-guards.yaml b/vt/specs/choice/interaction-guards.yaml index e16d8fb4..3698c93d 100644 --- a/vt/specs/choice/interaction-guards.yaml +++ b/vt/specs/choice/interaction-guards.yaml @@ -6,6 +6,7 @@ specs: - entering the choice line forces auto mode off and exposes the choice state - auto, skip, and background clicks do not change the state while the choice is visible - the choice button still advances to the next line + - the line after the choice can advance normally even without an explicit choice clear action skipInitialScreenshot: true viewport: id: capture @@ -45,6 +46,12 @@ steps: - action: wait ms: 150 - action: screenshot + - action: click + x: 150 + y: 400 + - action: wait + ms: 150 + - action: screenshot --- screen: width: 1280 @@ -257,7 +264,11 @@ story: content: Continue from choice - id: line3 actions: - choice: {} dialogue: content: - text: "Line 3: reached from the choice button." + - id: line4 + actions: + dialogue: + content: + - text: "Line 4: next-line input works normally after the choice." From 36943651433350f126c220943860e282662f62b5 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 22:23:32 +0800 Subject: [PATCH 04/11] fix: block non-choice actions while choice is visible --- ...EffectsHandler.routeGraphicsEvents.test.js | 94 ++++++++++ spec/system/renderState/addChoices.spec.yaml | 49 +++++ ...mStore.choiceVisibilityCloneSafety.test.js | 56 ++++++ src/RouteEngine.js | 5 + src/createEffectsHandler.js | 41 ++++- src/stores/constructRenderState.js | 36 ++-- src/stores/system.store.js | 45 ++++- .../choice/blocked-non-choice-actions.yaml | 173 ++++++++++++++++++ 8 files changed, 483 insertions(+), 16 deletions(-) create mode 100644 spec/systemStore.choiceVisibilityCloneSafety.test.js create mode 100644 vt/specs/choice/blocked-non-choice-actions.yaml diff --git a/spec/createEffectsHandler.routeGraphicsEvents.test.js b/spec/createEffectsHandler.routeGraphicsEvents.test.js index e02aea27..abb8447c 100644 --- a/spec/createEffectsHandler.routeGraphicsEvents.test.js +++ b/spec/createEffectsHandler.routeGraphicsEvents.test.js @@ -91,6 +91,7 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { selectRenderState: vi.fn(() => ({ id: "render-1" })), handleAction: vi.fn(), handleActions: vi.fn(), + selectIsChoiceVisible: vi.fn(() => false), }; const effectsHandler = createEffectsHandler({ getEngine: () => engine, @@ -130,6 +131,7 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { selectRenderState: vi.fn(() => ({ id: "render-1" })), handleAction: vi.fn(), handleActions: vi.fn(), + selectIsChoiceVisible: vi.fn(() => false), }; const effectsHandler = createEffectsHandler({ getEngine: () => engine, @@ -180,6 +182,98 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { ); }); + it("does not forward non-choice actions while a choice is visible", async () => { + const engine = { + selectRenderState: vi.fn(() => ({ id: "render-1" })), + handleAction: vi.fn(), + handleActions: vi.fn(), + selectIsChoiceVisible: vi.fn(() => true), + }; + const preprocessPayload = vi.fn(); + const onEvent = vi.fn(); + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics: { + render: vi.fn(), + }, + ticker: createTicker(), + }); + + const payload = { + actions: { + updateVariable: { + id: "blocked", + operations: [ + { + variableId: "marker", + op: "=", + value: "_event", + }, + ], + }, + }, + _event: { + view: globalThis, + }, + }; + + const eventHandler = effectsHandler.createRouteGraphicsEventHandler({ + preprocessPayload, + onEvent, + }); + + await eventHandler("click", payload); + + expect(preprocessPayload).not.toHaveBeenCalled(); + expect(engine.handleActions).not.toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith("click", payload); + }); + + it("still forwards choice-tagged actions while a choice is visible", async () => { + const engine = { + selectRenderState: vi.fn(() => ({ id: "render-1" })), + handleAction: vi.fn(), + handleActions: vi.fn(), + selectIsChoiceVisible: vi.fn(() => true), + }; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics: { + render: vi.fn(), + }, + ticker: createTicker(), + }); + + const eventHandler = effectsHandler.createRouteGraphicsEventHandler(); + + await eventHandler("click", { + _interactionSource: "choice", + actions: { + sectionTransition: { + sectionId: "next-section", + }, + }, + _event: { + x: 10, + y: 20, + }, + }); + + expect(engine.handleActions).toHaveBeenCalledWith( + { + sectionTransition: { + sectionId: "next-section", + }, + }, + { + _event: { + x: 10, + y: 20, + }, + }, + ); + }); + it("coalesces replaceable effects by name and keeps the last payload", () => { const ticker = createTicker(); const engine = { diff --git a/spec/system/renderState/addChoices.spec.yaml b/spec/system/renderState/addChoices.spec.yaml index 731ece96..90a715d8 100644 --- a/spec/system/renderState/addChoices.spec.yaml +++ b/spec/system/renderState/addChoices.spec.yaml @@ -93,11 +93,60 @@ out: text: "Continue" click: payload: + _interactionSource: "choice" actions: nextLine: _interactionSource: "choice" animations: [] --- +case: tags non-nextLine choice clicks at the payload level +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - presentationState: + choice: + resourceId: "choiceLayout" + items: + - id: "choice1" + text: "Option 1" + resources: + layouts: + choiceLayout: + elements: + - id: "choice-button" + type: "button" + text: "Go" + click: + payload: + actions: + sectionTransition: + sectionId: "next-section" + screen: + width: 1920 + height: 1080 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "choice-button" + type: "button" + text: "Go" + click: + payload: + _interactionSource: "choice" + actions: + sectionTransition: + sectionId: "next-section" + animations: [] +--- case: multiple choice layout elements in: - elements: diff --git a/spec/systemStore.choiceVisibilityCloneSafety.test.js b/spec/systemStore.choiceVisibilityCloneSafety.test.js new file mode 100644 index 00000000..e500efbe --- /dev/null +++ b/spec/systemStore.choiceVisibilityCloneSafety.test.js @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { selectIsChoiceVisible } from "../src/stores/system.store.js"; + +describe("selectIsChoiceVisible clone safety", () => { + it("does not rebuild presentation state for choice visibility checks", () => { + const state = { + global: {}, + projectData: { + story: { + scenes: { + scene1: { + sections: { + section1: { + lines: [ + { + id: "line1", + actions: { + background: { + resourceId: "bg-main", + view: globalThis, + }, + }, + }, + { + id: "line2", + actions: { + choice: { + resourceId: "choice-layout", + items: [], + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + contexts: [ + { + currentPointerMode: "read", + pointers: { + read: { + sectionId: "section1", + lineId: "line2", + }, + }, + }, + ], + }; + + expect(() => selectIsChoiceVisible({ state })).not.toThrow(); + expect(selectIsChoiceVisible({ state })).toBe(true); + }); +}); diff --git a/src/RouteEngine.js b/src/RouteEngine.js index 5b612480..b0e8715a 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -78,6 +78,10 @@ export default function createRouteEngine(options) { return _systemStore.selectAutoMode(); }; + const selectIsChoiceVisible = () => { + return _systemStore.selectIsChoiceVisible(); + }; + const handleAction = (actionType, payload) => { if (!_systemStore[actionType]) { return; @@ -152,6 +156,7 @@ export default function createRouteEngine(options) { selectSaveSlot, selectSaveSlotPage, selectSaveSlots: selectSaveSlotMap, + selectIsChoiceVisible, handleLineActions, }; } diff --git a/src/createEffectsHandler.js b/src/createEffectsHandler.js index b757d974..36d81c09 100644 --- a/src/createEffectsHandler.js +++ b/src/createEffectsHandler.js @@ -318,14 +318,54 @@ const createEffectsHandler = ({ return true; }; + const isChoiceInteractionPayload = (payload = {}) => { + if (payload?._interactionSource === "choice") { + return true; + } + + const actions = payload?.actions; + if (!actions || typeof actions !== "object" || Array.isArray(actions)) { + return false; + } + + return Object.values(actions).some( + (actionPayload) => actionPayload?._interactionSource === "choice", + ); + }; + + const shouldBlockChoiceActions = (engine, payload = {}) => { + if (!payload?.actions) { + return false; + } + + if (typeof engine?.selectIsChoiceVisible !== "function") { + return false; + } + + if (!engine.selectIsChoiceVisible()) { + return false; + } + + return !isChoiceInteractionPayload(payload); + }; + const createRouteGraphicsEventHandler = ({ preprocessPayload, onEvent, } = {}) => { return async (eventName, payload = {}) => { + const engine = getEngine(); + if (shouldBlockChoiceActions(engine, payload)) { + return onEvent?.(eventName, payload); + } + const nextPayload = (await preprocessPayload?.(eventName, payload)) ?? payload; + if (shouldBlockChoiceActions(engine, nextPayload)) { + return onEvent?.(eventName, nextPayload); + } + handleRouteGraphicsEvent(eventName, nextPayload); if (nextPayload?.actions) { @@ -335,7 +375,6 @@ const createEffectsHandler = ({ ? { _event: nextPayload.event } : undefined; - const engine = getEngine(); engine.handleActions(nextPayload.actions, eventContext); } diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index c217224e..5ec38047 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -974,29 +974,41 @@ const tagChoiceInteractionSource = (node) => { }); const clickPayload = taggedNode.click?.payload; - const actions = clickPayload?.actions; if ( - !actions || - typeof actions !== "object" || - Array.isArray(actions) || - !Object.prototype.hasOwnProperty.call(actions, "nextLine") + !clickPayload || + typeof clickPayload !== "object" || + Array.isArray(clickPayload) ) { return taggedNode; } + const actions = clickPayload.actions; + const nextLineAction = + actions && + typeof actions === "object" && + !Array.isArray(actions) && + Object.prototype.hasOwnProperty.call(actions, "nextLine") + ? { + ...actions.nextLine, + _interactionSource: "choice", + } + : undefined; + return { ...taggedNode, click: { ...taggedNode.click, payload: { ...clickPayload, - actions: { - ...actions, - nextLine: { - ...(actions.nextLine || {}), - _interactionSource: "choice", - }, - }, + _interactionSource: "choice", + ...(nextLineAction + ? { + actions: { + ...actions, + nextLine: nextLineAction, + }, + } + : {}), }, }, }; diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 9398424b..0bf192ce 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1225,12 +1225,51 @@ export const selectNextLineConfig = ({ state }) => { return state.global.nextLineConfig; }; -export const selectIsChoiceVisible = ({ state }) => { - if (!selectCurrentPointer({ state })?.pointer) { +const selectChoiceVisibilityState = ({ state }) => { + const pointer = selectCurrentPointer({ state })?.pointer; + if (!pointer) { + return false; + } + + const sectionId = pointer?.sectionId; + const lineId = pointer?.lineId; + const section = selectSection({ state }, { sectionId }); + const lines = section?.lines || []; + const currentLineIndex = lines.findIndex((line) => line.id === lineId); + + if (currentLineIndex < 0) { return false; } - return !!selectPresentationState({ state })?.choice; + let isChoiceVisible = false; + for (const line of lines.slice(0, currentLineIndex + 1)) { + const actions = line?.actions; + if ( + !actions || + !Object.prototype.hasOwnProperty.call(actions, "choice") + ) { + isChoiceVisible = false; + continue; + } + + const choice = actions.choice; + if (choice?.resourceId) { + isChoiceVisible = true; + continue; + } + + // `choice: { animations: ... }` should preserve the previous choice state, + // while `choice: {}` explicitly clears it. + if (!choice?.animations) { + isChoiceVisible = false; + } + } + + return isChoiceVisible; +}; + +export const selectIsChoiceVisible = ({ state }) => { + return selectChoiceVisibilityState({ state }); }; export const selectSystemState = ({ state }) => { diff --git a/vt/specs/choice/blocked-non-choice-actions.yaml b/vt/specs/choice/blocked-non-choice-actions.yaml new file mode 100644 index 00000000..92221419 --- /dev/null +++ b/vt/specs/choice/blocked-non-choice-actions.yaml @@ -0,0 +1,173 @@ +--- +title: Choice Blocks Non-Choice Actions +description: Non-choice clicks must be dropped before they can dispatch actions while a choice is visible. +specs: + - the marker stays idle when a non-choice control is clicked while a choice is visible +skipInitialScreenshot: true +viewport: + id: capture + width: 1280 + height: 720 +steps: + - action: wait + ms: 200 + - action: screenshot + - action: click + x: 180 + y: 210 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1280 + height: 720 + backgroundColor: "#151515" +resources: + variables: + blockedMarker: + type: string + scope: context + default: idle + controls: + backgroundControl: + elements: + - id: panel-hit + type: rect + x: 24 + y: 24 + width: 540 + height: 250 + colorId: panelColor + click: + payload: + actions: + updateVariable: + id: markerbg + operations: + - variableId: blockedMarker + op: "=" + value: background + - id: marker-label + type: text + x: 40 + y: 58 + content: "MARKER: ${variables.blockedMarker}" + textStyleId: statusText + - id: choice-label-on + $when: isChoiceVisible + type: text + x: 40 + y: 106 + content: "CHOICE: ON" + textStyleId: statusText + - id: choice-label-off + $when: "!isChoiceVisible" + type: text + x: 40 + y: 106 + content: "CHOICE: OFF" + textStyleId: statusText + - id: helper-copy + type: text + x: 40 + y: 158 + width: 480 + content: This panel click should be ignored while the choice is visible, then work again after the choice advances. + textStyleId: helperText + layouts: + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 40 + y: 520 + width: 1200 + height: 150 + colorId: dialogueColor + - id: dialogue-text + type: text + x: 72 + y: 566 + width: 1140 + content: ${dialogue.content[0].text} + textStyleId: dialogueText + choiceLayout: + elements: + - id: choice-button + type: rect + x: 680 + y: 180 + width: 520 + height: 84 + colorId: choiceColor + click: + payload: + actions: + nextLine: {} + - id: choice-button-text + type: text + x: 722 + y: 208 + content: ${choice.items[0].content} + textStyleId: statusText + fonts: + fontDefault: + fileId: Arial + colors: + textColor: + hex: "#F3EEDD" + backgroundColor: + hex: "#151515" + dialogueColor: + hex: "#272727" + panelColor: + hex: "#1F3A3D" + choiceColor: + hex: "#7F5539" + textStyles: + statusText: + fontId: fontDefault + colorId: textColor + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + helperText: + fontId: fontDefault + colorId: textColor + fontSize: 22 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.35 + dialogueText: + fontId: fontDefault + colorId: textColor + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.25 +story: + initialSceneId: choiceBlocks + scenes: + choiceBlocks: + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + control: + resourceId: backgroundControl + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1: the choice is visible now, so the panel click must not dispatch its updateVariable action." + choice: + resourceId: choiceLayout + items: + - id: continue + content: Continue From b1a7e319e8cae546c7f79c130a13df0245f061fc Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 22:24:19 +0800 Subject: [PATCH 05/11] style: format choice visibility selector --- src/stores/system.store.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 0bf192ce..030a1b09 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1244,10 +1244,7 @@ const selectChoiceVisibilityState = ({ state }) => { let isChoiceVisible = false; for (const line of lines.slice(0, currentLineIndex + 1)) { const actions = line?.actions; - if ( - !actions || - !Object.prototype.hasOwnProperty.call(actions, "choice") - ) { + if (!actions || !Object.prototype.hasOwnProperty.call(actions, "choice")) { isChoiceVisible = false; continue; } From 72c0009f404b227c5ef06dcdec526225b29af03e Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 22:49:21 +0800 Subject: [PATCH 06/11] fix: restore choice interaction compatibility --- spec/RouteEngine.lineCompletion.test.js | 6 +- spec/system/actions/nextLine.spec.yaml | 15 ++-- spec/system/renderState/addChoices.spec.yaml | 80 +++++++++++++++++++ .../selectors/selectIsChoiceVisible.spec.yaml | 29 +++++++ src/stores/constructRenderState.js | 14 ++++ src/stores/system.store.js | 27 +++---- 6 files changed, 147 insertions(+), 24 deletions(-) diff --git a/spec/RouteEngine.lineCompletion.test.js b/spec/RouteEngine.lineCompletion.test.js index 53cae0a5..7e5dd635 100644 --- a/spec/RouteEngine.lineCompletion.test.js +++ b/spec/RouteEngine.lineCompletion.test.js @@ -480,7 +480,7 @@ describe("RouteEngine line completion flow", () => { }); }); - it("stops active playback on a choice line and still allows choice-origin nextLine", () => { + it("stops active playback on a choice line and still allows public nextLine", () => { const routeGraphics = { render: vi.fn(), }; @@ -531,9 +531,7 @@ describe("RouteEngine line completion flow", () => { engine.handleAction("markLineCompleted", {}); engine.handleActions({ - nextLine: { - _interactionSource: "choice", - }, + nextLine: {}, }); state = engine.selectSystemState(); diff --git a/spec/system/actions/nextLine.spec.yaml b/spec/system/actions/nextLine.spec.yaml index b304dca9..4309af6d 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -506,7 +506,7 @@ out: sectionId: "section1" lineId: "2" --- -case: do not complete or advance when a choice is visible from non-choice input +case: complete a visible choice line in place before it can advance in: - state: global: @@ -539,13 +539,17 @@ in: lineId: "1" out: global: - isLineCompleted: false + isLineCompleted: true nextLineConfig: manual: enabled: true - pendingEffects: [] + pendingEffects: + - name: "clearNextLineConfigTimer" + - name: "render" viewedRegistry: - sections: [] + sections: + - sectionId: "section1" + lastLineId: "1" projectData: story: scenes: @@ -567,7 +571,7 @@ out: sectionId: "section1" lineId: "1" --- -case: allow next line when the interaction comes from a choice button +case: allow next line on a completed choice line without requiring a choice tag in: - state: global: @@ -598,7 +602,6 @@ in: read: sectionId: "section1" lineId: "1" - - _interactionSource: "choice" out: global: isLineCompleted: false diff --git a/spec/system/renderState/addChoices.spec.yaml b/spec/system/renderState/addChoices.spec.yaml index 90a715d8..66dfcf0f 100644 --- a/spec/system/renderState/addChoices.spec.yaml +++ b/spec/system/renderState/addChoices.spec.yaml @@ -39,6 +39,16 @@ out: x: 0 y: 0 children: + - id: "choice-blocker" + type: "rect" + fill: "transparent" + width: 1920 + height: 1080 + x: 0 + y: 0 + click: + payload: + actions: {} - type: "button" id: "choice-button" text: "Select an option" @@ -85,6 +95,16 @@ out: x: 0 y: 0 children: + - id: "choice-blocker" + type: "rect" + fill: "transparent" + width: 1920 + height: 1080 + x: 0 + y: 0 + click: + payload: + actions: {} - id: "choice-copy" type: "text" content: "Choice shown" @@ -136,6 +156,16 @@ out: x: 0 y: 0 children: + - id: "choice-blocker" + type: "rect" + fill: "transparent" + width: 1920 + height: 1080 + x: 0 + y: 0 + click: + payload: + actions: {} - id: "choice-button" type: "button" text: "Go" @@ -187,6 +217,16 @@ out: x: 0 y: 0 children: + - id: "choice-blocker" + type: "rect" + fill: "transparent" + width: 1920 + height: 1080 + x: 0 + y: 0 + click: + payload: + actions: {} - type: "container" id: "choices" children: @@ -268,6 +308,16 @@ out: x: 0 y: 0 children: + - id: "choice-blocker" + type: "rect" + fill: "transparent" + width: 1920 + height: 1080 + x: 0 + y: 0 + click: + payload: + actions: {} - id: "choice-label" type: "text" content: "Select an option" @@ -315,6 +365,16 @@ out: x: 0 y: 0 children: + - id: "choice-blocker" + type: "rect" + fill: "transparent" + width: 1920 + height: 1080 + x: 0 + y: 0 + click: + payload: + actions: {} - id: "choice-panel" type: "rect" width: 500 @@ -357,6 +417,16 @@ out: x: 0 y: 0 children: + - id: "choice-blocker" + type: "rect" + fill: "transparent" + width: 1920 + height: 1080 + x: 0 + y: 0 + click: + payload: + actions: {} - id: "choice-image" type: "sprite" src: "choice-frame.png" @@ -483,6 +553,16 @@ out: x: 0 y: 0 children: + - id: "choice-blocker" + type: "rect" + fill: "transparent" + width: 1920 + height: 1080 + x: 0 + y: 0 + click: + payload: + actions: {} - type: "text" content: "No choices available" animations: [] diff --git a/spec/system/selectors/selectIsChoiceVisible.spec.yaml b/spec/system/selectors/selectIsChoiceVisible.spec.yaml index c00483f9..c07149dd 100644 --- a/spec/system/selectors/selectIsChoiceVisible.spec.yaml +++ b/spec/system/selectors/selectIsChoiceVisible.spec.yaml @@ -129,3 +129,32 @@ in: sectionId: "section1" lineId: "line2" out: true +--- +case: false when cleanAll clears a previous choice before choice animations +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "line2" + actions: + cleanAll: true + choice: + animations: + resourceId: "fade" + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line2" +out: false diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 5ec38047..a36f450e 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -1865,6 +1865,7 @@ export const addChoices = ( presentationState, previousPresentationState, resources, + screen, isLineCompleted, skipTransitionsAndAnimations, variables, @@ -1915,6 +1916,19 @@ export const addChoices = ( ), ); + const hasChoiceElements = Array.isArray(choiceElements) + ? choiceElements.length > 0 + : !!choiceElements; + + if (hasChoiceElements) { + storyContainer.children.push( + createFullscreenClickBlocker({ + id: "choice-blocker", + screen, + }), + ); + } + if (Array.isArray(choiceElements)) { for (const element of choiceElements) { storyContainer.children.push(element); diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 030a1b09..6e98179d 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1225,10 +1225,10 @@ export const selectNextLineConfig = ({ state }) => { return state.global.nextLineConfig; }; -const selectChoiceVisibilityState = ({ state }) => { +const selectVisibleChoiceResourceId = ({ state }) => { const pointer = selectCurrentPointer({ state })?.pointer; if (!pointer) { - return false; + return undefined; } const sectionId = pointer?.sectionId; @@ -1238,35 +1238,39 @@ const selectChoiceVisibilityState = ({ state }) => { const currentLineIndex = lines.findIndex((line) => line.id === lineId); if (currentLineIndex < 0) { - return false; + return undefined; } - let isChoiceVisible = false; + let visibleChoiceResourceId; for (const line of lines.slice(0, currentLineIndex + 1)) { const actions = line?.actions; + if (actions?.cleanAll) { + visibleChoiceResourceId = undefined; + } + if (!actions || !Object.prototype.hasOwnProperty.call(actions, "choice")) { - isChoiceVisible = false; + visibleChoiceResourceId = undefined; continue; } const choice = actions.choice; if (choice?.resourceId) { - isChoiceVisible = true; + visibleChoiceResourceId = choice.resourceId; continue; } // `choice: { animations: ... }` should preserve the previous choice state, // while `choice: {}` explicitly clears it. if (!choice?.animations) { - isChoiceVisible = false; + visibleChoiceResourceId = undefined; } } - return isChoiceVisible; + return visibleChoiceResourceId; }; export const selectIsChoiceVisible = ({ state }) => { - return selectChoiceVisibilityState({ state }); + return !!selectVisibleChoiceResourceId({ state }); }; export const selectSystemState = ({ state }) => { @@ -2222,11 +2226,6 @@ export const nextLine = ({ state }, payload) => { return state; } - const isChoiceInteraction = payload?._interactionSource === "choice"; - if (selectIsChoiceVisible({ state }) && !isChoiceInteraction) { - return state; - } - // If line is not completed, complete it instantly instead of advancing if (!state.global.isLineCompleted) { state.global.isLineCompleted = true; From d95bd053b78c134ed81e15512de8aa5373867c16 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 23:07:56 +0800 Subject: [PATCH 07/11] refactor: move choice playback stop to line entry --- ...forceChoiceVisibilityConstraints.spec.yaml | 130 ------------------ spec/system/actions/jumpToLine.spec.yaml | 75 +++++++++- spec/system/actions/nextLine.spec.yaml | 87 ++++++++++++ .../actions/nextLineFromSystem.spec.yaml | 88 ++++++++++++ .../actions/sectionTransition.spec.yaml | 97 +++++++++++++ src/RouteEngine.js | 1 - src/stores/system.store.js | 70 ++++------ 7 files changed, 373 insertions(+), 175 deletions(-) delete mode 100644 spec/system/actions/enforceChoiceVisibilityConstraints.spec.yaml diff --git a/spec/system/actions/enforceChoiceVisibilityConstraints.spec.yaml b/spec/system/actions/enforceChoiceVisibilityConstraints.spec.yaml deleted file mode 100644 index 78d826da..00000000 --- a/spec/system/actions/enforceChoiceVisibilityConstraints.spec.yaml +++ /dev/null @@ -1,130 +0,0 @@ -file: "../../../src/stores/system.store.js" -group: systemStore.enforceChoiceVisibilityConstraints -suites: [enforceChoiceVisibilityConstraints] ---- -suite: enforceChoiceVisibilityConstraints -exportName: enforceChoiceVisibilityConstraints ---- -case: stops active playback and clears timers when a choice is visible -in: - - state: - global: - autoMode: true - skipMode: true - nextLineConfig: - manual: - enabled: true - auto: - enabled: true - trigger: "fromStart" - delay: 1500 - pendingEffects: [] - projectData: - story: - scenes: - scene1: - sections: - section1: - lines: - - id: "1" - actions: - choice: - resourceId: "choice-ui" - items: [] - contexts: - - currentPointerMode: "read" - pointers: - read: - sectionId: "section1" - lineId: "1" -out: - global: - autoMode: false - skipMode: false - nextLineConfig: - manual: - enabled: true - auto: - enabled: true - trigger: "fromStart" - delay: 1500 - pendingEffects: - - name: "clearAutoNextTimer" - - name: "clearSkipNextTimer" - - name: "clearNextLineConfigTimer" - - name: "render" - projectData: - story: - scenes: - scene1: - sections: - section1: - lines: - - id: "1" - actions: - choice: - resourceId: "choice-ui" - items: [] - contexts: - - currentPointerMode: "read" - pointers: - read: - sectionId: "section1" - lineId: "1" ---- -case: no-op when no choice is visible -in: - - state: - global: - autoMode: true - skipMode: false - nextLineConfig: - manual: - enabled: true - auto: - enabled: true - trigger: "fromStart" - delay: 1500 - pendingEffects: [] - projectData: - story: - scenes: - scene1: - sections: - section1: - lines: - - id: "1" - actions: {} - contexts: - - currentPointerMode: "read" - pointers: - read: - sectionId: "section1" - lineId: "1" -out: - global: - autoMode: true - skipMode: false - nextLineConfig: - manual: - enabled: true - auto: - enabled: true - trigger: "fromStart" - delay: 1500 - pendingEffects: [] - projectData: - story: - scenes: - scene1: - sections: - section1: - lines: - - id: "1" - actions: {} - contexts: - - currentPointerMode: "read" - pointers: - read: - sectionId: "section1" - lineId: "1" diff --git a/spec/system/actions/jumpToLine.spec.yaml b/spec/system/actions/jumpToLine.spec.yaml index e978f880..c3c16824 100644 --- a/spec/system/actions/jumpToLine.spec.yaml +++ b/spec/system/actions/jumpToLine.spec.yaml @@ -110,6 +110,79 @@ out: pendingEffects: - name: handleLineActions --- +case: jump to a choice line stops active playback and clears timers +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: line1 + text: "First line" + - id: line2 + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - pointers: + read: + sectionId: section1 + lineId: line1 + global: + isLineCompleted: true + autoMode: true + skipMode: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: [] + - + lineId: line2 +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: line1 + text: "First line" + - id: line2 + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - pointers: + read: + sectionId: section1 + lineId: line2 + global: + isLineCompleted: false + autoMode: false + skipMode: false + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: + - name: clearAutoNextTimer + - name: clearSkipNextTimer + - name: clearNextLineConfigTimer + - name: handleLineActions +--- case: return unchanged state when lineId is missing in: - state: @@ -319,4 +392,4 @@ out: pendingEffects: - name: "existingEffect" data: "test" - - name: handleLineActions \ No newline at end of file + - name: handleLineActions diff --git a/spec/system/actions/nextLine.spec.yaml b/spec/system/actions/nextLine.spec.yaml index 4309af6d..584c57cd 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -278,6 +278,93 @@ out: lineId: "2" rollbackPolicy: "free" --- +case: move to a choice line stops auto playback and clears the scene timer +in: + - state: + global: + isLineCompleted: true + autoMode: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 900 + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: {} + - id: "2" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + global: + isLineCompleted: false + autoMode: false + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 900 + pendingEffects: + - name: "clearAutoNextTimer" + - name: "clearNextLineConfigTimer" + - name: "handleLineActions" + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: {} + - id: "2" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + 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: - state: diff --git a/spec/system/actions/nextLineFromSystem.spec.yaml b/spec/system/actions/nextLineFromSystem.spec.yaml index f87b8b71..9aedc90f 100644 --- a/spec/system/actions/nextLineFromSystem.spec.yaml +++ b/spec/system/actions/nextLineFromSystem.spec.yaml @@ -190,6 +190,94 @@ out: lineId: "2" rollbackPolicy: "free" --- +case: advance to a choice line stops active playback and clears timers +in: + - state: + global: + isLineCompleted: true + autoMode: true + skipMode: true + variables: + _skipUnseenText: true + viewedRegistry: + sections: [] + nextLineConfig: + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + - id: "2" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + global: + isLineCompleted: false + autoMode: false + skipMode: false + variables: + _skipUnseenText: true + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" + nextLineConfig: + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: + - name: "clearAutoNextTimer" + - name: "clearSkipNextTimer" + - name: "clearNextLineConfigTimer" + - name: "handleLineActions" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + - id: "2" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + 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: - state: diff --git a/spec/system/actions/sectionTransition.spec.yaml b/spec/system/actions/sectionTransition.spec.yaml index 899dce0e..b704bd79 100644 --- a/spec/system/actions/sectionTransition.spec.yaml +++ b/spec/system/actions/sectionTransition.spec.yaml @@ -81,6 +81,103 @@ out: lineId: "10" rollbackPolicy: "free" --- +case: transition to a choice line stops playback and clears the scene timer +in: + - state: + global: + isLineCompleted: true + autoMode: true + skipMode: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + isDialogueUIVisible: true + pendingEffects: [] + projectData: + story: + initialSceneId: "scene1" + scenes: + scene1: + initialSectionId: "section1" + sections: + section1: + lines: + - id: "1" + actions: {} + section2: + lines: + - id: "10" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + - sectionId: "section2" +out: + global: + isLineCompleted: false + autoMode: false + skipMode: false + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + isDialogueUIVisible: true + pendingEffects: + - name: "clearAutoNextTimer" + - name: "render" + - name: "clearSkipNextTimer" + - name: "render" + - name: "clearNextLineConfigTimer" + - name: "handleLineActions" + projectData: + story: + initialSceneId: "scene1" + scenes: + scene1: + initialSectionId: "section1" + sections: + section1: + lines: + - id: "1" + actions: {} + section2: + lines: + - id: "10" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + 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: - state: diff --git a/src/RouteEngine.js b/src/RouteEngine.js index b0e8715a..7bcb4824 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -135,7 +135,6 @@ export default function createRouteEngine(options) { const line = _systemStore.selectCurrentLine(); if (line?.actions) { handleActions(line.actions); - _systemStore.enforceChoiceVisibilityConstraints({}); return true; } diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 6e98179d..be4ed3f2 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1225,8 +1225,8 @@ export const selectNextLineConfig = ({ state }) => { return state.global.nextLineConfig; }; -const selectVisibleChoiceResourceId = ({ state }) => { - const pointer = selectCurrentPointer({ state })?.pointer; +const selectVisibleChoiceResourceId = ({ state, pointer: targetPointer } = {}) => { + const pointer = targetPointer ?? selectCurrentPointer({ state })?.pointer; if (!pointer) { return undefined; } @@ -1652,19 +1652,12 @@ export const clearLayeredViews = ({ state }) => { return state; }; -export const enforceChoiceVisibilityConstraints = ({ state }) => { - if (!selectIsChoiceVisible({ state })) { - return state; - } - - let needsRender = false; - +const stopPlaybackForEnteredChoiceLine = (state) => { if (state.global.autoMode) { state.global.autoMode = false; state.global.pendingEffects.push({ name: "clearAutoNextTimer", }); - needsRender = true; } if (state.global.skipMode) { @@ -1672,7 +1665,6 @@ export const enforceChoiceVisibilityConstraints = ({ state }) => { state.global.pendingEffects.push({ name: "clearSkipNextTimer", }); - needsRender = true; } if (state.global.nextLineConfig?.auto?.enabled) { @@ -1680,14 +1672,23 @@ export const enforceChoiceVisibilityConstraints = ({ state }) => { name: "clearNextLineConfigTimer", }); } +}; - if (needsRender) { - state.global.pendingEffects.push({ - name: "render", - }); +const queueEnteredLineEffects = (state, pointer) => { + state.global.isLineCompleted = false; + + const isChoiceVisible = !!selectVisibleChoiceResourceId({ state, pointer }); + if (isChoiceVisible) { + stopPlaybackForEnteredChoiceLine(state); } - return state; + state.global.pendingEffects.push({ + name: "handleLineActions", + }); + + return { + isChoiceVisible, + }; }; export const startAutoMode = ({ state }) => { @@ -2203,13 +2204,7 @@ export const jumpToLine = ({ state }, payload) => { setActiveRollbackBatchCheckpoint(lastContext.rollback.currentIndex); } - // Reset line completion state - state.global.isLineCompleted = false; - - // Add appropriate pending effects - state.global.pendingEffects.push({ - name: "handleLineActions", - }); + queueEnteredLineEffects(state, lastContext.pointers.read); return state; }; @@ -2313,21 +2308,19 @@ export const nextLine = ({ state }, payload) => { }; } - state.global.isLineCompleted = false; - appendRollbackCheckpoint(state, { sectionId, lineId: nextLine.id, }); resetNextLineConfigIfSingleLine(state); - - state.global.pendingEffects.push({ - name: "handleLineActions", + const { isChoiceVisible } = queueEnteredLineEffects(state, { + sectionId, + lineId: nextLine.id, }); // Keep scene auto mode running after manual advances (e.g. choice click -> nextLine). const nextLineConfig = state.global.nextLineConfig; - if (nextLineConfig?.auto?.enabled) { + if (nextLineConfig?.auto?.enabled && !isChoiceVisible) { const trigger = nextLineConfig.auto.trigger; if (trigger === "fromStart") { state.global.pendingEffects.push({ @@ -2550,13 +2543,7 @@ export const sectionTransition = ({ state }, payload) => { } } - // Reset line completion state - state.global.isLineCompleted = false; - - // Add appropriate pending effects - state.global.pendingEffects.push({ - name: "handleLineActions", - }); + queueEnteredLineEffects(state, lastContext?.pointers?.read); return state; }; @@ -2620,21 +2607,19 @@ export const nextLineFromSystem = ({ state }) => { }; } - state.global.isLineCompleted = false; - appendRollbackCheckpoint(state, { sectionId, lineId: nextLine.id, }); resetNextLineConfigIfSingleLine(state); - - state.global.pendingEffects.push({ - name: "handleLineActions", + const { isChoiceVisible } = queueEnteredLineEffects(state, { + sectionId, + lineId: nextLine.id, }); // Only start timer immediately if trigger is "fromStart" // For "fromComplete" trigger, markLineCompleted will start it when renderComplete fires - if (state.global.nextLineConfig.auto?.enabled) { + if (state.global.nextLineConfig.auto?.enabled && !isChoiceVisible) { const trigger = state.global.nextLineConfig.auto.trigger; if (trigger === "fromStart") { state.global.pendingEffects.push({ @@ -2954,7 +2939,6 @@ export const createSystemStore = (initialState) => { popLayeredView, replaceLastLayeredView, clearLayeredViews, - enforceChoiceVisibilityConstraints, updateVariable, nextLineFromSystem, }; From 93d7597dbf7723576a864229a375391102b3f085 Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 23:08:21 +0800 Subject: [PATCH 08/11] style: format line entry choice helper --- src/stores/system.store.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/stores/system.store.js b/src/stores/system.store.js index be4ed3f2..f3f78209 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1225,7 +1225,10 @@ export const selectNextLineConfig = ({ state }) => { return state.global.nextLineConfig; }; -const selectVisibleChoiceResourceId = ({ state, pointer: targetPointer } = {}) => { +const selectVisibleChoiceResourceId = ({ + state, + pointer: targetPointer, +} = {}) => { const pointer = targetPointer ?? selectCurrentPointer({ state })?.pointer; if (!pointer) { return undefined; From 818180454969a4092baa3af11ca72b3c37836eeb Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 23:14:39 +0800 Subject: [PATCH 09/11] refactor: simplify choice interaction tagging --- spec/system/renderState/addChoices.spec.yaml | 3 +- src/stores/constructRenderState.js | 31 ++++---------------- 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/spec/system/renderState/addChoices.spec.yaml b/spec/system/renderState/addChoices.spec.yaml index 66dfcf0f..80e09b8f 100644 --- a/spec/system/renderState/addChoices.spec.yaml +++ b/spec/system/renderState/addChoices.spec.yaml @@ -115,8 +115,7 @@ out: payload: _interactionSource: "choice" actions: - nextLine: - _interactionSource: "choice" + nextLine: {} animations: [] --- case: tags non-nextLine choice clicks at the payload level diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index a36f450e..d0d968f0 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -960,18 +960,17 @@ const createFullscreenClickBlocker = ({ const tagChoiceInteractionSource = (node) => { if (Array.isArray(node)) { - return node.map((item) => tagChoiceInteractionSource(item)); + return node.map(tagChoiceInteractionSource); } if (!node || typeof node !== "object") { return node; } - const taggedNode = { ...node }; - - Object.keys(taggedNode).forEach((key) => { - taggedNode[key] = tagChoiceInteractionSource(taggedNode[key]); - }); + const taggedNode = {}; + for (const [key, value] of Object.entries(node)) { + taggedNode[key] = tagChoiceInteractionSource(value); + } const clickPayload = taggedNode.click?.payload; if ( @@ -982,18 +981,6 @@ const tagChoiceInteractionSource = (node) => { return taggedNode; } - const actions = clickPayload.actions; - const nextLineAction = - actions && - typeof actions === "object" && - !Array.isArray(actions) && - Object.prototype.hasOwnProperty.call(actions, "nextLine") - ? { - ...actions.nextLine, - _interactionSource: "choice", - } - : undefined; - return { ...taggedNode, click: { @@ -1001,14 +988,6 @@ const tagChoiceInteractionSource = (node) => { payload: { ...clickPayload, _interactionSource: "choice", - ...(nextLineAction - ? { - actions: { - ...actions, - nextLine: nextLineAction, - }, - } - : {}), }, }, }; From f11b731208635ad7045753b3c81e3b3abfb70b7a Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 23:57:26 +0800 Subject: [PATCH 10/11] fix: guard choice progression without overlay --- spec/RouteEngine.lineCompletion.test.js | 6 +- ...EffectsHandler.routeGraphicsEvents.test.js | 7 +- spec/system/actions/nextLine.spec.yaml | 73 ++++++++++++++-- spec/system/renderState/addChoices.spec.yaml | 85 +------------------ src/stores/constructRenderState.js | 33 ++++--- src/stores/system.store.js | 7 ++ .../choice/blocked-non-choice-actions.yaml | 6 +- vt/specs/choice/interaction-guards.yaml | 30 +++---- vt/specs/choice/skip-stops-on-choice.yaml | 8 +- .../nextLineConfig/choice-stops-auto.yaml | 2 +- 10 files changed, 127 insertions(+), 130 deletions(-) diff --git a/spec/RouteEngine.lineCompletion.test.js b/spec/RouteEngine.lineCompletion.test.js index 7e5dd635..8fb223a7 100644 --- a/spec/RouteEngine.lineCompletion.test.js +++ b/spec/RouteEngine.lineCompletion.test.js @@ -480,7 +480,7 @@ describe("RouteEngine line completion flow", () => { }); }); - it("stops active playback on a choice line and still allows public nextLine", () => { + it("stops active playback on a choice line and only allows choice-tagged nextLine", () => { const routeGraphics = { render: vi.fn(), }; @@ -531,7 +531,9 @@ describe("RouteEngine line completion flow", () => { engine.handleAction("markLineCompleted", {}); engine.handleActions({ - nextLine: {}, + nextLine: { + _interactionSource: "choice", + }, }); state = engine.selectSystemState(); diff --git a/spec/createEffectsHandler.routeGraphicsEvents.test.js b/spec/createEffectsHandler.routeGraphicsEvents.test.js index abb8447c..65867db5 100644 --- a/spec/createEffectsHandler.routeGraphicsEvents.test.js +++ b/spec/createEffectsHandler.routeGraphicsEvents.test.js @@ -206,14 +206,15 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { operations: [ { variableId: "marker", - op: "=", - value: "_event", + op: "set", + value: "blocked", }, ], }, }, _event: { - view: globalThis, + x: 10, + y: 20, }, }; diff --git a/spec/system/actions/nextLine.spec.yaml b/spec/system/actions/nextLine.spec.yaml index 584c57cd..95558d3b 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -624,19 +624,77 @@ in: read: sectionId: "section1" lineId: "1" +out: + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +--- +case: do not advance an untagged next line on a completed choice line +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + - {} out: global: isLineCompleted: true nextLineConfig: manual: enabled: true - pendingEffects: - - name: "clearNextLineConfigTimer" - - name: "render" + pendingEffects: [] viewedRegistry: - sections: - - sectionId: "section1" - lastLineId: "1" + sections: [] projectData: story: scenes: @@ -658,7 +716,7 @@ out: sectionId: "section1" lineId: "1" --- -case: allow next line on a completed choice line without requiring a choice tag +case: allow tagged next line on a completed choice line in: - state: global: @@ -689,6 +747,7 @@ in: read: sectionId: "section1" lineId: "1" + - _interactionSource: "choice" out: global: isLineCompleted: false diff --git a/spec/system/renderState/addChoices.spec.yaml b/spec/system/renderState/addChoices.spec.yaml index 80e09b8f..c349c198 100644 --- a/spec/system/renderState/addChoices.spec.yaml +++ b/spec/system/renderState/addChoices.spec.yaml @@ -39,22 +39,12 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - type: "button" id: "choice-button" text: "Select an option" animations: [] --- -case: tags nextLine actions from choice buttons and exposes choice visibility template data +case: tags choice click payloads and exposes choice visibility template data in: - elements: - id: "story" @@ -95,16 +85,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-copy" type: "text" content: "Choice shown" @@ -115,7 +95,8 @@ out: payload: _interactionSource: "choice" actions: - nextLine: {} + nextLine: + _interactionSource: "choice" animations: [] --- case: tags non-nextLine choice clicks at the payload level @@ -155,16 +136,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-button" type: "button" text: "Go" @@ -216,16 +187,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - type: "container" id: "choices" children: @@ -307,16 +268,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-label" type: "text" content: "Select an option" @@ -364,16 +315,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-panel" type: "rect" width: 500 @@ -416,16 +357,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-image" type: "sprite" src: "choice-frame.png" @@ -552,16 +483,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - type: "text" content: "No choices available" animations: [] diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index d0d968f0..cd66212d 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -981,6 +981,18 @@ const tagChoiceInteractionSource = (node) => { return taggedNode; } + const actions = clickPayload.actions; + const nextLineAction = + actions && + typeof actions === "object" && + !Array.isArray(actions) && + Object.prototype.hasOwnProperty.call(actions, "nextLine") + ? { + ...actions.nextLine, + _interactionSource: "choice", + } + : undefined; + return { ...taggedNode, click: { @@ -988,6 +1000,14 @@ const tagChoiceInteractionSource = (node) => { payload: { ...clickPayload, _interactionSource: "choice", + ...(nextLineAction + ? { + actions: { + ...actions, + nextLine: nextLineAction, + }, + } + : {}), }, }, }; @@ -1895,19 +1915,6 @@ export const addChoices = ( ), ); - const hasChoiceElements = Array.isArray(choiceElements) - ? choiceElements.length > 0 - : !!choiceElements; - - if (hasChoiceElements) { - storyContainer.children.push( - createFullscreenClickBlocker({ - id: "choice-blocker", - screen, - }), - ); - } - if (Array.isArray(choiceElements)) { for (const element of choiceElements) { storyContainer.children.push(element); diff --git a/src/stores/system.store.js b/src/stores/system.store.js index f3f78209..ed5e23cb 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -2224,6 +2224,13 @@ export const nextLine = ({ state }, payload) => { return state; } + if ( + selectIsChoiceVisible({ state }) && + payload?._interactionSource !== "choice" + ) { + return state; + } + // If line is not completed, complete it instantly instead of advancing if (!state.global.isLineCompleted) { state.global.isLineCompleted = true; diff --git a/vt/specs/choice/blocked-non-choice-actions.yaml b/vt/specs/choice/blocked-non-choice-actions.yaml index 92221419..e3420ea1 100644 --- a/vt/specs/choice/blocked-non-choice-actions.yaml +++ b/vt/specs/choice/blocked-non-choice-actions.yaml @@ -13,8 +13,8 @@ steps: ms: 200 - action: screenshot - action: click - x: 180 - y: 210 + x: 410 + y: 285 - action: wait ms: 150 - action: screenshot @@ -46,7 +46,7 @@ resources: id: markerbg operations: - variableId: blockedMarker - op: "=" + op: set value: background - id: marker-label type: text diff --git a/vt/specs/choice/interaction-guards.yaml b/vt/specs/choice/interaction-guards.yaml index 3698c93d..3a057b96 100644 --- a/vt/specs/choice/interaction-guards.yaml +++ b/vt/specs/choice/interaction-guards.yaml @@ -1,6 +1,6 @@ --- title: Choice Interaction Guards -description: Choice visibility stops active playback, blocks background/control advance, and still allows choice-origin nextLine. +description: Choice visibility stops active playback, blocks nextLine and auto/skip toggles, and still allows the choice to advance. specs: - auto mode is visibly on before the choice line is entered - entering the choice line forces auto mode off and exposes the choice state @@ -17,38 +17,38 @@ steps: ms: 200 - action: screenshot - action: click - x: 150 - y: 400 + x: 395 + y: 380 - action: wait ms: 150 - action: screenshot - action: click - x: 150 - y: 74 + x: 395 + y: 217 - action: wait ms: 120 - action: screenshot - action: click - x: 150 - y: 160 + x: 395 + y: 260 - action: wait ms: 120 - action: screenshot - action: click - x: 150 - y: 400 + x: 395 + y: 380 - action: wait ms: 120 - action: screenshot - action: click - x: 940 - y: 222 + x: 790 + y: 291 - action: wait ms: 150 - action: screenshot - action: click - x: 150 - y: 400 + x: 395 + y: 380 - action: wait ms: 150 - action: screenshot @@ -158,7 +158,7 @@ resources: x: 40 y: 300 width: 520 - content: Background click advances when allowed. Auto and skip buttons must do nothing while a choice is visible. + content: Background nextLine advances when allowed. Auto and skip toggles must do nothing while a choice is visible. textStyleId: helperText layouts: dialogueLayout: @@ -256,7 +256,7 @@ story: actions: dialogue: content: - - text: "Line 2: choice is visible. Background advance is blocked, auto and skip cannot be turned on, and the choice button should still continue." + - text: "Line 2: choice is visible. Background nextLine is blocked, auto and skip cannot be turned on, and the choice button should still continue." choice: resourceId: choiceLayout items: diff --git a/vt/specs/choice/skip-stops-on-choice.yaml b/vt/specs/choice/skip-stops-on-choice.yaml index 2fb0087c..af5980ac 100644 --- a/vt/specs/choice/skip-stops-on-choice.yaml +++ b/vt/specs/choice/skip-stops-on-choice.yaml @@ -15,8 +15,8 @@ steps: - action: wait ms: 200 - action: click - x: 150 - y: 74 + x: 395 + y: 217 - action: wait ms: 40 - action: screenshot @@ -27,8 +27,8 @@ steps: ms: 220 - action: screenshot - action: click - x: 940 - y: 222 + x: 790 + y: 291 - action: wait ms: 150 - action: screenshot diff --git a/vt/specs/nextLineConfig/choice-stops-auto.yaml b/vt/specs/nextLineConfig/choice-stops-auto.yaml index 3040137b..4e4ba851 100644 --- a/vt/specs/nextLineConfig/choice-stops-auto.yaml +++ b/vt/specs/nextLineConfig/choice-stops-auto.yaml @@ -19,7 +19,7 @@ steps: - action: screenshot - action: click x: 960 - y: 260 + y: 400 - action: wait ms: 150 - action: screenshot From 66b994374eb0f2ba529305519cd701fcd727886d Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 4 Apr 2026 23:59:12 +0800 Subject: [PATCH 11/11] chore: bump version to 0.7.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e70c6d9a..9ea15d26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "0.7.3", + "version": "0.7.4", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git",