From 1b5a66827d98b47e0f940ddfa51eedfd8e8ef85d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:01:04 +0000 Subject: [PATCH] fix(studio): light the tables that work, and a floor with no two tables alike A captured two-team Studio run (backend-go + docs, where docs names no worker and the pipeline's go-worker does its ticket) showed the tables dark while only the command center moved. The floor model was reading the real log wrong: - A rostered specialist working another team's ticket was lit at no table: at home the ticket was not theirs, and the visitor seat skipped anyone on any roster. They now sit at the table whose ticket they work, drawn as lent by their team, and their home chair is marked away. - The execute loop's generic "worker"/"tester" lines (file_change, latency) became a phantom person at the command center. They now count as the specialist doing the work. - Starts with no end (split's "assigned go-worker", test's "verification pass") lit an agent for the rest of the run. They are dropped when the run moves to another phase. - Debug and latency lines no longer make anyone "speak". And the floor was repetitive, and fell asleep: every table shared one party clock and one set of odds, and five idle minutes forced a nap. Now each table has its own habits and party programme, each agent a favourite pastime; idle agents chat, scroll a phone, think, stroll a path of their own, and walk to a shared break room (coffee bar and foosball) where every table and the command center's crew meet. Managers present at the board, tables applaud a landed ticket and wince at a failed one, a clicked agent waves, and a long wait in a live run no longer puts a whole table to sleep. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MursiSjrf51muBCzrWus9Y --- docs/changelog.md | 22 + .../components/Live/floor/FloorDossier.tsx | 17 +- web/src/components/Live/floor/RigBody.tsx | 4 + web/src/components/Live/floor/TeamFloor3D.tsx | 393 ++++++++++++++++-- .../components/Live/floor/TeamFloorFlat.tsx | 39 +- .../components/Live/floor/floorLife.test.ts | 59 ++- web/src/components/Live/floor/floorLife.ts | 216 ++++++++-- .../components/Live/floor/floorModel.test.ts | 60 +++ web/src/components/Live/floor/floorModel.ts | 129 +++++- web/src/components/Live/floor/floorRig.ts | 98 +++++ web/src/styles/globals.css | 4 + 11 files changed, 931 insertions(+), 110 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 7c31061..5b1ba41 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,8 +12,30 @@ ## Unreleased +### Fixed + +- **The Live floor's team tables light up again.** A specialist working a + ticket of a team that did not name one (the pipeline's go-worker doing the + docs team's ticket) was lit at no table at all; it now works at that table, + drawn as lent, and its chair at home says where it went. The execute loop's + generic `worker`/`tester` lines count as the specialist's, not a phantom + at the command center; a start with no end (the split phase's "assigned + go-worker", the test phase's "verification pass") is dropped when the run + moves on instead of lighting that agent for the rest of the run; debug and + latency lines no longer make anyone "speak". Verified against a captured + two-team Studio run. + ### Added +- **A floor with more life, and no two tables alike.** Idle agents chat with + a neighbour, scroll a phone, think, stroll a path of their own, and walk to + a shared break room behind the tables β€” a coffee bar and a foosball table β€” + where people from every table and the command center's crew meet. Managers + walk their table through the board. Tables applaud a ticket landing, wince + at one failing and turn to a teammate starting; a clicked agent waves back. + Every table has its own habits and its own party programme, every agent a + favourite pastime, and a long wait in a live run no longer puts a whole + table to sleep. - **A command center and a floor that lives.** On the Live floor the harness's agents wait inside a glass command center instead of crowding a table; whoever is on walks out through its sliding door onto a pad, speaks, and walks back. diff --git a/web/src/components/Live/floor/FloorDossier.tsx b/web/src/components/Live/floor/FloorDossier.tsx index e3773e3..5d07a0d 100644 --- a/web/src/components/Live/floor/FloorDossier.tsx +++ b/web/src/components/Live/floor/FloorDossier.tsx @@ -174,7 +174,7 @@ function AgentDossier({ )} {held && held.title &&
{held.title}
} - {!active && } + {!active && } {/* Management */} @@ -350,8 +350,17 @@ function TicketDossier({ /** A phase agent on the pipeline's stage: what phase, whether speaking, what was said. */ /** What someone idle is up to meanwhile β€” the same mood the stage draws. */ -function MoodLine({ id, since, running, partying, now }: { id: string; since?: number; running: boolean; partying: boolean; now: number }) { - const mood = moodFor({ active: false, running, partying, since: since ?? now, now, seed: seedOf(id) }); +function MoodLine({ id, since, running, partying, now, table, manager, away }: { id: string; since?: number; running: boolean; partying: boolean; now: number; table: string; manager?: boolean; away?: string }) { + const raw = moodFor({ active: false, running, partying, since: since ?? now, now, seed: seedOf(id), table: seedOf(table), manager, away: !!away }); + // The dossier is open because they were clicked: say what they were up to, not that they are waving. + const mood = table === 'harness' && raw === 'football' ? 'dance' : table === 'harness' && raw === 'present' ? 'think' : raw; + if (mood === 'away') { + return ( +
+ meanwhile: away, working at {away} +
+ ); + } return (
meanwhile: {MOOD_LABEL[mood]} @@ -383,7 +392,7 @@ function StageDossier({ seat, floor, events, running, now, onSelect }: { seat: F {seat.lastMessage && (
{seat.lastMessage}
)} - {!active && } + {!active && }
{seat.id === DISPATCHER_ID diff --git a/web/src/components/Live/floor/RigBody.tsx b/web/src/components/Live/floor/RigBody.tsx index 1612f4a..514dcc6 100644 --- a/web/src/components/Live/floor/RigBody.tsx +++ b/web/src/components/Live/floor/RigBody.tsx @@ -58,6 +58,10 @@ export default function RigBody({ rig, look, pick, children }: { rig: Rig; look: + + + + diff --git a/web/src/components/Live/floor/TeamFloor3D.tsx b/web/src/components/Live/floor/TeamFloor3D.tsx index 03e21e2..50d5407 100644 --- a/web/src/components/Live/floor/TeamFloor3D.tsx +++ b/web/src/components/Live/floor/TeamFloor3D.tsx @@ -8,7 +8,7 @@ import { STAGE_GLYPHS } from '@/components/shared/labels'; import { HARNESS_ID, PULSE_TTL_MS, workTables, type FloorAgent, type FloorHandoff, type FloorModel, type FloorPhase, type FloorPulse, type FloorStageSeat, type FloorTeam, type FloorTicket, type TicketState } from './floorModel'; import { LEGEND_STATES, TICKET_HEX, TICKET_LABEL, glyphFor, seatTitle, type FloorSelection } from './floorShared'; import { floorStore, rememberCamera, rememberCameraPrefs } from './floorStore'; -import { MOOD_GLYPH, MOOD_LABEL, floorShipped, isFootballRound, isParty, moodFor, moodShows, seedOf, tableParties, type LifeInputs, type Mood } from './floorLife'; +import { MOOD_GLYPH, MOOD_LABEL, floorShipped, isFootballRound, isBreak, isParty, isStanding, moodFor, moodShows, seedOf, tableParties, tableSignals, type LifeInputs, type Mood, type TableSignals } from './floorLife'; import { lookOf, makeWalk, poseRig, stillPose, turnToward, useRig, walkAt, type Walk } from './floorRig'; import RigBody from './RigBody'; @@ -34,10 +34,15 @@ import RigBody from './RigBody'; // room, not a crowd of name tags. // // Nobody is a statue. Everyone who is not working has a mood (floorLife): -// they watch the worker, sip a coffee, stretch, take a walk round the table, -// and doze off at the desk when nothing comes their way. A table whose every -// ticket is done stands up for a beer, a kick-about and a dance; when the -// whole run ships, the whole floor β€” command center included β€” joins in. +// they watch the worker, chat, scroll a phone, think, stretch, stroll, walk +// the table through its board, and walk round the room to the shared break +// room behind the tables β€” a coffee bar and a foosball table β€” where people +// from every table, and the command center's crew, meet. They applaud a +// ticket landing, wince at one failing, wave when clicked, and doze now and +// then. Each table has its own habits and its own party programme; a done +// table stands up for beers, a kick-about and a dance, and when the whole run +// ships the whole floor joins in. Someone working another table's ticket +// works it at THAT table; their chair at home says where they went. // // Everything is clickable: a person or a ticket opens its dossier (owned by // the wrapper), a table focuses the camera, empty floor clears. The camera is @@ -128,7 +133,6 @@ interface Placed { /** The command center's floor plan, for the harness. */ hq?: HQ; } - /** The command center's floor plan, in its own space (the room's centre at the origin). */ interface HQ { w: number; @@ -277,13 +281,13 @@ function AmbientPump({ on, resetKey }: { on: boolean; resetKey: unknown }) { */ function useMood(inputs: Omit): Mood { const [mood, setMood] = useState(() => moodFor({ ...inputs, now: Date.now() })); - const { active, running, partying, since, seed } = inputs; - // Inputs changed (the log moved): re-read at once, even with no frame coming. + const { active, running, partying, since, seed, table, manager, poked, away, cheerAt, groanAt, buzzAt } = inputs; + // Inputs changed (the log moved, a click): re-read at once, even with no frame coming. useEffect(() => { - setMood(moodFor({ active, running, partying, since, seed, now: Date.now() })); - }, [active, running, partying, since, seed]); + setMood(moodFor({ active, running, partying, since, seed, table, manager, poked, away, cheerAt, groanAt, buzzAt, now: Date.now() })); + }, [active, running, partying, since, seed, table, manager, poked, away, cheerAt, groanAt, buzzAt]); useFrame(() => { - const next = moodFor({ active, running, partying, since, seed, now: frame.now * 1000 }); + const next = moodFor({ active, running, partying, since, seed, table, manager, poked, away, cheerAt, groanAt, buzzAt, now: frame.now * 1000 }); if (next !== mood) setMood(next); }); return mood; @@ -346,6 +350,8 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec // frame rate, for a while after it last changed. const ambient = visible && !reducedMotion && !lively; const shipped = useMemo(() => floorShipped(floor, running), [floor, running]); + // The break room sits behind the back row, in the middle of the floor. + const lounge = useMemo(() => new THREE.Vector3(0, 0, Math.min(0, ...placed.map((p) => p.pos.z)) - LOUNGE_BACK), [placed]); // A lost context (GPU reset, VRAM eviction) is reported up; the wrapper // swaps in the flat map and says so. The listener is removed on unmount. @@ -483,6 +489,7 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec animate={animate} dark={dark} partying={shipped} + lounge={lounge} selection={selection} onSelect={onSelect} onFocus={() => { @@ -502,6 +509,7 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec pulses={pulses} onTicket={onTicket} partying={tableParties(p.team, shipped)} + lounge={lounge} onFocus={() => { setFocus(p.pos.clone().setY(0.8)); setFocusDistance(13); @@ -509,6 +517,7 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec /> ), )} + {floor.handoffs.map((h) => ( ))} @@ -619,6 +628,7 @@ function Table({ pulses, onTicket, partying, + lounge, onFocus, }: { placed: Placed; @@ -631,6 +641,8 @@ function Table({ onTicket?: (id: string) => void; /** Every ticket here is done: beers, a kick-about, a dance. */ partying: boolean; + /** The shared break room, in floor space. */ + lounge: THREE.Vector3; onFocus: () => void; }) { const { team, pos, hex, seats, slots } = placed; @@ -660,6 +672,10 @@ function Table({ return m; }, [mine]); const bursting = useMemo(() => mine.filter((p) => frame.now - p.at / 1000 <= BURST_S), [mine]); + // What just happened at this table, for its people to react to β€” and the + // table's own seed, so its habits and its party are its own. + const life = useMemo(() => tableSignals(team.id, mine, team.crew), [mine, team.id, team.crew]); + const loungeHere = useMemo(() => lounge.clone().sub(pos), [lounge, pos]); // The board stands behind the head seat; the team's sign hangs above it. const boardDir = useMemo(() => (managerPos ? managerPos.pos.clone().setY(0).normalize() : new THREE.Vector3(0, 0, -1)), [managerPos]); @@ -687,7 +703,7 @@ function Table({ )} - {partying && pitch.length >= 2 && } + {partying && pitch.length >= 2 && } {/* Pedestal + top. */} @@ -759,6 +775,8 @@ function Table({ tall={a.seat !== 'manager' && i % 2 === 1} lookAt={a.active ? undefined : lookAt} partying={partying} + life={life} + lounge={loungeHere} onSelect={() => onSelect(selected ? null : { kind: 'agent', id: a.id, team: team.id })} /> ); @@ -944,9 +962,45 @@ const _walkPos = new THREE.Vector3(); const SEATED_AT = new THREE.Vector3(0, 0, 0.1); /** Where it stands when it gets up: behind the chair, facing the table. */ const STANDING_AT = new THREE.Vector3(0, 0, 0.85); +/** Where the manager stands to walk the table through the board behind them. */ +const PRESENT_AT = new THREE.Vector3(0, 0, 1.55); +/** + * The shared break room, behind the back row of tables: everyone's coffee + * breaks and foosball rounds happen here, so people from different tables + * (and the command center's crew) meet. Offsets are from its centre. + */ +const LOUNGE_BACK = SEAT_R + 7; +const BAR_AT = new THREE.Vector3(-1.5, 0, 0); +const FOOS_AT = new THREE.Vector3(1.7, 0, 0.1); +const BAR_R = 1.05; -/** The stroll: up, round behind the chairs, and back to sit down. */ -const STROLL = [STANDING_AT, new THREE.Vector3(0.85, 0, 1.25), new THREE.Vector3(-0.85, 0, 1.25), STANDING_AT, SEATED_AT]; +/** + * Where someone stands in the break room, and what they face: at the coffee + * bar on the side they came from (spread by who they are), or at one of the + * foosball table's four handles. + */ +function breakSpot(lounge: THREE.Vector3, from: THREE.Vector3, seed: number, kind: 'coffee' | 'game'): { at: THREE.Vector3; face: THREE.Vector3 } { + if (kind === 'game') { + const f = lounge.clone().add(FOOS_AT); + const k = seed % 4; + return { at: f.clone().add(new THREE.Vector3(k % 2 ? 0.32 : -0.32, 0, k < 2 ? -0.62 : 0.62)), face: f }; + } + const bar = lounge.clone().add(BAR_AT); + const side = Math.atan2(from.z - bar.z, from.x - bar.x); + const a = side + (((seed % 1000) / 1000) * 2 - 1) * 1.1; + const r = BAR_R + ((seed >>> 10) % 3) * 0.3; + return { at: new THREE.Vector3(bar.x + Math.cos(a) * r, 0, bar.z + Math.sin(a) * r), face: bar }; +} + +/** A stroll: up, a wander behind the chairs of this person's own shape, and back to sit down. */ +function strollFor(seed: number): THREE.Vector3[] { + const side = seed % 2 ? 1 : -1; + const reach = 0.6 + ((seed >>> 3) % 5) * 0.12; + const depth = 1.1 + ((seed >>> 6) % 4) * 0.15; + const pts = [STANDING_AT, new THREE.Vector3(side * reach, 0, depth), new THREE.Vector3(-side * reach, 0, depth + 0.2)]; + if ((seed >>> 9) % 2) pts.push(new THREE.Vector3(side * reach * 0.5, 0, depth + 0.45)); + return [...pts, STANDING_AT, SEATED_AT]; +} function hoverPick(onSelect: () => void, setHover: (v: boolean) => void) { return { @@ -989,6 +1043,8 @@ function Person({ tall, lookAt, partying, + life, + lounge, onSelect, }: { agent: FloorAgent; @@ -1005,6 +1061,9 @@ function Person({ lookAt?: THREE.Vector3; /** The table is done: this person is at the party. */ partying: boolean; + life: TableSignals; + /** The shared coffee lounge, in table space. */ + lounge: THREE.Vector3; onSelect: () => void; }) { const isManager = agent.seat === 'manager'; @@ -1024,13 +1083,30 @@ function Person({ const seed = useMemo(() => seedOf(agent.id), [agent.id]); const look = useMemo(() => lookOf(seed), [seed]); const phase = (seed % 628) / 100; - const mood = useMood({ active, running, partying, since: agent.lastAt ?? FLOOR_BORN, seed }); - const standing = isParty(mood); + const mood = useMood({ + active, + running, + partying, + since: agent.lastAt ?? FLOOR_BORN, + seed, + table: life.table, + manager: isManager, + poked: selected, + away: !!agent.away, + cheerAt: life.cheerAt, + groanAt: life.groanAt, + buzzAt: life.buzzAt, + }); + const standing = isStanding(mood); + const home = mood === 'present' ? PRESENT_AT : standing ? STANDING_AT : SEATED_AT; + const facingHome = mood === 'present' ? Math.PI : 0; // The motion a mood carries: when it began, the stroll's path, how seated. const moodStart = useRef<{ mood: Mood; at: number }>({ mood, at: -1 }); const walk = useRef(null); const sit = useRef(standing ? 0 : 1); - const prev = useRef(new THREE.Vector3().copy(standing ? STANDING_AT : SEATED_AT)); + const prev = useRef(new THREE.Vector3().copy(home)); + /** A coffee run: out to the table's coffee corner, a sip there, back to the desk. */ + const trip = useRef<{ stage: 'out' | 'at' | 'back'; until: number; facing: number; pts: THREE.Vector3[] } | null>(null); // A flash ring when this person starts working: the "message received" // cue, once per activation. @@ -1054,6 +1130,31 @@ function Person({ return THREE.MathUtils.clamp(Math.atan2(-local.x, -local.z), -1.1, 1.1); }, [lookAt, pos, yaw]); const glance = mood === 'watch' || mood === 'coffee' ? headYaw : 0; + // The way to the break room, in this seat's space: up behind the chair, + // round the outside of the chairs to the side facing the break room, then + // across to a place at the coffee bar or the foosball table. + const breakRoutes = useMemo(() => { + const toLocal = (v: THREE.Vector3) => v.clone().sub(pos).applyAxisAngle(UP, -yaw); + const exit = Math.atan2(lounge.z, lounge.x); + let d = exit - angle; + while (d > Math.PI) d -= Math.PI * 2; + while (d < -Math.PI) d += Math.PI * 2; + const ring = SEAT_R + 0.85; + const base = [STANDING_AT.clone()]; + const steps = Math.max(1, Math.ceil(Math.abs(d) / 0.45)); + for (let k = 1; k <= steps; k++) { + const a = angle + (d * k) / steps; + base.push(toLocal(new THREE.Vector3(Math.cos(a) * ring, 0, Math.sin(a) * ring))); + } + const from = new THREE.Vector3(Math.cos(exit) * ring, 0, Math.sin(exit) * ring); + const route = (kind: 'coffee' | 'game') => { + const spot = breakSpot(lounge, from, seed, kind); + const at = toLocal(spot.at); + const face = toLocal(spot.face); + return { pts: [...base, at], facing: Math.atan2(-(face.x - at.x), -(face.z - at.z)) }; + }; + return { coffee: route('coffee'), game: route('game') }; + }, [pos, yaw, angle, lounge, seed]); useFrame(({ clock }, delta) => { const t = clock.getElapsedTime(); @@ -1063,12 +1164,41 @@ function Person({ const root = rig.root.current; if (root) { if (moodStart.current.mood !== mood || moodStart.current.at < 0) { + const first = moodStart.current.at < 0; moodStart.current = { mood, at: t }; - walk.current = mood === 'stroll' && animate ? makeWalk([root.position.clone(), ...STROLL], t, 0.85) : null; + const away = root.position.distanceTo(STANDING_AT) > 1.2; + if (!animate) { + walk.current = null; + trip.current = null; + } else if (isBreak(mood) && !first) { + const r = mood === 'game' ? breakRoutes.game : breakRoutes.coffee; + walk.current = makeWalk([root.position.clone(), ...r.pts], t, 2.2); + trip.current = { stage: 'out', until: 0, facing: r.facing, pts: r.pts }; + } else if (trip.current && away) { + // Called back mid-break: the long way round, not through the table. + walk.current = makeWalk([root.position.clone(), ...[...trip.current.pts].reverse(), SEATED_AT], t, 2.4); + trip.current = { stage: 'back', until: 0, facing: 0, pts: trip.current.pts }; + } else { + trip.current = null; + walk.current = mood === 'stroll' ? makeWalk([root.position.clone(), ...strollFor(seed)], t, 0.7 + (seed % 5) * 0.08) : null; + } } + if (animate && trip.current && !walk.current) { + const tr = trip.current; + if (tr.stage === 'out') { + tr.stage = 'at'; + tr.until = t + (mood === 'game' ? 7 : 3.5) + (seed % 20) / 10; + } else if (tr.stage === 'at' && t > tr.until) { + walk.current = makeWalk([root.position.clone(), ...[...tr.pts].reverse(), SEATED_AT], t, 2.2); + tr.stage = 'back'; + } else if (tr.stage === 'back') { + trip.current = null; + } + } + root.visible = mood !== 'away'; if (!animate) { - root.position.copy(standing ? STANDING_AT : SEATED_AT); - root.rotation.y = 0; + root.position.copy(home); + root.rotation.y = facingHome; sit.current = standing ? 0 : 1; stillPose(rig, mood, sit.current, glance); } else { @@ -1078,12 +1208,13 @@ function Person({ if (dir === null) walk.current = null; else facing = dir; root.position.copy(_walkPos); - } else { - root.position.lerp(standing ? STANDING_AT : SEATED_AT, 0.07); + } else if (trip.current?.stage !== 'at') { + root.position.lerp(home, 0.07); } const speed = delta > 0 ? root.position.distanceTo(prev.current) / delta : 0; prev.current.copy(root.position); - root.rotation.y = turnToward(root.rotation.y, walk.current && speed > 0.05 ? facing : 0, 0.15); + const atMachine = trip.current?.stage === 'at'; + root.rotation.y = turnToward(root.rotation.y, walk.current && speed > 0.05 ? facing : atMachine ? trip.current!.facing : facingHome, 0.15); // Seated only once back at the chair. const atChair = root.position.distanceToSquared(SEATED_AT) < 0.02; sit.current = THREE.MathUtils.lerp(sit.current, !standing && atChair && !walk.current ? 1 : 0, 0.14); @@ -1191,6 +1322,7 @@ function Person({
)} {mood === 'nap' && } + {mood === 'away' && πŸ“€ at {agent.away}} ([]); + const rods = useRef([]); + const ball = useRef(null); + useFrame(({ clock }) => { + if (!animate) return; + const t = clock.getElapsedTime(); + steam.current.forEach((m, i) => { + if (!m) return; + const k = (t * 0.45 + i / 3) % 1; + m.position.set(Math.sin(t * 2 + i) * 0.04, 0.42 + k * 0.55, -0.02); + m.scale.setScalar(0.6 + k); + (m.material as THREE.MeshBasicMaterial).opacity = 0.35 * (1 - k); + }); + rods.current.forEach((g, i) => { + if (g) g.rotation.x = Math.sin(t * 6 + i * 1.7) * 0.9; + }); + if (ball.current) { + ball.current.position.x = Math.sin(t * 1.9) * 0.55; + ball.current.position.z = Math.sin(t * 3.1) * 0.22; + } + }); + const wood = dark ? '#6b4f3a' : '#a16207'; + return ( + + + + + + {/* The coffee bar. */} + + + + + + + + + + + + + + + + + + + {animate && + [0, 1, 2].map((i) => ( + { if (el) steam.current[i] = el; }}> + + + + ))} + + + + + + + + + + + + {[[-0.3, 0.2, '#f9a8d4'], [-0.12, 0.34, '#a5f3fc'], [0.1, 0.4, '#fde68a']].map(([x, z, c]) => ( + + + + + ))} + + {/* The foosball table. */} + + {[[-0.5, -0.25], [0.5, -0.25], [-0.5, 0.25], [0.5, 0.25]].map(([x, z]) => ( + + + + + ))} + + + + + + + + + + + + + {[-0.42, -0.14, 0.14, 0.42].map((x, i) => ( + { if (el) rods.current[i] = el; }} position={[x, 0.95, 0]}> + + + + + {[-0.18, 0, 0.18].map((z) => ( + + + + + ))} + + ))} + + {/* A sofa to meet on, and plants. */} + + + + + + + + + + + {[[-3.0, -1.6], [3.0, -1.6]].map(([x, z]) => ( + + + + + + + + + + + ))} + + β˜• break room Β· πŸ•ΉοΈ foosball + + + ); +} + /** * The kick-about at a done table: a ball passed round the players behind * their chairs and back again, lobbed over the table when it has to be. * Only on the party's football rounds; between them it rests out of sight. */ -function Football({ spots, animate }: { spots: THREE.Vector3[]; animate: boolean }) { +function Football({ spots, animate, table }: { spots: THREE.Vector3[]; animate: boolean; table: number }) { const ball = useRef(null); const shadow = useRef(null); const PASS_S = 1.3; useFrame(() => { const m = ball.current; if (!m) return; - const on = isFootballRound(frame.now * 1000); + const on = isFootballRound(frame.now * 1000, table); m.visible = on; if (shadow.current) shadow.current.visible = on; if (!on) return; @@ -1307,6 +1583,7 @@ function CommandCenter({ animate, dark, partying, + lounge, selection, onSelect, onFocus, @@ -1321,11 +1598,14 @@ function CommandCenter({ dark: boolean; /** The run shipped: the crew parties too. */ partying: boolean; + /** The shared break room, in floor space. */ + lounge: THREE.Vector3; selection: FloorSelection; onSelect: (sel: FloorSelection) => void; onFocus: () => void; }) { const { w, d } = hq; + const loungeHere = useMemo(() => lounge.clone().sub(at), [lounge, at]); const out = seats.filter((s) => s.active && running && hq.pads.has(s.id)); const live = running && !!phase; const padGlow = useRef(null); @@ -1456,7 +1736,7 @@ function CommandCenter({ ))} - {/* A plant, and the coffee machine the coffee breaks come from. */} + {/* A plant, and an arcade cabinet. */} @@ -1467,14 +1747,19 @@ function CommandCenter({ - - - - + + {/* An arcade cabinet β€” coffee is in the break room, with everyone else. */} + + + + + + + - - - + + + {/* Stools. */} @@ -1534,6 +1819,7 @@ function CommandCenter({ partying={partying} selected={selected} door={door} + lounge={loungeHere} onSelect={() => onSelect(selected ? null : { kind: 'agent', id: seat.id, team: HARNESS_ID })} /> ); @@ -1542,6 +1828,8 @@ function CommandCenter({ ); } +const CREW_TABLE = seedOf('harness'); + /** One of the crew: on a stool inside, or out on the pad when it is their turn. */ function CrewFigure({ seat, @@ -1553,12 +1841,15 @@ function CrewFigure({ partying, selected, door, + lounge, onSelect, }: { seat: FloorStageSeat; spot: THREE.Vector3; pad?: THREE.Vector3; hq: HQ; + /** The shared break room, in the command center's space. */ + lounge: THREE.Vector3; running: boolean; animate: boolean; partying: boolean; @@ -1575,9 +1866,9 @@ function CrewFigure({ const seed = useMemo(() => seedOf(seat.id), [seat.id]); const look = useMemo(() => lookOf(seed), [seed]); const phase = (seed % 628) / 100; - const raw = useMood({ active, running, partying, since: seat.lastAt ?? FLOOR_BORN, seed }); - // No pitch in here: the crew dances instead. - const mood: Mood = raw === 'football' ? 'dance' : raw; + const raw = useMood({ active, running, partying, since: seat.lastAt ?? FLOOR_BORN, seed, table: CREW_TABLE, poked: selected }); + // No pitch and no board in here: the crew dances and thinks instead. + const mood: Mood = raw === 'football' ? 'dance' : raw === 'present' ? 'think' : raw; const dest = active ? pad! : spot; const destKey = `${dest.x.toFixed(2)},${dest.z.toFixed(2)}`; const pos = useRef(dest.clone()); @@ -1586,6 +1877,8 @@ function CrewFigure({ const planned = useRef(destKey); const moodStart = useRef<{ mood: Mood; at: number }>({ mood, at: -1 }); const sit = useRef(active || isParty(mood) ? 0 : 1); + /** A break: out through the door to the break room, a while there, back to the stool. */ + const trip = useRef<{ stage: 'out' | 'at' | 'back'; until: number; facing: number } | null>(null); // A new place to be: walk there β€” through the door when it is on the other side of it. useEffect(() => { @@ -1598,6 +1891,7 @@ function CrewFigure({ else if (!inside(from) && inside(dest)) pts.push(hq.doorOut, hq.doorIn); pts.push(dest.clone()); walk.current = animate ? makeWalk(pts, clock.getElapsedTime(), 1.7) : null; + trip.current = null; if (!animate) pos.current.copy(dest); invalidate(); }, [destKey, dest, hq, animate, clock, invalidate]); @@ -1611,12 +1905,33 @@ function CrewFigure({ const t = c.getElapsedTime(); const inside = pos.current.z < hq.d / 2 - 0.1; if (moodStart.current.mood !== mood || moodStart.current.at < 0) { + const first = moodStart.current.at < 0; moodStart.current = { mood, at: t }; - if (mood === 'stroll' && animate && inside && !walk.current) { + if (animate && !first && !active && isBreak(mood) && inside && !walk.current) { + const spotThere = breakSpot(lounge, hq.doorOut, seed, mood === 'game' ? 'game' : 'coffee'); + walk.current = makeWalk([pos.current.clone(), hq.doorIn, hq.doorOut, spotThere.at], t, 2.2); + trip.current = { stage: 'out', until: 0, facing: Math.atan2(-(spotThere.face.x - spotThere.at.x), -(spotThere.face.z - spotThere.at.z)) }; + } else if (animate && trip.current && !active) { + // Called back mid-break. + walk.current = makeWalk([pos.current.clone(), hq.doorOut, hq.doorIn, spot.clone()], t, 2.4); + trip.current = { stage: 'back', until: 0, facing: Math.PI }; + } else if (mood === 'stroll' && animate && inside && !walk.current) { walk.current = makeWalk([pos.current.clone(), spot.clone().add(new THREE.Vector3(0.38, 0, 0.42)), spot.clone().add(new THREE.Vector3(-0.38, 0, 0.42)), spot.clone()], t, 0.6); } } - let facing = Math.PI; + if (animate && trip.current && !walk.current) { + const tr = trip.current; + if (tr.stage === 'out') { + tr.stage = 'at'; + tr.until = t + (mood === 'game' ? 7 : 3.5) + (seed % 20) / 10; + } else if (tr.stage === 'at' && t > tr.until) { + walk.current = makeWalk([pos.current.clone(), hq.doorOut, hq.doorIn, spot.clone()], t, 2.2); + tr.stage = 'back'; + } else if (tr.stage === 'back') { + trip.current = null; + } + } + let facing = trip.current?.stage === 'at' ? trip.current.facing : Math.PI; let walking = false; if (walk.current && animate) { const dir = walkAt(walk.current, t, pos.current); @@ -1627,7 +1942,7 @@ function CrewFigure({ if (pos.current.distanceTo(hq.doorIn) < 1.1 || pos.current.distanceTo(hq.doorOut) < 1.1) door.current.until = t + 0.4; invalidate(); } - } else { + } else if (!(animate && trip.current)) { walk.current = null; pos.current.copy(dest); } diff --git a/web/src/components/Live/floor/TeamFloorFlat.tsx b/web/src/components/Live/floor/TeamFloorFlat.tsx index d8d45f7..ee0bae7 100644 --- a/web/src/components/Live/floor/TeamFloorFlat.tsx +++ b/web/src/components/Live/floor/TeamFloorFlat.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx'; import { teamColor } from '@/components/Board/teamColor'; import { workTables, type FloorAgent, type FloorHandoff, type FloorModel, type FloorPhase, type FloorPulse, type FloorTeam, type FloorTicket, type TicketState } from './floorModel'; import { LEGEND_STATES, TICKET_LABEL, ago, glyphFor, type FloorSelection } from './floorShared'; -import { MOOD_GLYPH, MOOD_LABEL, floorShipped, isParty, moodFor, moodShows, seedOf, tableParties, type Mood } from './floorLife'; +import { MOOD_GLYPH, MOOD_LABEL, floorShipped, isParty, moodFor, moodShows, seedOf, tableParties, tableSignals, type Mood, type TableSignals } from './floorLife'; // ── The team floor ─────────────────────────────────────────────────────── // @@ -80,6 +80,8 @@ const HQ_W = 300; const HQ_H = 112; const HQ_DOOR = 36; const HQ_COLS = 6; +const HQ_LIFE: TableSignals = { table: seedOf('harness') }; +const NO_PULSES: FloorPulse[] = []; /** Islands are laid out on a shallow arc, so three read as a floor and not a row. */ function layout(teams: FloorTeam[], running: boolean): Placed[] { @@ -187,7 +189,7 @@ export default function TeamFloorFlat({ floor, running, now, onTicket, selection })} {placed.map((p) => ( - + ))} {floor.handoffs.map((h) => ( @@ -217,6 +219,7 @@ function Island({ flashing, phase, partying, + life, }: { placed: Placed; running: boolean; @@ -227,6 +230,7 @@ function Island({ flashing: Set; phase: FloorPhase | null; partying: boolean; + life: TableSignals; }) { const { team, cx, cy, hex, agentPos } = placed; if (team.internal) return ; @@ -270,7 +274,7 @@ function Island({ {team.agents.map((a) => { const pos = agentPos.get(a.id); if (!pos) return null; - return ; + return ; })} {team.waitingOn.length > 0 && ( @@ -322,7 +326,7 @@ function HarnessIsland({ placed, running, tick, selection, onSelect, phase, part const pos = agentPos.get(a.id); if (!pos) return null; const onPad = out.includes(a); - return ; + return ; })} ); @@ -445,6 +449,7 @@ function Avatar({ selected, onSelect, partying = false, + life, compact = false, }: { agent: FloorAgent; @@ -458,6 +463,8 @@ function Avatar({ onSelect?: (sel: FloorSelection) => void; /** The table is done: the party is on. */ partying?: boolean; + /** The table's seed and what just happened at it. */ + life?: TableSignals; /** A chip, not a name tag: the command center's crew waiting inside. */ compact?: boolean; }) { @@ -466,7 +473,23 @@ function Avatar({ const label = agent.id.length > 16 ? agent.id.slice(0, 15) + '…' : agent.id; const isManager = agent.seat === 'manager'; const glowing = agent.active && running; - const mood: Mood = moodFor({ active: glowing, running, partying, since: agent.lastAt ?? tick, now: tick, seed: seedOf(agent.id) }); + const raw: Mood = moodFor({ + active: glowing, + running, + partying, + since: agent.lastAt ?? tick, + now: tick, + seed: seedOf(agent.id), + table: life?.table, + manager: isManager, + poked: selected, + away: !!agent.away, + cheerAt: life?.cheerAt, + groanAt: life?.groanAt, + buzzAt: life?.buzzAt, + }); + // The command center has no pitch and no board. + const mood: Mood = team.internal && raw === 'football' ? 'dance' : team.internal && raw === 'present' ? 'think' : raw; return ( )} + {mood === 'away' && !agent.borrowed && ( + + πŸ“€ at {agent.away} + + )} {glowing && ( diff --git a/web/src/components/Live/floor/floorLife.test.ts b/web/src/components/Live/floor/floorLife.test.ts index 2c24d48..5a13156 100644 --- a/web/src/components/Live/floor/floorLife.test.ts +++ b/web/src/components/Live/floor/floorLife.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { MOOD_SLOT_MS, PARTY_SLOT_MS, SLEEPY_MS, WATCH_MS, floorShipped, isFootballRound, isParty, moodFor, moodShows, seedOf, tableParties, type LifeInputs, type Mood } from './floorLife'; -import type { FloorTeam } from './floorModel'; +import { MOOD_SLOT_MS, PARTY_SLOT_MS, REACT_MS, SLEEPY_MS, WATCH_MS, floorShipped, isBreak, isFootballRound, isParty, moodFor, moodShows, partyActivity, seedOf, tableParties, tableSignals, type LifeInputs, type Mood } from './floorLife'; +import type { FloorPulse, FloorTeam } from './floorModel'; const T0 = Date.parse('2026-09-10T10:00:00Z'); @@ -40,11 +40,11 @@ describe('moodFor', () => { expect(moods.size).toBeGreaterThan(1); }); - it('dozes off after a long wait, and sleeps after a very long one', () => { + it('dozes off now and then after a long wait, but a live run never puts a table to sleep for good', () => { let naps = 0; - for (let k = 0; k < 40; k++) if (moodFor(life({ now: T0 + SLEEPY_MS + 1 + k * MOOD_SLOT_MS })) === 'nap') naps++; + for (let k = 0; k < 100; k++) if (moodFor(life({ now: T0 + SLEEPY_MS + 1 + k * MOOD_SLOT_MS })) === 'nap') naps++; expect(naps).toBeGreaterThan(10); - expect(moodFor(life({ now: T0 + SLEEPY_MS * 2 + 1 }))).toBe('nap'); + expect(naps).toBeLessThan(60); }); it('naps at the desk once the run has stopped', () => { @@ -84,3 +84,52 @@ describe('parties', () => { expect(floorShipped({ teams: [harness] }, false)).toBe(false); }); }); + +describe('variety', () => { + const tables = ['backend-go', 'frontend-react', 'docs', 'infra', 'backend-python'].map(seedOf); + + it('gives every table its own party programme and clock', () => { + // Not every table doing the same thing at the same time. + const sameMoment = tables.map((t) => partyActivity(T0, t)); + expect(new Set(sameMoment).size).toBeGreaterThan(1); + }); + + it('gives tables different habits: the same person idles differently at two tables', () => { + const tally = (table: number) => { + const m = new Map(); + for (let k = 0; k < 200; k++) { + const now = T0 + WATCH_MS + k * MOOD_SLOT_MS; + const mood = moodFor(life({ table, now, since: now - 30_000, seed: seedOf(`p${k % 7}`) })); + m.set(mood, (m.get(mood) ?? 0) + 1); + } + return m; + }; + const a = tally(tables[0]); + const b = tally(tables[1]); + const differs = [...new Set([...a.keys(), ...b.keys()])].some((k) => Math.abs((a.get(k) ?? 0) - (b.get(k) ?? 0)) > 10); + expect(differs).toBe(true); + }); + + it('reacts to what just happened at the table, waves when clicked, and is away when working elsewhere', () => { + const now = T0 + 60_000; + expect(moodFor(life({ now, cheerAt: now - 1000 }))).toBe('cheer'); + expect(moodFor(life({ now, groanAt: now - 1000 }))).toBe('facepalm'); + expect(moodFor(life({ now, cheerAt: now - REACT_MS - 1 }))).not.toBe('cheer'); + expect(moodFor(life({ now, poked: true }))).toBe('wave'); + expect(moodFor(life({ now, away: true }))).toBe('away'); + expect(moodFor(life({ now, active: true, poked: true }))).toBe('work'); + }); + + it('sends people to the break room and the manager to the board', () => { + const seen = new Set(); + for (let k = 0; k < 120; k++) seen.add(moodFor(life({ manager: true, now: T0 + WATCH_MS + k * MOOD_SLOT_MS }))); + expect(seen.has('present')).toBe(true); + expect([...seen].some(isBreak)).toBe(true); + }); + + it('reads a table’s pulses into its reactions', () => { + const pulse = (kind: FloorPulse['kind'], at: number, team = 'docs', tone: FloorPulse['tone'] = 'info'): FloorPulse => ({ id: `${kind}${at}`, kind, tone, at, team, text: '' }); + const s = tableSignals('docs', [pulse('ticket-done', 10), pulse('ticket-failed', 20), pulse('agent-start', 30), pulse('ticket-done', 99, 'other')]); + expect(s).toMatchObject({ table: seedOf('docs'), cheerAt: 10, groanAt: 20, buzzAt: 30 }); + }); +}); diff --git a/web/src/components/Live/floor/floorLife.ts b/web/src/components/Live/floor/floorLife.ts index 779974c..d87f87b 100644 --- a/web/src/components/Live/floor/floorLife.ts +++ b/web/src/components/Live/floor/floorLife.ts @@ -1,21 +1,47 @@ -import { workTables, type FloorModel, type FloorTeam } from './floorModel'; +import { workTables, type FloorModel, type FloorPulse, type FloorTeam } from './floorModel'; // ── Life on the floor ──────────────────────────────────────────────────── // // A run is mostly waiting: one agent types while the rest of the table has // nothing to do. Drawn as statues, that reads as a stuck page; drawn as -// people, it reads as an office. So everyone who is not working has a mood β€” -// they watch whoever is, fetch a coffee, stretch, take a walk round the room, -// and doze off when nothing has come their way for a while. A table whose -// every ticket is done throws a party: beers, a kick-about, a dance. When the -// whole run is over and everything shipped, the whole floor joins in. +// people, it reads as an office. So everyone who is not working has a mood. +// They watch whoever is, chat with a neighbour, scroll a phone, think, take +// a walk, and go to the shared break room for a coffee or a round of +// foosball β€” where people from other tables are too. The manager walks the +// table through the board. Now and then someone dozes off. A table reacts to +// what happens at it: applause when a ticket lands, a wince when one fails, +// heads turning when a teammate starts. Click someone and they wave. // -// It is pure and deterministic β€” a person's mood is a function of the time, -// how long they have been idle and a seed from their name β€” so both stages -// (and the dossier) agree on what someone is doing without sharing state, a -// re-render never reshuffles anybody, and it is testable. +// No two tables are copies: each has its own habits (a culture drawn from +// its id) and its own party programme on its own clock, and each person has +// a favourite pastime. A table whose every ticket is done throws a party β€” +// beers, a kick-about, a dance β€” and when the whole run ships, the whole +// floor joins in. +// +// It is pure and deterministic β€” a mood is a function of the time, how long +// someone has been idle, their seed and their table's β€” so both stages (and +// the dossier) agree without sharing state, a re-render never reshuffles +// anybody, and it is testable. -export type Mood = 'work' | 'watch' | 'coffee' | 'stretch' | 'stroll' | 'nap' | 'cheers' | 'football' | 'dance'; +export type Mood = + | 'work' + | 'watch' + | 'coffee' + | 'stretch' + | 'stroll' + | 'chat' + | 'phone' + | 'think' + | 'game' + | 'present' + | 'nap' + | 'cheer' + | 'facepalm' + | 'wave' + | 'away' + | 'cheers' + | 'football' + | 'dance'; export const MOOD_GLYPH: Record = { work: '⌨️', @@ -23,7 +49,16 @@ export const MOOD_GLYPH: Record = { coffee: 'β˜•', stretch: 'πŸ™†', stroll: '🚢', + chat: 'πŸ’¬', + phone: 'πŸ“±', + think: 'πŸ€”', + game: 'πŸ•ΉοΈ', + present: 'πŸ“Š', nap: 'πŸ’€', + cheer: 'πŸ‘', + facepalm: '🀦', + wave: 'πŸ‘‹', + away: 'πŸ“€', cheers: '🍻', football: '⚽', dance: 'πŸ•Ί', @@ -35,7 +70,16 @@ export const MOOD_LABEL: Record = { coffee: 'on a coffee break', stretch: 'stretching their legs', stroll: 'taking a walk round the room', + chat: 'chatting with a neighbour', + phone: 'scrolling their phone', + think: 'thinking it over', + game: 'playing foosball in the break room', + present: 'walking the team through the board', nap: 'having a nap', + cheer: 'applauding β€” a ticket just landed', + facepalm: 'wincing β€” a ticket just failed', + wave: 'waving at you', + away: 'away, working at another table', cheers: 'having a beer β€” the table is done', football: 'playing football β€” the table is done', dance: 'dancing β€” the table is done', @@ -51,16 +95,28 @@ export function isParty(mood: Mood): boolean { return mood === 'cheers' || mood === 'football' || mood === 'dance'; } +/** Moods spent on your feet, away from the desk. */ +export function isStanding(mood: Mood): boolean { + return isParty(mood) || mood === 'present'; +} + +/** Moods spent in the shared break room: a coffee at the bar, a round of foosball. */ +export function isBreak(mood: Mood): boolean { + return mood === 'coffee' || mood === 'game'; +} + /** Idle for less than this, a person just watches whoever is working. */ export const WATCH_MS = 12_000; /** How long one idle mood lasts before the next is drawn. */ export const MOOD_SLOT_MS = 9_000; -/** Idle past this, a nap becomes likely… */ +/** Idle past this, a nap becomes possible… */ export const DROWSY_MS = 60_000; -/** …and past this, the likeliest thing of all. */ +/** …and past this, likelier (never certain while the run is live). */ export const SLEEPY_MS = 150_000; -/** One round of the party: beers, then a kick-about, then beers again. */ +/** One round of the party; each table runs its own programme on its own clock. */ export const PARTY_SLOT_MS = 14_000; +/** How long a table reacts to something that just happened at it. */ +export const REACT_MS = 6_000; export interface LifeInputs { /** Working right now, in a live run. */ @@ -73,6 +129,18 @@ export interface LifeInputs { now: number; /** From seedOf(id): who they are decides what they like doing. */ seed: number; + /** From seedOf(team id): every table has its own habits and its own party. */ + table?: number; + /** The table's manager: presents at the board rather than scrolls a phone. */ + manager?: boolean; + /** Clicked on the floor: they wave back. */ + poked?: boolean; + /** Working another table's ticket: their chair here is empty. */ + away?: boolean; + /** When a ticket at this table last landed / last failed, and when a teammate last started. */ + cheerAt?: number; + groanAt?: number; + buzzAt?: number; } /** A stable 32-bit seed from a name (FNV-1a). */ @@ -93,32 +161,81 @@ function roll(seed: number, slot: number): number { return (h ^ (h >>> 16)) >>> 0; } -/** Which party round it is β€” shared by the whole table, so they play together. */ -export function partyRound(now: number): number { - return Math.floor(now / PARTY_SLOT_MS); +const PARTY_PROGRAMMES: Mood[][] = [ + ['cheers', 'football', 'dance'], + ['dance', 'cheers', 'football'], + ['football', 'dance', 'cheers'], + ['cheers', 'dance', 'football'], +]; + +/** What a table's party is doing right now: its own programme, on its own clock. */ +export function partyActivity(now: number, table = 0): Mood { + const programme = PARTY_PROGRAMMES[table % PARTY_PROGRAMMES.length]; + const round = Math.floor((now + (table % (PARTY_SLOT_MS * 3))) / PARTY_SLOT_MS); + return programme[round % programme.length]; } -/** Every third party round is a kick-about. */ -export function isFootballRound(now: number): boolean { - return partyRound(now) % 3 === 1; +/** Whether a table is in its kick-about round. */ +export function isFootballRound(now: number, table = 0): boolean { + return partyActivity(now, table) === 'football'; +} + +const IDLE: Mood[] = ['watch', 'coffee', 'stretch', 'stroll', 'chat', 'phone', 'think', 'game']; +const BASE: Record = { watch: 3, coffee: 2.2, stretch: 1.2, stroll: 1.8, chat: 2.4, phone: 1.8, think: 1.4, game: 1.3 }; + +/** A person's idle habits: the room's baseline, their favourite, their table's culture. */ +function habits(seed: number, table: number, manager: boolean): [Mood, number][] { + const w: Record = { ...BASE }; + w[IDLE[seed % IDLE.length]] += 3; + w[IDLE[(seed >>> 7) % IDLE.length]] += 1; + w[IDLE[table % IDLE.length]] += 3; + w[IDLE[(table >>> 5) % IDLE.length]] += 1.5; + if (manager) { + w.present = 3.5; + w.phone = 0.6; + } + return Object.entries(w) as [Mood, number][]; +} + +function pick(list: [Mood, number][], r: number): Mood { + const total = list.reduce((n, [, w]) => n + w, 0); + let x = (r / 0x100000000) * total; + for (const [m, w] of list) { + x -= w; + if (x < 0) return m; + } + return list[list.length - 1][0]; } /** * moodFor is what a person is doing right now. * - * Working beats everything. A done table parties β€” everyone at it together, - * the round decides whether it is beers or football, and a few dance. A live - * run's idle people watch at first, then draw a new mood every MOOD_SLOT_MS - * (each on their own offset, so the room never changes all at once), with - * naps growing likelier the longer nothing comes their way. A run that is not - * live and did not ship is a room of people napping at their desks. + * Working beats everything; a person working another table's ticket is away + * from this one. Clicked, they wave. A done table parties β€” together, on its + * own programme (one table dances while the next is mid-kick-about) β€” and a + * few dance whatever the round. A table reacts to what just happened at it: + * applause when a ticket lands, a wince when one fails, heads turning when a + * teammate starts. Otherwise idle people watch at first, then draw a new + * habit every MOOD_SLOT_MS on their own offset β€” from weights that are theirs + * (a favourite pastime) and their table's (a culture), so no two tables look + * alike β€” with naps growing likelier the longer nothing comes their way. */ export function moodFor(i: LifeInputs): Mood { if (i.active && i.running) return 'work'; + if (i.away && i.running) return 'away'; + if (i.poked) return 'wave'; + const table = i.table ?? 0; if (i.partying) { - const round = partyRound(i.now); - if (round % 3 === 1) return 'football'; - return roll(i.seed, round) % 4 === 0 ? 'dance' : 'cheers'; + const act = partyActivity(i.now, table); + if (act === 'football') return 'football'; + const round = Math.floor(i.now / PARTY_SLOT_MS); + const odd = roll(i.seed, round) % 4 === 0; + return odd ? (act === 'dance' ? 'cheers' : 'dance') : act; + } + if (i.running) { + if (i.groanAt !== undefined && i.now - i.groanAt < REACT_MS && i.now >= i.groanAt) return 'facepalm'; + if (i.cheerAt !== undefined && i.now - i.cheerAt < REACT_MS && i.now >= i.cheerAt) return 'cheer'; + if (i.buzzAt !== undefined && i.now - i.buzzAt < REACT_MS && i.now >= i.buzzAt) return 'watch'; } const idle = Math.max(0, i.now - i.since); if (!i.running) { @@ -126,17 +243,14 @@ export function moodFor(i: LifeInputs): Mood { return idle > 4_000 + (i.seed % 20_000) ? 'nap' : 'watch'; } if (idle < WATCH_MS) return 'watch'; - if (idle > SLEEPY_MS * 2) return 'nap'; const offset = i.seed % MOOD_SLOT_MS; const slot = Math.floor((i.now + offset) / MOOD_SLOT_MS); - const r = roll(i.seed, slot) % 100; - const napChance = idle > SLEEPY_MS ? 60 : idle > DROWSY_MS ? 22 : 0; - if (r < napChance) return 'nap'; - const rest = (r - napChance) / (100 - napChance); - if (rest < 0.34) return 'watch'; - if (rest < 0.58) return 'coffee'; - if (rest < 0.74) return 'stretch'; - return 'stroll'; + const r = roll(i.seed ^ table, slot); + // A nap is a doze, not a coma: however long a table waits for its turn in a + // live run, most of it stays up β€” on a break, at the foosball table. + const napChance = idle > SLEEPY_MS ? 30 : idle > DROWSY_MS ? 15 : 0; + if (r % 100 < napChance) return 'nap'; + return pick(habits(i.seed, table, !!i.manager), roll(i.seed + 0x51ed27, slot)); } /** @@ -154,3 +268,29 @@ export function tableParties(team: FloorTeam, shipped: boolean): boolean { if (shipped) return true; return !team.internal && team.total > 0 && team.complete; } + +/** A table's signals for its people's moods: its seed, and what just happened at it. */ +export interface TableSignals { + table: number; + cheerAt?: number; + groanAt?: number; + buzzAt?: number; +} + +/** + * tableSignals reads a table's recent pulses into what its people react to: + * the last ticket that landed (or a green gate), the last that failed or + * blocked (or a red gate), and the last time a teammate started on something. + */ +export function tableSignals(teamId: string, pulses: FloorPulse[], crew = false): TableSignals { + const out: TableSignals = { table: seedOf(teamId) }; + for (const p of pulses) { + if (p.team !== teamId && !(crew && !p.team)) continue; + const good = p.kind === 'ticket-done' || p.kind === 'team-complete' || (p.kind === 'gate' && p.tone === 'good'); + const bad = p.kind === 'ticket-failed' || p.kind === 'ticket-blocked' || (p.kind === 'gate' && p.tone === 'bad'); + if (good) out.cheerAt = Math.max(out.cheerAt ?? 0, p.at); + if (bad) out.groanAt = Math.max(out.groanAt ?? 0, p.at); + if (p.kind === 'agent-start') out.buzzAt = Math.max(out.buzzAt ?? 0, p.at); + } + return out; +} diff --git a/web/src/components/Live/floor/floorModel.test.ts b/web/src/components/Live/floor/floorModel.test.ts index 9e11995..1a9a570 100644 --- a/web/src/components/Live/floor/floorModel.test.ts +++ b/web/src/components/Live/floor/floorModel.test.ts @@ -449,3 +449,63 @@ describe('the harness table', () => { for (const id of ['planner', 'splitter', 'dispatcher', 'composer', 'architect']) expect(isWorkingRole(id)).toBe(false); }); }); + +// ── What a real two-team run sends ─────────────────────────────────────── +// +// Taken from a captured Studio run (backend-go + docs, where docs names no +// worker and the pipeline's go-worker does its ticket). Before these fixes +// the one agent working was lit at NO table, a phantom "worker" sat at the +// harness table, and go-tester stayed "working" for the rest of the run. +describe('buildFloor on a real two-team log', () => { + const twoTeams: SquadsView = { + ok: true, + summary: '2 squads', + squads: [ + { id: 'backend-go', name: 'Backend Β· Go', owns: ['cmd/**'], acceptance: 'go test ./...', worker: 'go-worker', reviewer: 'go-reviewer', tester: 'go-tester', total: 0, done: 0, blocked: 0, in_flight: 0, complete: false, stuck: false }, + { id: 'docs', name: 'Docs', owns: ['README.md'], acceptance: '', total: 1, done: 0, blocked: 0, in_flight: 1, complete: false, stuck: false }, + ], + task_teams: { T1: 'docs' }, + }; + const board = [task('T1', { squad: 'docs', role: 'go-worker', status: 'running', column: 'in_progress' })]; + const log: RunEvent[] = [ + { phase: 'split', kind: 'agent_start', agent: 'go-worker', task_id: 'T1', message: 'assigned go-worker β€” files are go', time: at(1) }, + { phase: 'execute', kind: 'phase', message: '1 tasks Β· parallel=1', time: at(2) }, + { phase: 'execute', kind: 'agent_start', agent: 'go-worker', task_id: 'T1', message: 'Add a Divide function', time: at(3) }, + { phase: 'execute', kind: 'file_change', agent: 'worker', message: 'write calc.go', time: at(4) }, + ]; + const floorAt = (events: RunEvent[]) => buildFloor({ squads: twoTeams, tasks: board, events, composition: null, running: true, now: T0 + 5000 }); + const seat = (f: ReturnType, team: string, id: string) => f.teams.find((t) => t.id === team)?.agents.find((a) => a.id === id); + + it('lights the lent worker at the table whose ticket it works, and marks its home chair away', () => { + const f = floorAt(log); + expect(seat(f, 'docs', 'go-worker')).toMatchObject({ active: true, task: 'T1', seat: 'worker', borrowed: 'Backend Β· Go' }); + expect(seat(f, 'backend-go', 'go-worker')).toMatchObject({ active: false, away: 'Docs' }); + }); + + it('reads a generic "worker" line inside go-worker’s turn as go-worker’s, not a new person', () => { + const f = floorAt(log); + expect(f.stage.map((s) => s.id)).not.toContain('worker'); + expect(f.now?.agent).toBe('go-worker'); + }); + + it('forgets a start that never ended once the run moves to another phase', () => { + const leaky: RunEvent[] = [ + { phase: 'test', kind: 'agent_start', agent: 'go-tester', message: 'verification pass', time: at(1) }, + { phase: 'tester', kind: 'agent_end', agent: 'tester', message: 'finished', time: at(2) }, + { phase: 'memory', kind: 'phase', message: 'distilling', time: at(3) }, + { phase: 'memory', kind: 'agent_start', agent: 'memory', message: 'distilling', time: at(4) }, + ]; + const f = floorAt(leaky); + expect(seat(f, 'backend-go', 'go-tester')?.active).toBe(false); + }); + + it('does not let debug and latency bookkeeping make anyone speak', () => { + const noisy: RunEvent[] = [ + { phase: 'init', kind: 'debug', agent: 'evolve', message: 'bandit pull', time: at(1) }, + { phase: 'execute', kind: 'latency', agent: 'worker', message: 'execute 130ms', time: at(2) }, + ]; + const f = floorAt(noisy); + expect(f.stage.map((s) => s.id)).not.toContain('evolve'); + expect(f.stage.map((s) => s.id)).not.toContain('worker'); + }); +}); diff --git a/web/src/components/Live/floor/floorModel.ts b/web/src/components/Live/floor/floorModel.ts index 9c16cc9..7438c7a 100644 --- a/web/src/components/Live/floor/floorModel.ts +++ b/web/src/components/Live/floor/floorModel.ts @@ -48,9 +48,16 @@ export interface FloorAgent { lastTask?: string; /** * Set when the team left this seat empty and the pipeline lent the agent: - * 'pipeline' or 'default'. A borrowed seat is drawn as such. + * 'pipeline' or 'default' β€” or the name of the team they belong to, when + * they came over from another table to work one of this table's tickets. + * A borrowed seat is drawn as such. */ borrowed?: string; + /** + * Set at a person's home table while they are working another table's + * ticket: the name of that table. Their chair here is empty meanwhile. + */ + away?: string; } export type GateState = 'green' | 'red' | 'unverified' | ''; @@ -402,15 +409,24 @@ function floorFromChart( ticketsByTeam.set(team, list); } - // Everyone on some team's roster, so a visitor is seated once, at most. - const rostered = new Set(); + // Each team's own roster, and which team everyone belongs to. Someone who + // works a ticket of a table they are not on the roster of β€” a lent + // specialist, a corrector β€” sits at THAT table while they do, whatever + // other table they call home: a team with no worker of its own still shows + // who is doing its work. + const rosterOf = new Map>(); + const homeOf = new Map(); for (const s of chart) { const comp = compTeams.get(s.id); + const own = new Set(); for (const id of [comp?.manager || s.manager || 'triage', s.worker, s.reviewer, s.tester, ...(s.agents ?? []), ...(comp?.seats ?? []).map((f) => f.agent)]) { - if (id) rostered.add(id.trim().toLowerCase()); + const v = (id ?? '').trim().toLowerCase(); + if (!v) continue; + own.add(v); + if (!homeOf.has(v)) homeOf.set(v, s.name || s.id); } + rosterOf.set(s.id, own); } - const visiting = new Set(); const teams: FloorTeam[] = chart.map((s) => { const tickets = ticketsByTeam.get(s.id) ?? []; @@ -418,12 +434,10 @@ function floorFromChart( // Someone off every roster who worked THIS team's tickets β€” a corrector, // an escalated worker β€” sits at this table for the run, drawn as lent by // the pipeline; the harness table is for those who worked none. + const own = rosterOf.get(s.id) ?? new Set(); const visitors = ticketWorkers(activity, tickets.map((t) => t.id)) - .filter((id) => !rostered.has(id) && !visiting.has(id)) - .map((id) => { - visiting.add(id); - return { role: 'member', agent: id, source: 'pipeline' }; - }); + .filter((id) => !own.has(id)) + .map((id) => ({ role: roleOf(id), agent: id, source: homeOf.get(id) ?? 'pipeline', visitor: true })); // The manager the run resolves: the composition knows (it applied the // triage-capability rule); the chart's own field is the author's wish. const manager = comp?.manager || s.manager || 'triage'; @@ -465,6 +479,8 @@ function floorFromChart( }; }); + markAway(teams); + const stalledKeys = new Set(stalls.map((st) => `${st.provider}β†’${st.squad}:${st.interface}`)); const links: FloorLink[] = []; for (const i of view.interfaces ?? []) { @@ -636,6 +652,28 @@ function seats( return out; } +/** The seat a visitor takes, from their id: go-tester tests, ts-reviewer reviews. */ +function roleOf(id: string): string { + const v = id.replace(/-(strict|l\d+)$/, ''); + for (const r of ['worker', 'reviewer', 'tester'] as const) if (v === r || v.endsWith('-' + r)) return r; + return 'member'; +} + +/** + * markAway: someone lit at one table (working its ticket) is, at every other + * table they sit at, away β€” their chair there is empty, not idle. + */ +function markAway(teams: FloorTeam[]): void { + const litAt = new Map(); + for (const t of teams) for (const a of t.agents) if (a.active && a.task) litAt.set(a.id, t.name); + for (const t of teams) { + for (const a of t.agents) { + const there = litAt.get(a.id); + if (!a.active && there && there !== t.name) a.away = there; + } + } +} + /** * withBorrowed adds the seats the pipeline lends a team (composer.FillSeats): * a tester the team never named still sits at its table for this run, drawn @@ -643,7 +681,7 @@ function seats( */ function withBorrowed( own: FloorAgent[], - fills: { role: string; agent: string; source: string }[] | undefined, + fills: { role: string; agent: string; source: string; visitor?: boolean }[] | undefined, tickets: FloorTicket[], activity: Activity, team: string, @@ -658,7 +696,8 @@ function withBorrowed( have.add(id); const seat: SeatKind = f.role === 'worker' || f.role === 'reviewer' || f.role === 'tester' ? f.role : 'member'; const { active, held } = workingOn(activity, id); - const onThisTeam = team === CREW_ID || held === '' || ticketIDs.has(held); + // A visitor is here for this table's tickets only; with none in hand they are home. + const onThisTeam = team === CREW_ID || (held === '' ? !f.visitor : ticketIDs.has(held)); out.push({ id, seat, @@ -717,8 +756,8 @@ export interface Activity { speakers: string[]; /** agent β†’ its most recent line. */ lastBy: Map; - /** Agents inside an agent_start … agent_end pair right now, with their ticket. */ - working: Map; + /** Agents inside an agent_start … agent_end pair right now, with their ticket and the phase they started in. */ + working: Map; /** The phase of the newest line, and who spoke it. */ phase: FloorPhase | null; /** agent β†’ the phase it last spoke in. */ @@ -728,6 +767,40 @@ export interface Activity { /** An agent_start with no agent_end for this long is a crash, not work. */ const WORKING_TTL_MS = 10 * 60_000; +/** + * The pipeline's own phases, in the order a run walks them. A `phase` event + * naming one of these is the run moving on: anyone still "started" in an + * earlier one never sent their agent_end (the split phase's "assigned + * go-worker", the test phase's "verification pass") and is not working now. + * `init` is absent on purpose: it is also said mid-run, about set-up. + */ +const RUN_PHASES = new Set(['explore', 'charter', 'plan', 'split', 'execute', 'test', 'verify', 'integrate', 'learn', 'memory', 'skills', 'session', 'done']); + +/** + * The generic role names the execute loop also speaks in. A `worker` line in + * the middle of go-worker's turn (its file_change, its latency) is go-worker's, + * not a second person's. + */ +const GENERIC_ROLES = new Set(['worker', 'tester', 'reviewer', 'corrector']); + +/** Voices that are not people: the harness's own monitor, its bookkeeping. */ +const NOT_PEOPLE = new Set(['manager', 'loop', 'harness', 'rewind', 'graph']); + +/** The specialist a generic role line belongs to: whoever of that role is working now. */ +function specialistFor(role: string, working: Map, task: string): string { + let best = ''; + let bestAt = -Infinity; + for (const [id, w] of working) { + if (id === role || !id.endsWith('-' + role)) continue; + if (task && w.task && w.task !== task) continue; + if (w.at > bestAt) { + best = id; + bestAt = w.at; + } + } + return best; +} + /** * readActivity is the floor's reading of the log: who is inside an * agent_start … agent_end pair right now, who spoke last, who holds which @@ -740,8 +813,10 @@ export function readActivity(events: RunEvent[]): Activity { const seen = new Set(); const speakers: string[] = []; const lastBy = new Map(); - const working = new Map(); + const working = new Map(); const phaseOf = new Map(); + /** role β†’ the specialist of that role heard from last (go-worker for worker). */ + const lastOfRole = new Map(); const cur: { phase: FloorPhase | null } = { phase: null }; let lastAgent = ''; let lastTask = ''; @@ -749,7 +824,10 @@ export function readActivity(events: RunEvent[]): Activity { let lastModel = ''; let lastAt = 0; for (const e of events) { - const agent = (e.agent ?? '').trim().toLowerCase(); + let agent = (e.agent ?? '').trim().toLowerCase(); + // A generic role's line inside (or trailing) a specialist's turn is the specialist's. + if (GENERIC_ROLES.has(agent)) agent = specialistFor(agent, working, e.task_id ?? '') || lastOfRole.get(agent) || agent; + else for (const r of GENERIC_ROLES) if (agent.endsWith('-' + r)) lastOfRole.set(r, agent); if (e.kind === 'token' || e.kind === 'delta' || e.kind === 'token_delta') { // Tokens are proof of life for whoever is inside a start/end pair. const w = agent ? working.get(agent) : undefined; @@ -757,15 +835,28 @@ export function readActivity(events: RunEvent[]): Activity { continue; } const at = Date.parse(e.time) || lastAt; + // Debug and latency lines are the harness's bookkeeping, not anyone speaking. + if (e.kind === 'debug' || e.kind === 'latency') continue; + // The run moved on: whoever started in an earlier phase and never said + // agent_end is not working any more. + if (e.kind === 'phase' && RUN_PHASES.has(e.phase)) { + for (const [id, w] of working) { + if (w.phase !== e.phase && RUN_PHASES.has(w.phase)) working.delete(id); + } + } if (e.phase && (e.kind !== 'agent_end' || !cur.phase)) { const prev = cur.phase; cur.phase = { id: e.phase, agent: agent === 'manager' || agent === 'loop' ? '' : agent, message: e.message ?? '', since: prev && prev.id === e.phase ? prev.since : at }; } if (!agent) continue; - // The charter voice and the loop are not people on a team. - if (agent === 'manager' || agent === 'loop') continue; + // The charter voice, the loop and the harness's bookkeeping are not people on a team. + if (NOT_PEOPLE.has(agent)) continue; if (e.phase) phaseOf.set(agent, e.phase); - if (e.kind === 'agent_start') working.set(agent, { task: e.task_id ?? '', at }); + if (e.kind === 'agent_start') { + // A nested start (phase "go-tester" inside phase "test") keeps the outer phase. + const prev = working.get(agent); + working.set(agent, { task: e.task_id || prev?.task || '', at, phase: RUN_PHASES.has(e.phase) ? e.phase : prev?.phase ?? e.phase }); + } if (e.kind === 'agent_end') working.delete(agent); if (!seen.has(agent)) { seen.add(agent); diff --git a/web/src/components/Live/floor/floorRig.ts b/web/src/components/Live/floor/floorRig.ts index 733f8bf..f683f96 100644 --- a/web/src/components/Live/floor/floorRig.ts +++ b/web/src/components/Live/floor/floorRig.ts @@ -25,6 +25,7 @@ export interface Rig { legR: RefObject; beer: RefObject; cup: RefObject; + phone: RefObject; } export function useRig(): Rig { @@ -38,6 +39,7 @@ export function useRig(): Rig { legR: useRef(null), beer: useRef(null), cup: useRef(null), + phone: useRef(null), }; } @@ -104,6 +106,7 @@ export function poseRig(rig: Rig, p: PoseInputs): void { let kick = 0; let beer = false; let cup = false; + let phone = false; switch (mood) { case 'work': { @@ -162,6 +165,100 @@ export function poseRig(rig: Rig, p: PoseInputs): void { headY = walking ? 0 : Math.sin(t * 0.8 + phase) * 0.6; break; } + case 'chat': { + // Turned to a neighbour, one hand making the point, a laugh now and then. + const side = phase > 3.14 ? 1 : -1; + headY = side * 0.85 + Math.sin(t * 0.7 + phase) * 0.15; + armR = 1.0 + Math.sin(t * 3.1 + phase) * 0.45; + armRz = 0.35; + armL = sit > 0.5 ? 0.9 : 0.25; + bodyY = side * 0.18; + const laugh = Math.max(0, Math.sin(t * 0.9 + phase)) ** 12; + headX = laugh * 0.35; + bob = laugh * 0.03 + Math.sin(t * 1.2 + phase) * 0.008; + break; + } + case 'phone': { + // Head down over the phone, a thumb scrolling. + phone = true; + armR = 1.35 + Math.sin(t * 7 + phase) * 0.04; + armRz = -0.35; + armL = 1.3; + armLz = 0.35; + headX = -0.45; + headY = Math.sin(t * 0.4 + phase) * 0.1; + bob = Math.sin(t * 1.2 + phase) * 0.006; + break; + } + case 'think': { + // Chin on a fist, eyes up, a slow sway. + armR = 2.35; + armRz = -0.55; + armL = sit > 0.5 ? 1.1 : 0.4; + armLz = 0.3; + headX = 0.3; + headY = Math.sin(t * 0.5 + phase) * 0.25; + bodyZ = Math.sin(t * 0.5 + phase) * 0.05; + break; + } + case 'present': { + // At the board: pointing at it, turning back to the table to explain. + const k = (since % 5) / 5; + const pointing = k < 0.6; + armR = pointing ? 2.5 + Math.sin(t * 2) * 0.15 : 1.1 + Math.sin(t * 3) * 0.3; + armRz = pointing ? 0.25 : 0.4; + armL = pointing ? 0.3 : 0.9 + Math.sin(t * 2.6) * 0.3; + armLz = pointing ? 0 : -0.35; + headY = pointing ? 0 : Math.PI * 0.55; + headX = pointing ? 0.25 : 0; + bodyY = pointing ? 0 : 0.35; + bob = Math.abs(Math.sin(t * 2)) * 0.01; + break; + } + case 'cheer': { + // Applause, hands up. + const clap = Math.sin(t * 16); + armL = Math.PI * 0.62; + armR = Math.PI * 0.62; + armLz = -0.15 - clap * 0.18; + armRz = 0.15 + clap * 0.18; + headX = 0.2; + bob = Math.abs(Math.sin(t * 8)) * 0.04; + break; + } + case 'facepalm': { + armR = 2.65; + armRz = -0.6; + armL = sit > 0.5 ? 0.9 : 0.3; + headX = -0.35; + headY = Math.sin(t * 5) * 0.12; + bodyX = -0.1; + break; + } + case 'wave': { + // Waving back at whoever clicked. + armR = Math.PI - 0.25; + armRz = 0.25 + Math.sin(t * 9) * 0.45; + armL = sit > 0.5 ? 0.9 : 0.2; + headX = 0.15; + bob = Math.abs(Math.sin(t * 4.5)) * 0.03; + break; + } + case 'game': { + // At the foosball rods: hands low on the handles, wrists snapping. + armL = 1.05 + Math.sin(t * 7 + phase) * 0.12; + armR = 1.05 + Math.sin(t * 7 + phase + 2) * 0.12; + armLz = Math.sin(t * 11 + phase) * 0.25; + armRz = -Math.sin(t * 9 + phase) * 0.25; + bodyX = -0.15; + bodyY = Math.sin(t * 3 + phase) * 0.2; + headX = -0.25; + bob = Math.abs(Math.sin(t * 5 + phase)) * 0.03; + break; + } + case 'away': { + break; + } case 'nap': { // Head down on folded arms, a slow breath. armL = sit > 0.5 ? 1.35 : 0.35; @@ -244,6 +341,7 @@ export function poseRig(rig: Rig, p: PoseInputs): void { } if (rig.beer.current) rig.beer.current.visible = beer; if (rig.cup.current) rig.cup.current.visible = cup && !walking; + if (rig.phone.current) rig.phone.current.visible = phone && !walking; } /** diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index f636b44..5cde21c 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -555,3 +555,7 @@ .floor3d-mood, .floor3d-zzz i, .floor-mood, .floor-avatar-nap .floor-avatar-inner, .floor-avatar-party .floor-avatar-inner, .floor-avatar-stroll .floor-avatar-inner, .floor-party-badge { animation: none !important; } .floor3d-zzz i { opacity: 1; } } +.floor3d-away { padding: 1px 7px; border-radius: 9999px; background: rgba(251, 191, 36, 0.9); color: #451a03; font-size: 9.5px; font-weight: 700; } +.floor-avatar-away .floor-avatar-inner { opacity: 0.35; } +.floor3d-breakroom { display: inline-block; padding: 3px 10px; border-radius: 9999px; background: rgba(254, 243, 199, 0.92); color: #78350f; font-size: 10.5px; font-weight: 700; white-space: nowrap; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); } +.dark .floor3d-breakroom { background: rgba(69, 26, 3, 0.85); color: #fde68a; }