From 6a0714d5268c9cec55ff949e4b15a8832689cfca Mon Sep 17 00:00:00 2001 From: han4wluc Date: Fri, 27 Mar 2026 11:13:50 +0800 Subject: [PATCH] fix seen line frontier tracking --- docs/Concepts.md | 15 ++++++ docs/RouteEngine.md | 14 ++++++ package.json | 2 +- .../actions/markLineCompleted.spec.yaml | 18 +++++++ spec/system/actions/nextLine.spec.yaml | 18 +++++-- .../actions/nextLineFromSystem.spec.yaml | 24 ++++++++++ .../selectors/selectIsLineViewed.spec.yaml | 6 +-- src/createEffectsHandler.js | 5 +- src/stores/system.store.js | 48 +++++++++++++++---- 9 files changed, 129 insertions(+), 21 deletions(-) diff --git a/docs/Concepts.md b/docs/Concepts.md index 7badbc66..35e234af 100644 --- a/docs/Concepts.md +++ b/docs/Concepts.md @@ -226,6 +226,13 @@ pointers: { - `trigger`: When to advance (`'fromStart'` or `'fromComplete'`) - `delay`: Milliseconds to wait before advancing +Global playback modes use a different timing model: + +- Global `autoMode` starts its delay after the current line is completed. +- In practice, completion is driven by Route Graphics `renderComplete`, so text reveal and other tracked render work finish first. +- Global `skipMode` does not wait for completion; it advances aggressively on its own short timer. +- `nextLineConfig.auto` is the only built-in auto-like behavior that can intentionally start from line start via `trigger: "fromStart"`. + ## Dialogue Modes ### ADV Mode (Adventure) @@ -303,6 +310,14 @@ Tracks content the player has seen: - **sections**: Array of `{ sectionId, lastLineId }` entries - **resources**: Array of `{ resourceId }` entries +For lines, this is intentionally a section-level frontier model: + +- `lastLineId` means the furthest seen line reached within that section. +- Any line at or before that frontier is treated as seen. +- This assumes section flow is effectively linear, which matches the engine's current use of seen-lines for skip behavior and progress tracking. + +The frontier is updated when the current line is completed and also when advancing away from the current line. That keeps the final completed line in a section marked as seen even if there is no later line to move to. + Used for: - Skip mode (skip only viewed content) diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index d1da3f71..33661159 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -366,6 +366,13 @@ const presentationState = engine.selectPresentationState(); | `stopSkipMode` | - | Disable skip mode | | `toggleSkipMode` | - | Toggle skip mode | +Playback timing semantics: + +- Global `autoMode` waits for the current line to complete before starting its `_autoForwardTime` delay. +- That completion is driven by Route Graphics `renderComplete`, so revealing text and other tracked render work finish first. +- Global `skipMode` does not use that completion gate; it advances on its own fast timer. +- `nextLineConfig.auto` is separate and may use `trigger: "fromStart"` or `trigger: "fromComplete"` depending on authored behavior. + ### UI Actions | Action | Payload | Description | @@ -390,6 +397,13 @@ const presentationState = engine.selectPresentationState(); | `addViewedResource` | `{ resourceId }` | Mark resource as viewed | | `addToHistory` | `{ item }` | Add entry to history sequence | +Seen-line semantics: + +- The engine stores seen progress per section as a single frontier: `{ sectionId, lastLineId }`. +- The frontier line itself counts as seen. +- Any earlier line in the same section also counts as seen. +- The frontier is updated when a line is completed and when progression moves away from the current line. + ### Save System Actions | Action | Payload | Description | diff --git a/package.json b/package.json index 8b603c14..ee6e121e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "0.3.7", + "version": "0.3.8", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", diff --git a/spec/system/actions/markLineCompleted.spec.yaml b/spec/system/actions/markLineCompleted.spec.yaml index df66b646..c0733a21 100644 --- a/spec/system/actions/markLineCompleted.spec.yaml +++ b/spec/system/actions/markLineCompleted.spec.yaml @@ -8,12 +8,30 @@ exportName: markLineCompleted case: mark line as completed in: - state: + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" global: isLineCompleted: false + viewedRegistry: + sections: [] pendingEffects: [] out: + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" global: isLineCompleted: true + 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 fa9777fd..3298f047 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -64,6 +64,8 @@ in: - state: global: isLineCompleted: false + viewedRegistry: + sections: [] nextLineConfig: manual: enabled: true @@ -88,6 +90,10 @@ in: out: global: isLineCompleted: true + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" nextLineConfig: manual: enabled: true @@ -150,7 +156,6 @@ out: manual: enabled: true pendingEffects: - - name: "render" - name: "handleLineActions" viewedRegistry: sections: @@ -223,7 +228,6 @@ out: delay: 900 applyMode: "persistent" pendingEffects: - - name: "render" - name: "handleLineActions" - name: "nextLineConfigTimer" payload: @@ -296,7 +300,6 @@ out: enabled: false applyMode: "persistent" pendingEffects: - - name: "render" - name: "clearNextLineConfigTimer" - name: "handleLineActions" viewedRegistry: @@ -649,7 +652,6 @@ out: manual: enabled: true pendingEffects: - - name: "render" - name: "handleLineActions" viewedRegistry: sections: @@ -723,6 +725,10 @@ out: payload: delay: 2000 - name: "render" + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" projectData: story: scenes: @@ -786,6 +792,10 @@ out: pendingEffects: - name: "clearNextLineConfigTimer" - name: "render" + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" projectData: story: scenes: diff --git a/spec/system/actions/nextLineFromSystem.spec.yaml b/spec/system/actions/nextLineFromSystem.spec.yaml index 1b6d8aea..9016d43e 100644 --- a/spec/system/actions/nextLineFromSystem.spec.yaml +++ b/spec/system/actions/nextLineFromSystem.spec.yaml @@ -58,6 +58,8 @@ in: - state: global: isLineCompleted: true + viewedRegistry: + sections: [] nextLineConfig: auto: { enabled: false } pendingEffects: [] @@ -79,6 +81,10 @@ in: out: global: isLineCompleted: false + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" nextLineConfig: auto: { enabled: false } pendingEffects: @@ -104,6 +110,8 @@ in: - state: global: isLineCompleted: true + viewedRegistry: + sections: [] nextLineConfig: auto: enabled: true @@ -129,6 +137,10 @@ in: out: global: isLineCompleted: false + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" nextLineConfig: auto: enabled: true @@ -161,6 +173,8 @@ in: - state: global: isLineCompleted: true + viewedRegistry: + sections: [] nextLineConfig: auto: enabled: true @@ -187,6 +201,10 @@ in: out: global: isLineCompleted: false + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" nextLineConfig: manual: enabled: true @@ -225,6 +243,8 @@ in: trigger: "fromComplete" delay: 2000 pendingEffects: [] + viewedRegistry: + sections: [] projectData: story: scenes: @@ -251,6 +271,10 @@ out: delay: 2000 pendingEffects: - name: "handleLineActions" + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" projectData: story: scenes: diff --git a/spec/system/selectors/selectIsLineViewed.spec.yaml b/spec/system/selectors/selectIsLineViewed.spec.yaml index 1941f7c8..355410d6 100644 --- a/spec/system/selectors/selectIsLineViewed.spec.yaml +++ b/spec/system/selectors/selectIsLineViewed.spec.yaml @@ -62,7 +62,7 @@ in: lastLineId: "10" - sectionId: "abc" lineId: "10" -out: false +out: true --- case: lineId before lastLineId (numeric comparison) in: @@ -142,7 +142,7 @@ in: actions: {} - sectionId: "intro" lineId: "3" -out: false +out: true --- case: lineId after lastLineId with projectData in: @@ -297,4 +297,4 @@ in: lastLineId: undefined - sectionId: "notfound" lineId: "5" -out: false \ No newline at end of file +out: false diff --git a/src/createEffectsHandler.js b/src/createEffectsHandler.js index 13f5242f..f617f250 100644 --- a/src/createEffectsHandler.js +++ b/src/createEffectsHandler.js @@ -32,10 +32,7 @@ const handleLineActions = ( const handledLineActions = engine.handleLineActions(); const renderDispatchCountAfter = getRenderDispatchCount?.() ?? 0; - if ( - renderDispatchCountAfter === renderDispatchCountBefore && - (!handledLineActions || renderDispatchCountBefore === 0) - ) { + if (renderDispatchCountAfter === renderDispatchCountBefore) { render({ engine, routeGraphics, trackRenderDispatch }, payload); } }; diff --git a/src/stores/system.store.js b/src/stores/system.store.js index ce824626..79a04970 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -146,7 +146,8 @@ export const selectDialogueUIHidden = ({ state }) => { }; export const selectDialogueHistory = ({ state }) => { - const lastContext = state.contexts[state.contexts.length - 1]; + const contexts = Array.isArray(state.contexts) ? state.contexts : []; + const lastContext = contexts[contexts.length - 1]; if (!lastContext) { return []; } @@ -214,8 +215,8 @@ export const selectIsLineViewed = ({ state }, payload) => { !foundSection.lines || !Array.isArray(foundSection.lines) ) { - // If we can't find the section or lines, fallback to original behavior - return false; + // If we can't find the section or lines, fallback to equality only. + return section.lastLineId === lineId; } // Find indices of both lines in the lines array @@ -231,8 +232,8 @@ export const selectIsLineViewed = ({ state }, payload) => { return section.lastLineId === lineId; } - // Line is viewed if its index is < last viewed line index - return currentLineIndex < lastLineIndex; + // Line is viewed if its index is at or before the last viewed line index + return currentLineIndex <= lastLineIndex; }; export const selectIsResourceViewed = ({ state }, payload) => { @@ -269,7 +270,8 @@ export const selectSaveSlot = ({ state }, payload) => { * @returns {Object} returns.pointer - The pointer configuration for the current mode */ export const selectCurrentPointer = ({ state }) => { - const lastContext = state.contexts[state.contexts.length - 1]; + const contexts = Array.isArray(state.contexts) ? state.contexts : []; + const lastContext = contexts[contexts.length - 1]; if (!lastContext) { return undefined; @@ -774,8 +776,14 @@ export const appendPendingEffect = ({ state }, payload) => { return state; }; -export const addViewedLine = ({ state }, payload) => { - const { sectionId, lineId } = payload; +const recordViewedLine = (state, { sectionId, lineId }) => { + if (!state.global.viewedRegistry) { + state.global.viewedRegistry = {}; + } + if (!Array.isArray(state.global.viewedRegistry.sections)) { + state.global.viewedRegistry.sections = []; + } + const section = state.global.viewedRegistry.sections.find( (section) => section.sectionId === sectionId, ); @@ -806,6 +814,10 @@ export const addViewedLine = ({ state }, payload) => { lastLineId: lineId, }); } +}; + +export const addViewedLine = ({ state }, payload) => { + recordViewedLine(state, payload); state.global.pendingEffects.push({ name: "render", @@ -1145,6 +1157,12 @@ export const nextLine = ({ state }) => { // If line is not completed, complete it instantly instead of advancing if (!state.global.isLineCompleted) { state.global.isLineCompleted = true; + const pointer = selectCurrentPointer({ state })?.pointer; + const sectionId = pointer?.sectionId; + const lineId = pointer?.lineId; + if (sectionId && lineId) { + recordViewedLine(state, { sectionId, lineId }); + } // Clear any running nextLineConfigTimer to prevent auto-advance after manual click state.global.pendingEffects.push({ name: "clearNextLineConfigTimer" }); @@ -1211,7 +1229,7 @@ export const nextLine = ({ state }) => { // Mark current line as viewed before moving const currentLineId = lastContext.pointers.read.lineId; if (currentLineId && sectionId) { - addViewedLine({ state }, { sectionId, lineId: currentLineId }); + recordViewedLine(state, { sectionId, lineId: currentLineId }); } lastContext.pointers.read = { @@ -1277,6 +1295,13 @@ export const markLineCompleted = ({ state }) => { }); } + const pointer = selectCurrentPointer({ state })?.pointer; + const sectionId = pointer?.sectionId; + const lineId = pointer?.lineId; + if (sectionId && lineId) { + recordViewedLine(state, { sectionId, lineId }); + } + // If nextLineConfig.auto is enabled with fromComplete trigger, start the timer const nextLineConfig = state.global.nextLineConfig; if (nextLineConfig?.auto?.enabled) { @@ -1492,6 +1517,11 @@ export const nextLineFromSystem = ({ state }) => { const lastContext = state.contexts[state.contexts.length - 1]; if (lastContext) { + const currentLineId = lastContext.pointers.read.lineId; + if (currentLineId && sectionId) { + recordViewedLine(state, { sectionId, lineId: currentLineId }); + } + lastContext.pointers.read = { sectionId, lineId: nextLine.id,