@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
@@ -1,12 +1,18 @@
1
1
  import * as T from './vendor/engine.js';
2
2
  import { WORLDS, PROVIDER_ORDER, characterIndex, GILDED, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, providerPositions, sessionPose, familiarPose, hash,
3
- stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, STAGING, photoSize, marksFor, SHOWN_HELPERS } from './model.mjs';
3
+ stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, yardExtras, playableClip, STAGING, photoSize, marksFor, SHOWN_HELPERS } from './model.mjs';
4
4
 
5
5
  const ASSETS = new URL('./assets/', import.meta.url);
6
6
  const vector = new T.Vector3();
7
7
  const lookModel = new T.Matrix4(), lookParent = new T.Matrix4(), lookFrame = new T.Quaternion(), lookTurn = new T.Quaternion(), lookTilt = new T.Quaternion();
8
8
  const axisX = new T.Vector3(1, 0, 0), axisY = new T.Vector3(0, 1, 0);
9
9
  const noop = () => {};
10
+ const cancelled = new WeakSet();
11
+ addEventListener('unhandledrejection', event => { if (cancelled.has(event.reason)) event.preventDefault(); });
12
+ const cancel = loader => { const reason = new DOMException('This piece is no longer shown.', 'AbortError'); cancelled.add(reason); loader.abortController.abort(reason); };
13
+ const worldFile = (skin, custom, file) => `../worlds/${skin}/${file.split('/').pop()}?v=${custom.revision}`;
14
+ const pieceFiles = (skin, slot, piece) => (slot === 'companion' ? [0, 1, 2, 3].map(n => `companion_${n}.glb`) : ['character.glb'])
15
+ .map(file => new URL(`../pieces/${skin}/${slot}/${file}?v=${piece.revision}`, ASSETS).href);
10
16
  const vector2 = new T.Vector2();
11
17
  // How fast characters turn to a new heading, in radians a second, and how
12
18
  // long a clip change blends.
@@ -81,6 +87,21 @@ function disposeSkeletons(root) {
81
87
  root?.traverse(node=>{if(node.skeleton)skeletons.add(node.skeleton);});
82
88
  for(const skeleton of skeletons)skeleton.dispose();
83
89
  }
90
+ function checkPiece({scene,animations}) {
91
+ if(!animations.every(playableClip))throw new Error('An animation in this piece has values the Yard cannot play.');
92
+ const probe=T.cloneSkeleton(scene);
93
+ try {
94
+ probe.updateMatrixWorld(true);
95
+ probe.traverse(node=>{
96
+ if(!node.isMesh)return;
97
+ const vertices=node.geometry.attributes.position?.count,triangles=node.geometry.index?.array;
98
+ if(!vertices||(triangles&&!triangles.every(at=>at<vertices)))throw new Error('A mesh in this piece refers to vertices it does not have.');
99
+ if(!node.isSkinnedMesh)return;
100
+ node.skeleton.update();node.computeBoundingBox();node.computeBoundingSphere();
101
+ if(!Number.isFinite(node.boundingSphere?.radius))throw new Error('A rigged mesh in this piece cannot be drawn.');
102
+ });
103
+ } finally {disposeSkeletons(probe);}
104
+ }
84
105
  function disposeTree(root) {
85
106
  disposeSkeletons(root);
86
107
  const geometries=new Set(),materials=new Set();
@@ -118,8 +139,9 @@ function disposeWorld(root) {
118
139
  }
119
140
  export class YardRenderer {
120
141
  // clock gives the time staging is judged at; tests pass a fixed one.
121
- constructor(host,labels,{select,open,error=noop,clock=Date.now}) {
122
- this.host=host;this.labels=labels;this.onSelect=select;this.onOpen=open;this.onError=error;this.clock=clock;
142
+ constructor(host,labels,{select,open,error=noop,piece=noop,clock=Date.now}) {
143
+ this.host=host;this.labels=labels;this.onSelect=select;this.onOpen=open;this.onError=error;this.onPiece=piece;this.clock=clock;
144
+ this.pieceSets={};this.shownPieces={skin:null,pieces:{}};this.pieceLoaders=new Map();this.preloads=new Map();
123
145
  this.scene=new T.Scene();this.units=new Map();this.halls=new Map();this.slots=new Map();this.cache=new Map();
124
146
  this.loadingManager=new T.LoadingManager();
125
147
  this.providers=[];this.sessions=[];this.selected=null;this.world=null;this.skin=null;this.request=0;this.worldRequest=0;this.pending=null;
@@ -176,20 +198,29 @@ export class YardRenderer {
176
198
  this.resizeObserver=new ResizeObserver(()=>this.resize());this.resizeObserver.observe(host);
177
199
  this.resize();this.overview();
178
200
  }
179
- async asset(name) {
201
+ async asset(name,url) {
180
202
  if(!this.cache.has(name)) {
181
- const pending=new T.GLTFLoader(this.loadingManager).loadAsync(new URL(name+'.glb',ASSETS).href).catch(err=>{
203
+ const failed=[],manager=url?new T.LoadingManager():this.loadingManager;
204
+ if(url){manager.onError=item=>failed.push(item);this.pieceLoaders.set(name,manager);}
205
+ const pending=new T.GLTFLoader(manager).loadAsync(url||new URL(name+'.glb',ASSETS).href).then(asset=>{
206
+ try {
207
+ if(failed.length)throw new Error('A texture in this piece could not be drawn.');
208
+ if(url)checkPiece(asset);
209
+ return asset;
210
+ } catch(err){disposeTree(asset.scene);throw err;}
211
+ }).catch(err=>{
182
212
  if(this.cache.get(name)===pending)this.cache.delete(name);
183
213
  throw err;
184
214
  });
185
215
  this.cache.set(name,pending);
216
+ if(url)pending.catch(noop).finally(()=>{if(this.pieceLoaders.get(name)===manager)this.pieceLoaders.delete(name);});
186
217
  }
187
218
  return this.cache.get(name);
188
219
  }
189
- goldAsset(name) {
220
+ goldAsset(name,url) {
190
221
  const key='gold:'+name;
191
222
  if(!this.cache.has(key)) {
192
- const pending=this.asset(name).then(asset=>{const scene=T.cloneSkeleton(asset.scene);gild(scene);return {...asset,scene};}).catch(err=>{
223
+ const pending=this.asset(name,url).then(asset=>{const scene=T.cloneSkeleton(asset.scene);gild(scene);return {...asset,scene};}).catch(err=>{
193
224
  if(this.cache.get(key)===pending)this.cache.delete(key);
194
225
  throw err;
195
226
  });
@@ -197,10 +228,63 @@ export class YardRenderer {
197
228
  }
198
229
  return this.cache.get(key);
199
230
  }
231
+ activePieces() { return this.pieceSets[this.skin]||{}; }
232
+ pieceSource(slot) {
233
+ const piece=this.activePieces()[slot];
234
+ return piece?{slot,revision:piece.revision,urls:pieceFiles(this.skin,slot,piece)}:null;
235
+ }
236
+ // skin's pieces by slot, each { revision }; a slot without one shows the original.
237
+ setPieces(skin,pieces) {
238
+ this.pieceSets[skin]=pieces;
239
+ if(skin===this.skin)this.applyPieces();
240
+ }
241
+ applyPieces() {
242
+ if(this.disposed||!this.world)return;
243
+ const before=this.shownPieces,after={skin:this.skin,pieces:{...this.activePieces()}};
244
+ const changed=[...new Set([...Object.keys(before.pieces),...Object.keys(after.pieces)])]
245
+ .filter(slot=>before.skin!==after.skin||before.pieces[slot]?.revision!==after.pieces[slot]?.revision);
246
+ if(!changed.length&&before.skin===after.skin)return;
247
+ this.shownPieces=after;
248
+ if(before.skin===after.skin)for(const unit of this.units.values()){
249
+ if(changed.includes('companion'))this.clearHelpers(unit);
250
+ if(changed.includes(PROVIDER_ORDER[characterIndex(unit.session.provider.id)])){this.dropModel(unit);this.loadUnit(unit,unit.session);}
251
+ if(changed.includes('companion')&&unit.loaded)this.helpers(unit,unit.session);
252
+ }
253
+ for(const slot of changed){
254
+ if(before.pieces[slot]){this.preloads.delete(slot);for(const url of pieceFiles(before.skin,slot,before.pieces[slot]))this.release(url);}
255
+ if(after.pieces[slot])this.preload(slot,after.pieces[slot]);
256
+ }
257
+ }
258
+ preload(slot,piece) {
259
+ const skin=this.skin,attempt={};
260
+ this.preloads.set(slot,attempt);
261
+ Promise.all(pieceFiles(skin,slot,piece).map(url=>this.asset(url,url))).then(()=>true,()=>false).then(ok=>{
262
+ if(this.preloads.get(slot)!==attempt)return;
263
+ this.preloads.delete(slot);
264
+ if(!this.disposed&&this.skin===skin&&this.activePieces()[slot]?.revision===piece.revision)this.onPiece({skin,slot,revision:piece.revision,ok});
265
+ });
266
+ }
267
+ pieceFailed(source) {
268
+ if(this.activePieces()[source.slot]?.revision===source.revision)this.onPiece({skin:this.skin,slot:source.slot,revision:source.revision,ok:false});
269
+ }
270
+ release(name) {
271
+ const loader=this.pieceLoaders.get(name);if(loader){cancel(loader);this.pieceLoaders.delete(name);}
272
+ for(const key of [name,'gold:'+name]){
273
+ const pending=this.cache.get(key);
274
+ if(!pending)continue;
275
+ this.cache.delete(key);
276
+ pending.then(asset=>disposeTree(asset.scene)).catch(noop);
277
+ }
278
+ }
279
+ dropModel(unit) {
280
+ for(const mixer of unit.mixers){mixer.stopAllAction();mixer.uncacheRoot(mixer.getRoot());}
281
+ if(unit.model){unit.root.remove(unit.model);disposeSkeletons(unit.model);}
282
+ 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});
283
+ }
200
284
  // Shows skin's world in theme, or only theme's plates when that world is
201
285
  // already up. What is on screen stays until the new world or theme is
202
286
  // ready; progress receives the bytes downloaded so far.
203
- async setWorld(skin,theme,{progress=noop}={}) {
287
+ async setWorld(skin,theme,{progress=noop,custom=null}={}) {
204
288
  if(this.disposed)return;
205
289
  if(!WORLDS[skin])throw new Error('This skin has no yard.');
206
290
  const request=++this.worldRequest;
@@ -208,7 +292,7 @@ export class YardRenderer {
208
292
  const pending=this.pending=tracked(progress);
209
293
  let loaded;
210
294
  try {
211
- if(this.skin===skin&&this.world){
295
+ if(this.skin===skin&&this.world&&(this.custom?.revision??null)===(custom?.revision??null)){
212
296
  const world=this.world;
213
297
  await this.plateTheme(world,themeKey(theme),pending.manager);
214
298
  if(!this.disposed&&request===this.worldRequest&&this.world===world){this.light(skin,theme);this.drawOnce();}
@@ -217,7 +301,7 @@ export class YardRenderer {
217
301
  const url=new URL(skin+'.glb',ASSETS).href;
218
302
  loaded=await new T.GLTFLoader(pending.manager).loadAsync(url,pending.stream(url));
219
303
  if(this.disposed||request!==this.worldRequest){disposeWorld(loaded.scene);return;}
220
- await Promise.all([this.surfaceWorld(loaded.scene,skin,pending.manager),this.plateWorld(loaded.scene,skin,theme,pending.manager)]);
304
+ await Promise.all([this.surfaceWorld(loaded.scene,skin,pending.manager,custom),this.plateWorld(loaded.scene,skin,theme,pending.manager,custom)]);
221
305
  } catch(err) {
222
306
  disposeWorld(loaded?.scene);
223
307
  if(!this.disposed&&request===this.worldRequest)throw err;
@@ -233,7 +317,8 @@ export class YardRenderer {
233
317
  for(const item of this.halls.values()){item.label.remove();item.chip.remove();}
234
318
  this.halls.clear();
235
319
  if(this.world){this.scene.remove(this.world);disposeWorld(this.world);}
236
- this.world=loaded.scene;this.skin=skin;this.scene.add(this.world);
320
+ this.world=loaded.scene;this.skin=skin;this.custom=custom;this.scene.add(this.world);
321
+ this.applyPieces();
237
322
  this.makeEffects(WORLDS[skin].effect);
238
323
  this.light(skin,theme);
239
324
  this.host.dataset.world=skin;
@@ -250,10 +335,12 @@ export class YardRenderer {
250
335
  // direction as the camera, so they line up at any pan or zoom. Each theme
251
336
  // has its own set: the current theme's base layer gates the load, and its
252
337
  // sharper layers and the other theme follow without blocking it.
253
- async plateWorld(world,skin,theme,manager) {
338
+ async plateWorld(world,skin,theme,manager,custom=null) {
254
339
  const response=await fetch(new URL(skin+'/plates.json',ASSETS));
255
340
  if(!response.ok)throw new Error('The Yard plates could not be loaded.');
256
- world.userData.plates={themes:(await response.json()).themes,groups:{}};
341
+ const {themes}=await response.json();
342
+ if(custom)for(const layers of Object.values(themes))for(const layer of layers)for(const tile of layer.tiles)tile.file=worldFile(skin,custom,tile.file);
343
+ world.userData.plates={themes,groups:{}};
257
344
  await this.plateTheme(world,themeKey(theme),manager);
258
345
  // Live halls and characters cast onto the plate's ground.
259
346
  const catcher=new T.Mesh(new T.PlaneGeometry(400,400),new T.ShadowMaterial({opacity:.3,depthWrite:false}));
@@ -314,7 +401,7 @@ export class YardRenderer {
314
401
  }
315
402
  // Live halls take scanned material sets by material name,
316
403
  // and light from a small copy of the plates' sky.
317
- async surfaceWorld(world,skin,manager) {
404
+ async surfaceWorld(world,skin,manager,custom=null) {
318
405
  const response=await fetch(new URL(skin+'/surfaces.json',ASSETS));
319
406
  if(!response.ok)throw new Error('The Yard surfaces could not be loaded.');
320
407
  const {sky,surfaces}=await response.json();
@@ -337,7 +424,7 @@ export class YardRenderer {
337
424
  const pmrem=new T.PMREMGenerator(this.renderer);
338
425
  try {
339
426
  for(const [theme,file] of Object.entries(sky)) {
340
- const equirect=await new T.HDRLoader(manager).loadAsync(new URL(file,ASSETS).href);
427
+ const equirect=await new T.HDRLoader(manager).loadAsync(new URL(custom?worldFile(skin,custom,file):file,ASSETS).href);
341
428
  environments[theme]=pmrem.fromEquirectangular(equirect).texture;equirect.dispose();
342
429
  }
343
430
  } finally { pmrem.dispose(); }
@@ -374,11 +461,11 @@ export class YardRenderer {
374
461
  this.scene.environment=this.world?.userData.environments?.[key]||null;
375
462
  if(this.world&&this.skin===skin)this.showPlates(key);
376
463
  this.renderer.toneMappingExposure=light?1.65:1.12;
377
- this.hemi.color.set(WORLDS[skin].hemi);
464
+ this.hemi.color.set(this.custom?.lights?.[key].hemi??WORLDS[skin].hemi);
378
465
  this.hemi.intensity=(light?3.2:1.8)*.35;
379
466
  // Blender and three.js place an equirectangular sky half a turn apart.
380
467
  this.scene.environmentRotation.y=Math.PI;this.scene.environmentIntensity=light?1.25:.7;
381
- this.sun.color.set(WORLDS[skin].sun[key]);
468
+ this.sun.color.set(this.custom?.lights?.[key].sun??WORLDS[skin].sun[key]);
382
469
  this.sun.intensity=light?4.0:3.2;
383
470
  this.dirty=true;
384
471
  }
@@ -590,22 +677,24 @@ export class YardRenderer {
590
677
  }
591
678
  async loadUnit(unit,session) {
592
679
  if(unit.loaded||unit.loading)return;
593
- const generation=this.request;
594
- const prefix=WORLDS[this.skin].characters;
595
- const index=characterIndex(session.provider.id);
680
+ const generation=this.request,token=unit.load=(unit.load||0)+1;
681
+ const index=characterIndex(session.provider.id),source=this.pieceSource(PROVIDER_ORDER[index]);
682
+ const name=source?source.urls[0]:WORLDS[this.skin].characters+index;
683
+ const stale=()=>this.disposed||generation!==this.request||token!==unit.load||!unit.root.parent;
596
684
  unit.loading=true;
597
685
  try {
598
- const asset=await (GILDED.includes(session.provider.id)?this.goldAsset(prefix+index):this.asset(prefix+index));
599
- if(this.disposed||generation!==this.request||!unit.root.parent)return;
686
+ const asset=await (GILDED.includes(session.provider.id)?this.goldAsset(name,source?.urls[0]):this.asset(name,source?.urls[0]));
687
+ if(stale())return;
600
688
  const model=T.cloneSkeleton(asset.scene);
601
689
  model.rotation.y=unit.heading;
602
- model.traverse(n=>{if(n.isMesh){n.castShadow=true;n.receiveShadow=true;}});
690
+ model.traverse(n=>{if(n.isMesh){n.castShadow=true;n.receiveShadow=true;}if(n.isSkinnedMesh)n.frustumCulled=false;});
603
691
  unit.root.add(model);unit.model=model;unit.loaded=true;
604
692
  unit.label.title='';
605
693
  // The walk's stride and cycle, and where blows land, as rig.py wrote them.
606
694
  let yard=null;asset.scene.traverse(n=>{if(n.userData?.yard)yard=n.userData.yard;});
607
- unit.speed=yard?.stride&&yard?.cycle?yard.stride/yard.cycle:1;
608
- unit.events=yard?.events||[];unit.height=yard?.height;unit.surface=yard?.surface;unit.look=lookLimits(yard);
695
+ yard=yardExtras(yard,'character');
696
+ unit.speed=yard.stride/yard.cycle;
697
+ unit.events=yard.events;unit.height=yard.height;unit.surface=yard.surface;unit.look=lookLimits(yard.look);
609
698
  const neck=model.getObjectByName('Neck'),head=model.getObjectByName('Head');
610
699
  unit.lookBones=neck&&head&&model.getObjectByName('LeftUpperLeg')?{neck,head}:null;
611
700
  this.scaleProp(unit.session?.id||session.id);
@@ -615,8 +704,10 @@ export class YardRenderer {
615
704
  unit.clip=null;this.play(unit);
616
705
  this.helpers(unit,unit.session||session);this.dirty=true;this.drawOnce();
617
706
  } catch(err){
618
- 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.';}
619
- } finally {unit.loading=false;}
707
+ if(stale())return;
708
+ if(source){this.pieceFailed(source);return;}
709
+ console.warn('Yard character unavailable:',err.message);unit.label.title='Character art unavailable. Session controls remain available.';
710
+ } finally {if(token===unit.load)unit.loading=false;}
620
711
  }
621
712
  // A hall's own copies of the materials that glow with its crew, so one
622
713
  // hall lighting up does not light the others that share them.
@@ -1052,28 +1143,47 @@ export class YardRenderer {
1052
1143
  if(helper.action){if(this.reduced)helper.action.stop();else helper.action.crossFadeTo(action,FADE,false);}
1053
1144
  helper.action=action;
1054
1145
  }
1146
+ clearHelpers(unit) {
1147
+ unit.revision++;unit.helperSignature=null;
1148
+ for(const helper of unit.helpers){unit.crew.remove(helper.model);helper.mixer?.stopAllAction();helper.mixer?.uncacheRoot(helper.model);disposeSkeletons(helper.model);}
1149
+ unit.helpers=[];
1150
+ this.dirty=true;this.drawOnce();
1151
+ }
1055
1152
  async helpers(unit,session) {
1056
1153
  const agents=helpersFor(session);
1057
- const signature=JSON.stringify(agents);
1154
+ const kinds=WORLDS[this.skin].helpers,source=this.pieceSource('companion');
1155
+ const signature=JSON.stringify([source?.revision??null,agents]);
1058
1156
  if(unit.helperSignature===signature)return;
1157
+ this.clearHelpers(unit);
1059
1158
  unit.helperSignature=signature;
1060
- const rev=++unit.revision,generation=this.request;
1061
- for(const helper of unit.helpers){unit.crew.remove(helper.model);helper.mixer?.stopAllAction();helper.mixer?.uncacheRoot(helper.model);disposeSkeletons(helper.model);}
1062
- unit.helpers=[];
1063
- const kinds=WORLDS[this.skin].helpers;
1159
+ const rev=unit.revision,generation=this.request;
1160
+ const stale=()=>this.disposed||generation!==this.request||rev!==unit.revision||!unit.root.parent;
1161
+ let pieces=null;
1162
+ if(source&&agents.length){
1163
+ try {pieces=await Promise.all(source.urls.map(url=>this.asset(url,url)));}
1164
+ catch {
1165
+ if(stale())return;
1166
+ unit.helperSignature=null;
1167
+ this.pieceFailed(source);
1168
+ return;
1169
+ }
1170
+ if(stale())return;
1171
+ }
1064
1172
  // The full helper count and all helper details remain in the canonical inspector.
1065
1173
  // Small squads preserve readable character silhouettes at the overview scale.
1066
1174
  await Promise.all(agents.slice(0,SHOWN_HELPERS).map(async(agent,i)=>{
1067
1175
  try {
1068
- const asset=await this.asset((agent.shell?kinds.shell:kinds.agent)+(hash(agent.name||agent.id)%4));
1069
- if(this.disposed||generation!==this.request||rev!==unit.revision||!unit.root.parent)return;
1176
+ const n=hash(agent.name||agent.id)%4;
1177
+ const asset=pieces?pieces[n]:await this.asset((agent.shell?kinds.shell:kinds.agent)+n);
1178
+ if(stale())return;
1070
1179
  const model=T.cloneSkeleton(asset.scene);
1180
+ model.traverse(n=>{if(n.isSkinnedMesh)n.frustumCulled=false;});
1071
1181
  const offset=helperOffset(i,agents.length);
1072
1182
  model.scale.setScalar(.45);model.position.set(unit.root.position.x+offset.x,.27,unit.root.position.z+offset.z);
1073
1183
  unit.crew.add(model);
1074
1184
  const mixer=new T.AnimationMixer(model);
1075
1185
  let yard=null;asset.scene.traverse(n=>{if(n.userData?.yard)yard=n.userData.yard;});
1076
- 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};
1186
+ 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};
1077
1187
  this.helperClip(helper,agent.pose);
1078
1188
  mixer.update(.3+(hash(agent.id)%40)/10);
1079
1189
  unit.helpers.push(helper);
@@ -1081,7 +1191,7 @@ export class YardRenderer {
1081
1191
  } catch {
1082
1192
  // The next state transition can rebuild this incomplete squad. Routine
1083
1193
  // unchanged updates do not call helpers(), so failure cannot loop.
1084
- if(!this.disposed&&generation===this.request&&rev===unit.revision&&unit.root.parent)unit.helperSignature=null;
1194
+ if(!stale())unit.helperSignature=null;
1085
1195
  }
1086
1196
  }));
1087
1197
  }
@@ -1135,7 +1245,7 @@ export class YardRenderer {
1135
1245
  this.select(this.selected);
1136
1246
  this.renderer.render(this.scene,this.camera);
1137
1247
  }
1138
- return {canvas,scale:size.scale,skin:this.skin,theme:this.theme,tags};
1248
+ return {canvas,scale:size.scale,skin:this.skin,title:this.custom?.title,theme:this.theme,tags};
1139
1249
  }
1140
1250
  tags() {
1141
1251
  const origin=this.host.getBoundingClientRect();
@@ -1151,6 +1261,8 @@ export class YardRenderer {
1151
1261
  const rect=this.canvas.getBoundingClientRect();
1152
1262
  this.pointer.set((event.clientX-rect.left)/rect.width*2-1,-(event.clientY-rect.top)/rect.height*2+1);
1153
1263
  this.ray.setFromCamera(this.pointer,this.camera);
1264
+ // A skinned mesh keeps the bounds of the pose it first had; picking needs the pose on screen.
1265
+ 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();});
1154
1266
  const hits=this.ray.intersectObjects([...this.halls.values(),...this.units.values()].flatMap(i=>i.crew?[i.root,i.crew]:[i.root]),true);
1155
1267
  for(const hit of hits) { for(let node=hit.object;node;node=node.parent) if(node.userData.entity)return node.userData.entity; }
1156
1268
  return null;
@@ -1338,7 +1450,7 @@ export class YardRenderer {
1338
1450
  this.clearFireworks();this.fireworkTexture?.dispose();
1339
1451
  for(const spark of this.sparks){spark.points.geometry.dispose();spark.points.material.dispose();}this.sparks=[];
1340
1452
  this.releaseEffects();this.clearProps();
1341
- this.loadingManager.abort();this.pending?.close();this.pending=null;
1453
+ this.loadingManager.abort();for(const loader of this.pieceLoaders.values())cancel(loader);this.pieceLoaders.clear();this.pending?.close();this.pending=null;
1342
1454
  this.resizeObserver.disconnect();this.controls.dispose();this.clearUnits();
1343
1455
  for(const item of this.halls.values()){item.label.remove();item.chip.remove();}this.halls.clear();this.list.remove();
1344
1456
  disposeWorld(this.world);