diff --git a/CLAUDE.md b/CLAUDE.md index 50518132..d91475e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,13 +116,13 @@ When you add a new feature that needs to write data, route it through `uuid`/`position`/`rotation`/`scale`/`visible`/`physics`/`_internal.three` only — there is **no** `gameObject.game`; in the editor `game` is typically `undefined`, so guard any `game.renderer.*` access. -- Lifecycle docs: `docs/behaviors/`. +- Lifecycle docs, catalog, and authoring guide: `docs/built-in-behaviors.md`. ## Lambdas (ECS) `client/packages/editor-oss/src/lambdas/` — archetype-driven systems on top of behaviors. Use when you need batched, dependency-scheduled work. -See `docs/lambdas/` for the architecture internals. +See `docs/lambdas.md` for the architecture internals. ## Scheduler, rendering, quality @@ -135,8 +135,10 @@ lambda systems directly. Two engines, one adapter surface: `client/packages/editor-oss/src/physics/` plus the per-behavior glue in -`behaviors/erth/physics/`. Memory management for Ammo and shape-system -conventions are non-obvious — read `docs/physics/` before touching either. +`behaviors/stem/physics/`. Memory management for Ammo and shape-system +conventions are non-obvious — read the physics helper / `PhysicsSettings` / +`RigidBodyHandle` sections of `docs/gameobject-and-game-manager-api.md` and the +code itself before touching either. ## Editor UI @@ -243,19 +245,24 @@ Pause and ask before: | You're touching... | Read first | |---|---| -| Behaviors / game logic | `behaviors/Behavior.ts`, then `docs/behaviors/` | -| Lambdas / ECS | `lambdas/`, then `docs/lambdas/` | -| Scheduler / frame loop / quality | `scheduler/`, `core/quality/`, `docs/engine-core/` | -| Physics | `physics/`, `behaviors/erth/physics/`, `docs/physics/` | -| Editor UI / import / camera | `editor/`, `controls/`, `serialization/`, `docs/editor/` | -| Runtime UI / HUD / UIKit | `behaviors/uikit/`, `behaviors/hud/`, `docs/ui/` | -| Multiplayer | `multiplayer/`, `multiplayer/worker/`, `docs/multiplayer/` | -| AI integration | `copilot/`, `agent/`, `server/server/controllers/tools/ai/`, `docs/ai/` | -| AI server | `server/main.go`, `server/server/server.go`, `docs/infrastructure/` | -| Persistence | `persistence/`, `docs/editor/` (asset-management.md) | -| Scene serialization | `object/`, `serialization/`, `docs/ARCHITECTURE.md` | -| Three.js conventions | `EngineRuntime.ts`, `render/`, `docs/engine-core/threejs-conventions.md` | -| Art budgets | `docs/art-specs/ART_SPECS.md` | +| Behaviors / game logic | `behaviors/Behavior.ts`, then `docs/built-in-behaviors.md` | +| Lambdas / ECS | `lambdas/`, then `docs/lambdas.md` | +| Import packs / reusable scripts | `editor/scripts/builtinPacks/`, then `docs/import-packs.md` | +| Runtime / engine API (`this.stem.*`) | `EngineRuntime.ts`, then `docs/runtime-api.md` | +| GameObject / GameManager API | `object/`, `behaviors/game/GameManager.ts`, then `docs/gameobject-and-game-manager-api.md` | +| Scheduler / frame loop / quality | `scheduler/`, `core/quality/`, then `docs/scheduler-and-editor-settings.md` | +| Physics | `physics/`, `behaviors/stem/physics/`, then `docs/gameobject-and-game-manager-api.md` (PhysicsSettings / RigidBodyHandle) | +| Editor UI / import / camera | `editor/`, `controls/`, `serialization/` (no dedicated doc) | +| Runtime UI / HUD / UIKit | `behaviors/uikit/`, `behaviors/hud/`, then `docs/uikit-api.md` | +| Multiplayer | `multiplayer/`, `multiplayer/worker/`, then `docs/multiplayer.md` | +| AI integration (client) | `copilot/`, `agent/`, `server/server/controllers/tools/ai/` | +| AI server (Go) | `server/cmd/ai-server/`, `server/server/server.go`, then `docs/architecture.md` | +| BYOK / provider keys | `docs/byok.md` | +| Persistence | `persistence/`, then `docs/server-side-storage.md` | +| Scene serialization | `object/`, `serialization/`, then `docs/architecture.md` | +| Three.js conventions | `EngineRuntime.ts`, `render/` (no dedicated doc) | +| Exporting a game | `scripts/`, then `docs/exporting-a-game.md` | +| Art budgets | `blog/docs/assets/10-art-specs.md` | ## Axis conventions diff --git a/README.md b/README.md index 858ad6c6..26342c5e 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,10 @@ If all three print a version, you're ready for [Quick start](#quick-start). ## Documentation - [Architecture overview](./docs/architecture.md) — how the editor, AI server, and multiplayer sidecar fit together. +- [Scheduler & editor settings](./docs/scheduler-and-editor-settings.md) — frame scheduler architecture, quality presets, performance controls, and profiling tools. +- [Built-in behaviors](./docs/built-in-behaviors.md) — the behavior model, the full catalog, and how to attach or author one. +- [Lambdas (ECS layer)](./docs/lambdas.md) — batched, dependency-scheduled systems over many objects, with examples. +- [Import packs](./docs/import-packs.md) — curated reusable script modules (`noise`, `prng`, `uikit-dual-mode`) and the `@import` workflow. - [BYOK setup](./docs/byok.md) — connect your AI provider keys. - [Multiplayer guide](./docs/multiplayer.md) — local sidecar and self-hosted deployment. - [Exporting a game](./docs/exporting-a-game.md) — package a Player-only static site. diff --git a/client/packages/editor-oss/src/agent/script-tool/ScriptCommandParser.test.ts b/client/packages/editor-oss/src/agent/script-tool/ScriptCommandParser.test.ts index 63daf1f3..562b5e67 100644 --- a/client/packages/editor-oss/src/agent/script-tool/ScriptCommandParser.test.ts +++ b/client/packages/editor-oss/src/agent/script-tool/ScriptCommandParser.test.ts @@ -311,6 +311,13 @@ describe("ScriptCommandParser.parse", () => { expect(result.params.filter).toBe("box"); }); + it("parses list lights to a read-only scene query", () => { + // Lights are scene objects; `list lights` aliases to get_scene_objects + // so the copilot can enumerate them during inspection. + const result = ScriptCommandParser.parse("list lights"); + expect(result.command).toBe("get_scene_objects"); + }); + it("parses get with target", () => { const result = ScriptCommandParser.parse("get MyBox"); expect(result.command).toBe("get_object"); diff --git a/client/packages/editor-oss/src/agent/script-tool/aliases.ts b/client/packages/editor-oss/src/agent/script-tool/aliases.ts index 2f6c57e8..01b664aa 100644 --- a/client/packages/editor-oss/src/agent/script-tool/aliases.ts +++ b/client/packages/editor-oss/src/agent/script-tool/aliases.ts @@ -110,6 +110,9 @@ export const ALIAS_MAP: Record = { // Scene queries "list objects": {command: "get_scene_objects"}, + // Lights are scene objects; enumerate them via the scene tree, then read + // one with `get light ` (-> get_light_settings). + "list lights": {command: "get_scene_objects"}, "list assets": {command: "list_scene_assets"}, "list imports": {command: "list_scene_assets", staticParams: {type: "imports"}}, "list files": {command: "list_scene_assets", staticParams: {type: "files"}}, diff --git a/client/packages/editor-oss/src/agent/script-tool/checkScript.test.ts b/client/packages/editor-oss/src/agent/script-tool/checkScript.test.ts index 11a5231b..4a2acbdd 100644 --- a/client/packages/editor-oss/src/agent/script-tool/checkScript.test.ts +++ b/client/packages/editor-oss/src/agent/script-tool/checkScript.test.ts @@ -1,8 +1,22 @@ import {describe, expect, it, vi} from "vitest"; -import {deriveCheckPlan, deriveCheckProbes, runScriptCheck} from "./checkScript"; +import {deriveCheckPlan, deriveCheckProbes, isReadOnlyCommand, runScriptCheck} from "./checkScript"; import {ScriptExecutor} from "./ScriptExecutor"; +describe("isReadOnlyCommand", () => { + it("classifies get_/list_/search_ prefixes and player/select as read-only", () => { + for (const cmd of ["get_scene_objects", "get_light_settings", "list_behaviors", "list_lambdas", "search_external_assets", "player", "select"]) { + expect(isReadOnlyCommand(cmd), cmd).toBe(true); + } + }); + + it("classifies mutating commands as not read-only", () => { + for (const cmd of ["modify_object", "create_primitive", "set_light_properties", "delete_object", "attach_behavior", "import", "save"]) { + expect(isReadOnlyCommand(cmd), cmd).toBe(false); + } + }); +}); + describe("checkScript probe derivation", () => { it("derives an object getter probe for primitive creation", () => { const lines = ScriptExecutor.parseScript('add box name="Crate" position=1,2,3 color=#ff0000'); diff --git a/client/packages/editor-oss/src/agent/script-tool/checkScript.ts b/client/packages/editor-oss/src/agent/script-tool/checkScript.ts index 68436f8a..a355d0e7 100644 --- a/client/packages/editor-oss/src/agent/script-tool/checkScript.ts +++ b/client/packages/editor-oss/src/agent/script-tool/checkScript.ts @@ -537,7 +537,7 @@ function deriveProbeForCommand( return probes; } -function isReadOnlyCommand(command: string): boolean { +export function isReadOnlyCommand(command: string): boolean { return READ_ONLY_PREFIXES.some(prefix => command.startsWith(prefix)) || command === "player" || command === "select"; } diff --git a/client/packages/editor-oss/src/copilot/playgroundStemscriptPlan.test.ts b/client/packages/editor-oss/src/copilot/playgroundStemscriptPlan.test.ts index 5272b6a5..cf12db37 100644 --- a/client/packages/editor-oss/src/copilot/playgroundStemscriptPlan.test.ts +++ b/client/packages/editor-oss/src/copilot/playgroundStemscriptPlan.test.ts @@ -111,6 +111,22 @@ describe("playgroundStemscriptPlan", () => { expect(() => validateInspectionStemscript("behavior attach Player behaviorId=character")).toThrow(/not allowed in inspection/); }); + it("allows `list lights` and any engine read-only command in inspection", () => { + // Regression: `list lights` previously aborted the whole request because + // it was absent from a narrow hardcoded allowlist. It now resolves to a + // real read-only command (get_scene_objects) and is permitted. + const result = validateInspectionStemscript( + ["list lights", "get light DirectionalLight", "get render settings"].join("\n"), + ); + expect(result.executableCommands).toBe(3); + expect(result.script).toContain("list lights"); + + // Read-only-prefixed commands the playground globally disallows (external + // search, library, project tasks) stay rejected even in inspection. + expect(() => validateInspectionStemscript("search_external_assets")).toThrow(/not allowed in inspection/); + expect(() => validateInspectionStemscript("list_project_tasks")).toThrow(/not allowed in inspection/); + }); + it("rejects file and external-asset commands in playground mode", () => { expect(() => validateGeneratedStemscript("import model Tree filepath=models/tree.glb")).toThrow(/not allowed/); expect(() => validateGeneratedStemscript("generate model prompt=\"make a spaceship\"")).toThrow(/not allowed/); diff --git a/client/packages/editor-oss/src/copilot/playgroundStemscriptPlan.ts b/client/packages/editor-oss/src/copilot/playgroundStemscriptPlan.ts index d560ffc4..e2f50909 100644 --- a/client/packages/editor-oss/src/copilot/playgroundStemscriptPlan.ts +++ b/client/packages/editor-oss/src/copilot/playgroundStemscriptPlan.ts @@ -1,3 +1,4 @@ +import {isReadOnlyCommand} from "../agent/script-tool/checkScript"; import {ScriptExecutor} from "../agent/script-tool/ScriptExecutor"; export interface PlaygroundStemscriptPlan { @@ -32,29 +33,12 @@ const DISALLOWED_COMMANDS = new Set([ "add_model_to_scene", "set_external_texture", ]); -const READ_ONLY_COMMANDS = new Set([ - "get_scene_objects", - "get_object", - "get_object_settings", - "get_material_settings", - "get_behavior_settings", - "get_selected_object", - "get_player", - "list_scene_assets", - "get_scene_asset", - "list_behaviors", - "get_behavior", - "list_lambdas", - "get_lambda", - "get_physics_settings", - "get_light_settings", - "get_vfx", - "list_prefabs", - "get_prefab", - "get_camera_settings", - "get_editor_settings", - "get_scene_setting", -]); +// Inspection allows any command the engine classifies as read-only +// (get_/list_/search_ + player/select via isReadOnlyCommand), so the copilot +// can inspect the full scene and every asset type — except commands the +// playground globally disallows (external search, library, project tasks). +const isAllowedInspectionCommand = (command: string): boolean => + isReadOnlyCommand(command) && !DISALLOWED_COMMANDS.has(command); const stripCodeFence = (value: string): string => { const trimmed = value.trim(); @@ -159,7 +143,7 @@ export function validateGeneratedStemscript(script: string): ValidatedStemscript } export function validateInspectionStemscript(script: string): ValidatedStemscript { - return validateStemscript(script, command => !READ_ONLY_COMMANDS.has(command), "inspection"); + return validateStemscript(script, command => !isAllowedInspectionCommand(command), "inspection"); } function validateStemscript( diff --git a/client/packages/site/src/components/MarkdownPage.tsx b/client/packages/site/src/components/MarkdownPage.tsx index e2326747..5f8e4545 100644 --- a/client/packages/site/src/components/MarkdownPage.tsx +++ b/client/packages/site/src/components/MarkdownPage.tsx @@ -7,29 +7,58 @@ import type {DocEntry} from "../content/docs-nav"; // Build-time raw imports keep docs in the static bundle (no fetch at runtime). // Vite's `?raw` query returns the file as a string. import architectureMd from "../../../../../docs/architecture.md?raw"; +import builtInBehaviorsMd from "../../../../../docs/built-in-behaviors.md?raw"; import byokMd from "../../../../../docs/byok.md?raw"; import exportingMd from "../../../../../docs/exporting-a-game.md?raw"; import gameObjectAndGameManagerApiMd from "../../../../../docs/gameobject-and-game-manager-api.md?raw"; +import importPacksMd from "../../../../../docs/import-packs.md?raw"; +import lambdasMd from "../../../../../docs/lambdas.md?raw"; import multiplayerMd from "../../../../../docs/multiplayer.md?raw"; import runtimeApiMd from "../../../../../docs/runtime-api.md?raw"; +import schedulerAndEditorSettingsMd from "../../../../../docs/scheduler-and-editor-settings.md?raw"; import serverSideStorageMd from "../../../../../docs/server-side-storage.md?raw"; import uikitApiMd from "../../../../../docs/uikit-api.md?raw"; import readmeMd from "../../../../../README.md?raw"; import contributingMd from "../../../../../CONTRIBUTING.md?raw"; +import defaultSceneSettingsImg from "../../../../../docs/assets/default-scene-settings.png"; +import directionalLightSettingsImg from "../../../../../docs/assets/directional-light-settings.png"; +import editorProjectTabMapImg from "../../../../../docs/assets/editor-project-tab-map.png"; +import projectSettingsOverviewImg from "../../../../../docs/assets/project-settings-overview.png"; +import schedulerBehaviorPerformanceImg from "../../../../../docs/assets/scheduler-behavior-performance.png"; +import schedulerControlsImg from "../../../../../docs/assets/scheduler-controls.png"; +import schedulerLambdaExplorerImg from "../../../../../docs/assets/scheduler-lambda-explorer.png"; +import schedulerQualityPresetsImg from "../../../../../docs/assets/scheduler-quality-presets.png"; +import schedulerSettingsOverviewImg from "../../../../../docs/assets/scheduler-settings-overview.png"; const SOURCES: Record = { "repo-docs:architecture.md": architectureMd, + "repo-docs:built-in-behaviors.md": builtInBehaviorsMd, "repo-docs:byok.md": byokMd, "repo-docs:exporting-a-game.md": exportingMd, "repo-docs:gameobject-and-game-manager-api.md": gameObjectAndGameManagerApiMd, + "repo-docs:import-packs.md": importPacksMd, + "repo-docs:lambdas.md": lambdasMd, "repo-docs:multiplayer.md": multiplayerMd, "repo-docs:runtime-api.md": runtimeApiMd, + "repo-docs:scheduler-and-editor-settings.md": schedulerAndEditorSettingsMd, "repo-docs:server-side-storage.md": serverSideStorageMd, "repo-docs:uikit-api.md": uikitApiMd, "repo-root:README.md": readmeMd, "repo-root:CONTRIBUTING.md": contributingMd, }; +const IMAGE_SOURCES: Record = { + "docs/assets/default-scene-settings.png": defaultSceneSettingsImg, + "docs/assets/directional-light-settings.png": directionalLightSettingsImg, + "docs/assets/editor-project-tab-map.png": editorProjectTabMapImg, + "docs/assets/project-settings-overview.png": projectSettingsOverviewImg, + "docs/assets/scheduler-behavior-performance.png": schedulerBehaviorPerformanceImg, + "docs/assets/scheduler-controls.png": schedulerControlsImg, + "docs/assets/scheduler-lambda-explorer.png": schedulerLambdaExplorerImg, + "docs/assets/scheduler-quality-presets.png": schedulerQualityPresetsImg, + "docs/assets/scheduler-settings-overview.png": schedulerSettingsOverviewImg, +}; + interface Props { entry: DocEntry; } @@ -70,10 +99,18 @@ function rewriteRepoLinks(md: string, entry: DocEntry): string { // Anchor non-curated relative links to the GitHub source so users don't // get 404s when a doc links to a file we haven't surfaced on the site. const basePath = entry.source === "repo-root" ? "" : "docs/"; - return md.replace(/\]\((?!https?:|#|\/)([^)]+)\)/g, (_match, rel: string) => { - if (rel.startsWith("docs/")) { - return `](${GITHUB_URL}/blob/main/${rel})`; - } - return `](${GITHUB_URL}/blob/main/${basePath}${rel})`; + const rawGithubBase = GITHUB_URL.replace("https://github.com/", "https://raw.githubusercontent.com/"); + return md.replace(/(!?)\[([^\]]*)\]\((?!https?:|#|\/)([^)]+)\)/g, (_match, bang: string, label: string, rel: string) => { + const repoPath = normalizeRepoPath(rel.startsWith("docs/") ? rel : `${basePath}${rel}`); + const target = bang + ? (IMAGE_SOURCES[repoPath] ?? `${rawGithubBase}/main/${repoPath}`) + : `${GITHUB_URL}/blob/main/${repoPath}`; + return `${bang}[${label}](${target})`; }); } + +function normalizeRepoPath(path: string): string { + return path + .replace(/^\.\//, "") + .replace(/\/\.\//g, "/"); +} diff --git a/client/packages/site/src/content/docs-nav.ts b/client/packages/site/src/content/docs-nav.ts index fd8052ac..1dd9d8ff 100644 --- a/client/packages/site/src/content/docs-nav.ts +++ b/client/packages/site/src/content/docs-nav.ts @@ -21,10 +21,19 @@ export const DOC_SECTIONS: DocSection[] = [ label: "Engine", entries: [ {slug: "architecture", title: "Architecture", source: "repo-docs", file: "architecture.md"}, + {slug: "scheduler-and-editor-settings", title: "Scheduler & editor settings", source: "repo-docs", file: "scheduler-and-editor-settings.md"}, {slug: "exporting-a-game", title: "Exporting a game", source: "repo-docs", file: "exporting-a-game.md"}, {slug: "server-side-storage", title: "Server-side storage & version control", source: "repo-docs", file: "server-side-storage.md"}, ], }, + { + label: "Scripting", + entries: [ + {slug: "built-in-behaviors", title: "Built-in behaviors", source: "repo-docs", file: "built-in-behaviors.md"}, + {slug: "lambdas", title: "Lambdas (ECS)", source: "repo-docs", file: "lambdas.md"}, + {slug: "import-packs", title: "Import packs", source: "repo-docs", file: "import-packs.md"}, + ], + }, { label: "APIs", entries: [ diff --git a/docs/assets/default-scene-settings.png b/docs/assets/default-scene-settings.png new file mode 100644 index 00000000..cb53acb2 Binary files /dev/null and b/docs/assets/default-scene-settings.png differ diff --git a/docs/assets/directional-light-settings.png b/docs/assets/directional-light-settings.png new file mode 100644 index 00000000..f3cdc50b Binary files /dev/null and b/docs/assets/directional-light-settings.png differ diff --git a/docs/assets/editor-project-tab-map.png b/docs/assets/editor-project-tab-map.png new file mode 100644 index 00000000..1105e2a2 Binary files /dev/null and b/docs/assets/editor-project-tab-map.png differ diff --git a/docs/assets/project-settings-overview.png b/docs/assets/project-settings-overview.png new file mode 100644 index 00000000..435ce1cd Binary files /dev/null and b/docs/assets/project-settings-overview.png differ diff --git a/docs/assets/scheduler-behavior-performance.png b/docs/assets/scheduler-behavior-performance.png new file mode 100644 index 00000000..42414278 Binary files /dev/null and b/docs/assets/scheduler-behavior-performance.png differ diff --git a/docs/assets/scheduler-controls.png b/docs/assets/scheduler-controls.png new file mode 100644 index 00000000..03d00854 Binary files /dev/null and b/docs/assets/scheduler-controls.png differ diff --git a/docs/assets/scheduler-lambda-explorer.png b/docs/assets/scheduler-lambda-explorer.png new file mode 100644 index 00000000..89111570 Binary files /dev/null and b/docs/assets/scheduler-lambda-explorer.png differ diff --git a/docs/assets/scheduler-quality-presets.png b/docs/assets/scheduler-quality-presets.png new file mode 100644 index 00000000..4a5ca34d Binary files /dev/null and b/docs/assets/scheduler-quality-presets.png differ diff --git a/docs/assets/scheduler-settings-overview.png b/docs/assets/scheduler-settings-overview.png new file mode 100644 index 00000000..e4585a13 Binary files /dev/null and b/docs/assets/scheduler-settings-overview.png differ diff --git a/docs/built-in-behaviors.md b/docs/built-in-behaviors.md new file mode 100644 index 00000000..2d6a7ba8 --- /dev/null +++ b/docs/built-in-behaviors.md @@ -0,0 +1,523 @@ +# Built-in Behaviors + +A **behavior** is a script attached to a single scene object. It gets a +lifecycle (init, start, per-frame update, dispose), a handle to the engine, +and a bag of per-instance attributes. If you have ever written a Unity +`MonoBehaviour` or a Unreal `Component`, this is the same idea. + +StemStudio ships ~40 built-in behaviors so you rarely start from scratch: +character controllers, triggers, sound, joints, billboards, spawners, and +more. **Reuse a built-in before you author a custom one** — search the catalog +below first. + +This doc covers: the model, how to attach one in the editor, the catalog of +built-ins, and how to author your own. For lambdas (the batched ECS layer) see +[`lambdas.md`](./lambdas.md); for reusable code modules see +[`import-packs.md`](./import-packs.md). + +--- + +## The model + +Built-in behaviors live in +`client/packages/editor-oss/src/behaviors/packs//`. Each pack is a +TypeScript class extending `BehaviorBase` plus a `behavior.json` declaring its +attributes and defaults. + +- **Base class & lifecycle** — `src/behaviors/Behavior.ts` +- **Registry** — `src/behaviors/BehaviorTypeRegistry.ts` (maps an id like + `"trigger"` to its constructor) +- **Manager** — `src/behaviors/BehaviorManager.ts` (creates, attaches, hydrates + configs, drives the update loop) +- **Scene shape** — `src/behaviors/BehaviorData.ts` + +Saved scenes store only the behavior **id** and the **per-instance attribute +overrides**, in `object.userData.behaviors`. Full defaults rehydrate from the +in-process registry on load, so built-in upgrades flow into existing projects. + +```jsonc +// object.userData.behaviors — one entry per attached behavior +{ + "id": "genericSound", // looked up in BehaviorTypeRegistry + "uuid": "…", // unique per instance + "enabled": true, + "priority": 0, + "attributesData": { // overrides merged onto registry defaults + "looping": true, + "volume": 0.6 + } +} +``` + +### Lifecycle hooks + +Override only the hooks you need. They are defined on the `Behavior` interface +in `Behavior.ts`. + +| Hook | When it fires | +|---|---| +| `init(game)` | Once at instantiation. Async-friendly. Target not yet attached. | +| `onStart()` | When attached to the object (target available). Prefer over the deprecated `onAdded()`. | +| `update(deltaTime)` | Every frame in play mode. | +| `fixedUpdate(dt)` | Fixed timestep, for physics-coupled logic. | +| `onAttributesUpdated()` | An attribute changed (e.g. the user edited a field). | +| `onPaused()` / `onResumed()` / `onReset()` | Pause / resume / game reset. | +| `onEvent(msg, data)` | A custom event was delivered to this behavior. | +| `onStateUpdated(key, value)` | Multiplayer state in `GameManager.storage` changed. | +| `onStop()` / `dispose()` | Removed / destroyed. Dispose Three.js resources here. | + +**Editor-mode hooks** run in the editor where `init(game)` is *never* called: +`onEditorAdded(editor)`, `onEditorUpdate()`, `onEditorAttributesUpdated()`, +`onEditorButtonClicked(action)`. + +> **Common bug.** Do not cache `const erth = this.stem` only inside `init()` and +> then use that local from an editor hook — `init()` did not run there, so the +> local is `undefined`. Always read `this.stem` / `this.gameObject` directly at +> the top of each entry point. There is no `gameObject.game`; in the editor +> `game` is typically `undefined`, so guard any `game.renderer.*` access. + +### Reaching the engine + +From inside a behavior: + +- `this.stem` — the engine API (`this.stem.asset`, `this.stem.physics`, + `this.stem.scene`, `this.stem.ai`, …). This is the current name. + `this.erth` and `this.stemEngine` are **deprecated aliases** that still work, + so you will see `this.erth` in older built-ins. +- `this.gameObject` — the wrapper around the attached object. Exposes + `uuid`/`position`/`rotation`/`scale`/`visible`/`physics`/`_internal.three`. +- `this.target` — the raw `THREE.Object3D` (deprecated in favor of + `this.gameObject`). +- `this.getAttribute(key)` — read a per-instance attribute. + +--- + +## Inter-behavior communication + +Use the narrowest communication path that matches the job: + +| Pattern | Use it for | +|---|---| +| Targeted `onEvent()` messages | One object telling behaviors on another object to react. This is the preferred behavior-to-behavior path. | +| `this.erth.behaviors.find*()` | Reading or requesting changes from a known behavior. | +| `this.erth.events.on()` | Engine-wide topics such as auth, score/lives, UI, or built-in gameplay events. Do not use it for ordinary behavior-to-behavior dispatch. | + +### Targeted messages + +Receiver: + +```js +this.onEvent = function (msg, data) { + if (msg !== "door.open") return; + this._open = true; + this._openedBy = data?.sourceName || ""; +}; +``` + +Sender: + +```js +this.triggerDoor = function (doorObject) { + this.game.behaviorManager.sendEventToObjectBehaviors( + doorObject, + "door.open", + {source: this.target.uuid, sourceName: this.target.name} + ); +}; +``` + +`sendEventToObjectBehaviors(target, msg, data, exceptIds?)` delivers the event +to every behavior attached to `target`. Use `exceptIds` when a behavior should +not receive its own event. + +### Finding and changing behaviors + +The `erth.behaviors` helper returns safe foreign-behavior views: + +```js +this.onStart = function () { + this._health = this.erth.behaviors.find(this.target, "health"); +}; + +this.damage = function (amount) { + if (!this._health) return; + const current = Number(this.erth.behaviors.getAttribute(this._health, "hp") ?? 0); + this.erth.behaviors.requestChange(this._health, "hp", Math.max(0, current - amount)); +}; +``` + +Use `find(target, id)` for a behavior on a specific object, +`findOnObject(target)` to list everything on an object, and `findAll(id)` to +query the scene by behavior id. + +### Engine-wide events + +Engine events are subscriptions, so always clean them up: + +```js +this.onStart = function () { + this._offScore = this.erth.events.on("game.score", (_topic, amount) => { + this.target.userData.lastScore = amount; + }); +}; + +this.dispose = function () { + this._offScore?.(); + this._offScore = null; +}; +``` + +Use this for engine topics and built-in systems. For custom gameplay messages +between objects, prefer targeted `onEvent()` dispatch so lifetimes stay local to +the target object. + +--- + +## Attaching a built-in behavior (step by step) + +1. **Select an object** in the scene tree (e.g. a coin mesh). +2. Open the **right-hand properties panel** and find the **Behaviors** section. +3. Click **Add Behavior** and pick one from the searchable list — e.g. + `genericSound`. +4. **Configure its attributes** inline. For `genericSound` that's the audio + asset, `looping`, `volume`, `positional`, `autoPlay`. Edits fire + `onAttributesUpdated()` live. +5. **Press Play.** The behavior's `init` → `onStart` → `update` loop runs. For + `genericSound`, the clip loads and (if `autoPlay`) plays. +6. **Save.** Only the id + your overrides are written to + `object.userData.behaviors`; everything else rehydrates from the registry. + +To remove one, use the **×** next to it in the Behaviors section. To toggle it +without removing, flip its `enabled` checkbox. + +> Some behaviors seed object-level state on attach. The `character` behavior, +> for instance, declares a default physics capsule. If your object sets physics +> explicitly (e.g. `enabled: false` on a ship), that explicit setting is +> respected — the behavior default only seeds objects that have *no* explicit +> physics. Keep that in mind when stacking controllers. + +--- + +## The catalog + +All under `src/behaviors/packs/`. Pick the closest match before writing custom +code. + +### Movement & control +| Behavior | What it does | +|---|---| +| `character` | Player controller: walk/run, jump, climb. Seeds a capsule body. | +| `npc` / `aiNpc` | Non-player character; `aiNpc` adds navmesh pathfinding. | +| `enemy` | AI adversary. | +| `follow` | Track a target (camera, parent, player). | +| `platform` | Moving / patrolling platform. | +| `touchControls` | Mobile on-screen joystick, buttons, steering wheel. | +| `cinematicCamera` | Scripted camera moves. | + +### Interaction & events +| Behavior | What it does | +|---|---| +| `trigger` | The workhorse event system: fire actions on collision, input, timer, distance, line-of-sight, metadata, … | +| `objectInteractions` | Pick up / use / interact with objects. | +| `consumable` | Health / ammo / buff pickups. | +| `shop` | NPC shop interaction. | +| `teleport` | Warp between locations. | +| `jumppad` | Velocity boost on contact. | +| `destructible` | Breakable objects. | +| `enableDisable` | Enable/disable other objects. | + +### Spawning & world +| Behavior | What it does | +|---|---| +| `spawnpoint` | Marks a player spawn location. | +| `randomizedSpawner` | Spawns objects on a timer/condition. | +| `projectile` | Fired projectile. | +| `dayNightCycle` | Animated lighting / time of day. | +| `terrain` | Terrain mesh handling. | +| `navmesh` / `navmesh-connection` | Navigation mesh and links between regions. | +| `cesium` | CesiumJS geographic data. | + +### Audio, graphics & FX +| Behavior | What it does | +|---|---| +| `genericSound` | Play an audio clip, positional or 2D, looping or one-shot. | +| `animation` | Play/stop skeletal or object animations; trigger via event. | +| `tween` | Interpolate properties over time. | +| `visualEffect` | Particles / VFX. | +| `postFx` | Post-processing effects. | +| `skybox` | Environment skybox. | +| `billboard` / `image_billboard` / `video_billboard` | Face-camera quads for sprites / images / video. | +| `csm` | Cascaded shadow mapping. | + +### Physics joints +| Behavior | What it does | +|---|---| +| `jointFixed` | Rigid constraint between two bodies. | +| `jointHinge` | Revolute joint (doors, wheels). | +| `jointPoint2Point` | Ball-and-socket joint. | + +(`testBehavior`, `testAttributesBehavior`, `spawn`, `volume` are dev/legacy +helpers.) + +--- + +## Authoring custom behaviors + +There are two authoring paths: + +- **In the editor** — use this for project/game logic. The Behavior Creator + saves a behavior asset with code, attributes, documentation, and revisions. +- **In engine source** — use this only when you are adding a reusable built-in + pack that should ship with every project. + +For gameplay authors, start in the editor. Source-code packs are covered after +the editor examples. + +### Create one in the editor: `SpinPickup` + +1. Open the **Assets** sidebar. +2. Click the **New** menu and choose **New Behavior**. +3. Name it `SpinPickup`. +4. Add these attributes in the right-hand settings panel: + +| Key | Type | Default | Purpose | +|---|---|---:|---| +| `spinSpeed` | number | `2` | Radians per second around the Y axis. | +| `bobHeight` | number | `0.2` | Vertical bob distance in world units. | +| `bobSpeed` | number | `3` | Bob cycles per second-ish. | + +Paste this into the behavior code editor: + +```js +this.applyConfig = function () { + this._spinSpeed = Number(this.getAttribute("spinSpeed") ?? 2); + this._bobHeight = Number(this.getAttribute("bobHeight") ?? 0.2); + this._bobSpeed = Number(this.getAttribute("bobSpeed") ?? 3); +}; + +this.onStart = function () { + this.applyConfig(); + this._time = 0; + this._baseY = this.target.position.y; +}; + +this.onAttributesUpdated = function () { + this.applyConfig(); +}; + +this.update = function (deltaTime) { + this._time += deltaTime; + this.target.rotation.y += this._spinSpeed * deltaTime; + this.target.position.y = this._baseY + Math.sin(this._time * this._bobSpeed) * this._bobHeight; +}; +``` + +Then select a coin, gem, or pickup object in the scene, open the **Behaviors** +section in the right panel, add `SpinPickup`, and press **Play**. The behavior +asset is saved with the project; it does not require a repo change. + +### Worker-backed behavior: `ThreatHeatmap` + +Use a worker when a behavior needs pure computation that might stall the main +thread: path scoring, terrain sampling, procedural placement, visibility +queries, or large array transforms. Workers cannot access Three.js objects, the +DOM, `this.stem`, or behavior attributes directly. Send plain JSON or +transferable buffers in, then apply the result on the main thread. + +Create a **Script** asset named `threat-heatmap-worker`: + +```js +self.onmessage = function (event) { + const message = event.data || {}; + if (message.type !== "sample") return; + + const data = message.data || {}; + const origin = data.origin || {x: 0, z: 0}; + const points = Array.isArray(data.points) ? data.points : []; + const radius = Math.max(0.001, Number(data.radius || 12)); + let nearestSq = radius * radius; + + for (const p of points) { + const dx = Number(p.x || 0) - origin.x; + const dz = Number(p.z || 0) - origin.z; + nearestSq = Math.min(nearestSq, dx * dx + dz * dz); + } + + const danger = Math.max(0, 1 - Math.sqrt(nearestSq) / radius); + self.postMessage({type: "danger", data: {danger}}); +}; +``` + +Create a behavior named `ThreatHeatmap` with these attributes: + +| Key | Type | Default | Purpose | +|---|---|---:|---| +| `radius` | number | `12` | Distance where threat fades to zero. | +| `sampleRate` | number | `6` | Worker samples per second. | +| `points` | object | `[]` | Array like `[{x: 5, z: 1}, {x: -3, z: 8}]`. | + +Paste this into the behavior code editor: + +```js +this.init = async function () { + this._danger = 0; + this._elapsed = 0; + this._pending = false; + + const url = await this.erth.asset.script.getUrlByName("threat-heatmap-worker"); + this._worker = new window.Worker(url); + this._worker.onmessage = (event) => { + const message = event.data || {}; + if (message.type !== "danger") return; + this._danger = Number(message.data?.danger || 0); + this._pending = false; + }; +}; + +this.update = function (deltaTime) { + this._elapsed += deltaTime; + + const rate = Math.max(1, Number(this.getAttribute("sampleRate") ?? 6)); + if (this._worker && !this._pending && this._elapsed >= 1 / rate) { + this._elapsed = 0; + this._pending = true; + this._worker.postMessage({ + type: "sample", + data: { + origin: {x: this.target.position.x, z: this.target.position.z}, + radius: Number(this.getAttribute("radius") ?? 12), + points: this.getAttribute("points") || [], + }, + }); + } + + // Main-thread application stays small: read the last worker result and + // apply it to a real scene object. + this.target.scale.y = 1 + this._danger * 2; +}; + +this.dispose = function () { + if (this._worker) { + this._worker.terminate(); + this._worker = null; + } +}; +``` + +This pattern keeps the worker stateless and disposable. For heavier jobs, send +typed arrays and transfer their buffers in `postMessage` so the browser does +not clone large payloads every frame. + +### Import, export, revisions, and managed files + +Behavior assets are portable YAML documents with three sections: + +```yaml +meta: + tool: StemStudio + type: behavior + exportVersion: 1 +config: + id: spin-pickup + name: Spin Pickup + attributes: {} +code: | + this.update = function (deltaTime) {}; +``` + +To import a behavior, open **Assets → Behaviors**, click the import/upload icon +on that row, and choose one or more `.yaml`/`.yml` behavior exports. The editor +parses the `config` and `code`, checks for duplicate names/ids, creates a new +behavior asset, and registers it so it appears in the behavior picker. + +The export helper in the codebase emits the same YAML shape, and full scene +exports include behavior YAML files under `behaviors/` when the scene bundle is +dumped. Use that format when you need to move a behavior between projects, +review a behavior in Git, or seed a project from a scripted import. + +When you use a visual designer, AI generator, or other higher-level authoring +surface, treat the files it creates as managed artifacts. The designer is +responsible for creating the behavior asset, matching config fields to the UI, +and keeping associated helper files/imports in sync. Edit through the designer +unless you intentionally switch that asset to hand-authored code. + +In the OSS playground, behavior edits are latest-only: the local adapter keeps a +single effective version for each behavior asset. In a server-backed install, +each save creates an immutable asset revision; the history icon on behavior +cards opens revision history, lets you diff, switch, or roll back, and stores +the selected revision in the scene's asset resolution context. + +### Source-code behavior packs + +Edit engine source only when the behavior should become a built-in pack. Use +`genericSound` as the reference for shape: load in `init`, apply config in +`onStart`/`onAttributesUpdated`, release in `onStop`. + +```ts +import {BehaviorBase} from "../../Behavior"; +import GameManager from "../../game/GameManager"; + +class SpinBehavior extends BehaviorBase { + private speed = 1; + + private applyConfig() { + this.speed = Number(this.getAttribute("speed") ?? 1); + } + + init(_game: GameManager) { + this.applyConfig(); + } + + onStart() { + this.applyConfig(); + } + + onAttributesUpdated() { + this.applyConfig(); + } + + update(deltaTime: number) { + this.gameObject.rotation.y += this.speed * deltaTime; + } + + dispose() { + // Release any geometries/materials/textures/listeners you created. + } +} + +export default SpinBehavior; +``` + +A `behavior.json` next to the class declares the editor-editable fields and +their defaults: + +```jsonc +{ + "id": "spin", + "name": "Spin", + "attributes": { + "speed": { "name": "Speed (rad/s)", "type": "number", "default": 1, "min": 0, "max": 10 } + } +} +``` + +> Read engine handles from `this.stem` / `this.gameObject` directly inside each +> hook. Re-deriving config in `onStart` and `onAttributesUpdated` keeps editor +> edits and play mode in sync. + +--- + +## Verification + +Behavior changes are exercised by the OSS smokes (they save → reload → play and +import a real game): + +```bash +bun run typecheck +bun run test # Vitest — NOT `bun test` +node scripts/playwright/oss-smoke.mjs # needs `bun run dev` on :5173 +node scripts/playwright/oss-import-3dchess.mjs +``` + +Re-run the smokes if you touch behavior attach/hydrate, the registry, or the +manager update loop. diff --git a/docs/import-packs.md b/docs/import-packs.md new file mode 100644 index 00000000..c03c8910 --- /dev/null +++ b/docs/import-packs.md @@ -0,0 +1,415 @@ +# Import Packs + +An **import pack** is a curated, read-only JavaScript module that ships with +the editor and can be cloned into a project as a **Script asset** in one click. +Once added, behaviors and lambdas pull it in with an `@import` directive and +call its exported helpers — shared math, RNG, UI scaffolding, and the like. + +Think of packs as the engine's standard library for game code: instead of +copy-pasting a simplex-noise implementation into five behaviors, you add the +`noise` pack once and `@import` it wherever you need it. + +> Import packs contain **code only** — no behaviors, lambdas, prefabs, or +> assets. For attachable game logic see [`built-in-behaviors.md`](./built-in-behaviors.md) +> and [`lambdas.md`](./lambdas.md). + +--- + +## What ships + +Three packs are bundled today, under +`client/packages/editor-oss/src/editor/scripts/builtinPacks/`: + +| Pack | What it gives you | +|---|---| +| `noise` | Seedable 2D/3D simplex noise. Deterministic — same seed, same field. Terrain heights, clouds, particle drift, organic motion. | +| `prng` | Seeded pseudo-random generator (alea). Deterministic across multiplayer clients and replays — same seed → same sequence everywhere. | +| `uikit-dual-mode` | UIKit lifecycle helper that renders the same `UIKit.Fullscreen` tree in both editor preview and play mode, replacing per-behavior inlined IIFEs. | + +Each is a StemStudio export-envelope YAML file (`meta` / `config` / `code`) — +the same shape you get when you export a Script asset from the editor. + +--- + +## Anatomy of a pack + +The loader interface (`builtinPacks/index.ts`) is deliberately tiny: + +```ts +export interface ImportPack { + name: string; // stable id — also becomes the Script asset name + description: string; // shown in the picker + code: string; // JS source, cloned verbatim into the new Script asset +} +``` + +Packs are bundled into the client at **build time** via Vite's +`import.meta.glob("./*.yaml")` — there is no backend round-trip and no runtime +fetch. `getSystemImportPacks()` parses and caches them on first use. + +The YAML envelope: + +```yaml +meta: + tool: StemStudio + type: import # "import" or "script" both accepted + exportVersion: 1 +config: + name: noise + description: | + Seedable 2D and 3D simplex noise. Deterministic … +code: | + // JavaScript source here — copied verbatim into the Script asset +``` + +--- + +## Adding a pack to your project (step by step) + +1. Open the **Assets** sidebar (left panel) and find the **Scripts** row. +2. Click the **Add New** (upload) button on that row to open the import menu. +3. Choose **Browse packs**. The picker lists every bundled pack with its + description. Packs already in the project are greyed out with a checkmark so + you can't add a duplicate. +4. Click a pack — say `noise`. It is cloned into the scene as a Script asset + named `noise` (its `code` copied verbatim). +5. The creation hook scans the new code for its own `@import` dependencies and + seeds the dependency cache, so the asset is immediately resolvable. + +That's it — the pack is now a normal Script asset in your project, editable +like any other (though you'll usually leave the curated source as-is). + +The same **Add New → Browse packs** flow is available from the script **Code +Editor** modal in the right panel; both routes converge on the same picker and +the same `useCreateScript()` hook. + +--- + +## Creating a project import in the editor + +You do not need to add an engine import pack for one-project helpers. Create a +normal Script asset and import it by name. + +1. Open the **Assets** sidebar. +2. Expand **Scripts**. +3. Click **Add New** and choose **New empty import**. +4. Name the Script asset `steering-utils`. +5. Paste this code and save: + +```js +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function seek2D(position, target, maxStep) { + const dx = Number(target.x || 0) - Number(position.x || 0); + const dz = Number(target.z || 0) - Number(position.z || 0); + const distance = Math.hypot(dx, dz); + + if (distance <= 0.0001) { + return {x: position.x, z: position.z, distance: 0}; + } + + const step = Math.min(distance, Math.max(0, Number(maxStep || 0))); + return { + x: position.x + dx / distance * step, + z: position.z + dz / distance * step, + distance + }; +} + +function arriveFactor(distance, slowingRadius) { + const radius = Math.max(0.0001, Number(slowingRadius || 1)); + return clamp(Number(distance || 0) / radius, 0, 1); +} +``` + +Only top-level function declarations, function expressions, and arrow-function +variables are exported onto the import alias. Plain variables stay private to +the import. + +### Use it from a behavior + +```js +@import "steering-utils" as steer; + +this.onStart = function () { + this._speed = Number(this.getAttribute("speed") ?? 4); + this._target = { + x: Number(this.getAttribute("targetX") ?? 0), + z: Number(this.getAttribute("targetZ") ?? 0) + }; +}; + +this.onAttributesUpdated = function () { + this.onStart(); +}; + +this.update = function (deltaTime) { + const next = steer.seek2D( + {x: this.target.position.x, z: this.target.position.z}, + this._target, + this._speed * deltaTime + ); + + this.target.position.x = next.x; + this.target.position.z = next.z; +}; +``` + +### Use it from a lambda + +```js +@import "steering-utils" as steer; + +function update(deltaTime) { + this.processObjects(deltaTime, (object, data, dt) => { + const target = { + x: Number(data.targetX ?? 0), + z: Number(data.targetZ ?? 0) + }; + const speed = Number(data.speed ?? 3); + const next = steer.seek2D( + {x: object.position.x, z: object.position.z}, + target, + speed * dt + ); + + object.position.x = next.x; + object.position.z = next.z; + + const arrive = steer.arriveFactor(next.distance, Number(data.slowingRadius ?? 4)); + this.setComponentData(object, "arrive", arrive); + }); +} +``` + +For that lambda, include component fields such as `targetX`, `targetZ`, +`speed`, `slowingRadius`, and `arrive` in the lambda config. If another lambda +reads `arrive`, put `arrive` in this lambda's `writeComponents` and the other +lambda's `readComponents` so the scheduler orders them correctly. + +--- + +## Using a pack or import from a behavior or lambda + +Once the Script asset exists, pull it in with an `@import "" as ;` +directive at the top of your behavior/lambda code. The alias becomes a normal +object whose methods are the pack's exports. + +### `noise` — procedural fields + +```js +@import "noise" as noise; + +const n = noise.create("world-1"); // seed by string or number +const h = n.noise2D(x * 0.05, z * 0.05); // -1..1 (e.g. terrain height) +const v = n.noise3D(x, y, z); // -1..1 (e.g. 3D density) +``` + +### `prng` — deterministic randomness + +```js +@import "prng" as prng; + +const rng = prng.create("level-42"); // seed by string or number +const x = rng.next(); // 0..1 +const dice = rng.intRange(1, 7); // [1,6] inclusive +const pick = rng.pick(["a", "b", "c"]); +const dup = rng.clone(); // independent copy at same state +rng.skip(1000); // advance N steps without consuming +``` + +Because the sequence is fully determined by the seed, every multiplayer client +that uses the same seed sees the same rolls — use `prng`, not `Math.random()`, +for anything that must agree across clients or replays. + +### `uikit-dual-mode` — UI that works in editor and play + +```js +@import "uikit-dual-mode" as uikit; + +this.init = function (_game) { + this._uikitCtx = uikit.createPlayContext(_game); + this._uiRoot = uikit.buildRoot(this._uikitCtx); + this._buildUIKitContent(); + uikit.attach(this, this._uikitCtx); +}; +this.update = function (dt) { uikit.tick(this, dt); }; +this.dispose = function () { uikit.teardown(this); }; + +this.onEditorAdded = async function (editor) { + if (!this._uiRoot) { + this._uikitCtx = await uikit.createEditorContext(editor); + this._uiRoot = uikit.buildRoot(this._uikitCtx); + this._buildUIKitContent(); + } +}; +``` + +(Requires `editor.ensureUICamera()` and `GameManager.initUIKit()` — present in +this build. See [`uikit-api.md`](./uikit-api.md).) + +--- + +## How packs relate to the rest of the import system + +The packs picker is one entry into the broader **script-tool import pipeline** +(`client/packages/editor-oss/src/agent/script-tool/`). The same pipeline also +handles: + +- **Uploading a file** — `.js`/`.mjs`/`.cjs` (raw code; filename becomes the + asset name) or a `.yaml` export envelope (parsed the same way packs are). +- **New empty import** — a blank Script asset you write from scratch. + +All of these land as Script assets through `useCreateScript()`, which scans for +`@import` dependencies and seeds the dependency cache. + +Script imports can import other Script imports with the same directive. Cycles +are rejected at load time so a helper graph cannot deadlock the runtime. + +The dashboard's **Import stemscript folder** feature is a larger sibling: it +stages a whole exported folder via `sessionStorage`, navigates to a fresh +project, and runs the `exec` flow to materialize an entire saved project +(scenes, assets, scripts) at once. Import packs are the single-module version +of the same idea. + +--- + +## Imports and background workers + +`@import` aliases are created when behavior or lambda code is evaluated. A Web +Worker is a separate global context, so it does not automatically receive those +aliases. + +Use one of these patterns: + +- Use imports on the main thread to prepare a serializable job, send that job to + the worker, then use imports again when applying the result. +- Put worker-only helpers directly in the worker Script asset or Blob source. +- For behavior-managed worker Script assets, load the worker with + `this.erth.asset.script.getUrlByName("worker-script-name")`; that URL points + at the raw script source with `@import` directives stripped. + +Example behavior using an import on the main thread and a separate worker: + +```js +@import "steering-utils" as steer; + +this.init = async function () { + const url = await this.erth.asset.script.getUrlByName("steering-worker"); + this._worker = new window.Worker(url); + this._worker.onmessage = (event) => { + this._result = event.data; + }; +}; + +this.update = function (deltaTime) { + const next = steer.seek2D( + {x: this.target.position.x, z: this.target.position.z}, + {x: 0, z: 0}, + Number(this.getAttribute("speed") ?? 3) * deltaTime + ); + + this._worker.postMessage({ + type: "score", + position: {x: next.x, z: next.z} + }); + + if (this._result?.type === "score") { + this.target.userData.lastScore = this._result.score; + } +}; + +this.dispose = function () { + this._worker?.terminate(); +}; +``` + +The worker script should be self-contained: + +```js +self.onmessage = function (event) { + const message = event.data || {}; + if (message.type !== "score") return; + + const p = message.position || {x: 0, z: 0}; + const score = Math.hypot(Number(p.x || 0), Number(p.z || 0)); + self.postMessage({type: "score", score}); +}; +``` + +For lambdas, use the same boundary but create the worker from a Blob inside the +lambda, as shown in [`lambdas.md`](./lambdas.md#worker-backed-lambda-flockoffsets). + +--- + +## Import/export, revisions, and managed files + +Script import assets can be moved as raw `.js`/`.mjs`/`.cjs` files or as the +StemStudio YAML envelope: + +```yaml +meta: + tool: StemStudio + type: import + exportVersion: 1 +config: + name: steering-utils + description: Shared movement helpers +code: | + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } +``` + +The **Scripts → Add New → Upload file** flow accepts raw JavaScript files and +YAML import exports. Raw JavaScript uses the filename as the import asset name; +YAML uses `config.name` and preserves the optional description. Scene bundle +exports write script imports under `imports/`, while behavior and lambda +exports that depend on those imports keep their dependency references through +the scene asset resolution context. + +When a visual designer, AI generator, or other higher-level authoring surface +creates helper imports for you, treat them as managed files. The designer owns +the import names, dependency graph, and generated helper code needed by the +behavior/lambda it produced. Rename or edit those imports only if you are also +updating every `@import` reference that depends on them. + +In the OSS playground, script import edits are latest-only: each asset resolves +to the single current local version. In a server-backed install, script imports +participate in the full asset revision system; the history icon on script cards +opens revision history and the scene pins the active revision just like it does +for behavior and lambda assets. + +--- + +## Shipping a new pack + +Because packs are loaded by a build-time glob, adding one is just dropping a +file in — no code changes: + +1. In the editor, author the helper as a Script asset and **export** it (you + get a `meta`/`config`/`code` YAML envelope). +2. Drop that `.yaml` into + `client/packages/editor-oss/src/editor/scripts/builtinPacks/`. +3. Ensure `meta.type` is `import` or `script`, and `config.name` + `code` are + present (the loader skips files that fail these checks and logs why). +4. Rebuild the client. `getSystemImportPacks()` picks it up automatically and + it appears in the picker, sorted by name. + +--- + +## Verification + +The packs picker feeds the script-tool import pipeline, which the OSS smokes +cover: + +```bash +bun run typecheck +bun run test # Vitest — NOT `bun test` +node scripts/playwright/oss-import-3dchess.mjs # needs `bun run dev` on :5173 +``` + +Re-run the import/persistence smokes if you change the picker, the loader, or +`useCreateScript()`. diff --git a/docs/lambdas.md b/docs/lambdas.md new file mode 100644 index 00000000..3239a95f --- /dev/null +++ b/docs/lambdas.md @@ -0,0 +1,594 @@ +# Lambdas (the ECS layer) + +A **lambda** is a system that runs the same logic over *many* objects every +frame, driven by per-object **component data** rather than per-object scripts. +It is the ECS-style ("entity-component-system") layer that sits on top of +behaviors. Where a [behavior](./built-in-behaviors.md) is one script bound to +one object, a lambda is one system that processes a whole archetype of objects +in a tight, cache-friendly loop. + +Reach for a lambda when you have **lots of objects doing the same cheap thing** +— gravity on every projectile, rotation on every fan blade, velocity +integration on a swarm — and you want it batched, throttled, and +dependency-ordered. Reach for a behavior when the logic is **bound to one +object's identity** (a boss state machine, a door, the player controller). + +Lambda code lives in `client/packages/editor-oss/src/lambdas/`. Built-in +lambdas are under `src/lambdas/packs/`. + +--- + +## Behavior vs. lambda — which one? + +| | Behavior | Lambda | +|---|---|---| +| Granularity | One instance per object | One system over many objects | +| Data | `this.attributes` (per instance) | component data per object + shared instance attributes | +| Best for | identity-bound logic, events, state machines | bulk per-frame work (movement, physics, transforms) | +| Scheduling | manager update loop | dependency-ordered **waves**, with throttling/culling | +| Authoring | Behavior Creator | Lambda Creator | + +They compose: a behavior can register objects with a lambda, and a lambda +(`setBehaviorEnabled`) can flip behaviors. + +--- + +## How a lambda is wired + +Each pack has a class plus a `lambda.json` manifest. The manifest is what makes +the system data-driven and schedulable: + +```jsonc +// src/lambdas/packs/gravity/lambda.json +{ + "id": "gravity", + "name": "Gravity Lambda", + "main": "GravityLambda.ts", + "attributes": { // shared across all registered objects + "gravityStrength": { "type": "number", "default": 9.81, "min": 0, "max": 100 } + }, + "componentSchema": { // per-object data fields + "mass": { "type": "number", "default": 1 }, + "drag": { "type": "number", "default": 0.1 }, + "useGravity": { "type": "boolean", "default": true } + }, + "readComponents": ["mass", "drag", "useGravity"], + "writeComponents": [] +} +``` + +- **`componentSchema`** — the per-object fields. Every object you attach the + lambda to gets its own copy (its "component data"), stored in + `object.userData.lambdaComponents[]`. +- **`attributes`** — instance-level config shared by every object the lambda + drives. +- **`readComponents` / `writeComponents`** — the scheduler's dependency + declaration. If lambda A *writes* `vx` and lambda B *reads* `vx`, the + scheduler runs A before B. Lambdas with no write→read conflict run in the + same **wave** and can be treated as independent. The current runtime visits + wave members sequentially on the main thread; the wave boundary is the + dependency contract. Omit these and everything falls back to a conservative + schema-based ordering. + +The archetype query (`src/lambdas/LambdaQueryRegistry.ts`) keeps a bitmask per +object so "all objects with [velocity AND gravity]" is an O(1) lookup. Waves +are built in `LambdaManager.buildWaves()` and run by `LambdaScheduler`. + +> **Never call a lambda's `apply()`/`update()` directly.** That bypasses wave +> ordering and throttling. The scheduler owns the loop. (The one exception is a +> lambda component with **Auto Apply off**, which is explicitly meant to be +> triggered from your own code — see below.) + +--- + +## The lambda API + +Defined in `src/lambdas/LambdaBase.ts` (and `SoALambdaBase.ts` for the +structure-of-arrays fast path). + +| Member | Purpose | +|---|---| +| `init(game)` | One-time setup. Store the `GameManager` reference. | +| `update(deltaTime)` | Per-frame logic. **Override this.** | +| `fixedUpdate(dt)` | Fixed-timestep variant for physics. | +| `onObjectAdded(target, componentData)` | An object registered with this lambda. | +| `onObjectRemoved(target)` | An object deregistered. | +| `dispose()` | Cleanup. | +| `this.registeredObjects` | `Map` of everything this lambda drives. | +| `this.processObjects(dt, cb, isCritical?)` | The iteration helper — use this in `update`. | +| `getComponentData(obj)` / `setComponentData(obj, key, value)` | Read/write per-object data. | + +`processObjects` is the recommended way to iterate. It applies, per object, +**frustum culling** (off-screen objects throttle ~20×), **distance LOD** (far +objects throttle 4×/10×), and a **frame budget** (bails out if the frame is +running long), then syncs matrices/instanced meshes after your callback. The +`dt` passed to your callback is `deltaTime × throttleMultiplier` so time-based +math still catches up after skipped frames — use `dt`, not the outer +`deltaTime`, for movement. + +### Cooperative scheduling notes + +`processObjects()` is the editor-author path for cooperative scheduling. It +checks the live frame deadline, applies culling/throttle multipliers, and stops +before the frame is exhausted. + +Source-authored lambdas can go deeper by overriding `applySliced()` or +`SoALambdaBase.updateSoASliced()` and yielding between chunks. Plain +editor-authored lambda `update()` functions are not resumed by the scheduler if +they return a generator. For long editor-authored work, split the job across +frames with explicit state, or send pure computation to a worker and apply the +result on the main thread. + +### Example: the rotation lambda (the whole thing) + +```ts +// src/lambdas/packs/rotation/RotationLambda.ts +import {MathUtils} from "three"; +import {LambdaBase} from "../../LambdaBase"; + +const DEG2RAD = MathUtils.DEG2RAD; + +export default class RotationLambda extends LambdaBase { + update(_deltaTime: number = 0.016): void { + // Rotation is absolute (not cumulative), so throttle compensation isn't needed. + this.processObjects(_deltaTime, (object, data) => { + if (data.useQuaternion) { + object.quaternion.set(data.qx, data.qy, data.qz, data.qw); + } else { + object.rotation.set( + data.x * DEG2RAD, + data.y * DEG2RAD, + data.z * DEG2RAD, + data.order || "XYZ", + ); + } + }); + } +} +``` + +`data` here is exactly the `componentSchema` from that pack's `lambda.json` +(`x`, `y`, `z`, `useQuaternion`, `qx…qw`). Every registered object supplies its +own values. + +### Example: the velocity lambda (SoA fast path) + +For hot systems, extend `SoALambdaBase` and override `updateSoA`. Component +data is laid out as parallel typed arrays (structure-of-arrays) for cache +locality: + +```ts +// src/lambdas/packs/velocity/VelocityLambda.ts +export default class VelocityLambda extends SoALambdaBase { + constructor(id: string, options: LambdaOptions) { + super(id, options, VELOCITY_SCHEMA); + } + + protected updateSoA(deltaTime: number): void { + const store = this.store; + const vx = store.getField("vx") as Float32Array; + // … vy, vz, damping, maxSpeed … + for (let i = 0; i < store.count; i++) { + const m = this._visibilityMask?.[i] ?? 1; // scheduler throttle + if (m === 0) continue; + const dt = deltaTime * m; + const obj = store.getObject(i); + if (obj) { + obj.position.x += vx[i]! * dt; // integrate velocity + // … y, z … + obj.updateMatrix(); + } + } + this.syncSoAToMap(["vx", "vy", "vz"]); // expose for other lambdas + } +} +``` + +`velocity` declares `writeComponents: ["vx","vy","vz"]`; a downstream lambda +that reads those will automatically be scheduled into a later wave. + +--- + +## The built-in lambdas + +All under `src/lambdas/packs/`: + +| Lambda | What it does | +|---|---| +| `position` | Set/drive object position. | +| `rotation` | Set absolute rotation (euler or quaternion). | +| `scale` | Set object scale. | +| `velocity` | Integrate velocity into position (SoA, clamps to max speed, damping). | +| `acceleration` | Apply acceleration to velocity. | +| `gravity` | Apply gravity using per-object mass/drag/`useGravity`. | +| `rigidbody` | Rigid body integration. | +| `fusedPhysics` | Combined physics pass. | +| `collider` | Collision data. | +| `setParent` | Reparent objects. | +| `setMaterial` | Swap material. | +| `setVariable` | Write a variable/state value. | +| `setBehaviorEnabled` | Enable/disable a behavior on the object. | +| `showObject` / `hideObject` | Toggle visibility. | +| `playSound` | Trigger audio. | +| `uiAction` | Drive a UI action. | +| `animationControl` | Drive animation playback. | +| `cooldownGate` / `debounce` | Rate-limit / gate other systems. | + +--- + +## Using a lambda in the editor (step by step) + +1. **Select an object** in the scene tree. +2. In the right panel open the **Lambda Components** tab. +3. Click **Add Lambda** and choose one — e.g. `gravity`. +4. **Fill in the component data** the schema asks for. For `gravity`: `mass`, + `drag`, `useGravity`. (Instance attributes like `gravityStrength` are shared + across all objects on that lambda.) +5. Choose **Auto Apply**: + - **On** — the scheduler runs the lambda over this object every frame. + - **Off** — the component stays on the object, but Play mode does not + auto-register it. A trigger, behavior, or another lambda can register it + and call `lambdaInstance.apply(deltaTime)` when it should run. +6. **Press Play.** The scheduler builds dependency waves and drives every + auto-apply lambda. With `gravity` attached, the object falls (or floats, if + `useGravity` is false) per its `mass`/`drag`. + +Attaching writes a component entry into `object.userData.lambdaComponents[]`; +the manager registers the object with the lambda instance on load. + +--- + +## Global, scene, and object-attached lambdas + +There are two user-facing ways to instantiate lambdas, plus one runtime API +path: + +| Path | Stored where | Runtime behavior | +|---|---|---| +| **Assets -> Lambdas -> Add to Project** | `scene.userData.projectLambdaInstances[]` | Creates one enabled project/global instance for the lambda type when Play mode starts. It can hold shared attributes, receive events, and register objects later. | +| **Object -> Lambdas -> Add Lambda** | `scene.userData.lambdaInstances[]` plus `object.userData.lambdaComponents[]` | Creates or reuses a scene-level instance for that lambda type, then attaches this object with per-object component data. | +| Runtime code | `game.lambdaManager.createInstance(lambdaId, options)` | Advanced path used by built-ins such as Trigger or collaboration sync when an instance must be created during play. | + +On Play, `GameManager.createLambdaInstancesFromScene()` merges project-level and +scene-level instance lists. Project-level entries win when both lists contain +the same lambda type. It then creates enabled instances and traverses the scene +to register object components with `autoApply: true`. + +Object-attached lambdas are the normal path for batched work: every registered +object contributes one component-data row to a shared instance. Global/project +lambdas are useful when the system needs shared state, events, or delayed +registration before it owns any objects. + +### Registering objects from behavior code + +Behavior code can query and register lambda instances through `this.erth`: + +```js +this.onStart = function () { + const [gravity] = this.erth.lambdas.getInstancesByType("gravity"); + if (!gravity) return; + + this.erth.lambdas.registerObject(gravity.uuid, this.target, { + mass: 1, + drag: 0.1, + useGravity: true + }); +}; + +this.onStop = function () { + const [gravity] = this.erth.lambdas.getInstancesByType("gravity"); + if (gravity) this.erth.lambdas.deregisterObject(gravity.uuid, this.target); +}; +``` + +Use this for objects created during play or for components with Auto Apply off. +If the lambda is already attached in the object UI with Auto Apply on, avoid +registering it again from code. + +### Events and manual apply + +The Trigger behavior can apply, activate/deactivate, set component fields, or +send events to lambda instances. Lambda code receives those through +`onEvent(msg, data)`. + +For components that should run only on demand, keep Auto Apply off and use a +trigger or behavior to: + +1. Register the object with the instance. +2. Update any component data needed for that action. +3. Call `lambdaInstance.apply(deltaTime)` once, or keep the component active + until another event deregisters it. + +Do not call `apply()` on always-on auto-apply lambdas as part of your normal +frame loop; the scheduler already owns those updates. + +--- + +## Authoring custom lambdas + +There are two authoring paths: + +- **In the editor** — use this for project/game systems. The Lambda Creator + saves the code, config JSON, documentation, and revisions as a project asset. +- **In engine source** — use this when you are adding a reusable built-in pack + that should ship with the engine. + +For most gameplay work, start in the editor. + +### Create one in the editor: `OrbitLane` + +1. Open the **Assets** sidebar. +2. Click the **New** menu and choose **New Lambda**. +3. Name it `OrbitLane`. +4. Open the code editor details/config panel for the lambda. +5. Paste this config: + +```jsonc +{ + "id": "orbit-lane", + "name": "Orbit Lane", + "description": "Moves registered objects around configurable orbit centers.", + "attributes": { + "speed": {"name": "Speed", "type": "number", "default": 1.25, "min": 0}, + "heightScale": {"name": "Height Scale", "type": "number", "default": 1} + }, + "componentSchema": { + "centerX": {"name": "Center X", "type": "number", "default": 0}, + "centerY": {"name": "Center Y", "type": "number", "default": 0}, + "centerZ": {"name": "Center Z", "type": "number", "default": 0}, + "radius": {"name": "Radius", "type": "number", "default": 2, "min": 0}, + "phase": {"name": "Phase", "type": "number", "default": 0}, + "heightAmp": {"name": "Height Amplitude", "type": "number", "default": 0} + }, + "readComponents": ["centerX", "centerY", "centerZ", "radius", "phase", "heightAmp"], + "writeComponents": [] +} +``` + +Paste this into the lambda code editor: + +```js +function init(game) { + this._game = game; + this._time = 0; +} + +function update(deltaTime) { + this._time += deltaTime; + + const speed = Number(this.attributes.speed ?? 1.25); + const heightScale = Number(this.attributes.heightScale ?? 1); + + this.processObjects(deltaTime, (object, data, dt) => { + const angle = this._time * speed + Number(data.phase ?? 0); + const radius = Number(data.radius ?? 2); + const centerX = Number(data.centerX ?? 0); + const centerY = Number(data.centerY ?? 0); + const centerZ = Number(data.centerZ ?? 0); + const heightAmp = Number(data.heightAmp ?? 0) * heightScale; + + object.position.x = centerX + Math.cos(angle) * radius; + object.position.y = centerY + Math.sin(angle * 2) * heightAmp; + object.position.z = centerZ + Math.sin(angle) * radius; + }); +} + +function dispose() { + this._game = null; +} +``` + +Then select every object that should orbit, open the **Lambda Components** tab +in the right panel, add `OrbitLane`, fill in per-object component values, leave +**Auto Apply** on, and press **Play**. One lambda instance now drives all +registered objects in a scheduler-aware pass. + +Use instance **attributes** for values shared by the whole system (`speed`), and +`componentSchema` for values each object owns (`radius`, `phase`, `centerX`). +Use `readComponents` and `writeComponents` when lambdas exchange component +fields. If your lambda only changes the Three.js transform directly and no +other lambda reads a component field from it, `writeComponents` can stay empty. + +### Worker-backed lambda: `FlockOffsets` + +A lambda still runs on the main thread, because it owns Object3D mutation and +component writes. Move only pure computation into a worker: steering solves, +heatmaps, grid searches, offline scoring, or large array transforms. Workers +must receive plain data; do not send Three.js objects, DOM nodes, lambdas, +behaviors, or `GameManager`. + +Create a lambda named `FlockOffsets` with this config: + +```jsonc +{ + "id": "flock-offsets", + "name": "Flock Offsets", + "description": "Computes lightweight wandering offsets in a background worker.", + "attributes": { + "solveRate": {"name": "Solve Rate", "type": "number", "default": 8, "min": 1} + }, + "componentSchema": { + "homeX": {"name": "Home X", "type": "number", "default": 0}, + "homeZ": {"name": "Home Z", "type": "number", "default": 0}, + "wanderRadius": {"name": "Wander Radius", "type": "number", "default": 1.5, "min": 0}, + "seed": {"name": "Seed", "type": "number", "default": 1}, + "offsetX": {"name": "Offset X", "type": "number", "default": 0}, + "offsetZ": {"name": "Offset Z", "type": "number", "default": 0} + }, + "readComponents": ["homeX", "homeZ", "wanderRadius", "seed", "offsetX", "offsetZ"], + "writeComponents": ["offsetX", "offsetZ"] +} +``` + +Paste this code: + +```js +function init() { + const workerSource = ` + self.onmessage = function (event) { + const message = event.data || {}; + if (message.type !== "solve") return; + + const time = Number(message.time || 0); + const rows = Array.isArray(message.rows) ? message.rows : []; + const offsets = rows.map((row) => { + const seed = Number(row.seed || 0); + const radius = Number(row.wanderRadius || 0); + const angle = time * 0.9 + seed * 12.9898; + return { + id: row.id, + x: Math.cos(angle) * radius, + z: Math.sin(angle * 0.73) * radius + }; + }); + + self.postMessage({type: "solved", offsets}); + }; + `; + + const blob = new window.Blob([workerSource], {type: "application/javascript"}); + this._workerUrl = window.URL.createObjectURL(blob); + this._worker = new window.Worker(this._workerUrl); + this._busy = false; + this._time = 0; + this._lastPost = 0; + this._offsets = new Map(); + + this._worker.onmessage = (event) => { + const message = event.data || {}; + if (message.type !== "solved") return; + + this._busy = false; + this._offsets.clear(); + for (const offset of message.offsets || []) { + this._offsets.set(offset.id, offset); + } + }; +} + +function update(deltaTime) { + this._time += deltaTime; + const rows = []; + + this.processObjects(deltaTime, (object, data) => { + const offset = this._offsets.get(object.uuid); + if (offset) { + this.setComponentData(object, "offsetX", offset.x); + this.setComponentData(object, "offsetZ", offset.z); + object.position.x = Number(data.homeX ?? 0) + offset.x; + object.position.z = Number(data.homeZ ?? 0) + offset.z; + } + + rows.push({ + id: object.uuid, + seed: Number(data.seed ?? 1), + wanderRadius: Number(data.wanderRadius ?? 1.5) + }); + }); + + const solveRate = Math.max(1, Number(this.attributes.solveRate ?? 8)); + if (!this._busy && rows.length > 0 && this._time - this._lastPost >= 1 / solveRate) { + this._busy = true; + this._lastPost = this._time; + this._worker.postMessage({type: "solve", time: this._time, rows}); + } +} + +function dispose() { + this._worker?.terminate(); + if (this._workerUrl) window.URL.revokeObjectURL(this._workerUrl); +} +``` + +This pattern keeps the scheduler contract intact: the lambda still calls +`processObjects()`, component writes happen on the main thread, and the worker +only returns serializable results that can be applied on a later frame. + +### Import, export, revisions, and managed files + +Lambda assets are portable YAML documents with the same export envelope as +behaviors, but `meta.type` is `lambda`: + +```yaml +meta: + tool: StemStudio + type: lambda + exportVersion: 1 +config: + id: orbit-lane + name: Orbit Lane + componentSchema: {} + readComponents: [] + writeComponents: [] +code: | + function update(deltaTime) {} +``` + +To import a lambda, open **Assets → Lambdas**, click the import/upload icon on +that row, and choose one or more `.yaml`/`.yml` lambda exports. The editor +parses `config` and `code`, checks for duplicate names, creates a lambda asset, +registers it with the local lambda registry, and updates the Lambda Components +picker immediately. When a current user handle is available, imported lambda IDs +are normalized so another author's ID does not collide with yours. + +Full scene exports include lambda YAML files under `lambdas/`, plus a scene +binding file when object attachments need to be reconstructed outside the +editor. Use that bundle path when you need to move a scene and all of its +lambda/import dependencies together. + +When you use a visual designer, AI generator, or other higher-level authoring +surface, let that surface manage the lambda file set. It owns the lambda config, +component schema, helper imports, and any generated files needed to keep the +visual model and code model synchronized. Edit the generated code directly only +when you are deliberately taking over maintenance. + +In the OSS playground, lambda edits are latest-only: the local adapter keeps a +single effective version for each lambda asset. In a server-backed install, +each save creates an immutable asset revision; the history icon on lambda cards +opens revision history, lets you diff, switch, or roll back, and the selected +revision is pinned in the scene's asset resolution context. + +### Lifecycle reference + +The **Lambda Creator** template (`LambdaScriptTemplate.ts`) gives you the same +lifecycle hooks: + +```js +// Available: this.registeredObjects, this.attributes, this._game, +// this.requestAttributeChange(key, value, options?) + +this.init = (game) => { + // Once, when the instance is created. `game` is also this._game. +}; + +this.update = (deltaTime) => { + // processObjects handles culling, distance LOD, frame budget, and matrix sync. + // Use `dt` (not deltaTime) for time-based math — it compensates for skipped frames. + this.processObjects(deltaTime, (object, data, dt) => { + // object: THREE.Object3D data: your componentSchema dt: throttle-adjusted + }); +}; + +this.onObjectAdded = (target, componentData) => {}; // object registered +this.onObjectRemoved = (target) => {}; // object deregistered +this.dispose = () => {}; +``` + +Define `componentSchema`, `attributes`, and `readComponents`/`writeComponents` +in the manifest so the scheduler can place your lambda in the right wave. If +your lambda *writes* fields another lambda *reads*, declaring them is what +guarantees correct ordering — don't skip it for systems that feed each other. + +--- + +## Verification + +```bash +bun run typecheck +bun run test # Vitest — NOT `bun test` +``` + +The scheduler drives lambdas every frame; if you change wave-building, +component registration, or `processObjects`, run the OSS play smokes too +(`node scripts/playwright/oss-smoke.mjs` with `bun run dev` on :5173) since +they exercise the live update loop. diff --git a/docs/planning/2026-06-02-copilot-inspection-allowlist.md b/docs/planning/2026-06-02-copilot-inspection-allowlist.md new file mode 100644 index 00000000..e50b897e --- /dev/null +++ b/docs/planning/2026-06-02-copilot-inspection-allowlist.md @@ -0,0 +1,57 @@ +# Copilot inspection allowlist — let the copilot inspect the full scene + +## Goal + +The playground copilot fails an entire request when its generated +`inspectionStemscript` contains a read-only query that isn't in a narrow +hardcoded allowlist (e.g. `list lights` → +`Generated StemScript used commands that are not allowed in inspection`). + +Make the copilot able to inspect the **full** scene and all asset types in the +read-only inspection phase, and resolve the `list lights` surface command to a +real command. Behavior/lambda parameter customization already works on the +mutation side (not blocked by `DISALLOWED_COMMANDS`). + +## Root cause + +- `validateInspectionStemscript` (`copilot/playgroundStemscriptPlan.ts:161`) + allows only the 21 commands in the hand-maintained `READ_ONLY_COMMANDS` set. +- The engine's real read-only notion is broader: + `isReadOnlyCommand` (`agent/script-tool/checkScript.ts:540`) = command starts + with `get_` / `list_` / `search_`, or is `player` / `select`. +- `list lights` has no alias, so the parser raw-passes it to command `list` + (`ScriptCommandParser.ts:105`), which is neither read-only nor a real command. + +## Approach + +- A: Inspection allows any command the engine classifies as read-only, except + ones globally disallowed in the playground (external search, library, project + tasks — already in `DISALLOWED_COMMANDS`). Rule: + `disallowed = !isReadOnlyCommand(cmd) || DISALLOWED_COMMANDS.has(cmd)`. +- B: Add `"list lights": {command: "get_scene_objects"}` to the alias map so the + surface command the model naturally emits resolves to a real read-only command + (lights are scene objects; details via the existing `get light` → `get_light_settings`). + +## Affected files + +- `client/packages/editor-oss/src/agent/script-tool/checkScript.ts` — export + `isReadOnlyCommand`. +- `client/packages/editor-oss/src/copilot/playgroundStemscriptPlan.ts` — reuse + `isReadOnlyCommand`; drop the narrow `READ_ONLY_COMMANDS` set. +- `client/packages/editor-oss/src/agent/script-tool/aliases.ts` — add + `list lights` alias. +- Tests: `playgroundStemscriptPlan.test.ts`, alias/contract tests. + +## Steps + +- [x] Export `isReadOnlyCommand` from `checkScript.ts`. +- [x] Rewrite `validateInspectionStemscript` to use it + `DISALLOWED_COMMANDS`. +- [x] Remove the now-unused `READ_ONLY_COMMANDS` set. +- [x] Add the `list lights` alias. +- [x] Update/extend tests. + +## Validation + +- [x] `bun run typecheck` — clean. +- [x] `bun run test` (Vitest) — 2537 passed, incl. new `list lights` regression. +- [ ] Manual code review. diff --git a/docs/scheduler-and-editor-settings.md b/docs/scheduler-and-editor-settings.md new file mode 100644 index 00000000..08476b82 --- /dev/null +++ b/docs/scheduler-and-editor-settings.md @@ -0,0 +1,341 @@ +# Scheduler and Editor Performance Settings + +Most scene-wide controls live in the **Project** tab. That tab is the map for +project metadata, editor preferences, runtime performance, default scene +lighting, cameras, and scene-level objects such as the default directional +light. + +![Project tab settings map](./assets/editor-project-tab-map.png) + +Use these entries as the main navigation points: + +| Project tab entry | What it controls | +|---|---| +| Project Settings | Project metadata, editor snapping/units, physics defaults, game rules, HUD/display, player settings, multiplayer, and developer tools. | +| Rendering & Performance | Quality presets, scheduler, rendering switches, physics runtime toggles, behavior throttling, profiling, LOD, and splat settings. | +| Default Scene | Scene-wide ambient light, hemisphere light, fog, background, tone mapping, and shadow defaults. | +| DefaultCamera | Camera object transform and camera-specific settings. | +| Directional Light | The default sun/key light object, including directional-light behavior, shadow casting, and shadow quality. | + +--- + +## Project settings + +![Project Settings panel](./assets/project-settings-overview.png) + +**Project Settings** is the broad project-authoring panel. It mixes scene +metadata with editor workflow preferences and game runtime defaults: + +| Section | Settings | +|---|---| +| Project Details | Name, description, content rating, thumbnail image, tags, and server-backed slug/publishing metadata when available. | +| Snapping | Grid snapping, snap resolution, rotation snapping, snap angle presets, scale snapping, geometric snapping, play-mode snapping, and snap priority. | +| CAD, units, and measurement | CAD tools, unit system, display unit, angle units, bounding-box mode, and color palette. | +| Physics | Default physics engine and gravity stored under `scene.userData.physics`. Runtime physics toggles such as sleeping and workers live in Rendering & Performance. | +| Level Rules | Max score, player lives, and time limit stored with `scene.userData.game`. | +| HUD & Display | Standard HUD panel toggle, HUD renderer, HUD customization entry point, orientation policy, orbit controls, SceneTraverser, and mobile VFX toggle. | +| Player Settings | Avatar/player defaults for play mode. | +| Multiplayer | Multiplayer, collaboration, room/client limits, auto-join, and voice chat options. | +| Developer Tools | Production mode, game project mode, Play-mode Inspector, Compartments, and first-time-experience reset. | + +Project Settings answers "what kind of project is this and how should the +editor/play mode behave?" Rendering & Performance answers "how should the +runtime spend frame time?" + +--- + +## Rendering and performance + +![Rendering and Performance panel](./assets/scheduler-settings-overview.png) + +The **Rendering & Performance** panel is the runtime tuning panel. It contains: + +| Section | Settings | +|---|---| +| Quality Presets | Target-device presets for rendering, physics, scheduler budget, view distance, culling, and LOD. | +| Rendering | Dynamic batching, mesh instancing, batching-data reset, post-processing, WebGL fallback, and VFX renderer fallback. | +| Physics | Physics sleeping and multi-threaded physics worker. | +| Scheduler | Modern Game Scheduler and fixed-rate behavior/lambda updates. | +| Behavior Performance | Off-screen optimization, distance optimization, consistent updates, priority, distance thresholds, and throttle factors. | +| Budget Inspector | Runtime budget visibility for avatars, plots, textures, and hot rows. | +| Lambda Explorer | Play-mode profiling for lambda instances, waves, entity counts, and timings. | +| LOD / developer tools | Batch LOD generation, root transform policy, performance overlay, memory overlay, debug mode, and splat/Spark renderer controls. | + +These controls affect how the runtime spends each frame, which systems are +allowed to skip work, and which diagnostics are visible while you tune a +project. + +--- + +## Scheduler architecture + +The modern scheduler is implemented by `FrameOrchestrator`. It replaces a +single sequential update loop with fixed pipeline stages: + +| Stage | What runs there | +|---|---| +| `INPUT` | Input state finalization. Always runs and is not budget gated. | +| `FIXED_UPDATE` | Fixed-timestep behaviors, fixed lambdas, and deterministic physics work. | +| `PRE_UPDATE` | Quality updates, spatial-grid rebuild, and budget setup before normal gameplay work. | +| `UPDATE` | Behaviors, lambdas, animation, audio, AI world, player events, texture residency, and other frame systems. | +| `POST_UPDATE` | Late events and sync points after main update work. | +| `RENDER` | Optional scheduled render stage and deferred render callbacks. | + +Systems register through adapters in +`client/packages/editor-oss/src/scheduler/createSchedulerFromConfig.ts`. Within +each stage, `DependencyGraph` orders systems by declared reads/writes and then +priority. The lambda scheduler uses the same idea at the lambda level: lambdas +that write fields another lambda reads are scheduled before their consumers. + +The scheduler also owns: + +- A shared frame deadline from `FrameBudgetManager`. +- A fixed-step accumulator with `maxFixedStepsPerFrame` to prevent spiral of + death. +- Render-pressure detection using average render time and frame delta spikes. +- Time slicing for supported update-stage systems. +- Background-tab throttling that skips expensive stages while the tab is hidden. +- A uniform spatial grid used by lambda/behavior throttling for distance checks. +- Command-buffer flushes between fixed/update boundaries so queued scene changes + land at predictable points. + +--- + +## Quality presets + +Quality presets bundle rendering, physics, scene, and scheduler settings by +target device class. Start from the closest device tab, inspect the preset +details, then override individual controls only when needed. + +![Quality presets](./assets/scheduler-quality-presets.png) + +Preset details include scheduler values such as frame budget, fixed timestep, +and maximum fixed steps. Those settings feed `createSchedulerFromConfig()` when +Play mode starts. + +| Preset field | Runtime effect | +|---|---| +| Pixel ratio, shadows, antialiasing, post processing | Renderer cost and visual quality. | +| Physics rate and substeps | Physics simulation cost and stability. | +| View distance, LOD distances, culling aggressiveness | Scene traversal and rendering load. | +| Scheduler budget, fixed timestep, max fixed steps | Logic budget and fixed-step behavior under load. | + +--- + +## Scheduler controls + +![Scheduler controls](./assets/scheduler-controls.png) + +| Setting | Use it for | +|---|---| +| **Modern Game Scheduler (Beta)** | Enables the pipeline scheduler. Prefer it for new scenes; turn it off only when auditing an older scene that depends on legacy ordering. | +| **Use Fixed Rate Updates (Beta)** | Registers fixed behavior and lambda adapters. Use for physics-dependent gameplay, deterministic controller logic, and code that implements `fixedUpdate()`. | + +Fixed-rate updates do not mean every behavior should move into `fixedUpdate()`. +Use normal `update(deltaTime)` for visual effects, UI, camera polish, and +non-deterministic gameplay. Reserve `fixedUpdate()` for logic that benefits from +a fixed timestep. + +These toggles persist on the scene: + +```jsonc +{ + "userData": { + "scheduler": { + "enabled": true, + "behaviorUpdateMode": "fixed" // or "variable" + } + } +} +``` + +The active quality profile supplies the lower-level scheduler fields: +`frameBudgetMs`, `fixedTimestepHz`, `maxFixedStepsPerFrame`, +`enableTimeSlicing`, `spatialGridCellSize`, `renderPressureThreshold`, and +`deltaTimePressureThreshold`. + +--- + +## Frame buffering, yielding, and catch-up + +The scheduler has three separate mechanisms that are easy to confuse: + +| Mechanism | What it does | +|---|---| +| Fixed-step accumulator | Buffers elapsed time for `FIXED_UPDATE`, runs zero or more fixed steps, then caps work with `maxFixedStepsPerFrame`. Under render pressure it runs at most one fixed step and drops excess fixed debt instead of replaying a long backlog. | +| Throttle catch-up | Behaviors that are skipped by throttling accumulate skipped `deltaTime`; the next update receives an effective delta that includes that skipped time. Lambda `processObjects()` does the same with the callback `dt` by multiplying by the throttle factor. | +| Generator yielding | Advanced source-level systems can return a `Generator` and yield between chunks when registered as time-sliceable update systems. Source-authored lambdas can also override `applySliced()` / `updateSoASliced()`. | + +For normal editor-authored behavior and lambda code, do not rely on returning a +generator from `update()` as a resume mechanism. Keep per-frame work bounded, +use `processObjects()` for lambda iteration, split long jobs across frames with +your own state machine, or move pure computation into a background worker. + +The fixed-step accumulator also publishes interpolation state in the frame +context (`interpolationAlpha`, `fixedOverstep`) so render-facing systems can +smooth visuals between fixed simulation steps. + +--- + +## Rendering and physics controls + +Rendering controls manage draw-call and renderer compatibility choices: + +| Setting | Stored at | Notes | +|---|---|---| +| Enable Dynamic Batching | `scene.userData.rendering.batching.enableDynamic` | Rebuilds batching state when toggled. | +| Mesh Instancing Optimization | editor setting | Reduces repeated mesh draw overhead when suitable. | +| Clear Batching Data | runtime action | Clears current batching stats/debug data. | +| Force WebGL | `scene.userData.rendering.forceWebGL` | Compatibility fallback when WebGPU is unstable. | +| Force WebGL for VFX | `scene.userData.rendering.forceWebGLForVFX` | Keeps VFX on WebGL while the main renderer may use WebGPU. | + +Physics controls: + +| Setting | Stored at | Notes | +|---|---|---| +| Enable Physics Sleeping | `scene.userData.physicsSleepingEnabled` | Lets inactive bodies sleep until woken. | +| Multi-threaded Physics | `scene.userData.physicsUseWorker` | Runs heavier physics work in a worker where supported. | + +Post-processing and shadow sections expose renderer-specific quality controls. +Use them after choosing a quality preset so you are tuning from a known baseline. + +--- + +## Behavior performance controls + +![Behavior performance controls](./assets/scheduler-behavior-performance.png) + +The **Behavior Performance** section configures throttling for behavior updates: + +| Setting | Effect | +|---|---| +| Off Screen Optimization | Allows off-screen behaviors to update less often. | +| Distance-Based Optimization | Allows far behaviors to update less often. | +| Force Consistent Updates | Keeps updates consistent when throttling would cause visible/gameplay issues. | +| Update Priority | Marks behaviors as critical/high/medium/low/minimal for scheduler decisions. | +| Mid/Far Distance Threshold | Distances where throttle tiers begin. | +| Mid/Far Throttle Factor | How aggressively non-critical behaviors are skipped at those tiers. | + +These values are stored in `scene.userData.behaviorThrottlingConfig` and also +update the running game config when Play mode is active. + +Use critical/high priority for player controllers, combat resolution, and logic +that must stay frame-accurate. Use lower priorities for ambient props, distant +NPC polish, idle effects, and visual-only behaviors. + +--- + +## Budget and lambda profiling + +![Budget inspector and Lambda Explorer](./assets/scheduler-lambda-explorer.png) + +The **Budget Inspector** surfaces runtime budget state for avatars, plots, +textures, and hot rows. Use it when a scene is spending too much memory or when +runtime budget coordination is shedding work. + +The **Lambda Explorer** is disabled by default. Enable it in Play mode to see: + +- Active lambda instance count. +- Dependency wave count. +- Entity count per lambda instance. +- Average and maximum execution time per lambda. + +This is the fastest way to find a lambda that should be converted to +`processObjects()`, split into smaller systems, or moved partially into a worker. + +--- + +## LOD, developer tools, and splats + +The same panel includes tools that affect performance but are not scheduler +settings: + +| Section | What it configures | +|---|---| +| LOD Generation | Batch model LOD settings and optimized model generation. | +| Scene Root Transform Policy | Whether runtime auto-resets, warns about, or ignores non-identity scene root transforms. Stored in `scene.userData.rendering.rootTransformPolicy`. | +| Performance Statistics Overlay | Runtime frame diagnostics while playing. | +| Memory Statistics Overlay | Runtime memory diagnostics while playing. | +| Debug Mode | Development-only diagnostics through `app.debug` / storage. | +| Gaussian Splats / Spark Renderer Options | Splat culling, sort, LOD, and Spark renderer tuning stored under `scene.userData.rendering.splat` and related Spark options. | + +Turn overlays on only while profiling; leave them off for normal authoring and +published play. + +--- + +## Default Scene settings + +![Default Scene settings](./assets/default-scene-settings.png) + +The **Default Scene** entry edits scene-wide environment settings rather than a +single mesh: + +| Section | What it controls | +|---|---| +| Ambient Lighting | Global flat light color and intensity. Use sparingly; high values flatten form and make shadows less useful. | +| Hemisphere Lighting | Sky color, ground color, and intensity for simple outdoor-style fill lighting. | +| Fog | Scene fog mode and related fog parameters when enabled. | +| Scene Background | Color, equirectangular texture, cubemap, or gradient backdrop. Texture/cubemap modes also expose rotation, intensity, and blurriness. | +| Tone Mapping | Tone mapping operator and exposure. Use this after lighting is close; it affects final brightness and contrast. | +| Shadows | Real-time shadow toggle and shadow map type. Shadow map type changes require a reload. | + +These settings are serialized through the scene rendering/environment data, so +they travel with saved projects and scene exports. + +--- + +## Directional Light settings and behaviors + +![Directional Light settings](./assets/directional-light-settings.png) + +The default **Directional Light** is the scene's sun/key light object. Select it +from the Project tab to edit both its transform and light-specific controls: + +| Setting | Use it for | +|---|---| +| Transform | Position and rotation determine the light direction and helper placement. Directional lights are effectively infinitely far away; direction matters more than distance. | +| Unity-style | Uses a Unity-like directional light workflow for scenes or imported content that expect that convention. | +| Cast Shadow | Enables real-time shadows from this light. Keep this enabled on the main sun/key light and limit other shadow-casting lights. | +| Color / Intensity | Controls the key-light tint and brightness. | +| Shadow Map Resolution | Shadow texture size. Higher values improve detail but cost GPU memory and render time. | +| Shadow Camera Distance | Coverage area for directional shadows. Smaller coverage gives sharper shadows. | +| Shadow Bias / Normal Bias | Fine tuning for acne, peter-panning, and shimmer. Use tiny values. | +| Shadow Radius / Blur Samples | Softness controls for supported shadow map types. | + +Use the **Behaviors** tab on the directional light when the light should follow +a target, react to triggers, or simulate a day-night cycle. The `dayNightCycle` +behavior is the built-in starting point for animated sun direction and time of +day. Keep static lighting in the Properties tab; use behaviors only when light +state changes during play. + +--- + +## Playground vs server-backed revision control + +The playground/OSS storage path keeps projects local and latest-only for asset +edits. Behavior, lambda, script import, and setting changes resolve to the +current local version so iteration is simple. + +A server-backed install adds full revision history through `ProjectStore`, +`AssetSource`, and the `@stem/network` revision endpoints. In that mode, each +save can create an immutable scene or asset revision, history panels can diff +and roll back, and published players can stay pinned to a release while editors +continue working on head. + +See [`server-side-storage.md`](./server-side-storage.md) for the backend +interfaces behind that version-control model. + +--- + +## Recommended workflow + +1. Pick the closest quality preset for the target device. +2. Enable the modern scheduler for new scenes. +3. Use fixed updates only for deterministic or physics-linked systems. +4. Tune behavior throttling before hand-optimizing individual scripts. +5. Use Lambda Explorer in Play mode when many objects share the same logic. +6. Use Budget Inspector and overlays to confirm the bottleneck before lowering + visual quality. +7. Save behavior, lambda, and import assets through the editor or designer so + revisions and dependencies stay pinned correctly. diff --git a/scripts/playwright/capture-scheduler-docs.mjs b/scripts/playwright/capture-scheduler-docs.mjs new file mode 100644 index 00000000..b043f58e --- /dev/null +++ b/scripts/playwright/capture-scheduler-docs.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * Capture screenshots for docs/scheduler-and-editor-settings.md. + * + * Requires the editor dev server: + * bun run dev:editor + * + * Optional: + * PLAYWRIGHT_BASE_URL=http://localhost:5173 + * HEADED=1 + */ +import {chromium} from "playwright"; +import {mkdirSync} from "node:fs"; +import {resolve} from "node:path"; + +const baseUrl = (process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173").replace(/\/$/, ""); +const headed = process.env.HEADED === "1"; +const outDir = resolve(process.cwd(), "docs/assets"); +mkdirSync(outDir, {recursive: true}); + +const browser = await chromium.launch({headless: !headed}); +const context = await browser.newContext({ + viewport: {width: 1440, height: 1000}, + deviceScaleFactor: 1, +}); +const page = await context.newPage(); + +const shotClip = () => { + const viewport = page.viewportSize() || {width: 1440, height: 1000}; + return { + x: Math.max(0, viewport.width - 430), + y: 64, + width: Math.min(430, viewport.width), + height: Math.min(880, viewport.height - 80), + }; +}; + +async function dismissBootstrapModal() { + const modal = page.locator('[aria-labelledby="oss-bootstrap-title"]').first(); + if (await modal.count() && await modal.isVisible().catch(() => false)) { + await modal.locator('button:has-text("Browser storage")').first().click({timeout: 3000}).catch(() => {}); + await modal.locator('button:has-text("Continue")').first().click({timeout: 5000}).catch(() => {}); + await page.waitForSelector('[aria-labelledby="oss-bootstrap-title"]', {state: "detached", timeout: 5000}).catch(() => {}); + } +} + +async function dismissTutorialModal() { + const gotIt = page.locator('button:has-text("Got It")').first(); + if (await gotIt.count() && await gotIt.isVisible().catch(() => false)) { + await gotIt.click({timeout: 3000}).catch(() => {}); + await page.waitForTimeout(300); + } +} + +async function openProjectTab() { + await page.locator('[data-testid="leftpanel-tab-project"]').first().click({timeout: 5000, force: true}); + await page.waitForTimeout(500); + await page.waitForSelector('.ProjectTab', {timeout: 5000}); +} + +async function openProjectPanel(label, waitForText) { + await openProjectTab(); + await page.locator('.ProjectTab').locator(`text=${label}`).first().click({timeout: 5000, force: true}); + await page.waitForSelector(`text=${waitForText}`, {timeout: 8000}); +} + +async function openRenderingPanel() { + await openProjectPanel("Rendering & Performance", "Quality Presets"); +} + +async function screenshotViewport(filename) { + await page.mouse.move(80, 80).catch(() => {}); + await page.waitForTimeout(500); + await page.screenshot({ + path: resolve(outDir, filename), + }); + console.log(`captured docs/assets/${filename}`); +} + +async function screenshotProjectPanel(label, waitForText, filename) { + await openProjectPanel(label, waitForText); + await screenshotRightPanel(filename); +} + +async function screenshotRightPanel(filename) { + await page.mouse.move(80, 80).catch(() => {}); + await page.waitForTimeout(500); + await page.screenshot({ + path: resolve(outDir, filename), + clip: shotClip(), + }); + console.log(`captured docs/assets/${filename}`); +} + +async function captureWhereToFindSettings() { + await openProjectTab(); + await screenshotViewport("editor-project-tab-map.png"); + await screenshotProjectPanel("Project Settings", "Project Details", "project-settings-overview.png"); +} + +async function captureRenderingPanel() { + await page.locator('.ProjectTab').locator('text=Rendering & Performance').first().click({timeout: 5000, force: true}); + await page.waitForSelector('text=Quality Presets', {timeout: 8000}); + + await screenshotRightPanel("scheduler-settings-overview.png"); + + await page.locator('text=Balanced').first().click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(500); + await screenshotRightPanel("scheduler-quality-presets.png"); + await clickPresetClose(); + await openRenderingPanel(); + + await scrollPanel(1150); + await screenshotRightPanel("scheduler-controls.png"); + + await scrollPanel(850); + await screenshotRightPanel("scheduler-behavior-performance.png"); + + await scrollPanel(1100); + const profilerToggle = page.locator('text=Enable Lambda Profiler').first(); + if (await profilerToggle.count()) { + await profilerToggle.click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(500); + } + await screenshotRightPanel("scheduler-lambda-explorer.png"); +} + +async function scrollPanel(deltaY) { + const viewport = page.viewportSize() || {width: 1440, height: 1000}; + await page.mouse.move(viewport.width - 220, 500); + await page.mouse.wheel(0, deltaY); + await page.waitForTimeout(700); +} + +async function clickPresetClose() { + const viewport = page.viewportSize() || {width: 1440, height: 1000}; + await page.mouse.click(viewport.width - 42, 255).catch(() => {}); + await page.keyboard.press("Escape").catch(() => {}); + await page.waitForTimeout(300); +} + +try { + await page.goto(`${baseUrl}/create/project`, {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrapModal(); + await page.waitForTimeout(10000); + await dismissTutorialModal(); + await captureWhereToFindSettings(); + await captureRenderingPanel(); + await screenshotProjectPanel("Default Scene", "Ambient Lighting", "default-scene-settings.png"); + await screenshotProjectPanel("Directional Light", "Shadow Parameters", "directional-light-settings.png"); +} finally { + await browser.close(); +} diff --git a/scripts/playwright/site-docs.mjs b/scripts/playwright/site-docs.mjs index 00522029..daa35166 100755 --- a/scripts/playwright/site-docs.mjs +++ b/scripts/playwright/site-docs.mjs @@ -75,6 +75,13 @@ try { const archUrl = page.url(); assert("URL is /docs/architecture", /\/docs\/architecture$/.test(archUrl), archUrl); + // Scheduler/settings page added for editor performance controls. + await page.locator('.docs-sidebar a:has-text("Scheduler & editor settings")').first().click(); + await page.waitForURL(/\/docs\/scheduler-and-editor-settings/, {timeout: 5000}); + await page.waitForSelector('.docs-content h1:has-text("Scheduler and Editor Performance Settings")', {timeout: 6000}); + const schedulerImages = await page.locator(".docs-content img").count(); + assert("scheduler page renders screenshots", schedulerImages >= 9, ` count=${schedulerImages}`); + // BYOK page — confirm at least one heading rendered + GitHub-rewritten link await page.locator('.docs-sidebar a:has-text("BYOK")').first().click(); await page.waitForURL(/\/docs\/byok/, {timeout: 5000});