From 48ec79edd97e2519e52e920862dd8d1039c5224e Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 20:15:51 -0500 Subject: [PATCH 01/14] feat: add Vibe Mode to the Yard A toggle beside Fireworks, kept in the browser for every Yard. While on, every character wanders the ground all worlds keep clear at its usual walking speed, and every helper flies its own loop over the camera's pan bounds. Turning it off, or reduced motion, puts everyone back where their sessions' states place them. Co-Authored-By: Claude Fable 5.2 --- tests/browser/yard.mjs | 84 ++++++++++++++++++++++++++++++++++++- tests/page-dialogs.test.mjs | 2 +- tests/yard.test.mjs | 42 ++++++++++++++++++- web/index.html | 2 +- web/yard/model.mjs | 30 +++++++++++++ web/yard/renderer.js | 50 +++++++++++++++++----- web/yard/view.js | 20 ++++++++- web/yard/yard.css | 1 + 8 files changed, 214 insertions(+), 17 deletions(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index a3854c2..15f65e8 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -1098,7 +1098,7 @@ test('the camera controls stay whole and reachable beside the swap status and he return {buttons,overlaps}; })()`); const at=`${name}, ${status} shown`; - assert.deepEqual(layout.buttons.map(c=>c.label),['Zoom in','Zoom out','Overview','Focus selected','Save a picture of the Yard','Fireworks in this Yard only'],at); + assert.deepEqual(layout.buttons.map(c=>c.label),['Zoom in','Zoom out','Overview','Focus selected','Save a picture of the Yard','Fireworks in this Yard only','Vibe Mode'],at); assert.deepEqual(layout.buttons.filter(c=>!c.inside||!c.reached).map(c=>c.label),[],at); assert.equal(new Set(layout.buttons.map(c=>c.height)).size,1,`every label stays on one line: ${at}`); assert.deepEqual(layout.overlaps,[],at); @@ -2087,4 +2087,84 @@ test('the row being replaced says Checking replacement until the check answers', answer(); await b.wait(status+"==='Customized: Mine'"); assert.deepEqual(b.errors,[]); -}); \ No newline at end of file +}); +test('Vibe Mode sets every character wandering and every helper flying, and turning it off or reducing motion puts them back',options,async t=>{ + const {f,b}=await setup(t); + const T0=Date.parse('2026-10-13T12:00:00.000Z'),iso=ms=>new Date(T0-ms).toISOString(); + const sessions=[...f.data.values()].slice(0,3).map((s,i)=>({...s,activity:'active',lastOutputAt:iso(0),createdAt:iso(3600000),shells:[], + agents:i===0?[{id:'a1',name:'Explorer',status:'working'}]:[]})); + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + window.__model=await import('/yard/model.mjs'); + const host=document.createElement('div'),labels=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${T0}}); + window.__sessions=${q(sessions)};window.__providers=${q(f.providers)}; + await __renderer.setWorld('guild','dark');__renderer.update(__providers,__sessions); + window.__units=()=>[...__renderer.units.values()]; + window.__home=()=>[...__renderer.units].every(([id,u])=>{const p=__renderer.staged.places.get(id);return !u.moving&&u.root.position.x===p.x&&u.root.position.z===p.z;}); + window.__run=n=>{for(let i=0;i{let n=0;while(__units().some(u=>u.moving)&&n<4000){__renderer.step(.05);n++;}return n;}; + window.__helper=()=>{const h=__renderer.units.get(__sessions[0].id).helpers[0];return {y:h.model.position.y,scale:h.model.scale.x,clip:h.action.getClip().name};}; + })()`); + await b.wait('__units().every(u=>u.loaded)&&__units()[0].helpers.length===1'); + assert.equal(await b.evaluate('__home()'),true,'sessions open at their stations'); + const roaming=await b.evaluate(`(()=>{ + __renderer.setVibe(true); + const u=__units()[0],from={x:u.root.position.x,z:u.root.position.z},leg=Math.hypot(u.path[0].x-from.x,u.path[0].z-from.z); + __renderer.step(.05); + const first={moved:Math.hypot(u.root.position.x-from.x,u.root.position.z-from.z),pace:u.speed*.05,leg}; + __run(800); + const boxes=[...__model.providerPositions(__providers)].map(([id,p])=>({...p,half:id==='docker'?__model.STAGING.hall.docker:__model.STAGING.hall.half})); + return {first,moving:__units().every(u=>u.moving),clips:__units().map(u=>u.clip),legs:__units().map(u=>u.roamed), + inHall:__units().some(u=>boxes.some(b=>Math.abs(u.root.position.x-b.x)'walk')); + assert.ok(roaming.legs.every(n=>n>=1),'each has gone on from its first stop'); + assert.equal(roaming.inHall,false,'none walks through a hall'); + const flight=await b.evaluate('__model.VIBE.flight'); + assert.ok(roaming.helper.y>=flight.height[0]-.5&&roaming.helper.y<=flight.height[1],'the helper flies'); + assert.ok(Math.abs(roaming.helper.scale-flight.scale)<1e-6,'at its flying size'); + assert.equal(roaming.helper.clip,'move'); + assert.equal(await b.evaluate(`(()=>{const before=__units().map(u=>u.target);__renderer.update(__providers,__sessions);return __units().every((u,i)=>u.target===before[i]&&u.moving);})()`),true); + assert.ok(await b.evaluate('(__renderer.setVibe(false),__settle())')<4000,'turned off, everyone walks back'); + assert.equal(await b.evaluate('__home()'),true); + assert.ok(await b.evaluate('__units().every(u=>__model.CLIPS.character.work.includes(u.clip))'),'and takes up its work'); + const landed=await b.evaluate('(__run(200),__helper())'); + assert.ok(Math.abs(landed.y-.27)<.01&&Math.abs(landed.scale-.45)<1e-6,'the helper comes back down beside its character'); + assert.equal(await b.evaluate('(__renderer.setVibe(true),__run(100),__renderer.setReducedMotion(true),__home())'),true,'reduced motion puts everyone back at once'); + assert.deepEqual(await b.evaluate('__helper()').then(h=>[h.y,h.scale]),[.27,.45]); + assert.equal(await b.evaluate('(__renderer.setReducedMotion(false),__units().every(u=>u.moving))'),true,'and they wander again when motion returns'); + await b.evaluate('__renderer.dispose()'); + assert.deepEqual(b.errors,[]); +}); + +test('the Vibe Mode button stays on across Yards and reloads, and reduced motion disables it',options,async t=>{ + const {b}=await setup(t,{yard:true}); + const button="document.querySelector('#yard-vibe')",state='['+button+'.getAttribute("aria-pressed"),'+button+'.disabled,localStorage.getItem("agentGuild.vibe")]'; + await ready(b); + assert.deepEqual(await b.evaluate(state),['false',true,null]); + assert.match(await b.evaluate(button+'.title'),/reduced motion/); + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'),original=YardRenderer.prototype.setVibe; + window.__vibes=[]; + YardRenderer.prototype.setVibe=function(value){window.__vibes.push(value);window.__yardRenderer=this;return original.call(this,value);}; + })()`); + await b.send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'no-preference'}]}); + await b.wait(button+'.disabled===false'); + assert.equal(await b.evaluate(button+'.getAttribute("aria-label")'),'Vibe Mode'); + assert.equal(await b.evaluate(button+'.previousElementSibling.id'),'yard-fireworks'); + await b.click('#yard-vibe'); + assert.deepEqual(await b.evaluate(state),['true',false,'on']); + assert.deepEqual(await b.evaluate('[__vibes,__yardRenderer.vibe]'),[[true],true]); + await b.evaluate("document.documentElement.dataset.skin='orbital'"); + await b.wait("document.querySelector('#yard-stage').dataset.world==='orbital'"); + assert.deepEqual(await b.evaluate('['+button+'.getAttribute("aria-pressed"),__yardRenderer.vibe]'),['true',true],'another Yard keeps it'); + await b.reload();await ready(b); + assert.deepEqual(await b.evaluate(state),['true',false,'on'],'a reload keeps it'); + await b.click('#yard-vibe'); + assert.deepEqual(await b.evaluate(state),['false',false,'off']); + assert.deepEqual(b.errors,[]); +}); diff --git a/tests/page-dialogs.test.mjs b/tests/page-dialogs.test.mjs index 684d823..23d8004 100644 --- a/tests/page-dialogs.test.mjs +++ b/tests/page-dialogs.test.mjs @@ -226,7 +226,7 @@ test('pending Photo opens are invalidated by Yard shutdown, sign-out and manager clearTimeout: noop, swapReveal: 0, observer: { disconnect: noop }, pendingRenderer: null, renderer: null, document: { removeEventListener: noop }, motion: { removeEventListener: noop }, window: { removeEventListener: noop }, visibility: noop, motionChange: noop, pageHide: noop, - $: () => ({ removeEventListener: noop }), celebrate: noop, fireworksButton: noop, + $: () => ({ removeEventListener: noop }), celebrate: noop, toggleVibe: noop, fireworksButton: noop, }); } else if (leave === 'sign out') p.showAuth('Sign in again.'); else p.enterStopping(0, leave === 'restart'); diff --git a/tests/yard.test.mjs b/tests/yard.test.mjs index 15bc73a..c1ee273 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync, statSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import { sessionPose, familiarPose, marksFor, layoutSessions, providerPositions, characterIndex, lookLimits, yardExtras, WORLDS, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, plateExtent, - stage, stagingMode, nextStagingChange, restSpots, route, clipFor, doorFor, stationProp, STAGING, CLIPS, SKELETON, PROVIDER_ORDER, photoSize, photoName } from '../web/yard/model.mjs'; + stage, stagingMode, nextStagingChange, restSpots, route, clipFor, doorFor, stationProp, STAGING, CLIPS, SKELETON, PROVIDER_ORDER, photoSize, photoName, VIBE, vibeStop, vibeFlight } from '../web/yard/model.mjs'; import { goldenVectors } from '../scripts/yard-golden.mjs'; import { Matrix4, Vector3 } from 'three'; const providers=['anthropic','openai','google','xai','shell','docker'].map(id=>({id})); @@ -159,6 +159,46 @@ test('clip choices are seeded per session and come from the contract clip set',( assert.equal(seq('s1'),seq('s1')); assert.notEqual(seq('s1'),seq('s2')); }); +// Clearance.live_distance in agent-guild-builders: how far a point is outside the ground worlds keep clear. +const keptClearBy=(x,z)=>Math.min(Math.max(0,Math.hypot(x,z)-14.7),Math.hypot(Math.max(Math.abs(x)-13,0),Math.max(z-32,-z,0))); +test('Vibe Mode wanders only the ground every world keeps clear, round the halls, the centrepiece and the station props',()=>{ + const custom=[...providers,{id:'custom-1'},{id:'custom-2'}]; + const props=[...layoutSessions(Array.from({length:12},(_,i)=>session('s'+i,providers[i%6].id)),custom).entries()].map(([id,p])=>stationProp(p,id)); + const boxes=[...[...providerPositions(custom)].map(([id,p])=>({...p,half:id==='docker'?STAGING.hall.docker:STAGING.hall.half})), + {x:STAGING.plaza.x,z:STAGING.plaza.z,half:STAGING.plaza.half},...props.map(p=>({x:p.x,z:p.z,half:STAGING.station.half}))]; + const inside=p=>boxes.some(b=>Math.abs(p.x-b.x)Array.from({length:60},(_,n)=>vibeStop(id,n,custom,props)); + for(const id of ['s0','s1','s7']){ + let from=stops(id).at(-1); + for(const stop of stops(id)){ + assert.equal(keptClearBy(stop.x,stop.z),0,JSON.stringify(stop)); + assert.ok(!boxes.some(b=>Math.abs(stop.x-b.x)p.x),zs=stops(id).map(p=>p.z); + assert.ok(Math.max(...xs)-Math.min(...xs)>VIBE.roam.radius&&Math.max(...zs)-Math.min(...zs)>VIBE.roam.radius,'the stops spread over the ground'); + } + assert.deepEqual(stops('s0'),stops('s0')); + assert.notDeepEqual(stops('s0'),stops('s1')); +}); +test('Vibe Mode flies each helper its own smooth loop inside the pan bounds, above the halls',()=>{ + const {minX,maxX,minZ,maxZ}=CAMERA.pan,[low,high]=VIBE.flight.height; + assert.ok(low>STAGING.hall.dockerHeight); + for(const key of ['s0:a1','s0:a2','s1:a3']){ + let last=vibeFlight(key,0),reach=0; + for(let t=.1;t<=VIBE.flight.lap[1];t+=.1){ + const p=vibeFlight(key,t); + assert.ok(p.x>=minX&&p.x<=maxX&&p.z>=minZ&&p.z<=maxZ&&p.y>=low-1e-9&&p.y<=high+1e-9,JSON.stringify(p)); + assert.ok(Math.hypot(p.x-last.x,p.y-last.y,p.z-last.z)<1.5,'no jump between moments'); + reach=Math.max(reach,Math.hypot(p.x,p.z-(minZ+maxZ)/2));last=p; + } + assert.ok(reach>STAGING.plaza.rings.at(-1)[0],'the loop goes out past the plaza'); + } + assert.deepEqual(vibeFlight('s0:a1',12.5),vibeFlight('s0:a1',12.5)); + assert.notDeepEqual(vibeFlight('s0:a1',12.5),vibeFlight('s0:a2',12.5)); +}); test('the staging contract matches its golden vectors',()=>{ const golden=JSON.parse(readFileSync(new URL('./fixtures/yard-choreography.json',import.meta.url),'utf8')); assert.deepEqual(JSON.parse(JSON.stringify(goldenVectors())),golden,'run node scripts/yard-golden.mjs --write only for a deliberate contract change (docs/yard/CONTRACT.md)'); diff --git a/web/index.html b/web/index.html index 81b3484..0a9283a 100644 --- a/web/index.html +++ b/web/index.html @@ -151,7 +151,7 @@

Start a session

Preparing your yard…
-
+

Drag to explore · Scroll to zoom · Select to inspect · Double-click a session to openDrag to explore · Pinch to zoom · Tap to inspect · Open from the Inspector

diff --git a/web/yard/model.mjs b/web/yard/model.mjs index 37a0b62..6a2baf6 100644 --- a/web/yard/model.mjs +++ b/web/yard/model.mjs @@ -434,6 +434,36 @@ export function clipFor(id, mode, n) { if (mode === 'work') return n % 3 === 2 && h % 2 ? set[1] : set[0]; return n % 3 === 2 ? set[1 + (h % (set.length - 1))] : set[0]; } +export const VIBE = { + // Must stay inside the ground every world keeps clear (Clearance.live_distance in agent-guild-builders). + roam: { radius: 13.2, half: 12.5, front: 24, margin: .9 }, + flight: { height: [8.5, 12], lap: [26, 44], reach: [.45, 1], scale: 1.3 }, +}; +function unit(value) { + let h = hash(value); + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} +export function vibeStop(id, n, providers, props = []) { + const { radius, half, front, margin } = VIBE.roam; + const boxes = [...obstacles(providers).map(box => ({ ...box, half: box.half + margin })), + ...props.map(p => ({ x: p.x, z: p.z, half: STAGING.station.half + margin }))]; + for (let k = 0; k < 48; k++) { + const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(-radius + unit(id + ':vibe:z:' + n + ':' + k) * (radius + front)); + const clear = Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0); + if (clear && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half)) return { x, z }; + } + return { x: STAGING.plaza.x, z: round(STAGING.plaza.z + STAGING.plaza.half + margin + STAGING.station.half) }; +} +export function vibeFlight(key, seconds) { + const { height, lap, reach } = VIBE.flight, { minX, maxX, minZ, maxZ } = CAMERA.pan; + const pick = (name, [low, high]) => low + unit(key + ':vibe:' + name) * (high - low); + const out = pick('reach', reach), turn = unit(key + ':vibe:turn') < .5 ? 1 : -1, lobes = 2 + Math.floor(unit(key + ':vibe:lobes') * 3); + const angle = turn * 2 * Math.PI * (seconds / pick('lap', lap) + unit(key + ':vibe:start')); + const wide = out * (1 + .12 * Math.sin(lobes * angle + pick('phase', [0, 2 * Math.PI]))) / 1.12; + return { x: (minX + maxX) / 2 + Math.cos(angle) * (maxX - minX) / 2 * wide, z: (minZ + maxZ) / 2 + Math.sin(angle) * (maxZ - minZ) / 2 * wide, + y: (height[0] + height[1]) / 2 + (height[1] - height[0]) / 2 * Math.sin(2 * angle + pick('rise', [0, 2 * Math.PI])) }; +} // Where a session's helpers stand, around it, as the renderer has always placed them. export function helperOffset(i, count) { const angle = (i / Math.min(count, 6)) * Math.PI * 2; diff --git a/web/yard/renderer.js b/web/yard/renderer.js index bfc9f8d..89ea670 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -1,6 +1,6 @@ import * as T from './vendor/engine.js'; import { WORLDS, PROVIDER_ORDER, characterIndex, GILDED, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, providerPositions, sessionPose, familiarPose, hash, - stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, yardExtras, playableClip, STAGING, photoSize, marksFor, SHOWN_HELPERS } from './model.mjs'; + stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, yardExtras, playableClip, STAGING, photoSize, marksFor, SHOWN_HELPERS, VIBE, vibeStop, vibeFlight } from './model.mjs'; const ASSETS = new URL('./assets/', import.meta.url); const vector = new T.Vector3(); @@ -147,7 +147,7 @@ export class YardRenderer { this.providers=[];this.sessions=[];this.selected=null;this.world=null;this.skin=null;this.request=0;this.worldRequest=0;this.pending=null; this.staged=null;this.seen=new Set();this.stageTimer=0;this.sparks=[];this.shows=[];this.marks=new T.Group();this.scene.add(this.marks);this.glyphs=null;this.markPlane=new T.PlaneGeometry(1,1); this.props=new Map();this.propSpots=[];this.station=null;this.effects=null; - this.active=false;this.reduced=false;this.disposed=false;this.frame=0;this.lastTime=0;this.dirty=true; + this.active=false;this.reduced=false;this.vibe=false;this.vibeTime=0;this.disposed=false;this.frame=0;this.lastTime=0;this.dirty=true; this.crowd=null;this.rows=new Map(); this.list=Object.assign(document.createElement('div'),{className:'yard-crowd-list',id:'yard-crowd-list-'+(++crowdLists),hidden:true}); this.list.setAttribute('role','group'); @@ -922,6 +922,7 @@ export class YardRenderer { goTo(unit,place) { const target=unit.target; unit.mode=place.mode; + if(this.vibing()){if(!unit.moving)this.roam(unit);return;} if(target&&target.x===place.x&&target.z===place.z){unit.target=place;if(!unit.moving)this.play(unit);return;} unit.target=place; const at={x:unit.root.position.x,z:unit.root.position.z}; @@ -929,12 +930,33 @@ export class YardRenderer { else {unit.path=route(at,place,this.providers,this.propSpots);unit.moving=true;} this.play(unit); } + roam(unit) { + unit.roamed=(unit.roamed??-1)+1; + const at={x:unit.root.position.x,z:unit.root.position.z},stop=vibeStop(unit.session.id,unit.roamed,this.providers,this.propSpots); + unit.target=stop;unit.path=route(at,stop,this.providers,this.propSpots);unit.moving=true; + this.play(unit); + } + vibing() { return this.vibe&&!this.reduced; } + setVibe(value) { + if(this.vibe===Boolean(value))return; + const was=this.vibing(); + this.vibe=Boolean(value); + if(was!==this.vibing())this.restage(); + } + restage() { + if(!this.staged)return; + for(const [id,unit]of this.units){ + const place=this.staged.places.get(id); + if(place){unit.target=null;this.goTo(unit,place);} + } + this.dirty=true;this.drawOnce(); + } // Straight to its place, helpers with it. arrive(unit) { const place=unit.target; unit.root.position.set(place.x,.17,place.z);unit.path=[];unit.heading=place.face;unit.moving=false; if(unit.model)unit.model.rotation.y=unit.heading; - for(const helper of unit.helpers)helper.model.position.set(place.x+helper.offset.x,.27,place.z+helper.offset.z); + for(const helper of unit.helpers){helper.model.position.set(place.x+helper.offset.x,.27,place.z+helper.offset.z);helper.model.scale.setScalar(.45);} } // The clip a character should be playing now: the walk while it moves, // otherwise its mode's seeded sequence (model.mjs clipFor). @@ -966,7 +988,7 @@ export class YardRenderer { // Moves a walking character along its path at its stride's speed, turning // smoothly, and turns a standing one to face its place. advance(unit,dt) { - const root=unit.root.position; + const root=unit.root.position,vibing=this.vibing(); let want=unit.target?.face??unit.heading; if(unit.moving&&unit.path.length) { let step=unit.speed*dt; @@ -976,7 +998,7 @@ export class YardRenderer { if(d<=step){root.x=next.x;root.z=next.z;unit.path.shift();step-=d;} else {root.x+=dx/d*step;root.z+=dz/d*step;step=0;} } - if(!unit.path.length){unit.moving=false;this.play(unit);} + if(!unit.path.length){if(vibing)this.roam(unit);else {unit.moving=false;this.play(unit);}} this.dirty=true; } const turn=angleTo(unit.heading,want); @@ -988,13 +1010,15 @@ export class YardRenderer { // Helpers trail their character, closing most of the gap in a second. const pull=1-Math.exp(-4*dt); for(const helper of unit.helpers){ - const tx=root.x+helper.offset.x,tz=root.z+helper.offset.z,p=helper.model.position; - const dx=tx-p.x,dz=tz-p.z,far=Math.hypot(dx,dz); - p.x+=dx*pull;p.z+=dz*pull; + const to=vibing?vibeFlight(unit.session.id+':'+helper.id,this.vibeTime):{x:root.x+helper.offset.x,y:.27,z:root.z+helper.offset.z}; + const p=helper.model.position,dx=to.x-p.x,dy=to.y-p.y,dz=to.z-p.z,far=Math.hypot(dx,dz); + const size=vibing?VIBE.flight.scale:.45,grow=size-helper.model.scale.x; + p.x+=dx*pull;p.y+=dy*pull;p.z+=dz*pull; + if(grow)helper.model.scale.setScalar(Math.abs(grow)<1e-3?size:helper.model.scale.x+grow*pull); if(far>.05)helper.model.rotation.y=Math.atan2(dx,dz); const moving=far>.08; if(moving!==helper.moving){helper.moving=moving;this.helperClip(helper,moving?'move':helper.pose);} - if(far>1e-3)this.dirty=true; + if(far>1e-3||Math.abs(dy)>1e-3||grow)this.dirty=true; } this.fire(unit); } @@ -1183,7 +1207,7 @@ export class YardRenderer { unit.crew.add(model); const mixer=new T.AnimationMixer(model); let yard=null;asset.scene.traverse(n=>{if(n.userData?.yard)yard=n.userData.yard;}); - const helper={id:agent.id,top:yardExtras(yard,'helper').height*model.scale.y,model,mixer,offset,pose:agent.pose,clips:asset.animations,moving:false,action:null}; + const helper={id:agent.id,height:yardExtras(yard,'helper').height,get top(){return this.height*this.model.scale.y;},model,mixer,offset,pose:agent.pose,clips:asset.animations,moving:false,action:null}; this.helperClip(helper,agent.pose); mixer.update(.3+(hash(agent.id)%40)/10); unit.helpers.push(helper); @@ -1343,7 +1367,9 @@ export class YardRenderer { this.list.style.top=Math.max(6,ct+half+4+lh<=h-6?ct+half+4:ct-half-4-lh)+'px'; } setReducedMotion(value) { + const was=this.vibing(); this.reduced=value; + if(was!==this.vibing())this.restage(); if(value)for(const unit of this.units.values()){this.unlook(unit);unit.lookYaw=0;unit.lookPitch=0;} if(value)for(const unit of this.units.values())if(unit.moving&&unit.target){this.arrive(unit);this.play(unit);} if(value){for(const item of this.halls.values())item.level=item.target;this.glow();this.stillEffects();this.clearFireworks();} @@ -1372,12 +1398,14 @@ export class YardRenderer { // Advances walks, turns, helpers, clips, glances, sparks and the halls' // glow and effects by dt seconds, without drawing. step(dt) { + const vibing=this.vibing(); + if(vibing)this.vibeTime+=dt; for(const unit of this.units.values()) { this.advance(unit,dt); // Characters out of view animate five times a second, enough to stay // in step for when the camera comes back to them. let step=dt; - if(unit.offscreen){unit.lag=(unit.lag||0)+dt;if(unit.lag<.2)continue;step=unit.lag;} + if(unit.offscreen&&!vibing){unit.lag=(unit.lag||0)+dt;if(unit.lag<.2)continue;step=unit.lag;} unit.lag=0; this.unlook(unit); for(const mixer of unit.mixers)mixer.update(step); diff --git a/web/yard/view.js b/web/yard/view.js index 394e1d5..b310aa5 100644 --- a/web/yard/view.js +++ b/web/yard/view.js @@ -169,6 +169,20 @@ export function initYard(controller) { button.disabled = !renderer || disposed || motion.matches; button.title = motion.matches ? 'Fireworks off: reduced motion is enabled' : 'Fireworks in this Yard only'; }; + let vibe = false; + try { vibe = localStorage.getItem('agentGuild.vibe') === 'on'; } catch {} + const vibeButton = () => { + const button = $('yard-vibe'); + button.setAttribute('aria-pressed', String(vibe)); + button.disabled = motion.matches; + button.title = motion.matches ? 'Vibe Mode off: reduced motion is enabled' : vibe ? 'Vibe Mode is on in every Yard' : 'Vibe Mode: everyone roams, in every Yard'; + }; + const toggleVibe = () => { + vibe = !vibe; + try { localStorage.setItem('agentGuild.vibe', vibe ? 'on' : 'off'); } catch {} + renderer?.setVibe(vibe); + vibeButton(); + }; const sceneVisible = () => view === 'yard' && !$('app').hidden && document.visibilityState !== 'hidden' && $('terminal-panel').hidden; const skinEntry = (skin = root.dataset.skin) => (window.agentGuildSkins || []).find((item) => item.id === skin) || null; const yardOff = root.dataset.yard === 'none'; @@ -299,6 +313,7 @@ export function initYard(controller) { renderer = candidate; lastSkin = skin; lastTheme = theme; lastRevision = revision; renderer.setReducedMotion(motion.matches); + renderer.setVibe(vibe); renderer.update(snapshot.providers, snapshot.sessions); renderer.select(selected); renderer.setActive(sceneVisible()); @@ -465,6 +480,8 @@ export function initYard(controller) { $('yard-zoom-out').addEventListener('click',() => renderer?.zoom(1/1.2)); $('yard-fireworks').addEventListener('click',celebrate); fireworksButton(); + $('yard-vibe').addEventListener('click',toggleVibe); + vibeButton(); $('yard-photo').hidden = !controller.photo; $('yard-photo').addEventListener('click',async () => { let YardPhoto; @@ -490,7 +507,7 @@ export function initYard(controller) { setView(preferYard && skinAllowsYard() ? 'yard' : 'cards', { remember: false }); renderer?.setActive(sceneVisible()); }; - const motionChange = () => { renderer?.setReducedMotion(motion.matches); fireworksButton(); }; + const motionChange = () => { renderer?.setReducedMotion(motion.matches); fireworksButton(); vibeButton(); }; const pageHide = () => renderer?.setActive(false); const observer = new MutationObserver((records) => { if (records.some((record) => record.attributeName === 'data-skin' || record.attributeName === 'data-theme')) onAppearance(); @@ -507,6 +524,7 @@ export function initYard(controller) { return { update, select, setView, closePhoto, celebrate, dispose() { closePhoto(); $('yard-fireworks').removeEventListener('click',celebrate); + $('yard-vibe').removeEventListener('click',toggleVibe); disposed=true; fireworksButton(); sceneAttempt++; worldAttempt++; clearTimeout(swapReveal); observer.disconnect(); pendingRenderer?.dispose(); renderer?.dispose(); document.removeEventListener('visibilitychange',visibility); motion.removeEventListener('change',motionChange); diff --git a/web/yard/yard.css b/web/yard/yard.css index 2b3c06e..9b42835 100644 --- a/web/yard/yard.css +++ b/web/yard/yard.css @@ -265,3 +265,4 @@ @container (max-width:440px) { .yard-play-text { display:none; } .yard-play-link { padding:0 7px; } } @container (max-width:380px) { .yard-play:not(.yard-play-solo) .yard-play-link { display:none; } .yard-play-chip { --play-more-w:44px; } .yard-play-more { padding:0; } .yard-play-more::before { display:none; } .yard-play-more-orb { display:block; grid-area:1 / 1; } .yard-play-caret { grid-area:1 / 1; place-self:end; width:9px; height:6px; margin:0 7px 6px 0; stroke-width:2.2; } } @container (max-width:340px) { :root:is([data-skin="orbital"],[data-skin="gnomeland"],[data-skin="goblinville"],[data-skin="grove"],[data-skin="guild"],[data-skin="professional"]) :is(.yard-eyebrow, #yard-subtitle) { display:none; } } +#yard-vibe[aria-pressed="true"] { border-color:var(--accent); color:var(--accent); } From f6fd0c20ce12b7e2e9274d2715b494753f66053f Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 20:24:28 -0500 Subject: [PATCH 02/14] feat: send Vibe Mode walkers to and fro across the Yard Each character's stops alternate between the courtyard round the halls and the open ground in front, so walkers keep crossing the middle instead of gathering in front. Co-Authored-By: Claude Fable 5.2 --- tests/yard.test.mjs | 3 ++- web/yard/model.mjs | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/yard.test.mjs b/tests/yard.test.mjs index c1ee273..3b08655 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -161,7 +161,7 @@ test('clip choices are seeded per session and come from the contract clip set',( }); // Clearance.live_distance in agent-guild-builders: how far a point is outside the ground worlds keep clear. const keptClearBy=(x,z)=>Math.min(Math.max(0,Math.hypot(x,z)-14.7),Math.hypot(Math.max(Math.abs(x)-13,0),Math.max(z-32,-z,0))); -test('Vibe Mode wanders only the ground every world keeps clear, round the halls, the centrepiece and the station props',()=>{ +test('Vibe Mode wanders only the ground every world keeps clear, to and fro across it, round the halls, the centrepiece and the station props',()=>{ const custom=[...providers,{id:'custom-1'},{id:'custom-2'}]; const props=[...layoutSessions(Array.from({length:12},(_,i)=>session('s'+i,providers[i%6].id)),custom).entries()].map(([id,p])=>stationProp(p,id)); const boxes=[...[...providerPositions(custom)].map(([id,p])=>({...p,half:id==='docker'?STAGING.hall.docker:STAGING.hall.half})), @@ -171,6 +171,7 @@ test('Vibe Mode wanders only the ground every world keeps clear, round the halls for(const id of ['s0','s1','s7']){ let from=stops(id).at(-1); for(const stop of stops(id)){ + assert.notEqual(stop.zMath.abs(stop.x-b.x)>> 16)) >>> 0) / 4294967296; } export function vibeStop(id, n, providers, props = []) { - const { radius, half, front, margin } = VIBE.roam; + const { radius, half, front, split, margin } = VIBE.roam; + const [near, far] = (n + hash(id)) % 2 ? [split, front] : [-radius, split]; const boxes = [...obstacles(providers).map(box => ({ ...box, half: box.half + margin })), ...props.map(p => ({ x: p.x, z: p.z, half: STAGING.station.half + margin }))]; for (let k = 0; k < 48; k++) { - const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(-radius + unit(id + ':vibe:z:' + n + ':' + k) * (radius + front)); + const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(near + unit(id + ':vibe:z:' + n + ':' + k) * (far - near)); const clear = Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0); if (clear && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half)) return { x, z }; } From 617269f3516c064cc4c6c4369b7dbcd4160069b2 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 20:30:41 -0500 Subject: [PATCH 03/14] feat: show Vibe Mode as a runner Co-Authored-By: Claude Fable 5.2 --- web/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/index.html b/web/index.html index 0a9283a..6a4e6dd 100644 --- a/web/index.html +++ b/web/index.html @@ -151,7 +151,7 @@

Start a session

Preparing your yard…
-
+

Drag to explore · Scroll to zoom · Select to inspect · Double-click a session to openDrag to explore · Pinch to zoom · Tap to inspect · Open from the Inspector

From b7961f2eb439e957abee731e7afe3d18784b488e Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 20:31:46 -0500 Subject: [PATCH 04/14] feat: call it Vibes Mode Co-Authored-By: Claude Fable 5.2 --- tests/browser/yard.mjs | 8 ++++---- tests/yard.test.mjs | 4 ++-- web/index.html | 2 +- web/yard/view.js | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index 15f65e8..35667f3 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -1098,7 +1098,7 @@ test('the camera controls stay whole and reachable beside the swap status and he return {buttons,overlaps}; })()`); const at=`${name}, ${status} shown`; - assert.deepEqual(layout.buttons.map(c=>c.label),['Zoom in','Zoom out','Overview','Focus selected','Save a picture of the Yard','Fireworks in this Yard only','Vibe Mode'],at); + assert.deepEqual(layout.buttons.map(c=>c.label),['Zoom in','Zoom out','Overview','Focus selected','Save a picture of the Yard','Fireworks in this Yard only','Vibes Mode'],at); assert.deepEqual(layout.buttons.filter(c=>!c.inside||!c.reached).map(c=>c.label),[],at); assert.equal(new Set(layout.buttons.map(c=>c.height)).size,1,`every label stays on one line: ${at}`); assert.deepEqual(layout.overlaps,[],at); @@ -2088,7 +2088,7 @@ test('the row being replaced says Checking replacement until the check answers', await b.wait(status+"==='Customized: Mine'"); assert.deepEqual(b.errors,[]); }); -test('Vibe Mode sets every character wandering and every helper flying, and turning it off or reducing motion puts them back',options,async t=>{ +test('Vibes Mode sets every character wandering and every helper flying, and turning it off or reducing motion puts them back',options,async t=>{ const {f,b}=await setup(t); const T0=Date.parse('2026-10-13T12:00:00.000Z'),iso=ms=>new Date(T0-ms).toISOString(); const sessions=[...f.data.values()].slice(0,3).map((s,i)=>({...s,activity:'active',lastOutputAt:iso(0),createdAt:iso(3600000),shells:[], @@ -2141,7 +2141,7 @@ test('Vibe Mode sets every character wandering and every helper flying, and turn assert.deepEqual(b.errors,[]); }); -test('the Vibe Mode button stays on across Yards and reloads, and reduced motion disables it',options,async t=>{ +test('the Vibes Mode button stays on across Yards and reloads, and reduced motion disables it',options,async t=>{ const {b}=await setup(t,{yard:true}); const button="document.querySelector('#yard-vibe')",state='['+button+'.getAttribute("aria-pressed"),'+button+'.disabled,localStorage.getItem("agentGuild.vibe")]'; await ready(b); @@ -2154,7 +2154,7 @@ test('the Vibe Mode button stays on across Yards and reloads, and reduced motion })()`); await b.send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'no-preference'}]}); await b.wait(button+'.disabled===false'); - assert.equal(await b.evaluate(button+'.getAttribute("aria-label")'),'Vibe Mode'); + assert.equal(await b.evaluate(button+'.getAttribute("aria-label")'),'Vibes Mode'); assert.equal(await b.evaluate(button+'.previousElementSibling.id'),'yard-fireworks'); await b.click('#yard-vibe'); assert.deepEqual(await b.evaluate(state),['true',false,'on']); diff --git a/tests/yard.test.mjs b/tests/yard.test.mjs index 3b08655..7ef9e31 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -161,7 +161,7 @@ test('clip choices are seeded per session and come from the contract clip set',( }); // Clearance.live_distance in agent-guild-builders: how far a point is outside the ground worlds keep clear. const keptClearBy=(x,z)=>Math.min(Math.max(0,Math.hypot(x,z)-14.7),Math.hypot(Math.max(Math.abs(x)-13,0),Math.max(z-32,-z,0))); -test('Vibe Mode wanders only the ground every world keeps clear, to and fro across it, round the halls, the centrepiece and the station props',()=>{ +test('Vibes Mode wanders only the ground every world keeps clear, to and fro across it, round the halls, the centrepiece and the station props',()=>{ const custom=[...providers,{id:'custom-1'},{id:'custom-2'}]; const props=[...layoutSessions(Array.from({length:12},(_,i)=>session('s'+i,providers[i%6].id)),custom).entries()].map(([id,p])=>stationProp(p,id)); const boxes=[...[...providerPositions(custom)].map(([id,p])=>({...p,half:id==='docker'?STAGING.hall.docker:STAGING.hall.half})), @@ -184,7 +184,7 @@ test('Vibe Mode wanders only the ground every world keeps clear, to and fro acro assert.deepEqual(stops('s0'),stops('s0')); assert.notDeepEqual(stops('s0'),stops('s1')); }); -test('Vibe Mode flies each helper its own smooth loop inside the pan bounds, above the halls',()=>{ +test('Vibes Mode flies each helper its own smooth loop inside the pan bounds, above the halls',()=>{ const {minX,maxX,minZ,maxZ}=CAMERA.pan,[low,high]=VIBE.flight.height; assert.ok(low>STAGING.hall.dockerHeight); for(const key of ['s0:a1','s0:a2','s1:a3']){ diff --git a/web/index.html b/web/index.html index 6a4e6dd..abbc905 100644 --- a/web/index.html +++ b/web/index.html @@ -151,7 +151,7 @@

Start a session

Preparing your yard…
-
+

Drag to explore · Scroll to zoom · Select to inspect · Double-click a session to openDrag to explore · Pinch to zoom · Tap to inspect · Open from the Inspector

diff --git a/web/yard/view.js b/web/yard/view.js index b310aa5..11a937c 100644 --- a/web/yard/view.js +++ b/web/yard/view.js @@ -175,7 +175,7 @@ export function initYard(controller) { const button = $('yard-vibe'); button.setAttribute('aria-pressed', String(vibe)); button.disabled = motion.matches; - button.title = motion.matches ? 'Vibe Mode off: reduced motion is enabled' : vibe ? 'Vibe Mode is on in every Yard' : 'Vibe Mode: everyone roams, in every Yard'; + button.title = motion.matches ? 'Vibes Mode off: reduced motion is enabled' : vibe ? 'Vibes Mode is on in every Yard' : 'Vibes Mode: everyone roams, in every Yard'; }; const toggleVibe = () => { vibe = !vibe; From 50a71e38f38f10790b868f0655befe5ccb255d3c Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 21:01:10 -0500 Subject: [PATCH 05/14] feat: keep Vibes Mode roaming on screen in every shape Stops and helper loops now fill the overview the stage shows, clear of the heading and the Yard controls, and follow the stage when it turns or resizes. Co-Authored-By: Claude Opus 5.5 --- tests/browser/yard.mjs | 27 ++++++++++++++++--- tests/yard.test.mjs | 61 ++++++++++++++++++++++++------------------ web/yard/model.mjs | 34 ++++++++++++++++------- web/yard/renderer.js | 30 +++++++++++++++------ web/yard/view.js | 1 + 5 files changed, 106 insertions(+), 47 deletions(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index 35667f3..bf77f48 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -2097,14 +2097,27 @@ test('Vibes Mode sets every character wandering and every helper flying, and tur const {YardRenderer}=await import('/yard/renderer.js'); window.__model=await import('/yard/model.mjs'); const host=document.createElement('div'),labels=document.createElement('div'); - host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); - window.__renderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${T0}}); + const top=document.createElement('div'),bottom=document.createElement('div'); + top.style.cssText='position:absolute;top:0;left:0;right:0;height:90px';bottom.style.cssText='position:absolute;bottom:0;left:0;right:0;height:120px'; + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels,top,bottom);document.body.append(host); + window.__renderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${T0},cover:[top,bottom]}); window.__sessions=${q(sessions)};window.__providers=${q(f.providers)}; await __renderer.setWorld('guild','dark');__renderer.update(__providers,__sessions); window.__units=()=>[...__renderer.units.values()]; window.__home=()=>[...__renderer.units].every(([id,u])=>{const p=__renderer.staged.places.get(id);return !u.moving&&u.root.position.x===p.x&&u.root.position.z===p.z;}); window.__run=n=>{for(let i=0;i{let n=0;while(__units().some(u=>u.moving)&&n<4000){__renderer.step(.05);n++;}return n;}; + window.__offScreen=()=>{ + const camera=__renderer.camera,w=host.clientWidth,h=host.clientHeight,v=camera.position.clone(),out=[]; + const px=(x,y,z)=>{v.set(x,y,z).project(camera);return {x:(v.x+1)/2*w,y:(1-v.y)/2*h};}; + const off=p=>p.y<90||p.y>h-120||p.x<0||p.x>w,edge=__model.VIBE.roam.edge*h*camera.zoom/__model.CAMERA.height-.5; + for(const u of __units()){ + const t=u.target,feet=px(t.x,0,t.z),head=px(t.x,__model.VIBE.roam.head,t.z); + if([feet,head].some(p=>p.xw-edge)||head.y<134+edge||feet.y>h-120-edge)out.push(['stop',t]); + for(const helper of u.helpers){const p=helper.model.position;if(off(px(p.x,p.y,p.z)))out.push(['helper',p.x,p.y,p.z]);} + } + return out; + }; window.__helper=()=>{const h=__renderer.units.get(__sessions[0].id).helpers[0];return {y:h.model.position.y,scale:h.model.scale.x,clip:h.action.getClip().name};}; })()`); await b.wait('__units().every(u=>u.loaded)&&__units()[0].helpers.length===1'); @@ -2117,7 +2130,7 @@ test('Vibes Mode sets every character wandering and every helper flying, and tur __run(800); const boxes=[...__model.providerPositions(__providers)].map(([id,p])=>({...p,half:id==='docker'?__model.STAGING.hall.docker:__model.STAGING.hall.half})); return {first,moving:__units().every(u=>u.moving),clips:__units().map(u=>u.clip),legs:__units().map(u=>u.roamed), - inHall:__units().some(u=>boxes.some(b=>Math.abs(u.root.position.x-b.x)boxes.some(b=>Math.abs(u.root.position.x-b.x)=flight.height[0]-.5&&roaming.helper.y<=flight.height[1],'the helper flies'); assert.ok(Math.abs(roaming.helper.scale-flight.scale)<1e-6,'at its flying size'); assert.equal(roaming.helper.clip,'move'); + assert.deepEqual(roaming.offScreen,[],'every stop and helper stays on screen, clear of what covers it'); + const turned=await b.evaluate(`(()=>{const frame=__model.vibeFrame(380/700,134/700,120/700),count=()=>__units().filter(u=>!__model.inVibeFrame(frame,u.target.x,0,u.target.z)||!__model.inVibeFrame(frame,u.target.x,__model.VIBE.roam.head,u.target.z)).length; + for(let n=0;n<4000&&!count();n++)__renderer.step(.05); + const leaving=count();__renderer.host.style.width='380px';__renderer.resize();const stops=__offScreen().filter(([kind])=>kind==='stop');__run(200);return {leaving,stops,after:__offScreen(),moving:__units().every(u=>u.moving)};})()`); + assert.ok(turned.leaving>0,'the turn leaves someone heading off screen'); + assert.deepEqual(turned.stops,[],'turned to portrait, nobody heads off screen'); + assert.deepEqual(turned.after,[],'and everyone stays on screen'); + assert.equal(turned.moving,true); assert.equal(await b.evaluate(`(()=>{const before=__units().map(u=>u.target);__renderer.update(__providers,__sessions);return __units().every((u,i)=>u.target===before[i]&&u.moving);})()`),true); assert.ok(await b.evaluate('(__renderer.setVibe(false),__settle())')<4000,'turned off, everyone walks back'); assert.equal(await b.evaluate('__home()'),true); diff --git a/tests/yard.test.mjs b/tests/yard.test.mjs index 7ef9e31..98e619d 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync, statSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import { sessionPose, familiarPose, marksFor, layoutSessions, providerPositions, characterIndex, lookLimits, yardExtras, WORLDS, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, plateExtent, - stage, stagingMode, nextStagingChange, restSpots, route, clipFor, doorFor, stationProp, STAGING, CLIPS, SKELETON, PROVIDER_ORDER, photoSize, photoName, VIBE, vibeStop, vibeFlight } from '../web/yard/model.mjs'; + stage, stagingMode, nextStagingChange, restSpots, route, clipFor, doorFor, stationProp, STAGING, CLIPS, SKELETON, PROVIDER_ORDER, photoSize, photoName, VIBE, vibeStop, vibeFlight, vibeFrame, inVibeFrame } from '../web/yard/model.mjs'; import { goldenVectors } from '../scripts/yard-golden.mjs'; import { Matrix4, Vector3 } from 'three'; const providers=['anthropic','openai','google','xai','shell','docker'].map(id=>({id})); @@ -161,44 +161,53 @@ test('clip choices are seeded per session and come from the contract clip set',( }); // Clearance.live_distance in agent-guild-builders: how far a point is outside the ground worlds keep clear. const keptClearBy=(x,z)=>Math.min(Math.max(0,Math.hypot(x,z)-14.7),Math.hypot(Math.max(Math.abs(x)-13,0),Math.max(z-32,-z,0))); -test('Vibes Mode wanders only the ground every world keeps clear, to and fro across it, round the halls, the centrepiece and the station props',()=>{ +const vibeFrames=[vibeFrame(16/9),vibeFrame(1,.2,.15),vibeFrame(9/16,.15,.1),vibeFrame(9/19.5,.17,.25)]; +const inView=(frame,p)=>{const {right,up}=viewBasis(),d=[p.x-frame.target[0],(p.y||0)-frame.target[1],p.z-frame.target[2]];return {r:d[0]*right[0]+d[1]*right[1]+d[2]*right[2],u:d[0]*up[0]+d[1]*up[1]+d[2]*up[2]};}; +const span=values=>Math.max(...values)-Math.min(...values); +test('Vibes Mode wanders only the ground every world keeps clear and the overview shows, to and fro across it, round the halls, the centrepiece and the station props',()=>{ const custom=[...providers,{id:'custom-1'},{id:'custom-2'}]; const props=[...layoutSessions(Array.from({length:12},(_,i)=>session('s'+i,providers[i%6].id)),custom).entries()].map(([id,p])=>stationProp(p,id)); const boxes=[...[...providerPositions(custom)].map(([id,p])=>({...p,half:id==='docker'?STAGING.hall.docker:STAGING.hall.half})), {x:STAGING.plaza.x,z:STAGING.plaza.z,half:STAGING.plaza.half},...props.map(p=>({x:p.x,z:p.z,half:STAGING.station.half}))]; const inside=p=>boxes.some(b=>Math.abs(p.x-b.x)Array.from({length:60},(_,n)=>vibeStop(id,n,custom,props)); - for(const id of ['s0','s1','s7']){ - let from=stops(id).at(-1); - for(const stop of stops(id)){ - assert.notEqual(stop.zMath.abs(stop.x-b.x)Array.from({length:60},(_,n)=>vibeStop(id,n,custom,props,frame)); + for(const id of ['s0','s1','s7']){ + let from=stops(id).at(-1); + for(const stop of stops(id)){ + assert.notEqual(stop.zMath.abs(stop.x-b.x)inView(frame,p)); + assert.ok(span(seen.map(p=>p.r))>(frame.right[1]-frame.right[0])/2&&span(seen.map(p=>p.u))>(frame.up[1]-frame.up[0])/2,'the stops spread over what the overview shows'); } - const xs=stops(id).map(p=>p.x),zs=stops(id).map(p=>p.z); - assert.ok(Math.max(...xs)-Math.min(...xs)>VIBE.roam.radius&&Math.max(...zs)-Math.min(...zs)>VIBE.roam.radius,'the stops spread over the ground'); + assert.deepEqual(stops('s0'),stops('s0')); + assert.notDeepEqual(stops('s0'),stops('s1')); } - assert.deepEqual(stops('s0'),stops('s0')); - assert.notDeepEqual(stops('s0'),stops('s1')); + const hidden=Array.from({length:60},(_,n)=>vibeStop('s0',n,custom,props,vibeFrame(16/9,.5,.5))); + assert.equal(new Set(hidden.map(p=>p.x+','+p.z)).size,hidden.length,'with nothing visible, stops still spread over the clear ground'); }); -test('Vibes Mode flies each helper its own smooth loop inside the pan bounds, above the halls',()=>{ - const {minX,maxX,minZ,maxZ}=CAMERA.pan,[low,high]=VIBE.flight.height; +test('Vibes Mode flies each helper its own smooth loop across what the overview shows, above the halls',()=>{ + const [low,high]=VIBE.flight.height; assert.ok(low>STAGING.hall.dockerHeight); - for(const key of ['s0:a1','s0:a2','s1:a3']){ - let last=vibeFlight(key,0),reach=0; + for(const frame of vibeFrames)for(const key of ['s0:a1','s0:a2','s1:a3']){ + let last=vibeFlight(key,0,frame);const seen=[]; for(let t=.1;t<=VIBE.flight.lap[1];t+=.1){ - const p=vibeFlight(key,t); - assert.ok(p.x>=minX&&p.x<=maxX&&p.z>=minZ&&p.z<=maxZ&&p.y>=low-1e-9&&p.y<=high+1e-9,JSON.stringify(p)); + const p=vibeFlight(key,t,frame),v=inView(frame,p); + assert.ok(inVibeFrame(frame,p.x,p.y,p.z)&&v.u+VIBE.flight.top<=frame.up[1]+1e-9&&p.y>=low-1e-9&&p.y<=high+1e-9,JSON.stringify(p)); assert.ok(Math.hypot(p.x-last.x,p.y-last.y,p.z-last.z)<1.5,'no jump between moments'); - reach=Math.max(reach,Math.hypot(p.x,p.z-(minZ+maxZ)/2));last=p; + seen.push(v);last=p; } - assert.ok(reach>STAGING.plaza.rings.at(-1)[0],'the loop goes out past the plaza'); + assert.ok(span(seen.map(p=>p.r))>(frame.right[1]-frame.right[0])*.3&&span(seen.map(p=>p.u))>(frame.up[1]-frame.up[0]-VIBE.flight.top)*.3,'the loop crosses the view'); } - assert.deepEqual(vibeFlight('s0:a1',12.5),vibeFlight('s0:a1',12.5)); - assert.notDeepEqual(vibeFlight('s0:a1',12.5),vibeFlight('s0:a2',12.5)); + const frame=vibeFrames[0]; + assert.deepEqual(vibeFlight('s0:a1',12.5,frame),vibeFlight('s0:a1',12.5,frame)); + assert.notDeepEqual(vibeFlight('s0:a1',12.5,frame),vibeFlight('s0:a2',12.5,frame)); }); test('the staging contract matches its golden vectors',()=>{ const golden=JSON.parse(readFileSync(new URL('./fixtures/yard-choreography.json',import.meta.url),'utf8')); diff --git a/web/yard/model.mjs b/web/yard/model.mjs index 297462a..e522c83 100644 --- a/web/yard/model.mjs +++ b/web/yard/model.mjs @@ -436,34 +436,48 @@ export function clipFor(id, mode, n) { } export const VIBE = { // Must stay inside the ground every world keeps clear (Clearance.live_distance in agent-guild-builders). - roam: { radius: 13.2, half: 12.5, front: 24, split: 3, margin: .9 }, - flight: { height: [8.5, 12], lap: [26, 44], reach: [.45, 1], scale: 1.3 }, + roam: { radius: 13.2, half: 12.5, front: 30.5, split: 3, margin: .9, edge: 1.2, head: 2.4 }, + flight: { height: [8.5, 12], lap: [26, 44], reach: [.45, 1], scale: 1.3, top: 2 }, }; +export function vibeFrame(aspect, top = 0, bottom = 0) { + const { target, zoom } = overviewFor(aspect), height = CAMERA.height / zoom, width = height * aspect, { edge } = VIBE.roam; + return { target, right: [edge - width / 2, width / 2 - edge], up: [(bottom - .5) * height + edge, (.5 - top) * height - edge] }; +} +export function inVibeFrame(frame, x, y, z) { + const { right, up } = viewBasis(), d = [x - frame.target[0], y - frame.target[1], z - frame.target[2]]; + const r = dot(d, right), u = dot(d, up); + return r >= frame.right[0] && r <= frame.right[1] && u >= frame.up[0] && u <= frame.up[1]; +} function unit(value) { let h = hash(value); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } -export function vibeStop(id, n, providers, props = []) { - const { radius, half, front, split, margin } = VIBE.roam; +export function vibeStop(id, n, providers, props = [], frame = null) { + const { radius, half, front, split, margin, head } = VIBE.roam; const [near, far] = (n + hash(id)) % 2 ? [split, front] : [-radius, split]; const boxes = [...obstacles(providers).map(box => ({ ...box, half: box.half + margin })), ...props.map(p => ({ x: p.x, z: p.z, half: STAGING.station.half + margin }))]; - for (let k = 0; k < 48; k++) { + const seen = (x, z) => !frame || (inVibeFrame(frame, x, 0, z) && inVibeFrame(frame, x, head, z)); + for (const framed of [true, false]) for (let k = 0; k < 48; k++) { const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(near + unit(id + ':vibe:z:' + n + ':' + k) * (far - near)); const clear = Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0); - if (clear && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half)) return { x, z }; + if (clear && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half) && (!framed || seen(x, z))) return { x, z }; } return { x: STAGING.plaza.x, z: round(STAGING.plaza.z + STAGING.plaza.half + margin + STAGING.station.half) }; } -export function vibeFlight(key, seconds) { - const { height, lap, reach } = VIBE.flight, { minX, maxX, minZ, maxZ } = CAMERA.pan; +export function vibeFlight(key, seconds, frame) { + const { height, lap, reach, top } = VIBE.flight, { right, up, forward } = viewBasis(); const pick = (name, [low, high]) => low + unit(key + ':vibe:' + name) * (high - low); const out = pick('reach', reach), turn = unit(key + ':vibe:turn') < .5 ? 1 : -1, lobes = 2 + Math.floor(unit(key + ':vibe:lobes') * 3); const angle = turn * 2 * Math.PI * (seconds / pick('lap', lap) + unit(key + ':vibe:start')); const wide = out * (1 + .12 * Math.sin(lobes * angle + pick('phase', [0, 2 * Math.PI]))) / 1.12; - return { x: (minX + maxX) / 2 + Math.cos(angle) * (maxX - minX) / 2 * wide, z: (minZ + maxZ) / 2 + Math.sin(angle) * (maxZ - minZ) / 2 * wide, - y: (height[0] + height[1]) / 2 + (height[1] - height[0]) / 2 * Math.sin(2 * angle + pick('rise', [0, 2 * Math.PI])) }; + const y = (height[0] + height[1]) / 2 + (height[1] - height[0]) / 2 * Math.sin(2 * angle + pick('rise', [0, 2 * Math.PI])); + const [r0, r1] = frame.right, u0 = frame.up[0], u1 = frame.up[1] - top; + const r = (r0 + r1) / 2 + Math.cos(angle) * Math.max(0, r1 - r0) / 2 * wide; + const u = (u0 + u1) / 2 + Math.sin(angle) * Math.max(0, u1 - u0) / 2 * wide; + const s = (y - frame.target[1] - u * up[1]) / forward[1]; + return { x: frame.target[0] + r * right[0] + u * up[0] + s * forward[0], y, z: frame.target[2] + r * right[2] + u * up[2] + s * forward[2] }; } // Where a session's helpers stand, around it, as the renderer has always placed them. export function helperOffset(i, count) { diff --git a/web/yard/renderer.js b/web/yard/renderer.js index 89ea670..d224f6a 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -1,6 +1,6 @@ import * as T from './vendor/engine.js'; import { WORLDS, PROVIDER_ORDER, characterIndex, GILDED, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, providerPositions, sessionPose, familiarPose, hash, - stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, yardExtras, playableClip, STAGING, photoSize, marksFor, SHOWN_HELPERS, VIBE, vibeStop, vibeFlight } from './model.mjs'; + stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, yardExtras, playableClip, STAGING, photoSize, marksFor, SHOWN_HELPERS, VIBE, vibeStop, vibeFlight, vibeFrame, inVibeFrame } from './model.mjs'; const ASSETS = new URL('./assets/', import.meta.url); const vector = new T.Vector3(); @@ -16,7 +16,7 @@ const pieceFiles = (skin, slot, piece) => (slot === 'companion' ? [0, 1, 2, 3].m const vector2 = new T.Vector2(); // How fast characters turn to a new heading, in radians a second, and how // long a clip change blends. -const TURN = 7, FADE = .3; +const TURN = 7, FADE = .3, TAG = 44; // Models rigged before the staging contract name their clips differently. const LEGACY = { walk: 'working', idle_0: 'resting', idle_1: 'resting', idle_2: 'resting', pause: 'resting', work_0: 'working', work_1: 'working', idle: 'resting', move: 'working' }; // How bright a hall's glow grows at low and high activity, over what the @@ -139,15 +139,15 @@ function disposeWorld(root) { } export class YardRenderer { // clock gives the time staging is judged at; tests pass a fixed one. - constructor(host,labels,{select,open,error=noop,piece=noop,clock=Date.now}) { - this.host=host;this.labels=labels;this.onSelect=select;this.onOpen=open;this.onError=error;this.onPiece=piece;this.clock=clock; + constructor(host,labels,{select,open,error=noop,piece=noop,clock=Date.now,cover=[]}) { + this.host=host;this.labels=labels;this.cover=cover;this.onSelect=select;this.onOpen=open;this.onError=error;this.onPiece=piece;this.clock=clock; this.pieceSets={};this.shownPieces={skin:null,pieces:{}};this.pieceLoaders=new Map();this.preloads=new Map(); this.scene=new T.Scene();this.units=new Map();this.halls=new Map();this.slots=new Map();this.cache=new Map(); this.loadingManager=new T.LoadingManager(); this.providers=[];this.sessions=[];this.selected=null;this.world=null;this.skin=null;this.request=0;this.worldRequest=0;this.pending=null; this.staged=null;this.seen=new Set();this.stageTimer=0;this.sparks=[];this.shows=[];this.marks=new T.Group();this.scene.add(this.marks);this.glyphs=null;this.markPlane=new T.PlaneGeometry(1,1); this.props=new Map();this.propSpots=[];this.station=null;this.effects=null; - this.active=false;this.reduced=false;this.vibe=false;this.vibeTime=0;this.disposed=false;this.frame=0;this.lastTime=0;this.dirty=true; + this.active=false;this.reduced=false;this.vibe=false;this.vibeTime=0;this.vibeFrames={stop:null,flight:vibeFrame(1)};this.disposed=false;this.frame=0;this.lastTime=0;this.dirty=true; this.crowd=null;this.rows=new Map(); this.list=Object.assign(document.createElement('div'),{className:'yard-crowd-list',id:'yard-crowd-list-'+(++crowdLists),hidden:true}); this.list.setAttribute('role','group'); @@ -195,7 +195,7 @@ export class YardRenderer { this.canvas.addEventListener('pointerdown',this.onPointerDown); this.canvas.addEventListener('click',this.onClick); this.canvas.addEventListener('dblclick',this.onDouble); - this.resizeObserver=new ResizeObserver(()=>this.resize());this.resizeObserver.observe(host); + this.resizeObserver=new ResizeObserver(()=>this.resize());for(const el of [host,...cover])this.resizeObserver.observe(el); this.resize();this.overview(); } async asset(name,url) { @@ -482,8 +482,22 @@ export class YardRenderer { this.camera.zoom=T.MathUtils.clamp(this.camera.zoom,this.controls.minZoom,this.controls.maxZoom); this.camera.updateProjectionMatrix(); this.dirty=true; + this.frameVibe(w,h); if(this.atOverview)this.overview();else this.drawOnce(); } + frameVibe(w,h) { + const box=this.host.getBoundingClientRect();let top=0,bottom=0; + for(const el of this.cover){ + const r=el.getBoundingClientRect(); + if(!r.width||!r.height)continue; + if(r.top+r.bottom controller.openSession(id), error: () => { if (attempt === sceneAttempt) failScene(); }, piece: pieceDrawn, + cover: [...document.querySelectorAll('.yard-heading > *, #yard-stage .yard-overlay')], }); pendingRenderer = candidate; // Appearance can change while the first world is loading. From 4a2f339b31b6325d2ba12aea5758d0cef2c846d9 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 21:29:09 -0500 Subject: [PATCH 06/14] fix: plan Vibes Mode stops and routes inside what the screen shows Stops are drawn from the visible ground, then a full scan of it, and routes only turn at corners on screen. When no on-screen way exists the character stands where it is until a resize or update; only a stage too small to show a character roams beyond it. A resize re-routes walks whose remaining way left the screen. Co-Authored-By: Claude Opus 5.5 --- tests/browser/yard.mjs | 32 ++++++++++++++++------ tests/yard.test.mjs | 61 +++++++++++++++++++++++++----------------- web/yard/model.mjs | 48 ++++++++++++++++++++++++--------- web/yard/renderer.js | 14 +++++++--- 4 files changed, 105 insertions(+), 50 deletions(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index bf77f48..ceb3235 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -2099,7 +2099,7 @@ test('Vibes Mode sets every character wandering and every helper flying, and tur const host=document.createElement('div'),labels=document.createElement('div'); const top=document.createElement('div'),bottom=document.createElement('div'); top.style.cssText='position:absolute;top:0;left:0;right:0;height:90px';bottom.style.cssText='position:absolute;bottom:0;left:0;right:0;height:120px'; - host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels,top,bottom);document.body.append(host); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels,top,bottom);document.body.append(host);window.__bottom=bottom; window.__renderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${T0},cover:[top,bottom]}); window.__sessions=${q(sessions)};window.__providers=${q(f.providers)}; await __renderer.setWorld('guild','dark');__renderer.update(__providers,__sessions); @@ -2112,8 +2112,10 @@ test('Vibes Mode sets every character wandering and every helper flying, and tur const px=(x,y,z)=>{v.set(x,y,z).project(camera);return {x:(v.x+1)/2*w,y:(1-v.y)/2*h};}; const off=p=>p.y<90||p.y>h-120||p.x<0||p.x>w,edge=__model.VIBE.roam.edge*h*camera.zoom/__model.CAMERA.height-.5; for(const u of __units()){ - const t=u.target,feet=px(t.x,0,t.z),head=px(t.x,__model.VIBE.roam.head,t.z); - if([feet,head].some(p=>p.xw-edge)||head.y<134+edge||feet.y>h-120-edge)out.push(['stop',t]); + for(const t of new Set([...u.path,u.target])){ + const feet=px(t.x,0,t.z),head=px(t.x,__model.VIBE.roam.head,t.z); + if([feet,head].some(p=>p.xw-edge)||head.y<134+edge||feet.y>h-120-edge)out.push([t===u.target?'stop':'way',t]); + } for(const helper of u.helpers){const p=helper.model.position;if(off(px(p.x,p.y,p.z)))out.push(['helper',p.x,p.y,p.z]);} } return out; @@ -2142,13 +2144,27 @@ test('Vibes Mode sets every character wandering and every helper flying, and tur assert.ok(Math.abs(roaming.helper.scale-flight.scale)<1e-6,'at its flying size'); assert.equal(roaming.helper.clip,'move'); assert.deepEqual(roaming.offScreen,[],'every stop and helper stays on screen, clear of what covers it'); - const turned=await b.evaluate(`(()=>{const frame=__model.vibeFrame(380/700,134/700,120/700),count=()=>__units().filter(u=>!__model.inVibeFrame(frame,u.target.x,0,u.target.z)||!__model.inVibeFrame(frame,u.target.x,__model.VIBE.roam.head,u.target.z)).length; - for(let n=0;n<4000&&!count();n++)__renderer.step(.05); - const leaving=count();__renderer.host.style.width='380px';__renderer.resize();const stops=__offScreen().filter(([kind])=>kind==='stop');__run(200);return {leaving,stops,after:__offScreen(),moving:__units().every(u=>u.moving)};})()`); - assert.ok(turned.leaving>0,'the turn leaves someone heading off screen'); - assert.deepEqual(turned.stops,[],'turned to portrait, nobody heads off screen'); + const turned=await b.evaluate(`(()=>{const frame=__model.vibeFrame(240/700,134/700,120/700),seen=p=>__model.inVibeFrame(frame,p.x,0,p.z)&&__model.inVibeFrame(frame,p.x,__model.VIBE.roam.head,p.z); + const count=()=>__units().filter(u=>!seen(u.target)).length,detour=()=>__units().filter(u=>seen(u.target)&&!u.path.every(seen)).length; + const turn=until=>{for(let n=0;n<4000&&!until();n++)__renderer.step(.05);const found=until();__renderer.host.style.width='240px';__renderer.resize();return {found,off:__offScreen().filter(([kind])=>kind!=='helper')};}; + const leaving=turn(count);__renderer.host.style.width='900px';__renderer.resize();const rerouted=turn(detour); + const roams=()=>__units().reduce((n,u)=>n+u.roamed,0),goal=roams()+40,during=[]; + for(let n=0;n<20000&&roams()kind!=='helper'));} + return {leaving,rerouted,walked:roams()>=goal,during:during.slice(0,3),after:__offScreen(),moving:__units().every(u=>u.moving)};})()`); + assert.ok(turned.leaving.found>0,'a turn leaves someone heading off screen'); + assert.deepEqual(turned.leaving.off,[],'who heads somewhere on screen instead'); + assert.ok(turned.rerouted.found>0,'a turn leaves someone with a way off screen'); + assert.deepEqual(turned.rerouted.off,[],'who takes a way on screen instead'); + assert.equal(turned.walked,true); + assert.deepEqual(turned.during,[],'every way walked in portrait stays on screen'); assert.deepEqual(turned.after,[],'and everyone stays on screen'); assert.equal(turned.moving,true); + const stuck=await b.evaluate(`(()=>{__bottom.style.height='490px';__renderer.resize();const at=__units().map(u=>[u.root.position.x,u.root.position.z]);__run(100); + const still=__units().every((u,i)=>!u.moving&&u.root.position.x===at[i][0]&&u.root.position.z===at[i][1]); + __bottom.style.height='120px';__renderer.resize();return {still,moving:__units().every(u=>u.moving),offScreen:__offScreen().filter(([kind])=>kind!=='helper')};})()`); + assert.equal(stuck.still,true,'with no way on screen, everyone stands where they are'); + assert.equal(stuck.moving,true,'and walks on once there is room again'); + assert.deepEqual(stuck.offScreen,[]); assert.equal(await b.evaluate(`(()=>{const before=__units().map(u=>u.target);__renderer.update(__providers,__sessions);return __units().every((u,i)=>u.target===before[i]&&u.moving);})()`),true); assert.ok(await b.evaluate('(__renderer.setVibe(false),__settle())')<4000,'turned off, everyone walks back'); assert.equal(await b.evaluate('__home()'),true); diff --git a/tests/yard.test.mjs b/tests/yard.test.mjs index 98e619d..be6a0e0 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -164,33 +164,44 @@ const keptClearBy=(x,z)=>Math.min(Math.max(0,Math.hypot(x,z)-14.7),Math.hypot(Ma const vibeFrames=[vibeFrame(16/9),vibeFrame(1,.2,.15),vibeFrame(9/16,.15,.1),vibeFrame(9/19.5,.17,.25)]; const inView=(frame,p)=>{const {right,up}=viewBasis(),d=[p.x-frame.target[0],(p.y||0)-frame.target[1],p.z-frame.target[2]];return {r:d[0]*right[0]+d[1]*right[1]+d[2]*right[2],u:d[0]*up[0]+d[1]*up[1]+d[2]*up[2]};}; const span=values=>Math.max(...values)-Math.min(...values); -test('Vibes Mode wanders only the ground every world keeps clear and the overview shows, to and fro across it, round the halls, the centrepiece and the station props',()=>{ - const custom=[...providers,{id:'custom-1'},{id:'custom-2'}]; - const props=[...layoutSessions(Array.from({length:12},(_,i)=>session('s'+i,providers[i%6].id)),custom).entries()].map(([id,p])=>stationProp(p,id)); - const boxes=[...[...providerPositions(custom)].map(([id,p])=>({...p,half:id==='docker'?STAGING.hall.docker:STAGING.hall.half})), - {x:STAGING.plaza.x,z:STAGING.plaza.z,half:STAGING.plaza.half},...props.map(p=>({x:p.x,z:p.z,half:STAGING.station.half}))]; - const inside=p=>boxes.some(b=>Math.abs(p.x-b.x)Array.from({length:60},(_,n)=>vibeStop(id,n,custom,props,frame)); - for(const id of ['s0','s1','s7']){ - let from=stops(id).at(-1); - for(const stop of stops(id)){ - assert.notEqual(stop.zMath.abs(stop.x-b.x){ + const propsFor=(count,provs)=>[...layoutSessions(Array.from({length:count},(_,i)=>session('s'+i,providers[i%6].id)),provs).entries()].map(([id,p])=>stationProp(p,id)); + const walk=(id,frame,provs,props)=>{const legs=[];let from=null;for(let n=0;n<60;n++){const stop=vibeStop(id,n,provs,props,frame,from);legs.push({from,stop});from=stop;}return legs;}; + const check=(frame,provs,props,ids,{alternate=true,half=null}={})=>{ + const boxes=[...[...providerPositions(provs)].map(([id,p])=>({...p,half:id==='docker'?STAGING.hall.docker:STAGING.hall.half})), + {x:STAGING.plaza.x,z:STAGING.plaza.z,half:STAGING.plaza.half},...props.map(p=>({x:p.x,z:p.z,half:STAGING.station.half}))]; + const inside=p=>boxes.some(b=>Math.abs(p.x-b.x)inVibeFrame(frame,p.x,0,p.z)&&inVibeFrame(frame,p.x,VIBE.roam.head,p.z); + for(const id of ids){ + const legs=walk(id,frame,provs,props); + for(const {from,stop} of legs){ + const at=JSON.stringify([id,from,stop]); + assert.ok(stop&&seen(stop),at); + assert.equal(keptClearBy(stop.x,stop.z),0,at); + assert.ok(!boxes.some(b=>Math.abs(stop.x-b.x)inView(frame,p)); - assert.ok(span(seen.map(p=>p.r))>(frame.right[1]-frame.right[0])/2&&span(seen.map(p=>p.u))>(frame.up[1]-frame.up[0])/2,'the stops spread over what the overview shows'); + const spread=legs.map(l=>inView(frame,l.stop)); + if(!half)assert.ok(span(spread.map(p=>p.r))>(frame.right[1]-frame.right[0])/2&&span(spread.map(p=>p.u))>(frame.up[1]-frame.up[0])/2,'the stops spread over what the overview shows'); } - assert.deepEqual(stops('s0'),stops('s0')); - assert.notDeepEqual(stops('s0'),stops('s1')); - } - const hidden=Array.from({length:60},(_,n)=>vibeStop('s0',n,custom,props,vibeFrame(16/9,.5,.5))); - assert.equal(new Set(hidden.map(p=>p.x+','+p.z)).size,hidden.length,'with nothing visible, stops still spread over the clear ground'); + }; + const custom=[...providers,{id:'custom-1'},{id:'custom-2'}]; + for(const frame of vibeFrames)check(frame,custom,propsFor(12,custom),['s0','s1','s7']); + check(vibeFrame(226/718,132/718,160/718),providers,propsFor(3,providers),['s0','s1','s8']); + check(vibeFrame(16/9,.6,0),providers,[],['s0','s1'],{alternate:false,half:'front'}); + check(vibeFrame(16/9,0,.6),providers,[],['s0','s1'],{alternate:false,half:'courtyard'}); + const frame=vibeFrames[0],stops=id=>walk(id,frame,custom,[]).map(l=>l.stop); + assert.deepEqual(stops('s0'),stops('s0')); + assert.notDeepEqual(stops('s0'),stops('s1')); + assert.deepEqual(Array.from({length:20},(_,n)=>vibeStop('s0',n,providers,[],vibeFrame(16/9,0,.7),{x:0,z:6})),Array(20).fill(null),'room on screen but no ground to stand on: no stop, rather than one off screen'); + const tooSmall=Array.from({length:60},(_,n)=>vibeStop('s0',n,custom,[],vibeFrame(16/9,.5,.5))); + assert.equal(new Set(tooSmall.map(p=>p.x+','+p.z)).size,tooSmall.length,'a stage too small to show a character still roams the clear ground'); }); test('Vibes Mode flies each helper its own smooth loop across what the overview shows, above the halls',()=>{ const [low,high]=VIBE.flight.height; diff --git a/web/yard/model.mjs b/web/yard/model.mjs index e522c83..e4e1682 100644 --- a/web/yard/model.mjs +++ b/web/yard/model.mjs @@ -350,8 +350,9 @@ function crosses(a, b, box, half) { // The shortest way from a to b that keeps out of every hall, the // centrepiece and every station prop: straight when clear, otherwise by the // corners of what is in the way. A prop that a or b stands in is no -// obstacle. Returns the waypoints after a, ending at b. -export function route(a, b, providers, props = []) { +// obstacle. Returns the waypoints after a, ending at b; with a Vibes frame, +// waypoints stay on screen and no such way returns null. +export function route(a, b, providers, props = [], frame = null) { const inside = (p, box) => Math.abs(p.x - box.x) < box.half - .05 && Math.abs(p.z - box.z) < box.half - .05; const boxes = [...obstacles(providers), ...props.map(p => ({ x: p.x, z: p.z, half: STAGING.station.half })) .filter(box => !inside(a, box) && !inside(b, box))], m = STAGING.hall.margin; @@ -360,7 +361,7 @@ export function route(a, b, providers, props = []) { const nodes = [a, b]; for (const box of boxes) for (const [sx, sz] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { const p = { x: box.x + sx * (box.half + m), z: box.z + sz * (box.half + m) }; - if (!boxes.some(other => Math.abs(p.x - other.x) < other.half && Math.abs(p.z - other.z) < other.half)) nodes.push(p); + if (!boxes.some(other => Math.abs(p.x - other.x) < other.half && Math.abs(p.z - other.z) < other.half) && (!frame || onScreen(frame, p))) nodes.push(p); } const dist = nodes.map(() => Infinity), from = nodes.map(() => -1), done = nodes.map(() => false); dist[0] = 0; @@ -375,7 +376,7 @@ export function route(a, b, providers, props = []) { if (d < dist[v] - 1e-9) { dist[v] = d; from[v] = u; } } } - if (dist[1] === Infinity) return [{ x: b.x, z: b.z }]; + if (dist[1] === Infinity) return frame ? null : [{ x: b.x, z: b.z }]; const path = []; for (let i = 1; i > 0; i = from[i]) path.unshift({ x: round(nodes[i].x), z: round(nodes[i].z) }); path[path.length - 1] = { x: b.x, z: b.z }; @@ -448,23 +449,44 @@ export function inVibeFrame(frame, x, y, z) { const r = dot(d, right), u = dot(d, up); return r >= frame.right[0] && r <= frame.right[1] && u >= frame.up[0] && u <= frame.up[1]; } +const onScreen = (frame, p) => inVibeFrame(frame, p.x, 0, p.z) && inVibeFrame(frame, p.x, VIBE.roam.head, p.z); function unit(value) { let h = hash(value); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } -export function vibeStop(id, n, providers, props = [], frame = null) { - const { radius, half, front, split, margin, head } = VIBE.roam; - const [near, far] = (n + hash(id)) % 2 ? [split, front] : [-radius, split]; +export function vibeStop(id, n, providers, props = [], frame = null, from = null) { + const { radius, half, front, split, margin, head } = VIBE.roam, { right, up, forward } = viewBasis(); + const courtFirst = (n + hash(id)) % 2 === 0; const boxes = [...obstacles(providers).map(box => ({ ...box, half: box.half + margin })), ...props.map(p => ({ x: p.x, z: p.z, half: STAGING.station.half + margin }))]; - const seen = (x, z) => !frame || (inVibeFrame(frame, x, 0, z) && inVibeFrame(frame, x, head, z)); - for (const framed of [true, false]) for (let k = 0; k < 48; k++) { - const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(near + unit(id + ':vibe:z:' + n + ':' + k) * (far - near)); - const clear = Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0); - if (clear && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half) && (!framed || seen(x, z))) return { x, z }; + const valid = (x, z, court) => (Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0)) + && (court ? z < split : z >= split && z <= front) && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half); + const [r0, r1] = frame ? frame.right : [0, 0], u0 = frame ? frame.up[0] : 0, u1 = frame ? frame.up[1] - head * up[1] : 0; + if (!(r1 > r0 && u1 > u0)) { + const [near, far] = courtFirst ? [-radius, split] : [split, front]; + for (let k = 0; k < 48; k++) { + const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(near + unit(id + ':vibe:z:' + n + ':' + k) * (far - near)); + if (valid(x, z, courtFirst)) return { x, z }; + } + return { x: STAGING.plaza.x, z: round(STAGING.plaza.z + STAGING.plaza.half + margin + STAGING.station.half) }; + } + const at = (r, u) => { + const s = (-frame.target[1] - u * up[1]) / forward[1]; + return { x: round(frame.target[0] + r * right[0] + u * up[0] + s * forward[0]), z: round(frame.target[2] + r * right[2] + u * up[2] + s * forward[2]) }; + }; + const fits = (p, court) => valid(p.x, p.z, court) && onScreen(frame, p) && (!from || route(from, p, providers, props, frame) !== null); + for (const court of [courtFirst, !courtFirst]) { + if (court === courtFirst) for (let k = 0; k < 48; k++) { + const p = at(r0 + unit(id + ':vibe:r:' + n + ':' + k) * (r1 - r0), u0 + unit(id + ':vibe:u:' + n + ':' + k) * (u1 - u0)); + if (fits(p, court)) return p; + } + const cells = []; + for (let r = r0; r <= r1; r += .5) for (let u = u0; u <= u1; u += .5) cells.push(at(r, u)); + const start = Math.floor(unit(id + ':vibe:scan:' + n) * cells.length); + for (let i = 0; i < cells.length; i++) if (fits(cells[(start + i) % cells.length], court)) return cells[(start + i) % cells.length]; } - return { x: STAGING.plaza.x, z: round(STAGING.plaza.z + STAGING.plaza.half + margin + STAGING.station.half) }; + return null; } export function vibeFlight(key, seconds, frame) { const { height, lap, reach, top } = VIBE.flight, { right, up, forward } = viewBasis(); diff --git a/web/yard/renderer.js b/web/yard/renderer.js index d224f6a..c57298f 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -495,8 +495,13 @@ export class YardRenderer { top=T.MathUtils.clamp(top,0,h);bottom=T.MathUtils.clamp(bottom,0,h); this.vibeFrames={stop:vibeFrame(w/h,(top+TAG)/h,bottom/h),flight:vibeFrame(w/h,top/h,bottom/h)}; if(!this.vibing())return; - const {stop}=this.vibeFrames,{head}=VIBE.roam; - for(const unit of this.units.values())if(unit.moving&&unit.target&&!(inVibeFrame(stop,unit.target.x,0,unit.target.z)&&inVibeFrame(stop,unit.target.x,head,unit.target.z)))this.roam(unit); + const {stop}=this.vibeFrames,seen=p=>inVibeFrame(stop,p.x,0,p.z)&&inVibeFrame(stop,p.x,VIBE.roam.head,p.z); + for(const unit of this.units.values()){ + if(!unit.moving||!unit.target||!seen(unit.target)){this.roam(unit);continue;} + if(unit.path.every(seen))continue; + const path=route({x:unit.root.position.x,z:unit.root.position.z},unit.target,this.providers,this.propSpots,stop); + if(path)unit.path=path;else this.roam(unit); + } } overview() { const {target,zoom}=overviewFor((this.camera.right-this.camera.left)/(this.camera.top-this.camera.bottom)); @@ -946,8 +951,9 @@ export class YardRenderer { } roam(unit) { unit.roamed=(unit.roamed??-1)+1; - const at={x:unit.root.position.x,z:unit.root.position.z},stop=vibeStop(unit.session.id,unit.roamed,this.providers,this.propSpots,this.vibeFrames.stop); - unit.target=stop;unit.path=route(at,stop,this.providers,this.propSpots);unit.moving=true; + const at={x:unit.root.position.x,z:unit.root.position.z},frame=this.vibeFrames.stop,stop=vibeStop(unit.session.id,unit.roamed,this.providers,this.propSpots,frame,at); + if(!stop){unit.target=at;unit.path=[];unit.moving=false;this.play(unit);return;} + unit.target=stop;unit.path=(frame&&route(at,stop,this.providers,this.propSpots,frame))||route(at,stop,this.providers,this.propSpots);unit.moving=true; this.play(unit); } vibing() { return this.vibe&&!this.reduced; } From d1127dc1c9244b7e133f163d4a0f168391ff78e9 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 22:16:06 -0500 Subject: [PATCH 07/14] fix: treat a manager that misses a health check as busy, not gone A health request that times out while the runtime file's owner of that URL still runs now means a busy manager: open reports it running instead of starting a second one, stop and restart still shut it down, and status says it did not answer instead of reporting it stopped. Co-Authored-By: Claude Opus 5.5 --- bin/agent-guild.mjs | 37 ++++++++++++++++++++++----------- tests/fixtures/fake-manager.mjs | 6 +++--- tests/launcher.test.mjs | 30 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/bin/agent-guild.mjs b/bin/agent-guild.mjs index d73c8ef..1e838ae 100755 --- a/bin/agent-guild.mjs +++ b/bin/agent-guild.mjs @@ -66,17 +66,24 @@ function portOf(url) { return parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80; } -async function health(url, timeoutMs = 1000) { +/** The manager's health, `{ busy: pid }` when the manager its runtime file names for `url` runs but did not answer in time, or null. */ +async function reach(url, timeoutMs = 1000) { try { const res = await fetch(`${url}/api/v1/health`, { signal: AbortSignal.timeout(timeoutMs) }); if (!res.ok) return null; const body = await res.json(); return body?.name === 'agent-guild' ? body : null; - } catch { - return null; + } catch (err) { + const owner = err.name === 'TimeoutError' ? readRuntimeFile() : null; + return owner?.url === url && running(owner.pid) ? { busy: owner.pid } : null; } } +async function health(url, timeoutMs = 1000) { + const found = await reach(url, timeoutMs); + return found?.busy ? null : found; +} + function pageUrl(url, token) { return `${url}/#token=${token}`; } @@ -114,7 +121,7 @@ async function ensureManager({ port = null } = {}) { // A pinned port is the endpoint to serve, so it is also the one to check; // a manager found anywhere else is not the one asked for. const knownUrl = port === null ? baseUrl() : expectedUrl; - const answering = await health(knownUrl); + const answering = await reach(knownUrl); if (answering) return { url: knownUrl, started: false, version: answering.version }; // Say why no manager can run here rather than start one that fails. @@ -209,13 +216,13 @@ async function waitForExit(pid, timeoutMs = 15000) { async function cmdStop() { const url = baseUrl(); - const h = await health(url); + const h = await reach(url); if (!h) { console.log('Session manager is not running.'); return; } await requestShutdown(url); - if (await waitForExit(h.pid)) { + if (await waitForExit(h.busy ?? h.pid)) { console.log('Session manager stopped.'); return; } @@ -225,13 +232,14 @@ async function cmdStop() { async function cmdRestart() { const url = baseUrl(); - const before = await health(url); - if (!before) { + const found = await reach(url); + if (!found) { // Nothing to stop: a restart of a stopped manager is a start. const { url: started, version } = await ensureManager(); console.log(`Session manager was not running; started Agent Guild ${version} at ${started}.`); return; } + const before = found.busy ? { pid: found.busy } : found; const { restart } = await requestShutdown(url, { restart: true }); if (!restart) { // A manager from before restarts stops without starting a successor, @@ -239,7 +247,7 @@ async function cmdRestart() { if (!(await waitForExit(before.pid))) throw new Error('the session manager did not stop, so it could not be restarted.'); // On the port the old one served, which an ephemeral port setting would otherwise lose. const { url: started, version } = await ensureManager({ port: portOf(url) }); - const changed = version !== before.version ? `, now Agent Guild ${version} (was ${before.version})` : ''; + const changed = before.version && version !== before.version ? `, now Agent Guild ${version} (was ${before.version})` : ''; console.log(`Session manager restarted at ${started}${changed}.`); return; } @@ -250,7 +258,7 @@ async function cmdRestart() { await new Promise((r) => setTimeout(r, 250)); const now = await health(url, 500); if (now && now.pid !== before.pid) { - const changed = now.version !== before.version ? `, now Agent Guild ${now.version} (was ${before.version})` : ''; + const changed = before.version && now.version !== before.version ? `, now Agent Guild ${now.version} (was ${before.version})` : ''; console.log(`Session manager restarted at ${url}${changed}.`); return; } @@ -261,7 +269,12 @@ async function cmdRestart() { async function cmdStatus() { const url = baseUrl(); - const h = await health(url); + const h = await reach(url); + if (h?.busy) { + console.log(`Session manager running at ${url} (pid ${h.busy}) did not answer in time.`); + process.exitCode = 1; + return; + } if (!h) { console.log('Session manager is not running.'); // With no manager to ask, the boot service is read from systemd directly. @@ -294,7 +307,7 @@ async function cmdStatus() { async function cmdFireworks() { const url = baseUrl(); - if (!(await health(url))) { + if (!(await reach(url))) { console.log('Session manager is not running.'); process.exitCode = 3; return; diff --git a/tests/fixtures/fake-manager.mjs b/tests/fixtures/fake-manager.mjs index 999edc6..e01afc7 100644 --- a/tests/fixtures/fake-manager.mjs +++ b/tests/fixtures/fake-manager.mjs @@ -1,8 +1,8 @@ // A stand-in session manager in a process of its own, so the launcher can wait for it to exit. -// node fake-manager.mjs [slow|stuck] +// node fake-manager.mjs [slow|stuck|busy] // Answers health with its own pid and writes /manager.json, which goes when a shutdown is requested. It then // exits at once, or with `slow` 2 s later, answering no health check meanwhile, as a manager busy ending sessions; -// with `stuck` it never exits. +// with `stuck` it never exits. With `busy` it answers nothing but a shutdown. // Prints its port once listening. import fs from 'node:fs'; import http from 'node:http'; @@ -22,7 +22,7 @@ const server = http.createServer((req, res) => { else setImmediate(() => { server.closeAllConnections(); server.close(() => process.exit(0)); }); return; } - if (stopping) return; + if (stopping || mode === 'busy') return; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, name: 'agent-guild', version, pid: process.pid })); }); diff --git a/tests/launcher.test.mjs b/tests/launcher.test.mjs index 867274d..df3682b 100644 --- a/tests/launcher.test.mjs +++ b/tests/launcher.test.mjs @@ -397,6 +397,36 @@ test('stop fails when the manager has not exited by the end of its wait', async assert.equal(running(fake.pid), true, 'the manager still runs'); }); +test('a manager whose health check times out is busy, not gone: open starts no second one, status and stop find it, restart replaces it', async (t) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-guild-launcher-')); + t.after(() => fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })); + fs.writeFileSync(path.join(home, 'providers.json'), JSON.stringify({ providers: [{ id: 'openai', usage: null, reporting: null }] })); + const busy = await fakeManager(t, home, '0.0.1', 'busy'); + const busyEnv = { ...env, AGENT_GUILD_HOME: home, AGENT_GUILD_PORT: String(busy.port) }; + const opened = await runWith(busyEnv, 'open', '--no-browser'); + assert.equal(opened.code, 0, opened.stderr); + assert.match(opened.stdout, new RegExp(`Session manager already running at http://127\\.0\\.0\\.1:${busy.port}`)); + assert.equal(fs.existsSync(path.join(home, 'manager.log')), false, 'no second manager was started'); + const status = await runWith(busyEnv, 'status'); + assert.equal(status.code, 1); + assert.match(status.stdout, new RegExp(`running at http://127\\.0\\.0\\.1:${busy.port} \\(pid ${busy.pid}\\) did not answer in time`)); + const stopped = await runWith(busyEnv, 'stop'); + assert.equal(stopped.code, 0, stopped.stderr); + assert.match(stopped.stdout, /Session manager stopped/); + assert.equal(running(busy.pid), false, 'the busy manager was stopped'); + + const again = await fakeManager(t, home, '0.0.1', 'busy'); + const againEnv = { ...env, AGENT_GUILD_HOME: home, AGENT_GUILD_PORT: String(again.port) }; + try { + const restarted = await runWith(againEnv, 'restart'); + assert.equal(restarted.code, 0, restarted.stderr); + assert.match(restarted.stdout, new RegExp(`Session manager restarted at http://127\\.0\\.0\\.1:${again.port}\\.`)); + assert.notEqual(JSON.parse(fs.readFileSync(path.join(home, 'manager.json'), 'utf8')).pid, again.pid); + } finally { + await runWith(againEnv, 'stop'); + } +}); + test('restart keeps an ephemeral port, and starts the manager itself when the old one only stops', async (t) => { // AGENT_GUILD_PORT=0: the successor must listen where the old manager did, not on another free port. const zeroHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-guild-launcher-')); From 977bfbfb8c3140fee48aa5479d8039f545b591c7 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 22:31:16 -0500 Subject: [PATCH 08/14] Revert "fix: plan Vibes Mode stops and routes inside what the screen shows" This reverts commit 4a2f339b31b6325d2ba12aea5758d0cef2c846d9. --- tests/browser/yard.mjs | 32 ++++++---------------- tests/yard.test.mjs | 61 +++++++++++++++++------------------------- web/yard/model.mjs | 48 +++++++++------------------------ web/yard/renderer.js | 14 +++------- 4 files changed, 50 insertions(+), 105 deletions(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index ceb3235..bf77f48 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -2099,7 +2099,7 @@ test('Vibes Mode sets every character wandering and every helper flying, and tur const host=document.createElement('div'),labels=document.createElement('div'); const top=document.createElement('div'),bottom=document.createElement('div'); top.style.cssText='position:absolute;top:0;left:0;right:0;height:90px';bottom.style.cssText='position:absolute;bottom:0;left:0;right:0;height:120px'; - host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels,top,bottom);document.body.append(host);window.__bottom=bottom; + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels,top,bottom);document.body.append(host); window.__renderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${T0},cover:[top,bottom]}); window.__sessions=${q(sessions)};window.__providers=${q(f.providers)}; await __renderer.setWorld('guild','dark');__renderer.update(__providers,__sessions); @@ -2112,10 +2112,8 @@ test('Vibes Mode sets every character wandering and every helper flying, and tur const px=(x,y,z)=>{v.set(x,y,z).project(camera);return {x:(v.x+1)/2*w,y:(1-v.y)/2*h};}; const off=p=>p.y<90||p.y>h-120||p.x<0||p.x>w,edge=__model.VIBE.roam.edge*h*camera.zoom/__model.CAMERA.height-.5; for(const u of __units()){ - for(const t of new Set([...u.path,u.target])){ - const feet=px(t.x,0,t.z),head=px(t.x,__model.VIBE.roam.head,t.z); - if([feet,head].some(p=>p.xw-edge)||head.y<134+edge||feet.y>h-120-edge)out.push([t===u.target?'stop':'way',t]); - } + const t=u.target,feet=px(t.x,0,t.z),head=px(t.x,__model.VIBE.roam.head,t.z); + if([feet,head].some(p=>p.xw-edge)||head.y<134+edge||feet.y>h-120-edge)out.push(['stop',t]); for(const helper of u.helpers){const p=helper.model.position;if(off(px(p.x,p.y,p.z)))out.push(['helper',p.x,p.y,p.z]);} } return out; @@ -2144,27 +2142,13 @@ test('Vibes Mode sets every character wandering and every helper flying, and tur assert.ok(Math.abs(roaming.helper.scale-flight.scale)<1e-6,'at its flying size'); assert.equal(roaming.helper.clip,'move'); assert.deepEqual(roaming.offScreen,[],'every stop and helper stays on screen, clear of what covers it'); - const turned=await b.evaluate(`(()=>{const frame=__model.vibeFrame(240/700,134/700,120/700),seen=p=>__model.inVibeFrame(frame,p.x,0,p.z)&&__model.inVibeFrame(frame,p.x,__model.VIBE.roam.head,p.z); - const count=()=>__units().filter(u=>!seen(u.target)).length,detour=()=>__units().filter(u=>seen(u.target)&&!u.path.every(seen)).length; - const turn=until=>{for(let n=0;n<4000&&!until();n++)__renderer.step(.05);const found=until();__renderer.host.style.width='240px';__renderer.resize();return {found,off:__offScreen().filter(([kind])=>kind!=='helper')};}; - const leaving=turn(count);__renderer.host.style.width='900px';__renderer.resize();const rerouted=turn(detour); - const roams=()=>__units().reduce((n,u)=>n+u.roamed,0),goal=roams()+40,during=[]; - for(let n=0;n<20000&&roams()kind!=='helper'));} - return {leaving,rerouted,walked:roams()>=goal,during:during.slice(0,3),after:__offScreen(),moving:__units().every(u=>u.moving)};})()`); - assert.ok(turned.leaving.found>0,'a turn leaves someone heading off screen'); - assert.deepEqual(turned.leaving.off,[],'who heads somewhere on screen instead'); - assert.ok(turned.rerouted.found>0,'a turn leaves someone with a way off screen'); - assert.deepEqual(turned.rerouted.off,[],'who takes a way on screen instead'); - assert.equal(turned.walked,true); - assert.deepEqual(turned.during,[],'every way walked in portrait stays on screen'); + const turned=await b.evaluate(`(()=>{const frame=__model.vibeFrame(380/700,134/700,120/700),count=()=>__units().filter(u=>!__model.inVibeFrame(frame,u.target.x,0,u.target.z)||!__model.inVibeFrame(frame,u.target.x,__model.VIBE.roam.head,u.target.z)).length; + for(let n=0;n<4000&&!count();n++)__renderer.step(.05); + const leaving=count();__renderer.host.style.width='380px';__renderer.resize();const stops=__offScreen().filter(([kind])=>kind==='stop');__run(200);return {leaving,stops,after:__offScreen(),moving:__units().every(u=>u.moving)};})()`); + assert.ok(turned.leaving>0,'the turn leaves someone heading off screen'); + assert.deepEqual(turned.stops,[],'turned to portrait, nobody heads off screen'); assert.deepEqual(turned.after,[],'and everyone stays on screen'); assert.equal(turned.moving,true); - const stuck=await b.evaluate(`(()=>{__bottom.style.height='490px';__renderer.resize();const at=__units().map(u=>[u.root.position.x,u.root.position.z]);__run(100); - const still=__units().every((u,i)=>!u.moving&&u.root.position.x===at[i][0]&&u.root.position.z===at[i][1]); - __bottom.style.height='120px';__renderer.resize();return {still,moving:__units().every(u=>u.moving),offScreen:__offScreen().filter(([kind])=>kind!=='helper')};})()`); - assert.equal(stuck.still,true,'with no way on screen, everyone stands where they are'); - assert.equal(stuck.moving,true,'and walks on once there is room again'); - assert.deepEqual(stuck.offScreen,[]); assert.equal(await b.evaluate(`(()=>{const before=__units().map(u=>u.target);__renderer.update(__providers,__sessions);return __units().every((u,i)=>u.target===before[i]&&u.moving);})()`),true); assert.ok(await b.evaluate('(__renderer.setVibe(false),__settle())')<4000,'turned off, everyone walks back'); assert.equal(await b.evaluate('__home()'),true); diff --git a/tests/yard.test.mjs b/tests/yard.test.mjs index be6a0e0..98e619d 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -164,44 +164,33 @@ const keptClearBy=(x,z)=>Math.min(Math.max(0,Math.hypot(x,z)-14.7),Math.hypot(Ma const vibeFrames=[vibeFrame(16/9),vibeFrame(1,.2,.15),vibeFrame(9/16,.15,.1),vibeFrame(9/19.5,.17,.25)]; const inView=(frame,p)=>{const {right,up}=viewBasis(),d=[p.x-frame.target[0],(p.y||0)-frame.target[1],p.z-frame.target[2]];return {r:d[0]*right[0]+d[1]*right[1]+d[2]*right[2],u:d[0]*up[0]+d[1]*up[1]+d[2]*up[2]};}; const span=values=>Math.max(...values)-Math.min(...values); -test('Vibes Mode wanders only the ground every world keeps clear and the overview shows, to and fro across it, by on-screen ways round the halls, the centrepiece and the station props',()=>{ - const propsFor=(count,provs)=>[...layoutSessions(Array.from({length:count},(_,i)=>session('s'+i,providers[i%6].id)),provs).entries()].map(([id,p])=>stationProp(p,id)); - const walk=(id,frame,provs,props)=>{const legs=[];let from=null;for(let n=0;n<60;n++){const stop=vibeStop(id,n,provs,props,frame,from);legs.push({from,stop});from=stop;}return legs;}; - const check=(frame,provs,props,ids,{alternate=true,half=null}={})=>{ - const boxes=[...[...providerPositions(provs)].map(([id,p])=>({...p,half:id==='docker'?STAGING.hall.docker:STAGING.hall.half})), - {x:STAGING.plaza.x,z:STAGING.plaza.z,half:STAGING.plaza.half},...props.map(p=>({x:p.x,z:p.z,half:STAGING.station.half}))]; - const inside=p=>boxes.some(b=>Math.abs(p.x-b.x)inVibeFrame(frame,p.x,0,p.z)&&inVibeFrame(frame,p.x,VIBE.roam.head,p.z); - for(const id of ids){ - const legs=walk(id,frame,provs,props); - for(const {from,stop} of legs){ - const at=JSON.stringify([id,from,stop]); - assert.ok(stop&&seen(stop),at); - assert.equal(keptClearBy(stop.x,stop.z),0,at); - assert.ok(!boxes.some(b=>Math.abs(stop.x-b.x){ + const custom=[...providers,{id:'custom-1'},{id:'custom-2'}]; + const props=[...layoutSessions(Array.from({length:12},(_,i)=>session('s'+i,providers[i%6].id)),custom).entries()].map(([id,p])=>stationProp(p,id)); + const boxes=[...[...providerPositions(custom)].map(([id,p])=>({...p,half:id==='docker'?STAGING.hall.docker:STAGING.hall.half})), + {x:STAGING.plaza.x,z:STAGING.plaza.z,half:STAGING.plaza.half},...props.map(p=>({x:p.x,z:p.z,half:STAGING.station.half}))]; + const inside=p=>boxes.some(b=>Math.abs(p.x-b.x)Array.from({length:60},(_,n)=>vibeStop(id,n,custom,props,frame)); + for(const id of ['s0','s1','s7']){ + let from=stops(id).at(-1); + for(const stop of stops(id)){ + assert.notEqual(stop.zMath.abs(stop.x-b.x)inView(frame,l.stop)); - if(!half)assert.ok(span(spread.map(p=>p.r))>(frame.right[1]-frame.right[0])/2&&span(spread.map(p=>p.u))>(frame.up[1]-frame.up[0])/2,'the stops spread over what the overview shows'); + const seen=stops(id).map(p=>inView(frame,p)); + assert.ok(span(seen.map(p=>p.r))>(frame.right[1]-frame.right[0])/2&&span(seen.map(p=>p.u))>(frame.up[1]-frame.up[0])/2,'the stops spread over what the overview shows'); } - }; - const custom=[...providers,{id:'custom-1'},{id:'custom-2'}]; - for(const frame of vibeFrames)check(frame,custom,propsFor(12,custom),['s0','s1','s7']); - check(vibeFrame(226/718,132/718,160/718),providers,propsFor(3,providers),['s0','s1','s8']); - check(vibeFrame(16/9,.6,0),providers,[],['s0','s1'],{alternate:false,half:'front'}); - check(vibeFrame(16/9,0,.6),providers,[],['s0','s1'],{alternate:false,half:'courtyard'}); - const frame=vibeFrames[0],stops=id=>walk(id,frame,custom,[]).map(l=>l.stop); - assert.deepEqual(stops('s0'),stops('s0')); - assert.notDeepEqual(stops('s0'),stops('s1')); - assert.deepEqual(Array.from({length:20},(_,n)=>vibeStop('s0',n,providers,[],vibeFrame(16/9,0,.7),{x:0,z:6})),Array(20).fill(null),'room on screen but no ground to stand on: no stop, rather than one off screen'); - const tooSmall=Array.from({length:60},(_,n)=>vibeStop('s0',n,custom,[],vibeFrame(16/9,.5,.5))); - assert.equal(new Set(tooSmall.map(p=>p.x+','+p.z)).size,tooSmall.length,'a stage too small to show a character still roams the clear ground'); + assert.deepEqual(stops('s0'),stops('s0')); + assert.notDeepEqual(stops('s0'),stops('s1')); + } + const hidden=Array.from({length:60},(_,n)=>vibeStop('s0',n,custom,props,vibeFrame(16/9,.5,.5))); + assert.equal(new Set(hidden.map(p=>p.x+','+p.z)).size,hidden.length,'with nothing visible, stops still spread over the clear ground'); }); test('Vibes Mode flies each helper its own smooth loop across what the overview shows, above the halls',()=>{ const [low,high]=VIBE.flight.height; diff --git a/web/yard/model.mjs b/web/yard/model.mjs index e4e1682..e522c83 100644 --- a/web/yard/model.mjs +++ b/web/yard/model.mjs @@ -350,9 +350,8 @@ function crosses(a, b, box, half) { // The shortest way from a to b that keeps out of every hall, the // centrepiece and every station prop: straight when clear, otherwise by the // corners of what is in the way. A prop that a or b stands in is no -// obstacle. Returns the waypoints after a, ending at b; with a Vibes frame, -// waypoints stay on screen and no such way returns null. -export function route(a, b, providers, props = [], frame = null) { +// obstacle. Returns the waypoints after a, ending at b. +export function route(a, b, providers, props = []) { const inside = (p, box) => Math.abs(p.x - box.x) < box.half - .05 && Math.abs(p.z - box.z) < box.half - .05; const boxes = [...obstacles(providers), ...props.map(p => ({ x: p.x, z: p.z, half: STAGING.station.half })) .filter(box => !inside(a, box) && !inside(b, box))], m = STAGING.hall.margin; @@ -361,7 +360,7 @@ export function route(a, b, providers, props = [], frame = null) { const nodes = [a, b]; for (const box of boxes) for (const [sx, sz] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { const p = { x: box.x + sx * (box.half + m), z: box.z + sz * (box.half + m) }; - if (!boxes.some(other => Math.abs(p.x - other.x) < other.half && Math.abs(p.z - other.z) < other.half) && (!frame || onScreen(frame, p))) nodes.push(p); + if (!boxes.some(other => Math.abs(p.x - other.x) < other.half && Math.abs(p.z - other.z) < other.half)) nodes.push(p); } const dist = nodes.map(() => Infinity), from = nodes.map(() => -1), done = nodes.map(() => false); dist[0] = 0; @@ -376,7 +375,7 @@ export function route(a, b, providers, props = [], frame = null) { if (d < dist[v] - 1e-9) { dist[v] = d; from[v] = u; } } } - if (dist[1] === Infinity) return frame ? null : [{ x: b.x, z: b.z }]; + if (dist[1] === Infinity) return [{ x: b.x, z: b.z }]; const path = []; for (let i = 1; i > 0; i = from[i]) path.unshift({ x: round(nodes[i].x), z: round(nodes[i].z) }); path[path.length - 1] = { x: b.x, z: b.z }; @@ -449,44 +448,23 @@ export function inVibeFrame(frame, x, y, z) { const r = dot(d, right), u = dot(d, up); return r >= frame.right[0] && r <= frame.right[1] && u >= frame.up[0] && u <= frame.up[1]; } -const onScreen = (frame, p) => inVibeFrame(frame, p.x, 0, p.z) && inVibeFrame(frame, p.x, VIBE.roam.head, p.z); function unit(value) { let h = hash(value); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } -export function vibeStop(id, n, providers, props = [], frame = null, from = null) { - const { radius, half, front, split, margin, head } = VIBE.roam, { right, up, forward } = viewBasis(); - const courtFirst = (n + hash(id)) % 2 === 0; +export function vibeStop(id, n, providers, props = [], frame = null) { + const { radius, half, front, split, margin, head } = VIBE.roam; + const [near, far] = (n + hash(id)) % 2 ? [split, front] : [-radius, split]; const boxes = [...obstacles(providers).map(box => ({ ...box, half: box.half + margin })), ...props.map(p => ({ x: p.x, z: p.z, half: STAGING.station.half + margin }))]; - const valid = (x, z, court) => (Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0)) - && (court ? z < split : z >= split && z <= front) && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half); - const [r0, r1] = frame ? frame.right : [0, 0], u0 = frame ? frame.up[0] : 0, u1 = frame ? frame.up[1] - head * up[1] : 0; - if (!(r1 > r0 && u1 > u0)) { - const [near, far] = courtFirst ? [-radius, split] : [split, front]; - for (let k = 0; k < 48; k++) { - const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(near + unit(id + ':vibe:z:' + n + ':' + k) * (far - near)); - if (valid(x, z, courtFirst)) return { x, z }; - } - return { x: STAGING.plaza.x, z: round(STAGING.plaza.z + STAGING.plaza.half + margin + STAGING.station.half) }; - } - const at = (r, u) => { - const s = (-frame.target[1] - u * up[1]) / forward[1]; - return { x: round(frame.target[0] + r * right[0] + u * up[0] + s * forward[0]), z: round(frame.target[2] + r * right[2] + u * up[2] + s * forward[2]) }; - }; - const fits = (p, court) => valid(p.x, p.z, court) && onScreen(frame, p) && (!from || route(from, p, providers, props, frame) !== null); - for (const court of [courtFirst, !courtFirst]) { - if (court === courtFirst) for (let k = 0; k < 48; k++) { - const p = at(r0 + unit(id + ':vibe:r:' + n + ':' + k) * (r1 - r0), u0 + unit(id + ':vibe:u:' + n + ':' + k) * (u1 - u0)); - if (fits(p, court)) return p; - } - const cells = []; - for (let r = r0; r <= r1; r += .5) for (let u = u0; u <= u1; u += .5) cells.push(at(r, u)); - const start = Math.floor(unit(id + ':vibe:scan:' + n) * cells.length); - for (let i = 0; i < cells.length; i++) if (fits(cells[(start + i) % cells.length], court)) return cells[(start + i) % cells.length]; + const seen = (x, z) => !frame || (inVibeFrame(frame, x, 0, z) && inVibeFrame(frame, x, head, z)); + for (const framed of [true, false]) for (let k = 0; k < 48; k++) { + const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(near + unit(id + ':vibe:z:' + n + ':' + k) * (far - near)); + const clear = Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0); + if (clear && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half) && (!framed || seen(x, z))) return { x, z }; } - return null; + return { x: STAGING.plaza.x, z: round(STAGING.plaza.z + STAGING.plaza.half + margin + STAGING.station.half) }; } export function vibeFlight(key, seconds, frame) { const { height, lap, reach, top } = VIBE.flight, { right, up, forward } = viewBasis(); diff --git a/web/yard/renderer.js b/web/yard/renderer.js index c57298f..d224f6a 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -495,13 +495,8 @@ export class YardRenderer { top=T.MathUtils.clamp(top,0,h);bottom=T.MathUtils.clamp(bottom,0,h); this.vibeFrames={stop:vibeFrame(w/h,(top+TAG)/h,bottom/h),flight:vibeFrame(w/h,top/h,bottom/h)}; if(!this.vibing())return; - const {stop}=this.vibeFrames,seen=p=>inVibeFrame(stop,p.x,0,p.z)&&inVibeFrame(stop,p.x,VIBE.roam.head,p.z); - for(const unit of this.units.values()){ - if(!unit.moving||!unit.target||!seen(unit.target)){this.roam(unit);continue;} - if(unit.path.every(seen))continue; - const path=route({x:unit.root.position.x,z:unit.root.position.z},unit.target,this.providers,this.propSpots,stop); - if(path)unit.path=path;else this.roam(unit); - } + const {stop}=this.vibeFrames,{head}=VIBE.roam; + for(const unit of this.units.values())if(unit.moving&&unit.target&&!(inVibeFrame(stop,unit.target.x,0,unit.target.z)&&inVibeFrame(stop,unit.target.x,head,unit.target.z)))this.roam(unit); } overview() { const {target,zoom}=overviewFor((this.camera.right-this.camera.left)/(this.camera.top-this.camera.bottom)); @@ -951,9 +946,8 @@ export class YardRenderer { } roam(unit) { unit.roamed=(unit.roamed??-1)+1; - const at={x:unit.root.position.x,z:unit.root.position.z},frame=this.vibeFrames.stop,stop=vibeStop(unit.session.id,unit.roamed,this.providers,this.propSpots,frame,at); - if(!stop){unit.target=at;unit.path=[];unit.moving=false;this.play(unit);return;} - unit.target=stop;unit.path=(frame&&route(at,stop,this.providers,this.propSpots,frame))||route(at,stop,this.providers,this.propSpots);unit.moving=true; + const at={x:unit.root.position.x,z:unit.root.position.z},stop=vibeStop(unit.session.id,unit.roamed,this.providers,this.propSpots,this.vibeFrames.stop); + unit.target=stop;unit.path=route(at,stop,this.providers,this.propSpots);unit.moving=true; this.play(unit); } vibing() { return this.vibe&&!this.reduced; } From 8882750b97d8ac4a9dd25fad4584e769fdb62e7c Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 23:20:45 -0500 Subject: [PATCH 09/14] fix: select Vibes Mode destinations from visible ground Keep seeded courtyard/front preferences and use a deterministic obstacle-edge fallback when visible samples miss. Continue ordinary roaming only when no usable visible ground remains. Add focused coverage for crowded crews, narrow gaps, covered-view recovery and portrait resizing. Shared routing and waypoint handling remain unchanged; off-screen route corners are deferred. --- tests/browser/yard.mjs | 63 +++++++++++++++++++++++++++ tests/yard.test.mjs | 56 ++++++++++++++++++++++++ web/yard/model.mjs | 96 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 211 insertions(+), 4 deletions(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index bf77f48..28dffb9 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -2162,6 +2162,69 @@ test('Vibes Mode sets every character wandering and every helper flying, and tur assert.deepEqual(b.errors,[]); }); +test('Vibes destination selection keeps normal and crowded crews moving through portrait resizing and a covered view',options,async t=>{ + const {f,b}=await setup(t),now=Date.parse('2026-10-13T12:00:00.000Z'); + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js');window.__destModel=await import('/yard/model.mjs'); + const host=document.createElement('div'),labels=document.createElement('div'),top=document.createElement('div'),bottom=document.createElement('div'); + host.style.cssText='position:fixed;top:0;left:0;width:900px;height:700px'; + top.style.cssText='position:absolute;top:0;width:100%;height:90px';bottom.style.cssText='position:absolute;bottom:0;width:100%;height:120px'; + host.append(labels,top,bottom);document.body.append(host); + const r=window.__destRenderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${now},cover:[top,bottom]}); + window.__destCover={top,bottom}; + window.__destProviders=${q([...f.providers,{id:'docker',tool:'Docker Agent',available:true}])}; + await r.setWorld('guild','dark'); + window.__destCalls=0;window.__destTimes=[];window.__destOutside=[];window.__destExpectVisible=true; + window.__destVisible=p=>__destModel.inVibeFrame(r.vibeFrames.stop,p.x,0,p.z)&&__destModel.inVibeFrame(r.vibeFrames.stop,p.x,__destModel.VIBE.roam.head,p.z); + const roam=r.roam;r.roam=function(u){const at=performance.now();roam.call(this,u);__destTimes.push(performance.now()-at);__destCalls++;if(__destExpectVisible&&!__destVisible(u.target))__destOutside.push(u.target);}; + window.__destStats=values=>{const sorted=[...values].sort((a,b)=>a-b),at=p=>Number((sorted[Math.min(sorted.length-1,Math.floor(sorted.length*p))]||0).toFixed(3));return {p50:at(.5),p95:at(.95),max:at(1)};}; + window.__destUpdates=()=>{ + const calls=__destCalls,targets=[...r.units.values()].map(u=>u.target),times=[]; + for(let n=0;n<40;n++){const at=performance.now();r.update(__destProviders,__destSessions);times.push(performance.now()-at);} + return {ms:__destStats(times),searches:__destCalls-calls,sameTargets:[...r.units.values()].every((u,i)=>u.target===targets[i])}; + }; + window.__destWalk=()=>{ + const units=[...r.units.values()],distance=units.map(()=>0),legs=units.map(u=>u.roamed),steps=[]; + for(let n=0;n<1600;n++){ + const before=units.map(u=>({x:u.root.position.x,z:u.root.position.z})),at=performance.now();r.step(.05);steps.push(performance.now()-at); + units.forEach((u,i)=>{distance[i]+=Math.hypot(u.root.position.x-before[i].x,u.root.position.z-before[i].z);}); + } + return {ms:__destStats(steps),minDistance:Math.min(...distance),minLegs:Math.min(...units.map((u,i)=>u.roamed-legs[i])),moving:units.every(u=>u.moving&&u.path.length>0)}; + }; + })()`); + t.after(()=>b.evaluate('__destRenderer.dispose();__destRenderer.host.remove()')); + for(const [name,count,provider] of [['normal',3,null],['crowded-xai',24,'xai'],['crowded-openai',24,'openai']]){ + const sessions=Array.from({length:count},(_,i)=>({...f.data.values().next().value,id:name+'-'+i,name:name+' '+i, + provider:f.providers.find(p=>p.id===(provider||f.providers[i%f.providers.length].id)),activity:'active',createdAt:new Date(now-3600000).toISOString(),lastOutputAt:new Date(now).toISOString(),agents:[],shells:[]})); + await b.evaluate(`(()=>{const r=__destRenderer;r.setVibe(false);__destExpectVisible=true;r.host.style.width='900px';r.host.style.height='700px';__destCover.top.style.height='90px';__destCover.bottom.style.height='120px';r.resize();window.__destSessions=${q(sessions)};r.update(__destProviders,__destSessions);})()`); + await b.wait('[...__destRenderer.units.values()].every(u=>u.loaded)'); + const result=await b.evaluate(`(()=>{ + const r=__destRenderer,slots=JSON.stringify([...r.staged.slots]);__destTimes=[];__destOutside=[]; + const at=performance.now();r.setVibe(true);const activationMs=performance.now()-at; + const landscape={updates:__destUpdates(),walk:__destWalk()}; + r.host.style.width='360px';r.host.style.height='780px';__destCover.bottom.style.height='195px'; + const turn=performance.now();r.resize();const resizeMs=performance.now()-turn; + const portrait={updates:__destUpdates(),walk:__destWalk()}; + __destExpectVisible=false;__destCover.top.style.height='500px';__destCover.bottom.style.height='500px';r.resize(); + const covered={updates:__destUpdates(),walk:__destWalk()}; + __destExpectVisible=true;__destCover.top.style.height='90px';__destCover.bottom.style.height='195px';r.resize(); + const recovered=[...r.units.values()].every(u=>u.moving&&__destVisible(u.target)); + return {activationMs,resizeMs,landscape,portrait,covered,recovered,outside:__destOutside,roamMs:__destStats(__destTimes),sameSlots:JSON.stringify([...r.staged.slots])===slots}; + })()`); + for(const phase of ['landscape','portrait','covered']){ + assert.equal(result[phase].updates.searches,0,`${name} ${phase}: unchanged snapshots do not select destinations again`); + assert.equal(result[phase].updates.sameTargets,true); + assert.equal(result[phase].walk.moving,true,`${name} ${phase}: nobody freezes`); + assert.ok(result[phase].walk.minDistance>1&&result[phase].walk.minLegs>=1,`${name} ${phase}: every character travels and starts another leg`); + } + assert.deepEqual(result.outside,[],`${name}: every newly chosen destination fits the usable view`); + assert.equal(result.recovered,true,`${name}: uncovering the view immediately restores visible destinations`); + assert.equal(result.sameSlots,true,`${name}: station slots stay unchanged`); + t.diagnostic(JSON.stringify({name,...result})); + } + assert.deepEqual(b.errors,[]); +}); + test('the Vibes Mode button stays on across Yards and reloads, and reduced motion disables it',options,async t=>{ const {b}=await setup(t,{yard:true}); const button="document.querySelector('#yard-vibe')",state='['+button+'.getAttribute("aria-pressed"),'+button+'.disabled,localStorage.getItem("agentGuild.vibe")]'; diff --git a/tests/yard.test.mjs b/tests/yard.test.mjs index 98e619d..378c483 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -192,6 +192,62 @@ test('Vibes Mode wanders only the ground every world keeps clear and the overvie const hidden=Array.from({length:60},(_,n)=>vibeStop('s0',n,custom,props,vibeFrame(16/9,.5,.5))); assert.equal(new Set(hidden.map(p=>p.x+','+p.z)).size,hidden.length,'with nothing visible, stops still spread over the clear ground'); }); +test('crowded Vibes destinations stay visible on activation and after portrait resizing',()=>{ + for(const provider of ['xai','openai']){ + const crew=Array.from({length:24},(_,i)=>session('s'+i,provider)); + const props=[...layoutSessions(crew,providers)].map(([id,p])=>stationProp(p,id)); + for(const frame of [vibeFrame(16/9,.2,.15),vibeFrame(9/19.5,.17,.25)])for(const s of crew){ + const stops=[]; + for(let n=0;n<200;n++){ + const p=vibeStop(s.id,n,providers,props,frame);stops.push(p.x+','+p.z); + assert.ok(inVibeFrame(frame,p.x,0,p.z)&&inVibeFrame(frame,p.x,VIBE.roam.head,p.z),`${provider} ${s.id} stop ${n}: ${JSON.stringify(p)}`); + assert.equal(keptClearBy(p.x,p.z),0); + assert.ok(props.every(b=>Math.abs(p.x-b.x)>=STAGING.station.half+VIBE.roam.margin||Math.abs(p.z-b.z)>=STAGING.station.half+VIBE.roam.margin)); + } + assert.equal(new Set(stops).size,stops.length,'the crew keeps getting distinct destinations'); + } + } +}); +test('Vibes finds visible apron and circular slivers missed by rounded samples, including on the non-preferred side',()=>{ + const {up}=viewBasis(); + for(const center of [{x:6.123456,z:10.234567},{x:5.123456,z:-Math.sqrt(VIBE.roam.radius**2-5.123456**2)}]){ + const target=[0,1,0],{r,u}=inView({target},center),gap=.00001; + const frame={target,right:[r-gap,r+gap],up:[u-gap,u+VIBE.roam.head*up[1]+gap]}; + const rounded={x:Math.round(center.x*1000)/1000,z:Math.round(center.z*1000)/1000}; + assert.ok(!inVibeFrame(frame,rounded.x,0,rounded.z),'no millimetre-rounded sample fits this opening'); + for(const id of ['s0','s1'])for(let n=0;n<4;n++){ + const p=vibeStop(id,n,providers,[],frame); + assert.ok(inVibeFrame(frame,p.x,0,p.z)&&inVibeFrame(frame,p.x,VIBE.roam.head,p.z)); + assert.ok(Math.hypot(p.x-center.x,p.z-center.z)<.001); + assert.ok(Math.hypot(p.x,p.z)<=VIBE.roam.radius||(Math.abs(p.x)<=VIBE.roam.half&&p.z>=0)); + assert.deepEqual(vibeStop(id,n,providers,[],frame),p,'fallback is seeded and deterministic'); + assert.notDeepEqual(vibeStop(id,n+1,providers,[],frame),p,'a fallback is not one fixed stop'); + } + } +}); +test('Vibes finds the narrow visible gap between props without a sampling or grid limit',()=>{ + const {up}=viewBasis(),center={x:6.123456,z:10.234567},target=[0,1,0],{r,u}=inView({target},center),gap=.000001; + const frame={target,right:[r-.5,r+.5],up:[u-.3,u+VIBE.roam.head*up[1]+.3]}; + const reach=STAGING.station.half+VIBE.roam.margin,props=[-1,1].map(side=>({x:center.x+side*(reach+gap),z:center.z})); + for(let n=0;n<12;n++){ + const p=vibeStop('s0',n,providers,props,frame); + assert.ok(Math.abs(p.x-center.x)Math.abs(p.x-b.x)>=reach||Math.abs(p.z-b.z)>=reach)); + } +}); +test('fully obstructed visible ground keeps the original walk and recovers when the view opens',()=>{ + const {up}=viewBasis(),center={x:6,z:10},target=[0,1,0],{r,u}=inView({target},center); + const covered={target,right:[r-.1,r+.1],up:[u-.1,u+VIBE.roam.head*up[1]+.1]},props=[center]; + const stops=[]; + for(let n=0;n<30;n++){ + const p=vibeStop('s0',n,providers,props,covered);stops.push(p.x+','+p.z); + assert.deepEqual(p,vibeStop('s0',n,providers,props),'a genuinely covered view uses ordinary roaming, not a blocked state'); + const frame=vibeFrame(9/19.5,.17,.25),next=vibeStop('s0',n,providers,props,frame); + assert.ok(inVibeFrame(frame,next.x,0,next.z)&&inVibeFrame(frame,next.x,VIBE.roam.head,next.z),'an open view immediately selects visible ground again'); + } + assert.equal(new Set(stops).size,stops.length); +}); test('Vibes Mode flies each helper its own smooth loop across what the overview shows, above the halls',()=>{ const [low,high]=VIBE.flight.height; assert.ok(low>STAGING.hall.dockerHeight); diff --git a/web/yard/model.mjs b/web/yard/model.mjs index e522c83..5d337dd 100644 --- a/web/yard/model.mjs +++ b/web/yard/model.mjs @@ -453,16 +453,104 @@ function unit(value) { h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } +// Clip the visible ground polygon to an axis-aligned interval. Destination +// geometry only: this neither builds nor checks a route. +function vibeClip(points, axis, low, high) { + for (const [edge, sign] of [[low, 1], [high, -1]]) { + const next = []; + for (let i = 0; i < points.length; i++) { + const a = points[i], b = points[(i + 1) % points.length], da = sign * (a[axis] - edge), db = sign * (b[axis] - edge); + if (da >= 0) next.push(a); + if ((da < 0) !== (db < 0)) { const t = da / (da - db); next.push({ x: a.x + t * (b.x - a.x), z: a.z + t * (b.z - a.z) }); } + } + points = next; + } + return points; +} +// A missed sample is not an empty view. Obstacle x edges divide the ground +// into at most 2B+1 bands, each with at most B+1 free z intervals. Check those +// finite regions, not a grid whose spacing could miss a narrow opening. +function vibeGap(polygon, boxes, near, far, seed) { + const { radius, half, front } = VIBE.roam; + polygon = vibeClip(vibeClip(polygon, 'x', -radius, radius), 'z', near, far); + const point = points => { + if (points.length < 3) return null; + const area = points.reduce((sum, a, i) => { const b = points[(i + 1) % points.length]; return sum + a.x * b.z - b.x * a.z; }, 0); + if (Math.abs(area) < 1e-10) return null; + const center = { x: 0, z: 0 }; + for (const p of points) { center.x += p.x / points.length; center.z += p.z / points.length; } + const p = points[Math.floor(seed * points.length)], t = .25 + seed / 2; + return { x: center.x + (p.x - center.x) * t, z: center.z + (p.z - center.z) * t }; + }; + const ground = points => { + const p = point(points); + if (!p) return null; + if (Math.hypot(p.x, p.z) < radius || (Math.abs(p.x) <= half && p.z >= 0 && p.z <= front)) return p; + const apron = point(vibeClip(vibeClip(points, 'x', -half, half), 'z', 0, front)); + if (apron) return apron; + // The plaza's obstacle excludes the circle's centre from every free + // region, so its nearest point is on an edge. Move from it into the + // polygon, staying inside the circle even for a small circular sliver. + let nearest = null, distance = Infinity; + for (let i = 0; i < points.length; i++) { + const a = points[i], b = points[(i + 1) % points.length], dx = b.x - a.x, dz = b.z - a.z; + const t = clamp(-(a.x * dx + a.z * dz) / (dx * dx + dz * dz || 1), 0, 1), q = { x: a.x + t * dx, z: a.z + t * dz }, d = Math.hypot(q.x, q.z); + if (d < distance) { nearest = q; distance = d; } + } + if (distance >= radius) return null; + const t = Math.min(.5, (radius - distance) / (2 * Math.hypot(p.x - nearest.x, p.z - nearest.z))); + return { x: nearest.x + t * (p.x - nearest.x), z: nearest.z + t * (p.z - nearest.z) }; + }; + if (!point(polygon)) return null; + const left = Math.min(...polygon.map(p => p.x)), right = Math.max(...polygon.map(p => p.x)); + const edges = [...new Set([left, right, ...boxes.flatMap(b => [b.x - b.half, b.x + b.half]).filter(x => x > left && x < right)])].sort((a, b) => a - b); + const start = Math.floor(seed * (edges.length - 1)); + for (let i = 0; i < edges.length - 1; i++) { + const k = (start + i) % (edges.length - 1), lo = edges[k], hi = edges[k + 1], x = (lo + hi) / 2; + const blocked = boxes.filter(b => Math.abs(x - b.x) < b.half).map(b => [b.z - b.half, b.z + b.half]).sort((a, b) => a[0] - b[0]); + let z = near; + for (const [low, high] of [...blocked, [far, far]]) { + if (low > z) { + const p = ground(vibeClip(vibeClip(polygon, 'x', lo, hi), 'z', z, Math.min(low, far))); + if (p) return p; + } + z = Math.max(z, high); + if (z >= far) break; + } + } + return null; +} export function vibeStop(id, n, providers, props = [], frame = null) { const { radius, half, front, split, margin, head } = VIBE.roam; const [near, far] = (n + hash(id)) % 2 ? [split, front] : [-radius, split]; const boxes = [...obstacles(providers).map(box => ({ ...box, half: box.half + margin })), ...props.map(p => ({ x: p.x, z: p.z, half: STAGING.station.half + margin }))]; - const seen = (x, z) => !frame || (inVibeFrame(frame, x, 0, z) && inVibeFrame(frame, x, head, z)); - for (const framed of [true, false]) for (let k = 0; k < 48; k++) { + const clear = (x, z) => (Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0 && z <= front)) + && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half); + if (frame) { + const { right, up, forward } = viewBasis(), [r0, r1] = frame.right, u0 = frame.up[0], u1 = frame.up[1] - head * up[1]; + const at = (r, u) => { + const s = (-frame.target[1] - u * up[1]) / forward[1]; + return { x: frame.target[0] + r * right[0] + u * up[0] + s * forward[0], z: frame.target[2] + r * right[2] + u * up[2] + s * forward[2] }; + }; + if (r1 > r0 && u1 > u0) { + const polygon = [at(r0, u0), at(r1, u0), at(r1, u1), at(r0, u1)]; + for (const [low, high] of [[near, far], near === split ? [-radius, split] : [split, front]]) { + for (let k = 0; k < 48; k++) { + const p = at(r0 + unit(id + ':vibe:x:' + n + ':' + k) * (r1 - r0), u0 + unit(id + ':vibe:z:' + n + ':' + k) * (u1 - u0)); + const x = round(p.x), z = round(p.z); + if (z >= low && z <= high && clear(x, z) && inVibeFrame(frame, x, 0, z) && inVibeFrame(frame, x, head, z)) return { x, z }; + } + const p = vibeGap(polygon, boxes, low, high, unit(id + ':vibe:gap:' + n)); + if (p) return p; + } + } + } + // No usable visible ground (or no frame): keep the original seeded walk. + // Never turn a crowded/covered view into a stationary, repeatedly retried unit. + for (let k = 0; k < 48; k++) { const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(near + unit(id + ':vibe:z:' + n + ':' + k) * (far - near)); - const clear = Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0); - if (clear && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half) && (!framed || seen(x, z))) return { x, z }; + if (clear(x, z)) return { x, z }; } return { x: STAGING.plaza.x, z: round(STAGING.plaza.z + STAGING.plaza.half + margin + STAGING.station.half) }; } From 9df7da394b3237f274c3ed2e1218f6e5712239b8 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 23:39:24 -0500 Subject: [PATCH 10/14] fix: preserve Vibes helpers and size flight clearance Retain surviving helpers and their animation state across status and membership updates, while cleaning up removed helpers. Reserve flight headroom from companion height, flying scale and camera projection, retaining the existing minimum for default companions. Add deterministic regressions for airborne updates, landing continuity and a validated five-metre replacement companion. Both reported browser regressions fail before the correction; seven targeted model tests and eight targeted browser tests pass after it. --- tests/browser/yard.mjs | 98 ++++++++++++++++++++++++++++++++++++++++++ tests/yard.test.mjs | 13 ++++++ web/yard/model.mjs | 6 +-- web/yard/renderer.js | 25 ++++++++--- 4 files changed, 132 insertions(+), 10 deletions(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index 28dffb9..9de03c5 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -2088,6 +2088,104 @@ test('the row being replaced says Checking replacement until the check answers', await b.wait(status+"==='Customized: Mine'"); assert.deepEqual(b.errors,[]); }); +test('Vibes helpers retain transforms and animation through status, membership and landing updates',options,async t=>{ + const {f,b}=await setup(t),now=Date.parse('2026-10-13T12:00:00.000Z'); + const s={...f.data.values().next().value,id:'s0',activity:'active',createdAt:new Date(now-3600000).toISOString(),lastOutputAt:new Date(now).toISOString(),agents:[{id:'a1',name:'Explorer',status:'working'}],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div');host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__helperRenderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${now}}); + window.__helperSession=${q(s)};window.__helperProviders=${q(f.providers)}; + await __helperRenderer.setWorld('guild','dark');__helperRenderer.update(__helperProviders,[__helperSession]); + window.__helperUnit=__helperRenderer.units.get('s0');window.__survivor=()=>__helperUnit.helpers.find(h=>h.id==='a1'); + window.__helperState=()=>{const h=__survivor();return {model:h.model.uuid,position:h.model.position.toArray(),rotation:h.model.quaternion.toArray(),scale:h.model.scale.toArray(),mixer:h.mixer.time,clip:h.action.getClip().name,time:h.action.time,weight:h.action.getEffectiveWeight()};}; + window.__helperStep=n=>{for(let i=0;i__helperRenderer.update(__helperProviders,[__helperSession]); + })()`); + t.after(()=>b.evaluate('__helperRenderer.dispose();__helperRenderer.host.remove()')); + await b.wait('__helperUnit.loaded&&__helperUnit.helpers.length===1'); + const before=await b.evaluate('(__helperRenderer.setVibe(true),__helperStep(600),__helperState())'); + assert.ok(before.position[1]>8&&before.scale[0]===1.3); + assert.equal(before.clip,'move'); + await b.evaluate("__helperSession.agents[0].status='waiting';__helperUpdate()"); + await b.wait("__survivor()?.pose==='waiting'"); + assert.deepEqual(await b.evaluate('__helperState()'),before,'a status update must not reset the airborne model or animation'); + await b.evaluate("__helperSession.agents.unshift({id:'a2',name:'Builder',status:'working'});__helperUpdate()"); + await b.wait('__helperUnit.helpers.length===2'); + assert.deepEqual(await b.evaluate('__helperState()'),before,'joining peers do not reset a surviving helper'); + assert.deepEqual(await b.evaluate("(h=>[h.model.position.y,h.model.scale.x])(__helperUnit.helpers.find(h=>h.id==='a2'))"),[.27,.45],'new helpers start normally'); + const airborne=await b.evaluate(`(()=>{ + __helperStep(200);window.__removedHelper=__helperUnit.helpers.find(h=>h.id==='a2');window.__helperDisposed=0; + const skeletons=new Set();__removedHelper.model.traverse(n=>{if(n.skeleton)skeletons.add(n.skeleton);}); + for(const skeleton of skeletons){const dispose=skeleton.dispose.bind(skeleton);skeleton.dispose=()=>{__helperDisposed++;dispose();};} + return __helperState(); + })()`); + await b.evaluate('__helperSession.agents.splice(0,1);__helperUpdate()'); + await b.wait('__helperUnit.helpers.length===1'); + assert.deepEqual(await b.evaluate('__helperState()'),airborne,'leaving peers do not reset a surviving helper'); + assert.deepEqual(await b.evaluate('[__removedHelper.model.parent===null,!__removedHelper.action.isRunning(),__helperDisposed>0]'),[true,true,true]); + const landing=await b.evaluate('(__helperRenderer.setVibe(false),__helperStep(1),__helperState())'); + assert.ok(landing.position[1].27&&landing.scale[0]>.45,'landing starts smoothly'); + await b.evaluate("__helperSession.agents[0].status='done';__helperUpdate()"); + await b.wait("__survivor()?.pose==='done'"); + assert.deepEqual(await b.evaluate('__helperState()'),landing,'an update during landing preserves its progress'); + const landed=await b.evaluate(`(()=>{__helperStep(1600);const h=__survivor(),p=__helperUnit.root.position;return {y:h.model.position.y,scale:h.model.scale.x,dx:h.model.position.x-p.x,dz:h.model.position.z-p.z,clip:h.action.getClip().name};})()`); + assert.ok(Math.abs(landed.y-.27)<1e-9&&landed.scale===.45&&Math.abs(landed.dx-.82)<1e-9&&Math.abs(landed.dz)<1e-9,'resting positions and scale stay unchanged'); + assert.equal(landed.clip,'done'); + await b.evaluate("__helperSession.agents[0].status='waiting';__helperUpdate()"); + await b.wait("__survivor()?.pose==='waiting'"); + assert.equal(await b.evaluate('__survivor().action.getClip().name'),'waiting','stationary helpers still reflect their status'); + assert.deepEqual(b.errors,[]); +}); + +test('Vibes flight keeps a supported five-metre custom companion below the heading and above controls',options,async t=>{ + const temp=await fs.mkdtemp(path.join(os.tmpdir(),'guild-tall-companion-'));t.after(()=>fs.rm(temp,{recursive:true,force:true})); + const glb=await fs.readFile(path.join(PIECE_FILES,'companion.glb')),size=glb.readUInt32LE(12),json=JSON.parse(glb.toString('utf8',20,20+size)); + json.nodes[0].extras.yard.height=5;json.nodes[0].scale=[1,5/1.6,1]; + const text=Buffer.from(JSON.stringify(json)),padded=Buffer.concat([text,Buffer.alloc((4-text.length%4)%4,0x20)]),head=Buffer.from(glb.subarray(0,20)),binary=glb.subarray(20+size); + head.writeUInt32LE(20+padded.length+binary.length,8);head.writeUInt32LE(padded.length,12); + const file=path.join(temp,'companion.glb');await fs.writeFile(file,Buffer.concat([head,padded,binary])); + const {checkModels}=await import('../../src/manager/yard-piece-check.mjs');await checkModels([{path:file,name:'companion.glb'}],'companion'); + const yardPieces=fakePieces({guild:{companion:{revision:'tall'}}},{companion:file}),{f,b}=await setup(t,{yardPieces}),now=Date.parse('2026-10-13T12:00:00.000Z'); + const s={...f.data.values().next().value,id:'s0',activity:'active',createdAt:new Date(now-3600000).toISOString(),lastOutputAt:new Date(now).toISOString(),agents:[{id:'a1',name:'Explorer',status:'working'}],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div'),top=document.createElement('div'),bottom=document.createElement('div'); + host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';top.style.cssText='position:absolute;top:0;width:100%;height:90px';bottom.style.cssText='position:absolute;bottom:0;width:100%;height:120px'; + host.append(labels,top,bottom);document.body.append(host);window.__flightBottom=bottom; + window.__flightRenderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${now},cover:[top,bottom]}); + __flightRenderer.setPieces('guild',{companion:{revision:'tall'}});await __flightRenderer.setWorld('guild','dark');__flightRenderer.update(${q(f.providers)},[${q(s)}]); + window.__flightUnit=__flightRenderer.units.get('s0'); + })()`); + t.after(()=>b.evaluate('__flightRenderer.dispose();__flightRenderer.host.remove()')); + await b.wait('__flightUnit.loaded&&__flightUnit.helpers.length===1'); + assert.equal(await b.evaluate('__flightUnit.helpers[0].height'),5); + await b.evaluate('__flightRenderer.setVibe(true)'); + for(const [width,height,bottom] of [[900,700,120],[360,780,195]]){ + const result=await b.evaluate(`(()=>{ + const r=__flightRenderer,h=__flightUnit.helpers[0],position=h.model.position.clone();r.host.style.width='${width}px';r.host.style.height='${height}px';__flightBottom.style.height='${bottom}px';r.resize();const resizeJump=position.distanceTo(h.model.position); + for(let i=0;i<200;i++)r.step(.05); + const v=h.model.position.clone(),project=y=>{v.copy(h.model.position);v.y+=y;v.project(r.camera);return {x:(v.x+1)*${width}/2,y:(1-v.y)*${height}/2};}; + let highest=Infinity,lowest=-Infinity,meshHighest=Infinity,meshLowest=-Infinity,maxStep=0; + for(let i=0;i<880;i++){ + const before=h.model.position.clone();r.step(.05);maxStep=Math.max(maxStep,before.distanceTo(h.model.position)); + highest=Math.min(highest,project(h.height*h.model.scale.y).y);lowest=Math.max(lowest,project(0).y); + h.model.updateMatrixWorld(true);h.model.traverse(node=>{ + if(!node.isMesh)return;if(node.skeleton)node.skeleton.update(); + for(let j=0;j=90,`model top overlaps heading: ${JSON.stringify(result)}`); + assert.ok(result.lowest<=height-bottom,`model base overlaps controls: ${JSON.stringify(result)}`); + assert.ok(result.meshHighest>=90&&result.meshLowest<=height-bottom,`animated companion mesh overlaps the overlays: ${JSON.stringify(result)}`); + assert.equal(result.resizeJump,0,'resizing does not teleport the helper'); + assert.ok(result.maxStep<1.5&&result.scale===1.3,'flight remains smooth at the original scale'); + } + assert.deepEqual(b.errors,[]); +}); + test('Vibes Mode sets every character wandering and every helper flying, and turning it off or reducing motion puts them back',options,async t=>{ const {f,b}=await setup(t); const T0=Date.parse('2026-10-13T12:00:00.000Z'),iso=ms=>new Date(T0-ms).toISOString(); diff --git a/tests/yard.test.mjs b/tests/yard.test.mjs index 378c483..5d705c0 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -265,6 +265,19 @@ test('Vibes Mode flies each helper its own smooth loop across what the overview assert.deepEqual(vibeFlight('s0:a1',12.5,frame),vibeFlight('s0:a1',12.5,frame)); assert.notDeepEqual(vibeFlight('s0:a1',12.5,frame),vibeFlight('s0:a2',12.5,frame)); }); +test('Vibes flight reserves the scaled projected height of supported companions',()=>{ + const {up}=viewBasis(); + for(const frame of [vibeFrame(900/700,90/700,120/700),vibeFrame(360/780,90/780,195/780)])for(const height of [.1,1.1,5]){ + const top=height*VIBE.flight.scale*up[1];let last=null; + for(let step=0;step<=880;step++){ + const p=vibeFlight('s0:a1',step*.05,frame,height),v=inView(frame,p); + assert.ok(v.u+top<=frame.up[1]+1e-9&&v.u>=frame.up[0]-1e-9,`height ${height}, step ${step}`); + if(last)assert.ok(Math.hypot(p.x-last.x,p.y-last.y,p.z-last.z)<1.5,'height-aware flight stays smooth'); + if(height===1.1)assert.deepEqual(p,vibeFlight('s0:a1',step*.05,frame),'default companions keep their flight'); + last=p; + } + } +}); test('the staging contract matches its golden vectors',()=>{ const golden=JSON.parse(readFileSync(new URL('./fixtures/yard-choreography.json',import.meta.url),'utf8')); assert.deepEqual(JSON.parse(JSON.stringify(goldenVectors())),golden,'run node scripts/yard-golden.mjs --write only for a deliberate contract change (docs/yard/CONTRACT.md)'); diff --git a/web/yard/model.mjs b/web/yard/model.mjs index 5d337dd..f9a2aea 100644 --- a/web/yard/model.mjs +++ b/web/yard/model.mjs @@ -554,14 +554,14 @@ export function vibeStop(id, n, providers, props = [], frame = null) { } return { x: STAGING.plaza.x, z: round(STAGING.plaza.z + STAGING.plaza.half + margin + STAGING.station.half) }; } -export function vibeFlight(key, seconds, frame) { - const { height, lap, reach, top } = VIBE.flight, { right, up, forward } = viewBasis(); +export function vibeFlight(key, seconds, frame, companionHeight = 1) { + const { height, lap, reach, top, scale } = VIBE.flight, { right, up, forward } = viewBasis(); const pick = (name, [low, high]) => low + unit(key + ':vibe:' + name) * (high - low); const out = pick('reach', reach), turn = unit(key + ':vibe:turn') < .5 ? 1 : -1, lobes = 2 + Math.floor(unit(key + ':vibe:lobes') * 3); const angle = turn * 2 * Math.PI * (seconds / pick('lap', lap) + unit(key + ':vibe:start')); const wide = out * (1 + .12 * Math.sin(lobes * angle + pick('phase', [0, 2 * Math.PI]))) / 1.12; const y = (height[0] + height[1]) / 2 + (height[1] - height[0]) / 2 * Math.sin(2 * angle + pick('rise', [0, 2 * Math.PI])); - const [r0, r1] = frame.right, u0 = frame.up[0], u1 = frame.up[1] - top; + const [r0, r1] = frame.right, u0 = frame.up[0], u1 = frame.up[1] - Math.max(top, companionHeight * scale * up[1]); const r = (r0 + r1) / 2 + Math.cos(angle) * Math.max(0, r1 - r0) / 2 * wide; const u = (u0 + u1) / 2 + Math.sin(angle) * Math.max(0, u1 - u0) / 2 * wide; const s = (y - frame.target[1] - u * up[1]) / forward[1]; diff --git a/web/yard/renderer.js b/web/yard/renderer.js index d224f6a..47b23e1 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -1024,7 +1024,7 @@ export class YardRenderer { // Helpers trail their character, closing most of the gap in a second. const pull=1-Math.exp(-4*dt); for(const helper of unit.helpers){ - const to=vibing?vibeFlight(unit.session.id+':'+helper.id,this.vibeTime,this.vibeFrames.flight):{x:root.x+helper.offset.x,y:.27,z:root.z+helper.offset.z}; + const to=vibing?vibeFlight(unit.session.id+':'+helper.id,this.vibeTime,this.vibeFrames.flight,helper.height):{x:root.x+helper.offset.x,y:.27,z:root.z+helper.offset.z}; const p=helper.model.position,dx=to.x-p.x,dy=to.y-p.y,dz=to.z-p.z,far=Math.hypot(dx,dz); const size=vibing?VIBE.flight.scale:.45,grow=size-helper.model.scale.x; p.x+=dx*pull;p.y+=dy*pull;p.z+=dz*pull; @@ -1192,9 +1192,15 @@ export class YardRenderer { const kinds=WORLDS[this.skin].helpers,source=this.pieceSource('companion'); const signature=JSON.stringify([source?.revision??null,agents]); if(unit.helperSignature===signature)return; - this.clearHelpers(unit); + const shown=agents.slice(0,SHOWN_HELPERS); + unit.helpers=unit.helpers.filter(helper=>{ + if(shown.some(agent=>agent.id===helper.id))return true; + unit.crew.remove(helper.model);helper.mixer.stopAllAction();helper.mixer.uncacheRoot(helper.model);disposeSkeletons(helper.model); + return false; + }); unit.helperSignature=signature; - const rev=unit.revision,generation=this.request; + const rev=++unit.revision,generation=this.request; + this.dirty=true;this.drawOnce(); const stale=()=>this.disposed||generation!==this.request||rev!==unit.revision||!unit.root.parent; let pieces=null; if(source&&agents.length){ @@ -1209,14 +1215,21 @@ export class YardRenderer { } // The full helper count and all helper details remain in the canonical inspector. // Small squads preserve readable character silhouettes at the overview scale. - await Promise.all(agents.slice(0,SHOWN_HELPERS).map(async(agent,i)=>{ + await Promise.all(shown.map(async(agent,i)=>{ + const offset=helperOffset(i,agents.length),existing=unit.helpers.find(helper=>helper.id===agent.id); + if(existing){ + existing.offset=offset;existing.pose=agent.pose; + if(this.reduced)existing.model.position.set(unit.root.position.x+offset.x,.27,unit.root.position.z+offset.z); + if(!existing.moving&&!this.vibing())this.helperClip(existing,agent.pose); + this.dirty=true;this.drawOnce(); + return; + } try { const n=hash(agent.name||agent.id)%4; const asset=pieces?pieces[n]:await this.asset((agent.shell?kinds.shell:kinds.agent)+n); if(stale())return; const model=T.cloneSkeleton(asset.scene); model.traverse(n=>{if(n.isSkinnedMesh)n.frustumCulled=false;}); - const offset=helperOffset(i,agents.length); model.scale.setScalar(.45);model.position.set(unit.root.position.x+offset.x,.27,unit.root.position.z+offset.z); unit.crew.add(model); const mixer=new T.AnimationMixer(model); @@ -1227,8 +1240,6 @@ export class YardRenderer { unit.helpers.push(helper); this.dirty=true;this.drawOnce(); } catch { - // The next state transition can rebuild this incomplete squad. Routine - // unchanged updates do not call helpers(), so failure cannot loop. if(!stale())unit.helperSignature=null; } })); From 8661dbd12ab654abf5b51bf23d7b2cf5cbaa200d Mon Sep 17 00:00:00 2001 From: oddessentials Date: Tue, 10 Nov 2026 23:54:50 -0500 Subject: [PATCH 11/14] fix: apply helper status poses with reduced motion Clear stale helper movement and evaluate stationary status poses without advancing mixer time. Preserve normal-motion blends and retained helper models. Add deterministic regressions for working-to-waiting poses and flight-to-reduced-motion followed by done. Both reproduce the old failures; all six affected browser checks pass with the correction. --- tests/browser/yard.mjs | 51 ++++++++++++++++++++++++++++++++++++++++++ web/yard/renderer.js | 9 +++++--- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index 9de03c5..547937b 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -2088,6 +2088,57 @@ test('the row being replaced says Checking replacement until the check answers', await b.wait(status+"==='Customized: Mine'"); assert.deepEqual(b.errors,[]); }); +async function reducedHelper(t){ + const {f,b}=await setup(t),now=Date.parse('2026-10-13T12:00:00.000Z'); + const s={...f.data.values().next().value,id:'s0',activity:'active',createdAt:new Date(now-3600000).toISOString(),lastOutputAt:new Date(now).toISOString(),agents:[{id:'a1',name:'Explorer',status:'working'}],shells:[]}; + await b.evaluate(`(async()=>{ + const {YardRenderer}=await import('/yard/renderer.js'); + const host=document.createElement('div'),labels=document.createElement('div');host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); + window.__poseRenderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${now}});window.__poseSession=${q(s)};window.__poseProviders=${q(f.providers)}; + await __poseRenderer.setWorld('guild','dark');__poseRenderer.update(__poseProviders,[__poseSession]);window.__poseUnit=__poseRenderer.units.get('s0'); + })()`); + t.after(()=>b.evaluate('__poseRenderer.dispose();__poseRenderer.host.remove()')); + await b.wait('__poseUnit.loaded&&__poseUnit.helpers.length===1'); + await b.evaluate(`(()=>{ + const h=window.__poseHelper=__poseUnit.helpers[0],sample=h.clips[0],Track=sample.tracks.find(track=>track.ValueTypeName==='quaternion').constructor; + h.mixer.stopAllAction();h.action=null; + const poses=[[0,0,0,1],[0,0,1,0],[1,0,0,0],[0,1,0,0]]; + h.clips=['working','waiting','done','move'].map((name,i)=>{const clip=sample.clone();clip.name=name;clip.duration=1;clip.tracks=[new Track('Head.quaternion',[0,1],[...poses[i],...poses[i]])];return clip;}); + __poseRenderer.helperClip(h,'working');h.mixer.update(0); + window.__poseState=()=>({moving:h.moving,clip:h.action.getClip().name,head:h.model.getObjectByName('Head').quaternion.toArray(),time:h.mixer.time,placement:[...h.model.position.toArray(),...h.model.quaternion.toArray(),...h.model.scale.toArray()],retained:__poseUnit.helpers[0]===h}); + window.__poseUpdates=[];const update=h.mixer.update.bind(h.mixer);h.mixer.update=dt=>{__poseUpdates.push(dt);return update(dt);}; + window.__poseStatus=status=>{__poseSession.agents[0].status=status;__poseRenderer.update(__poseProviders,[__poseSession]);}; + })()`); + return b; +} + +test('reduced-motion helpers immediately display a retained working-to-waiting pose without advancing time',options,async t=>{ + const b=await reducedHelper(t); + const before=await b.evaluate('(__poseRenderer.setReducedMotion(true),__poseUpdates.length=0,__poseState())'); + assert.deepEqual(before.head,[0,0,0,1]); + await b.evaluate("__poseStatus('waiting')");await b.wait("__poseHelper.pose==='waiting'"); + const after=await b.evaluate('__poseState()'); + assert.deepEqual([after.clip,after.head],['waiting',[0,0,1,0]],'selecting a status clip must apply its visible pose immediately'); + assert.equal(after.retained,true);assert.deepEqual(after.placement,before.placement);assert.equal(after.time,before.time); + assert.deepEqual(await b.evaluate('__poseUpdates'),[0],'evaluate the pose without an artificial animation tick'); + assert.deepEqual(b.errors,[]); +}); + +test('reduced motion clears airborne helper movement and applies stationary status poses immediately',options,async t=>{ + const b=await reducedHelper(t); + const airborne=await b.evaluate(`(()=>{__poseRenderer.setVibe(true);for(let i=0;i<600;i++)__poseRenderer.step(.05);__poseUpdates.length=0;return __poseState();})()`); + assert.ok(airborne.moving&&airborne.placement[1]>8);assert.equal(airborne.clip,'move'); + const stopped=await b.evaluate('(__poseRenderer.setReducedMotion(true),__poseState())'); + await b.evaluate("__poseStatus('done')");await b.wait("__poseHelper.pose==='done'"); + const done=await b.evaluate('__poseState()'); + assert.deepEqual({stopped:[stopped.moving,stopped.clip,stopped.head],done:[done.moving,done.clip,done.head]}, + {stopped:[false,'working',[0,0,0,1]],done:[false,'done',[1,0,0,0]]},'reduced motion must leave no stale flight state or pose'); + assert.equal(stopped.retained,true);assert.equal(done.retained,true);assert.deepEqual(done.placement,stopped.placement); + assert.equal(stopped.time,airborne.time);assert.equal(done.time,airborne.time); + assert.deepEqual(await b.evaluate('__poseUpdates'),[0,0],'neither pose update advances animation time'); + assert.deepEqual(b.errors,[]); +}); + test('Vibes helpers retain transforms and animation through status, membership and landing updates',options,async t=>{ const {f,b}=await setup(t),now=Date.parse('2026-10-13T12:00:00.000Z'); const s={...f.data.values().next().value,id:'s0',activity:'active',createdAt:new Date(now-3600000).toISOString(),lastOutputAt:new Date(now).toISOString(),agents:[{id:'a1',name:'Explorer',status:'working'}],shells:[]}; diff --git a/web/yard/renderer.js b/web/yard/renderer.js index 47b23e1..f1effa7 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -1176,10 +1176,12 @@ export class YardRenderer { const clip=helper.clips.find(c=>c.name===name)||helper.clips.find(c=>c.name===LEGACY[name])||helper.clips[0]; if(!clip)return; const action=helper.mixer.clipAction(clip); - if(action===helper.action)return; + if(action===helper.action&&!this.reduced)return; + if(this.reduced){helper.moving=false;helper.mixer.stopAllAction();} action.reset().play(); - if(helper.action){if(this.reduced)helper.action.stop();else helper.action.crossFadeTo(action,FADE,false);} + if(helper.action&&!this.reduced)helper.action.crossFadeTo(action,FADE,false); helper.action=action; + if(this.reduced)helper.mixer.update(0); } clearHelpers(unit) { unit.revision++;unit.helperSignature=null; @@ -1220,7 +1222,7 @@ export class YardRenderer { if(existing){ existing.offset=offset;existing.pose=agent.pose; if(this.reduced)existing.model.position.set(unit.root.position.x+offset.x,.27,unit.root.position.z+offset.z); - if(!existing.moving&&!this.vibing())this.helperClip(existing,agent.pose); + if(this.reduced||(!existing.moving&&!this.vibing()))this.helperClip(existing,agent.pose); this.dirty=true;this.drawOnce(); return; } @@ -1397,6 +1399,7 @@ export class YardRenderer { if(was!==this.vibing())this.restage(); if(value)for(const unit of this.units.values()){this.unlook(unit);unit.lookYaw=0;unit.lookPitch=0;} if(value)for(const unit of this.units.values())if(unit.moving&&unit.target){this.arrive(unit);this.play(unit);} + if(value)for(const unit of this.units.values())for(const helper of unit.helpers)this.helperClip(helper,helper.pose); if(value){for(const item of this.halls.values())item.level=item.target;this.glow();this.stillEffects();this.clearFireworks();} this.dirty=true;this.drawOnce(); } From 5c8ae5a933551b391a152089ff6ef3c0427ede09 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Wed, 11 Nov 2026 00:07:35 -0500 Subject: [PATCH 12/14] fix: rest helpers immediately with reduced motion --- tests/browser/yard.mjs | 43 ++++++++++++++++++++++++++++++++++++++++-- web/yard/renderer.js | 23 +++++++++++++--------- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index 547937b..0070000 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -2088,14 +2088,15 @@ test('the row being replaced says Checking replacement until the check answers', await b.wait(status+"==='Customized: Mine'"); assert.deepEqual(b.errors,[]); }); -async function reducedHelper(t){ +async function reducedHelper(t,reduced=false){ const {f,b}=await setup(t),now=Date.parse('2026-10-13T12:00:00.000Z'); const s={...f.data.values().next().value,id:'s0',activity:'active',createdAt:new Date(now-3600000).toISOString(),lastOutputAt:new Date(now).toISOString(),agents:[{id:'a1',name:'Explorer',status:'working'}],shells:[]}; await b.evaluate(`(async()=>{ const {YardRenderer}=await import('/yard/renderer.js'); const host=document.createElement('div'),labels=document.createElement('div');host.style.cssText='position:fixed;width:900px;height:700px;top:0;left:0';host.append(labels);document.body.append(host); window.__poseRenderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${now}});window.__poseSession=${q(s)};window.__poseProviders=${q(f.providers)}; - await __poseRenderer.setWorld('guild','dark');__poseRenderer.update(__poseProviders,[__poseSession]);window.__poseUnit=__poseRenderer.units.get('s0'); + await __poseRenderer.setWorld('guild','dark');__poseRenderer.setReducedMotion(${reduced});__poseRenderer.setVibe(${reduced}); + __poseRenderer.update(__poseProviders,[__poseSession]);window.__poseUnit=__poseRenderer.units.get('s0'); })()`); t.after(()=>b.evaluate('__poseRenderer.dispose();__poseRenderer.host.remove()')); await b.wait('__poseUnit.loaded&&__poseUnit.helpers.length===1'); @@ -2106,12 +2107,30 @@ async function reducedHelper(t){ h.clips=['working','waiting','done','move'].map((name,i)=>{const clip=sample.clone();clip.name=name;clip.duration=1;clip.tracks=[new Track('Head.quaternion',[0,1],[...poses[i],...poses[i]])];return clip;}); __poseRenderer.helperClip(h,'working');h.mixer.update(0); window.__poseState=()=>({moving:h.moving,clip:h.action.getClip().name,head:h.model.getObjectByName('Head').quaternion.toArray(),time:h.mixer.time,placement:[...h.model.position.toArray(),...h.model.quaternion.toArray(),...h.model.scale.toArray()],retained:__poseUnit.helpers[0]===h}); + window.__poseResting=()=>__poseUnit.helpers.every(h=>{const p=__poseUnit.root.position;return h.model.position.x===p.x+h.offset.x&&h.model.position.y===.27&&h.model.position.z===p.z+h.offset.z&&h.model.scale.toArray().every(n=>n===.45)&&!h.moving;}); window.__poseUpdates=[];const update=h.mixer.update.bind(h.mixer);h.mixer.update=dt=>{__poseUpdates.push(dt);return update(dt);}; window.__poseStatus=status=>{__poseSession.agents[0].status=status;__poseRenderer.update(__poseProviders,[__poseSession]);}; })()`); return b; } +test('reduced-motion helpers initialize and follow squad offsets at rest without advancing time',options,async t=>{ + const b=await reducedHelper(t,true); + const before=await b.evaluate('__poseState()'); + assert.equal(before.time,0,'initialization must not advance the mixer to a seeded animation time'); + assert.equal(await b.evaluate('__poseResting()'),true); + await b.evaluate("__poseSession.agents.unshift({id:'a2',name:'Builder',status:'waiting'});__poseRenderer.update(__poseProviders,[__poseSession])"); + await b.wait('__poseUnit.helpers.length===2'); + assert.equal(await b.evaluate('__poseResting()'),true,'all helpers use their current squad offsets'); + assert.notDeepEqual((await b.evaluate('__poseState()')).placement,before.placement,'the retained helper takes its new offset'); + assert.deepEqual(await b.evaluate('__poseUnit.helpers.map(h=>[h.pose,h.mixer.time,h.action.time])'),[['working',0,0],['waiting',0,0]]); + await b.evaluate('__poseSession.agents.splice(0,1);__poseRenderer.update(__poseProviders,[__poseSession])'); + await b.wait('__poseUnit.helpers.length===1'); + assert.deepEqual(await b.evaluate('__poseState()'),before,'removing a peer restores the offset without replacing or advancing the retained helper'); + assert.deepEqual(await b.evaluate('__poseUpdates'),[0,0]); + assert.deepEqual(b.errors,[]); +}); + test('reduced-motion helpers immediately display a retained working-to-waiting pose without advancing time',options,async t=>{ const b=await reducedHelper(t); const before=await b.evaluate('(__poseRenderer.setReducedMotion(true),__poseUpdates.length=0,__poseState())'); @@ -2129,6 +2148,7 @@ test('reduced motion clears airborne helper movement and applies stationary stat const airborne=await b.evaluate(`(()=>{__poseRenderer.setVibe(true);for(let i=0;i<600;i++)__poseRenderer.step(.05);__poseUpdates.length=0;return __poseState();})()`); assert.ok(airborne.moving&&airborne.placement[1]>8);assert.equal(airborne.clip,'move'); const stopped=await b.evaluate('(__poseRenderer.setReducedMotion(true),__poseState())'); + assert.equal(await b.evaluate('__poseResting()'),true); await b.evaluate("__poseStatus('done')");await b.wait("__poseHelper.pose==='done'"); const done=await b.evaluate('__poseState()'); assert.deepEqual({stopped:[stopped.moving,stopped.clip,stopped.head],done:[done.moving,done.clip,done.head]}, @@ -2139,6 +2159,25 @@ test('reduced motion clears airborne helper movement and applies stationary stat assert.deepEqual(b.errors,[]); }); +test('reduced motion immediately rests a landing helper after its character has stopped',options,async t=>{ + const b=await reducedHelper(t); + const landing=await b.evaluate(`(()=>{ + __poseRenderer.setVibe(true);for(let i=0;i<3;i++)__poseRenderer.step(.05); + __poseRenderer.setVibe(false);for(let i=0;i<20&&__poseUnit.moving;i++)__poseRenderer.step(.05); + window.__landingAction=__poseHelper.action;__poseUpdates.length=0; + return {moving:__poseUnit.moving,root:__poseUnit.root.position.toArray(),helper:__poseState()}; + })()`); + assert.equal(landing.moving,false,'the character has already returned home'); + assert.ok(landing.helper.placement[1]>.28&&landing.helper.placement[7]>.45,'the helper is still airborne and enlarged'); + const stopped=await b.evaluate('(__poseRenderer.setReducedMotion(true),__poseState())'); + assert.equal(await b.evaluate('__poseResting()'),true,'a stationary character must not leave its helper suspended'); + assert.deepEqual(await b.evaluate('__poseUnit.root.position.toArray()'),landing.root); + assert.deepEqual([stopped.moving,stopped.clip,stopped.head,stopped.retained],[false,'working',[0,0,0,1],true]); + assert.equal(stopped.time,landing.helper.time); + assert.deepEqual(await b.evaluate('[__landingAction.isRunning(),__poseHelper.action.getEffectiveWeight(),__poseUpdates]'),[false,1,[0]],'cancel the flight blend and evaluate only the stationary pose'); + assert.deepEqual(b.errors,[]); +}); + test('Vibes helpers retain transforms and animation through status, membership and landing updates',options,async t=>{ const {f,b}=await setup(t),now=Date.parse('2026-10-13T12:00:00.000Z'); const s={...f.data.values().next().value,id:'s0',activity:'active',createdAt:new Date(now-3600000).toISOString(),lastOutputAt:new Date(now).toISOString(),agents:[{id:'a1',name:'Explorer',status:'working'}],shells:[]}; diff --git a/web/yard/renderer.js b/web/yard/renderer.js index f1effa7..078a03a 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -1172,16 +1172,21 @@ export class YardRenderer { spark.points.geometry.attributes.position.needsUpdate=true; } } + restHelper(unit,helper) { + helper.model.position.set(unit.root.position.x+helper.offset.x,.27,unit.root.position.z+helper.offset.z); + helper.model.scale.setScalar(.45); + helper.moving=false;helper.mixer.stopAllAction();helper.action=null; + this.helperClip(helper,helper.pose); + helper.mixer.update(0); + } helperClip(helper,name) { const clip=helper.clips.find(c=>c.name===name)||helper.clips.find(c=>c.name===LEGACY[name])||helper.clips[0]; if(!clip)return; const action=helper.mixer.clipAction(clip); - if(action===helper.action&&!this.reduced)return; - if(this.reduced){helper.moving=false;helper.mixer.stopAllAction();} + if(action===helper.action)return; action.reset().play(); - if(helper.action&&!this.reduced)helper.action.crossFadeTo(action,FADE,false); + if(helper.action)helper.action.crossFadeTo(action,FADE,false); helper.action=action; - if(this.reduced)helper.mixer.update(0); } clearHelpers(unit) { unit.revision++;unit.helperSignature=null; @@ -1221,8 +1226,8 @@ export class YardRenderer { const offset=helperOffset(i,agents.length),existing=unit.helpers.find(helper=>helper.id===agent.id); if(existing){ existing.offset=offset;existing.pose=agent.pose; - if(this.reduced)existing.model.position.set(unit.root.position.x+offset.x,.27,unit.root.position.z+offset.z); - if(this.reduced||(!existing.moving&&!this.vibing()))this.helperClip(existing,agent.pose); + if(this.reduced)this.restHelper(unit,existing); + else if(!existing.moving&&!this.vibing())this.helperClip(existing,agent.pose); this.dirty=true;this.drawOnce(); return; } @@ -1237,8 +1242,8 @@ export class YardRenderer { const mixer=new T.AnimationMixer(model); let yard=null;asset.scene.traverse(n=>{if(n.userData?.yard)yard=n.userData.yard;}); const helper={id:agent.id,height:yardExtras(yard,'helper').height,get top(){return this.height*this.model.scale.y;},model,mixer,offset,pose:agent.pose,clips:asset.animations,moving:false,action:null}; - this.helperClip(helper,agent.pose); - mixer.update(.3+(hash(agent.id)%40)/10); + if(this.reduced)this.restHelper(unit,helper); + else {this.helperClip(helper,agent.pose);mixer.update(.3+(hash(agent.id)%40)/10);} unit.helpers.push(helper); this.dirty=true;this.drawOnce(); } catch { @@ -1399,7 +1404,7 @@ export class YardRenderer { if(was!==this.vibing())this.restage(); if(value)for(const unit of this.units.values()){this.unlook(unit);unit.lookYaw=0;unit.lookPitch=0;} if(value)for(const unit of this.units.values())if(unit.moving&&unit.target){this.arrive(unit);this.play(unit);} - if(value)for(const unit of this.units.values())for(const helper of unit.helpers)this.helperClip(helper,helper.pose); + if(value)for(const unit of this.units.values())for(const helper of unit.helpers)this.restHelper(unit,helper); if(value){for(const item of this.halls.values())item.level=item.target;this.glow();this.stillEffects();this.clearFireworks();} this.dirty=true;this.drawOnce(); } From dafc827d0da3f73d00c18f4c3f54e80a029c4e91 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Wed, 11 Nov 2026 00:26:26 -0500 Subject: [PATCH 13/14] fix: bound launcher requests to unresponsive managers --- bin/agent-guild.mjs | 51 +++++++++++++++++++-------------- tests/fixtures/fake-manager.mjs | 10 ++++--- tests/launcher.test.mjs | 25 ++++++++++++++++ 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/bin/agent-guild.mjs b/bin/agent-guild.mjs index 1e838ae..8051a54 100755 --- a/bin/agent-guild.mjs +++ b/bin/agent-guild.mjs @@ -68,13 +68,14 @@ function portOf(url) { /** The manager's health, `{ busy: pid }` when the manager its runtime file names for `url` runs but did not answer in time, or null. */ async function reach(url, timeoutMs = 1000) { + const signal = AbortSignal.timeout(timeoutMs); try { - const res = await fetch(`${url}/api/v1/health`, { signal: AbortSignal.timeout(timeoutMs) }); + const res = await fetch(`${url}/api/v1/health`, { signal }); if (!res.ok) return null; const body = await res.json(); return body?.name === 'agent-guild' ? body : null; - } catch (err) { - const owner = err.name === 'TimeoutError' ? readRuntimeFile() : null; + } catch { + const owner = signal.aborted ? readRuntimeFile() : null; return owner?.url === url && running(owner.pid) ? { busy: owner.pid } : null; } } @@ -174,21 +175,23 @@ async function cmdOpen({ browser, signIn = false }) { * a successor itself (a manager from before restarts only stops). */ async function requestShutdown(url, { restart = false } = {}) { - // `stop` and `restart` are documented as ending every session, so they do - // not ask; the web page's buttons are the ones that confirm first. - const res = await fetch(`${url}/api/v1/shutdown`, { - method: 'POST', - headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }), - }); - if (!res.ok) { - const { error } = await res.json().catch(() => ({})); - throw new Error(`${restart ? 'restart' : 'stop'} failed: ${error?.message ?? `HTTP ${res.status}`}`); + const signal = AbortSignal.timeout(5000); + try { + const res = await fetch(`${url}/api/v1/shutdown`, { + method: 'POST', signal, + headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }), + }); + const body = await res.json().catch(() => ({})); + signal.throwIfAborted(); + if (!res.ok) throw new Error(`${restart ? 'restart' : 'stop'} failed: ${body.error?.message ?? `HTTP ${res.status}`}`); + const running = body.running ?? 0; + if (running > 0) console.log(`Ending ${running} running session(s).`); + return { running, restart: body.restart === true }; + } catch (err) { + if (signal.aborted) throw new Error(`the session manager did not answer the ${restart ? 'restart' : 'stop'} request within 5 seconds; its outcome could not be confirmed.`); + throw err; } - const body = await res.json().catch(() => ({})); - const running = body.running ?? 0; - if (running > 0) console.log(`Ending ${running} running session(s).`); - return { running, restart: body.restart === true }; } function running(pid) { @@ -312,10 +315,16 @@ async function cmdFireworks() { process.exitCode = 3; return; } - const res = await fetch(`${url}/api/v1/yard/fireworks`, { method: 'POST', headers: { Authorization: `Bearer ${loadOrCreateToken()}` } }); - if (!res.ok) throw new Error(`the session manager answered ${res.status}.`); - const { pages } = await res.json(); - console.log(pages ? `Fireworks sent to ${pages} open page(s); every Yard on show lights up.` : 'No page is open to see the fireworks.'); + const signal = AbortSignal.timeout(5000); + try { + const res = await fetch(`${url}/api/v1/yard/fireworks`, { method: 'POST', signal, headers: { Authorization: `Bearer ${loadOrCreateToken()}` } }); + if (!res.ok) throw new Error(`the session manager answered ${res.status}.`); + const { pages } = await res.json(); + console.log(pages ? `Fireworks sent to ${pages} open page(s); every Yard on show lights up.` : 'No page is open to see the fireworks.'); + } catch (err) { + if (signal.aborted) throw new Error('the session manager did not answer the fireworks request within 5 seconds; delivery could not be confirmed.'); + throw err; + } } const MIN_NODE_MAJOR = 22; diff --git a/tests/fixtures/fake-manager.mjs b/tests/fixtures/fake-manager.mjs index e01afc7..3cd4a2d 100644 --- a/tests/fixtures/fake-manager.mjs +++ b/tests/fixtures/fake-manager.mjs @@ -1,8 +1,4 @@ // A stand-in session manager in a process of its own, so the launcher can wait for it to exit. -// node fake-manager.mjs [slow|stuck|busy] -// Answers health with its own pid and writes /manager.json, which goes when a shutdown is requested. It then -// exits at once, or with `slow` 2 s later, answering no health check meanwhile, as a manager busy ending sessions; -// with `stuck` it never exits. With `busy` it answers nothing but a shutdown. // Prints its port once listening. import fs from 'node:fs'; import http from 'node:http'; @@ -12,6 +8,12 @@ const [home, version, mode] = process.argv.slice(2); const runtime = path.join(home, 'manager.json'); let stopping = false; const server = http.createServer((req, res) => { + if (mode === 'unresponsive') return; + if (mode === 'partial') { + res.writeHead(req.url === '/api/v1/shutdown' ? 202 : 200, { 'Content-Type': 'application/json' }); + res.write('{'); + return; + } if (req.url === '/api/v1/shutdown') { stopping = true; res.writeHead(202, { 'Content-Type': 'application/json' }); diff --git a/tests/launcher.test.mjs b/tests/launcher.test.mjs index df3682b..533ee97 100644 --- a/tests/launcher.test.mjs +++ b/tests/launcher.test.mjs @@ -427,6 +427,31 @@ test('a manager whose health check times out is busy, not gone: open starts no s } }); +for (const mode of ['unresponsive', 'partial']) { + test(`stop, restart and fireworks time out without false success or duplicate launches when manager responses are ${mode}`, async (t) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-guild-launcher-')); + t.after(() => fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })); + const fake = await fakeManager(t, home, '0.0.1', mode); + const runtime = fs.readFileSync(path.join(home, 'manager.json'), 'utf8'); + const runEnv = { ...env, AGENT_GUILD_HOME: home, AGENT_GUILD_PORT: '0' }; + await Promise.all(['stop', 'restart', 'fireworks'].map(async (command) => { + const result = await new Promise((resolve) => { + execFile(process.execPath, [cli, command], { env: runEnv, timeout: 10000 }, (error, stdout, stderr) => { + resolve({ code: error?.code ?? 0, killed: Boolean(error?.killed), stdout, stderr }); + }); + }); + assert.equal(result.killed, false, `${command} must terminate before the test watchdog`); + assert.equal(result.code, 1, result.stderr); + assert.equal(result.stdout, '', `${command} must not report absence or success`); + const outcome = command === 'fireworks' ? 'delivery' : 'its outcome'; + assert.equal(result.stderr, `agent-guild: the session manager did not answer the ${command} request within 5 seconds; ${outcome} could not be confirmed.\n`); + })); + assert.equal(running(fake.pid), true, 'the existing manager still runs'); + assert.equal(fs.readFileSync(path.join(home, 'manager.json'), 'utf8'), runtime, 'the existing manager still owns its ephemeral port'); + assert.equal(fs.existsSync(path.join(home, 'manager.log')), false, 'no duplicate manager was launched'); + }); +} + test('restart keeps an ephemeral port, and starts the manager itself when the old one only stops', async (t) => { // AGENT_GUILD_PORT=0: the successor must listen where the old manager did, not on another free port. const zeroHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-guild-launcher-')); From c8e02f02940c5be6d2401e09a488e650d64b9c75 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Wed, 11 Nov 2026 01:30:33 -0500 Subject: [PATCH 14/14] fix: register subprocess cleanup before spawning A signal that arrived after a detached child started but before its cleanup was registered ended this process without ending the child's process group. The login shell probe, runInOwnGroup and the Codex probe now register first and release on a spawn failure. Co-Authored-By: Claude Fable 5.2 --- src/manager/command-resolver.mjs | 6 ++++-- src/manager/session-hooks.mjs | 3 ++- src/manager/shell-env.mjs | 6 ++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/manager/command-resolver.mjs b/src/manager/command-resolver.mjs index 1381610..672af54 100644 --- a/src/manager/command-resolver.mjs +++ b/src/manager/command-resolver.mjs @@ -184,17 +184,19 @@ export function npmSpawnSpec(npm, args, env = process.env, platform = process.pl function runInOwnGroup(file, args, { env, cwd, timeoutMs, input, maxBuffer, signal }) { return new Promise((resolve, reject) => { let child; + // Registered before the spawn: a signal that lands once the command runs must find it. + const end = () => killGroup(child?.pid); + const release = endWithProcess(end); try { child = spawn(file, args, { env, cwd, detached: true }); } catch (err) { + release(); return reject(Object.assign(err, { stdout: '', stderr: '' })); } let stdout = ''; let stderr = ''; let timedOut = false; let settled = false; - const end = () => killGroup(child.pid); - const release = endWithProcess(end); const finish = (err) => { if (settled) return; settled = true; diff --git a/src/manager/session-hooks.mjs b/src/manager/session-hooks.mjs index 1882dc3..c497555 100644 --- a/src/manager/session-hooks.mjs +++ b/src/manager/session-hooks.mjs @@ -214,6 +214,8 @@ export function codexHooksList(resolved, args, { env, platform = process.platfor const stopped = () => finish(new Error('The manager is stopping')); const timer = setTimeout(() => finish(new Error('Codex did not answer in time')), timeoutMs); timer.unref?.(); + // Registered before the spawn: a signal that lands once Codex runs must find it. + if (platform !== 'win32') release = endWithProcess(() => killGroup(child?.pid)); try { const verbatim = typeof spec.args === 'string'; child = spawn(spec.file, verbatim ? [spec.args] : spec.args, { @@ -222,7 +224,6 @@ export function codexHooksList(resolved, args, { env, platform = process.platfor } catch (err) { return finish(err); } - if (platform !== 'win32') release = endWithProcess(() => killGroup(child.pid)); signal?.addEventListener('abort', stopped, { once: true }); let stderr = ''; let buffer = ''; diff --git a/src/manager/shell-env.mjs b/src/manager/shell-env.mjs index bebdeba..7eb48ac 100644 --- a/src/manager/shell-env.mjs +++ b/src/manager/shell-env.mjs @@ -78,6 +78,9 @@ export function probeLoginShell({ shell, env = process.env, timeoutMs = LOGIN_SH if (process.platform === 'win32' || !shell) return Promise.resolve({ vars: null, failure: null }); return new Promise((resolve) => { let child; + // Registered before the spawn: a signal that lands once the shell runs must find it. + const end = () => killGroup(child?.pid); + const release = endWithProcess(end); try { child = spawn(shell, LOGIN_SHELL_ARGS, { detached: true, @@ -85,12 +88,11 @@ export function probeLoginShell({ shell, env = process.env, timeoutMs = LOGIN_SH env: { ...env, AGENT_GUILD_RESOLVING_ENV: '1' }, }); } catch (err) { + release(); return resolve({ vars: null, failure: `could not start (${err.code || err.message})` }); } let stdout = ''; let settled = false; - const end = () => killGroup(child.pid); - const release = endWithProcess(end); const finish = (result) => { if (settled) return; settled = true;