diff --git a/CLAUDE.md b/CLAUDE.md index 25333264..50518132 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,19 @@ When you add a new feature that needs to write data, route it through - Reach the engine from inside a behavior via `this.erth.*` and `this.gameObject`. Old `EventBus` and `this.target` style code is deprecated. +- **Editor lifecycle hooks run without `init()`.** `onEditorAdded`, + `onEditorAttributesUpdated`, and `onEditorUpdate` fire in the editor + (e.g. at import/attach time) where `init(game)` is never called. A + common bug class: a behavior caches `const erth = this.erth` (or + `game`) *only* inside `init()`, then dereferences that module-local in + an editor hook — yielding `Cannot read properties of undefined (reading + 'asset')` and silently failing to load textures/assets. Always read + engine handles from `this.erth` / `this.gameObject` directly, or + re-derive the locals at the top of *every* lifecycle entry point — + never assume `init()` already ran. Note `this.gameObject` exposes + `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/`. ## Lambdas (ECS) diff --git a/README.md b/README.md index 9265cff6..858ad6c6 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ If StemStudio is useful to you or your organization, please consider sponsoring ## Quick start -Prerequisites: [Bun](https://bun.sh) 1.0+, [Go](https://go.dev) 1.21+, [Node.js](https://nodejs.org) 20+. +Prerequisites: [Bun](https://bun.sh) 1.0+, [Go](https://go.dev) 1.21+, [Node.js](https://nodejs.org) 20+. New machine? See [Setting up your dev environment](#setting-up-your-dev-environment) for per-OS install steps (macOS, Windows, Linux). ```bash git clone https://github.com/your-org/stemstudio.git @@ -60,6 +60,72 @@ ANYTHING_WORLD_API_KEY=... Any key you omit makes that provider unavailable — the editor will prompt you for it when you first try a feature that needs it. +## Setting up your dev environment + +You need three tools on your `PATH`: **[Bun](https://bun.sh) 1.0+** (package manager + task runner), **[Go](https://go.dev) 1.21+** (the AI proxy server), and **[Node.js](https://nodejs.org) 20+** with `npm` (the multiplayer sidecar). Pick your OS below, then continue with [Quick start](#quick-start). + +### macOS + +Using [Homebrew](https://brew.sh): + +```bash +brew install oven-sh/bun/bun go node git +``` + +Or install each from its official site (links above). Apple Silicon and Intel are both supported. + +### Linux + +```bash +# Bun +curl -fsSL https://bun.sh/install | bash + +# Node.js 20+ — via nvm (recommended; distro packages are often older) +curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash +nvm install 20 + +# Go 1.21+ — prefer the official tarball; distro `golang-go` may lag +# https://go.dev/doc/install (or: sudo apt install golang-go / sudo dnf install golang) + +# Build essentials for any native dependency compilation +sudo apt install -y build-essential git # Debian/Ubuntu +# sudo dnf groupinstall "Development Tools" # Fedora/RHEL +``` + +Open a new shell (or `source ~/.bashrc`) so the freshly installed tools land on your `PATH`. + +### Windows + +**Use [WSL2](https://learn.microsoft.com/windows/wsl/install) (recommended).** Several `package.json` scripts use Unix-shell idioms — inline env vars like `BUILD_MODE=oss …` and `.sh` deploy scripts — that do not run under native `cmd`/PowerShell. WSL2 gives you a real Linux shell where everything works as documented. + +```powershell +wsl --install # installs Ubuntu; reboot if prompted +``` + +Then open the **Ubuntu** terminal and follow the **Linux** instructions above. Clone the repo *inside* the WSL filesystem (e.g. `~/code/…`, not `/mnt/c/…`) for usable file-watch and build performance. + +
+Native Windows (advanced, unsupported) + +```powershell +winget install OpenJS.NodeJS.LTS GoLang.Go Git.Git +powershell -c "irm bun.sh/install.ps1 | iex" +``` + +You will still need to work around the Unix-style scripts (e.g. run the editor, AI server, and MP sidecar separately and set `BUILD_MODE` via `$env:BUILD_MODE`). WSL2 is strongly preferred. + +
+ +### Verify + +```bash +bun --version # 1.0+ +go version # go1.21+ +node --version # v20+ +``` + +If all three print a version, you're ready for [Quick start](#quick-start). + ## What's in the box - Editor, player, runtime, behaviors, lambdas, physics, rendering, scheduler, asset loading. diff --git a/client/packages/editor-oss/src/EngineRuntime.ts b/client/packages/editor-oss/src/EngineRuntime.ts index 0fc9cc5b..8a9c3050 100644 --- a/client/packages/editor-oss/src/EngineRuntime.ts +++ b/client/packages/editor-oss/src/EngineRuntime.ts @@ -285,7 +285,11 @@ export class EngineRuntime extends AppRuntime implements RuntimeContext { global.app = this; global.three$1 = THREE; - + // TEMP debug: mirror the engine onto window so it can be inspected from + // the browser console (`global` is a module export, not window.app). + // Lets us read e.g. `app.scene.userData._matchStarted`. Remove later. + (window as unknown as {app?: unknown}).app = this; + this.viewport = undefined; this.width = this.container.clientWidth; this.height = this.container.clientHeight; @@ -1877,6 +1881,8 @@ export class EngineRuntime extends AppRuntime implements RuntimeContext { this.playerMask.hide(); //global.app.setAutoSave(this.autoSaveState); + // NOTE: game.reset() tears down the HUD DOM root via `this.hud?.clear()`, + // so the `hud-view-container` created in startPlayer is removed here. this.game?.reset(); this.clock.stop(); diff --git a/client/packages/editor-oss/src/agent/handlers/SettingsHandlers.ts b/client/packages/editor-oss/src/agent/handlers/SettingsHandlers.ts index 45d48d04..17c39478 100644 --- a/client/packages/editor-oss/src/agent/handlers/SettingsHandlers.ts +++ b/client/packages/editor-oss/src/agent/handlers/SettingsHandlers.ts @@ -1,9 +1,10 @@ import * as THREE from "three"; import {PCFShadowMap, PCFSoftShadowMap} from "three"; -import {Asset, AssetType, getAssetRevisionData} from "@stem/network/api/asset"; +import {Asset, AssetType} from "@stem/network/api/asset"; import {updateSceneThumbnail} from "@stem/network/api/scene/thumbnail"; import {getAssetResolutionContext, resolveAssetRevisionId} from "../../asset-management/AssetResolutionContext"; +import type {AssetRef} from "../../asset-management/AssetRef"; import EngineRuntime from "../../EngineRuntime"; import { normalizeBackgroundGradient, @@ -48,8 +49,24 @@ const DEFAULT_RENDERING = { export class SettingsHandlers { constructor(private engine: EngineRuntime) {} - private async resolveImageSource(source?: string): Promise { - if (!source) return source; + /** + * Resolves a background image source to the value that should be PERSISTED + * in the scene's rendering config: the original source string (a literal + * URL/path, or the asset name) plus, when it names a scene image asset, the + * stable {@link AssetRef} for that asset. + * + * Crucially this never returns a `blob:` object URL. The old code persisted + * `URL.createObjectURL(blob)` as the background texture, which is the cause + * of the "scene goes dark on reload" bug: object URLs are revoked when the + * page reloads, so the saved background/environment texture could no longer + * be fetched — the scene lost both its skybox and its image-based + * environment lighting, and the loader logged "Failed to load texture: + * blob:…". The AssetRef is what survives a reload; + * {@link EnvironmentSettingsManager.applyBackgroundSettings} prefers it and + * re-fetches the texture through the asset loader. + */ + private async resolveImageAsset(source?: string): Promise<{value?: string; assetRef?: AssetRef}> { + if (!source) return {value: source}; if ( source.startsWith("http://") || source.startsWith("https://") @@ -57,25 +74,26 @@ export class SettingsHandlers { || source.startsWith("blob:") || source.startsWith("/") ) { - return source; + return {value: source}; } const assetSource = this.engine.editor?.assetSource; if (!assetSource) { - return source; + return {value: source}; } const {assets} = await assetSource.getAssets({types: [AssetType.Image]}); const match = assets?.find((asset: Asset) => asset.name.toLowerCase() === source.toLowerCase()); if (!match) { - return source; + return {value: source}; } const context = getAssetResolutionContext(this.engine.scene); const revisionId = context ? resolveAssetRevisionId(match.id, context) : undefined; const finalRevisionId = revisionId || match.headRevisionId; - const blob = await getAssetRevisionData(match.id, finalRevisionId, "blob"); - return URL.createObjectURL(blob); + // Keep the human-readable source (the asset name) as the persisted + // `texture` value; the AssetRef is the durable handle used to re-fetch. + return {value: source, assetRef: {assetId: match.id, revisionId: finalRevisionId}}; } /** @@ -247,18 +265,26 @@ export class SettingsHandlers { const current = scene.userData.rendering.background || {...DEFAULT_BACKGROUND}; const normalizedGradient = normalizeBackgroundGradient(gradient, current.gradient); const normalizedGradientMode = normalizeGradientMode(gradientMode, current.gradientMode); - const resolvedTexture = texture ? await this.resolveImageSource(texture) : undefined; + // Resolve to a displayable URL AND a stable AssetRef. The AssetRef is + // what survives a reload (the `blob:` URL does not), so persisting it + // keeps the skybox + environment lighting after the project is saved + // and reopened. + const resolvedTexture = texture ? await this.resolveImageAsset(texture) : undefined; const resolvedCubemap = cubemap - ? await Promise.all(cubemap.map(face => this.resolveImageSource(face))) + ? await Promise.all(cubemap.map(face => this.resolveImageAsset(face))) : undefined; scene.userData.rendering.background = { ...current, type: type ?? current.type, color: color ?? current.color, - texture: resolvedTexture ?? current.texture, + texture: resolvedTexture?.value ?? current.texture, + textureAsset: texture ? resolvedTexture?.assetRef : current.textureAsset, cubemap: resolvedCubemap - ? (resolvedCubemap as [string, string, string, string, string, string]) + ? (resolvedCubemap.map(face => face.value ?? "") as [string, string, string, string, string, string]) : current.cubemap, + cubemapAssets: resolvedCubemap + ? resolvedCubemap.map(face => face.assetRef) + : current.cubemapAssets, gradient: normalizedGradient, gradientMode: normalizedGradientMode, rotation: rotation ?? current.rotation, diff --git a/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.test.ts b/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.test.ts new file mode 100644 index 00000000..b4f8d3e4 --- /dev/null +++ b/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import {describe, expect, it} from "vitest"; + +import {autoResolveImports, findByFilepath} from "./ImportBatchDialog"; +import type {ImportRequest} from "./ScriptExecutor"; + +/** Build a File whose webkitRelativePath encodes a subfolder path. */ +const fileAt = (relPath: string, bytes = "x"): File => { + const name = relPath.split("/").pop()!; + const f = new File([bytes], name, {type: "application/octet-stream"}); + Object.defineProperty(f, "webkitRelativePath", {value: relPath, configurable: true}); + return f; +}; + +const imageReq = (index: number, filepath: string, name: string): ImportRequest => ({ + index, + type: "image", + name, + filepath, + extensions: [".png", ".jpg", ".jpeg", ".webp"], +}); + +describe("autoResolveImports — explicit filepath resolution", () => { + it("resolves a filepath whose file has a NON-STANDARD extension (regression)", () => { + // Repro of the pirate-ship hang: a generator emitted duplicate textures + // as `PIR_Water.png-2 … -5`. Those filenames end in `.png-2`, not a known + // image extension. The resolver used to filter candidates by extension + // BEFORE matching the filepath, dropping these files → the import stayed + // "unresolved" → the blocking batch-import dialog opened → headless runs + // hung forever. An explicit filepath must resolve regardless of ext. + const folder = [ + fileAt("textures/PIR_Water.png"), + fileAt("textures/PIR_Water.png-2"), + fileAt("textures/PIR_Water.png-3"), + fileAt("textures/PIR_Water.png-4"), + fileAt("textures/PIR_Water.png-5"), + ]; + const imports: ImportRequest[] = [ + imageReq(0, "textures/PIR_Water.png", "PIR_Water.png"), + imageReq(1, "textures/PIR_Water.png-2", "PIR_Water.png 2"), + imageReq(2, "textures/PIR_Water.png-3", "PIR_Water.png 3"), + imageReq(3, "textures/PIR_Water.png-4", "PIR_Water.png 4"), + imageReq(4, "textures/PIR_Water.png-5", "PIR_Water.png 5"), + ]; + + const {files} = autoResolveImports(imports, folder); + + // EVERY import resolves — nothing is left for the dialog. + expect(files.size).toBe(5); + expect(files.get(1)?.name).toBe("PIR_Water.png-2"); + expect(files.get(4)?.name).toBe("PIR_Water.png-5"); + }); + + it("findByFilepath matches an odd-extension file by exact basename", () => { + const folder = [fileAt("textures/PIR_Water.png-2")]; + expect(findByFilepath(folder, "textures/PIR_Water.png-2")?.name).toBe("PIR_Water.png-2"); + }); + + it("still resolves normal model filepaths and reuses one file for several imports", () => { + // Four wheels all reference the one wheel.glb. + const folder = [fileAt("models/wheel.glb")]; + const wheel = (i: number): ImportRequest => ({ + index: i, type: "model", name: `wheel ${i}`, filepath: "models/wheel.glb", extensions: [".glb", ".gltf", ".fbx"], + }); + const {files} = autoResolveImports([wheel(0), wheel(1), wheel(2), wheel(3)], folder); + expect(files.size).toBe(4); + expect([...files.values()].every(f => f.name === "wheel.glb")).toBe(true); + }); +}); diff --git a/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.ts b/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.ts index 30ad48ed..89a335bc 100644 --- a/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.ts +++ b/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.ts @@ -50,19 +50,41 @@ export function autoResolveImports( const claimed = new Set(); for (const req of imports) { - const extMatches = folderFiles.filter(f => - //if multiple objects are created from the same model, this check filters out this file after first use - /*!claimed.has(fileKey(f)) && */req.extensions.some(ext => f.name.toLowerCase().endsWith(ext)), - ); - if (extMatches.length === 0) continue; + // Extension predicate reused below for both filepath and fuzzy matching. + // origin's variant filtered `folderFiles` into a local `extMatches` here + // and removed the claimed-filter so one model file can back several + // imports; that concern is already handled below where filepath matches + // resolve against the full (unfiltered) list. The fuzzy fallback still + // redeclares its own claimed-filtered `extMatches`, so keep the shared + // predicate form to avoid a duplicate declaration. + const extOf = (f: File) => req.extensions.some(ext => f.name.toLowerCase().endsWith(ext)); let match: File | null = null; + // An explicit filepath wins and may reuse a file already claimed by + // another import: one asset file can legitimately back several imports + // (e.g. four wheels all referencing wheel.glb, or one image reused as a + // texture and the scene thumbnail). Matching only *unclaimed* files left + // those duplicates unresolved, which popped a blocking import dialog and + // hung headless / automated imports. So resolve filepath against the + // full file list, not the claimed-filtered subset. if (req.filepath) { - match = findByFilepath(extMatches, req.filepath); + // Match an explicit filepath against the FULL file list, not the + // extension-filtered subset. A generator can emit a source file + // whose name carries a non-standard extension — e.g. duplicate + // textures saved as `PIR_Water.png-2`, `…-3`. Gating filepath + // resolution by `extOf` silently drops those files, leaving the + // import "unresolved", which pops the blocking batch-import dialog + // and hangs any headless/automated run forever (no user to click). + // The explicit filepath is already precise (exact path / basename), + // so it does not need — and is actively harmed by — the ext guard. + match = findByFilepath(folderFiles, req.filepath); } if (!match) { + // Fuzzy fallback only considers still-unclaimed files so two + // ambiguous imports don't grab the same file. + const extMatches = folderFiles.filter(f => !claimed.has(fileKey(f)) && extOf(f)); if (extMatches.length === 1) { match = extMatches[0]!; } else if (req.name) { diff --git a/client/packages/editor-oss/src/agent/script-tool/builtins.ts b/client/packages/editor-oss/src/agent/script-tool/builtins.ts index 9843edbf..619e06d5 100644 --- a/client/packages/editor-oss/src/agent/script-tool/builtins.ts +++ b/client/packages/editor-oss/src/agent/script-tool/builtins.ts @@ -24,8 +24,10 @@ export interface BuiltinContext { commandBuffer: string[]; /** Callback to clear terminal output */ clearOutput: () => void; - /** Callback to run a .stemscript file, optionally with folder files for auto-resolve */ - runScript?: (content: string, folderFiles?: File[]) => Promise; + /** Callback to run a .stemscript file, optionally with folder files for auto-resolve. + * Resolves to a run summary (`{executedCommands, successCount, failCount}`) or + * `undefined` for early-return paths (cancelled / no imports). */ + runScript?: (content: string, folderFiles?: File[]) => Promise<{executedCommands: number; successCount: number; failCount: number} | undefined>; /** Last script executed through the terminal, if any */ getLastScript?: () => {content: string; label?: string} | null; /** Callback to validate a script against current scene state */ diff --git a/client/packages/editor-oss/src/agent/script-tool/importHandler.ts b/client/packages/editor-oss/src/agent/script-tool/importHandler.ts index 54a8b617..f755c0b6 100644 --- a/client/packages/editor-oss/src/agent/script-tool/importHandler.ts +++ b/client/packages/editor-oss/src/agent/script-tool/importHandler.ts @@ -265,6 +265,14 @@ export function getSupportedImportTypes(): string[] { * @param name * @param companionFiles */ +/** Hex SHA-256 of a byte buffer, used to content-address imported model files. */ +async function sha256Hex(buffer: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(digest)) + .map(b => b.toString(16).padStart(2, "0")) + .join(""); +} + export async function processImportedFile( file: File, type: string, @@ -279,6 +287,16 @@ export async function processImportedFile( * the behavior is imported but never attached to any object. */ behaviorIdOverride?: string, + /** + * Run-scoped cache keyed by source-file content hash → the asset created for + * it. A stemscript that places the same model many times (e.g. pirate-ship's + * 77 `import model … filepath=models/rocks-a.glb`) otherwise creates a fresh + * multi-MB inline asset per placement. With this cache, identical source + * bytes import once and every later placement reuses that one asset (a new + * scene object still gets created per placement). Pass the same Map for an + * entire import batch; omit it for one-off imports. + */ + modelAssetDedupCache?: Map, ): Promise<{success: boolean; message: string}> { // Dynamic imports to keep module resolution lightweight for tests const [{default: global}, {setAssetRevision, resolveAssetRevisionId, getAssetResolutionContext}, {AssetType, ModelFormat, createAssetRevisionWithData, isNoChangesError, getAsset}, {createAsset}] = await Promise.all([ @@ -331,14 +349,27 @@ export async function processImportedFile( ); } } + let survivorAssetId: string | undefined; + let resultMessage = `Behavior "${config.name}" imported`; if (existingBhvConfig) { const assetId = existingBhvConfig.id || originalConfigId; // Always fetch the asset's actual HEAD revision to avoid stale-parent 409s let headRevisionId: string; try { headRevisionId = (await getAsset(assetId)).headRevisionId; - } catch { - // Fall back to scene-pinned revision if getAsset fails + } catch (err) { + // We matched this asset in the scene's behavior configs, so + // it exists — a getAsset failure here is unexpected. Falling + // back to the scene-pinned revision silently risks building + // a revision on a STALE parent (the merge-on-stale path can + // then drop the user's first edit). Surface it loudly; the + // pinned revision is only a last resort, not a quiet default. + console.error( + `[ScriptImport] getAsset("${assetId}") failed while resolving HEAD for ` + + `behavior "${config.name}"; falling back to scene-pinned revision. ` + + `A stale parent here can drop the next edit.`, + err, + ); const context = getAssetResolutionContext(scene); headRevisionId = (context ? resolveAssetRevisionId(assetId, context) : undefined) as string; } @@ -355,18 +386,59 @@ export async function processImportedFile( aliasId, retryOnConflict: true, }); - return {success: true, message: `Behavior "${config.name}" updated (new revision)`}; + survivorAssetId = assetId; + resultMessage = `Behavior "${config.name}" updated (new revision)`; } } - const newBehavior = await createBehavior({ - assetSource: global.app?.editor?.assetSource, - name: config.name, - code, - config, - aliasId: originalConfigId !== config.name ? originalConfigId : undefined, - }); - return {success: true, message: `Behavior "${config.name}" imported (${newBehavior.id})`}; + if (!survivorAssetId) { + const newBehavior = await createBehavior({ + assetSource: global.app?.editor?.assetSource, + name: config.name, + code, + config, + aliasId: originalConfigId !== config.name ? originalConfigId : undefined, + }); + survivorAssetId = newBehavior.id; + resultMessage = `Behavior "${config.name}" imported (${newBehavior.id})`; + } + + // OSS de-duplication (on import, per user request). OSS has no + // revision history — there is only the latest version — so earlier + // imports of the same behavior leave orphan asset records that pile + // up in the Behaviors panel (the reported 3× copies). Collapse every + // other same-named behavior record down to the survivor we just + // imported. Scene objects attach by the logical/alias id, which + // resolves to this survivor, so dropping the other records is safe + // for attachments. Gated to OSS; integrated keeps server-side history. + const {IS_OSS} = await import("../../mode/buildMode"); + if (IS_OSS) { + const {getOssAssetsForProject, unregisterOssAsset} = await import("@stem/network/api/asset"); + const projectId = editor.sceneID; + if (projectId) { + const configRegistry = editor.behaviorConfigRegistry; + const scriptRegistry = editor.behaviorScriptRegistry; + const dupes = getOssAssetsForProject(projectId).filter(r => + r.type === AssetType.Behavior && + r.name === config.name && + r.assetId !== survivorAssetId && + r.assetId !== originalConfigId, + ); + for (const dup of dupes) { + unregisterOssAsset(dup.assetId); + configRegistry?.unregisterConfig(dup.assetId, true); + scriptRegistry?.unregisterScript(dup.assetId, true); + } + if (dupes.length) { + console.info( + `[ScriptImport] OSS dedup: collapsed ${dupes.length} duplicate ` + + `"${config.name}" behavior record(s) into ${survivorAssetId}`, + ); + } + } + } + + return {success: true, message: resultMessage}; } case "lambda": { @@ -485,14 +557,63 @@ export async function processImportedFile( return {success: true, message: `Model "${modelName}" already in scene, skipping re-import`}; } + // Content-addressed dedup. If an identical source file was already + // imported in this batch, reuse that asset and just place a new + // scene object — skipping the whole load/convert/texture-bake + // pipeline AND avoiding a duplicate multi-MB inline asset. + let srcHash: string | undefined; + if (modelAssetDedupCache) { + try { + srcHash = await sha256Hex(await file.arrayBuffer()); + } catch { + srcHash = undefined; // crypto unavailable — fall through to a normal import + } + const cached = srcHash ? modelAssetDedupCache.get(srcHash) : undefined; + if (cached) { + setAssetRevision(scene, cached.id, cached.headRevisionId); + const {loadModel} = await import("../../model/load-util"); + const context = scene.userData?.assetResolutionContext || {}; + const object = await loadModel(cached.id, context); + if (name) object.name = name; + editor.addObject(object); + app?.call("objectChanged", null, scene); + return {success: true, message: `Model "${modelName}" placed (reused shared asset ${cached.id})`}; + } + } + const abortController = new AbortController(); const abortSignal = abortController.signal; // 1. Load model into Three.js (needed for thumbnail + texture cleanup) let model; + let loadedFormat: string | undefined; + let loadedRootFile: File | Blob | undefined; + let loadedAtlas: unknown; + let loadedTextureOverrides: unknown; try { - //all models are ZIP archives - ({model} = await loadModelFromFile(file, abortSignal, companionFiles, "application/zip")); + // Sniff the real container. A model import can be EITHER a + // ZIP archive bundling model + textures (e.g. pirate-ship's + // `.glb` files are actually `PK\x03\x04` zips) OR a raw + // model file (`.glb`/`.gltf`/`.fbx`/`.obj`, e.g. 100-cars, + // 3d-chess). Hardcoding "application/zip" here forced JSZip + // onto raw files, which threw "Can't find end of central + // directory" and silently dropped every non-zipped model + // from the scene. Detect by magic bytes so both shapes work. + const head = new Uint8Array(await file.slice(0, 4).arrayBuffer()); + const isZipArchive = + head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04; + ({ + model, + format: loadedFormat, + rootFile: loadedRootFile, + atlasData: loadedAtlas, + textureOverrides: loadedTextureOverrides, + } = await loadModelFromFile( + file, + abortSignal, + companionFiles, + isZipArchive ? "application/zip" : "", + )); } catch (loadErr) { if (loadErr instanceof AnimationOnlyModelError) { return {success: true, message: `Skipped "${modelName}": This file contains only animations (no 3D geometry). Animation files should be imported via the Animation Combiner tool.`}; @@ -503,8 +624,23 @@ export async function processImportedFile( // 2. Fix broken textures (especially FBX) await cleanupInvalidTextures(model); - // 3. Convert to GLB - const sourceGlbBuffer = await convertToGlb(model, abortSignal, {}); + // 3. Produce the GLB buffer to store. + // Fast path: a self-contained GLB source (no atlas, no loose + // texture remapping) is already a valid GLB — reuse its bytes + // and skip the GLTFExporter round-trip. `convertToGlb` here runs + // with empty options, so for GLB sources it would only re-encode + // the same data. The exporter is the dominant synchronous cost + // per model; skipping it for the common all-GLB asset pack case + // is what keeps large imports (e.g. 100+ models) from blocking + // the main thread for many minutes. FBX/OBJ/gltf-with-loose- + // textures/atlas still need the exporter to normalize into one + // GLB, so they take the fallback. + let sourceGlbBuffer: ArrayBuffer; + if (loadedFormat === "glb" && !loadedAtlas && !loadedTextureOverrides && loadedRootFile) { + sourceGlbBuffer = await loadedRootFile.arrayBuffer(); + } else { + sourceGlbBuffer = await convertToGlb(model, abortSignal, {}); + } // 4. Create LODs with meshopt compression + texture compression (best effort). // Skip in OSS: derivatives don't persist (no upload endpoint), and the @@ -539,6 +675,9 @@ export async function processImportedFile( lods: modelLods, thumbnail: thumbnailParam, }); + if (modelAssetDedupCache && srcHash) { + modelAssetDedupCache.set(srcHash, {id: asset.id, headRevisionId: asset.headRevisionId}); + } setAssetRevision(scene, asset.id, asset.headRevisionId); const {loadModel} = await import("../../model/load-util"); const context = scene.userData?.assetResolutionContext || {}; diff --git a/client/packages/editor-oss/src/behaviors/game/GameManager.ts b/client/packages/editor-oss/src/behaviors/game/GameManager.ts index 0bd39d3d..db15ce61 100644 --- a/client/packages/editor-oss/src/behaviors/game/GameManager.ts +++ b/client/packages/editor-oss/src/behaviors/game/GameManager.ts @@ -860,7 +860,7 @@ class GameManager { const promises: Promise[] = []; for (const behavior of behaviors) { const target = behaviorToTargetMap.get(behavior.uuid)!; - console.log( + console.debug( `[GameManager] About to add behavior "${behavior.id}" (uuid: ${behavior.uuid}) to object "${target.name}" (uuid: ${target.uuid})`, ); @@ -871,7 +871,7 @@ class GameManager { }; const promise = this.addBehaviorToObject(target, behavior.id, options) .then(() => { - console.log( + console.debug( `[GameManager] ✓ Successfully added behavior "${behavior.id}" to object "${target.name}"`, ); }) @@ -1504,7 +1504,7 @@ class GameManager { behaviorId: string, behaviorOptions?: CreateBehaviorOptions, ): Promise { - console.log( + console.debug( `[GameManager] addBehaviorToObject called with behaviorId: "${behaviorId}", target: "${target.name || target.uuid}", options:`, behaviorOptions, ); @@ -1540,7 +1540,7 @@ class GameManager { } } - console.log(`[GameManager] BehaviorManager exists, calling createBehavior for "${behaviorKey}"`); + console.debug(`[GameManager] BehaviorManager exists, calling createBehavior for "${behaviorKey}"`); const behavior = await this.behaviorManager.createBehavior(target, behaviorKey, behaviorOptions); if (!behavior) { @@ -1549,7 +1549,7 @@ class GameManager { return Promise.reject(error); } - console.log( + console.debug( `[GameManager] Successfully created behavior "${behaviorKey}" for object "${target.name || target.uuid}"`, ); @@ -1557,7 +1557,7 @@ class GameManager { const enableAtStart = typeof target.userData.enableAtStart === "boolean" ? target.userData.enableAtStart : true; // default to true if not set - console.log(`[GameManager] Object "${target.name || target.uuid}" enableAtStart: ${enableAtStart}`); + console.debug(`[GameManager] Object "${target.name || target.uuid}" enableAtStart: ${enableAtStart}`); if (!enableAtStart) { this.pauseObject(target, false); // Pause behaviors without cascading to children } diff --git a/client/packages/editor-oss/src/behaviors/uikit/UIKitPointerEvents.ts b/client/packages/editor-oss/src/behaviors/uikit/UIKitPointerEvents.ts index 82c6dc78..8ce7e07c 100644 --- a/client/packages/editor-oss/src/behaviors/uikit/UIKitPointerEvents.ts +++ b/client/packages/editor-oss/src/behaviors/uikit/UIKitPointerEvents.ts @@ -71,9 +71,11 @@ let initRefCount = 0; const activeRoots = new Set(); let hasConfiguredTransparentSort = false; -// Diagnostic logging for UIKit layout/sizing issues. Bounded so a stuck -// scene doesn't spam the console forever. -const UIKIT_DIAG = true; +// Diagnostic logging for UIKit layout/sizing issues. OFF by default — this is +// developer instrumentation that emits per-frame `[UIKitDiag]` snapshots for the +// first few frames of every registered root, which is console noise in normal +// use. Flip to true locally when debugging UIKit sizing/positioning. +const UIKIT_DIAG = false; const diagFramesRemaining = new Map(); const DIAG_MAX_FRAMES = 6; @@ -423,7 +425,21 @@ function initializePointerEvents(): void { return; } - pointerEventsInstance = forwardHtmlEvents(canvas, camera, scene, { + // The camera controls call setPointerCapture() on the scene container + // (#scene-container) on pointerdown for drag handling. That capture + // redirects every subsequent pointermove/pointerup for the gesture to the + // container, so a listener bound to the only ever sees the initial + // pointerdown — never the pointerup that completes a click. The result is + // that UIKit buttons receive pointerdown but never click. Bind the pointer + // forwarder to the capturing ancestor (falling back to the canvas) so the + // UI pointer system sees the full down -> up -> click sequence. The + // container and canvas share the same client rect, so the pointer -> NDC + // coordinate mapping (which uses the bound element's bounding rect) is + // unchanged. + const eventSource = + canvas.closest("#scene-container") ?? canvas.parentElement ?? canvas; + + pointerEventsInstance = forwardHtmlEvents(eventSource, camera, scene, { batchEvents: true, intersectEveryFrame: false, forwardPointerCapture: true, diff --git a/client/packages/editor-oss/src/controls/ControlsManager.js b/client/packages/editor-oss/src/controls/ControlsManager.js index c347fcac..56f88689 100644 --- a/client/packages/editor-oss/src/controls/ControlsManager.js +++ b/client/packages/editor-oss/src/controls/ControlsManager.js @@ -50,15 +50,48 @@ class ControlsManager extends BaseControls { const target = controls?.target || new THREE.Vector3(0, 0, 0); const sceneId = global.app.editor.sceneID; - const allData = JSON.parse(localStorage.getItem("savedCameras") || "{}"); + let allData; + try { + allData = JSON.parse(localStorage.getItem("savedCameras") || "{}"); + } catch { + allData = {}; + } allData[sceneId] = { position: camera.position.toArray(), target: target.toArray(), + savedAt: Date.now(), }; - localStorage.setItem("savedCameras", JSON.stringify(allData)); - console.log(`Camera saved for scene ${sceneId}:`, allData[sceneId]); + if (this._writeSavedCameras(allData)) { + console.log(`Camera saved for scene ${sceneId}:`, allData[sceneId]); + return; + } + + // localStorage is full (commonly from a large autoSaveData blob). Prune + // savedCameras to the most recent entries and retry once. This must + // NEVER rethrow: handlePlay() calls saveCamera(), so an uncaught + // QuotaExceededError silently breaks the Play button. + try { + const recent = Object.entries(allData) + .sort((a, b) => (b[1]?.savedAt || 0) - (a[1]?.savedAt || 0)) + .slice(0, 25); + const pruned = Object.fromEntries(recent); + pruned[sceneId] = allData[sceneId]; + if (this._writeSavedCameras(pruned)) return; + } catch { + /* fall through to warn */ + } + console.warn("ControlsManager.saveCamera: localStorage full; skipping camera save"); + } + + _writeSavedCameras(data) { + try { + localStorage.setItem("savedCameras", JSON.stringify(data)); + return true; + } catch { + return false; + } } initCameraPosition() { diff --git a/client/packages/editor-oss/src/controls/input/InputManager.ts b/client/packages/editor-oss/src/controls/input/InputManager.ts index a255f660..c41bae2e 100644 --- a/client/packages/editor-oss/src/controls/input/InputManager.ts +++ b/client/packages/editor-oss/src/controls/input/InputManager.ts @@ -275,7 +275,8 @@ export class InputManager implements InputProv */ getMotion(motionId: Motion): number { const motion = this.motions.get(motionId); - return motion ? motion.value + motion.delta : 0; + const value = motion ? motion.value + motion.delta : 0; + return value; } getVirtualDispatcher(): VirtualInputDispatcher { diff --git a/client/packages/editor-oss/src/editor/Editor.ts b/client/packages/editor-oss/src/editor/Editor.ts index 52ebdad8..26b3752e 100644 --- a/client/packages/editor-oss/src/editor/Editor.ts +++ b/client/packages/editor-oss/src/editor/Editor.ts @@ -120,6 +120,7 @@ import Vector3AttributeConverter from "./behaviors/converters/Vector3AttributeCo import VideoAttributeConverter from "./behaviors/converters/VideoAttributeConverter"; import {isSceneBehaviorsMigrated, migrateLegacyBehaviors} from "./behaviors/LegacyBehaviorMigration"; import {isLegacyBehaviorId} from "../behaviors/util"; +import {IS_OSS} from "../mode/buildMode"; import AssetAttributeConverter from "./behaviors/converters/AssetAttributeConverter"; import BooleanWidget from "./behaviors/widgets/BooleanWidget"; import ButtonWidget from "./behaviors/widgets/ButtonWidget"; @@ -1099,8 +1100,12 @@ class Editor { return; } - // Migrate legacy behaviors to Assets API - if (this.engine?.mode === ApplicationMode.EDIT && this.sceneID) { + // Migrate legacy behaviors to Assets API. + // OSS has no legacy cloud/Mongo behavior backend — and OSS asset IDs + // ("oss-asset--") are not 24-char Mongo ObjectIDs, so + // `isLegacyBehaviorId` would wrongly flag every OSS behavior as legacy + // and mint a fresh duplicate asset on every load. Skip migration in OSS. + if (!IS_OSS && this.engine?.mode === ApplicationMode.EDIT && this.sceneID) { await migrateLegacyBehaviors({ scene: this.scene, sceneId: this.sceneID, @@ -3256,14 +3261,30 @@ class Editor { const compact = (config: BehaviorConfig): BehaviorConfig | {id: string} => builtInIds.has(config.id) ? {id: config.id} : config; + // A behavior can be registered under more than one registry key — its + // asset id AND an import alias (the YAML config.id) — so getAllConfigs() + // returns the SAME behavior more than once, every copy carrying the same + // config.id. Worse, a behavior edit re-registers only the asset-id key + // (and moves it to the end of the registry Map), leaving the alias-keyed + // copy stale with the OLD code/config. Writing both a fresh and a stale + // copy into the scene is what made the FIRST behavior edit revert on + // reload: the stale duplicate could be hydrated last and win. De-duplicate + // by config.id, keeping the LAST (newest) entry — re-registration moves + // the freshly-saved config to the end, so last-wins selects new content. + const dedupeById = (configs: BehaviorConfig[]): BehaviorConfig[] => { + const byId = new Map(); + for (const config of configs) byId.set(config.id, config); + return Array.from(byId.values()); + }; + if (this.isSandbox) { - this.scene.userData.behaviorConfigs = legacyConfigs.map(compact); + this.scene.userData.behaviorConfigs = dedupeById(legacyConfigs).map(compact); return; } - this.scene.userData.behaviorConfigs = legacyConfigs - .filter(config => this.usedBehaviorIds.has(config.id) || config.isScript) - .map(compact); + this.scene.userData.behaviorConfigs = dedupeById( + legacyConfigs.filter(config => this.usedBehaviorIds.has(config.id) || config.isScript), + ).map(compact); const configNames = this.scene.userData.behaviorConfigs.map((config: any) => config.id || config.name); console.debug("[Editor] Saved scene behavior configs", configNames); diff --git a/client/packages/editor-oss/src/editor/asset-management/hooks/assets.ts b/client/packages/editor-oss/src/editor/asset-management/hooks/assets.ts index bf48d5e2..661ff17a 100644 --- a/client/packages/editor-oss/src/editor/asset-management/hooks/assets.ts +++ b/client/packages/editor-oss/src/editor/asset-management/hooks/assets.ts @@ -625,9 +625,23 @@ export const fetchAssetImageDerivative = async ( const derivatives = await getAssetDerivatives(assetId, resolvedRevisionId, {includeDataUrl: true}); const imageDerivative = derivatives.find(d => d.type === AssetDerivativeType.Image); - if (!imageDerivative?.dataUrl) throw new Error("Image derivative missing dataUrl"); + if (imageDerivative?.dataUrl) return imageDerivative.dataUrl; + + // No image derivative — fall back to the revision's inline data URL, the + // same fallback AssetLoader.getImageDataUrl uses. This is the *only* path + // that works in OSS: there is no integrated CDN, so getAssetDerivatives + // always returns [] and the image bytes live inline as a data: URL on the + // synthesized revision record. (It also covers the integrated case where a + // derivative simply hasn't been generated yet.) Without this, textures + // resolved through this fallback — e.g. an OceanSurface base map whose + // revision id isn't in the resolution context, so materialUtils skips the + // assetLoader path — never render. + const revision = await getAssetRevision(defaultQueryClient, assetId, resolvedRevisionId, { + includeDataUrl: true, + }); + if (revision?.dataUrl) return revision.dataUrl; - return imageDerivative.dataUrl; + throw new Error("Image derivative missing dataUrl"); }; export const useAssetImageDerivative = (assetId?: string, revisionId?: string) => { diff --git a/client/packages/editor-oss/src/editor/assets/v2/AiCopilot/TerminalView/useTerminal.ts b/client/packages/editor-oss/src/editor/assets/v2/AiCopilot/TerminalView/useTerminal.ts index 0755af1d..15b9c5c5 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/AiCopilot/TerminalView/useTerminal.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/AiCopilot/TerminalView/useTerminal.ts @@ -17,7 +17,7 @@ import {refreshEditorAssets} from "../../../../../editor/asset-management/hooks/ import type EngineRuntime from "@stem/editor-oss/EngineRuntime"; import global from "@stem/editor-oss/global"; import {queryClient} from "@web-shared/queryClient"; -import {showToast} from "@stem/editor-oss/showToast"; +import {showToast, showLoadingToast, dismissToast} from "@stem/editor-oss/showToast"; const DEFAULT_EXTENSION_BY_TYPE: Record = { model: ".glb", @@ -45,6 +45,52 @@ const CONTENT_TYPE_BY_EXT: Record = { const getEngineRuntime = (): EngineRuntime | undefined => global.app as EngineRuntime | undefined; +/** + * Per-import safety ceiling. A single asset/behavior import that stalls + * indefinitely would hang the entire stemscript run with no surfaced error. + * Generous enough that a heavy model (FBX → GLB conversion + texture bake) or a + * behavior revision completes well within it; short enough that a true hang + * fails the run in seconds, not the 20-minute outer ceiling. + */ +const IMPORT_STEP_TIMEOUT_MS = 90_000; + +/** + * Race a single import against {@link IMPORT_STEP_TIMEOUT_MS}. On timeout, the + * import is reported as a failed result (so the run's failCount reflects it and + * the named culprit is logged) and the loop continues with the next import — + * the run never silently spins forever on one stuck import. + */ +async function withImportTimeout( + work: Promise<{success: boolean; message: string}>, + label: string, +): Promise<{success: boolean; message: string}> { + let timer: ReturnType | undefined; + const timeout = new Promise<{success: boolean; message: string}>(resolve => { + timer = setTimeout(() => { + const message = `Import "${label}" timed out after ${IMPORT_STEP_TIMEOUT_MS}ms — skipped so the run can continue. This import hangs; it needs investigation.`; + console.error(`[import-timeout] ${message}`); + resolve({success: false, message}); + }, IMPORT_STEP_TIMEOUT_MS); + }); + try { + return await Promise.race([work, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Programmatic result of a full `runScript` pass. `failCount > 0` means at + * least one command (including an unresolved import) failed — the run was NOT + * clean. Surfaced through `window.__stemRunScript` so a test harness can assert + * a complete, error-free import instead of inferring it from console scraping. + */ +export type ScriptRunSummary = { + executedCommands: number; + successCount: number; + failCount: number; +}; + type SceneAuditObject = { visible?: boolean; parent?: SceneAuditObject | null; @@ -247,6 +293,9 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} return aFirst - bFirst; }); const results = new Map(); + // One dedup cache for the whole batch: identical model source files + // import once and every later placement reuses that shared asset. + const modelAssetDedupCache = new Map(); for (const req of sorted) { const file = files.get(req.index); if (file && hasEditorContext) { @@ -256,8 +305,33 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} const behaviorIdOverride = req.type === "behavior" && req.filepath ? behaviorIdByFilepath?.get(req.filepath) : undefined; - const result = await processImportedFile(file, req.type, req.name, companions, behaviorIdOverride); + // A single import must never be able to hang the whole run. A + // never-resolving await inside processImportedFile (e.g. a + // behavior revision call that stalls) would otherwise spin + // forever with no error — the exact "failure that never + // surfaces" we forbid. Bound each import and, on timeout, + // surface it as a named failure and move on to the next. + const result = await withImportTimeout( + processImportedFile(file, req.type, req.name, companions, behaviorIdOverride, modelAssetDedupCache), + `${req.type} "${label}"`, + ); results.set(req.index, result); + } else { + // Previously this branch dropped the import silently — that is how + // raw-glb imports outside the asset folder (e.g. pirate-ship's + // skybox_day.glb at models/skybox_day.glb) disappeared with no + // trace in the scene or the logs. Surface the skip + its reason. + const label = req.name || req.filepath || `${req.type} #${req.index}`; + const reason = !hasEditorContext + ? "no active editor/asset context" + : "no source file resolved (filepath not matched in the imported folder)"; + console.warn(`[import-skip] Skipped "${label}" (${req.type}) — ${reason}`, { + index: req.index, + filepath: req.filepath, + url: req.url, + }); + addEntry(`import ${req.type} ${label}`, `Skipped: ${reason}`, "error"); + results.set(req.index, {success: false, message: `Skipped: ${reason}`}); } } return results; @@ -300,73 +374,100 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} // The full runScript flow; exposed below on `window.__stemRunScript` in // OSS dev so a Playwright test can drive the `exec` pipeline without // going through the OS file picker. - const runScript = useCallback(async (content: string, folderFiles?: File[]) => { + const runScript = useCallback(async (content: string, folderFiles?: File[]): Promise => { let scriptExecutionFinished = false; + let runSummary: ScriptRunSummary | undefined; - // Show persistent toasts for proxy requirements - const proxyRequirements = ScriptExecutor.extractProxyRequirements(content); - for (const req of proxyRequirements) { - const label = req.comment || req.alias; - showToast({ - type: "info", - title: `Proxy required: ${label}`, - body: `Route "${req.alias}" → ${req.destination}`, - duration: 30000, - }); - } + // Persistent spinner toast for the *entire* import — asset resolution + // (which is the slow part: models are fetched and baked here) through + // command execution and the trailing auto-save. Created up front and + // dismissed in the single `finally` below so the user always has + // feedback that an import is in progress; every early-return path + // (cancelled dialog, no imports) flows through that same `finally`. + const importSpinnerId = showLoadingToast("Importing scene…", "Loading assets & running stemscript…"); - // Pre-scan for imports and show batch dialog if any found - const importRequests = ScriptExecutor.extractImports(content); - - // Pre-resolve URL-based imports (export-mode bundles) by fetching each - // URL into a File. These go into the resolved map *before* the batch - // dialog / folder auto-resolve, so the user never has to hand-pick a - // file for an asset whose URL is already known. - const urlResolved = new Map(); - const urlFailures: {req: typeof importRequests[number]; message: string}[] = []; - for (const req of importRequests) { - if (!req.url) continue; - try { - const response = await fetch(req.url); - if (!response.ok) { - urlFailures.push({req, message: `HTTP ${response.status}`}); - continue; + try { + // Show persistent toasts for proxy requirements + const proxyRequirements = ScriptExecutor.extractProxyRequirements(content); + for (const req of proxyRequirements) { + const label = req.comment || req.alias; + showToast({ + type: "info", + title: `Proxy required: ${label}`, + body: `Route "${req.alias}" → ${req.destination}`, + duration: 30000, + }); + } + + // Pre-scan for imports and show batch dialog if any found + const importRequests = ScriptExecutor.extractImports(content); + + // Pre-resolve URL-based imports (export-mode bundles) by fetching each + // URL into a File. These go into the resolved map *before* the batch + // dialog / folder auto-resolve, so the user never has to hand-pick a + // file for an asset whose URL is already known. + const urlResolved = new Map(); + const urlFailures: {req: typeof importRequests[number]; message: string}[] = []; + for (const req of importRequests) { + if (!req.url) continue; + try { + const response = await fetch(req.url); + if (!response.ok) { + urlFailures.push({req, message: `HTTP ${response.status}`}); + continue; + } + const blob = await response.blob(); + const filename = filenameForUrlImport(req.url, req.type, req.name); + const file = new File([blob], filename, {type: blob.type || inferContentType(filename)}); + urlResolved.set(req.index, file); + } catch (err: unknown) { + urlFailures.push({req, message: err instanceof Error ? err.message : String(err)}); } - const blob = await response.blob(); - const filename = filenameForUrlImport(req.url, req.type, req.name); - const file = new File([blob], filename, {type: blob.type || inferContentType(filename)}); - urlResolved.set(req.index, file); - } catch (err: unknown) { - urlFailures.push({req, message: err instanceof Error ? err.message : String(err)}); } - } - for (const failure of urlFailures) { - const label = failure.req.name || failure.req.type; - addEntry( - `(prefetch)`, - `Failed to fetch asset URL for ${label}: ${failure.message}`, - "error", - ); - } + for (const failure of urlFailures) { + const label = failure.req.name || failure.req.type; + addEntry( + `(prefetch)`, + `Failed to fetch asset URL for ${label}: ${failure.message}`, + "error", + ); + } - if (importRequests.length > 0) { - let resolvedFiles: Map = new Map(urlResolved); - let resolvedCompanions: Map = new Map(); - // Imports still needing a source (neither a URL we could fetch nor - // pre-picked) — these feed into folder auto-resolve / dialog. - const unresolvedRequests = importRequests.filter(r => !resolvedFiles.has(r.index)); - - if (unresolvedRequests.length === 0) { - // All imports resolved from URLs. Skip folder/dialog entirely. - } else if (folderFiles && folderFiles.length > 0) { - // Auto-resolve imports from the folder - const autoResult: AutoResolveResult = autoResolveImports(unresolvedRequests, folderFiles); - for (const [idx, file] of autoResult.files) resolvedFiles.set(idx, file); - for (const [idx, comps] of autoResult.companionFiles) resolvedCompanions.set(idx, comps); - const stillUnresolved = unresolvedRequests.filter(r => !resolvedFiles.has(r.index)); - - if (stillUnresolved.length > 0) { - const dialogResult = await showBatchImportDialog(unresolvedRequests, autoResult); + if (importRequests.length > 0) { + let resolvedFiles: Map = new Map(urlResolved); + let resolvedCompanions: Map = new Map(); + // Imports still needing a source (neither a URL we could fetch nor + // pre-picked) — these feed into folder auto-resolve / dialog. + const unresolvedRequests = importRequests.filter(r => !resolvedFiles.has(r.index)); + + if (unresolvedRequests.length === 0) { + // All imports resolved from URLs. Skip folder/dialog entirely. + } else if (folderFiles && folderFiles.length > 0) { + // Auto-resolve imports from the folder + const autoResult: AutoResolveResult = autoResolveImports(unresolvedRequests, folderFiles); + for (const [idx, file] of autoResult.files) resolvedFiles.set(idx, file); + for (const [idx, comps] of autoResult.companionFiles) resolvedCompanions.set(idx, comps); + const stillUnresolved = unresolvedRequests.filter(r => !resolvedFiles.has(r.index)); + + if (stillUnresolved.length > 0) { + const dialogResult = await showBatchImportDialog(unresolvedRequests, autoResult); + if (dialogResult.action === "cancel") { + addEntry("(script)", "Script execution cancelled.", "info"); + return; + } + if (dialogResult.action === "import" && dialogResult.files.size > 0) { + for (const [idx, file] of dialogResult.files) resolvedFiles.set(idx, file); + for (const [idx, comps] of dialogResult.companionFiles) resolvedCompanions.set(idx, comps); + } else { + importResultsRef.current = new Map(); + importCounterRef.current = 0; + resolvedFiles = new Map(); + resolvedCompanions = new Map(); + } + } + } else { + // No folder files — dialog for the unresolved ones. + const dialogResult = await showBatchImportDialog(unresolvedRequests); if (dialogResult.action === "cancel") { addEntry("(script)", "Script execution cancelled.", "info"); return; @@ -381,73 +482,44 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} resolvedCompanions = new Map(); } } - } else { - // No folder files — dialog for the unresolved ones. - const dialogResult = await showBatchImportDialog(unresolvedRequests); - if (dialogResult.action === "cancel") { - addEntry("(script)", "Script execution cancelled.", "info"); - return; - } - if (dialogResult.action === "import" && dialogResult.files.size > 0) { - for (const [idx, file] of dialogResult.files) resolvedFiles.set(idx, file); - for (const [idx, comps] of dialogResult.companionFiles) resolvedCompanions.set(idx, comps); - } else { + + if (resolvedFiles.size > 0) { + const behaviorIdByFilepath = await buildBehaviorIdMap(folderFiles); + const results = await processResolvedImports( + importRequests, resolvedFiles, resolvedCompanions, behaviorIdByFilepath, + ); + importResultsRef.current = results; + importCounterRef.current = 0; + } else if (!importResultsRef.current) { importResultsRef.current = new Map(); importCounterRef.current = 0; - resolvedFiles = new Map(); - resolvedCompanions = new Map(); } } - if (resolvedFiles.size > 0) { - const behaviorIdByFilepath = await buildBehaviorIdMap(folderFiles); - const results = await processResolvedImports( - importRequests, resolvedFiles, resolvedCompanions, behaviorIdByFilepath, - ); - importResultsRef.current = results; - importCounterRef.current = 0; - } else if (!importResultsRef.current) { - importResultsRef.current = new Map(); - importCounterRef.current = 0; - } - } - - // All import-resolution and cancellation paths above have already - // returned. Wipe previously-imported content now so the script runs - // against the same blank-scene baseline every time. Wrapping the - // execute() call in runInScriptImportContext tags every object the - // script adds with userData.isImported, which the next exec uses to - // wipe these same objects again. - const editor = getEngineRuntime()?.editor; - if (editor) { - try { - editor.clearImportedContent(); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - addEntry("(script)", `Pre-exec wipe failed (continuing): ${message}`, "error"); + // All import-resolution and cancellation paths above have already + // returned. Wipe previously-imported content now so the script runs + // against the same blank-scene baseline every time. Wrapping the + // execute() call in runInScriptImportContext tags every object the + // script adds with userData.isImported, which the next exec uses to + // wipe these same objects again. + const editor = getEngineRuntime()?.editor; + if (editor) { + try { + editor.clearImportedContent(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + addEntry("(script)", `Pre-exec wipe failed (continuing): ${message}`, "error"); + } } - } - try { + const onProgress = (current: number, total: number, line: string) => { + addEntry(line, `Executing ${current}/${total}...`, "info"); + }; const result = editor ? await editor.runInScriptImportContext(() => - ScriptExecutor.execute( - content, - executeRegistryCommand, - (current: number, total: number, line: string) => { - addEntry(line, `Executing ${current}/${total}...`, "info"); - }, - executeScriptBuiltin, - ), + ScriptExecutor.execute(content, executeRegistryCommand, onProgress, executeScriptBuiltin), ) - : await ScriptExecutor.execute( - content, - executeRegistryCommand, - (current: number, total: number, line: string) => { - addEntry(line, `Executing ${current}/${total}...`, "info"); - }, - executeScriptBuiltin, - ); + : await ScriptExecutor.execute(content, executeRegistryCommand, onProgress, executeScriptBuiltin); const summary = `Script complete: ${result.successCount}/${result.executedCommands} succeeded, ${result.failCount} failed`; addEntry("(script)", summary, result.failCount > 0 ? "error" : "success"); @@ -462,6 +534,15 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} } scriptExecutionFinished = true; + // Programmatic summary so a harness (window.__stemRunScript) can + // assert the run was actually clean — failCount > 0 includes any + // import that could not be resolved (e.g. a skybox file the folder + // didn't supply). An incomplete import must never look successful. + runSummary = { + executedCommands: result.executedCommands, + successCount: result.successCount, + failCount: result.failCount, + }; } finally { if (scriptExecutionFinished) { try { @@ -476,9 +557,11 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} } } + dismissToast(importSpinnerId); importResultsRef.current = null; importCounterRef.current = 0; } + return runSummary; }, [executeRegistryCommand, addEntry, executeScriptBuiltin, processResolvedImports, buildBehaviorIdMap]); const executeInput = useCallback(async (input: string): Promise => { @@ -584,7 +667,7 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} __stemRunScript?: ( content: string, files?: Array<{name: string; mime?: string; data: string}>, - ) => Promise; + ) => Promise; }; w.__stemRunScript = async (content, files) => { console.log("[__stemRunScript] start", {scriptBytes: content.length, fileCount: (files ?? []).length}); @@ -621,15 +704,21 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} // the timeout breadcrumb. Generous enough that a full game // import (dozens of models, real GLB parsing) completes inside // it rather than tripping a spurious timeout. - const HARD_TIMEOUT_MS = 240_000; + // Bumped from 240s→600s→1200s: the heaviest games (procedural + // terrain; cubecity's 32 building GLBs through the per-model + // texture-conversion pipeline; 100+ vehicle GLBs) can take many + // minutes, and a premature timeout makes the harness save/reload + // before every asset is created (a partial import). + const HARD_TIMEOUT_MS = 1_200_000; try { - await Promise.race([ + const summary = await Promise.race([ runScript(content, folderFiles), - new Promise((_, reject) => + new Promise((_, reject) => setTimeout(() => reject(new Error(`__stemRunScript timed out after ${HARD_TIMEOUT_MS}ms`)), HARD_TIMEOUT_MS), ), ]); - console.log("[__stemRunScript] done"); + console.log("[__stemRunScript] done", summary); + return summary; } catch (err) { console.error("[__stemRunScript] threw", err); throw err; @@ -649,6 +738,7 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} const w = window as unknown as { __stemGetScene?: () => { sceneName: string | null; + sceneID: string | null; mode: string | null; isPlaying: boolean; assetCount: number; @@ -662,6 +752,24 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} visibleRenderableCount: number; meshCount: number; visibleMeshCount: number; + lights: Array<{type: string; name: string; intensity: number; visible: boolean; parent: string | null}>; + rendering: { + ambient: {color: string; intensity: number} | null; + hemisphere: {skyColor: string; groundColor: string; intensity: number} | null; + backgroundType: string | null; + backgroundTexture: string | null; + hasBackgroundTextureAsset: boolean; + }; + sceneEnv: { + background: string | null; + environment: string | null; + backgroundDetail?: unknown; + hasBackgroundNode?: boolean; + hasEnvironmentNode?: boolean; + backgroundIntensity?: number | null; + backgroundBlurriness?: number | null; + backgroundRotationY?: number | null; + }; }; }; w.__stemGetScene = () => { @@ -670,6 +778,7 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} if (!scene) { return { sceneName: null, + sceneID: null, mode: null, isPlaying: false, assetCount: 0, @@ -683,6 +792,15 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} visibleRenderableCount: 0, meshCount: 0, visibleMeshCount: 0, + lights: [], + rendering: { + ambient: null, + hemisphere: null, + backgroundType: null, + backgroundTexture: null, + hasBackgroundTextureAsset: false, + }, + sceneEnv: {background: null, environment: null}, }; } @@ -698,6 +816,45 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} const getObjectLabel = (object: SceneAuditObject) => object.name || `${object.type || "Object3D"}:${object.uuid || "unknown"}`; + // Classifies scene.background / scene.environment into a coarse, + // serializable label so Playwright can assert the skybox/IBL is + // present (Texture/CubeTexture) vs. lost (a flat Color / null). + const classifyEnv = (value: unknown): string | null => { + if (!value || typeof value !== "object") return null; + const v = value as {isCubeTexture?: boolean; isTexture?: boolean; isColor?: boolean}; + if (v.isCubeTexture) return "CubeTexture"; + if (v.isTexture) return "Texture"; + if (v.isColor) return "Color"; + return "Unknown"; + }; + // Detailed texture description so we can tell a *correctly rendered* + // skybox from one that loaded with the wrong mapping/colorSpace or a + // missing image (which renders flat/black even though it classifies + // as "Texture"). + const describeTexture = (value: unknown) => { + const t = value as { + isTexture?: boolean; + isColor?: boolean; + mapping?: number; + colorSpace?: string; + flipY?: boolean; + image?: {width?: number; height?: number} | null; + source?: {data?: {width?: number; height?: number} | null} | null; + } | null; + if (!t || typeof t !== "object") return null; + if (t.isColor) return {kind: "Color"}; + if (!t.isTexture) return {kind: "Unknown"}; + const img = t.image || t.source?.data || null; + return { + kind: "Texture", + mapping: t.mapping ?? null, + colorSpace: t.colorSpace ?? null, + flipY: t.flipY ?? null, + width: img?.width ?? null, + height: img?.height ?? null, + }; + }; + const names: string[] = []; const visibleObjectNames: string[] = []; const renderableNames: string[] = []; @@ -708,11 +865,35 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} let visibleRenderableCount = 0; let meshCount = 0; let visibleMeshCount = 0; + const lights: Array<{ + type: string; + name: string; + intensity: number; + visible: boolean; + parent: string | null; + }> = []; scene.traverse(object => { objectCount += 1; if (object.name) names.push(object.name); + if ((object as unknown as {isLight?: boolean}).isLight) { + const light = object as unknown as { + type: string; + name: string; + intensity: number; + visible: boolean; + parent?: {name?: string; type?: string} | null; + }; + lights.push({ + type: light.type, + name: light.name, + intensity: light.intensity, + visible: light.visible, + parent: light.parent ? light.parent.name || light.parent.type || null : null, + }); + } + const visible = isVisibleInHierarchy(object); if (visible) { visibleObjectCount += 1; @@ -744,6 +925,7 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} }); return { sceneName: app?.editor?.sceneName ?? null, + sceneID: app?.editor?.sceneID ?? null, mode: app?.mode ?? null, isPlaying: !!app?.isPlaying, assetCount: app?.editor?.assetsCount ?? 0, @@ -757,10 +939,102 @@ export function useTerminal(onExit: () => void, options: UseTerminalOptions = {} visibleRenderableCount, meshCount, visibleMeshCount, + lights, + rendering: { + ambient: app?.editor?.rendering?.ambient ?? null, + hemisphere: app?.editor?.rendering?.hemisphere ?? null, + backgroundType: app?.editor?.rendering?.background?.type ?? null, + backgroundTexture: app?.editor?.rendering?.background?.texture ?? null, + hasBackgroundTextureAsset: !!app?.editor?.rendering?.background?.textureAsset, + }, + sceneEnv: { + background: classifyEnv((scene as {background?: unknown}).background), + environment: classifyEnv((scene as {environment?: unknown}).environment), + backgroundDetail: describeTexture((scene as {background?: unknown}).background), + hasBackgroundNode: !!(scene as {backgroundNode?: unknown}).backgroundNode, + hasEnvironmentNode: !!(scene as {environmentNode?: unknown}).environmentNode, + backgroundIntensity: (scene as {backgroundIntensity?: number}).backgroundIntensity ?? null, + backgroundBlurriness: (scene as {backgroundBlurriness?: number}).backgroundBlurriness ?? null, + backgroundRotationY: (scene as {backgroundRotation?: {y?: number}}).backgroundRotation?.y ?? null, + }, }; }; + // Test affordance: deterministically pin the editor camera so visual + // skybox/background regression shots are comparable across reloads + // (the camera otherwise resets to a default orientation on reload). + const wc = window as unknown as { + __stemSetEditorCamera?: (pos: [number, number, number], target: [number, number, number]) => boolean; + }; + wc.__stemSetEditorCamera = (pos, target) => { + const app = getEngineRuntime() as unknown as { + camera?: { + position?: {set: (x: number, y: number, z: number) => void}; + lookAt?: (x: number, y: number, z: number) => void; + updateMatrixWorld?: () => void; + }; + editor?: {controls?: {current?: {controls?: {target?: {set: (x: number, y: number, z: number) => void}; update?: () => void}}}}; + }; + const cam = app?.camera; + if (!cam?.position) return false; + const orbit = app?.editor?.controls?.current?.controls; + cam.position.set(pos[0], pos[1], pos[2]); + if (orbit?.target?.set) { + orbit.target.set(target[0], target[1], target[2]); + orbit.update?.(); + } + cam.lookAt?.(target[0], target[1], target[2]); + cam.updateMatrixWorld?.(); + return true; + }; + // Debug affordance: dump the material maps applied to named objects so a + // Playwright probe can verify which texture slots actually resolved. + (window as unknown as {__stemInspectMaterials?: (names: string[]) => unknown}).__stemInspectMaterials = ( + names: string[], + ) => { + const scene = getEngineRuntime()?.editor?.scene; + if (!scene) return {error: "no scene"}; + const tex = (t: any) => + t ? {cs: t.colorSpace, w: t.image?.width ?? null, h: t.image?.height ?? null, hasImg: !!t.image} : null; + const mat = (m: any) => ({ + type: m?.type ?? m?.constructor?.name, + isNode: !!(m?.isNodeMaterial || /NodeMaterial/.test(m?.type ?? m?.constructor?.name ?? "")), + color: m?.color ? "#" + m.color.getHexString() : null, + map: tex(m?.map), + normalMap: tex(m?.normalMap), + metalnessMap: tex(m?.metalnessMap), + roughnessMap: tex(m?.roughnessMap), + metalness: m?.metalness, + roughness: m?.roughness, + }); + const byName: Record = {}; + scene.traverse((o: any) => { + if (o.name && !byName[o.name]) byName[o.name] = o; + }); + const out: Record = {}; + for (const name of names) { + const obj = byName[name]; + if (!obj) { + out[name] = {missing: true}; + continue; + } + const meshes: unknown[] = []; + obj.traverse((c: any) => { + if (!c.isMesh) return; + const mats = Array.isArray(c.material) ? c.material : [c.material]; + mats.forEach((m: any, i: number) => meshes.push({mesh: c.name || "(unnamed)", idx: i, ...mat(m)})); + }); + out[name] = { + meshCount: meshes.length, + hasMaterialSettings: !!obj.userData?.materialSettings, + materials: meshes, + }; + } + return out; + }; return () => { delete (window as unknown as {__stemGetScene?: unknown}).__stemGetScene; + delete (window as unknown as {__stemSetEditorCamera?: unknown}).__stemSetEditorCamera; + delete (window as unknown as {__stemInspectMaterials?: unknown}).__stemInspectMaterials; }; }, []); diff --git a/client/packages/editor-oss/src/editor/assets/v2/AiCopilot/workspaceChatSnapshot.ts b/client/packages/editor-oss/src/editor/assets/v2/AiCopilot/workspaceChatSnapshot.ts index 95c6484d..26e486fd 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/AiCopilot/workspaceChatSnapshot.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/AiCopilot/workspaceChatSnapshot.ts @@ -78,9 +78,18 @@ const writeSnapshot = (snapshot: StoredWorkspaceChatSnapshot) => { if (typeof window === "undefined") return; try { const serialized = JSON.stringify(snapshot); - window.localStorage.setItem(storageKey(snapshot.sceneID), serialized); if (snapshot.sessionID) { + // Full snapshot under the session key; `:latest` holds only a tiny + // pointer to the most recent session — not a second copy of the + // (up to ~1.6MB) blob, which is what previously doubled this cache. window.localStorage.setItem(storageKey(snapshot.sceneID, snapshot.sessionID), serialized); + window.localStorage.setItem( + storageKey(snapshot.sceneID), + JSON.stringify({latestSessionID: snapshot.sessionID}), + ); + } else { + // No session: the `:latest` key holds the snapshot itself. + window.localStorage.setItem(storageKey(snapshot.sceneID), serialized); } } catch (error) { console.warn("[workspaceChatSnapshot] Failed to write workspace chat snapshot:", error); @@ -109,19 +118,10 @@ export const saveWorkspaceChatSnapshot = (input: { }); }; -export const readWorkspaceChatSnapshot = ( - sceneID: string | null | undefined, - sessionID?: string | null, -): WorkspaceChatSnapshot | null => { - const normalizedSceneID = sceneID?.trim(); - if (!normalizedSceneID || typeof window === "undefined") return null; - - const raw = window.localStorage.getItem(storageKey(normalizedSceneID, sessionID)); - if (!raw) return null; - +const parseSnapshot = (raw: string, sceneID: string): WorkspaceChatSnapshot | null => { try { const parsed = JSON.parse(raw) as Partial; - if (parsed.sceneID !== normalizedSceneID) return null; + if (parsed.sceneID !== sceneID) return null; const messages = Array.isArray(parsed.messages) ? parsed.messages @@ -131,7 +131,7 @@ export const readWorkspaceChatSnapshot = ( if (messages.length === 0) return null; return { - sceneID: normalizedSceneID, + sceneID, sessionID: typeof parsed.sessionID === "string" ? parsed.sessionID : null, updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : 0, messages, @@ -141,3 +141,31 @@ export const readWorkspaceChatSnapshot = ( return null; } }; + +export const readWorkspaceChatSnapshot = ( + sceneID: string | null | undefined, + sessionID?: string | null, +): WorkspaceChatSnapshot | null => { + const normalizedSceneID = sceneID?.trim(); + if (!normalizedSceneID || typeof window === "undefined") return null; + + if (sessionID) { + const raw = window.localStorage.getItem(storageKey(normalizedSceneID, sessionID)); + return raw ? parseSnapshot(raw, normalizedSceneID) : null; + } + + // No session requested: `:latest` is either a small pointer to the most + // recent session, or (for session-less saves) the snapshot itself. + const latestRaw = window.localStorage.getItem(storageKey(normalizedSceneID)); + if (!latestRaw) return null; + try { + const maybePointer = JSON.parse(latestRaw) as {latestSessionID?: unknown}; + if (typeof maybePointer.latestSessionID === "string") { + const raw = window.localStorage.getItem(storageKey(normalizedSceneID, maybePointer.latestSessionID)); + return raw ? parseSnapshot(raw, normalizedSceneID) : null; + } + } catch { + return null; + } + return parseSnapshot(latestRaw, normalizedSceneID); +}; diff --git a/client/packages/editor-oss/src/editor/assets/v2/AssetsLibrary/BehaviorCreator/hooks/useBehaviorSave.ts b/client/packages/editor-oss/src/editor/assets/v2/AssetsLibrary/BehaviorCreator/hooks/useBehaviorSave.ts index c621e68d..a1702db9 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/AssetsLibrary/BehaviorCreator/hooks/useBehaviorSave.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/AssetsLibrary/BehaviorCreator/hooks/useBehaviorSave.ts @@ -7,6 +7,18 @@ import {BehaviorConfig} from "../../../../../behaviors/BehaviorConfig"; import {useGetBehaviorRevisionData} from "../../../../../behaviors/hooks/behaviors"; import {createBehaviorRevision} from "../../../../../behaviors/util"; +/** + * TEMP diagnostics for the "first save loses behavior edits" bug. Logs the + * first line of code (the symptom the user reported) plus revision ids at every + * decision point in the save/merge path. Remove once the root cause is fixed. + */ +const BEHAVIOR_SAVE_DEBUG = true; +const firstLineOf = (text: string | undefined): string => + (text ?? "").split("\n", 1)[0]?.slice(0, 120) ?? ""; +const bsLog = (...args: unknown[]): void => { + if (BEHAVIOR_SAVE_DEBUG) console.log("[behavior-save]", ...args); +}; + /** * Recursively validate a behavior's attributes map. Returns the dotted path * of the first invalid attribute found, or null if all are valid. Validation @@ -139,6 +151,15 @@ export function useBehaviorSave(options: UseBehaviorSaveOptions): UseBehaviorSav // Check for newer revisions const {headRevisionId} = await getAsset(behaviorId); + bsLog("mergeBehavior", { + behaviorId, + codeRevisionId, + configRevisionId, + headRevisionId, + baseEqualsHead: codeRevisionId === headRevisionId, + localFirstLine: firstLineOf(code), + }); + // If no newer revisions, we're done if (codeRevisionId === headRevisionId && configRevisionId === headRevisionId) { return { @@ -155,6 +176,13 @@ export function useBehaviorSave(options: UseBehaviorSaveOptions): UseBehaviorSav if (codeRevisionId !== headRevisionId) { let mergedCode = code; + bsLog("code branch (base behind head)", { + behaviorId, + localEqualsLatest: code === latestRevision.code, + localFirstLine: firstLineOf(code), + latestFirstLine: firstLineOf(latestRevision.code), + }); + // Only display the merge dialog if the code is different from the // latest revision if (code !== latestRevision.code) { @@ -209,6 +237,13 @@ export function useBehaviorSave(options: UseBehaviorSaveOptions): UseBehaviorSav async (params: SaveBehaviorParams): Promise => { const {behaviorId, revisionId, code, config, name, description, tags} = params; + bsLog("save() called", { + behaviorId, + baseRevisionId: revisionId, + localFirstLine: firstLineOf(code), + configName: config?.name, + }); + setIsSaving(true); try { @@ -254,6 +289,15 @@ export function useBehaviorSave(options: UseBehaviorSaveOptions): UseBehaviorSav // If a merge happened, notify caller and stop (user must save again) const didMergeHappen = mergeRevisionId !== revisionId; + bsLog("merge decision", { + behaviorId, + baseRevisionId: revisionId, + mergeRevisionId, + didMergeHappen, + note: didMergeHappen + ? "FIRST SAVE WILL BE SKIPPED — returning false without persisting (base revision was behind head)" + : "no merge — proceeding to persist", + }); if (didMergeHappen && onMergeComplete) { onMergeComplete({ behaviorId, @@ -307,6 +351,14 @@ export function useBehaviorSave(options: UseBehaviorSaveOptions): UseBehaviorSav const saveResult = await Promise.all(savePromises); const newRevisionId = saveResult[0]!.id; + bsLog("persisted new revision", { + behaviorId, + parentRevisionId: mergeRevisionId, + newRevisionId, + unchanged: newRevisionId === mergeRevisionId, + persistedFirstLine: firstLineOf(mergedCode), + }); + // Notify caller of successful save - caller handles registry updates and scene dependencies await onSaveComplete?.({ assetId: behaviorId, diff --git a/client/packages/editor-oss/src/editor/assets/v2/AssetsLibrary/CodeEditor/CodeEditorShell.tsx b/client/packages/editor-oss/src/editor/assets/v2/AssetsLibrary/CodeEditor/CodeEditorShell.tsx index 76433e1c..c2529091 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/AssetsLibrary/CodeEditor/CodeEditorShell.tsx +++ b/client/packages/editor-oss/src/editor/assets/v2/AssetsLibrary/CodeEditor/CodeEditorShell.tsx @@ -7,12 +7,35 @@ */ import {useCallback} from "react"; +import {saveScene} from "@stem/network/api/scene"; +import {IS_OSS} from "@stem/editor-oss/mode/buildMode"; + import {CodeEditor} from "./CodeEditor"; import type {InitialSelection, CodeEditorPopoutPayload} from "./types"; import type {InitialDrafts} from "./hooks/useCodeEditorState"; import {useUpdateSceneBehaviorRevision} from "../../../../behaviors/hooks/behaviors"; import type {SaveCompleteInfo} from "../BehaviorCreator/hooks"; +/** + * In OSS the "Behavior saved" toast must mean *persisted to the filesystem*, + * not just updated in the in-memory scene registry. `createBehaviorRevision` + * only seeds the new content into the session asset registry; nothing reaches + * the `ProjectStore` until the scene is saved. So after updating the scene's + * behavior registry we route through `saveScene` (→ `ossSaveScene` → + * `ProjectStore.save` + `persistProjectAssets`), mirroring `useImportBehaviors`. + * No-op in integrated mode, where the asset service already persisted the + * revision server-side. Best-effort: a persist failure is logged, not thrown, + * so the in-memory edit still stands. + */ +const persistOssBehaviorEdit = async () => { + if (!IS_OSS) return; + try { + await saveScene(false, false); + } catch (err) { + console.error("[CodeEditorShell] failed to persist behavior edit to ProjectStore", err); + } +}; + export interface CodeEditorShellProps { sceneId: string; initialSelection?: InitialSelection; @@ -43,6 +66,7 @@ export const CodeEditorShell: React.FC = ({ const handleSaveComplete = useCallback( async ({assetId, revisionId, code, config}: SaveCompleteInfo) => { await updateSceneBehaviorRevision(assetId, revisionId, {code, config}); + await persistOssBehaviorEdit(); }, [updateSceneBehaviorRevision], ); @@ -52,6 +76,8 @@ export const CodeEditorShell: React.FC = ({ for (const {assetId, revisionId, code, config} of infos) { await updateSceneBehaviorRevision(assetId, revisionId, {code, config}); } + // Persist once after all in-memory updates rather than per behavior. + await persistOssBehaviorEdit(); }, [updateSceneBehaviorRevision], ); diff --git a/client/packages/editor-oss/src/editor/assets/v2/BehaviorEditor/scriptCompletions.ts b/client/packages/editor-oss/src/editor/assets/v2/BehaviorEditor/scriptCompletions.ts index ae41d592..6996bc90 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/BehaviorEditor/scriptCompletions.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/BehaviorEditor/scriptCompletions.ts @@ -232,7 +232,18 @@ export function registerScriptCompletions( return {suggestions}; } - // General → globals + lifecycle function names + // General → globals + lifecycle function names. Only offer these + // when the cursor is actually on a word being typed. Without this + // guard the provider returns the full list for *any* position, + // including right after a space, so the suggest widget pops up (or + // refuses to dismiss) every time you type a space — most visibly on + // the first line of top-level code. An empty word means "not typing + // an identifier", so return whatever context-specific suggestions we + // already collected (e.g. @import) and nothing else. + if (!word.word) { + return {suggestions}; + } + for (const g of globals) { suggestions.push({ label: g.name, diff --git a/client/packages/editor-oss/src/editor/assets/v2/CopilotWorkspace/copilotPreviewDraftStorage.test.ts b/client/packages/editor-oss/src/editor/assets/v2/CopilotWorkspace/copilotPreviewDraftStorage.test.ts index 8ca497e0..d5c37104 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/CopilotWorkspace/copilotPreviewDraftStorage.test.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/CopilotWorkspace/copilotPreviewDraftStorage.test.ts @@ -75,19 +75,16 @@ describe("copilotPreviewDraftStorage", () => { vi.unstubAllGlobals(); }); - it("persists, reads, and clears active preview drafts through the local fallback", async () => { - await persistCopilotPreviewDraft(makeApp() as any, makeSession()); + it("never falls back to localStorage when IndexedDB is unavailable", async () => { + // Preview drafts carry a full scene snapshot and must NOT be mirrored + // into localStorage (that bloated storage and threw QuotaExceededError). + // With IndexedDB unavailable, persist/read/clear are graceful no-ops and + // localStorage stays untouched. + await expect(persistCopilotPreviewDraft(makeApp() as any, makeSession())).resolves.toBeUndefined(); + expect(window.localStorage.length).toBe(0); - const draft = await readCopilotPreviewDraft("scene-1"); - expect(draft?.sceneId).toBe("scene-1"); - expect(draft?.baseRevisionId).toBe("rev-1"); - expect(draft?.previewId).toBe("preview-1"); - expect(draft?.previewSceneJson).toEqual([{type: "Scene", uuid: "preview-scene"}]); - expect(draft?.previewAssetResolutionContext.assetIdToRevisionId).toEqual({ - "asset-player": "rev-player-preview", - }); - - await clearCopilotPreviewDraft("scene-1"); await expect(readCopilotPreviewDraft("scene-1")).resolves.toBeNull(); + await expect(clearCopilotPreviewDraft("scene-1")).resolves.toBeUndefined(); + expect(window.localStorage.length).toBe(0); }); }); diff --git a/client/packages/editor-oss/src/editor/assets/v2/CopilotWorkspace/copilotPreviewDraftStorage.ts b/client/packages/editor-oss/src/editor/assets/v2/CopilotWorkspace/copilotPreviewDraftStorage.ts index 8675e069..547c4be4 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/CopilotWorkspace/copilotPreviewDraftStorage.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/CopilotWorkspace/copilotPreviewDraftStorage.ts @@ -27,7 +27,24 @@ export type StoredCopilotPreviewDraft = { }; }; -const localStorageKey = (sceneId: string) => `${LOCAL_STORAGE_PREFIX}:${encodeURIComponent(sceneId)}`; +// One-time cleanup: earlier builds mirrored the full preview scene snapshot +// into localStorage as a "fallback". That is scene-scoped data and has no +// business in the ~5MB localStorage budget — it bloated storage and threw +// QuotaExceededError. Drafts live in IndexedDB only now; purge any leftovers. +const purgeLegacyLocalDrafts = (): void => { + if (typeof window === "undefined") return; + try { + const keys: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key && key.startsWith(LOCAL_STORAGE_PREFIX)) keys.push(key); + } + keys.forEach(key => window.localStorage.removeItem(key)); + } catch { + /* localStorage unavailable — nothing to purge */ + } +}; +purgeLegacyLocalDrafts(); const cloneRecord = (record?: Readonly>): Record => ({...(record ?? {})}); @@ -104,31 +121,6 @@ const runStoreRequest = async ( }); }; -const writeLocalDraft = (draft: StoredCopilotPreviewDraft) => { - if (typeof window === "undefined") return; - window.localStorage.setItem(localStorageKey(draft.sceneId), JSON.stringify(draft)); -}; - -const readLocalDraft = (sceneId: string): StoredCopilotPreviewDraft | null => { - if (typeof window === "undefined") return null; - const raw = window.localStorage.getItem(localStorageKey(sceneId)); - if (!raw) return null; - - try { - const parsed = JSON.parse(raw) as StoredCopilotPreviewDraft; - if (parsed.schemaVersion !== 1 || parsed.sceneId !== sceneId || !parsed.session?.previewId) return null; - return parsed; - } catch (error) { - console.warn("[copilotPreviewDraftStorage] Failed to read local preview draft:", error); - return null; - } -}; - -const clearLocalDraft = (sceneId: string) => { - if (typeof window === "undefined") return; - window.localStorage.removeItem(localStorageKey(sceneId)); -}; - export const persistCopilotPreviewDraft = async ( app: EngineRuntime, session: CopilotPreviewSession, @@ -136,15 +128,13 @@ export const persistCopilotPreviewDraft = async ( const draft = createDraft(app, session); if (!draft || typeof window === "undefined") return; + // IndexedDB only. The draft carries a full scene snapshot — it must never + // touch localStorage. If IndexedDB is unavailable the draft is simply not + // persisted (preview is recoverable from the authoritative scene anyway). try { await runStoreRequest("readwrite", store => store.put(draft)); - clearLocalDraft(draft.sceneId); } catch (error) { - try { - writeLocalDraft(draft); - } catch (fallbackError) { - console.warn("[copilotPreviewDraftStorage] Failed to persist preview draft:", error, fallbackError); - } + console.warn("[copilotPreviewDraftStorage] Failed to persist preview draft:", error); } }; @@ -160,18 +150,15 @@ export const readCopilotPreviewDraft = async (sceneId: string): Promise => { if (!sceneId || typeof window === "undefined") return; - clearLocalDraft(sceneId); try { await runStoreRequest("readwrite", store => store.delete(sceneId)); } catch (error) { diff --git a/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/AssetsRows/AssetsRows.tsx b/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/AssetsRows/AssetsRows.tsx index edbcbc5a..c90de94c 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/AssetsRows/AssetsRows.tsx +++ b/client/packages/editor-oss/src/editor/assets/v2/LeftPanel/MainTabs/AssetsTab/AssetsRows/AssetsRows.tsx @@ -137,9 +137,18 @@ export const AssetsRows = () => { useEffect(() => { if (!sceneID) return setExpandedPanels([PANEL_TYPES.PRIMITIVES]); - const savedPanels = localStorage.getItem(`expandedPanels_${sceneID}`); + let savedPanels: string | null = null; + try { + savedPanels = localStorage.getItem(`expandedPanels_${sceneID}`); + } catch { + savedPanels = null; + } if (savedPanels) { - setExpandedPanels(JSON.parse(savedPanels)); + try { + setExpandedPanels(JSON.parse(savedPanels)); + } catch { + setExpandedPanels([PANEL_TYPES.PRIMITIVES]); + } } else { setExpandedPanels([PANEL_TYPES.PRIMITIVES]); } @@ -148,7 +157,14 @@ export const AssetsRows = () => { useEffect(() => { if (!sceneID || !isLoaded) return; - localStorage.setItem(`expandedPanels_${sceneID}`, JSON.stringify(expandedPanels)); + // Persisting expanded-panel UI state is best-effort. localStorage can be + // full (e.g. a bloated project), and a QuotaExceededError here must not + // crash the asset panel — swallow it. + try { + localStorage.setItem(`expandedPanels_${sceneID}`, JSON.stringify(expandedPanels)); + } catch { + /* storage full or unavailable — ignore, this is non-critical UI state */ + } }, [expandedPanels, sceneID]); const handleImportButton = useCallback( diff --git a/client/packages/editor-oss/src/editor/assets/v2/RightPanel/panels/ProjectSettings/GameSettings.tsx b/client/packages/editor-oss/src/editor/assets/v2/RightPanel/panels/ProjectSettings/GameSettings.tsx index 2f5fed0b..af4ed0da 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/RightPanel/panels/ProjectSettings/GameSettings.tsx +++ b/client/packages/editor-oss/src/editor/assets/v2/RightPanel/panels/ProjectSettings/GameSettings.tsx @@ -81,6 +81,14 @@ const normalizeProjectGameSettings = ( }; }; +const SERIALIZED_GAME_BOOLEAN_SETTINGS = new Set([ + "showHUD", + "useAvatar", + "isMultiplayer", + "multiplayerAutoJoin", + "voiceChatEnabled", +]); + const loadGameMappingApi = () => import("@stem/network/api/gameMapping"); const GameSettingsComponent = ({openUIPanel}: {openUIPanel: () => void}) => { @@ -316,6 +324,18 @@ const GameSettingsComponent = ({openUIPanel}: {openUIPanel: () => void}) => { const current = (editor as any)[key] as boolean; const newValue = !current; (editor as any)[key] = newValue; + + if (SERIALIZED_GAME_BOOLEAN_SETTINGS.has(key)) { + if (!editor.scene.userData) editor.scene.userData = {}; + const gameSettings = { + ...normalizeProjectGameSettings(editor.scene.userData.game), + [key]: newValue, + }; + editor.scene.userData.game = gameSettings; + setGame(gameSettings); + app.call("objectChanged", app.editor, app.editor?.scene); + } + setter(newValue); }; diff --git a/client/packages/editor-oss/src/editor/assets/v2/materials/materialUtils.ts b/client/packages/editor-oss/src/editor/assets/v2/materials/materialUtils.ts index f0e216be..f2cc8e6e 100644 --- a/client/packages/editor-oss/src/editor/assets/v2/materials/materialUtils.ts +++ b/client/packages/editor-oss/src/editor/assets/v2/materials/materialUtils.ts @@ -377,7 +377,17 @@ const applyOrClearMap = (params: { * @param value */ export const isAssetId = (value: string): boolean => { - return /^([a-f0-9]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(value); + if (typeof value !== "string" || !value) return false; + // Mongo-style 24-hex id or UUID (integrated builds). + if (/^([a-f0-9]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(value)) { + return true; + } + // OSS synthesizes asset ids as `oss-asset--` (see + // network/.../asset/index.ts). These are real asset ids, not URLs. Without + // this, a material texture backed by an imported OSS image asset fails both + // the apply path (treated as a URL → TextureLoader 404 → blank texture) and + // the resolve path (skipped entirely), so the texture renders empty. + return value.startsWith("oss-asset-"); }; /** diff --git a/client/packages/editor-oss/src/editor/behaviors/BehaviorObjectSettingsApplier.test.ts b/client/packages/editor-oss/src/editor/behaviors/BehaviorObjectSettingsApplier.test.ts index 62b01f38..35b145a2 100644 --- a/client/packages/editor-oss/src/editor/behaviors/BehaviorObjectSettingsApplier.test.ts +++ b/client/packages/editor-oss/src/editor/behaviors/BehaviorObjectSettingsApplier.test.ts @@ -46,4 +46,31 @@ describe("BehaviorObjectSettingsApplier", () => { expect(group.userData.physics.shape).toBe(BodyShapeType.SPHERE); }); + + it("seeds default physics on an object that has none", () => { + const object = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial()); + + BehaviorObjectSettingsApplier.applyObjectSettings(object, { + physics: { enabled: true, shape: "capsule" }, + }); + + // No prior physics → the behavior default applies, including enabling it. + expect(object.userData.physics.enabled).toBe(true); + }); + + it("does NOT override an explicit physics.enabled:false with a behavior default", () => { + // Repro of the pirate-ship freeze: the object's physics was deliberately + // disabled (e.g. by an import's `physics set ... enabled:false`). Attaching + // a behavior whose default physics is enabled (the character controller) + // must NOT re-enable it — a re-enabled dynamic body would hijack the + // object's transform and override the game's own controller. + const object = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial()); + object.userData.physics = { enabled: false }; + + BehaviorObjectSettingsApplier.applyObjectSettings(object, { + physics: { enabled: true, shape: "capsule" }, + }); + + expect(object.userData.physics.enabled).toBe(false); + }); }); diff --git a/client/packages/editor-oss/src/editor/behaviors/BehaviorObjectSettingsApplier.ts b/client/packages/editor-oss/src/editor/behaviors/BehaviorObjectSettingsApplier.ts index 123817dc..421c738d 100644 --- a/client/packages/editor-oss/src/editor/behaviors/BehaviorObjectSettingsApplier.ts +++ b/client/packages/editor-oss/src/editor/behaviors/BehaviorObjectSettingsApplier.ts @@ -64,13 +64,24 @@ class BehaviorObjectSettingsApplier { private static applyPhysicsSettings(object: THREE.Object3D, physicsSettings: ObjectSettings['physics']): void { if (!physicsSettings) return; + // Did the object already carry an explicit `enabled` choice (set by the + // user, or by an import's `physics set`) BEFORE this behavior's default + // physics was applied? A behavior's default physics is a convenience for + // objects that have none — it must NOT silently override a deliberate + // `enabled` value. Without this guard, attaching a behavior whose + // default physics is `enabled:true` (e.g. the character controller's + // dynamic capsule) onto an object whose physics was intentionally turned + // off re-enables a body that then hijacks the object's transform — which + // is exactly how a "disabled" character behavior froze the pirate ship. + const hadExplicitEnabled = object.userData.physics?.enabled !== undefined; + if (!object.userData.physics) { object.userData.physics = { enabled: false, }; } - if (physicsSettings.enabled !== undefined) { + if (physicsSettings.enabled !== undefined && !hadExplicitEnabled) { object.userData.physics.enabled = physicsSettings.enabled; } diff --git a/client/packages/editor-oss/src/editor/behaviors/util.ts b/client/packages/editor-oss/src/editor/behaviors/util.ts index 8643cdcf..6eda4c33 100644 --- a/client/packages/editor-oss/src/editor/behaviors/util.ts +++ b/client/packages/editor-oss/src/editor/behaviors/util.ts @@ -391,6 +391,14 @@ export const createBehaviorRevision = async ({ } }; + // TEMP diagnostics for "first save loses behavior edits" — remove with the + // matching [behavior-save] logs in useBehaviorSave.ts. + console.log("[behavior-save] createBehaviorRevision", { + assetId, + parentRevisionId, + codeFirstLine: (code ?? "").split("\n", 1)[0]?.slice(0, 120) ?? "", + }); + try { const revision = await createAssetRevision({ assetId, @@ -408,6 +416,10 @@ export const createBehaviorRevision = async ({ // Still apply scene sync to ensure aliases (e.g. imported YAML config.id) are // re-registered in the current editor session after reload. if (isNoChangesError(err)) { + console.log("[behavior-save] createBehaviorRevision: server reports NO CHANGES — data matches head, returning parent", { + assetId, + parentRevisionId, + }); applyScene(parentRevisionId); return {id: parentRevisionId}; } diff --git a/client/packages/editor-oss/src/editor/images/hooks/index.ts b/client/packages/editor-oss/src/editor/images/hooks/index.ts index aacb3b4f..81788ac9 100644 --- a/client/packages/editor-oss/src/editor/images/hooks/index.ts +++ b/client/packages/editor-oss/src/editor/images/hooks/index.ts @@ -4,6 +4,14 @@ import {useCreateAssetWithData} from "../../asset-management/hooks/assets"; export const parseMaterialAssetIdWithRevision = (value: string): {assetId: string} | null => { if (!value) return null; + // OSS synthesizes asset ids as `oss-asset--` (optionally + // with a legacy `:` suffix). The hex/UUID regex below does not + // match these, so without this branch a material texture backed by an + // imported OSS image asset is never resolved and renders blank. + if (value.startsWith("oss-asset-")) { + return {assetId: value.split(":")[0] || value}; + } + const match = value.match(/^([a-f0-9]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); if (!match) return null; diff --git a/client/packages/editor-oss/src/event/AutoSaveEvent.js b/client/packages/editor-oss/src/event/AutoSaveEvent.js index 9682826c..0269f2c7 100644 --- a/client/packages/editor-oss/src/event/AutoSaveEvent.js +++ b/client/packages/editor-oss/src/event/AutoSaveEvent.js @@ -8,13 +8,7 @@ import BaseEvent from "./BaseEvent"; import {saveScene} from "@web-shared/api/scene"; import global from "../global"; -import i8n from "../i18n/config"; import Converter from "../serialization/Converter"; -import {showToast} from "../showToast"; -import {ElementsUtils} from "../utils/ElementsUtils"; -import TimeUtils from "../utils/TimeUtils"; - -const {t} = i8n; class AutoSaveEvent extends BaseEvent { constructor() { @@ -70,16 +64,11 @@ class AutoSaveEvent extends BaseEvent { scene: app.scene, }); - const now = TimeUtils.getDateTime("yyyy-MM-dd HH:mm:ss"); - - window.localStorage.setItem("autoSaveData", JSON.stringify(obj)); - window.localStorage.setItem("autoSaveTime", now); - window.localStorage.setItem("autoSaveSceneID", global.app.editor.sceneID); - window.localStorage.setItem("autoSaveSceneName", global.app.editor.sceneName); - window.localStorage.setItem("autoSaveSceneLockedItems", JSON.stringify(editor.sceneLockedItems || [])); - - console.log(`${now}, scene auto saved.`); - + // Single authoritative path: the active ProjectStore (in playground/OSS + // that is the File System folder). The scene is large and scene-scoped, + // so it must NOT also be mirrored into localStorage (5MB cap) — that was + // pure redundancy that blew the quota. Recovery already lives in the + // saved project itself. if (this.autoSave) { this.commitSaveScene(obj); this.saveProcess = setTimeout(this.handleSave, this.autoSaveTime); @@ -87,48 +76,16 @@ class AutoSaveEvent extends BaseEvent { } handleLoad() { - const autoSaveTime = window.localStorage.getItem("autoSaveTime"); - const autoSaveData = window.localStorage.getItem("autoSaveData"); - const autoSaveSceneID = window.localStorage.getItem("autoSaveSceneID"); - const autoSaveSceneName = window.localStorage.getItem("autoSaveSceneName"); - const autoSaveSceneLockedItems = window.localStorage.getItem("autoSaveSceneLockedItems"); - - if (!autoSaveData) { - return; - } - - this.queryLoad = true; - - ElementsUtils.confirm({ - title: t("Load Scene"), - content: t("An auto-save scene was detected. Load?") + ` (${autoSaveTime})`, - cancelText: t("Clear"), - onOK: () => { - this.queryLoad = false; - this.commitLoadScene(autoSaveData, autoSaveSceneName, autoSaveSceneID, autoSaveSceneLockedItems); - }, - onCancel: () => { - window.localStorage.removeItem("autoSaveTime"); - window.localStorage.removeItem("autoSaveData"); - window.localStorage.removeItem("autoSaveSceneID"); - window.localStorage.removeItem("autoSaveSceneName"); - showToast({type: "info", title: t("Auto-save scene is cleared.")}); - this.queryLoad = false; - }, - }); - } - - commitLoadScene(data, name, id, lockedItems) { - var obj = JSON.parse(data); - const lockedItemsList = JSON.parse(lockedItems); - const lockedItemsData = lockedItemsList ? lockedItemsList.join(",") : ""; - - const prevAutoSaveState = global.app.storage.autoSave; - global.app.setAutoSave(false); - - if (obj) { - global.app.call(`loadSceneList`, this, obj, name, id, lockedItemsData, prevAutoSaveState); - } + // The localStorage autosave cache has been removed — the ProjectStore is + // authoritative. Purge any legacy keys a previous build left behind so + // they stop consuming the localStorage budget. No recovery prompt. + try { + window.localStorage.removeItem("autoSaveData"); + window.localStorage.removeItem("autoSaveTime"); + window.localStorage.removeItem("autoSaveSceneID"); + window.localStorage.removeItem("autoSaveSceneName"); + window.localStorage.removeItem("autoSaveSceneLockedItems"); + } catch { /* localStorage unavailable — nothing to purge */ } } handleStorageChange(name, value) { diff --git a/client/packages/editor-oss/src/persistence/FileSystemProjectStore.test.ts b/client/packages/editor-oss/src/persistence/FileSystemProjectStore.test.ts index 31a0ff62..aa9a443b 100644 --- a/client/packages/editor-oss/src/persistence/FileSystemProjectStore.test.ts +++ b/client/packages/editor-oss/src/persistence/FileSystemProjectStore.test.ts @@ -14,17 +14,24 @@ const sampleBody = (name = "Demo", id = ""): ProjectBody => ({ sceneJson: JSON.stringify({name}), }); +// The real File System Access API throws a DOMException named "NotFoundError" +// for a missing entry — and FileSystemProjectStore now relies on that name to +// tell a *legitimate absence* (return []) apart from a *read failure* (throw). +// The mock must reproduce that contract, not a generic Error. +const notFound = (what: string): DOMException => new DOMException(`missing ${what}`, "NotFoundError"); + class MemoryFileHandle { readonly kind = "file" as const; constructor( readonly name: string, - private readonly dir: MemoryDirectoryHandle, + private readonly read: () => string | undefined, + private readonly write: (value: string) => void, ) {} async getFile(): Promise { - const data = this.dir.files.get(this.name); - if (data === undefined) throw new Error(`missing file ${this.name}`); + const data = this.read(); + if (data === undefined) throw notFound(`file ${this.name}`); return new File([data], this.name, {type: "application/json"}); } @@ -35,7 +42,7 @@ class MemoryFileHandle { pending = typeof data === "string" ? data : await data.text(); }, close: async () => { - this.dir.files.set(this.name, pending); + this.write(pending); }, }; } @@ -43,27 +50,48 @@ class MemoryFileHandle { class MemoryDirectoryHandle { readonly kind = "directory" as const; - readonly name = "projects"; readonly files = new Map(); + readonly subdirs = new Map(); + + constructor(readonly name = "projects") {} async getFileHandle(name: string, options?: {create?: boolean}): Promise { if (!this.files.has(name) && !options?.create) { - throw new Error(`missing file ${name}`); + throw notFound(`file ${name}`); } - if (!this.files.has(name)) { - this.files.set(name, ""); + if (!this.files.has(name)) this.files.set(name, ""); + return new MemoryFileHandle(name, () => this.files.get(name), v => this.files.set(name, v)); + } + + async getDirectoryHandle(name: string, options?: {create?: boolean}): Promise { + if (!this.subdirs.has(name)) { + if (!options?.create) throw notFound(`directory ${name}`); + this.subdirs.set(name, new MemoryDirectoryHandle(name)); } - return new MemoryFileHandle(name, this); + return this.subdirs.get(name)!; } async removeEntry(name: string): Promise { this.files.delete(name); + this.subdirs.delete(name); } - async *entries(): AsyncIterableIterator<[string, MemoryFileHandle]> { + async *entries(): AsyncIterableIterator<[string, MemoryFileHandle | MemoryDirectoryHandle]> { for (const name of this.files.keys()) { - yield [name, new MemoryFileHandle(name, this)]; + yield [name, new MemoryFileHandle(name, () => this.files.get(name), v => this.files.set(name, v))]; } + for (const [name, sub] of this.subdirs) { + yield [name, sub]; + } + } + + /** Test helper: stage an asset subdirectory with a manifest + asset files. */ + seedAssetDir(projectId: string, manifest: unknown, assetFiles: Record = {}): MemoryDirectoryHandle { + const sub = new MemoryDirectoryHandle(projectId); + sub.files.set("assets.json", typeof manifest === "string" ? manifest : JSON.stringify(manifest)); + for (const [file, content] of Object.entries(assetFiles)) sub.files.set(file, content); + this.subdirs.set(projectId, sub); + return sub; } } @@ -103,4 +131,40 @@ describe("FileSystemProjectStore", () => { await store.delete(id); expect([...dir.files.keys()]).toEqual([]); }); + + // --- loadAssets must not silently mask a read failure as "no assets". --- + + it("loadAssets returns [] when the project has no asset subdirectory", async () => { + // Legitimate absence (NotFoundError) — the one quiet path that's allowed. + await expect(store.loadAssets("project-without-assets")).resolves.toEqual([]); + }); + + it("loadAssets returns the recorded assets when the manifest is valid", async () => { + dir.seedAssetDir( + "project-1", + [{file: "a.glb", assetId: "a", revisionId: "r", type: "model", name: "A"}], + {"a.glb": "GLBDATA"}, + ); + const assets = await store.loadAssets("project-1"); + expect(assets).toHaveLength(1); + expect(assets[0]!.assetId).toBe("a"); + }); + + it("loadAssets THROWS on a corrupt asset manifest instead of pretending there are no assets", async () => { + // A manifest that exists but is unreadable must surface — returning [] + // here would silently drop every model on reload. + dir.seedAssetDir("project-1", "{ this is not valid json"); + await expect(store.loadAssets("project-1")).rejects.toThrow(/manifest.*unreadable|malformed/i); + }); + + it("loadAssets THROWS when a manifest-listed asset file is missing", async () => { + // The manifest references b.glb but the file isn't there — a real, + // data-losing problem, not an empty project. + dir.seedAssetDir( + "project-1", + [{file: "b.glb", assetId: "b", revisionId: "r", type: "model", name: "B"}], + /* no b.glb on disk */ + ); + await expect(store.loadAssets("project-1")).rejects.toThrow(); + }); }); diff --git a/client/packages/editor-oss/src/persistence/FileSystemProjectStore.ts b/client/packages/editor-oss/src/persistence/FileSystemProjectStore.ts index 3b1ece03..3d5462d6 100644 --- a/client/packages/editor-oss/src/persistence/FileSystemProjectStore.ts +++ b/client/packages/editor-oss/src/persistence/FileSystemProjectStore.ts @@ -42,6 +42,18 @@ const SUFFIX = ".stemscript.json"; /** Filename for an asset's binary payload inside the project's subdirectory. */ const ASSET_MANIFEST = "assets.json"; +/** + * True only for the File System Access API's "this entry does not exist" error + * (`getDirectoryHandle`/`getFileHandle` on a missing path). This is the *one* + * absence we may treat as "legitimately empty". Every other error (permission + * revoked, malformed JSON, truncated binary) is a real failure that must + * surface — never be swallowed into an empty result. + */ +const isNotFoundError = (err: unknown): boolean => + err instanceof DOMException + ? err.name === "NotFoundError" + : (err as {name?: string})?.name === "NotFoundError"; + const bytesToBase64 = (bytes: Uint8Array): string => { let binary = ""; const chunk = 0x8000; @@ -82,6 +94,53 @@ export class FileSystemProjectStore implements ProjectStore { constructor(private readonly dir: FsDirectoryHandle) {} + // Serializes mutating operations. The File System Access API throws + // `NoModificationAllowedError` if two `createWritable()` calls target the + // same file concurrently, and our save flow writes many files plus a + // manifest. When two saves overlap (e.g. an autosave firing while a manual + // save is mid-write — more likely for large projects that take seconds to + // persist), the second save's writes collide with the first, the asset + // persist throws, and the project is left with no/partial assets. Chaining + // every write through this promise guarantees they run one-at-a-time. + private writeChain: Promise = Promise.resolve(); + + private serializeWrite(op: () => Promise): Promise { + const run = this.writeChain.then(() => this.runWithRetry(op), () => this.runWithRetry(op)); + // Keep the chain alive regardless of this op's outcome. + this.writeChain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + /** + * Heavy writes (e.g. a project with tens of MB of GLBs) intermittently fail + * with transient File System Access errors — `NotFoundError`, + * `InvalidStateError`, `NoModificationAllowedError` — when the browser is + * under write pressure. These are not real data errors; the same op + * succeeds on a retry. Retry a few times with a short backoff before giving + * up so a single transient blip doesn't fail an entire project save. + */ + private async runWithRetry(op: () => Promise, attempts = 3): Promise { + let lastErr: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await op(); + } catch (err) { + lastErr = err; + const name = (err as {name?: string})?.name ?? ""; + const transient = + name === "NotFoundError" || + name === "InvalidStateError" || + name === "NoModificationAllowedError"; + if (!transient || i === attempts - 1) throw err; + await new Promise(resolve => setTimeout(resolve, 150 * (i + 1))); + } + } + throw lastErr; + } + /** Folder name the user picked, surfaced in the dashboard UI. */ getDirectoryName(): string { return this.dir.name; @@ -99,8 +158,13 @@ export class FileSystemProjectStore implements ProjectStore { const file = await (handle).getFile(); const body = JSON.parse(await file.text()) as ProjectBody; if (body?.meta) all.push(body.meta); - } catch { - // Skip unreadable / malformed files; don't fail the whole list. + } catch (err) { + // We're iterating files that demonstrably exist, so a failure + // here means the file is present but unreadable/corrupt — a real + // problem. Keep listing the *other* projects (hiding all of them + // behind one bad file would be its own masking fallback), but + // make the bad file loud instead of silently dropping it. + console.error(`[FileSystemProjectStore] Skipping unreadable project file "${name}":`, err); } } @@ -159,7 +223,11 @@ export class FileSystemProjectStore implements ProjectStore { return JSON.parse(await file.text()) as ProjectBody; } - async save(body: ProjectBody): Promise { + save(body: ProjectBody): Promise { + return this.serializeWrite(() => this.saveLocked(body)); + } + + private async saveLocked(body: ProjectBody): Promise { const meta: ProjectMeta = { ...body.meta, id: body.meta.id || newId(), @@ -182,7 +250,11 @@ export class FileSystemProjectStore implements ProjectStore { return meta; } - async delete(id: string): Promise { + delete(id: string): Promise { + return this.serializeWrite(() => this.deleteLocked(id)); + } + + private async deleteLocked(id: string): Promise { for (const name of await this.matchingNamesForId(id)) { await this.dir.removeEntry(name); } @@ -194,7 +266,11 @@ export class FileSystemProjectStore implements ProjectStore { } } - async saveAssets(projectId: string, assets: StoredAsset[]): Promise { + saveAssets(projectId: string, assets: StoredAsset[]): Promise { + return this.serializeWrite(() => this.saveAssetsLocked(projectId, assets)); + } + + private async saveAssetsLocked(projectId: string, assets: StoredAsset[]): Promise { // Replace the whole subdirectory so a re-save drops assets no longer // referenced. The project lives as `..stemscript.json` in // the picked folder; its binary assets live in a sibling `/`. @@ -208,8 +284,13 @@ export class FileSystemProjectStore implements ProjectStore { } const projectDir = await this.dir.getDirectoryHandle(projectId, {create: true}); - const manifest: AssetManifestEntry[] = []; - for (const asset of assets) { + // Write the asset files concurrently. Each targets a distinct file, so + // there's no `NoModificationAllowedError` risk (that only arises from two + // writers on the *same* file — prevented by serializeWrite at the call + // level). Sequential awaits made large projects (dozens of MB) take many + // seconds, long enough that a reload could beat the manifest write and + // lose every asset. Parallelizing cuts that window dramatically. + const writeAsset = async (asset: StoredAsset): Promise => { const file = `${asset.assetId}.${asset.format || "bin"}`; const handle = await projectDir.getFileHandle(file, {create: true}); const writable = await handle.createWritable(); @@ -219,8 +300,11 @@ export class FileSystemProjectStore implements ProjectStore { await writable.close(); } const {data: _omit, ...meta} = asset; - manifest.push({...meta, file}); - } + return {...meta, file}; + }; + // The manifest is still written LAST (after all file writes resolve) so + // loadAssets never sees a manifest referencing a not-yet-written file. + const manifest: AssetManifestEntry[] = await Promise.all(assets.map(writeAsset)); const manifestHandle = await projectDir.getFileHandle(ASSET_MANIFEST, {create: true}); const manifestWritable = await manifestHandle.createWritable(); @@ -235,32 +319,43 @@ export class FileSystemProjectStore implements ProjectStore { let projectDir: FsDirectoryHandle; try { projectDir = await this.dir.getDirectoryHandle(projectId); - } catch { - // No asset subdirectory (project saved before assets existed, - // or has no binary assets). - return []; + } catch (err) { + // The *only* acceptable quiet path: no asset subdirectory at all + // (project saved before assets existed, or has no binary assets). + // A permission error or anything else is real — surface it. + if (isNotFoundError(err)) return []; + throw err; } let manifest: AssetManifestEntry[]; try { const manifestFile = await (await projectDir.getFileHandle(ASSET_MANIFEST)).getFile(); manifest = JSON.parse(await manifestFile.text()) as AssetManifestEntry[]; - } catch { - return []; + } catch (err) { + // No manifest file → no assets recorded (legit empty). But a manifest + // that exists and fails to read/parse means the asset index is + // corrupt — returning [] there would silently drop every model on + // reload. Surface it instead of pretending the project has no assets. + if (isNotFoundError(err)) return []; + throw new Error( + `Asset manifest for project "${projectId}" is unreadable or malformed: ` + + (err instanceof Error ? err.message : String(err)), + ); + } + if (!Array.isArray(manifest)) { + throw new Error(`Asset manifest for project "${projectId}" is not an array`); } - if (!Array.isArray(manifest)) return []; const assets: StoredAsset[] = []; for (const entry of manifest) { - try { - const file = await (await projectDir.getFileHandle(entry.file)).getFile(); - const bytes = new Uint8Array(await file.arrayBuffer()); - const {file: _file, ...meta} = entry; - assets.push({...meta, data: bytesToBase64(bytes)}); - } catch { - // Skip a missing/unreadable asset file rather than failing - // the whole project load. - } + // The manifest lists this asset, so the file is expected to exist. + // A failure here is a real, data-losing problem (a model that won't + // appear after reload) — fail the load loudly rather than returning + // a half-populated scene that looks fine but is missing geometry. + const file = await (await projectDir.getFileHandle(entry.file)).getFile(); + const bytes = new Uint8Array(await file.arrayBuffer()); + const {file: _file, ...meta} = entry; + assets.push({...meta, data: bytesToBase64(bytes)}); } return assets; } diff --git a/client/packages/editor-oss/src/persistence/ossSceneSave.test.ts b/client/packages/editor-oss/src/persistence/ossSceneSave.test.ts index e9b83785..ec9f7f95 100644 --- a/client/packages/editor-oss/src/persistence/ossSceneSave.test.ts +++ b/client/packages/editor-oss/src/persistence/ossSceneSave.test.ts @@ -64,7 +64,11 @@ vi.mock("../serialization/Converter", () => { }; }); -const stubStore = (kind: "indexeddb" | "filesystem" | "remote", save?: ProjectStore["save"]): ProjectStore => ({ +const stubStore = ( + kind: "indexeddb" | "filesystem" | "remote", + save?: ProjectStore["save"], + saveAssets?: ProjectStore["saveAssets"], +): ProjectStore => ({ kind, list: vi.fn(async () => ({projects: [], page: 1, hasMore: false, totalCount: 0})), load: vi.fn(async () => ({meta: {id: "", name: "", updatedAt: "", createdAt: ""}, sceneJson: "{}"})), @@ -72,7 +76,7 @@ const stubStore = (kind: "indexeddb" | "filesystem" | "remote", save?: ProjectSt delete: vi.fn(async () => undefined), exportToBlob: vi.fn(async () => new Blob([])), importFromBlob: vi.fn(async (): Promise => ({id: "", name: "", updatedAt: "", createdAt: ""})), - saveAssets: vi.fn(async () => undefined), + saveAssets: saveAssets ?? vi.fn(async () => undefined), loadAssets: vi.fn(async () => []), }); @@ -218,6 +222,30 @@ describe("ossSaveScene", () => { converterMod.default = original; }); + it("reports a save FAILURE (not success) when binary asset persistence throws", async () => { + // The scene JSON saves fine, but persisting its binary assets fails. + // Reporting "Saved" here would be a masking fallback: a reload would + // render a scene with missing models. The save must surface as failed. + const saveSpy = vi.fn(async (body: ProjectBody): Promise => body.meta); + const saveAssetsSpy = vi.fn(async () => { + throw new Error("asset disk write failed"); + }); + setProjectStore(stubStore("filesystem", saveSpy, saveAssetsSpy)); + + const app = buildApp({sceneID: "proj-assets"}); + const globalMod = await import("../global"); + // @ts-expect-error mutate for test + globalMod.default.app = app; + + await ossSaveScene(false, false); + + expect(saveSpy).toHaveBeenCalledTimes(1); // scene JSON did persist + expect(saveAssetsSpy).toHaveBeenCalledTimes(1); + expect(app.call).toHaveBeenCalledWith("sceneSaveFailed"); + // Must NOT have falsely announced success. + expect(app.call).not.toHaveBeenCalledWith("sceneSaved", expect.anything(), expect.anything()); + }); + it("short-circuits in read-only mode", async () => { const saveSpy = vi.fn(); setProjectStore(stubStore("indexeddb", saveSpy)); diff --git a/client/packages/editor-oss/src/persistence/ossSceneSave.ts b/client/packages/editor-oss/src/persistence/ossSceneSave.ts index 90b8e8ac..ba84dce4 100644 --- a/client/packages/editor-oss/src/persistence/ossSceneSave.ts +++ b/client/packages/editor-oss/src/persistence/ossSceneSave.ts @@ -28,41 +28,39 @@ import type {ProjectBody, ProjectMeta, StoredAsset} from "./types"; * Persist the binary OSS assets (models, images, audio) a project depends * on into the active ProjectStore. OSS synthesizes these as in-memory * `data:` URLs with no asset service behind them; without this the scene - * JSON's model references would dangle after a reload. Best-effort: a - * failure here is logged but doesn't fail the scene save. + * JSON's model references would dangle after a reload. A failure here means + * the scene was saved but its binary assets were NOT — a reload would show a + * scene with missing models. That is a real save failure, so this throws and + * the caller surfaces it instead of reporting a clean "Saved". */ async function persistProjectAssets(projectId: string): Promise { - try { - const splitDataUrl = (url: string): {contentType?: string; base64: string} => { - // `data:;base64,` → {mime, payload} - const comma = url.indexOf(","); - if (comma < 0) return {base64: url}; - const header = url.slice(5, comma); // skip "data:" - const semi = header.indexOf(";"); - const mime = semi >= 0 ? header.slice(0, semi) : header; - return {contentType: mime || undefined, base64: url.slice(comma + 1)}; - }; - const assets: StoredAsset[] = getOssAssetsForProject(projectId) - .filter(record => record.dataUrl) - .map(record => { - const main = splitDataUrl(record.dataUrl!); - const thumb = record.thumbnailDataUrl ? splitDataUrl(record.thumbnailDataUrl) : undefined; - return { - assetId: record.assetId, - revisionId: record.revisionId, - type: record.type, - format: record.format, - name: record.name, - contentType: record.contentType, - metadata: record.metadata, - data: main.base64, - ...(thumb ? {thumbnailData: thumb.base64, thumbnailContentType: thumb.contentType} : {}), - }; - }); - await getProjectStore().saveAssets(projectId, assets); - } catch (err) { - console.warn("[ossSaveScene] failed to persist project assets", err); - } + const splitDataUrl = (url: string): {contentType?: string; base64: string} => { + // `data:;base64,` → {mime, payload} + const comma = url.indexOf(","); + if (comma < 0) return {base64: url}; + const header = url.slice(5, comma); // skip "data:" + const semi = header.indexOf(";"); + const mime = semi >= 0 ? header.slice(0, semi) : header; + return {contentType: mime || undefined, base64: url.slice(comma + 1)}; + }; + const assets: StoredAsset[] = getOssAssetsForProject(projectId) + .filter(record => record.dataUrl) + .map(record => { + const main = splitDataUrl(record.dataUrl!); + const thumb = record.thumbnailDataUrl ? splitDataUrl(record.thumbnailDataUrl) : undefined; + return { + assetId: record.assetId, + revisionId: record.revisionId, + type: record.type, + format: record.format, + name: record.name, + contentType: record.contentType, + metadata: record.metadata, + data: main.base64, + ...(thumb ? {thumbnailData: thumb.base64, thumbnailContentType: thumb.contentType} : {}), + }; + }); + await getProjectStore().saveAssets(projectId, assets); } /** @@ -159,8 +157,18 @@ export async function ossSaveScene(_createThumbnail: boolean, shouldShowToast: b } // Persist binary assets (models/images/audio) alongside the project so - // the scene's asset references resolve after a reload. - await persistProjectAssets(saved.id); + // the scene's asset references resolve after a reload. If this fails the + // scene JSON is saved but its assets are not — a reload would render a + // scene with missing models. Surface that as a save failure rather than + // reporting a clean "Saved". + try { + await persistProjectAssets(saved.id); + } catch (err) { + console.error("ossSaveScene: failed to persist project assets", err); + if (shouldShowToast) showToast({type: "error", title: "Save failed — could not persist assets."}); + app.call("sceneSaveFailed"); + return; + } if (shouldShowToast) { showToast({type: "success", title: "Saved"}); diff --git a/client/packages/editor-oss/src/showToast.tsx b/client/packages/editor-oss/src/showToast.tsx index 6089e636..dc3dad75 100644 --- a/client/packages/editor-oss/src/showToast.tsx +++ b/client/packages/editor-oss/src/showToast.tsx @@ -113,6 +113,28 @@ const showToast = (props: ToastMessageProps) => { export {showToast}; +/** + * Show a persistent spinner ("loading") toast and return its id. Unlike + * `showToast`, this does NOT auto-dismiss — the caller owns the lifecycle and + * must call `dismissToast(id)` when the work finishes. Use for long async work + * with no fixed duration, e.g. while a stemscript import is executing. + * + * @param title - Headline shown next to the spinner. + * @param body - Optional secondary line. + * @returns The toast id, to pass to `dismissToast`. + */ +export const showLoadingToast = (title: string, body?: string): number => + toastywaveToast.loading(title, { + description: body, + duration: Infinity, + showCountdown: false, + }); + +/** Dismiss a toast (e.g. a `showLoadingToast` spinner) by its id. No-op if already gone. */ +export const dismissToast = (id: number): void => { + toastywaveToast.dismiss(id); +}; + /** * Utility function to create clickable items for objects in the scene * diff --git a/client/packages/editor-oss/src/utils/EnvironmentSettingsManager.ts b/client/packages/editor-oss/src/utils/EnvironmentSettingsManager.ts index 06a7eba3..524e6053 100644 --- a/client/packages/editor-oss/src/utils/EnvironmentSettingsManager.ts +++ b/client/packages/editor-oss/src/utils/EnvironmentSettingsManager.ts @@ -505,6 +505,19 @@ export class EnvironmentSettingsManager { rendering.background; const textureAsset: AssetRef | undefined = backgroundWithAssets.textureAsset; const cubemapAssets: Array = backgroundWithAssets.cubemapAssets || []; + // A `blob:` object URL only lives for the session that created it. Older + // scenes persisted such URLs as the background texture; on reload they + // are revoked and unfetchable. When there's no AssetRef to recover from, + // treat the texture as absent so we fall back to a plain background + // instead of throwing "Failed to load texture: blob:…". + const usableTexture = + typeof texture === "string" && texture.startsWith("blob:") && !textureAsset ? undefined : texture; + if (usableTexture !== texture) { + console.warn( + "EnvironmentSettingsManager: ignoring stale blob: background texture with no asset reference " + + "(re-set the scene background to restore it).", + ); + } const currentRotation = rotation ?? 0; const currentIntensity = intensity ?? 1; const currentBlurriness = blurriness ?? 0; @@ -568,7 +581,7 @@ export class EnvironmentSettingsManager { fogType: effectiveFogType, }; - if (type === "Color" || !type || (type === "Texture" && !texture && !textureAsset)) { + if (type === "Color" || !type || (type === "Texture" && !usableTexture && !textureAsset)) { if (scene.background instanceof Texture) { scene.background.dispose(); } @@ -585,11 +598,11 @@ export class EnvironmentSettingsManager { sceneWithNodes.backgroundNode = null; } sceneWithNodes.environmentNode = bgNode; - } else if (type === "Texture" && (texture || textureAsset)) { + } else if (type === "Texture" && (usableTexture || textureAsset)) { if (effectiveFogType !== "height") { sceneWithNodes.backgroundNode = null; } - const resolvedTexture = await this.resolveBackgroundImageSource(texture, textureAsset); + const resolvedTexture = await this.resolveBackgroundImageSource(usableTexture, textureAsset); const ext = (resolvedTexture.format || resolvedTexture.url.split(".").pop()?.toLowerCase())?.toLowerCase(); const engine = this.editor.engine; diff --git a/client/packages/network/src/adapters/remote-go/asset/index.ts b/client/packages/network/src/adapters/remote-go/asset/index.ts index 33775abd..49a8386b 100644 --- a/client/packages/network/src/adapters/remote-go/asset/index.ts +++ b/client/packages/network/src/adapters/remote-go/asset/index.ts @@ -110,6 +110,20 @@ export const setOssAssetThumbnail = (assetId: string, thumbnailDataUrl: string): export const lookupOssAsset = (idOrRevisionId: string): OssAssetRecord | undefined => ossAssetRegistry.get(idOrRevisionId); +/** + * Drop a synthesized OSS asset from the registry (both its asset-id and + * revision-id keys). Used by the OSS behavior-import de-duplication to collapse + * surplus same-named behavior records down to a single latest one — OSS has no + * revision history, so duplicates created by earlier imports are pure noise. + * After removal the record no longer surfaces in `getOssAssetsForProject`, so it + * drops out of the asset list and is not re-persisted on the next project save. + */ +export const unregisterOssAsset = (assetId: string): void => { + const record = ossAssetRegistry.get(assetId); + ossAssetRegistry.delete(assetId); + if (record?.revisionId) ossAssetRegistry.delete(record.revisionId); +}; + /** * Every synthesized OSS asset created for a given project, de-duplicated. * Used by the persistence layer to write a project's binary assets to the @@ -596,13 +610,21 @@ export const createAssetRevision = async ({ // imports route their payload (`data` is base64) through this and // expect a usable revision id back; we encode the inline data as a // data: URL so resolvers downstream still read the same shape. - const id = `oss-rev-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + // + // OSS has NO revision management — there is only ever the latest version + // of an asset. So reuse the asset's stable head revision id and overwrite + // the registry record in place, instead of minting a fresh + // `oss-rev-${Date.now()}` on every save. Minting a new id per save spawned + // parallel revision ids and made the scene's pinned `assetId→revisionId` + // drift, which surfaced as "multiple revisions of the same behavior". + // The stable id matches what `createAsset` assigns (`oss-rev-${assetId}`). + const existing = lookupOssAsset(assetId); + const id = existing?.revisionId ?? `oss-rev-${assetId}`; let dataUrl: string | undefined; if (typeof data === "string" && data.length > 0) { const mime = format === "json" ? "application/json" : (contentType || "application/octet-stream"); dataUrl = `data:${mime};base64,${data}`; } - const existing = lookupOssAsset(assetId); registerOssAsset({ assetId, revisionId: id, @@ -610,8 +632,17 @@ export const createAssetRevision = async ({ format: format ?? existing?.format ?? "", name: existing?.name ?? assetId, contentType: contentType ?? existing?.contentType, - metadata: options.metadata, + metadata: options.metadata ?? existing?.metadata, dataUrl, + // Carry the existing record's thumbnail + project tag across the + // overwrite. Dropping `projectId` here is why the FIRST save after a + // behavior edit didn't persist: `getOssAssetsForProject(projectId)` + // skips records whose `projectId` no longer matches, so the edited + // behavior was excluded from `persistProjectAssets`. Fall back to the + // current scene id so a behavior created before the project's first + // save (fresh import) still gets tagged on its next revision. + thumbnailDataUrl: existing?.thumbnailDataUrl, + projectId: existing?.projectId ?? global.app?.editor?.sceneID ?? undefined, }); return { id, diff --git a/client/packages/network/src/adapters/remote-go/scene/v2.ts b/client/packages/network/src/adapters/remote-go/scene/v2.ts index bdd37104..2a6d9366 100644 --- a/client/packages/network/src/adapters/remote-go/scene/v2.ts +++ b/client/packages/network/src/adapters/remote-go/scene/v2.ts @@ -148,18 +148,30 @@ async function loadSceneFromProjectStore(sceneId: string): Promise = {}; let ossLogicalIdToAssetId: Record = {}; + // `showHud` (and the other game flags) live in the persisted scene's + // `userData.game`, not in separate metadata. Default to OFF — the HUD is + // opt-in (see SceneConfig.showHUD = false). Hardcoding `true` here made the + // HUD appear on every reload regardless of the project's actual setting. + let ossShowHud = false; try { const parsed = JSON.parse(body.sceneJson) as Record; for (const part of Object.values(parsed)) { - const ctx = (part as {userData?: {assetResolutionContext?: { - assetIdToRevisionId?: Record; - logicalIdToAssetId?: Record; - }}})?.userData?.assetResolutionContext; + const userData = (part as {userData?: { + assetResolutionContext?: { + assetIdToRevisionId?: Record; + logicalIdToAssetId?: Record; + }; + game?: {showHUD?: boolean}; + }})?.userData; + const ctx = userData?.assetResolutionContext; if (ctx) { ossDependencies = ctx.assetIdToRevisionId ?? ossDependencies; ossLogicalIdToAssetId = ctx.logicalIdToAssetId ?? ossLogicalIdToAssetId; - break; } + if (typeof userData?.game?.showHUD === "boolean") { + ossShowHud = userData.game.showHUD; + } + if (ctx) break; } } catch (err) { console.warn("[scene/v2] failed to extract asset resolution context from scene JSON", err); @@ -185,7 +197,7 @@ async function loadSceneFromProjectStore(sceneId: string): Promise = { "repo-docs:architecture.md": architectureMd, "repo-docs:byok.md": byokMd, "repo-docs:exporting-a-game.md": exportingMd, + "repo-docs:gameobject-and-game-manager-api.md": gameObjectAndGameManagerApiMd, "repo-docs:multiplayer.md": multiplayerMd, + "repo-docs:runtime-api.md": runtimeApiMd, "repo-docs:server-side-storage.md": serverSideStorageMd, + "repo-docs:uikit-api.md": uikitApiMd, "repo-root:README.md": readmeMd, "repo-root:CONTRIBUTING.md": contributingMd, }; diff --git a/client/packages/site/src/content/docs-nav.ts b/client/packages/site/src/content/docs-nav.ts index 6f653e51..fd8052ac 100644 --- a/client/packages/site/src/content/docs-nav.ts +++ b/client/packages/site/src/content/docs-nav.ts @@ -25,6 +25,14 @@ export const DOC_SECTIONS: DocSection[] = [ {slug: "server-side-storage", title: "Server-side storage & version control", source: "repo-docs", file: "server-side-storage.md"}, ], }, + { + label: "APIs", + entries: [ + {slug: "runtime-api", title: "Runtime API", source: "repo-docs", file: "runtime-api.md"}, + {slug: "gameobject-and-game-manager-api", title: "GameObject & GameManager", source: "repo-docs", file: "gameobject-and-game-manager-api.md"}, + {slug: "uikit-api", title: "UIKit API", source: "repo-docs", file: "uikit-api.md"}, + ], + }, { label: "AI & Multiplayer", entries: [ diff --git a/docs/gameobject-and-game-manager-api.md b/docs/gameobject-and-game-manager-api.md new file mode 100644 index 00000000..98e28d16 --- /dev/null +++ b/docs/gameobject-and-game-manager-api.md @@ -0,0 +1,428 @@ +# GameObject and GameManager API + +`GameObject` is the behavior-friendly wrapper around a Three.js `Object3D`. `GameManager` is the lower-level runtime controller passed to behavior `init(game)`. + +Use `this.erth` for normal gameplay code. Reach for `this.game` only after storing the `game` argument yourself, and only when you need lower-level systems that are not exposed through `this.erth`. + +```ts +this.init = function (game) { + this.game = game; +}; +``` + +## GameObject + +You get a `GameObject` from: + +- `this.gameObject` +- `this.erth.object.createFromThreeObject(object3d)` +- `this.erth.asset.model.createInstance(ref)` +- `this.erth.asset.stem.createInstance(ref)` + +```ts +interface GameObject { + readonly uuid: string; + readonly position: THREE.Vector3; + readonly rotation: THREE.Quaternion; + readonly scale: THREE.Vector3; + visible: boolean; + readonly physics: GameObjectPhysics; + readonly _internal: {three?: THREE.Object3D}; +} +``` + +`position`, `rotation`, and `scale` are read-only references to mutable Three.js objects. Change their components instead of replacing the property. + +```ts +this.gameObject.position.set(0, 2, 0); +this.gameObject.scale.set(2, 2, 2); +this.gameObject.visible = false; +``` + +Use `this.target` when an API requires the raw `THREE.Object3D`. Use `this.gameObject` when you want the wrapper and its physics helper. + +## Physics helper + +Every `GameObject` has: + +```ts +interface GameObjectPhysics { + configure(settings: PhysicsSettings): void; + getSettings(): PhysicsSettings | undefined; + getBody(): RigidBodyHandle | undefined; +} +``` + +Call `configure()` before adding a new runtime object to the scene. The runtime body is created when the object is initialized by `erth.scene.addObject()` or `game.addObject()`. + +```ts +this.onStart = async function () { + const mesh = new THREE.Mesh( + new THREE.SphereGeometry(0.5, 24, 16), + new THREE.MeshStandardMaterial({color: 0xffaa00}), + ); + + const ball = this.erth.object.createFromThreeObject(mesh); + ball.position.set(0, 6, 0); + ball.physics.configure({ + enabled: true, + bodyType: "dynamic", + shape: "sphere", + mass: 1, + restitution: 0.6, + material: "rubber", + }); + + await this.erth.scene.addObject(ball); + ball.physics.getBody()?.applyImpulse({x: 2, y: 0, z: -4}); +}; +``` + +## PhysicsSettings + +```ts +interface PhysicsSettings { + enabled?: boolean; + bodyType?: "static" | "dynamic" | "kinematic"; + shape?: "box" | "sphere" | "capsule" | "convexHull" | "concaveHull"; + mass?: number; + friction?: number; + restitution?: number; + rollingFriction?: number; + spinningFriction?: number; + material?: PhysicsMaterial; + climbable?: boolean; + rotationLock?: {x?: boolean; y?: boolean; z?: boolean}; + shapeOffset?: {x: number; y: number; z: number}; + shapeScale?: {x: number; y: number; z: number}; + excludeHiddenObjects?: boolean; + shapeDimensions?: BoxShapeDimensions | SphereShapeDimensions | CapsuleShapeDimensions; +} +``` + +Body types: + +| Type | Use for | +|---|---| +| `static` | Floors, walls, static level geometry | +| `dynamic` | Objects affected by gravity and impulses | +| `kinematic` | Objects moved by code, such as platforms and doors | + +Physics materials: + +```ts +type PhysicsMaterial = + | "metal" + | "dirt" + | "ground" + | "plastic" + | "snow" + | "wood" + | "concrete" + | "mud" + | "ice" + | "slime" + | "water" + | "slipperyGround" + | "rubber" + | "sand"; +``` + +Manual dimensions are supported for `box`, `sphere`, and `capsule` shapes. + +```ts +wall.physics.configure({ + enabled: true, + bodyType: "static", + shape: "box", + material: "concrete", + shapeDimensions: {width: 1, height: 3, length: 10}, +}); +``` + +## RigidBodyHandle + +After the object has been added to the scene, `gameObject.physics.getBody()` can return: + +```ts +interface RigidBodyHandle { + readonly uuid: string; + applyImpulse(impulse, relativePosition?): void; + setVelocity(velocity): void; + setCollisionBehavior(behavior: "regular" | "ghost"): void; + remove(): void; +} +``` + +```ts +const body = this.gameObject.physics.getBody(); +body?.setVelocity({x: 0, y: 0, z: 8}); +body?.setCollisionBehavior("ghost"); +``` + +`ghost` bodies pass through other bodies but can still be used for trigger-style detection. + +## GameManager state + +`GameManager` is passed to `init(game)`: + +```ts +this.init = function (game) { + this.game = game; +}; +``` + +Game state values: + +```ts +this.game.state; +this.game.score; +this.game.lives; +this.game.health; +this.game.initialLives; +this.game.initialHealth; +this.game.maxScore; +``` + +State helpers: + +```ts +this.game.isGameOver(); +this.game.isWinner(); +this.game.isGameStarted(); +``` + +The current states are `NOT_STARTED`, `STARTED`, `FINISHED`, and `PAUSED`. `isGameStarted()` only returns true after startup initialization has completed. + +## GameManager object methods + +```ts +await this.game.addObject(object3d, parent?); +this.game.removeObject(object3d); +const clone = this.game.cloneObject(sourceObject); +this.game.pauseObject(object3d, pauseChildren?); +this.game.resumeObject(object3d, resumeChildren?); +``` + +Prefer `this.erth.object.createFromThreeObject()` plus `this.erth.scene.addObject()` when you are creating new runtime objects from behavior code. Use `game.addObject()` when you intentionally need to work with raw Three.js objects. + +## GameManager behavior methods + +```ts +await this.game.addBehaviorToObject(target, behaviorId, options?); +this.game.removeBehaviorByUUID(uuid); +this.game.updateBehaviorAttributes(uuid, updatedProperties); +``` + +For most cross-behavior work, prefer the higher-level `this.erth.behaviors` helpers. They return foreign behavior views and route attribute updates through the behavior change pipeline. + +## Sound and animation + +Sound helpers delegate to the active HUD sound manager: + +```ts +this.game.loadSounds(sounds); +this.game.playSound(soundId); +this.game.stopSound(soundId); +this.game.clearSounds(); +``` + +Animation helpers delegate to the animation controller: + +```ts +this.game.playBlendedAnimations(this.target, [ + {name: "walk", weight: 0.7}, + {name: "wave", weight: 0.3}, +]); + +this.game.updateBlendedAnimationWeights(this.target, { + walk: 0.2, + run: 0.8, +}); +``` + +## Input + +Use `inputManager.getAction(actionId)` for boolean buttons and `inputManager.getMotion(motionId)` for continuous axes. + +```ts +this.update = function () { + if (this.game?.inputManager.getAction("jump")) { + this.gameObject.physics.getBody()?.applyImpulse({x: 0, y: 4, z: 0}); + } +}; +``` + +```ts +this.update = function () { + const steer = this.game?.inputManager.getMotion("lateral") ?? 0; + const throttle = this.game?.inputManager.getMotion("forward") ?? 0; + this.drive(steer, throttle); +}; +``` + +Current built-in action names include `jump`, `run`, `use`, `drop`, `pull`, and `primary`. Common motion names include `forward`, `lateral`, `view_x`, and `view_y`. + +## Direct subsystem access + +GameManager exposes engine subsystems for advanced cases: + +| Property | Use | +|---|---| +| `scene` | Active Three.js scene | +| `camera` | Active perspective camera | +| `renderer` | Active renderer | +| `physics` | Physics engine interface | +| `animationController` | Animation playback | +| `animationGraphController` | Animation graph playback | +| `audioController` | Audio subsystem | +| `cameraControl` | Camera control system | +| `collisionDetector` | Collision detection updates | +| `objectPicker` | Pointer/raycast object picking | +| `inputManager` | Keyboard, mouse, touch, gamepad action state | +| `pointerEventManager` | Pointer input | +| `behaviorManager` | Behavior lifecycle and targeted events | +| `lambdaManager` | Lambda lifecycle and object registration | +| `multiplayerState` | Multiplayer state bridge | +| `player` | Current player object, if assigned | +| `discord` | Discord integration service | + +This surface is intentionally lower-level and can change faster than `this.erth`. + +## Events handled by GameManager + +GameManager listens to the `game` event namespace and handles: + +```ts +game.start +game.resume +game.pause +game.stop +game.score.inc +game.score.dec +game.lives.inc +game.lives.dec +game.health.inc +game.health.dec +game.time.inc +game.time.dec +game.loadSounds +game.playSound +game.stop_sound +game.clear_sounds +game.loginSuccess +``` + +Direct `EventBus` sending still exists for legacy scripts, but new behavior-to-behavior messages should use: + +```ts +this.game.behaviorManager.sendEventToObjectBehaviors(target, "custom.topic", data); +``` + +and receive them with: + +```ts +this.onEvent = function (msg, data) { + if (msg === "custom.topic") { + // react here + } +}; +``` + +## Patterns from real playground games + +These examples are adapted from game behaviors and keep to the current supported API surface. + +### Controller loop with input, motion, and telemetry + +Vehicle and flight behaviors usually store the `GameManager`, read input every frame, mutate the `GameObject` transform, then publish plain telemetry for HUD and audio behaviors. + +```ts +const forward = new THREE.Vector3(); +const up = new THREE.Vector3(0, 1, 0); + +this.init = function (game) { + this.game = game; + this.speed = 0; + this.drift = 0; +}; + +this.update = function (deltaTime) { + const input = this.game.inputManager; + const steer = input.getMotion("lateral"); + const throttle = input.getMotion("forward"); + const boost = input.getAction("use"); + + const acceleration = boost ? 26 : 14; + this.speed = THREE.MathUtils.clamp( + this.speed + throttle * acceleration * deltaTime, + -8, + 38, + ); + + this.target.getWorldDirection(forward); + this.gameObject.position.addScaledVector(forward, this.speed * deltaTime); + this.target.rotateOnAxis(up, -steer * deltaTime * 1.7); + + this.drift = Math.abs(steer) * Math.min(Math.abs(this.speed) / 24, 1); + this.erth.store.set("vehicle.telemetry", { + speed: this.speed, + boost, + drift: this.drift, + }); +}; +``` + +Keep the controller authoritative for movement. Let HUD, sound, particles, and camera behaviors read telemetry instead of duplicating the driving math. + +### Camera follow behavior + +Rail and vehicle games often keep camera logic in a separate behavior so the player controller can stay focused on movement. + +```ts +const desired = new THREE.Vector3(); +const offset = new THREE.Vector3(0, 4, 9); + +this.init = function (game) { + this.game = game; +}; + +this.update = function (deltaTime) { + desired.copy(offset) + .applyQuaternion(this.target.quaternion) + .add(this.gameObject.position); + + const camera = this.game.camera; + camera.position.lerp(desired, Math.min(deltaTime * 5, 1)); + camera.lookAt(this.gameObject.position); +}; +``` + +Use `this.game.camera` when you need the raw Three.js camera. Use `this.erth.camera.lookAt(x, y, z)` for simpler camera aiming. + +### Targeted behavior events + +Chess, weapon, and vehicle systems use targeted behavior messages when one object owns a gameplay decision and another behavior should respond. + +```ts +// Sender behavior +this.fire = function () { + this.game.behaviorManager.sendEventToObjectBehaviors(this.target, "weapon.fire", { + origin: this.gameObject.position.toArray(), + speed: 32, + }); +}; +``` + +```ts +// Receiver behavior on the same object, or on a targeted object +this.onEvent = function (msg, data) { + if (msg !== "weapon.fire") return; + + const projectile = this.spawnProjectile(); + projectile.position.fromArray(data.origin); + projectile.physics.getBody()?.setVelocity({x: 0, y: 0, z: -data.speed}); +}; +``` + +This is the right tool when a message is about a specific object. For broad engine state such as login, score, or health topics, use `this.erth.events.on()`. diff --git a/docs/planning/2026-05-30-oss-behavior-dedup-and-edit-persist.md b/docs/planning/2026-05-30-oss-behavior-dedup-and-edit-persist.md new file mode 100644 index 00000000..d9eea544 --- /dev/null +++ b/docs/planning/2026-05-30-oss-behavior-dedup-and-edit-persist.md @@ -0,0 +1,152 @@ +# OSS behavior de-duplication + edit persistence + +Goal: in OSS / playground mode, load and use exactly **one (latest) revision per +logical behavior**, and make a behavior edit actually update the behavior + +scene **and persist to the filesystem store** (so the "Saved" toast is truthful). + +## Symptoms (reported) + +- Importing the Pirate Ship game shows **3 copies** of every behavior in the + Behaviors asset panel (AIShipController ×3, ShipController ×3, … 21 total). +- Editing a behavior shows "Behavior saved successfully" but the change is **not + written to the file system** — gone after reload. + +## Root cause (verified) + +1. **List = registry, keyed by id.** + `BehaviorConfigRegistry` is `Map` keyed by `id` + (`editor/behaviors/BehaviorConfigRegistry.ts:4,8`). The panel renders + `getAllConfigs()`. Three visible copies ⇒ **three distinct asset ids** all + with the same `name` — the Map cannot collapse them. + +2. **Import creates new assets for the same logical behavior.** + `agent/script-tool/importHandler.ts:300-370` is idempotent only if it finds + the existing behavior by YAML `config.id` or by a **unique** name match. Once + two same-named assets exist, the name fallback bails (lines 325-332) and a + **new** asset is created — a snowball that produces 3, 4, … copies across + re-imports / round-trips. + +3. **Edit creates a revision but never persists the project.** + `useBehaviorSave.ts:278-283` calls `createBehaviorRevision({assetId, + parentRevisionId, config, code})` **without `assetSource`**, so + `createBehaviorRevision` skips `updateSceneBehaviorRevision` + (`editor/behaviors/util.ts:389-391`). The new revision is only **seeded into + the in-memory query cache / OSS session registry** (`seedAssetRevisionData`). + Nothing writes the project to the `ProjectStore`, so a filesystem reload + loses the edit. The toast fires regardless (`useBehaviorSave.ts:318`). + +## Assumptions / open questions + +- OSS keeps one asset id per logical behavior across edits (edits = new + revisions of the same id). Duplicates therefore have **different** ids and the + same `name`, so collapsing by `name` → latest is safe for imported games. +- Confirm whether a single import truly produces 3, or whether re-runs/round + trips accumulate them. The fix is robust either way (collapse + idempotent + register), but reproduction will validate. + +## Affected files + +- `editor/behaviors/BehaviorConfigRegistry.ts` — register/dedup semantics. +- `behaviors/BehaviorLoadingService.ts` — load/merge of backend + scene configs. +- `agent/script-tool/importHandler.ts` — behavior import idempotency. +- `editor/assets/v2/AssetsLibrary/BehaviorCreator/hooks/useBehaviorSave.ts` — + edit save path (pass `assetSource`; persist project in OSS). +- `editor/assets/v2/AssetsLibrary/CodeEditor/CodeEditorShell.tsx` — onSaveComplete. +- Possibly a small persistence hook to save the project after an OSS edit. + +## Decisions (confirmed with user, 2026-05-30) + +1. **Saved toast must mean persisted to the filesystem store.** +2. **De-duplicate on import**, not on every scene load. +3. **OSS / playground has NO revision management — only the latest version.** + +## Plan (confirmed approach) + +### Change 1 — OSS: no revision churn, latest-only (network adapter) +`packages/network/src/adapters/remote-go/asset/index.ts` — `createAssetRevision` +OSS branch currently mints a fresh `oss-rev-${Date.now()}-…` id on every save, so +each edit creates a parallel revision id. Make it **reuse the asset's stable head +revision id** `oss-rev-${assetId}` (the same id `createAsset` assigns) and +**overwrite the registry record in place**. Result: exactly one revision per +asset, the scene's pinned `assetId→revisionId` never drifts, and "latest wins" +is literal. IS_OSS-gated; integrated path untouched. +- [x] Stable head-revision id reuse in `createAssetRevision` OSS branch. + +### Change 2 — OSS: dedup behaviors on import (importHandler) +`packages/editor-oss/src/agent/script-tool/importHandler.ts` (case "behavior"). +In OSS, an imported behavior should **replace** any existing same-named +behaviors so the panel converges to one per behavior: +- [x] Capture the just-imported behavior as the survivor (update-in-place when it + already exists, else create). +- [x] After import, in OSS, collapse every other same-named `AssetType.Behavior` + record: new `unregisterOssAsset()` drops the orphan record + both registry + keys; `unregisterConfig` / `unregisterScript` clear the registries. Scene + objects attach by the logical/alias id (→ survivor), so no re-pointing is + needed. Re-import heals existing 3× duplicates → 1×. + +### Change 3 — OSS: behavior edit persists to filesystem (CodeEditorShell) +`packages/editor-oss/src/editor/assets/v2/AssetsLibrary/CodeEditor/CodeEditorShell.tsx` +`handleSaveComplete` already runs `updateSceneBehaviorRevision`. In OSS, follow it +with `saveScene(false, false)` (→ `ossSaveScene` → `ProjectStore.save` + +`persistProjectAssets`) so the edited behavior + scene body reach the filesystem +before the success state. Mirrors the existing `useImportBehaviors` pattern +(`services.ts:122`). +- [x] Persist via `saveScene(false,false)` after an OSS behavior save + (`CodeEditorShell.handleSaveComplete` / `handleSaveAllComplete`, OSS-gated). + +## Follow-up fixes (2026-05-30, from user testing) + +### Change 4 — first behavior save not persisting (network adapter) +`createAssetRevision` (OSS) re-registered the record **without `projectId`**, so +after an edit the behavior fell out of `getOssAssetsForProject(projectId)` and +`persistProjectAssets` skipped it — the first save silently dropped the change. +Now the overwrite carries `projectId` (and `thumbnailDataUrl`) across, falling +back to the current `editor.sceneID` so a behavior created before the project's +first save still gets tagged on its next revision. +- [x] Preserve `projectId` / `thumbnailDataUrl` on the in-place revision rewrite. + +### Change 5 — spinner toast during stemscript import +`showToast.tsx` gains `showLoadingToast(title, body) → id` (persistent +`type: "loading"` spinner, `duration: Infinity`) and `dismissToast(id)`. +`useTerminal.runScript` shows it before executing the script and dismisses it in +the `finally` (after the auto-save), so the user sees a spinner while the +stemscript runs. +- [x] `showLoadingToast` / `dismissToast` helpers + `runScript` wiring. + +## Follow-up fixes (2026-05-30, round 2 — from console evidence) + +### Change 6 — first behavior edit reverts on reload (the actual root cause) +Console showed `[Editor] Saved scene behavior configs` listing **every** imported +behavior **twice** with the same `config.id`. Cause: a behavior is registered +under two registry keys (its asset id AND its import alias), so +`getAllConfigs()` returns it twice. A behavior edit re-registers only the +asset-id key (moving it to the end of the Map), leaving the alias-keyed copy +**stale**. The scene saved both fresh+stale; on reload the stale duplicate could +hydrate last and win → the first edit "reverted" (the second worked because the +fresh entry had moved to the Map's end). NOTE: Change 4's `projectId` reasoning +was wrong-model — OSS behaviors are inlined in the scene JSON +(`isLegacyBehaviorId` is true for `oss-asset-*`), not stored as asset files. +- [x] `Editor.saveLegacySceneBehaviorConfigs` de-duplicates configs by + `config.id`, keeping the last (newest) registration. Halves the saved + behaviorConfigs and makes the first edit persist. + +### Change 7 — suggest widget pops on space (first line) +`scriptCompletions.ts` returned the full globals+lifecycle list as an +unconditional "General" fallthrough, so after typing a space (cursor not on a +word) Monaco kept the suggest widget open showing everything. Now returns no +general suggestions when `word.word` is empty. +- [x] Guard the general fallthrough on a non-empty current word. + +## Validation + +- [x] `bun run typecheck` (0 errors). +- [x] `bun run lint` on changed files (0 errors; only pre-existing `any` warnings). +- [x] Targeted Vitest: import / behaviorRevision / BehaviorLoadingService / + script-tool / network adapter — 127+ pass. +- [ ] Re-import Pirate Ship in OSS filesystem playground → exactly 7 behaviors + listed; repeated re-import stays at 7. (Blocked locally: the Pirate Ship + import exceeds the Playwright harness's 20-min budget — verify manually or + with a lighter game.) +- [ ] Edit a behavior → reload from filesystem store → edit is present. (Manual.) +- [ ] `node scripts/playwright/oss-smoke.mjs` and the FS roundtrip smoke. +- [ ] Manual code review. diff --git a/docs/planning/2026-05-30-oss-glb-import-skip-reexport.md b/docs/planning/2026-05-30-oss-glb-import-skip-reexport.md new file mode 100644 index 00000000..9b171149 --- /dev/null +++ b/docs/planning/2026-05-30-oss-glb-import-skip-reexport.md @@ -0,0 +1,63 @@ +# OSS GLB import: skip redundant GLTFExporter round-trip + +## Goal +Cut import time for stemscripts with many GLB models. Importing the Pirate +Ship game (101 `import model` commands) blocks the main thread for ~1000s +because each model is parsed → re-exported via `GLTFExporter` → re-parsed. +For sources that are already self-contained GLB, the re-export is pure waste. + +## Evidence +- Diagnostic caught a single `page.evaluate` blocked 291s→1290s during + import-resolution → main thread monopolized ~1000s, never reaching the + command-execution loop. +- 101 model imports; `convertToGlb(model, signal, {})` is called with empty + options, so it does *only* the `GLTFExporter.parse` round-trip (no + simplify/compress/optimize) — wasted for an already-valid GLB. + +## Approach +In `importHandler.ts` model case, after `loadModelFromFile` returns +`{rootFile, format, atlasData, textureOverrides}`: +- Fast path when `format === "glb" && !atlasData && !textureOverrides` + (self-contained GLB, no loose-texture remapping): use + `await rootFile.arrayBuffer()` as `sourceGlbBuffer`, skipping `convertToGlb`. +- Otherwise (FBX/OBJ/gltf+loose-textures/atlas): keep `convertToGlb` — the + exporter is required to normalize those into a single GLB. +- Keep the existing `loadModel(asset.id, context)` re-load for scene/asset + wiring + reload persistence (NOT skipped — out of scope, correctness risk). + +## Affected files +- `client/packages/editor-oss/src/agent/script-tool/importHandler.ts` (model case) + +## Implementation steps +- [ ] Capture `rootFile`, `format`, `atlasData`, `textureOverrides` from + `loadModelFromFile`. +- [ ] Compute `sourceGlbBuffer` via fast path or `convertToGlb` fallback. +- [ ] Leave LOD/thumbnail OSS gates and `loadModel` re-load unchanged. + +## Validation steps +- [x] `bun run typecheck` +- [x] Manual code review. + +## Findings (post-implementation) +Per-step timing instrumentation (temporary, since removed) of a full +pirate-ship import showed: +- `import-resolution = 333.5s` (109 imports; 100 models) — the dominant phase. +- `execute-loop = 33.4s` (incl. one pathological `behavior attach + ship-pirate-large.glb` = **23.8s** on first exported-behavior attach). +- `save = 1.1s`. Total ≈ 377s when run fire-and-forget. + +**The fast path is INERT for this game.** Every model reports +`fmt=glb, atlas=false, ovr=true`: the Kenney models reference an external +`colormap` texture, so `loadModelFromFile` sets `textureOverrides` and +`convertToGlb` (the GLTFExporter bake, ~1.2s/model) is genuinely required to +inline that texture into a single GLB. The skip only benefits self-contained +GLBs with no loose textures (other games), so it is kept as a correct, +low-risk optimization but does not speed up pirate-ship. + +**The 1200s smoke timeouts were not a hang.** Real work is ~377s; the import +is borderline vs the cap and highly sensitive to machine/harness load +(fire-and-forget diag finished at 377s; the smoke's awaited `page.evaluate` +under concurrent load exceeded the cap). The genuine lever to make large +shared-texture imports reliably fast is to avoid re-baking the same external +texture into N separate GLBs (bake once / share the texture asset) — a larger +import-pipeline change, deferred pending product decision. diff --git a/docs/planning/2026-05-31-oss-model-import-zip-assumption.md b/docs/planning/2026-05-31-oss-model-import-zip-assumption.md new file mode 100644 index 00000000..143110f6 --- /dev/null +++ b/docs/planning/2026-05-31-oss-model-import-zip-assumption.md @@ -0,0 +1,55 @@ +# OSS model import: stop assuming every model file is a ZIP + +## Goal +Fix the regression where many game models silently fail to import (100-cars, +3d-chess) while others import fine (pirate-ship), and behaviors import in all +cases. + +## Root cause (verified by repro) +`processImportedFile` (script-tool import path) calls +`loadModelFromFile(file, abortSignal, companionFiles, "application/zip")` — +hardcoding the source container as a ZIP archive (comment: `//all models are +ZIP archives`). `loadModelFromFile` then runs `expandZip` (JSZip) on it. + +- Pirate-ship `.glb` files are *actually* ZIP archives (`PK\x03\x04`) bundling + model + textures → `expandZip` works → models load. +- 100-cars `.glb` (`glTF…`) and 3d-chess `.gltf` (`{`) are raw model files → + `expandZip` throws `Can't find end of central directory : is this a zip + file?` → `processImportedFile` returns `{success:false}` per model. + +The per-model failure does **not** throw, so the script continues, behaviors +(which come later and don't go through this path) import normally, and exec +reports "done". Net effect: a mixed library imports "about half" of its models. + +Every other caller of `loadModelFromFile` (UI upload, batch LOD, URL upload, +asset-pack import) passes no `overriddenFileType` and lets the function detect +the container from `file.type`. Only the script-tool path forces zip. + +## Repro +`GAME_FOLDER=/Users/n/erth/Games-StemScript/3d-chess node +scripts/playwright/repro-import-inspect.mjs` → persisted scene had 0 model +objects / 0 model assets, only the 3 behaviors; per-import logs showed all 6 +models failing with the JSZip "central directory" error. + +## Affected files +- `client/packages/editor-oss/src/agent/script-tool/importHandler.ts` (model case) + +## Implementation +- [x] Sniff the real container in the model case: read the first 4 bytes and + treat the file as a ZIP only when they are `PK\x03\x04`. Pass + `"application/zip"` to `loadModelFromFile` only for true zips; pass `""` + otherwise so raw `.glb`/`.gltf`/`.fbx`/`.obj` take the direct-load path + (with companion files). +- [x] Update the misleading `//all models are ZIP archives` comment. + +## Validation +- [x] Repro on 3d-chess: 6/6 model objects + 6 model assets persisted (was 0). +- [x] Repro on 100-cars: 11/11 model objects + 11 model assets persisted (was 0). +- [x] Pirate-ship still imports (zip-wrapped glb path is byte-for-byte unchanged + — PK-magic files still pass "application/zip"). Logs confirm expandZip + + texture-override + convertToGlb running normally; the repro's empty + persisted scene is the known ~333s import-vs-save timing artifact + (`exec done signal: null`), not a failure. +- [x] `bun run typecheck` +- [x] Remove temporary `[DIAG]` instrumentation from `useTerminal.ts`. +- [ ] Manual code review. diff --git a/docs/planning/2026-06-01-import-dedup-and-localstorage-hygiene.md b/docs/planning/2026-06-01-import-dedup-and-localstorage-hygiene.md new file mode 100644 index 00000000..9562e6d3 --- /dev/null +++ b/docs/planning/2026-06-01-import-dedup-and-localstorage-hygiene.md @@ -0,0 +1,78 @@ +# Import asset dedup + localStorage hygiene + +## Goal +Fix the upstream root cause behind today's pirate-ship cluster (can't move, +physics wrongly enabled, missing skybox, `QuotaExceededError`): the project is +bloated and scene-scoped state is being dumped into localStorage. Two changes: + +1. **Import asset dedup** — identical source `.glb` files import **once** as a + shared asset; scene objects reference it. (Pirate-ship has 77 `import model` + lines pointing at 3 rock files → 77 inline assets today.) +2. **localStorage hygiene (architectural rule)** — localStorage holds **only + global user/device preferences** (FTUE, theme, language, quality, persistence + mode). All **scene-scoped** data moves to `scene.userData` (persisted to the + File System via `ProjectStore`) or is dropped if redundant. + +## Why this is the root cause +- `importHandler.ts` model path (≈614-632) creates a new asset **per import** + with no dedup (the media path at ≈662-670 *does* dedup by name). 77 rocks → 77 + multi-KB inline GLB assets. +- Scene-scoped data is serialized into localStorage (5 MB cap) regardless of + persistence mode: `autoSaveData` (full scene, every 10 s), copilot preview + drafts (`previewSceneJson`, per `sceneId`, never pruned), chat snapshots. +- A bloated scene × accumulating per-scene blobs → quota exhausted → even a tiny + write (`expandedPanels`) throws. Oversized saves are also the likely reason + `physics:false` / skybox / behavior edits don't persist. + +## Open questions (need confirmation) +- **Autosave recovery cache** (`autoSaveData…`): drop entirely (FS folder is + authoritative), or migrate "recover unsaved work" into `scene.userData`? +- **Instanced rendering** for the 77 identical rocks: do now, or follow-up? + (Storage dedup is independent of and higher-priority than instanced rendering.) + +## localStorage key classification +**Keep (global user/device prefs):** FTUE flags, `codeEditorTheme/FontSize/ +FontFamily`, language, quality settings, persistence-mode key, bootstrap flag, +dashboard FTUE, AssetsList filters, code-editor open/pinned/width, playmode- +inspector position, `expandedPanels`, signed-url cache, guest id. + +**Move to `scene.userData` (FS) or drop (scene-scoped):** +- `autoSaveData` + `autoSaveTime/SceneID/SceneName/SceneLockedItems` + (`event/AutoSaveEvent.js`) — drop or migrate. +- copilot preview drafts (`copilotPreviewDraftStorage.ts` localStorage fallback, + `previewSceneJson`) — keep IndexedDB, remove the full-scene localStorage write. +- chat snapshots (`workspaceChatSnapshot.ts`). +- `savedCameras` (`controls/ControlsManager.js`). +- runtime rig overrides (`assets/js/animations/runtimeRig.ts`). +- `lastPlayState` (`behaviors/packs/character/CharacterBehavior.ts`). + +## Affected files +- `agent/script-tool/importHandler.ts` (model dedup) +- `agent/script-tool/useTerminal.ts` / `processResolvedImports` (thread a + run-scoped content-hash→assetId cache) +- `event/AutoSaveEvent.js` +- `editor/assets/v2/CopilotWorkspace/copilotPreviewDraftStorage.ts` +- `editor/assets/v2/AiCopilot/workspaceChatSnapshot.ts` +- `controls/ControlsManager.js`, `assets/js/animations/runtimeRig.ts`, + `behaviors/packs/character/CharacterBehavior.ts` +- one-time cleanup that purges stale scene-scoped localStorage keys + +## Implementation steps +- [ ] **Import dedup**: hash `sourceGlbBuffer`; run-scoped `Map` + passed through `processImportedFile`. On hit, skip `createModelWithData`, + reuse `assetId`, still `loadModel(assetId)` + place the new object. +- [ ] Verify multiple scene objects sharing one `asset.id` save/load correctly. +- [ ] **autoSave**: drop (or migrate) the localStorage scene cache per decision. +- [ ] **copilot draft**: remove `writeLocalDraft` full-scene write; IndexedDB only. +- [ ] **chat snapshot / cameras / rig / play-state**: move to `scene.userData`. +- [ ] One-time cleanup of stale scene-scoped localStorage keys on boot. +- [ ] (Optional) instanced rendering for identical shared-asset placements. + +## Validation +- [ ] Re-import pirate ship → **3** rock assets, not 77; scene size drops sharply. +- [ ] `localStorage` stays small (run the size-dump one-liner). +- [ ] After reload: `physics:false`, skybox present, behavior edits persist; + `_matchStarted` flips true and **W moves the ship**. +- [ ] `bun run typecheck`, `bun run lint`, `bun run test`. +- [ ] Re-run OSS smokes touching persistence/import. +- [ ] Manual code review. diff --git a/docs/planning/2026-06-01-no-masking-fallbacks-playground.md b/docs/planning/2026-06-01-no-masking-fallbacks-playground.md new file mode 100644 index 00000000..efd8c5f7 --- /dev/null +++ b/docs/planning/2026-06-01-no-masking-fallbacks-playground.md @@ -0,0 +1,144 @@ +# No error-masking fallbacks in playground-critical paths + +## Goal + +No fallback in the playground-critical paths (import → persistence/save-load → +play) may silently hide a failure. Every fallback must either: + +- surface the failure loudly (error log + user-visible signal), or +- fail hard when the fallback is covering a **bug in our own implementation**. + +Only a *genuine, expected absence* (file not created yet, no asset subdir, +`crypto` unavailable in an insecure context) may degrade quietly — and even +then it must be the *narrow* expected error, not a blanket `catch {}`. + +Playwright tests must exercise these paths so a regression surfaces. + +## Scope (decided with user) + +Playground-critical paths only: `agent/script-tool/`, `persistence/`, the OSS +save path (`ossSceneSave.ts`), and the stemscript import driver +(`useTerminal.ts`). Render/picking/websocket degradation is explicitly out of +scope (legitimate, non-data-path). + +## Guiding rule + +`catch { return [] }` / `catch { /* skip */ }` is only acceptable when the +caught error is the **specific** expected one (e.g. `NotFoundError`). A blanket +catch that also swallows parse errors, permission errors, or our own bugs is a +masking fallback and must be split: expected-absence → degrade; anything else → +surface / rethrow. + +## Findings & fixes + +- [ ] `persistence/FileSystemProjectStore.ts:327` — per-asset read failure on + **load** silently dropped → scene reloads with missing models, no trace. + Fix: the manifest lists the asset, so a read failure is real → **throw**. +- [ ] `FileSystemProjectStore.ts:305/315` — `loadAssets` conflates "no asset + subdir / no manifest" (legit empty → `[]`) with read/parse failure. + Fix: only `NotFoundError` → `[]`; parse error / other → **throw**. +- [ ] `FileSystemProjectStore.ts:149` — `list()` swallows unreadable/malformed + project files with no log. Fix: **`console.error` with the filename** + (keep listing the rest; one bad file must not hide the others, but it + must be visible). +- [ ] `persistence/ossSceneSave.ts:63` — `persistProjectAssets` failure + swallowed; save still reports "Saved" while binary assets were lost. + Fix: **propagate**; surface as a save failure (error toast + + `sceneSaveFailed`), do not report success. +- [ ] `agent/script-tool/importHandler.ts:360` — `getAsset` failure for an + asset we just matched in-scene → silently uses a **stale** scene-pinned + revision (stale-parent merge can drop edits). Fix: log loudly; only fall + back on genuine not-found, rethrow otherwise. +- [ ] `editor/.../useTerminal.ts` — unresolved import file (e.g. skybox) now + logs an error entry but the run still reports overall success. Fix: an + unresolved import **fails the run** (surfaced result), so an incomplete + import can never masquerade as a clean one. + +## Validation + +- [x] `bun run typecheck` — clean. +- [x] `bun run lint` — 0 errors (pre-existing `any` warnings only). +- [x] `bun run test` (Vitest) — 2488 passed (+5 new): `FileSystemProjectStore.loadAssets` + throws on corrupt manifest / missing asset (3 tests) and returns the + recorded assets on a valid manifest; `ossSaveScene` reports a save FAILURE + (not success) when asset persistence throws. +- [x] Playwright: **`oss-import-fallback-verify.mjs` — PASSES 12/12** end-to-end + on a light real game (small-world) in ~2 min. Asserts the hardened paths + behave honestly: `no-batch-import-dialog`, `import-no-failed-commands` + (`failCount===0`), `models-present`, and the crux **`assets-survive-reload`** + (mesh count preserved across save→reload — a live check that + `loadAssets`/`ossSaveScene` don't silently drop/swallow). Also strengthened + `oss-pirate-ship-playground.mjs` (same assertions + dialog guard); it + confirms `no-batch-import-dialog` but the heaviest game's full import + exceeds the timeout due to import perf (task #10), so the *fast* verify + smoke is the authoritative end-to-end Playwright check. +- [ ] Manual code review. + +## Per-import timeout (added) + +A single `processImportedFile` could previously hang the whole run with no +surfaced error — the ultimate "failure that never surfaces". Each import is now +raced against a 90s ceiling (`withImportTimeout` in `useTerminal.ts`); on +timeout it is reported as a failed result (named culprit logged via +`[import-timeout]`) and the loop continues. Directive-aligned: a hang now +surfaces loudly instead of spinning forever. + +## RESOLVED: pirate-ship import hang — root cause found + fixed + +- **Root cause:** `showBatchImportDialog` (`ImportBatchDialog.ts`) is a bare + `new Promise` that only resolves on a user button click — no timeout, no + headless escape. It opened because 4 of the 5 `PIR_Water.png` texture imports + did not auto-resolve: the generator emitted duplicate textures as + `PIR_Water.png-2 … -5` (extension `.png-2`), and `autoResolveImports` filtered + candidate files by extension **before** matching the explicit `filepath`, + dropping those odd-extension files. Unresolved → blocking modal → headless + `__stemRunScript` hung forever (the 20-min "timeout" was that hang). +- **Fix 1 (`ImportBatchDialog.ts`):** an explicit `filepath` now matches against + the FULL file list, not the extension-filtered subset. The filepath is already + precise, so the ext guard only ever caused false misses. Regression test: + `ImportBatchDialog.test.ts` (odd-extension filepath resolves; one file backs + several imports). +- **Fix 2 (`oss-pirate-ship-playground.mjs`):** the smoke now polls instead of + blocking inside `evaluate`, asserts `no-batch-import-dialog`, and dismisses the + dialog if it ever appears — so a future auto-resolution failure fails the test + loudly in seconds instead of hanging it for 20 minutes. +- **Also found:** the running dev server had been serving a STALE bundle all + session (none of the edited code executed in the page) until `bun run dev` was + restarted — that is why browser verification looked broken earlier. Unit tests + (no browser) were always authoritative. + +## Project-data fix (PIR_Water textures) + +The 5 `PIR_Water` imports referenced `PIR_Water.png`, `…png-2 … png-5`. The +`-N` files are **5 distinct, valid PNGs** (different md5/size — water frames), +just saddled with a malformed extension suffix. Renamed on disk to +`PIR_Water_2.png … _5.png` and updated the stemscript `filepath=` lines. So the +data is clean AND the resolver is robust (two independent layers). + +## Residual: import is slow (NOT hung, NOT in scope of the fallback work) + +After the dialog fix the import progresses steadily — object count climbs +90→189 over ~4 min (~2.4s/object) and keeps going; it does not freeze. The +heaviest games (113 imports + hundreds of placement commands, each firing +`objectChanged`/scene reprocessing) take 10–15+ min. This is a performance +matter, separate from "masking fallbacks". Options if pursued: batch/defer +`objectChanged` during bulk script import; the smoke would then complete and its +`failCount===0` / `skybox-object-present` / play assertions can be verified. + +## Notes + +- The strengthened smoke surfaced that the pirate-ship import **hangs** (object + count freezes at ~90 of ~101, then the 20-min outer timeout). Confirmed a hang + (not slowness) — `objCount` frozen, observed repeatedly. +- Pinpointing the exact stall is **blocked by the dev environment**: Vite serves + the freshly-edited module (verified via curl: `withImportTimeout` present), but + the running page never executes the edited `runScript` (no injected + `[phase]`/`[import-diag]`/`[exec-progress]` log ever fires across 6+ + instrumented runs, even with `serviceWorkers: "block"`). Strong indication the + dev server (or its service worker, registered in `AppUpdateManager.tsx`) is + serving a **stale bundle**. **Action:** restart `bun run dev` to clear it, then + re-run the diagnostic (`scripts/playwright/diag-pirate-import-progress.mjs`), + which will name the hanging import via the per-import timeout. +- NOTE: this also means earlier browser-based smoke results this session may have + run against stale code. Unit tests (Vitest, no browser/SW) remain authoritative + and are green. diff --git a/docs/runtime-api.md b/docs/runtime-api.md new file mode 100644 index 00000000..5b5f7b36 --- /dev/null +++ b/docs/runtime-api.md @@ -0,0 +1,541 @@ +# Runtime API + +Behavior scripts access the engine through `this.erth`. Class-based engine code also has `this.stem` and `this.stemEngine` aliases, but the editor scripting runtime consistently exposes `this.erth`, so the public docs use that name. + +Use `this.erth` for normal gameplay work before dropping down to `this.game`. It is the stable author-facing layer for assets, runtime objects, shared state, behavior lookup, AI generation, events, and utility systems. + +## Top-level namespaces + +```ts +this.erth.ai +this.erth.asset +this.erth.camera +this.erth.object +this.erth.scene +this.erth.store +this.erth.behaviors +this.erth.lambdas +this.erth.events +this.erth.combat +this.erth.team +this.erth.pool +this.erth.tween +this.erth.fsm +this.erth.behaviorTree +this.erth.spatial +``` + +`tween`, `fsm`, `behaviorTree`, and `spatial` load their underlying libraries the first time you call them. Await the creator once during `init()` or `onStart()`, then use the returned handle from `update()`. + +## Assets + +Most asset methods work with an `AssetRef`: + +```ts +type AssetRef = { + assetId: string; + revisionId: string; +}; +``` + +Asset attributes selected in the editor usually already have this shape. You can also resolve scene assets by name. + +```ts +this.onStart = async function () { + const ref = await this.erth.asset.model.findByName("Enemy"); + if (!ref) return; + + const enemy = await this.erth.asset.model.createInstance(ref); + enemy.position.set(0, 1, -4); + await this.erth.scene.addObject(enemy); +}; +``` + +| Namespace | Methods | +|---|---| +| `asset.model` | `createFromUrl(params)`, `preload(ref)`, `createInstance(ref)`, `unload(ref)`, `findByName(name)` | +| `asset.stem` | `preload(ref)`, `createInstance(ref)`, `unload(ref)`, `findByName(name)` | +| `asset.image` | `createTexture(ref)`, `getUrl(ref)`, `findByName(name)` | +| `asset.audio` | `getUrl(ref)`, `getUrlByName(name)`, `findByName(name)` | +| `asset.video` | `getUrl(ref)`, `getUrlByName(name)`, `findByName(name)` | +| `asset.file` | `getUrl(ref)`, `getUrlByName(name)`, `findByName(name)` | +| `asset.script` | `getUrl(ref)`, `getUrlByName(name)`, `findByName(name)` | + +`asset.script.getUrl()` returns a `blob:` URL for a script asset and strips `@import` directives because those are only valid inside the behavior runtime. Use it for raw workers or standalone script loading. + +```ts +this.init = async function () { + const workerUrl = await this.erth.asset.script.getUrlByName("pathfinding-worker"); + this.worker = new Worker(workerUrl); +}; +``` + +The asset namespace also exposes management calls: + +```ts +await this.erth.asset.createAssetRelease(params); +await this.erth.asset.getAssetDerivatives({assetId, revisionId}); +await this.erth.asset.getMyAssets({types, includeLatestRelease: true}); +``` + +These methods exist in the engine, but they depend on the configured asset backend. In local playground projects, most behavior code should prefer scene asset refs, `findByName()`, and the loader methods above. + +## Objects, scene, and camera + +`erth.object.createFromThreeObject()` wraps a raw Three.js object as a `GameObject`. `erth.scene.addObject()` adds that wrapper to the running scene and initializes behaviors, lambdas, and physics. + +```ts +this.onStart = async function () { + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial({color: 0xff5533}), + ); + + const box = this.erth.object.createFromThreeObject(mesh); + box.position.set(0, 2, 0); + await this.erth.scene.addObject(box); +}; +``` + +`erth.camera` exposes the active camera position, orientation, projection planes, field of view, and `lookAt(x, y, z)`. + +```ts +this.update = function () { + this.erth.camera.lookAt(0, 1, 0); +}; +``` + +For lower-level object and physics details, see [GameObject and GameManager API](/docs/gameobject-and-game-manager-api). + +## Store + +The global store is a per-game-session key-value map shared by behaviors on the local client. + +```ts +this.erth.store.get(key); +this.erth.store.set(key, value); +this.erth.store.has(key); +this.erth.store.delete(key); +this.erth.store.keys(); +this.erth.store.size; +``` + +Important constraints: + +- The store is cleared when a game session starts. +- It has a hard limit of 128 keys. +- It is local to each client. It does not automatically synchronize multiplayer state. +- Store plain gameplay data, not Three.js objects, DOM elements, or functions. + +```ts +this.onStart = function () { + if (!this.erth.store.has("score")) { + this.erth.store.set("score", 0); + } +}; + +this.onEvent = function (msg, data) { + if (msg !== "coin.collected") return; + const current = this.erth.store.get("score") ?? 0; + this.erth.store.set("score", current + (data?.points ?? 1)); +}; +``` + +## Behaviors and lambdas + +Use `erth.behaviors` for cross-behavior lookup and attribute changes. + +```ts +const mover = this.erth.behaviors.find(this.target, "moving-platform"); +if (mover) { + await this.erth.behaviors.requestChange(mover, "speed", 4); +} +``` + +```ts +this.erth.behaviors.find(target, id); +this.erth.behaviors.findAll(id); +this.erth.behaviors.findOnObject(target); +this.erth.behaviors.getAttribute(behavior, key); +this.erth.behaviors.requestChange(behavior, key, value, options?); +``` + +Use `erth.lambdas` when a behavior needs to query or register objects with lambda instances. + +```ts +const systems = this.erth.lambdas.getInstancesByType("damage-system"); +const damageSystem = systems[0]; + +if (damageSystem) { + this.erth.lambdas.registerObject(damageSystem.uuid, this.target, { + health: 100, + armorType: "light", + }); +} +``` + +```ts +this.erth.lambdas.getInstance(instanceId); +this.erth.lambdas.getInstancesByType(lambdaId); +this.erth.lambdas.registerObject(instanceId, target, componentData?); +this.erth.lambdas.deregisterObject(instanceId, target); +this.erth.lambdas.getObjectLambdas(target); +``` + +## Events + +There are two event paths: + +1. Targeted behavior messages use `onEvent(msg, data)` on the receiver and `game.behaviorManager.sendEventToObjectBehaviors(target, msg, data)` on the sender. +2. Engine-wide topics use `this.erth.events.on(topic, callback)` and return an unsubscribe function. + +```ts +this.init = function (game) { + this.game = game; +}; + +this.onStart = function () { + this.stopScoreListener = this.erth.events.on("game.score", (msg, amount) => { + console.log("score event", msg, amount); + }); +}; + +this.dispose = function () { + this.stopScoreListener?.(); +}; +``` + +Engine event subscriptions are hierarchical. Subscribing to `game.score` receives `game.score.inc` and `game.score.dec`; the callback's first argument is the actual topic. + +Current engine topic groups include: + +| Group | Topics | +|---|---| +| Game state | `game.lives.inc`, `game.lives.dec`, `game.health.inc`, `game.health.dec`, `game.score.inc`, `game.score.dec`, `game.time.inc`, `game.time.dec`, `game.loginSuccess` | +| Enemy | `enemy.spawned`, `enemy.died`, `enemy.got.hit`, `enemy.state.changed`, `enemy.player.detected`, `enemy.player.lost`, `enemy.attack.started`, `enemy.attack`, `enemy.attack.ended` | +| Character motion | `character.motion.none`, `character.motion_start`, `character.motion`, `character.motion_end`, `character.motion.walk_start`, `character.motion.walk`, `character.motion.walk_end`, `character.motion.run_start`, `character.motion.run`, `character.motion.run_end` | +| Character action | `character.action.jump_start`, `character.action.jump`, `character.action.land`, `character.action.climb_start`, `character.action.climb`, `character.action.climb_end`, `character.action.crouch_start`, `character.action.crouch`, `character.action.crouch_end`, `character.action.fall_start`, `character.action.fall`, `character.action.fall_end`, `character.action.fall_back`, `character.action.dead`, `character.action.interact` | +| Animation | `character.animation.trigger`, `character.animation.stop`, `character.animation.complete` | +| Pickups and triggers | `consumable.in.range`, `consumable.not.in.range`, `consumable.collected`, `consumable.collided`, `jumppad.activated`, `platform.activated`, `platform.moving`, `platform.deactivated`, `volume.activated`, `randomized.spawner.activated`, `spawner.activated`, `teleport.activated` | +| NPC | `npc.interaction.started`, `npc.interaction.ended`, `npc.action.started`, `npc.action.ended` | +| Device and services | `device.orientation`, `gameServices.authenticated` | + +The global `EventBus` object is still injected for legacy scripts, but new behavior-to-behavior code should use `onEvent()` and `game.behaviorManager.sendEventToObjectBehaviors()`. + +## AI generation + +3D model generation is available through: + +```ts +await this.erth.ai.gen.generate3dModel({ + generationType: "text_to_model", + prompt: "low poly treasure chest", + generator: "meshy", + quality: "preview", + onProgress: (progress) => console.log(progress), +}); +``` + +Parameters include: + +```ts +{ + generationType: "text_to_model" | "image_to_model"; + prompt: string; + negativePrompt?: string; + url?: string; + fileToken?: string; + quality?: string; + modelVersion?: string; + generator?: "meshy" | "tripo"; + targetPolygonCount?: number; + autoRig?: boolean; + refine?: boolean; + onProgress?: (progress: number) => void; + onTaskCreated?: (taskId: string) => void; +} +``` + +The call returns `{taskId, modelUrl, thumbnailUrl}`. In the public playground, the user must configure the relevant provider key before generation can complete. + +## Combat, teams, and pooling + +`erth.combat` exposes reusable stat helpers: + +```ts +this.erth.combat.calculateDamage(attacker, target); +this.erth.combat.applyDamage(target, damage); +this.erth.combat.regenerateHealth(unit, deltaTime); +this.erth.combat.getAttackPriority(unit); +this.erth.combat.selectBestTarget(attackerPos, targets); +this.erth.combat.getDamageEffectiveness(damageType, armorType); +``` + +`erth.team` exposes friendly/enemy checks: + +```ts +this.erth.team.isEnemy(a, b); +this.erth.team.isFriendly(a, b); +this.erth.team.canAttack(attacker, target, friendlyFire?); +this.erth.team.findNearestEnemy(unit, allUnits, maxRange?); +this.erth.team.getEnemiesInRange(unit, allUnits, range); +``` + +`erth.pool.create(config)` builds a generic object pool: + +```ts +this.bulletPool = this.erth.pool.create({ + create: () => new THREE.Object3D(), + reset: (obj) => { + obj.visible = true; + }, + destroy: (obj) => { + obj.parent?.remove(obj); + }, + initialSize: 10, + maxSize: 100, +}); +``` + +## Tween + +`erth.tween.to(target, options)` animates numeric properties. Time values are seconds, matching behavior `update(deltaTime)`. + +```ts +this.onStart = async function () { + this.popIn = await this.erth.tween.to(this.gameObject.position, { + y: 5, + duration: 0.6, + easing: "Cubic.InOut", + autoStart: true, + }); +}; + +this.dispose = function () { + this.popIn?.stop(); +}; +``` + +Common handle methods: `start()`, `stop()`, `pause()`, `resume()`, `onComplete(cb)`, `onUpdate(cb)`, `delay(seconds)`, `repeat(count)`, `yoyo()`, `chain(...)`, `isPlaying()`. + +Use `this.erth.tween.killAll()` only when you intentionally want to stop every active engine tween. + +## Finite state machines + +`erth.fsm.create(config)` wraps XState v5 and returns an actor. + +```ts +this.init = async function () { + this.door = (await this.erth.fsm.create({ + id: "door", + initial: "closed", + context: {locked: false}, + states: { + closed: {on: {OPEN: {target: "open", guard: ({context}) => !context.locked}}}, + open: {on: {CLOSE: "closed"}}, + }, + })).start(); + + this.unsubscribeDoor = this.door.subscribe((snapshot) => { + console.log(snapshot.value, snapshot.context); + }); +}; + +this.dispose = function () { + this.unsubscribeDoor?.(); + this.door?.stop(); +}; +``` + +Actor methods: `start()`, `stop()`, `send(event)`, `snapshot()`, `subscribe(fn)`, `matches(statePath)`. + +## Behavior trees + +`erth.behaviorTree.create(definition, agent)` wraps mistreevous. Conditions and actions are looked up by method name on the agent object. + +```ts +this.init = async function () { + const agent = { + canSeePlayer: () => !!this.erth.behaviors.findAll("player.tag")[0], + attack: () => "SUCCEEDED", + patrol: () => "RUNNING", + }; + + this.tree = await this.erth.behaviorTree.create({ + type: "selector", + children: [ + {type: "sequence", children: [ + {type: "condition", call: "canSeePlayer"}, + {type: "action", call: "attack"}, + ]}, + {type: "action", call: "patrol"}, + ], + }, agent); +}; + +this.update = function () { + this.tree?.step(); +}; +``` + +Action return values are `"SUCCEEDED"`, `"FAILED"`, or `"RUNNING"`. + +## Spatial queries + +`erth.spatial.octree()` builds an octree for static scene geometry. + +```ts +this.init = async function (game) { + const levelRoot = game.scene.getObjectByName("Level"); + if (!levelRoot) return; + + this.octree = (await this.erth.spatial.octree()).fromGroup(levelRoot); +}; +``` + +Octree methods: + +```ts +octree.fromGroup(group); +octree.rayCast(ray); +octree.intersectSphere(sphere); +octree.intersectCapsule(capsule); +octree.getBox(); +``` + +`fromGroup()` walks mesh descendants and should be called when static world geometry changes, not every frame. + +## Patterns from real playground games + +These examples are condensed from working game projects and adjusted to the current author-facing API. They show how the APIs above tend to fit together in full behaviors. + +### Procedural runtime world builder + +Kenny Cars-style track builders create raw Three.js geometry, wrap it as a `GameObject`, add it to the scene, then publish spawn state for the player controller. + +```ts +this.onStart = async function () { + const root = new THREE.Group(); + root.name = "RuntimeTrack"; + root.userData.isRuntimeOnly = true; + + const road = new THREE.Mesh( + new THREE.BoxGeometry(24, 0.25, 80), + new THREE.MeshStandardMaterial({color: 0x30343a}), + ); + road.position.set(0, 0, -20); + road.userData.isRuntimeOnly = true; + root.add(road); + + const startGate = new THREE.Mesh( + new THREE.BoxGeometry(8, 4, 0.25), + new THREE.MeshStandardMaterial({color: 0xffcc33}), + ); + startGate.position.set(0, 2, 12); + startGate.userData.isRuntimeOnly = true; + root.add(startGate); + + const track = this.erth.object.createFromThreeObject(root); + await this.erth.scene.addObject(track); + + this.erth.store.set("race.spawn", { + position: {x: 0, y: 0.5, z: 10}, + yaw: Math.PI, + }); + this.erth.store.set("race.trackReady", true); +}; +``` + +This pattern is useful when authored scene data describes a course, puzzle, or arena, but the actual mesh layout is generated at runtime. + +### Asset-driven model, texture, and sound setup + +Rail shooters and chess games commonly let designers pick model/image/audio assets as behavior attributes, with name lookup as a fallback for template projects. + +```ts +this.onStart = async function () { + let shipRef = this.getAttribute("shipModel"); + if (!shipRef) { + shipRef = await this.erth.asset.model.findByName("Player Ship"); + } + + if (shipRef) { + const ship = await this.erth.asset.model.createInstance(shipRef); + const shipObject = ship._internal?.three ?? ship.target ?? ship; + shipObject.userData.isRuntimeOnly = true; + this.target.add(shipObject); + this.ship = shipObject; + } + + let reticleRef = this.getAttribute("reticleImage"); + if (!reticleRef) { + reticleRef = await this.erth.asset.image.findByName("Reticle"); + } + if (reticleRef) { + this.reticleTexture = await this.erth.asset.image.createTexture(reticleRef); + } + + let fireRef = this.getAttribute("fireSound"); + if (!fireRef) { + fireRef = await this.erth.asset.audio.findByName("LaserFire"); + } + if (fireRef) { + this.fireSoundUrl = await this.erth.asset.audio.getUrl(fireRef); + } +}; +``` + +Prefer passing an `AssetRef` into `createInstance()`, `createTexture()`, or `getUrl()`. `getUrlByName()` still exists for audio/video/file/script assets, but behavior attributes and `findByName()` keep the asset dependency explicit. + +### Store as a lightweight blackboard + +Vehicle games and HUD-heavy arcade games use `erth.store` to share numbers between independent behaviors without introducing hard references. Keep the values plain and overwrite them as state changes. + +```ts +// Vehicle controller behavior +this.update = function () { + this.erth.store.set("car.telemetry", { + speed: this.speed, + drift: this.driftAmount, + boost: this.boostActive, + }); +}; +``` + +```ts +// Audio or HUD behavior +this.update = function () { + const telemetry = this.erth.store.get("car.telemetry") ?? {}; + const speed = telemetry.speed ?? 0; + const drift = telemetry.drift ?? 0; + + this.speedText?.setProperties({text: `${Math.round(speed)} km/h`}); + this.engineGain = THREE.MathUtils.lerp(0.35, 1.0, Math.min(speed / 140, 1)); + this.driftGain = Math.min(drift, 1); +}; +``` + +For cross-client or authoritative state, use the multiplayer systems instead. The store is local to one running game session. + +### Engine topic subscription with cleanup + +Menu and lobby behaviors can react to engine topics such as `game.loginSuccess`, then tear down the subscription when the behavior is disposed. + +```ts +this.onStart = function () { + this.offLogin = this.erth.events.on("game.loginSuccess", (_topic, user) => { + this.erth.store.set("player.profile", { + id: user?.id, + name: user?.displayName ?? "Player", + }); + this.showLobby(); + }); +}; + +this.dispose = function () { + this.offLogin?.(); + this.offLogin = null; +}; +``` + +Use targeted behavior events for object-to-object gameplay messages. Use `erth.events.on()` for engine-wide topics. diff --git a/docs/uikit-api.md b/docs/uikit-api.md new file mode 100644 index 00000000..de0d9b50 --- /dev/null +++ b/docs/uikit-api.md @@ -0,0 +1,412 @@ +# UIKit API + +Behavior and lambda scripts receive two UIKit globals: + +```ts +UIKit +UIKitPointerEvents +``` + +Use UIKit for in-scene panels, diegetic UI, HUD overlays, labels, buttons, and interactive 3D controls. + +## Basic in-world panel + +```ts +let panel; +let scoreText; + +this.init = function (game) { + UIKitPointerEvents.initialize(game); +}; + +this.onStart = function () { + panel = new UIKit.Container({ + width: 220, + height: 80, + backgroundColor: 0x222222, + backgroundOpacity: 0.85, + borderRadius: 8, + padding: 12, + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + pointerEvents: "auto", + }); + + scoreText = new UIKit.Text({ + text: "Score: 0", + fontSize: 22, + color: 0xffffff, + }); + + panel.add(scoreText); + this.target.add(panel); + UIKitPointerEvents.registerRoot(panel); +}; + +this.update = function (deltaTime) { + UIKitPointerEvents.update(deltaTime); +}; + +this.dispose = function () { + if (panel) { + UIKitPointerEvents.unregisterRoot(panel); + panel.dispose(); + panel = null; + } + UIKitPointerEvents.deinitialize(); +}; +``` + +## Fullscreen HUD + +`UIKit.Fullscreen` should be parented to a camera. Use `game.uiCamera` when available, with `game.camera` as the fallback. + +```ts +let hud; + +this.init = function (game) { + this.game = game; + UIKitPointerEvents.initialize(game); +}; + +this.onStart = function () { + hud = new UIKit.Fullscreen(this.game.renderer, { + flexDirection: "column", + pointerEvents: "auto", + }); + + hud.add(new UIKit.Text({ + text: "Wave 1", + fontSize: 28, + color: 0xffffff, + })); + + const camera = this.game.uiCamera ?? this.game.camera; + camera.add(hud); + UIKitPointerEvents.registerRoot(hud); +}; + +this.update = function (deltaTime) { + UIKitPointerEvents.update(deltaTime); +}; + +this.dispose = function () { + if (hud) { + UIKitPointerEvents.unregisterRoot(hud); + hud.parent?.remove(hud); + hud.dispose(); + hud = null; + } + UIKitPointerEvents.deinitialize(); +}; +``` + +## Components + +### Container + +`UIKit.Container` is the layout building block. It supports fixed sizing, flexbox-style layout, backgrounds, borders, overflow, and pointer handlers. + +```ts +const button = new UIKit.Container({ + width: 120, + height: 40, + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + backgroundColor: 0x3344aa, + borderRadius: 6, + pointerEvents: "auto", + hover: {backgroundColor: 0x4455cc}, + active: {backgroundColor: 0x223388}, + onClick: () => console.log("clicked"), +}); +``` + +Common properties: + +```ts +width +height +backgroundColor +backgroundOpacity +borderRadius +borderWidth +borderColor +padding +paddingTop +paddingBottom +paddingLeft +paddingRight +margin +flexDirection +justifyContent +alignItems +gap +overflow +pointerEvents +hover +active +onClick +onPointerEnter +onPointerLeave +``` + +### Text + +```ts +const label = new UIKit.Text({ + text: "Ready", + fontSize: 24, + fontWeight: "bold", + color: 0xffffff, + opacity: 1, + textAlign: "center", + verticalAlign: "center", + lineHeight: 1.2, + maxLines: 2, +}); +``` + +### Image + +```ts +const icon = new UIKit.Image({ + src: "https://example.com/icon.png", + width: 48, + height: 48, + objectFit: "contain", + borderRadius: 6, +}); +``` + +### Input + +```ts +const nameInput = new UIKit.Input({ + value: "", + placeholder: "Name", + fontSize: 18, + color: 0xffffff, + backgroundColor: 0x222222, + borderRadius: 4, + padding: 8, + onValueChange: (value) => { + this.erth.store.set("player.name", value); + }, +}); +``` + +### Other components + +| Component | Use | +|---|---| +| `UIKit.Fullscreen` | Camera-attached viewport UI | +| `UIKit.Content` | Scrollable content inside a container | +| `UIKit.Svg` | SVG graphics | +| `UIKit.Video` | Video surfaces | + +## Updating properties + +Call `setProperties()` to update one or more properties. + +```ts +scoreText.setProperties({text: `Score: ${score}`}); + +button.setProperties({ + backgroundColor: disabled ? 0x555555 : 0x3344aa, + pointerEvents: disabled ? "none" : "auto", +}); +``` + +## Pointer events lifecycle + +`UIKitPointerEvents` is reference counted so multiple behaviors can use it at the same time. + +```ts +UIKitPointerEvents.initialize(game); +UIKitPointerEvents.registerRoot(root); +UIKitPointerEvents.update(deltaTime); +UIKitPointerEvents.unregisterRoot(root); +UIKitPointerEvents.deinitialize(); +``` + +Available methods: + +| Method | Use | +|---|---| +| `initialize(game)` | Store GameManager and increment the init reference count | +| `deinitialize()` | Decrement the init reference count | +| `registerRoot(component)` | Enable pointer events for a root component | +| `unregisterRoot(component)` | Remove a root component | +| `update(deltaTime?)` | Update pointer state and registered roots | +| `forceDispose()` | Force cleanup, bypassing reference counts | +| `isActive()` | True when pointer events are running with roots | +| `isInitialized()` | True when a game reference is present | +| `getRootCount()` | Number of registered roots | +| `getInitRefCount()` | Current initialization reference count | + +Always pair `initialize()` with `deinitialize()`, and `registerRoot()` with `unregisterRoot()`. + +## Common patterns + +### Health bar + +```ts +function createHealthBar(width, height, health, maxHealth) { + const root = new UIKit.Container({ + width, + height, + backgroundColor: 0x333333, + borderRadius: 4, + overflow: "hidden", + }); + + const fill = new UIKit.Container({ + width: (health / maxHealth) * width, + height: "100%", + backgroundColor: health > 30 ? 0x44aa44 : 0xaa4444, + }); + + root.add(fill); + return {root, fill}; +} +``` + +### Button with text + +```ts +const button = new UIKit.Container({ + width: 140, + height: 44, + justifyContent: "center", + alignItems: "center", + backgroundColor: 0x224488, + borderRadius: 6, + pointerEvents: "auto", + onClick: () => this.game.behaviorManager.sendEventToObjectBehaviors(this.target, "menu.start"), +}); + +button.add(new UIKit.Text({ + text: "Start", + fontSize: 18, + color: 0xffffff, +})); +``` + +## Patterns from real playground games + +These examples are adapted from UIKit-heavy games such as puzzle boards, title screens, lobbies, and combat HUDs. + +### Dual play/editor UIKit root + +The built-in `uikit-dual-mode` script is useful when a HUD should render both in the editor preview and in play mode. Import it from a behavior script, build your UI once, then let the helper attach and update the root. + +```ts +@import "uikit-dual-mode" as uikit; + +this.init = function (game) { + this.game = game; + this._uikitCtx = uikit.createPlayContext(game); + this._uiRoot = uikit.buildRoot(this._uikitCtx, { + pointerEvents: "auto", + }); + + this.buildHud(); + uikit.attach(this, this._uikitCtx); +}; + +this.buildHud = function () { + this.scoreText = new UIKit.Text({ + text: "Score 0", + fontSize: 28, + color: 0xffffff, + }); + this._uiRoot.add(this.scoreText); +}; + +this.update = function (deltaTime) { + uikit.tick(this, deltaTime); + const score = this.erth.store.get("score") ?? 0; + this.scoreText.setProperties({text: `Score ${score}`}); +}; + +this.dispose = function () { + uikit.teardown(this); +}; +``` + +For editor preview support, add `onEditorAdded`, `onEditorAttributesUpdated`, and `onEditorDispose` using the helper's `createEditorContext()` and `teardown()` methods. + +### HUD button that drives gameplay + +Chess and lobby screens use UIKit buttons to send targeted behavior events back into game logic. This keeps UI code from directly mutating board or match state. + +```ts +this.createHudButton = function (label, eventName, payload) { + const button = new UIKit.Container({ + width: 160, + height: 44, + justifyContent: "center", + alignItems: "center", + backgroundColor: 0x26334d, + borderRadius: 6, + pointerEvents: "auto", + hover: {backgroundColor: 0x314263}, + active: {backgroundColor: 0x1b2538}, + onClick: () => { + this.game.behaviorManager.sendEventToObjectBehaviors( + this.target, + eventName, + payload ?? {}, + ); + }, + }); + + button.add(new UIKit.Text({ + text: label, + fontSize: 18, + color: 0xffffff, + })); + + return button; +}; + +this._uiRoot.add(this.createHudButton("Reset", "resetGame")); +this._uiRoot.add(this.createHudButton("Promote", "promotePiece", {piece: "queen"})); +``` + +The receiver implements `onEvent(msg, data)` in the gameplay behavior. + +### Input and UIKit in the same puzzle loop + +Puzzle games often support both UI clicks and keyboard/gamepad input. Keep input reads in one method, then let the game update decide what state changes are legal. + +```ts +this.readControls = function () { + const input = this.game.inputManager; + const lateral = input.getMotion("lateral"); + + return { + left: input.getAction("drop7_Left") || lateral < -0.5, + right: input.getAction("drop7_Right") || lateral > 0.5, + drop: input.getAction("drop7_Drop") || + input.getAction("jump") || + input.getAction("use"), + restart: input.getAction("drop7_Restart"), + }; +}; + +this.update = function (deltaTime) { + UIKitPointerEvents.update(deltaTime); + + const controls = this.readControls(); + if (controls.left) this.moveCursor(-1); + if (controls.right) this.moveCursor(1); + if (controls.drop) this.dropPiece(); + if (controls.restart) this.resetBoard(); +}; +``` + +For pointer interactions, put `onClick` handlers on the relevant `UIKit.Container` cells or buttons. For continuous input, poll `game.inputManager` in `update()`. diff --git a/package.json b/package.json index 9352cbaf..1c638acc 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,6 @@ "lint:oss-boundary": "cd client && eslint --config eslint.boundary.cjs --no-inline-config --no-warn-ignored --quiet 'packages/editor-oss/src/**/*.{ts,tsx}'", "test": "BUILD_MODE=oss bunx --bun vitest run", "test:e2e": "node scripts/playwright/oss-smoke.mjs", - "test:e2e:import": "node scripts/playwright/oss-import-3dchess.mjs", - "test:e2e:stemscript-play-remix": "node scripts/playwright/oss-stemscript-play-remix-assets.mjs", "test:e2e:site": "node scripts/playwright/site-deploy-routing.mjs && node scripts/playwright/site-landing.mjs && node scripts/playwright/site-docs.mjs && node scripts/playwright/site-nav.mjs && node scripts/playwright/site-playground.mjs", "test:e2e:site:routing": "node scripts/playwright/site-deploy-routing.mjs", "deploy": "./scripts/deploy.sh", diff --git a/scripts/playwright/diag-pirate-import-progress.mjs b/scripts/playwright/diag-pirate-import-progress.mjs new file mode 100644 index 00000000..866f1b39 --- /dev/null +++ b/scripts/playwright/diag-pirate-import-progress.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** + * DIAGNOSTIC (not a smoke): is the pirate-ship import steadily slow, or stuck + * on one command? Bootstraps playground + OPFS folder store, opens Copilot to + * expose __stemRunScript, fires the import WITHOUT awaiting, then polls the + * `[exec-progress] current/total (+dt) line` console breadcrumb + live object + * count for ~3.5 minutes. Steadily-rising current → slow; frozen current → + * stuck (the printed line is the culprit command). + * + * bun run dev must be up on PLAYWRIGHT_BASE_URL (default localhost:5173). + */ +import {chromium} from "playwright"; +import {readFileSync, readdirSync, statSync} from "node:fs"; +import {join} from "node:path"; + +const baseUrl = (process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173").replace(/\/$/, ""); +const gameFolder = process.env.GAME_FOLDER || "/Users/n/erth/Games-StemScript/Pirate-Ship-Battle-Royal-v1.0"; +const OBSERVE_MS = Number(process.env.OBSERVE_MS || 210000); // ~3.5 min +const POLL_MS = 5000; + +const MIME = {".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".bin": "application/octet-stream", ".json": "application/json", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".hdr": "image/vnd.radiance", ".yaml": "text/yaml", ".yml": "text/yaml", ".stemscript": "text/plain"}; +const mimeFor = n => MIME[(n.match(/\.[^.]+$/) || [""])[0].toLowerCase()] || "application/octet-stream"; + +function walk(root) { + const out = []; + const rec = (dir, prefix) => { + for (const e of readdirSync(dir)) { + if (e === ".DS_Store") continue; + const abs = join(dir, e); + const rel = prefix ? `${prefix}/${e}` : e; + if (statSync(abs).isDirectory()) rec(abs, rel); + else out.push({name: rel, abs}); + } + }; + rec(root, ""); + return out; +} + +const files = walk(gameFolder); +const scriptFile = files.find(f => f.name.endsWith(".stemscript")); +const scriptContent = readFileSync(scriptFile.abs, "utf8"); +const folderFiles = files.filter(f => f !== scriptFile) + .map(f => ({name: f.name, mime: mimeFor(f.name), data: readFileSync(f.abs).toString("base64")})); +console.log(`[diag] folder=${gameFolder} files=${files.length} script=${scriptFile.name}`); + +const progress = []; // {t, current, total, dt, line} +const PROG_RE = /\[exec-progress\] (\d+)\/(\d+) \(\+(\d+)ms\) (.*)/; +const importDiag = []; // raw [import-diag] lines +const DIAG_RE = /\[import-diag\] (START|DONE) +#(\d+)(.*)/; + +const browser = await chromium.launch({headless: process.env.HEADED !== "1"}); +// Block the app's service worker — a stale SW cache otherwise serves an old +// bundle, defeating any code change under test. +const page = await (await browser.newContext({viewport: {width: 1440, height: 900}, serviceWorkers: "block"})).newPage(); +const startWall = Date.now(); +page.on("console", m => { + const t = m.text(); + if (process.env.VERBOSE && (Date.now() - startWall) < (Number(process.env.VERBOSE_MS) || 40000)) { + console.log(` [console.${m.type()}] ${t.slice(0, 160)}`); + } + const mm = PROG_RE.exec(t); + if (mm) progress.push({t: Date.now() - startWall, current: +mm[1], total: +mm[2], dt: +mm[3], line: mm[4].slice(0, 80)}); + const dm = DIAG_RE.exec(t); + if (dm) { importDiag.push(t); console.log(` [${((Date.now() - startWall) / 1000).toFixed(0)}s]`, t.slice(0, 150)); } + if (/\[RUNSCRIPT-ENTRY\]|\[phase\]|\[exec-progress\]|\[import-skip\]|\[import-timeout\]|\[ScriptImport\] getAsset/.test(t)) console.log(` >> [${((Date.now() - startWall) / 1000).toFixed(0)}s]`, t.slice(0, 200)); +}); + +const bootstrapFS = async p => p.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry("stem-fs", {recursive: true}); } catch { /* first run */ } + const fsRoot = await root.getDirectoryHandle("stem-fs", {create: true}); + await new Promise((res, rej) => { + const req = indexedDB.open("stemstudio-fs-handle", 1); + req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains("handles")) db.createObjectStore("handles"); }; + req.onsuccess = () => { const tx = req.result.transaction("handles", "readwrite"); tx.objectStore("handles").put(fsRoot, "project-dir"); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }; + req.onerror = () => rej(req.error); + }); + localStorage.setItem("stemstudio.persistence.mode", "filesystem"); + localStorage.setItem("stemstudio.bootstrap.complete", "true"); +}); +const dismissBootstrap = async () => { + const bs = page.locator('[aria-labelledby="oss-bootstrap-title"]').first(); + if (await bs.count() && await bs.isVisible().catch(() => false)) { + await bs.locator('button:has-text("Browser storage")').first().click({timeout: 3000}).catch(() => {}); + await bs.locator('button:has-text("Continue")').first().click({timeout: 5000}).catch(() => {}); + await page.waitForTimeout(400); + } +}; +const dismissTutorial = async () => { + const g = page.locator('button:has-text("Got It")').first(); + if (await g.count() && await g.isVisible().catch(() => false)) { await g.click({timeout: 3000}).catch(() => {}); await page.waitForTimeout(300); } +}; + +await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 30000}); +await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); +await dismissBootstrap(); +await bootstrapFS(page); +await page.goto(baseUrl + "/create/project", {waitUntil: "domcontentloaded", timeout: 30000}); +await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); +await dismissBootstrap(); +await page.waitForTimeout(6000); +await dismissTutorial(); +const copilotBtn = page.locator('[data-testid="actionbar-copilot"]').first(); +const cBox = await copilotBtn.boundingBox().catch(() => null); +if (cBox) await page.mouse.click(cBox.x + cBox.width / 2, cBox.y + cBox.height / 2); +else await copilotBtn.click({timeout: 3000, force: true}).catch(() => {}); +await page.waitForTimeout(2000); +const hookPresent = await page.evaluate(() => typeof window.__stemRunScript === "function"); +console.log(`[diag] hook present=${hookPresent}`); +if (!hookPresent) { await browser.close(); process.exit(1); } + +// Fire WITHOUT awaiting — we only observe progress. +await page.evaluate(({content, fileList}) => { + window.__diagDone = null; + window.__stemRunScript(content, fileList).then(s => { window.__diagDone = {ok: true, summary: s}; }, e => { window.__diagDone = {ok: false, err: String(e && e.message || e)}; }); +}, {content: scriptContent, fileList: folderFiles}); + +const deadline = Date.now() + OBSERVE_MS; +let lastMarker = "", lastChangeT = Date.now(); +while (Date.now() < deadline) { + await page.waitForTimeout(POLL_MS); + const objCount = await page.evaluate(() => (window.__stemGetScene ? window.__stemGetScene().objectCount : -1)).catch(() => -2); + const dialogUp = await page.evaluate(() => /Import Assets \(/.test(document.body?.innerText || "")).catch(() => false); + if (dialogUp) console.log(` >>> [${((Date.now() - startWall) / 1000).toFixed(0)}s] BATCH IMPORT DIALOG IS OPEN — runScript is blocked awaiting a user click (headless = infinite hang).`); + const done = await page.evaluate(() => window.__diagDone).catch(() => null); + const marker = `${objCount}|${(importDiag[importDiag.length - 1] || "")}|${(progress.length ? progress[progress.length - 1].current : "")}`; + if (marker !== lastMarker) { lastMarker = marker; lastChangeT = Date.now(); } + const stalledMs = Date.now() - lastChangeT; + const tSec = ((Date.now() - startWall) / 1000).toFixed(0); + const lastDiag = importDiag[importDiag.length - 1] || "(no import-diag yet)"; + console.log(`[diag t=${tSec}s] objCount=${objCount} stalledFor=${(stalledMs / 1000).toFixed(0)}s lastDiag="${lastDiag.replace(/^.*\[import-diag\] /, "").slice(0, 90)}"`); + if (done) { console.log(`[diag] run resolved: ${JSON.stringify(done)}`); break; } + // Don't break before the in-app per-import timeout (90s) has a chance to + // fire its [import-timeout] culprit line (~110s wall). Give it headroom. + if (stalledMs > 140000) { console.log(`[diag] >>> STUCK ${(stalledMs / 1000).toFixed(0)}s (no [import-timeout] seen — unexpected).`); break; } +} + +// Summary: slowest commands. +const byDt = [...progress].sort((a, b) => b.dt - a.dt).slice(0, 12); +console.log("\n[diag] slowest commands (dt ms):"); +for (const p of byDt) console.log(` +${p.dt}ms @${(p.t / 1000).toFixed(0)}s ${p.current}/${p.total} ${p.line}`); +console.log(`\n[diag] total progress events=${progress.length}, reached=${lastCurrent}/${progress[0] ? progress[progress.length - 1].total : "?"}`); +await browser.close(); +console.log("DIAG_DONE"); diff --git a/scripts/playwright/oss-all-games-playground.mjs b/scripts/playwright/oss-all-games-playground.mjs new file mode 100644 index 00000000..da4a1735 --- /dev/null +++ b/scripts/playwright/oss-all-games-playground.mjs @@ -0,0 +1,362 @@ +#!/usr/bin/env node +/** + * Playground smoke for EVERY stemscript game under GAMES_ROOT. + * + * For each game folder that contains a `.stemscript`, this: + * 1. activates playground mode (sessionStorage flag via ?mode=playground), + * 2. boots a fresh project and imports the game via window.__stemRunScript, + * 3. saves, reloads through the dashboard (the scene/v2 load path), + * 4. enters Play and clicks START GAME (#startGameBtn) if present, + * 5. screenshots each phase and audits console/page errors. + * + * A game PASSES when there are no uncaught page errors and no + * "exception-like" console errors (TypeError/ReferenceError/Cannot read/…, + * including BehaviorPluginManager onEditor* errors). Known-noisy lines + * (THREE deprecations, WebGL shader warnings, [Violation], ResizeObserver) + * are recorded but do not fail a game. + * + * Each game runs in its own browser context (isolated IndexedDB/sessionStorage) + * and a failure in one game does not stop the others. + * + * Env: + * GAMES_ROOT default /Users/n/erth/Games-StemScript + * GAMES optional comma-list of folder names to restrict the run + * HEADED=1 watch the browser + * PLAYWRIGHT_BASE_URL default http://localhost:5173 + * Report → scripts/playwright/oss-all-games-playground-output/ + */ +import {chromium} from "playwright"; +import {writeFileSync, mkdirSync, readFileSync, readdirSync, statSync, existsSync} from "node:fs"; +import {dirname, resolve, basename, join} from "node:path"; +import {fileURLToPath} from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// STORE_MODE=filesystem exercises the File System Access (folder) project store +// instead of the default IndexedDB store. Output goes to a mode-specific dir so +// the two runs don't clobber each other. +const storeMode = process.env.STORE_MODE === "filesystem" ? "filesystem" : "indexeddb"; +const outRoot = resolve(__dirname, storeMode === "filesystem" + ? "oss-all-games-filesystem-output" + : "oss-all-games-playground-output"); +mkdirSync(outRoot, {recursive: true}); + +const baseUrl = (process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173").replace(/\/$/, ""); +const headed = process.env.HEADED === "1"; +const gamesRoot = process.env.GAMES_ROOT || "/Users/n/erth/Games-StemScript"; +const gamesFilter = (process.env.GAMES || "").split(",").map(s => s.trim()).filter(Boolean); + +// ---- file helpers ---- +function walkFiles(root) { + const out = []; + const recurse = (dir, prefix) => { + for (const entry of readdirSync(dir)) { + if (entry === ".DS_Store") continue; + const abs = join(dir, entry); + const rel = prefix ? `${prefix}/${entry}` : entry; + if (statSync(abs).isDirectory()) recurse(abs, rel); + else out.push({name: rel, abs}); + } + }; + recurse(root, ""); + return out; +} +function mimeFor(name) { + const l = name.toLowerCase(); + if (l.endsWith(".gltf")) return "model/gltf+json"; + if (l.endsWith(".glb")) return "model/gltf-binary"; + if (l.endsWith(".png")) return "image/png"; + if (l.endsWith(".jpg") || l.endsWith(".jpeg")) return "image/jpeg"; + if (l.endsWith(".webp")) return "image/webp"; + if (l.endsWith(".mp3")) return "audio/mpeg"; + if (l.endsWith(".wav")) return "audio/wav"; + if (l.endsWith(".ogg")) return "audio/ogg"; + if (l.endsWith(".mp4")) return "video/mp4"; + if (l.endsWith(".hdr")) return "image/vnd.radiance"; + if (l.endsWith(".exr")) return "image/x-exr"; + if (l.endsWith(".yaml") || l.endsWith(".yml")) return "application/x-yaml"; + if (l.endsWith(".json")) return "application/json"; + if (l.endsWith(".md") || l.endsWith(".txt") || l.endsWith(".stemscript")) return "text/plain"; + return "application/octet-stream"; +} + +// ---- error classification ---- +// Lines that are noisy-but-harmless in this engine; recorded, never fatal. +const NOISE = [ + /THREE\.\w+ has been deprecated/i, + /THREE\.Clock: This module has been deprecated/i, + /WebGLProgram: Shader Error/i, // shader warns surface as console.error + /Vertex shader is not compiled/i, + /\[Violation\]/i, + /ResizeObserver loop/i, + /favicon/i, + /Download the React DevTools/i, + /sceneFields|lastSaveTime is undefined/i, + /non-passive event listener/i, +]; +// Lines that indicate a genuine runtime exception / behavior failure. +const EXCEPTION = [ + /TypeError/i, + /ReferenceError/i, + /RangeError/i, + /is not a function/i, + /Cannot read properties of/i, + /Cannot access /i, + /is not defined/i, + /Unhandled|Uncaught/i, + /Error in onEditor\w+ for plugin/i, // BehaviorPluginManager lifecycle failures + /Initialisation error in/i, + /script (init|update|onStart|onEvent)/i, +]; +const isNoise = t => NOISE.some(r => r.test(t)); +const isException = t => !isNoise(t) && EXCEPTION.some(r => r.test(t)); + +// ---- discover games ---- +function discoverGames() { + const games = []; + for (const entry of readdirSync(gamesRoot)) { + if (entry.startsWith(".")) continue; + const dir = join(gamesRoot, entry); + if (!statSync(dir).isDirectory()) continue; + if (gamesFilter.length && !gamesFilter.includes(entry)) continue; + const files = walkFiles(dir); + const scriptFile = files.find(f => f.name.toLowerCase().endsWith(".stemscript")); + if (scriptFile) games.push({name: entry, dir, files, scriptFile}); + } + return games.sort((a, b) => a.name.localeCompare(b.name)); +} + +const games = discoverGames(); +console.log(`Discovered ${games.length} games under ${gamesRoot}${gamesFilter.length ? ` (filtered: ${gamesFilter.join(",")})` : ""}`); +if (!games.length) { console.error("No games found"); process.exit(1); } + +console.log(`Project store mode: ${storeMode}`); +const browser = await chromium.launch({headless: !headed}); +const summary = {baseUrl, gamesRoot, storeMode, startedAt: new Date().toISOString(), games: []}; + +// Bootstrap the File System Access (folder) store via OPFS so no directory +// picker is needed. Each browser context has its own OPFS partition, so a fresh +// "stem-fs" folder per game keeps them isolated. Must run on the origin before +// the navigation that boots the editor (rehydrateProjectStore reads these). +const bootstrapFilesystemStore = async (page) => { + await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry("stem-fs", {recursive: true}); } catch { /* first run */ } + const fsRoot = await root.getDirectoryHandle("stem-fs", {create: true}); + await new Promise((res, rej) => { + const req = indexedDB.open("stemstudio-fs-handle", 1); + req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains("handles")) db.createObjectStore("handles"); }; + req.onsuccess = () => { const tx = req.result.transaction("handles", "readwrite"); tx.objectStore("handles").put(fsRoot, "project-dir"); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }; + req.onerror = () => rej(req.error); + }); + localStorage.setItem("stemstudio.persistence.mode", "filesystem"); + localStorage.setItem("stemstudio.bootstrap.complete", "true"); + }); +}; + +// dialog/modal helpers parameterised by page +const mkHelpers = (page) => ({ + dismissBootstrap: async () => { + const bs = page.locator('[aria-labelledby="oss-bootstrap-title"]').first(); + if (await bs.count() && await bs.isVisible().catch(() => false)) { + await bs.locator('button:has-text("Browser storage")').first().click({timeout: 3000}).catch(() => {}); + await bs.locator('button:has-text("Continue")').first().click({timeout: 5000}).catch(() => {}); + await page.waitForSelector('[aria-labelledby="oss-bootstrap-title"]', {state: "detached", timeout: 5000}).catch(() => {}); + } + }, + dismissTutorial: async () => { + const g = page.locator('button:has-text("Got It")').first(); + if (await g.count() && await g.isVisible().catch(() => false)) await g.click({timeout: 3000}).catch(() => {}); + }, +}); + +async function runGame(game) { + const outDir = resolve(outRoot, game.name); + mkdirSync(outDir, {recursive: true}); + const rec = { + name: game.name, script: basename(game.scriptFile.name), files: game.files.length, + steps: [], consoleErrors: [], pageErrors: [], failedRequests: [], assetErrors: [], + startClicked: false, canvasVisible: false, status: "pending", failReasons: [], + }; + const step = (s, ok = true, d) => { rec.steps.push({s, ok, d}); console.log(` ${ok ? "·" : "✗"} ${s}${d ? ` (${d})` : ""}`); }; + + const ctx = await browser.newContext({viewport: {width: 1440, height: 900}}); + const page = await ctx.newPage(); + const {dismissBootstrap, dismissTutorial} = mkHelpers(page); + // Asset-load failures surface as console WARNINGS (not errors), so the + // exception filter misses them. These mean the scene didn't load fully + // (missing models / skybox) — a hard fail for "loads perfectly". + const ASSET_FAIL = /No data URL found for|Failed to load model|Failed to load texture|failed to restore project assets|failed to persist project assets/i; + page.on("console", m => { + const t = m.text(); + if (m.type() === "error") rec.consoleErrors.push(t.slice(0, 400)); + if (ASSET_FAIL.test(t)) rec.assetErrors.push(t.slice(0, 300)); + }); + page.on("pageerror", e => { + // Capture the first stack frame too — it carries the behavior:// URL and + // line, which pinpoints which game behavior threw. + const stackFrame = (e.stack || "").split("\n").find(l => /behavior:\/\/|http/.test(l)) || ""; + rec.pageErrors.push(`${(e.message || String(e)).slice(0, 200)}${stackFrame ? ` @@ ${stackFrame.trim().slice(0, 200)}` : ""}`); + }); + page.on("requestfailed", r => { const u = r.url(); if (!/favicon|analytics|sentry/i.test(u)) rec.failedRequests.push(`${r.method()} ${u} :: ${r.failure()?.errorText}`); }); + + try { + const scriptContent = readFileSync(game.scriptFile.abs, "utf8"); + const folderFiles = game.files.filter(f => f !== game.scriptFile) + .map(f => ({name: f.name, mime: mimeFor(f.name), data: readFileSync(f.abs).toString("base64")})); + + // 1. activate playground + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + const pg = await page.evaluate(() => { try { return window.sessionStorage.getItem("stem.playgroundMode") === "1"; } catch { return false; } }); + step("playground activated", pg); + if (storeMode === "filesystem") { + await bootstrapFilesystemStore(page); + step("filesystem (OPFS) store bootstrapped"); + } + + // 2. fresh project + await page.goto(baseUrl + "/create/project", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(6000); + await dismissTutorial(); + + // 3. expose __stemRunScript via copilot, import + const copilotBtn = page.locator('[data-testid="actionbar-copilot"]').first(); + const cBox = await copilotBtn.boundingBox().catch(() => null); + if (cBox) await page.mouse.click(cBox.x + cBox.width / 2, cBox.y + cBox.height / 2); + else await copilotBtn.click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(2000); + const hookPresent = await page.evaluate(() => typeof window.__stemRunScript === "function"); + step("run-script hook exposed", hookPresent); + if (!hookPresent) throw new Error("__stemRunScript not exposed"); + + const execStartUrl = page.url(); + try { + await page.evaluate(({content, fileList}) => + window.__stemRunScript(content, fileList).then(() => { window.__d = "ok"; }, e => { window.__d = String(e && e.message ? e.message : e); }), + {content: scriptContent, fileList: folderFiles}); + } catch { /* exec may navigate */ } + await page.waitForLoadState("networkidle", {timeout: 120000}).catch(() => {}); + await page.waitForTimeout(6000); + await dismissTutorial(); + const execResult = await page.evaluate(() => window.__d ?? null).catch(() => null); + const execOk = execResult === "ok" || page.url() !== execStartUrl; + step("import exec", execOk, execResult ?? "no signal"); + await page.screenshot({path: resolve(outDir, "01-after-import.png")}).catch(() => {}); + + // 4. save + await page.locator('[data-testid="topnav-app-menu"]').first().click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(400); + const save = page.locator("text=Save Project").first(); + if (await save.isVisible().catch(() => false)) { + await save.click({timeout: 3000}).catch(() => {}); + // Filesystem (OPFS) saves write asset files then assets.json LAST and + // can take several seconds; reloading before the manifest is written + // loses every asset (skybox/models). Wait for the "Saved" toast, + // which the save handler emits only after assets are fully persisted. + // Heavy games (cubecity's ~21MB of building GLBs) take well over a + // minute to write to OPFS; wait generously for the "Saved" toast so + // the body + asset manifest are fully persisted before we reload. + await page.locator("text=/^Saved$/").first().waitFor({state: "visible", timeout: 180000}).catch(() => {}); + await page.waitForTimeout(1500); + } + const sceneId = (page.url().match(/\/create\/project\/([^/?#]+)/) || [])[1] || null; + step("saved", !!sceneId, sceneId ?? "no scene id"); + + // 5. reload via dashboard + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 20000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(2000); + if (sceneId) { + const card = page.locator(`[data-scene-id="${sceneId}"]`).first(); + // Poll for the card: the folder store's project list can take a + // moment to rehydrate from OPFS, especially right after a heavy save. + await card.waitFor({state: "attached", timeout: 30000}).catch(() => {}); + if (await card.count()) { + await card.click({timeout: 5000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 30000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForSelector("canvas", {timeout: 30000}).catch(() => {}); + await dismissTutorial(); + await page.waitForTimeout(8000); + } else step("project card found on dashboard", false, sceneId); + } + rec.canvasVisible = await page.locator("canvas").first().isVisible().catch(() => false); + step("editor reloaded, canvas visible", rec.canvasVisible); + await page.screenshot({path: resolve(outDir, "02-reloaded-editor.png")}).catch(() => {}); + + // mark: errors before play are "load" errors + const loadErrCount = rec.consoleErrors.length; + + // 6. enter Play + START GAME + const playBtn = page.locator('[data-testid="topnav-play"]').first(); + if (await playBtn.isVisible().catch(() => false)) { + await playBtn.click({timeout: 3000, force: true}).catch(() => {}); + const dontSave = page.locator("button", {hasText: /don['’]t\s*save/i}).first(); + if (await dontSave.count() && await dontSave.isVisible().catch(() => false)) { await dontSave.click().catch(() => {}); await page.waitForTimeout(500); } + await page.waitForTimeout(4000); + await page.screenshot({path: resolve(outDir, "03-play-pre-start.png")}).catch(() => {}); + + const startGame = page.locator("#startGameBtn").first(); + try { + await startGame.waitFor({state: "visible", timeout: 12000}); + await page.waitForFunction(() => { const b = document.getElementById("startGameBtn"); return b && !b.disabled && b.getAttribute("aria-disabled") !== "true"; }, {timeout: 12000}); + await startGame.click({timeout: 5000, force: true}); + rec.startClicked = true; + step("clicked START GAME"); + } catch { + step("no START GAME button (auto-start game)", true); + } + // Generous settle: runtime-generated games (procedural terrain, + // spawned troops) and FS-mode asset decode need time before the + // scene is fully populated for the screenshot / UIKit check. + await page.waitForTimeout(20000); + await page.screenshot({path: resolve(outDir, "04-playing.png")}).catch(() => {}); + } else { + step("Play button not visible", false); + rec.failReasons.push("play-button-missing"); + } + } catch (e) { + step("FATAL", false, (e.message || String(e)).slice(0, 200)); + rec.failReasons.push("fatal:" + (e.message || String(e)).slice(0, 160)); + } finally { + // classify errors + rec.exceptionErrors = [...new Set(rec.consoleErrors.filter(isException))].slice(0, 15); + rec.noiseErrorCount = rec.consoleErrors.filter(isNoise).length; + rec.assetErrors = [...new Set(rec.assetErrors)].slice(0, 15); + if (rec.pageErrors.length) rec.failReasons.push(`${rec.pageErrors.length} uncaught page error(s)`); + if (rec.exceptionErrors.length) rec.failReasons.push(`${rec.exceptionErrors.length} exception-like console error(s)`); + if (rec.assetErrors.length) rec.failReasons.push(`${rec.assetErrors.length} asset-load failure(s)`); + if (!rec.canvasVisible) rec.failReasons.push("canvas-not-visible"); + rec.status = rec.failReasons.length ? "FAIL" : "PASS"; + writeFileSync(resolve(outDir, "report.json"), JSON.stringify(rec, null, 2)); + await ctx.close(); + } + return rec; +} + +for (let i = 0; i < games.length; i++) { + console.log(`\n[${i + 1}/${games.length}] ▶ ${games[i].name}`); + const rec = await runGame(games[i]); + summary.games.push(rec); + const tag = rec.status === "PASS" ? "✅" : "❌"; + console.log(` ${tag} ${rec.name}: ${rec.status}${rec.failReasons.length ? " — " + rec.failReasons.join("; ") : ""}`); +} + +summary.finishedAt = new Date().toISOString(); +summary.passed = summary.games.filter(g => g.status === "PASS").map(g => g.name); +summary.failed = summary.games.filter(g => g.status === "FAIL").map(g => g.name); +writeFileSync(resolve(outRoot, "summary.json"), JSON.stringify(summary, null, 2)); + +console.log(`\n=================== SUMMARY ===================`); +console.log(`Passed: ${summary.passed.length}/${summary.games.length}`); +for (const g of summary.games) { + const tag = g.status === "PASS" ? "✅" : "❌"; + console.log(`${tag} ${g.name.padEnd(22)} start=${g.startClicked ? "Y" : "-"} canvas=${g.canvasVisible ? "Y" : "-"} exc=${g.exceptionErrors?.length ?? 0} page=${g.pageErrors.length} asset=${g.assetErrors?.length ?? 0} noise=${g.noiseErrorCount ?? 0}${g.failReasons.length ? " << " + g.failReasons.join("; ") : ""}`); +} +console.log(`\nReport dir: ${outRoot}`); +await browser.close(); +process.exit(summary.failed.length ? 1 : 0); diff --git a/scripts/playwright/oss-hud-replay-leak.mjs b/scripts/playwright/oss-hud-replay-leak.mjs new file mode 100644 index 00000000..f476bdf7 --- /dev/null +++ b/scripts/playwright/oss-hud-replay-leak.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * HUD replay diagnostic + regression test. + * + * Reproduces the reported bug: "fresh import + Play = fine, but Edit then Play + * again makes the HUD appear out of nowhere." Drives a fresh Solar System + * import, then runs two Play sessions with an Edit in between, probing at each + * step: + * - editor.showHUD (sceneConfig, from meta.showHud) + * - scene.userData.game.showHUD (settings-command field) + * - #hud-view-container count (leaked HUDManager root divs) + * - #hud-wrapper visible? (the actual rendered HUD) + * + * Assertions: showHUD stays false, the HUD wrapper never becomes visible, and + * HUDManager containers do not accumulate across Play sessions. + * + * Prereq: `bun run dev` on PLAYWRIGHT_BASE_URL (default localhost:5173). + * Set HEADED=1 to watch. Report → scripts/playwright/oss-hud-replay-leak-output/. + */ +import {chromium} from "playwright"; +import {writeFileSync, mkdirSync, readFileSync, readdirSync, statSync, existsSync} from "node:fs"; +import {dirname, resolve, basename, join} from "node:path"; +import {fileURLToPath} from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(__dirname, "oss-hud-replay-leak-output"); +mkdirSync(outDir, {recursive: true}); + +const baseUrl = (process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173").replace(/\/$/, ""); +const headed = process.env.HEADED === "1"; +const gameFolder = process.env.GAME_FOLDER || "/Users/n/erth/Games-StemScript/solar-system"; + +function walkFiles(root) { + const out = []; + const recurse = (dir, prefix) => { + for (const entry of readdirSync(dir)) { + if (entry === ".DS_Store") continue; + const abs = join(dir, entry); + const rel = prefix ? `${prefix}/${entry}` : entry; + if (statSync(abs).isDirectory()) recurse(abs, rel); + else out.push({name: rel, abs}); + } + }; + recurse(root, ""); + return out; +} +function mimeFor(name) { + const l = name.toLowerCase(); + if (l.endsWith(".png")) return "image/png"; + if (l.endsWith(".jpg") || l.endsWith(".jpeg")) return "image/jpeg"; + if (l.endsWith(".webp")) return "image/webp"; + if (l.endsWith(".yaml") || l.endsWith(".yml")) return "application/x-yaml"; + if (l.endsWith(".json")) return "application/json"; + if (l.endsWith(".md") || l.endsWith(".txt") || l.endsWith(".stemscript")) return "text/plain"; + return "application/octet-stream"; +} + +const report = {baseUrl, gameFolder, startedAt: new Date().toISOString(), probes: {}, assertions: {}, consoleErrors: []}; +const failures = []; +function assert(name, cond, detail) { + report.assertions[name] = {pass: !!cond, detail}; + console.log(cond ? `✓ assert: ${name}` : `✗ assert: ${name} — ${detail ?? ""}`); + if (!cond) failures.push(name); +} + +assert("game-folder-exists", existsSync(gameFolder), gameFolder); +if (failures.length) { writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); process.exit(1); } + +const files = walkFiles(gameFolder); +const scriptFile = files.find(f => f.name.toLowerCase().endsWith(".stemscript")); +if (!scriptFile) { console.error("no .stemscript"); process.exit(1); } +const scriptContent = readFileSync(scriptFile.abs, "utf8"); +const folderFiles = files.filter(f => f !== scriptFile) + .map(f => ({name: f.name, mime: mimeFor(f.name), data: readFileSync(f.abs).toString("base64")})); +console.log(`read ${files.length} files (${basename(scriptFile.name)})`); + +const browser = await chromium.launch({headless: !headed}); +const ctx = await browser.newContext({viewport: {width: 1440, height: 900}}); +const page = await ctx.newPage(); +page.on("console", m => { if (m.type() === "error") report.consoleErrors.push(m.text().slice(0, 200)); }); + +const dismissBootstrap = async () => { + const bs = page.locator('[aria-labelledby="oss-bootstrap-title"]').first(); + if (await bs.count() && await bs.isVisible().catch(() => false)) { + await bs.locator('button:has-text("Browser storage")').first().click({timeout: 3000}).catch(() => {}); + await bs.locator('button:has-text("Continue")').first().click({timeout: 5000}).catch(() => {}); + await page.waitForSelector('[aria-labelledby="oss-bootstrap-title"]', {state: "detached", timeout: 5000}).catch(() => {}); + } +}; +const dismissTutorial = async () => { + const g = page.locator('button:has-text("Got It")').first(); + if (await g.count() && await g.isVisible().catch(() => false)) await g.click({timeout: 3000}).catch(() => {}); +}; + +// Probe the live HUD state from the page. +const probe = async (label) => { + const data = await page.evaluate(() => { + const app = window.app || window.global?.app; + const editor = app?.editor; + const game = editor?.scene?.userData?.game; + const containers = document.querySelectorAll("#hud-view-container"); + const wrapper = document.getElementById("hud-wrapper"); + const startBtn = document.getElementById("startGameBtn"); + const wrapperVisible = !!wrapper && wrapper.offsetParent !== null && + wrapper.getBoundingClientRect().width > 0 && wrapper.getBoundingClientRect().height > 0; + return { + editorShowHUD: editor?.showHUD ?? null, + gameShowHUD: game?.showHUD ?? null, + isGame: game?.isGame ?? null, + isPlaying: app?.isPlaying ?? null, + hudContainerCount: containers.length, + hudWrapperPresent: !!wrapper, + hudWrapperVisible: wrapperVisible, + startGameBtnPresent: !!startBtn, + }; + }).catch(e => ({error: String(e)})); + report.probes[label] = data; + console.log(`· probe[${label}] ${JSON.stringify(data)}`); + await page.screenshot({path: resolve(outDir, `${label}.png`)}).catch(() => {}); + return data; +}; + +const enterPlay = async () => { + await page.locator('[data-testid="topnav-play"]').first().click({timeout: 5000, force: true}).catch(() => {}); + const dontSave = page.locator("button", {hasText: /don['’]t\s*save/i}).first(); + if (await dontSave.count() && await dontSave.isVisible().catch(() => false)) { await dontSave.click().catch(() => {}); } + await page.waitForTimeout(6000); +}; +const backToEdit = async () => { + await page.locator("button", {hasText: /^Edit$/}).first().click({timeout: 5000, force: true}).catch(() => {}); + await page.waitForTimeout(4000); +}; + +try { + await page.goto(baseUrl + "/create/project", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(6000); + await dismissTutorial(); + + // Open Copilot → expose __stemRunScript. + const copilotBtn = page.locator('[data-testid="actionbar-copilot"]').first(); + const cBox = await copilotBtn.boundingBox().catch(() => null); + if (cBox) await page.mouse.click(cBox.x + cBox.width / 2, cBox.y + cBox.height / 2); + else await copilotBtn.click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(2000); + const hookPresent = await page.evaluate(() => typeof window.__stemRunScript === "function"); + assert("run-script-hook-exposed", hookPresent, "no __stemRunScript"); + if (!hookPresent) throw new Error("no stemRunScript"); + + // Import. + try { + await page.evaluate(({content, fileList}) => + window.__stemRunScript(content, fileList).then(() => { window.__d = "ok"; }, e => { window.__d = String(e); }), + {content: scriptContent, fileList: folderFiles}); + } catch { /* navigation */ } + await page.waitForLoadState("networkidle", {timeout: 90000}).catch(() => {}); + await page.waitForTimeout(6000); + await dismissTutorial(); + assert("import-completed", await page.evaluate(() => window.__d === "ok").catch(() => false) || /create\/project\//.test(page.url()), "import did not complete"); + + await probe("00-edit-baseline"); + + // === Save the project. === + await page.locator('[data-testid="topnav-app-menu"]').first().click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(400); + const save = page.locator("text=Save Project").first(); + if (await save.isVisible().catch(() => false)) { await save.click({timeout: 3000}).catch(() => {}); await page.waitForTimeout(3500); } + const sceneId = (page.url().match(/\/create\/project\/([^/?#]+)/) || [])[1] || null; + assert("scene-id-resolved", !!sceneId, `URL: ${page.url()}`); + + // === Reload through the dashboard — this is the path that builds the + // scene DTO via scene/v2.ts, where `showHud` used to be hardcoded true. === + await page.goto(baseUrl + "/dashboard", {waitUntil: "domcontentloaded", timeout: 20000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(2000); + if (sceneId) { + const card = page.locator(`[data-scene-id="${sceneId}"]`).first(); + assert("imported-project-listed", (await card.count()) > 0, `data-scene-id="${sceneId}" not found`); + await card.click({timeout: 5000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 30000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForSelector("canvas", {timeout: 30000}).catch(() => {}); + await dismissTutorial(); + await page.waitForTimeout(8000); + } + const reloaded = await probe("01-reloaded-edit"); + + // === Play session #1 (post-reload) === + await enterPlay(); + const play1 = await probe("02-play-1"); + + // === Back to Edit, then Play again (the reported replay repro) === + await backToEdit(); + const edit1 = await probe("03-edit-after-play-1"); + await enterPlay(); + const play2 = await probe("04-play-2"); + + // The HUD start menu (#startGameBtn) and #hud-wrapper only render when + // showHUD is true. With showHud no longer hardcoded true on load, a + // non-HUD game must auto-start with no HUD chrome in either Play session. + assert("no-hud-startmenu-play1", play1.startGameBtnPresent !== true, `play1 startGameBtn present=${play1.startGameBtnPresent}`); + assert("no-hud-startmenu-play2", play2.startGameBtnPresent !== true, `play2 startGameBtn present=${play2.startGameBtnPresent}`); + assert("hud-not-visible-play1", play1.hudWrapperVisible !== true, `play1 hud-wrapper visible=${play1.hudWrapperVisible}`); + assert("hud-not-visible-play2", play2.hudWrapperVisible !== true, `play2 hud-wrapper visible=${play2.hudWrapperVisible}`); + // The HUD root is torn down on stop via game.reset() -> hud.clear(); it must + // not accumulate across Play sessions (a stale-root leak would grow the count). + assert("hud-containers-not-accumulating", (play2.hudContainerCount ?? 0) <= 1, + `container counts: reloaded=${reloaded.hudContainerCount}, play1=${play1.hudContainerCount}, edit1=${edit1.hudContainerCount}, play2=${play2.hudContainerCount}`); + console.log(`container counts: reloaded=${reloaded.hudContainerCount}, play1=${play1.hudContainerCount}, edit1=${edit1.hudContainerCount}, play2=${play2.hudContainerCount}`); +} catch (e) { + console.error("FATAL", e.message); + failures.push("fatal:" + e.message); +} finally { + report.finishedAt = new Date().toISOString(); + report.failures = failures; + writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); + console.log(`\n=== HUD replay report ===`); + console.log(`Assertions: ${Object.values(report.assertions).filter(a => a.pass).length}/${Object.keys(report.assertions).length} passed`); + console.log(`Probes:\n${JSON.stringify(report.probes, null, 2)}`); + console.log(`Output: ${outDir}`); + await browser.close(); + if (failures.length) { console.error(`\nFAIL: ${failures.join(", ")}`); process.exit(1); } +} diff --git a/scripts/playwright/oss-import-fallback-verify.mjs b/scripts/playwright/oss-import-fallback-verify.mjs new file mode 100644 index 00000000..0d19f0b4 --- /dev/null +++ b/scripts/playwright/oss-import-fallback-verify.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node +/** + * Playwright verification for the "no error-masking fallbacks" work. + * + * Drives a real stemscript game import end-to-end through the playground + + * File System (OPFS) store and asserts that the hardened fallback paths behave + * honestly — i.e. a failure cannot hide: + * + * 1. no-batch-import-dialog — every import auto-resolves; the blocking + * "Import Assets" modal (which hangs headless) + * never opens. Regression guard for the + * odd-extension filepath bug. + * 2. import-no-failed-commands — runScript's summary.failCount === 0; an + * unresolved/failed import would surface here. + * 3. models-present — the import actually created mesh content. + * 4. assets-survive-reload — after save → reload from the folder store, + * the mesh count is preserved. This is the live + * check on FileSystemProjectStore.loadAssets and + * ossSaveScene: if either silently dropped an + * asset / swallowed a persist failure, the + * reloaded scene would be missing geometry and + * this assertion fails loudly. + * + * Uses a LIGHT game by default (small-world: 6 models) so it completes quickly, + * independent of heavy-game import performance. Override with $GAME_FOLDER. + * + * bun run dev must be up on PLAYWRIGHT_BASE_URL (default localhost:5173). + * HEADED=1 to watch. Report → scripts/playwright/oss-import-fallback-verify-output/. + */ +import {chromium} from "playwright"; +import {writeFileSync, mkdirSync, readFileSync, readdirSync, statSync, existsSync} from "node:fs"; +import {dirname, resolve, basename, join} from "node:path"; +import {fileURLToPath} from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(__dirname, "oss-import-fallback-verify-output"); +mkdirSync(outDir, {recursive: true}); + +const baseUrl = (process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173").replace(/\/$/, ""); +const headed = process.env.HEADED === "1"; +const gameFolder = process.env.GAME_FOLDER || "/Users/n/erth/Games-StemScript/small-world"; +const IMPORT_TIMEOUT_MS = Number(process.env.IMPORT_TIMEOUT_MS || 8 * 60 * 1000); + +const MIME = {".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".bin": "application/octet-stream", ".json": "application/json", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".hdr": "image/vnd.radiance", ".yaml": "text/yaml", ".yml": "text/yaml", ".stemscript": "text/plain"}; +const mimeFor = n => MIME[(n.match(/\.[^.]+$/) || [""])[0].toLowerCase()] || "application/octet-stream"; + +function walk(root) { + const out = []; + const rec = (dir, prefix) => { + for (const e of readdirSync(dir)) { + if (e === ".DS_Store") continue; + const abs = join(dir, e); + const rel = prefix ? `${prefix}/${e}` : e; + if (statSync(abs).isDirectory()) rec(abs, rel); + else out.push({name: rel, abs}); + } + }; + rec(root, ""); + return out; +} + +const report = {baseUrl, gameFolder, startedAt: new Date().toISOString(), assertions: {}, steps: [], consoleErrors: []}; +const failures = []; +function assert(name, cond, detail) { + report.assertions[name] = {pass: !!cond, detail}; + if (cond) console.log(`✓ assert: ${name}`); + else { console.log(`✗ assert: ${name} — ${detail ?? ""}`); failures.push(name); } +} +function logStep(name, status = "ok", details = {}) { + report.steps.push({name, status, details}); + console.log(`${status === "ok" ? "✓" : status === "warn" ? "⚠" : "✗"} ${name}${Object.keys(details).length ? ` — ${JSON.stringify(details).slice(0, 160)}` : ""}`); +} + +assert("game-folder-exists", existsSync(gameFolder), gameFolder); +const files = existsSync(gameFolder) ? walk(gameFolder) : []; +const scriptFile = files.find(f => f.name.endsWith(".stemscript")); +assert("script-file-found", !!scriptFile, `no .stemscript in ${gameFolder}`); +if (!scriptFile) { writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); process.exit(1); } +const scriptContent = readFileSync(scriptFile.abs, "utf8"); +const folderFiles = files.filter(f => f !== scriptFile).map(f => ({name: f.name, mime: mimeFor(f.name), data: readFileSync(f.abs).toString("base64")})); +logStep("read game folder", "ok", {files: files.length, script: basename(scriptFile.name)}); + +const browser = await chromium.launch({headless: !headed}); +const page = await (await browser.newContext({viewport: {width: 1440, height: 900}, serviceWorkers: "block"})).newPage(); +page.on("console", m => { if (m.type() === "error") report.consoleErrors.push(m.text().slice(0, 300)); }); +page.on("pageerror", e => report.consoleErrors.push("pageerror: " + e.message.slice(0, 300))); + +const bootstrapFS = async p => p.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry("stem-fs", {recursive: true}); } catch { /* first run */ } + const fsRoot = await root.getDirectoryHandle("stem-fs", {create: true}); + await new Promise((res, rej) => { + const req = indexedDB.open("stemstudio-fs-handle", 1); + req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains("handles")) db.createObjectStore("handles"); }; + req.onsuccess = () => { const tx = req.result.transaction("handles", "readwrite"); tx.objectStore("handles").put(fsRoot, "project-dir"); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }; + req.onerror = () => rej(req.error); + }); + localStorage.setItem("stemstudio.persistence.mode", "filesystem"); + localStorage.setItem("stemstudio.bootstrap.complete", "true"); +}); +const dismissBootstrap = async () => { + const bs = page.locator('[aria-labelledby="oss-bootstrap-title"]').first(); + if (await bs.count() && await bs.isVisible().catch(() => false)) { + await bs.locator('button:has-text("Browser storage")').first().click({timeout: 3000}).catch(() => {}); + await bs.locator('button:has-text("Continue")').first().click({timeout: 5000}).catch(() => {}); + await page.waitForTimeout(400); + } +}; +const dismissTutorial = async () => { + const g = page.locator('button:has-text("Got It")').first(); + if (await g.count() && await g.isVisible().catch(() => false)) { await g.click({timeout: 3000}).catch(() => {}); await page.waitForTimeout(300); } +}; +const batchDialogOpen = async () => page.evaluate(() => /Import Assets \(/.test(document.body?.innerText || "")).catch(() => false); +const sceneState = async () => page.evaluate(() => (window.__stemGetScene ? window.__stemGetScene() : null)).catch(() => null); + +try { + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await bootstrapFS(page); + logStep("filesystem (OPFS) store bootstrapped"); + + await page.goto(baseUrl + "/create/project", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(6000); + await dismissTutorial(); + assert("filesystem-store-selected", + (await page.evaluate(() => { try { return localStorage.getItem("stemstudio.persistence.mode"); } catch { return null; } })) === "filesystem"); + + const copilotBtn = page.locator('[data-testid="actionbar-copilot"]').first(); + const cBox = await copilotBtn.boundingBox().catch(() => null); + if (cBox) await page.mouse.click(cBox.x + cBox.width / 2, cBox.y + cBox.height / 2); + else await copilotBtn.click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(2000); + const hookPresent = await page.evaluate(() => typeof window.__stemRunScript === "function"); + assert("run-script-hook-exposed", hookPresent); + if (!hookPresent) throw new Error("no __stemRunScript"); + + // Fire the import; poll for completion, dismissing (and flagging) the batch + // dialog if it ever appears so the run can never hang silently. + await page.evaluate(({content, fileList}) => { + window.__done = null; window.__summary = null; + window.__stemRunScript(content, fileList).then( + s => { window.__done = "ok"; window.__summary = s ?? null; }, + e => { window.__done = String(e && e.message ? e.message : e); }, + ); + }, {content: scriptContent, fileList: folderFiles}); + + let dialogSeen = false, done = null; + const deadline = Date.now() + IMPORT_TIMEOUT_MS; + while (Date.now() < deadline) { + done = await page.evaluate(() => window.__done).catch(() => null); + if (done) break; + if (await batchDialogOpen()) { + dialogSeen = true; + const skip = page.locator('button:has-text("Skip All")').first(); + if (await skip.count()) await skip.click({timeout: 2000, force: true}).catch(() => {}); + else await page.keyboard.press("Escape").catch(() => {}); + } + await page.waitForTimeout(2500); + } + await page.waitForLoadState("networkidle", {timeout: 30000}).catch(() => {}); + await dismissTutorial(); + + assert("exec-completed", done === "ok", `done=${done}`); + assert("no-batch-import-dialog", !dialogSeen, "batch-import dialog appeared (an import failed to auto-resolve)"); + + const summary = await page.evaluate(() => window.__summary).catch(() => null); + report.runSummary = summary; + assert("import-no-failed-commands", !!summary && summary.failCount === 0, `summary=${JSON.stringify(summary)}`); + + const afterImport = await sceneState(); + const meshAfterImport = afterImport?.meshCount ?? 0; + report.meshAfterImport = meshAfterImport; + assert("models-present", meshAfterImport > 0, `meshCount=${meshAfterImport}`); + await page.screenshot({path: resolve(outDir, "01-after-import.png")}).catch(() => {}); + + // === Save → reload → assert assets survived (the persistence fallback paths). === + await page.locator('[data-testid="topnav-app-menu"]').first().click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(400); + const save = page.locator("text=Save Project").first(); + if (await save.isVisible().catch(() => false)) { + await save.click({timeout: 3000}).catch(() => {}); + await page.locator("text=/^Saved$/").first().waitFor({state: "visible", timeout: 120000}).catch(() => {}); + await page.waitForTimeout(1500); + } + const sceneId = (page.url().match(/\/create\/project\/([^/?#]+)/) || [])[1] || null; + assert("scene-id-resolved", !!sceneId, `URL: ${page.url()}`); + + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 20000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(2000); + if (sceneId) { + const card = page.locator(`[data-scene-id="${sceneId}"]`).first(); + await card.waitFor({state: "attached", timeout: 30000}).catch(() => {}); + assert("imported-project-listed", (await card.count()) > 0, `data-scene-id="${sceneId}" not found`); + if (await card.count()) { + await card.click({timeout: 5000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 30000}).catch(() => {}); + await page.waitForTimeout(8000); + await dismissTutorial(); + } + } + const afterReload = await sceneState(); + const meshAfterReload = afterReload?.meshCount ?? 0; + report.meshAfterReload = meshAfterReload; + await page.screenshot({path: resolve(outDir, "02-after-reload.png")}).catch(() => {}); + // The crux: if loadAssets silently dropped an asset, or ossSaveScene + // swallowed a persist failure, the reloaded scene would be missing geometry. + // Allow tiny variance but require the bulk of meshes to survive. + assert("assets-survive-reload", meshAfterReload >= Math.floor(meshAfterImport * 0.9), + `meshAfterImport=${meshAfterImport} meshAfterReload=${meshAfterReload}`); + + assert("no-uncaught-page-errors", report.consoleErrors.filter(e => e.startsWith("pageerror")).length === 0, + report.consoleErrors.filter(e => e.startsWith("pageerror"))[0] ?? ""); +} catch (err) { + logStep("fatal", "fail", {error: String(err && err.message ? err.message : err).slice(0, 300)}); + failures.push("fatal"); +} finally { + report.finishedAt = new Date().toISOString(); + report.failures = failures; + writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); + await browser.close(); +} + +console.log(`\n${failures.length === 0 ? "✅ PASS" : "❌ FAIL"} — ${Object.values(report.assertions).filter(a => a.pass).length}/${Object.keys(report.assertions).length} assertions passed`); +if (failures.length) console.log("failed: " + failures.join(", ")); +console.log("FALLBACK_VERIFY_DONE"); +process.exit(failures.length ? 1 : 0); diff --git a/scripts/playwright/oss-pirate-ship-playground.mjs b/scripts/playwright/oss-pirate-ship-playground.mjs new file mode 100644 index 00000000..2b225b82 --- /dev/null +++ b/scripts/playwright/oss-pirate-ship-playground.mjs @@ -0,0 +1,397 @@ +#!/usr/bin/env node +/** + * End-to-end (PLAYGROUND + FILESYSTEM storage): import the Pirate Ship Battle + * Royal stemscript into a fresh project, save to the File System Access (folder) + * store, reload through the playground dashboard, enter Play, START GAME, then + * STOP play — auditing the console for the three regressions reported against + * this game: + * + * 1. `Image derivative missing dataUrl` — OceanSurface's PIR_Water base map + * never resolves because `fetchAssetImageDerivative` lacked the revision + * data-URL fallback that OSS (no integrated CDN) depends on. Fixed in + * editor-oss/.../hooks/assets.ts. + * 2. The OceanSurface texture not being applied (the visible symptom of #1). + * 3. `this._stopWakeSound is not a function` — thrown from the ShipController + * behavior's dispose() (and onReset) because the method was never defined, + * aborting that ship's teardown when play mode stops. Fixed in the game's + * behaviors/ShipController.yaml. Only surfaces on STOP, so the test must + * enter Play and then exit back to Edit to exercise BehaviorManager.dispose. + * + * Storage: filesystem (OPFS-backed File System Access store), matching the + * reporter's setup. No directory picker — each context gets its own OPFS + * partition seeded with a "stem-fs" handle (same trick as oss-all-games-playground). + * + * Source folder: GAME_FOLDER (defaults to the fixed Games-StemScript copy). + * Prereq: `bun run dev` on PLAYWRIGHT_BASE_URL (default localhost:5173). + * Set HEADED=1 to watch. Report → scripts/playwright/oss-pirate-ship-playground-output/. + */ +import {chromium} from "playwright"; +import {writeFileSync, mkdirSync, readFileSync, readdirSync, statSync, existsSync} from "node:fs"; +import {dirname, resolve, basename, join} from "node:path"; +import {fileURLToPath} from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(__dirname, "oss-pirate-ship-playground-output"); +mkdirSync(outDir, {recursive: true}); + +const baseUrl = (process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173").replace(/\/$/, ""); +const headed = process.env.HEADED === "1"; +const gameFolder = process.env.GAME_FOLDER || "/Users/n/erth/Games-StemScript/Pirate-Ship-Battle-Royal-v1.0"; + +function walkFiles(root) { + const out = []; + const recurse = (dir, prefix) => { + for (const entry of readdirSync(dir)) { + if (entry === ".DS_Store") continue; + const abs = join(dir, entry); + const rel = prefix ? `${prefix}/${entry}` : entry; + if (statSync(abs).isDirectory()) recurse(abs, rel); + else out.push({name: rel, abs}); + } + }; + recurse(root, ""); + return out; +} + +function mimeFor(name) { + const l = name.toLowerCase(); + if (l.endsWith(".gltf")) return "model/gltf+json"; + if (l.endsWith(".glb")) return "model/gltf-binary"; + if (l.endsWith(".png")) return "image/png"; + if (l.endsWith(".jpg") || l.endsWith(".jpeg")) return "image/jpeg"; + if (l.endsWith(".webp")) return "image/webp"; + if (l.endsWith(".mp3")) return "audio/mpeg"; + if (l.endsWith(".wav")) return "audio/wav"; + if (l.endsWith(".ogg")) return "audio/ogg"; + if (l.endsWith(".yaml") || l.endsWith(".yml")) return "application/x-yaml"; + if (l.endsWith(".json")) return "application/json"; + if (l.endsWith(".md") || l.endsWith(".txt") || l.endsWith(".stemscript")) return "text/plain"; + return "application/octet-stream"; +} + +const report = {baseUrl, gameFolder, mode: "playground", storeMode: "filesystem", startedAt: new Date().toISOString(), steps: [], consoleErrors: [], pageErrors: [], assetErrors: [], migrationLogs: [], assertions: {}}; +const failures = []; +function logStep(name, status = "ok", details = {}) { + report.steps.push({name, status, details, t: new Date().toISOString()}); + const tag = status === "ok" ? "✓" : status === "warn" ? "⚠" : "✗"; + console.log(`${tag} ${name}${Object.keys(details).length ? ` — ${JSON.stringify(details).slice(0, 240)}` : ""}`); +} +function assert(name, cond, detail) { + report.assertions[name] = {pass: !!cond, detail}; + if (cond) console.log(`✓ assert: ${name}`); + else { console.log(`✗ assert: ${name} — ${detail ?? ""}`); failures.push(name); } +} + +// Regression signatures for the three reported exceptions. +const DERIVATIVE_RE = /Image derivative missing dataUrl/i; +const WAKESOUND_RE = /_stopWakeSound is not a function/i; +const DISPOSE_RE = /Error during behavior dispose/i; +// Texture / asset-load failures surface as warnings, so the exception filter +// would miss them; track separately. +const ASSET_FAIL_RE = /No data URL found for|missing dataUrl|Failed to load texture|Cannot fetch asset|not being applied as a texture/i; + +assert("game-folder-exists", existsSync(gameFolder), gameFolder); +if (failures.length) { writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); process.exit(1); } + +const files = walkFiles(gameFolder); +const scriptFile = files.find(f => f.name.toLowerCase().endsWith(".stemscript")); +assert("script-file-found", !!scriptFile, `no .stemscript in ${gameFolder}`); +if (!scriptFile) { writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); process.exit(1); } +const scriptContent = readFileSync(scriptFile.abs, "utf8"); +const folderFiles = files.filter(f => f !== scriptFile) + .map(f => ({name: f.name, mime: mimeFor(f.name), data: readFileSync(f.abs).toString("base64")})); +logStep("read pirate-ship folder", "ok", {files: files.length, script: basename(scriptFile.name)}); + +const browser = await chromium.launch({headless: !headed}); +const ctx = await browser.newContext({viewport: {width: 1440, height: 900}}); +const page = await ctx.newPage(); +page.on("console", m => { + const t = m.text(); + if (m.type() === "error") report.consoleErrors.push({text: t.slice(0, 400), location: m.location()}); + if (ASSET_FAIL_RE.test(t)) report.assetErrors.push(t.slice(0, 300)); + // OSS must never run legacy behavior migration — it mints duplicate + // behavior assets every load. Any [LegacyBehaviorMigration] Migrat* line + // is a regression of the !IS_OSS gate in Editor.onSceneLoaded. + if (/\[LegacyBehaviorMigration\]\s+(Migrat|Updat)/i.test(t)) report.migrationLogs.push(t.slice(0, 300)); +}); +page.on("pageerror", e => report.pageErrors.push({message: e.message, stack: e.stack?.slice(0, 2000)})); + +// Seed the File System Access (folder) store via OPFS so no directory picker is +// needed. Must run on the origin before the navigation that boots the editor — +// rehydrateProjectStore reads the persistence mode + handle from here. +const bootstrapFilesystemStore = async (p) => { + await p.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry("stem-fs", {recursive: true}); } catch { /* first run */ } + const fsRoot = await root.getDirectoryHandle("stem-fs", {create: true}); + await new Promise((res, rej) => { + const req = indexedDB.open("stemstudio-fs-handle", 1); + req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains("handles")) db.createObjectStore("handles"); }; + req.onsuccess = () => { const tx = req.result.transaction("handles", "readwrite"); tx.objectStore("handles").put(fsRoot, "project-dir"); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }; + req.onerror = () => rej(req.error); + }); + localStorage.setItem("stemstudio.persistence.mode", "filesystem"); + localStorage.setItem("stemstudio.bootstrap.complete", "true"); + }); +}; + +const dismissBootstrap = async () => { + const bs = page.locator('[aria-labelledby="oss-bootstrap-title"]').first(); + if (await bs.count() && await bs.isVisible().catch(() => false)) { + await bs.locator('button:has-text("Browser storage")').first().click({timeout: 3000}).catch(() => {}); + await bs.locator('button:has-text("Continue")').first().click({timeout: 5000}).catch(() => {}); + await page.waitForSelector('[aria-labelledby="oss-bootstrap-title"]', {state: "detached", timeout: 5000}).catch(() => {}); + await page.waitForTimeout(400); + } +}; +const dismissTutorial = async () => { + 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); + } +}; + +// The batch-import dialog ("Import Assets (N required)") only appears when an +// import could NOT auto-resolve. In a headless run nobody clicks it, so it +// hangs runScript forever. A clean import (all files resolved) never shows it. +// We assert it does NOT appear; if it ever does, that's a real auto-resolution +// failure — record it (so the run fails loudly) and dismiss it so the harness +// doesn't hang for 20 minutes instead of reporting the problem. +const batchImportDialogAppeared = async () => + page.evaluate(() => /Import Assets \(/.test(document.body?.innerText || "")).catch(() => false); +const dismissBatchImportDialogIfPresent = async () => { + if (!(await batchImportDialogAppeared())) return false; + // Skip All → continue the script; the skipped imports show up in failCount. + const skip = page.locator('button:has-text("Skip All")').first(); + if (await skip.count() && await skip.isVisible().catch(() => false)) { + await skip.click({timeout: 3000, force: true}).catch(() => {}); + } else { + await page.keyboard.press("Escape").catch(() => {}); + } + await page.waitForTimeout(500); + return true; +}; + +try { + // === Activate playground mode, then seed the filesystem store. === + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + const playgroundActive = await page.evaluate(() => { try { return window.sessionStorage.getItem("stem.playgroundMode") === "1"; } catch { return false; } }); + assert("playground-mode-active", playgroundActive, "stem.playgroundMode sessionStorage flag not set"); + await bootstrapFilesystemStore(page); + logStep("filesystem (OPFS) store bootstrapped", "ok"); + + // === Boot a fresh project (flags persist via sessionStorage / localStorage). === + await page.goto(baseUrl + "/create/project", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(6000); + await dismissTutorial(); + const fsMode = await page.evaluate(() => { try { return window.localStorage.getItem("stemstudio.persistence.mode"); } catch { return null; } }); + assert("filesystem-store-selected", fsMode === "filesystem", `persistence.mode=${fsMode}`); + assert("editor-mounted", /\/create\/project/.test(page.url()), `URL: ${page.url()}`); + + // === Open Copilot so `__stemRunScript` is exposed (allowed in playground). === + const copilotBtn = page.locator('[data-testid="actionbar-copilot"]').first(); + const cBox = await copilotBtn.boundingBox().catch(() => null); + if (cBox) await page.mouse.click(cBox.x + cBox.width / 2, cBox.y + cBox.height / 2); + else await copilotBtn.click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(2000); + const hookPresent = await page.evaluate(() => typeof window.__stemRunScript === "function"); + assert("run-script-hook-exposed", hookPresent, "window.__stemRunScript not exposed"); + if (!hookPresent) throw new Error("no stemRunScript"); + + // === Run the import. === + // Fire WITHOUT awaiting the whole import inside evaluate: that would block + // this flow and, if the batch-import dialog ever opened, hang forever. We + // poll for completion and dismiss the dialog if it appears. + const execStartUrl = page.url(); + try { + await page.evaluate(({content, fileList}) => { + window.__stemRunScriptDone = null; + window.__stemRunScriptSummary = null; + window.__stemRunScript(content, fileList).then( + summary => { window.__stemRunScriptDone = "ok"; window.__stemRunScriptSummary = summary ?? null; }, + err => { window.__stemRunScriptDone = String(err && err.message ? err.message : err); }, + ); + }, {content: scriptContent, fileList: folderFiles}); + } catch (e) { + logStep("exec fire detached (likely navigation)", "warn", {error: e.message.slice(0, 120)}); + } + // Poll until the run resolves (a full game import takes a few minutes). + let importDialogSeen = false; + const importDeadline = Date.now() + 15 * 60 * 1000; + let execResult = null; + while (Date.now() < importDeadline) { + execResult = await page.evaluate(() => window.__stemRunScriptDone ?? null).catch(() => null); + if (execResult) break; + if (await dismissBatchImportDialogIfPresent()) importDialogSeen = true; + await page.waitForTimeout(3000); + } + await page.waitForLoadState("networkidle", {timeout: 60000}).catch(() => {}); + await dismissTutorial(); + const execOk = execResult === "ok" || page.url() !== execStartUrl; + assert("exec-completed", execOk, execResult ?? "no completion signal (timed out)"); + // The dialog must NOT have appeared — if it did, an import failed to + // auto-resolve (the exact bug that hung this game's import headlessly). + assert("no-batch-import-dialog", !importDialogSeen, + "batch-import dialog appeared — an import failed to auto-resolve"); + + // The import must be COMPLETE, not merely "finished". A non-zero failCount + // means at least one command (including an unresolved asset import) failed — + // exactly the silent-skip class of bug (skybox_day.glb) we refuse to let + // masquerade as a clean import. + const runSummary = await page.evaluate(() => window.__stemRunScriptSummary ?? null).catch(() => null); + report.runSummary = runSummary; + assert("import-no-failed-commands", !!runSummary && runSummary.failCount === 0, + `summary=${JSON.stringify(runSummary)}`); + + // The skybox is an imported model object; assert it actually landed in the + // scene rather than being silently dropped during resolution. + const sceneAudit = await page.evaluate(() => (window.__stemGetScene ? window.__stemGetScene() : null)).catch(() => null); + const objectNames = sceneAudit?.objectNames ?? []; + report.importedObjectCount = objectNames.length; + assert("skybox-object-present", objectNames.some(n => /skybox/i.test(n)), + `no object matching /skybox/ in ${objectNames.length} objects`); + // A real game import lands many models — guard against an empty/partial scene. + assert("scene-has-models", (sceneAudit?.meshCount ?? 0) > 0, `meshCount=${sceneAudit?.meshCount ?? 0}`); + + await page.screenshot({path: resolve(outDir, "01-after-import.png")}).catch(() => {}); + + // No "Image derivative missing dataUrl" while the OceanSurface base map loads. + const importDerivativeErrors = report.consoleErrors.filter(e => DERIVATIVE_RE.test(e.text)); + assert("no-derivative-errors-during-import", importDerivativeErrors.length === 0, + `${importDerivativeErrors.length}: ${importDerivativeErrors[0]?.text.slice(0, 200) ?? ""}`); + + // === Save to the filesystem store via AppMenu. === + await page.locator('[data-testid="topnav-app-menu"]').first().click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(400); + const save = page.locator("text=Save Project").first(); + if (await save.isVisible().catch(() => false)) { + await save.click({timeout: 3000}).catch(() => {}); + // OPFS saves write asset files then the manifest LAST; reloading before + // the "Saved" toast loses assets. Wait generously for it. + await page.locator("text=/^Saved$/").first().waitFor({state: "visible", timeout: 180000}).catch(() => {}); + await page.waitForTimeout(1500); + } + const sceneId = (page.url().match(/\/create\/project\/([^/?#]+)/) || [])[1] || null; + assert("scene-id-resolved", !!sceneId, `URL: ${page.url()}`); + + // === Reload through the playground dashboard (the folder-store load path). === + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 20000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(2000); + const reloadMarkIdx = report.consoleErrors.length; // ignore pre-reload errors + + if (sceneId) { + const card = page.locator(`[data-scene-id="${sceneId}"]`).first(); + // Folder store project list can take a moment to rehydrate from OPFS. + await card.waitFor({state: "attached", timeout: 30000}).catch(() => {}); + assert("imported-project-listed", (await card.count()) > 0, `data-scene-id="${sceneId}" not found`); + if (await card.count()) { + await card.click({timeout: 5000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 30000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForSelector("canvas", {timeout: 30000}).catch(() => {}); + await dismissTutorial(); + await page.waitForTimeout(8000); + await page.screenshot({path: resolve(outDir, "02-reloaded-playground.png")}).catch(() => {}); + assert("canvas-visible-on-reload", await page.locator("canvas").first().isVisible().catch(() => false), "no canvas after reload"); + + const reloadDerivativeErrors = report.consoleErrors.slice(reloadMarkIdx).filter(e => DERIVATIVE_RE.test(e.text)); + assert("no-derivative-errors-after-reload", reloadDerivativeErrors.length === 0, + `${reloadDerivativeErrors.length}: ${reloadDerivativeErrors[0]?.text.slice(0, 200) ?? ""}`); + } + } + + // === Enter Play and START GAME. === + const playMarkIdx = report.consoleErrors.length; + const playBtn = page.locator('[data-testid="topnav-play"]').first(); + let entered = false; + if (await playBtn.isVisible().catch(() => false)) { + await playBtn.click({timeout: 3000, force: true}).catch(() => {}); + const dontSave = page.locator("button", {hasText: /don['’]t\s*save/i}).first(); + if (await dontSave.count() && await dontSave.isVisible().catch(() => false)) { await dontSave.click({timeout: 3000}).catch(() => {}); await page.waitForTimeout(500); } + await page.waitForTimeout(3000); + const startGame = page.locator("#startGameBtn").first(); + try { + await startGame.waitFor({state: "visible", timeout: 20000}); + await page.waitForFunction(() => { const b = document.getElementById("startGameBtn"); return b && !b.disabled && b.getAttribute("aria-disabled") !== "true"; }, {timeout: 20000}); + await startGame.click({timeout: 5000, force: true}); + logStep("clicked START GAME", "ok"); + } catch (e) { logStep("START GAME never became clickable (may auto-start)", "warn", {error: String(e).slice(0, 160)}); } + entered = true; + await page.waitForTimeout(10000); + await page.screenshot({path: resolve(outDir, "03-play.png")}).catch(() => {}); + } else { + logStep("Play button not visible — skipping play audit", "warn"); + failures.push("play-button-missing"); + } + assert("entered-play", entered, "could not enter play mode"); + + // No texture errors while playing (OceanSurface base map must be live). + const playDerivativeErrors = report.consoleErrors.slice(playMarkIdx).filter(e => DERIVATIVE_RE.test(e.text)); + assert("no-derivative-errors-in-play", playDerivativeErrors.length === 0, + `${playDerivativeErrors.length}: ${playDerivativeErrors[0]?.text.slice(0, 200) ?? ""}`); + + // === STOP play (Edit) — this is what triggers BehaviorManager.dispose and + // surfaced `this._stopWakeSound is not a function` on ShipController. === + if (entered) { + const stopMarkIdx = report.consoleErrors.length; + const editBtn = page.locator('button:has-text("Edit")').first(); + if (await editBtn.isVisible().catch(() => false)) { + await editBtn.click({timeout: 3000, force: true}).catch(() => {}); + } else { + // Fall back to the Play/Stop toggle. + await playBtn.click({timeout: 3000, force: true}).catch(() => {}); + } + await page.waitForTimeout(5000); + await page.screenshot({path: resolve(outDir, "04-after-stop.png")}).catch(() => {}); + + const stopErrors = report.consoleErrors.slice(stopMarkIdx); + const wakeErrors = stopErrors.filter(e => WAKESOUND_RE.test(e.text)); + const disposeErrors = stopErrors.filter(e => DISPOSE_RE.test(e.text)); + if (wakeErrors.length || disposeErrors.length) { + writeFileSync(resolve(outDir, "dispose-errors.json"), JSON.stringify({wakeErrors, disposeErrors}, null, 2)); + } + assert("no-stopWakeSound-error-on-stop", wakeErrors.length === 0, + `${wakeErrors.length}: ${wakeErrors[0]?.text.slice(0, 200) ?? ""}`); + assert("no-behavior-dispose-errors-on-stop", disposeErrors.length === 0, + `${disposeErrors.length}: ${disposeErrors[0]?.text.slice(0, 200) ?? ""}`); + } + + // === Whole-run audit: the two concrete regression strings must never appear. === + const allDerivative = report.consoleErrors.filter(e => DERIVATIVE_RE.test(e.text)); + const allWake = report.consoleErrors.filter(e => WAKESOUND_RE.test(e.text)); + assert("no-derivative-errors-overall", allDerivative.length === 0, `${allDerivative.length}`); + assert("no-stopWakeSound-errors-overall", allWake.length === 0, `${allWake.length}`); + report.assetErrors = [...new Set(report.assetErrors)].slice(0, 20); + if (report.assetErrors.length) writeFileSync(resolve(outDir, "asset-errors.json"), JSON.stringify(report.assetErrors, null, 2)); + assert("no-asset-load-failures", report.assetErrors.length === 0, + `${report.assetErrors.length}: ${report.assetErrors[0]?.slice(0, 200) ?? ""}`); + assert("no-uncaught-page-errors", report.pageErrors.length === 0, + `${report.pageErrors.length}: ${report.pageErrors[0]?.message?.slice(0, 200) ?? ""}`); + report.migrationLogs = [...new Set(report.migrationLogs)]; + assert("no-legacy-migration-in-oss", report.migrationLogs.length === 0, + `${report.migrationLogs.length}: ${report.migrationLogs[0]?.slice(0, 200) ?? ""}`); +} catch (e) { + logStep("FATAL", "error", {error: e.message, stack: e.stack?.slice(0, 600)}); + failures.push("fatal:" + e.message); +} finally { + report.finishedAt = new Date().toISOString(); + report.failures = failures; + writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); + console.log(`\n=== Report (pirate-ship, playground + filesystem) ===`); + console.log(`Console errors: ${report.consoleErrors.length}`); + console.log(`Page errors: ${report.pageErrors.length}`); + console.log(`Asset errors: ${report.assetErrors.length}`); + console.log(`Migration logs: ${report.migrationLogs.length}`); + console.log(`Assertions: ${Object.values(report.assertions).filter(a => a.pass).length}/${Object.keys(report.assertions).length} passed`); + console.log(`Output dir: ${outDir}`); + await browser.close(); + if (failures.length > 0) { console.error(`\nFAIL: ${failures.length} failed: ${failures.join(", ")}`); process.exit(1); } + console.log("\nPASS"); +} diff --git a/scripts/playwright/oss-solar-system-playground.mjs b/scripts/playwright/oss-solar-system-playground.mjs new file mode 100644 index 00000000..fc921680 --- /dev/null +++ b/scripts/playwright/oss-solar-system-playground.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +/** + * End-to-end (PLAYGROUND mode): activate playground, import the Solar System + * stemscript fresh, save, reload through the playground dashboard, and verify + * the behavior framework wires texture loading without the + * `applyTexture` / `loadRingTexture` "Cannot read properties of undefined + * (reading 'asset')" crash — the exact regression reported in playground mode. + * + * The crash originates in the game's editor lifecycle hooks (onEditorAdded) + * reading an init()-only `erth` local. Playground is just a UI-gating flag; it + * loads the project through the same `onSceneLoaded` path, so a clean fresh + * import here proves the fixed behavior code is good in playground too. + * + * Source folder: GAME_FOLDER (defaults to the fixed Games-StemScript copy). + * Prereq: `bun run dev` on PLAYWRIGHT_BASE_URL (default localhost:5173). + * Set HEADED=1 to watch. Report → scripts/playwright/oss-solar-system-playground-output/. + */ +import {chromium} from "playwright"; +import {writeFileSync, mkdirSync, readFileSync, readdirSync, statSync, existsSync} from "node:fs"; +import {dirname, resolve, basename, join} from "node:path"; +import {fileURLToPath} from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(__dirname, "oss-solar-system-playground-output"); +mkdirSync(outDir, {recursive: true}); + +const baseUrl = (process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173").replace(/\/$/, ""); +const headed = process.env.HEADED === "1"; +const gameFolder = process.env.GAME_FOLDER || "/Users/n/erth/Games-StemScript/solar-system"; + +function walkFiles(root) { + const out = []; + const recurse = (dir, prefix) => { + for (const entry of readdirSync(dir)) { + if (entry === ".DS_Store") continue; + const abs = join(dir, entry); + const rel = prefix ? `${prefix}/${entry}` : entry; + if (statSync(abs).isDirectory()) recurse(abs, rel); + else out.push({name: rel, abs}); + } + }; + recurse(root, ""); + return out; +} + +function mimeFor(name) { + const lower = name.toLowerCase(); + if (lower.endsWith(".gltf")) return "model/gltf+json"; + if (lower.endsWith(".glb")) return "model/gltf-binary"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".md") || lower.endsWith(".txt") || lower.endsWith(".stemscript")) return "text/plain"; + return "application/octet-stream"; +} + +const report = {baseUrl, gameFolder, mode: "playground", startedAt: new Date().toISOString(), steps: [], consoleErrors: [], pageErrors: [], assertions: {}}; +const failures = []; +function logStep(name, status = "ok", details = {}) { + report.steps.push({name, status, details, t: new Date().toISOString()}); + const tag = status === "ok" ? "✓" : status === "warn" ? "⚠" : "✗"; + console.log(`${tag} ${name}${Object.keys(details).length ? ` — ${JSON.stringify(details).slice(0, 240)}` : ""}`); +} +function assert(name, cond, detail) { + report.assertions[name] = {pass: !!cond, detail}; + if (cond) console.log(`✓ assert: ${name}`); + else { console.log(`✗ assert: ${name} — ${detail ?? ""}`); failures.push(name); } +} + +assert("game-folder-exists", existsSync(gameFolder), gameFolder); +if (failures.length) { writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); process.exit(1); } + +const files = walkFiles(gameFolder); +const scriptFile = files.find(f => f.name.toLowerCase().endsWith(".stemscript")); +assert("script-file-found", !!scriptFile, `no .stemscript in ${gameFolder}`); +if (!scriptFile) process.exit(1); +const scriptContent = readFileSync(scriptFile.abs, "utf8"); +const folderFiles = files.filter(f => f !== scriptFile) + .map(f => ({name: f.name, mime: mimeFor(f.name), data: readFileSync(f.abs).toString("base64")})); +logStep("read solar-system folder", "ok", {files: files.length, script: basename(scriptFile.name)}); + +const browser = await chromium.launch({headless: !headed}); +const ctx = await browser.newContext({viewport: {width: 1440, height: 900}}); +const page = await ctx.newPage(); +page.on("console", m => { if (m.type() === "error") report.consoleErrors.push({text: m.text(), location: m.location()}); }); +page.on("pageerror", e => report.pageErrors.push({message: e.message, stack: e.stack?.slice(0, 2000)})); + +const dismissBootstrap = async () => { + const bs = page.locator('[aria-labelledby="oss-bootstrap-title"]').first(); + if (await bs.count() && await bs.isVisible().catch(() => false)) { + await bs.locator('button:has-text("Browser storage")').first().click({timeout: 3000}).catch(() => {}); + await bs.locator('button:has-text("Continue")').first().click({timeout: 5000}).catch(() => {}); + await page.waitForSelector('[aria-labelledby="oss-bootstrap-title"]', {state: "detached", timeout: 5000}).catch(() => {}); + await page.waitForTimeout(400); + } +}; +const dismissTutorial = async () => { + 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); + } +}; + +try { + // === Activate playground mode on the dashboard (sets sessionStorage flag). === + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + const playgroundActive = await page.evaluate(() => document.documentElement.dataset.playgroundMode === "true"); + assert("playground-mode-active", playgroundActive, "data-playground-mode not set on "); + + // === Boot a fresh project (flag persists via sessionStorage). === + await page.goto(baseUrl + "/create/project", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(6000); + await dismissTutorial(); + // The `data-playground-mode` attribute is only applied on the public-site + // container mount; the editor route persists the flag via sessionStorage. + const stillPlayground = await page.evaluate(() => { + try { return window.sessionStorage.getItem("stem.playgroundMode") === "1"; } catch { return false; } + }); + assert("playground-persists-into-editor", stillPlayground, "playground sessionStorage flag lost after navigation"); + assert("editor-mounted", /\/create\/project/.test(page.url()), `URL: ${page.url()}`); + + // === Open Copilot so `__stemRunScript` is exposed (allowed in playground). === + const copilotBtn = page.locator('[data-testid="actionbar-copilot"]').first(); + const cBox = await copilotBtn.boundingBox().catch(() => null); + if (cBox) await page.mouse.click(cBox.x + cBox.width / 2, cBox.y + cBox.height / 2); + else await copilotBtn.click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(2000); + const hookPresent = await page.evaluate(() => typeof window.__stemRunScript === "function"); + assert("run-script-hook-exposed", hookPresent, "window.__stemRunScript not exposed"); + if (!hookPresent) throw new Error("no stemRunScript"); + + // === Run the import. === + const execStartUrl = page.url(); + try { + await page.evaluate(({content, fileList}) => + window.__stemRunScript(content, fileList).then( + () => { window.__stemRunScriptDone = "ok"; }, + err => { window.__stemRunScriptDone = String(err && err.message ? err.message : err); }, + ), {content: scriptContent, fileList: folderFiles}); + } catch (e) { + logStep("exec evaluate detached (likely navigation)", "warn", {error: e.message.slice(0, 120)}); + } + await page.waitForLoadState("networkidle", {timeout: 90000}).catch(() => {}); + await page.waitForTimeout(6000); + const execResult = await page.evaluate(() => window.__stemRunScriptDone ?? null).catch(() => null); + const execOk = execResult === "ok" || page.url() !== execStartUrl; + assert("exec-completed", execOk, execResult ?? "no completion signal"); + await page.screenshot({path: resolve(outDir, "01-after-import.png")}).catch(() => {}); + + // Errors during the in-editor import (the AttachBehaviorCommand path). + const importErrors = report.consoleErrors.filter(e => /applyTexture|loadRingTexture|reading 'asset'/i.test(e.text)); + assert("no-texture-errors-during-import", importErrors.length === 0, + `${importErrors.length}: ${importErrors[0]?.text.slice(0, 160) ?? ""}`); + + // === Save via AppMenu. === + await page.locator('[data-testid="topnav-app-menu"]').first().click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(400); + const save = page.locator("text=Save Project").first(); + if (await save.isVisible().catch(() => false)) { await save.click({timeout: 3000}).catch(() => {}); await page.waitForTimeout(3500); } + const importedUrl = page.url(); + const sceneId = (importedUrl.match(/\/create\/project\/([^/?#]+)/) || [])[1] || null; + assert("scene-id-resolved", !!sceneId, `URL: ${importedUrl}`); + + // === Reload through the playground dashboard (the onSceneLoaded path). === + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 20000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(2000); + const reloadMarkIdx = report.consoleErrors.length; // ignore pre-reload errors + + if (sceneId) { + const card = page.locator(`[data-scene-id="${sceneId}"]`).first(); + assert("imported-project-listed", (await card.count()) > 0, `data-scene-id="${sceneId}" not found`); + if (await card.count()) { + await card.click({timeout: 5000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 30000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForSelector("canvas", {timeout: 30000}).catch(() => {}); + await dismissTutorial(); + await page.waitForTimeout(8000); + await page.screenshot({path: resolve(outDir, "02-reloaded-playground.png")}).catch(() => {}); + assert("canvas-visible-on-reload", await page.locator("canvas").first().isVisible().catch(() => false), "no canvas after reload"); + + const textureErrors = report.consoleErrors.slice(reloadMarkIdx) + .filter(e => /applyTexture|loadRingTexture|reading 'asset'/i.test(e.text)); + if (textureErrors.length) writeFileSync(resolve(outDir, "texture-errors.json"), JSON.stringify(textureErrors, null, 2)); + logStep("texture errors after playground reload", textureErrors.length ? "warn" : "ok", {count: textureErrors.length}); + assert("no-texture-load-errors-after-playground-reload", textureErrors.length === 0, + `${textureErrors.length}: ${textureErrors[0]?.text.slice(0, 200) ?? ""}`); + } + } + + // === Enter Play and start the game (best-effort, like the sibling test). === + const playBtn = page.locator('[data-testid="topnav-play"]').first(); + if (await playBtn.isVisible().catch(() => false)) { + await playBtn.click({timeout: 3000}).catch(() => {}); + const dontSave = page.locator("button", {hasText: /don['’]t\s*save/i}).first(); + if (await dontSave.count() && await dontSave.isVisible().catch(() => false)) { await dontSave.click({timeout: 3000}).catch(() => {}); await page.waitForTimeout(500); } + await page.waitForTimeout(3000); + const startGame = page.locator("#startGameBtn").first(); + try { + await startGame.waitFor({state: "visible", timeout: 20000}); + await page.waitForFunction(() => { const b = document.getElementById("startGameBtn"); return b && !b.disabled; }, {timeout: 20000}); + await startGame.click({timeout: 5000, force: true}); + logStep("clicked START GAME", "ok"); + } catch (e) { logStep("START GAME never became clickable", "warn", {error: String(e).slice(0, 160)}); } + await page.waitForTimeout(8000); + await page.screenshot({path: resolve(outDir, "03-play.png")}).catch(() => {}); + const playErrors = report.consoleErrors.filter(e => /applyTexture|loadRingTexture|reading 'asset'/i.test(e.text)); + assert("no-texture-errors-in-play", playErrors.length === 0, `${playErrors.length}: ${playErrors[0]?.text.slice(0, 160) ?? ""}`); + } else { + logStep("Play button not visible — skipping play audit", "warn"); + } +} catch (e) { + logStep("FATAL", "error", {error: e.message, stack: e.stack?.slice(0, 600)}); + failures.push("fatal:" + e.message); +} finally { + report.finishedAt = new Date().toISOString(); + report.failures = failures; + writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); + console.log(`\n=== Report (playground) ===`); + console.log(`Console errors: ${report.consoleErrors.length}`); + console.log(`Page errors: ${report.pageErrors.length}`); + console.log(`Assertions: ${Object.values(report.assertions).filter(a => a.pass).length}/${Object.keys(report.assertions).length} passed`); + console.log(`Output dir: ${outDir}`); + await browser.close(); + if (failures.length > 0) { console.error(`\nFAIL: ${failures.length} failed: ${failures.join(", ")}`); process.exit(1); } +} diff --git a/scripts/playwright/oss-uikit-overlay-click.mjs b/scripts/playwright/oss-uikit-overlay-click.mjs new file mode 100644 index 00000000..774240f7 --- /dev/null +++ b/scripts/playwright/oss-uikit-overlay-click.mjs @@ -0,0 +1,424 @@ +/** + * UIKit overlay click-blocking regression test. + * + * Reproduces — and verifies the fix for — the cubecity-hex bug where HUD + * buttons became unclickable. Root cause: a full-screen overlay with + * pointerEvents:"auto" that is "hidden" via visibility:"hidden" stays in the + * layout and KEEPS raycast-blocking everything behind it (the @ni2khanna/uikit + * raycast path only skips elements whose pointerEvents === "none", never + * visibility:"hidden"). Switching the hide to display:"none" removes the + * element from layout AND raycasting, so clicks reach the buttons underneath. + * + * This drives the REAL engine click stack — UIKitPointerEvents.initialize / + * registerRoot + forwardHtmlEvents(canvas, uiCamera, scene) — on a deliberately + * light scene (one button + one overlay) so it runs headless without the heavy + * cubecity scene crashing the renderer. + * + * Phase A: overlay visibility:"hidden" -> click center -> EXPECT clicks == 0 (blocked) + * Phase B: overlay display:"none" -> click center -> EXPECT clicks == 1 (lands) + * + * Usage: node scripts/playwright/oss-uikit-overlay-click.mjs + * Requires `bun run dev` on :5173. + */ +import {chromium} from "playwright"; +import {mkdirSync, writeFileSync} from "node:fs"; +import {resolve, dirname} from "node:path"; +import {fileURLToPath} from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const baseUrl = process.env.BASE_URL || "http://localhost:5173"; +const headed = process.env.HEADED === "1"; +const outDir = resolve(__dirname, "_uikit-overlay-click-output"); +mkdirSync(outDir, {recursive: true}); + +// A tiny behavior that builds the exact blocking scenario using the real +// engine globals injected into behavior scripts (UIKit, UIKitPointerEvents). +const PROBE_BEHAVIOR = `meta: + tool: StemStudio + type: behavior + exportVersion: 1 + +config: + name: "Overlay Click Probe" + id: "probe.overlayClick" + author: "test" + isScript: true + main: "script.js" + version: "1.0.0" + description: "Minimal HUD button under a full-screen overlay, for click-blocking tests." + tags: + - gameplay + priority: 0 + attributes: {} + +code: | + this.init = function (_game) { + var game = _game; + window.__clicks = 0; + window.__probeReady = false; + window.__probeErr = null; + try { + // Mirror cubecity's useNodeMaterialProps: pick the material mode the active + // renderer actually supports (node materials only render under the WebGPU/ + // TSL pipeline; headless here is WebGL, so this resolves to false). + function unmp(props) { + var useNM = true; + try { + if (game && game.renderContext && game.renderContext.useNodeMaterial !== undefined) { + useNM = game.renderContext.useNodeMaterial; + } + } catch (e) {} + props.useNodeMaterial = useNM; + return props; + } + var fs = new UIKit.Fullscreen(game.renderer, unmp({ + pointerEvents: "none", + justifyContent: "center", + alignItems: "center" + })); + window.__h = { click: 0, down: 0, up: 0, over: 0, move: 0 }; + var btn = new UIKit.Container(unmp({ + width: 220, height: 90, + backgroundColor: "rgba(120,200,255,0.6)", + pointerEvents: "auto", + justifyContent: "center", alignItems: "center", + onClick: function () { window.__clicks++; window.__h.click++; }, + onPointerDown: function () { window.__h.down++; }, + onPointerUp: function () { window.__h.up++; }, + onPointerOver: function () { window.__h.over++; }, + onPointerMove: function () { window.__h.move++; } + })); + fs.add(btn); + var overlay = new UIKit.Container(unmp({ + positionType: "absolute", positionLeft: 0, positionTop: 0, + width: "100%", height: "100%", + backgroundColor: "rgba(0,0,0,0.25)", + pointerEvents: "auto" + })); + fs.add(overlay); + btn.name = "BUTTON"; + overlay.name = "OVERLAY"; + UIKitPointerEvents.initialize(game); + game.uiCamera.add(fs); + UIKitPointerEvents.registerRoot(fs); + this._fs = fs; + // Renderer-independent hit test: cast a ray from the UI camera through + // screen-center (NDC 0,0) into the UI root and report the CLOSEST hit's + // owning component. This is exactly what forwardHtmlEvents does internally + // (Raycaster vs scene geometry) — it works even though the headless WebGL + // fallback never paints the UIKit meshes to screen. The fix is about + // whether a hidden overlay still occupies the raycast, so this is the + // mechanism under test. + window.__hitTopName = function () { + try { + // Cast through the BUTTON's actual screen position, not NDC center — + // the UI camera is a perspective camera and the UI is not at the + // screen center. This mirrors a real click landing on the button: + // does the overlay still intercept that ray before the button? + var box = new THREE.Box3().setFromObject(btn); + var center = box.getCenter(new THREE.Vector3()); + var ndc = center.clone().project(game.uiCamera); + var rc = new THREE.Raycaster(); + rc.setFromCamera(new THREE.Vector2(ndc.x, ndc.y), game.uiCamera); + var hits = rc.intersectObject(fs, true); + for (var i = 0; i < hits.length; i++) { + var o = hits[i].object; + while (o) { + if (o.name === "OVERLAY") return "OVERLAY"; + if (o.name === "BUTTON") return "BUTTON"; + o = o.parent; + } + } + return hits.length ? ("OTHER:" + hits.length) : "NONE"; + } catch (e) { return "ERR:" + (e && e.message ? e.message : e); } + }; + window.__btnNDC = function () { + try { + var c = new THREE.Box3().setFromObject(btn).getCenter(new THREE.Vector3()); + var ndc = c.project(game.uiCamera); + return {x: ndc.x, y: ndc.y}; + } catch (e) { return null; } + }; + window.__diag = function () { + var out = {meshes: [], camera: null, fsChildren: (fs.children ? fs.children.length : -1)}; + try { + var r = game.renderer; + out.rendererType = r ? (r.isWebGPURenderer ? "WebGPU" : (r.isWebGLRenderer ? "WebGL" : (r.constructor && r.constructor.name))) : "no-renderer"; + var sz = new THREE.Vector2(); if (r && r.getSize) r.getSize(sz); out.rendererSize = {x: sz.x, y: sz.y}; + } catch (e) { out.rendererErr = String(e); } + try { + var cam = game.uiCamera; + if (cam) { + out.camera = { + type: cam.isOrthographicCamera ? "Ortho" : (cam.isPerspectiveCamera ? "Persp" : (cam.type || "?")), + pos: cam.position ? [cam.position.x, cam.position.y, cam.position.z].map(function (n) { return +n.toFixed(2); }) : null, + inScene: !!(cam.parent), + parentName: cam.parent ? (cam.parent.name || cam.parent.type) : null + }; + } + } catch (e) { out.cameraErr = String(e); } + function sig(c, prop) { + try { var s = c[prop]; return s && typeof s.peek === "function" ? s.peek() : (s && "value" in s ? s.value : s); } catch (e) { return "err"; } + } + try { + out.overlaySignals = {isVisible: sig(overlay, "isVisible"), displayed: sig(overlay, "displayed"), explicitVisible: sig(overlay, "explicitVisible")}; + out.btnSignals = {isVisible: sig(btn, "isVisible"), displayed: sig(btn, "displayed"), explicitVisible: sig(btn, "explicitVisible")}; + out.fsSignals = {isVisible: sig(fs, "isVisible"), displayed: sig(fs, "displayed")}; + } catch (e) { out.sigErr = String(e); } + try { + var box = new THREE.Box3(); var v = new THREE.Vector3(); + fs.traverse(function (o) { + if (!o.isMesh) return; + o.updateWorldMatrix && o.updateWorldMatrix(true, false); + var bb = null; + try { box.setFromObject(o); bb = box.isEmpty() ? "empty" : {min: [+box.min.x.toFixed(1), +box.min.y.toFixed(1), +box.min.z.toFixed(1)], max: [+box.max.x.toFixed(1), +box.max.y.toFixed(1), +box.max.z.toFixed(1)]}; } catch (e) { bb = "err"; } + o.getWorldPosition && o.getWorldPosition(v); + out.meshes.push({ + name: o.name || (o.parent && o.parent.name) || o.type, + visible: o.visible, + hasRaycast: typeof o.raycast === "function", + worldPos: [+v.x.toFixed(1), +v.y.toFixed(1), +v.z.toFixed(1)], + bbox: bb + }); + }); + } catch (e) { out.meshErr = String(e); } + return out; + }; + function sigVal(c, prop) { + try { var s = c[prop]; return s && typeof s.peek === "function" ? s.peek() : null; } catch (e) { return "err"; } + } + window.__probe = { + hideVisibility: function () { overlay.setProperties({ visibility: "hidden" }); }, + hideDisplay: function () { overlay.setProperties({ display: "none", visibility: "visible" }); }, + showOverlay: function () { overlay.setProperties({ display: "flex", visibility: "visible" }); }, + resetClicks: function () { window.__clicks = 0; }, + // isVisible is the exact gate makeClippedCast() checks: a panel with + // isVisible===false is skipped by the pointer-events hit test, so it can + // neither receive nor block clicks. + vis: function () { return { overlay: sigVal(overlay, "isVisible"), button: sigVal(btn, "isVisible") }; } + }; + window.__probeReady = true; + } catch (e) { + window.__probeErr = String(e && e.message ? e.message : e); + } + }; + this.update = function (dt) { + try { UIKitPointerEvents.update(dt); } catch (e) {} + }; + this.dispose = function () { + try { + if (this._fs) { UIKitPointerEvents.unregisterRoot(this._fs); this._fs.dispose && this._fs.dispose(); } + UIKitPointerEvents.deinitialize(); + } catch (e) {} + }; +`; + +const PROBE_STEMSCRIPT = `project title "Overlay Click Probe" +import behavior name="Overlay Click Probe" filepath="behaviors/probeOverlayClick.yaml" +game settings enabled=true showHUD=false maxScore=0 +add group name="UIHost" position=0,0,0 +behavior attach UIHost behaviorId=probe.overlayClick +`; + +const steps = []; +const step = (s, ok = true, d) => { steps.push({s, ok, d}); console.log(` ${ok ? "·" : "✗"} ${s}${d !== undefined ? ` (${d})` : ""}`); }; + +const browser = await chromium.launch({ + headless: !headed, + args: ["--disable-dev-shm-usage", "--no-sandbox"], +}); +const ctx = await browser.newContext({viewport: {width: 1280, height: 800}}); +const page = await ctx.newPage(); +page.on("console", m => { if (m.type() === "error") console.log(" [console.error]", m.text().slice(0, 200)); }); +page.on("pageerror", e => console.log(" [pageerror]", (e.message || String(e)).slice(0, 200))); + +const dismissBootstrap = async () => { + const bs = page.locator('[aria-labelledby="oss-bootstrap-title"]').first(); + if (await bs.count() && await bs.isVisible().catch(() => false)) { + await bs.locator('button:has-text("Browser storage")').first().click({timeout: 3000}).catch(() => {}); + await bs.locator('button:has-text("Continue")').first().click({timeout: 5000}).catch(() => {}); + await page.waitForSelector('[aria-labelledby="oss-bootstrap-title"]', {state: "detached", timeout: 5000}).catch(() => {}); + } +}; +const dismissTutorial = async () => { + const g = page.locator('button:has-text("Got It")').first(); + if (await g.count() && await g.isVisible().catch(() => false)) await g.click({timeout: 3000}).catch(() => {}); +}; + +let status = "pending"; +try { + // 1. playground + fresh project + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + step("playground activated"); + + await page.goto(baseUrl + "/create/project", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(6000); + await dismissTutorial(); + + // 2. expose __stemRunScript + import the probe + const copilotBtn = page.locator('[data-testid="actionbar-copilot"]').first(); + const cBox = await copilotBtn.boundingBox().catch(() => null); + if (cBox) await page.mouse.click(cBox.x + cBox.width / 2, cBox.y + cBox.height / 2); + else await copilotBtn.click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(2000); + const hookPresent = await page.evaluate(() => typeof window.__stemRunScript === "function"); + step("run-script hook exposed", hookPresent); + if (!hookPresent) throw new Error("__stemRunScript not exposed"); + + const fileList = [{name: "behaviors/probeOverlayClick.yaml", mime: "text/yaml", data: Buffer.from(PROBE_BEHAVIOR).toString("base64")}]; + await page.evaluate(({content, fileList}) => + window.__stemRunScript(content, fileList).then(() => { window.__d = "ok"; }, e => { window.__d = String(e && e.message ? e.message : e); }), + {content: PROBE_STEMSCRIPT, fileList}); + await page.waitForLoadState("networkidle", {timeout: 60000}).catch(() => {}); + await page.waitForTimeout(3000); + await dismissTutorial(); + const execResult = await page.evaluate(() => window.__d ?? null).catch(() => null); + step("import exec", execResult === "ok", execResult ?? "no signal"); + + // 3. save + reload via the dashboard. The uiCamera overlay render pass is + // only wired up on a fresh editor load (the scene/v2 reload path) — UIKit + // HUDs do NOT render on same-session import->play. The probe scene is tiny, + // so unlike the heavy cubecity scene this reload renders without crashing. + await page.locator('[data-testid="topnav-app-menu"]').first().click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(400); + const save = page.locator("text=Save Project").first(); + if (await save.isVisible().catch(() => false)) { + await save.click({timeout: 3000}).catch(() => {}); + await page.locator("text=/^Saved$/").first().waitFor({state: "visible", timeout: 30000}).catch(() => {}); + await page.waitForTimeout(1000); + } + const sceneId = (page.url().match(/\/create\/project\/([^/?#]+)/) || [])[1] || null; + step("saved", !!sceneId, sceneId ?? "no scene id"); + + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 20000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(1500); + if (sceneId) { + const card = page.locator(`[data-scene-id="${sceneId}"]`).first(); + await card.waitFor({state: "attached", timeout: 20000}).catch(() => {}); + if (await card.count()) { + await card.click({timeout: 5000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 30000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForSelector("canvas", {timeout: 30000}).catch(() => {}); + await dismissTutorial(); + await page.waitForTimeout(4000); + } + } + step("editor reloaded, canvas visible", await page.locator("canvas").first().isVisible().catch(() => false)); + + // 4. enter Play (the probe builds its UI in init(), play-only) + const playBtn = page.locator('[data-testid="topnav-play"]').first(); + await playBtn.click({timeout: 3000, force: true}); + const dontSave = page.locator("button", {hasText: /don['’]t\s*save/i}).first(); + if (await dontSave.count() && await dontSave.isVisible().catch(() => false)) { await dontSave.click().catch(() => {}); await page.waitForTimeout(500); } + await page.waitForFunction(() => window.__probeReady === true || window.__probeErr, {timeout: 20000}).catch(() => {}); + const probeErr = await page.evaluate(() => window.__probeErr); + const probeReady = await page.evaluate(() => window.__probeReady === true); + step("probe UI built in play", probeReady, probeErr || undefined); + if (!probeReady) throw new Error("probe did not build: " + (probeErr || "unknown")); + await page.waitForTimeout(1500); + const diag = await page.evaluate(() => window.__diag()); + step("UIKit root diagnostic", true, JSON.stringify(diag)); + await page.screenshot({path: resolve(outDir, "01-overlay-shown.png")}).catch(() => {}); + + // Center of the canvas == center of the button (Fullscreen centers it). + const canvas = page.locator("canvas").first(); + const box = await canvas.boundingBox(); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + + // Real synthetic click at the BUTTON's actual screen position (projected via + // the UI camera), so we exercise the genuine forwardHtmlEvents pointer path + // rather than guessing the center. Bonus signal — needs the panel to paint, + // which the overlay-compositing pass may not do headless. + const clickButton = async () => { + const ndc = await page.evaluate(() => window.__btnNDC()); + if (!ndc) return -1; + const px = box.x + (ndc.x * 0.5 + 0.5) * box.width; + const py = box.y + (-ndc.y * 0.5 + 0.5) * box.height; + await page.mouse.move(box.x + 8, box.y + 8); + await page.waitForTimeout(80); + await page.mouse.move(px, py, {steps: 6}); + await page.waitForTimeout(120); + await page.mouse.down(); + await page.waitForTimeout(80); + await page.mouse.up(); + await page.waitForTimeout(450); + return page.evaluate(() => window.__clicks); + }; + + // Apply a hide mode, let the layout settle a few frames (root.update runs + // every frame via the behavior), then read the isVisible GATE (what + // makeClippedCast checks to include/exclude a panel from hit-testing) plus a + // real click attempt. + const probe = async (modeFn) => { + await page.evaluate((m) => { window.__probe.resetClicks(); window.__probe[m](); }, modeFn); + await page.waitForTimeout(700); + const vis = await page.evaluate(() => window.__probe.vis()); + const clicks = await clickButton(); + return {vis, clicks}; + }; + + // Informational: confirm the isVisible gate (what makeClippedCast checks to + // include/exclude a panel from the pointer hit-test) responds to both hide + // modes. NOTE: a handler-less full-screen overlay does NOT actually block a + // sibling button behind it (same-plane z-ordering), so overlay visibility is + // a red herring for the click bug — see the CLICK DELIVERY check below. + const visShown = (await probe("showOverlay")).vis; + step("info: shown overlay isVisible", true, `overlay.isVisible=${visShown.overlay} button.isVisible=${visShown.button}`); + const visGone = (await probe("hideDisplay")).vis; + step("info: display:none clears overlay isVisible", visGone.overlay === false, `overlay.isVisible=${visGone.overlay}`); + + // CLICK-DELIVERY REGRESSION (the real bug): the camera controls call + // setPointerCapture() on #scene-container on pointerdown, which redirects + // pointerup away from the . Before the fix (UIKitPointerEvents bound + // to #scene-container instead of the canvas) a UIKit button got onPointerDown + // but never onClick. Dispatch a real pointer sequence at the button's + // projected screen position and assert the full down -> up -> click fires. + await page.evaluate(() => { + window.__probe.hideDisplay(); + window.__h = {click:0,down:0,up:0,over:0,move:0}; + // DOM capture-phase counters: did the native pointer events reach the + // document at all (i.e. were they swallowed by pointer capture)? + window.__dom = {down:0, up:0, move:0, lost:0, downEv:null, upEv:null}; + const rec = (e) => { const t = e.target || {}; return {button: e.button, tag: t.tagName, id: t.id, cls: (typeof t.className === "string" ? t.className : "").slice(0, 80), style: t.getAttribute ? (t.getAttribute("style") || "").slice(0, 120) : ""}; }; + document.addEventListener("pointerdown", (e) => { window.__dom.down++; window.__dom.downEv = rec(e); }, true); + document.addEventListener("pointerup", (e) => { window.__dom.up++; window.__dom.upEv = rec(e); }, true); + document.addEventListener("pointermove", () => window.__dom.move++, true); + document.addEventListener("lostpointercapture", () => window.__dom.lost++, true); + }); + await page.waitForTimeout(400); + const ndc = await page.evaluate(() => window.__btnNDC()); + const px = box.x + (ndc.x * 0.5 + 0.5) * box.width; + const py = box.y + (-ndc.y * 0.5 + 0.5) * box.height; + const targetTag = await page.evaluate(({x, y}) => { const el = document.elementFromPoint(x, y); return el ? (el.tagName + (el.id ? "#" + el.id : "")) : "none"; }, {x: px, y: py}); + await page.mouse.move(box.x + 12, box.y + 12); + await page.waitForTimeout(80); + await page.mouse.move(px, py, {steps: 8}); + await page.waitForTimeout(150); + await page.mouse.down(); + await page.waitForTimeout(100); + await page.mouse.up(); + await page.waitForTimeout(500); + const h = await page.evaluate(() => window.__h); + const dom = await page.evaluate(() => window.__dom); + step("CLICK DELIVERY: button receives full down->up->click", h.click > 0, `target=${targetTag} px=(${Math.round(px)},${Math.round(py)}) uikit=${JSON.stringify(h)} dom.upTarget=${dom.upEv && dom.upEv.id ? "#" + dom.upEv.id : dom.upEv && dom.upEv.tag}`); + + status = (h.click > 0 && h.up > 0) ? "PASS" : "FAIL"; +} catch (e) { + step("FATAL", false, (e.message || String(e)).slice(0, 200)); + status = "FAIL"; +} finally { + writeFileSync(resolve(outDir, "report.json"), JSON.stringify({status, steps}, null, 2)); + console.log(`\n=== UIKit overlay click test: ${status} ===`); + console.log(`Report dir: ${outDir}`); + await ctx.close(); + await browser.close(); + process.exit(status === "PASS" ? 0 : 1); +} diff --git a/scripts/playwright/oss-voxel-valley-lighting.mjs b/scripts/playwright/oss-voxel-valley-lighting.mjs new file mode 100644 index 00000000..e5329398 --- /dev/null +++ b/scripts/playwright/oss-voxel-valley-lighting.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +/** + * Regression test (PLAYGROUND mode): scene background + image-based environment + * lighting must survive a save → reload (and an edit → play → edit round-trip). + * + * Bug it guards against: a stemscript `scene background type=Texture texture="…"` + * command resolved the named image asset to an ephemeral `blob:` URL and + * persisted only that URL. Object URLs are revoked on page reload, so after a + * reload the editor could no longer fetch the skybox texture — losing both the + * visible background AND `scene.environment` (the image-based lighting that lit + * the whole scene). Result: "everything goes dark" on reload, even though the + * ambient/hemisphere/directional lights themselves were intact. + * + * Fix: `SettingsHandlers.handleSetSceneBackground` now also persists the stable + * `textureAsset` (AssetRef), which `EnvironmentSettingsManager.applyBackgroundSettings` + * re-fetches through the asset loader after a reload. + * + * The probe uses the `__stemGetScene` test affordance (extended to report + * lights, rendering, and scene.background/environment classification). + * + * Prereq: `bun run dev` on PLAYWRIGHT_BASE_URL (default localhost:5173). + * Set HEADED=1 to watch. Report → scripts/playwright/oss-voxel-valley-lighting-output/. + */ +import {chromium} from "playwright"; +import {writeFileSync, mkdirSync, readFileSync, readdirSync, statSync, existsSync} from "node:fs"; +import {dirname, resolve, basename, join} from "node:path"; +import {fileURLToPath} from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(__dirname, "oss-voxel-valley-lighting-output"); +mkdirSync(outDir, {recursive: true}); + +const baseUrl = (process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173").replace(/\/$/, ""); +const headed = process.env.HEADED === "1"; +const gameFolder = process.env.GAME_FOLDER || "/Users/n/erth/Games-StemScript/voxel-valley"; + +function walkFiles(root) { + const out = []; + const recurse = (dir, prefix) => { + for (const entry of readdirSync(dir)) { + if (entry === ".DS_Store") continue; + const abs = join(dir, entry); + const rel = prefix ? `${prefix}/${entry}` : entry; + if (statSync(abs).isDirectory()) recurse(abs, rel); + else out.push({name: rel, abs}); + } + }; + recurse(root, ""); + return out; +} +function mimeFor(name) { + const lower = name.toLowerCase(); + if (lower.endsWith(".gltf")) return "model/gltf+json"; + if (lower.endsWith(".glb")) return "model/gltf-binary"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".wav")) return "audio/wav"; + if (lower.endsWith(".mp3")) return "audio/mpeg"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".md") || lower.endsWith(".txt") || lower.endsWith(".stemscript")) return "text/plain"; + return "application/octet-stream"; +} + +// Probe via the sanctioned `__stemGetScene` affordance (lights + background + env). +const PROBE = () => { + const fn = window.__stemGetScene; + if (typeof fn !== "function") return {error: "no __stemGetScene hook"}; + const s = fn(); + const lights = s.lights || []; + return { + counts: { + ambient: lights.filter(l => l.type === "AmbientLight").length, + hemisphere: lights.filter(l => l.type === "HemisphereLight").length, + directional: lights.filter(l => l.type === "DirectionalLight").length, + }, + rendering: s.rendering, + sceneEnv: s.sceneEnv, + mode: s.mode, + sceneName: s.sceneName, + }; +}; + +const report = {baseUrl, gameFolder, startedAt: new Date().toISOString(), checkpoints: {}, assertions: {}, consoleErrors: [], pageErrors: []}; +const failures = []; +function assert(name, cond, detail) { + report.assertions[name] = {pass: !!cond, detail}; + if (cond) console.log(`✓ ${name}`); + else { console.log(`✗ ${name} — ${detail ?? ""}`); failures.push(name); } +} + +if (!existsSync(gameFolder)) { console.error("missing game folder", gameFolder); process.exit(1); } +const files = walkFiles(gameFolder); +const scriptFile = files.find(f => f.name.toLowerCase().endsWith(".stemscript")); +const scriptContent = readFileSync(scriptFile.abs, "utf8"); +const folderFiles = files.filter(f => f !== scriptFile) + .map(f => ({name: f.name, mime: mimeFor(f.name), data: readFileSync(f.abs).toString("base64")})); +console.log(`read ${basename(gameFolder)}: ${files.length} files`); + +const browser = await chromium.launch({headless: !headed}); +const ctx = await browser.newContext({viewport: {width: 1440, height: 900}}); +const page = await ctx.newPage(); +page.on("console", m => { if (m.type() === "error") report.consoleErrors.push(m.text().slice(0, 300)); }); +page.on("pageerror", e => report.pageErrors.push({message: e.message, stack: e.stack?.slice(0, 800)})); + +const dismissBootstrap = async () => { + const bs = page.locator('[aria-labelledby="oss-bootstrap-title"]').first(); + if (await bs.count() && await bs.isVisible().catch(() => false)) { + await bs.locator('button:has-text("Browser storage")').first().click({timeout: 3000}).catch(() => {}); + await bs.locator('button:has-text("Continue")').first().click({timeout: 5000}).catch(() => {}); + await page.waitForSelector('[aria-labelledby="oss-bootstrap-title"]', {state: "detached", timeout: 5000}).catch(() => {}); + await page.waitForTimeout(400); + } +}; +const dismissTutorial = async () => { + 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); } +}; +const openCopilot = async () => { + if (await page.evaluate(() => typeof window.__stemGetScene === "function").catch(() => false)) return; + const btn = page.locator('[data-testid="actionbar-copilot"]').first(); + const box = await btn.boundingBox().catch(() => null); + if (box) await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + else await btn.click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(1500); +}; +// Pin the editor camera to a fixed viewpoint that frames a large patch of sky, +// so the skybox render is comparable across fresh-import vs reload (the camera +// otherwise resets on reload). +const SKY_CAM = {pos: [0, 30, 70], target: [0, 34, 0]}; +const pinCamera = async () => { + await openCopilot(); + await page.evaluate(({pos, target}) => window.__stemSetEditorCamera?.(pos, target), SKY_CAM).catch(() => {}); + await page.waitForTimeout(1200); +}; +const probe = async (label) => { + await openCopilot(); + const data = await page.evaluate(PROBE).catch(e => ({error: String(e)})); + report.checkpoints[label] = data; + console.log(`\n[${label}] ${JSON.stringify(data)}`); + return data; +}; +// A correctly-lit voxel-valley scene: skybox texture background + image-based +// environment + all three scene lights present. +const assertLit = (prefix, d) => { + assert(`${prefix}-no-probe-error`, !d.error, d.error); + assert(`${prefix}-ambient-light`, d.counts?.ambient === 1, JSON.stringify(d.counts)); + assert(`${prefix}-hemisphere-light`, d.counts?.hemisphere === 1, JSON.stringify(d.counts)); + assert(`${prefix}-directional-light`, d.counts?.directional >= 1, JSON.stringify(d.counts)); + assert(`${prefix}-background-is-texture`, d.rendering?.backgroundType === "Texture", `backgroundType=${d.rendering?.backgroundType}`); + assert(`${prefix}-has-texture-assetref`, d.rendering?.hasBackgroundTextureAsset === true, "missing textureAsset (blob-only persistence)"); + assert(`${prefix}-scene-background-texture`, d.sceneEnv?.background === "Texture", `scene.background=${d.sceneEnv?.background}`); + assert(`${prefix}-scene-environment-texture`, d.sceneEnv?.environment === "Texture", `scene.environment=${d.sceneEnv?.environment} (IBL lost -> dark scene)`); +}; + +const storeMode = process.env.STORE_MODE === "filesystem" ? "filesystem" : "indexeddb"; +report.storeMode = storeMode; +console.log(`store mode: ${storeMode}`); + +// Bootstrap the File System Access store via OPFS (no picker needed): create a +// clean OPFS folder, persist its handle where the bootstrap looks, and flip the +// persistence mode. A subsequent fresh navigation makes rehydrateProjectStore() +// register FileSystemProjectStore against it. +const bootstrapFilesystemStore = async () => { + await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry("stem-fs", {recursive: true}); } catch { /* first run */ } + const fsRoot = await root.getDirectoryHandle("stem-fs", {create: true}); + await new Promise((res, rej) => { + const req = indexedDB.open("stemstudio-fs-handle", 1); + req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains("handles")) db.createObjectStore("handles"); }; + req.onsuccess = () => { const tx = req.result.transaction("handles", "readwrite"); tx.objectStore("handles").put(fsRoot, "project-dir"); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }; + req.onerror = () => rej(req.error); + }); + localStorage.setItem("stemstudio.persistence.mode", "filesystem"); + localStorage.setItem("stemstudio.bootstrap.complete", "true"); + }); +}; + +try { + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + if (storeMode === "filesystem") { + await bootstrapFilesystemStore(); + await page.waitForTimeout(300); + } + + await page.goto(baseUrl + "/create/project", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(6000); + await dismissTutorial(); + await openCopilot(); + assert("run-script-hook-exposed", await page.evaluate(() => typeof window.__stemRunScript === "function"), "no __stemRunScript"); + + const execStartUrl = page.url(); + try { + await page.evaluate(({content, fileList}) => + window.__stemRunScript(content, fileList).then( + () => { window.__stemRunScriptDone = "ok"; }, + err => { window.__stemRunScriptDone = String(err && err.message ? err.message : err); }, + ), {content: scriptContent, fileList: folderFiles}); + } catch (e) { console.log("exec evaluate detached:", e.message.slice(0, 120)); } + await page.waitForLoadState("networkidle", {timeout: 90000}).catch(() => {}); + await page.waitForTimeout(7000); + await pinCamera(); + await page.screenshot({path: resolve(outDir, "A-after-import.png")}).catch(() => {}); + assertLit("import", await probe("A_fresh_import")); + + // Save + await page.locator('[data-testid="topnav-app-menu"]').first().click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(400); + const save = page.locator("text=Save Project").first(); + if (await save.isVisible().catch(() => false)) { + await save.click({timeout: 3000}).catch(() => {}); + // CRITICAL for filesystem mode: the OPFS save writes every asset file + // then assets.json LAST and can take several seconds. Reloading before + // it finishes leaves the folder without a manifest, so loadAssets + // returns [] and the scene loses its skybox/models. Wait for the + // "Saved" toast (emitted only after assets are fully persisted). + await page.locator("text=/^Saved$/").first().waitFor({state: "visible", timeout: 30000}).catch(() => {}); + await page.waitForTimeout(1000); + } + const sceneId = (page.url().match(/\/create\/project\/([^/?#]+)/) || [])[1] || null; + assert("scene-id-resolved", !!sceneId, page.url()); + + // === C) full dashboard reload (the primary regression) === + if (sceneId) { + await page.goto(baseUrl + "/dashboard?mode=playground", {waitUntil: "domcontentloaded", timeout: 20000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForTimeout(2000); + const card = page.locator(`[data-scene-id="${sceneId}"]`).first(); + assert("imported-project-listed", (await card.count()) > 0, `data-scene-id="${sceneId}" not found`); + if (await card.count()) { + await card.click({timeout: 5000}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 30000}).catch(() => {}); + await dismissBootstrap(); + await page.waitForSelector("canvas", {timeout: 30000}).catch(() => {}); + await dismissTutorial(); + await page.waitForTimeout(9000); + await pinCamera(); + await page.screenshot({path: resolve(outDir, "C-reloaded.png")}).catch(() => {}); + assertLit("reload", await probe("C_reload")); + } + } +} catch (e) { + console.error("FATAL", e.message); + report.fatal = {message: e.message, stack: e.stack?.slice(0, 600)}; + failures.push("fatal:" + e.message); +} finally { + report.finishedAt = new Date().toISOString(); + report.failures = failures; + writeFileSync(resolve(outDir, "report.json"), JSON.stringify(report, null, 2)); + console.log(`\n=== voxel-valley lighting persistence ===`); + console.log(`Assertions: ${Object.values(report.assertions).filter(a => a.pass).length}/${Object.keys(report.assertions).length} passed`); + console.log(`pageErrors: ${report.pageErrors.length} Output: ${outDir}`); + await browser.close(); + if (failures.length) { console.error(`\nFAIL: ${failures.join(", ")}`); process.exit(1); } + console.log("\nPASS"); +} diff --git a/scripts/playwright/repro-import-inspect.mjs b/scripts/playwright/repro-import-inspect.mjs new file mode 100644 index 00000000..0ba31878 --- /dev/null +++ b/scripts/playwright/repro-import-inspect.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/** + * Focused regression repro: import a stemscript folder, then introspect the + * ACTUAL scene + registries to count how many models / behaviors materialized. + * Unlike oss-import-3dchess.mjs this does not care about save/reload — it asks + * the engine directly what landed. + * + * GAME_FOLDER=/path/to/game node scripts/playwright/repro-import-inspect.mjs + */ +import {chromium} from "playwright"; +import {readFileSync, readdirSync, statSync} from "node:fs"; +import {join} from "node:path"; + +const baseUrl = (process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173").replace(/\/$/, ""); +const gameFolder = process.env.GAME_FOLDER || "/Users/n/erth/Games-StemScript/3d-chess"; +const headed = process.env.HEADED === "1"; + +function walk(root) { + const out = []; + const rec = (dir, prefix) => { + for (const e of readdirSync(dir)) { + if (e === ".DS_Store") continue; + const abs = join(dir, e); + const rel = prefix ? `${prefix}/${e}` : e; + if (statSync(abs).isDirectory()) rec(abs, rel); + else out.push({name: rel, abs}); + } + }; + rec(root, ""); + return out; +} +const mimeFor = n => + n.endsWith(".yaml") || n.endsWith(".yml") ? "text/yaml" + : n.endsWith(".json") || n.endsWith(".stemscript") ? "application/json" + : n.endsWith(".gltf") ? "model/gltf+json" + : n.endsWith(".glb") ? "model/gltf-binary" + : n.endsWith(".png") ? "image/png" + : n.endsWith(".jpg") || n.endsWith(".jpeg") ? "image/jpeg" + : "application/octet-stream"; + +const files = walk(gameFolder); +const scriptFile = files.find(f => f.name.toLowerCase().endsWith(".stemscript")); +const scriptContent = readFileSync(scriptFile.abs, "utf8"); +const folderFiles = files.filter(f => f !== scriptFile).map(f => ({ + name: f.name, mime: mimeFor(f.name), data: readFileSync(f.abs).toString("base64"), +})); + +// What the script *asks* to import. +const wantModels = [...scriptContent.matchAll(/^import model name="([^"]+)"/gm)].map(m => m[1]); +const wantBehaviors = [...scriptContent.matchAll(/^import behavior name="([^"]+)"/gm)].map(m => m[1]); +console.log(`SCRIPT WANTS: ${wantModels.length} models ${JSON.stringify(wantModels)}`); +console.log(`SCRIPT WANTS: ${wantBehaviors.length} behaviors ${JSON.stringify(wantBehaviors)}`); + +const browser = await chromium.launch({headless: !headed}); +const page = await (await browser.newContext({viewport: {width: 1440, height: 900}})).newPage(); +import {writeFileSync as _wf} from "node:fs"; +const importLogs = []; +const allLogs = []; +page.on("console", m => { + const t = m.text(); + allLogs.push(`[${m.type()}] ${t}`); + if (/ScriptImport|importHandler|dedup|Behavior|model|Failed|Error|throw|exec/i.test(t)) { + importLogs.push(`[${m.type()}] ${t.slice(0, 300)}`); + } +}); +page.on("pageerror", e => { importLogs.push(`[pageerror] ${e.message}`); allLogs.push(`[pageerror] ${e.message}`); }); +process.on("exit", () => { try { _wf("/tmp/repro-all-logs.txt", allLogs.join("\n")); } catch {} }); + +try { + await page.goto(baseUrl + "/create/project", {waitUntil: "domcontentloaded", timeout: 30000}); + await page.waitForLoadState("networkidle", {timeout: 15000}).catch(() => {}); + 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(() => {}); + } + await page.waitForLoadState("networkidle", {timeout: 30000}).catch(() => {}); + await page.waitForTimeout(8000); + 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(() => {}); + + const copilotBtn = page.locator('[data-testid="actionbar-copilot"]').first(); + const cBox = await copilotBtn.boundingBox(); + if (cBox) await page.mouse.click(cBox.x + cBox.width / 2, cBox.y + cBox.height / 2); + else await copilotBtn.click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(2000); + + const hookPresent = await page.evaluate(() => typeof window.__stemRunScript === "function"); + console.log("hook present:", hookPresent); + + await page.evaluate(({content, fileList}) => { + return window.__stemRunScript(content, fileList).then( + () => { window.__done = "ok"; }, + err => { window.__done = String(err && err.message ? err.message : err); }, + ); + }, {content: scriptContent, fileList: folderFiles}).catch(() => {}); + await page.waitForLoadState("networkidle", {timeout: 60000}).catch(() => {}); + await page.waitForTimeout(6000); + console.log("exec done signal:", await page.evaluate(() => window.__done ?? null).catch(() => null)); + + // === Save the project so it lands in the ProjectStore === + await page.locator('[data-testid="topnav-app-menu"]').first().click({timeout: 3000, force: true}).catch(() => {}); + await page.waitForTimeout(400); + const save = page.locator('text=Save Project').first(); + if (await save.isVisible().catch(() => false)) { + await save.click({timeout: 3000}).catch(() => {}); + await page.waitForTimeout(4000); + } + + // === Introspect the PERSISTED project (ground truth for reload) === + const state = await page.evaluate(async () => { + const open = () => new Promise((res, rej) => { + const r = indexedDB.open("stemstudio-projects"); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); + const all = (store, idx, key) => new Promise((res, rej) => { + const src = idx ? store.index(idx) : store; + const r = key ? src.getAll(key) : src.getAll(); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); + const db = await open(); + const projects = await all(db.transaction("projects").objectStore("projects")); + if (!projects.length) return {error: "no projects persisted"}; + projects.sort((a, b) => (b.meta?.updatedAt || 0) > (a.meta?.updatedAt || 0) ? 1 : -1); + const p = projects[0]; + let scene = p.sceneJson; + if (typeof scene === "string") { try { scene = JSON.parse(scene); } catch { scene = {}; } } + // scene is an ARRAY of serialized entries; entry 0 carries options/userData. + const arr = Array.isArray(scene) ? scene : []; + const flat = arr.map(e => ({ + gen: e?.metadata?.generator, + name: e?.name, + behaviors: e?.userData?.behaviors ? Object.keys(e.userData.behaviors) : undefined, + })); + const cfgs = arr.flatMap(e => e?.userData?.behaviorConfigs || []); + let assets = []; + try { + assets = await all(db.transaction("assets").objectStore("assets"), "byProjectId", p.meta.id); + } catch (e) { assets = [{error: String(e)}]; } + const byType = {}; + for (const a of assets) { const t = a.type || a.asset?.type || "?"; byType[t] = (byType[t] || 0) + 1; } + return { + projectId: p.meta?.id, + title: p.meta?.title || p.meta?.name, + persistedObjects: flat, + persistedObjectCount: flat.length, + behaviorConfigCount: cfgs.length, + behaviorConfigs: cfgs.map(c => ({id: c.id, name: c.name})), + assetCount: assets.length, + assetsByType: byType, + assetNames: assets.map(a => ({name: a.name || a.asset?.name, type: a.type || a.asset?.type})), + }; + }); + console.log("\n=== PERSISTED PROJECT AFTER IMPORT+SAVE ==="); + console.log(JSON.stringify(state, null, 1)); + console.log("\n=== IMPORT-RELATED LOGS ==="); + console.log(importLogs.join("\n")); +} catch (e) { + console.error("REPRO ERROR:", e); +} finally { + await browser.close(); +} diff --git a/scripts/playwright/site-docs.mjs b/scripts/playwright/site-docs.mjs index 8c4f19e4..00522029 100755 --- a/scripts/playwright/site-docs.mjs +++ b/scripts/playwright/site-docs.mjs @@ -43,6 +43,11 @@ try { /stem\s*studio|open[- ]?source/i.test(introHeading), introHeading.slice(0, 120), ); + const introLinks = await page.locator(".docs-content a").evaluateAll((els) => + els.map((e) => e.getAttribute("href") ?? ""), + ); + const hasGithubRewrite = introLinks.some((h) => h.includes("github.com/")); + assert("relative repo links rewritten to github.com", hasGithubRewrite, introLinks.slice(0, 4).join(", ")); const sectionLabels = await page.locator(".docs-sidebar h5").allInnerTexts(); assert( @@ -54,6 +59,10 @@ try { "sidebar includes Engine section", sectionLabels.some((s) => /engine/i.test(s)), ); + assert( + "sidebar includes APIs section", + sectionLabels.some((s) => /apis/i.test(s)), + ); // Navigate to Architecture await page.locator('.docs-sidebar a:has-text("Architecture")').first().click(); @@ -73,13 +82,21 @@ try { const byokHeadings = await page.locator(".docs-content h1, .docs-content h2").count(); assert("BYOK page renders headings", byokHeadings > 0); - const links = await page.locator(".docs-content a").evaluateAll((els) => - els.map((e) => e.getAttribute("href") ?? ""), + // API docs added for the playground scripting surface. + await page.locator('.docs-sidebar a:has-text("Runtime API")').first().click(); + await page.waitForURL(/\/docs\/runtime-api/, {timeout: 5000}); + await page.waitForSelector('.docs-content h1:has-text("Runtime API")', {timeout: 6000}); + const runtimeText = await page.locator(".docs-content").innerText(); + assert( + "Runtime API page documents current erth events surface", + /this\.erth\.events\.on/.test(runtimeText), + runtimeText.slice(0, 160), + ); + assert( + "Runtime API page includes game-derived examples", + /Patterns from real playground games/.test(runtimeText) && /race\.trackReady/.test(runtimeText), + runtimeText.slice(-220), ); - const relativeRepoLink = links.find((h) => /^github\.com|^https?:\/\/github\.com|^\/docs\//.test(h)); - void relativeRepoLink; - const hasGithubRewrite = links.some((h) => h.includes("github.com/")); - assert("relative repo links rewritten to github.com", hasGithubRewrite, links.slice(0, 4).join(", ")); } catch (e) { failures.push(`exception: ${e.message}`); console.error(e); diff --git a/scripts/playwright/verify-playground-play-output/1-editor.png b/scripts/playwright/verify-playground-play-output/1-editor.png deleted file mode 100644 index 538fcddb..00000000 Binary files a/scripts/playwright/verify-playground-play-output/1-editor.png and /dev/null differ diff --git a/scripts/playwright/verify-playground-play-output/2-play.png b/scripts/playwright/verify-playground-play-output/2-play.png deleted file mode 100644 index 73933a2a..00000000 Binary files a/scripts/playwright/verify-playground-play-output/2-play.png and /dev/null differ diff --git a/scripts/playwright/verify-playground-play-output/3-play-running.png b/scripts/playwright/verify-playground-play-output/3-play-running.png deleted file mode 100644 index a414c5ef..00000000 Binary files a/scripts/playwright/verify-playground-play-output/3-play-running.png and /dev/null differ