feat(viz): the mission becomes a map — centre, stations, and agents at theirs

The clump was structural, not cosmetic. Four causes, each fixed here.

`homes` (an agent's resting node) was written only by `seed()`, so every agent
homed to the mission centre and orbited the same dot regardless of which phase
it was on. `setHome` points each agent at its CURRENT phase, and the existing
physics does the rest for free: the pawn rests at its station, the pawn→home
line tethers it there, and a touch becomes a visible departure and return. No
new motion code.

The mission node was seeded as `level: "team"` (my own bug from the focus
work). `seed()` casts that straight to a Tier and `ensureNode` is
first-write-wins, so it was created as a small teal team dot that the later
`node.activity` could never upgrade. That dot at the centre of the scene was
one line.

`mission`/`phase` replace the retired `repo`/`loop` tiers rather than adding a
parallel set. The backend stopped emitting repo:/loop: ids, which left their
whole landmark treatment — bigger radius, distinct colour, always-labelled, 60s
fade instead of 22s — orphaned on prefixes nothing sends. Missions and phases
need exactly that treatment. The two separately-written prefix→tier ternaries
in onTouch and onNodeActivity are now one `tierFor`: they agreed only by luck,
and whichever path saw a node first fixed its tier forever.

Phase stations spring out at 190 rather than the shared 64, or they pack into a
rosette around the centre and the point — agents moving BETWEEN stations — is
invisible. Idle roam is off under a mission scope: wandering to a random node
keeps an idle workspace alive, but inside one mission it sends agents to files
nobody opened, which reads as work and isn't.

Palette is injected at construction and keyed on template_kind, so a benchmark
run and a security sweep no longer render identically to a research mission.
It also collapses two uncoordinated kind→colour maps that had drifted:
LEVEL_COLOR by tier, and the fireColor if-chain by id prefix.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-10 18:17:12 -07:00
co-authored by Claude Opus 5
parent 5f85dbb718
commit 006432c2dc
6 changed files with 377 additions and 69 deletions
+121
View File
@@ -0,0 +1,121 @@
// Colour, keyed on what the mission is FOR.
//
// A benchmark run and a security sweep were rendered in the same palette as a
// research mission, so the only thing distinguishing them on screen was the
// label. Colour is the cheapest signal a viewer reads before any text, and it
// was carrying no information.
//
// This also collapses two uncoordinated kind→colour maps that had drifted
// apart: `LEVEL_COLOR` in engine.ts (node colour by tier) and the `fireColor`
// if-chain in `onTouch` (beam colour by id prefix). They answered the same
// question in different places with different answers.
import type { Tier } from "./engine";
export type PhaseKind = "research" | "coding" | "benchmark" | "security_scan";
export interface Palette {
/** The mission landmark at the centre. */
mission: string;
/** Station colour per phase kind. */
phase: Record<PhaseKind, string>;
dir: string;
file: string;
tool: string;
finding: string;
comm: { message: string; delegate: string; room: string; web: string };
}
/** The default — what the World looked like before palettes, so an unrecognised
* template renders exactly as it always did rather than as something odd. */
const BASE: Palette = {
mission: "#ff8a7a",
phase: {
research: "#c98af0",
coding: "#8ec5ff",
benchmark: "#f0b866",
security_scan: "#ff5f57",
},
dir: "#5ec8d8",
file: "#5ec8d8",
tool: "#5ec8d8",
finding: "#ff8a7a",
comm: { message: "#ff5eae", delegate: "#ff8a7a", room: "#c98af0", web: "#5ec8d8" },
};
/** Measurement work reads amber/green — instrument tones, not creative ones. */
const BENCHMARK: Palette = {
...BASE,
mission: "#f0b866",
phase: { ...BASE.phase, benchmark: "#5fd08a", coding: "#e8b465" },
dir: "#a8b06a",
file: "#d8c06a",
tool: "#5fd08a",
comm: { ...BASE.comm, web: "#a8b06a" },
};
/** Adversarial work reads crimson/steel. */
const SECURITY: Palette = {
...BASE,
mission: "#ff5f57",
phase: { ...BASE.phase, security_scan: "#ff5f57", coding: "#8a9af0" },
dir: "#7a8a9a",
file: "#9aa8b8",
tool: "#ff8a7a",
finding: "#ff3b30",
comm: { ...BASE.comm, web: "#7a8a9a" },
};
/** Reading and mapping an existing codebase — cooler, quieter. */
const REFACTOR: Palette = {
...BASE,
mission: "#6fd0c0",
phase: { ...BASE.phase, coding: "#6fd0c0" },
dir: "#4aa3b8",
file: "#6fd0c0",
comm: { ...BASE.comm, web: "#4aa3b8" },
};
/** Pure research — violet and sky, the existing landmark tones. */
const RESEARCH: Palette = {
...BASE,
mission: "#c98af0",
phase: { ...BASE.phase, research: "#c98af0" },
dir: "#8a9af0",
file: "#8ec5ff",
};
const PALETTES: Record<string, Palette> = {
research_only: RESEARCH,
research_and_code: BASE,
security_hardening: SECURITY,
refactor: REFACTOR,
benchmark: BENCHMARK,
custom: BASE,
};
export function paletteFor(templateKind?: string | null): Palette {
return (templateKind && PALETTES[templateKind]) || BASE;
}
/** Node colour for an id + tier. Replaces `LEVEL_COLOR[tier]` for everything a
* mission scope draws; structural tiers keep their own constants. */
export function colorFor(p: Palette, nodeId: string, tier: Tier): string | undefined {
if (tier === "mission") return p.mission;
if (nodeId.startsWith("finding:")) return p.finding;
if (nodeId.startsWith("dir:")) return p.dir;
if (nodeId.startsWith("file:")) return p.file;
if (nodeId.startsWith("tool:")) return p.tool;
// `phase` deliberately absent: a station's colour comes from its KIND, which
// the node id does not carry. `applyMissionPlan` passes it explicitly.
return undefined;
}
/** Beam colour when a pawn touches a node — the old `fireColor` if-chain. */
export function fireFor(p: Palette, nodeId: string, weight: number): string | undefined {
if (nodeId.startsWith("file:")) return weight >= 0.9 ? p.file : p.tool;
if (nodeId.startsWith("tool:")) return p.tool;
if (nodeId.startsWith("phase:")) return p.mission;
if (nodeId.startsWith("finding:")) return p.finding;
return undefined;
}