@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.
Files changed (133) hide show
  1. package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
  2. package/bin/native/win32-x64/agent-guild-report.exe +0 -0
  3. package/bin/windows/arm64/agent-guild-start.exe +0 -0
  4. package/bin/windows/x64/agent-guild-start.exe +0 -0
  5. package/docs/api.md +10 -2
  6. package/node_modules/@jsquash/jpeg/CHANGELOG.md +54 -0
  7. package/node_modules/@jsquash/jpeg/LICENSE +201 -0
  8. package/node_modules/@jsquash/jpeg/README.md +105 -0
  9. package/node_modules/@jsquash/jpeg/codec/LICENSE.codec.md +132 -0
  10. package/node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.d.ts +7 -0
  11. package/node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.js +15 -0
  12. package/node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm +0 -0
  13. package/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.d.ts +37 -0
  14. package/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.js +15 -0
  15. package/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm +0 -0
  16. package/node_modules/@jsquash/jpeg/codec/pre.js +24 -0
  17. package/node_modules/@jsquash/jpeg/decode.d.ts +16 -0
  18. package/node_modules/@jsquash/jpeg/decode.js +36 -0
  19. package/node_modules/@jsquash/jpeg/emscripten-types.d.ts +137 -0
  20. package/node_modules/@jsquash/jpeg/encode.d.ts +21 -0
  21. package/node_modules/@jsquash/jpeg/encode.js +35 -0
  22. package/node_modules/@jsquash/jpeg/index.d.ts +3 -0
  23. package/node_modules/@jsquash/jpeg/index.js +2 -0
  24. package/node_modules/@jsquash/jpeg/meta.d.ts +24 -0
  25. package/node_modules/@jsquash/jpeg/meta.js +25 -0
  26. package/node_modules/@jsquash/jpeg/package.json +32 -0
  27. package/node_modules/@jsquash/jpeg/utils.d.ts +17 -0
  28. package/node_modules/@jsquash/jpeg/utils.js +31 -0
  29. package/node_modules/@jsquash/png/CHANGELOG.md +69 -0
  30. package/node_modules/@jsquash/png/LICENSE +201 -0
  31. package/node_modules/@jsquash/png/README.md +114 -0
  32. package/node_modules/@jsquash/png/codec/LICENSE.codec.md +29 -0
  33. package/node_modules/@jsquash/png/codec/README.md +5 -0
  34. package/node_modules/@jsquash/png/codec/pkg/README.md +5 -0
  35. package/node_modules/@jsquash/png/codec/pkg/squoosh_png.d.ts +71 -0
  36. package/node_modules/@jsquash/png/codec/pkg/squoosh_png.js +314 -0
  37. package/node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm +0 -0
  38. package/node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm.d.ts +13 -0
  39. package/node_modules/@jsquash/png/codec/pre.js +24 -0
  40. package/node_modules/@jsquash/png/decode.d.ts +29 -0
  41. package/node_modules/@jsquash/png/decode.js +35 -0
  42. package/node_modules/@jsquash/png/encode.d.ts +31 -0
  43. package/node_modules/@jsquash/png/encode.js +40 -0
  44. package/node_modules/@jsquash/png/index.d.ts +3 -0
  45. package/node_modules/@jsquash/png/index.js +2 -0
  46. package/node_modules/@jsquash/png/meta.d.ts +16 -0
  47. package/node_modules/@jsquash/png/meta.js +15 -0
  48. package/node_modules/@jsquash/png/package.json +34 -0
  49. package/node_modules/@jsquash/webp/CHANGELOG.md +48 -0
  50. package/node_modules/@jsquash/webp/LICENSE +201 -0
  51. package/node_modules/@jsquash/webp/README.md +100 -0
  52. package/node_modules/@jsquash/webp/codec/LICENSE.codec.md +29 -0
  53. package/node_modules/@jsquash/webp/codec/Makefile.animated.backup +88 -0
  54. package/node_modules/@jsquash/webp/codec/README.md +5 -0
  55. package/node_modules/@jsquash/webp/codec/dec/README.md +18 -0
  56. package/node_modules/@jsquash/webp/codec/dec/webp_dec.d.ts +7 -0
  57. package/node_modules/@jsquash/webp/codec/dec/webp_dec.js +16 -0
  58. package/node_modules/@jsquash/webp/codec/dec/webp_dec.wasm +0 -0
  59. package/node_modules/@jsquash/webp/codec/enc/README.md +22 -0
  60. package/node_modules/@jsquash/webp/codec/enc/webp_enc.d.ts +42 -0
  61. package/node_modules/@jsquash/webp/codec/enc/webp_enc.js +16 -0
  62. package/node_modules/@jsquash/webp/codec/enc/webp_enc.wasm +0 -0
  63. package/node_modules/@jsquash/webp/codec/enc/webp_enc_simd.d.ts +1 -0
  64. package/node_modules/@jsquash/webp/codec/enc/webp_enc_simd.js +16 -0
  65. package/node_modules/@jsquash/webp/codec/enc/webp_enc_simd.wasm +0 -0
  66. package/node_modules/@jsquash/webp/codec/pre.js +24 -0
  67. package/node_modules/@jsquash/webp/decode.d.ts +15 -0
  68. package/node_modules/@jsquash/webp/decode.js +34 -0
  69. package/node_modules/@jsquash/webp/emscripten-types.d.ts +137 -0
  70. package/node_modules/@jsquash/webp/encode.d.ts +23 -0
  71. package/node_modules/@jsquash/webp/encode.js +43 -0
  72. package/node_modules/@jsquash/webp/index.d.ts +3 -0
  73. package/node_modules/@jsquash/webp/index.js +2 -0
  74. package/node_modules/@jsquash/webp/meta.d.ts +19 -0
  75. package/node_modules/@jsquash/webp/meta.js +33 -0
  76. package/node_modules/@jsquash/webp/package.json +35 -0
  77. package/node_modules/@jsquash/webp/utils.d.ts +17 -0
  78. package/node_modules/@jsquash/webp/utils.js +31 -0
  79. package/node_modules/argparse/lib/argparse.js +1 -1
  80. package/node_modules/argparse/lib/textwrap.js +13 -2
  81. package/node_modules/argparse/package.json +1 -1
  82. package/node_modules/markdown-it/dist/browser/markdown-it.esm.min.mjs +737 -686
  83. package/node_modules/markdown-it/dist/browser/markdown-it.esm.min.mjs.map +1 -1
  84. package/node_modules/markdown-it/dist/browser/markdown-it.umd.min.js +8 -8
  85. package/node_modules/markdown-it/dist/browser/markdown-it.umd.min.js.map +1 -1
  86. package/node_modules/markdown-it/dist/markdown-it.cjs.js +660 -518
  87. package/node_modules/markdown-it/dist/markdown-it.cjs.js.map +1 -1
  88. package/node_modules/markdown-it/dist/markdown-it.d.mts +1 -0
  89. package/node_modules/markdown-it/dist/markdown-it.mjs +179 -124
  90. package/node_modules/markdown-it/dist/markdown-it.mjs.map +1 -1
  91. package/node_modules/markdown-it/package.json +11 -11
  92. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  93. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  94. package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  95. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  96. package/node_modules/wasm-feature-detect/LICENSE +202 -0
  97. package/node_modules/wasm-feature-detect/README.md +109 -0
  98. package/node_modules/wasm-feature-detect/README.md.ejs +89 -0
  99. package/node_modules/wasm-feature-detect/dist/cjs/index.cjs +1 -0
  100. package/node_modules/wasm-feature-detect/dist/esm/index.js +1 -0
  101. package/node_modules/wasm-feature-detect/dist/index.d.cts +24 -0
  102. package/node_modules/wasm-feature-detect/dist/index.d.ts +24 -0
  103. package/node_modules/wasm-feature-detect/dist/umd/index.js +1 -0
  104. package/node_modules/wasm-feature-detect/package.json +53 -0
  105. package/node_modules/ws/README.md +21 -29
  106. package/node_modules/ws/lib/buffer-util.js +13 -2
  107. package/node_modules/ws/lib/sender.js +4 -1
  108. package/node_modules/ws/lib/websocket.js +8 -3
  109. package/node_modules/ws/package.json +1 -1
  110. package/package.json +7 -4
  111. package/src/manager/config.mjs +2 -0
  112. package/src/manager/folder-browser.mjs +39 -3
  113. package/src/manager/main.mjs +4 -0
  114. package/src/manager/server.mjs +32 -5
  115. package/src/manager/steam-workshop.mjs +64 -0
  116. package/src/manager/yard-art.mjs +1 -1
  117. package/src/manager/yard-piece-check.mjs +424 -0
  118. package/src/manager/yard-pieces.mjs +188 -0
  119. package/src/manager/yard-worlds.mjs +214 -0
  120. package/web/app.js +175 -11
  121. package/web/index.html +20 -2
  122. package/web/skins/grove/ui/companion-bird.webp +0 -0
  123. package/web/skins/guild/skin.css +2 -13
  124. package/web/styles.css +15 -1
  125. package/web/yard/art.json +1 -1
  126. package/web/yard/model.mjs +44 -2
  127. package/web/yard/photo.js +3 -4
  128. package/web/yard/renderer.js +150 -38
  129. package/web/yard/vendor/engine.js +500 -495
  130. package/web/yard/view.js +160 -14
  131. package/web/yard/yard.css +55 -12
  132. package/web/skins/grove/ui/install-now-dark.svg +0 -91
  133. 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
- const top = Math.round(box.bottom + 6);
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 = `${innerHeight - top - 8}px`;
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
- : !entries.length ? (listing.entries.length ? 'No folders match.' : 'No folders here.')
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', async (e) => {
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', () => placeMenu($('yard-play-menu'), $('yard-play-more')), { passive: true });
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) {