From ca318f9a268aa08f6e42d49596a7de8cecbec422 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Wed, 11 Nov 2026 21:20:22 -0500 Subject: [PATCH 1/2] feat: set a Yard character on fire when it has more than six helpers A character whose session has more than six sub-agents and shells burns in its provider's colour: a flame shell, a glow on its body, a ring at its feet and rising embers. Reduced motion shows only the ring and a dimmer glow. Co-Authored-By: Claude Fable 5.2 --- tests/browser/yard.mjs | 80 ++++++++++++++++++++ tests/yard.test.mjs | 18 ++++- web/yard/aura.js | 165 +++++++++++++++++++++++++++++++++++++++++ web/yard/model.mjs | 7 ++ web/yard/renderer.js | 43 +++++++++-- 5 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 web/yard/aura.js diff --git a/tests/browser/yard.mjs b/tests/browser/yard.mjs index 0070000..418e398 100644 --- a/tests/browser/yard.mjs +++ b/tests/browser/yard.mjs @@ -496,6 +496,86 @@ test('marks show reported state over the crew and leave every character\'s motio assert.deepEqual(b.errors,[]); }); +test('a character with more than six helpers burns in its provider\'s colour, fades without restarting, and leaves picking and other characters alone',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 crew=id=>Array.from({length:5},(_,i)=>({id:id+i,name:'H'+i,status:i?'working':'done'})); + const sessions=[...f.data.values()].slice(0,2).map((s,i)=>({...s,provider:{...s.provider,color:i?'#55aaff':'#e96c42'},activity:'active',lastOutputAt:iso(0),createdAt:iso(3600000),agents:crew('h'+i),shells:[{id:'m'+i,kind:'monitor'}]})); + 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); + const r=window.__renderer=new YardRenderer(host,labels,{select(){},open(){},clock:()=>${T0}}); + await r.setWorld('guild','dark'); + window.__sessions=${q(sessions)};window.__providers=${q(f.providers)}; + window.__push=()=>r.update(__providers,structuredClone(__sessions)); + window.__seventh=(i,on)=>{__sessions[i].shells=on?[{id:'m'+i,kind:'monitor'},{id:'x'+i}]:[{id:'m'+i,kind:'monitor'}];__push();}; + window.__steps=(n,dt=.05)=>{for(let i=0;ir.units.get(__sessions[i].id).aura; + window.__state=i=>(a=>a&&[a.active,a.strength,a.elapsed])(__aura(i)); + window.__picks=()=>{const u=r.units.get(__sessions[0].id),rect=r.canvas.getBoundingClientRect(),V=u.root.position.constructor; + const at=world=>{const p=world.project(r.camera);return {clientX:rect.left+(p.x+1)/2*rect.width,clientY:rect.top+(1-p.y)/2*rect.height};}; + r.renderer.render(r.scene,r.camera); + return [u.model.getObjectByName('Hips').getWorldPosition(new V()),u.root.position.clone().add(new V(0,u.height*1.18,0))].map(p=>r.pick(at(p))?.id??null);}; + __push(); + })()`); + const [a]=sessions.map(s=>s.id); + await b.wait('[...__renderer.units.values()].every(u=>u.loaded&&u.helpers.length===6)'); + assert.deepEqual(await b.evaluate('[__aura(0)??null,__aura(1)??null]'),[null,null],'six helpers light nothing'); + const plain=await b.evaluate('__picks()'); + assert.equal(plain[0],a);assert.notEqual(plain[1],a); + await b.evaluate('__seventh(0,true)'); + assert.deepEqual(await b.evaluate('[__state(0).slice(0,2),__aura(1)??null,__renderer.units.get(__sessions[0].id).helpers.length]'),[[true,0],null,6],'the seventh helper lights only its own character'); + await b.evaluate('__steps(1,.1)'); + const rising=await b.evaluate('__state(0)'); + assert.ok(rising[1]>0&&rising[1]<1); + await b.evaluate('window.__first=__aura(0);__push()'); + assert.deepEqual(await b.evaluate('[__aura(0)===__first,__state(0)]'),[true,rising],'a routine snapshot restarts nothing'); + await b.evaluate(`__sessions[0].provider.color='#aaaaaa';__push()`); + assert.deepEqual(await b.evaluate('(({r,g,b})=>[r===g,g===b])(__aura(0).tint)'),[true,true],'a grey provider burns white'); + assert.deepEqual(await b.evaluate('__state(0)'),rising); + await b.evaluate(`__sessions[0].provider.color='#e96c42';__seventh(0,false);__steps(1)`); + const falling=await b.evaluate('__state(0)[1]'); + assert.ok(falling>0&&fallingfalling,'and back in from where it was'); + await b.evaluate('__steps(20)'); + assert.deepEqual(await b.evaluate(`(a=>{const u=__renderer.units.get(__sessions[0].id); + return [a.strength,a.group.visible,a.group.parent===u.root,a.flames.visible,a.overlays.length>0, + a.overlays.every(o=>o.visible&&o.material===a.glow&&!o.castShadow&&o.parent.children.some(n=>n!==o&&n.isSkinnedMesh&&n.skeleton===o.skeleton&&n.geometry===o.geometry)), + [...a.embers.geometry.attributes.alpha.array].some(v=>v>0)];})(__aura(0))`),[1,true,true,true,true,true,true]); + assert.deepEqual(await b.evaluate('__picks()'),plain,'the fire is not something to click'); + // A replaced character model keeps its fire. + await b.evaluate(`(u=>{window.__old=__aura(0).overlays.slice();__renderer.dropModel(u);__renderer.loadUnit(u,u.session);})(__renderer.units.get(__sessions[0].id))`); + await b.wait(`__renderer.units.get(${q(a)}).loaded`); + assert.deepEqual(await b.evaluate(`(a=>{const u=__renderer.units.get(__sessions[0].id);let inside=true; + for(const o of a.overlays){let n=o;while(n&&n!==u.model)n=n.parent;inside&&=n===u.model;} + return [a===__first,a.strength,a.overlays.length>0,inside,__old.every(o=>!o.parent)];})(__aura(0))`),[true,1,true,true,true]); + // A second fire is its own; putting the first out leaves it burning. + await b.evaluate('__seventh(1,true);__steps(7)'); + const other=await b.evaluate('(a=>[a.strength,a.tint.getHexString(),a.glow!==__first.glow,a.flames.material!==__first.flames.material])(__aura(1))'); + assert.deepEqual([other[0],other[2],other[3]],[1,true,true]); + assert.notEqual(other[1],await b.evaluate('__first.tint.getHexString()')); + await b.evaluate('__seventh(0,false);__steps(13)'); + assert.deepEqual(await b.evaluate('[__first.strength,__first.group.visible,__first.overlays.some(o=>o.visible),__aura(1).strength,__aura(1).tint.getHexString()]'),[0,false,false,1,other[1]]); + // Reduced motion shows the ring and a dimmer glow at once, and nothing moves. + await b.evaluate('__seventh(0,true);__steps(2);__renderer.setReducedMotion(true)'); + const still=await b.evaluate('__state(0)'); + assert.equal(still[1],1); + assert.deepEqual(await b.evaluate('(a=>[a.advance(.2),a.elapsed,a.group.visible,a.ring.visible,a.flames.visible,a.embers.visible,a.glow.uniforms.strength.value])(__first)'),[false,still[2],true,true,false,false,.35]); + await b.evaluate('__seventh(0,false)'); + assert.deepEqual(await b.evaluate('[__first.strength,__first.group.visible]'),[0,false]); + await b.evaluate('__renderer.setReducedMotion(false)'); + // An exited session goes out; a removed one takes its fire with it. + await b.evaluate(`Object.assign(__sessions[1],{status:'exited',exitCode:0,activity:'quiet'});__push()`); + assert.equal(await b.evaluate('__aura(1).active'),false); + await b.evaluate('__seventh(0,true);__steps(8);window.__second=__aura(1);__sessions.shift();__push()'); + assert.deepEqual(await b.evaluate(`[__renderer.units.has(${q(a)}),__first.group.parent,__first.overlays.length]`),[false,null,0]); + await b.evaluate('__renderer.dispose()'); + assert.equal(await b.evaluate('__second.group.parent'),null); + assert.deepEqual(b.errors,[]); +}); + test('a mark drawn before its glyph loads is drawn again once it loads, with reduced motion and nothing else changing',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 5d705c0..bc8d590 100644 --- a/tests/yard.test.mjs +++ b/tests/yard.test.mjs @@ -2,7 +2,7 @@ import { test } from 'node:test'; 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, +import { sessionPose, familiarPose, marksFor, onFire, fireTint,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, vibeFrame, inVibeFrame } from '../web/yard/model.mjs'; import { goldenVectors } from '../scripts/yard-golden.mjs'; import { Matrix4, Vector3 } from 'three'; @@ -67,6 +67,22 @@ test('marks follow reported state only: asleep resting alone, waiting helpers, c assert.equal(exit({exitCode:1}),'failed');assert.equal(exit({signal:'SIGTERM'}),'failed'); assert.equal(exit({exitCode:1,multiplexer:{label:'tmux'}}),null); }); +test('a character is on fire above six helpers of any kind until its session exits, in its provider\'s own hue',()=>{ + const helpers=n=>Array.from({length:n},(_,i)=>({id:'a'+i,status:i?'working':'done'})); + const run={status:'running',agents:helpers(5),shells:[{id:'s',kind:'monitor'}]}; + assert.equal(onFire(run),false); + assert.equal(onFire({...run,shells:[...run.shells,{id:'t'}]}),true); + assert.equal(onFire({status:'running',agents:helpers(7)}),true); + assert.equal(onFire({status:'running',shells:helpers(7)}),true); + assert.equal(onFire({status:'running'}),false); + assert.equal(onFire({...run,agents:helpers(9),status:'exited'}),false); + assert.deepEqual(fireTint(.5,.5,.5),[.9,.9,.9]); + assert.deepEqual(fireTint(0,0,0),[.9,.9,.9]); + assert.deepEqual(fireTint(1,.5,0),[1,.5,0]); + assert.deepEqual(fireTint(0,.25,1),[0,.25,1]); + const dull=fireTint(.4,.3,.3); + assert.equal(dull[0],.9);assert.equal(dull[1],dull[2]);assert.ok(Math.abs(dull[1]-.61875)<1e-12); +}); test('all sessions remain addressable beyond the prototype limit and retain their slots',()=>{ const sessions=Array.from({length:40},(_,i)=>session('s'+i)); const first=layoutSessions(sessions,providers); diff --git a/web/yard/aura.js b/web/yard/aura.js new file mode 100644 index 0000000..4bd99c1 --- /dev/null +++ b/web/yard/aura.js @@ -0,0 +1,165 @@ +import * as T from './vendor/engine.js'; +import { hash, fireTint } from './model.mjs'; + +const RISE = .35, FALL = .6, LIFE = 1.25, EMBERS = 24, STILL = .35; +export const EMBER_SIZE = .072; +const GAIN = { flame: 2, glow: .8, ring: .8, ember: 2 }; +const NOISE = ` + uvec2 pcg2d(uvec2 v){v=v*1664525u+1013904223u;v.x+=v.y*1664525u;v.y+=v.x*1664525u;v^=v>>16u;v.x+=v.y*1664525u;v.y+=v.x*1664525u;v^=v>>16u;return v;} + vec2 gradient(vec2 cell){float a=float(pcg2d(uvec2(ivec2(cell)+65536)).x)*(6.2831853/4294967296.);return vec2(cos(a),sin(a));} + float noise(vec2 p){vec2 i=floor(p),f=fract(p);vec2 u=f*f*f*(f*(f*6.-15.)+10.); + float va=dot(gradient(i),f),vb=dot(gradient(i+vec2(1,0)),f-vec2(1,0)),vc=dot(gradient(i+vec2(0,1)),f-vec2(0,1)),vd=dot(gradient(i+vec2(1,1)),f-vec2(1,1)); + return va+u.x*(vb-va)+u.y*(vc-va)+u.x*u.y*(va-vb-vc+vd);}`; +const FLAME = { + vertexShader: `uniform float clock;varying float vHeight;varying vec2 vUv;varying vec3 vNormal; + void main(){vHeight=position.y+.5;vUv=uv;float bend=sin(uv.x*6.2831853*5.+clock*1.7+vHeight*4.); + vec3 p=position;p.xz*=1.+bend*vHeight*vHeight*.12;vNormal=normalMatrix*normal;gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.);}`, + fragmentShader: `uniform vec3 tint;uniform float strength;uniform float clock;varying float vHeight;varying vec2 vUv;varying vec3 vNormal; + ${NOISE} + void main(){float h=clamp(vHeight,0.,1.),a=vUv.x*6.2831853,drift=clock*1.4;vec2 around=vec2(cos(a),sin(a)); + float coarse=noise(around*3.+vec2(h*1.4,-drift)),fine=noise(around*7.+vec2(h*3.,-drift*1.8)); + float tip=.68+.24*sin(a*7.+coarse*3.+clock*.7)+coarse*.2; + float flame=smoothstep(0.,.065,tip-h+fine*.17),foot=smoothstep(0.,.07,h); + float wave=sin(a*9.+coarse*6.+sin(h*6.-clock*2.+a)*1.4)*.5+.5,strands=pow(clamp(wave,0.,1.),1.2+h*2.); + float edge=pow(clamp(1.-abs(normalize(vNormal).z),0.,1.),1.5); + float alpha=flame*foot*strands*(.12+.55*edge)*strength; + gl_FragColor=vec4(mix(tint,vec3(1.),pow(1.-h,6.)*.16)*${GAIN.flame.toFixed(2)}*alpha,1.); + #include + }`, +}; +const GLOW = { + vertexShader: `#include + #include + varying vec3 vNormal; + void main(){ + #include + #include + #include + #include + #include + #include + #include + vNormal=transformedNormal; + }`, + fragmentShader: `uniform vec3 tint;uniform float strength;varying vec3 vNormal; + void main(){float rim=pow(clamp(1.-normalize(vNormal).z,0.,1.),3.); + gl_FragColor=vec4(mix(tint,vec3(1.),rim*.25)*strength*${GAIN.glow.toFixed(2)}*(.025+rim*1.5),1.); + #include + }`, +}; +const EMBER = { + vertexShader: `attribute float size;attribute float alpha;varying float vAlpha;uniform float scale; + void main(){vAlpha=alpha;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);gl_PointSize=size*scale*projectionMatrix[1][1];}`, + fragmentShader: `uniform vec3 tint;varying float vAlpha; + void main(){vec2 c=gl_PointCoord-.5;c.x*=2.667;float a=vAlpha*smoothstep(.5,.1,length(c));if(a<.004)discard; + gl_FragColor=vec4(mix(tint,vec3(1.),.2)*${GAIN.ember.toFixed(2)}*a,1.); + #include + }`, +}; +const additive = { transparent: true, depthWrite: false, blending: T.AdditiveBlending }; +const inert = node => { node.raycast = () => {}; node.frustumCulled = false; node.userData.aura = true; return node; }; +const seeThrough = mesh => [mesh.material].flat().some(m => m.transparent || m.alphaTest > 0); + +export const auraShapes = () => ({ shell: new T.CylinderGeometry(.78, 1, 1, 48, 16, true), ring: new T.RingGeometry(.96, 1, 64) }); + +export class Aura { + constructor(seed, shapes, pointScale) { + this.active = false; this.reduced = false; this.color = null; this.strength = 0; + this.elapsed = hash(seed) % 1000 / 100; + this.tint = new T.Color(); + this.overlays = []; + this.radius = .55; this.tall = 2; + this.group = new T.Group(); + const uniforms = extra => ({ tint: { value: this.tint }, strength: { value: 0 }, ...extra }); + this.flames = inert(new T.Mesh(shapes.shell, new T.ShaderMaterial({ ...FLAME, ...additive, side: T.DoubleSide, uniforms: uniforms({ clock: { value: this.elapsed } }) }))); + this.glow = new T.ShaderMaterial({ ...GLOW, ...additive, uniforms: uniforms(), polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }); + this.ring = inert(new T.Mesh(shapes.ring, new T.MeshBasicMaterial({ ...additive, opacity: 0, side: T.DoubleSide, toneMapped: false }))); + this.ring.rotation.x = -Math.PI / 2; this.ring.position.y = .2; + const geometry = new T.BufferGeometry(); + geometry.setAttribute('position', new T.BufferAttribute(new Float32Array(EMBERS * 3), 3)); + geometry.setAttribute('size', new T.BufferAttribute(new Float32Array(EMBERS), 1)); + geometry.setAttribute('alpha', new T.BufferAttribute(new Float32Array(EMBERS), 1)); + this.embers = inert(new T.Points(geometry, new T.ShaderMaterial({ ...EMBER, ...additive, uniforms: { tint: { value: this.tint }, scale: pointScale } }))); + this.embers.renderOrder = 20; + this.sparks = Array.from({ length: EMBERS }, (_, i) => ({ age: -i * LIFE / EMBERS, live: false, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, size: 0 })); + let state = hash(seed); + this.random = () => { + state = state + 0x6D2B79F5 | 0; + let t = Math.imul(state ^ state >>> 15, 1 | state); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + this.group.add(this.flames, this.ring, this.embers); + this.sync(); + } + bind(model, girth = 0, height = 2) { + for (const overlay of this.overlays) overlay.removeFromParent(); + this.overlays = []; + if (!model) return; + const meshes = []; + model.traverse(node => { if (node.isSkinnedMesh && !seeThrough(node)) meshes.push(node); }); + for (const mesh of meshes) { + const overlay = inert(mesh.clone(false)); + overlay.material = this.glow; overlay.castShadow = false; overlay.receiveShadow = false; + mesh.parent.add(overlay); this.overlays.push(overlay); + } + this.radius = Math.min(.9, Math.max(.55, girth * .42)); this.tall = Math.max(height, .5); + this.flames.scale.set(this.radius + .12, this.tall * 1.22, this.radius + .12); + this.flames.position.y = this.tall * .61 + .04; + const ring = Math.max(.74, this.radius + .12); + this.ring.scale.set(ring, ring, 1); + this.sync(); + } + set(on, color, reduced) { + this.active = on; this.reduced = reduced; + if (color !== this.color) { + this.color = color; + const { r, g, b } = new T.Color(color).getRGB({}, T.SRGBColorSpace); + this.tint.setRGB(...fireTint(r, g, b), T.SRGBColorSpace); + this.ring.material.color.copy(this.tint); + } + if (reduced) this.strength = on ? 1 : 0; + this.sync(); + } + advance(dt) { + if (this.reduced || (!this.active && !this.strength)) return false; + this.strength = Math.max(0, Math.min(1, this.strength + (this.active ? dt / RISE : -dt / FALL))); + if (this.strength) { this.elapsed += dt; this.fly(dt); } + this.sync(); + return true; + } + fly(dt) { + const { position, size, alpha } = this.embers.geometry.attributes, random = this.random; + this.sparks.forEach((p, i) => { + p.age += dt; + if (p.age >= LIFE || (p.age >= 0 && !p.live)) { + p.live = this.active; + if (p.live) { + const a = random() * Math.PI * 2, r = this.radius * (.65 + .35 * Math.sqrt(random())), tilt = random() * .21, turn = random() * Math.PI * 2, speed = this.tall * (.4 + .4 * random()); + Object.assign(p, { age: 0, x: Math.cos(a) * r, y: random() * .1, z: Math.sin(a) * r, vx: Math.sin(tilt) * Math.cos(turn) * speed, vy: Math.cos(tilt) * speed, vz: Math.sin(tilt) * Math.sin(turn) * speed, size: EMBER_SIZE * (.55 + .45 * random()) }); + } + } + if (!p.live) { alpha.setX(i, 0); return; } + p.vy += .25 * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; + const u = p.age / LIFE; + position.setXYZ(i, p.x, p.y, p.z); size.setX(i, p.size); alpha.setX(i, this.strength * Math.min(1, u / .15, (1 - u) / .3)); + }); + position.needsUpdate = size.needsUpdate = alpha.needsUpdate = true; + } + sync() { + const shown = this.strength > 0; + this.group.visible = shown; + this.flames.visible = this.embers.visible = !this.reduced; + this.flames.material.uniforms.strength.value = this.strength; + this.flames.material.uniforms.clock.value = this.elapsed; + this.glow.uniforms.strength.value = this.strength * (this.reduced ? STILL : 1); + this.ring.material.opacity = this.strength * (this.reduced ? STILL : GAIN.ring); + for (const overlay of this.overlays) overlay.visible = shown; + } + dispose() { + this.bind(null); + this.group.removeFromParent(); + for (const material of [this.flames.material, this.glow, this.ring.material, this.embers.material]) material.dispose(); + this.embers.geometry.dispose(); + } +} diff --git a/web/yard/model.mjs b/web/yard/model.mjs index f9a2aea..dfaab1c 100644 --- a/web/yard/model.mjs +++ b/web/yard/model.mjs @@ -121,6 +121,13 @@ export function marksFor(s, mode) { const crew = waiting.length > shown.length ? 'waiting' : mode === 'rest' && !agents.length && !s.shells?.length ? 'asleep' : null; return { crew, helpers: shown.map(a => a.id) }; } +export const onFire = s => s.status !== 'exited' && (s.agents?.length || 0) + (s.shells?.length || 0) > SHOWN_HELPERS; +export function fireTint(r, g, b) { + const max = Math.max(r, g, b), spread = max - Math.min(r, g, b); + const hue = !spread ? 0 : max === r ? ((g - b) / spread + 6) % 6 : max === g ? (b - r) / spread + 2 : (r - g) / spread + 4; + const s = Math.min((max ? spread / max : 0) * 1.25, 1), v = Math.max(max, .9); + return [5, 3, 1].map(n => { const k = (n + hue) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); }); +} export function hash(value) { let n = 0; for (const char of String(value)) n = ((n << 5) - n + char.charCodeAt(0)) | 0; diff --git a/web/yard/renderer.js b/web/yard/renderer.js index 078a03a..da64af3 100644 --- a/web/yard/renderer.js +++ b/web/yard/renderer.js @@ -1,6 +1,7 @@ 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, vibeFrame, inVibeFrame } from './model.mjs'; + stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, yardExtras, playableClip, STAGING, photoSize, marksFor, SHOWN_HELPERS, onFire, VIBE, vibeStop, vibeFlight, vibeFrame, inVibeFrame } from './model.mjs'; +import { Aura, auraShapes, EMBER_SIZE } from './aura.js'; const ASSETS = new URL('./assets/', import.meta.url); const vector = new T.Vector3(); @@ -102,6 +103,21 @@ function checkPiece({scene,animations}) { }); } finally {disposeSkeletons(probe);} } +function girth(scene) { + const probe=T.cloneSkeleton(scene); + try { + probe.updateMatrixWorld(true); + const inverse=new T.Matrix4().copy(probe.matrixWorld).invert(),local=new T.Matrix4(); + let left=Infinity,right=-Infinity,back=Infinity,front=-Infinity; + probe.traverse(node=>{ + if(!node.isSkinnedMesh)return; + node.skeleton.update();node.computeBoundingBox(); + const {min,max}=node.boundingBox.applyMatrix4(local.multiplyMatrices(inverse,node.matrixWorld)); + left=Math.min(left,min.x);right=Math.max(right,max.x);back=Math.min(back,min.z);front=Math.max(front,max.z); + }); + return Math.max(right-left,front-back,0); + } finally {disposeSkeletons(probe);} +} function disposeTree(root) { disposeSkeletons(root); const geometries=new Set(),materials=new Set(); @@ -146,6 +162,7 @@ export class YardRenderer { 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.pointScale={value:1};this.auraShapes=auraShapes(); 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.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(); @@ -278,6 +295,7 @@ export class YardRenderer { } dropModel(unit) { for(const mixer of unit.mixers){mixer.stopAllAction();mixer.uncacheRoot(mixer.getRoot());} + unit.aura?.bind(null); if(unit.model){unit.root.remove(unit.model);disposeSkeletons(unit.model);} Object.assign(unit,{mixers:[],model:null,loaded:false,loading:false,animations:null,action:null,clip:null,lookBones:null,looking:false,load:(unit.load||0)+1}); } @@ -474,7 +492,7 @@ export class YardRenderer { const w=this.host.clientWidth,h=this.host.clientHeight; if(!w||!h)return; this.renderer.setSize(w,h,false); - if(this.effects)this.effects.points.material.uniforms.scale.value=this.renderer.getDrawingBufferSize(vector2).y/2; + this.pointScale.value=this.renderer.getDrawingBufferSize(vector2).y/2; const half=CAMERA.height/2; this.camera.left=-half*w/h;this.camera.right=half*w/h; this.camera.top=half;this.camera.bottom=-half; @@ -685,6 +703,9 @@ export class YardRenderer { unit.label.style.setProperty('--unit-color',session.provider.color||'#d0ba82'); unit.label.setAttribute('aria-label',session.name+', '+unit.label.children[1].textContent); unit.ring.material.opacity=place.mode==='work'?.65:place.mode==='done'?.15:.35; + const burning=onFire(session); + if(burning&&!unit.aura){unit.aura=new Aura(session.id,this.auraShapes,this.pointScale);unit.root.add(unit.aura.group);unit.aura.bind(unit.model,unit.girth,unit.height);} + unit.aura?.set(burning,session.provider.color||'#d0ba82',this.reduced); } for(const id of live)this.seen.add(id); this.select(this.selected);this.dirty=true;this.drawOnce(); @@ -709,6 +730,7 @@ export class YardRenderer { yard=yardExtras(yard,'character'); unit.speed=yard.stride/yard.cycle; unit.events=yard.events;unit.height=yard.height;unit.surface=yard.surface;unit.look=lookLimits(yard.look); + unit.girth=asset.girth??=girth(asset.scene);unit.aura?.bind(model,unit.girth,unit.height); const neck=model.getObjectByName('Neck'),head=model.getObjectByName('Head'); unit.lookBones=neck&&head&&model.getObjectByName('LeftUpperLeg')?{neck,head}:null; this.scaleProp(unit.session?.id||session.id); @@ -771,7 +793,7 @@ export class YardRenderer { geometry.setAttribute('size',new T.BufferAttribute(new Float32Array(PARTICLES),1)); geometry.setAttribute('alpha',new T.BufferAttribute(new Float32Array(PARTICLES),1)); const material=new T.ShaderMaterial({ - uniforms:{color:{value:new T.Color(preset.color)},scale:{value:1}}, + uniforms:{color:{value:new T.Color(preset.color)},scale:this.pointScale}, vertexShader:`attribute float size;attribute float alpha;varying float vAlpha;uniform float scale; void main(){vAlpha=alpha;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);gl_PointSize=size*scale*projectionMatrix[1][1];}`, fragmentShader:`uniform vec3 color;varying float vAlpha; @@ -1271,9 +1293,10 @@ export class YardRenderer { const w=this.host.clientWidth,h=this.host.clientHeight,buffer=this.renderer.getDrawingBufferSize(new T.Vector2()); const size=photoSize(w,h,buffer.x,buffer.y); const viewport=this.renderer.getViewport({copy:v=>v.clone()}),ratio=this.renderer.getPixelRatio(); - const particleScale=this.effects?.points.material.uniforms.scale,scale=particleScale?.value; + const particleScale=this.pointScale,scale=particleScale.value; const fireworks=this.sparks.filter(s=>s.firework).map(s=>({material:s.points.material,size:s.points.material.size})); - const radius=Math.max((this.effects?Math.max(...this.effects.preset.size):0)*size.height*this.camera.projectionMatrix.elements[5]/4, + const embers=[...this.units.values()].some(unit=>unit.aura)?EMBER_SIZE:0; + const radius=Math.max(Math.max(embers,...(this.effects?this.effects.preset.size:[]))*size.height*this.camera.projectionMatrix.elements[5]/4, ...fireworks.map(s=>s.size*size.scale/2)); const pad=Math.ceil(radius)+1,tw=buffer.x+2*pad,th=buffer.y+2*pad; const canvas=Object.assign(document.createElement('canvas'),{width:size.width,height:size.height}); @@ -1287,7 +1310,7 @@ export class YardRenderer { // Keep particle centers within a padded frustum. Extending the viewport // beyond the canvas crops the margins without enlarging the live buffer. this.renderer.setViewport(-pad/ratio,-pad/ratio,tw/ratio,th/ratio); - if(particleScale)particleScale.value=th/2; + particleScale.value=th/2; for(const {x,y}of size.tiles) { this.camera.setViewOffset(size.width,size.height,x-pad,y-pad,tw,th); this.renderer.render(this.scene,this.camera); @@ -1296,7 +1319,7 @@ export class YardRenderer { } finally { for(const spark of fireworks)spark.material.size=spark.size; this.renderer.setViewport(viewport); - if(particleScale)particleScale.value=scale; + particleScale.value=scale; this.camera.clearViewOffset(); this.select(this.selected); this.renderer.render(this.scene,this.camera); @@ -1318,7 +1341,7 @@ export class YardRenderer { this.pointer.set((event.clientX-rect.left)/rect.width*2-1,-(event.clientY-rect.top)/rect.height*2+1); this.ray.setFromCamera(this.pointer,this.camera); // A skinned mesh keeps the bounds of the pose it first had; picking needs the pose on screen. - for(const unit of this.units.values())for(const root of [unit.root,unit.crew])root?.traverse(n=>{if(!n.isSkinnedMesh)return;n.computeBoundingSphere();if(n.boundingBox)n.computeBoundingBox();}); + for(const unit of this.units.values())for(const root of [unit.root,unit.crew])root?.traverse(n=>{if(!n.isSkinnedMesh||n.userData.aura)return;n.computeBoundingSphere();if(n.boundingBox)n.computeBoundingBox();}); const hits=this.ray.intersectObjects([...this.halls.values(),...this.units.values()].flatMap(i=>i.crew?[i.root,i.crew]:[i.root]),true); for(const hit of hits) { for(let node=hit.object;node;node=node.parent) if(node.userData.entity)return node.userData.entity; } return null; @@ -1405,6 +1428,7 @@ export class YardRenderer { 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); + for(const unit of this.units.values())unit.aura?.set(unit.aura.active,unit.aura.color,value); if(value){for(const item of this.halls.values())item.level=item.target;this.glow();this.stillEffects();this.clearFireworks();} this.dirty=true;this.drawOnce(); } @@ -1449,6 +1473,7 @@ export class YardRenderer { this.updateSparks(dt); this.updateHalls(dt); this.stepMarks(dt); + for(const unit of this.units.values())if(unit.aura?.advance(dt))this.dirty=true; } drawOnce() { if(this.active)this.schedule(); } mark(unit,place) { @@ -1501,6 +1526,7 @@ export class YardRenderer { for(const mixer of unit.mixers){mixer.stopAllAction();mixer.uncacheRoot(mixer.getRoot());} for(const helper of unit.helpers){helper.mixer.stopAllAction();helper.mixer.uncacheRoot(helper.model);} for(const mark of unit.marks?.values()||[])this.dropMark(mark); + unit.aura?.dispose(); disposeSkeletons(unit.root);disposeSkeletons(unit.crew); this.scene.remove(unit.root);this.scene.remove(unit.crew);unit.label.remove();unit.ring.geometry.dispose();unit.ring.material.dispose(); } @@ -1517,6 +1543,7 @@ export class YardRenderer { disposeWorld(this.world); for(const entry of this.cache.values())entry.then(a=>disposeTree(a.scene)).catch(noop);this.cache.clear(); for(const texture of Object.values(this.glyphs||{}))texture.dispose();this.markPlane.dispose(); + for(const shape of Object.values(this.auraShapes))shape.dispose(); this.sun.shadow.map?.dispose(); this.canvas.removeEventListener('webglcontextlost',this.contextLost); this.canvas.removeEventListener('pointerdown',this.onPointerDown); From b4d3d0ba7946a7d687efc61c020139ab1eceb688 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Thu, 12 Nov 2026 00:19:48 -0500 Subject: [PATCH 2/2] test: judge only systemd warnings about the unit under test systemd-analyze verify also loads the machine's own units and prints their warnings. The Ubuntu 26.04 runner image ships units that set the removed CPUAccounting= option, so the check for an empty stderr failed there. Co-Authored-By: Claude Fable 5.2 --- tests/systemd-service.test.mjs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/systemd-service.test.mjs b/tests/systemd-service.test.mjs index c76dcfa..875ce06 100644 --- a/tests/systemd-service.test.mjs +++ b/tests/systemd-service.test.mjs @@ -233,10 +233,33 @@ test('systemd reads the unit as written', { skip: !hasAnalyze() && 'systemd-anal fs.writeFileSync(launcher, BOOT_LAUNCHER_SCRIPT, { mode: 0o755 }); const file = path.join(dir, SERVICE); fs.writeFileSync(file, serviceUnit({ launcher, execPath: process.execPath, script: launcher, file, port: 47821 })); - // Unknown keys and unusable commands are warnings on stderr, so an empty stderr is the check. The - // system manager's rules read a unit's syntax as the user manager's do, and need no user runtime folder. + // Unknown keys and unusable commands are warnings on stderr. Verifying also loads the machine's own + // units, and warnings that name another unit's file are not about this one. The system manager's + // rules read a unit's syntax as the user manager's do, and need no user runtime folder. const r = spawnSync('systemd-analyze', ['verify', file], { encoding: 'utf8' }); - assert.deepEqual({ status: r.status, stdout: r.stdout, stderr: r.stderr }, { status: 0, stdout: '', stderr: '' }); + assert.deepEqual({ status: r.status, stdout: r.stdout, stderr: ownWarnings(r.stderr, file) }, { status: 0, stdout: '', stderr: [] }); +}); + +function ownWarnings(stderr, file) { + return stderr.split('\n').filter((line) => { + const named = line.match(/^(\/[^:]+):\d+: /); + return line && !(named && named[1] !== file); + }); +} + +test('only warnings that name another unit file are left out of the systemd check', () => { + const file = '/tmp/data 100%/agent-guild.service'; + const stderr = [ + '/usr/lib/systemd/system/xfs_scrub_all.service:26: Support for option CPUAccounting= has been removed and it is ignored', + `${file}:9: Unknown key 'Nope' in section [Service], ignoring.`, + 'agent-guild.service: Command /bin/nope is not executable: No such file or directory', + '', + ].join('\n'); + assert.deepEqual(ownWarnings(stderr, file), [ + `${file}:9: Unknown key 'Nope' in section [Service], ignoring.`, + 'agent-guild.service: Command /bin/nope is not executable: No such file or directory', + ]); + assert.deepEqual(ownWarnings('', file), []); }); function hasAnalyze() {