diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index 3bba6c17..264ca639 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -31,11 +31,6 @@ through `4294967295`. Production hosts can omit it; the engine uses `crypto.getRandomValues` when available and falls back to `Math.random` for compatibility. -`createRouteEngine` also accepts `validation: "strict"` to reject invalid -project data when it is loaded instead of tolerating it. The default -(`validation` omitted or `"default"`) keeps the existing behavior; any other -value throws a `TypeError`. See [Strict Validation](#strict-validation). - ## Initialization ### `init({ initialState, namespace })` @@ -148,40 +143,6 @@ completed dispatch gets a fresh budget, so loops that pause for player input continue to work. After a limit error, the host can reinitialize, dispose, or reset to a safe section with `resetStoryAtSection`. -### Strict Validation - -With `createRouteEngine({ ..., validation: "strict" })`, `init(...)` and -`updateProjectData` check the canonical project data (before L10n resolution) -before they replace any state. `init(...)` also checks `initialState.l10nData` -for the first two rules below. A failure throws an `Error` with a stable -`code` and a `path`, a JSON Pointer (RFC 6901) into `initialState` or into the -`updateProjectData` payload, so paths start with `/projectData` or -`/l10nData`. Identify failures by `code` and `path`, not by message text. As -with any failed initialization, the previous generation stays active. - -Checks run in three passes and the first failure is thrown: a walk of the -whole document in key order (`lone_surrogate`, `reserved_key`), the project -envelope (top-level keys, `screen`, `resources`), then the story (scenes in key -order, each section's lines and their action maps). - -| `code` | Rejected input | -| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lone_surrogate` | A string value or object key containing an unpaired UTF-16 surrogate. The path names the value or key. | -| `reserved_key` | An object key `__proto__`, `constructor` or `prototype` anywhere in the document, for example a control keyboard binding or a resource ID. | -| `invalid_value` | Project data that is not an object or contains a cyclic reference; a screen `width` or `height` that is not a positive integer; `resources`, a scene, section, line, line `actions` or action payload that is not an object; `lines` that is not a non-empty array; a line `id` or initial ID that is not a string. | -| `unknown_field` | A top-level project key other than `config`, `screen`, `resources` and `story`, or a `screen` key other than `width`, `height` and `backgroundColor`. | -| `missing_field` | A missing `screen`, screen `width`/`height`, `story`, `story.scenes`, `story.initialSceneId`, scene `sections`, section `lines`, line `id`, or `initialSectionId` of the initial scene. | -| `unknown_reference` | `story.initialSceneId`, a scene `initialSectionId` or a section `initialLineId` that names no scene, section of that scene, or line of that section. | -| `unknown_action` | A key of a story line `actions` map, or of the nested `conditional` branch and weighted `random` outcome `actions` maps, that is not an authored action: the actions of `presentationActions.yaml` and `systemActions.yaml`, plus `addViewedLine`, `addViewedResource` and `updateProjectData`. Internal store actions and selectors are rejected. | - -Without strict validation the engine tolerates most of this input: for -example it renders a missing screen at 1920x1080, points the story at a -missing initial line, and skips unknown action names when their line runs. -Strict mode does not yet check unknown fields below the project and screen -level, action maps inside resources (layout and control events, keyboard -bindings) or in other action payloads, or values in `initialState.global` and -save slots. - ### `dispose()` Stops the active engine generation and releases engine-owned timers and event diff --git a/spec/RouteEngine.strictValidation.test.js b/spec/RouteEngine.strictValidation.test.js deleted file mode 100644 index 84e1720e..00000000 --- a/spec/RouteEngine.strictValidation.test.js +++ /dev/null @@ -1,681 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { load, loadAll } from "js-yaml"; -import { describe, expect, it } from "vitest"; -import createRouteEngine from "../src/RouteEngine.js"; -import { AUTHORED_ACTION_TYPES } from "../src/strictValidation.js"; -import { createIntegrationProject } from "./integration/helpers/createEngineIntegrationHarness.js"; - -const repoRoot = path.resolve(import.meta.dirname, ".."); -const LINES = "/projectData/story/scenes/scene/sections/main/lines"; - -const createEngine = (options = {}) => { - let engine; - engine = createRouteEngine({ - ...options, - handlePendingEffects: (effects) => { - if (effects.some(({ name }) => name === "handleLineActions")) { - engine.handleLineActions(); - } - }, - }); - return engine; -}; - -const createProjectData = () => ({ - screen: { width: 1280, height: 720 }, - resources: { - controls: {}, - layouts: {}, - variables: {}, - }, - story: { - initialSceneId: "scene", - scenes: { - scene: { - initialSectionId: "main", - sections: { - main: { - lines: [ - { id: "one", actions: {} }, - { id: "two", actions: {} }, - ], - }, - }, - }, - }, - }, -}); - -const createInitialState = (mutate = () => {}) => { - const projectData = createProjectData(); - const initialState = { projectData }; - mutate(projectData, initialState); - return initialState; -}; - -const captureError = (callback) => { - try { - callback(); - } catch (error) { - return error; - } - throw new Error("expected the callback to throw"); -}; - -const initialize = (engine, initialState) => - engine.init({ namespace: "strict-validation", initialState }); - -const selectPointer = (engine) => - engine.selectSystemState().contexts.at(-1).pointers.read; - -const setLineActions = (projectData, index, actions) => { - projectData.story.scenes.scene.sections.main.lines[index].actions = actions; -}; - -// [name, mutate(projectData, initialState), expected strict failure] -const REJECTED_CASES = [ - [ - "a lone high surrogate in a string value", - (p) => { - p.resources.variables.playerName = { - type: "string", - scope: "context", - default: "Ann\uD800", - }; - }, - { - code: "lone_surrogate", - path: "/projectData/resources/variables/playerName/default", - }, - ], - [ - "a lone low surrogate in a line id", - (p) => { - p.story.scenes.scene.sections.main.lines[1].id = "two\uDC00"; - }, - { code: "lone_surrogate", path: `${LINES}/1/id` }, - ], - [ - "a reversed surrogate pair", - (p) => { - p.story.scenes.scene.sections.main.lines[1].id = "\uDE00\uD83D"; - }, - { code: "lone_surrogate", path: `${LINES}/1/id` }, - ], - [ - "a lone surrogate in an object key", - (p) => { - p.resources.variables["\uD83D"] = { - type: "number", - scope: "context", - default: 0, - }; - }, - { code: "lone_surrogate", path: "/projectData/resources/variables/\uD83D" }, - ], - [ - "a lone surrogate below a key that needs JSON Pointer escaping", - (p) => { - p.resources.variables["a/b~c"] = { - type: "string", - scope: "context", - default: "\uDFFF", - }; - }, - { - code: "lone_surrogate", - path: "/projectData/resources/variables/a~1b~0c/default", - }, - ], - [ - "a lone surrogate in L10n data", - (p, initialState) => { - p.story.scenes.scene.sections.main.lines[0].actions = { - dialogue: { content: [{ text: "Hello" }] }, - }; - initialState.l10nData = { - packages: { - ja: { - language: "Japanese", - files: [], - patches: [ - { - type: "line.dialogue", - lineId: "one", - payload: { content: [{ text: "\uD800" }] }, - }, - ], - }, - }, - }; - }, - { - code: "lone_surrogate", - path: "/l10nData/packages/ja/patches/0/payload/content/0/text", - }, - ], - [ - "a __proto__ keyboard binding", - (p) => { - p.resources.controls.keys = { - keyboard: JSON.parse('{"__proto__":{"actions":{"nextLine":{}}}}'), - }; - }, - { - code: "reserved_key", - path: "/projectData/resources/controls/keys/keyboard/__proto__", - }, - ], - [ - "a constructor keyboard binding", - (p) => { - p.resources.controls.keys = { - keyboard: { constructor: { actions: { nextLine: {} } } }, - }; - }, - { - code: "reserved_key", - path: "/projectData/resources/controls/keys/keyboard/constructor", - }, - ], - [ - "a prototype key in layout data", - (p) => { - p.resources.layouts.menu = { elements: [], prototype: {} }; - }, - { - code: "reserved_key", - path: "/projectData/resources/layouts/menu/prototype", - }, - ], - [ - "a __proto__ action name", - (p) => setLineActions(p, 1, JSON.parse('{"__proto__":{}}')), - { code: "reserved_key", path: `${LINES}/1/actions/__proto__` }, - ], - [ - "an unknown top-level project key", - (p) => { - p.unknownKey = 1; - }, - { code: "unknown_field", path: "/projectData/unknownKey" }, - ], - [ - "a missing screen", - (p) => { - delete p.screen; - }, - { code: "missing_field", path: "/projectData/screen" }, - ], - [ - "a zero screen width", - (p) => { - p.screen.width = 0; - }, - { code: "invalid_value", path: "/projectData/screen/width" }, - ], - [ - "a fractional screen width", - (p) => { - p.screen.width = 1280.5; - }, - { code: "invalid_value", path: "/projectData/screen/width" }, - ], - [ - "a string screen width", - (p) => { - p.screen.width = "1280"; - }, - { code: "invalid_value", path: "/projectData/screen/width" }, - ], - [ - "a non-finite screen height", - (p) => { - p.screen.height = Number.NaN; - }, - { code: "invalid_value", path: "/projectData/screen/height" }, - ], - [ - "an unknown screen field", - (p) => { - p.screen.depth = 32; - }, - { code: "unknown_field", path: "/projectData/screen/depth" }, - ], - [ - "a non-object resources value", - (p) => { - p.resources = "none"; - }, - { code: "invalid_value", path: "/projectData/resources" }, - ], - [ - "an unknown initial line", - (p) => { - p.story.scenes.scene.sections.main.initialLineId = "ghost"; - }, - { - code: "unknown_reference", - path: "/projectData/story/scenes/scene/sections/main/initialLineId", - }, - ], - [ - "a line without an id", - (p) => { - p.story.scenes.scene.sections.main.lines.push({ actions: {} }); - }, - { code: "missing_field", path: `${LINES}/2/id` }, - ], - [ - "a section without lines", - (p) => { - p.story.scenes.scene.sections.later = {}; - }, - { - code: "missing_field", - path: "/projectData/story/scenes/scene/sections/later/lines", - }, - ], - [ - "an unknown action name", - (p) => setLineActions(p, 1, { teleport: {} }), - { code: "unknown_action", path: `${LINES}/1/actions/teleport` }, - ], - [ - "an inherited Object.prototype member as an action name", - (p) => setLineActions(p, 1, { toString: {} }), - { code: "unknown_action", path: `${LINES}/1/actions/toString` }, - ], - [ - "a store selector as an action name", - (p) => setLineActions(p, 1, { selectRenderState: {} }), - { code: "unknown_action", path: `${LINES}/1/actions/selectRenderState` }, - ], - [ - "an internal store action as an action name", - (p) => setLineActions(p, 1, { appendPendingEffect: { name: "render" } }), - { code: "unknown_action", path: `${LINES}/1/actions/appendPendingEffect` }, - ], - [ - "an unknown action in a conditional branch", - (p) => - setLineActions(p, 1, { - conditional: { branches: [{ actions: { teleport: {} } }] }, - }), - { - code: "unknown_action", - path: `${LINES}/1/actions/conditional/branches/0/actions/teleport`, - }, - ], - [ - "an unknown action in a weighted random outcome", - (p) => - setLineActions(p, 1, { - random: { - distribution: { - type: "weighted", - outcomes: [{ weight: 1, actions: { teleport: {} } }], - }, - }, - }), - { - code: "unknown_action", - path: `${LINES}/1/actions/random/distribution/outcomes/0/actions/teleport`, - }, - ], - [ - "a null action payload", - (p) => setLineActions(p, 1, { hideDialogueUI: null }), - { code: "invalid_value", path: `${LINES}/1/actions/hideDialogueUI` }, - ], - [ - "a string action payload", - (p) => setLineActions(p, 1, { setMenuPage: "gallery" }), - { code: "invalid_value", path: `${LINES}/1/actions/setMenuPage` }, - ], - [ - "a null line actions map", - (p) => setLineActions(p, 1, null), - { code: "invalid_value", path: `${LINES}/1/actions` }, - ], -]; - -const readVtProjects = () => { - const collect = (directory) => - readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const fullPath = path.join(directory, entry.name); - if (entry.isDirectory()) return collect(fullPath); - return entry.name.endsWith(".yaml") ? [fullPath] : []; - }); - return collect(path.join(repoRoot, "vt", "specs")) - .sort() - .map((file) => { - // VT specs keep L10n data next to the project, as vt/static/main.js does. - const { l10nData, ...projectData } = loadAll( - readFileSync(file, "utf8"), - ).at(-1); - return { - name: path.relative(repoRoot, file), - initialState: - l10nData === undefined ? { projectData } : { projectData, l10nData }, - }; - }); -}; - -describe("RouteEngine validation option", () => { - it.each([undefined, "default", "strict"])("accepts %s", (validation) => { - const engine = createEngine(validation === undefined ? {} : { validation }); - initialize(engine, createInitialState()); - expect(selectPointer(engine)).toMatchObject({ - sectionId: "main", - lineId: "one", - }); - }); - - it.each(["Strict", "lenient", true, null, 1])( - "rejects %s at engine creation", - (validation) => { - expect(() => createEngine({ validation })).toThrow(TypeError); - expect(() => createEngine({ validation })).toThrow( - 'RouteEngine validation must be "default" or "strict" when provided', - ); - }, - ); -}); - -describe("strict validation at initialization", () => { - it.each(REJECTED_CASES)("rejects %s", (_name, mutate, expected) => { - const engine = createEngine({ validation: "strict" }); - const error = captureError(() => - initialize(engine, createInitialState(mutate)), - ); - expect(error).toBeInstanceOf(Error); - expect({ code: error.code, path: error.path }).toEqual(expected); - expect(error.message).toContain(`(${expected.code})`); - expect(engine.getNamespace()).toBeNull(); - }); - - it.each(REJECTED_CASES)( - "keeps accepting %s in the default mode", - (_name, mutate) => { - const engine = createEngine(); - initialize(engine, createInitialState(mutate)); - expect(selectPointer(engine).sectionId).toBe("main"); - }, - ); - - it("shows the default-mode fallbacks that strict mode rejects", () => { - const missingScreen = createEngine(); - initialize( - missingScreen, - createInitialState((p) => { - delete p.screen; - p.resources.colors = { black: { hex: "#000000" } }; - setLineActions(p, 0, { background: { colorId: "black" } }); - }), - ); - const [backgroundColor] = - missingScreen.selectRenderState().elements[0].children; - expect(backgroundColor).toMatchObject({ - type: "rect", - width: 1920, - height: 1080, - }); - - const unknownInitialLine = createEngine(); - initialize( - unknownInitialLine, - createInitialState((p) => { - p.story.scenes.scene.sections.main.initialLineId = "ghost"; - }), - ); - expect(selectPointer(unknownInitialLine).lineId).toBe("ghost"); - - const unknownAction = createEngine(); - initialize( - unknownAction, - createInitialState((p) => setLineActions(p, 1, { teleport: {} })), - ); - unknownAction.handleAction("jumpToLine", { lineId: "two" }); - expect(selectPointer(unknownAction).lineId).toBe("two"); - }); - - it("gives an unknown initial scene a code in strict mode", () => { - const mutate = (p) => { - p.story.initialSceneId = "ghost"; - }; - expect(() => - initialize(createEngine(), createInitialState(mutate)), - ).toThrow(TypeError); - const error = captureError(() => - initialize( - createEngine({ validation: "strict" }), - createInitialState(mutate), - ), - ); - expect({ code: error.code, path: error.path }).toEqual({ - code: "unknown_reference", - path: "/projectData/story/initialSceneId", - }); - }); - - it("gives a cyclic reference a code and accepts shared references", () => { - const cyclic = (p) => { - p.resources.layouts.menu = { elements: [] }; - p.resources.layouts.menu.self = p.resources.layouts.menu; - }; - expect(() => - initialize(createEngine(), createInitialState(cyclic)), - ).toThrow(/circular/); - const error = captureError(() => - initialize( - createEngine({ validation: "strict" }), - createInitialState(cyclic), - ), - ); - expect({ code: error.code, path: error.path }).toEqual({ - code: "invalid_value", - path: "/projectData/resources/layouts/menu/self", - }); - - const engine = createEngine({ validation: "strict" }); - initialize( - engine, - createInitialState((p) => { - const black = { hex: "#000000" }; - p.resources.colors = { black, shadow: black }; - }), - ); - expect(selectPointer(engine).lineId).toBe("one"); - }); - - it("accepts well-formed surrogate pairs and look-alike keys", () => { - const engine = createEngine({ validation: "strict" }); - initialize( - engine, - createInitialState((p) => { - p.resources.variables.emoji = { - type: "string", - scope: "context", - default: "\u{1F680} rocket", - }; - p.resources.controls.keys = { - keyboard: { - Constructor: { actions: { nextLine: {} } }, - toString: { actions: { nextLine: {} } }, - }, - }; - }), - ); - expect(selectPointer(engine).lineId).toBe("one"); - }); - - it("keeps the previous generation when a strict reinitialization fails", () => { - const engine = createEngine({ validation: "strict" }); - initialize(engine, createInitialState()); - engine.handleAction("jumpToLine", { lineId: "two" }); - const before = engine.selectSystemState(); - - const error = captureError(() => - initialize( - engine, - createInitialState((p) => { - p.screen.width = -1; - }), - ), - ); - - expect(error.code).toBe("invalid_value"); - expect(engine.selectSystemState()).toEqual(before); - engine.handleAction("jumpToLine", { lineId: "one" }); - expect(selectPointer(engine).lineId).toBe("one"); - }); -}); - -describe("strict validation of updateProjectData", () => { - const replaceProject = (engine, mutate) => { - const projectData = createProjectData(); - mutate(projectData); - engine.handleAction("updateProjectData", { projectData }); - }; - - it.each([ - [ - "an unknown action", - (p) => setLineActions(p, 0, { teleport: {} }), - { code: "unknown_action", path: `${LINES}/0/actions/teleport` }, - ], - [ - "a string screen height", - (p) => { - p.screen.height = "720"; - }, - { code: "invalid_value", path: "/projectData/screen/height" }, - ], - [ - "a reserved resource id", - (p) => { - p.resources.layouts = JSON.parse('{"__proto__":{"elements":[]}}'); - }, - { - code: "reserved_key", - path: "/projectData/resources/layouts/__proto__", - }, - ], - ])("rejects %s without replacing state", (_name, mutate, expected) => { - const engine = createEngine({ validation: "strict" }); - initialize(engine, createInitialState()); - const before = engine.selectSystemState(); - - const error = captureError(() => replaceProject(engine, mutate)); - - expect({ code: error.code, path: error.path }).toEqual(expected); - expect(engine.selectSystemState()).toEqual(before); - - const defaultEngine = createEngine(); - initialize(defaultEngine, createInitialState()); - replaceProject(defaultEngine, mutate); - }); - - it("accepts a valid replacement", () => { - const engine = createEngine({ validation: "strict" }); - initialize(engine, createInitialState()); - replaceProject(engine, (p) => { - p.screen.width = 1920; - }); - expect(engine.selectSystemState().projectData.screen.width).toBe(1920); - }); -}); - -describe("strict validation against existing fixtures", () => { - it("authors the schema actions plus the documented public actions", () => { - const schemaActionTypes = [ - "src/schemas/presentationActions.yaml", - "src/schemas/systemActions.yaml", - ].flatMap((file) => - Object.keys( - load(readFileSync(path.join(repoRoot, file), "utf8")).properties, - ), - ); - const story = load( - readFileSync( - path.join(repoRoot, "src/schemas/projectData/story.yaml"), - "utf8", - ), - ); - const lineActionTypes = Object.keys( - story.properties.scenes.patternProperties["^.+$"].properties.sections - .patternProperties["^.+$"].properties.lines.items.properties.actions - .properties, - ); - - expect(new Set(lineActionTypes)).toEqual(new Set(schemaActionTypes)); - expect(new Set(AUTHORED_ACTION_TYPES)).toEqual( - new Set([ - ...schemaActionTypes, - "addViewedLine", - "addViewedResource", - "updateProjectData", - ]), - ); - expect(AUTHORED_ACTION_TYPES).toHaveLength( - new Set(AUTHORED_ACTION_TYPES).size, - ); - }); - - it("accepts every VT project the default mode accepts", () => { - // complete/one.yaml still authors `nextConfig`, an action removed in the - // v0.1.0 rewrite that the default mode silently skips. - const expectedRejections = { - "vt/specs/complete/one.yaml": { - code: "unknown_action", - path: "/projectData/story/scenes/39kd932/sections/2dk302s/lines/0/actions/nextConfig", - }, - }; - const projects = readVtProjects(); - const rejections = {}; - - for (const { name, initialState } of projects) { - initialize( - createRouteEngine({ handlePendingEffects: () => {} }), - structuredClone(initialState), - ); - try { - initialize( - createRouteEngine({ - validation: "strict", - handlePendingEffects: () => {}, - }), - structuredClone(initialState), - ); - } catch (error) { - rejections[name] = { code: error.code, path: error.path }; - } - } - - expect(projects.length).toBeGreaterThan(200); - expect(rejections).toEqual(expectedRejections); - }); - - it("accepts the integration harness project shape", () => { - const projectData = createIntegrationProject({ - sections: { - main: { - lines: [ - { - id: "start", - actions: { - addViewedLine: { sectionId: "main", lineId: "start" }, - }, - }, - { id: "next" }, - ], - }, - }, - }); - const engine = createEngine({ validation: "strict" }); - initialize(engine, { projectData }); - expect(selectPointer(engine).lineId).toBe("start"); - }); -}); diff --git a/src/RouteEngine.js b/src/RouteEngine.js index 8c8494ca..550938f1 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -29,12 +29,6 @@ import { isTerminalNavigationActionType, orderActionEntries, } from "./actionExecutionOrder.js"; -import { - assertStrictInitialState, - assertStrictProjectData, - normalizeValidationMode, - STRICT_VALIDATION_MODE, -} from "./strictValidation.js"; const PERSISTENT_PLAYBACK_RESET_ACTIONS = new Set([ "loadSlot", @@ -196,8 +190,6 @@ export default function createRouteEngine(options) { if (typeof handlePendingEffects !== "function") { throw new TypeError("RouteEngine requires handlePendingEffects"); } - const _isStrictValidation = - normalizeValidationMode(options.validation) === STRICT_VALIDATION_MODE; const handleEffects = handlePendingEffects.bind(handlePendingEffects); const reconcilePlayback = typeof handlePendingEffects.reconcilePlaybackScheduleV1 === "function" @@ -596,12 +588,6 @@ export default function createRouteEngine(options) { const normalizedL10nData = l10nData === undefined ? undefined : structuredClone(l10nData); const canonicalProjectData = structuredClone(initialState.projectData); - if (_isStrictValidation) { - assertStrictInitialState({ - projectData: canonicalProjectData, - l10nData: normalizedL10nData, - }); - } const localizationPackages = getLocalizationPackageOptions({ projectData: canonicalProjectData, l10nData: normalizedL10nData, @@ -1205,9 +1191,6 @@ export default function createRouteEngine(options) { if (actionType === "updateProjectData") { validateProjectDataUpdatePayload(payload); nextCanonicalProjectData = structuredClone(payload.projectData); - if (_isStrictValidation) { - assertStrictProjectData(nextCanonicalProjectData); - } storePayload = { ...payload, projectData: resolveL10nProjectData({ diff --git a/src/strictValidation.js b/src/strictValidation.js deleted file mode 100644 index 02da391a..00000000 --- a/src/strictValidation.js +++ /dev/null @@ -1,469 +0,0 @@ -// Opt-in strict validation of authored engine input. -// -// The default mode keeps the engine's historical tolerance. Strict mode adds -// eager checks at initialization and project-data replacement. Each failure -// throws an Error with a stable `code` and a JSON Pointer `path` (RFC 6901) -// into the object passed to `init({ initialState })` or to the -// `updateProjectData` payload, so both paths start with `/projectData`. - -const DEFAULT_VALIDATION_MODE = "default"; -export const STRICT_VALIDATION_MODE = "strict"; - -const VALIDATION_MODES = new Set([ - DEFAULT_VALIDATION_MODE, - STRICT_VALIDATION_MODE, -]); - -// Stable error codes. Hosts and tests match these, never message text. -const LONE_SURROGATE = "lone_surrogate"; -const RESERVED_KEY = "reserved_key"; -const UNKNOWN_FIELD = "unknown_field"; -const MISSING_FIELD = "missing_field"; -const INVALID_VALUE = "invalid_value"; -const UNKNOWN_REFERENCE = "unknown_reference"; -const UNKNOWN_ACTION = "unknown_action"; - -// Keys that JavaScript property access treats specially. The engine already -// refuses all three as L10n IDs and template path segments, and "__proto__" -// as a variable ID; assigning "__proto__" through obj[key] sets a prototype -// instead of an entry. -const RESERVED_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]); - -// Action types an author may write in a story line batch: the properties of -// presentationActions.yaml and systemActions.yaml (exactly the line action -// properties of story.yaml) plus the public actions docs/RouteEngine.md -// documents that the schemas do not list yet. Internal store actions such as -// appendPendingEffect or markLineCompleted are not authored actions. -export const AUTHORED_ACTION_TYPES = Object.freeze([ - "cleanAll", - "screen", - "background", - "dialogue", - "character", - "visual", - "choice", - "form", - "sfx", - "bgm", - "voice", - "control", - "layout", - "conditional", - "random", - "nextLine", - "setNextLineConfig", - "startAutoMode", - "stopAutoMode", - "toggleAutoMode", - "startSkipMode", - "stopSkipMode", - "toggleSkipMode", - "showDialogueUI", - "hideDialogueUI", - "toggleDialogueUI", - "setDialogueTextSpeed", - "setAutoForwardDelay", - "setAutoForwardSpeed", - "setSkipUnseenText", - "setSkipTransitionsAndAnimations", - "updateLocalizationPackage", - "setSoundVolume", - "setMusicVolume", - "setMuteAll", - "setSaveLoadPagination", - "incrementSaveLoadPagination", - "decrementSaveLoadPagination", - "setMenuPage", - "setMenuEntryPoint", - "showConfirmDialog", - "hideConfirmDialog", - "saveSlot", - "loadSlot", - "resetStoryAtSection", - "sectionTransition", - "jumpToLine", - "pushOverlay", - "popOverlay", - "replaceLastOverlay", - "clearOverlays", - "rollbackByOffset", - "rollbackToLine", - "updateVariable", - "updateFormField", - "submitForm", - "cancelForm", - "completeAchievement", - "setAchievementProgress", - "showImageGalleryVariant", - "moveToPreviousImageGalleryVariant", - "moveToNextImageGalleryVariant", - "clearImageGallerySelection", - "moveToImageGalleryPage", - "moveToNextImageGalleryPage", - "moveToPreviousImageGalleryPage", - "playMusicRoomTrack", - "playMusicRoom", - "pauseMusicRoom", - "stopMusicRoom", - "seekMusicRoom", - "playPreviousMusicRoomTrack", - "playNextMusicRoomTrack", - "clearMusicRoomSelection", - "moveToMusicRoomPage", - "moveToNextMusicRoomPage", - "moveToPreviousMusicRoomPage", - "startSceneReplay", - "finishSceneReplay", - "exitSceneReplay", - "moveToSceneReplayPage", - "moveToNextSceneReplayPage", - "moveToPreviousSceneReplayPage", - "addViewedLine", - "addViewedResource", - "updateProjectData", -]); - -const AUTHORED_ACTION_TYPE_SET = new Set(AUTHORED_ACTION_TYPES); -const PROJECT_DATA_FIELDS = new Set(["config", "screen", "resources", "story"]); -const SCREEN_FIELDS = new Set(["width", "height", "backgroundColor"]); - -const isRecord = (value) => - value !== null && typeof value === "object" && !Array.isArray(value); - -const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); - -const isPlainContainer = (value) => { - if (Array.isArray(value)) { - return true; - } - if (!isRecord(value)) { - return false; - } - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; - -const childPath = (path, token) => - `${path}/${String(token).replaceAll("~", "~0").replaceAll("/", "~1")}`; - -const fail = (code, path, detail) => { - // JSON.stringify escapes lone surrogates, keeping the message printable. - const error = new Error( - `Strict validation failed (${code}) at ${JSON.stringify(path)}: ${detail}`, - ); - // Hosts identify the failure by code and path, not by message text. - error.code = code; - error.path = path; - throw error; -}; - -export const normalizeValidationMode = (validation) => { - if (validation === undefined) { - return DEFAULT_VALIDATION_MODE; - } - if (!VALIDATION_MODES.has(validation)) { - throw new TypeError( - 'RouteEngine validation must be "default" or "strict" when provided', - ); - } - return validation; -}; - -// Returns the UTF-16 code-unit index of the first unpaired surrogate, or -1. -const findLoneSurrogate = (value) => { - if (typeof value.isWellFormed === "function" && value.isWellFormed()) { - return -1; - } - for (let index = 0; index < value.length; index += 1) { - const unit = value.charCodeAt(index); - if (unit >= 0xd800 && unit <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - index += 1; - continue; - } - return index; - } - if (unit >= 0xdc00 && unit <= 0xdfff) { - return index; - } - } - return -1; -}; - -// Walks plain objects and arrays in document order. Strings and keys must be -// well-formed UTF-16 and keys must not be reserved. The pointer is only built -// for a failure, which keeps the walk cheap on large projects. -const assertDocumentKeysAndStrings = (root, rootPath) => { - const tokens = []; - const ancestors = new Set(); - const checked = new WeakSet(); - const failAt = (code, detail) => - fail(code, tokens.reduce(childPath, rootPath), detail); - const visit = (value) => { - if (typeof value === "string") { - const index = findLoneSurrogate(value); - if (index !== -1) { - failAt( - LONE_SURROGATE, - `string has an unpaired UTF-16 surrogate at code unit ${index}`, - ); - } - return; - } - if (!isPlainContainer(value) || checked.has(value)) { - return; - } - if (ancestors.has(value)) { - failAt(INVALID_VALUE, "value contains a cyclic reference"); - } - - ancestors.add(value); - if (Array.isArray(value)) { - value.forEach((item, index) => { - tokens.push(index); - visit(item); - tokens.pop(); - }); - } else { - for (const key of Object.keys(value)) { - tokens.push(key); - if (findLoneSurrogate(key) !== -1) { - failAt(LONE_SURROGATE, "key has an unpaired UTF-16 surrogate"); - } - if (RESERVED_OBJECT_KEYS.has(key)) { - failAt(RESERVED_KEY, `"${key}" is a reserved key`); - } - visit(value[key]); - tokens.pop(); - } - } - ancestors.delete(value); - checked.add(value); - }; - - visit(root); -}; - -const assertRequiredRecord = (parent, key, parentPath) => { - const path = childPath(parentPath, key); - if (!hasOwn(parent, key) || parent[key] === undefined) { - fail(MISSING_FIELD, path, `${key} is required`); - } - if (!isRecord(parent[key])) { - fail(INVALID_VALUE, path, `${key} must be an object`); - } - return parent[key]; -}; - -const assertKnownFields = (value, knownFields, path) => { - for (const key of Object.keys(value)) { - if (!knownFields.has(key)) { - fail(UNKNOWN_FIELD, childPath(path, key), `unknown field "${key}"`); - } - } -}; - -const assertScreen = (projectData, projectDataPath) => { - const screen = assertRequiredRecord(projectData, "screen", projectDataPath); - const screenPath = childPath(projectDataPath, "screen"); - assertKnownFields(screen, SCREEN_FIELDS, screenPath); - for (const dimension of ["width", "height"]) { - const path = childPath(screenPath, dimension); - if (screen[dimension] === undefined) { - fail(MISSING_FIELD, path, `screen ${dimension} is required`); - } - if (!Number.isSafeInteger(screen[dimension]) || screen[dimension] <= 0) { - fail( - INVALID_VALUE, - path, - `screen ${dimension} must be a positive integer`, - ); - } - } -}; - -const assertString = (value, path, label) => { - if (typeof value !== "string") { - fail(INVALID_VALUE, path, `${label} must be a string`); - } - return value; -}; - -const assertActionMap = (actions, path) => { - if (!isRecord(actions)) { - fail(INVALID_VALUE, path, "actions must be an object"); - } - for (const actionType of Object.keys(actions)) { - const actionPath = childPath(path, actionType); - if (!AUTHORED_ACTION_TYPE_SET.has(actionType)) { - fail( - UNKNOWN_ACTION, - actionPath, - `"${actionType}" is not an authored action type`, - ); - } - const payload = actions[actionType]; - if (!isRecord(payload)) { - fail(INVALID_VALUE, actionPath, "action payload must be an object"); - } - - // Nested batches the action schemas type as action maps. - if (actionType === "conditional" && Array.isArray(payload.branches)) { - const branchesPath = childPath(actionPath, "branches"); - payload.branches.forEach((branch, index) => { - if (isRecord(branch) && branch.actions !== undefined) { - assertActionMap( - branch.actions, - childPath(childPath(branchesPath, index), "actions"), - ); - } - }); - } - if ( - actionType === "random" && - isRecord(payload.distribution) && - Array.isArray(payload.distribution.outcomes) - ) { - const outcomesPath = childPath( - childPath(actionPath, "distribution"), - "outcomes", - ); - payload.distribution.outcomes.forEach((outcome, index) => { - if (isRecord(outcome) && outcome.actions !== undefined) { - assertActionMap( - outcome.actions, - childPath(childPath(outcomesPath, index), "actions"), - ); - } - }); - } - } -}; - -const assertSection = (section, sectionPath) => { - if (!isRecord(section)) { - fail(INVALID_VALUE, sectionPath, "section must be an object"); - } - const linesPath = childPath(sectionPath, "lines"); - if (section.lines === undefined) { - fail(MISSING_FIELD, linesPath, "lines is required"); - } - if (!Array.isArray(section.lines)) { - fail(INVALID_VALUE, linesPath, "lines must be an array"); - } - if (section.lines.length === 0) { - fail(INVALID_VALUE, linesPath, "lines must contain at least one line"); - } - - const lineIds = new Set(); - section.lines.forEach((line, index) => { - const linePath = childPath(linesPath, index); - if (!isRecord(line)) { - fail(INVALID_VALUE, linePath, "line must be an object"); - } - const idPath = childPath(linePath, "id"); - if (line.id === undefined) { - fail(MISSING_FIELD, idPath, "line id is required"); - } - lineIds.add(assertString(line.id, idPath, "line id")); - if (line.actions !== undefined) { - assertActionMap(line.actions, childPath(linePath, "actions")); - } - }); - - if (section.initialLineId !== undefined) { - const path = childPath(sectionPath, "initialLineId"); - const lineId = assertString(section.initialLineId, path, "initialLineId"); - if (!lineIds.has(lineId)) { - fail(UNKNOWN_REFERENCE, path, `line "${lineId}" does not exist`); - } - } -}; - -const assertStory = (projectData, projectDataPath) => { - const story = assertRequiredRecord(projectData, "story", projectDataPath); - const storyPath = childPath(projectDataPath, "story"); - const scenes = assertRequiredRecord(story, "scenes", storyPath); - const scenesPath = childPath(storyPath, "scenes"); - - const initialScenePath = childPath(storyPath, "initialSceneId"); - if (story.initialSceneId === undefined) { - fail(MISSING_FIELD, initialScenePath, "initialSceneId is required"); - } - const initialSceneId = assertString( - story.initialSceneId, - initialScenePath, - "initialSceneId", - ); - if (!hasOwn(scenes, initialSceneId)) { - fail( - UNKNOWN_REFERENCE, - initialScenePath, - `scene "${initialSceneId}" does not exist`, - ); - } - - for (const [sceneId, scene] of Object.entries(scenes)) { - const scenePath = childPath(scenesPath, sceneId); - if (!isRecord(scene)) { - fail(INVALID_VALUE, scenePath, "scene must be an object"); - } - const sections = assertRequiredRecord(scene, "sections", scenePath); - const sectionsPath = childPath(scenePath, "sections"); - const initialSectionPath = childPath(scenePath, "initialSectionId"); - if (scene.initialSectionId === undefined && sceneId === initialSceneId) { - fail( - MISSING_FIELD, - initialSectionPath, - "the initial scene requires initialSectionId", - ); - } - if (scene.initialSectionId !== undefined) { - const sectionId = assertString( - scene.initialSectionId, - initialSectionPath, - "initialSectionId", - ); - if (!hasOwn(sections, sectionId)) { - fail( - UNKNOWN_REFERENCE, - initialSectionPath, - `section "${sectionId}" does not exist in scene "${sceneId}"`, - ); - } - } - for (const [sectionId, section] of Object.entries(sections)) { - assertSection(section, childPath(sectionsPath, sectionId)); - } - } -}; - -// Validates canonical project data, before L10n resolution. -export const assertStrictProjectData = (projectData) => { - const projectDataPath = "/projectData"; - if (projectData === undefined) { - fail(MISSING_FIELD, projectDataPath, "projectData is required"); - } - assertDocumentKeysAndStrings(projectData, projectDataPath); - if (!isRecord(projectData)) { - fail(INVALID_VALUE, projectDataPath, "projectData must be an object"); - } - assertKnownFields(projectData, PROJECT_DATA_FIELDS, projectDataPath); - assertScreen(projectData, projectDataPath); - if (projectData.resources !== undefined && !isRecord(projectData.resources)) { - fail( - INVALID_VALUE, - childPath(projectDataPath, "resources"), - "resources must be an object", - ); - } - assertStory(projectData, projectDataPath); -}; - -export const assertStrictInitialState = ({ projectData, l10nData }) => { - assertStrictProjectData(projectData); - if (l10nData !== undefined) { - assertDocumentKeysAndStrings(l10nData, "/l10nData"); - } -};