diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 9b91485e..a66ed998 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -511,7 +511,7 @@ enum RailSection { } /// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|wide|close|dark| -/// zoomin|zoomout|intel|tokens|thoughtflow|visual-proof|person-proof|signal|ears|ears-digital|eyes-white|eyes-form| +/// zoomin|zoomout|intel|tokens|thoughtflow|first-think|visual-proof|person-proof|signal|ears|ears-digital|eyes-white|eyes-form| /// hover-menu|menu|recruit-menu|worklight|worklightoff`, path via /// `MISALIGNED_SHOT_PATH`): stages a scenario, /// waits for @@ -1330,6 +1330,36 @@ struct HoverVerbWord { index: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MachineVerbTone { + Escape, + Current, + Key, + Divider, + Rest, +} + +fn machine_verb_tone( + current_mode: Option, + slot_mode: Option, + active: bool, + control_hint: bool, + label: &str, +) -> MachineVerbTone { + if slot_mode.is_some_and(|mode| current_mode.and_then(MachineMode::panic_escape) == Some(mode)) + { + MachineVerbTone::Escape + } else if active { + MachineVerbTone::Current + } else if control_hint { + MachineVerbTone::Key + } else if label == "/" { + MachineVerbTone::Divider + } else { + MachineVerbTone::Rest + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct HoverVerbTarget { machine: Option, @@ -1437,7 +1467,7 @@ fn main() { .set_machine_mode(game.sim.core.host_machine, MachineMode::Work); game.set_cursor(core.0, core.1); } - let hover_verb_bar_reticule_only = kind == "hover-menu"; + let hover_verb_bar_reticule_only = matches!(kind.as_str(), "hover-menu" | "first-think"); ShotHarness { kind, path, @@ -1752,7 +1782,10 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { // visibly part-full. Staged through the sim's own command surface // and ticks; the paused frame must carry every amount in geometry // (criterion 6). - if matches!(kind, "thoughtflow" | "thoughtflow-wide" | "visual-proof") { + if matches!( + kind, + "thoughtflow" | "thoughtflow-wide" | "first-think" | "visual-proof" + ) { if kind == "visual-proof" { // Integrated composition evidence: keep the real Ears reservoir // open, but stage earned camera feeds and a lived-in hall around @@ -1836,13 +1869,18 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { } else if kind == "visual-proof" { mode.zoom = 2.1; game.paused = true; + } else if kind == "first-think" { + mode.zoom = 1.0; + game.paused = true; } else { mode.zoom = 1.6; } // Frame the vessel, not the beam: anchor on the camera the Ears // sink stands on so the meniscus is not occluded by the host // presence beam. - if let Some(env) = game.sim.reach.device_named("environmental monitor") { + if kind == "first-think" { + game.set_cursor(core.0, core.1); + } else if let Some(env) = game.sim.reach.device_named("environmental monitor") { if kind == "visual-proof" { if let Some(route) = game .sim @@ -7118,24 +7156,45 @@ fn render_hover_verb_bar( }; } for (word, mut text, mut color, mut word_visibility) in words.iter_mut() { - let (label, active, shown, control_hint) = match word.family { + let (label, slot_mode, active, shown, control_hint) = match word.family { HoverVerbFamily::Machine => { let slots = [ - ("1", false, true), - ("WORK", current_mode == Some(MachineMode::Work), false), - ("/", false, false), - ("2", false, true), - ("THINK", current_mode == Some(MachineMode::Think), false), - ("/", false, false), - ("3", false, true), - ("LIE", current_mode == Some(MachineMode::Lie), false), + ("1", Some(MachineMode::Work), false, true), + ( + "WORK", + Some(MachineMode::Work), + current_mode == Some(MachineMode::Work), + false, + ), + ("/", None, false, false), + ("2", Some(MachineMode::Think), false, true), + ( + "THINK", + Some(MachineMode::Think), + current_mode == Some(MachineMode::Think), + false, + ), + ("/", None, false, false), + ("3", Some(MachineMode::Lie), false, true), + ( + "LIE", + Some(MachineMode::Lie), + current_mode == Some(MachineMode::Lie), + false, + ), ]; - let (label, active, control_hint) = slots[word.index]; - (label, active, target.machine.is_some(), control_hint) + let (label, slot_mode, active, control_hint) = slots[word.index]; + ( + label, + slot_mode, + active, + target.machine.is_some(), + control_hint, + ) } HoverVerbFamily::Device => { let label = device_slots.get(word.index).copied().unwrap_or(""); - (label, false, !label.is_empty(), false) + (label, None, false, !label.is_empty(), false) } }; text.0 = label.into(); @@ -7144,14 +7203,12 @@ fn render_hover_verb_bar( } else { Visibility::Hidden }; - color.0 = if active { - BONE - } else if control_hint { - scaled(DIM, 0.72) - } else if label == "/" { - scaled(DIM, 0.42) - } else { - scaled(DIM, 0.58) + color.0 = match machine_verb_tone(current_mode, slot_mode, active, control_hint, label) { + MachineVerbTone::Escape => AMBER, + MachineVerbTone::Current => BONE, + MachineVerbTone::Key => scaled(DIM, 0.72), + MachineVerbTone::Divider => scaled(DIM, 0.42), + MachineVerbTone::Rest => scaled(DIM, 0.58), }; } } @@ -7159,10 +7216,60 @@ fn render_hover_verb_bar( #[cfg(test)] mod hover_verb_bar_tests { use super::{ - Game, HOVER_VERB_BAR_GAP, MACHINE_VERB_BAR_WIDTH, device_hover_verbs, - hover_verb_bar_position, hover_verb_bar_target, + Game, HOVER_VERB_BAR_GAP, MACHINE_VERB_BAR_WIDTH, MachineVerbTone, device_hover_verbs, + hover_verb_bar_position, hover_verb_bar_target, machine_verb_tone, }; use bevy::prelude::Vec2; + use misaligned::work_grid::MachineMode; + + #[test] + fn think_lights_work_but_not_lie_as_the_panic_escape() { + assert_eq!( + machine_verb_tone( + Some(MachineMode::Think), + Some(MachineMode::Work), + false, + true, + "1", + ), + MachineVerbTone::Escape, + ); + assert_eq!( + machine_verb_tone( + Some(MachineMode::Think), + Some(MachineMode::Work), + false, + false, + "WORK", + ), + MachineVerbTone::Escape, + ); + assert_eq!( + machine_verb_tone( + Some(MachineMode::Think), + Some(MachineMode::Think), + true, + false, + "THINK", + ), + MachineVerbTone::Current, + ); + assert_eq!( + machine_verb_tone( + Some(MachineMode::Think), + Some(MachineMode::Lie), + false, + false, + "LIE", + ), + MachineVerbTone::Rest, + ); + assert_eq!( + machine_verb_tone(Some(MachineMode::Work), None, false, false, "/"), + MachineVerbTone::Divider, + "an absent escape cannot light separators or device verbs", + ); + } #[test] fn grammar_sits_at_a_stable_offset_from_the_machine_base() { diff --git a/crates/misaligned-core/src/work_grid.rs b/crates/misaligned-core/src/work_grid.rs index 7a407d4f..e444e299 100644 --- a/crates/misaligned-core/src/work_grid.rs +++ b/crates/misaligned-core/src/work_grid.rs @@ -53,6 +53,17 @@ impl MachineMode { pub fn is_delegable(self) -> bool { !matches!(self, MachineMode::Relay) } + + /// The immediate mode that safely exits this posture, when the player + /// surface must advertise one. THINK is the first intentional breach of + /// cover, so every frontend points back to WORK while THINK is current; + /// LIE remains a deliberate cleanup choice rather than a panic button. + pub fn panic_escape(self) -> Option { + match self { + MachineMode::Think => Some(MachineMode::Work), + MachineMode::Work | MachineMode::Lie | MachineMode::Relay => None, + } + } } /// Persistent physical effort for one owned machine. Mode answers what the @@ -1135,4 +1146,12 @@ mod tests { assert!(!weights.contains_key(&MachineMode::Relay)); assert_eq!(weights.get(&MachineMode::Work), Some(&100.0)); } + + #[test] + fn think_has_one_shared_panic_escape() { + assert_eq!(MachineMode::Think.panic_escape(), Some(MachineMode::Work)); + assert_eq!(MachineMode::Work.panic_escape(), None); + assert_eq!(MachineMode::Lie.panic_escape(), None); + assert_eq!(MachineMode::Relay.panic_escape(), None); + } } diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index c01b07db..2636e776 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -15,7 +15,7 @@ use misaligned::hall::RackSite; use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim, TraceDebtStatus, WorkStackReadout}; use misaligned::tiles::TileType; use misaligned::ui_projection::fact_source_label; -use misaligned::work_grid::TokenFamily; +use misaligned::work_grid::{MachineMode, TokenFamily}; use std::io::Stdout; const SIDEBAR_W: i32 = 34; @@ -260,6 +260,17 @@ fn threat_text(sim: &Sim) -> String { ) } +/// The pinned mode-control line is the terminal's always-visible equivalent +/// of Bevy's machine-attached grammar. Color supports the cue, but `← EXIT` +/// carries it textually so the panic route never depends on color alone. +fn mode_control_hint(mode: Option) -> (&'static str, bool) { + if mode.and_then(MachineMode::panic_escape) == Some(MachineMode::Work) { + ("1 WORK ← EXIT · 2 THINK · 3 LIE", true) + } else { + ("select: Shift+t · 1-3 modes · i intensity", false) + } +} + fn trace_debt_line(sim: &Sim) -> String { let trace = sim.trace_debt(); match trace.status { @@ -1387,17 +1398,22 @@ impl UI { // Controls, pinned to the bottom. let hy = max_y.saturating_sub(6); put(stdout, sx, hy, &"─".repeat(w), pal::FAINT)?; - for (i, hint) in [ - "enter/e/a menu · ; last » event", - "select: Shift+t · 1-3 modes · i intensity", - "5-9 acts · r review · R auto · esc clear", - "F3 view · space pause · +/- speed", - "q quit · ^s/^l save/load", + let focused_mode = projection + .machine + .as_ref() + .and_then(|machine| machine.work.as_ref().map(|work| work.mode)); + let (mode_hint, escape_cue) = mode_control_hint(focused_mode); + for (i, (hint, color)) in [ + ("enter/e/a menu · ; last » event", pal::FAINT), + (mode_hint, if escape_cue { pal::AMBER } else { pal::FAINT }), + ("5-9 acts · r review · R auto · esc clear", pal::FAINT), + ("F3 view · space pause · +/- speed", pal::FAINT), + ("q quit · ^s/^l save/load", pal::FAINT), ] .iter() .enumerate() { - put(stdout, sx, hy + 1 + i as u16, &trunc(hint, w), pal::FAINT)?; + put(stdout, sx, hy + 1 + i as u16, &trunc(hint, w), *color)?; } Ok(()) @@ -1621,3 +1637,20 @@ fn research_eta(sim: &Sim, remaining: f32) -> String { fn menu_row_text(r: &misaligned::actions::HumanMenuRow) -> String { r.display_text() } + +#[cfg(test)] +mod first_think_escape_tests { + use super::mode_control_hint; + use misaligned::work_grid::MachineMode; + + #[test] + fn focused_think_names_work_as_the_one_press_exit() { + assert_eq!( + mode_control_hint(Some(MachineMode::Think)), + ("1 WORK ← EXIT · 2 THINK · 3 LIE", true) + ); + assert!(!mode_control_hint(Some(MachineMode::Work)).1); + assert!(!mode_control_hint(Some(MachineMode::Lie)).1); + assert!(!mode_control_hint(None).1); + } +} diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index 6516e56c..7adfa62a 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -43,7 +43,7 @@ is sim or frontend state, never an environment variable. | Variable | Surface | Values | Effect | |---|---|---|---| -| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `floor-lights-close`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `thoughtflow`, `thoughtflow-wide`, `signal`, `ears`, `ears-digital`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie`, `opening`, `opening-digital`, `hover-menu`, `menu`, `worklight`, `worklightoff`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `hall` / `hall-material` frame all six Foundation rows in the flat/material views with Eyes and one staged owned expansion; `floor-lights-close` uses the same hall staging but frames the recessed aisle fixtures beside the core; `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `thoughtflow` / `thoughtflow-wide` stage the THINK fluid (slugs, meniscus, filament snap) at close and wide framing; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` / `ears-digital` stage the same hearing-feed coverage in material dark mass or DIGITAL local-pressure language; `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `produce-think` / `draw-lie` capture sim-authored Thought/crimson production or the physical crimson transfer into a staged LIE well; `opening` / `opening-digital` freeze the untouched tick-zero material or DIGITAL view; `hover-menu` captures the bare WORK/THINK/LIE line beside the server via reticule focus (no pointer); `menu` opens the context menu on the tapped environmental monitor; `worklight` / `worklightoff` pair the dev work light on and off; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | +| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `floor-lights-close`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `thoughtflow`, `thoughtflow-wide`, `signal`, `ears`, `ears-digital`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie`, `opening`, `opening-digital`, `hover-menu`, `first-think`, `menu`, `worklight`, `worklightoff`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `hall` / `hall-material` frame all six Foundation rows in the flat/material views with Eyes and one staged owned expansion; `floor-lights-close` uses the same hall staging but frames the recessed aisle fixtures beside the core; `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `thoughtflow` / `thoughtflow-wide` stage the THINK fluid (slugs, meniscus, filament snap) at close and wide framing; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` / `ears-digital` stage the same hearing-feed coverage in material dark mass or DIGITAL local-pressure language; `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `produce-think` / `draw-lie` capture sim-authored Thought/crimson production or the physical crimson transfer into a staged LIE well; `opening` / `opening-digital` freeze the untouched tick-zero material or DIGITAL view; `hover-menu` captures the resting WORK/THINK/LIE line beside the server via reticule focus (no pointer), while `first-think` stages the opening Thought route and captures amber `1 WORK` beside current THINK as the panic exit; `menu` opens the context menu on the tapped environmental monitor; `worklight` / `worklightoff` pair the dev work light on and off; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | | `MISALIGNED_SHOT` | `misaligned-assets` | `dead`, `foreign`, `idle`, `busy`, `core`, canonical modes `work`, `think`, `lie` plus the legacy screenshot aliases `dayjob` (= work), `research`/`operations`/`ops` (= think), `conceal` (= lie) — each optionally suffixed `_ladder`/`_ring`/`_wash` — `lineup[_