@oddessentials/agent-guild 1.29.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 (107) 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 +6 -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/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  80. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  81. package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  82. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  83. package/node_modules/wasm-feature-detect/LICENSE +202 -0
  84. package/node_modules/wasm-feature-detect/README.md +109 -0
  85. package/node_modules/wasm-feature-detect/README.md.ejs +89 -0
  86. package/node_modules/wasm-feature-detect/dist/cjs/index.cjs +1 -0
  87. package/node_modules/wasm-feature-detect/dist/esm/index.js +1 -0
  88. package/node_modules/wasm-feature-detect/dist/index.d.cts +24 -0
  89. package/node_modules/wasm-feature-detect/dist/index.d.ts +24 -0
  90. package/node_modules/wasm-feature-detect/dist/umd/index.js +1 -0
  91. package/node_modules/wasm-feature-detect/package.json +53 -0
  92. package/package.json +4 -1
  93. package/src/manager/config.mjs +1 -0
  94. package/src/manager/folder-browser.mjs +31 -2
  95. package/src/manager/main.mjs +2 -0
  96. package/src/manager/server.mjs +19 -5
  97. package/src/manager/steam-workshop.mjs +64 -0
  98. package/src/manager/yard-piece-check.mjs +424 -0
  99. package/src/manager/yard-pieces.mjs +188 -0
  100. package/web/app.js +54 -16
  101. package/web/index.html +14 -1
  102. package/web/skins/guild/skin.css +2 -13
  103. package/web/styles.css +14 -0
  104. package/web/yard/art.json +1 -1
  105. package/web/yard/model.mjs +44 -2
  106. package/web/yard/renderer.js +136 -27
  107. package/web/yard/view.js +123 -11
@@ -1,13 +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); };
10
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);
11
16
  const vector2 = new T.Vector2();
12
17
  // How fast characters turn to a new heading, in radians a second, and how
13
18
  // long a clip change blends.
@@ -82,6 +87,21 @@ function disposeSkeletons(root) {
82
87
  root?.traverse(node=>{if(node.skeleton)skeletons.add(node.skeleton);});
83
88
  for(const skeleton of skeletons)skeleton.dispose();
84
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
+ }
85
105
  function disposeTree(root) {
86
106
  disposeSkeletons(root);
87
107
  const geometries=new Set(),materials=new Set();
@@ -119,8 +139,9 @@ function disposeWorld(root) {
119
139
  }
120
140
  export class YardRenderer {
121
141
  // clock gives the time staging is judged at; tests pass a fixed one.
122
- constructor(host,labels,{select,open,error=noop,clock=Date.now}) {
123
- 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();
124
145
  this.scene=new T.Scene();this.units=new Map();this.halls=new Map();this.slots=new Map();this.cache=new Map();
125
146
  this.loadingManager=new T.LoadingManager();
126
147
  this.providers=[];this.sessions=[];this.selected=null;this.world=null;this.skin=null;this.request=0;this.worldRequest=0;this.pending=null;
@@ -177,20 +198,29 @@ export class YardRenderer {
177
198
  this.resizeObserver=new ResizeObserver(()=>this.resize());this.resizeObserver.observe(host);
178
199
  this.resize();this.overview();
179
200
  }
180
- async asset(name) {
201
+ async asset(name,url) {
181
202
  if(!this.cache.has(name)) {
182
- 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=>{
183
212
  if(this.cache.get(name)===pending)this.cache.delete(name);
184
213
  throw err;
185
214
  });
186
215
  this.cache.set(name,pending);
216
+ if(url)pending.catch(noop).finally(()=>{if(this.pieceLoaders.get(name)===manager)this.pieceLoaders.delete(name);});
187
217
  }
188
218
  return this.cache.get(name);
189
219
  }
190
- goldAsset(name) {
220
+ goldAsset(name,url) {
191
221
  const key='gold:'+name;
192
222
  if(!this.cache.has(key)) {
193
- 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=>{
194
224
  if(this.cache.get(key)===pending)this.cache.delete(key);
195
225
  throw err;
196
226
  });
@@ -198,6 +228,59 @@ export class YardRenderer {
198
228
  }
199
229
  return this.cache.get(key);
200
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
+ }
201
284
  // Shows skin's world in theme, or only theme's plates when that world is
202
285
  // already up. What is on screen stays until the new world or theme is
203
286
  // ready; progress receives the bytes downloaded so far.
@@ -235,6 +318,7 @@ export class YardRenderer {
235
318
  this.halls.clear();
236
319
  if(this.world){this.scene.remove(this.world);disposeWorld(this.world);}
237
320
  this.world=loaded.scene;this.skin=skin;this.custom=custom;this.scene.add(this.world);
321
+ this.applyPieces();
238
322
  this.makeEffects(WORLDS[skin].effect);
239
323
  this.light(skin,theme);
240
324
  this.host.dataset.world=skin;
@@ -593,22 +677,24 @@ export class YardRenderer {
593
677
  }
594
678
  async loadUnit(unit,session) {
595
679
  if(unit.loaded||unit.loading)return;
596
- const generation=this.request;
597
- const prefix=WORLDS[this.skin].characters;
598
- 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;
599
684
  unit.loading=true;
600
685
  try {
601
- const asset=await (GILDED.includes(session.provider.id)?this.goldAsset(prefix+index):this.asset(prefix+index));
602
- 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;
603
688
  const model=T.cloneSkeleton(asset.scene);
604
689
  model.rotation.y=unit.heading;
605
- 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;});
606
691
  unit.root.add(model);unit.model=model;unit.loaded=true;
607
692
  unit.label.title='';
608
693
  // The walk's stride and cycle, and where blows land, as rig.py wrote them.
609
694
  let yard=null;asset.scene.traverse(n=>{if(n.userData?.yard)yard=n.userData.yard;});
610
- unit.speed=yard?.stride&&yard?.cycle?yard.stride/yard.cycle:1;
611
- 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);
612
698
  const neck=model.getObjectByName('Neck'),head=model.getObjectByName('Head');
613
699
  unit.lookBones=neck&&head&&model.getObjectByName('LeftUpperLeg')?{neck,head}:null;
614
700
  this.scaleProp(unit.session?.id||session.id);
@@ -618,8 +704,10 @@ export class YardRenderer {
618
704
  unit.clip=null;this.play(unit);
619
705
  this.helpers(unit,unit.session||session);this.dirty=true;this.drawOnce();
620
706
  } catch(err){
621
- 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.';}
622
- } 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;}
623
711
  }
624
712
  // A hall's own copies of the materials that glow with its crew, so one
625
713
  // hall lighting up does not light the others that share them.
@@ -1055,28 +1143,47 @@ export class YardRenderer {
1055
1143
  if(helper.action){if(this.reduced)helper.action.stop();else helper.action.crossFadeTo(action,FADE,false);}
1056
1144
  helper.action=action;
1057
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
+ }
1058
1152
  async helpers(unit,session) {
1059
1153
  const agents=helpersFor(session);
1060
- 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]);
1061
1156
  if(unit.helperSignature===signature)return;
1157
+ this.clearHelpers(unit);
1062
1158
  unit.helperSignature=signature;
1063
- const rev=++unit.revision,generation=this.request;
1064
- for(const helper of unit.helpers){unit.crew.remove(helper.model);helper.mixer?.stopAllAction();helper.mixer?.uncacheRoot(helper.model);disposeSkeletons(helper.model);}
1065
- unit.helpers=[];
1066
- 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
+ }
1067
1172
  // The full helper count and all helper details remain in the canonical inspector.
1068
1173
  // Small squads preserve readable character silhouettes at the overview scale.
1069
1174
  await Promise.all(agents.slice(0,SHOWN_HELPERS).map(async(agent,i)=>{
1070
1175
  try {
1071
- const asset=await this.asset((agent.shell?kinds.shell:kinds.agent)+(hash(agent.name||agent.id)%4));
1072
- 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;
1073
1179
  const model=T.cloneSkeleton(asset.scene);
1180
+ model.traverse(n=>{if(n.isSkinnedMesh)n.frustumCulled=false;});
1074
1181
  const offset=helperOffset(i,agents.length);
1075
1182
  model.scale.setScalar(.45);model.position.set(unit.root.position.x+offset.x,.27,unit.root.position.z+offset.z);
1076
1183
  unit.crew.add(model);
1077
1184
  const mixer=new T.AnimationMixer(model);
1078
1185
  let yard=null;asset.scene.traverse(n=>{if(n.userData?.yard)yard=n.userData.yard;});
1079
- 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};
1080
1187
  this.helperClip(helper,agent.pose);
1081
1188
  mixer.update(.3+(hash(agent.id)%40)/10);
1082
1189
  unit.helpers.push(helper);
@@ -1084,7 +1191,7 @@ export class YardRenderer {
1084
1191
  } catch {
1085
1192
  // The next state transition can rebuild this incomplete squad. Routine
1086
1193
  // unchanged updates do not call helpers(), so failure cannot loop.
1087
- if(!this.disposed&&generation===this.request&&rev===unit.revision&&unit.root.parent)unit.helperSignature=null;
1194
+ if(!stale())unit.helperSignature=null;
1088
1195
  }
1089
1196
  }));
1090
1197
  }
@@ -1154,6 +1261,8 @@ export class YardRenderer {
1154
1261
  const rect=this.canvas.getBoundingClientRect();
1155
1262
  this.pointer.set((event.clientX-rect.left)/rect.width*2-1,-(event.clientY-rect.top)/rect.height*2+1);
1156
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();});
1157
1266
  const hits=this.ray.intersectObjects([...this.halls.values(),...this.units.values()].flatMap(i=>i.crew?[i.root,i.crew]:[i.root]),true);
1158
1267
  for(const hit of hits) { for(let node=hit.object;node;node=node.parent) if(node.userData.entity)return node.userData.entity; }
1159
1268
  return null;
@@ -1341,7 +1450,7 @@ export class YardRenderer {
1341
1450
  this.clearFireworks();this.fireworkTexture?.dispose();
1342
1451
  for(const spark of this.sparks){spark.points.geometry.dispose();spark.points.material.dispose();}this.sparks=[];
1343
1452
  this.releaseEffects();this.clearProps();
1344
- 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;
1345
1454
  this.resizeObserver.disconnect();this.controls.dispose();this.clearUnits();
1346
1455
  for(const item of this.halls.values()){item.label.remove();item.chip.remove();}this.halls.clear();this.list.remove();
1347
1456
  disposeWorld(this.world);
package/web/yard/view.js CHANGED
@@ -1,4 +1,4 @@
1
- import { WORLDS, sessionPose } from './model.mjs';
1
+ import { WORLDS, PROVIDER_ORDER, sessionPose } from './model.mjs';
2
2
 
3
3
  const $ = id => document.getElementById(id);
4
4
  const make = (tag, cls, text) => Object.assign(document.createElement(tag), { className: cls, ...(text === undefined ? {} : { textContent: text }) });
@@ -17,6 +17,8 @@ async function boundedLoad(start, onProgress) {
17
17
  } finally { settled = true; clearTimeout(timer); }
18
18
  }
19
19
  const megabytes = bytes => (bytes / 1048576).toFixed(1) + ' MB';
20
+ const CREWS = { anthropic: 'Claude Code', openai: 'Codex CLI', google: 'Antigravity CLI', xai: 'Grok Build', shell: 'Shell', docker: 'Docker Agent' };
21
+ const pieceKind = slot => slot === 'companion' ? 'companion' : 'character';
20
22
 
21
23
  /** A layout with injected canonical controls. No API, sockets, or business rules. */
22
24
  export function initYard(controller) {
@@ -36,9 +38,18 @@ export function initYard(controller) {
36
38
  return world && !world.problem && unloadable.get(skin) !== world.revision ? world : null;
37
39
  };
38
40
  const titleFor = (skin) => customFor(skin)?.title ?? WORLDS[skin].title;
41
+ const worldShown = new Map();
39
42
  const loadWorld = (target, skin, theme, progress) => {
43
+ if (target.skin !== skin) {
44
+ worldShown.delete(skin);
45
+ for (const [key, shown] of pieceShown) if (key.startsWith(skin + '/') && shown.ok) pieceShown.delete(key);
46
+ target.setPieces(skin, piecesFor(skin));
47
+ worldSpot();
48
+ }
40
49
  const custom = customFor(skin);
41
- return target.setWorld(skin, theme, { progress, custom }).catch((err) => {
50
+ return target.setWorld(skin, theme, { progress, custom }).then(() => {
51
+ if (custom) { worldShown.set(skin, custom.revision); worldSpot(); }
52
+ }, (err) => {
42
53
  if (!custom || disposed) throw err;
43
54
  unloadable.set(skin, custom.revision);
44
55
  if (skin === lastSkin) lastRevision = null;
@@ -46,6 +57,99 @@ export function initYard(controller) {
46
57
  return target.setWorld(skin, theme, { progress });
47
58
  });
48
59
  };
60
+ const pieceShown = new Map();
61
+ const pieceKey = (skin, slot) => skin + '/' + slot;
62
+ const piecesOf = (skin) => controller.yardPieces?.(skin) ?? {};
63
+ const piecesFor = (skin) => {
64
+ const pieces = {};
65
+ for (const [slot, piece] of Object.entries(piecesOf(skin))) {
66
+ const shown = pieceShown.get(pieceKey(skin, slot));
67
+ if (piece.revision && !(shown?.revision === piece.revision && !shown.ok)) pieces[slot] = { revision: piece.revision };
68
+ }
69
+ return pieces;
70
+ };
71
+ const syncPieces = (target) => {
72
+ const skin = root.dataset.skin;
73
+ if (target && WORLDS[skin]) target.setPieces(skin, piecesFor(skin));
74
+ };
75
+ const pieceDrawn = ({ skin, slot, revision, ok }) => {
76
+ if (disposed || piecesOf(skin)[slot]?.revision !== revision) return;
77
+ const key = pieceKey(skin, slot), was = pieceShown.get(key);
78
+ if (was?.revision === revision && (!was.ok || was.ok === ok)) return;
79
+ pieceShown.set(key, { revision, ok });
80
+ if (!ok) for (const target of [renderer, pendingRenderer]) target?.setPieces(skin, piecesFor(skin));
81
+ worldSpot();
82
+ };
83
+ function worldStatus(skin) {
84
+ const world = controller.yardWorld(skin);
85
+ if (!world) return { state: 'original', text: 'Original' };
86
+ if (world.problem) return { state: 'failed', text: world.problem + ' Showing the original.' };
87
+ if (!customFor(skin)) return { state: 'failed', text: world.title + ' could not load. Showing the original.' };
88
+ return worldShown.get(skin) === world.revision ? { state: 'custom', text: 'Customized: ' + world.title } : { state: 'loading', text: 'Loading ' + world.title + '…' };
89
+ }
90
+ function pieceStatus(skin, slot) {
91
+ const piece = piecesOf(skin)[slot], kind = pieceKind(slot);
92
+ if (!piece) return { state: 'original', text: 'Original' };
93
+ if (piece.problem) return { state: 'failed', text: piece.problem + ' Showing the original ' + kind + '.' };
94
+ const shown = pieceShown.get(pieceKey(skin, slot));
95
+ if (shown?.revision !== piece.revision) return { state: 'loading', text: 'Loading ' + piece.title + '…' };
96
+ return shown.ok ? { state: 'custom', text: 'Customized: ' + piece.title } : { state: 'failed', text: piece.title + ' could not load. Showing the original ' + kind + '.' };
97
+ }
98
+ const slotName = (slot) => slot === 'world' ? 'World' : slot === 'companion' ? 'Companions' : snapshot.providers.find(p => p.id === slot)?.tool || CREWS[slot];
99
+ const customizeRows = new Map();
100
+ function customizeRow(slot) {
101
+ if (customizeRows.has(slot)) return customizeRows.get(slot);
102
+ const row = make('li', 'flag-row yard-customize-row'), copy = make('div', 'yard-customize-copy'), actions = make('div', 'yard-customize-actions');
103
+ const name = make('span', 'flag-name'), status = make('span', 'yard-customize-status'), note = make('span', 'yard-customize-note');
104
+ const replace = make('button', 'btn', 'Replace'), restore = make('button', 'btn', 'Restore original');
105
+ replace.type = restore.type = 'button';
106
+ note.hidden = slot === 'world';
107
+ const skin = () => root.dataset.skin;
108
+ replace.addEventListener('click', () => slot === 'world' ? controller.replaceYard(skin()) : controller.replacePiece(skin(), slot));
109
+ restore.addEventListener('click', () => slot === 'world' ? controller.restoreYard(skin()) : controller.restorePiece(skin(), slot));
110
+ copy.append(name, status, note);
111
+ actions.append(replace, restore);
112
+ row.append(copy, actions);
113
+ row.dataset.slot = slot;
114
+ const parts = { row, name, status, note, replace, restore };
115
+ customizeRows.set(slot, parts);
116
+ return parts;
117
+ }
118
+ function paintCustomize() {
119
+ const dialog = $('yard-customize'), skin = root.dataset.skin;
120
+ if (!dialog.open) return;
121
+ if (!WORLDS[skin]) { dialog.close(); return; }
122
+ $('yard-customize-sub').textContent = WORLDS[skin].title + ' only. Other Yards keep their own.';
123
+ const busy = Boolean(controller.yardBusy?.()) || !snapshot.connected;
124
+ const heading = (text) => {
125
+ if (!customizeRows.has(text)) { const item = make('li', 'yard-customize-heading', text); item.setAttribute('role', 'presentation'); customizeRows.set(text, item); }
126
+ return customizeRows.get(text);
127
+ };
128
+ const items = [];
129
+ for (const [title, slots] of [['Yard', ['world']], ['Characters', PROVIDER_ORDER], ['Companions', ['companion']]]) {
130
+ items.push(heading(title));
131
+ for (const slot of slots) {
132
+ const parts = customizeRow(slot), status = controller.yardChecking?.() === pieceKey(skin, slot) ? { state: 'loading', text: 'Checking replacement…' } : slot === 'world' ? worldStatus(skin) : pieceStatus(skin, slot);
133
+ const installed = slot === 'world' ? Boolean(controller.yardWorld(skin)) : Boolean(piecesOf(skin)[slot]);
134
+ parts.name.textContent = slotName(slot);
135
+ parts.status.textContent = status.text;
136
+ if (slot !== 'world') parts.note.textContent = slot === 'companion' ? 'Shows beside sessions with helpers in this Yard.'
137
+ : 'Shows on your ' + slotName(slot) + ' sessions in this Yard' + (slot === 'shell' ? ', and in gold on Terminal AI Chat’s.' : '.');
138
+ parts.row.dataset.state = status.state;
139
+ parts.replace.disabled = busy;
140
+ parts.restore.disabled = busy || !installed;
141
+ parts.replace.setAttribute('aria-label', 'Replace ' + slotName(slot));
142
+ parts.restore.setAttribute('aria-label', 'Restore the original ' + slotName(slot));
143
+ items.push(parts.row);
144
+ }
145
+ }
146
+ const list = $('yard-customize-list');
147
+ if (items.some((item, i) => list.children[i] !== item) || list.children.length !== items.length) {
148
+ const focused = list.contains(document.activeElement) ? document.activeElement : null;
149
+ list.replaceChildren(...items);
150
+ focused?.focus();
151
+ }
152
+ }
49
153
  function worldSpot() {
50
154
  const skin = root.dataset.skin;
51
155
  if (!WORLDS[skin]) return;
@@ -53,11 +157,11 @@ export function initYard(controller) {
53
157
  $('yard-title').textContent = titleFor(skin);
54
158
  $('yard-title').classList.toggle('yard-title-custom', Boolean(customFor(skin)));
55
159
  const reason = world?.problem || (world && !customFor(skin) ? world.title + ' could not load.' : '');
56
- $('yard-world-replace').hidden = !controller.canReplaceYard();
57
- $('yard-world-replace').textContent = world ? 'Replace' : 'Replace this Yard';
58
- $('yard-world-restore').hidden = !world;
59
- $('yard-world-note').hidden = !reason;
60
- $('yard-world-note').textContent = reason && reason + ' This Yard is showing its original.';
160
+ const failed = !reason && Object.keys(piecesOf(skin)).map(slot => pieceStatus(skin, slot)).find(status => status.state === 'failed');
161
+ $('yard-customize-open').hidden = !controller.canReplaceYard();
162
+ $('yard-world-note').hidden = !reason && !failed;
163
+ $('yard-world-note').textContent = reason ? reason + ' This Yard is showing its original.' : failed ? failed.text : '';
164
+ paintCustomize();
61
165
  }
62
166
  const celebrate = () => { if (!disposed && sceneVisible()) renderer?.fireworks(); };
63
167
  const fireworksButton = () => {
@@ -96,7 +200,7 @@ export function initYard(controller) {
96
200
  if (allowed) yardChoice.parentElement.removeAttribute('title');
97
201
  else yardChoice.parentElement.title = `${skinEntry()?.name || 'This skin'} has no yard yet.`;
98
202
  $('yard').hidden = view !== 'yard';
99
- if (view !== 'yard') closePhoto();
203
+ if (view !== 'yard') { closePhoto(); $('yard-customize').close(); }
100
204
  $('providers').hidden = view === 'yard';
101
205
  $('providers').inert = view === 'yard';
102
206
  for (const section of document.querySelectorAll('#app > section.sessions, #app > section.news')) {
@@ -176,6 +280,7 @@ export function initYard(controller) {
176
280
  select: next => select(next),
177
281
  open: id => controller.openSession(id),
178
282
  error: () => { if (attempt === sceneAttempt) failScene(); },
283
+ piece: pieceDrawn,
179
284
  });
180
285
  pendingRenderer = candidate;
181
286
  // Appearance can change while the first world is loading.
@@ -184,10 +289,12 @@ export function initYard(controller) {
184
289
  skin = root.dataset.skin; theme = root.dataset.theme;
185
290
  if (!WORLDS[skin]) { candidate.dispose(); return; }
186
291
  revision = customFor(skin)?.revision ?? null;
292
+ syncPieces(candidate);
187
293
  showLoading('Loading ' + titleFor(skin) + '…', 'scene');
188
294
  await boundedLoad(progress => loadWorld(candidate, skin, theme, progress), loadingProgress);
189
295
  } while (!disposed && !candidate.disposed && attempt === sceneAttempt && (skin !== root.dataset.skin || theme !== root.dataset.theme || revision !== (customFor(skin)?.revision ?? null)));
190
296
  if (disposed || candidate.disposed || attempt !== sceneAttempt) { candidate.dispose(); return; }
297
+ syncPieces(candidate);
191
298
  pendingRenderer = null;
192
299
  renderer = candidate;
193
300
  lastSkin = skin; lastTheme = theme; lastRevision = revision;
@@ -318,9 +425,10 @@ export function initYard(controller) {
318
425
  lastSkin = root.dataset.skin; lastTheme = root.dataset.theme; lastRevision = revision;
319
426
  swapWorld();
320
427
  }
428
+ syncPieces(renderer);
321
429
  renderer.update(snapshot.providers,snapshot.sessions);
322
430
  renderer.setActive(sceneVisible());
323
- } else ensureScene();
431
+ } else { syncPieces(pendingRenderer); ensureScene(); }
324
432
  }
325
433
  $('view-cards').addEventListener('change',() => setView('cards'));
326
434
  $('view-yard').addEventListener('change',() => { if (skinAllowsYard()) setView('yard'); });
@@ -342,8 +450,12 @@ export function initYard(controller) {
342
450
  lastSkin = ''; lastTheme = '';
343
451
  update();
344
452
  });
345
- $('yard-world-replace').addEventListener('click',() => controller.replaceYard(root.dataset.skin));
346
- $('yard-world-restore').addEventListener('click',() => controller.restoreYard(root.dataset.skin));
453
+ $('yard-customize-open').addEventListener('click',() => {
454
+ if (!$('yard-customize').open) $('yard-customize').showModal();
455
+ paintCustomize();
456
+ });
457
+ $('yard-customize-close').addEventListener('click',() => $('yard-customize').close());
458
+ $('yard-customize').addEventListener('click',event => { if (event.target === $('yard-customize')) $('yard-customize').close(); });
347
459
  $('yard-filter').addEventListener('input',roster);
348
460
  $('yard-news').addEventListener('click',controller.openNews);
349
461
  $('yard-deselect').addEventListener('click',() => { select(null); $('yard-filter').focus(); });