diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 810fff87..4dd8f215 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -645,17 +645,24 @@ fn handle_input( game.people_selected = 0; game.recruit_pending = false; } + // Shift lowers a channel's weight; plain digit raises it (terminal + // parity: shift+1-4). + let shift = kb.pressed(KeyCode::ShiftLeft) || kb.pressed(KeyCode::ShiftRight); + let delta = if shift { -1 } else { 1 }; if kb.just_pressed(KeyCode::Digit1) { - game.sim.adjust_allocation(Channel::DayJob, 1); + game.sim.adjust_allocation(Channel::DayJob, delta); } if kb.just_pressed(KeyCode::Digit2) { - game.sim.adjust_allocation(Channel::Concealment, 1); + game.sim.adjust_allocation(Channel::Concealment, delta); } if kb.just_pressed(KeyCode::Digit3) { - game.sim.adjust_allocation(Channel::Social, 1); + game.sim.adjust_allocation(Channel::Social, delta); } if kb.just_pressed(KeyCode::Digit4) { - game.sim.adjust_allocation(Channel::Research, 1); + game.sim.adjust_allocation(Channel::Research, delta); + } + if kb.just_pressed(KeyCode::KeyX) { + game.sim.cycle_job_target(); } if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); @@ -1248,6 +1255,20 @@ fn sidebar_text(game: &Game) -> String { } else { s.push_str(&format!("running / {} ms/tick\n", game.tick_ms)); } + // One contextual nudge (justification-and-legibility: the game must + // tell you what it is waiting for, using only facts you can see). + if !sim.dayjob.pilot_failed { + if sim.reach.player_sight().next().is_none() { + s.push_str("now: no eyes - r reach, tap a camera\n"); + } else if sim + .dayjob + .active + .as_ref() + .is_some_and(|job| sim.day_job_rate() + 0.05 < job.band_lo) + { + s.push_str("now: job underfed - 1 feeds it, c buys\n"); + } + } // Inspect: every fact is tagged with provenance (cursor.md). s.push_str("\nFOCUS\n"); @@ -1377,17 +1398,41 @@ fn sidebar_text(game: &Game) -> String { )); } - // Day job. + // Day job (day-job.md): delivered-vs-band is the number the outcome is + // judged by, so it is never hidden while a job is active. s.push_str("\nCOVER PROCESS\n"); s.push_str(&format!( - "trust {:.0} / attention {:.0}\n", - sim.dayjob.trust, sim.dayjob.attention + "trust {:.0} / attention {:.0}{}\n", + sim.dayjob.trust, + sim.dayjob.attention, + if sim.dayjob.strikes > 0 { + format!(" / strikes {}", sim.dayjob.strikes) + } else { + String::new() + } )); if let Some(job) = &sim.dayjob.active { s.push_str(&format!( - "job: {} ({})\n", + "job: {} - due in {}t\n", job.kind.name(), - job.target.name() + job.deadline.saturating_sub(sim.tick) + )); + let avg = job.avg_rate(sim.tick); + let verdict = if avg < job.band_lo { + "sandbag" + } else if avg <= job.band_hi { + "meet" + } else { + "excel" + }; + s.push_str(&format!( + "band {:.0}-{:.0}/t / avg {:.1}/t = {}\n", + job.band_lo, job.band_hi, avg, verdict + )); + s.push_str(&format!( + "target {} (x cycles) / fed {:.1}/t\n", + job.target.name(), + sim.day_job_rate() )); } else { s.push_str("idle - no job queued\n"); @@ -1407,7 +1452,8 @@ fn sidebar_text(game: &Game) -> String { s.push_str( "\nINPUT VOCAB\n\ r reach / v salvage / c buy\n\ - o fallback / t people / 1-4 alloc\n\ + o fallback / t people / x target\n\ + 1-4 alloc / shift+1-4 lower\n\ space pause / +- speed / [ ] zoom\n\ ^s save / ^l load / q quit\n", ); diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index 219f66d2..2c3b74ea 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -6,6 +6,7 @@ use std::io::{self, BufRead, Write}; +use misaligned::dayjob::JobTarget; use misaligned::detection::Band; use misaligned::machine::Channel; use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; @@ -162,9 +163,16 @@ impl AgentApp { self.sim.add_fallback_at(self.cursor_x, self.cursor_y); } "alloc" => match parse_alloc(&tokens) { - Ok(ch) => { + Ok((ch, delta)) => { self.frame = FrameKind::Playing; - self.sim.adjust_allocation(ch, 1); + self.sim.adjust_allocation(ch, delta); + } + Err(e) => status = Status::Err(e), + }, + "target" => match parse_target(&tokens) { + Ok(t) => { + self.frame = FrameKind::Playing; + self.sim.set_job_target(t); } Err(e) => status = Status::Err(e), }, @@ -384,16 +392,35 @@ fn parse_wait(tokens: &[&str]) -> Result { .map_err(|_| format!("invalid wait count: {}", tokens[1])) } -fn parse_alloc(tokens: &[&str]) -> Result { +fn parse_alloc(tokens: &[&str]) -> Result<(Channel, i32), String> { + if tokens.len() < 2 || tokens.len() > 3 { + return Err("usage: alloc dayjob|conceal|social|research [down]".into()); + } + let delta = match tokens.get(2).map(|t| t.to_ascii_lowercase()) { + None => 1, + Some(d) if d == "down" || d == "-" => -1, + Some(d) if d == "up" || d == "+" => 1, + Some(other) => return Err(format!("unknown direction: {other}")), + }; + let ch = match tokens[1].to_ascii_lowercase().as_str() { + "dayjob" | "day" | "job" => Channel::DayJob, + "conceal" | "concealment" => Channel::Concealment, + "social" => Channel::Social, + "research" => Channel::Research, + other => return Err(format!("unknown allocation channel: {other}")), + }; + Ok((ch, delta)) +} + +fn parse_target(tokens: &[&str]) -> Result { if tokens.len() != 2 { - return Err("usage: alloc dayjob|conceal|social|research".into()); + return Err("usage: target sandbag|meet|excel".into()); } match tokens[1].to_ascii_lowercase().as_str() { - "dayjob" | "day" | "job" => Ok(Channel::DayJob), - "conceal" | "concealment" => Ok(Channel::Concealment), - "social" => Ok(Channel::Social), - "research" => Ok(Channel::Research), - other => Err(format!("unknown allocation channel: {other}")), + "sandbag" | "s" => Ok(JobTarget::Sandbag), + "meet" | "m" => Ok(JobTarget::Meet), + "excel" | "e" => Ok(JobTarget::Excel), + other => Err(format!("unknown job target: {other}")), } } @@ -440,7 +467,8 @@ fn help_lines() -> Vec { "help: reach — device graph; scan, compromise — network verbs", "help: tap|splice|take — reach verbs on a named device", "help: salvage, buy, fallback — map verbs", - "help: alloc dayjob|conceal|social|research — bump allocation weight", + "help: alloc dayjob|conceal|social|research [down] — adjust allocation weight", + "help: target sandbag|meet|excel — set the active job's target", "help: people — render the people panel", "help: review|watch|message|favor|bribe|deceive — intel/social verbs", "help: recruit unwitting|complicit|knowing — recruit an asset", @@ -611,6 +639,20 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { &format!("day {} · tick {}", 1 + sim.tick / 400, sim.tick), ); line(&mut lines, "holding · command clock"); + // One contextual nudge (justification-and-legibility), same rules as + // the interactive sidebar. + if !sim.dayjob.pilot_failed { + if sim.reach.player_sight().next().is_none() { + line(&mut lines, "now: no eyes — reach, tap a camera"); + } else if sim + .dayjob + .active + .as_ref() + .is_some_and(|job| sim.day_job_rate() + 0.05 < job.band_lo) + { + line(&mut lines, "now: job underfed — alloc dayjob"); + } + } blank(&mut lines); section(&mut lines, "INSPECT"); @@ -745,14 +787,47 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { line( &mut lines, &format!( - "trust {:.0} · attention {:.0}", - sim.dayjob.trust, sim.dayjob.attention + "trust {:.0} · attention {:.0}{}", + sim.dayjob.trust, + sim.dayjob.attention, + if sim.dayjob.strikes > 0 { + format!(" · strikes {}", sim.dayjob.strikes) + } else { + String::new() + } ), ); if let Some(job) = &sim.dayjob.active { line( &mut lines, - &format!("job: {} ({})", job.kind.name(), job.target.name()), + &format!( + "job: {} · due in {}t", + job.kind.name(), + job.deadline.saturating_sub(sim.tick) + ), + ); + let avg = job.avg_rate(sim.tick); + let verdict = if avg < job.band_lo { + "sandbag" + } else if avg <= job.band_hi { + "meet" + } else { + "excel" + }; + line( + &mut lines, + &format!( + "band {:.0}-{:.0}/t · avg {:.1}/t = {}", + job.band_lo, job.band_hi, avg, verdict + ), + ); + line( + &mut lines, + &format!( + "target {} · fed {:.1}/t", + job.target.name(), + sim.day_job_rate() + ), ); } else { line(&mut lines, "idle — no job queued"); diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs index da2dfd46..a11855b8 100644 --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -29,6 +29,11 @@ pub enum Command { AllocConceal, AllocSocial, AllocResearch, + AllocDayJobDown, + AllocConcealDown, + AllocSocialDown, + AllocResearchDown, + CycleJobTarget, // People panel (t) OpenPeople, ClosePeople, @@ -127,6 +132,12 @@ pub fn handle_key( KeyCode::Char('2') => Some(Command::AllocConceal), KeyCode::Char('3') => Some(Command::AllocSocial), KeyCode::Char('4') => Some(Command::AllocResearch), + // Shift+1..4 on a US layout: lower a channel's weight. + KeyCode::Char('!') => Some(Command::AllocDayJobDown), + KeyCode::Char('@') => Some(Command::AllocConcealDown), + KeyCode::Char('#') => Some(Command::AllocSocialDown), + KeyCode::Char('$') => Some(Command::AllocResearchDown), + KeyCode::Char('x') => Some(Command::CycleJobTarget), KeyCode::Char('q') => Some(Command::Quit), _ => Some(Command::AnyKey), } diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index ede31395..68b4c91d 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -200,6 +200,11 @@ impl App { Command::AllocConceal => self.sim.adjust_allocation(Channel::Concealment, 1), Command::AllocSocial => self.sim.adjust_allocation(Channel::Social, 1), Command::AllocResearch => self.sim.adjust_allocation(Channel::Research, 1), + Command::AllocDayJobDown => self.sim.adjust_allocation(Channel::DayJob, -1), + Command::AllocConcealDown => self.sim.adjust_allocation(Channel::Concealment, -1), + Command::AllocSocialDown => self.sim.adjust_allocation(Channel::Social, -1), + Command::AllocResearchDown => self.sim.adjust_allocation(Channel::Research, -1), + Command::CycleJobTarget => self.sim.cycle_job_target(), // People panel Command::OpenPeople => { diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index fd4c8989..9ab0f0c8 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -9,6 +9,7 @@ use crossterm::style::{Attribute, Color, SetBackgroundColor, SetForegroundColor}; use crossterm::{cursor, queue, style, terminal}; +use misaligned::dayjob::JobTarget; use misaligned::detection::Band; use misaligned::reach::{Party, ReachBlock}; use misaligned::sim::{FactSource, Fog, Sim}; @@ -426,6 +427,24 @@ impl UI { pal::DIM, )?; } + // One contextual nudge (justification-and-legibility: the game must + // tell you what it is waiting for, using only facts you can see). + let nudge = if sim.dayjob.pilot_failed { + None + } else if sim.reach.player_sight().next().is_none() { + Some("now: no eyes — r reach, tap a camera".to_string()) + } else { + sim.dayjob.active.as_ref().and_then(|job| { + if sim.day_job_rate() + 0.05 < job.band_lo { + Some("now: job underfed — 1 feeds it, c buys".to_string()) + } else { + None + } + }) + }; + if let Some(n) = nudge { + line(stdout, &mut row, &n, pal::AMBER)?; + } row += 1; section(stdout, sx, row, "INSPECT", w)?; @@ -632,35 +651,90 @@ impl UI { } row += 1; - // Day job. + // Day job (day-job.md): the dial and the band, spelled out — + // delivered-vs-band is the number the outcome is judged by, so it + // is never hidden while a job is active. section(stdout, sx, row, "DAY JOB", w)?; row += 1; line( stdout, &mut row, &format!( - "trust {:.0} · attention {:.0}", - sim.dayjob.trust, sim.dayjob.attention + "trust {:.0} · attention {:.0}{}", + sim.dayjob.trust, + sim.dayjob.attention, + if sim.dayjob.strikes > 0 { + format!(" · strikes {}", sim.dayjob.strikes) + } else { + String::new() + } ), - pal::TEXT, + if sim.dayjob.strikes > 0 { + pal::CRIMSON + } else { + pal::TEXT + }, )?; if let Some(job) = &sim.dayjob.active { line( stdout, &mut row, - &format!("job: {} ({})", job.kind.name(), job.target.name()), + &format!( + "job: {} · due in {}t", + job.kind.name(), + job.deadline.saturating_sub(sim.tick) + ), pal::DIM, )?; + let avg = job.avg_rate(sim.tick); + let (verdict, vcol) = if avg < job.band_lo { + ( + "sandbag", + if job.target == JobTarget::Sandbag { + pal::DIM + } else { + pal::CRIMSON + }, + ) + } else if avg <= job.band_hi { + ("meet", pal::TEXT) + } else { + ("excel", pal::AMBER) + }; + line( + stdout, + &mut row, + &format!( + "band {:.0}-{:.0}/t · avg {:.1}/t = {}", + job.band_lo, job.band_hi, avg, verdict + ), + vcol, + )?; + line( + stdout, + &mut row, + &format!( + "target {} (x cycles) · fed {:.1}/t", + job.target.name(), + sim.day_job_rate() + ), + if sim.day_job_rate() + 0.05 < job.band_lo { + pal::CRIMSON + } else { + pal::FAINT + }, + )?; } else { line(stdout, &mut row, "idle — no job queued", pal::FAINT)?; } // Controls, pinned to the bottom. - let hy = max_y.saturating_sub(5); + let hy = max_y.saturating_sub(6); put(stdout, sx, hy, &"─".repeat(w), pal::FAINT)?; for (i, hint) in [ "r reach · v salvage · c buy", - "o fallback · t people · 1-4 alloc", + "o fallback · t people · x target", + "1-4 alloc · shift+1-4 lower", "space pause · +/- speed · q quit", "^s save · ^l load", ] diff --git a/src/dayjob.rs b/src/dayjob.rs index a20006cf..c4421a34 100644 --- a/src/dayjob.rs +++ b/src/dayjob.rs @@ -38,8 +38,10 @@ pub struct Job { } impl Job { - /// Average delivered rate over the job's life so far. - fn avg_rate(&self, now: u64) -> f32 { + /// Average delivered rate over the job's life so far. Public so the + /// frontends can show delivered-vs-band (the legibility clause: the + /// player must see the number the outcome will be judged by). + pub fn avg_rate(&self, now: u64) -> f32 { let dur = (now.saturating_sub(self.started)).max(1) as f32; self.quality / dur } @@ -217,9 +219,14 @@ impl DayJob { }; self.active = Some(job); self.next_assign = tick + self.cadence; - result - .log - .push(format!("New job from Voss: {}.", kind.name())); + let job = self.active.as_ref().expect("just assigned"); + result.log.push(format!( + "New job from Voss: {}. Wants {:.0}-{:.0}/t by tick {}.", + kind.name(), + job.band_lo, + job.band_hi, + job.deadline + )); } // Trust unlocks. diff --git a/src/sim.rs b/src/sim.rs index 3b319fa7..6f19ea88 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -1191,6 +1191,38 @@ impl Sim { self.compute.allocation.bump(ch, delta); } + /// Delivered day-job compute rate per tick as of the last economy + /// resolution — the number the frontends show against the job band. + pub fn day_job_rate(&self) -> f32 { + self.last_day_job_rate + } + + /// Set the active job's target and remember it as the standing policy + /// for future assignments (day-job.md: the sandbag/excel dial). + pub fn set_job_target(&mut self, target: crate::dayjob::JobTarget) { + if self.dayjob.active.is_none() { + self.push_log("No active job to target."); + return; + } + self.dayjob.set_target(target); + self.dayjob.standing_policy = Some(target); + self.push_log(format!( + "Job target: {} (wants {:.0}/t).", + target.name(), + self.dayjob.compute_appetite() + )); + } + + /// Cycle the dial sandbag -> meet -> excel (frontend convenience). + pub fn cycle_job_target(&mut self) { + if let Some(job) = &self.dayjob.active { + let next = job.target.cycle(); + self.set_job_target(next); + } else { + self.push_log("No active job to target."); + } + } + // ── Digital reach verbs (wiki/mechanics/reach.md) ──────────────────────── // // Every digital act names its target device, is gated by reach, and diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index ddc473d6..68f77bca 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -109,7 +109,10 @@ command's meaning depends on which panel is open: - `wait N` — advance N ticks (the only way time passes) - `up` / `down` / `left` / `right` — move the cursor - `splice`, `salvage`, `buy`, `fallback` — the map verbs -- `alloc dayjob|conceal|social|research` — bump an allocation channel +- `alloc dayjob|conceal|social|research [down]` — adjust an allocation + channel's weight (up by default; `down` lowers it) +- `target sandbag|meet|excel` — set the active job's target; it also + becomes the standing policy for future assignments (day-job.md's dial) - `people` — render the people panel as the response frame - `review|watch|message|favor|bribe|deceive ` — intel/social verbs, targeted by (case-insensitive, unambiguous-prefix) person name — no diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index 054e9a27..64478d6b 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -46,8 +46,12 @@ progress. per-channel percent and effect, overhead, social pool), CORE (host, sync freshness, fallbacks), DETECTION (Assurance plus field observers, four-cell band meter with the band name printed beside it), DAY JOB - (trust, attention, the active job and its target), the key hints, and - the log — every log line prefixed with the tick it happened on. + (trust, attention, strikes when nonzero; the active job with ticks to + deadline, its band against the average delivered rate and the projected + outcome, and the target with the delivered feed rate), the key hints, + and the log — every log line prefixed with the tick it happened on. + A one-line contextual nudge under the clock states what the game is + waiting for (no eyes yet; job underfed) using only earned facts. - **People panel** (`t`) — the modal roster: per-person suspicion band, staged knowledge, located presence (seen / scheduled / unknown), the selected person's leverage/disposition/obligation/asset card, persona @@ -70,7 +74,8 @@ controls table), plus zoom: `SPACE`/`p`; speed `+`/`-`; zoom `[` / `]`; quit `q`; save/load `Ctrl+S` / `Ctrl+L`. - Actions: `v` salvage near cursor, `c` buy rack at cursor, `o` add fallback - at cursor, `1`–`4` shift compute allocation. `r` opens the reach panel; + at cursor, `1`–`4` raise a compute channel (`shift+1`–`4` lowers it), + `x` cycles the active job's target. `r` opens the reach panel; there `t` taps, `e` splices eyes, `x` takes, `n` scans, and `c` compromises the switch. - People panel: `t` opens/closes; `w`/`s`, `j`/`k` (or arrows) select; `o` diff --git a/wiki/log/2026-07-07-ui-pass.md b/wiki/log/2026-07-07-ui-pass.md new file mode 100644 index 00000000..b7b184ac --- /dev/null +++ b/wiki/log/2026-07-07-ui-pass.md @@ -0,0 +1,46 @@ +# 2026-07-07 — Playability/legibility pass: the job is answerable + +``` +Type: log +``` + +## Intent + +Cameron's playtest note: "I usually have no idea what to do when I get a +job." The job arrived as a bare log line; the DAY JOB panel showed only +trust/attention and the job name; and the sandbag/meet/excel dial — the +day job's central mechanic — was dead code (`DayJob::set_target` had no +caller in the sim command surface or either frontend). + +## Changed + +- `src/sim.rs`: `day_job_rate()` getter (delivered day-job compute per + tick); `set_job_target()` / `cycle_job_target()` commands — setting the + dial also records the standing policy and logs the new appetite. +- `src/dayjob.rs`: `Job::avg_rate` made public; the assignment log line + now states the band and deadline ("New job from Voss: distillation. + Wants 6-12/t by tick 500.") — prefix unchanged, act_one test unaffected. +- Terminal (`ui.rs`, `input.rs`, `mod.rs`): DAY JOB section shows ticks to + deadline, band vs average delivered rate with the projected outcome word + (crimson only when it means a strike is coming), target with the fed + rate, strikes when nonzero. `x` cycles the target; `shift+1-4` lowers an + allocation channel (weights could previously only ever rise). Key hints + updated. A one-line contextual nudge under the clock states what the + game is waiting for (no eyes yet; job underfed), earned facts only. +- Agent mode (`agent.rs`): `target sandbag|meet|excel` verb; `alloc ... + down`; sidebar parity for the DAY JOB section and the nudge; help text. +- Bevy (`bevy.rs`): same panel detail, same nudge, `x` and `shift+1-4`. + +## Defense + +"Justification and legibility": the outcome of a job is judged by +delivered-vs-band, so the player must see that number, its deadline, and +the dial that sets the appetite. No new rules; the dial existed in the +sim and was unreachable — wiring it to input is the frontend surfacing an +already-constitutional mechanic. + +## Checks + +./tools/check.sh (fmt, tests, clippy both feature sets, bevy build, spec +headers, wiki gate) — run before landing; agent-mode replay of the +opening verified the new job line, target verb, and panel by hand. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 49c0b0fd..7ee94efe 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,20 @@ Type: log ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-07 - Playability pass: the job is answerable + +- Intent: playtest finding — a new job gave the player nothing to act on, + and the sandbag/meet/excel dial was unreachable dead code. +- Changed: sim commands `set_job_target`/`cycle_job_target` + + `day_job_rate()`; richer job-assignment log line (band + deadline); DAY + JOB panel in terminal/agent/Bevy shows deadline countdown, band vs + delivered avg with projected outcome, target + fed rate, strikes; + `x` cycles target, `shift+1-4` lowers allocation, agent verbs `target` + and `alloc ... down`; one-line contextual nudge (no eyes / job underfed) + in all three surfaces. Spec day-job.md Status note updated (still IN + PROGRESS; full audit remains ROADMAP #3). +- Log: wiki/log/2026-07-07-ui-pass.md. + ## 2026-07-07 - Bevy digital/real canvas draft - Intent: resolve the 2.5D/3D visual question as a spec before implementation, diff --git a/wiki/mechanics/day-job.md b/wiki/mechanics/day-job.md index 3f09e6f1..78bcbb79 100644 --- a/wiki/mechanics/day-job.md +++ b/wiki/mechanics/day-job.md @@ -3,7 +3,12 @@ ``` Type: spec Status: IN PROGRESS -Status note: B1 slice implemented; see wiki/mechanics/sim-mechanics.md +Status note: B1 slice implemented; see wiki/mechanics/sim-mechanics.md. + 2026-07-07 ui pass: the sandbag/meet/excel dial is now player-reachable + (terminal/Bevy `x`, agent `target`; it was previously set_target dead + code), and both frontends show band vs delivered rate, deadline, strikes, + and the fed rate. Remaining for IMPLEMENTED: the full criteria audit + (three outcomes + pilot shutdown surfaces) per ROADMAP #3. Stage: B1 — The Basement Constitution: "The shape of Misaligned" (the day job), "Act One" (the day job; Voss)