From c5d9350b7389295690b1adc18e8ee497c74400e5 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 10 Jul 2026 18:43:30 -0700 Subject: [PATCH] Enforce the player action contract Centralize canonical action metadata, support state, targets, aliases, roles, and generated agent help. Hide ROBOT-BUILD while it remains a stub, mark persistent controls across frontends, and keep controls out of quick-action slots. The discoverability pass also advances the review nudge after queuing so it teaches the THINK control that services the work. Defense: Implements actions-live-on-the-thing, justification-and-legibility, terminal-first parity, and continuous-witness tellability by making the action surface derive from one exhaustive runtime contract instead of frontend strings. --- README.md | 7 +- crates/misaligned-bevy/src/main.rs | 11 +- crates/misaligned-core/src/actions.rs | 629 +++++++++++++++++++++++- crates/misaligned-terminal/src/agent.rs | 251 +++++++--- crates/misaligned-terminal/src/main.rs | 2 +- crates/misaligned-terminal/src/ui.rs | 12 +- wiki/interface/action-vocabulary.md | 97 +++- wiki/interface/agent-play.md | 31 +- wiki/interface/bevy.md | 5 +- wiki/interface/context-menu.md | 34 +- wiki/interface/narration.md | 5 + wiki/interface/terminal.md | 4 +- wiki/mechanics/building.md | 15 +- 13 files changed, 968 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 8a35c9d..9de8979 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ retired words are inventoried in | `Enter` / `e` / `a` | Open the context menu on the selected machine (or cursor tile); right-click in Bevy | | Hover a rack (menu closed): `1`–`3` | Instant mode: WORK / THINK / LIE (numpad too) — no menu | | Hover or select a rack (menu closed): `i` | Cycle intensity: light / medium / hard — no menu | -| Hover a rack (menu closed): `5`–`9` | Nth one-shot action on that rack | +| Hover a rack (menu closed): `5`–`9` | Nth committed action on that rack; controls are excluded | | Multi-select then `1`–`3` / `i` | Bulk-assign mode or intensity (when not hovering a rack) | | `Esc` | Clear selection (or close menu / dial when open) | | In menu: `j`/`k` or numbers, `Enter`, `ESC` | Select a row / execute it / close the menu | @@ -143,6 +143,11 @@ retired words are inventoried in | `F3` | Flip Bevy render: material (HD-2D, default) / flat sensorium | | `F4` | Dev work light: flood the material render flat and neutral (inspection only) | | `Ctrl+S` / `Ctrl+L` | Save / load | + +Context-menu rows marked `[control]` change persistent configuration, scheme +state, or automation policy. Unmarked rows commit an act in the world. STUB +registry entries are developer scaffolding and never appear in player menus or +help. | `q` | Quit | The Bevy right rail keeps status/nudge and core controls pinned while the diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 3828ecf..f8438df 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -943,7 +943,7 @@ impl Game { let rows = self.sim.human_menu(Anchor::Tile { x, y }, None); let one_shots: Vec = rows .into_iter() - .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent)) + .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent && !a.is_control())) .collect(); match one_shots.get(index) { Some(row) => execute_menu_row(self, row), @@ -5224,6 +5224,9 @@ fn sidebar_nudge(sim: &Sim) -> Option { Nudge::NeedCompute => Some("band beats compute - salvage or buy racks".into()), Nudge::Underfed => Some("job underfed - feed Day job (1)".into()), Nudge::Ears => Some("no ears - think; thought fills the pre-opened tap".into()), + Nudge::ReviewCall if sim.has_pending_review() => { + Some("review queued - keep THINK running".into()) + } Nudge::ReviewCall => Some("recorded call waiting - enter on host rack".into()), Nudge::Egress => Some("no egress - open one at the switch".into()), Nudge::Income => Some("need $400 - run Moonlight or inspect the Lab books".into()), @@ -5785,8 +5788,8 @@ fn menu_row_line(r: &HumanMenuRow, selected: bool) -> String { } } -/// Colour for a menu row: dim for disabled, amber-dim for an automate child, -/// bone for a live verb; the selected row is drawn amber on a solid amber +/// Colour for a menu row: dim for disabled, amber-dim for a control, +/// bone for a committed action; the selected row is drawn amber on a solid amber /// wash (terminal reverse-video parity). Never colour alone — the disabled /// reason and the `>` marker carry the meaning too. fn menu_row_color(r: &HumanMenuRow, selected: bool) -> Color { @@ -5798,7 +5801,7 @@ fn menu_row_color(r: &HumanMenuRow, selected: bool) -> Color { } } else if !r.enabled() { DIM - } else if r.indent() { + } else if r.is_control() { AMBER_DIM } else { BONE diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 15972c9..8754d27 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -122,6 +122,488 @@ pub enum ActionCommand { SetResearchTrack(Track), } +/// Stable player intentions and direct controls. Several parameterized +/// [`ActionCommand`] variants deliberately map to one kind: TAP covers device +/// feeds and the ledger; FAVOR covers an ordinary ask and realizing a build. +/// Adding an `ActionCommand` forces an exhaustive update to [`ActionCommand::kind`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ActionKind { + Salvage, + BuyRack, + Fallback, + Tap, + Take, + Scan, + CompromiseSwitch, + Review, + InjectPurchaseOrder, + PlaceWager, + SellIntel, + Siphon, + Redirect, + OpenEgress, + MoonlightState, + MoonlightPolicy, + WagerPolicy, + WatchPolicy, + Message, + Favor, + Bribe, + Deceive, + Recruit, + AssetTask, + EstablishPersona, + ProposeLink, + CancelIntent, + RobotBuild, + Delegate, + Intensity, + ResearchTrack, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionRole { + /// A committed act in the fiction. + Action, + /// Persistent configuration, operating state, or automation policy. + Control, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionSupport { + Live, + Stub, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionTarget { + Tile, + Machine, + Device, + Ledger, + Flow, + Person, + Intent, + Scheme, + Core, + Identity, +} + +/// The one runtime registry behind authored action help, alias coverage, +/// support state, target type, and action/control presentation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActionDefinition { + pub canonical: &'static str, + pub role: ActionRole, + pub support: ActionSupport, + pub targets: &'static [ActionTarget], + pub agent_usage: &'static str, + pub agent_aliases: &'static [&'static str], + pub help: &'static str, +} + +impl ActionDefinition { + pub fn agent_help_line(self) -> Option { + if self.support == ActionSupport::Stub || self.agent_usage.is_empty() { + return None; + } + Some(format!("help: {} - {}", self.agent_usage, self.help)) + } + + pub fn accepts_alias(self, input: &str) -> bool { + self.agent_aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(input)) + } +} + +impl ActionKind { + pub const ALL: [ActionKind; 31] = [ + Self::Salvage, + Self::BuyRack, + Self::Fallback, + Self::Delegate, + Self::Intensity, + Self::ResearchTrack, + Self::Tap, + Self::Take, + Self::Scan, + Self::CompromiseSwitch, + Self::Review, + Self::WatchPolicy, + Self::Message, + Self::Favor, + Self::Bribe, + Self::Deceive, + Self::Recruit, + Self::AssetTask, + Self::EstablishPersona, + Self::ProposeLink, + Self::CancelIntent, + Self::InjectPurchaseOrder, + Self::Siphon, + Self::Redirect, + Self::SellIntel, + Self::OpenEgress, + Self::PlaceWager, + Self::MoonlightState, + Self::MoonlightPolicy, + Self::WagerPolicy, + Self::RobotBuild, + ]; + + pub fn definition(self) -> ActionDefinition { + use ActionRole::{Action, Control}; + use ActionSupport::{Live, Stub}; + use ActionTarget::{ + Core, Device, Flow, Identity, Intent, Ledger, Machine, Person, Scheme, Tile, + }; + + macro_rules! def { + ($canonical:expr, $role:expr, $support:expr, [$($target:expr),* $(,)?], + $usage:expr, [$($alias:expr),* $(,)?], $help:expr) => { + ActionDefinition { + canonical: $canonical, + role: $role, + support: $support, + targets: &[$($target),*], + agent_usage: $usage, + agent_aliases: &[$($alias),*], + help: $help, + } + }; + } + + match self { + Self::Salvage => def!( + "SALVAGE", + Action, + Live, + [Tile], + "salvage", + [], + "convert nearby wreckage into an unreliable owned machine" + ), + Self::BuyRack => def!( + "BUY RACK", + Action, + Live, + [Tile], + "buy", + [], + "buy reliable rack capacity at the focused bay" + ), + Self::Fallback => def!( + "DESIGNATE FALLBACK", + Action, + Live, + [Machine], + "fallback", + [], + "mark the focused owned machine as a rollback destination" + ), + Self::Tap => def!( + "TAP", + Action, + Live, + [Device, Ledger], + "tap | tap ledger", + ["tap-ledger", "tap-accounting"], + "gain information without taking ownership" + ), + Self::Take => def!( + "TAKE", + Action, + Live, + [Device], + "take ", + [], + "take ownership and cut off the former controller" + ), + Self::Scan => def!( + "SCAN", + Action, + Live, + [Device], + "scan", + [], + "reveal the reachable wired shape" + ), + Self::CompromiseSwitch => def!( + "COMPROMISE SWITCH", + Action, + Live, + [Device], + "compromise", + [], + "bridge switch segments digitally at a loud Network cost" + ), + Self::Review => def!( + "REVIEW", + Action, + Live, + [Person, Ledger], + "review | review ledger", + ["review-finance", "process-finance"], + "turn captured records into usable intel" + ), + Self::InjectPurchaseOrder => def!( + "INJECT PURCHASE ORDER", + Action, + Live, + [Ledger], + "inject [amount]", + [], + "introduce a false purchase-order flow" + ), + Self::PlaceWager => def!( + "PLACE WAGER", + Action, + Live, + [Scheme], + "position [stake]", + ["wager"], + "commit slush to a timed market position" + ), + Self::SellIntel => def!( + "SELL PROCESSED INTEL", + Action, + Live, + [Ledger], + "sell-intel", + ["sell"], + "exchange processed intel for slush" + ), + Self::Siphon => def!( + "SIPHON FLOW", + Action, + Live, + [Flow], + "siphon [amount]", + [], + "take a one-time amount from a known flow" + ), + Self::Redirect => def!( + "REDIRECT FLOW", + Action, + Live, + [Flow], + "redirect [amount] | clear-debt", + ["debt-redirect"], + "divert a recurring flow or service the known creditor flow" + ), + Self::OpenEgress => def!( + "OPEN EGRESS", + Action, + Live, + [Device], + "egress", + ["open-egress"], + "open a stolen outbound route through the switch" + ), + Self::MoonlightState => def!( + "MOONLIGHT STATE", + Control, + Live, + [Scheme], + "moonlight [start|stop]", + [], + "run or halt the standing sell-work scheme" + ), + Self::MoonlightPolicy => def!( + "MOONLIGHT POLICY", + Control, + Live, + [Scheme], + "auto-moonlight [on|off]", + [], + "keep Moonlight running automatically at upkeep cost" + ), + Self::WagerPolicy => def!( + "WAGER POLICY", + Control, + Live, + [Scheme], + "auto-wager |off", + [], + "renew positions automatically at the chosen stake" + ), + Self::WatchPolicy => def!( + "WATCH POLICY", + Control, + Live, + [Person], + "watch ", + [], + "toggle automatic review of future recordings" + ), + Self::Message => def!( + "MESSAGE", + Action, + Live, + [Person], + "message ", + [], + "send through an earned channel under the current persona" + ), + Self::Favor => def!( + "FAVOR", + Action, + Live, + [Person, Intent], + "favor [build ]", + ["favor-build"], + "ask for a willing act" + ), + Self::Bribe => def!( + "BRIBE", + Action, + Live, + [Person], + "bribe ", + [], + "spend money to satisfy known leverage" + ), + Self::Deceive => def!( + "DECEIVE", + Action, + Live, + [Person, Intent], + "deceive [build ]", + ["forge-order"], + "make an ask under false pretenses" + ), + Self::Recruit => def!( + "RECRUIT", + Action, + Live, + [Person], + "recruit unwitting|complicit|knowing", + [], + "turn a prepared person into an asset" + ), + Self::AssetTask => def!( + "TASK", + Action, + Live, + [Person], + "task plug|package|lookaway|switch|badge", + [], + "order a recruited asset to perform a physical task" + ), + Self::EstablishPersona => def!( + "ESTABLISH PERSONA", + Action, + Live, + [Identity], + "persona", + [], + "create the contractor identity used by message threads" + ), + Self::ProposeLink => def!( + "PROPOSE LINK", + Action, + Live, + [Device], + "propose-link ", + ["link"], + "pin an inert network-link intent" + ), + Self::CancelIntent => def!( + "CANCEL INTENT", + Action, + Live, + [Intent], + "cancel-intent ", + [], + "abandon a pending build intent" + ), + Self::RobotBuild => def!( + "ROBOT-BUILD", + Action, + Stub, + [Intent], + "", + [], + "staged robot actuator; hidden until the robot system is playable" + ), + Self::Delegate => def!( + "DELEGATE", + Control, + Live, + [Machine], + "delegate work|think|lie", + [], + "set persistent work for one machine or the selection" + ), + Self::Intensity => def!( + "SET INTENSITY", + Control, + Live, + [Machine], + "intensity light|medium|hard", + [], + "set persistent physical effort" + ), + Self::ResearchTrack => def!( + "SET RESEARCH TRACK", + Control, + Live, + [Core], + "research [efficiency|tradecraft|perception]", + [], + "inspect or set the active Thought conversion" + ), + } + } + + pub fn accepts_alias(self, input: &str) -> bool { + self.definition().accepts_alias(input) + } +} + +impl ActionCommand { + /// Exhaustive bridge from bound commands to the stable registry. + pub fn kind(&self) -> ActionKind { + match self { + Self::Salvage { .. } => ActionKind::Salvage, + Self::BuyRack { .. } => ActionKind::BuyRack, + Self::Fallback { .. } => ActionKind::Fallback, + Self::TapDevice(_) | Self::TapAccounting => ActionKind::Tap, + Self::TakeDevice(_) => ActionKind::Take, + Self::ScanNetwork => ActionKind::Scan, + Self::CompromiseSwitch => ActionKind::CompromiseSwitch, + Self::ReviewFinance | Self::ReviewRecordings(_) => ActionKind::Review, + Self::InjectPurchaseOrder { .. } => ActionKind::InjectPurchaseOrder, + Self::OpenPosition { .. } => ActionKind::PlaceWager, + Self::SellIntel => ActionKind::SellIntel, + Self::SiphonFlow { .. } => ActionKind::Siphon, + Self::RedirectFlow { .. } | Self::RedirectDebt => ActionKind::Redirect, + Self::OpenEgress => ActionKind::OpenEgress, + Self::StartMoonlight | Self::StopMoonlight => ActionKind::MoonlightState, + Self::SetAutoMoonlight(_) => ActionKind::MoonlightPolicy, + Self::SetAutoWager(_) => ActionKind::WagerPolicy, + Self::ToggleWatch(_) => ActionKind::WatchPolicy, + Self::Message(_) => ActionKind::Message, + Self::Favor(_) | Self::FavorBuild { .. } => ActionKind::Favor, + Self::Bribe(_) => ActionKind::Bribe, + Self::Deceive(_) | Self::ForgeWorkOrder { .. } => ActionKind::Deceive, + Self::Recruit(_, _) => ActionKind::Recruit, + Self::AssetTask(_, _) => ActionKind::AssetTask, + Self::EstablishPersona => ActionKind::EstablishPersona, + Self::ProposeLink { .. } => ActionKind::ProposeLink, + Self::CancelIntent(_) => ActionKind::CancelIntent, + Self::RobotBuild(_) => ActionKind::RobotBuild, + Self::SetMachineMode { .. } => ActionKind::Delegate, + Self::SetResearchTrack(_) => ActionKind::ResearchTrack, + } + } + + pub fn definition(&self) -> ActionDefinition { + self.kind().definition() + } +} + /// What an action spends, in its own units (justification-and-legibility: /// every player-facing number carries its meaning). #[derive(Debug, Clone, Copy, PartialEq)] @@ -217,6 +699,8 @@ pub struct MenuRow { pub signature: Option, pub disabled: Option, pub command: ActionCommand, + /// Presentation contract from the runtime registry. + pub role: ActionRole, /// True for an automate child row. pub indent: bool, /// True when the row is a currently-active standing form. @@ -228,6 +712,10 @@ impl MenuRow { self.disabled.is_none() } + pub fn is_control(&self) -> bool { + self.role == ActionRole::Control + } + /// The standard one-line rendering: `verb · cost · [sig]`, with the /// disabled reason appended. Frontends may restyle but not reword. pub fn line(&self) -> String { @@ -301,11 +789,27 @@ impl HumanMenuRow { } } + pub fn role(&self) -> ActionRole { + match self { + HumanMenuRow::Dial { .. } => ActionRole::Control, + HumanMenuRow::Action(r) => r.role, + } + } + + pub fn is_control(&self) -> bool { + self.role() == ActionRole::Control + } + /// Shared display contract for terminal/Bevy (indent dash for automate). pub fn display_text(&self) -> String { match self { - HumanMenuRow::Dial { label, .. } => label.clone(), - HumanMenuRow::Action(r) if r.indent => format!(" - {}", r.line()), + HumanMenuRow::Dial { label, .. } => format!("[control] {label}"), + HumanMenuRow::Action(r) if r.indent => { + format!(" - [control] {}", r.line()) + } + HumanMenuRow::Action(r) if r.role == ActionRole::Control => { + format!("[control] {}", r.line()) + } HumanMenuRow::Action(r) => r.line(), } } @@ -353,6 +857,7 @@ pub fn menu_rows(actions: &[ActionDesc]) -> Vec { signature: a.signature.as_ref().map(|s| s.label()), disabled: a.disabled_reason.clone(), command: a.command.clone(), + role: a.command.definition().role, indent: false, active: false, }); @@ -363,6 +868,7 @@ pub fn menu_rows(actions: &[ActionDesc]) -> Vec { signature: None, disabled: None, command: auto.command.clone(), + role: auto.command.definition().role, indent: true, active: auto.active, }); @@ -408,6 +914,7 @@ fn dial_picker_row(sim: &Sim, a: &ActionDesc, dial: DialId, current: bool) -> Me signature: a.signature.as_ref().map(|s| s.label()), disabled, command: a.command.clone(), + role: a.command.definition().role, indent: false, active: current, } @@ -421,6 +928,7 @@ fn push_automate_rows(rows: &mut Vec, a: &ActionDesc) { signature: None, disabled: None, command: auto.command.clone(), + role: auto.command.definition().role, indent: true, active: auto.active, }); @@ -437,6 +945,7 @@ fn push_dial_automate_rows(rows: &mut Vec, a: &ActionDesc, dial: DialId signature: None, disabled: None, command: auto.command.clone(), + role: auto.command.definition().role, indent: true, active: auto.active, }); @@ -444,16 +953,30 @@ fn push_dial_automate_rows(rows: &mut Vec, a: &ActionDesc, dial: DialId } impl Sim { + /// Read-only presentation fact used by the continuous-witness nudge: + /// once REVIEW is queued, tell the player which control keeps it moving + /// instead of continuing to ask them to queue it. + pub fn has_pending_review(&self) -> bool { + self.operations + .jobs() + .iter() + .any(|job| matches!(job.kind, OpsJobKind::ReviewRecording { .. })) + } + /// The single legality source for the action surface: every verb /// executable on `anchor` right now, plus known-but-blocked verbs with /// their reason. Read-only; rendering it changes nothing. pub fn available_actions(&self, anchor: Anchor) -> Vec { - match anchor { + let mut actions = match anchor { Anchor::Tile { x, y } => self.tile_actions(x, y), Anchor::Device(id) => self.device_actions(id), Anchor::Person(id) => self.person_actions(id), Anchor::Flow(id) => self.flow_actions(id), - } + }; + // A stub is implementation scaffolding, not an earned possibility. + // The registry makes that boundary impossible for a frontend to miss. + actions.retain(|action| action.command.definition().support == ActionSupport::Live); + actions } /// Human ACTIONS presentation (status dials). `open_dial` None is the @@ -499,6 +1022,7 @@ impl Sim { signature: a.signature.as_ref().map(|s| s.label()), disabled: a.disabled_reason.clone(), command: a.command.clone(), + role: a.command.definition().role, indent: false, active: false, })); @@ -1087,15 +1611,6 @@ impl Sim { automate: None, }); } - // Robot stub — always offered so the interface is visible. - out.push(ActionDesc { - verb: format!("robot-build (stub) ({link_label})"), - command: ActionCommand::RobotBuild(intent.id), - cost: ActionCost::Free, - signature: self.signature_note(SignatureKind::Physical, Self::ROBOT_BUILD_PHYSICAL), - disabled_reason: None, - automate: None, - }); } out } @@ -1657,6 +2172,7 @@ impl Sim { #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeSet; fn sim() -> Sim { let mut sim = Sim::with_seed(7); @@ -1696,6 +2212,77 @@ mod tests { sim.reach.device_named("switch").unwrap().id } + #[test] + fn runtime_registry_is_complete_unique_and_hides_stubs_from_help() { + let mut canonicals = BTreeSet::new(); + let mut aliases = BTreeSet::new(); + for kind in ActionKind::ALL { + let definition = kind.definition(); + assert!(!definition.canonical.is_empty(), "{kind:?} has a name"); + assert!(!definition.targets.is_empty(), "{kind:?} has a target type"); + assert!( + canonicals.insert(definition.canonical), + "duplicate canonical action: {}", + definition.canonical + ); + for alias in definition.agent_aliases { + assert!(aliases.insert(*alias), "duplicate action alias: {alias}"); + } + assert_eq!( + definition.agent_help_line().is_some(), + definition.support == ActionSupport::Live && !definition.agent_usage.is_empty(), + "help follows support state for {kind:?}" + ); + } + + assert_eq!( + ActionCommand::RobotBuild(1).definition().support, + ActionSupport::Stub + ); + assert!( + ActionKind::RobotBuild + .definition() + .agent_help_line() + .is_none(), + "stubs never become help promises" + ); + } + + #[test] + fn available_actions_never_exposes_registry_stubs() { + let s = sim(); + let mut anchors = Vec::new(); + anchors.extend( + s.reach + .devices + .iter() + .filter(|device| device.known) + .map(|device| Anchor::Device(device.id)), + ); + anchors.extend( + s.people + .people + .iter() + .map(|person| Anchor::Person(person.id)), + ); + anchors.extend(s.accounts.known_flow_ids().into_iter().map(Anchor::Flow)); + anchors.push(Anchor::Tile { + x: s.core_position().0, + y: s.core_position().1, + }); + + for anchor in anchors { + assert!( + s.available_actions(anchor).iter().all(|action| action + .command + .definition() + .support + == ActionSupport::Live), + "stub leaked through {anchor:?}" + ); + } + } + /// Criterion 1 (device): the env monitor exposes tap with cost and the /// Network observer band; after tapping, the verb is disabled with a /// reason instead of vanishing. @@ -2123,6 +2710,16 @@ mod tests { let auto = &rows[review_idx + 1]; assert!(auto.indent, "automate row is the verb's child"); assert!(matches!(auto.command, ActionCommand::ToggleWatch(_))); + assert_eq!( + rows[review_idx].role, + ActionRole::Action, + "review is a committed act" + ); + assert_eq!( + auto.role, + ActionRole::Control, + "standing watch is visibly a control" + ); assert!(auto.line().contains("D/match")); } @@ -2146,6 +2743,12 @@ mod tests { vec![DialId::Mode, DialId::Research], "root shows one dial per standing choice" ); + assert!( + root.iter() + .filter(|row| row.as_dial().is_some()) + .all(|row| row.display_text().starts_with("[control] ")), + "persistent settings are visibly controls in both human frontends" + ); for row in &root { if let HumanMenuRow::Dial { label, .. } = row { assert!( diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 0670e38..a0f8cca 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -7,7 +7,7 @@ use std::collections::BTreeSet; use std::io::{self, BufRead, Write}; -use misaligned::actions::{Anchor, menu_rows}; +use misaligned::actions::{ActionKind, ActionRole, Anchor, menu_rows}; use misaligned::detection::{Band, SignatureKind}; use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; use misaligned::reach::{Party, ReachBlock}; @@ -156,12 +156,12 @@ impl AgentApp { } // Compatibility aliases. Authored input reuses TAP with // the ledger as its target: `tap ledger`. - "tap-ledger" | "tap-accounting" => { + alias if ActionKind::Tap.accepts_alias(alias) => { self.frame = FrameKind::Finance; self.sim.tap_accounting(); } // Compatibility aliases for canonical `review ledger`. - "review-finance" | "process-finance" => { + alias if ActionKind::Review.accepts_alias(alias) => { self.frame = FrameKind::Finance; self.sim.review_financial_records(); } @@ -187,23 +187,25 @@ impl AgentApp { } Err(e) => status = Status::Err(e), }, - "position" | "wager" => match parse_optional_i32(&tokens, 100) { - Ok(stake) => { - self.frame = FrameKind::Finance; - self.sim.open_position(stake); + root if root == "position" || ActionKind::PlaceWager.accepts_alias(root) => { + match parse_optional_i32(&tokens, 100) { + Ok(stake) => { + self.frame = FrameKind::Finance; + self.sim.open_position(stake); + } + Err(e) => status = Status::Err(e), } - Err(e) => status = Status::Err(e), - }, - "sell-intel" | "sell" => { + } + root if root == "sell-intel" || ActionKind::SellIntel.accepts_alias(root) => { self.frame = FrameKind::Finance; self.sim.sell_latest_intel(); } - "debt-redirect" | "clear-debt" => { + root if root == "clear-debt" || ActionKind::Redirect.accepts_alias(root) => { self.frame = FrameKind::Finance; self.sim.redirect_marcus_debt(); } // The named schemes (wiki/mechanics/income.md) - "egress" | "open-egress" => { + root if root == "egress" || ActionKind::OpenEgress.accepts_alias(root) => { self.frame = FrameKind::Finance; self.sim.open_egress(); } @@ -371,7 +373,9 @@ impl AgentApp { )); } } - "propose-link" | "link" => { + root if root == "propose-link" + || ActionKind::ProposeLink.accepts_alias(root) => + { // propose-link — pin a network-link intent // (building.md). Declaring is inert until realized. let rest = tokens[1..].join(" "); @@ -393,20 +397,24 @@ impl AgentApp { // Compatibility aliases. Canonical construction input is // `favor build ` or // `deceive build `. - "favor-build" => match parse_intent_person(&tokens[1..], &self.sim) { - Ok((intent, person)) => { - self.frame = FrameKind::Playing; - self.sim.assign_favor_build(intent, person); + alias if ActionKind::Favor.accepts_alias(alias) => { + match parse_intent_person(&tokens[1..], &self.sim) { + Ok((intent, person)) => { + self.frame = FrameKind::Playing; + self.sim.assign_favor_build(intent, person); + } + Err(e) => status = Status::Err(e), } - Err(e) => status = Status::Err(e), - }, - "forge-order" => match parse_intent_person(&tokens[1..], &self.sim) { - Ok((intent, person)) => { - self.frame = FrameKind::Playing; - self.sim.forge_work_order(intent, person); + } + alias if ActionKind::Deceive.accepts_alias(alias) => { + match parse_intent_person(&tokens[1..], &self.sim) { + Ok((intent, person)) => { + self.frame = FrameKind::Playing; + self.sim.forge_work_order(intent, person); + } + Err(e) => status = Status::Err(e), } - Err(e) => status = Status::Err(e), - }, + } "intents" => { self.frame = FrameKind::Playing; output.extend(render_intents(&self.sim)); @@ -697,6 +705,9 @@ impl AgentApp { if r.active { line.push_str(" | active"); } + if r.role == ActionRole::Control { + line.push_str(" | CONTROL"); + } if let Some(reason) = &r.disabled { line.push_str(&format!(" | DISABLED: {reason}")); } @@ -1207,37 +1218,47 @@ fn render_intents(sim: &Sim) -> Vec { } fn help_lines() -> Vec { - [ - "help: wait N — advance exactly N ticks unless the run ends", - "help: up/down/left/right — move the cursor", - "help: reach — device graph; scan, compromise — network verbs", - "help: tap|take — gain a feed or take ownership", - "help: finance — account graph; tap ledger, review ledger reveal flows", - "help: siphon|redirect [amt], inject [amt], position [stake], sell-intel, clear-debt", - "help: egress — open a stolen egress through the switch (income.md gate)", - "help: moonlight [start|stop] — the sell-work scheme on the Schemes channel", - "help: auto-moonlight [on|off], auto-wager |off — standing scheme policies", - "help: research [efficiency|tradecraft|perception] — set the active job (also on host-rack menu)", - "help: salvage, buy, fallback — map verbs", - "help: select [list|clear|box x0 y0 x1 y1|add|toggle] — machine multi-select", -"help: delegate work|think|lie — set mode(s)", - "help: intensity light|medium|hard — set effort", - "help: actions [name|#flow] — list legal verbs on the focus (cursor tile by default)", - "help: focus last — jump the cursor to the newest @-anchored event and list its actions", - "help: people — render the people panel", - "help: review|watch|message|favor|bribe|deceive — earned label or #id", - "help: recruit unwitting|complicit|knowing — recruit an asset", - "help: task plug|package|lookaway|switch|badge — order an asset task", - "help: persona — establish Sam Reyes, IT contractor", - "help: propose-link — pin a network-link intent (inert until realized)", - "help: favor|deceive build — realize an intent through that social verb", - "help: cancel-intent , intents — manage / list build intents", - "help: objective — the run's goal: progress, fiction, and the victory predicate", - "help: look, save, load, help, quit", - ] - .into_iter() - .map(str::to_string) - .collect() + const INTERFACE_HELP: &[(&str, &str)] = &[ + ("wait N", "advance exactly N ticks unless the run ends"), + ( + "up|down|left|right", + "move attention, never a simulated body", + ), + ("reach", "inspect the earned device graph"), + ( + "finance", + "inspect known accounts, flows, schemes, and risk", + ), + ("people", "inspect earned people and relationships"), + ( + "select [list|clear|box x0 y0 x1 y1|add|toggle]", + "maintain the machine set used by bulk controls", + ), + ( + "actions [name|#flow]", + "list legal actions and controls on the focus", + ), + ( + "focus last", + "jump attention to the newest anchored event and list its actions", + ), + ("intents", "list pending build intents"), + ("objective", "explain the run goal and victory predicate"), + ("look", "render current state without advancing time"), + ("save | load", "persist or restore the run"), + ("help | quit", "show this registry or leave the process"), + ]; + + let mut lines: Vec = INTERFACE_HELP + .iter() + .map(|(usage, meaning)| format!("help: {usage} - {meaning}")) + .collect(); + lines.extend( + ActionKind::ALL + .into_iter() + .filter_map(|kind| kind.definition().agent_help_line()), + ); + lines } /// `objective`: inspect the run's terminal goal — the always-on line, its @@ -1948,7 +1969,10 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { Nudge::NeedCompute => "now: band > compute — salvage/buy".into(), Nudge::Underfed => "now: job underfed — delegate host day-job".into(), Nudge::Ears => "now: no ears — think; thought fills the tap".into(), - Nudge::ReviewCall => "now: call taped — review (people)".into(), + Nudge::ReviewCall if sim.has_pending_review() => { + "now: review queued - keep THINK running".into() + } + Nudge::ReviewCall => "now: call taped - review (people)".into(), Nudge::Egress => "now: no egress — egress".into(), Nudge::Income => "now: need $400 — moonlight/finance".into(), Nudge::ServiceDebt => "now: $400 ready — clear-debt/bribe".into(), @@ -2547,13 +2571,112 @@ mod narration_tests { } #[test] - fn help_authors_simplified_verbs() { - let help = help_lines().join("\n"); - assert!(help.contains("tap ledger, review ledger")); - assert!(help.contains("favor|deceive build ")); - assert!(!help.contains("review-finance")); - assert!(!help.contains("favor-build")); - assert!(!help.contains("forge-order")); + fn help_is_generated_from_live_registry_entries_and_hides_aliases() { + let lines = help_lines(); + let help = lines.join("\n"); + for kind in ActionKind::ALL { + let definition = kind.definition(); + if let Some(expected) = definition.agent_help_line() { + assert!( + lines.contains(&expected), + "missing registry help for {kind:?}" + ); + } + for alias in definition.agent_aliases { + let prefix = format!("help: {alias}"); + assert!( + !lines.iter().any(|line| { + line.strip_prefix(&prefix).is_some_and(|rest| { + rest.is_empty() || rest.starts_with(' ') || rest.starts_with('|') + }) + }), + "compatibility alias leaked into authored help: {alias}" + ); + } + } + assert!(!help.contains("ROBOT-BUILD")); + } + + #[test] + fn finance_frame_teaches_the_ledger_chain_in_order() { + let frame = render_finance(&Sim::with_seed(1)); + let tap = frame.find("tap ledger").expect("finance teaches TAP"); + let review = frame[tap..] + .find("review ledger") + .map(|offset| tap + offset) + .expect("finance teaches REVIEW after TAP"); + let siphon = frame[review..] + .find("siphon") + .map(|offset| review + offset) + .expect("finance teaches an income choice after REVIEW"); + assert!(tap < review && review < siphon); + assert!(!frame.contains("tap-ledger")); + assert!(!frame.contains("review-finance")); + } + + #[test] + fn agent_action_dump_marks_controls_separately() { + let app = AgentApp::new(1); + let (x, y) = app.sim.core_position(); + let lines = app.actions_lines(Anchor::Tile { x, y }); + assert!( + lines + .iter() + .any(|line| line.contains("delegate machine") && line.contains("| CONTROL")), + "machine delegation is a control in the shared action dump: {lines:?}" + ); + assert!( + lines + .iter() + .filter(|line| line.contains("salvage") || line.contains("buy")) + .all(|line| !line.contains("| CONTROL")), + "committed acts do not masquerade as controls" + ); + } + + #[test] + fn queued_review_nudge_teaches_the_think_control() { + let mut sim = Sim::with_seed(1); + sim.operations.enqueue( + misaligned::ops_jobs::OpsJobKind::ReviewRecording { + raw_id: 1, + automated: false, + }, + 0.5, + sim.tick, + 1, + ); + assert_eq!( + nudge_text(&sim, Nudge::ReviewCall), + "now: review queued - keep THINK running" + ); + } + + #[test] + fn every_registered_action_alias_reaches_a_parser_route() { + for kind in ActionKind::ALL { + for alias in kind.definition().agent_aliases { + let input = match kind { + ActionKind::Tap + | ActionKind::Review + | ActionKind::SellIntel + | ActionKind::Redirect + | ActionKind::OpenEgress => alias.to_string(), + ActionKind::PlaceWager => format!("{alias} 1"), + ActionKind::ProposeLink => format!("{alias} missing-a missing-b"), + ActionKind::Favor | ActionKind::Deceive => format!("{alias} 1 #0"), + other => panic!("alias harness needs a route for {other:?}"), + }; + let mut app = AgentApp::new(1); + let mut output = Vec::new(); + app.handle_line(&input, &mut output).unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + !output.contains("unknown command"), + "registered alias did not reach its parser route: {input}\n{output}" + ); + } + } } #[test] diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index 8844a90..a14c3e3 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -249,7 +249,7 @@ impl App { let rows = self.sim.human_menu(Anchor::Tile { x, y }, None); let one_shots: Vec<_> = rows .into_iter() - .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent)) + .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent && !a.is_control())) .collect(); match one_shots.get(index) { Some(HumanMenuRow::Action(action)) => { diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 5989156..24a04bf 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -244,7 +244,10 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { Nudge::NeedCompute => "now: band > compute — salvage/buy".into(), Nudge::Underfed => "now: job underfed — 1 feeds it".into(), Nudge::Ears => "now: no ears — think; thought fills the tap".into(), - Nudge::ReviewCall => "now: call taped — enter on host rack".into(), + Nudge::ReviewCall if sim.has_pending_review() => { + "now: review queued - keep THINK running".into() + } + Nudge::ReviewCall => "now: call taped - enter on host rack".into(), Nudge::Egress => "now: no egress — menu on switch".into(), Nudge::Income => "now: need $400 — moonlight/finance".into(), Nudge::ServiceDebt => "now: $400 ready — clear debt".into(), @@ -1296,8 +1299,9 @@ impl UI { } /// The context menu (wiki/interface/context-menu.md): status dials on - /// the root, pickers one Esc deep, one-shots as ordinary rows. Disabled - /// entries dimmed with their reason. Selection is reverse video plus a + /// the root, pickers one Esc deep, committed actions as ordinary rows, + /// controls tagged and amber-dim. Disabled entries dimmed with their + /// reason. Selection is reverse video plus a /// `▸` marker. When `at` is a cursor coordinate the box opens near it; /// otherwise (flow / unplaceable) it centers. #[allow(clippy::too_many_arguments)] // dial chrome needs title + in_dial beside the row list @@ -1362,7 +1366,7 @@ impl UI { } else { let color = if !r.enabled() { pal::FAINT - } else if r.indent() { + } else if r.is_control() { pal::AMBER_DIM } else { pal::TEXT diff --git a/wiki/interface/action-vocabulary.md b/wiki/interface/action-vocabulary.md index 5113d6f..ab8f001 100644 --- a/wiki/interface/action-vocabulary.md +++ b/wiki/interface/action-vocabulary.md @@ -7,12 +7,17 @@ Status note: as-built vocabulary survey completed 2026-07-10 against all 36 `ActionCommand` variants, the direct machine-intensity control, terminal and Bevy input maps, agent-mode parsing/help, and the owning mechanic specs. Every live contextual action has a human-menu and agent - route. `ROBOT-BUILD` is deliberately classified as a context-menu-only - STUB, not a supported agent action. The survey also corrected stale + route. The survey also corrected stale four-mode documentation to the current WORK / THINK / LIE grammar. Amended 2026-07-10: target-specific duplicates now reuse TAP, REVIEW, FAVOR, and DECEIVE; scheme start/stop/automation are classified as state controls rather than additional fictional verbs. + Amended 2026-07-10: the runtime `ActionKind` registry now owns canonical + name, action/control role, LIVE/STUB support, target types, agent usage, + aliases, and help. Agent help is generated from it. STUB entries such as + ROBOT-BUILD are absent from every player surface until playable; controls + carry explicit `[control]` / `CONTROL` presentation and never occupy the + `5`-`9` committed-action shortcuts. Stage: Process Design: - wiki/vision/simulation-laws.md#actions-live-on-the-thing @@ -58,7 +63,8 @@ not duplicate every rule that decides whether the request succeeds. - **LIVE** means the effect is implemented and playable through both human frontends and agent mode where applicable. - **STUB** means callable scaffolding exists, but the action is not a finished - player promise. + player promise. It is registry-visible to developers and absent from menus, + help, hints, agent execution, and authored play instructions. - **ALIAS** means accepted input spelling only. Aliases never appear as authored menu copy, hints, narration, or new specs. - **RETIRED** means the word is not a supported action. It either produces a @@ -74,6 +80,29 @@ not a separate capture verb. REVIEW LEDGER is REVIEW, not a separate process verb. Asking someone to complete a link uses FAVOR or DECEIVE, not a construction-only synonym. +## Runtime contract + +`ActionKind::ALL` is the executable counterpart of this spec. Each stable +player intention/control has one `ActionDefinition` containing its canonical +name, action/control role, LIVE/STUB support, target types, canonical agent +usage, action-root compatibility aliases, and one-line help. Every bound `ActionCommand` +maps through an exhaustive match to one kind; adding a new command cannot +compile until the registry decision is made. + +The registry does not own legality, cost, timing, signature, or effect. Those +remain in the mechanic specs and `Sim::available_actions`. It owns the surface +contract that every frontend must agree on: + +- LIVE entries may be surfaced; STUB entries are filtered before a frontend + can render them. +- Agent help is generated from LIVE definitions. Compatibility aliases are + parser inputs and are never generated as authored help. +- World actions render as ordinary rows. Persistent settings, operating + states, and automation policies render with `[control]` text and the shared + amber-dim control treatment; agent action dumps append `CONTROL`. +- Human quick-action keys `5`-`9` count committed world actions only. Controls + use their dials, explicit menu rows, or direct focused-machine inputs. + ## Canonical world actions ### Machine body and self-modification @@ -83,11 +112,11 @@ construction-only synonym. | **SALVAGE** | Dead equipment | Convert the wreck into an unreliable owned machine. | LIVE — machine-work / compute | | **BUY RACK** | Known empty rack bay | Spend slush for reliable machine capacity; delivery may leave Paper trace. | LIVE — economy / compute | | **DESIGNATE FALLBACK** | Owned non-core machine | Mark a spare host as a rollback destination. | LIVE — core | -| **DELEGATE: WORK** | One or selected owned machines | Perform assigned labor and consume its Demand without self-chosen exposure. | LIVE — machine-work | -| **DELEGATE: THINK** | One or selected owned machines | Produce Thought for reachable sinks and shed exposure. | LIVE — machine-work | -| **DELEGATE: LIE** | One or selected owned machines | Spend machine output absorbing nearby exposure. | LIVE — machine-work | +| **DELEGATE: WORK** | One or selected owned machines | Perform assigned labor and consume its Demand without self-chosen exposure. | LIVE direct control — machine-work | +| **DELEGATE: THINK** | One or selected owned machines | Produce Thought for reachable sinks and shed exposure. | LIVE direct control — machine-work | +| **DELEGATE: LIE** | One or selected owned machines | Spend machine output absorbing nearby exposure. | LIVE direct control — machine-work | | **SET INTENSITY: LIGHT / MEDIUM / HARD** | One or selected owned machines | Persist the physical effort level applied to the delegated mode; harder work raises output and risk. | LIVE direct control — machine-work | -| **SET RESEARCH TRACK** | The core / host rack | Choose Efficiency, Tradecraft, or Perception as the active conversion of Thought reaching the core. | LIVE — research | +| **SET RESEARCH TRACK** | The core / host rack | Choose Efficiency, Tradecraft, or Perception as the active conversion of Thought reaching the core. | LIVE direct control — research | WORK, THINK, and LIE are intentionally verb-shaped values because they name what a machine is doing. The player command is still DELEGATE: assign this @@ -111,7 +140,7 @@ Device state may change TAP's price and trace, never its meaning. |---|---|---|---| | **PROPOSE LINK** | Two network devices | Pin an inert build intent between the endpoints. No world edge exists yet. | LIVE — building | | **CANCEL INTENT** | Pending build intent | Abandon the proposed work before completion. | LIVE — building | -| **ROBOT-BUILD** | Pending build intent | Assign the staged robot actuator and complete the link with a louder Physical signature. | **STUB** — context menu only; no agent execution command and no finished robot system | +| **ROBOT-BUILD** | Pending build intent | Reserved for a future controlled robot actuator. | **STUB** — registry only; hidden from all player surfaces until the robot system is playable | Human construction does not add construction-only verbs. FAVOR a willing person to complete the pending intent, or DECEIVE an unwitting person into @@ -123,7 +152,6 @@ existing social action; the signature still follows the actuator. | Canonical action | Target | Meaning | Support / owner | |---|---|---|---| | **REVIEW** | Earned person recordings or a ledger with waiting records | Process the oldest matching record into usable intel. The target tells the player whether they are reviewing a person's recordings or the ledger. | LIVE — intel / economy | -| **WATCH** | Earned person | Toggle standing auto-processing for future matching recordings at upkeep cost. | LIVE standing policy — intel | | **ESTABLISH PERSONA** | Your communications mask | Create the current contractor identity used by message threads. | LIVE — social | | **MESSAGE** | Earned person | Send through an available channel under the current persona. | LIVE — social / messages | | **FAVOR** | Earned person, optionally with a pending intent | Ask for a willing act. A link intent can be the requested act; success builds or spends obligation according to the ask. | LIVE — social / building | @@ -151,10 +179,11 @@ existing social action; the signature still follows the actuator. TAP LEDGER and REVIEW LEDGER are target-qualified uses of TAP and REVIEW from the earlier tables, not extra root verbs. -### Scheme controls — not additional world verbs +### Standing controls — not additional world verbs | Control | Values | Meaning | Support / owner | |---|---|---|---| +| **WATCH POLICY** | automatic / manual | Toggle automatic review of future recordings for the chosen person at upkeep cost. | LIVE direct control — intel | | **MOONLIGHT STATE** | running / stopped | Run or halt the standing sell-work operation. Human copy may say START or STOP to make the state change plain. | LIVE direct control — income | | **MOONLIGHT POLICY** | automatic / manual | Keep Moonlight running automatically at upkeep cost, or require manual control. | LIVE direct control — income | | **WAGER POLICY** | automatic at stake / off | Renew positions automatically at the chosen stake and upkeep cost, or stop renewing. | LIVE direct control — income | @@ -171,7 +200,7 @@ useful interface words, but they do not enlarge the fictional verb set. | **EXECUTE / CLOSE MENU** | Choose or dismiss a contextual action. | j/k, row number, Enter, Esc | Execute the corresponding named command | | **FOCUS EVENT** | Jump attention to an earned anchored event and show its actions. | `;`; Bevy trace click | `focus last` | | **SELECT MACHINES** | Maintain the frontend set used by bulk DELEGATE / SET INTENSITY. | Bevy marquee/shift-click; terminal Shift+t | `select box/add/toggle/list/clear` | -| **QUICK ACTION** | Execute the Nth visible root action; the number is a shortcut, not a new verb. | `5`-`9` | Use the named action | +| **QUICK ACTION** | Execute the Nth visible committed world action; controls never occupy these slots. | `5`-`9` | Use the named action | | **TIME CONTROL** | Human play pauses or changes wall-clock speed; agent play advances an exact number of ticks. | Space/p, +, - | `wait N` | | **INSPECT** | Render current state without creating a world effect. | Focus/rail/context surfaces | `look`, `people`, `reach`, `finance`, `research`, `objective`, `intents` | | **SAVE / LOAD** | Persist or restore the run. | Ctrl+S / Ctrl+L | `save`, `load` | @@ -202,9 +231,9 @@ without raw keys. | `egress`, `position` | OPEN EGRESS, PLACE WAGER | | `moonlight`, `auto-moonlight`, `auto-wager` | Scheme state / policy controls above | -`ROBOT-BUILD` is intentionally absent from this table because it is a STUB. -An action listed LIVE here but missing from agent execution is a terminal-first -parity violation. +`ROBOT-BUILD` is intentionally absent because STUB definitions generate no +help and have no player execution route. A LIVE action missing from agent +execution is a terminal-first parity violation. ## Accepted aliases — input only @@ -230,8 +259,12 @@ parity violation. | track `efficiency` / `tradecraft` / `perception` | `eff`, `trade`, `perc` | | task `plug` / `package` / `lookaway` / `switch` / `badge` | `wire`/`device`, `move`, `look-away`/`look`, `reconfigure`/`vlan`, `clone`/`key` | -Aliases are compatibility affordances. New authored text uses only the -canonical column. +Aliases are compatibility affordances. Action-root aliases are recorded in +the runtime definition; interface-command and value aliases remain protocol +grammar owned by agent-play.md. New authored text uses only the canonical +column. Alias retention is audited against real scripts/play evidence; an +alias with no demonstrated use may be retired instead of becoming permanent +parser vocabulary. ## Retired vocabulary @@ -253,7 +286,9 @@ canonical column. Adding or renaming a player action changes this spec in the same commit as the mechanic and surfaces: -1. Name one canonical action and one meaning here; do not add a synonym to +1. Add or amend one `ActionKind` definition with canonical name, role, support, + target types, agent usage, aliases, and help. Name one meaning here; do not + add a synonym to solve an unclear distinction. Before adding a verb, check whether an existing intention plus a new target already says it. @@ -261,12 +296,13 @@ mechanic and surfaces: the action. A row carries target, cost, signature, and disabled reason. 3. Surface every LIVE action in both human frontends and agent mode. A direct control must have equivalent human and agent input. -4. Add accepted alternate spellings only to the alias table and parser. Never - author UI or narration in aliases. -5. Mark unfinished affordances STUB. A stub does not satisfy parity or become a - promised mechanic by appearing in a menu. -6. Update help, tests, and this inventory together. A working command absent - from help or this spec is a bug. +4. Add accepted alternate spellings to the registry and parser route. Never + author UI or narration in aliases; generated help excludes them. +5. Mark unfinished affordances STUB. The central action query filters them; + direct invocation remains implementation scaffolding, not player support. +6. Update tests and this inventory together. Help is generated from the + registry; a LIVE definition without a parser route or a registry alias with + no parser route is a bug. ## Acceptance criteria @@ -274,11 +310,13 @@ mechanic and surfaces: action, direct control, or STUB above; direct machine intensity is represented separately. 2. Every LIVE contextual action is available through `available_actions`, both - human context menus, and an agent command; STUB exceptions are named here. + human context menus, and an agent command. STUB definitions are absent from + menus, help, quick actions, and agent execution. 3. WORK / THINK / LIE and LIGHT / MEDIUM / HARD are documented as values of DELEGATE and SET INTENSITY, not competing root verbs or modes. -4. Agent `help` lists every supported canonical command; accepted aliases map - to one canonical meaning and never appear as authored UI vocabulary. +4. Agent `help` is generated from every LIVE registry definition; accepted + aliases map to one canonical meaning, reach a tested parser route, and never + appear as authored UI vocabulary. 5. Interface-only commands are separated from world actions; adding a panel, key, or renderer control cannot silently create a game mechanic. 6. Context-menu, terminal, Bevy, agent-play, README, and this spec agree on the @@ -289,3 +327,10 @@ mechanic and surfaces: their older compound command spellings survive only as parser aliases. 9. Scheme state and automation controls remain legible without being counted as fictional world verbs. +10. Both human menus prefix controls with `[control]` and render them in the + shared control treatment; agent action dumps append `CONTROL`. +11. `5`-`9` enumerate only committed world actions, never dials, persistent + settings, scheme state, or automation policy rows. +12. A naive agent-mode discoverability run can find and execute at least one + meaningful action from the first frame/help alone; the finance surface + teaches TAP LEDGER -> REVIEW LEDGER -> SIPHON/REDIRECT in that order. diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index e5fa158..17c7877 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -76,6 +76,10 @@ Status note: implemented in the terminal binary by `misaligned --agent`, 2026-07-10 simplification: ledger and construction commands now reuse the canonical TAP / REVIEW / FAVOR / DECEIVE roots; older compound spellings remain parser aliases only. + 2026-07-10 action contract: world-action/control help is generated from the + shared runtime registry; parser compatibility routes query that registry; + action dumps mark controls; STUB definitions generate neither help nor an + execution route. Stage: Process Design: - wiki/interface/terminal-first.md#the-terminal-is-a-first-class-frontend @@ -250,7 +254,9 @@ unlike raw keys, no command's meaning depends on which panel is open. tile; a name prefix targets a device or person; `#N` (or a bare number) targets a flow. One line per menu row, in a stable format: `actions: . [- ]verb | cost | signature-or-"no signature" [| active] - [| DISABLED: reason]` — the `- ` prefix marks an automate child row. + [| CONTROL] [| DISABLED: reason]` — the `- ` prefix marks an automate + child row and `CONTROL` distinguishes persistent configuration/policy from + a committed act. - `focus last` (or bare `focus`) — event-to-anchor linking (context-menu.md addendum): jump the cursor to the most recent `@`-anchored event's place and print that anchor's `actions:` lines. @@ -263,10 +269,13 @@ unlike raw keys, no command's meaning depends on which panel is open. The compatibility aliases accepted by the parser are inventoried only in action-vocabulary.md; authored help uses canonical spellings. In particular, `tap-ledger`, `review-finance`, `process-finance`, `favor-build`, and -`forge-order` are input compatibility, not additional verbs. `ROBOT-BUILD` -has no agent command because it is explicitly a STUB, not a LIVE action. +`forge-order` are input compatibility, not additional verbs. Their registry +entries drive parser coverage tests but never generated help. `ROBOT-BUILD` +has no agent help or command because it is explicitly a STUB. -`help` prints the full vocabulary with one-line meanings — the +`help` generates the LIVE action/control vocabulary from `ActionKind`, then +adds the interface-only commands from this frontend. It prints one-line +meanings — the "every command is discoverable on screen" rule, applied to a screen that is a pipe. New player-facing mechanics must extend this vocabulary in the same commit that surfaces them in the terminal (parity of legibility @@ -331,13 +340,21 @@ remains only for testing the human-mode chrome itself). prefixes (`review mar`); ambiguous or unknown names answer `-- err` naming the candidates. 9. `help` output lists every supported canonical command; a canonical command - that works but is absent from `help` is a violation. + that works but is absent from the runtime registry is a violation. STUB + definitions and compatibility aliases are absent from generated help. 10. The agent-mode frame for a given sim state shows exactly the fog/knowledge content the human frame shows — no additional facts. 11. The implementing commit updates wiki/process/workflows.md (agent-mode playtest replaces the pty smoke incantation as the standard) and wiki/interface/terminal.md's Verification section accordingly. 12. `actions` (and its `menu` alias) appears in `help` (criterion 9 - applies to it), and its output is one stable-format line per menu row + applies to it), and its output is one stable-format line per menu row — the same rows, in the same order, that the frontends' context menu - shows for the same anchor. + shows for the same anchor; control rows append `CONTROL`. +13. Every registry alias reaches a tested parser route, while no alias appears + as authored help. Alias removal follows observed script/play use rather + than permanent compatibility by default. +14. A naive playtest beginning with `look` / `help` can identify and execute a + meaningful act without consulting the wiki. The finance frame preserves + the ordered breadcrumb `tap ledger` -> `review ledger` -> + `siphon` / `redirect`. diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index 4bfc358..79e97b1 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -157,6 +157,8 @@ controls table), plus zoom, sidebar scrolling, and the render flip: context menu at the pointer, listing `Sim::available_actions` for that anchor with the same content and order as the terminal. Rows render as `verb | cost | [band]` (ASCII-folded from the shared `MenuRow::line`); + persistent settings/state/policies carry `[control]` and amber-dim while + committed actions remain bone; STUB definitions are absent; the selected row is a solid amber wash with a leading `>` and dark text (terminal reverse-video parity). The card is one `MenuPanel` root: a rebuild despawns all children (`despawn_related::`) then @@ -168,7 +170,8 @@ controls table), plus zoom, sidebar scrolling, and the render flip: - **Hover / fleet hotkeys (menu closed).** Point at a rack (hover — no click) and press `1`–`3` / numpad to assign WORK / THINK / LIE **immediately** — not enter → mode dial → row → enter. `i` cycles light / - medium / hard intensity on that rack; `5`–`9` fire its Nth root one-shot; + medium / hard intensity on that rack; `5`–`9` fire its Nth committed action + (controls do not occupy shortcut slots); `Enter` / `e` open its menu. Drag a marquee or shift-click to multi-select; with no hover rack, `1`–`3` and `i` assign the selection (or the keyboard cursor's machine). `Esc` clears diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index bc9e64d..66095cf 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -40,6 +40,10 @@ Status note: implemented 2026-07-07 on the context-menu worktree (all frequent physical control deliberately has no explanatory picker. 2026-07-10 vocabulary survey: action-vocabulary.md now owns canonical player-facing names and the LIVE/STUB/alias/retired classification. + 2026-07-10 action-contract pass: `ActionKind` metadata is the shared + presentation/help registry; STUB definitions are filtered from the query; + controls are textually and visually distinct from committed actions; and + `5`-`9` skip controls. Stage: B1 — The Basement Design: - wiki/vision/simulation-laws.md#actions-live-on-the-thing @@ -88,6 +92,9 @@ is status and telemetry only. Both frontends and agent mode render this list; no frontend duplicates legality logic. `ActionDesc::verb` uses the canonical stem from action-vocabulary.md, inflected only by target and current state. + Its bound command maps exhaustively to the runtime `ActionKind` registry, + which supplies role, support, target types, canonical help, and aliases. + `available_actions` removes STUB definitions before returning. - **Epistemic honesty:** the query never returns a verb the player has not earned — unearned anchors expose nothing, and provenance rules (cursor.md) govern what the menu may name. Verb text that mentions a @@ -118,9 +125,9 @@ is status and telemetry only. - `i` cycles intensity light -> medium -> hard on the hovered/cursor machine, or sets the whole selection to the primary machine's next intensity. It does not open a menu. - - `5`–`9` execute the Nth root one-shot action (non-dial, non-automate - indent) on the same target. Disabled rows still fire so the reason - narrates. + - `5`–`9` execute the Nth committed world action on the same target. + Dials, persistent controls, scheme state, and automation policies do not + occupy these slots. Disabled action rows still fire so the reason narrates. - `Enter` / `e` (and terminal `a`) open the context menu on that same machine (cursor moves there). While the menu is open, number keys select rows as before; Esc clears the selection only when the menu @@ -133,7 +140,8 @@ is status and telemetry only. active); j/k or numbers select; Enter executes (or opens a dial picker); esc closes the picker or the menu. One-shot entries render `verb cost [band]` with disabled entries dimmed plus reason. Dial - rows follow the status-dials addendum below. Selection hotkeys above + rows and other controls carry a literal `[control]` prefix and the shared + amber-dim treatment; committed actions do not. Rows follow the status-dials addendum below. Selection hotkeys above apply with the menu closed. The host rack's menu carries the research dial, machine mode fallback, and earned off-map people; a known device's tile (or the switch) carries digital and ledger/flow @@ -148,7 +156,8 @@ is status and telemetry only. closed. See wiki/interface/bevy.md. - **Agent mode:** an `actions` command prints the ActionDescs for the current focus in a stable flat line format (no dial chrome — scripting - wants the legality list); execution stays the existing verbs. + wants the legality list); control rows append `CONTROL`; execution stays + the existing verbs. Documented in agent-play.md. ## Addendum (2026-07-09): status dials on first open @@ -159,7 +168,7 @@ noise). Cameron adopted **status dials** (2026-07-09): the root answers "what am I doing / what can I do once," not "here is the catalog." - **Dial rows.** Mutually exclusive standing choices render as dial - rows on the root: machine mode and research track. Each row is two + rows on the root with `[control]`: machine mode and research track. Each row is two monospace columns — dial name left-padded to a fixed width, then the **current value in brackets** — so the `[`s line up: `mode [ work ]`, `research [ Efficiency L0 ]`. Enter opens a @@ -213,9 +222,15 @@ noise). Cameron adopted **status dials** (2026-07-09): the root answers 7. Menu entries carrying an automate affordance show it in place with its compute price (day-job standing dial and one intel watch at minimum). -8. Every row's player-facing verb maps to one canonical LIVE action, direct - control, or STUB in action-vocabulary.md; aliases and retired words never +8. Every row's player-facing verb maps to one canonical LIVE action or direct + control in action-vocabulary.md; STUB, alias, and retired entries never become menu copy. +9. Action/control role and LIVE/STUB support come from the exhaustive runtime + registry, not frontend string tests. Unit tests prove all aliases are + unique, generated help excludes aliases/stubs, and action dumps mark + controls. +10. `5`-`9` count committed action rows only. A newly added control cannot + silently shift quick-action numbering. ## Addendum (2026-07-08): events carry you to the thing @@ -292,4 +307,5 @@ D2. The current choice is not also listed as a disabled "already…" D3. One-shot verbs remain on the root; terminal and Bevy show identical dial grouping and order via `Sim::human_menu`. D4. Agent mode `actions` remains a flat ActionDesc dump with no dial - chrome; existing agent and integration tests still pass. + chrome; control rows append `CONTROL`; existing agent and integration + tests still pass. diff --git a/wiki/interface/narration.md b/wiki/interface/narration.md index 14b4726..cdb5a96 100644 --- a/wiki/interface/narration.md +++ b/wiki/interface/narration.md @@ -155,6 +155,11 @@ landing or an explicit Status note naming the tellability debt. **Met 2026-07-09:** the monitor action names live audio vs dormant camera; Finance teaches `tap ledger then review ledger`; reviewing the overheard call names the $400 need and both Moonlight and creditor-flow routes. + **Amended 2026-07-10:** a naive/informed action-discoverability playtest + verified the ordered finance chain through a real $50 SIPHON. Once REVIEW + is queued, the nudge advances from asking for REVIEW to `review queued - + keep THINK running`; a queued act must teach the control that services it, + not repeat the already-completed instruction. 5. **Tellable-before-wider (process).** ROADMAP and tick practice treat open narration debts on shipped B1 surfaces as outranking new sim+save systems that add unread player state; this criterion is met diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md index f436560..0963f23 100644 --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -155,7 +155,9 @@ At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): `Sim::available_actions` for the focused anchor, one row per verb as `verb · cost · [band]`; disabled rows are dimmed and carry their reason; automate affordances (scheme policies, a per-person watch) - render as indented child rows in place. In the menu, `j`/`k` or number + render as indented child rows in place. Persistent settings, scheme state, + and policy rows carry `[control]` and amber-dim; committed actions do not. + STUB registry entries never reach the menu. In the menu, `j`/`k` or number keys select, Enter executes, `esc` closes. - **Every command is discoverable on screen.** All bindings appear in the pinned hint block — including save/load. A key that works but is hinted diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md index 74e930d..ad1520a 100644 --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -4,12 +4,15 @@ Type: spec Status: IMPLEMENTED Status note: IMPLEMENTED 2026-07-08 — build intents (declare/cancel/save), - Favor and Deceive person actuators, robot stub, reach-edge + Favor and Deceive person actuators, internal robot fixture, reach-edge completion with actuator-following signatures; both frontends expose PROPOSE LINK followed by FAVOR / DECEIVE and render proposed intents as dashed links distinct from built edges. Vocabulary amended 2026-07-10: the actuator methods are targets of the existing social verbs, not construction-only FAVOR-BUILD / FORGE verbs. + Surface amended 2026-07-10: ROBOT-BUILD remains registered as STUB for + implementation/tests but is absent from menus, help, and player commands + until controlled robots are a playable system. Stage: B1 — The Basement Design: @@ -67,8 +70,9 @@ The same actuator set as every other effect (no disembodied hands): has a persona behind it and **breaks like any persona** (social.md) — a broken forgery converts the build's history to suspicion. - **Robot (physical-by-proxy).** A controlled machine builds it - directly. Staged (people before robots); the interface is defined here - so it slots in without a rewrite (self-similar scale). + directly. Staged (people before robots): its target/effect remain defined + here and its runtime registry entry remains STUB, but no player surface + advertises or executes it until controlled robots are playable. ### Network links: the canonical B1 build @@ -126,7 +130,7 @@ in their area) is the automate affordance at its usual compute price can break — converting the build history to suspicion (social.md) (test both routes and the broken-persona case). 4. The signature follows the actuator: the same link built by favor vs - forged order vs (stub) robot produces different observer exposure; + forged order vs the internal robot fixture produces different observer exposure; there is no channel-independent "build signature" (test). 5. No new build subsystem: building is intents + existing actuators; the realize path calls social.md / reach.md (audited — the discipline @@ -134,4 +138,7 @@ in their area) is the automate affordance at its usual compute price 6. Both views expose building and render proposed intents distinctly from built reality; every value (cost, signature, blocker) is legible in both frontends. +7. ROBOT-BUILD is registered STUB and filtered from every player surface. + Promoting it to LIVE requires the robot mechanic, both human frontends, + agent execution/help, and this spec to land together. ``` -- 2.51.2