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
+22 -12
View File
@@ -489,8 +489,10 @@ pub async fn world_live(
let mut last: std::collections::HashMap<String, String> = std::collections::HashMap::new();
// Per-run journal cursor so we stream only NEW run_events each poll.
let mut cursors: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
// Last emitted signature per phase, so the plan is sent once and then
// only when a phase actually moves.
// Last emitted signature per mission / per phase, so the plan is sent
// once and then only when something actually moves.
let mut last_mission: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut last_phase: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
// Audit-log cursor for edge-initiated inter-agent events (delegation,
@@ -555,16 +557,24 @@ pub async fn world_live(
let mut seen_missions: HashSet<String> = HashSet::new();
for m in &missions {
if seen_missions.insert(m.mission_id.clone()) {
yield sse(
"mission.update",
json!({
"missionId": m.mission_id,
"title": m.title,
"status": m.status,
"templateKind": m.template_kind,
"completedAt": m.completed_at,
}),
);
// Delta-guarded like every other stateful event here. Without
// this it re-sent the same mission on every poll — harmless
// to a client that keys on missionId, and pure noise on the
// wire that hides the events that DID change.
let sig = format!("{}|{}", m.status, m.completed_at.as_deref().unwrap_or(""));
if last_mission.get(&m.mission_id).map(|s| s != &sig).unwrap_or(true) {
last_mission.insert(m.mission_id.clone(), sig);
yield sse(
"mission.update",
json!({
"missionId": m.mission_id,
"title": m.title,
"status": m.status,
"templateKind": m.template_kind,
"completedAt": m.completed_at,
}),
);
}
// A terminal mission is a map to read, not a scene to
// animate: it still gets an orb, but no heat.
let running = m.status == "running";