diff --git a/bin/agent-guild.mjs b/bin/agent-guild.mjs index d73c8ef..8051a54 100755 --- a/bin/agent-guild.mjs +++ b/bin/agent-guild.mjs @@ -66,17 +66,25 @@ 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) { + 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 { - return null; + const owner = signal.aborted ? 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 +122,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. @@ -167,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) { @@ -209,13 +219,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 +235,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 +250,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 +261,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 +272,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,15 +310,21 @@ 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; } - 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/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; diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index a3854c2..0070000 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','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); @@ -2087,4 +2087,356 @@ 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 +}); +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.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'); + 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.__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())'); + 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())'); + 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]}, + {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('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:[]}; + 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(); + 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'); + 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'); + 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.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); + 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('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")]'; + 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")'),'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']); + 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/fixtures/fake-manager.mjs b/tests/fixtures/fake-manager.mjs index 999edc6..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] -// 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. // 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' }); @@ -22,7 +24,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..533ee97 100644 --- a/tests/launcher.test.mjs +++ b/tests/launcher.test.mjs @@ -397,6 +397,61 @@ 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'); + } +}); + +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-')); 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..5d705c0 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, 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})); @@ -159,6 +159,125 @@ 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))); +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)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'); + } + 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('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); + 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,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'); + seen.push(v);last=p; + } + 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'); + } + 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('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/index.html b/web/index.html index 81b3484..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/model.mjs b/web/yard/model.mjs index 37a0b62..f9a2aea 100644 --- a/web/yard/model.mjs +++ b/web/yard/model.mjs @@ -434,6 +434,139 @@ 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: 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; +} +// 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 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)); + if (clear(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, 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] - 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]; + 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) { const angle = (i / Math.min(count, 6)) * Math.PI * 2; diff --git a/web/yard/renderer.js b/web/yard/renderer.js index bfc9f8d..078a03a 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, 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.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.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); } @@ -1134,13 +1172,20 @@ 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)return; action.reset().play(); - if(helper.action){if(this.reduced)helper.action.stop();else helper.action.crossFadeTo(action,FADE,false);} + if(helper.action)helper.action.crossFadeTo(action,FADE,false); helper.action=action; } clearHelpers(unit) { @@ -1154,9 +1199,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){ @@ -1171,26 +1222,31 @@ 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)this.restHelper(unit,existing); + else 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); 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}; - this.helperClip(helper,agent.pose); - mixer.update(.3+(hash(agent.id)%40)/10); + 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}; + 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 { - // 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; } })); @@ -1343,9 +1399,12 @@ 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 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(); } @@ -1372,12 +1431,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..ba2bdfb 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 ? '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; + 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'; @@ -281,6 +295,7 @@ export function initYard(controller) { open: id => 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. @@ -299,6 +314,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 +481,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 +508,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 +525,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); }