Validating stories
The engine trusts the story it's given: a reference to a missing scene throws when playback reaches it. When a story comes from outside your code, such as a JSON file, an API or a CMS, check it first with validateStory.
import { validateStory } from "@ivkit/core";
const data: unknown = await fetch("/stories/intro.json").then((r) => r.json());
const result = validateStory(data);
if (result.valid) {
player.story = result.story; // typed as Story
} else {
for (const error of result.errors) {
console.error(`${error.path}: ${error.message}`);
}
}validateStory doesn't modify its input or load any media. Unknown fields are allowed, so you can keep your own metadata in the story.
Errors
Each error has:
path: a JSON Pointer to the invalid field, such as/scenes/intro/events/0/at. An empty string means the root.code: what kind of problem it is.message: a human-readable description.
All errors are reported at once, not just the first one.
code | Meaning | Example |
|---|---|---|
invalid_type | An object, array, boolean or number field has the wrong type. | scenes is an array instead of an object. |
invalid_value | The value isn't allowed, including a missing or non-string text field and a time that isn't a non-negative number. | A negative time, an empty string, src: 3, a question without options, a hotspot whose to isn't after from, an area outside the frame. |
duplicate_id | An id is used twice. | Two hotspots with the id "map". |
missing_scene | start, next or goto names a scene that doesn't exist. | goto: "forst" |
missing_geometry | A hotspot has no geometry for its shape. | A polygon without points. |
missing_variable | A condition or set uses a variable not declared in vars. | { var: "scroe", gte: 1 } |
invalid_cue | A plugin's check rejected a cue. See Plugins' cues. | A chapter cue without a title. |
What gets checked
versionand everysrc,textandlabelare non-empty strings.- There's at least one scene, and
start,nextand everygotopoint to existing scenes. - Times, including every
seek, are finite and non-negative, and thetoof a hotspot or cue comes after itsfrom. - Question, hotspot and cue ids are unique across the story, and option ids within their question.
- Every question has at least one option.
multipleis a boolean.minandmaxonly appear onmultiplequestions, as whole numbers from 1 to the number of options, withmaxnot belowmin. Options of amultiplequestion have nogoto,branchesorseek.timeoutis a positive number.defaultonly appears with atimeoutand picks distinct options of its question, as many as an answer takes.areavalues are fractions from 0 to 1, with positive width and height, inside the frame.- Polygons have at least three points inside the frame.
- Every keyframe has a unique
atand a valideasing, and resolves to a usable geometry. - Every cue has a non-empty
kind. data, wherever it appears, is a JSON object: no functions,undefined,NaN,Infinityor cycles inside.varsvalues are strings, booleans or finite numbers. Conditions andsetonly use declared variables, every condition has an operator, and{ add }only applies to numbers.
Plugins' cues
IVKit only checks a cue's own fields. A plugin can check the rest, usually its data, by passing a check for its kind:
import { validateStory, type CueCheck } from "@ivkit/core";
const checkChapter: CueCheck = (cue, report) => {
const data = cue.data as { title?: unknown } | undefined;
if (typeof data?.title !== "string") report("/data/title", "A chapter needs a title.");
};
const result = validateStory(story, { cues: { chapter: checkChapter } });A check gets the cue after IVKit has checked it, as plain data, since its fields may still be invalid. It calls report with a JSON Pointer relative to the cue, or "" for the cue itself, and each report becomes an invalid_cue error with the full path, such as /scenes/intro/events/0/data/title. Cues of kinds without a check only get IVKit's checks.
In the editor and in CI
validateStory is small (about 2.8 KiB gzip) and has no dependencies, so it can run in a story editor on every change, or in a script that checks the story files in your repository:
// scripts/check-stories.ts
import { readFile } from "node:fs/promises";
import { validateStory } from "@ivkit/core";
let failed = false;
for (const file of process.argv.slice(2)) {
const result = validateStory(JSON.parse(await readFile(file, "utf8")));
for (const error of result.errors) {
console.error(`${file}#${error.path}: ${error.message}`);
failed = true;
}
}
process.exit(failed ? 1 : 0);