diff --git a/docs/RuntimeImplementationPlan.md b/docs/RuntimeImplementationPlan.md new file mode 100644 index 00000000..4b1f33ad --- /dev/null +++ b/docs/RuntimeImplementationPlan.md @@ -0,0 +1,822 @@ +# Runtime Implementation Plan + +This document translates the current "internal/system variables" discussion into +an implementation plan across `route-engine`, `routevn-creator-model`, and +`routevn-creator-client`. + +It is intentionally technical and migration-oriented. + +## Goal + +Replace hidden engine-owned variable ids with a first-class runtime surface. + +That means: + +- `route-engine` should stop hardcoding ids like `_autoForwardTime` or `loadPage` +- engine-owned state should no longer live in `resources.variables` +- authored content should read engine state through `runtime.*` +- engine-owned mutations should happen through explicit actions, not + `updateVariable` +- project-level config/default overrides are deferred from the first + implementation pass + +## Non-Goals + +Out of scope for this migration: + +- turning all engine state into generic mutable data +- adding a generic `updateRuntime` action +- adding project-level runtime/config default overrides in v1 +- keeping long-term dual support for old internal-variable authoring +- exposing every `state.global` property to authored templates/conditions +- solving every future runtime feature in this pass + +## Agreed Design + +### 1. `runtime` is a public authored API, not a variable bag + +Authoring should read: + +- `${runtime.dialogueTextSpeed}` +- `${runtime.autoMode}` +- `runtime.skipMode == true` + +Authoring should not rely on: + +- `${variables._dialogueTextSpeed}` +- `${variables._autoForwardTime}` +- `variables.loadPage` +- `state.*` +- `state.global.*` + +Implementation rule: + +- `state.global` and `state.contexts[]` remain internal engine storage +- `runtime.*` is the only public authored read surface for engine-owned state +- authored templates, layout conditions, and similar view-facing expressions + must not receive direct access to `state` or `state.global` +- `selectSystemState()` may continue to exist for debugging/tooling, but it is + not part of the authored/view contract + +### 2. Project config is deferred from v1 + +The first implementation pass does not add a new `projectData.config` shape. + +V1 uses engine-defined defaults only. + +Possible future direction: + +- add a top-level `projectData.config` +- allow selected runtime defaults to be overridden there + +But this is intentionally not part of the first coding pass. + +### 3. Keep `state.global` as engine storage + +This migration does not require replacing `state.global`. + +Global/session/device-like runtime state stays in `state.global`. +Context-scoped runtime state moves into `state.contexts[].runtime`. + +The public `runtime.*` API is produced by a selector that reads from those +storage locations. + +### 4. Exposed runtime is defined by a source-only registry + +Use a canonical registry in `route-engine`, for example: + +```js +export const RUNTIME_FIELDS = Object.freeze({ + dialogueTextSpeed: { source: "global.dialogueTextSpeed" }, + autoForwardDelay: { source: "global.autoForwardDelay" }, + skipUnseenText: { source: "global.skipUnseenText" }, + autoMode: { source: "global.autoMode" }, + skipMode: { source: "global.skipMode" }, + dialogueUIHidden: { source: "global.dialogueUIHidden" }, + isLineCompleted: { source: "global.isLineCompleted" }, + saveLoadPagination: { source: "context.runtime.saveLoadPagination" }, + menuPage: { source: "context.runtime.menuPage" }, + menuEntryPoint: { source: "context.runtime.menuEntryPoint" }, + soundVolume: { source: "global.soundVolume" }, + musicVolume: { source: "global.musicVolume" }, + muteAll: { source: "global.muteAll" }, + skipTransitionsAndAnimations: { + source: "global.skipTransitionsAndAnimations", + }, +}); +``` + +Important: + +- the registry defines exposure only +- it does not define defaults +- it does not define type +- it does not define scope metadata + +Defaults stay in engine initialization. +Type validation stays in engine/model code. + +### 5. No generic `updateRuntime` + +Engine-owned runtime state is mutated through explicit actions. + +Examples: + +- `setDialogueTextSpeed` +- `setAutoForwardDelay` +- `setSkipUnseenText` +- `setSoundVolume` +- `setMusicVolume` +- `setMuteAll` +- `setSaveLoadPagination` +- `setMenuPage` +- `setMenuEntryPoint` + +Existing explicit actions remain explicit: + +- `toggleAutoMode` +- `toggleSkipMode` +- `toggleDialogueUI` +- `markLineCompleted` + +This is intentionally less flexible than `updateVariable`, but more precise and +robust for engine-owned state. + +## Current State Problems + +Today the contract is split and drifting: + +### In `route-engine` + +Engine code reads variable ids directly: + +- `_skipUnseenText` +- `_autoForwardTime` +- `_skipTransitionsAndAnimations` +- `loadPage` +- `_muteAll` +- `_soundVolume` +- `_musicVolume` +- `_textSpeed` + +### In `creator-model` + +The "system variable" catalog currently contains: + +- `_skipUnseenText` +- `_dialogueTextSpeed` +- `_currentSaveLoadPagination` +- `_currentMenuPage` +- `_menuEntryPoint` + +### In `creator-client` + +The client merges that catalog into projected variables and uses it for: + +- variable pickers +- layout conditions +- preview/runtime shims +- a dedicated `System Variables` page + +This creates several inconsistencies: + +- engine and client/model do not agree on the same ids +- `_dialogueTextSpeed` and `_textSpeed` are both used for the same concept +- `loadPage` and `_currentSaveLoadPagination` overlap +- save/load pagination is modeled as a variable in the editor, but the engine + hardcodes `loadPage` +- slider bindings to system variables are actually coupling authored UI to + hidden engine variable ids + +## Target Runtime Surface + +The first migration pass should cover all currently known engine-owned internal +values. + +### Canonical runtime ids + +| Canonical runtime id | Replaces old ids / concepts | +| --- | --- | +| `dialogueTextSpeed` | `_dialogueTextSpeed`, `_textSpeed` | +| `autoForwardDelay` | `_autoForwardTime` | +| `skipUnseenText` | `_skipUnseenText` | +| `skipTransitionsAndAnimations` | `_skipTransitionsAndAnimations` | +| `soundVolume` | `_soundVolume` | +| `musicVolume` | `_musicVolume` | +| `muteAll` | `_muteAll` | +| `saveLoadPagination` | `_currentSaveLoadPagination`, `loadPage` | +| `menuPage` | `_currentMenuPage` | +| `menuEntryPoint` | `_menuEntryPoint` | +| `autoMode` | current `state.global.autoMode` | +| `skipMode` | current `state.global.skipMode` | +| `dialogueUIHidden` | current `state.global.dialogueUIHidden` | +| `isLineCompleted` | current `state.global.isLineCompleted` | + +Notes: + +- `saveLoadPagination` is the canonical pagination runtime field +- `pageNumber` is intentionally rejected as too generic +- `dialogueTextSpeed` is the canonical text-speed field + +### Frozen v1 runtime ids + +The first implementation pass includes all currently known engine-owned +runtime/system values: + +- `dialogueTextSpeed` +- `autoForwardDelay` +- `skipUnseenText` +- `skipTransitionsAndAnimations` +- `soundVolume` +- `musicVolume` +- `muteAll` +- `saveLoadPagination` +- `menuPage` +- `menuEntryPoint` +- `autoMode` +- `skipMode` +- `dialogueUIHidden` +- `isLineCompleted` + +### Frozen v1 action surface + +Writable runtime values use explicit actions with payload shape: + +```yaml +setDialogueTextSpeed: + value: 50 + +setAutoForwardDelay: + value: 1000 + +setSkipUnseenText: + value: false + +setSkipTransitionsAndAnimations: + value: false + +setSoundVolume: + value: 500 + +setMusicVolume: + value: 500 + +setMuteAll: + value: true + +setSaveLoadPagination: + value: 0 + +incrementSaveLoadPagination: {} +decrementSaveLoadPagination: {} + +setMenuPage: + value: options + +setMenuEntryPoint: + value: title +``` + +Existing explicit actions remain: + +- `toggleAutoMode` +- `toggleSkipMode` +- `toggleDialogueUI` +- `markLineCompleted` + +### Frozen v1 migration decisions + +- `menuPage` and `menuEntryPoint` are included immediately in v1 +- save/load pagination drops `paginationVariableId` immediately in current + authoring; no new-schema transitional support is kept for that field + +## Route-Engine Plan + +### Phase 1: Add runtime contract files + +Add a small internal runtime module, for example: + +- `src/runtimeFields.js` + +It should export: + +- `RUNTIME_FIELDS` +- helpers to resolve a runtime field by source path +- helper to build the exposed `runtime` object + +### Phase 2: Move engine-owned defaults out of variables + +Initialize engine-owned defaults directly in state creation. + +Global/runtime-like defaults belong in `state.global`, for example: + +- `dialogueTextSpeed` +- `autoForwardDelay` +- `skipUnseenText` +- `skipTransitionsAndAnimations` +- `soundVolume` +- `musicVolume` +- `muteAll` +- `autoMode` +- `skipMode` +- `dialogueUIHidden` +- `isLineCompleted` + +Context-scoped defaults belong in `createDefaultContextState`, for example: + +- `context.runtime.saveLoadPagination` +- `context.runtime.menuPage` +- `context.runtime.menuEntryPoint` + +Implementation points: + +- `src/stores/system.store.js` +- `src/util.js` +- `src/schemas/systemState/systemState.yaml` + +### Phase 3: Defer project config/default overrides + +Do not add project-level runtime/config overrides in v1. + +During engine init: + +- use engine hardcoded defaults +- then merge loaded persisted values / loaded save-slot values on top + +Any future `projectData.config` work should be a follow-up once the runtime API +surface is stable. + +### Phase 4: Add `selectRuntime` + +Add a selector that returns the exposed flat runtime API by reading +`RUNTIME_FIELDS`. + +For example: + +- global sources read from `state.global` +- context sources read from the active context + +This selector becomes the authored runtime read surface. + +Use it in: + +- `RouteEngine.handleActions()` template context +- render/template construction +- any future public runtime inspection API + +Plumbing points: + +- `src/stores/system.store.js` +- `src/RouteEngine.js` +- `src/util.js` +- `src/stores/constructRenderState.js` + +### Phase 5: Replace hardcoded variable reads + +Remove direct reads from `state.global.variables.*` and merged `allVariables.*` +for engine-owned behavior. + +Replace with explicit state fields plus `selectRuntime()` where relevant. + +Known call sites to migrate: + +- `_autoForwardTime` in `startAutoMode`, `nextLine`, `markLineCompleted` +- `_skipUnseenText` checks in skip behavior +- `loadPage` in `selectSaveSlotPage` +- `_skipTransitionsAndAnimations` in render-state construction +- `_muteAll`, `_soundVolume`, `_musicVolume`, `_textSpeed` in template data and + audio/render helpers + +Also unify naming: + +- `_dialogueTextSpeed` and `_textSpeed` -> `dialogueTextSpeed` +- `_currentSaveLoadPagination` and `loadPage` -> `saveLoadPagination` + +### Phase 6: Keep explicit mutation actions + +Do not add `updateRuntime`. + +Instead: + +- keep existing explicit actions for engine modes +- add explicit set/toggle/increment/decrement actions where needed for writable + runtime values + +This requires: + +- `src/schemas/systemActions.yaml` +- `src/stores/system.store.js` +- docs updates in `docs/RouteEngine.md` + +The first implementation pass includes: + +- `setDialogueTextSpeed` +- `setAutoForwardDelay` +- `setSkipUnseenText` +- `setSkipTransitionsAndAnimations` +- `setSoundVolume` +- `setMusicVolume` +- `setMuteAll` +- `setSaveLoadPagination` +- `incrementSaveLoadPagination` +- `decrementSaveLoadPagination` +- `setMenuPage` +- `setMenuEntryPoint` + +Existing actions stay: + +- `toggleAutoMode` +- `toggleSkipMode` +- `toggleDialogueUI` + +### Phase 7: Save/load, rollback, and persistence + +Global runtime values that outlive a story session must stop depending on +variable persistence. + +Required changes: + +- persist device/account-like runtime values separately from variables +- include context runtime values in save slots +- restore context runtime on `loadSlot` +- decide rollback behavior for context runtime + +Recommended rule: + +- context runtime should follow the same restore path as context variables +- global runtime should not be rolled back unless there is an explicit feature + requirement + +Files: + +- `src/stores/system.store.js` +- `src/schemas/systemState/systemState.yaml` +- save/load docs/specs + +### Phase 8: Templating and layout runtime access + +Templates and authored conditions must be able to read `runtime.*`. + +Work: + +- add `runtime` to template context +- keep `variables` for project variables only +- migrate built-in template uses such as text reveal speed to + `${runtime.dialogueTextSpeed}` + +Files: + +- `src/RouteEngine.js` +- `src/util.js` +- `src/stores/constructRenderState.js` + +Important distinction: + +- `runtime.*` is the public authored read surface +- the engine may still keep other internal-only state in `state.global` +- only ids listed in `RUNTIME_FIELDS` are exposed +- `state` and `state.global` must not be exposed to authored view/template + contexts + +## Creator-Model Plan + +### Phase 1: Bump schema version + +This is a large authoring-contract change. + +The model should move to a new schema line: + +- `SCHEMA_VERSION = 3` + +### Phase 2: Defer project config/state schema changes + +Do not add `state.project.config` in v1. + +Schema 3 is still justified by the runtime migration because: + +- old internal-variable references are removed from current-schema authoring +- `runtime.*` becomes a first-class authored read surface +- old schema-1/schema-2 authored content needs destructive rewrite + +### Phase 3: Remove system variable support from variable validation + +Remove the current special-case system variable path: + +- delete `src/systemVariables.js` +- stop exporting `SYSTEM_VARIABLE_GROUPS` +- remove `isSystemVariableId` +- stop accepting system variable ids inside `isVariableReferenceTarget` + +After this migration: + +- `variables` means authored project variables only +- engine runtime is no longer represented as variables + +### Phase 4: Add `runtime.*` condition target support + +Current model condition parsing accepts: + +- `variables.foo` +- `variables["foo"]` +- special top-level runtime flags like `autoMode` + +Target model should accept: + +- `runtime.dialogueTextSpeed` +- `runtime.autoMode` +- `runtime.saveLoadPagination` + +Recommended migration: + +- add `runtime.` parsing/validation +- keep the old special targets only as schema-1/schema-2 migration input +- remove them from current-schema authoring once client/editor is migrated + +### Phase 5: One-time schema-1/schema-2 overwrite + +This migration is intentionally destructive for older schemas. + +Compatibility adapters for schema 1 and schema 2 should rewrite old +internal-variable usage into the new runtime contract. + +At minimum, rewrite: + +1. Layout/control `variableId` bindings to system variables + +- `_dialogueTextSpeed` -> remove `variableId`, set `initialValue` to + `${runtime.dialogueTextSpeed}`, and replace auto-generated `updateVariable` + change action with `setDialogueTextSpeed` + +2. Save/load pagination bindings + +- `paginationVariableId: _currentSaveLoadPagination` +- `paginationVariableId: loadPage` + +Rewrite to the new runtime-backed save/load pagination model and remove the old +variable binding field immediately in the current schema. + +3. Layout condition targets + +- `variables._skipUnseenText` -> `runtime.skipUnseenText` +- `variables._dialogueTextSpeed` -> `runtime.dialogueTextSpeed` +- and equivalent bracket syntax + +4. Opaque authored action payloads where known system variable operations appear + +- `updateVariable` operations targeting known internal ids must be split into + explicit runtime actions +- mixed payloads must keep project-variable operations under `updateVariable` + and move runtime mutations to explicit action keys + +Known legacy ids to rewrite with one shared mapping: + +- `_dialogueTextSpeed` and `_textSpeed` -> `dialogueTextSpeed` +- `_autoForwardTime` -> `autoForwardDelay` +- `_skipUnseenText` -> `skipUnseenText` +- `_skipTransitionsAndAnimations` -> `skipTransitionsAndAnimations` +- `_soundVolume` -> `soundVolume` +- `_musicVolume` -> `musicVolume` +- `_muteAll` -> `muteAll` +- `_currentSaveLoadPagination` and `loadPage` -> `saveLoadPagination` +- `_currentMenuPage` -> `menuPage` +- `_menuEntryPoint` -> `menuEntryPoint` + +This rewrite logic should be implemented once and reused by: + +- creator-model compatibility adapters +- any client/repository migration path that rewrites persisted project state + +### Phase 6: Update fixtures and direct coverage + +Required model work: + +- regenerate compat fixtures for schema 3 +- keep schema-1 and schema-2 archives +- update upgrade adapters to rewrite to schema 3 +- update direct coverage that currently expects `_dialogueTextSpeed` + to be a valid variable reference + +Files: + +- `src/model.js` +- `src/index.js` +- `scripts/generate-compat-fixtures.js` +- `tests/support/compatFixtures.js` +- `tests/model-api.test.js` +- `tests/command-direct-coverage.test.js` +- `tests/compat/schema-*` + +## Creator-Client Plan + +### Phase 1: Stop projecting system variables into `resources.variables` + +Remove the synthetic merge: + +- project variables remain in `resources.variables` +- runtime values are read from `runtime.*` in preview/runtime contexts + +Files: + +- `src/internal/systemVariables.js` should be removed +- `src/internal/project/projection.js` + +### Phase 2: Replace the System Variables page + +`System Variables` is no longer the right concept. + +Replace it with a `Runtime` page or equivalent read-only runtime-reference page +that +shows: + +- exposed runtime ids +- descriptions + +This page should be driven from the runtime contract, not from variables. + +Affected files: + +- `src/pages/systemVariables/*` -> rename/rework +- `src/pages/resourceTypes/resourceTypes.store.js` +- route wiring in `src/pages/app/*` + +### Phase 3: Remove system variables from variable pickers + +`updateVariable` and variable-binding UIs should show authored variables only. + +System/runtime ids should disappear from: + +- `commandLineUpdateVariable` +- layout editor variable selectors +- visibility/pagination variable pickers +- preview variable editors + +Files include: + +- `src/components/commandLineUpdateVariable/*` +- `src/components/layoutEditPanel/*` +- `src/components/layoutEditorPreview/*` +- `src/internal/project/projection.js` + +### Phase 4: Add explicit runtime action authoring + +Because runtime changes are explicit actions now, the action editor must expose +them directly. + +Work: + +- add new action modes/previews in `systemActions` +- add dedicated forms for runtime actions where needed +- update any convenience UIs that previously depended on system-variable-based + `updateVariable` + +Files: + +- `src/components/systemActions/*` +- `src/components/commandLineActions/*` +- any new dedicated runtime-action components + +### Phase 5: Migrate slider/runtime bindings + +Current slider convenience binds through: + +- `variableId` +- auto-generated `updateVariable` +- `${variables.}` initial value + +Target behavior: + +- `variableId` remains for project variables only +- runtime-backed sliders use explicit actions plus `${runtime.}` initial + value + +This should be handled in: + +- layout/control editor helpers +- compatibility rewrite path for old schema content + +Files: + +- `src/internal/layoutEditorElementRegistry.js` +- `src/internal/project/layout.js` + +### Phase 6: Move built-in client preview/runtime shims to `runtime` + +Current client preview logic injects fake system variables like +`_dialogueTextSpeed`. + +Replace that with a preview/runtime object: + +- scene preview text speed override +- layout preview variables +- save/load pagination preview +- any system-variable-only preview shims + +Files: + +- `src/pages/sceneEditor/sceneEditor.store.js` +- `src/components/layoutEditorPreview/support/*` +- `src/internal/layoutConditions.js` + +### Phase 7: Save/load pagination editor cleanup + +The current save/load layout editor asks for `paginationVariableId`, but the +engine does not actually honor arbitrary pagination variables as a true runtime +contract. + +Target model: + +- paginated save/load uses `runtime.saveLoadPagination` +- layout config no longer points at an arbitrary variable id +- pagination controls use explicit runtime actions + +This is an intentional simplification and tightening of the contract. + +Files: + +- `src/components/layoutEditPanel/support/layoutEditPanelPagination.js` +- `src/components/layoutEditorPreview/support/layoutEditorPreviewSaveLoad.js` +- corresponding model validation and migration logic + +## Migration Rules + +### Runtime defaults + +After migration: + +- engine defaults remain canonical +- no project-level override layer exists in v1 + +### Old internal-variable references + +They should not remain valid authoring in the new schema. + +Compatibility strategy: + +- rewrite old schema content once +- save back only the new runtime-based form + +### Temporary engine fallback + +Avoid shipping long-term dual-read behavior in engine logic. + +Short-lived branch-local fallback from old internal variables may help while +migrating tests, but final steady-state should read the new runtime state only. + +## Testing Plan + +### Route-engine + +Required coverage: + +- unit/system tests for new explicit runtime actions +- selector tests for `selectRuntime` +- render-state tests proving runtime-backed text/audio/save-load behavior +- save/load tests for context runtime restore +- rollback tests for context runtime replay/restore rules +- VT coverage for: + - dialogue text speed + - save/load pagination + - sound/music volume + - mute-all behavior + +### Creator-model + +Required coverage: + +- schema-3 state validation +- rejection of old system variable references in current schema +- compatibility upgrades from schema 1 and 2 +- direct coverage for rewritten slider/layout/action cases + +### Creator-client + +Required coverage: + +- action editor support for new runtime actions +- layout preview reads from `runtime.*` +- save/load pagination editor/preview no longer depends on variables + +## Rollout Order + +Recommended order: + +1. Land the runtime contract and selector in `route-engine` +2. Land explicit runtime actions and state/persistence changes in `route-engine` +3. Land schema-3 and destructive schema-1/schema-2 rewrites in + `routevn-creator-model` +4. Update `routevn-creator-client` projection, action editors, and preview UI +5. Remove the old system variable page and synthetic variable merging +6. Delete remaining legacy internal-variable references and fallback helpers + +## Open Constraints To Preserve During Implementation + +- `variables` must remain fully generic and project-owned +- runtime must stay outside `resources` +- runtime exposure must come from a source-only registry +- no generic `updateRuntime` +- no project-level config/default override layer in v1 +- explicit actions remain the only mutation path for engine-owned runtime state +- the public authored surface must be a flat `runtime.*` API regardless of + whether the underlying value lives in `state.global` or the active context +- `state` and `state.global` are internal only and must not be exposed to the + authored view layer diff --git a/package.json b/package.json index 4e0d7f10..1406c13e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "0.8.0", + "version": "1.0.0-rc1", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", diff --git a/spec/RouteEngine.actionTemplates.test.js b/spec/RouteEngine.actionTemplates.test.js index d3ef90cd..34080a07 100644 --- a/spec/RouteEngine.actionTemplates.test.js +++ b/spec/RouteEngine.actionTemplates.test.js @@ -83,6 +83,31 @@ describe("RouteEngine action templating", () => { expect(engine.selectSystemState().contexts[0].variables.score).toBe(7); }); + it("resolves ${runtime.*} bindings from engine runtime state", () => { + const engine = createRouteEngine({ + handlePendingEffects: () => {}, + }); + + engine.init({ + initialState: { + global: { + runtime: { + dialogueTextSpeed: 73, + }, + }, + projectData: createMinimalProjectData(), + }, + }); + + engine.handleActions({ + setDialogueTextSpeed: { + value: "${runtime.dialogueTextSpeed}", + }, + }); + + expect(engine.selectRuntime().dialogueTextSpeed).toBe(73); + }); + it("resolves ${variables.*} bindings for authored line actions without event context", () => { let engine; const handlePendingEffects = (pendingEffects) => { diff --git a/spec/RouteEngine.runtime.test.js b/spec/RouteEngine.runtime.test.js new file mode 100644 index 00000000..f1519cb1 --- /dev/null +++ b/spec/RouteEngine.runtime.test.js @@ -0,0 +1,320 @@ +import { describe, expect, it } from "vitest"; +import createRouteEngine from "../src/RouteEngine.js"; +import { createSystemStore } from "../src/stores/system.store.js"; + +const createProjectData = (variables = {}, storyOverride = {}) => ({ + screen: { + width: 1920, + height: 1080, + backgroundColor: "#000000", + }, + resources: { + layouts: {}, + sounds: {}, + images: {}, + videos: {}, + sprites: {}, + spritesheets: {}, + characters: {}, + variables, + transforms: {}, + sectionTransitions: {}, + animations: {}, + fonts: {}, + colors: {}, + textStyles: {}, + controls: {}, + }, + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [{ id: "line1", actions: {} }], + }, + }, + }, + }, + ...storyOverride, + }, +}); + +describe("RouteEngine runtime", () => { + it("uses runtime defaults and keeps variables storage separate", () => { + const store = createSystemStore({ + projectData: createProjectData(), + }); + + const runtime = store.selectRuntime(); + const state = store.selectSystemState(); + + expect(runtime.dialogueTextSpeed).toBe(50); + expect(runtime.autoForwardDelay).toBe(1000); + expect(runtime.muteAll).toBe(false); + expect(runtime.saveLoadPagination).toBe(1); + expect(state.global.dialogueTextSpeed).toBe(50); + expect(state.global.autoForwardDelay).toBe(1000); + expect(state.global.muteAll).toBe(false); + expect(state.global.variables).toEqual({}); + expect(state.contexts[0].runtime).toBeUndefined(); + }); + + it("updates runtime through explicit actions and queues runtime persistence", () => { + const store = createSystemStore({ + projectData: createProjectData(), + }); + + store.setDialogueTextSpeed({ value: 84 }); + + expect(store.selectRuntime().dialogueTextSpeed).toBe(84); + expect(store.selectPendingEffects()).toEqual([ + { + name: "saveGlobalRuntime", + payload: { + globalRuntime: { + dialogueTextSpeed: 84, + autoForwardDelay: 1000, + skipUnseenText: false, + skipTransitionsAndAnimations: false, + soundVolume: 500, + musicVolume: 500, + muteAll: false, + }, + }, + }, + { + name: "render", + }, + ]); + }); + + it("does not route updateVariable operations into runtime values", () => { + const store = createSystemStore({ + projectData: createProjectData({ + dialogueTextSpeed: { + type: "number", + scope: "device", + default: 50, + }, + saveLoadPagination: { + type: "number", + scope: "context", + default: 1, + }, + }), + }); + + store.updateVariable({ + id: "regularVariables", + operations: [ + { + variableId: "dialogueTextSpeed", + op: "set", + value: 92, + }, + { + variableId: "saveLoadPagination", + op: "set", + value: 4, + }, + ], + }); + + expect(store.selectRuntime()).toMatchObject({ + dialogueTextSpeed: 50, + saveLoadPagination: 1, + }); + expect(store.selectAllVariables()).toMatchObject({ + dialogueTextSpeed: 92, + saveLoadPagination: 4, + }); + expect(store.selectSystemState().global.variables).toEqual({ + dialogueTextSpeed: 92, + }); + expect(store.selectSystemState().contexts[0].variables).toEqual({ + saveLoadPagination: 4, + }); + }); + + it("rejects undeclared internal-style variable ids", () => { + const store = createSystemStore({ + projectData: createProjectData(), + }); + + expect(() => + store.updateVariable({ + id: "undeclaredInternalVariable", + operations: [ + { + variableId: "_internalRuntimeValue", + op: "set", + value: 92, + }, + ], + }), + ).toThrowError( + "Variable scope is required for variable: _internalRuntimeValue", + ); + }); + + it("renders runtime values into authored layout templates", () => { + const engine = createRouteEngine({ + handlePendingEffects: () => {}, + }); + + engine.init({ + initialState: { + global: { + runtime: { + dialogueTextSpeed: 77, + }, + }, + projectData: createProjectData( + {}, + { + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [ + { + id: "line1", + actions: { + layout: { + resourceId: "runtimeHud", + }, + }, + }, + ], + }, + }, + }, + }, + }, + ), + }, + }); + + const projectData = engine.selectSystemState().projectData; + projectData.resources.layouts.runtimeHud = { + elements: [ + { + id: "runtime-text", + type: "text", + content: "${runtime.dialogueTextSpeed}", + }, + ], + }; + + engine.handleAction("updateProjectData", { + projectData, + }); + + const renderState = engine.selectRenderState(); + const storyContainer = renderState.elements.find( + (element) => element.id === "story", + ); + const runtimeText = storyContainer.children.find( + (element) => element.id === "layout-runtimeHud", + ); + + expect(runtimeText.children[0].content).toBe(77); + }); + + it("does not expose duplicate top-level runtime fields to authored layouts", () => { + const engine = createRouteEngine({ + handlePendingEffects: () => {}, + }); + + engine.init({ + initialState: { + global: { + runtime: { + dialogueTextSpeed: 77, + }, + }, + projectData: createProjectData( + {}, + { + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [ + { + id: "line1", + actions: { + layout: { + resourceId: "runtimeHud", + }, + }, + }, + ], + }, + }, + }, + }, + }, + ), + }, + }); + + const projectData = engine.selectSystemState().projectData; + projectData.resources.layouts.runtimeHud = { + elements: [ + { + id: "runtime-text", + type: "text", + content: "${textSpeed}", + }, + ], + }; + + engine.handleAction("updateProjectData", { + projectData, + }); + + const renderState = engine.selectRenderState(); + const storyContainer = renderState.elements.find( + (element) => element.id === "story", + ); + const runtimeText = storyContainer.children.find( + (element) => element.id === "layout-runtimeHud", + ); + + expect(runtimeText.children[0].content).toBeUndefined(); + }); + + it("filters unknown persisted runtime keys during initialization", () => { + const store = createSystemStore({ + global: { + runtime: { + dialogueTextSpeed: 90, + legacyRuntimeKey: 123, + }, + }, + projectData: createProjectData(), + }); + + const state = store.selectSystemState(); + expect(state.global.dialogueTextSpeed).toBe(90); + expect(state.global.legacyRuntimeKey).toBeUndefined(); + }); + + it("rejects invalid persisted runtime value types during initialization", () => { + expect(() => + createSystemStore({ + global: { + runtime: { + dialogueTextSpeed: "fast", + }, + }, + projectData: createProjectData(), + }), + ).toThrowError("dialogueTextSpeed requires a finite numeric value"); + }); +}); diff --git a/spec/indexedDbPersistence.test.js b/spec/indexedDbPersistence.test.js index e3d37ce4..98c5e01d 100644 --- a/spec/indexedDbPersistence.test.js +++ b/spec/indexedDbPersistence.test.js @@ -257,12 +257,14 @@ describe("indexedDbPersistence", () => { textSpeed: 42, }, globalAccountVariables: {}, + globalRuntime: {}, }); expect(await betaPersistence.load()).toEqual({ saveSlots: {}, globalDeviceVariables: {}, globalAccountVariables: {}, + globalRuntime: {}, }); }); @@ -301,6 +303,7 @@ describe("indexedDbPersistence", () => { saveSlots: {}, globalDeviceVariables: {}, globalAccountVariables: {}, + globalRuntime: {}, }); expect(await betaPersistence.load()).toEqual({ @@ -309,6 +312,7 @@ describe("indexedDbPersistence", () => { globalAccountVariables: { routeUnlocked: true, }, + globalRuntime: {}, }); }); @@ -372,6 +376,7 @@ describe("indexedDbPersistence", () => { globalAccountVariables: { unlockedChapter: 3, }, + globalRuntime: {}, }); }); }); diff --git a/spec/system/actions/markLineCompleted.spec.yaml b/spec/system/actions/markLineCompleted.spec.yaml index bd94bb07..974a8b19 100644 --- a/spec/system/actions/markLineCompleted.spec.yaml +++ b/spec/system/actions/markLineCompleted.spec.yaml @@ -68,8 +68,7 @@ in: global: isLineCompleted: false autoMode: true - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 nextLineConfig: manual: enabled: true @@ -80,8 +79,7 @@ out: global: isLineCompleted: true autoMode: true - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 nextLineConfig: manual: enabled: true @@ -93,14 +91,13 @@ out: delay: 1000 - name: render --- -case: mark line as completed with auto mode and custom _autoForwardTime +case: mark line as completed with auto mode and custom autoForwardDelay in: - state: global: isLineCompleted: false autoMode: true - variables: - _autoForwardTime: 2000 + autoForwardDelay: 2000 nextLineConfig: manual: enabled: true @@ -111,8 +108,7 @@ out: global: isLineCompleted: true autoMode: true - variables: - _autoForwardTime: 2000 + autoForwardDelay: 2000 nextLineConfig: manual: enabled: true @@ -124,13 +120,12 @@ out: delay: 2000 - name: render --- -case: mark line as completed with auto mode and default _autoForwardTime (not set) +case: mark line as completed with auto mode and default autoForwardDelay (not set) in: - state: global: isLineCompleted: false autoMode: true - variables: {} nextLineConfig: manual: enabled: true @@ -141,7 +136,6 @@ out: global: isLineCompleted: true autoMode: true - variables: {} nextLineConfig: manual: enabled: true @@ -224,8 +218,7 @@ in: global: isLineCompleted: false autoMode: true - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 nextLineConfig: manual: enabled: true @@ -270,8 +263,7 @@ out: global: isLineCompleted: true autoMode: true - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 nextLineConfig: manual: enabled: true diff --git a/spec/system/actions/nextLine.spec.yaml b/spec/system/actions/nextLine.spec.yaml index 95558d3b..f7cc1e0e 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -976,13 +976,12 @@ out: sectionId: "section1" lineId: "1" --- -case: skip mode stops at unviewed line when _skipUnseenText is undefined +case: skip mode stops at unviewed line when skipUnseenText is undefined in: - state: global: isLineCompleted: true skipMode: true - variables: {} nextLineConfig: manual: enabled: true @@ -1012,7 +1011,6 @@ out: global: isLineCompleted: true skipMode: false - variables: {} nextLineConfig: manual: enabled: true @@ -1041,14 +1039,13 @@ out: sectionId: "section1" lineId: "1" --- -case: skip mode stops at unviewed line when _skipUnseenText is false +case: skip mode stops at unviewed line when skipUnseenText is false in: - state: global: isLineCompleted: true skipMode: true - variables: - _skipUnseenText: false + skipUnseenText: false nextLineConfig: manual: enabled: true @@ -1078,8 +1075,7 @@ out: global: isLineCompleted: true skipMode: false - variables: - _skipUnseenText: false + skipUnseenText: false nextLineConfig: manual: enabled: true @@ -1108,14 +1104,13 @@ out: sectionId: "section1" lineId: "1" --- -case: skip mode continues when _skipUnseenText is true (skip all) +case: skip mode continues when skipUnseenText is true (skip all) in: - state: global: isLineCompleted: true skipMode: true - variables: - _skipUnseenText: true + skipUnseenText: true nextLineConfig: manual: enabled: true @@ -1147,8 +1142,7 @@ out: global: isLineCompleted: false skipMode: true - variables: - _skipUnseenText: true + skipUnseenText: true nextLineConfig: manual: enabled: true diff --git a/spec/system/actions/nextLineFromSystem.spec.yaml b/spec/system/actions/nextLineFromSystem.spec.yaml index 9aedc90f..360337ac 100644 --- a/spec/system/actions/nextLineFromSystem.spec.yaml +++ b/spec/system/actions/nextLineFromSystem.spec.yaml @@ -197,8 +197,7 @@ in: isLineCompleted: true autoMode: true skipMode: true - variables: - _skipUnseenText: true + skipUnseenText: true viewedRegistry: sections: [] nextLineConfig: @@ -231,8 +230,7 @@ out: isLineCompleted: false autoMode: false skipMode: false - variables: - _skipUnseenText: true + skipUnseenText: true viewedRegistry: sections: - sectionId: "section1" diff --git a/spec/system/actions/startAutoMode.spec.yaml b/spec/system/actions/startAutoMode.spec.yaml index a6ac1c61..b08695aa 100644 --- a/spec/system/actions/startAutoMode.spec.yaml +++ b/spec/system/actions/startAutoMode.spec.yaml @@ -12,14 +12,12 @@ in: autoMode: false isLineCompleted: false pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: true isLineCompleted: false - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: - name: clearAutoNextTimer - name: render @@ -31,14 +29,12 @@ in: autoMode: true isLineCompleted: false pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: true isLineCompleted: false - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: - name: clearAutoNextTimer - name: render @@ -50,14 +46,12 @@ in: autoMode: false isLineCompleted: true pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: true isLineCompleted: true - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: - name: clearAutoNextTimer - name: startAutoNextTimer @@ -65,21 +59,19 @@ out: delay: 1000 - name: render --- -case: start auto mode with custom _autoForwardTime +case: start auto mode with custom autoForwardDelay in: - state: global: autoMode: false isLineCompleted: true pendingEffects: [] - variables: - _autoForwardTime: 2500 + autoForwardDelay: 2500 out: global: autoMode: true isLineCompleted: true - variables: - _autoForwardTime: 2500 + autoForwardDelay: 2500 pendingEffects: - name: clearAutoNextTimer - name: startAutoNextTimer @@ -87,19 +79,17 @@ out: delay: 2500 - name: render --- -case: start auto mode with default _autoForwardTime (not set) +case: start auto mode with default autoForwardDelay (not set) in: - state: global: autoMode: false isLineCompleted: true pendingEffects: [] - variables: {} out: global: autoMode: true isLineCompleted: true - variables: {} pendingEffects: - name: clearAutoNextTimer - name: startAutoNextTimer @@ -114,8 +104,7 @@ in: autoMode: false isLineCompleted: true pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 projectData: story: scenes: @@ -156,6 +145,5 @@ out: global: autoMode: false isLineCompleted: true - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: [] diff --git a/spec/system/actions/toggleAutoMode.spec.yaml b/spec/system/actions/toggleAutoMode.spec.yaml index 8a437315..f31980ba 100644 --- a/spec/system/actions/toggleAutoMode.spec.yaml +++ b/spec/system/actions/toggleAutoMode.spec.yaml @@ -12,14 +12,12 @@ in: autoMode: false isLineCompleted: false pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: true isLineCompleted: false - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: - name: clearAutoNextTimer - name: render @@ -31,14 +29,12 @@ in: autoMode: false isLineCompleted: true pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: true isLineCompleted: true - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: - name: clearAutoNextTimer - name: startAutoNextTimer @@ -53,14 +49,12 @@ in: autoMode: true isLineCompleted: false pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: false isLineCompleted: false - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: - name: clearAutoNextTimer - name: render @@ -73,13 +67,11 @@ in: skipMode: true isLineCompleted: false pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: true - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 skipMode: false isLineCompleted: false pendingEffects: @@ -95,13 +87,11 @@ in: skipMode: true isLineCompleted: false pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: false - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 skipMode: true isLineCompleted: false pendingEffects: @@ -117,14 +107,12 @@ in: pendingEffects: - name: existingEffect data: test - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: true isLineCompleted: false - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: - name: existingEffect data: test @@ -140,35 +128,31 @@ in: pendingEffects: - name: existingEffect data: test - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 out: global: autoMode: false isLineCompleted: false - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: - name: existingEffect data: test - name: clearAutoNextTimer - name: render --- -case: toggle auto mode with custom _autoForwardTime +case: toggle auto mode with custom autoForwardDelay in: - state: global: autoMode: false isLineCompleted: true pendingEffects: [] - variables: - _autoForwardTime: 2500 + autoForwardDelay: 2500 out: global: autoMode: true isLineCompleted: true - variables: - _autoForwardTime: 2500 + autoForwardDelay: 2500 pendingEffects: - name: clearAutoNextTimer - name: startAutoNextTimer @@ -176,19 +160,17 @@ out: delay: 2500 - name: render --- -case: toggle auto mode with default _autoForwardTime (not set) +case: toggle auto mode with default autoForwardDelay (not set) in: - state: global: autoMode: false isLineCompleted: true pendingEffects: [] - variables: {} out: global: autoMode: true isLineCompleted: true - variables: {} pendingEffects: - name: clearAutoNextTimer - name: startAutoNextTimer @@ -203,8 +185,7 @@ in: autoMode: false isLineCompleted: true pendingEffects: [] - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 projectData: story: scenes: @@ -245,6 +226,5 @@ out: global: autoMode: false isLineCompleted: true - variables: - _autoForwardTime: 1000 + autoForwardDelay: 1000 pendingEffects: [] diff --git a/spec/system/createInitialState.spec.yaml b/spec/system/createInitialState.spec.yaml index 2c68f48d..25220964 100644 --- a/spec/system/createInitialState.spec.yaml +++ b/spec/system/createInitialState.spec.yaml @@ -26,6 +26,13 @@ out: autoMode: false skipMode: false dialogueUIHidden: false + dialogueTextSpeed: 50 + autoForwardDelay: 1000 + skipUnseenText: false + skipTransitionsAndAnimations: false + soundVolume: 500 + musicVolume: 500 + muteAll: false confirmDialog: null viewedRegistry: sections: [] @@ -112,6 +119,13 @@ out: autoMode: false skipMode: false dialogueUIHidden: false + dialogueTextSpeed: 50 + autoForwardDelay: 1000 + skipUnseenText: false + skipTransitionsAndAnimations: false + soundVolume: 500 + musicVolume: 500 + muteAll: false confirmDialog: null viewedRegistry: sections: [] @@ -211,6 +225,13 @@ out: autoMode: false skipMode: false dialogueUIHidden: false + dialogueTextSpeed: 50 + autoForwardDelay: 1000 + skipUnseenText: false + skipTransitionsAndAnimations: false + soundVolume: 500 + musicVolume: 500 + muteAll: false confirmDialog: null viewedRegistry: sections: [] @@ -304,6 +325,13 @@ out: autoMode: false skipMode: false dialogueUIHidden: false + dialogueTextSpeed: 50 + autoForwardDelay: 1000 + skipUnseenText: false + skipTransitionsAndAnimations: false + soundVolume: 500 + musicVolume: 500 + muteAll: false confirmDialog: null viewedRegistry: sections: [] @@ -394,6 +422,13 @@ out: autoMode: false skipMode: false dialogueUIHidden: false + dialogueTextSpeed: 50 + autoForwardDelay: 1000 + skipUnseenText: false + skipTransitionsAndAnimations: false + soundVolume: 500 + musicVolume: 500 + muteAll: false confirmDialog: null viewedRegistry: sections: [] diff --git a/spec/system/renderState/addBgm.spec.yaml b/spec/system/renderState/addBgm.spec.yaml index 35376d97..b45dc742 100644 --- a/spec/system/renderState/addBgm.spec.yaml +++ b/spec/system/renderState/addBgm.spec.yaml @@ -24,8 +24,8 @@ in: sounds: backgroundMusic: fileId: "bgm.mp3" - variables: - _musicVolume: 700 + runtime: + musicVolume: 700 out: elements: - id: "story" @@ -94,8 +94,8 @@ in: sounds: customMusic: fileId: "custom.mp3" - variables: - _musicVolume: 300 + runtime: + musicVolume: 300 out: elements: - id: "story" @@ -215,4 +215,4 @@ out: y: 0 children: [] animations: [] - audio: [] \ No newline at end of file + audio: [] diff --git a/spec/system/renderState/addConfirmDialog.spec.yaml b/spec/system/renderState/addConfirmDialog.spec.yaml index dbf5c68e..d441fe0f 100644 --- a/spec/system/renderState/addConfirmDialog.spec.yaml +++ b/spec/system/renderState/addConfirmDialog.spec.yaml @@ -159,14 +159,15 @@ in: layouts: saveOverwriteConfirmLayout: elements: - - id: confirm-slot-${saveSlots[0].slotId}-${isLineCompleted} + - id: confirm-slot-${saveSlots[0].slotId}-${runtime.isLineCompleted} type: text content: Slot ${saveSlots[0].slotId} colors: {} variables: {} - autoMode: false - skipMode: false - isLineCompleted: true + runtime: + autoMode: false + skipMode: false + isLineCompleted: true saveSlots: - slotId: 3 savedAt: 1700000000000 diff --git a/spec/system/renderState/addLayout.spec.yaml b/spec/system/renderState/addLayout.spec.yaml index bcf683f4..10865aa7 100644 --- a/spec/system/renderState/addLayout.spec.yaml +++ b/spec/system/renderState/addLayout.spec.yaml @@ -244,7 +244,7 @@ in: width: 300 height: 120 colorId: - $if autoMode: "activeColor" + $if runtime.autoMode: "activeColor" $else: "idleColor" hover: colorId: "hoverColor" @@ -262,8 +262,9 @@ in: clickColor: hex: "#404040" variables: {} - autoMode: true - skipMode: false + runtime: + autoMode: true + skipMode: false out: elements: - id: "story" @@ -373,7 +374,7 @@ in: type: "text" content: "Styled" textStyleId: - $if autoMode: "active" + $if runtime.autoMode: "active" $else: "idle" textStyles: idle: @@ -399,8 +400,9 @@ in: activeColor: hex: "#FF0000" variables: {} - autoMode: true - skipMode: false + runtime: + autoMode: true + skipMode: false out: elements: - id: "story" @@ -444,7 +446,7 @@ in: - id: "layout-image" type: "sprite" imageId: - $if autoMode: "activePreview" + $if runtime.autoMode: "activePreview" $else: "idlePreview" hoverImageId: "hoverPreview" clickImageId: "clickPreview" @@ -466,8 +468,9 @@ in: width: 400 height: 225 variables: {} - autoMode: true - skipMode: false + runtime: + autoMode: true + skipMode: false out: elements: - id: "story" diff --git a/spec/system/selectors/selectCurrentPageSlots.spec.yaml b/spec/system/selectors/selectCurrentPageSlots.spec.yaml index 151e8c69..376a4640 100644 --- a/spec/system/selectors/selectCurrentPageSlots.spec.yaml +++ b/spec/system/selectors/selectCurrentPageSlots.spec.yaml @@ -10,11 +10,10 @@ case: default 6 slots per page, page 1 in: - state: global: - variables: - loadPage: 1 saveSlots: {} contexts: - - variables: {} + - runtime: + saveLoadPagination: 1 out: saveSlots: - slotId: 1 @@ -28,11 +27,10 @@ case: default 6 slots per page, page 2 in: - state: global: - variables: - loadPage: 2 saveSlots: {} contexts: - - variables: {} + - runtime: + saveLoadPagination: 2 out: saveSlots: - slotId: 7 @@ -46,11 +44,10 @@ case: default 6 slots per page, page 3 in: - state: global: - variables: - loadPage: 3 saveSlots: {} contexts: - - variables: {} + - runtime: + saveLoadPagination: 3 out: saveSlots: - slotId: 13 @@ -60,14 +57,13 @@ out: - slotId: 17 - slotId: 18 --- -case: loadPage defaults to 1 when undefined +case: saveLoadPagination defaults to 1 when undefined in: - state: global: - variables: {} saveSlots: {} contexts: - - variables: {} + - {} out: saveSlots: - slotId: 1 @@ -77,15 +73,14 @@ out: - slotId: 5 - slotId: 6 --- -case: loadPage from context variables +case: saveLoadPagination from context runtime in: - state: global: - variables: {} saveSlots: {} contexts: - - variables: - loadPage: 2 + - runtime: + saveLoadPagination: 2 out: saveSlots: - slotId: 7 @@ -95,31 +90,31 @@ out: - slotId: 11 - slotId: 12 --- -case: context variables override global variables +case: unrelated variables do not affect save slot pagination in: - state: global: variables: - loadPage: 1 + pageCounter: 1 saveSlots: {} contexts: - variables: - loadPage: 3 + pageCounter: 3 + runtime: + saveLoadPagination: 2 out: saveSlots: - - slotId: 13 - - slotId: 14 - - slotId: 15 - - slotId: 16 - - slotId: 17 - - slotId: 18 + - slotId: 7 + - slotId: 8 + - slotId: 9 + - slotId: 10 + - slotId: 11 + - slotId: 12 --- case: with saved slot data in: - state: global: - variables: - loadPage: 1 saveSlots: "1": savedAt: 1704556800000 @@ -130,7 +125,8 @@ in: "3": savedAt: 1704643200000 contexts: - - variables: {} + - runtime: + saveLoadPagination: 1 out: saveSlots: - slotId: 1 @@ -150,11 +146,10 @@ case: custom 12 slots per page in: - state: global: - variables: - loadPage: 1 saveSlots: {} contexts: - - variables: {} + - runtime: + saveLoadPagination: 1 - slotsPerPage: 12 out: saveSlots: @@ -175,11 +170,10 @@ case: custom 4 slots per page in: - state: global: - variables: - loadPage: 1 saveSlots: {} contexts: - - variables: {} + - runtime: + saveLoadPagination: 1 - slotsPerPage: 4 out: saveSlots: @@ -192,11 +186,10 @@ case: custom 10 slots per page, page 2 in: - state: global: - variables: - loadPage: 2 saveSlots: {} contexts: - - variables: {} + - runtime: + saveLoadPagination: 2 - slotsPerPage: 10 out: saveSlots: @@ -214,11 +207,10 @@ out: case: undefined saveSlots handled gracefully in: - state: - global: - variables: - loadPage: 1 + global: {} contexts: - - variables: {} + - runtime: + saveLoadPagination: 1 out: saveSlots: - slotId: 1 diff --git a/src/RouteEngine.js b/src/RouteEngine.js index e434bd22..aef7a59c 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -85,6 +85,10 @@ export default function createRouteEngine(options) { return _systemStore.selectAutoMode(); }; + const selectRuntime = () => { + return _systemStore.selectRuntime(); + }; + const selectIsChoiceVisible = () => { return _systemStore.selectIsChoiceVisible(); }; @@ -107,6 +111,7 @@ export default function createRouteEngine(options) { variables: _systemStore.selectAllVariables ? _systemStore.selectAllVariables() : undefined, + runtime: _systemStore.selectRuntime ? _systemStore.selectRuntime() : {}, }; } if (Object.prototype.hasOwnProperty.call(eventContext, "event")) { @@ -122,6 +127,7 @@ export default function createRouteEngine(options) { ...additionalContext, _event, variables, + runtime: _systemStore.selectRuntime ? _systemStore.selectRuntime() : {}, }; }; @@ -162,6 +168,7 @@ export default function createRouteEngine(options) { selectSaveSlot, selectSaveSlotPage, selectSaveSlots: selectSaveSlotMap, + selectRuntime, selectIsChoiceVisible, handleLineActions, getNamespace, diff --git a/src/createEffectsHandler.js b/src/createEffectsHandler.js index 7c2f17e4..7c4204dd 100644 --- a/src/createEffectsHandler.js +++ b/src/createEffectsHandler.js @@ -184,11 +184,18 @@ const saveGlobalAccountVariables = ({ enqueuePersistenceWrite }, payload) => { ); }; +const saveGlobalRuntime = ({ enqueuePersistenceWrite }, payload) => { + enqueuePersistenceWrite((persistence) => + persistence.saveGlobalRuntime(payload?.globalRuntime), + ); +}; + const effects = { render, saveSlots, saveGlobalDeviceVariables, saveGlobalAccountVariables, + saveGlobalRuntime, handleLineActions, startAutoNextTimer, clearAutoNextTimer, @@ -204,6 +211,7 @@ const COALESCIBLE_EFFECT_NAMES = new Set([ "saveSlots", "saveGlobalDeviceVariables", "saveGlobalAccountVariables", + "saveGlobalRuntime", "startAutoNextTimer", "clearAutoNextTimer", "startSkipNextTimer", diff --git a/src/indexedDbPersistence.js b/src/indexedDbPersistence.js index cdc2644a..0b6b5a5a 100644 --- a/src/indexedDbPersistence.js +++ b/src/indexedDbPersistence.js @@ -6,6 +6,7 @@ const createEmptyPersistedState = () => ({ saveSlots: {}, globalDeviceVariables: {}, globalAccountVariables: {}, + globalRuntime: {}, }); const isPlainObject = (value) => @@ -26,6 +27,9 @@ const normalizePersistedState = (value = {}) => { ) ? normalizedValue.globalAccountVariables : {}, + globalRuntime: isPlainObject(normalizedValue.globalRuntime) + ? normalizedValue.globalRuntime + : {}, }; }; @@ -327,6 +331,15 @@ export const createIndexedDbPersistence = (options = {}) => { : {}, }, }), + saveGlobalRuntime: async (globalRuntime) => + writeNamespaceRecord({ + databasePromise, + objectStoreName, + namespace: resolvedNamespace, + patch: { + globalRuntime: isPlainObject(globalRuntime) ? globalRuntime : {}, + }, + }), }; }; diff --git a/src/runtimeFields.js b/src/runtimeFields.js new file mode 100644 index 00000000..771b2c0b --- /dev/null +++ b/src/runtimeFields.js @@ -0,0 +1,142 @@ +export const RUNTIME_FIELDS = Object.freeze({ + dialogueTextSpeed: { + source: "global.dialogueTextSpeed", + }, + autoForwardDelay: { + source: "global.autoForwardDelay", + }, + skipUnseenText: { + source: "global.skipUnseenText", + }, + skipTransitionsAndAnimations: { + source: "global.skipTransitionsAndAnimations", + }, + soundVolume: { + source: "global.soundVolume", + }, + musicVolume: { + source: "global.musicVolume", + }, + muteAll: { + source: "global.muteAll", + }, + saveLoadPagination: { + source: "context.runtime.saveLoadPagination", + }, + menuPage: { + source: "context.runtime.menuPage", + }, + menuEntryPoint: { + source: "context.runtime.menuEntryPoint", + }, + autoMode: { + source: "global.autoMode", + }, + skipMode: { + source: "global.skipMode", + }, + dialogueUIHidden: { + source: "global.dialogueUIHidden", + }, + isLineCompleted: { + source: "global.isLineCompleted", + }, +}); + +export const GLOBAL_RUNTIME_DEFAULTS = Object.freeze({ + dialogueTextSpeed: 50, + autoForwardDelay: 1000, + skipUnseenText: false, + skipTransitionsAndAnimations: false, + soundVolume: 500, + musicVolume: 500, + muteAll: false, + autoMode: false, + skipMode: false, + dialogueUIHidden: false, + isLineCompleted: false, +}); + +export const CONTEXT_RUNTIME_DEFAULTS = Object.freeze({ + saveLoadPagination: 1, + menuPage: "", + menuEntryPoint: "", +}); + +export const RUNTIME_FIELD_TYPES = Object.freeze({ + dialogueTextSpeed: "number", + autoForwardDelay: "number", + skipUnseenText: "boolean", + skipTransitionsAndAnimations: "boolean", + soundVolume: "number", + musicVolume: "number", + muteAll: "boolean", + saveLoadPagination: "number", + menuPage: "string", + menuEntryPoint: "string", + autoMode: "boolean", + skipMode: "boolean", + dialogueUIHidden: "boolean", + isLineCompleted: "boolean", +}); + +export const PERSISTED_GLOBAL_RUNTIME_FIELDS = Object.freeze([ + "dialogueTextSpeed", + "autoForwardDelay", + "skipUnseenText", + "skipTransitionsAndAnimations", + "soundVolume", + "musicVolume", + "muteAll", +]); + +export const CONTEXT_RUNTIME_FIELDS = Object.freeze([ + "saveLoadPagination", + "menuPage", + "menuEntryPoint", +]); + +const readRuntimeValueFromState = (state, source) => { + if (source.startsWith("global.")) { + const key = source.slice("global.".length); + return state?.global?.[key]; + } + + if (source.startsWith("context.runtime.")) { + const key = source.slice("context.runtime.".length); + const contexts = Array.isArray(state?.contexts) ? state.contexts : []; + const lastContext = contexts[contexts.length - 1]; + return lastContext?.runtime?.[key]; + } + + return undefined; +}; + +export const selectRuntimeFromState = (state) => { + return Object.fromEntries( + Object.entries(RUNTIME_FIELDS).map(([runtimeId, config]) => { + const sourceValue = readRuntimeValueFromState(state, config.source); + const defaultValue = + sourceValue !== undefined + ? sourceValue + : (GLOBAL_RUNTIME_DEFAULTS[runtimeId] ?? + CONTEXT_RUNTIME_DEFAULTS[runtimeId]); + + return [runtimeId, defaultValue]; + }), + ); +}; + +export const selectRuntimeValueFromState = (state, runtimeId) => { + const runtime = selectRuntimeFromState(state); + return runtime[runtimeId]; +}; + +export const pickPersistedGlobalRuntime = (globalState = {}) => { + return Object.fromEntries( + PERSISTED_GLOBAL_RUNTIME_FIELDS.map((runtimeId) => [ + runtimeId, + globalState[runtimeId] ?? GLOBAL_RUNTIME_DEFAULTS[runtimeId], + ]), + ); +}; diff --git a/src/schemas/systemActions.yaml b/src/schemas/systemActions.yaml index 274350e1..36062bf9 100644 --- a/src/schemas/systemActions.yaml +++ b/src/schemas/systemActions.yaml @@ -113,6 +113,110 @@ properties: properties: {} additionalProperties: false + setDialogueTextSpeed: + type: object + description: Set dialogue reveal speed + properties: + value: + type: number + required: [value] + additionalProperties: false + + setAutoForwardDelay: + type: object + description: Set auto mode delay in milliseconds + properties: + value: + type: number + minimum: 0 + required: [value] + additionalProperties: false + + setSkipUnseenText: + type: object + description: Set whether skip mode should include unseen text + properties: + value: + type: boolean + required: [value] + additionalProperties: false + + setSkipTransitionsAndAnimations: + type: object + description: Set whether transitions and animations should be skipped + properties: + value: + type: boolean + required: [value] + additionalProperties: false + + setSoundVolume: + type: object + description: Set effective SFX volume + properties: + value: + type: number + required: [value] + additionalProperties: false + + setMusicVolume: + type: object + description: Set effective music volume + properties: + value: + type: number + required: [value] + additionalProperties: false + + setMuteAll: + type: object + description: Set whether all sound output is muted + properties: + value: + type: boolean + required: [value] + additionalProperties: false + + setSaveLoadPagination: + type: object + description: Set the current save/load pagination page + properties: + value: + type: integer + minimum: 1 + required: [value] + additionalProperties: false + + incrementSaveLoadPagination: + type: object + description: Increment the current save/load pagination page + properties: {} + additionalProperties: false + + decrementSaveLoadPagination: + type: object + description: Decrement the current save/load pagination page + properties: {} + additionalProperties: false + + setMenuPage: + type: object + description: Set the current menu page + properties: + value: + type: string + required: [value] + additionalProperties: false + + setMenuEntryPoint: + type: object + description: Set the current menu entry point + properties: + value: + type: string + required: [value] + additionalProperties: false + showConfirmDialog: type: object description: Show a transient confirm dialog rendered from a layout resource diff --git a/src/schemas/systemState/effects.yaml b/src/schemas/systemState/effects.yaml index af4563f7..4de136e2 100644 --- a/src/schemas/systemState/effects.yaml +++ b/src/schemas/systemState/effects.yaml @@ -67,6 +67,22 @@ anyOf: required: [name, payload] additionalProperties: false + - title: Save runtime effect + type: object + properties: + name: + const: saveGlobalRuntime + payload: + type: object + properties: + globalRuntime: + type: object + additionalProperties: true + required: [globalRuntime] + additionalProperties: false + required: [name, payload] + additionalProperties: false + - title: Start auto next timer effect type: object properties: diff --git a/src/schemas/systemState/systemState.yaml b/src/schemas/systemState/systemState.yaml index 30ccee7c..811ac61b 100644 --- a/src/schemas/systemState/systemState.yaml +++ b/src/schemas/systemState/systemState.yaml @@ -183,6 +183,18 @@ defs: variables: type: object additionalProperties: true + runtime: + type: object + properties: + saveLoadPagination: + type: integer + minimum: 1 + menuPage: + type: string + menuEntryPoint: + type: string + required: [saveLoadPagination, menuPage, menuEntryPoint] + additionalProperties: false rollback: $ref: "#/defs/rollbackState" required: @@ -219,6 +231,20 @@ properties: $ref: "#/defs/saveSlotEntry" dialogueUIHidden: type: boolean + dialogueTextSpeed: + type: number + autoForwardDelay: + type: number + skipUnseenText: + type: boolean + skipTransitionsAndAnimations: + type: boolean + soundVolume: + type: number + musicVolume: + type: number + muteAll: + type: boolean confirmDialog: type: [object, "null"] properties: @@ -255,6 +281,13 @@ properties: variables, saveSlots, dialogueUIHidden, + dialogueTextSpeed, + autoForwardDelay, + skipUnseenText, + skipTransitionsAndAnimations, + soundVolume, + musicVolume, + muteAll, confirmDialog, autoMode, skipMode, diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 2d59bc01..d3c08529 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -910,8 +910,17 @@ const getStoryContainer = (elements = []) => { return elements.find((element) => element.id === "story"); }; +const getEffectiveSoundVolume = (runtime = {}) => { + return runtime?.muteAll ? 0 : (runtime?.soundVolume ?? 500); +}; + +const getEffectiveMusicVolume = (runtime = {}) => { + return runtime?.muteAll ? 0 : (runtime?.musicVolume ?? 500); +}; + const createLayoutTemplateData = ({ variables, + runtime, saveSlots = [], isLineCompleted, autoMode, @@ -921,22 +930,37 @@ const createLayoutTemplateData = ({ confirmDialog, historyDialogue = [], characters = {}, + dialogueUIHidden, + skipOnlyViewedLines, + skipTransitionsAndAnimations, } = {}) => { + const resolvedRuntime = { + dialogueTextSpeed: runtime?.dialogueTextSpeed ?? 50, + autoForwardDelay: runtime?.autoForwardDelay ?? 1000, + skipUnseenText: runtime?.skipUnseenText ?? false, + skipTransitionsAndAnimations: + runtime?.skipTransitionsAndAnimations ?? false, + soundVolume: runtime?.soundVolume ?? 500, + musicVolume: runtime?.musicVolume ?? 500, + muteAll: runtime?.muteAll ?? false, + saveLoadPagination: runtime?.saveLoadPagination ?? 1, + menuPage: runtime?.menuPage ?? "", + menuEntryPoint: runtime?.menuEntryPoint ?? "", + autoMode: runtime?.autoMode ?? autoMode ?? false, + skipMode: runtime?.skipMode ?? skipMode ?? false, + dialogueUIHidden: runtime?.dialogueUIHidden ?? dialogueUIHidden ?? false, + isLineCompleted: runtime?.isLineCompleted ?? isLineCompleted ?? false, + }; + return { variables, + runtime: resolvedRuntime, saveSlots, - isLineCompleted, - autoMode, - skipMode, isChoiceVisible, canRollback, confirmDialog, historyDialogue, characters, - effectiveSoundVolume: variables?._muteAll - ? 0 - : (variables?._soundVolume ?? 500), - textSpeed: variables?._textSpeed ?? 50, }; }; @@ -1307,6 +1331,7 @@ export const addBackgroundOrCg = ( isLineCompleted, skipTransitionsAndAnimations, variables, + runtime, autoMode, skipMode, isChoiceVisible, @@ -1375,12 +1400,14 @@ export const addBackgroundOrCg = ( bgContainer, createLayoutTemplateData({ variables, + runtime, saveSlots, isLineCompleted, autoMode, skipMode, isChoiceVisible, canRollback, + skipTransitionsAndAnimations, }), { functions: jemplFunctions }, ); @@ -1582,6 +1609,7 @@ export const addVisuals = ( isLineCompleted, skipTransitionsAndAnimations, variables, + runtime, autoMode, skipMode, isChoiceVisible, @@ -1691,12 +1719,14 @@ export const addVisuals = ( visualContainer, createLayoutTemplateData({ variables, + runtime, saveSlots, isLineCompleted, autoMode, skipMode, isChoiceVisible, canRollback, + skipTransitionsAndAnimations, }), { functions: jemplFunctions }, ); @@ -1759,6 +1789,7 @@ export const addDialogue = ( isLineCompleted, skipTransitionsAndAnimations, variables, + runtime, saveSlots = [], }, ) => { @@ -1824,20 +1855,25 @@ export const addDialogue = ( }; }, ); - - const templateData = { + const resolvedRuntime = createLayoutTemplateData({ variables, + runtime, + isLineCompleted, autoMode, skipMode, isChoiceVisible, canRollback, + dialogueUIHidden, skipOnlyViewedLines, - isLineCompleted, + skipTransitionsAndAnimations, + }).runtime; + + const templateData = { + variables, + runtime: resolvedRuntime, + isChoiceVisible, + canRollback, saveSlots, - effectiveSoundVolume: variables?._muteAll - ? 0 - : (variables?._soundVolume ?? 500), - textSpeed: variables?._textSpeed ?? 50, dialogueLines, dialogue: { character: { @@ -1921,6 +1957,7 @@ export const addChoices = ( isLineCompleted, skipTransitionsAndAnimations, variables, + runtime, autoMode, skipMode, isChoiceVisible, @@ -1942,12 +1979,14 @@ export const addChoices = ( { ...createLayoutTemplateData({ variables, + runtime, saveSlots, isLineCompleted, autoMode, skipMode, isChoiceVisible: isChoiceVisible ?? !!presentationState.choice, canRollback, + skipTransitionsAndAnimations, }), choice: { items: presentationState.choice?.items ?? [], @@ -2009,6 +2048,7 @@ export const addControl = ( presentationState, resources = {}, variables, + runtime, isLineCompleted, autoMode, skipMode, @@ -2055,12 +2095,14 @@ export const addControl = ( resources, templateData: createLayoutTemplateData({ variables, + runtime, saveSlots, isLineCompleted, autoMode, skipMode, isChoiceVisible, canRollback, + skipTransitionsAndAnimations, }), isLineCompleted, skipMode, @@ -2071,7 +2113,10 @@ export const addControl = ( return state; }; -export const addBgm = (state, { presentationState, resources, variables }) => { +export const addBgm = ( + state, + { presentationState, resources, runtime, variables }, +) => { const { elements, audio } = state; if (presentationState.bgm && resources) { // Find the story container @@ -2080,18 +2125,17 @@ export const addBgm = (state, { presentationState, resources, variables }) => { const audioResource = resources.sounds[presentationState.bgm.resourceId]; if (!audioResource) return state; - - // Calculate effective music volume respecting _muteAll and _musicVolume - const effectiveMusicVolume = variables?._muteAll - ? 0 - : (variables?._musicVolume ?? 500); + const resolvedRuntime = createLayoutTemplateData({ + variables, + runtime, + }).runtime; audio.push({ id: "bgm", type: "sound", src: audioResource.fileId, loop: presentationState.bgm.loop ?? true, - volume: effectiveMusicVolume, + volume: getEffectiveMusicVolume(resolvedRuntime), delay: presentationState.bgm.delay ?? null, }); } @@ -2153,6 +2197,7 @@ export const addLayout = ( previousPresentationState, resources = {}, variables, + runtime, autoMode, skipMode, isChoiceVisible, @@ -2202,12 +2247,14 @@ export const addLayout = ( resources, templateData: createLayoutTemplateData({ variables, + runtime, saveSlots, isLineCompleted, autoMode, skipMode, isChoiceVisible, canRollback, + skipTransitionsAndAnimations, }), isLineCompleted, skipMode, @@ -2250,6 +2297,7 @@ export const addLayeredViews = ( { resources = {}, variables, + runtime, autoMode, skipMode, isChoiceVisible, @@ -2307,6 +2355,7 @@ export const addLayeredViews = ( const templateData = createLayoutTemplateData({ variables, + runtime, saveSlots, isLineCompleted, autoMode, @@ -2315,6 +2364,7 @@ export const addLayeredViews = ( canRollback, historyDialogue: historyDialogueWithNames, characters: resources.characters || {}, + skipTransitionsAndAnimations, }); const processedLayeredView = parseAndRender( @@ -2357,6 +2407,7 @@ export const addConfirmDialog = ( { resources = {}, variables, + runtime, saveSlots = [], autoMode, skipMode, @@ -2414,6 +2465,7 @@ export const addConfirmDialog = ( confirmDialogContainer, createLayoutTemplateData({ variables, + runtime, saveSlots, isLineCompleted, autoMode, @@ -2423,6 +2475,7 @@ export const addConfirmDialog = ( confirmDialog, historyDialogue: historyDialogueWithNames, characters: resources.characters || {}, + skipTransitionsAndAnimations, }), { functions: jemplFunctions, diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 742f250c..e7df23ab 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -11,6 +11,16 @@ import { } from "../util.js"; import { constructPresentationState } from "./constructPresentationState.js"; import { constructRenderState } from "./constructRenderState.js"; +import { + CONTEXT_RUNTIME_DEFAULTS, + CONTEXT_RUNTIME_FIELDS, + GLOBAL_RUNTIME_DEFAULTS, + PERSISTED_GLOBAL_RUNTIME_FIELDS, + pickPersistedGlobalRuntime, + RUNTIME_FIELD_TYPES, + selectRuntimeFromState, + selectRuntimeValueFromState, +} from "../runtimeFields.js"; const DEFAULT_NEXT_LINE_CONFIG = { manual: { @@ -474,12 +484,24 @@ const normalizeLoadedContext = (context, projectData, index) => { if (context.variables !== undefined && !isRecord(context.variables)) { throw new Error(`Malformed save slot contexts[${index}].variables entry.`); } + if (context.runtime !== undefined && !isRecord(context.runtime)) { + throw new Error(`Malformed save slot contexts[${index}].runtime entry.`); + } const historyPointer = normalizeLoadedHistoryPointer( context.pointers?.history, projectData, ); + const loadedContextVariables = context.variables + ? cloneStateValue(context.variables) + : {}; + const loadedContextRuntime = createInitialContextRuntimeState({ + loadedContextRuntime: isRecord(context.runtime) + ? cloneStateValue(context.runtime) + : {}, + }); + const normalizedContext = { currentPointerMode: context.currentPointerMode === "history" && historyPointer.lineId @@ -500,7 +522,7 @@ const normalizeLoadedContext = (context, projectData, index) => { }, variables: { ...contextVariableDefaults, - ...(context.variables ? cloneStateValue(context.variables) : {}), + ...loadedContextVariables, }, rollback: normalizeLoadedRollback( context.rollback, @@ -509,6 +531,10 @@ const normalizeLoadedContext = (context, projectData, index) => { ), }; + if (loadedContextRuntime) { + normalizedContext.runtime = loadedContextRuntime; + } + return normalizedContext; }; @@ -644,6 +670,162 @@ const getRollbackContextVariableDefaults = (projectData) => { return cloneStateValue(contextVariableDefaultValues); }; +const createDefaultContextRuntimeState = () => ({ + ...cloneStateValue(CONTEXT_RUNTIME_DEFAULTS), +}); + +const normalizeLoadedRuntimeFields = ({ + loadedRuntime, + runtimeIds, + defaults = {}, + path, +}) => { + if (loadedRuntime === undefined) { + return { + runtimeState: cloneStateValue(defaults), + hasLoadedValues: false, + }; + } + + if (!isRecord(loadedRuntime)) { + throw new Error(`Malformed ${path}.`); + } + + const runtimeState = cloneStateValue(defaults); + let hasLoadedValues = false; + + runtimeIds.forEach((runtimeId) => { + if (loadedRuntime[runtimeId] === undefined) { + return; + } + + runtimeState[runtimeId] = normalizeRuntimeValue( + runtimeId, + cloneStateValue(loadedRuntime[runtimeId]), + ); + hasLoadedValues = true; + }); + + return { + runtimeState, + hasLoadedValues, + }; +}; + +const createInitialGlobalRuntimeState = ({ loadedGlobalRuntime = {} }) => { + return normalizeLoadedRuntimeFields({ + loadedRuntime: loadedGlobalRuntime, + runtimeIds: PERSISTED_GLOBAL_RUNTIME_FIELDS, + defaults: GLOBAL_RUNTIME_DEFAULTS, + path: "global.runtime", + }).runtimeState; +}; + +const createInitialContextRuntimeState = ({ loadedContextRuntime = {} }) => { + const { runtimeState, hasLoadedValues } = normalizeLoadedRuntimeFields({ + loadedRuntime: loadedContextRuntime, + runtimeIds: CONTEXT_RUNTIME_FIELDS, + defaults: createDefaultContextRuntimeState(), + path: "context.runtime", + }); + + return hasLoadedValues ? runtimeState : undefined; +}; + +const getCurrentContext = (state) => { + const contexts = Array.isArray(state?.contexts) ? state.contexts : []; + return contexts[contexts.length - 1]; +}; + +const ensureContextRuntimeState = (context) => { + if (!context.runtime) { + context.runtime = createDefaultContextRuntimeState(); + return context.runtime; + } + + CONTEXT_RUNTIME_FIELDS.forEach((runtimeId) => { + if (context.runtime[runtimeId] === undefined) { + context.runtime[runtimeId] = cloneStateValue( + CONTEXT_RUNTIME_DEFAULTS[runtimeId], + ); + } + }); + + return context.runtime; +}; + +const getPersistedGlobalRuntime = (state) => { + return pickPersistedGlobalRuntime(state.global); +}; + +const queueGlobalRuntimePersistence = (state) => { + state.global.pendingEffects.push({ + name: "saveGlobalRuntime", + payload: { + globalRuntime: getPersistedGlobalRuntime(state), + }, + }); +}; + +const getRuntimeFieldType = (runtimeId) => { + return RUNTIME_FIELD_TYPES[runtimeId]; +}; + +const assertRuntimeValueType = (runtimeId, value) => { + const type = getRuntimeFieldType(runtimeId); + + if (type === "number") { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${runtimeId} requires a finite numeric value`); + } + return; + } + + if (type === "boolean") { + if (typeof value !== "boolean") { + throw new Error(`${runtimeId} requires a boolean value`); + } + return; + } + + if (type === "string") { + if (typeof value !== "string") { + throw new Error(`${runtimeId} requires a string value`); + } + return; + } + + throw new Error(`Unsupported runtime field "${runtimeId}"`); +}; + +const normalizeRuntimeValue = (runtimeId, value) => { + assertRuntimeValueType(runtimeId, value); + + if (runtimeId === "saveLoadPagination") { + return Math.max(1, Math.trunc(value)); + } + + return value; +}; + +const applyRuntimeValue = (state, runtimeId, value) => { + const normalizedValue = normalizeRuntimeValue(runtimeId, value); + + if (runtimeId in CONTEXT_RUNTIME_DEFAULTS) { + const context = getCurrentContext(state); + if (!context) { + return normalizedValue; + } + + const runtimeState = ensureContextRuntimeState(context); + runtimeState[runtimeId] = normalizedValue; + return normalizedValue; + } + + state.global[runtimeId] = normalizedValue; + return normalizedValue; +}; + const createDefaultContextState = ({ pointer, projectData }) => ({ currentPointerMode: "read", pointers: { @@ -882,6 +1064,11 @@ const replayRecordedRollbackActions = (state, checkpoint) => { popLayeredView, replaceLastLayeredView, clearLayeredViews, + setSaveLoadPagination, + incrementSaveLoadPagination, + decrementSaveLoadPagination, + setMenuPage, + setMenuEntryPoint, }; checkpoint.executedActions.forEach(({ type, payload }) => { @@ -996,6 +1183,7 @@ const restoreRollbackCheckpoint = (state, checkpointIndex) => { lastContext.variables = getRollbackContextVariableDefaults( state.projectData, ); + delete lastContext.runtime; state.global.dialogueUIHidden = false; delete state.global.isDialogueHistoryShowing; state.global.nextLineConfig = cloneStateValue(DEFAULT_NEXT_LINE_CONFIG); @@ -1044,7 +1232,11 @@ const restoreRollbackCheckpoint = (state, checkpointIndex) => { export const createInitialState = (payload) => { const { projectData } = payload; const global = payload.global ?? {}; - const { saveSlots = {}, variables: loadedGlobalVariables = {} } = global; + const { + saveSlots = {}, + variables: loadedGlobalVariables = {}, + runtime: loadedGlobalRuntime = {}, + } = global; assertUniqueSectionIds(projectData); @@ -1072,17 +1264,16 @@ export const createInitialState = (payload) => { const state = { projectData, global: { - isLineCompleted: false, pendingEffects: [], - autoMode: false, - skipMode: false, - dialogueUIHidden: false, confirmDialog: null, viewedRegistry: createDefaultViewedRegistry(), nextLineConfig: createDefaultNextLineConfig(), saveSlots: normalizeStoredSaveSlots(saveSlots), layeredViews: [], variables: globalVariables, + ...createInitialGlobalRuntimeState({ + loadedGlobalRuntime, + }), }, contexts: [ createDefaultContextState({ pointer: initialPointer, projectData }), @@ -1290,10 +1481,18 @@ export const selectSaveSlot = ({ state }, payload) => { return state.global.saveSlots[storageKey]; }; +export const selectRuntime = ({ state }) => { + return selectRuntimeFromState(state); +}; + +export const selectRuntimeValue = ({ state }, payload) => { + return selectRuntimeValueFromState(state, payload?.runtimeId); +}; + export const selectAllVariables = ({ state }) => { return { ...(state.global?.variables ?? {}), - ...(state.contexts?.[state.contexts.length - 1]?.variables ?? {}), + ...(getCurrentContext(state)?.variables ?? {}), }; }; @@ -1462,8 +1661,7 @@ export const selectPreviousPresentationState = ({ state }) => { }; /** - * Selects the save slots to display on the current page based on loadPage variable - * and page configuration. Returns a flat array of slots for the current page. + * Selects the save slots to display on the current save/load pagination page. * * @param {Object} params - The selector parameters * @param {Object} params.state - The full application state @@ -1474,7 +1672,7 @@ export const selectPreviousPresentationState = ({ state }) => { * * @description * This selector calculates which save slots should be displayed on the current page - * based on the `loadPage` variable and slots per page configuration. It returns a + * based on the current `saveLoadPagination` runtime value and slots per page configuration. It returns a * flat array of slots. The UI layer handles wrapping slots into rows using container * layout properties (width, gap, direction). * @@ -1524,12 +1722,9 @@ export const selectPreviousPresentationState = ({ state }) => { * } */ export const selectSaveSlotPage = ({ state }, { slotsPerPage = 6 } = {}) => { - const allVariables = { - ...state.global.variables, - ...state.contexts[state.contexts.length - 1].variables, - }; - const loadPage = allVariables.loadPage ?? 1; - const startSlot = (loadPage - 1) * slotsPerPage + 1; + const runtime = selectRuntime({ state }); + const saveLoadPagination = runtime.saveLoadPagination ?? 1; + const startSlot = (saveLoadPagination - 1) * slotsPerPage + 1; const slots = []; @@ -1571,11 +1766,9 @@ const shouldSettleCurrentLinePresentation = (state) => { export const selectRenderState = ({ state }) => { const presentationState = selectPresentationState({ state }); const previousPresentationState = selectPreviousPresentationState({ state }); + const runtime = selectRuntime({ state }); - const allVariables = { - ...state.global.variables, - ...state.contexts[state.contexts.length - 1].variables, - }; + const allVariables = selectAllVariables({ state }); const { saveSlots } = selectSaveSlotPage({ state }); const settleCurrentLinePresentation = @@ -1586,21 +1779,21 @@ export const selectRenderState = ({ state }) => { previousPresentationState, resources: state.projectData.resources, screen: state.projectData.screen, - dialogueUIHidden: state.global.dialogueUIHidden, - autoMode: state.global.autoMode, - skipMode: state.global.skipMode, + dialogueUIHidden: runtime.dialogueUIHidden, + autoMode: runtime.autoMode, + skipMode: runtime.skipMode, isChoiceVisible: selectIsChoiceVisible({ state }), canRollback: selectCanRollback({ state }), - skipOnlyViewedLines: !allVariables._skipUnseenText, - isLineCompleted: state.global.isLineCompleted, + skipOnlyViewedLines: !runtime.skipUnseenText, + isLineCompleted: runtime.isLineCompleted, skipTransitionsAndAnimations: - !!allVariables._skipTransitionsAndAnimations || - settleCurrentLinePresentation, + !!runtime.skipTransitionsAndAnimations || settleCurrentLinePresentation, layeredViews: state.global.layeredViews, confirmDialog: state.global.confirmDialog, dialogueHistory: selectDialogueHistory({ state }), saveSlots, variables: allVariables, + runtime, }); return renderState; }; @@ -1713,10 +1906,11 @@ export const startAutoMode = ({ state }) => { // Only start timer immediately if line is already completed // Otherwise, markLineCompleted will start it when renderComplete fires if (state.global.isLineCompleted) { - const autoForwardTime = state.global.variables._autoForwardTime ?? 1000; state.global.pendingEffects.push({ name: "startAutoNextTimer", - payload: { delay: autoForwardTime }, + payload: { + delay: selectRuntimeValueFromState(state, "autoForwardDelay"), + }, }); } @@ -1839,6 +2033,109 @@ export const toggleDialogueUI = ({ state }) => { return state; }; +const setGlobalRuntimeField = (state, runtimeId, value) => { + applyRuntimeValue(state, runtimeId, value); + queueGlobalRuntimePersistence(state); + state.global.pendingEffects.push({ + name: "render", + }); + return state; +}; + +const setContextRuntimeField = (state, runtimeId, value, actionType) => { + const normalizedValue = applyRuntimeValue(state, runtimeId, value); + state.global.pendingEffects.push({ + name: "render", + }); + recordRollbackAction(state, actionType, { + value: normalizedValue, + }); + return state; +}; + +export const setDialogueTextSpeed = ({ state }, payload) => { + return setGlobalRuntimeField(state, "dialogueTextSpeed", payload?.value); +}; + +export const setAutoForwardDelay = ({ state }, payload) => { + return setGlobalRuntimeField(state, "autoForwardDelay", payload?.value); +}; + +export const setSkipUnseenText = ({ state }, payload) => { + return setGlobalRuntimeField(state, "skipUnseenText", payload?.value); +}; + +export const setSkipTransitionsAndAnimations = ({ state }, payload) => { + return setGlobalRuntimeField( + state, + "skipTransitionsAndAnimations", + payload?.value, + ); +}; + +export const setSoundVolume = ({ state }, payload) => { + return setGlobalRuntimeField(state, "soundVolume", payload?.value); +}; + +export const setMusicVolume = ({ state }, payload) => { + return setGlobalRuntimeField(state, "musicVolume", payload?.value); +}; + +export const setMuteAll = ({ state }, payload) => { + return setGlobalRuntimeField(state, "muteAll", payload?.value); +}; + +export const setSaveLoadPagination = ({ state }, payload) => { + return setContextRuntimeField( + state, + "saveLoadPagination", + payload?.value, + "setSaveLoadPagination", + ); +}; + +export const incrementSaveLoadPagination = ({ state }) => { + const nextValue = + selectRuntimeValueFromState(state, "saveLoadPagination") + 1; + setContextRuntimeField( + state, + "saveLoadPagination", + nextValue, + "incrementSaveLoadPagination", + ); + return state; +}; + +export const decrementSaveLoadPagination = ({ state }) => { + const nextValue = + selectRuntimeValueFromState(state, "saveLoadPagination") - 1; + setContextRuntimeField( + state, + "saveLoadPagination", + nextValue, + "decrementSaveLoadPagination", + ); + return state; +}; + +export const setMenuPage = ({ state }, payload) => { + return setContextRuntimeField( + state, + "menuPage", + payload?.value, + "setMenuPage", + ); +}; + +export const setMenuEntryPoint = ({ state }, payload) => { + return setContextRuntimeField( + state, + "menuEntryPoint", + payload?.value, + "setMenuEntryPoint", + ); +}; + export const clearPendingEffects = ({ state }) => { state.global.pendingEffects = []; return state; @@ -2245,10 +2542,11 @@ export const nextLine = ({ state }, payload) => { // If auto mode is on, continue auto-advancing after the skip if (state.global.autoMode) { - const autoForwardTime = state.global.variables._autoForwardTime ?? 1000; state.global.pendingEffects.push({ name: "startAutoNextTimer", - payload: { delay: autoForwardTime }, + payload: { + delay: selectRuntimeValueFromState(state, "autoForwardDelay"), + }, }); } @@ -2284,7 +2582,10 @@ export const nextLine = ({ state }, payload) => { const nextLine = lines[nextLineIndex]; // Check if skip mode should stop at unviewed lines - const skipOnlyViewedLines = !state.global.variables?._skipUnseenText; + const skipOnlyViewedLines = !selectRuntimeValueFromState( + state, + "skipUnseenText", + ); if (state.global.skipMode && skipOnlyViewedLines) { const isNextLineViewed = selectIsLineViewed( { state }, @@ -2369,10 +2670,11 @@ export const markLineCompleted = ({ state }) => { // If auto mode is on, start the delay timer to advance after completion if (state.global.autoMode && !isChoiceVisible) { - const autoForwardTime = state.global.variables._autoForwardTime ?? 1000; state.global.pendingEffects.push({ name: "startAutoNextTimer", - payload: { delay: autoForwardTime }, + payload: { + delay: selectRuntimeValueFromState(state, "autoForwardDelay"), + }, }); } @@ -2584,7 +2886,10 @@ export const nextLineFromSystem = ({ state }) => { const nextLine = lines[nextLineIndex]; // Check if skip mode should stop at unviewed lines - const skipOnlyViewedLines = !state.global.variables?._skipUnseenText; + const skipOnlyViewedLines = !selectRuntimeValueFromState( + state, + "skipUnseenText", + ); if (state.global.skipMode && skipOnlyViewedLines) { const isNextLineViewed = selectIsLineViewed( { state }, @@ -2901,6 +3206,8 @@ export const createSystemStore = (initialState) => { selectSaveSlotMap, selectSaveSlots, selectSaveSlot, + selectRuntime, + selectRuntimeValue, selectAllVariables, selectCurrentPointer, selectSection, @@ -2925,6 +3232,18 @@ export const createSystemStore = (initialState) => { showDialogueUI, hideDialogueUI, toggleDialogueUI, + setDialogueTextSpeed, + setAutoForwardDelay, + setSkipUnseenText, + setSkipTransitionsAndAnimations, + setSoundVolume, + setMusicVolume, + setMuteAll, + setSaveLoadPagination, + incrementSaveLoadPagination, + decrementSaveLoadPagination, + setMenuPage, + setMenuEntryPoint, showConfirmDialog, hideConfirmDialog, resetStorySession, diff --git a/vt/specs/choice/interaction-guards.yaml b/vt/specs/choice/interaction-guards.yaml index 206d974c..cb8eb607 100644 --- a/vt/specs/choice/interaction-guards.yaml +++ b/vt/specs/choice/interaction-guards.yaml @@ -58,11 +58,6 @@ screen: height: 720 backgroundColor: "#101010" resources: - variables: - _autoForwardTime: - type: number - scope: device - default: 5000 controls: guardControls: elements: @@ -112,28 +107,28 @@ resources: content: SKIP textStyleId: statusText - id: auto-status-on - $when: autoMode + $when: runtime.autoMode type: text x: 300 y: 52 content: "AUTO: ON" textStyleId: statusText - id: auto-status-off - $when: "!autoMode" + $when: "!runtime.autoMode" type: text x: 300 y: 52 content: "AUTO: OFF" textStyleId: statusText - id: skip-status-on - $when: skipMode + $when: runtime.skipMode type: text x: 300 y: 138 content: "SKIP: ON" textStyleId: statusText - id: skip-status-off - $when: "!skipMode" + $when: "!runtime.skipMode" type: text x: 300 y: 138 @@ -243,6 +238,8 @@ story: lines: - id: line1 actions: + setAutoForwardDelay: + value: 5000 control: resourceId: guardControls startAutoMode: {} diff --git a/vt/specs/choice/skip-stops-on-choice.yaml b/vt/specs/choice/skip-stops-on-choice.yaml index 68023e07..0479a73d 100644 --- a/vt/specs/choice/skip-stops-on-choice.yaml +++ b/vt/specs/choice/skip-stops-on-choice.yaml @@ -38,11 +38,6 @@ screen: height: 720 backgroundColor: "#101010" resources: - variables: - _skipUnseenText: - type: boolean - scope: device - default: true controls: guardControls: elements: @@ -75,14 +70,14 @@ resources: content: SKIP textStyleId: statusText - id: skip-status-on - $when: skipMode + $when: runtime.skipMode type: text x: 300 y: 56 content: "SKIP: ON" textStyleId: statusText - id: skip-status-off - $when: "!skipMode" + $when: "!runtime.skipMode" type: text x: 300 y: 56 @@ -176,6 +171,8 @@ story: lines: - id: line1 actions: + setSkipUnseenText: + value: true control: resourceId: guardControls dialogue: diff --git a/vt/specs/complete/one.yaml b/vt/specs/complete/one.yaml index a9daafe2..f26faddd 100644 --- a/vt/specs/complete/one.yaml +++ b/vt/specs/complete/one.yaml @@ -1071,7 +1071,7 @@ resources: actions: toggleAutoMode: {} textStyleId: - $if autoMode: textStyle9 + $if runtime.autoMode: textStyle9 $else: textStyle2 - id: dialogue-action-skip type: text @@ -1087,7 +1087,7 @@ resources: actions: toggleSkipMode: {} textStyleId: - $if skipMode: textStyle9 + $if runtime.skipMode: textStyle9 $else: textStyle2 - id: dialogue-action-history type: text diff --git a/vt/specs/dialogue/autoplay.yaml b/vt/specs/dialogue/autoplay.yaml index 23790476..08236a76 100644 --- a/vt/specs/dialogue/autoplay.yaml +++ b/vt/specs/dialogue/autoplay.yaml @@ -52,7 +52,7 @@ resources: actions: toggleAutoMode: {} textStyleId: - $if autoMode: textStyle2 + $if runtime.autoMode: textStyle2 $else: textStyle1 - id: skip-button type: text @@ -66,7 +66,7 @@ resources: actions: toggleSkipMode: {} textStyleId: - $if skipMode: textStyle2 + $if runtime.skipMode: textStyle2 $else: textStyle1 - id: speed-slow-button type: text diff --git a/vt/specs/dialogue/sceneMode.yaml b/vt/specs/dialogue/sceneMode.yaml index 6c40d6d7..67072df5 100644 --- a/vt/specs/dialogue/sceneMode.yaml +++ b/vt/specs/dialogue/sceneMode.yaml @@ -87,7 +87,7 @@ resources: actions: toggleSkipMode: {} textStyleId: - $if skipMode: textStyle3 + $if runtime.skipMode: textStyle3 $else: textStyle1 fonts: fontDefault: diff --git a/vt/specs/dialogue/skip-revealing.yaml b/vt/specs/dialogue/skip-revealing.yaml index db496b0c..7cd0726b 100644 --- a/vt/specs/dialogue/skip-revealing.yaml +++ b/vt/specs/dialogue/skip-revealing.yaml @@ -19,11 +19,6 @@ screen: height: 1080 backgroundColor: "#000000" resources: - variables: - _skipUnseenText: - type: boolean - scope: device - default: true characters: narrator: name: Narrator @@ -108,6 +103,8 @@ story: lines: - id: line1 actions: + setSkipUnseenText: + value: true background: resourceId: stageLayout dialogue: diff --git a/vt/specs/dialogue/skip-timing.yaml b/vt/specs/dialogue/skip-timing.yaml index 001df180..1c781d79 100644 --- a/vt/specs/dialogue/skip-timing.yaml +++ b/vt/specs/dialogue/skip-timing.yaml @@ -19,11 +19,6 @@ screen: height: 1080 backgroundColor: "#000000" resources: - variables: - _skipUnseenText: - type: boolean - scope: device - default: true characters: narrator: name: Narrator @@ -106,6 +101,8 @@ story: lines: - id: line1 actions: + setSkipUnseenText: + value: true background: resourceId: stageLayout dialogue: diff --git a/vt/specs/dialogue/skip.yaml b/vt/specs/dialogue/skip.yaml index d5ece136..d50b3485 100644 --- a/vt/specs/dialogue/skip.yaml +++ b/vt/specs/dialogue/skip.yaml @@ -65,7 +65,7 @@ resources: actions: toggleSkipMode: {} textStyleId: - $if skipMode: textStyle3 + $if runtime.skipMode: textStyle3 $else: textStyle1 - id: skip-all-button type: text @@ -79,7 +79,7 @@ resources: actions: toggleSkipOnlyViewedLines: {} textStyleId: - $if skipOnlyViewedLines: textStyle1 + $if !runtime.skipUnseenText: textStyle1 $else: textStyle3 fonts: fontDefault: diff --git a/vt/specs/save/dynamic-slot-selector-test.yaml b/vt/specs/save/dynamic-slot-selector-test.yaml index 4c068708..7f62a4a4 100644 --- a/vt/specs/save/dynamic-slot-selector-test.yaml +++ b/vt/specs/save/dynamic-slot-selector-test.yaml @@ -12,10 +12,6 @@ resources: width: 280 height: 148 variables: - loadPage: - type: number - scope: device - default: 1 previewFrame: type: string scope: context @@ -147,7 +143,7 @@ resources: textStyleId: textStyle4 - id: subtitle type: text - content: Page ${variables.loadPage} + content: Page ${runtime.saveLoadPagination} x: 380 y: 280 textStyleId: textStyle5 @@ -182,11 +178,7 @@ resources: click: payload: actions: - updateVariable: - id: prevPage - operations: - - variableId: loadPage - op: decrement + decrementSaveLoadPagination: {} textStyleId: textStyle6 - id: btnNextPage type: text @@ -198,11 +190,7 @@ resources: click: payload: actions: - updateVariable: - id: nextPage - operations: - - variableId: loadPage - op: increment + incrementSaveLoadPagination: {} textStyleId: textStyle6 - id: slotsGrid type: container diff --git a/vt/specs/sfx/sound-volume.yaml b/vt/specs/sfx/sound-volume.yaml index 9c044679..7aaeaf1c 100644 --- a/vt/specs/sfx/sound-volume.yaml +++ b/vt/specs/sfx/sound-volume.yaml @@ -6,15 +6,6 @@ screen: height: 1080 backgroundColor: "#000000" resources: - variables: - _soundVolume: - type: number - scope: device - default: 500 - _muteAll: - type: boolean - scope: device - default: false layouts: main: elements: @@ -23,7 +14,7 @@ resources: x: 960 y: 100 anchorX: 0.5 - content: "Sound Volume: ${variables._soundVolume}" + content: "Sound Volume: ${runtime.soundVolume}" textStyleId: textStyle1 - id: vol-250 type: text @@ -66,10 +57,10 @@ resources: x: 960 y: 500 anchorX: 0.5 - content: Play at _soundVolume + content: Play at runtime.soundVolume click: soundSrc: xj323 - soundVolume: ${effectiveSoundVolume} + soundVolume: ${runtime.soundVolume} textStyleId: textStyle3 fonts: fontDefault: diff --git a/vt/static/main.js b/vt/static/main.js index 85b2ee03..a01cd1f3 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -233,8 +233,12 @@ const init = async () => { return bytes.buffer; }; const persistence = createIndexedDbPersistence({ namespace }); - const { saveSlots, globalDeviceVariables, globalAccountVariables } = - await persistence.load(); + const { + saveSlots, + globalDeviceVariables, + globalAccountVariables, + globalRuntime, + } = await persistence.load(); let engine; const effectsHandler = createEffectsHandler({ @@ -323,6 +327,7 @@ const init = async () => { global: { saveSlots, variables: { ...globalDeviceVariables, ...globalAccountVariables }, + runtime: globalRuntime, }, projectData, },