Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
822 changes: 822 additions & 0 deletions docs/RuntimeImplementationPlan.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
25 changes: 25 additions & 0 deletions spec/RouteEngine.actionTemplates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
320 changes: 320 additions & 0 deletions spec/RouteEngine.runtime.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
5 changes: 5 additions & 0 deletions spec/indexedDbPersistence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,14 @@ describe("indexedDbPersistence", () => {
textSpeed: 42,
},
globalAccountVariables: {},
globalRuntime: {},
});

expect(await betaPersistence.load()).toEqual({
saveSlots: {},
globalDeviceVariables: {},
globalAccountVariables: {},
globalRuntime: {},
});
});

Expand Down Expand Up @@ -301,6 +303,7 @@ describe("indexedDbPersistence", () => {
saveSlots: {},
globalDeviceVariables: {},
globalAccountVariables: {},
globalRuntime: {},
});

expect(await betaPersistence.load()).toEqual({
Expand All @@ -309,6 +312,7 @@ describe("indexedDbPersistence", () => {
globalAccountVariables: {
routeUnlocked: true,
},
globalRuntime: {},
});
});

Expand Down Expand Up @@ -372,6 +376,7 @@ describe("indexedDbPersistence", () => {
globalAccountVariables: {
unlockedChapter: 3,
},
globalRuntime: {},
});
});
});
Loading
Loading