@oddessentials/agent-guild 1.28.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
- package/bin/native/win32-x64/agent-guild-report.exe +0 -0
- package/bin/windows/arm64/agent-guild-start.exe +0 -0
- package/bin/windows/x64/agent-guild-start.exe +0 -0
- package/docs/api.md +10 -2
- package/node_modules/@jsquash/jpeg/CHANGELOG.md +54 -0
- package/node_modules/@jsquash/jpeg/LICENSE +201 -0
- package/node_modules/@jsquash/jpeg/README.md +105 -0
- package/node_modules/@jsquash/jpeg/codec/LICENSE.codec.md +132 -0
- package/node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.d.ts +7 -0
- package/node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.js +15 -0
- package/node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm +0 -0
- package/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.d.ts +37 -0
- package/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.js +15 -0
- package/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm +0 -0
- package/node_modules/@jsquash/jpeg/codec/pre.js +24 -0
- package/node_modules/@jsquash/jpeg/decode.d.ts +16 -0
- package/node_modules/@jsquash/jpeg/decode.js +36 -0
- package/node_modules/@jsquash/jpeg/emscripten-types.d.ts +137 -0
- package/node_modules/@jsquash/jpeg/encode.d.ts +21 -0
- package/node_modules/@jsquash/jpeg/encode.js +35 -0
- package/node_modules/@jsquash/jpeg/index.d.ts +3 -0
- package/node_modules/@jsquash/jpeg/index.js +2 -0
- package/node_modules/@jsquash/jpeg/meta.d.ts +24 -0
- package/node_modules/@jsquash/jpeg/meta.js +25 -0
- package/node_modules/@jsquash/jpeg/package.json +32 -0
- package/node_modules/@jsquash/jpeg/utils.d.ts +17 -0
- package/node_modules/@jsquash/jpeg/utils.js +31 -0
- package/node_modules/@jsquash/png/CHANGELOG.md +69 -0
- package/node_modules/@jsquash/png/LICENSE +201 -0
- package/node_modules/@jsquash/png/README.md +114 -0
- package/node_modules/@jsquash/png/codec/LICENSE.codec.md +29 -0
- package/node_modules/@jsquash/png/codec/README.md +5 -0
- package/node_modules/@jsquash/png/codec/pkg/README.md +5 -0
- package/node_modules/@jsquash/png/codec/pkg/squoosh_png.d.ts +71 -0
- package/node_modules/@jsquash/png/codec/pkg/squoosh_png.js +314 -0
- package/node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm +0 -0
- package/node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm.d.ts +13 -0
- package/node_modules/@jsquash/png/codec/pre.js +24 -0
- package/node_modules/@jsquash/png/decode.d.ts +29 -0
- package/node_modules/@jsquash/png/decode.js +35 -0
- package/node_modules/@jsquash/png/encode.d.ts +31 -0
- package/node_modules/@jsquash/png/encode.js +40 -0
- package/node_modules/@jsquash/png/index.d.ts +3 -0
- package/node_modules/@jsquash/png/index.js +2 -0
- package/node_modules/@jsquash/png/meta.d.ts +16 -0
- package/node_modules/@jsquash/png/meta.js +15 -0
- package/node_modules/@jsquash/png/package.json +34 -0
- package/node_modules/@jsquash/webp/CHANGELOG.md +48 -0
- package/node_modules/@jsquash/webp/LICENSE +201 -0
- package/node_modules/@jsquash/webp/README.md +100 -0
- package/node_modules/@jsquash/webp/codec/LICENSE.codec.md +29 -0
- package/node_modules/@jsquash/webp/codec/Makefile.animated.backup +88 -0
- package/node_modules/@jsquash/webp/codec/README.md +5 -0
- package/node_modules/@jsquash/webp/codec/dec/README.md +18 -0
- package/node_modules/@jsquash/webp/codec/dec/webp_dec.d.ts +7 -0
- package/node_modules/@jsquash/webp/codec/dec/webp_dec.js +16 -0
- package/node_modules/@jsquash/webp/codec/dec/webp_dec.wasm +0 -0
- package/node_modules/@jsquash/webp/codec/enc/README.md +22 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc.d.ts +42 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc.js +16 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc.wasm +0 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc_simd.d.ts +1 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc_simd.js +16 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc_simd.wasm +0 -0
- package/node_modules/@jsquash/webp/codec/pre.js +24 -0
- package/node_modules/@jsquash/webp/decode.d.ts +15 -0
- package/node_modules/@jsquash/webp/decode.js +34 -0
- package/node_modules/@jsquash/webp/emscripten-types.d.ts +137 -0
- package/node_modules/@jsquash/webp/encode.d.ts +23 -0
- package/node_modules/@jsquash/webp/encode.js +43 -0
- package/node_modules/@jsquash/webp/index.d.ts +3 -0
- package/node_modules/@jsquash/webp/index.js +2 -0
- package/node_modules/@jsquash/webp/meta.d.ts +19 -0
- package/node_modules/@jsquash/webp/meta.js +33 -0
- package/node_modules/@jsquash/webp/package.json +35 -0
- package/node_modules/@jsquash/webp/utils.d.ts +17 -0
- package/node_modules/@jsquash/webp/utils.js +31 -0
- package/node_modules/argparse/lib/argparse.js +1 -1
- package/node_modules/argparse/lib/textwrap.js +13 -2
- package/node_modules/argparse/package.json +1 -1
- package/node_modules/markdown-it/dist/browser/markdown-it.esm.min.mjs +737 -686
- package/node_modules/markdown-it/dist/browser/markdown-it.esm.min.mjs.map +1 -1
- package/node_modules/markdown-it/dist/browser/markdown-it.umd.min.js +8 -8
- package/node_modules/markdown-it/dist/browser/markdown-it.umd.min.js.map +1 -1
- package/node_modules/markdown-it/dist/markdown-it.cjs.js +660 -518
- package/node_modules/markdown-it/dist/markdown-it.cjs.js.map +1 -1
- package/node_modules/markdown-it/dist/markdown-it.d.mts +1 -0
- package/node_modules/markdown-it/dist/markdown-it.mjs +179 -124
- package/node_modules/markdown-it/dist/markdown-it.mjs.map +1 -1
- package/node_modules/markdown-it/package.json +11 -11
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/node_modules/wasm-feature-detect/LICENSE +202 -0
- package/node_modules/wasm-feature-detect/README.md +109 -0
- package/node_modules/wasm-feature-detect/README.md.ejs +89 -0
- package/node_modules/wasm-feature-detect/dist/cjs/index.cjs +1 -0
- package/node_modules/wasm-feature-detect/dist/esm/index.js +1 -0
- package/node_modules/wasm-feature-detect/dist/index.d.cts +24 -0
- package/node_modules/wasm-feature-detect/dist/index.d.ts +24 -0
- package/node_modules/wasm-feature-detect/dist/umd/index.js +1 -0
- package/node_modules/wasm-feature-detect/package.json +53 -0
- package/node_modules/ws/README.md +21 -29
- package/node_modules/ws/lib/buffer-util.js +13 -2
- package/node_modules/ws/lib/sender.js +4 -1
- package/node_modules/ws/lib/websocket.js +8 -3
- package/node_modules/ws/package.json +1 -1
- package/package.json +7 -4
- package/src/manager/config.mjs +2 -0
- package/src/manager/folder-browser.mjs +39 -3
- package/src/manager/main.mjs +4 -0
- package/src/manager/server.mjs +32 -5
- package/src/manager/steam-workshop.mjs +64 -0
- package/src/manager/yard-art.mjs +1 -1
- package/src/manager/yard-piece-check.mjs +424 -0
- package/src/manager/yard-pieces.mjs +188 -0
- package/src/manager/yard-worlds.mjs +214 -0
- package/web/app.js +175 -11
- package/web/index.html +20 -2
- package/web/skins/grove/ui/companion-bird.webp +0 -0
- package/web/skins/guild/skin.css +2 -13
- package/web/styles.css +15 -1
- package/web/yard/art.json +1 -1
- package/web/yard/model.mjs +44 -2
- package/web/yard/photo.js +3 -4
- package/web/yard/renderer.js +150 -38
- package/web/yard/vendor/engine.js +500 -495
- package/web/yard/view.js +160 -14
- package/web/yard/yard.css +55 -12
- package/web/skins/grove/ui/install-now-dark.svg +0 -91
- package/web/skins/grove/ui/install-now-light.svg +0 -91
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import zlib from 'node:zlib';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import { WORLDS, CAMERA, SUN, STAGING } from '../../web/yard/model.mjs';
|
|
9
|
+
import { extractArchive, renameRetrying } from './yard-art.mjs';
|
|
10
|
+
|
|
11
|
+
const gunzip = promisify(zlib.gunzip);
|
|
12
|
+
const MANIFEST = 'world.json';
|
|
13
|
+
const INSTALL = 'install-id';
|
|
14
|
+
const FORMAT = 1;
|
|
15
|
+
const KIND = 'yard-environment';
|
|
16
|
+
const MAX_PACKAGE = 64 * 1024 * 1024;
|
|
17
|
+
const MAX_UNPACKED = 64 * 1024 * 1024;
|
|
18
|
+
const MAX_ENTRY = 16 * 1024 * 1024;
|
|
19
|
+
const MAX_RATIO = 20;
|
|
20
|
+
const RATIO_FROM = 8 * 1024 * 1024;
|
|
21
|
+
const MAX_SIDE = 4096;
|
|
22
|
+
const SKY_HEADER = '#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 256 +X 512\n';
|
|
23
|
+
const SKY_SIZE = SKY_HEADER.length + 512 * 256 * 4;
|
|
24
|
+
const THEMES = ['light', 'dark'];
|
|
25
|
+
const TILES = ['base-0-0', 'base-1-0', 'detail-0-0', 'detail-1-0', 'close-0-0'];
|
|
26
|
+
const REQUIRED = [...THEMES.flatMap((theme) => TILES.map((tile) => `web/${theme}-${tile}.webp`)), 'web/sky-light.hdr', 'web/sky-dark.hdr'];
|
|
27
|
+
const ALLOWED = new Set([...REQUIRED, 'preview.jpg']);
|
|
28
|
+
const SERVED = new Set(REQUIRED.map((name) => name.slice('web/'.length)));
|
|
29
|
+
|
|
30
|
+
const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
31
|
+
const failure = (status, code, message) => Object.assign(new Error(message), { status, code });
|
|
32
|
+
const invalid = (message) => failure(400, 'world_invalid', message);
|
|
33
|
+
|
|
34
|
+
export function environmentId() {
|
|
35
|
+
return sha256(JSON.stringify([CAMERA, SUN, STAGING.plaza])).slice(0, 16);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkManifest(meta) {
|
|
39
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw invalid('This world’s details can’t be read.');
|
|
40
|
+
if (!Number.isInteger(meta.format) || meta.format < 1) throw invalid('This world’s format is missing.');
|
|
41
|
+
if (meta.format > FORMAT) throw invalid('This world needs a newer Agent Guild.');
|
|
42
|
+
if (meta.kind !== KIND) throw invalid('This isn’t a Yard environment.');
|
|
43
|
+
const title = typeof meta.title === 'string' ? meta.title.trim() : '';
|
|
44
|
+
if (!title || title.length > 128 || /\p{Cc}/u.test(title)) throw invalid('This world’s title is missing or too long.');
|
|
45
|
+
if (meta.environment !== environmentId()) throw invalid('This world was made for a different Yard layout, so it wouldn’t line up.');
|
|
46
|
+
if (!meta.files || typeof meta.files !== 'object' || Array.isArray(meta.files)) throw invalid('This world’s list of files is missing.');
|
|
47
|
+
let total = 0;
|
|
48
|
+
for (const [name, file] of Object.entries(meta.files)) {
|
|
49
|
+
if (!ALLOWED.has(name)) throw invalid(`${name} doesn’t belong in a world file.`);
|
|
50
|
+
if (!Number.isSafeInteger(file?.size) || file.size < 0 || file.size > MAX_ENTRY || !/^[0-9a-f]{64}$/.test(file.sha256)) throw invalid(`${name} has invalid details.`);
|
|
51
|
+
total += file.size;
|
|
52
|
+
}
|
|
53
|
+
if (total > MAX_UNPACKED) throw invalid('This world unpacks to more than a world may.');
|
|
54
|
+
for (const name of REQUIRED) if (!Object.hasOwn(meta.files, name)) throw invalid(`${name} is missing.`);
|
|
55
|
+
if (meta.lights !== undefined) {
|
|
56
|
+
const colour = (value) => Number.isInteger(value) && value >= 0 && value <= 0xffffff;
|
|
57
|
+
if (!THEMES.every((theme) => colour(meta.lights?.[theme]?.sun) && colour(meta.lights?.[theme]?.hemi))) throw invalid('This world’s light colours aren’t valid.');
|
|
58
|
+
}
|
|
59
|
+
return { title, lights: meta.lights ?? null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function webpSize(b) {
|
|
63
|
+
const chunk = b.toString('latin1', 12, 16);
|
|
64
|
+
if (chunk === 'VP8 ' && b.length >= 30) return [b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff];
|
|
65
|
+
if (chunk === 'VP8L' && b.length >= 25) {
|
|
66
|
+
const v = b.readUInt32LE(21);
|
|
67
|
+
return [(v & 0x3fff) + 1, ((v >>> 14) & 0x3fff) + 1];
|
|
68
|
+
}
|
|
69
|
+
if (chunk === 'VP8X' && b.length >= 30) return [b.readUIntLE(24, 3) + 1, b.readUIntLE(27, 3) + 1];
|
|
70
|
+
return [0, 0];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkContent(name, b) {
|
|
74
|
+
if (name.endsWith('.webp')) {
|
|
75
|
+
if (b.toString('latin1', 0, 4) !== 'RIFF' || b.toString('latin1', 8, 12) !== 'WEBP') throw invalid(`${name} isn’t a WebP image.`);
|
|
76
|
+
if (webpSize(b).some((side) => side <= 0 || side > MAX_SIDE)) throw invalid(`${name} has an unsafe image size.`);
|
|
77
|
+
} else if (name.endsWith('.hdr')) {
|
|
78
|
+
if (b.length !== SKY_SIZE || b.toString('latin1', 0, SKY_HEADER.length) !== SKY_HEADER) throw invalid(`${name} isn’t a sky image.`);
|
|
79
|
+
} else if (b[0] !== 0xff || b[1] !== 0xd8 || b[2] !== 0xff) throw invalid(`${name} isn’t a JPEG image.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readPackage(file) {
|
|
83
|
+
if (typeof file !== 'string' || !path.isAbsolute(file) || file.includes('\0') || !file.toLowerCase().endsWith('.agworld')) throw invalid('Choose an .agworld file.');
|
|
84
|
+
let raw;
|
|
85
|
+
try {
|
|
86
|
+
const stat = await fsp.stat(file);
|
|
87
|
+
if (!stat.isFile()) throw invalid('Choose an .agworld file.');
|
|
88
|
+
if (stat.size > MAX_PACKAGE) throw invalid('This file is larger than a world can be.');
|
|
89
|
+
raw = await fsp.readFile(file);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (err.status) throw err;
|
|
92
|
+
throw invalid('This file can’t be read.');
|
|
93
|
+
}
|
|
94
|
+
let tar;
|
|
95
|
+
try { tar = await gunzip(raw, { maxOutputLength: MAX_UNPACKED + 1 }); } catch (err) {
|
|
96
|
+
throw invalid(err.code === 'ERR_BUFFER_TOO_LARGE' ? 'This world unpacks to more than a world may.' : 'This isn’t a world file.');
|
|
97
|
+
}
|
|
98
|
+
if (tar.length > MAX_UNPACKED) throw invalid('This world unpacks to more than a world may.');
|
|
99
|
+
if (tar.length > RATIO_FROM && tar.length / raw.length > MAX_RATIO) throw invalid('This file expands far more than a world should.');
|
|
100
|
+
const name = tar.toString('utf8', 0, 100).replace(/\0.*$/s, '');
|
|
101
|
+
const size = parseInt(tar.toString('latin1', 124, 136).replace(/\0.*$/s, '').trim(), 8);
|
|
102
|
+
const type = tar[156] === 0 ? '0' : String.fromCharCode(tar[156]);
|
|
103
|
+
if (name !== MANIFEST || type !== '0' || !Number.isSafeInteger(size) || size > MAX_ENTRY || 512 + size > tar.length) throw invalid('This isn’t a world file.');
|
|
104
|
+
const bytes = tar.subarray(512, 512 + size);
|
|
105
|
+
let meta;
|
|
106
|
+
try { meta = JSON.parse(bytes.toString('utf8')); } catch { throw invalid('This world’s details can’t be read.'); }
|
|
107
|
+
checkManifest(meta);
|
|
108
|
+
const files = [{ path: MANIFEST, size, sha256: sha256(bytes) }, ...Object.entries(meta.files).map(([entry, details]) => ({ path: entry, size: details.size, sha256: details.sha256 }))];
|
|
109
|
+
return { files, meta };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class YardWorlds extends EventEmitter {
|
|
113
|
+
constructor({ dir = null, remove = (folder) => fsp.rm(folder, { recursive: true, force: true, maxRetries: 5 }) } = {}) {
|
|
114
|
+
super();
|
|
115
|
+
this.dir = dir;
|
|
116
|
+
this.worlds = {};
|
|
117
|
+
this.busy = false;
|
|
118
|
+
this.remove = remove;
|
|
119
|
+
this.swept = Promise.resolve();
|
|
120
|
+
if (!dir) return;
|
|
121
|
+
for (const skin of Object.keys(WORLDS)) this.read(skin);
|
|
122
|
+
this.swept = this.sweep();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
snapshot() { return this.worlds; }
|
|
126
|
+
|
|
127
|
+
discard(folder) {
|
|
128
|
+
return this.remove(folder).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async sweep() {
|
|
132
|
+
const names = await fsp.readdir(this.dir).catch(() => []);
|
|
133
|
+
await Promise.all(names.filter((name) => name.startsWith('.')).map((name) => this.discard(path.join(this.dir, name))));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
aside(skin, kind) {
|
|
137
|
+
return path.join(this.dir, `.${skin}.${crypto.randomUUID()}.${kind}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
file(skin, name) {
|
|
141
|
+
return this.dir && Object.hasOwn(WORLDS, skin) && SERVED.has(name) ? path.join(this.dir, skin, 'web', name) : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
read(skin) {
|
|
145
|
+
const folder = path.join(this.dir, skin);
|
|
146
|
+
if (!fs.existsSync(folder)) { delete this.worlds[skin]; return; }
|
|
147
|
+
let title;
|
|
148
|
+
try {
|
|
149
|
+
const bytes = fs.readFileSync(path.join(folder, MANIFEST));
|
|
150
|
+
const meta = JSON.parse(bytes.toString('utf8'));
|
|
151
|
+
const checked = checkManifest(meta);
|
|
152
|
+
title = checked.title;
|
|
153
|
+
for (const [name, details] of Object.entries(meta.files)) {
|
|
154
|
+
let content;
|
|
155
|
+
try { content = fs.readFileSync(path.join(folder, ...name.split('/'))); } catch { content = null; }
|
|
156
|
+
if (!content || content.length !== details.size || sha256(content) !== details.sha256) throw invalid(`${title} is damaged: ${name} is missing or changed.`);
|
|
157
|
+
}
|
|
158
|
+
const install = fs.readFileSync(path.join(folder, INSTALL));
|
|
159
|
+
this.worlds[skin] = { title, revision: sha256(Buffer.concat([bytes, install])).slice(0, 16), lights: checked.lights };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
this.worlds[skin] = { title: title ?? null, problem: err.status ? err.message : `${title ?? 'This Yard’s world'} is damaged.` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async exclusive(skin, work) {
|
|
166
|
+
if (!Object.hasOwn(WORLDS, skin)) throw failure(404, 'unknown_yard', 'There is no such Yard.');
|
|
167
|
+
if (!this.dir) throw failure(409, 'worlds_unsupported', 'This manager has no data folder to keep worlds in.');
|
|
168
|
+
if (this.busy) throw failure(409, 'world_busy', 'Another world is being installed. Try again when it finishes.');
|
|
169
|
+
this.busy = true;
|
|
170
|
+
const before = JSON.stringify(this.worlds[skin] ?? null);
|
|
171
|
+
try {
|
|
172
|
+
await work();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err.status) throw err;
|
|
175
|
+
throw failure(500, 'world_unwritable', `Agent Guild cannot write to its data folder: ${err.message}`);
|
|
176
|
+
} finally {
|
|
177
|
+
this.read(skin);
|
|
178
|
+
this.busy = false;
|
|
179
|
+
if (JSON.stringify(this.worlds[skin] ?? null) !== before) this.emit('updated', this.snapshot());
|
|
180
|
+
}
|
|
181
|
+
return this.snapshot();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
replace(skin, file) {
|
|
185
|
+
return this.exclusive(skin, async () => {
|
|
186
|
+
const { files } = await readPackage(file);
|
|
187
|
+
const target = path.join(this.dir, skin), staging = this.aside(skin, 'staging'), previous = this.aside(skin, 'old');
|
|
188
|
+
await fsp.mkdir(this.dir, { recursive: true });
|
|
189
|
+
try {
|
|
190
|
+
try { await extractArchive(file, files, staging); } catch (err) {
|
|
191
|
+
if (['ENOSPC', 'EACCES', 'EPERM', 'EBUSY', 'EROFS'].includes(err.code)) throw err;
|
|
192
|
+
throw invalid(`This world file is damaged: ${err.message}.`);
|
|
193
|
+
}
|
|
194
|
+
for (const { path: name } of files) if (name !== MANIFEST) checkContent(name, await fsp.readFile(path.join(staging, ...name.split('/'))));
|
|
195
|
+
await fsp.writeFile(path.join(staging, INSTALL), crypto.randomUUID());
|
|
196
|
+
const had = fs.existsSync(target);
|
|
197
|
+
if (had) await renameRetrying(target, previous);
|
|
198
|
+
try { await renameRetrying(staging, target); } catch (err) {
|
|
199
|
+
if (had) await renameRetrying(previous, target);
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
} finally { await this.discard(staging); }
|
|
203
|
+
await this.sweep();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
restore(skin) {
|
|
208
|
+
return this.exclusive(skin, async () => {
|
|
209
|
+
const target = path.join(this.dir, skin);
|
|
210
|
+
if (fs.existsSync(target)) await renameRetrying(target, this.aside(skin, 'old'));
|
|
211
|
+
await this.sweep();
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
package/web/app.js
CHANGED
|
@@ -202,7 +202,7 @@ function holdToast(held) {
|
|
|
202
202
|
const firstClick = (run) => (e) => { if (e.detail < 2) run(); };
|
|
203
203
|
|
|
204
204
|
/** Buttons that act through the session manager. While it cannot be reached they are unavailable rather than left to fail. */
|
|
205
|
-
const MANAGER_ACTIONS = '.provider :is(.new, .existing, .install, .update), .session-card :is(.resume, .rename, .stop, .remove), .history-resume, #panel-stop, #yard-install';
|
|
205
|
+
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';
|
|
206
206
|
|
|
207
207
|
function paintManagerActions(root = document) {
|
|
208
208
|
for (const button of root.querySelectorAll(MANAGER_ACTIONS)) button.disabled = !state.connected;
|
|
@@ -595,9 +595,12 @@ function managerGone() {
|
|
|
595
595
|
function placeMenu(menu, button) {
|
|
596
596
|
if (!menu.matches(':popover-open') || !button) return;
|
|
597
597
|
const box = button.getBoundingClientRect();
|
|
598
|
-
|
|
598
|
+
menu.style.maxHeight = '';
|
|
599
|
+
const height = menu.offsetHeight, below = Math.round(box.bottom + 6), above = Math.round(box.top - 6), room = innerHeight - below - 8;
|
|
600
|
+
const flip = height > room && above - 8 > room;
|
|
601
|
+
const top = flip ? Math.max(8, above - height) : below;
|
|
599
602
|
menu.style.top = `${top}px`;
|
|
600
|
-
menu.style.maxHeight = `${
|
|
603
|
+
menu.style.maxHeight = `${flip ? above - top : room}px`;
|
|
601
604
|
menu.style.left = 'auto';
|
|
602
605
|
menu.style.right = `${Math.max(8, Math.round(innerWidth - box.right))}px`;
|
|
603
606
|
if (menu.getBoundingClientRect().left < 8) {
|
|
@@ -1431,13 +1434,20 @@ function recentKeys(field, e) {
|
|
|
1431
1434
|
const FOLDER_FIELDS = {
|
|
1432
1435
|
cwd: { title: 'Choose working folder', input: 'cwd' },
|
|
1433
1436
|
clone: { title: 'Choose clone folder', input: 'github-parent' },
|
|
1437
|
+
world: { title: 'Choose a world file', input: null },
|
|
1438
|
+
character: { title: 'Choose a character', input: null },
|
|
1439
|
+
companion: { title: 'Choose a companion', input: null },
|
|
1434
1440
|
};
|
|
1441
|
+
const WORLD_FOLDER_KEY = 'agentGuild.worldFolder';
|
|
1442
|
+
const PIECE_FOLDER_KEY = 'agentGuild.pieceFolder';
|
|
1443
|
+
const PIECE_FIELDS = ['character', 'companion'];
|
|
1435
1444
|
const folderView = { field: null, listing: null, loading: false, error: null, request: 0, opener: null };
|
|
1436
1445
|
|
|
1437
|
-
function chooseFolder(field) {
|
|
1446
|
+
function chooseFolder(field, skin = null, slot = null) {
|
|
1438
1447
|
if (!state.connected) return;
|
|
1439
1448
|
const dialog = $('folder-browser');
|
|
1440
|
-
Object.assign(folderView, { field, listing: null, loading: false, error: null });
|
|
1449
|
+
Object.assign(folderView, { field, skin, slot, listing: null, loading: false, error: null, toWorkshop: PIECE_FIELDS.includes(field) && !load(PIECE_FOLDER_KEY) });
|
|
1450
|
+
$('folder-use').hidden = $('folder-new-open').hidden = field === 'world' || PIECE_FIELDS.includes(field);
|
|
1441
1451
|
$('folder-title').textContent = FOLDER_FIELDS[field].title;
|
|
1442
1452
|
$('folder-filter').value = '';
|
|
1443
1453
|
$('folder-hidden').checked = load(HIDDEN_FOLDERS_KEY) === '1';
|
|
@@ -1445,7 +1455,7 @@ function chooseFolder(field) {
|
|
|
1445
1455
|
folderView.opener = document.activeElement;
|
|
1446
1456
|
dialog.showModal();
|
|
1447
1457
|
}
|
|
1448
|
-
browseTo($(FOLDER_FIELDS[field].input).value.trim());
|
|
1458
|
+
browseTo(field === 'world' ? load(WORLD_FOLDER_KEY) ?? '' : PIECE_FIELDS.includes(field) ? load(PIECE_FOLDER_KEY) ?? '' : $(FOLDER_FIELDS[field].input).value.trim());
|
|
1449
1459
|
}
|
|
1450
1460
|
|
|
1451
1461
|
function showNewFolder(open) {
|
|
@@ -1499,8 +1509,13 @@ async function browseTo(dir) {
|
|
|
1499
1509
|
folderView.error = null;
|
|
1500
1510
|
renderFolderBrowser();
|
|
1501
1511
|
try {
|
|
1502
|
-
const listing = await api('GET', `/folders?path=${encodeURIComponent(dir)}`);
|
|
1512
|
+
const listing = await api('GET', `/folders?path=${encodeURIComponent(dir)}${folderView.field === 'world' ? '&files=agworld' : PIECE_FIELDS.includes(folderView.field) ? `&pieces=${folderView.field}` : ''}`);
|
|
1503
1513
|
if (request !== folderView.request) return;
|
|
1514
|
+
if (folderView.toWorkshop && listing.workshop && listing.path !== listing.workshop) {
|
|
1515
|
+
folderView.toWorkshop = false;
|
|
1516
|
+
return browseTo(listing.workshop);
|
|
1517
|
+
}
|
|
1518
|
+
folderView.toWorkshop = false;
|
|
1504
1519
|
folderView.listing = listing;
|
|
1505
1520
|
$('folder-filter').value = '';
|
|
1506
1521
|
} catch (err) {
|
|
@@ -1528,6 +1543,8 @@ function renderFolderBrowser() {
|
|
|
1528
1543
|
$('folder-roots').replaceChildren(...(listing?.roots ?? []).map((root) => button(root.name, () => browseTo(root.path), 'btn folder-chip')));
|
|
1529
1544
|
$('folder-up').disabled = loading || !listing?.parent;
|
|
1530
1545
|
$('folder-home').disabled = loading;
|
|
1546
|
+
$('folder-workshop').hidden = !listing?.workshop;
|
|
1547
|
+
$('folder-workshop').disabled = loading;
|
|
1531
1548
|
const note = $('folder-note');
|
|
1532
1549
|
note.hidden = !listing?.note;
|
|
1533
1550
|
note.textContent = listing?.note ? `${listing.note} was not found, so this shows the nearest folder that exists.` : '';
|
|
@@ -1537,12 +1554,25 @@ function renderFolderBrowser() {
|
|
|
1537
1554
|
row.classList.toggle('hidden-folder', entry.hidden);
|
|
1538
1555
|
row.title = entry.path;
|
|
1539
1556
|
return row;
|
|
1557
|
+
}), ...(listing?.files ?? []).filter((file) => !query || file.name.toLowerCase().includes(query)).map((file) => {
|
|
1558
|
+
const row = button(file.name, () => useWorldFile(file.path), 'folder-row folder-file');
|
|
1559
|
+
row.title = file.path;
|
|
1560
|
+
return row;
|
|
1561
|
+
}), ...(listing?.pieces ?? []).filter((piece) => !query || piece.title.toLowerCase().includes(query) || piece.folder.toLowerCase().includes(query)).map((piece) => {
|
|
1562
|
+
const row = button('', () => { if (!piece.problem) usePiece(piece.path); }, 'folder-row folder-piece');
|
|
1563
|
+
if (piece.problem) row.setAttribute('aria-disabled', 'true');
|
|
1564
|
+
row.append(Object.assign(document.createElement('span'), { className: 'folder-piece-title', textContent: piece.title }),
|
|
1565
|
+
Object.assign(document.createElement('span'), { className: 'folder-piece-sub', textContent: piece.problem ? `${piece.folder} · ${piece.problem}` : `${folderView.field === 'character' ? 'Character' : 'Companion'} · ${piece.folder}` }));
|
|
1566
|
+
row.title = piece.path;
|
|
1567
|
+
return row;
|
|
1540
1568
|
}));
|
|
1541
1569
|
const status = $('folder-status');
|
|
1542
1570
|
status.textContent = loading ? 'Loading folders…'
|
|
1543
1571
|
: error ? error
|
|
1544
1572
|
: !listing ? ''
|
|
1545
|
-
:
|
|
1573
|
+
: listing.files?.length || listing.pieces?.length ? ''
|
|
1574
|
+
: 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.`
|
|
1575
|
+
: !entries.length ? (folderView.field === 'world' ? 'No world files here.' : listing.entries.length ? 'No folders match.' : 'No folders here.')
|
|
1546
1576
|
: listing.truncated ? `Showing the first ${listing.entries.length.toLocaleString()} folders.` : '';
|
|
1547
1577
|
status.classList.toggle('error', Boolean(error));
|
|
1548
1578
|
$('folder-current').textContent = listing?.path ?? '';
|
|
@@ -1551,6 +1581,35 @@ function renderFolderBrowser() {
|
|
|
1551
1581
|
$('folder-new-create').disabled = loading || !listing || !$('folder-new-name').value.trim();
|
|
1552
1582
|
}
|
|
1553
1583
|
|
|
1584
|
+
async function yardChange(request, row = true) {
|
|
1585
|
+
state.yardBusy = row;
|
|
1586
|
+
notifyViews();
|
|
1587
|
+
try {
|
|
1588
|
+
await request();
|
|
1589
|
+
return true;
|
|
1590
|
+
} catch (err) {
|
|
1591
|
+
if (err instanceof AuthError) showAuth(err.message);
|
|
1592
|
+
else toast(err.message, 8000);
|
|
1593
|
+
return false;
|
|
1594
|
+
} finally {
|
|
1595
|
+
state.yardBusy = false;
|
|
1596
|
+
notifyViews();
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function useWorldFile(file) {
|
|
1601
|
+
const { skin, listing } = folderView;
|
|
1602
|
+
$('folder-browser').close();
|
|
1603
|
+
save(WORLD_FOLDER_KEY, listing.path);
|
|
1604
|
+
return yardChange(() => api('PUT', `/yard-worlds/${skin}`, { path: file }), `${skin}/world`);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
async function usePiece(folder) {
|
|
1608
|
+
const { skin, slot, listing } = folderView;
|
|
1609
|
+
$('folder-browser').close();
|
|
1610
|
+
if (await yardChange(() => api('PUT', `/yard-pieces/${skin}/${slot}`, { path: folder }), `${skin}/${slot}`)) save(PIECE_FOLDER_KEY, listing.path);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1554
1613
|
function folderBrowserClosed() {
|
|
1555
1614
|
if (!$('folder-browser').open && folderView.opener?.isConnected) folderView.opener.focus();
|
|
1556
1615
|
}
|
|
@@ -2355,6 +2414,85 @@ async function changeMcp(provider, entry, view) {
|
|
|
2355
2414
|
|
|
2356
2415
|
let dealt = false;
|
|
2357
2416
|
|
|
2417
|
+
const BREAK_CARD = 'terminal-ai-chat';
|
|
2418
|
+
const BREAK_LABEL = 'Need a break?';
|
|
2419
|
+
const BREAK_SOURCES = ['yard-play-arena', 'yard-play-more', 'yard-play-solo', 'yard-install', 'yard-play-builders'];
|
|
2420
|
+
let breakMenus = 0, breakPointer = '', breakArmed = null;
|
|
2421
|
+
|
|
2422
|
+
function paintBreak(root = document) {
|
|
2423
|
+
for (const section of root.querySelectorAll('.break-buttons:not([hidden])')) {
|
|
2424
|
+
section.querySelector('.break-label').textContent = breakArmed ? `${breakArmed}. Tap again to open.` : BREAK_LABEL;
|
|
2425
|
+
for (const link of section.querySelectorAll('.yard-play-chip > .yard-play-link')) {
|
|
2426
|
+
link.closest('.yard-play').classList.toggle('armed', link.getAttribute('aria-label') === breakArmed);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function armBreak(name) {
|
|
2432
|
+
breakArmed = name;
|
|
2433
|
+
paintBreak();
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// On the window, ahead of the host's link handler on the document.
|
|
2437
|
+
window.addEventListener('pointerdown', (e) => {
|
|
2438
|
+
breakPointer = e.pointerType;
|
|
2439
|
+
if (breakArmed && e.target.closest?.('.armed .yard-play-link')?.getAttribute('aria-label') !== breakArmed) armBreak(null);
|
|
2440
|
+
}, true);
|
|
2441
|
+
window.addEventListener('click', (e) => {
|
|
2442
|
+
const touched = breakPointer === 'touch';
|
|
2443
|
+
breakPointer = '';
|
|
2444
|
+
const name = e.target.closest?.('.break-row .yard-play-chip > .yard-play-link')?.getAttribute('aria-label');
|
|
2445
|
+
if (!name || !touched || breakArmed === name) {
|
|
2446
|
+
if (breakArmed) armBreak(null);
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
e.preventDefault();
|
|
2450
|
+
e.stopPropagation();
|
|
2451
|
+
armBreak(name);
|
|
2452
|
+
}, true);
|
|
2453
|
+
|
|
2454
|
+
function breakItem(id) {
|
|
2455
|
+
const source = $(id).closest('.yard-play');
|
|
2456
|
+
const item = source.cloneNode(true);
|
|
2457
|
+
item.querySelector('.yard-play-burst').remove();
|
|
2458
|
+
for (const part of item.querySelectorAll('.yard-play-text, .yard-play-chev')) part.remove();
|
|
2459
|
+
for (const part of item.querySelectorAll('[id]')) part.removeAttribute('id');
|
|
2460
|
+
const menu = item.querySelector('.yard-play-menu');
|
|
2461
|
+
if (!menu) return item;
|
|
2462
|
+
item.querySelector('.yard-play-chip > .yard-play-link').remove();
|
|
2463
|
+
const more = item.querySelector('.yard-play-more');
|
|
2464
|
+
menu.id = `break-play-menu-${++breakMenus}`;
|
|
2465
|
+
more.setAttribute('popovertarget', menu.id);
|
|
2466
|
+
onMenu(menu, () => {
|
|
2467
|
+
placeMenu(menu, more);
|
|
2468
|
+
menuItems(menu)[0]?.focus();
|
|
2469
|
+
}, () => returnFocus(menu, more));
|
|
2470
|
+
menu.addEventListener('click', (e) => { if (e.target.closest('a')) closeMenu(menu); });
|
|
2471
|
+
menu.addEventListener('keydown', moveInMenu);
|
|
2472
|
+
return item;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
function placeBreakMenus() {
|
|
2476
|
+
for (const menu of document.querySelectorAll('.break-row .yard-play-menu:popover-open')) placeMenu(menu, menu.closest('.yard-play').querySelector('.yard-play-more'));
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
function mountBreakButtons(card) {
|
|
2480
|
+
const section = card.querySelector('.break-buttons'), row = section.querySelector('.break-row');
|
|
2481
|
+
for (const id of BREAK_SOURCES) {
|
|
2482
|
+
if (id === 'yard-install' && $(id).hidden) continue;
|
|
2483
|
+
const item = breakItem(id);
|
|
2484
|
+
if (id === 'yard-install') {
|
|
2485
|
+
const button = item.querySelector('.yard-play-link');
|
|
2486
|
+
button.hidden = false;
|
|
2487
|
+
button.classList.add('break-install');
|
|
2488
|
+
button.addEventListener('click', installCompanion);
|
|
2489
|
+
}
|
|
2490
|
+
row.append(item);
|
|
2491
|
+
}
|
|
2492
|
+
section.hidden = false;
|
|
2493
|
+
paintBreak(card);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2358
2496
|
// Both layouts mount these same controls with these same command handlers.
|
|
2359
2497
|
function buildProvider(provider) {
|
|
2360
2498
|
const node = $('provider-template').content.firstElementChild.cloneNode(true);
|
|
@@ -2367,6 +2505,7 @@ function buildProvider(provider) {
|
|
|
2367
2505
|
stateLine.classList.toggle('update-available', provider.updateAvailable || checkFailed || Boolean(installNote(provider)));
|
|
2368
2506
|
stateLine.title = [provider.resolvedPath, provider.updateCommand && `Update: ${provider.updateCommand}`].filter(Boolean).join('\n');
|
|
2369
2507
|
node.dataset.id = provider.id;
|
|
2508
|
+
if (provider.id === BREAK_CARD && document.documentElement.dataset.yard !== 'none') mountBreakButtons(node);
|
|
2370
2509
|
node.classList.toggle('unavailable', !provider.available);
|
|
2371
2510
|
node.setAttribute('aria-label', `${provider.vendor} ${provider.tool}, ${!provider.available ? 'not installed' : checkFailed ? 'version check failed' : 'ready'}`);
|
|
2372
2511
|
const start = node.querySelector('.new');
|
|
@@ -7797,6 +7936,7 @@ function closeDialogs() {
|
|
|
7797
7936
|
closeFlags();
|
|
7798
7937
|
closeMemory();
|
|
7799
7938
|
if ($('folder-browser').open) $('folder-browser').close();
|
|
7939
|
+
if ($('yard-customize').open) $('yard-customize').close();
|
|
7800
7940
|
yardUi?.closePhoto();
|
|
7801
7941
|
// An open question is answered as cancelled.
|
|
7802
7942
|
if ($('ask').open) $('ask').close();
|
|
@@ -7944,6 +8084,9 @@ function connectEvents() {
|
|
|
7944
8084
|
state.folderOpener = msg.folderOpener || null;
|
|
7945
8085
|
state.platform = msg.platform || null;
|
|
7946
8086
|
$('yard-install').hidden = !['win32', 'darwin'].includes(state.platform);
|
|
8087
|
+
$('yard-world-get').hidden = state.platform !== 'win32';
|
|
8088
|
+
state.yardWorlds = msg.yardWorlds ?? null;
|
|
8089
|
+
state.yardPieces = msg.yardPieces ?? null;
|
|
7947
8090
|
state.remoteAccessUI?.setAvailable(msg.remoteAccess);
|
|
7948
8091
|
state.remoteAccessUI?.signInsUpdated();
|
|
7949
8092
|
state.glassesLinkUI?.setAvailable(msg.remoteAccess);
|
|
@@ -7985,6 +8128,12 @@ function connectEvents() {
|
|
|
7985
8128
|
} else if (msg.type === 'yard-art.updated') {
|
|
7986
8129
|
state.yardArt = msg.yardArt;
|
|
7987
8130
|
notifyViews();
|
|
8131
|
+
} else if (msg.type === 'yard-worlds.updated') {
|
|
8132
|
+
state.yardWorlds = msg.yardWorlds;
|
|
8133
|
+
notifyViews();
|
|
8134
|
+
} else if (msg.type === 'yard-pieces.updated') {
|
|
8135
|
+
state.yardPieces = msg.yardPieces;
|
|
8136
|
+
notifyViews();
|
|
7988
8137
|
} else if (msg.type === 'yard.fireworks') {
|
|
7989
8138
|
yardUi?.celebrate();
|
|
7990
8139
|
} else if (msg.type === 'news.updated') {
|
|
@@ -8605,7 +8754,8 @@ onMenu($('yard-play-menu'), (menu) => {
|
|
|
8605
8754
|
$('yard-play-menu').addEventListener('click', (e) => {
|
|
8606
8755
|
if (e.target.closest('a')) closeMenu(e.currentTarget);
|
|
8607
8756
|
});
|
|
8608
|
-
$('yard-install').addEventListener('click',
|
|
8757
|
+
$('yard-install').addEventListener('click', installCompanion);
|
|
8758
|
+
async function installCompanion(e) {
|
|
8609
8759
|
const button = e.currentTarget;
|
|
8610
8760
|
button.disabled = true;
|
|
8611
8761
|
try {
|
|
@@ -8618,9 +8768,12 @@ $('yard-install').addEventListener('click', async (e) => {
|
|
|
8618
8768
|
} finally {
|
|
8619
8769
|
button.disabled = !state.connected;
|
|
8620
8770
|
}
|
|
8621
|
-
}
|
|
8771
|
+
}
|
|
8622
8772
|
new MutationObserver(() => closeMenu($('yard-play-menu'))).observe(document.documentElement, { attributes: true, attributeFilter: ['data-view', 'data-skin', 'data-layout'] });
|
|
8623
|
-
window.addEventListener('scroll', () =>
|
|
8773
|
+
window.addEventListener('scroll', () => {
|
|
8774
|
+
placeMenu($('yard-play-menu'), $('yard-play-more'));
|
|
8775
|
+
placeBreakMenus();
|
|
8776
|
+
}, { passive: true });
|
|
8624
8777
|
for (const menu of [$('manager-menu'), $('panel-sessions'), $('yard-play-menu')]) menu.addEventListener('keydown', moveInMenu);
|
|
8625
8778
|
// Recorded only on the click that opens the menu: a click that closes it leaves what the open menu showed.
|
|
8626
8779
|
const sessionMenuFrom = (mode, invoker) => () => {
|
|
@@ -8641,6 +8794,7 @@ window.addEventListener('resize', () => {
|
|
|
8641
8794
|
placeMenu($('panel-text-size'), $('panel-text-toggle'));
|
|
8642
8795
|
placeMenu($('cards-menu'), $('cards-open'));
|
|
8643
8796
|
placeMenu($('yard-play-menu'), $('yard-play-more'));
|
|
8797
|
+
placeBreakMenus();
|
|
8644
8798
|
});
|
|
8645
8799
|
onMenu($('cards-menu'), (menu) => {
|
|
8646
8800
|
placeMenu(menu, $('cards-open'));
|
|
@@ -8723,6 +8877,7 @@ $('folder-browser').addEventListener('keydown', folderBrowserKeys);
|
|
|
8723
8877
|
$('folder-list').addEventListener('keydown', moveInFolders);
|
|
8724
8878
|
$('folder-up').addEventListener('click', () => browseTo(folderView.listing.parent));
|
|
8725
8879
|
$('folder-home').addEventListener('click', () => browseTo('~'));
|
|
8880
|
+
$('folder-workshop').addEventListener('click', () => browseTo(folderView.listing.workshop));
|
|
8726
8881
|
$('folder-filter').addEventListener('input', renderFolderBrowser);
|
|
8727
8882
|
$('folder-hidden').addEventListener('change', (e) => {
|
|
8728
8883
|
save(HIDDEN_FOLDERS_KEY, e.target.checked ? '1' : null);
|
|
@@ -8812,6 +8967,15 @@ yardUi = initYard({
|
|
|
8812
8967
|
openNews,
|
|
8813
8968
|
selected: dockMakesWayForYard,
|
|
8814
8969
|
yardArt: () => state.yardArt,
|
|
8970
|
+
yardWorld: (skin) => state.yardWorlds?.[skin] ?? null,
|
|
8971
|
+
canReplaceYard: () => Boolean(state.yardWorlds),
|
|
8972
|
+
replaceYard: (skin) => chooseFolder('world', skin),
|
|
8973
|
+
restoreYard: (skin) => yardChange(() => api('DELETE', `/yard-worlds/${skin}`)),
|
|
8974
|
+
yardPieces: (skin) => state.yardPieces?.[skin] ?? {},
|
|
8975
|
+
yardBusy: () => Boolean(state.yardBusy),
|
|
8976
|
+
yardChecking: () => (typeof state.yardBusy === 'string' ? state.yardBusy : null),
|
|
8977
|
+
replacePiece: (skin, slot) => chooseFolder(slot === 'companion' ? 'companion' : 'character', skin, slot),
|
|
8978
|
+
restorePiece: (skin, slot) => yardChange(() => api('DELETE', `/yard-pieces/${skin}/${slot}`)),
|
|
8815
8979
|
// The new state arrives as yard-art.updated, ordered with the progress events.
|
|
8816
8980
|
yardArtAction: (action) => api('POST', `/yard-art/${action}`),
|
|
8817
8981
|
mountInspector(host, selection) {
|