From f778a0add7c923f78cb61f0942a2a9f24bdff8ea Mon Sep 17 00:00:00 2001 From: oddessentials Date: Sun, 8 Nov 2026 22:00:02 -0500 Subject: [PATCH 1/4] feat: customize a Yard with World characters and companions Customize this Yard replaces the world, any crew's character, or the companions of one Yard with Agent Guild: World pieces from the Steam Workshop or a local folder. The picker opens in World's Workshop folder, found from Steam's library records. Pieces are checked before the loader runs against the exact glTF shape World exports, including glTF's accessor types, plain trees, unit rotations, supported texture channels, playable in-place clips and bounded data, then drawn with the Yard's own loader in a bounded worker that fully decodes every PNG, JPEG and WebP texture. Pieces are installed atomically per slot, and obsolete downloads are cancelled. A piece that cannot be drawn falls back to the original, and each row says whether its piece is original, being checked, loading, customized or failed to load. Guild dialogs now reserve room for their ornate frame, and the Customize dialog stacks its buttons on phones. Co-Authored-By: Claude Opus 5.5 --- docs/api.md | 8 +- docs/yard/CONTRACT.md | 2 +- docs/yard/fixture.mjs | 4 +- package-lock.json | 37 ++ package.json | 3 + src/manager/config.mjs | 1 + src/manager/folder-browser.mjs | 27 +- src/manager/main.mjs | 2 + src/manager/server.mjs | 24 +- src/manager/steam-workshop.mjs | 64 +++ src/manager/yard-piece-check.mjs | 421 ++++++++++++++++++++ src/manager/yard-pieces.mjs | 188 +++++++++ tests/browser/layout.mjs | 58 ++- tests/browser/yard.mjs | 455 +++++++++++++++++++++- tests/fixtures/pieces/character.glb | Bin 0 -> 8672 bytes tests/fixtures/pieces/companion.glb | Bin 0 -> 5016 bytes tests/fixtures/pieces/external_uri.glb | Bin 0 -> 8696 bytes tests/fixtures/pieces/missing_bone.glb | Bin 0 -> 8412 bytes tests/fixtures/pieces/missing_clip.glb | Bin 0 -> 8244 bytes tests/fixtures/pieces/texture_over.glb | Bin 0 -> 8672 bytes tests/fixtures/pieces/truncated.glb | Bin 0 -> 4336 bytes tests/page-folders.test.mjs | 29 +- tests/server-source.test.mjs | 2 +- tests/steam-workshop.test.mjs | 64 +++ tests/yard-pieces.test.mjs | 514 +++++++++++++++++++++++++ tests/yard.test.mjs | 42 +- web/app.js | 69 +++- web/index.html | 15 +- web/skins/guild/skin.css | 15 +- web/styles.css | 13 + web/yard/model.mjs | 46 ++- web/yard/renderer.js | 155 ++++++-- web/yard/view.js | 134 ++++++- 33 files changed, 2293 insertions(+), 99 deletions(-) create mode 100644 src/manager/steam-workshop.mjs create mode 100644 src/manager/yard-piece-check.mjs create mode 100644 src/manager/yard-pieces.mjs create mode 100644 tests/fixtures/pieces/character.glb create mode 100644 tests/fixtures/pieces/companion.glb create mode 100644 tests/fixtures/pieces/external_uri.glb create mode 100644 tests/fixtures/pieces/missing_bone.glb create mode 100644 tests/fixtures/pieces/missing_clip.glb create mode 100644 tests/fixtures/pieces/texture_over.glb create mode 100644 tests/fixtures/pieces/truncated.glb create mode 100644 tests/steam-workshop.test.mjs create mode 100644 tests/yard-pieces.test.mjs diff --git a/docs/api.md b/docs/api.md index f366407..caaeaff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -638,7 +638,7 @@ All paths are under `/api/v1`. | GET | `/remote-access` | | `{ remoteAccess }`: `revision`, `mode`, `busy`, `pending`, `problem` and connection details. `url` is the active address. `candidate` is the address enabling would use with Tailscale's current settings, `{ url, existing, remembered, standardUrl? }`: `remembered` when it is the address used before, and `standardUrl` when `standard: true` would choose another. | | POST | `/remote-access/check` | | `{ remoteAccess }` after refreshing Tailscale's status. | | PUT | `/remote-access` | `{ revision, action, adopt?, hosts?, origins? }` | `202`. `action` is `enable` (`adopt: true` manages a matching existing route; `standard: true`, only while remote access is off, skips the remembered address and takes port 443, else the next free port), `disable`, `custom` (with `hosts` and `origins`) or `forget` (leave an inactive route awaiting cleanup in Tailscale). Poll until `busy` is null and read `problem`. 409 `stale_settings` for an old `revision`. Needs the API token. Revoked WebSockets close with code 4403; their terminals keep running. | -| GET | `/folders?path=&files=` | | `{ path, parent, home, segments, roots, entries, truncated, note }`: the subfolders of `path` (blank or `~` for the home folder). `entries` are `{ name, path, hidden }`. A missing `path` lists the nearest folder above it and names the requested path in `note`. `files=agworld` adds `files`, the world files in that folder, each `{ name, path }`. | +| GET | `/folders?path=&files=&pieces=` | | `{ path, parent, home, segments, roots, entries, truncated, note }`: the subfolders of `path` (blank or `~` for the home folder). `entries` are `{ name, path, hidden }`. A missing `path` lists the nearest folder above it and names the requested path in `note`. `files=agworld` adds `files`, the world files in that folder, each `{ name, path }`. `pieces=character` or `pieces=companion` adds `pieces`, the matching pieces in that folder and the folders directly inside it, each `{ title, folder, path }`, sorted by title, and `workshop`, the Steam Workshop folder of Agent Guild: World found from Steam's library records, or null. | | POST | `/folders` | `{ path, name }` | 201 with the `GET /folders` listing of the new folder `name`, made inside `path`. | | GET | `/autostart` | | `{ autostart: { available, enabled, reason, note?, lastRun?, log? } }`: whether the manager starts when the user signs in. `reason` says why it is unavailable. `lastRun` is null or `{ at, outcome }`. On Linux outside WSL it also has `mode` (`off`, `sign-in`, `boot` or `both`) and `boot`, the systemd service's `{ available, enabled, reason, note, user, linger, state, commands }`. | | PUT | `/autostart` | `{ enabled }` or `{ mode }` | `{ autostart }` as above. `mode` (`off`, `sign-in` or `boot`) makes that the one way the manager starts; `boot` is offered only on Linux with systemd. | @@ -669,6 +669,9 @@ All paths are under `/api/v1`. | GET | `/yard-worlds` | | `{ yardWorlds }`: the world replacing each Yard, by skin. A world is `{ title, revision, lights }`, or `{ title, problem }` when it cannot be used, in which case the Yard shows its original. A Yard with no entry shows its original. | | PUT | `/yard-worlds/:skin` | `{ path }` | `{ yardWorlds }`: checks the `.agworld` file at `path` on the manager's computer and makes it this Yard's world, replacing any before it. 400 `world_invalid` says what is wrong and leaves the Yard unchanged; 404 `unknown_yard`; 409 `world_busy` while another install runs; 409 `worlds_unsupported` without a data folder; 500 `world_unwritable`. Its plates and skies are served at `/yard/worlds/:skin/:file`. | | DELETE | `/yard-worlds/:skin` | | `{ yardWorlds }`: deletes this Yard's world, so it shows its original. | +| GET | `/yard-pieces` | | `{ yardPieces }`: the pieces replacing each Yard's characters and companions, by skin, then by slot: a crew id (`anthropic`, `openai`, `google`, `xai`, `shell`, `docker`) or `companion`. A piece is `{ title, revision }`, or `{ title, problem }` when it cannot be used, in which case the Yard shows its original. Terminal AI Chat wears the `shell` piece, gilded. | +| PUT | `/yard-pieces/:skin/:slot` | `{ path }` | `{ yardPieces }`: copies the piece folder at `path` on the manager's computer and makes it this slot's piece, replacing any before it. The folder holds `piece.json` (`format: 1`, `kind` `character` or `companion`, optional `title`) and `character.glb`, or all four of `companion_0.glb` to `companion_3.glb`; the models follow the Yard's skeleton, animations and `yard` extras, and are checked by drawing them with the Yard's own loader; their sizes, joints, clip lengths and `yard` extras must fall within the limits in `src/manager/yard-piece-check.mjs` and `yardExtras` in `web/yard/model.mjs`. 400 `piece_invalid` says what is wrong and leaves the slot unchanged; 404 `unknown_yard` or `unknown_slot`; 409 `piece_busy` while another change runs; 409 `pieces_unsupported` without a data folder; 500 `piece_unwritable`. Its models are served at `/yard/pieces/:skin/:slot/:file`. | +| DELETE | `/yard-pieces/:skin/:slot` | | `{ yardPieces }`: deletes this slot's piece, so it shows its original. 500 `piece_unwritable` leaves the piece in place. | | POST | `/companion/install` | | `201 { session }`: starts a session that downloads the latest Grove Companion, checks its signature, installs it and starts it. 400 `companion_unsupported` unless the manager runs on Windows or macOS; 409 `install_in_progress` while one runs. | | POST | `/yard/fireworks` | | `{ ok, pages }`: fireworks over the Yard of every open page, sent as `yard.fireworks`. `pages` is how many pages were listening. | | GET | `/changelog` | | `{ refreshing, okAt, error, releases }`: Agent Guild's own releases, newest first, each `{ version, url, publishedAt, sections: [{ title, changes }] }`, where `title` may be null and a change is a list of text runs. A `changelog.updated` event follows a refresh. | @@ -746,7 +749,7 @@ but `{ type: "ping" }`, which the manager answers with `{ type: "pong" }`. | Message | Meaning | | --- | --- | -| `{ type: "hello", version, pid, platform, startedAt, launcher, folderOpener, remoteAccess, upgrade, yardArt, yardWorlds, sessions, heartbeat, notesRevision, notesUnreadable }` | Sent first: the manager (as in `/info`), the full session list and the notepad's revision. | +| `{ type: "hello", version, pid, platform, startedAt, launcher, folderOpener, remoteAccess, upgrade, yardArt, yardWorlds, yardPieces, sessions, heartbeat, notesRevision, notesUnreadable }` | Sent first: the manager (as in `/info`), the full session list and the notepad's revision. | | `{ type: "session.created", session }` | A session was started by any client. | | `{ type: "session.updated", session }` | Status, activity, agents, name or size changed. | | `{ type: "session.removed", sessionId }` | A session was removed. | @@ -755,6 +758,7 @@ but `{ type: "ping" }`, which the manager answers with `{ type: "pong" }`. | `{ type: "news.updated" }` | Fetch `/news` again. | | `{ type: "yard-art.updated", yardArt }` | The Yard's art changed state or made download progress, as in `/yard-art`. | | `{ type: "yard-worlds.updated", yardWorlds }` | A Yard's world was replaced or deleted, as in `/yard-worlds`. | +| `{ type: "yard-pieces.updated", yardPieces }` | A Yard's character or companion was replaced or deleted, as in `/yard-pieces`. | | `{ type: "yard.fireworks" }` | Fireworks over the Yard, when it is on show. | | `{ type: "changelog.updated" }` | Fetch `/changelog` again. | | `{ type: "github.updated" }` | Fetch `/github` again. | diff --git a/docs/yard/CONTRACT.md b/docs/yard/CONTRACT.md index 8e8f3ca..882039b 100644 --- a/docs/yard/CONTRACT.md +++ b/docs/yard/CONTRACT.md @@ -109,7 +109,7 @@ has posed them, about the model's own up and right axes, the neck taking `STAGING.look.yaw` (0.7 rad) either way and `STAGING.look.pitch` (0.3 rad) up or down, easing in and out. A model whose head tears at those limits carries narrower ones as `yard.look` (`{"yaw", "pitch"}`, in radians), and a -client turns it no further (`lookLimits(yard)`). A walking character does +client turns it no further (`lookLimits(yardExtras(yard, 'character').look)`). A walking character does not look. With reduced motion a character does not glance. ## Clip choices diff --git a/docs/yard/fixture.mjs b/docs/yard/fixture.mjs index 3a070bc..5d8b037 100644 --- a/docs/yard/fixture.mjs +++ b/docs/yard/fixture.mjs @@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { createManagerServer } from '../../src/manager/server.mjs'; -export async function startFixture({port=0,yardArt,yardWorlds}={}) { +export async function startFixture({port=0,yardArt,yardWorlds,yardPieces}={}) { const calls=[],data=new Map(),terminals=new Map(); let next=16; const now=()=>new Date().toISOString(); @@ -96,7 +96,7 @@ export async function startFixture({port=0,yardArt,yardWorlds}={}) { agents:i===0?[{id:'a1',name:'Explorer',status:'working',detail:'Reading the project'},{id:'a2',name:'Reviewer',status:'waiting',detail:'Waiting for input'}]:i===1?[{id:'a3',name:'Designer',status:'working'}]:[], shells:i===4?[{id:'sh1'}]:[]},false)); const token=randomBytes(24).toString('hex'); - const api=createManagerServer({manager,registry,usage,history,modelStats,news,changelog,github,selfUpdate,token,port,webDir:fileURLToPath(new URL('../../web',import.meta.url)),version:'1.0.0',...(yardArt?{yardArt}:{}),...(yardWorlds?{yardWorlds}:{}), + const api=createManagerServer({manager,registry,usage,history,modelStats,news,changelog,github,selfUpdate,token,port,webDir:fileURLToPath(new URL('../../web',import.meta.url)),version:'1.0.0',...(yardArt?{yardArt}:{}),...(yardWorlds?{yardWorlds}:{}),...(yardPieces?{yardPieces}:{}), onShutdownRequest:({restart})=>{calls.push(['shutdown',restart]);manager.emit('event',{type:'manager.stopped',remaining:0,restart});if(restart){data.clear();manager.closing=false;setTimeout(()=>manager.emit('event',{type:'hello',version:'1.1.0',pid:process.pid,sessions:[],upgrade:null}),100);}} }); await api.listen(); diff --git a/package-lock.json b/package-lock.json index c160661..3b42bee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@oddessentials/agent-guild", "version": "0.0.0-development", "bundleDependencies": [ + "@jsquash/jpeg", + "@jsquash/png", + "@jsquash/webp", "@noble/hashes", "@xterm/addon-fit", "@xterm/addon-serialize", @@ -22,6 +25,9 @@ ], "license": "AGPL-3.0-only", "dependencies": { + "@jsquash/jpeg": "1.6.0", + "@jsquash/png": "3.1.1", + "@jsquash/webp": "1.5.0", "@noble/hashes": "2.4.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-serialize": "^0.14.0", @@ -1126,6 +1132,30 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jsquash/jpeg": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jsquash/jpeg/-/jpeg-1.6.0.tgz", + "integrity": "sha512-zwN46Awh1VM6gXlIcALwb5WzqK5H2e6+Awcs1QP8AvS8ohsK/sbE4esvmH4jhlhW7+CgiUUww66vg0aTnlSIMA==", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/@jsquash/png": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jsquash/png/-/png-3.1.1.tgz", + "integrity": "sha512-C10pc+0H6j0h8fENOfnGOvkXCmvpSQTDGlfGd0sHphZhPSGTyLjIrHba0FaZZdsKqA/wlmhYicUHb92vfZphaw==", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/@jsquash/webp": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jsquash/webp/-/webp-1.5.0.tgz", + "integrity": "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw==", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "wasm-feature-detect": "^1.2.11" + } + }, "node_modules/@noble/hashes": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", @@ -1547,6 +1577,13 @@ "dev": true, "license": "MIT" }, + "node_modules/wasm-feature-detect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", + "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==", + "inBundle": true, + "license": "Apache-2.0" + }, "node_modules/ws": { "version": "8.22.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", diff --git a/package.json b/package.json index 447aae7..799e1aa 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,9 @@ "test": "node --test --test-concurrency=1 \"tests/*.test.mjs\"" }, "dependencies": { + "@jsquash/jpeg": "1.6.0", + "@jsquash/png": "3.1.1", + "@jsquash/webp": "1.5.0", "@noble/hashes": "2.4.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-serialize": "^0.14.0", diff --git a/src/manager/config.mjs b/src/manager/config.mjs index bdfd7fd..2a5abe5 100644 --- a/src/manager/config.mjs +++ b/src/manager/config.mjs @@ -57,6 +57,7 @@ export const paths = { /** The Yard's art packs, one folder per content id. */ get yardArt() { return path.join(dataDir(), 'yard-art'); }, get yardWorlds() { return path.join(dataDir(), 'yard-worlds'); }, + get yardPieces() { return path.join(dataDir(), 'yard-pieces'); }, }; function writePrivate(file, contents) { diff --git a/src/manager/folder-browser.mjs b/src/manager/folder-browser.mjs index 8b0ec91..78b8247 100644 --- a/src/manager/folder-browser.mjs +++ b/src/manager/folder-browser.mjs @@ -1,9 +1,11 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { readManifest } from './yard-pieces.mjs'; const failure = (status, code, message) => Object.assign(new Error(message), { status, code }); -const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) || (a.name < b.name ? -1 : 1); +const PIECE_SCAN = 500; +const byName =(a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) || (a.name < b.name ? -1 : 1); export function folderSegments(dir, paths = path) { const root = paths.parse(dir).root; @@ -30,7 +32,7 @@ export function folderNameProblem(name, platform = process.platform) { return null; } -export function createFolderBrowser({ platform = process.platform, home = os.homedir(), limit = 2000, driveTimeoutMs = 500, fsp = fs.promises } = {}) { +export function createFolderBrowser({ platform = process.platform, home = os.homedir(), limit = 2000, driveTimeoutMs = 500, fsp = fs.promises, workshop = null } = {}) { const paths = platform === 'win32' ? path.win32 : path.posix; async function roots() { @@ -79,6 +81,23 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom .map((entry) => ({ name: entry.name, path: paths.join(dir, entry.name) })).sort(byName); } + async function pieceIn(dir, kind) { + const file = paths.join(dir, 'piece.json'); + try { + const stat = await fsp.lstat(file); + if (!stat.isFile() || stat.size > 16 * 1024) return null; + const { title } = readManifest(await fsp.readFile(file), kind); + return { title: title || paths.basename(dir), folder: paths.basename(dir), path: dir }; + } catch { + return null; + } + } + + async function piecesIn(dir, folders, kind) { + const found = await Promise.all([dir, ...folders.slice(0, PIECE_SCAN).map((entry) => entry.path)].map((folder) => pieceIn(folder, kind))); + return found.filter(Boolean).sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base', numeric: true }) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + } + const browser = { async create(rawParent, rawName) { if (typeof rawParent !== 'string' || !rawParent.trim() || rawParent.includes('\0')) throw failure(400, 'bad_path', 'The folder must be a path.'); @@ -103,7 +122,7 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom } return browser.list(dir); }, - async list(raw, { extension = null } = {}) { + async list(raw, { extension = null, pieces = null } = {}) { if (raw !== undefined && raw !== null && (typeof raw !== 'string' || raw.includes('\0'))) { throw failure(400, 'bad_path', 'The folder must be a path.'); } @@ -113,6 +132,7 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom const dir = await nearestFolder(wanted); const [folders, drives, files] = await Promise.all([entries(dir), roots(), extension ? filesIn(dir, extension) : null]); const parent = paths.dirname(dir); + const [found, steam] = pieces ? await Promise.all([piecesIn(dir, folders, pieces), workshop ? workshop() : null]) : [null, null]; return { path: dir, parent: parent === dir ? null : parent, @@ -123,6 +143,7 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom truncated: folders.length > limit, note: dir === wanted ? null : wanted, ...(files ? { files: files.slice(0, limit) } : {}), + ...(found ? { pieces: found, workshop: steam } : {}), }; }, }; diff --git a/src/manager/main.mjs b/src/manager/main.mjs index d1d30bb..0853dfc 100644 --- a/src/manager/main.mjs +++ b/src/manager/main.mjs @@ -18,6 +18,7 @@ import { GitHub } from './github.mjs'; import { createManagerServer } from './server.mjs'; import { YardArt } from './yard-art.mjs'; import { YardWorlds } from './yard-worlds.mjs'; +import { YardPieces } from './yard-pieces.mjs'; import { loadPty } from './pty.mjs'; import { createNotesStore } from './notes.mjs'; import { createCardNames } from './card-names.mjs'; @@ -188,6 +189,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, launcher: launcherPath(), yardArt: new YardArt({ webDir, dir: paths.yardArt }), yardWorlds: new YardWorlds({ dir: paths.yardWorlds }), + yardPieces: new YardPieces({ dir: paths.yardPieces }), mcpConnect, onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then((code) => process.exit(code)), }); diff --git a/src/manager/server.mjs b/src/manager/server.mjs index d916cfb..c823ca6 100644 --- a/src/manager/server.mjs +++ b/src/manager/server.mjs @@ -20,6 +20,8 @@ import { MODES as AUTOSTART_MODES } from './autostart.mjs'; import { ptyRestartProblem } from './pty.mjs'; import { YardArt } from './yard-art.mjs'; import { YardWorlds } from './yard-worlds.mjs'; +import { YardPieces } from './yard-pieces.mjs'; +import { createWorkshopFinder } from './steam-workshop.mjs'; import { spokenText } from './spoken-text.mjs'; import { MCP_ACTIONS } from './mcp-connect.mjs'; @@ -70,8 +72,8 @@ const SECURITY_HEADERS = { 'Referrer-Policy': 'no-referrer', 'Cross-Origin-Resource-Policy': 'same-origin', 'Content-Security-Policy': - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://i.ytimg.com; " + - "connect-src 'self' ws://127.0.0.1:* ws://localhost:*; frame-src https://www.youtube-nocookie.com; " + + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://i.ytimg.com; " + + "connect-src 'self' blob: ws://127.0.0.1:* ws://localhost:*; frame-src https://www.youtube-nocookie.com; " + "frame-ancestors vscode-webview: vscode-file:; base-uri 'none'; form-action 'none'", }; @@ -177,7 +179,7 @@ export function createManagerServer({ notes = createNotesStore(), deviceSignIn = new DeviceSignIn({ token }), folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }), - folderBrowser = createFolderBrowser(), + folderBrowser = createFolderBrowser({ workshop: createWorkshopFinder() }), /** The double-click launcher file for this platform, or null when the package carries none. */ launcher = null, /** @type {(opts: { restart: boolean }) => void} */ @@ -187,6 +189,7 @@ export function createManagerServer({ /** The Yard's art; without a data folder, only a checkout's. */ yardArt = new YardArt({ webDir }), yardWorlds = new YardWorlds(), + yardPieces = new YardPieces(), mcpConnect = null, heartbeatMs = 30000, }) { @@ -300,6 +303,12 @@ export function createManagerServer({ if (!world) throw new HttpError(404, 'not found', 'not_found'); return serveFile(req, res, world); } + if (rel.startsWith('yard/pieces/')) { + const [skin, slot, name, ...rest] = rel.slice('yard/pieces/'.length).split('/'); + const piece = rest.length ? null : yardPieces.file(skin, slot, name); + if (!piece) throw new HttpError(404, 'not found', 'not_found'); + return serveFile(req, res, piece); + } let root = webDir; if (rel.startsWith('yard/assets/')) { root = yardArt.assetsDir(); @@ -420,6 +429,10 @@ export function createManagerServer({ const yardWorld = route.match(/^\/yard-worlds\/([a-z]+)$/); if (yardWorld && method === 'PUT') return sendJson(res, 200, { yardWorlds: await yardWorlds.replace(yardWorld[1], (await readJsonBody(req)).path) }); if (yardWorld && method === 'DELETE') return sendJson(res, 200, { yardWorlds: await yardWorlds.restore(yardWorld[1]) }); + if (route === '/yard-pieces' && method === 'GET') return sendJson(res, 200, { yardPieces: yardPieces.snapshot() }); + const yardPiece = route.match(/^\/yard-pieces\/([a-z]+)\/([a-z]+)$/); + if (yardPiece && method === 'PUT') return sendJson(res, 200, { yardPieces: await yardPieces.replace(yardPiece[1], yardPiece[2], (await readJsonBody(req)).path) }); + if (yardPiece && method === 'DELETE') return sendJson(res, 200, { yardPieces: await yardPieces.restore(yardPiece[1], yardPiece[2]) }); if (route === '/yard/fireworks' && method === 'POST') { broadcast({ type: 'yard.fireworks' }); return sendJson(res, 200, { ok: true, pages: [...eventClients].filter((ws) => !ws.revoked && ws.readyState === ws.OPEN).length }); @@ -447,7 +460,7 @@ export function createManagerServer({ return sendJson(res, 200, { ok: true }); } if (route === '/folders' && method === 'GET') { - return sendJson(res, 200, await folderBrowser.list(url.searchParams.get('path') ?? undefined, { extension: url.searchParams.get('files') === 'agworld' ? '.agworld' : null })); + return sendJson(res, 200, await folderBrowser.list(url.searchParams.get('path') ?? undefined, { extension: url.searchParams.get('files') === 'agworld' ? '.agworld' : null, pieces: ['character', 'companion'].includes(url.searchParams.get('pieces')) ? url.searchParams.get('pieces') : null })); } if (route === '/folders' && method === 'POST') { const { path: parent, name } = await readJsonBody(req); @@ -784,10 +797,11 @@ export function createManagerServer({ deviceSignIn.on('updated', () => broadcast({ type: 'sign-in.updated' })); yardArt.on('updated', (status) => broadcast({ type: 'yard-art.updated', yardArt: status })); yardWorlds.on('updated', (worlds) => broadcast({ type: 'yard-worlds.updated', yardWorlds: worlds })); + yardPieces.on('updated', (pieces) => broadcast({ type: 'yard-pieces.updated', yardPieces: pieces })); function handleEvents(ws, req) { eventClients.add(ws); - const hello = { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), yardArt: yardArt.snapshot(), yardWorlds: yardWorlds.snapshot(), sessions: manager.list(), heartbeat: true }; + const hello = { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), yardArt: yardArt.snapshot(), yardWorlds: yardWorlds.snapshot(), yardPieces: yardPieces.snapshot(), sessions: manager.list(), heartbeat: true }; const notesHello = notes.helloRevision(); if (notesHello.known) hello.notesRevision = notesHello.revision; else hello.notesUnreadable = true; diff --git a/src/manager/steam-workshop.mjs b/src/manager/steam-workshop.mjs new file mode 100644 index 0000000..991f6e2 --- /dev/null +++ b/src/manager/steam-workshop.mjs @@ -0,0 +1,64 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export const WORLD_APP = '5462970'; + +export function parseVdf(text) { + const tokens = text.match(/"(?:[^"\\]|\\.)*"|[{}]/g) ?? []; + const root = {}, stack = [root]; + let key = null; + for (const token of tokens) { + const top = stack[stack.length - 1]; + if (token === '{') { + if (key === null) return root; + stack.push(top[key.toLowerCase()] = {}); + key = null; + } else if (token === '}') { + if (stack.length > 1) stack.pop(); + key = null; + } else { + const value = token.slice(1, -1).replace(/\\(.)/g, '$1'); + if (key === null) key = value; + else { top[key.toLowerCase()] = value; key = null; } + } + } + return root; +} + +function windowsSteam() { + return new Promise((resolve) => { + execFile('reg', ['query', 'HKCU\\Software\\Valve\\Steam', '/v', 'SteamPath'], { timeout: 5000, windowsHide: true }, (err, stdout) => { + resolve(err ? null : String(stdout).match(/SteamPath\s+REG_\w+\s+(.+)/)?.[1]?.trim() || null); + }); + }); +} + +export function steamRoots({ platform = process.platform, home = os.homedir(), registry = windowsSteam } = {}) { + if (platform === 'win32') return registry().then((root) => (root ? [root] : [])); + if (platform === 'darwin') return Promise.resolve([path.join(home, 'Library', 'Application Support', 'Steam')]); + return Promise.resolve([path.join(home, '.local', 'share', 'Steam'), path.join(home, '.steam', 'steam'), path.join(home, '.var', 'app', 'com.valvesoftware.Steam', '.local', 'share', 'Steam')]); +} + +export async function worldLibrary({ roots = steamRoots(), fsp = fs.promises } = {}) { + for (const root of await roots) { + let folders; + try { folders = parseVdf(await fsp.readFile(path.join(root, 'steamapps', 'libraryfolders.vdf'), 'utf8')).libraryfolders; } catch { continue; } + for (const entry of Object.values(folders ?? {})) { + if (typeof entry === 'object' && typeof entry.path === 'string' && entry.apps && Object.hasOwn(entry.apps, WORLD_APP)) return path.normalize(entry.path); + } + } + return null; +} + +export function createWorkshopFinder({ library = () => worldLibrary(), fsp = fs.promises } = {}) { + let found = null; + return async () => { + found ??= library().catch(() => null); + const root = await found; + if (!root) { found = null; return null; } + const folder = path.join(root, 'steamapps', 'workshop', 'content', WORLD_APP); + return (await fsp.stat(folder).then((stat) => stat.isDirectory(), () => false)) ? folder : null; + }; +} diff --git a/src/manager/yard-piece-check.mjs b/src/manager/yard-piece-check.mjs new file mode 100644 index 0000000..2e10190 --- /dev/null +++ b/src/manager/yard-piece-check.mjs @@ -0,0 +1,421 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'; +import { SKELETON, CLIPS, yardExtras, playableClip, unitQuaternion } from '../../web/yard/model.mjs'; + +export const LIMITS = { + character: { faces: 210000, side: 4096, bytes: 128 * 1024 * 1024 }, + companion: { faces: 63000, side: 2048, bytes: 48 * 1024 * 1024 }, +}; +const COUNTS = { nodes: 256, meshes: 16, materials: 16, textures: 16, samplers: 16, images: 8, animations: 16, accessors: 2048, bufferViews: 2048, skins: 2, buffers: 1 }; +const REQUIRED_OK = new Set(['EXT_texture_webp', 'KHR_texture_transform', 'KHR_materials_emissive_strength', 'KHR_mesh_quantization']); +const ELEMENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }; +const COMPONENT_BYTES = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }; +const MATERIAL_TEXTURES = { + KHR_materials_clearcoat: ['clearcoatTexture', 'clearcoatRoughnessTexture', 'clearcoatNormalTexture'], + KHR_materials_diffuse_roughness: ['diffuseRoughnessTexture'], + KHR_materials_sheen: ['sheenColorTexture', 'sheenRoughnessTexture'], + KHR_materials_specular: ['specularTexture', 'specularColorTexture'], + KHR_materials_transmission: ['transmissionTexture'], + KHR_materials_iridescence: ['iridescenceTexture', 'iridescenceThicknessTexture'], + KHR_materials_anisotropy: ['anisotropyTexture'], + KHR_materials_volume: ['thicknessTexture'], + EXT_materials_bump: ['bumpTexture'], +}; +const IN_PLACE = 0.1; +const FLOAT = [5126, false]; +const SIGNED = { byte: [5120, false], normalByte: [5120, true], short: [5122, false], normalShort: [5122, true] }; +const UNSIGNED = { byte: [5121, false], normalByte: [5121, true], short: [5123, false], normalShort: [5123, true], int: [5125, false] }; +const ATTRIBUTE_FORMS = { + POSITION: [['VEC3'], [FLOAT]], + NORMAL: [['VEC3'], [FLOAT]], + TANGENT: [['VEC4'], [FLOAT]], + TEXCOORD: [['VEC2'], [FLOAT, UNSIGNED.normalByte, UNSIGNED.normalShort]], + COLOR: [['VEC3', 'VEC4'], [FLOAT, UNSIGNED.normalByte, UNSIGNED.normalShort]], + JOINTS: [['VEC4'], [UNSIGNED.byte, UNSIGNED.short]], + WEIGHTS: [['VEC4'], [FLOAT, UNSIGNED.normalByte, UNSIGNED.normalShort]], +}; +const QUANTIZED_FORMS = { + POSITION: [SIGNED.byte, SIGNED.normalByte, UNSIGNED.byte, UNSIGNED.normalByte, SIGNED.short, SIGNED.normalShort, UNSIGNED.short, UNSIGNED.normalShort], + NORMAL: [SIGNED.normalByte, SIGNED.normalShort], + TANGENT: [SIGNED.normalByte, SIGNED.normalShort], + TEXCOORD: [SIGNED.byte, SIGNED.normalByte, UNSIGNED.byte, SIGNED.short, SIGNED.normalShort, UNSIGNED.short], +}; +const TRACKS ={ translation: 'VEC3', rotation: 'VEC4', scale: 'VEC3' }; +const METADATA = ['name', 'extras']; +const PROPERTIES = { + accessor: ['bufferView', 'byteOffset', 'componentType', 'normalized', 'count', 'type', 'max', 'min', 'sparse', ...METADATA], + bufferView: ['buffer', 'byteOffset', 'byteLength', 'byteStride', 'target', ...METADATA], + skin: ['inverseBindMatrices', 'skeleton', 'joints', ...METADATA], + node: ['children', 'skin', 'matrix', 'mesh', 'rotation', 'scale', 'translation', 'extensions', ...METADATA], + mesh: ['primitives', ...METADATA], + primitive: ['attributes', 'indices', 'material', 'mode', 'targets', ...METADATA], + attributes: /^(POSITION|NORMAL|TANGENT|(TEXCOORD|COLOR|JOINTS|WEIGHTS)_\d+)$/, + animation: ['channels', 'samplers', ...METADATA], + sampler: ['input', 'interpolation', 'output', ...METADATA], + channel: ['sampler', 'target', ...METADATA], + target: ['node', 'path', ...METADATA], +}; +const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); +const MAX_JSON = 1024 * 1024; +const TIMEOUT = 30000; + +const require = createRequire(import.meta.url); +const DECODERS = { + 'image/png': ['@jsquash/png/decode.js', '@jsquash/png/codec/pkg/squoosh_png_bg.wasm'], + 'image/jpeg': ['@jsquash/jpeg/decode.js', '@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm'], + 'image/webp': ['@jsquash/webp/decode.js', '@jsquash/webp/codec/dec/webp_dec.wasm'], +}; +const decoders = new Map(); + +function decoder(type) { + if (!DECODERS[type]) return Promise.reject(new Error('unsupported image')); + if (!decoders.has(type)) { + decoders.set(type, (async () => { + globalThis.ImageData ??= class ImageData { constructor(data, width, height) { Object.assign(this, { data, width, height }); } }; + const [entry, wasm] = DECODERS[type]; + const codec = await import(entry); + await codec.init(await WebAssembly.compile(fs.readFileSync(require.resolve(wasm)))); + return codec.default; + })()); + } + return decoders.get(type); +} + +const invalid = (message) =>Object.assign(new Error(message), { status: 400, code: 'piece_invalid' }); + +export function imageSize(b) { + if (b.length >= 24 && b.readUInt32BE(0) === 0x89504e47 && b.toString('latin1', 12, 16) === 'IHDR') return [b.readUInt32BE(16), b.readUInt32BE(20)]; + if (b.length >= 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') { + const chunk = b.toString('latin1', 12, 16); + if (chunk === 'VP8 ') return [b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff]; + if (chunk === 'VP8L') { const v = b.readUInt32LE(21); return [(v & 0x3fff) + 1, ((v >>> 14) & 0x3fff) + 1]; } + if (chunk === 'VP8X') return [b.readUIntLE(24, 3) + 1, b.readUIntLE(27, 3) + 1]; + } + if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) { + for (let i = 2; i + 9 < b.length;) { + if (b[i] !== 0xff) return null; + const marker = b[i + 1]; + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; } + if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return [b.readUInt16BE(i + 7), b.readUInt16BE(i + 5)]; + i += 2 + b.readUInt16BE(i + 2); + } + } + return null; +} + +function readGlb(b, name) { + if (b.length < 20 || b.toString('latin1', 0, 4) !== 'glTF' || b.readUInt32LE(4) !== 2 || b.readUInt32LE(8) !== b.length) throw invalid(`${name} isn’t a binary glTF model.`); + const size = b.readUInt32LE(12); + if (b.readUInt32LE(16) !== 0x4e4f534a || size > MAX_JSON || 20 + size > b.length) throw invalid(`${name} isn’t a binary glTF model.`); + try { return JSON.parse(b.toString('utf8', 20, 20 + size)); } catch { throw invalid(`${name} isn’t a binary glTF model.`); } +} + +function binLength(b, name) { + const at = 20 + b.readUInt32LE(12); + if (at + 8 > b.length) return 0; + const size = b.readUInt32LE(at); + if (b.readUInt32LE(at + 4) !== 0x004e4942 || at + 8 + size > b.length) throw invalid(`${name} isn’t a binary glTF model.`); + return size; +} + +function checkJson(json, name, limit, bin) { + if (!json || typeof json !== 'object' || Array.isArray(json)) throw invalid(`${name} isn’t a binary glTF model.`); + for (const [key, most] of Object.entries(COUNTS)) { + const list = json[key] ?? []; + if (!Array.isArray(list) || list.length > most) throw invalid(`${name} has more ${key} than a piece may.`); + } + for (const extension of json.extensionsRequired ?? []) if (!REQUIRED_OK.has(extension)) throw invalid(`${name} needs ${extension}, which the Yard can’t draw.`); + if ((json.buffers ?? []).some((buffer) => buffer.uri !== undefined)) throw invalid(`${name} refers to files outside itself.`); + for (const image of json.images ?? []) { + if (image.uri !== undefined || image.bufferView === undefined) throw invalid(`${name} refers to files outside itself.`); + if (!IMAGE_TYPES.has(image.mimeType)) throw invalid(`${name} has an image the Yard can’t draw.`); + } + if ((json.accessors ?? []).some((accessor) => accessor?.sparse)) throw invalid(`${name} uses sparse data, which a piece may not.`); + for (const material of json.materials ?? []) { + const pbr = material?.pbrMetallicRoughness; + const extensions = Object.entries(MATERIAL_TEXTURES).flatMap(([extension, slots]) => slots.map((slot) => material?.extensions?.[extension]?.[slot])); + for (const info of [pbr?.baseColorTexture, pbr?.metallicRoughnessTexture, material?.normalTexture, material?.occlusionTexture, material?.emissiveTexture, ...extensions]) { + if (!info) continue; + for (const channel of [info.texCoord ?? 0, info.extensions?.KHR_texture_transform?.texCoord ?? 0]) { + if (!(Number.isInteger(channel) && channel >= 0 && channel <= 3)) throw invalid(`${name} uses texture channel ${channel}, which the Yard can’t draw.`); + } + } + } + const allowed = (kind, object) => { + if (!object || typeof object !== 'object' || Array.isArray(object)) throw invalid(`${name} has a ${kind} that can’t be read.`); + const extra = Object.keys(object).find((key) => !(PROPERTIES[kind] instanceof RegExp ? PROPERTIES[kind].test(key) : PROPERTIES[kind].includes(key))); + if (extra !== undefined) throw invalid(`${name} uses a glTF property the Yard doesn’t read (${kind}.${extra}).`); + }; + for (const accessor of json.accessors ?? []) allowed('accessor', accessor); + for (const view of json.bufferViews ?? []) allowed('bufferView', view); + for (const skin of json.skins ?? []) allowed('skin', skin); + for (const node of json.nodes ?? []) allowed('node', node); + for (const mesh of json.meshes ?? []) { + allowed('mesh', mesh); + if (!Array.isArray(mesh.primitives)) throw invalid(`${name} has a mesh that can’t be read.`); + for (const primitive of mesh.primitives) { + allowed('primitive', primitive); + allowed('attributes', primitive.attributes); + if (primitive.attributes.POSITION === undefined) throw invalid(`${name} has a mesh without positions.`); + } + } + for (const animation of json.animations ?? []) { + allowed('animation', animation); + for (const sampler of Array.isArray(animation.samplers) ? animation.samplers : []) allowed('sampler', sampler); + for (const channel of Array.isArray(animation.channels) ? animation.channels : []) { allowed('channel', channel); allowed('target', channel.target); } + } + const missing =() => invalid(`${name} refers to data it doesn’t contain.`); + const multiplied = () => invalid(`${name} reuses its data in a way that would multiply its size.`); + const index = (value, list) => Number.isInteger(value) && value >= 0 && value < (Array.isArray(list) ? list.length : 0); + const length = (value) => Number.isSafeInteger(value) && value >= 0; + let viewBytes = 0; + for (const view of json.bufferViews ?? []) { + const offset = view?.byteOffset ?? 0; + if (view?.buffer !== 0 || !length(view.byteLength) || !length(offset) || offset + view.byteLength > bin) throw missing(); + viewBytes += view.byteLength; + } + if (viewBytes > bin) throw multiplied(); + let accessorBytes = 0; + for (const accessor of json.accessors ?? []) { + const width = (ELEMENTS[accessor?.type] ?? 0) * (COMPONENT_BYTES[accessor?.componentType] ?? 0); + if (!width || !length(accessor.count) || !index(accessor.bufferView, json.bufferViews) || !length(accessor.byteOffset ?? 0)) throw missing(); + const stride = json.bufferViews[accessor.bufferView].byteStride; + if (stride !== undefined && stride !== width) throw invalid(`${name} interleaves its data, which a piece may not.`); + accessorBytes += accessor.count * width; + } + if (accessorBytes > bin) throw multiplied(); + const quantized = (json.extensionsUsed ?? []).includes('KHR_mesh_quantization'); + const drawable = (at, types, forms) => types.includes(json.accessors[at].type) && forms.some(([type, normalized]) => json.accessors[at].componentType === type && Boolean(json.accessors[at].normalized) === normalized); + for (const mesh of json.meshes ?? []) for (const primitive of mesh.primitives) { + for (const [attribute, at] of Object.entries(primitive.attributes)) { + const semantic = attribute.replace(/_\d+$/, ''), [types, forms] = ATTRIBUTE_FORMS[semantic]; + if (index(at, json.accessors) && !drawable(at, types, [...forms, ...(quantized ? QUANTIZED_FORMS[semantic] ?? [] : [])])) throw invalid(`${name} has a ${attribute} attribute of a type the Yard can’t draw.`); + } + if (index(primitive.indices, json.accessors) && !drawable(primitive.indices, ['SCALAR'], [UNSIGNED.byte, UNSIGNED.short, UNSIGNED.int])) throw invalid(`${name} has triangle indices of a type the Yard can’t draw.`); + const vertices = json.accessors[primitive.attributes.POSITION]?.count; + if (!Object.values(primitive.attributes).every((at) => index(at, json.accessors) && json.accessors[at].count === vertices)) throw invalid(`${name} has a mesh whose attributes don’t share one vertex count.`); + if (primitive.indices !== undefined && (!index(primitive.indices, json.accessors) || json.accessors[primitive.indices].count % 3 !== 0)) throw invalid(`${name} has a mesh whose triangles can’t be read.`); + } + if ((json.meshes ?? []).some((mesh) => (mesh?.primitives ?? []).some((primitive) => primitive?.targets !== undefined))) throw invalid(`${name} has shape keys, which the Yard doesn’t use.`); + let channels = 0; + for (const animation of json.animations ?? []) { + if (!Array.isArray(animation?.channels) || !Array.isArray(animation.samplers)) throw invalid(`${name} has an animation that can’t be read.`); + if (animation.channels.length > 256) throw invalid(`${name} has an animation with more than 256 channels.`); + channels += animation.channels.length; + for (const channel of animation.channels) { + const sampler = index(channel?.sampler, animation.samplers) ? animation.samplers[channel.sampler] : null; + if (!sampler || ![sampler.input, sampler.output].every((at) => index(at, json.accessors))) throw invalid(`${name} has an animation that can’t be read.`); + if (![sampler.input, sampler.output].every((at) => json.accessors[at].componentType === 5126 && !json.accessors[at].normalized)) throw invalid(`${name} has animation data that isn’t plain 32-bit floats.`); + const input = json.accessors[sampler.input], output = json.accessors[sampler.output], path = TRACKS[channel.target.path]; + if (!['LINEAR', 'STEP'].includes(sampler.interpolation ?? 'LINEAR')) throw invalid(`${name} uses ${sampler.interpolation} animation, which the Yard doesn’t play.`); + if (!path || !index(channel.target.node, json.nodes)) throw invalid(`${name} animates something the Yard doesn’t move.`); + if (input.type !== 'SCALAR' || output.type !== path || input.count < 1 || output.count !== input.count) throw invalid(`${name} has animation keyframes that don’t match their times.`); + } + } + if (channels > 2048) throw invalid(`${name} has more than 2,048 animation channels.`); + const nodes = json.nodes ?? []; + if (!Array.isArray(json.scenes) || json.scenes.length !== 1 || (json.scene ?? 0) !== 0 || !Array.isArray(json.scenes[0]?.nodes ?? [])) throw invalid(`${name} needs exactly one scene.`); + const parent = new Array(nodes.length).fill(null); + const place = (child, from) => { + if (!index(child, nodes)) throw invalid(`${name} refers to a node it doesn’t have.`); + if (parent[child] !== null) throw invalid(`${name} has a node used in two places.`); + parent[child] = from; + }; + for (const root of json.scenes[0].nodes ?? []) place(root, -1); + nodes.forEach((node, at) => { + if (!node || typeof node !== 'object' || !Array.isArray(node.children ?? [])) throw invalid(`${name} has a node that can’t be read.`); + for (const child of node.children ?? []) place(child, at); + if (node.rotation !== undefined && !(Array.isArray(node.rotation) && node.rotation.length === 4 && unitQuaternion(...node.rotation))) throw invalid(`${name} has a node rotation that isn’t a unit quaternion (${node.name ?? at}).`); + }); + const shown = new Set(); + for (let at = 0; at < nodes.length; at++) { + let up = at; + for (let steps = 0; up !== null && up >= 0; steps++) { + if (steps > nodes.length) throw invalid(`${name} has nodes that loop.`); + up = parent[up]; + } + if (up === -1) shown.add(at); + } + for (const skin of json.skins ?? []) { + if (!Array.isArray(skin?.joints) || skin.joints.length > 128 || !skin.joints.every((joint) => index(joint, nodes))) throw invalid(`${name} needs at most 128 joints per skin.`); + if (skin.inverseBindMatrices !== undefined && (!index(skin.inverseBindMatrices, json.accessors) || !drawable(skin.inverseBindMatrices, ['MAT4'], [FLOAT]))) throw invalid(`${name} has inverse bind matrices of a type the Yard can’t use.`); + for (const joint of skin.joints) shown.add(joint); + } + for (const node of nodes) { + if ((node.mesh !== undefined && !index(node.mesh, json.meshes)) || (node.skin !== undefined && !index(node.skin, json.skins))) throw invalid(`${name} refers to a part it doesn’t have.`); + } + const extensions = [...(json.extensionsUsed ?? []), ...Object.keys(json.extensions ?? {}), ...nodes.flatMap((node) => Object.keys(node?.extensions ?? {}))]; + if (extensions.includes('KHR_lights_punctual')) throw invalid(`${name} uses lights, which a piece may not.`); + if (extensions.includes('EXT_mesh_gpu_instancing')) throw invalid(`${name} uses instancing, which a piece may not.`); + const meshFaces = (json.meshes ?? []).map((mesh) => (mesh?.primitives ?? []).reduce((sum, primitive) => { + if ((primitive.mode ?? 4) !== 4) throw invalid(`${name} has geometry that isn’t triangles.`); + return sum + Math.floor((json.accessors?.[primitive.indices ?? primitive.attributes?.POSITION]?.count ?? 0) / 3); + }, 0)); + let faces = 0; + for (const [at, node] of nodes.entries()) { + if (node.mesh === undefined || !shown.has(at)) continue; + faces += meshFaces[node.mesh] ?? 0; + if (node.skin !== undefined && (json.meshes?.[node.mesh]?.primitives ?? []).some((primitive) => primitive.attributes?.JOINTS_0 === undefined || primitive.attributes?.WEIGHTS_0 === undefined)) { + throw invalid(`${name} has a rigged mesh without weights.`); + } + } + if (faces > limit.faces) throw invalid(`${name} has more than ${limit.faces.toLocaleString('en-US')} faces.`); +} + +const IMAGE_ENDS = { + 'image/png': (data) => data.length - data.lastIndexOf('IEND', data.length - 1, 'latin1') - 8, + 'image/jpeg': (data) => { const end = data.lastIndexOf(Buffer.from([0xff, 0xd9])); return end < 0 ? Infinity : data.length - end - 2; }, + 'image/webp': (data) => (data.length >= 8 ? data.length - data.readUInt32LE(4) - 8 : Infinity), +}; + +function checkImages(json, b, name) { + const start = 20 + b.readUInt32LE(12) + 8; + for (const image of json.images ?? []) { + const view = json.bufferViews?.[image.bufferView]; + const from = start + (view?.byteOffset ?? 0), to = from + (view?.byteLength ?? 0); + if (!view || to > b.length) throw invalid(`${name} has an image that is incomplete or damaged.`); + const spare = IMAGE_ENDS[image.mimeType](b.subarray(from, to)); + if (!(spare >= 0 && spare <= 3)) throw invalid(`${name} has an image that is incomplete or damaged.`); + } +} + +async function inspect(file, name, kind) { + globalThis.self ??= globalThis; + for (const method of ['log', 'info', 'warn', 'error']) console[method] = () => {}; + const T = await import('../../web/yard/vendor/engine.js'); + const limit = LIMITS[kind]; + const b = fs.readFileSync(file); + const json = readGlb(b, name); + checkJson(json, name, limit, binLength(b, name)); + checkImages(json, b, name); + const problems = []; + let pixels = 0, decoding = Promise.resolve(); + globalThis.createImageBitmap = (blob) => { + const turn = decoding.then(async () => { + const bytes = Buffer.from(await blob.arrayBuffer()), size = imageSize(bytes); + if (!size || size.some((side) => side <= 0 || side > limit.side)) { + problems.push(size ? `${name} has a texture larger than ${limit.side} pixels.` : `${name} has an image that can’t be read.`); + throw new Error('refused'); + } + pixels += size[0] * size[1]; + if (pixels > 4 * limit.side * limit.side) { + problems.push(`${name}’s textures need more memory than a piece may.`); + throw new Error('refused'); + } + let image; + try { image = await (await decoder(blob.type))(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)); } catch { image = null; } + if (image?.width !== size[0] || image?.height !== size[1]) { + problems.push(`${name} has an image that is incomplete or damaged.`); + throw new Error('refused'); + } + return { width: size[0], height: size[1], close() {} }; + }); + decoding = turn.catch(() => {}); + return turn; + }; + const ab = b.buffer.slice(b.byteOffset, b.byteOffset + b.length); + let gltf; + try { gltf = await new T.GLTFLoader().parseAsync(ab, ''); } catch { throw invalid(`${name} is damaged and can’t be drawn.`); } + if (problems.length) throw invalid(problems[0]); + const bones = new Set(), skinned = []; + let yard = null; + gltf.scene.traverse((node) => { + if (node.isBone) bones.add(node.name); + if (node.isSkinnedMesh) skinned.push(node); + if (node.userData?.yard) yard = node.userData.yard; + }); + if (!skinned.length) throw invalid(`${name} has no rigged mesh.`); + gltf.scene.updateMatrixWorld(true); + gltf.scene.traverse((node) => { + const vertices = node.geometry?.attributes.position?.count, triangles = node.isMesh ? node.geometry.index?.array : null; + if (triangles && !triangles.every((at) => at < vertices)) throw invalid(`${name} has a triangle that refers to a vertex it doesn’t have.`); + }); + const picked = (mesh) => { + const order = mesh.geometry.index?.array, corners = order ? order.length : mesh.geometry.attributes.position.count; + const points = [new T.Vector3(), new T.Vector3(), new T.Vector3()], normal = new T.Vector3(), edge = new T.Vector3(), scale = new T.Vector3(); + for (const material of [mesh.material].flat()) material.side = T.DoubleSide; + mesh.getWorldScale(scale); + const reach = mesh.boundingSphere.radius * Math.max(Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z)) * 2 + 1; + for (let at = 0; at + 2 < corners; at += 3) { + points.forEach((point, k) => mesh.localToWorld(mesh.getVertexPosition(order ? order[at + k] : at + k, point))); + normal.subVectors(points[2], points[1]).cross(edge.subVectors(points[0], points[1])); + if (!(normal.lengthSq() > 1e-12)) continue; + normal.normalize(); + const centre = points[0].clone().add(points[1]).add(points[2]).divideScalar(3); + try { return new T.Raycaster(centre.clone().addScaledVector(normal, reach), normal.clone().negate()).intersectObject(mesh).length > 0; } catch { return false; } + } + return false; + }; + let low = Infinity, high = -Infinity; + for (const mesh of skinned) { + const position = mesh.geometry.attributes.position, joints = mesh.geometry.attributes.skinIndex, weights = mesh.geometry.attributes.skinWeight; + if (!position || !joints || !weights) throw invalid(`${name} has a rigged mesh without positions or weights.`); + for (let i = 0; i < joints.count; i++) for (let k = 0; k < joints.itemSize; k++) { + if (weights.getComponent(i, k) > 0 && !(joints.getComponent(i, k) < mesh.skeleton.bones.length)) throw invalid(`${name} weights a vertex to a joint it doesn’t have.`); + } + try { + mesh.skeleton.update(); + mesh.computeBoundingBox(); + mesh.computeBoundingSphere(); + } catch { throw invalid(`${name} has a rigged mesh the Yard can’t draw.`); } + if (!Number.isFinite(mesh.boundingSphere?.radius)) throw invalid(`${name} has a rigged mesh the Yard can’t draw.`); + const { min, max } = mesh.boundingBox, corner = new T.Vector3(); + for (const x of [min.x, max.x]) for (const y of [min.y, max.y]) for (const z of [min.z, max.z]) { + corner.set(x, y, z).applyMatrix4(mesh.matrixWorld); + low = Math.min(low, corner.y); + high = Math.max(high, corner.y); + } + if (!picked(mesh)) throw invalid(`${name} has a rigged mesh the Yard can’t pick.`); + } + const missingBone = SKELETON[kind === 'character' ? 'character' : 'helper'].find((bone) => !bones.has(bone)); + if (missingBone) throw invalid(`${name} has no ${missingBone} bone.`); + const clips = new Set(gltf.animations.map((clip) => clip.name)); + const wanted = kind === 'character' ? Object.values(CLIPS.character).flat() : CLIPS.helper; + const missingClip = wanted.find((clip) => !clips.has(clip)); + if (missingClip) throw invalid(`${name} has no ${missingClip} animation.`); + for (const clip of gltf.animations) { + if (!Number.isFinite(clip.duration) || clip.duration <= 0 || clip.duration > 60) throw invalid(`${name} has an animation ${clip.name} that must last more than 0 and at most 60 seconds.`); + } + for (const clip of gltf.animations) if (!playableClip(clip)) throw invalid(`${name} has an animation with values the Yard can’t play (${clip.name}).`); + const height = high - low; + const anchors = ['Root', 'Hips'].map((bone) => gltf.scene.getObjectByName(bone)); + const rest = anchors.map((bone) => bone.getWorldPosition(new T.Vector3())); + const mixer = new T.AnimationMixer(gltf.scene), at = new T.Vector3(); + for (const clip of gltf.animations) for (let step = 0; step <= 16; step++) { + mixer.stopAllAction(); + mixer.clipAction(clip).reset().play(); + mixer.setTime(clip.duration * step / 16); + gltf.scene.updateMatrixWorld(true); + anchors.forEach((bone, i) => { + bone.getWorldPosition(at); + if (!(Math.hypot(at.x - rest[i].x, at.z - rest[i].z) <= IN_PLACE * height)) throw invalid(`${name} has a clip that moves the character instead of playing in place (${clip.name}).`); + }); + } + const { problem } = yardExtras(yard, kind); + if (problem) throw invalid(`${name} ${problem}`); +} + +export function checkModels(files, kind, { timeout = TIMEOUT } = {}) { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL(import.meta.url), { workerData: { files, kind }, resourceLimits: { maxOldGenerationSizeMb: 1024, maxYoungGenerationSizeMb: 64 } }); + let settled = false; + const finish = (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + worker.terminate(); + if (err) reject(err); else resolve(); + }; + const timer = setTimeout(() => finish(invalid('This piece took too long to check.')), timeout); + worker.once('message', (result) => finish(result.error ? invalid(result.error) : null)); + worker.once('error', (err) => finish(invalid(err.code === 'ERR_WORKER_OUT_OF_MEMORY' ? 'This piece needs more memory than a piece may.' : 'This piece couldn’t be checked.'))); + worker.once('exit', () => finish(invalid('This piece couldn’t be checked.'))); + }); +} + +if (!isMainThread && workerData?.files) { + (async () => { + for (const { path: file, name } of workerData.files) await inspect(file, name, workerData.kind); + })().then(() => parentPort.postMessage({}), (err) => parentPort.postMessage({ error: err.status ? err.message : 'This piece is damaged and can’t be drawn.' })); +} diff --git a/src/manager/yard-pieces.mjs b/src/manager/yard-pieces.mjs new file mode 100644 index 0000000..bd6f999 --- /dev/null +++ b/src/manager/yard-pieces.mjs @@ -0,0 +1,188 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { EventEmitter } from 'node:events'; +import { WORLDS, PROVIDER_ORDER } from '../../web/yard/model.mjs'; +import { renameRetrying } from './yard-art.mjs'; +import { checkModels, LIMITS } from './yard-piece-check.mjs'; + +const MANIFEST = 'piece.json'; +const INSTALL = 'install.json'; +const MAX_MANIFEST = 16 * 1024; +export const SLOTS = [...PROVIDER_ORDER, 'companion']; +const KIND = (slot) => (slot === 'companion' ? 'companion' : 'character'); +export const MODELS = { character: ['character.glb'], companion: ['companion_0.glb', 'companion_1.glb', 'companion_2.glb', 'companion_3.glb'] }; + +const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex'); +const failure = (status, code, message) => Object.assign(new Error(message), { status, code }); +const invalid = (message) => failure(400, 'piece_invalid', message); + +function checkManifest(meta, kind) { + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw invalid('This piece’s details can’t be read.'); + if (meta.format !== 1) throw invalid(Number.isInteger(meta.format) && meta.format > 1 ? 'This piece needs a newer Agent Guild.' : 'This piece’s format is missing.'); + if (meta.kind !== 'character' && meta.kind !== 'companion') throw invalid('This isn’t a character or companion piece.'); + if (meta.kind !== kind) throw invalid(kind === 'character' ? 'This is a companion piece, not a character.' : 'This is a character piece, not a companion.'); + if (meta.title !== undefined && (typeof meta.title !== 'string' || meta.title.length > 128 || /\p{Cc}/u.test(meta.title))) throw invalid('This piece’s title is too long or unreadable.'); + return typeof meta.title === 'string' ? meta.title.trim() : ''; +} + +export function readManifest(bytes, kind = null) { + let meta; + try { meta = JSON.parse(bytes.toString('utf8')); } catch { throw invalid('This piece’s details can’t be read.'); } + return { kind: meta?.kind, title: checkManifest(meta, kind ?? meta?.kind) }; +} + +async function readSource(folder, name, most) { + const file = path.join(folder, name); + let stat; + try { stat = await fsp.lstat(file); } catch { throw invalid(`${name} is missing.`); } + if (!stat.isFile()) throw invalid(`${name} isn’t a plain file.`); + if (stat.size > most) throw invalid(`${name} is larger than a piece may be.`); + try { return await fsp.readFile(file); } catch { throw invalid(`${name} can’t be read.`); } +} + +export class YardPieces extends EventEmitter { + constructor({ dir = null, remove = (folder) => fsp.rm(folder, { recursive: true, force: true, maxRetries: 5 }), check = checkModels } = {}) { + super(); + this.dir = dir; + this.pieces = {}; + this.busy = false; + this.remove = remove; + this.check = check; + this.swept = Promise.resolve(); + if (!dir) return; + for (const skin of Object.keys(WORLDS)) for (const slot of SLOTS) this.read(skin, slot); + this.swept = this.sweep(); + } + + snapshot() { return this.pieces; } + + discard(folder) { return this.remove(folder).catch(() => {}); } + + async sweep() { + const names = await fsp.readdir(this.dir).catch(() => []); + await Promise.all(names.filter((name) => name.startsWith('.')).map(async (name) => { + const [, skin, slot, , kind] = name.split('.'); + const target = Object.hasOwn(WORLDS, skin) && SLOTS.includes(slot) ? path.join(this.dir, skin, slot) : null; + if (kind === 'old' && target && !fs.existsSync(target)) { + try { + await renameRetrying(path.join(this.dir, name), target); + this.read(skin, slot); + this.emit('updated', this.snapshot()); + return; + } catch {} + } + let folder = path.join(this.dir, name); + if (kind === 'old' && target && fs.existsSync(target)) { + const removed = this.aside(skin, slot, 'removed'); + try { await renameRetrying(folder, removed); folder = removed; } catch { return; } + } + await this.discard(folder); + })); + } + + aside(skin, slot, kind) { return path.join(this.dir, `.${skin}.${slot}.${crypto.randomUUID()}.${kind}`); } + + file(skin, slot, name) { + return this.dir && Object.hasOwn(WORLDS, skin) && SLOTS.includes(slot) && MODELS[KIND(slot)].includes(name) && this.pieces[skin]?.[slot]?.revision + ? path.join(this.dir, skin, slot, name) : null; + } + + set(skin, slot, value) { + if (value) (this.pieces[skin] ??= {})[slot] = value; + else if (this.pieces[skin]) { + delete this.pieces[skin][slot]; + if (!Object.keys(this.pieces[skin]).length) delete this.pieces[skin]; + } + } + + read(skin, slot) { + const folder = path.join(this.dir, skin, slot); + if (!fs.existsSync(folder)) return this.set(skin, slot, null); + let title = null; + try { + const kind = KIND(slot); + title = readManifest(fs.readFileSync(path.join(folder, MANIFEST)), kind).title; + const bytes = fs.readFileSync(path.join(folder, INSTALL)); + const install = JSON.parse(bytes.toString('utf8')); + title = install.title; + for (const name of [MANIFEST, ...MODELS[kind]]) { + const details = install.files?.[name]; + let content; + try { content = fs.readFileSync(path.join(folder, name)); } catch { content = null; } + if (!details || !content || content.length !== details.size || sha256(content) !== details.sha256) throw invalid(`${title} is damaged: ${name} is missing or changed.`); + } + this.set(skin, slot, { title, revision: sha256(bytes).slice(0, 16) }); + } catch (err) { + this.set(skin, slot, { title: title || null, problem: err.status ? err.message : `${title || 'This piece'} is damaged.` }); + } + } + + async exclusive(skin, slot, work) { + if (!Object.hasOwn(WORLDS, skin)) throw failure(404, 'unknown_yard', 'There is no such Yard.'); + if (!SLOTS.includes(slot)) throw failure(404, 'unknown_slot', 'There is no such character or companion.'); + if (!this.dir) throw failure(409, 'pieces_unsupported', 'This manager has no data folder to keep pieces in.'); + if (this.busy) throw failure(409, 'piece_busy', 'Another piece is being changed. Try again when it finishes.'); + this.busy = true; + let before; + try { + await this.swept; + before = JSON.stringify(this.pieces[skin]?.[slot] ?? null); + await work(); + } catch (err) { + if (err.status) throw err; + throw failure(500, 'piece_unwritable', `Agent Guild cannot write to its data folder: ${err.message}`); + } finally { + this.read(skin, slot); + this.busy = false; + if (JSON.stringify(this.pieces[skin]?.[slot] ?? null) !== before) this.emit('updated', this.snapshot()); + } + return this.snapshot(); + } + + replace(skin, slot, folder) { + return this.exclusive(skin, slot, async () => { + const kind = KIND(slot); + if (typeof folder !== 'string' || !path.isAbsolute(folder) || folder.includes('\0')) throw invalid('Choose a piece folder.'); + try { + if (!(await fsp.stat(folder)).isDirectory()) throw invalid('Choose a piece folder.'); + } catch (err) { + throw err.status ? err : invalid('This folder can’t be read.'); + } + const manifest = await readSource(folder, MANIFEST, MAX_MANIFEST); + const title = readManifest(manifest, kind).title || path.basename(folder); + const files = new Map([[MANIFEST, manifest]]); + for (const name of MODELS[kind]) files.set(name, await readSource(folder, name, LIMITS[kind].bytes)); + const target = path.join(this.dir, skin, slot), staging = this.aside(skin, slot, 'staging'), previous = this.aside(skin, slot, 'old'); + await fsp.mkdir(path.join(this.dir, skin), { recursive: true }); + try { + await fsp.mkdir(staging); + for (const [name, bytes] of files) await fsp.writeFile(path.join(staging, name), bytes); + await this.check(MODELS[kind].map((name) => ({ path: path.join(staging, name), name })), kind); + const install = { id: crypto.randomUUID(), title, files: Object.fromEntries([...files].map(([name, bytes]) => [name, { size: bytes.length, sha256: sha256(bytes) }])) }; + await fsp.writeFile(path.join(staging, INSTALL), JSON.stringify(install)); + const had = fs.existsSync(target); + if (had) await renameRetrying(target, previous); + try { await renameRetrying(staging, target); } catch (err) { + if (had) await renameRetrying(previous, target); + throw err; + } + if (had) await renameRetrying(previous, this.aside(skin, slot, 'removed')).catch(() => {}); + } finally { await this.discard(staging); } + await this.sweep(); + }); + } + + restore(skin, slot) { + return this.exclusive(skin, slot, async () => { + const target = path.join(this.dir, skin, slot); + const names = await fsp.readdir(this.dir).catch(err => { if (err.code === 'ENOENT') return []; throw err; }); + for (const name of names) if (name.startsWith(`.${skin}.${slot}.`) && name.endsWith('.old')) { + await renameRetrying(path.join(this.dir, name), this.aside(skin, slot, 'removed')); + } + if (fs.existsSync(target)) await renameRetrying(target, this.aside(skin, slot, 'removed')); + await this.sweep(); + }); + } +} diff --git a/tests/browser/layout.mjs b/tests/browser/layout.mjs index ccfb4e8..c67b145 100644 --- a/tests/browser/layout.mjs +++ b/tests/browser/layout.mjs @@ -418,8 +418,8 @@ const checks = await withPage({ name: 'layout', instrumentation }, async ({ orig await size(1440, 900); assert.equal(await visible('.yard-play-stamp'), true, 'the Builders button is stamped as a beta'); await size(1440, 900); - assert.equal(await visible('#yard-world-replace'), false, 'the demo has no manager to install a world'); - const spotClear = (label) => evaluate(`(()=>{for(const id of ['#yard-world-get','#yard-world-replace','#yard-world-restore'])document.querySelector(id).hidden=false;const box=(e)=>e.getBoundingClientRect(),stage=box(document.querySelector('#yard-stage')); + assert.equal(await visible('#yard-customize-open'), false, 'the demo has no manager to customize a Yard'); + const spotClear = (label) => evaluate(`(()=>{for(const id of ['#yard-world-get','#yard-customize-open'])document.querySelector(id).hidden=false;const box=(e)=>e.getBoundingClientRect(),stage=box(document.querySelector('#yard-stage')); const parts=[...document.querySelectorAll('#yard-world .btn:not([hidden])')],camera=[...document.querySelectorAll('.yard-camera .btn:not([hidden])')]; const inside=parts.every((e)=>{const r=box(e);return r.width>=44&&r.left>=stage.left&&r.right<=stage.right&&r.top>=stage.top&&r.bottom<=stage.bottom;}); const apart=parts.every((e)=>camera.every((c)=>{const a=box(e),b=box(c);return a.right<=b.left||b.right<=a.left||a.bottom<=b.top||b.bottom<=a.top;})); @@ -430,7 +430,7 @@ const checks = await withPage({ name: 'layout', instrumentation }, async ({ orig for (const [width, height] of [[1440, 900], [336, 640]]) { await size(width, height); const at = await spotClear(`${skin} ${width}`); - assert.deepEqual([at.count, at.inside, at.apart], [3, true, true], JSON.stringify(at)); + assert.deepEqual([at.count, at.inside, at.apart], [2, true, true], JSON.stringify(at)); if (width === 1440) lefts.add(at.left); } await size(1440, 900); @@ -441,13 +441,31 @@ const checks = await withPage({ name: 'layout', instrumentation }, async ({ orig for (const [width, height] of [[280, 500]]) { await size(width, height); const compactSpot = await spotClear(`compact ${width}`); - assert.deepEqual([compactSpot.count, compactSpot.inside, compactSpot.apart], [3, true, true], JSON.stringify(compactSpot)); + assert.deepEqual([compactSpot.count, compactSpot.inside, compactSpot.apart], [2, true, true], JSON.stringify(compactSpot)); } + await size(280, 500); + await evaluate('document.querySelector("#yard-customize-open").click()'); + await until('the Customize dialog opens', () => evaluate('document.querySelector("#yard-customize").open&&document.querySelectorAll("#yard-customize-list [data-slot]").length===8')); + assert.deepEqual(await evaluate(`(()=>{const d=document.querySelector('#yard-customize'),r=d.getBoundingClientRect(),body=d.querySelector('.models-body'),wide=[...d.querySelectorAll('button')].filter((b)=>b.getBoundingClientRect().right>innerWidth); + return [r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight,body.scrollHeight>body.clientHeight,getComputedStyle(body).overflowY,wide.length];})()`), [true, true, 'auto', 0], 'the dialog fits a compact screen and scrolls'); + const stacked = () => evaluate(`[...document.querySelectorAll('#yard-customize-list [data-slot]')].map((row)=>row.querySelector('.yard-customize-actions').getBoundingClientRect().top>=row.querySelector('.yard-customize-copy').getBoundingClientRect().bottom-1)`); + for (const [width, height] of [[280, 500], [390, 844], [479, 844]]) { + await size(width, height); + assert.deepEqual(await stacked(), Array(8).fill(true), `Customize stacks its buttons under each row at ${width}px`); + } + await size(560, 844); + assert.deepEqual(await stacked(), Array(8).fill(false), 'Customize keeps its buttons beside each row from 481px'); + await size(280, 500); + await evaluate('(b=>{b.focus();b.click();})(document.querySelector("#yard-customize-list [data-slot=anthropic] button"))'); + await until('the piece dialog opens over it', () => evaluate('document.querySelector("#folder-browser").open&&document.querySelector("#folder-title").textContent==="Choose a character"&&document.querySelector("#yard-customize").open')); + assert.deepEqual(await evaluate('[document.querySelector("#folder-use").hidden,document.querySelector("#folder-new-open").hidden]'), [true, true]); + await evaluate('document.querySelector("#folder-browser").close()'); + await until('focus returns to Replace', () => evaluate('document.activeElement===document.querySelector("#yard-customize-list [data-slot=anthropic] button")')); + await evaluate('document.querySelector("#yard-customize-list [data-slot=world] button").click()'); + await until('the world file dialog opens', () => evaluate('document.querySelector("#folder-browser").open&&document.querySelector("#folder-title").textContent==="Choose a world file"')); + await evaluate('document.querySelector("#folder-browser").close();document.querySelector("#yard-customize").close()'); await size(1440, 900); - await evaluate('document.querySelector("#yard-world-replace").click()'); - await until('the file dialog opens', () => evaluate('document.querySelector("#folder-browser").open&&document.querySelector("#folder-title").textContent==="Choose a world file"')); - assert.deepEqual(await evaluate('[getComputedStyle(document.querySelector("#folder-browser")).display!=="none",document.querySelector("#folder-use").hidden,document.querySelector("#folder-new-open").hidden]'), [true, true, true]); - await evaluate('document.querySelector("#folder-browser").close();delete document.documentElement.dataset.layout;document.querySelector("#yard-world-get").hidden=true;document.querySelector("#yard-world-replace").hidden=true'); + await evaluate('delete document.documentElement.dataset.layout;document.querySelector("#yard-world-get").hidden=true;document.querySelector("#yard-customize-open").hidden=true'); await layoutReady(); for (const skin of ['professional', 'gnomeland']) { await evaluate(`document.documentElement.dataset.skin="${skin}"`); @@ -458,7 +476,7 @@ const checks = await withPage({ name: 'layout', instrumentation }, async ({ orig } } await size(1440, 900); - pass('the world spot sits in one place in all six Yards at every width and opens the file dialog in the compact layout'); + pass('the world spot sits in one place in all six Yards at every width, and Customize fits a compact screen and opens each picker over itself'); await evaluate('document.documentElement.dataset.skin="professional"'); await layoutReady(); await evaluate(`document.documentElement.dataset.skin=${JSON.stringify(startSkin[0])}`); @@ -1000,6 +1018,28 @@ const checks = await withPage({ name: 'layout', instrumentation }, async ({ orig await send('Page.removeScriptToEvaluateOnNewDocument', { identifier: tv.identifier }); pass('with &yard=none the page shows the Cards, offers no Yard, ignores requests for it and never loads its scene or art, after a reload too'); + const ORNAMENT_SHARE = 79 / 232; + await evaluate('document.documentElement.dataset.skin="guild"'); + const crowded = () => evaluate(`(()=>{const out=[]; + for(const d of document.querySelectorAll('dialog.models')){ + if(d.open)d.close();d.showModal(); + const r=d.getBoundingClientRect(),ornament=parseFloat(getComputedStyle(d,'::after').borderTopWidth)*${ORNAMENT_SHARE};let gap=Infinity; + const near=(e,el)=>{let x0=e.left,y0=e.top,x1=e.right,y1=e.bottom; + for(let a=el;a&&a!==d;a=a.parentElement){const s=getComputedStyle(a);if(a!==el&&(s.overflowX!=='visible'||s.overflowY!=='visible')){const c=a.getBoundingClientRect();x0=Math.max(x0,c.left);y0=Math.max(y0,c.top);x1=Math.min(x1,c.right);y1=Math.min(y1,c.bottom);}} + if(x1-x0>1&&y1-y0>1)gap=Math.min(gap,x0-r.left,r.right-x1,y0-r.top,r.bottom-y1);}; + for(const el of d.querySelectorAll('button,input,select,textarea,.btn'))if(el.checkVisibility())near(el.getBoundingClientRect(),el); + const walk=document.createTreeWalker(d,NodeFilter.SHOW_TEXT); + while(walk.nextNode()){const t=walk.currentNode;if(!t.textContent.trim()||!t.parentElement.checkVisibility())continue;const range=document.createRange();range.selectNodeContents(t);for(const e of range.getClientRects())near(e,t.parentElement);} + d.close(); + if(!(gap-ornament>=12))out.push(d.id+' '+Math.round(gap)+'px from the edge, ornament '+Math.round(ornament)+'px'); + }return out;})()`); + for (const [width, height] of [[1440, 900], [390, 844]]) { + await size(width, height); + assert.deepEqual(await crowded(), [], `guild ${width}`); + } + await size(1440, 900); + pass('every framed Guild dialog keeps its controls and text at least 12px clear of the ornament, on desktop and phone'); + assert.deepEqual(errors, []); pass('the page has no uncaught browser exceptions'); }); diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index 92a07e5..140a711 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -41,8 +41,8 @@ async function framesSettled(b){ }); return settled.frames; } -async function setup(t,{yard=false,auth=true,source='',width=1440,height=1000,hash='',until=null,yardWorlds}={}) { - const fixture=await startFixture({yardWorlds});t.after(()=>fixture.close()); +async function setup(t,{yard=false,auth=true,source='',width=1440,height=1000,hash='',until=null,yardWorlds,yardPieces}={}) { + const fixture=await startFixture({yardWorlds,yardPieces});t.after(()=>fixture.close()); const b=await browser(); await b.navigate('about:blank'); b.errors.length=0;b.requests.length=0;b.events.length=0; @@ -1534,3 +1534,454 @@ test('fireworks keep their size and glow across photo tiles at different pixel r for(const shot of result.results){assert.ok(shot.alpha>0);assert.ok(shot.maxDelta<=2,JSON.stringify(shot));assert.equal(shot.size,result.size);} assert.deepEqual(b.errors,[]); }); + +const PIECE_FILES=fileURLToPath(new URL('../fixtures/pieces/',import.meta.url)); +function fakePieces(current,files={}){ + return Object.assign(new EventEmitter(),{ + current, + snapshot(){return this.current;}, + file(skin,slot,name){ + if(!this.current[skin]?.[slot]?.revision)return null; + if(files[slot])return files[slot]; + if(slot==='openai')return path.join(PIECE_FILES,'truncated.glb'); + return path.join(PIECE_FILES,slot==='companion'?'companion.glb':'character.glb'); + }, + }); +} + +test('pieces replace one crew and the helpers, keep Terminal AI Chat gold, fall back whole, and never let an older load return',options,async t=>{ + const yardPieces=fakePieces({guild:{anthropic:{revision:'r1'},shell:{revision:'s1'},companion:{revision:'c1'},openai:{revision:'x1'}}}); + const {f,b}=await setup(t,{yardPieces,source:` + const originalFetch=window.fetch;window.__held=[];window.__hold=null; + window.fetch=(...args)=>{ + const url=String(args[0]?.url||args[0]); + if(window.__hold&&url.includes(window.__hold))return new Promise((resolve,reject)=>window.__held.push(()=>originalFetch(...args).then(resolve,reject))); + return originalFetch(...args); + };`}); + const by=id=>f.providers.find(p=>p.id===id),gold={...by('shell'),id:'terminal-ai-chat',tool:'Terminal AI Chat'}; + const base=[...f.data.values()][0]; + const sessions=[ + {...base,id:'claude',provider:by('anthropic'),agents:[{id:'a',name:'A',status:'working'},{id:'b',name:'B',status:'working'}],shells:[]}, + {...base,id:'codex',provider:by('openai'),agents:[],shells:[]}, + {...base,id:'gold',provider:gold,agents:[],shells:[]}, + {...base,id:'grok',provider:by('xai'),agents:[],shells:[]}, + ]; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__events=[]; + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},piece:e=>{__events.push(e);if(!e.ok){const p={...__renderer.pieceSets[e.skin]};delete p[e.slot];__renderer.setPieces(e.skin,p);}}}); + window.__providers=${q([...f.providers,gold])};window.__sessions=${q(sessions)}; + __renderer.setPieces('guild',{anthropic:{revision:'r1'},shell:{revision:'s1'},companion:{revision:'c1'},openai:{revision:'x1'}}); + await __renderer.setWorld('guild','dark');__renderer.update(__providers,__sessions); + window.__unit=id=>__renderer.units.get(id); + window.__pieceKeys=()=>[...__renderer.cache.keys()].filter(k=>k.includes('/pieces/')).map(k=>k.replace(/^(gold:)?.*\\/pieces\\//,'$1')).sort(); + window.__maps=root=>{const out=[];root?.traverse(n=>{if(n.isMesh)for(const m of [n.material].flat())if(m.map)out.push(m.map.image?.width||0);});return out;}; + })()`); + await b.wait("['claude','codex','gold','grok'].every(id=>__unit(id).loaded) && __unit('claude').helpers.length===2 && __events.length===4"); + const first=await b.evaluate(`({ + events:__events.map(e=>e.slot+':'+e.revision+':'+e.ok).sort(), + claudeTexture:__maps(__unit('claude').model), + claudeClips:[...__unit('claude').animations.keys()].sort(), + goldGilded:(()=>{const ms=[];__unit('gold').model.traverse(n=>{if(n.isMesh)ms.push(...[n.material].flat());});return ms.length>0&&ms.every(m=>m.onBeforeCompile!==Object.getPrototypeOf(m).onBeforeCompile);})(), + keys:__pieceKeys(), + original:['hero_1','hero_3'].every(k=>__renderer.cache.has(k)), + })`); + assert.deepEqual(first.events,['anthropic:r1:true','companion:c1:true','openai:x1:false','shell:s1:true']); + assert.ok(first.claudeTexture.length>0&&first.claudeTexture.every(w=>w>0),'the embedded texture decodes under the page’s policy'); + assert.deepEqual(first.claudeClips,['done','idle_0','idle_1','idle_2','pause','walk','work_0','work_1']); + assert.equal(first.goldGilded,true,'Terminal AI Chat wears the Shell piece in gold'); + assert.deepEqual(first.keys,['gold:guild/shell/character.glb?v=s1','guild/anthropic/character.glb?v=r1','guild/companion/companion_0.glb?v=c1','guild/companion/companion_1.glb?v=c1','guild/companion/companion_2.glb?v=c1','guild/companion/companion_3.glb?v=c1','guild/shell/character.glb?v=s1']); + assert.equal(first.original,true,'the broken Codex piece falls back to the shipped character, and Grok keeps its own'); + const grok=await b.evaluate("__unit('grok').model.uuid"); + await b.evaluate("__events.length=0;__renderer.setPieces('guild',{...__renderer.pieceSets.guild,anthropic:{revision:'r2'}});__renderer.setPieces('guild',{...__renderer.pieceSets.guild,anthropic:{revision:'r3'}})"); + await b.wait("__unit('claude').loaded && __events.some(e=>e.revision==='r3')"); + assert.deepEqual(await b.evaluate("__events.map(e=>e.revision)"),['r3'],'a superseded revision reports nothing'); + assert.deepEqual(await b.evaluate("__pieceKeys().filter(k=>k.startsWith('guild/anthropic'))"),['guild/anthropic/character.glb?v=r3']); + assert.equal(await b.evaluate("__unit('grok').model.uuid"),grok,'an unaffected character keeps its model'); + await b.evaluate("__hold='v=r4';__renderer.setPieces('guild',{...__renderer.pieceSets.guild,anthropic:{revision:'r4'}});window.__r4=[...__renderer.cache].find(([k])=>k.endsWith('v=r4'))[1];(async()=>{await __renderer.setWorld('orbital','dark');})()"); + await b.wait("__renderer.skin==='orbital' && ['claude','codex','gold','grok'].every(id=>__unit(id)?.loaded) && __unit('claude').helpers.length===2"); + await b.evaluate("(async()=>{__hold=null;for(const go of __held.splice(0))go();await __r4.catch(()=>{});await new Promise(resolve=>setTimeout(resolve,0));})()"); + assert.deepEqual(await b.evaluate("({keys:__pieceKeys(),robot:__renderer.cache.has('robot_0'),late:__events.some(e=>e.revision==='r4')})"),{keys:[],robot:true,late:false},'another Yard shows its own cast, the Guild’s pieces are released, and a late download reports nothing'); + await b.evaluate("(async()=>{await __renderer.setWorld('guild','dark');})()"); + await b.wait("__renderer.skin==='guild' && __unit('claude')?.loaded && __pieceKeys().includes('guild/anthropic/character.glb?v=r4')"); + await b.evaluate("__renderer.setPieces('guild',{})"); + await b.wait("__unit('claude').loaded && __unit('claude').helpers.length===2 && __pieceKeys().length===0"); + assert.equal(await b.evaluate("__renderer.cache.has('hero_0')"),true,'restoring brings back the original'); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); + +test('companion changes clear helpers and pending loads even while their character reloads',options,async t=>{ + const yardPieces=fakePieces({guild:{anthropic:{revision:'r1'},companion:{revision:'c1'}}}); + const {f,b}=await setup(t,{yardPieces}); + const session={...[...f.data.values()][0],provider:f.providers.find(p=>p.id==='anthropic'),agents:[{id:'a',name:'A',status:'working'}],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__renderer=new YardRenderer(host,labels,{select(){},open(){}}); + __renderer.setReducedMotion(true); + __renderer.setPieces('guild',${q(yardPieces.current.guild)}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},[${q(session)}]); + window.__unit=__renderer.units.get(${q(session.id)}); + window.__released=0; + window.__holdRevision=null;window.__held=[]; + const asset=__renderer.asset.bind(__renderer); + __renderer.asset=(name,...args)=>{ + const pending=asset(name,...args); + return name.includes('v='+__holdRevision)?new Promise((resolve,reject)=>__held.push(()=>pending.then(resolve,reject))):pending; + }; + window.__change=(character,companion)=>__renderer.setPieces('guild',{anthropic:{revision:character},companion:{revision:companion}}); + window.__matches=async revision=>{ + const assets=await Promise.all([...__renderer.cache].filter(([key])=>key.includes('/companion/')&&key.endsWith('v='+revision)).map(([,asset])=>asset)); + return assets.length===4&&__unit.helpers.length===1&&__unit.helpers.every(helper=>assets.some(asset=>helper.clips===asset.animations)); + }; + })()`); + await b.wait('__unit.loaded && __unit.helpers.length===1'); + await b.evaluate(`(async()=>{ + for(const [key,pending]of __renderer.cache)if(key.includes('/companion/')&&key.endsWith('v=c1')){ + (await pending).scene.traverse(n=>{if(n.isMesh)n.geometry.addEventListener('dispose',()=>__released++);}); + } + window.__oldHelpers=__unit.helpers.slice(); + __holdRevision='r2';__change('r2','c2'); + })()`); + assert.equal(await b.evaluate('__unit.helpers.length'),0,'a simultaneous character/companion change immediately removes old helpers'); + assert.equal(await b.evaluate('__oldHelpers.every(h=>!h.model.parent)'),true); + await b.wait('__released>0'); + assert.equal(await b.evaluate("[...__renderer.cache.keys()].some(k=>k.includes('/companion/')&&k.endsWith('v=c1'))"),false); + await b.evaluate("__change('r2','c3');__holdRevision=null;for(const go of __held.splice(0))go()"); + await b.wait('__unit.loaded && __unit.helpers.length===1'); + assert.equal(await b.evaluate("__matches('c3')"),true,'a companion changed during character loading supplies the new helper clips'); + await b.evaluate("__holdRevision='c4';__change('r2','c4');__change('r2','c5')"); + await b.wait('__unit.helpers.length===1'); + assert.equal(await b.evaluate("__matches('c5')"),true); + await b.evaluate("(async()=>{__holdRevision=null;for(const go of __held.splice(0))go();await new Promise(resolve=>setTimeout(resolve,0));})()"); + assert.equal(await b.evaluate("__matches('c5')"),true,'a superseded helper download cannot reappear'); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); + +test('pieces changed during the first world load are drawn and reported as Customized',options,async t=>{ + const yardPieces=fakePieces({guild:{anthropic:{title:'old',revision:'old'}}}); + const b=await browser(); + await b.send('Fetch.enable',{patterns:[{urlPattern:'*/yard/assets/guild/dark*'}]}); + t.after(()=>b.send('Fetch.disable')); + await setup(t,{yard:true,yardPieces}); + await until(()=>b.events.some(event=>event.method==='Fetch.requestPaused')); + await b.click('#yard-customize-open'); + const status="document.querySelector('#yard-customize-list [data-slot=anthropic] .yard-customize-status').textContent"; + yardPieces.current={guild:{anthropic:{title:'new',revision:'new'}}}; + yardPieces.emit('updated',yardPieces.current); + await b.wait(status+"==='Loading new…'"); + await b.send('Fetch.disable'); + await ready(b); + await b.wait(status+"==='Customized: new'"); + assert.ok(b.requests.some(r=>r.url.includes('/pieces/guild/anthropic/')&&r.url.includes('v=new'))); + assert.equal(b.requests.some(r=>r.url.includes('/pieces/guild/anthropic/')&&r.url.includes('v=old')),false); + assert.deepEqual(b.errors,[]); +}); + +test('the Customize dialog tells installed from drawn, per Yard, and returns focus',options,async t=>{ + const yardPieces=fakePieces({guild:{anthropic:{title:'Patchwork',revision:'p1'},openai:{title:'Broken',revision:'b1'}}}); + const {b}=await setup(t,{yard:true,yardPieces}); + await ready(b); + await b.evaluate("(button=>{button.focus();button.click();})(document.querySelector('#yard-customize-open'))"); + const row=slot=>`document.querySelector('#yard-customize-list [data-slot="${slot}"]')`; + await b.wait(`${row('anthropic')}?.dataset.state==='custom' && ${row('openai')}?.dataset.state==='failed'`); + const shown=await b.evaluate(`[...document.querySelectorAll('#yard-customize-list > li')].map(li=>li.dataset.slot?[li.dataset.slot,li.querySelector('.yard-customize-status').textContent,li.querySelectorAll('button')[1].disabled]:li.textContent)`); + assert.deepEqual(shown,[ + 'Yard',['world','Original',true], + 'Characters',['anthropic','Customized: Patchwork',false],['openai','Broken could not load. Showing the original character.',false],['google','Original',true],['xai','Original',true],['shell','Original',true],['docker','Original',true], + 'Companions',['companion','Original',true], + ]); + assert.equal(await b.evaluate("document.querySelector('#yard-world-note').textContent"),'Broken could not load. Showing the original character.'); + assert.deepEqual(await b.evaluate("['world','anthropic','shell','companion'].map(s=>(n=>n.hidden?null:n.textContent)(document.querySelector('#yard-customize-list [data-slot='+s+'] .yard-customize-note')))"), + [null,'Shows on your Claude Code sessions in this Yard.','Shows on your Shell sessions in this Yard, and in gold on Terminal AI Chat’s.','Shows beside sessions with helpers in this Yard.']); + await b.evaluate("document.documentElement.dataset.skin='grove'"); + await b.wait(`${row('anthropic')}.dataset.state==='original' && ${row('openai')}.dataset.state==='original'`); + assert.equal(await b.evaluate("document.querySelector('#yard-customize-sub').textContent"),await b.evaluate("document.querySelector('#yard-title').textContent+' only. Other Yards keep their own.'")); + await b.evaluate("document.documentElement.dataset.skin='guild'"); + await b.wait(`${row('openai')}.dataset.state==='failed'`); + yardPieces.current={guild:{anthropic:{title:'Patchwork',revision:'p1'}}}; + yardPieces.emit('updated',yardPieces.current); + await b.wait(`${row('openai')}.dataset.state==='original' && document.querySelector('#yard-world-note').hidden`); + await b.send('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27}); + await b.send('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27}); + await b.wait("!document.querySelector('#yard-customize').open && document.activeElement===document.querySelector('#yard-customize-open')"); + assert.deepEqual(b.errors,[]); +}); + +test('a piece whose texture cannot be decoded falls back to the original without touching other pieces',options,async t=>{ + const temp=await fs.mkdtemp(path.join(os.tmpdir(),'guild-blank-'));t.after(()=>fs.rm(temp,{recursive:true,force:true})); + const glb=await fs.readFile(path.join(PIECE_FILES,'character.glb')),size=glb.readUInt32LE(12),json=JSON.parse(glb.toString('utf8',20,20+size)); + const view=json.bufferViews[json.images[0].bufferView],start=20+size+8+(view.byteOffset||0); + const blank=Buffer.from(glb);blank.fill(0,start+24,start+view.byteLength); + await fs.writeFile(path.join(temp,'character.glb'),blank); + const yardPieces=fakePieces({guild:{anthropic:{revision:'good'},google:{revision:'blank'}}},{google:path.join(temp,'character.glb')}); + const {f,b}=await setup(t,{yardPieces}); + const base=[...f.data.values()][0],by=id=>f.providers.find(p=>p.id===id); + const sessions=[{...base,id:'claude',provider:by('anthropic'),agents:[],shells:[]},{...base,id:'antigravity',provider:by('google'),agents:[],shells:[]}]; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__events=[]; + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},piece:e=>{__events.push(e.slot+':'+e.ok);if(!e.ok){const p={...__renderer.pieceSets[e.skin]};delete p[e.slot];__renderer.setPieces(e.skin,p);}}}); + __renderer.setPieces('guild',{anthropic:{revision:'good'},google:{revision:'blank'}}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},${q(sessions)}); + })()`); + await b.wait("__events.length===2 && ['claude','antigravity'].every(id=>__renderer.units.get(id).loaded)"); + assert.deepEqual(await b.evaluate(`({ + events:__events.slice().sort(), + original:__renderer.cache.has('hero_2'), + pieces:[...__renderer.cache.keys()].filter(k=>k.includes('/pieces/')).map(k=>k.replace(/^.*\\/pieces\\//,'')), + textured:(()=>{let maps=0;__renderer.units.get('claude').model.traverse(n=>{if(n.isMesh&&n.material.map)maps++;});return maps>0;})(), + })`),{events:['anthropic:true','google:false'],original:true,pieces:['guild/anthropic/character.glb?v=good'],textured:true}); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); +test('a rigged mesh the engine cannot bound fails its load and falls back, so drawing and picking keep working',options,async t=>{ + const temp=await fs.mkdtemp(path.join(os.tmpdir(),'guild-bones-'));t.after(()=>fs.rm(temp,{recursive:true,force:true})); + const glb=await fs.readFile(path.join(PIECE_FILES,'character.glb')),size=glb.readUInt32LE(12),json=JSON.parse(glb.toString('utf8',20,20+size)); + const joints=json.accessors[json.meshes[0].primitives[0].attributes.JOINTS_0]; + const bad=Buffer.from(glb);bad[20+size+8+(json.bufferViews[joints.bufferView].byteOffset||0)+(joints.byteOffset||0)]=250; + await fs.writeFile(path.join(temp,'character.glb'),bad); + const yardPieces=fakePieces({guild:{google:{revision:'bones'}}},{google:path.join(temp,'character.glb')}); + const {f,b}=await setup(t,{yardPieces}); + const session={...[...f.data.values()][0],id:'antigravity',provider:f.providers.find(p=>p.id==='google'),agents:[],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__events=[]; + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},piece:e=>{__events.push(e.slot+':'+e.ok);if(!e.ok)__renderer.setPieces(e.skin,{});}}); + __renderer.setPieces('guild',{google:{revision:'bones'}}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},[${q(session)}]); + })()`); + await b.wait("__events.length===1 && __renderer.units.get('antigravity').loaded"); + await b.evaluate("(async()=>{__renderer.dirty=true;__renderer.drawOnce();await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));const u=__renderer.units.get('antigravity'),p=u.root.position.clone().project(__renderer.camera);__renderer.ray.setFromCamera({x:p.x,y:p.y},__renderer.camera);__renderer.ray.intersectObjects([u.root],true);})()"); + assert.deepEqual(await b.evaluate("({events:__events,original:__renderer.cache.has('hero_2'),piece:[...__renderer.cache.keys()].some(k=>k.includes('/pieces/'))})"),{events:['google:false'],original:true,piece:false}); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); +test('a piece with a triangle that misses its vertices fails its load and falls back without disturbing the Yard',options,async t=>{ + const temp=await fs.mkdtemp(path.join(os.tmpdir(),'guild-triangles-'));t.after(()=>fs.rm(temp,{recursive:true,force:true})); + const glb=await fs.readFile(path.join(PIECE_FILES,'character.glb')),size=glb.readUInt32LE(12),json=JSON.parse(glb.toString('utf8',20,20+size)); + const indices=json.accessors[json.meshes[0].primitives[0].indices],bad=Buffer.from(glb); + bad.writeUInt16LE(250,20+size+8+(json.bufferViews[indices.bufferView].byteOffset||0)+(indices.byteOffset||0)); + await fs.writeFile(path.join(temp,'character.glb'),bad); + const yardPieces=fakePieces({guild:{google:{revision:'triangles'}}},{google:path.join(temp,'character.glb')}); + const {f,b}=await setup(t,{yardPieces}); + const session={...[...f.data.values()][0],id:'antigravity',provider:f.providers.find(p=>p.id==='google'),agents:[],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__events=[]; + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},piece:e=>{__events.push(e.slot+':'+e.ok);if(!e.ok)__renderer.setPieces(e.skin,{});}}); + __renderer.setPieces('guild',{google:{revision:'triangles'}}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},[${q(session)}]); + })()`); + await b.wait("__events.length===1 && __renderer.units.get('antigravity').loaded"); + await b.evaluate("(async()=>{for(let i=0;i<3;i++){__renderer.dirty=true;__renderer.drawOnce();await new Promise(r=>requestAnimationFrame(r));}const u=__renderer.units.get('antigravity'),p=u.root.position.clone().project(__renderer.camera);__renderer.ray.setFromCamera({x:p.x,y:p.y},__renderer.camera);__renderer.ray.intersectObjects([u.root],true);})()"); + assert.deepEqual(await b.evaluate("({events:__events,original:__renderer.cache.has('hero_2'),piece:[...__renderer.cache.keys()].some(k=>k.includes('/pieces/'))})"),{events:['google:false'],original:true,piece:false}); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); + +async function offsetPiece(dir){ + const glb=await fs.readFile(path.join(PIECE_FILES,'character.glb')),size=glb.readUInt32LE(12),json=JSON.parse(glb.toString('utf8',20,20+size)); + const binary=glb.subarray(20+size+8,20+size+8+glb.readUInt32LE(20+size)),most=Math.max(...json.animations.map(a=>json.accessors[a.samplers[0].input].count)); + const offsets=Buffer.alloc(most*12);for(let i=0;in.name==='Root'); + for(const animation of json.animations){ + const input=animation.samplers[0].input,output=json.accessors.push({bufferView:view,componentType:5126,count:json.accessors[input].count,type:'VEC3'})-1; + animation.channels.push({sampler:animation.samplers.push({input,output})-1,target:{node:root,path:'translation'}}); + } + const data=Buffer.concat([binary,offsets]);json.buffers[0].byteLength=data.length; + const text=Buffer.from(JSON.stringify(json)),padded=Buffer.concat([text,Buffer.alloc((4-text.length%4)%4,0x20)]); + const head=Buffer.alloc(20),chunk=Buffer.alloc(8);head.write('glTF',0,'latin1');head.writeUInt32LE(2,4);head.writeUInt32LE(20+padded.length+8+data.length,8);head.writeUInt32LE(padded.length,12);head.write('JSON',16,'latin1'); + chunk.writeUInt32LE(data.length,0);chunk.write('BIN\0',4,'latin1'); + await fs.writeFile(path.join(dir,'character.glb'),Buffer.concat([head,padded,chunk,data])); + return path.join(dir,'character.glb'); +} + +test('an animated piece stays pickable and drawn at every pose, with nothing left on its cached asset',options,async t=>{ + const temp=await fs.mkdtemp(path.join(os.tmpdir(),'guild-posed-'));t.after(()=>fs.rm(temp,{recursive:true,force:true})); + const yardPieces=fakePieces({guild:{google:{revision:'posed'},xai:{revision:'offset'}}},{xai:await offsetPiece(temp)}); + const {f,b}=await setup(t,{yardPieces}); + const sessions=[['antigravity','google'],['grok','xai']].map(([id,provider])=>({...[...f.data.values()][0],id,provider:f.providers.find(p=>p.id===provider),agents:[],shells:[]})); + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},piece(){}}); + __renderer.setPieces('guild',{google:{revision:'posed'},xai:{revision:'offset'}}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},${q(sessions)}); + })()`); + await b.wait("['antigravity','grok'].every(id=>__renderer.units.get(id)?.loaded)"); + const results=await b.evaluate(`(async()=>{ + const T=await import('/yard/vendor/engine.js'); + const r=__renderer,frame=()=>{r.renderer.render(r.scene,r.camera);},out=[]; + frame(); + for(const id of ['antigravity','grok']){const unit=r.units.get(id);let mesh=null;unit.model.traverse(n=>{if(n.isSkinnedMesh)mesh=n;}); + for(const [name,action] of unit.animations)for(const share of [0,0.3,0.6,0.9]){ + unit.mixers[0].stopAllAction();action.reset().play();unit.mixers[0].setTime(action.getClip().duration*share); + r.scene.updateMatrixWorld(true);mesh.skeleton.update();frame(); + const index=mesh.geometry.index.array,point=new T.Vector3(),corner=new T.Vector3(); + for(let k=0;k<3;k++){mesh.getVertexPosition(index[k],corner);point.add(corner);} + point.divideScalar(3);mesh.localToWorld(point); + const ndc=point.clone().project(r.camera); + r.ray.setFromCamera({x:ndc.x,y:ndc.y},r.camera); + const hit=r.ray.intersectObject(mesh,false).length>0; + const drawn=r.renderer.info.render.triangles;mesh.visible=false;frame();const hidden=r.renderer.info.render.triangles;mesh.visible=true;frame(); + out.push({pose:id+' '+name+'@'+share,inView:Math.abs(ndc.x)<1&&Math.abs(ndc.y)<1,hit,culled:drawn<=hidden}); + }} + let leftover=false;for(const [key,pending] of r.cache)if(key.includes('/pieces/'))(await pending).scene.traverse(n=>{if(n.isSkinnedMesh&&(n.boundingBox||n.boundingSphere))leftover=true;}); + return {out,leftover}; + })()`); + const wrong=results.out.filter(r=>!r.inView||!r.hit||r.culled); + assert.deepEqual(wrong,[],'every pose is in view, picked and drawn'); + assert.equal(results.out.length>=48,true); + assert.equal(results.leftover,false,'the cached piece keeps no bounding volumes from its check'); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); +test('a piece with an animation the Yard cannot play fails its load and falls back without disturbing the Yard',options,async t=>{ + const temp=await fs.mkdtemp(path.join(os.tmpdir(),'guild-nan-'));t.after(()=>fs.rm(temp,{recursive:true,force:true})); + const glb=await fs.readFile(path.join(PIECE_FILES,'character.glb')),size=glb.readUInt32LE(12),json=JSON.parse(glb.toString('utf8',20,20+size)); + const sampler=json.animations[0].samplers[json.animations[0].channels[0].sampler],output=json.accessors[sampler.output],bad=Buffer.from(glb); + const start=20+size+8+(json.bufferViews[output.bufferView].byteOffset||0)+(output.byteOffset||0); + for(let i=0;ip.id==='google'),agents:[],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__events=[]; + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},piece:e=>{__events.push(e.slot+':'+e.ok);if(!e.ok)__renderer.setPieces(e.skin,{});}}); + __renderer.setPieces('guild',{google:{revision:'nan'}}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},[${q(session)}]); + })()`); + await b.wait("__events.length===1 && __renderer.units.get('antigravity').loaded"); + await b.evaluate("(async()=>{for(let i=0;i<3;i++){__renderer.dirty=true;__renderer.drawOnce();await new Promise(r=>requestAnimationFrame(r));}})()"); + assert.deepEqual(await b.evaluate("({events:__events,original:__renderer.cache.has('hero_2'),piece:[...__renderer.cache.keys()].some(k=>k.includes('/pieces/'))})"),{events:['google:false'],original:true,piece:false}); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); +test('a piece whose rotations are not unit quaternions fails its load and falls back without disturbing the Yard',options,async t=>{ + const temp=await fs.mkdtemp(path.join(os.tmpdir(),'guild-quaternion-'));t.after(()=>fs.rm(temp,{recursive:true,force:true})); + const glb=await fs.readFile(path.join(PIECE_FILES,'character.glb')),size=glb.readUInt32LE(12),json=JSON.parse(glb.toString('utf8',20,20+size)); + const sampler=json.animations[0].samplers[json.animations[0].channels[0].sampler],output=json.accessors[sampler.output],bad=Buffer.from(glb); + const start=20+size+8+(json.bufferViews[output.bufferView].byteOffset||0)+(output.byteOffset||0); + for(let i=0;ibad.writeFloatLE(value,start+(i*4+k)*4)); + await fs.writeFile(path.join(temp,'character.glb'),bad); + const yardPieces=fakePieces({guild:{google:{revision:'half'}}},{google:path.join(temp,'character.glb')}); + const {f,b}=await setup(t,{yardPieces}); + const session={...[...f.data.values()][0],id:'antigravity',provider:f.providers.find(p=>p.id==='google'),agents:[],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__events=[]; + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},piece:e=>{__events.push(e.slot+':'+e.ok);if(!e.ok)__renderer.setPieces(e.skin,{});}}); + __renderer.setPieces('guild',{google:{revision:'half'}}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},[${q(session)}]); + })()`); + await b.wait("__events.length===1 && __renderer.units.get('antigravity').loaded"); + await b.evaluate("(async()=>{for(let i=0;i<3;i++){__renderer.dirty=true;__renderer.drawOnce();await new Promise(r=>requestAnimationFrame(r));}})()"); + assert.deepEqual(await b.evaluate("({events:__events,original:__renderer.cache.has('hero_2'),piece:[...__renderer.cache.keys()].some(k=>k.includes('/pieces/'))})"),{events:['google:false'],original:true,piece:false}); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); +test('restoring a piece or disposing the Yard cancels its download, and the original still draws',options,async t=>{ + const yardPieces=fakePieces({guild:{anthropic:{revision:'first'}}}); + const {f,b}=await setup(t,{yardPieces,source:` + const originalFetch=window.fetch;window.__held=[]; + window.fetch=(...args)=>{ + const url=String(args[0]?.url||args[0]); + if(!url.includes('/yard/pieces/'))return originalFetch(...args); + const signal=args[0]?.signal||args[1]?.signal,entry={url,signal};window.__held.push(entry); + return new Promise((resolve,reject)=>signal?.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')))); + };`}); + const session={...[...f.data.values()][0],id:'claude',provider:f.providers.find(p=>p.id==='anthropic'),agents:[],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},piece(){}}); + __renderer.setPieces('guild',{anthropic:{revision:'first'}}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},[${q(session)}]); + })()`); + await b.wait("__held.some(h=>h.url.includes('v=first'))"); + await b.evaluate("__renderer.setPieces('guild',{})"); + await b.wait("__renderer.units.get('claude').loaded && __renderer.cache.has('hero_0')"); + assert.equal(await b.evaluate("__held.filter(h=>h.url.includes('v=first')).every(h=>h.signal?.aborted)"),true,'restoring cancels the download'); + await b.evaluate("__renderer.setPieces('guild',{anthropic:{revision:'second'}})"); + await b.wait("__held.some(h=>h.url.includes('v=second'))"); + await b.evaluate('__renderer.dispose()'); + assert.equal(await b.evaluate("__held.filter(h=>h.url.includes('v=second')).every(h=>h.signal?.aborted)"),true,'disposing cancels the download'); + assert.deepEqual(b.errors,[]); +}); +test('a Yard that reloads shows its pieces as loading until they draw, and a failed piece stays failed without new downloads',options,async t=>{ + const yardPieces=fakePieces({guild:{anthropic:{title:'Patchwork',revision:'p1'}}}); + const {b}=await setup(t,{yard:true,yardPieces,source:` + const originalFetch=window.fetch;window.__mode='fail';window.__held=[];window.__requests=0; + window.fetch=(...args)=>{ + const url=String(args[0]?.url||args[0]); + if(!url.includes('/yard/pieces/'))return originalFetch(...args); + window.__requests++; + if(window.__mode==='pass')return originalFetch(...args); + if(window.__mode==='fail')return Promise.reject(new TypeError('Fixture download failed')); + return new Promise((resolve,reject)=>window.__held.push(()=>originalFetch(...args).then(resolve,reject))); + };`}); + await ready(b); + await b.evaluate("(x=>{x.focus();x.click();})(document.querySelector('#yard-customize-open'))"); + const status="document.querySelector('#yard-customize-list [data-slot=anthropic] .yard-customize-status').textContent"; + const visit=async()=>{ + await b.evaluate("document.documentElement.dataset.skin='grove'");await b.wait("document.querySelector('#yard-stage').dataset.world==='grove'"); + await b.evaluate("document.documentElement.dataset.skin='guild'");await b.wait("document.querySelector('#yard-stage').dataset.world==='guild'"); + }; + await b.wait(status+"==='Patchwork could not load. Showing the original character.'"); + const requests=await b.evaluate('__requests'); + await b.evaluate("__mode='pass'"); + await visit(); + assert.equal(await b.evaluate(status),'Patchwork could not load. Showing the original character.','a failed piece keeps its failure after a reload'); + assert.equal(await b.evaluate('__requests'),requests,'a failed piece is not downloaded again'); + yardPieces.current={guild:{anthropic:{title:'Patchwork',revision:'p2'}}};yardPieces.emit('updated',yardPieces.current); + await b.wait(status+"==='Customized: Patchwork'"); + await b.evaluate("__mode='hold'"); + await visit(); + await b.wait("__held.length>0"); + assert.equal(await b.evaluate(status),'Loading Patchwork…','a reload shows the piece as loading while it downloads'); + await b.evaluate("__mode='pass';for(const go of __held.splice(0))go()"); + await b.wait(status+"==='Customized: Patchwork'"); + assert.deepEqual(b.errors,[]); +}); + +test('the row being replaced says Checking replacement until the check answers',options,async t=>{ + const temp=await fs.mkdtemp(path.join(os.tmpdir(),'guild-checking-'));t.after(()=>fs.rm(temp,{recursive:true,force:true})); + await fs.mkdir(path.join(temp,'mine')); + await fs.writeFile(path.join(temp,'mine','piece.json'),JSON.stringify({format:1,kind:'character',title:'Mine'})); + await fs.copyFile(path.join(PIECE_FILES,'character.glb'),path.join(temp,'mine','character.glb')); + const yardPieces=fakePieces({});let answer; + yardPieces.replace=(skin,slot)=>new Promise(resolve=>{answer=()=>{yardPieces.current={[skin]:{[slot]:{title:'Mine',revision:'m1'}}};yardPieces.emit('updated',yardPieces.current);resolve(yardPieces.current);};}); + const {b}=await setup(t,{yard:true,yardPieces,source:`localStorage.setItem('agentGuild.pieceFolder',${q(temp)});`}); + await ready(b); + await b.evaluate("(x=>{x.focus();x.click();})(document.querySelector('#yard-customize-open'))"); + const status="document.querySelector('#yard-customize-list [data-slot=anthropic] .yard-customize-status').textContent"; + await b.wait(status+"==='Original'"); + await b.evaluate("document.querySelector('#yard-customize-list [data-slot=anthropic] button').click()"); + await b.wait("[...document.querySelectorAll('.folder-piece-title')].some(e=>e.textContent==='Mine')"); + await b.evaluate("[...document.querySelectorAll('.folder-piece')].find(r=>r.querySelector('.folder-piece-title').textContent==='Mine').click()"); + await b.wait(status+"==='Checking replacement…'"); + assert.equal(await b.evaluate("document.querySelector('#yard-customize-list [data-slot=openai] .yard-customize-status').textContent"),'Original','only the row being replaced says it is checking'); + answer(); + await b.wait(status+"==='Customized: Mine'"); + assert.deepEqual(b.errors,[]); +}); \ No newline at end of file diff --git a/tests/fixtures/pieces/character.glb b/tests/fixtures/pieces/character.glb new file mode 100644 index 0000000000000000000000000000000000000000..a5060ba17f1df0f24d1c3591bd381991d8b1c99b GIT binary patch literal 8672 zcmc&(OK%%h6rS{fOQlLYHh@@!5rRdh#xr9(aila!leBS_I#pr{QWTYmeQgi1J!WRy zCPYyqc1VaFi*{8ZA)$i0>AIyWR;h%hfbYeBNwzH<} zKjFR6fUG=YHw-cWZ>1V++YIhkRYl7}nr*ta0p)FZ%dpFItueD&16JUJ7uPn+^f$Qtj4G2c6E=m?MOmR5RU*nV{l_troN6XlIpi zMI8&8D6rCo7;?mQNN<4|Ww9O*2LigtD(*_nYS+umj>;!f@DhzquG<2iDCi`xfY5fw zNR1#^wC*xntgit<#DLljV8Lh#rkfNhhd6Q5wptVe0dksqG+2D9i>xY8P?bw5950a4t(^WV*3rQ>i}kn%b?<54sjFFq+y8zOQjD)fJe&QP(uR zYpVT?r{fu?14}Tc{f&>uGOnv}j7Rv=KP-BzgzB-Fu0`0`H!SIR2~EdJXj+g^Y%NBq z#2m-!Y%YA`ppArY2qml0vYI%B7PneBmS$89yF$roH*uOBM;+vE4SAIs@|!V-qjwotn(jk;vO4y-mmSqms!Z`q6M??`&C~Hkc9B<|d;k(-CbPx}(}X z<`~sZi?q2(?puc;KSV3DGmA6J5%=6v@2R6ScMRN^DMtN|iTod+M`*(CJ2}E(Ydkvu~j!Vc^5nIIZ0vC@qtv-1a#_O;}u+L&KuH#q)Sy3K`qrG|@ycflbPD4ja zgfL#(aqvDZh?f?V_t=5V1{yTM6RWqSgJ})3!MvK|34PFNR%JTrx@fQP)(y339II{f zb_tE3CBtmfsC)MOPK`rnXhm+V+c%hN)azzx*=kp7O_V(9%&Z#@o3-keUG&V-JF{74 zs987!;wbLvTs2C(pOo6+j&MNtQTtx}lY7fH8>VaC@dphTt)BI^OOM;~&83xmF~3yc zb+tJE@$Ayl^4#qdnm>wseJNikuFwNw_|bg++Jg6_0|VfgsL=8RmTzZ@gpPNnXnNj! zKebG+SA;HHR0@RAL;|V*VV?=J`BJNNv3KpxLr_ws}L z_kEe*br*K&d3Oru=_#@ozINX4PK{u!-yhi^{e#a*KaWVK;rz68N;)IGBqi{jkj~=G z8UB41=ucrZf$s#yC0yzIC5(?iB7yG&#=pE-xOUsB zQ2M(5>%jV0=T9Z+`M~W*0>s@K>#an;yJvmJL!5`Fz!Se^)L+OJ*nDgNNy?QpdKJZTt$sfdjDE>?i w$shO+@<)<>c=QM{4SqaMd0dgC#N&JZ`1Ev8s>8jg+{6D#?)fqM-96Oazd0A&(*OVf literal 0 HcmV?d00001 diff --git a/tests/fixtures/pieces/companion.glb b/tests/fixtures/pieces/companion.glb new file mode 100644 index 0000000000000000000000000000000000000000..a0a151e64cd930404d5291b624d960c5d97a9549 GIT binary patch literal 5016 zcmcgvOK%%h6rQAMT~#Uqx`I_S8y20I8ISEeq~@I_u2Q>7OhJmGa${fHLp%>=#%V%i zHDZT^*ddXw5E2`NDx~fUD^{t5#Eu0!#0q``-?=kRCZ4flDp}fak&#*0dM$V*FS&{49;x6-T7c&e4f1=(p zn$-!<=gb^8*)D%lGxq(K%RRZR$X*5V@{AhxJb7m8P`2zUR%f=J0NAuBJq zCL;h?t2BV^a+u%Jlqm@Fecxr!J(LfaTcx(ajd}xAwX_B!zH3y;UOJzL_CckIftJ?s zYq#6Vsfi8S_5lwp%WE=UY;a3e@CO?aqnYi9(wgCT2})Wzrlv>LHXQ>lNH7M%UFNb; zy`={C5=o}DT(@E_s`!E9aCaE(d{n#0E4#^TV^$}ldTX3jll4G0;>8`idH^|-dyOD2 zb`2b_N|CVBs?`u~B1U!&e7?@Dx(_KWqej*r8~kY~Bjg1NiI-|M5wr-lsP=T8tOwXJ z*=dAHR5+jQBdq1Sp!G~Y>FLaQY%Md@M_kWzflu}W*JI$h=|14OTo;#YwjUQwRlCSs zAU$<{r~Un+W`_dL4hKFp6!_Fo;0VO?8|~{BocDR@{%(mySAXz)92{4ORVv)`?9L?; z7lba12>gm|I<|$JT|RJ-7js&MlBC_TkUoLti&^>c%92hOl&AWH)&!R4{gDgC-oeW+PovRsWBTVDk0y54r8`IX4w0rC^uANQN7LdZW4xrFQ zn@bDp3mY->!cuP+PoC;OTqwuQV>!O{GxG4vdF!1cQq%Mn2~I{wzuToa*}-se@Nrht zgKCF9Nn(Y{A;wC>O}Ydv?Se2H$XnSYUWpNybPS2^(k=+Wfp{gIqLvt)gY(J_>h3=0 zh)O4Zju!;|*@gpuAyf?$g=6#$9$bAeG!2snxhW8O-?8d4RR%ulK2cIq`RLg#w?ai8 zt)NuouEClCURXhTR8uIjv*SMGK5I6O%7)#lH!P$^Dj9c}$CvD;?Un;rnlr2_M{ZNw zfDCo+vx=y5)wVE%f+b`fS_vx|$2CmDH}=9s=A#DPY56oflpmEg3*|zoDDrQ4<)fui zX=C|`ike1|ACwBk@+J-7qz_jLt82kX2M5qGkjdp49D8SrV#h>S{_x z(Uq){Q}W6rL=1}xcfJUmhYW-S7Ch|j@+SAGp4SM8SvU+Te*?I1V@4HaGh~_RG&}W} zVL2_lCUDwz%NJebD;VdlV|OZOdA(3vp}Yv>N`qMz505285>D`$TMs@h=rcg<-C#y*cD?LAc=T)RXOa0d;Cv%u{XnO=)8XwK!6LgO7&bOWUqqz~R_4_9b(m(rM^>{?O z2IJSHQE5!NA*Jw4N!KwmCZ5;9eiW-IJX2Vg(9-h;)<+7$)#>l=tlo8}4uAaRySsnCd~vYQe1wq`0?lHcKelcz9UaAiJh!={(q(SUM; L+U-8$xZVE%{77Cp literal 0 HcmV?d00001 diff --git a/tests/fixtures/pieces/external_uri.glb b/tests/fixtures/pieces/external_uri.glb new file mode 100644 index 0000000000000000000000000000000000000000..0b82d366893fbdaa064298fb381536aadf06f2b5 GIT binary patch literal 8696 zcmc&(O>7%Q6yBsQE|n_ra{*3eh2YSw>)o}TI8vIVN!qwdohmT}DT>O*9@|T7ui0I< z2~pID8xrEip*>YdNT{G*dT!~7Qz{{GAZf-^_HH@i-Dy<;b9hu|dM-I;msee>Qo z^JZVF_2RWrNs|6~SCZ0iNz#p#rNXvsI1Y2=iEa5VvmMiF$`i7dOv#E|Wld%qu4SXe zz=M0K+BWOuOODHo#)XEl!R}PdO}A|`N8V9nr-bqHM9RN&jzCBSq%JXCQFTQN39qzF_()(fsdh`JgGOg-%n?FOs+n%VOi=K{R*Ttjq_fJn zqK*Ys6j*6P3^`&tB)7nfvRDp?0}fqe6?dg(wd-YON5>~quoCr7u3H12DCi{60M~Z= zNR1#^wC*umEUy7UM1k53V8Lh#rkfNBhd6P|wp!!^0dksq)LDG0i>xXTP?bw5?y)d| z)exD;7@w|C2Wd!0BCCd2MH;S&7mrvv+wHBQg>50a4t(^0MLpuRI-3IUQJnG3~E>JeG1@jiWrmm;P?iV+B-?#dIyg#=dSz#|vmWRzTB&fMRPg zN+sqvR%dhJ8wYJ9d_yQ%jh5BKDYUrN!m%`?YS^SluZs(RQ^; zDFNx9_UR1NH;hd*Qk$C0(vis9BfU+>v!jyABihk$WbbTQo-~*dmF6m=DAN&X9J-^@ zJ?0pdPK%_uN^V<+BHu+Tvonh`%MtV3QtzoFHFp%;oGC`_kBRIbpoeS1<~uRMVQW7$ z-n$3Ae{?=PjRpgRNcVHnff*1X3X)!P`h@pD+V_zNqz=H{fmgAG98IwhmU;;7j)W^C z!Uf4;a2-O)!Ep)MDq@S+U*O`=rqw5oLVq2$2=-Yl#&sNvAS;UFaI{yCgZCnT(P`*t zfe`vjI}YBb1^&`v@*X>o*+79Ncw+UobTF-9Hkg-lyg`>+O?q)`+a}5{$>e4HI;>_ow4q@sIQ^+iaMwdDrhW zT-1Qp+b%tB%eR(R^2Pj8fhXMJ{KvCPOUrY2Qm6_k@{Of@p}0a1aPCL*`RfbblMW1k zVA#!xQFDcC`=3*u~rf{&V3P?J%! zsG=w;YAHDdJOft{RSo5!w1Sr#vPso8b``d78khvPk8n8~tj=62!l~p4qlw5;VakKx zJL8^Fr;3bYG+On}%Z%An zcbse3Rj(dMwL9*wmO-ybvTeCukqj}S9MVB!*&A@I7!Q_y2M?C<$>C9Z;GtaC0Yr)G zL=d%%wnJ4vQNZXxz7Gf)>qG#V{)9&9> z`<1}>&OL7rjK?#Vz4GwE1Ak2Lx(mJZygP++_7v$0Upwu)QzPi>_eVNN{@`=c&m+<) z7(XSAN~fh)qy)Yb(iyaz=HF+4eiXe4d?(N^;Y#1Hpnn7-68KJ_|I1s2>*vqCa}HhS z^9ys!xK7jGSsMJ<{OosJD*3sY;^uFE{W9}9sZZs;d-M85dwl!H7vEm|^XY3Brgx>0 z(bH##y7@byOn&{wH=loS{|{J{^7DncCs#lD0=M0MrS3RwamJEv`t1JXUlHT@@-O$k z8cJT09t|b0>raQ0uT*{>N?zB09atXg{Fx-Z7+9Y6jbY@me+(sGsk{&DgX713G?YB> zKht$*VE>UkxIdJ<7el^|rsA$2&;FzLyeqA}cI8SD0%P)a~|Bj zm(K>52mZ++`2+tC#h=L``2+hw{z%dfj~~OQ!H>r&k1LXtcyizGpPmkm>ToYQ?$Q4= M?!__N-91#_zhDaFFaQ7m literal 0 HcmV?d00001 diff --git a/tests/fixtures/pieces/missing_bone.glb b/tests/fixtures/pieces/missing_bone.glb new file mode 100644 index 0000000000000000000000000000000000000000..e2bf1a6ef7ac2e100a7a48b778a790dc95241627 GIT binary patch literal 8412 zcmc&&&u`mw6nDFgj7gLDaX@gYECfjNLTo2#Q>kmawp%l0?bL>WR8_-GzDZ0IJJ`up1WVdSimeaAAEp{c*u3)`5nepyyadK@(G|VbyC)YCT-F0dI z3FBG|YsFcsr4azMRT{vy7}T%Hl9I)0w&hqFgm=Vk&8pJ1!Ss3qT)_`o980f~!gK-B z+m$Bn<+O?q<~mkQtMJKj1xUBSo@lIhyIpBt`-*8g&3(Y1L>sF!tG`#A?oJ!|XJx%nGkhXP_l)okBkX-zdT<0s*G& zw~-uTP%`f@D_ULwgAjn+Phe3qBDR~36b=N`4a;nk4I-G+-owV?TP?AAgn%qv%y5eZ z16U3O6Di}{jl>{?EP2-Fq7{1W2KvZT8FrGK4}lDH6F{Q;6o@EJW1ype5NYg*5Q#`v z4H6GT8u^N|2-)55 zWb$z8@-~nTP~I@B+gRITcEha*n>rGEhHAh@r&dFu;niVd+hHYU)E%r+)NJ-({-)0E zcx(82jzY_qYc*c3PyqbWg*=6;iyc~9$OjkavqKik`95e>8MeBB^5Q2VD-%POt4bgE z_%Lua1fDAl0ng?7xnzgJD;XslBtMM0l2QBMhZ;u7p;0<`c2N`y*gDxDv~uN<4=uu^g(zbEq22p=yLfky^A$h1s^*OD;TdP$J<8p<=e$ zrh#){dAp5+VNO<%6)I-Oz9~4if;^-i1&8Kd%5tR^<2~7tt-It0ItuL!}`ag^dGQ?X@cr|KEiQl-!<;(pMC-KKD>ze9RyE@w;81d zfezA|vw9=EBdPLvG89sVJvL@`{Ys z!}H3$EMA<#ke6UTilhB_94|pW%8$vyUOtYvkMbj(h7!cRavX7=jj)#zGwzXr^cGqq z{u8U4(h+Gby~WCIb>WLXY#VivPPz^n9K3l#jS}1JSiE6DGiZu1OV^q%p4Tg}=?pE2 z?G5WXbF^ktudJAzdc#1;qu$7dX0ti7X(({cMy@D3G;9vjozgh`|M;N9-=!Fzgg zeB=RqsF?K#QDr(2Ms2NQQ)4a4VRTsBCxnu8B82p6lMQqkLid0XLh|Go4Ro3>s_{OZ zAR}dv*L-nJ1?WU9a7a9VgpNZ~sroWaOx{(T1QM=_hicM9_YuJrv1=0~t1h3^#R zzq+w>_58Va&SC0&adCbH*BSahON$@spZ|$VtvEkh-unHYUuRz@_)Ou4H?Ll>CU$;$ z_WgywAHOy>vnz~@o<5uC=HHYw_01dKe)0a@zo1blE-cMIy8P*vxb3$qO~)CFvzD;w zv-`7mg^%OQKi~U0k-Q*0NF=YSj}yt)YQH3sSJmH+ERXm6i6A^XvOMLDB=X1~iR5dw z_rU$={E?3m$%FqBRke?7Kaxl6Cy*~qCD}jxhx~kW`+<@_&@GA^bl7e_2{lQKQrTB)zckb_uzk8 M_w1PU?!FN3KOl{vmH+?% literal 0 HcmV?d00001 diff --git a/tests/fixtures/pieces/missing_clip.glb b/tests/fixtures/pieces/missing_clip.glb new file mode 100644 index 0000000000000000000000000000000000000000..580579611c5ea8884d719baa39b77bfb7cb35b6c GIT binary patch literal 8244 zcmc&&OK%%h6rQ9Hmr9j*NJy}XMhF(28qbXF#F5e@P143ynpBA?NKsTK_O(64_L!M* zn-E2f*dZZyNVKa8iA_P>bluVwt5iZ_$ATSV1;2ss+!-e`p6hAsDZx=Zk9)s!zIz_` zT%TH_bahmcr0kd^efPE`U0+*S-jxl z;9jbA%tqys<1(W;)-<-*?W(!$c5LRzdy4FoF<+j{_;-#xxxOp6tO`aa*E1V?8_MAm z-WyHKm1pgyK?0zyT!(C%LH)X_XnD+L+pcW@yesb*c7?8WX4dN93VzVy+Gd3mrVBvt zlpDBL(>gvF>)2JJ%m>FcNV)^|L}O!bZ%;YYzG_)6G($_vX&5eF`+7#fAG8PfXd400 zqSQ<*wVRm$WIs+|H>lAt=@P#6r1QKA{UK^LQ#g2>fcDA2eFVW9MbhYUfH!bLjs* z%j(+E_$`y&_2=;UTnbHJsaAQhMEMD#3whelJlT=Cg?xB&J{vPx&G(1~t^T6b0*YIZ zh^kG*OxLv@;p1__^@#9XAx3yE*UKfJ$;8ap)LuXwb!{-iXc5cA3fD3{hKaShrs+LP zjTfFBOgJ4_f`$bO?-W{zWZ^PlH7 zC zhalfXYjd+pv#SyHTvPw0qk47)+?Xvz^^c46AF+pN!s@#|!g=V>HQvty`iao}@JbhS z5Ih}cq$4#5geXb+B@*M_BeZWL5lJ0{dmXPL%a12mNK1W5*d2>kPQ(kyB)nb{lEd>7 zMyn`V#P&ig9&cGOeiYj4Wl3-t#bREsA_?*$KPC%%^;N{Z$X;|{Ii4dtdm+=!CVCaY z!<)Bt!lO;I$-KSYoAhD3Rg>w^=c3cVyBO55aIB8aI|np^?gX<B2BtWBzC+>L(B8l`T`)4Mn}*?LMQ+yLddK%SYMYRfR7jBYg)GqkLw2lwB37P!Am_b(0(rn~R` zdwPF4Fu!}x-v{R7nPV?MxPRZD6TI$2E=V{qJ9A-yrds!Vhm?gu|KIC~cJ!t-T`X9dMT`Bhl%RlIzN@9P+59-74{sZqsc*GCZJiLCd zo(+!&|EYxW1N$e6pQ(iL1O37Hk)$6VJ%UYxA1|i7SdpaE<9q)2%uH}rH}<0Q9{x}B Jp1(4^{{nbXTRs2) literal 0 HcmV?d00001 diff --git a/tests/fixtures/pieces/texture_over.glb b/tests/fixtures/pieces/texture_over.glb new file mode 100644 index 0000000000000000000000000000000000000000..e5ba5525b8be5d750566b9180bb64b4b83fc9f90 GIT binary patch literal 8672 zcmc&(&u<$=6yCN;1C@%9xB%i%R)`B**Sl*waik_qlQeOaCRO6LNKsTa_Sjisdsn;b zHc=E2aYN$5v6YZ=0ih!G!nvgs>Y@Ar9FX_}_}=U~*&kykYlpB=y}L8>-uvdg zZ|2Ru)*Iy;XC+B`^RXm-^u8qBTv=M&kqy^no;15tF4x;;qk7r(n9;n{G&b4&nz`k*9p=iritJV}ULMZ|Pp&+^x+Ax&DtgCPvunF+ z%E24jjV8v*Q%=($1+Z4xfNqDu{Hm&G1&n4}o?}3GN8UD^D&04jS>FIw;DZ&59-Z`p4(*vL<7;C$`yUKy}Wy|tl8CF_u!|?drSF;NKU_FFK zTMG%zo3<+uQUNI@rYowhXc6HR+k}qH@0^1XSg6mU}Ep zU^PM}GRCJX)Il0@iO8xER*{Aq;>9DDF2uc6wy-TE*Fk_j6Y=gcE&8quIS@&NXe?}$ zE9puSC=szF!ZNO*D@~w56lyI(G_FEOly2e?M-WrEiiGC^Cq%h&IAu|w`IG~>C|fi= z;kdMP(ISN7K+^Cw34L^wBD}Okmh)9THp5A0&CSh zjM8$W+F}1)lRXT^@bO#=4PUC&c(O$L2}>6Xw4eF96Jv{o=-@)G$6&P(Q>|;gMi-IX z!a!7QtjBO&iz&yPyW`dq%Jan@!}Iw#mO?h$W4xxu1$sf((m6&` z>9MKyR-Q|zoDMAEnD$mamP)yIuiMNq`&F-c2rV%OgkEm?42#kmj*ME(p+T{WiBC&Lw8cT z%N&!^IgvD1$!+UU6u4+*dTL>6Iboh#>OOU(=8l3}Q{|-nQIY*a^l(kod?!XYY#oHg z|MsBokIu!@XgENKbT20zngJ1#An7xwM|cmV10RV%>Hr)MyoxR4c#4Iv)FWtjEL?dJ zE=Uf7>j+Brk4wl_5nIIm0vC@rtsZd{`s=7gaKK_Qu9H{<1yLLaqrG|(ychY4PD95F zgwS8wN$@@=@Ryd7_t=5VCJHp+6RW?agK15($^4w-54vKv>N1^lJ(O2?>4wTQuGMyU zxrBO9l3|W%G<F)SQCSi&{XjL{U*PDJq~7D1qQ)R(J;wR_p2EQM=%wl-2=6`RP;;*+$!?;+`mAbnZR?gv4|z zfXr%xbqyH^e255?JUv8RgBD^nR@EsY(z-=0#A`}Jr{be3z7x9Op^VjG5v8C15YO7~ zrAt4(ElJl)i_)u?3(xW22GZk4A3P5|PtfzLmoFzf?ZGp(PlU#Io_Tv{Jl?tN#N$Vg zf-&Ly9`w@t-XzY`lcX!k@pIByX-GORjo^1g8b-?y z{~ZSUv*;bc?+E%O-0Al``p;m*2!2P<|IO{ixr-M*ynwEYrTLjG{2Q!~R0N<<0lQ z|BT=pB(Lkg z3?yHx{Wg%iuK(V*Jl6R$NqW(@Jnb8U$YcK)NWND46xjR6kNs#MdEkGh>u%rvBYALt zAbCHAd>u`uT|d74{ZITmt-W;X54Nuk;(zc5`Jw;#0rx=i;1A~9zkNTS^)Cj1U?O4KBs)HNYcpDuY&$-*TSPZ+)Iwz|DVRaIAObcN9y|* DLRi$; literal 0 HcmV?d00001 diff --git a/tests/fixtures/pieces/truncated.glb b/tests/fixtures/pieces/truncated.glb new file mode 100644 index 0000000000000000000000000000000000000000..d7bcec9b6882dd4064c0a222068543ad40c70c04 GIT binary patch literal 4336 zcmb_f%Wm306h-}tF1l#Fb}Y~M!DAOijiQPsRiN1*R5@koZ zxZ4@n!_Ck<{KEBMj#%R|od=u%*}^HfQ-<^dVp1Q`>@`mVfY-(bC@vJV%f@+cnk;7{ma6fVjVx+)+GPf@P}oVsfYO$0 zL=*+xpBvwH%zmRXR%~S8;}G{llY2`{io&6O0A*IoPSUr0U-DhyJ8P4iKe9tzuF^a?&Iq z(YZu&nP50+6ENsnO(mlV3awF2!KFfQrw9tAa=RkPUO6sV*J!ci9Zq%&b(F%@wF{LD zCxq1T3zgg^;B^f1n2c5^(VS(|?SAwWVOz2J4)nMjkFm{(z3Fk4vo4D#IijfBq{sUY z5qsIih&p)EvHk(jg@6_>owG^)lgT{P>hx!3|W>X`{5!62OlJ2AcV<$ zk;FKK_E!rWOFcp{6~bg0<22jCJ}9ml58tmGKDhX}8t^Rv+drSv9_Z)6D=zYD#Pa!( zXgedfLuZ?ZaLP0DP#oF!BWsI>GgP#oR8_hP8i#HbePWF&+SQ>2rO54oXjh`%)n)hc zUP&)9onAV&<~_mB%f6bwsmp&(H9*y9a;&*nh+}T5J$HT~(xG8W=$p z$zO9S+IvLrYNQkCKybP71S8};Vy!IlhV9PM<>_<*`4L@jSn~b4v~Jb8MPDy?@w{bK c^if-{w;qCni}kwx$3x)j`uH*1lXv_6AHzLUF#rGn literal 0 HcmV?d00001 diff --git a/tests/page-folders.test.mjs b/tests/page-folders.test.mjs index d7fe758..59f954b 100644 --- a/tests/page-folders.test.mjs +++ b/tests/page-folders.test.mjs @@ -12,6 +12,7 @@ function element(props = {}) { textContent: '', hidden: false, disabled: false, value: '', checked: false, title: '', open: false, children: [], attrs: {}, classes: new Set(), classList: { toggle(name, on) { this.owner.classes[on ? 'add' : 'delete'](name); } }, replaceChildren(...children) { this.children = children; }, + append(...children) { this.children.push(...children); }, setAttribute(key, value) { this.attrs[key] = value; }, querySelector() { return this.children[0] ?? null; }, focus() { focused.push(this); }, @@ -53,10 +54,10 @@ function page({ answers = [] } = {}) { rememberRecent: () => {}, loadRepos: () => reloads.push(true), cloneParent: () => $('github-parent').value.trim(), - document: { activeElement: null }, + document: { activeElement: null, createElement: () => element() }, Object, }; - runInNewContext(`${constant('FOLDER_FIELDS')}${constant('HIDDEN_FOLDERS_KEY')}${constant('CLONE_PARENT_KEY')}${constant('folderView')} + runInNewContext(`${constant('FOLDER_FIELDS')}${constant('PIECE_FIELDS')}${constant('PIECE_FOLDER_KEY')}${constant('HIDDEN_FOLDERS_KEY')}${constant('CLONE_PARENT_KEY')}${constant('folderView')} ${pick(['chooseFolder', 'showNewFolder', 'createFolder', 'newFolderKeys', 'browseTo', 'renderFolderBrowser', 'folderBrowserClosed', 'setCloneParent', 'useBrowsedFolder'])} this.folderView = folderView;`, context); return { context, $, requests, saved, used, auth, reloads }; @@ -97,6 +98,30 @@ test('choosing opens at the field value and lists the folders there, hidden ones assert.equal(p.$('folder-status').textContent, 'No folders match.'); }); +test('a first piece choice opens in World’s Workshop folder, later ones where the last choice worked', async () => { + const workshop = '/steam/steamapps/workshop/content/5462970'; + const home = { ...listing, path: '/home/me', workshop, pieces: [] }; + const there = { ...listing, path: workshop, entries: [], workshop, pieces: [{ title: 'Turkeys', folder: '3825906265', path: `${workshop}/3825906265` }] }; + const first = page({ answers: [home, there, home] }); + first.context.chooseFolder('companion', 'guild', 'companion'); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(first.requests.map(([, route]) => route), ['/folders?path=&pieces=companion', `/folders?path=${encodeURIComponent(workshop)}&pieces=companion`]); + assert.equal(first.$('folder-current').textContent, workshop); + assert.equal(first.$('folder-workshop').hidden, false); + assert.deepEqual(first.$('folder-list').children.map((row) => row.className), ['folder-row folder-piece']); + await first.context.browseTo('/home/me'); + assert.equal(first.$('folder-current').textContent, '/home/me', 'the shortcut does not hold the browser there'); + const later = page({ answers: [{ ...listing, workshop, pieces: [] }] }); + later.saved['agentGuild.pieceFolder'] = '/work/space'; + later.context.chooseFolder('character', 'guild', 'anthropic'); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(later.requests.map(([, route]) => route), ['/folders?path=%2Fwork%2Fspace&pieces=character']); + const plain = page({ answers: [listing] }); + plain.context.chooseFolder('cwd'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(plain.$('folder-workshop').hidden, true, 'no Workshop shortcut outside piece choices'); +}); + test('the browser explains fallbacks, empty folders and cut listings', async () => { const p = page({ answers: [{ ...listing, note: '/work/space/gone', entries: [] }, { ...listing, truncated: true }] }); p.$('folder-browser').open = true; diff --git a/tests/server-source.test.mjs b/tests/server-source.test.mjs index b9b7ebd..3e18f24 100644 --- a/tests/server-source.test.mjs +++ b/tests/server-source.test.mjs @@ -129,7 +129,7 @@ test('default Host, Origin, authentication and CSP behavior stays loopback-only' const api = await server(t); const page = await request(api); assert.equal(page.status, 200); - assert.equal(page.headers['content-security-policy'], "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://i.ytimg.com; connect-src 'self' ws://127.0.0.1:* ws://localhost:*; frame-src https://www.youtube-nocookie.com; frame-ancestors vscode-webview: vscode-file:; base-uri 'none'; form-action 'none'"); + assert.equal(page.headers['content-security-policy'], "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://i.ytimg.com; connect-src 'self' blob: ws://127.0.0.1:* ws://localhost:*; frame-src https://www.youtube-nocookie.com; frame-ancestors vscode-webview: vscode-file:; base-uri 'none'; form-action 'none'"); // Only VS Code webviews may frame the page, so X-Frame-Options, which cannot name them, is not sent. assert.equal(page.headers['x-frame-options'], undefined); for (const host of [`localhost:${api.port}`, `[::1]:${api.port}`, `127.0.0.1:${api.port}`]) { diff --git a/tests/steam-workshop.test.mjs b/tests/steam-workshop.test.mjs new file mode 100644 index 0000000..992c95f --- /dev/null +++ b/tests/steam-workshop.test.mjs @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parseVdf, steamRoots, worldLibrary, createWorkshopFinder } from '../src/manager/steam-workshop.mjs'; + +const vdf = (libraries) => `"libraryfolders" +{ +${libraries.map(([dir, apps], i) => `\t"${i}" +\t{ +\t\t"path"\t\t"${dir.replaceAll('\\', '\\\\')}" +\t\t"label"\t\t"" +\t\t"apps" +\t\t{ +${apps.map((app) => `\t\t\t"${app}"\t\t"1000"`).join('\n')} +\t\t} +\t}`).join('\n')} +} +`; + +function setup(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guild-steam-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +test('Steam’s library list is read, escaped paths included', () => { + const parsed = parseVdf(vdf([['C:\\Program Files (x86)\\Steam', ['228980']], ['D:\\SteamLibrary', ['5462970', '241100']]])); + assert.equal(parsed.libraryfolders['1'].path, 'D:\\SteamLibrary'); + assert.deepEqual(Object.keys(parsed.libraryfolders['1'].apps).sort(), ['241100', '5462970']); + assert.deepEqual(parseVdf('"a" { "b" "c" } }} {'), { a: { b: 'c' } }); +}); + +test('World’s Workshop folder is the one in the library that holds World, and only once it exists', async (t) => { + const root = setup(t); + const steam = path.join(root, 'Steam'), other = path.join(root, 'Library two'); + fs.mkdirSync(path.join(steam, 'steamapps'), { recursive: true }); + fs.writeFileSync(path.join(steam, 'steamapps', 'libraryfolders.vdf'), vdf([[steam, ['228980']], [other, ['5462970']]])); + assert.equal(await worldLibrary({ roots: [path.join(root, 'missing'), steam] }), path.normalize(other)); + const content = path.join(other, 'steamapps', 'workshop', 'content', '5462970'); + let asked = 0; + const find = createWorkshopFinder({ library: async () => { asked++; return worldLibrary({ roots: [steam] }); } }); + assert.equal(await find(), null, 'no subscriptions yet'); + fs.mkdirSync(content, { recursive: true }); + assert.equal(await find(), content); + assert.equal(asked, 1, 'where Steam keeps World is looked up once'); +}); + +test('without Steam or World there is no folder, and the next look tries again', async () => { + let asked = 0; + const find = createWorkshopFinder({ library: async () => { asked++; return null; } }); + assert.equal(await find(), null); + assert.equal(await find(), null); + assert.equal(asked, 2); + assert.equal(await createWorkshopFinder({ library: async () => { throw new Error('unreadable'); } })(), null); +}); + +test('Steam is found from the registry on Windows and in its own folders elsewhere', async () => { + assert.deepEqual(await steamRoots({ platform: 'win32', registry: async () => 'c:/program files (x86)/steam' }), ['c:/program files (x86)/steam']); + assert.deepEqual(await steamRoots({ platform: 'win32', registry: async () => null }), []); + assert.deepEqual(await steamRoots({ platform: 'darwin', home: '/Users/me' }), [path.join('/Users/me', 'Library', 'Application Support', 'Steam')]); + assert.equal((await steamRoots({ platform: 'linux', home: '/home/me' })).length, 3); +}); diff --git a/tests/yard-pieces.test.mjs b/tests/yard-pieces.test.mjs new file mode 100644 index 0000000..8732d00 --- /dev/null +++ b/tests/yard-pieces.test.mjs @@ -0,0 +1,514 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { YardPieces } from '../src/manager/yard-pieces.mjs'; +import { createFolderBrowser } from '../src/manager/folder-browser.mjs'; +import { createManagerServer } from '../src/manager/server.mjs'; + +const FIXTURES = fileURLToPath(new URL('./fixtures/pieces/', import.meta.url)); +const fixture = (name) => fs.readFileSync(path.join(FIXTURES, `${name}.glb`)); + +function setup(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guild-pieces-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + let made = 0; + const folder = ({ kind = 'character', title = 'Patchwork', models, manifest, name = `piece-${++made}` } = {}) => { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'piece.json'), manifest ?? JSON.stringify({ format: 1, kind, world: 'guild', crew: 'anthropic', title })); + const files = models ?? (kind === 'character' ? { 'character.glb': fixture('character') } : Object.fromEntries([0, 1, 2, 3].map((n) => [`companion_${n}.glb`, fixture('companion')]))); + for (const [file, bytes] of Object.entries(files)) fs.writeFileSync(path.join(dir, file), bytes); + return dir; + }; + return { root, folder, dir: path.join(root, 'pieces') }; +} + +const refused = (reason) => ({ status: 400, code: 'piece_invalid', message: reason }); + +test('a character and a companion replace one Yard’s slots, survive a restart and restore', async (t) => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + const events = []; + pieces.on('updated', (state) => events.push(JSON.stringify(state))); + const source = folder(); + const after = await pieces.replace('guild', 'anthropic', source); + assert.equal(after.guild.anthropic.title, 'Patchwork'); + assert.match(after.guild.anthropic.revision, /^[0-9a-f]{16}$/); + await pieces.replace('guild', 'companion', folder({ kind: 'companion', title: 'Moths' })); + assert.deepEqual(Object.keys(pieces.snapshot()), ['guild'], 'other Yards keep their originals'); + assert.equal(pieces.file('guild', 'anthropic', 'character.glb'), path.join(dir, 'guild', 'anthropic', 'character.glb')); + assert.equal(pieces.file('guild', 'companion', 'companion_3.glb'), path.join(dir, 'guild', 'companion', 'companion_3.glb')); + for (const [skin, slot, name] of [['grove', 'anthropic', 'character.glb'], ['guild', 'openai', 'character.glb'], ['guild', 'anthropic', 'piece.json'], ['guild', 'anthropic', 'install.json'], ['guild', 'anthropic', 'companion_0.glb'], ['guild', 'companion', 'character.glb'], ['guild', 'constructor', 'character.glb']]) { + assert.equal(pieces.file(skin, slot, name), null, `${skin}/${slot}/${name}`); + } + fs.rmSync(source, { recursive: true }); + const reopened = new YardPieces({ dir }); + assert.deepEqual(reopened.snapshot(), pieces.snapshot(), 'a piece no longer needs the folder it came from'); + await reopened.restore('guild', 'anthropic'); + assert.deepEqual(Object.keys(reopened.snapshot().guild), ['companion']); + await reopened.restore('guild', 'companion'); + assert.deepEqual(reopened.snapshot(), {}); + assert.equal(events.length, 2); + await assert.rejects(pieces.replace('nowhere', 'anthropic', folder()), { status: 404, code: 'unknown_yard' }); + await assert.rejects(pieces.replace('guild', 'toString', folder()), { status: 404, code: 'unknown_slot' }); +}); + +test('a bad piece is refused with the reason and the slot keeps its working piece', async (t) => { + const { root, folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + const before = await pieces.replace('guild', 'shell', folder({ title: 'Before' })); + const three = Object.fromEntries([0, 1, 2].map((n) => [`companion_${n}.glb`, fixture('companion')])); + const cases = [ + ['a relative path', 'piece', /Choose a piece folder/], + ['a file instead of a folder', path.join(folder(), 'piece.json'), /Choose a piece folder/], + ['a missing folder', path.join(root, 'none'), /can’t be read/], + ['no manifest', (() => { const dir = folder(); fs.rmSync(path.join(dir, 'piece.json')); return dir; })(), /piece\.json is missing/], + ['unreadable details', folder({ manifest: '{' }), /details can’t be read/], + ['a newer format', folder({ manifest: JSON.stringify({ format: 2, kind: 'character' }) }), /newer Agent Guild/], + ['a companion for a character', folder({ kind: 'companion' }), /companion piece, not a character/], + ['a long title', folder({ title: 'x'.repeat(129) }), /title/], + ['a missing model', folder({ models: {} }), /character\.glb is missing/], + ['a model that refers outside itself', folder({ models: { 'character.glb': fixture('external_uri') } }), /refers to files outside itself/], + ['a missing bone', folder({ models: { 'character.glb': fixture('missing_bone') } }), /has no LeftFoot bone/], + ['a missing animation', folder({ models: { 'character.glb': fixture('missing_clip') } }), /has no done animation/], + ['a truncated model', folder({ models: { 'character.glb': fixture('truncated') } }), /isn’t a binary glTF model/], + ['an oversized texture', folder({ models: { 'character.glb': fixture('texture_over') } }), /texture larger than 4096/], + ['a model that needs KTX2', folder({ models: { 'character.glb': requiring(fixture('character'), 'KHR_texture_basisu') } }), /needs KHR_texture_basisu/], + ]; + for (const [label, source, reason] of cases) { + await assert.rejects(pieces.replace('guild', 'shell', source), refused(reason), label); + assert.deepEqual(pieces.snapshot(), before, label); + } + await assert.rejects(pieces.replace('guild', 'companion', folder({ kind: 'companion', models: three })), refused(/companion_3\.glb is missing/)); + await assert.rejects(pieces.replace('guild', 'companion', folder({ kind: 'companion', models: { ...three, 'companion_3.glb': fixture('character') } })), refused(/companion_3\.glb has no idle animation/), 'one bad helper refuses the whole companion'); + assert.equal(pieces.snapshot().guild.companion, undefined); + assert.deepEqual(fs.readdirSync(dir).filter((name) => name.startsWith('.')), [], 'nothing is left staged'); +}); + +function requiring(glb, extension) { + return variant(glb, json => { json.extensionsRequired = [...(json.extensionsRequired ?? []), extension]; }); +} + +function variant(glb, change) { + const size = glb.readUInt32LE(12); + const json = JSON.parse(glb.toString('utf8', 20, 20 + size)); + const rest = Buffer.from(glb.subarray(20 + size)); + change(json, rest.subarray(8)); + const text = Buffer.from(JSON.stringify(json)); + const padded = Buffer.concat([text, Buffer.alloc((4 - (text.length % 4)) % 4, 0x20)]); + const out = Buffer.concat([glb.subarray(0, 20), padded, rest]); + out.writeUInt32LE(out.length, 8); + out.writeUInt32LE(padded.length, 12); + return out; +} + +test('unsafe extras, clip lengths and joint counts are refused before replacing a piece', async t => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + await pieces.replace('guild', 'anthropic', folder()); + const before = JSON.stringify(pieces.snapshot()); + const extras = changes => json => Object.assign(json.nodes.find(n => n.extras?.yard).extras.yard, changes); + const duration = seconds => (json, binary) => { + for (const sampler of json.animations[0].samplers) { + const accessor = json.accessors[sampler.input], view = json.bufferViews[accessor.bufferView]; + for (let i = 0; i < accessor.count; i++) binary.writeFloatLE(seconds * i / (accessor.count - 1), (view.byteOffset || 0) + (accessor.byteOffset || 0) + i * (view.byteStride || 4)); + accessor.min = [0]; accessor.max = [seconds]; + } + }; + for (const [change, reason] of [ + [extras({ events: [null] }), /animation event/], + [extras({ events: [{ clip: 'work_0', time: .2, at: [0, 'bad', 0] }] }), /animation event/], + [extras({ events: {} }), /animation event array/], + [extras({ look: { yaw: 'bad' } }), /yard look/], + [extras({ stride: 1e300 }), /yard stride/], + [extras({ cycle: .01 }), /yard cycle/], + [extras({ height: 1e300 }), /yard height/], + [extras({ surface: 1e300 }), /yard surface/], + [duration(0), /animation .*more than 0/], + [duration(61), /animation .*at most 60/], + [json => { json.skins[0].joints = Array(129).fill(json.skins[0].joints[0]); }, /at most 128 joints/], + ]) { + const source = folder({ models: { 'character.glb': variant(fixture('character'), change) } }); + await assert.rejects(pieces.replace('guild', 'anthropic', source), refused(reason)); + assert.equal(JSON.stringify(pieces.snapshot()), before); + } + const models = Object.fromEntries([0, 1, 2, 3].map(n => [`companion_${n}.glb`, variant(fixture('companion'), extras({ height: 'bad' }))])); + await assert.rejects(pieces.replace('guild', 'companion', folder({ kind: 'companion', models })), refused(/companion_0.glb.*yard height/)); +}); + +function extended(glb, build) { + const size = glb.readUInt32LE(12); + const json = JSON.parse(glb.toString('utf8', 20, 20 + size)); + const binary = glb.subarray(20 + size + 8, 20 + size + 8 + glb.readUInt32LE(20 + size)); + const view = json.bufferViews.length; + const [bytes, change] = build(json, binary, view); + json.bufferViews.push({ buffer: 0, byteOffset: binary.length, byteLength: bytes.length }); + change(); + const data = Buffer.concat([binary, bytes, Buffer.alloc((4 - ((binary.length + bytes.length) % 4)) % 4)]); + json.buffers[0].byteLength = data.length; + const text = Buffer.from(JSON.stringify(json)); + const padded = Buffer.concat([text, Buffer.alloc((4 - (text.length % 4)) % 4, 0x20)]); + const head = Buffer.alloc(12), chunk = Buffer.alloc(8); + head.write('glTF', 0, 'latin1'); head.writeUInt32LE(2, 4); head.writeUInt32LE(12 + 8 + padded.length + 8 + data.length, 8); + chunk.writeUInt32LE(data.length, 0); chunk.write('BIN\0', 4, 'latin1'); + const jsonChunk = Buffer.alloc(8); jsonChunk.writeUInt32LE(padded.length, 0); jsonChunk.write('JSON', 4, 'latin1'); + return Buffer.concat([head, jsonChunk, padded, chunk, data]); +} + +test('data the loader would allocate or draw without bound, and damaged images, are refused before parsing', async t => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + await pieces.replace('guild', 'anthropic', folder()); + const before = JSON.stringify(pieces.snapshot()); + const image = (json, binary, change) => { const view = json.bufferViews[json.images[0].bufferView]; change(view, binary.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)); }; + for (const [label, change, reason] of [ + ['a bufferless accessor', json => { json.meshes[0].primitives[0].attributes.COLOR_0 = json.accessors.push({ componentType: 5126, count: 1e9, type: 'VEC4' }) - 1; }, /refers to data it doesn’t contain/], + + ['lights', json => { json.extensionsUsed = [...(json.extensionsUsed ?? []), 'KHR_lights_punctual']; }, /uses lights/], + ['instancing', json => { json.nodes.at(-1).extensions = { EXT_mesh_gpu_instancing: { attributes: {} } }; }, /uses instancing/], + ['a rigged mesh without weights', json => { delete json.meshes[0].primitives[0].attributes.WEIGHTS_0; }, /rigged mesh without weights/], + ['an image cut short', (json, binary) => image(json, binary, (view, data) => data.fill(0, 24)), /image that is incomplete or damaged/], + ['many views over the same bytes', json => { for (let i = 0; i < 3; i++) json.bufferViews.push({ buffer: 0, byteOffset: 0, byteLength: json.buffers[0].byteLength }); }, /reuses its data in a way that would multiply its size/], + ['many accessors over the same view', json => { for (let i = 0; i < 3; i++) json.accessors.push({ ...json.accessors[0] }); }, /reuses its data in a way that would multiply its size/], + ['a negative view length', json => { json.bufferViews[0].byteLength = -1; }, /refers to data it doesn’t contain/], + ['a view length past safe integers', json => { json.bufferViews[0].byteLength = 2 ** 60; }, /refers to data it doesn’t contain/], + ['a view past the end of the data', json => { json.bufferViews[0].byteOffset = json.buffers[0].byteLength; }, /refers to data it doesn’t contain/], + ['an accessor with an unknown view', json => { json.accessors[0].bufferView = 999; }, /refers to data it doesn’t contain/], + ['a scene root listed twice', json => { json.scenes[0].nodes.push(json.scenes[0].nodes[0]); }, /node used in two places/], + ['a second scene', json => { json.scenes.push({ nodes: [...json.scenes[0].nodes] }); }, /exactly one scene/], + ['a node under two parents', json => { const child = json.nodes[0].children[0]; json.nodes.find((node, at) => at !== 0 && node.children).children.push(child); }, /node used in two places/], + ['nodes that loop', json => { const at = json.nodes.length; json.nodes.push({ name: 'a', children: [at + 1] }, { name: 'b', children: [at] }); }, /nodes that loop/], + ['a node index it doesn’t have', json => { json.scenes[0].nodes.push(999); }, /refers to a node it doesn’t have/], + ['repeated shape keys', json => { const position = json.meshes[0].primitives[0].attributes.POSITION; json.meshes[0].primitives[0].targets = Array(64).fill({ POSITION: position }); }, /shape keys/], + ['128 channels sharing one normalized output', json => { + const animation = json.animations[0], output = json.accessors[animation.samplers[animation.channels[0].sampler].output]; + Object.assign(output, { componentType: 5122, normalized: true }); + animation.channels = Array(128).fill(animation.channels[0]); + }, /animation data that isn’t plain 32-bit floats/], + ['integer animation times', json => { const animation = json.animations[0]; json.accessors[animation.samplers[0].input].componentType = 5125; }, /animation data that isn’t plain 32-bit floats/], + ['legacy animation parameters', json => { + const animation = json.animations[0], sampler = animation.samplers[0], output = json.accessors[sampler.output]; + const remapped = json.accessors.push({ ...output, componentType: 5122, normalized: true, count: Math.floor(output.count / 2) }) - 1; + animation.parameters = { [sampler.input]: sampler.input, [sampler.output]: remapped }; + }, /glTF property the Yard doesn’t read \(animation\.parameters\)/], + ['cubic spline animation', json => { json.animations[0].samplers[0].interpolation = 'CUBICSPLINE'; }, /CUBICSPLINE animation, which the Yard doesn’t play/], + ['an animated weights path', json => { json.animations[0].channels[0].target.path = 'weights'; }, /animates something the Yard doesn’t move/], + ['an animation aimed at a missing node', json => { json.animations[0].channels[0].target.node = 999; }, /animates something the Yard doesn’t move/], + ['more than 256 channels in a clip', json => { json.animations[0].channels = Array(257).fill(json.animations[0].channels[0]); }, /more than 256 channels/], + ['more than 2,048 channels in a file', json => { json.animations = json.animations.map(clip => ({ ...clip, channels: Array(256).fill(clip.channels[0]) })); json.animations.push({ ...json.animations[0], name: 'extra', channels: [json.animations[0].channels[0]] }); }, /more than 2,048 animation channels/], ]) { + const source = folder({ models: { 'character.glb': variant(fixture('character'), change) } }); + await assert.rejects(pieces.replace('guild', 'anthropic', source), refused(reason), label); + assert.equal(JSON.stringify(pieces.snapshot()), before, label); + } + const picture = (json, binary) => { const view = json.bufferViews[json.images[0].bufferView]; return Buffer.from(binary.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)); }; + const tail = text => (json, binary, view) => [Buffer.concat([picture(json, binary), Buffer.from(text, 'latin1')]), () => { json.images[0].bufferView = view; }]; + const junk = extended(fixture('character'), tail('\0\0\0\0')); + await assert.rejects(pieces.replace('guild', 'anthropic', folder({ models: { 'character.glb': junk } })), refused(/image that is incomplete or damaged/), 'an image with trailing junk'); + const padded = extended(fixture('character'), tail(' ')); + assert.equal((await pieces.replace('guild', 'anthropic', folder({ title: 'Padded', models: { 'character.glb': padded } }))).guild.anthropic.title, 'Padded', 'exporters pad an image to four bytes'); + const keyframes = extended(fixture('character'), (json, binary, view) => [Buffer.alloc(4096 * 16), () => { + const animation = json.animations[0], channel = animation.channels.find(c => c.target.path === 'rotation') ?? animation.channels[0]; + channel.target.path = 'rotation'; + animation.samplers[channel.sampler].output = json.accessors.push({ bufferView: view, componentType: 5126, count: 4096, type: 'VEC4' }) - 1; + }]); + await assert.rejects(pieces.replace('guild', 'anthropic', folder({ models: { 'character.glb': keyframes } })), refused(/keyframes that don’t match their times/), '4,096 rotations for two times'); + const interleaved = extended(fixture('character'), (json, binary, view) => [Buffer.alloc(8 * 32), () => { + json.bufferViews.at(-1).byteStride = 32; + json.meshes[0].primitives[0].attributes.COLOR_0 = json.accessors.push({ bufferView: view, componentType: 5126, count: 8, type: 'VEC3' }) - 1; + }]); + await assert.rejects(pieces.replace('guild', 'anthropic', folder({ models: { 'character.glb': interleaved } })), refused(/interleaves its data/), 'an interleaved attribute'); + const triangles = (twice) => extended(fixture('character'), (json, binary, view) => [(() => { + const indices = json.accessors[json.meshes[0].primitives[0].indices], start = (json.bufferViews[indices.bufferView].byteOffset || 0) + (indices.byteOffset || 0), many = Buffer.alloc(330000 * 2); + for (let i = 0; i < 330000; i++) many.writeUInt16LE(binary.readUInt16LE(start + (i % indices.count) * 2), i * 2); + return many; + })(), () => { + json.meshes[0].primitives[0].indices = json.accessors.push({ bufferView: view, componentType: 5123, count: 330000, type: 'SCALAR' }) - 1; + if (twice) { json.nodes.push({ name: 'again', mesh: 0, skin: 0 }); json.nodes[0].children.push(json.nodes.length - 1); } + }]); + assert.equal((await pieces.replace('guild', 'anthropic', folder({ title: 'Once', models: { 'character.glb': triangles(false) } }))).guild.anthropic.title, 'Once', '110,000 faces drawn once fit'); + await assert.rejects(pieces.replace('guild', 'anthropic', folder({ models: { 'character.glb': triangles(true) } })), refused(/more than 210,000 faces/), 'the same mesh drawn by two nodes counts twice'); +}); + +test('malformed rigged meshes and images that fail to decode are refused, and the working piece stays', async t => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + await pieces.replace('guild', 'anthropic', folder({ title: 'Working' })); + const before = JSON.stringify(pieces.snapshot()); + const require = createRequire(import.meta.url); + globalThis.ImageData ??= class ImageData { constructor(data, width, height) { Object.assign(this, { data, width, height }); } }; + const encoder = async (entry, wasm) => { const codec = await import(entry); await codec.init(await WebAssembly.compile(fs.readFileSync(require.resolve(wasm)))); return codec.default; }; + const square = new ImageData(new Uint8ClampedArray(16 * 16 * 4).map((_, i) => (i * 37) % 256), 16, 16); + const webp = Buffer.from(await (await encoder('@jsquash/webp/encode.js', '@jsquash/webp/codec/enc/webp_enc.wasm'))(square)); + const jpeg = Buffer.from(await (await encoder('@jsquash/jpeg/encode.js', '@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm'))(square)); + const picture = (bytes, mimeType) => extended(fixture('character'), (json, binary, view) => [bytes, () => { json.images[0] = { bufferView: view, mimeType }; }]); + const damaged = (bytes, change) => { const copy = Buffer.from(bytes); change(copy); return copy; }; + for (const [label, model] of [['a WebP', picture(webp, 'image/webp')], ['a JPEG', picture(jpeg, 'image/jpeg')]]) { + assert.equal((await pieces.replace('guild', 'shell', folder({ title: label, models: { 'character.glb': model } }))).guild.shell.title, label, `${label} that decodes is accepted`); + } + const zeroedIdat = variant(fixture('character'), (json, binary) => { + const view = json.bufferViews[json.images[0].bufferView], png = binary.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength); + for (let at = 8; at < png.length; at += 12 + png.readUInt32BE(at)) if (png.toString('latin1', at + 4, at + 8) === 'IDAT') png.fill(0, at + 8, at + 8 + png.readUInt32BE(at)); + }); + for (const [label, model, reason] of [ + ['a mesh without positions', variant(fixture('character'), json => { delete json.meshes[0].primitives[0].attributes.POSITION; }), /mesh without positions/], + ['a vertex weighted to joint 250', variant(fixture('character'), (json, binary) => { const joints = json.accessors[json.meshes[0].primitives[0].attributes.JOINTS_0]; binary[(json.bufferViews[joints.bufferView].byteOffset || 0) + (joints.byteOffset || 0)] = 250; }), /weights a vertex to a joint it doesn’t have/], + ['a PNG whose pixel data is zeroed', zeroedIdat, /image that is incomplete or damaged/], + ['a WebP whose pixel data is zeroed', picture(damaged(webp, copy => copy.fill(0, 30)), 'image/webp'), /image that is incomplete or damaged/], + ['a JPEG with a bogus Huffman table', picture(damaged(jpeg, copy => { const table = copy.indexOf(Buffer.from([0xff, 0xc4])); copy.fill(0xff, table + 5, table + 21); }), 'image/jpeg'), /image that is incomplete or damaged/], + ]) { + await assert.rejects(pieces.replace('guild', 'anthropic', folder({ models: { 'character.glb': model } })), refused(reason), label); + assert.equal(JSON.stringify(pieces.snapshot().guild.anthropic), JSON.stringify(JSON.parse(before).guild.anthropic), label); + } +}); + +test('triangles that miss their vertices, split vertex counts and clips that leave their place are refused', async t => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + await pieces.replace('guild', 'anthropic', folder({ title: 'Working' })); + const before = JSON.stringify(pieces.snapshot().guild.anthropic); + const wanders = extended(fixture('character'), (json, binary, view) => { + const times = json.accessors[json.animations[0].samplers[0].input], values = Buffer.alloc(times.count * 12); + values.writeFloatLE(0.5, (times.count - 1) * 12); + return [values, () => { + const animation = json.animations[0], output = json.accessors.push({ bufferView: view, componentType: 5126, count: times.count, type: 'VEC3' }) - 1; + const sampler = animation.samplers.push({ input: animation.samplers[0].input, output }) - 1; + animation.channels.push({ sampler, target: { node: json.nodes.findIndex((node) => node.name === 'Root'), path: 'translation' } }); + }]; + }); + for (const [label, model, reason] of [ + ['a triangle aimed at vertex 250', variant(fixture('character'), (json, binary) => { const indices = json.accessors[json.meshes[0].primitives[0].indices]; binary.writeUInt16LE(250, (json.bufferViews[indices.bufferView].byteOffset || 0) + (indices.byteOffset || 0)); }), /triangle that refers to a vertex it doesn’t have/], + ['texture coordinates for fewer vertices', variant(fixture('character'), json => { json.accessors[json.meshes[0].primitives[0].attributes.TEXCOORD_0].count -= 1; }), /attributes don’t share one vertex count/], + ['a clip that walks the root half a metre', wanders, /moves the character instead of playing in place \(walk\)/], + ...[ + ['a Hips rotation of NaN', (out, write) => { for (let i = 0; i < out.count * 4; i++) write(i, NaN); }], + ['times that run backwards', (out, write, times) => { times(0, 1); times(1, 0.5); }], + ['a zero-length rotation', (out, write) => { for (let i = 0; i < 4; i++) write(i, 0); }], + ['rotation samples of [0.5, 0.5, 0, 0]', (out, write) => { for (let i = 0; i < out.count; i++) [0.5, 0.5, 0, 0].forEach((value, k) => write(i * 4 + k, value)); }], + ].map(([label, change]) => [label, variant(fixture('character'), (json, binary) => { + const sampler = json.animations[0].samplers[json.animations[0].channels[0].sampler], at = (accessor) => (json.bufferViews[accessor.bufferView].byteOffset || 0) + (accessor.byteOffset || 0); + const output = json.accessors[sampler.output], input = json.accessors[sampler.input]; + change(output, (i, value) => binary.writeFloatLE(value, at(output) + i * 4), (i, value) => binary.writeFloatLE(value, at(input) + i * 4)); + }), /animation with values the Yard can’t play \(walk\)/]), + ['a Hips rotation that isn’t a unit quaternion', variant(fixture('character'), json => { json.nodes.find((node) => node.name === 'Hips').rotation = [0.5, 0.5, 0, 0]; }), /node rotation that isn’t a unit quaternion \(Hips\)/], + ['a base colour from texture channel 4', variant(fixture('character'), json => { const primitive = json.meshes[0].primitives[0]; primitive.attributes.TEXCOORD_4 = primitive.attributes.TEXCOORD_0; json.materials[primitive.material].pbrMetallicRoughness.baseColorTexture.texCoord = 4; }), /uses texture channel 4, which the Yard can’t draw/], + ['a clearcoat texture from channel 4', variant(fixture('character'), json => { const primitive = json.meshes[0].primitives[0]; primitive.attributes.TEXCOORD_4 = primitive.attributes.TEXCOORD_0; json.extensionsUsed = [...(json.extensionsUsed ?? []), 'KHR_materials_clearcoat']; json.materials[primitive.material].extensions = { KHR_materials_clearcoat: { clearcoatFactor: 1, clearcoatTexture: { index: 0, texCoord: 4 } } }; }), /uses texture channel 4, which the Yard can’t draw/], + ['signed triangle indices', variant(fixture('character'), json => { json.accessors[json.meshes[0].primitives[0].indices].componentType = 5122; }), /triangle indices of a type the Yard can’t draw/], + ['raw unsigned-short texture coordinates without quantization', variant(fixture('character'), json => { Object.assign(json.accessors[json.meshes[0].primitives[0].attributes.TEXCOORD_0], { componentType: 5123, normalized: false }); }), /TEXCOORD_0 attribute of a type the Yard can’t draw/], + ]) { + await assert.rejects(pieces.replace('guild', 'anthropic', folder({ models: { 'character.glb': model } })), refused(reason), label); + assert.equal(JSON.stringify(pieces.snapshot().guild.anthropic), before, label); + } + const nearUnit = variant(fixture('character'), (json, binary) => { + const output = json.accessors[json.animations[0].samplers[json.animations[0].channels[0].sampler].output], start = (json.bufferViews[output.bufferView].byteOffset || 0) + (output.byteOffset || 0); + for (let i = 0; i < output.count * 4; i++) binary.writeFloatLE(binary.readFloatLE(start + i * 4) * 1.0005, start + i * 4); + }); + assert.equal((await pieces.replace('guild', 'anthropic', folder({ title: 'Near unit', models: { 'character.glb': nearUnit } }))).guild.anthropic.title, 'Near unit', 'rotations within 0.001 of unit length are accepted'); +}); + +test('the in-place rule judges a scaled export exactly like the same model unscaled', async t => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + const exportAt = (scale, sway) => extended(fixture('character'), (json, binary, view) => { + const floats = (at, change) => { const accessor = json.accessors[at], start = (json.bufferViews[accessor.bufferView].byteOffset || 0) + (accessor.byteOffset || 0), width = { VEC3: 3, MAT4: 16 }[accessor.type]; for (let i = 0; i < accessor.count * width; i++) { const value = change(i % width); if (value) binary.writeFloatLE(binary.readFloatLE(start + i * 4) * value, start + i * 4); } }; + const position = json.accessors[json.meshes[0].primitives[0].attributes.POSITION]; + floats(json.meshes[0].primitives[0].attributes.POSITION, () => scale); + position.min = position.min.map((value) => value * scale); position.max = position.max.map((value) => value * scale); + floats(json.skins[0].inverseBindMatrices, (k) => (k >= 12 && k <= 14 ? scale : 0)); + for (const node of json.nodes) if (node.translation) node.translation = node.translation.map((value) => value * scale); + const times = json.accessors[json.animations[0].samplers[0].input], values = Buffer.alloc(times.count * 12); + values.writeFloatLE(sway * scale, (times.count - 1) * 12); + return [values, () => { + const animation = json.animations[0], output = json.accessors.push({ bufferView: view, componentType: 5126, count: times.count, type: 'VEC3' }) - 1; + animation.channels.push({ sampler: animation.samplers.push({ input: animation.samplers[0].input, output }) - 1, target: { node: json.nodes.findIndex((node) => node.name === 'Root'), path: 'translation' } }); + const wrapper = json.nodes.push({ name: 'Export', scale: [1 / scale, 1 / scale, 1 / scale], children: [...json.scenes[0].nodes] }) - 1; + json.scenes[0].nodes = [wrapper]; + }]; + }); + for (const scale of [1, 0.01]) { + assert.equal((await pieces.replace('guild', 'anthropic', folder({ title: `Sways at ${scale}`, models: { 'character.glb': exportAt(scale, 0.05) } }))).guild.anthropic.title, `Sways at ${scale}`, `a 5 cm sway plays in place at export scale ${scale}`); + await assert.rejects(pieces.replace('guild', 'anthropic', folder({ models: { 'character.glb': exportAt(scale, 0.5) } })), refused(/moves the character instead of playing in place \(walk\)/), `a 50 cm walk leaves its place at export scale ${scale}`); + assert.equal(pieces.snapshot().guild.anthropic.title, `Sways at ${scale}`, 'the refusal keeps the working piece'); + } +}); + +test('a model link is refused rather than followed', async (t) => { + const { folder, dir } = setup(t); + const source = folder({ models: {} }); + const target = path.join(path.dirname(source), 'outside.glb'); + fs.writeFileSync(target, fixture('character')); + try { fs.symlinkSync(target, path.join(source, 'character.glb'), 'file'); } catch (err) { + if (err.code === 'EPERM') return t.skip('this account cannot create links'); + throw err; + } + await assert.rejects(new YardPieces({ dir }).replace('guild', 'anthropic', source), refused(/isn’t a plain file/)); +}); + +test('only one change runs at a time, and the second is told to wait', async (t) => { + const { folder, dir } = setup(t); + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const pieces = new YardPieces({ dir, check: () => gate }); + const first = pieces.replace('guild', 'anthropic', folder({ title: 'First' })); + await assert.rejects(pieces.replace('grove', 'openai', folder()), { status: 409, code: 'piece_busy' }); + await assert.rejects(pieces.restore('guild', 'anthropic'), { status: 409, code: 'piece_busy' }); + release(); + assert.equal((await first).guild.anthropic.title, 'First'); +}); + +test('damage, an interrupted install and a crash between the two renames all recover', async (t) => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + await pieces.replace('guild', 'anthropic', folder({ title: 'Kept' })); + await pieces.replace('orbital', 'docker', folder({ title: 'Bent' })); + fs.appendFileSync(path.join(dir, 'orbital', 'docker', 'character.glb'), 'x'); + fs.mkdirSync(path.join(dir, '.guild.openai.0.staging')); + fs.renameSync(path.join(dir, 'guild', 'anthropic'), path.join(dir, '.guild.anthropic.1.old')); + fs.mkdirSync(path.join(dir, 'grove', 'xai'), { recursive: true }); + fs.mkdirSync(path.join(dir, '.grove.xai.2.old')); + const reopened = new YardPieces({ dir }); + await reopened.swept; + assert.equal(reopened.snapshot().guild.anthropic.title, 'Kept', 'the working piece comes back'); + assert.match(reopened.snapshot().orbital.docker.problem, /Bent is damaged: character\.glb/); + assert.equal(reopened.snapshot().orbital.docker.revision, undefined); + assert.equal(reopened.file('orbital', 'docker', 'character.glb'), null, 'a damaged piece is not served'); + assert.equal(reopened.snapshot().grove.xai.problem, 'This piece is damaged.'); + assert.deepEqual(fs.readdirSync(dir).filter((name) => name.startsWith('.')), []); + await reopened.restore('orbital', 'docker'); + assert.equal(reopened.snapshot().orbital, undefined); + const unusable = path.join(dir, 'guild', 'anthropic', 'install.json'); + fs.rmSync(unusable); + assert.match(new YardPieces({ dir }).snapshot().guild.anthropic.problem, /damaged/); +}); + +test('a restored piece stays gone after a restart, even when its files could not be deleted', async (t) => { + const { folder, dir } = setup(t); + const stuck = async () => { throw Object.assign(new Error('denied'), { code: 'EPERM' }); }; + const pieces = new YardPieces({ dir, remove: stuck }); + await pieces.replace('guild', 'anthropic', folder({ title: 'A' })); + await pieces.replace('guild', 'anthropic', folder({ title: 'B' })); + assert.equal(pieces.snapshot().guild.anthropic.title, 'B'); + await pieces.restore('guild', 'anthropic'); + assert.deepEqual(pieces.snapshot(), {}); + const reopened = new YardPieces({ dir, remove: stuck }); + await reopened.swept; + assert.deepEqual(reopened.snapshot(), {}); +}); + +test('a change waits for startup recovery and Restore retires legacy backups', async t => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + await pieces.replace('guild', 'anthropic', folder({ title: 'A' })); + const legacy = path.join(dir, '.guild.anthropic.legacy.old'); + fs.cpSync(path.join(dir, 'guild', 'anthropic'), legacy, { recursive: true }); + let release, checked = false; + pieces.swept = new Promise(resolve => { release = resolve; }); + pieces.check = async () => { checked = true; }; + const pending = pieces.replace('guild', 'anthropic', folder({ title: 'B' })); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(checked, false); + assert.equal(pieces.snapshot().guild.anthropic.title, 'A'); + release();await pending; + fs.cpSync(path.join(dir, 'guild', 'anthropic'), legacy, { recursive: true }); + pieces.remove = async () => { throw new Error('denied'); }; + await pieces.restore('guild', 'anthropic'); + assert.deepEqual(pieces.snapshot(), {}); + assert.ok(!fs.existsSync(legacy)); +}); + +test('a replace that cannot retire the old copy still succeeds, and Restore retires it later', async t => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir, remove: async () => { throw new Error('denied'); } }); + await pieces.replace('guild', 'anthropic', folder({ title: 'A' })); + const rename = fs.promises.rename; + const mock = t.mock.method(fs.promises, 'rename', async (from, to) => { + if (from.endsWith('.old')) throw Object.assign(new Error('busy'), { code: 'EIO' }); + return rename(from, to); + }); + assert.equal((await pieces.replace('guild', 'anthropic', folder({ title: 'B' }))).guild.anthropic.title, 'B'); + mock.mock.restore(); + await pieces.restore('guild', 'anthropic'); + assert.deepEqual(pieces.snapshot(), {}); + assert.deepEqual(new YardPieces({ dir }).snapshot(), {}); +}); + +test('a companion without yard extras is accepted, as World accepts it', async t => { + const { folder, dir } = setup(t); + const bare = variant(fixture('companion'), json => { for (const node of json.nodes) delete node.extras; }); + const models = Object.fromEntries([0, 1, 2, 3].map(n => [`companion_${n}.glb`, bare])); + assert.equal((await new YardPieces({ dir }).replace('guild', 'companion', folder({ kind: 'companion', title: 'Bare', models }))).guild.companion.title, 'Bare'); +}); + +test('Restore leaves the active piece unchanged if a legacy backup cannot be retired', async t => { + const { folder, dir } = setup(t); + const pieces = new YardPieces({ dir }); + await pieces.replace('guild', 'anthropic', folder({ title: 'Kept' })); + const before = JSON.stringify(pieces.snapshot()); + const legacy = path.join(dir, '.guild.anthropic.legacy.old'); + fs.cpSync(path.join(dir, 'guild', 'anthropic'), legacy, { recursive: true }); + const rename = fs.promises.rename; + t.mock.method(fs.promises, 'rename', async (from, to) => { + if (from === legacy) throw Object.assign(new Error('backup cannot be renamed'), { code: 'EIO' }); + return rename(from, to); + }); + await assert.rejects(pieces.restore('guild', 'anthropic'), { code: 'piece_unwritable', message: /backup cannot be renamed/ }); + assert.equal(JSON.stringify(pieces.snapshot()), before); + assert.ok(fs.existsSync(pieces.file('guild', 'anthropic', 'character.glb'))); +}); + +test('the folder dialog lists matching pieces here and one level down, by title', async (t) => { + const { root, folder } = setup(t); + const library = path.join(root, 'workshop'); + folder({ name: 'workshop/3002', title: 'Giant' }); + folder({ name: 'workshop/3001', title: 'giant' }); + folder({ name: 'workshop/3003', title: '' }); + folder({ name: 'workshop/3004', kind: 'companion', title: 'Moths' }); + folder({ name: 'workshop/3005/deeper', title: 'Too deep' }); + fs.writeFileSync(path.join(root, 'workshop', 'piece.json'), '{'); + const browser = createFolderBrowser({ home: root, workshop: async () => library }); + const listing = await browser.list(library, { pieces: 'character' }); + assert.equal(listing.workshop, library); + assert.deepEqual(listing.pieces.map(({ title, folder: name }) => [title, name]), [['3003', '3003'], ['giant', '3001'], ['Giant', '3002']]); + assert.deepEqual((await browser.list(library, { pieces: 'companion' })).pieces.map((piece) => piece.title), ['Moths']); + assert.deepEqual((await browser.list(path.join(library, '3004'), { pieces: 'companion' })).pieces.map((piece) => piece.path), [path.join(library, '3004')]); + assert.equal((await browser.list(library)).pieces, undefined); +}); + +test('the API needs the token, serves only installed models, and allows blob images under remote access', async (t) => { + const { root, folder, dir } = setup(t); + const source = folder({ name: 'mine' }); + const manager = Object.assign(new EventEmitter(), { list: () => [], listExiting: () => [], resolveCwd: (cwd) => cwd }); + const registry = Object.assign(new EventEmitter(), { list: () => [], warnings: [], platform: process.platform, get: () => null }); + const api = createManagerServer({ manager, registry, folderBrowser: createFolderBrowser({ home: root }), yardPieces: new YardPieces({ dir }), extraHosts: ['guild.example.ts.net:8443'], + token: 'test-manager-token', webDir: fileURLToPath(new URL('../web', import.meta.url)) }); + await api.listen(); + t.after(() => api.close()); + const call = (method, route, body) => fetch(`${api.url}${route}`, { method, headers: { Authorization: 'Bearer test-manager-token', 'Content-Type': 'application/json' }, body: body && JSON.stringify(body) }); + const unauthenticated = await fetch(`${api.url}/api/v1/yard-pieces/guild/anthropic`, { method: 'PUT', body: JSON.stringify({ path: source }) }); + assert.equal(unauthenticated.status, 401); + assert.equal((await fetch(`${api.url}/api/v1/yard-pieces`)).status, 401); + assert.equal((await call('GET', `/api/v1/folders?path=${encodeURIComponent(root)}&pieces=character`).then((r) => r.json())).pieces[0].path, source); + const { yardPieces } = await (await call('PUT', '/api/v1/yard-pieces/guild/anthropic', { path: source })).json(); + assert.equal(yardPieces.guild.anthropic.title, 'Patchwork'); + const model = await fetch(`${api.url}/yard/pieces/guild/anthropic/character.glb?v=${yardPieces.guild.anthropic.revision}`); + assert.equal(model.status, 200); + assert.equal(model.headers.get('content-type'), 'model/gltf-binary'); + assert.deepEqual(Buffer.from(await model.arrayBuffer()), fixture('character')); + const policy = Object.fromEntries(model.headers.get('content-security-policy').split(';').map((part) => part.trim().split(/\s+/)).map(([name, ...sources]) => [name, sources])); + assert.ok(policy['img-src'].includes('blob:') && policy['connect-src'].includes('blob:'), 'embedded textures decode from blob: URLs'); + assert.ok(policy['connect-src'].includes('wss://guild.example.ts.net:8443')); + assert.ok(!policy['script-src'].includes('blob:') && !policy['default-src'].includes('blob:')); + for (const route of ['/yard/pieces/guild/anthropic/piece.json', '/yard/pieces/guild/anthropic/install.json', '/yard/pieces/guild/openai/character.glb', '/yard/pieces/guild/anthropic/..%2Fanthropic%2Fcharacter.glb', '/yard/pieces/guild/anthropic/x/character.glb']) { + const response = await fetch(`${api.url}${route}`); + assert.equal(response.status, 404, route); + await response.arrayBuffer(); + } + const bad = await call('PUT', '/api/v1/yard-pieces/guild/anthropic', { path: 'relative' }); + assert.equal((await bad.json()).error.code, 'piece_invalid'); + assert.deepEqual((await (await call('DELETE', '/api/v1/yard-pieces/guild/anthropic')).json()).yardPieces, {}); + assert.equal((await fetch(`${api.url}/yard/pieces/guild/anthropic/character.glb`)).status, 404); +}); diff --git a/tests/yard.test.mjs b/tests/yard.test.mjs index e9b1960..15bc73a 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, statSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; -import { sessionPose, familiarPose, marksFor, layoutSessions, providerPositions, characterIndex, lookLimits, WORLDS, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, plateExtent, +import { sessionPose, familiarPose, marksFor, layoutSessions, providerPositions, characterIndex, lookLimits, yardExtras, WORLDS, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, plateExtent, stage, stagingMode, nextStagingChange, restSpots, route, clipFor, doorFor, stationProp, STAGING, CLIPS, SKELETON, PROVIDER_ORDER, photoSize, photoName } from '../web/yard/model.mjs'; import { goldenVectors } from '../scripts/yard-golden.mjs'; import { Matrix4, Vector3 } from 'three'; @@ -11,8 +11,39 @@ const session=(id,provider='anthropic')=>({id,provider:{id:provider},createdAt:' test('a model narrows its glance with yard.look but never widens it',()=>{ assert.deepEqual(lookLimits(undefined),{yaw:.7,pitch:.3,neck:.4}); - assert.deepEqual(lookLimits({look:{yaw:.3}}),{yaw:.3,pitch:.3,neck:.4}); - assert.deepEqual(lookLimits({look:{yaw:1.2,pitch:.9}}),{yaw:.7,pitch:.3,neck:.4}); + assert.deepEqual(lookLimits({yaw:.3}),{yaw:.3,pitch:.3,neck:.4}); + assert.deepEqual(lookLimits({yaw:1.2,pitch:.9}),{yaw:.7,pitch:.3,neck:.4}); +}); + +test('yard extras bound untrusted numbers, drop bad events and ignore a bad look',()=>{ + const valid={height:2,stride:.8,cycle:2,surface:1,events:[{clip:'work_0',time:.5,at:[0,1,.5]}],look:{yaw:.3,pitch:.2}}; + assert.deepEqual(yardExtras(valid,'character'),{...valid,problem:null}); + for(const events of [[null],[{...valid.events[0],at:[0,'bad',0]}],[{...valid.events[0],time:Infinity}],[{...valid.events[0],clip:'unknown'}],'bad']){ + const clean=yardExtras({...valid,events},'character'); + assert.match(clean.problem,/animation event/); + assert.deepEqual(clean.events,[]); + } + const mixed=yardExtras({...valid,events:[null,...valid.events]},'character'); + assert.deepEqual(mixed.events,valid.events,'valid events survive alongside malformed ones'); + const many=yardExtras({...valid,events:Array(33).fill(valid.events[0])},'character'); + assert.match(many.problem,/at most 32/);assert.equal(many.events.length,32); + for(const look of [null,'bad',{yaw:'bad'},{pitch:Infinity},{yaw:-.1},{pitch:Math.PI+.1}]){ + const clean=yardExtras({...valid,look},'character'); + assert.match(clean.problem,/yard look/);assert.equal(clean.look,undefined); + assert.deepEqual(lookLimits(clean.look),lookLimits(undefined)); + } + for(const [key,min,max] of [['height',.5,5],['stride',.1,5],['cycle',.25,10],['surface',.1,4]]){ + for(const value of [min,max])assert.equal(yardExtras({...valid,[key]:value},'character').problem,null); + for(const value of [NaN,Infinity,'1',null,min-.01,max+.01]){ + const clean=yardExtras({...valid,[key]:value},'character'); + assert.match(clean.problem,new RegExp('yard '+key)); + assert.ok(clean[key]===undefined||Number.isFinite(clean[key])); + } + } + assert.equal(yardExtras({height:'bad'},'helper').height,1); + assert.deepEqual([yardExtras({},'helper').problem,yardExtras({},'helper').height],[null,1],'a companion may carry no extras, as in World'); + for(const height of [.1,5])assert.equal(yardExtras({height},'helper').problem,null); + assert.equal(yardExtras(null,'character').height,2); }); test('visual poses preserve running/quiet vs exited and reported helper states',()=>{ @@ -277,6 +308,11 @@ test('the shipped characters and helpers follow the contract skeleton and carry } // The runtime walks a character at the speed its stride and cycle give. const yard=asset.nodes.find(n=>n.extras?.yard)?.extras.yard; + const clean=yardExtras(yard,kind); + assert.equal(clean.problem,null,name+' accepts shipped extras'); + for(const key of kind==='character'?['height','stride','cycle','surface']:['height'])assert.equal(clean[key],yard[key],name+' keeps '+key); + assert.deepEqual(clean.events,(yard.events||[]).map(({clip,time,at})=>({clip,time,at})),name+' keeps event timing and positions'); + if(yard.look)assert.deepEqual(lookLimits(clean.look),lookLimits(yard.look),name+' keeps look'); assert.ok(yard?.stride>0&&yard?.cycle>0&&yard?.height>0,name+' yard extras'); // Builders work at their station's surface, scaled to their own shoulders. if(kind==='character')assert.ok(yard.surface>0,name+' working height'); diff --git a/web/app.js b/web/app.js index 551e95e..e73c587 100644 --- a/web/app.js +++ b/web/app.js @@ -202,7 +202,7 @@ function holdToast(held) { const firstClick = (run) => (e) => { if (e.detail < 2) run(); }; /** Buttons that act through the session manager. While it cannot be reached they are unavailable rather than left to fail. */ -const MANAGER_ACTIONS = '.provider :is(.new, .existing, .install, .update), .session-card :is(.resume, .rename, .stop, .remove), .history-resume, #panel-stop, #yard-install, #yard-world button, .break-install'; +const MANAGER_ACTIONS = '.provider :is(.new, .existing, .install, .update), .session-card :is(.resume, .rename, .stop, .remove), .history-resume, #panel-stop, #yard-install, #yard-customize-open, .break-install'; function paintManagerActions(root = document) { for (const button of root.querySelectorAll(MANAGER_ACTIONS)) button.disabled = !state.connected; @@ -1435,15 +1435,19 @@ const FOLDER_FIELDS = { cwd: { title: 'Choose working folder', input: 'cwd' }, clone: { title: 'Choose clone folder', input: 'github-parent' }, world: { title: 'Choose a world file', input: null }, + character: { title: 'Choose a character', input: null }, + companion: { title: 'Choose a companion', input: null }, }; const WORLD_FOLDER_KEY = 'agentGuild.worldFolder'; +const PIECE_FOLDER_KEY = 'agentGuild.pieceFolder'; +const PIECE_FIELDS = ['character', 'companion']; const folderView = { field: null, listing: null, loading: false, error: null, request: 0, opener: null }; -function chooseFolder(field, skin = null) { +function chooseFolder(field, skin = null, slot = null) { if (!state.connected) return; const dialog = $('folder-browser'); - Object.assign(folderView, { field, skin, listing: null, loading: false, error: null }); - $('folder-use').hidden = $('folder-new-open').hidden = field === 'world'; + Object.assign(folderView, { field, skin, slot, listing: null, loading: false, error: null, toWorkshop: PIECE_FIELDS.includes(field) && !load(PIECE_FOLDER_KEY) }); + $('folder-use').hidden = $('folder-new-open').hidden = field === 'world' || PIECE_FIELDS.includes(field); $('folder-title').textContent = FOLDER_FIELDS[field].title; $('folder-filter').value = ''; $('folder-hidden').checked = load(HIDDEN_FOLDERS_KEY) === '1'; @@ -1451,7 +1455,7 @@ function chooseFolder(field, skin = null) { folderView.opener = document.activeElement; dialog.showModal(); } - browseTo(field === 'world' ? load(WORLD_FOLDER_KEY) ?? '' : $(FOLDER_FIELDS[field].input).value.trim()); + browseTo(field === 'world' ? load(WORLD_FOLDER_KEY) ?? '' : PIECE_FIELDS.includes(field) ? load(PIECE_FOLDER_KEY) ?? '' : $(FOLDER_FIELDS[field].input).value.trim()); } function showNewFolder(open) { @@ -1505,8 +1509,13 @@ async function browseTo(dir) { folderView.error = null; renderFolderBrowser(); try { - const listing = await api('GET', `/folders?path=${encodeURIComponent(dir)}${folderView.field === 'world' ? '&files=agworld' : ''}`); + const listing = await api('GET', `/folders?path=${encodeURIComponent(dir)}${folderView.field === 'world' ? '&files=agworld' : PIECE_FIELDS.includes(folderView.field) ? `&pieces=${folderView.field}` : ''}`); if (request !== folderView.request) return; + if (folderView.toWorkshop && listing.workshop && listing.path !== listing.workshop) { + folderView.toWorkshop = false; + return browseTo(listing.workshop); + } + folderView.toWorkshop = false; folderView.listing = listing; $('folder-filter').value = ''; } catch (err) { @@ -1534,6 +1543,8 @@ function renderFolderBrowser() { $('folder-roots').replaceChildren(...(listing?.roots ?? []).map((root) => button(root.name, () => browseTo(root.path), 'btn folder-chip'))); $('folder-up').disabled = loading || !listing?.parent; $('folder-home').disabled = loading; + $('folder-workshop').hidden = !listing?.workshop; + $('folder-workshop').disabled = loading; const note = $('folder-note'); note.hidden = !listing?.note; note.textContent = listing?.note ? `${listing.note} was not found, so this shows the nearest folder that exists.` : ''; @@ -1547,12 +1558,19 @@ function renderFolderBrowser() { const row = button(file.name, () => useWorldFile(file.path), 'folder-row folder-file'); row.title = file.path; return row; + }), ...(listing?.pieces ?? []).filter((piece) => !query || piece.title.toLowerCase().includes(query) || piece.folder.toLowerCase().includes(query)).map((piece) => { + const row = button('', () => usePiece(piece.path), 'folder-row folder-piece'); + row.append(Object.assign(document.createElement('span'), { className: 'folder-piece-title', textContent: piece.title }), + Object.assign(document.createElement('span'), { className: 'folder-piece-sub', textContent: `${folderView.field === 'character' ? 'Character' : 'Companion'} · ${piece.folder}` })); + row.title = piece.path; + return row; })); const status = $('folder-status'); status.textContent = loading ? 'Loading folders…' : error ? error : !listing ? '' - : listing.files?.length ? '' + : listing.files?.length || listing.pieces?.length ? '' + : PIECE_FIELDS.includes(folderView.field) ? `No ${folderView.field === 'character' ? 'characters' : 'companions'} here or in the folders inside it. Steam keeps Workshop items in its library under steamapps › workshop › content › 5462970.` : !entries.length ? (folderView.field === 'world' ? 'No world files here.' : listing.entries.length ? 'No folders match.' : 'No folders here.') : listing.truncated ? `Showing the first ${listing.entries.length.toLocaleString()} folders.` : ''; status.classList.toggle('error', Boolean(error)); @@ -1562,17 +1580,19 @@ function renderFolderBrowser() { $('folder-new-create').disabled = loading || !listing || !$('folder-new-name').value.trim(); } -async function yardWorldChange(request) { - const buttons = document.querySelectorAll('#yard-world button'); - for (const item of buttons) item.disabled = true; +async function yardChange(request, row = true) { + state.yardBusy = row; + notifyViews(); try { - // The new state arrives as yard-worlds.updated. await request(); + return true; } catch (err) { - if (err instanceof AuthError) return showAuth(err.message); - toast(err.message, 8000); + if (err instanceof AuthError) showAuth(err.message); + else toast(err.message, 8000); + return false; } finally { - for (const item of buttons) item.disabled = !state.connected; + state.yardBusy = false; + notifyViews(); } } @@ -1580,7 +1600,13 @@ function useWorldFile(file) { const { skin, listing } = folderView; $('folder-browser').close(); save(WORLD_FOLDER_KEY, listing.path); - return yardWorldChange(() => api('PUT', `/yard-worlds/${skin}`, { path: file })); + return yardChange(() => api('PUT', `/yard-worlds/${skin}`, { path: file }), `${skin}/world`); +} + +async function usePiece(folder) { + const { skin, slot, listing } = folderView; + $('folder-browser').close(); + if (await yardChange(() => api('PUT', `/yard-pieces/${skin}/${slot}`, { path: folder }), `${skin}/${slot}`)) save(PIECE_FOLDER_KEY, listing.path); } function folderBrowserClosed() { @@ -7909,6 +7935,7 @@ function closeDialogs() { closeFlags(); closeMemory(); if ($('folder-browser').open) $('folder-browser').close(); + if ($('yard-customize').open) $('yard-customize').close(); yardUi?.closePhoto(); // An open question is answered as cancelled. if ($('ask').open) $('ask').close(); @@ -8058,6 +8085,7 @@ function connectEvents() { $('yard-install').hidden = !['win32', 'darwin'].includes(state.platform); $('yard-world-get').hidden = state.platform !== 'win32'; state.yardWorlds = msg.yardWorlds ?? null; + state.yardPieces = msg.yardPieces ?? null; state.remoteAccessUI?.setAvailable(msg.remoteAccess); state.remoteAccessUI?.signInsUpdated(); state.glassesLinkUI?.setAvailable(msg.remoteAccess); @@ -8102,6 +8130,9 @@ function connectEvents() { } else if (msg.type === 'yard-worlds.updated') { state.yardWorlds = msg.yardWorlds; notifyViews(); + } else if (msg.type === 'yard-pieces.updated') { + state.yardPieces = msg.yardPieces; + notifyViews(); } else if (msg.type === 'yard.fireworks') { yardUi?.celebrate(); } else if (msg.type === 'news.updated') { @@ -8845,6 +8876,7 @@ $('folder-browser').addEventListener('keydown', folderBrowserKeys); $('folder-list').addEventListener('keydown', moveInFolders); $('folder-up').addEventListener('click', () => browseTo(folderView.listing.parent)); $('folder-home').addEventListener('click', () => browseTo('~')); +$('folder-workshop').addEventListener('click', () => browseTo(folderView.listing.workshop)); $('folder-filter').addEventListener('input', renderFolderBrowser); $('folder-hidden').addEventListener('change', (e) => { save(HIDDEN_FOLDERS_KEY, e.target.checked ? '1' : null); @@ -8937,7 +8969,12 @@ yardUi = initYard({ yardWorld: (skin) => state.yardWorlds?.[skin] ?? null, canReplaceYard: () => Boolean(state.yardWorlds), replaceYard: (skin) => chooseFolder('world', skin), - restoreYard: (skin) => yardWorldChange(() => api('DELETE', `/yard-worlds/${skin}`)), + restoreYard: (skin) => yardChange(() => api('DELETE', `/yard-worlds/${skin}`)), + yardPieces: (skin) => state.yardPieces?.[skin] ?? {}, + yardBusy: () => Boolean(state.yardBusy), + yardChecking: () => (typeof state.yardBusy === 'string' ? state.yardBusy : null), + replacePiece: (skin, slot) => chooseFolder(slot === 'companion' ? 'companion' : 'character', skin, slot), + restorePiece: (skin, slot) => yardChange(() => api('DELETE', `/yard-pieces/${skin}/${slot}`)), // The new state arrives as yard-art.updated, ordered with the progress events. yardArtAction: (action) => api('POST', `/yard-art/${action}`), mountInspector(host, selection) { diff --git a/web/index.html b/web/index.html index 4651eec..81b3484 100644 --- a/web/index.html +++ b/web/index.html @@ -151,7 +151,7 @@

Start a session

Preparing your yard…
-
+

Drag to explore · Scroll to zoom · Select to inspect · Double-click a session to openDrag to explore · Pinch to zoom · Tap to inspect · Open from the Inspector

@@ -742,6 +742,18 @@

+ +
+
+

Customize this Yard

+

+
+ +
+
+
    +
    +
    @@ -754,6 +766,7 @@

    Choose working folder

    +
    diff --git a/web/skins/guild/skin.css b/web/skins/guild/skin.css index f061148..5252711 100644 --- a/web/skins/guild/skin.css +++ b/web/skins/guild/skin.css @@ -235,13 +235,7 @@ content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; border: 54px solid transparent; border-image: url("ui/frame-ornate.png") 232; } - .models-bar { padding: 30px 36px 12px; } - .models-body { margin: 0 24px; padding: 0 12px 14px; } - .models-source { padding: 10px 36px 30px; } - .history-tools { padding: 10px 36px 0; } - .history-form { padding: 10px 36px 30px; } - .memory-body { margin: 0 24px 28px; } - .github-accounts { padding: 10px 36px; } + .models { padding: 32px; } .model-group h4, .news-day, .changelog-type { font-family: var(--font-display); letter-spacing: 0.08em; color: var(--gold-ink); } .news-bar { padding: 16px 18px 12px; } .news-filters, .news-status { padding-left: 18px; padding-right: 18px; } @@ -259,12 +253,7 @@ @media (max-width: 640px) { .models::after, .auth::after { border-width: 40px; } - .models-bar { padding: 24px 26px 10px; } - .models-body { margin: 0 16px; padding: 0 10px 12px; } - .models-source { padding: 10px 26px 24px; } - .history-tools { padding: 10px 26px 0; } - .history-form { padding: 10px 26px 24px; } - .github-accounts { padding: 10px 26px; } + .models { padding: 26px; } .auth { padding: 32px 30px; } } diff --git a/web/styles.css b/web/styles.css index 2f5ce0a..a88c58f 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1408,6 +1408,19 @@ body:has(#terminal-panel:not([hidden]) .terminal-controls:not([hidden])) .toast .folder-row::before { content: ""; flex: none; width: 18px; height: 14px; border: 1.6px solid var(--muted); border-radius: 2px 4px 3px 3px; } .folder-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); } .folder-row.hidden-folder { color: var(--muted); } +.folder-row.folder-piece { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0 10px; } +.folder-row.folder-piece::before { grid-row: span 2; width: 14px; height: 14px; border-radius: 50%; border-color: var(--accent); background: color-mix(in srgb, var(--accent) 25%, transparent); } +.folder-piece-title { font-weight: 600; } +.folder-piece-sub { color: var(--muted); font-size: 0.8rem; } +.yard-customize { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); } +.yard-customize-heading { padding: 14px 0 4px; color: var(--muted); font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; } +.yard-customize-copy { display: flex; flex-direction: column; gap: 2px; min-width: 0; overflow-wrap: anywhere; } +.yard-customize-status, .yard-customize-note { color: var(--muted); font-size: 0.82rem; } +.yard-customize-row[data-state="custom"] .yard-customize-status { color: var(--text); } +.yard-customize-row[data-state="failed"] .yard-customize-status { color: var(--danger); } +.yard-customize-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; } +.yard-customize-actions .btn { min-height: 36px; } +@media (max-width: 480px) { .flag-row.yard-customize-row { grid-template-columns: minmax(0, 1fr); } .yard-customize-actions { justify-content: flex-start; } } #folder-status.error { color: var(--danger); } .accounts-dialog { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); } .accounts-list { margin: 0; padding: 0; list-style: none; } diff --git a/web/yard/model.mjs b/web/yard/model.mjs index b4ff38c..37a0b62 100644 --- a/web/yard/model.mjs +++ b/web/yard/model.mjs @@ -202,6 +202,48 @@ export const CLIPS = { character: { walk: 'walk', work: ['work_0', 'work_1'], pause: ['pause'], rest: ['idle_0', 'idle_1', 'idle_2'], done: ['done'] }, helper: ['idle', 'working', 'waiting', 'done', 'move'], }; +export function yardExtras(raw, kind) { + const yard = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; + const character = kind === 'character'; + const clean = { events: [], look: undefined, problem: null }; + const problem = message => { clean.problem ??= message; }; + const within = (value, min, max) => Number.isFinite(value) && value >= min && value <= max; + const fields = character + ? { height: [.5, 5, 2], stride: [.1, 5, 1], cycle: [.25, 10, 1], surface: [.1, 4, undefined] } + : { height: [.1, 5, 1] }; + for (const [key, [min, max, fallback]] of Object.entries(fields)) { + clean[key] = within(yard[key], min, max) ? yard[key] : fallback; + if (!within(yard[key], min, max) && (character || yard[key] !== undefined)) problem(`needs a yard ${key} from ${min} to ${max}.`); + } + if (yard.events !== undefined) { + if (!Array.isArray(yard.events) || yard.events.length > 32) problem('needs an animation event array with at most 32 entries.'); + if (Array.isArray(yard.events)) { + const clips = character ? Object.values(CLIPS.character).flat() : CLIPS.helper; + for (const event of yard.events.slice(0, 32)) { + if (!event || !clips.includes(event.clip) || !within(event.time, 0, 60) || !Array.isArray(event.at) || event.at.length !== 3 || !event.at.every(value => within(value, -5, 5))) { + problem('has an animation event without a valid clip, time and position.'); + } else clean.events.push({ clip: event.clip, time: event.time, at: [...event.at] }); + } + } + } + if (yard.look !== undefined) { + const look = yard.look; + if (!look || typeof look !== 'object' || Array.isArray(look) || !['yaw', 'pitch'].every(key => look[key] === undefined || within(look[key], 0, Math.PI))) { + problem('needs yard look yaw and pitch from 0 to π.'); + } else clean.look = { yaw: look.yaw, pitch: look.pitch }; + } + return clean; +} +export const unitQuaternion = (x, y, z, w) => Math.abs(Math.hypot(x, y, z, w) - 1) <= 0.001; +export function playableClip(clip) { + return clip.tracks.every(({ times, values, ValueTypeName }) => { + if (!times.length || !(times[0] >= 0)) return false; + for (let i = 0; i < times.length; i++) if (!Number.isFinite(times[i]) || (i > 0 && !(times[i] > times[i - 1]))) return false; + for (const value of values) if (!Number.isFinite(value)) return false; + if (ValueTypeName === 'quaternion') for (let i = 0; i < values.length; i += 4) if (!unitQuaternion(values[i], values[i + 1], values[i + 2], values[i + 3])) return false; + return true; + }); +} // The shared skeleton, named after Godot's humanoid profile. Helpers carry // the trunk only; characters add limbs and a prop socket in each hand. const LIMBS = ['Shoulder', 'UpperArm', 'LowerArm', 'Hand', 'UpperLeg', 'LowerLeg', 'Foot', 'Toes']; @@ -363,8 +405,8 @@ export function hallActivity(sessions, providerId) { } // How far a character's head may turn to look: STAGING.look, or less where // the model's own `yard.look` extras (rig.py's --look) narrow it. -export function lookLimits(yard) { - const own = yard?.look || {}; +export function lookLimits(look) { + const own = look || {}; return { yaw: Math.min(STAGING.look.yaw, own.yaw ?? Infinity), pitch: Math.min(STAGING.look.pitch, own.pitch ?? Infinity), neck: STAGING.look.neck }; } // Whom a resting or pausing character looks at: the selected session when diff --git a/web/yard/renderer.js b/web/yard/renderer.js index 8d19348..b088d52 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -1,13 +1,18 @@ import * as T from './vendor/engine.js'; import { WORLDS, PROVIDER_ORDER, characterIndex, GILDED, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, providerPositions, sessionPose, familiarPose, hash, - stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, STAGING, photoSize, marksFor, SHOWN_HELPERS } from './model.mjs'; + stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, yardExtras, playableClip, STAGING, photoSize, marksFor, SHOWN_HELPERS } from './model.mjs'; const ASSETS = new URL('./assets/', import.meta.url); const vector = new T.Vector3(); const lookModel = new T.Matrix4(), lookParent = new T.Matrix4(), lookFrame = new T.Quaternion(), lookTurn = new T.Quaternion(), lookTilt = new T.Quaternion(); const axisX = new T.Vector3(1, 0, 0), axisY = new T.Vector3(0, 1, 0); const noop = () => {}; +const cancelled = new WeakSet(); +addEventListener('unhandledrejection', event => { if (cancelled.has(event.reason)) event.preventDefault(); }); +const cancel = loader => { const reason = new DOMException('This piece is no longer shown.', 'AbortError'); cancelled.add(reason); loader.abortController.abort(reason); }; const worldFile = (skin, custom, file) => `../worlds/${skin}/${file.split('/').pop()}?v=${custom.revision}`; +const pieceFiles = (skin, slot, piece) => (slot === 'companion' ? [0, 1, 2, 3].map(n => `companion_${n}.glb`) : ['character.glb']) + .map(file => new URL(`../pieces/${skin}/${slot}/${file}?v=${piece.revision}`, ASSETS).href); const vector2 = new T.Vector2(); // How fast characters turn to a new heading, in radians a second, and how // long a clip change blends. @@ -82,6 +87,21 @@ function disposeSkeletons(root) { root?.traverse(node=>{if(node.skeleton)skeletons.add(node.skeleton);}); for(const skeleton of skeletons)skeleton.dispose(); } +function checkPiece({scene,animations}) { + if(!animations.every(playableClip))throw new Error('An animation in this piece has values the Yard cannot play.'); + const probe=T.cloneSkeleton(scene); + try { + probe.updateMatrixWorld(true); + probe.traverse(node=>{ + if(!node.isMesh)return; + const vertices=node.geometry.attributes.position?.count,triangles=node.geometry.index?.array; + if(!vertices||(triangles&&!triangles.every(at=>atthis.resize());this.resizeObserver.observe(host); this.resize();this.overview(); } - async asset(name) { + async asset(name,url) { if(!this.cache.has(name)) { - const pending=new T.GLTFLoader(this.loadingManager).loadAsync(new URL(name+'.glb',ASSETS).href).catch(err=>{ + const failed=[],manager=url?new T.LoadingManager():this.loadingManager; + if(url){manager.onError=item=>failed.push(item);this.pieceLoaders.set(name,manager);} + const pending=new T.GLTFLoader(manager).loadAsync(url||new URL(name+'.glb',ASSETS).href).then(asset=>{ + try { + if(failed.length)throw new Error('A texture in this piece could not be drawn.'); + if(url)checkPiece(asset); + return asset; + } catch(err){disposeTree(asset.scene);throw err;} + }).catch(err=>{ if(this.cache.get(name)===pending)this.cache.delete(name); throw err; }); this.cache.set(name,pending); + if(url)pending.catch(noop).finally(()=>{if(this.pieceLoaders.get(name)===manager)this.pieceLoaders.delete(name);}); } return this.cache.get(name); } - goldAsset(name) { + goldAsset(name,url) { const key='gold:'+name; if(!this.cache.has(key)) { - const pending=this.asset(name).then(asset=>{const scene=T.cloneSkeleton(asset.scene);gild(scene);return {...asset,scene};}).catch(err=>{ + const pending=this.asset(name,url).then(asset=>{const scene=T.cloneSkeleton(asset.scene);gild(scene);return {...asset,scene};}).catch(err=>{ if(this.cache.get(key)===pending)this.cache.delete(key); throw err; }); @@ -198,6 +228,56 @@ export class YardRenderer { } return this.cache.get(key); } + activePieces() { return this.pieceSets[this.skin]||{}; } + pieceSource(slot) { + const piece=this.activePieces()[slot]; + return piece?{slot,revision:piece.revision,urls:pieceFiles(this.skin,slot,piece)}:null; + } + // skin's pieces by slot, each { revision }; a slot without one shows the original. + setPieces(skin,pieces) { + this.pieceSets[skin]=pieces; + if(skin===this.skin)this.applyPieces(); + } + applyPieces() { + if(this.disposed||!this.world)return; + const before=this.shownPieces,after={skin:this.skin,pieces:{...this.activePieces()}}; + const changed=[...new Set([...Object.keys(before.pieces),...Object.keys(after.pieces)])] + .filter(slot=>before.skin!==after.skin||before.pieces[slot]?.revision!==after.pieces[slot]?.revision); + if(!changed.length&&before.skin===after.skin)return; + this.shownPieces=after; + if(before.skin===after.skin)for(const unit of this.units.values()){ + if(changed.includes('companion'))this.clearHelpers(unit); + if(changed.includes(PROVIDER_ORDER[characterIndex(unit.session.provider.id)])){this.dropModel(unit);this.loadUnit(unit,unit.session);} + if(changed.includes('companion')&&unit.loaded)this.helpers(unit,unit.session); + } + for(const slot of changed){ + if(before.pieces[slot])for(const url of pieceFiles(before.skin,slot,before.pieces[slot]))this.release(url); + if(after.pieces[slot])this.preload(slot,after.pieces[slot]); + } + } + preload(slot,piece) { + const skin=this.skin; + Promise.all(pieceFiles(skin,slot,piece).map(url=>this.asset(url,url))).then(()=>true,()=>false).then(ok=>{ + if(!this.disposed&&this.skin===skin&&this.activePieces()[slot]?.revision===piece.revision)this.onPiece({skin,slot,revision:piece.revision,ok}); + }); + } + pieceFailed(source) { + if(this.activePieces()[source.slot]?.revision===source.revision)this.onPiece({skin:this.skin,slot:source.slot,revision:source.revision,ok:false}); + } + release(name) { + const loader=this.pieceLoaders.get(name);if(loader){cancel(loader);this.pieceLoaders.delete(name);} + for(const key of [name,'gold:'+name]){ + const pending=this.cache.get(key); + if(!pending)continue; + this.cache.delete(key); + pending.then(asset=>disposeTree(asset.scene)).catch(noop); + } + } + dropModel(unit) { + for(const mixer of unit.mixers){mixer.stopAllAction();mixer.uncacheRoot(mixer.getRoot());} + if(unit.model){unit.root.remove(unit.model);disposeSkeletons(unit.model);} + Object.assign(unit,{mixers:[],model:null,loaded:false,loading:false,animations:null,action:null,clip:null,lookBones:null,looking:false,load:(unit.load||0)+1}); + } // Shows skin's world in theme, or only theme's plates when that world is // already up. What is on screen stays until the new world or theme is // ready; progress receives the bytes downloaded so far. @@ -235,6 +315,7 @@ export class YardRenderer { this.halls.clear(); if(this.world){this.scene.remove(this.world);disposeWorld(this.world);} this.world=loaded.scene;this.skin=skin;this.custom=custom;this.scene.add(this.world); + this.applyPieces(); this.makeEffects(WORLDS[skin].effect); this.light(skin,theme); this.host.dataset.world=skin; @@ -593,13 +674,14 @@ export class YardRenderer { } async loadUnit(unit,session) { if(unit.loaded||unit.loading)return; - const generation=this.request; - const prefix=WORLDS[this.skin].characters; - const index=characterIndex(session.provider.id); + const generation=this.request,token=unit.load=(unit.load||0)+1; + const index=characterIndex(session.provider.id),source=this.pieceSource(PROVIDER_ORDER[index]); + const name=source?source.urls[0]:WORLDS[this.skin].characters+index; + const stale=()=>this.disposed||generation!==this.request||token!==unit.load||!unit.root.parent; unit.loading=true; try { - const asset=await (GILDED.includes(session.provider.id)?this.goldAsset(prefix+index):this.asset(prefix+index)); - if(this.disposed||generation!==this.request||!unit.root.parent)return; + const asset=await (GILDED.includes(session.provider.id)?this.goldAsset(name,source?.urls[0]):this.asset(name,source?.urls[0])); + if(stale())return; const model=T.cloneSkeleton(asset.scene); model.rotation.y=unit.heading; model.traverse(n=>{if(n.isMesh){n.castShadow=true;n.receiveShadow=true;}}); @@ -607,8 +689,9 @@ export class YardRenderer { unit.label.title=''; // The walk's stride and cycle, and where blows land, as rig.py wrote them. let yard=null;asset.scene.traverse(n=>{if(n.userData?.yard)yard=n.userData.yard;}); - unit.speed=yard?.stride&&yard?.cycle?yard.stride/yard.cycle:1; - unit.events=yard?.events||[];unit.height=yard?.height;unit.surface=yard?.surface;unit.look=lookLimits(yard); + yard=yardExtras(yard,'character'); + unit.speed=yard.stride/yard.cycle; + unit.events=yard.events;unit.height=yard.height;unit.surface=yard.surface;unit.look=lookLimits(yard.look); const neck=model.getObjectByName('Neck'),head=model.getObjectByName('Head'); unit.lookBones=neck&&head&&model.getObjectByName('LeftUpperLeg')?{neck,head}:null; this.scaleProp(unit.session?.id||session.id); @@ -618,8 +701,10 @@ export class YardRenderer { unit.clip=null;this.play(unit); this.helpers(unit,unit.session||session);this.dirty=true;this.drawOnce(); } catch(err){ - if(!this.disposed&&generation===this.request&&unit.root.parent){console.warn('Yard character unavailable:',err.message);unit.label.title='Character art unavailable. Session controls remain available.';} - } finally {unit.loading=false;} + if(stale())return; + if(source){this.pieceFailed(source);return;} + console.warn('Yard character unavailable:',err.message);unit.label.title='Character art unavailable. Session controls remain available.'; + } finally {if(token===unit.load)unit.loading=false;} } // A hall's own copies of the materials that glow with its crew, so one // hall lighting up does not light the others that share them. @@ -1055,28 +1140,46 @@ export class YardRenderer { if(helper.action){if(this.reduced)helper.action.stop();else helper.action.crossFadeTo(action,FADE,false);} helper.action=action; } + clearHelpers(unit) { + unit.revision++;unit.helperSignature=null; + for(const helper of unit.helpers){unit.crew.remove(helper.model);helper.mixer?.stopAllAction();helper.mixer?.uncacheRoot(helper.model);disposeSkeletons(helper.model);} + unit.helpers=[]; + this.dirty=true;this.drawOnce(); + } async helpers(unit,session) { const agents=helpersFor(session); - const signature=JSON.stringify(agents); + const kinds=WORLDS[this.skin].helpers,source=this.pieceSource('companion'); + const signature=JSON.stringify([source?.revision??null,agents]); if(unit.helperSignature===signature)return; + this.clearHelpers(unit); unit.helperSignature=signature; - const rev=++unit.revision,generation=this.request; - for(const helper of unit.helpers){unit.crew.remove(helper.model);helper.mixer?.stopAllAction();helper.mixer?.uncacheRoot(helper.model);disposeSkeletons(helper.model);} - unit.helpers=[]; - const kinds=WORLDS[this.skin].helpers; + const rev=unit.revision,generation=this.request; + const stale=()=>this.disposed||generation!==this.request||rev!==unit.revision||!unit.root.parent; + let pieces=null; + if(source&&agents.length){ + try {pieces=await Promise.all(source.urls.map(url=>this.asset(url,url)));} + catch { + if(stale())return; + unit.helperSignature=null; + this.pieceFailed(source); + return; + } + if(stale())return; + } // The full helper count and all helper details remain in the canonical inspector. // Small squads preserve readable character silhouettes at the overview scale. await Promise.all(agents.slice(0,SHOWN_HELPERS).map(async(agent,i)=>{ try { - const asset=await this.asset((agent.shell?kinds.shell:kinds.agent)+(hash(agent.name||agent.id)%4)); - if(this.disposed||generation!==this.request||rev!==unit.revision||!unit.root.parent)return; + const n=hash(agent.name||agent.id)%4; + const asset=pieces?pieces[n]:await this.asset((agent.shell?kinds.shell:kinds.agent)+n); + if(stale())return; const model=T.cloneSkeleton(asset.scene); const offset=helperOffset(i,agents.length); model.scale.setScalar(.45);model.position.set(unit.root.position.x+offset.x,.27,unit.root.position.z+offset.z); unit.crew.add(model); const mixer=new T.AnimationMixer(model); let yard=null;asset.scene.traverse(n=>{if(n.userData?.yard)yard=n.userData.yard;}); - const helper={id:agent.id,top:(yard?.height||1)*model.scale.y,model,mixer,offset,pose:agent.pose,clips:asset.animations,moving:false,action:null}; + const helper={id:agent.id,top:yardExtras(yard,'helper').height*model.scale.y,model,mixer,offset,pose:agent.pose,clips:asset.animations,moving:false,action:null}; this.helperClip(helper,agent.pose); mixer.update(.3+(hash(agent.id)%40)/10); unit.helpers.push(helper); @@ -1084,7 +1187,7 @@ export class YardRenderer { } catch { // The next state transition can rebuild this incomplete squad. Routine // unchanged updates do not call helpers(), so failure cannot loop. - if(!this.disposed&&generation===this.request&&rev===unit.revision&&unit.root.parent)unit.helperSignature=null; + if(!stale())unit.helperSignature=null; } })); } @@ -1341,7 +1444,7 @@ export class YardRenderer { this.clearFireworks();this.fireworkTexture?.dispose(); for(const spark of this.sparks){spark.points.geometry.dispose();spark.points.material.dispose();}this.sparks=[]; this.releaseEffects();this.clearProps(); - this.loadingManager.abort();this.pending?.close();this.pending=null; + this.loadingManager.abort();for(const loader of this.pieceLoaders.values())cancel(loader);this.pieceLoaders.clear();this.pending?.close();this.pending=null; this.resizeObserver.disconnect();this.controls.dispose();this.clearUnits(); for(const item of this.halls.values()){item.label.remove();item.chip.remove();}this.halls.clear();this.list.remove(); disposeWorld(this.world); diff --git a/web/yard/view.js b/web/yard/view.js index 9fb8034..f60668e 100644 --- a/web/yard/view.js +++ b/web/yard/view.js @@ -1,4 +1,4 @@ -import { WORLDS, sessionPose } from './model.mjs'; +import { WORLDS, PROVIDER_ORDER, sessionPose } from './model.mjs'; const $ = id => document.getElementById(id); const make = (tag, cls, text) => Object.assign(document.createElement(tag), { className: cls, ...(text === undefined ? {} : { textContent: text }) }); @@ -17,6 +17,8 @@ async function boundedLoad(start, onProgress) { } finally { settled = true; clearTimeout(timer); } } const megabytes = bytes => (bytes / 1048576).toFixed(1) + ' MB'; +const CREWS = { anthropic: 'Claude Code', openai: 'Codex CLI', google: 'Antigravity CLI', xai: 'Grok Build', shell: 'Shell', docker: 'Docker Agent' }; +const pieceKind = slot => slot === 'companion' ? 'companion' : 'character'; /** A layout with injected canonical controls. No API, sockets, or business rules. */ export function initYard(controller) { @@ -36,9 +38,18 @@ export function initYard(controller) { return world && !world.problem && unloadable.get(skin) !== world.revision ? world : null; }; const titleFor = (skin) => customFor(skin)?.title ?? WORLDS[skin].title; + const worldShown = new Map(); const loadWorld = (target, skin, theme, progress) => { + if (target.skin !== skin) { + worldShown.delete(skin); + for (const [key, shown] of pieceShown) if (key.startsWith(skin + '/') && shown.ok) pieceShown.delete(key); + target.setPieces(skin, piecesFor(skin)); + worldSpot(); + } const custom = customFor(skin); - return target.setWorld(skin, theme, { progress, custom }).catch((err) => { + return target.setWorld(skin, theme, { progress, custom }).then(() => { + if (custom) { worldShown.set(skin, custom.revision); worldSpot(); } + }, (err) => { if (!custom || disposed) throw err; unloadable.set(skin, custom.revision); if (skin === lastSkin) lastRevision = null; @@ -46,6 +57,99 @@ export function initYard(controller) { return target.setWorld(skin, theme, { progress }); }); }; + const pieceShown = new Map(); + const pieceKey = (skin, slot) => skin + '/' + slot; + const piecesOf = (skin) => controller.yardPieces?.(skin) ?? {}; + const piecesFor = (skin) => { + const pieces = {}; + for (const [slot, piece] of Object.entries(piecesOf(skin))) { + const shown = pieceShown.get(pieceKey(skin, slot)); + if (piece.revision && !(shown?.revision === piece.revision && !shown.ok)) pieces[slot] = { revision: piece.revision }; + } + return pieces; + }; + const syncPieces = (target) => { + const skin = root.dataset.skin; + if (target && WORLDS[skin]) target.setPieces(skin, piecesFor(skin)); + }; + const pieceDrawn = ({ skin, slot, revision, ok }) => { + if (disposed || piecesOf(skin)[slot]?.revision !== revision) return; + const key = pieceKey(skin, slot), was = pieceShown.get(key); + if (was?.revision === revision && (!was.ok || was.ok === ok)) return; + pieceShown.set(key, { revision, ok }); + if (!ok) for (const target of [renderer, pendingRenderer]) target?.setPieces(skin, piecesFor(skin)); + worldSpot(); + }; + function worldStatus(skin) { + const world = controller.yardWorld(skin); + if (!world) return { state: 'original', text: 'Original' }; + if (world.problem) return { state: 'failed', text: world.problem + ' Showing the original.' }; + if (!customFor(skin)) return { state: 'failed', text: world.title + ' could not load. Showing the original.' }; + return worldShown.get(skin) === world.revision ? { state: 'custom', text: 'Customized: ' + world.title } : { state: 'loading', text: 'Loading ' + world.title + '…' }; + } + function pieceStatus(skin, slot) { + const piece = piecesOf(skin)[slot], kind = pieceKind(slot); + if (!piece) return { state: 'original', text: 'Original' }; + if (piece.problem) return { state: 'failed', text: piece.problem + ' Showing the original ' + kind + '.' }; + const shown = pieceShown.get(pieceKey(skin, slot)); + if (shown?.revision !== piece.revision) return { state: 'loading', text: 'Loading ' + piece.title + '…' }; + return shown.ok ? { state: 'custom', text: 'Customized: ' + piece.title } : { state: 'failed', text: piece.title + ' could not load. Showing the original ' + kind + '.' }; + } + const slotName = (slot) => slot === 'world' ? 'World' : slot === 'companion' ? 'Companions' : snapshot.providers.find(p => p.id === slot)?.tool || CREWS[slot]; + const customizeRows = new Map(); + function customizeRow(slot) { + if (customizeRows.has(slot)) return customizeRows.get(slot); + const row = make('li', 'flag-row yard-customize-row'), copy = make('div', 'yard-customize-copy'), actions = make('div', 'yard-customize-actions'); + const name = make('span', 'flag-name'), status = make('span', 'yard-customize-status'), note = make('span', 'yard-customize-note'); + const replace = make('button', 'btn', 'Replace'), restore = make('button', 'btn', 'Restore original'); + replace.type = restore.type = 'button'; + note.hidden = slot === 'world'; + const skin = () => root.dataset.skin; + replace.addEventListener('click', () => slot === 'world' ? controller.replaceYard(skin()) : controller.replacePiece(skin(), slot)); + restore.addEventListener('click', () => slot === 'world' ? controller.restoreYard(skin()) : controller.restorePiece(skin(), slot)); + copy.append(name, status, note); + actions.append(replace, restore); + row.append(copy, actions); + row.dataset.slot = slot; + const parts = { row, name, status, note, replace, restore }; + customizeRows.set(slot, parts); + return parts; + } + function paintCustomize() { + const dialog = $('yard-customize'), skin = root.dataset.skin; + if (!dialog.open) return; + if (!WORLDS[skin]) { dialog.close(); return; } + $('yard-customize-sub').textContent = WORLDS[skin].title + ' only. Other Yards keep their own.'; + const busy = Boolean(controller.yardBusy?.()) || !snapshot.connected; + const heading = (text) => { + if (!customizeRows.has(text)) { const item = make('li', 'yard-customize-heading', text); item.setAttribute('role', 'presentation'); customizeRows.set(text, item); } + return customizeRows.get(text); + }; + const items = []; + for (const [title, slots] of [['Yard', ['world']], ['Characters', PROVIDER_ORDER], ['Companions', ['companion']]]) { + items.push(heading(title)); + for (const slot of slots) { + const parts = customizeRow(slot), status = controller.yardChecking?.() === pieceKey(skin, slot) ? { state: 'loading', text: 'Checking replacement…' } : slot === 'world' ? worldStatus(skin) : pieceStatus(skin, slot); + const installed = slot === 'world' ? Boolean(controller.yardWorld(skin)) : Boolean(piecesOf(skin)[slot]); + parts.name.textContent = slotName(slot); + parts.status.textContent = status.text; + if (slot !== 'world') parts.note.textContent = slot === 'companion' ? 'Shows beside sessions with helpers in this Yard.' + : 'Shows on your ' + slotName(slot) + ' sessions in this Yard' + (slot === 'shell' ? ', and in gold on Terminal AI Chat’s.' : '.'); + parts.row.dataset.state = status.state; + parts.replace.disabled = busy; + parts.restore.disabled = busy || !installed; + parts.replace.setAttribute('aria-label', 'Replace ' + slotName(slot)); + parts.restore.setAttribute('aria-label', 'Restore the original ' + slotName(slot)); + items.push(parts.row); + } + } + const list = $('yard-customize-list'); + if (items.some((item, i) => list.children[i] !== item) || list.children.length !== items.length) { + const focused = list.contains(document.activeElement) ? document.activeElement : null; + list.replaceChildren(...items); + focused?.focus(); + } + } function worldSpot() { const skin = root.dataset.skin; if (!WORLDS[skin]) return; @@ -53,11 +157,11 @@ export function initYard(controller) { $('yard-title').textContent = titleFor(skin); $('yard-title').classList.toggle('yard-title-custom', Boolean(customFor(skin))); const reason = world?.problem || (world && !customFor(skin) ? world.title + ' could not load.' : ''); - $('yard-world-replace').hidden = !controller.canReplaceYard(); - $('yard-world-replace').textContent = world ? 'Replace' : 'Replace this Yard'; - $('yard-world-restore').hidden = !world; - $('yard-world-note').hidden = !reason; - $('yard-world-note').textContent = reason && reason + ' This Yard is showing its original.'; + const failed = !reason && Object.keys(piecesOf(skin)).map(slot => pieceStatus(skin, slot)).find(status => status.state === 'failed'); + $('yard-customize-open').hidden = !controller.canReplaceYard(); + $('yard-world-note').hidden = !reason && !failed; + $('yard-world-note').textContent = reason ? reason + ' This Yard is showing its original.' : failed ? failed.text : ''; + paintCustomize(); } const celebrate = () => { if (!disposed && sceneVisible()) renderer?.fireworks(); }; const fireworksButton = () => { @@ -96,7 +200,7 @@ export function initYard(controller) { if (allowed) yardChoice.parentElement.removeAttribute('title'); else yardChoice.parentElement.title = `${skinEntry()?.name || 'This skin'} has no yard yet.`; $('yard').hidden = view !== 'yard'; - if (view !== 'yard') closePhoto(); + if (view !== 'yard') { closePhoto(); $('yard-customize').close(); } $('providers').hidden = view === 'yard'; $('providers').inert = view === 'yard'; for (const section of document.querySelectorAll('#app > section.sessions, #app > section.news')) { @@ -176,6 +280,7 @@ export function initYard(controller) { select: next => select(next), open: id => controller.openSession(id), error: () => { if (attempt === sceneAttempt) failScene(); }, + piece: pieceDrawn, }); pendingRenderer = candidate; // Appearance can change while the first world is loading. @@ -184,10 +289,12 @@ export function initYard(controller) { skin = root.dataset.skin; theme = root.dataset.theme; if (!WORLDS[skin]) { candidate.dispose(); return; } revision = customFor(skin)?.revision ?? null; + syncPieces(candidate); showLoading('Loading ' + titleFor(skin) + '…', 'scene'); await boundedLoad(progress => loadWorld(candidate, skin, theme, progress), loadingProgress); } while (!disposed && !candidate.disposed && attempt === sceneAttempt && (skin !== root.dataset.skin || theme !== root.dataset.theme || revision !== (customFor(skin)?.revision ?? null))); if (disposed || candidate.disposed || attempt !== sceneAttempt) { candidate.dispose(); return; } + syncPieces(candidate); pendingRenderer = null; renderer = candidate; lastSkin = skin; lastTheme = theme; lastRevision = revision; @@ -318,9 +425,10 @@ export function initYard(controller) { lastSkin = root.dataset.skin; lastTheme = root.dataset.theme; lastRevision = revision; swapWorld(); } + syncPieces(renderer); renderer.update(snapshot.providers,snapshot.sessions); renderer.setActive(sceneVisible()); - } else ensureScene(); + } else { syncPieces(pendingRenderer); ensureScene(); } } $('view-cards').addEventListener('change',() => setView('cards')); $('view-yard').addEventListener('change',() => { if (skinAllowsYard()) setView('yard'); }); @@ -342,8 +450,12 @@ export function initYard(controller) { lastSkin = ''; lastTheme = ''; update(); }); - $('yard-world-replace').addEventListener('click',() => controller.replaceYard(root.dataset.skin)); - $('yard-world-restore').addEventListener('click',() => controller.restoreYard(root.dataset.skin)); + $('yard-customize-open').addEventListener('click',() => { + if (!$('yard-customize').open) $('yard-customize').showModal(); + paintCustomize(); + }); + $('yard-customize-close').addEventListener('click',() => $('yard-customize').close()); + $('yard-customize').addEventListener('click',event => { if (event.target === $('yard-customize')) $('yard-customize').close(); }); $('yard-filter').addEventListener('input',roster); $('yard-news').addEventListener('click',controller.openNews); $('yard-deselect').addEventListener('click',() => { select(null); $('yard-filter').focus(); }); From 2f8e180ff9b8333b82ad67147a8507d2c525faf8 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Sun, 8 Nov 2026 22:54:07 -0500 Subject: [PATCH 2/4] fix: bound piece mesh parts and ignore released piece loads A piece may draw at most 16 mesh parts, counting each primitive once for every node that draws its mesh, so one mesh repeating its primitive can no longer add thousands of draw calls. Every shipped character, helper and Workshop piece draws one part. Each piece preload now has its own identity, dropped when the piece is released, so a load left over from before switching Yards away and back can no longer mark the returned piece as failed or customized. Co-Authored-By: Claude Opus 5.5 --- src/manager/yard-piece-check.mjs | 5 ++- tests/browser/yard.mjs | 54 ++++++++++++++++++++++++++++++++ tests/yard-pieces.test.mjs | 4 +++ web/yard/renderer.js | 9 ++++-- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/manager/yard-piece-check.mjs b/src/manager/yard-piece-check.mjs index 2e10190..fd4cd5e 100644 --- a/src/manager/yard-piece-check.mjs +++ b/src/manager/yard-piece-check.mjs @@ -23,6 +23,7 @@ const MATERIAL_TEXTURES = { EXT_materials_bump: ['bumpTexture'], }; const IN_PLACE = 0.1; +const MAX_PARTS = 16; const FLOAT = [5126, false]; const SIGNED = { byte: [5120, false], normalByte: [5120, true], short: [5122, false], normalShort: [5122, true] }; const UNSIGNED = { byte: [5121, false], normalByte: [5121, true], short: [5123, false], normalShort: [5123, true], int: [5125, false] }; @@ -252,10 +253,12 @@ function checkJson(json, name, limit, bin) { if ((primitive.mode ?? 4) !== 4) throw invalid(`${name} has geometry that isn’t triangles.`); return sum + Math.floor((json.accessors?.[primitive.indices ?? primitive.attributes?.POSITION]?.count ?? 0) / 3); }, 0)); - let faces = 0; + let faces = 0, parts = 0; for (const [at, node] of nodes.entries()) { if (node.mesh === undefined || !shown.has(at)) continue; faces += meshFaces[node.mesh] ?? 0; + parts += json.meshes?.[node.mesh]?.primitives?.length ?? 0; + if (parts > MAX_PARTS) throw invalid(`${name} draws more than ${MAX_PARTS} mesh parts.`); if (node.skin !== undefined && (json.meshes?.[node.mesh]?.primitives ?? []).some((primitive) => primitive.attributes?.JOINTS_0 === undefined || primitive.attributes?.WEIGHTS_0 === undefined)) { throw invalid(`${name} has a rigged mesh without weights.`); } diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index 140a711..94ec0f5 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -1613,6 +1613,60 @@ test('pieces replace one crew and the helpers, keep Terminal AI Chat gold, fall assert.deepEqual(b.errors,[]); }); +test('a released piece load never decides the piece after its Yard returns, whichever load finishes first',options,async t=>{ + const yardPieces=fakePieces({guild:{anthropic:{revision:'r1'}}}); + const {f,b}=await setup(t,{yardPieces}); + await b.evaluate(`(()=>{ + window.__piece=()=>[...__renderer.cache].find(([key])=>key.includes('/pieces/guild/anthropic/'))?.[1]; + window.__settled=async pending=>{await pending.then(()=>{},()=>{});await new Promise(resolve=>setTimeout(resolve,0));}; + })()`); + for(const [lateOk,lateFirst] of [[true,false],[true,true],[false,false],[false,true]]){ + const label=`a late ${lateOk?'success':'failure'} ${lateFirst?'before':'after'} the current load`; + await b.evaluate(`(async()=>{ + window.__held=[];window.__events=[]; + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},piece:e=>{__events.push(e);if(!e.ok){const p={...__renderer.pieceSets[e.skin]};delete p[e.slot];__renderer.setPieces(e.skin,p);}}}); + const asset=__renderer.asset.bind(__renderer); + __renderer.asset=(name,url)=>{ + const pending=asset(name,url); + if(!url?.includes('/pieces/guild/anthropic/'))return pending; + const held={pending}; + held.promise=new Promise((resolve,reject)=>Object.assign(held,{succeed:()=>pending.then(resolve,()=>resolve(null)),fail:()=>reject(new Error('Failed to fetch'))})); + __held.push(held); + return held.promise; + }; + __renderer.setPieces('guild',{anthropic:{revision:'r1'}}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},[]); + })()`); + await b.wait('__held.length===1'); + await b.evaluate("window.__a1=__piece();0"); + await b.evaluate("(async()=>{await __renderer.setWorld('orbital','dark');await __renderer.setWorld('guild','dark');})()"); + await b.wait('__held.length===2'); + await b.evaluate('window.__a2=__piece();0'); + assert.equal(await b.evaluate('Boolean(__a1&&__a2&&__a1!==__a2)'),true,label+': the return starts a load of its own'); + const late=`(async()=>{__held[0].${lateOk?'succeed':'fail'}();await __settled(__held[0].promise);})()`; + const current=`__held[1].${lateOk?'fail':'succeed'}()`; + if(lateFirst){ + await b.evaluate(late); + assert.deepEqual(await b.evaluate('__events'),[],label+': reports nothing'); + await b.evaluate(current); + await b.wait('__events.length===1'); + } else { + await b.evaluate(current); + await b.wait('__events.length===1'); + await b.evaluate(late); + } + await b.evaluate('__settled(__held[1].promise)'); + assert.deepEqual(await b.evaluate("__events.map(e=>[e.skin,e.slot,e.revision,e.ok])"),[['guild','anthropic','r1',!lateOk]],label+': only the current load decides'); + assert.equal(await b.evaluate("Boolean(__renderer.pieceSets.guild.anthropic)"),!lateOk,label+': the piece stays exactly when the current load drew it'); + assert.equal(await b.evaluate(lateOk?'__piece()===undefined':'__piece()===__a2'),true,label+': the cache holds only the current load'); + await b.evaluate('__renderer.dispose()'); + } + assert.deepEqual(b.errors,[]); +}); + test('companion changes clear helpers and pending loads even while their character reloads',options,async t=>{ const yardPieces=fakePieces({guild:{anthropic:{revision:'r1'},companion:{revision:'c1'}}}); const {f,b}=await setup(t,{yardPieces}); diff --git a/tests/yard-pieces.test.mjs b/tests/yard-pieces.test.mjs index 8732d00..d84bc10 100644 --- a/tests/yard-pieces.test.mjs +++ b/tests/yard-pieces.test.mjs @@ -184,6 +184,8 @@ test('data the loader would allocate or draw without bound, and damaged images, ['a node under two parents', json => { const child = json.nodes[0].children[0]; json.nodes.find((node, at) => at !== 0 && node.children).children.push(child); }, /node used in two places/], ['nodes that loop', json => { const at = json.nodes.length; json.nodes.push({ name: 'a', children: [at + 1] }, { name: 'b', children: [at] }); }, /nodes that loop/], ['a node index it doesn’t have', json => { json.scenes[0].nodes.push(999); }, /refers to a node it doesn’t have/], + ['one mesh repeating its part 17 times', json => { json.meshes[0].primitives = Array(17).fill(json.meshes[0].primitives[0]); }, /draws more than 16 mesh parts/], + ['nine parts drawn by two nodes', json => { json.meshes[0].primitives = Array(9).fill(json.meshes[0].primitives[0]); json.nodes.push({ name: 'again', mesh: 0, skin: 0 }); json.nodes[0].children.push(json.nodes.length - 1); }, /draws more than 16 mesh parts/], ['repeated shape keys', json => { const position = json.meshes[0].primitives[0].attributes.POSITION; json.meshes[0].primitives[0].targets = Array(64).fill({ POSITION: position }); }, /shape keys/], ['128 channels sharing one normalized output', json => { const animation = json.animations[0], output = json.accessors[animation.samplers[animation.channels[0].sampler].output]; @@ -205,6 +207,8 @@ test('data the loader would allocate or draw without bound, and damaged images, await assert.rejects(pieces.replace('guild', 'anthropic', source), refused(reason), label); assert.equal(JSON.stringify(pieces.snapshot()), before, label); } + const sixteen = variant(fixture('character'), json => { json.meshes[0].primitives = Array(16).fill(json.meshes[0].primitives[0]); }); + assert.equal((await pieces.replace('guild', 'anthropic', folder({ title: 'Sixteen', models: { 'character.glb': sixteen } }))).guild.anthropic.title, 'Sixteen', '16 mesh parts fit'); const picture = (json, binary) => { const view = json.bufferViews[json.images[0].bufferView]; return Buffer.from(binary.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)); }; const tail = text => (json, binary, view) => [Buffer.concat([picture(json, binary), Buffer.from(text, 'latin1')]), () => { json.images[0].bufferView = view; }]; const junk = extended(fixture('character'), tail('\0\0\0\0')); diff --git a/web/yard/renderer.js b/web/yard/renderer.js index b088d52..ac3d029 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -141,7 +141,7 @@ export class YardRenderer { // clock gives the time staging is judged at; tests pass a fixed one. constructor(host,labels,{select,open,error=noop,piece=noop,clock=Date.now}) { this.host=host;this.labels=labels;this.onSelect=select;this.onOpen=open;this.onError=error;this.onPiece=piece;this.clock=clock; - this.pieceSets={};this.shownPieces={skin:null,pieces:{}};this.pieceLoaders=new Map(); + this.pieceSets={};this.shownPieces={skin:null,pieces:{}};this.pieceLoaders=new Map();this.preloads=new Map(); this.scene=new T.Scene();this.units=new Map();this.halls=new Map();this.slots=new Map();this.cache=new Map(); this.loadingManager=new T.LoadingManager(); this.providers=[];this.sessions=[];this.selected=null;this.world=null;this.skin=null;this.request=0;this.worldRequest=0;this.pending=null; @@ -251,13 +251,16 @@ export class YardRenderer { if(changed.includes('companion')&&unit.loaded)this.helpers(unit,unit.session); } for(const slot of changed){ - if(before.pieces[slot])for(const url of pieceFiles(before.skin,slot,before.pieces[slot]))this.release(url); + if(before.pieces[slot]){this.preloads.delete(slot);for(const url of pieceFiles(before.skin,slot,before.pieces[slot]))this.release(url);} if(after.pieces[slot])this.preload(slot,after.pieces[slot]); } } preload(slot,piece) { - const skin=this.skin; + const skin=this.skin,attempt={}; + this.preloads.set(slot,attempt); Promise.all(pieceFiles(skin,slot,piece).map(url=>this.asset(url,url))).then(()=>true,()=>false).then(ok=>{ + if(this.preloads.get(slot)!==attempt)return; + this.preloads.delete(slot); if(!this.disposed&&this.skin===skin&&this.activePieces()[slot]?.revision===piece.revision)this.onPiece({skin,slot,revision:piece.revision,ok}); }); } From b5bc4835a3608106a91877650c4389afe200e761 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Sun, 8 Nov 2026 23:24:44 -0500 Subject: [PATCH 3/4] fix: pick and draw Yard characters in their current pose A skinned mesh keeps the bounds of the pose it first had, so a character whose animation leaves that pose, as every shipped and Workshop character does, could not be clicked where it stood and could be culled while on screen. Picking now refreshes the bounds of the shown characters and helpers, and their animated meshes are no longer frustum culled. Co-Authored-By: Claude Opus 5.5 --- tests/browser/yard.mjs | 49 ++++++++++++++++++++++++++++++++++++++++++ web/yard/renderer.js | 5 ++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index 94ec0f5..0dfbe7f 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -1667,6 +1667,55 @@ test('a released piece load never decides the piece after its Yard returns, whic assert.deepEqual(b.errors,[]); }); +test('picking and drawing follow the animated pose, not the pose a character first had',options,async t=>{ + const yardPieces=fakePieces({guild:{anthropic:{revision:'r1'}}}); + const {f,b}=await setup(t,{yardPieces}); + const session={...[...f.data.values()][0],id:'claude',provider:f.providers.find(p=>p.id==='anthropic'),agents:[{id:'a',name:'A',status:'working'}],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__renderer=new YardRenderer(host,labels,{select(){},open(){}}); + __renderer.setPieces('guild',{anthropic:{revision:'r1'}}); + await __renderer.setWorld('guild','dark');__renderer.update(${q(f.providers)},[${q(session)}]); + })()`); + await b.wait("__renderer.units.get('claude')?.loaded && __renderer.units.get('claude').helpers.length===1"); + const result=await b.evaluate(`(async()=>{ + const r=__renderer,unit=r.units.get('claude'),out={}; + r.renderer.render(r.scene,r.camera); + const skinned=[];unit.model.traverse(n=>{if(n.isSkinnedMesh)skinned.push(n);}); + const helpers=[];unit.crew.traverse(n=>{if(n.isSkinnedMesh)helpers.push(n);}); + const halls=[];for(const hall of r.halls.values())hall.root?.traverse(n=>{if(n.isMesh&&!n.isSkinnedMesh)halls.push(n);}); + out.animatedUnculled=[...skinned,...helpers].length>0&&[...skinned,...helpers].every(n=>n.frustumCulled===false); + out.staticCulled=halls.length>0&&halls.every(n=>n.frustumCulled===true); + const rect=r.canvas.getBoundingClientRect(); + const at=world=>{const p=world.clone().project(r.camera);return {clientX:rect.left+(p.x+1)/2*rect.width,clientY:rect.top+(1-p.y)/2*rect.height,onScreen:Math.abs(p.x)<1&&Math.abs(p.y)<1};}; + const head=unit.model.getObjectByName('Head'),hips=unit.model.getObjectByName('Hips'); + const before=at(head.getWorldPosition(new head.position.constructor())); + out.normal=r.pick(before)?.id??null; + for(const n of skinned)if(!n.boundingSphere)n.computeBoundingSphere(); + const reach=Math.max(...skinned.map(n=>n.boundingSphere.radius*n.getWorldScale(new head.position.constructor()).y))*1.5; + for(const mixer of unit.mixers)mixer.stopAllAction(); + const lifted=hips.getWorldPosition(new head.position.constructor()),toward=r.camera.position.clone().sub(lifted).setY(0).normalize(); + lifted.addScaledVector(toward,reach);lifted.y+=reach; + hips.position.copy(hips.parent.worldToLocal(lifted)); + r.renderer.render(r.scene,r.camera); + const raised=head.getWorldPosition(new head.position.constructor()),after=at(raised); + out.raisedOnScreen=after.onScreen; + out.raised=r.pick(after)?.id??null; + const camera=r.camera.clone();Object.assign(camera,{left:-.3,right:.3,top:.3,bottom:-.3,zoom:1});camera.position.copy(raised).addScaledVector(toward.clone().cross(new head.position.constructor(0,1,0)).normalize(),8);camera.lookAt(raised);camera.updateProjectionMatrix();camera.updateMatrixWorld(true); + let drawn=0;for(const n of skinned)n.onBeforeRender=()=>{drawn++;}; + const shadows=r.renderer.shadowMap.autoUpdate;r.renderer.shadowMap.autoUpdate=false;r.renderer.shadowMap.needsUpdate=false; + r.renderer.render(r.scene,camera); + r.renderer.shadowMap.autoUpdate=shadows; + out.drawnAtRaisedPose=drawn>0; + return out; + })()`); + assert.deepEqual(result,{animatedUnculled:true,staticCulled:true,normal:'claude',raisedOnScreen:true,raised:'claude',drawnAtRaisedPose:true}); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); + test('companion changes clear helpers and pending loads even while their character reloads',options,async t=>{ const yardPieces=fakePieces({guild:{anthropic:{revision:'r1'},companion:{revision:'c1'}}}); const {f,b}=await setup(t,{yardPieces}); diff --git a/web/yard/renderer.js b/web/yard/renderer.js index ac3d029..bfc9f8d 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -687,7 +687,7 @@ export class YardRenderer { if(stale())return; const model=T.cloneSkeleton(asset.scene); model.rotation.y=unit.heading; - model.traverse(n=>{if(n.isMesh){n.castShadow=true;n.receiveShadow=true;}}); + model.traverse(n=>{if(n.isMesh){n.castShadow=true;n.receiveShadow=true;}if(n.isSkinnedMesh)n.frustumCulled=false;}); unit.root.add(model);unit.model=model;unit.loaded=true; unit.label.title=''; // The walk's stride and cycle, and where blows land, as rig.py wrote them. @@ -1177,6 +1177,7 @@ export class YardRenderer { const asset=pieces?pieces[n]:await this.asset((agent.shell?kinds.shell:kinds.agent)+n); if(stale())return; const model=T.cloneSkeleton(asset.scene); + model.traverse(n=>{if(n.isSkinnedMesh)n.frustumCulled=false;}); const offset=helperOffset(i,agents.length); model.scale.setScalar(.45);model.position.set(unit.root.position.x+offset.x,.27,unit.root.position.z+offset.z); unit.crew.add(model); @@ -1260,6 +1261,8 @@ export class YardRenderer { const rect=this.canvas.getBoundingClientRect(); this.pointer.set((event.clientX-rect.left)/rect.width*2-1,-(event.clientY-rect.top)/rect.height*2+1); this.ray.setFromCamera(this.pointer,this.camera); + // A skinned mesh keeps the bounds of the pose it first had; picking needs the pose on screen. + for(const unit of this.units.values())for(const root of [unit.root,unit.crew])root?.traverse(n=>{if(!n.isSkinnedMesh)return;n.computeBoundingSphere();if(n.boundingBox)n.computeBoundingBox();}); const hits=this.ray.intersectObjects([...this.halls.values(),...this.units.values()].flatMap(i=>i.crew?[i.root,i.crew]:[i.root]),true); for(const hit of hits) { for(let node=hit.object;node;node=node.parent) if(node.userData.entity)return node.userData.entity; } return null; From fad539de6f3d78f101f680f6f2fd85e9af83767b Mon Sep 17 00:00:00 2001 From: oddessentials Date: Sun, 8 Nov 2026 23:45:52 -0500 Subject: [PATCH 4/4] fix: show pieces that need a newer Agent Guild in the chooser A piece whose format is newer than this release now appears in the character or companion chooser, unselectable, with "This piece needs a newer Agent Guild." instead of being hidden behind the empty-folder message. Such pieces are still refused. Co-Authored-By: Claude Opus 5.5 --- docs/api.md | 2 +- src/manager/folder-browser.mjs | 14 +++++++++++--- src/manager/yard-piece-check.mjs | 6 +++--- src/manager/yard-pieces.mjs | 2 +- tests/page-folders.test.mjs | 8 ++++++-- tests/yard-pieces.test.mjs | 6 ++++-- web/app.js | 5 +++-- web/styles.css | 1 + 8 files changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/api.md b/docs/api.md index caaeaff..ecd09f0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -638,7 +638,7 @@ All paths are under `/api/v1`. | GET | `/remote-access` | | `{ remoteAccess }`: `revision`, `mode`, `busy`, `pending`, `problem` and connection details. `url` is the active address. `candidate` is the address enabling would use with Tailscale's current settings, `{ url, existing, remembered, standardUrl? }`: `remembered` when it is the address used before, and `standardUrl` when `standard: true` would choose another. | | POST | `/remote-access/check` | | `{ remoteAccess }` after refreshing Tailscale's status. | | PUT | `/remote-access` | `{ revision, action, adopt?, hosts?, origins? }` | `202`. `action` is `enable` (`adopt: true` manages a matching existing route; `standard: true`, only while remote access is off, skips the remembered address and takes port 443, else the next free port), `disable`, `custom` (with `hosts` and `origins`) or `forget` (leave an inactive route awaiting cleanup in Tailscale). Poll until `busy` is null and read `problem`. 409 `stale_settings` for an old `revision`. Needs the API token. Revoked WebSockets close with code 4403; their terminals keep running. | -| GET | `/folders?path=&files=&pieces=` | | `{ path, parent, home, segments, roots, entries, truncated, note }`: the subfolders of `path` (blank or `~` for the home folder). `entries` are `{ name, path, hidden }`. A missing `path` lists the nearest folder above it and names the requested path in `note`. `files=agworld` adds `files`, the world files in that folder, each `{ name, path }`. `pieces=character` or `pieces=companion` adds `pieces`, the matching pieces in that folder and the folders directly inside it, each `{ title, folder, path }`, sorted by title, and `workshop`, the Steam Workshop folder of Agent Guild: World found from Steam's library records, or null. | +| GET | `/folders?path=&files=&pieces=` | | `{ path, parent, home, segments, roots, entries, truncated, note }`: the subfolders of `path` (blank or `~` for the home folder). `entries` are `{ name, path, hidden }`. A missing `path` lists the nearest folder above it and names the requested path in `note`. `files=agworld` adds `files`, the world files in that folder, each `{ name, path }`. `pieces=character` or `pieces=companion` adds `pieces`, the matching pieces in that folder and the folders directly inside it, each `{ title, folder, path }`, sorted by title, with `problem` when the piece needs a newer Agent Guild, and `workshop`, the Steam Workshop folder of Agent Guild: World found from Steam's library records, or null. | | POST | `/folders` | `{ path, name }` | 201 with the `GET /folders` listing of the new folder `name`, made inside `path`. | | GET | `/autostart` | | `{ autostart: { available, enabled, reason, note?, lastRun?, log? } }`: whether the manager starts when the user signs in. `reason` says why it is unavailable. `lastRun` is null or `{ at, outcome }`. On Linux outside WSL it also has `mode` (`off`, `sign-in`, `boot` or `both`) and `boot`, the systemd service's `{ available, enabled, reason, note, user, linger, state, commands }`. | | PUT | `/autostart` | `{ enabled }` or `{ mode }` | `{ autostart }` as above. `mode` (`off`, `sign-in` or `boot`) makes that the one way the manager starts; `boot` is offered only on Linux with systemd. | diff --git a/src/manager/folder-browser.mjs b/src/manager/folder-browser.mjs index 78b8247..299bd0d 100644 --- a/src/manager/folder-browser.mjs +++ b/src/manager/folder-browser.mjs @@ -5,7 +5,7 @@ import { readManifest } from './yard-pieces.mjs'; const failure = (status, code, message) => Object.assign(new Error(message), { status, code }); const PIECE_SCAN = 500; -const byName =(a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) || (a.name < b.name ? -1 : 1); +const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) || (a.name < b.name ? -1 : 1); export function folderSegments(dir, paths = path) { const root = paths.parse(dir).root; @@ -86,8 +86,16 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom try { const stat = await fsp.lstat(file); if (!stat.isFile() || stat.size > 16 * 1024) return null; - const { title } = readManifest(await fsp.readFile(file), kind); - return { title: title || paths.basename(dir), folder: paths.basename(dir), path: dir }; + const bytes = await fsp.readFile(file), folder = paths.basename(dir); + try { + const { title } = readManifest(bytes, kind); + return { title: title || folder, folder, path: dir }; + } catch (err) { + const meta = err.newer ? JSON.parse(bytes.toString('utf8')) : null; + if (meta?.kind !== kind) return null; + const title = typeof meta.title === 'string' && meta.title.length <= 128 && !/\p{Cc}/u.test(meta.title) ? meta.title.trim() : ''; + return { title: title || folder, folder, path: dir, problem: err.message }; + } } catch { return null; } diff --git a/src/manager/yard-piece-check.mjs b/src/manager/yard-piece-check.mjs index fd4cd5e..d823109 100644 --- a/src/manager/yard-piece-check.mjs +++ b/src/manager/yard-piece-check.mjs @@ -42,7 +42,7 @@ const QUANTIZED_FORMS = { TANGENT: [SIGNED.normalByte, SIGNED.normalShort], TEXCOORD: [SIGNED.byte, SIGNED.normalByte, UNSIGNED.byte, SIGNED.short, SIGNED.normalShort, UNSIGNED.short], }; -const TRACKS ={ translation: 'VEC3', rotation: 'VEC4', scale: 'VEC3' }; +const TRACKS = { translation: 'VEC3', rotation: 'VEC4', scale: 'VEC3' }; const METADATA = ['name', 'extras']; const PROPERTIES = { accessor: ['bufferView', 'byteOffset', 'componentType', 'normalized', 'count', 'type', 'max', 'min', 'sparse', ...METADATA], @@ -83,7 +83,7 @@ function decoder(type) { return decoders.get(type); } -const invalid = (message) =>Object.assign(new Error(message), { status: 400, code: 'piece_invalid' }); +const invalid = (message) => Object.assign(new Error(message), { status: 400, code: 'piece_invalid' }); export function imageSize(b) { if (b.length >= 24 && b.readUInt32BE(0) === 0x89504e47 && b.toString('latin1', 12, 16) === 'IHDR') return [b.readUInt32BE(16), b.readUInt32BE(20)]; @@ -166,7 +166,7 @@ function checkJson(json, name, limit, bin) { for (const sampler of Array.isArray(animation.samplers) ? animation.samplers : []) allowed('sampler', sampler); for (const channel of Array.isArray(animation.channels) ? animation.channels : []) { allowed('channel', channel); allowed('target', channel.target); } } - const missing =() => invalid(`${name} refers to data it doesn’t contain.`); + const missing = () => invalid(`${name} refers to data it doesn’t contain.`); const multiplied = () => invalid(`${name} reuses its data in a way that would multiply its size.`); const index = (value, list) => Number.isInteger(value) && value >= 0 && value < (Array.isArray(list) ? list.length : 0); const length = (value) => Number.isSafeInteger(value) && value >= 0; diff --git a/src/manager/yard-pieces.mjs b/src/manager/yard-pieces.mjs index bd6f999..dcde2b1 100644 --- a/src/manager/yard-pieces.mjs +++ b/src/manager/yard-pieces.mjs @@ -20,7 +20,7 @@ const invalid = (message) => failure(400, 'piece_invalid', message); function checkManifest(meta, kind) { if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw invalid('This piece’s details can’t be read.'); - if (meta.format !== 1) throw invalid(Number.isInteger(meta.format) && meta.format > 1 ? 'This piece needs a newer Agent Guild.' : 'This piece’s format is missing.'); + if (meta.format !== 1) throw Number.isInteger(meta.format) && meta.format > 1 ? Object.assign(invalid('This piece needs a newer Agent Guild.'), { newer: true }) : invalid('This piece’s format is missing.'); if (meta.kind !== 'character' && meta.kind !== 'companion') throw invalid('This isn’t a character or companion piece.'); if (meta.kind !== kind) throw invalid(kind === 'character' ? 'This is a companion piece, not a character.' : 'This is a character piece, not a companion.'); if (meta.title !== undefined && (typeof meta.title !== 'string' || meta.title.length > 128 || /\p{Cc}/u.test(meta.title))) throw invalid('This piece’s title is too long or unreadable.'); diff --git a/tests/page-folders.test.mjs b/tests/page-folders.test.mjs index 59f954b..25aad70 100644 --- a/tests/page-folders.test.mjs +++ b/tests/page-folders.test.mjs @@ -101,14 +101,18 @@ test('choosing opens at the field value and lists the folders there, hidden ones test('a first piece choice opens in World’s Workshop folder, later ones where the last choice worked', async () => { const workshop = '/steam/steamapps/workshop/content/5462970'; const home = { ...listing, path: '/home/me', workshop, pieces: [] }; - const there = { ...listing, path: workshop, entries: [], workshop, pieces: [{ title: 'Turkeys', folder: '3825906265', path: `${workshop}/3825906265` }] }; + const there = { ...listing, path: workshop, entries: [], workshop, pieces: [{ title: 'Turkeys', folder: '3825906265', path: `${workshop}/3825906265` }, { title: 'Future', folder: '3825906266', path: `${workshop}/3825906266`, problem: 'This piece needs a newer Agent Guild.' }] }; const first = page({ answers: [home, there, home] }); first.context.chooseFolder('companion', 'guild', 'companion'); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(first.requests.map(([, route]) => route), ['/folders?path=&pieces=companion', `/folders?path=${encodeURIComponent(workshop)}&pieces=companion`]); assert.equal(first.$('folder-current').textContent, workshop); assert.equal(first.$('folder-workshop').hidden, false); - assert.deepEqual(first.$('folder-list').children.map((row) => row.className), ['folder-row folder-piece']); + assert.deepEqual(first.$('folder-list').children.map((row) => row.className), ['folder-row folder-piece', 'folder-row folder-piece']); + const future = first.$('folder-list').children[1]; + assert.equal(future.attrs['aria-disabled'], 'true'); + assert.deepEqual(future.children.map((part) => part.textContent), ['Future', '3825906266 · This piece needs a newer Agent Guild.']); + assert.equal(first.$('folder-status').textContent, '', 'a newer piece is not reported as no pieces'); await first.context.browseTo('/home/me'); assert.equal(first.$('folder-current').textContent, '/home/me', 'the shortcut does not hold the browser there'); const later = page({ answers: [{ ...listing, workshop, pieces: [] }] }); diff --git a/tests/yard-pieces.test.mjs b/tests/yard-pieces.test.mjs index d84bc10..3c9e52e 100644 --- a/tests/yard-pieces.test.mjs +++ b/tests/yard-pieces.test.mjs @@ -472,12 +472,14 @@ test('the folder dialog lists matching pieces here and one level down, by title' folder({ name: 'workshop/3003', title: '' }); folder({ name: 'workshop/3004', kind: 'companion', title: 'Moths' }); folder({ name: 'workshop/3005/deeper', title: 'Too deep' }); + folder({ name: 'workshop/3006', manifest: JSON.stringify({ format: 2, kind: 'character', title: 'Future' }) }); + folder({ name: 'workshop/3007', manifest: JSON.stringify({ format: 2, kind: 'companion', title: 'Future moths' }) }); fs.writeFileSync(path.join(root, 'workshop', 'piece.json'), '{'); const browser = createFolderBrowser({ home: root, workshop: async () => library }); const listing = await browser.list(library, { pieces: 'character' }); assert.equal(listing.workshop, library); - assert.deepEqual(listing.pieces.map(({ title, folder: name }) => [title, name]), [['3003', '3003'], ['giant', '3001'], ['Giant', '3002']]); - assert.deepEqual((await browser.list(library, { pieces: 'companion' })).pieces.map((piece) => piece.title), ['Moths']); + assert.deepEqual(listing.pieces.map(({ title, folder: name, problem }) => [title, name, problem]), [['3003', '3003', undefined], ['Future', '3006', 'This piece needs a newer Agent Guild.'], ['giant', '3001', undefined], ['Giant', '3002', undefined]]); + assert.deepEqual((await browser.list(library, { pieces: 'companion' })).pieces.map((piece) => piece.title), ['Future moths', 'Moths']); assert.deepEqual((await browser.list(path.join(library, '3004'), { pieces: 'companion' })).pieces.map((piece) => piece.path), [path.join(library, '3004')]); assert.equal((await browser.list(library)).pieces, undefined); }); diff --git a/web/app.js b/web/app.js index e73c587..5bbdd17 100644 --- a/web/app.js +++ b/web/app.js @@ -1559,9 +1559,10 @@ function renderFolderBrowser() { row.title = file.path; return row; }), ...(listing?.pieces ?? []).filter((piece) => !query || piece.title.toLowerCase().includes(query) || piece.folder.toLowerCase().includes(query)).map((piece) => { - const row = button('', () => usePiece(piece.path), 'folder-row folder-piece'); + const row = button('', () => { if (!piece.problem) usePiece(piece.path); }, 'folder-row folder-piece'); + if (piece.problem) row.setAttribute('aria-disabled', 'true'); row.append(Object.assign(document.createElement('span'), { className: 'folder-piece-title', textContent: piece.title }), - Object.assign(document.createElement('span'), { className: 'folder-piece-sub', textContent: `${folderView.field === 'character' ? 'Character' : 'Companion'} · ${piece.folder}` })); + Object.assign(document.createElement('span'), { className: 'folder-piece-sub', textContent: piece.problem ? `${piece.folder} · ${piece.problem}` : `${folderView.field === 'character' ? 'Character' : 'Companion'} · ${piece.folder}` })); row.title = piece.path; return row; })); diff --git a/web/styles.css b/web/styles.css index a88c58f..c4eb00a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1410,6 +1410,7 @@ body:has(#terminal-panel:not([hidden]) .terminal-controls:not([hidden])) .toast .folder-row.hidden-folder { color: var(--muted); } .folder-row.folder-piece { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0 10px; } .folder-row.folder-piece::before { grid-row: span 2; width: 14px; height: 14px; border-radius: 50%; border-color: var(--accent); background: color-mix(in srgb, var(--accent) 25%, transparent); } +.folder-row.folder-piece[aria-disabled="true"] { cursor: default; background: none; color: var(--muted); } .folder-piece-title { font-weight: 600; } .folder-piece-sub { color: var(--muted); font-size: 0.8rem; } .yard-customize { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }