diff --git a/docs/api.md b/docs/api.md index f366407..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=` | | `{ 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, 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. | @@ -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..299bd0d 100644 --- a/src/manager/folder-browser.mjs +++ b/src/manager/folder-browser.mjs @@ -1,8 +1,10 @@ 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 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) { @@ -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,31 @@ 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 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; + } + } + + 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 +130,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 +140,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 +151,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..d823109 --- /dev/null +++ b/src/manager/yard-piece-check.mjs @@ -0,0 +1,424 @@ +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 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] }; +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, 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.`); + } + } + 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..dcde2b1 --- /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 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.'); + 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..0dfbe7f 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,557 @@ 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('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('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}); + 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 0000000..a5060ba Binary files /dev/null and b/tests/fixtures/pieces/character.glb differ diff --git a/tests/fixtures/pieces/companion.glb b/tests/fixtures/pieces/companion.glb new file mode 100644 index 0000000..a0a151e Binary files /dev/null and b/tests/fixtures/pieces/companion.glb differ diff --git a/tests/fixtures/pieces/external_uri.glb b/tests/fixtures/pieces/external_uri.glb new file mode 100644 index 0000000..0b82d36 Binary files /dev/null and b/tests/fixtures/pieces/external_uri.glb differ diff --git a/tests/fixtures/pieces/missing_bone.glb b/tests/fixtures/pieces/missing_bone.glb new file mode 100644 index 0000000..e2bf1a6 Binary files /dev/null and b/tests/fixtures/pieces/missing_bone.glb differ diff --git a/tests/fixtures/pieces/missing_clip.glb b/tests/fixtures/pieces/missing_clip.glb new file mode 100644 index 0000000..5805796 Binary files /dev/null and b/tests/fixtures/pieces/missing_clip.glb differ diff --git a/tests/fixtures/pieces/texture_over.glb b/tests/fixtures/pieces/texture_over.glb new file mode 100644 index 0000000..e5ba552 Binary files /dev/null and b/tests/fixtures/pieces/texture_over.glb differ diff --git a/tests/fixtures/pieces/truncated.glb b/tests/fixtures/pieces/truncated.glb new file mode 100644 index 0000000..d7bcec9 Binary files /dev/null and b/tests/fixtures/pieces/truncated.glb differ diff --git a/tests/page-folders.test.mjs b/tests/page-folders.test.mjs index d7fe758..25aad70 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,34 @@ 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` }, { 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', '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: [] }] }); + 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..3c9e52e --- /dev/null +++ b/tests/yard-pieces.test.mjs @@ -0,0 +1,520 @@ +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/], + ['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]; + 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 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')); + 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' }); + 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, 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); +}); + +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..5bbdd17 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,20 @@ 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('', () => { 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: piece.problem ? `${piece.folder} · ${piece.problem}` : `${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 +1581,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 +1601,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 +7936,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 +8086,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 +8131,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 +8877,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 +8970,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..c4eb00a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1408,6 +1408,20 @@ 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-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); } +.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..bfc9f8d 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,59 @@ 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]){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,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}); + }); + } + 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 +318,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,22 +677,24 @@ 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;}}); + 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. 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 +704,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 +1143,47 @@ 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); + 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); 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 +1191,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; } })); } @@ -1154,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; @@ -1341,7 +1450,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(); });