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_GLYPH[mood]} {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; }