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
99 changes: 99 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

68 changes: 61 additions & 7 deletions docs/AnimationModel.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

## Status

This note records the intended animation model and authoring direction.

Some current engine schema and fixtures still expose `in/out/update` fields.
Those are compatibility shapes. The target design is the one described below.
This note records the current animation model and the background-specific
behavior implemented in the engine.

## Decision

Expand All @@ -22,7 +20,7 @@ There are only two structural animation kinds:

`in`, `out`, and `replace` are not separate structural types. They are semantic cases of `transition`.

At the engine authoring layer, the direction is to converge on a single animation reference:
At the engine authoring layer, animations use a single reference:

```yaml
background:
Expand All @@ -36,11 +34,13 @@ The referenced animation resource declares the structural type through its own `
- `type: update`
- `type: transition`

That means the engine does not need separate author-facing fields like:
Legacy resource types such as `live` and `replace` are not supported.

The legacy wrapper fields are no longer supported:

- `animations.in.resourceId`
- `animations.out.resourceId`
- `animations.replace.resourceId`
- `animations.update.resourceId`

## Motivation

Expand Down Expand Up @@ -68,6 +68,55 @@ In practice:

If a `transition` resolves to the same compatible visual subject on both sides, the runtime may optimize execution into a single-subject tween. That is an execution optimization, not a separate authoring concept.

## Current Background Behavior

Background animation dispatch is based on resolved presentation state, not only
the raw action shape.

### Animations-only background actions

If a line provides:

```yaml
background:
animations:
resourceId: bg-slide-out
```

and a background already exists in presentation state, the engine resolves the
next background as:

- same `resourceId` as the currently resolved background
- updated `animations`

That means this shape means "animate the current background from state", not
"remove the background".

### Same-subject transitions

Because the comparison is done against resolved previous and next presentation
state:

- repeating the same `background.resourceId` with a `transition` animates
- omitting `background.resourceId` and providing only `background.animations`
still animates if the background persists from state

For persisted backgrounds, the runtime treats the resolved subject as both the
`prev` and `next` side of the `transition`.

### Background update fallback

Background currently has one narrow compatibility fallback:

- if the referenced animation resource is `type: update`
- and the resolved lifecycle is not true `update`
- and there is an incoming background target

the engine animates the incoming background target instead of throwing.

This fallback exists for background enter/replace handling only. It should be
treated as compatibility behavior, not the general rule for all element types.

## State Rule

Animation-only values are not written back into stored presentation state.
Expand All @@ -85,6 +134,11 @@ On completion:
- `transition` commits the authored next state if `next` exists
- `transition` commits removal if `next` does not exist

For backgrounds specifically, an animations-only action may still resolve to a
persistent next background state if the previous presentation state already has
one. In that case the persisted `resourceId` comes from state resolution, not
from animation transforms being written back.

## Consequences

- high-level APIs like background and character do not need separate `in/out` animation fields
Expand Down
36 changes: 19 additions & 17 deletions docs/RouteEngine.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ engine.init({
For browser-backed save/load hydration, the runtime also exports
`createIndexedDbPersistence({ namespace })`. Use the same `namespace` both when
loading persisted data before init and when calling `engine.init(...)` so
different visual novels on the same domain do not share persistence.
different visual novels on the same domain do not share persistence. The
returned adapter also exposes `clear()` to delete persisted data for that
namespace.

Localization is not implemented in the current runtime. The planned
patch-based l10n model is documented in [L10n.md](./L10n.md).
Expand Down Expand Up @@ -389,18 +391,18 @@ Playback timing semantics:

### State Management Actions

| Action | Payload | Description |
| ------------------- | -------------------- | ------------------------------ |
| `setNextLineConfig` | `{ manual?, auto? }` | Configure line advancement |
| `updateProjectData` | `{ projectData }` | Replace project data |
| Action | Payload | Description |
| ------------------- | -------------------- | ------------------------------- |
| `setNextLineConfig` | `{ manual?, auto? }` | Configure line advancement |
| `updateProjectData` | `{ projectData }` | Replace project data |
| `resetStorySession` | - | Reset story-local session state |

### Registry Actions

| Action | Payload | Description |
| ------------------- | ----------------------- | ----------------------------- |
| `addViewedLine` | `{ sectionId, lineId }` | Mark line as viewed |
| `addViewedResource` | `{ resourceId }` | Mark resource as viewed |
| Action | Payload | Description |
| ------------------- | ----------------------- | ----------------------- |
| `addViewedLine` | `{ sectionId, lineId }` | Mark line as viewed |
| `addViewedResource` | `{ resourceId }` | Mark resource as viewed |

Seen-line semantics:

Expand All @@ -411,10 +413,10 @@ Seen-line semantics:

### Save System Actions

| Action | Payload | Description |
| -------------- | --------------------------------- | -------------------------- |
| `saveSlot` | `{ slotId, thumbnailImage? }` | Save game to a slot |
| `loadSlot` | `{ slotId }` | Load game from a slot |
| Action | Payload | Description |
| ---------- | ----------------------------- | --------------------- |
| `saveSlot` | `{ slotId, thumbnailImage? }` | Save game to a slot |
| `loadSlot` | `{ slotId }` | Load game from a slot |

Save/load design, requirements, and storage boundaries are documented in [SaveLoad.md](./SaveLoad.md).
Story-session reset semantics are documented in [StorySessionReset.md](./StorySessionReset.md).
Expand All @@ -432,12 +434,12 @@ Notes:

These actions exist inside the store/runtime but are not part of the stable authored/public API surface:

| Action | Payload | Description |
| --------------------- | ---------------------- | ---------------------- |
| Action | Payload | Description |
| --------------------- | ---------------------- | ----------------------------------- |
| `markLineCompleted` | - | Internal render-complete transition |
| `nextLineFromSystem` | - | Internal timer-driven advance |
| `appendPendingEffect` | `{ name, ...options }` | Queue a side effect |
| `clearPendingEffects` | - | Clear the effect queue |
| `appendPendingEffect` | `{ name, ...options }` | Queue a side effect |
| `clearPendingEffects` | - | Clear the effect queue |

Use these only if you are extending engine internals or writing engine-level tests.

Expand Down
1 change: 1 addition & 0 deletions docs/SaveLoad.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ The host app is responsible for:
- hydrating `initialState.global.saveSlots` from durable storage before engine init
- hydrating persistent global variables before engine init
- choosing and reusing a per-VN `namespace` during persistence hydration and `engine.init(...)`
- calling `createIndexedDbPersistence({ namespace }).clear()` when the host wants to wipe one VN's persisted data
- providing thumbnail image payloads when a save action wants one
- mapping dynamic UI/event data into the action `slotId` field when save/load is triggered from generated UI
- executing storage effects emitted by the engine
Expand Down
13 changes: 4 additions & 9 deletions docs/vt-guidelines.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# VT Guidelines

Last updated: 2026-03-24
Last updated: 2026-04-09

## Purpose

Expand All @@ -13,20 +13,15 @@ Define one stable standard for VT authoring in this repo:
## Runtime Notes

- The VT Docker flow runs Chromium in a software WebGL fallback path on this repo's current container/runtime setup.
- Full-size `1920x1080` Pixi capture on that path is expensive, so VT syncs a local ignored `vt/static/RouteGraphics.js` bundle before VT runs.
- The sync script downloads `route-graphics@1.1.4/dist/RouteGraphics.js` from jsDelivr by default.
- Override the source with `VT_ROUTE_GRAPHICS_URL`, or just the package version with `VT_ROUTE_GRAPHICS_VERSION`.
- That VT-only bundle is patched during sync to initialize Pixi with `resolution: 0.5` and `preserveDrawingBuffer: true`.
- Full-frame VT references are exported from that native half-resolution render surface.
- Keep this path VT-only. Do not copy these settings into the product runtime without a separate rendering review.
- Do not switch VT back to the CDN `route-graphics` import unless you re-benchmark the Docker capture path.
- VT now copies the published `route-graphics` package bundle from `node_modules` into the ignored local `vt/static/RouteGraphics.js` path during `bun run esbuild.js`.
- VT captures keep the existing `960x540` reference size by downscaling screenshot output inside the VT harness, not by patching `RouteGraphics`.
- Keep this path VT-only. Do not copy VT-specific screenshot scaling into the product runtime without a separate rendering review.

## VT Workflow

- Capture screenshots with `bun run vt:docker`.
- Generate the comparison report with `bun run vt:report`.
- Accept an intentional visual change with `rtgl vt accept`.
- If the sync step needs a different upstream build, point `VT_ROUTE_GRAPHICS_URL` at the desired CDN file.
- Local VT defaults to `2` Docker workers and a `60000ms` timeout.
- Local VT reports default to a `0.8%` diff threshold to tolerate small Docker text raster drift.
- VT is currently local-only. GitHub Actions VT is disabled again until the runner instability is fixed separately.
Expand Down
23 changes: 23 additions & 0 deletions esbuild.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,25 @@
import esbuild from "esbuild";
import fs from "node:fs";
import path from "node:path";

const copyVtRouteGraphicsBundle = () => {
const source = path.resolve(
"node_modules",
"route-graphics",
"dist",
"RouteGraphics.js",
);
const target = path.resolve("vt", "static", "RouteGraphics.js");

if (!fs.existsSync(source)) {
throw new Error(
"Missing route-graphics dist bundle. Run `bun install` before building VT assets.",
);
}

fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(source, target);
};

esbuild
.build({
Expand All @@ -14,6 +35,8 @@ esbuild
console.log("Build failed");
});

copyVtRouteGraphicsBundle();

esbuild
.build({
bundle: true,
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "route-engine-js",
"version": "0.7.4",
"version": "0.7.5",
"description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines",
"repository": {
"type": "git",
Expand Down Expand Up @@ -40,12 +40,11 @@
"lint": "bun run prettier src -c",
"lint:fix": "bun run prettier src -w",
"coverage": "bun run vitest run --coverage",
"vt:sync-route-graphics": "bun scripts/sync-vt-route-graphics.js",
"vt:generate": "bun scripts/sync-vt-route-graphics.js && bun run esbuild.js && rtgl vt generate",
"vt:docker": "bun scripts/sync-vt-route-graphics.js && bun run esbuild.js && docker run --rm ${VT_DOCKER_ARGS:-} --user $(id -u):$(id -g) -e RTGL_VT_DEBUG=true -v \"$PWD:/app\" -w /app docker.io/han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.10 rtgl vt screenshot --wait-event vt:ready --concurrency ${VT_DOCKER_CONCURRENCY:-2} --timeout ${VT_DOCKER_TIMEOUT:-60000}",
"vt:generate": "bun run esbuild.js && rtgl vt generate",
"vt:docker": "bun run esbuild.js && docker run --rm ${VT_DOCKER_ARGS:-} --user $(id -u):$(id -g) -e RTGL_VT_DEBUG=true -v \"$PWD:/app\" -w /app docker.io/han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.10 rtgl vt screenshot --wait-event vt:ready --concurrency ${VT_DOCKER_CONCURRENCY:-2} --timeout ${VT_DOCKER_TIMEOUT:-60000}",
"vt:report": "bun run vt:docker && rtgl vt report --diff-threshold ${VT_REPORT_DIFF_THRESHOLD:-0.8}",
"vt:accept": "rtgl vt accept",
"serve": "bun run esbuild.js && bunx serve -p 3004 .rettangoli/vt/_site"
"serve": "bun run vt:generate && bunx serve -p 3004 .rettangoli/vt/_site"
},
"dependencies": {
"immer": "^10.1.1",
Expand All @@ -59,6 +58,7 @@
"ajv": "^8.18.0",
"husky": "^9.1.7",
"prettier": "^3.7.4",
"route-graphics": "1.7.0",
"vitest": "^4.0.16"
}
}
44 changes: 0 additions & 44 deletions scripts/sync-vt-route-graphics.js

This file was deleted.

50 changes: 50 additions & 0 deletions spec/indexedDbPersistence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ class FakeObjectStore {

return request;
}

delete(key) {
const request = new FakeRequest();

this.transaction.track(() => {
this.definition.records.delete(key);
request.result = undefined;
request.onsuccess?.({ target: request });
});

return request;
}
}

class FakeTransaction {
Expand Down Expand Up @@ -262,6 +274,44 @@ describe("indexedDbPersistence", () => {
);
});

it("clears persisted data for a single namespace", async () => {
const indexedDB = createFakeIndexedDB();
const alphaPersistence = createIndexedDbPersistence({
indexedDB,
namespace: "vn-alpha",
});
const betaPersistence = createIndexedDbPersistence({
indexedDB,
namespace: "vn-beta",
});

await alphaPersistence.saveSlots({
1: {
slotId: 1,
savedAt: 1700000000000,
},
});
await betaPersistence.saveGlobalAccountVariables({
routeUnlocked: true,
});

await alphaPersistence.clear();

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

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

it("normalizes namespace values", () => {
expect(normalizeNamespace(" sample-vn ")).toBe("sample-vn");
expect(normalizeNamespace("")).toBeNull();
Expand Down
Loading
Loading