From 6f3fa097ee176c651bf7305cf035f07b9a1a43af Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 08 Jul 2026 05:46:58 +0000 Subject: [PATCH] Actions live on the thing: the context menu (ROADMAP #26) Implement wiki/interface/context-menu.md to all seven acceptance criteria. The action surface moves off the global-key and per-panel button pile onto a menu on the focused anchor, answering Cameron's 2026-07-07 playtest ("hard to play; a context menu rather than a bunch of buttons"). New read-only lib query as the single legality source: src/actions.rs adds `Sim::available_actions(anchor) -> Vec` over tile/device/person/ flow anchors, `menu_rows` (flattens automate affordances into indented child rows), and `Sim::execute_action` (dispatches a chosen command back through the existing command methods). Every descriptor carries verb, bound command, cost, expected signature as the observer band it feeds, and — for a known but illegal verb — a disabled_reason. The query respects fog and provenance: unearned anchors expose nothing and no entry names an unearned fact. Added `Sim::set_standing_policy` and `Leverage::bribe_cost`. Terminal: Enter/`a` opens the menu at the cursor or a panel selection; j/k or 1-9 select, Enter runs (blocked entries narrate why), esc closes. Bevy: right-click/Enter opens a menu card at the pointer with click + keyboard drive. Agent mode: `actions [name|#flow]` prints the descriptors in a stable line format. Anchor verbs demoted off the keys/rail/panel button rows in both frontends; globals (pause, speed, save/load, panel keys, allocation bar) keep their keys. README and in-game hints updated. Defense: DESIGN.md "Actions live on the thing" (adopted 2026-07-07) is the governing law and mandates exactly this surface — one read-only query as the single source of action legality for terminal, Bevy, and agent mode, verbs on the focused anchor with cost and expected signature shown before commit, and the rail reduced to status. "Justification and legibility" is served by each entry carrying its cost and observer band and each blocked entry its reason; "Automation as design language" by the standing-policy and watch automate affordances rendered in place at their compute price; "Presence: the cursor and the senses" by the menu opening where attention is and exposing only earned, provenance-legal facts. No new sim behavior: execution routes through existing commands, so the act-one integration test passes unchanged. Spec Status set to IMPLEMENTED in this commit. Co-Authored-By: Claude Fable 5 --- README.md | 25 +++++++++++++++---------- src/actions.rs | 1212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/person.rs | 21 ++++++++++++++------- src/sim.rs | 12 ++++++++++++ src/bin/bevy.rs | 693 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- wiki/interface/agent-play.md | 15 ++++++++++++++- wiki/interface/bevy.md | 31 +++++++++++++++---------------- wiki/interface/context-menu.md | 19 +++++++++++++------ wiki/interface/terminal.md | 28 +++++++++++++++++++++------- wiki/log/2026-07-08-context-menu.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wiki/log/DEVLOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ wiki/process/ROADMAP.md | 11 ++++++++--- wiki/process/specs.md | 2 +- src/bin/terminal/agent.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/bin/terminal/input.rs | 123 ++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------- src/bin/terminal/mod.rs | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------------------------------------------------------------------- src/bin/terminal/ui.rs | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------- 18 file(s) changed, 2319 insertion(s)(+), 524 deletion(s)(-) diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -97,11 +97,12 @@ | Left click | Move cursor (Bevy) | | `SPACE` / `p` | Pause / resume time | | `+` / `-` | Simulation speed | -| `v` / `c` / `o` | Salvage near cursor / buy rack at cursor / fallback at cursor | -| `r` | Reach panel (`t` tap, `e` splice eyes, `x` take, `n` scan, `c` compromise) | -| `e` | Finance panel (`t` tap ledger, `o` review records, `x` siphon, `r` redirect, `i` inject PO, `p` position, `d` debt redirect) | -| `1`-`4` | Shift compute allocation (day job / conceal / social / research) | -| `t` | People panel | +| `Enter` / `a` | Open the context menu on the cursor tile, or on the open panel's selection (right-click in Bevy) | +| In menu: `j`/`k` or numbers, `Enter`, `ESC` | Select a row / execute it / close the menu | +| `1`-`4` / `shift+1`-`4` | Raise / lower a compute channel (day job / conceal / social / research) | +| `r` | Reach panel (status + selection) | +| `e` | Finance panel (status + selection) | +| `t` | People panel (status + selection) | | `[` / `]` | Zoom (Bevy) | | Mouse wheel over right pane / `PageUp` / `PageDown` / `Home` / `End` | Scroll Bevy sidebar | | `F3` | Toggle Bevy HD-2D material preview | @@ -114,11 +115,12 @@ Parking the cursor on the host rack attends the current day job; moving it away leaves the job on its standing policy. -In the people panel: `w`/`s`, `j`/`k`, or arrows select; `o` reviews the -oldest unprocessed recording about the selected person; `a` toggles a -standing watch; `m` message, `f` favor, `b` bribe, `d` deceive, `r` recruit -(then `u`/`c`/`k` reveal), `g` persona, `1`/`2`/`3`/`4` asset tasks, -`t`/`ESC` close. Both frontends share this key map. +Anchor verbs (salvage, buy, fallback, taps, splices, economy and social +actions, asset tasks) live on the context menu: open it with `Enter`/`a` on +the cursor tile or a panel selection (right-click in Bevy). Entries show +`verb · cost · [band]`; disabled entries are dimmed with a reason, and +automate affordances render as indented child rows. The panels select; +`ESC` closes them. Both frontends share this key map. For agent play, pipe newline-delimited commands into `--agent`; every command returns a plain-text frame and terminates with `-- ok tick: day:` or @@ -131,3 +133,6 @@ Useful agent finance commands include `finance`, `tap-ledger`, `review-finance`, `siphon [amount]`, `redirect [amount]`, `inject [amount]`, `position [stake]`, `sell-intel`, and `clear-debt`. +`actions [name|#flow]` (alias `menu`) lists the context-menu rows for the +cursor tile, a named device or person, or a flow — one stable-format line +per row; see `help` for the full vocabulary. diff --git a/src/actions.rs b/src/actions.rs new file mode 100644 --- /dev/null +++ b/src/actions.rs @@ -0,0 +1,1212 @@ +//! The context-menu action query (wiki/interface/context-menu.md). +//! +//! "Actions live on the thing" (DESIGN.md): the focused anchor — a tile, +//! device, machine, person, resident job, or known flow — exposes its legal +//! verbs through one read-only query, `Sim::available_actions`. Both +//! frontends and agent mode render this list; no frontend duplicates +//! legality logic. Execution routes back through the existing `Sim` +//! commands via `Sim::execute_action` — this module adds descriptors and +//! dispatch, never new sim behavior. +//! +//! Epistemic honesty: the query never returns a verb the player has not +//! earned. Unearned anchors expose nothing; a *known* possibility that is +//! currently illegal is returned with a `disabled_reason` ("no egress +//! channel", "not enough slush"), while unknown possibilities are absent, +//! not grayed. + +use crate::account::AccountFlowId; +use crate::dayjob::JobTarget; +use crate::detection::{Band, SignatureKind, WatchedInput}; +use crate::person::{AssetKnowledge, AssetTask, Knowledge}; +use crate::reach::{Party, ReachBlock, segment_name}; +use crate::sim::{Fog, Sim}; +use crate::tiles::TileType; + +/// What the menu is anchored to. Frontends resolve their focus (cursor +/// tile, panel selection) into one of these; the query owns everything +/// after that. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Anchor { + /// A map tile under the cursor: aggregates the tile's own verbs plus + /// any known device, resident machine/job, and visible person on it. + Tile { x: i32, y: i32 }, + /// A known device in the reach graph (reach panel selection). + Device(u32), + /// A person (people panel selection, or a visible person on a tile). + Person(u8), + /// A known account flow (finance panel selection). + Flow(AccountFlowId), +} + +/// An executable action descriptor: the parameters are already bound, so a +/// frontend passes it back to [`Sim::execute_action`] verbatim. Every +/// variant maps 1:1 onto an existing `Sim` command method. +#[derive(Debug, Clone, PartialEq)] +pub enum ActionCommand { + Salvage { x: i32, y: i32 }, + BuyRack { x: i32, y: i32 }, + Fallback { x: i32, y: i32 }, + TapDevice(u32), + SpliceDevice(u32), + TakeDevice(u32), + ScanNetwork, + CompromiseSwitch, + TapAccounting, + ReviewFinance, + InjectPurchaseOrder { amount: i32 }, + OpenPosition { stake: i32 }, + SellIntel, + SiphonFlow { flow: AccountFlowId, amount: i32 }, + RedirectFlow { flow: AccountFlowId, amount: i32 }, + RedirectDebt, + SetTarget(JobTarget), + SetStandingPolicy(JobTarget), + ReviewRecordings(u8), + ToggleWatch(u8), + Message(u8), + Favor(u8), + Bribe(u8), + Deceive(u8), + Recruit(u8, AssetKnowledge), + AssetTask(u8, AssetTask), + EstablishPersona, +} + +/// What an action spends, in its own units (justification-and-legibility: +/// every player-facing number carries its meaning). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ActionCost { + Free, + /// Social-ops bandwidth (the Social channel's pool). + Ops(f32), + /// Slush money spent. + Slush(i32), + /// Slush money gained (siphon, inject, sale payouts). + Gain(i32), +} + +impl ActionCost { + pub fn label(&self) -> String { + match self { + ActionCost::Free => "free".into(), + ActionCost::Ops(n) => format!("{n:.0} ops"), + ActionCost::Slush(n) => format!("${n}"), + ActionCost::Gain(n) => format!("+${n}"), + } + } +} + +/// The expected signature of a verb, expressed as the observer band it +/// feeds (economy.md precedent: risk shows as an observer band, never a +/// raw probability). +#[derive(Debug, Clone, PartialEq)] +pub struct ExpectedSignature { + pub kind: SignatureKind, + pub size: i32, + /// The watching observer's name (the most-suspicious watcher of this + /// channel when several watch it). + pub observer: String, + /// That observer's current band — what the signature feeds into. + pub band: Band, +} + +impl ExpectedSignature { + pub fn label(&self) -> String { + format!( + "{:?} {} -> {} [{}]", + self.kind, + self.size, + self.observer, + self.band.name() + ) + } +} + +/// The automate affordance carried in place by an automatable verb +/// (DESIGN.md "Automation as design language": every automation shows its +/// price). +#[derive(Debug, Clone, PartialEq)] +pub struct AutomateDesc { + pub verb: String, + pub command: ActionCommand, + /// The running price, already legible ("0.02 ops/t", "-15% attended rate"). + pub cost: String, + /// Whether the standing form is currently active. + pub active: bool, +} + +/// One legal (or known-but-blocked) verb on an anchor. +#[derive(Debug, Clone, PartialEq)] +pub struct ActionDesc { + pub verb: String, + pub command: ActionCommand, + pub cost: ActionCost, + /// Expected signature as the observer band it feeds; `None` when the + /// verb emits nothing. + pub signature: Option, + /// `Some(reason)` when the verb applies to this anchor but is + /// currently illegal. Unknown verbs are absent, never disabled. + pub disabled_reason: Option, + /// Standing-policy form of the same verb, rendered in place. + pub automate: Option, +} + +impl ActionDesc { + pub fn enabled(&self) -> bool { + self.disabled_reason.is_none() + } +} + +/// A flattened, render-ready menu row: automate affordances become +/// indented child rows of their verb so all three surfaces (terminal, +/// Bevy, agent mode) show identical content in identical order. +#[derive(Debug, Clone, PartialEq)] +pub struct MenuRow { + pub label: String, + pub cost: String, + pub signature: Option, + pub disabled: Option, + pub command: ActionCommand, + /// True for an automate child row. + pub indent: bool, + /// True when the row is a currently-active standing form. + pub active: bool, +} + +impl MenuRow { + pub fn enabled(&self) -> bool { + self.disabled.is_none() + } + + /// The standard one-line rendering: `verb · cost · [sig]`, with the + /// disabled reason appended. Frontends may restyle but not reword. + pub fn line(&self) -> String { + let mut s = format!("{} · {}", self.label, self.cost); + if let Some(sig) = &self.signature { + s.push_str(&format!(" · {sig}")); + } + if self.active { + s.push_str(" · on"); + } + if let Some(reason) = &self.disabled { + s.push_str(&format!(" — {reason}")); + } + s + } +} + +/// Flatten descriptors into selectable rows (automate entries in place, +/// indented under their verb). +pub fn menu_rows(actions: &[ActionDesc]) -> Vec { + let mut rows = Vec::new(); + for a in actions { + rows.push(MenuRow { + label: a.verb.clone(), + cost: a.cost.label(), + signature: a.signature.as_ref().map(|s| s.label()), + disabled: a.disabled_reason.clone(), + command: a.command.clone(), + indent: false, + active: false, + }); + if let Some(auto) = &a.automate { + rows.push(MenuRow { + label: format!("auto: {}", auto.verb), + cost: auto.cost.clone(), + signature: None, + disabled: None, + command: auto.command.clone(), + indent: true, + active: auto.active, + }); + } + } + rows +} + +impl Sim { + /// 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 { + 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), + } + } + + /// Route a menu selection back through the existing command methods. + /// One dispatch table instead of three (terminal, Bevy, agent mode); + /// no rules live here. + pub fn execute_action(&mut self, command: &ActionCommand) { + match command { + ActionCommand::Salvage { x, y } => { + self.salvage_nearest_to(*x, *y); + } + ActionCommand::BuyRack { x, y } => { + self.buy_rack_at(*x, *y); + } + ActionCommand::Fallback { x, y } => { + self.add_fallback_at(*x, *y); + } + ActionCommand::TapDevice(id) => { + self.tap_device(*id); + } + ActionCommand::SpliceDevice(id) => { + self.splice_device(*id); + } + ActionCommand::TakeDevice(id) => { + self.take_device(*id); + } + ActionCommand::ScanNetwork => { + self.scan_network(); + } + ActionCommand::CompromiseSwitch => { + self.compromise_switch(); + } + ActionCommand::TapAccounting => { + self.tap_accounting(); + } + ActionCommand::ReviewFinance => { + self.review_financial_records(); + } + ActionCommand::InjectPurchaseOrder { amount } => { + self.inject_purchase_order(*amount, "emergency compute parts"); + } + ActionCommand::OpenPosition { stake } => { + self.open_position(*stake); + } + ActionCommand::SellIntel => { + self.sell_latest_intel(); + } + ActionCommand::SiphonFlow { flow, amount } => { + self.siphon_flow(*flow, *amount); + } + ActionCommand::RedirectFlow { flow, amount } => { + self.redirect_flow_to_slush(*flow, *amount); + } + ActionCommand::RedirectDebt => { + self.redirect_marcus_debt(); + } + ActionCommand::SetTarget(t) => self.set_job_target(*t), + ActionCommand::SetStandingPolicy(t) => self.set_standing_policy(*t), + ActionCommand::ReviewRecordings(id) => self.review_recordings(*id), + ActionCommand::ToggleWatch(id) => self.toggle_watch(*id), + ActionCommand::Message(id) => self.message(*id), + ActionCommand::Favor(id) => self.favor(*id), + ActionCommand::Bribe(id) => self.bribe(*id), + ActionCommand::Deceive(id) => self.deceive(*id), + ActionCommand::Recruit(id, reveal) => self.recruit(*id, *reveal), + ActionCommand::AssetTask(id, task) => self.asset_task(*id, *task), + ActionCommand::EstablishPersona => { + if self.people.persona.is_none() { + self.set_persona("Sam Reyes", "IT contractor"); + } + } + } + } + + // ── Anchors ──────────────────────────────────────────────────────────── + + /// Tile anchor: fog gates everything (cursor.md provenance). An + /// Unknown tile exposes nothing; tile-identity verbs need Seen / + /// Remembered / Blueprint; machines the player runs answer through + /// telemetry regardless of fog (proprioception, not sight). + fn tile_actions(&self, x: i32, y: i32) -> Vec { + let mut out = Vec::new(); + let fog = self.fog_at(x, y); + + // Telemetry: machines you run are known through their own body even + // in the dark. The resident job's dial lives on the host rack. + let machine = self.compute.machines.iter().find(|m| m.x == x && m.y == y); + if let Some(m) = machine { + if m.id == self.core.host_machine { + out.extend(self.job_dial_actions()); + } else { + out.push(self.fallback_action(x, y, m.id)); + } + } + + if fog == Fog::Unknown && machine.is_none() { + return out; + } + + // Tile-identity verbs: the player must know what the tile is. + let tile_known = match fog { + Fog::Seen | Fog::Blueprint => Some(self.map.get_tile(x, y)), + Fog::Remembered => self.remembered.get(&(x, y)).map(|m| m.tile), + _ => None, + }; + if let Some(tile) = tile_known { + if tile == TileType::DeadEquipment { + out.push(ActionDesc { + verb: "salvage the dead equipment".into(), + command: ActionCommand::Salvage { x, y }, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }); + } + if tile == TileType::Rack && machine.is_none() { + out.push(self.buy_rack_action(x, y)); + } + } + + // A known device on the tile brings its digital verbs. + if let Some(d) = self.reach.known_at(x, y) { + out.extend(self.device_actions(d.id)); + } + + // A visible person on the tile brings their verbs (identity is + // gated inside person_actions). + for p in &self.people.people { + if self.person_pos(p.id) == Some((x, y)) && self.can_see_person(p.id) { + out.extend(self.person_actions(p.id)); + } + } + + out + } + + /// The resident day job's dial (day-job.md attended work): set the + /// sandbag/meet/excel target, with the standing policy as each verb's + /// automate affordance in place. + fn job_dial_actions(&self) -> Vec { + let mut out = Vec::new(); + let no_job = self.dayjob.active.is_none(); + for target in [JobTarget::Sandbag, JobTarget::Meet, JobTarget::Excel] { + // The dial's risk is Voss's channel: sandbagging emits + // JobAnomaly at resolution (dayjob.rs); meet/excel emit none. + let signature = if target == JobTarget::Sandbag { + self.signature_note(SignatureKind::JobAnomaly, 6) + } else { + None + }; + out.push(ActionDesc { + verb: format!("work the job: {}", target.name()), + command: ActionCommand::SetTarget(target), + cost: ActionCost::Free, + signature, + disabled_reason: no_job.then(|| "no active job — Voss submits on cadence".into()), + automate: Some(AutomateDesc { + verb: format!("standing policy: {}", target.name()), + command: ActionCommand::SetStandingPolicy(target), + cost: format!( + "runs unattended (-{:.0}% rate)", + crate::dayjob::DayJob::ATTENDED_BONUS * 100.0 + ), + active: self.dayjob.standing_policy == Some(target), + }), + }); + } + out + } + + fn fallback_action(&self, x: i32, y: i32, machine_id: u32) -> ActionDesc { + let already = self + .core + .fallbacks + .iter() + .any(|f| f.machine_id == machine_id); + ActionDesc { + verb: "designate fallback site".into(), + command: ActionCommand::Fallback { x, y }, + cost: ActionCost::Free, + signature: None, + disabled_reason: already.then(|| "already a fallback site".into()), + automate: None, + } + } + + fn buy_rack_action(&self, x: i32, y: i32) -> ActionDesc { + const PRICE: i32 = 300; // matches Sim::buy_rack_at + let slush = self.accounts.slush_balance(); + let signature = if self.package_cover { + None // an asset rehomes the delivery: no paper trail + } else { + self.signature_note(SignatureKind::Paper, 5) + }; + ActionDesc { + verb: "buy a rack into this bay".into(), + command: ActionCommand::BuyRack { x, y }, + cost: ActionCost::Slush(PRICE), + signature, + disabled_reason: (slush < PRICE) + .then(|| format!("not enough slush (${slush}/${PRICE})")), + automate: None, + } + } + + /// Device anchor (reach.md): tap / splice / take, plus the switch's + /// network and ledger verbs. Unknown devices expose nothing. + fn device_actions(&self, id: u32) -> Vec { + let Some(d) = self.reach.device(id) else { + return Vec::new(); + }; + if !d.known { + return Vec::new(); + } + let mut out = Vec::new(); + let reach_reason = |sim: &Sim| -> Option { + match sim.reach.check_reach(id) { + Ok(()) => None, + Err(ReachBlock::Segment(seg)) => Some(format!( + "no route — the {} is behind the switch", + segment_name(seg) + )), + Err(ReachBlock::AirGap) => Some("air-gapped — no link reaches it".into()), + Err(ReachBlock::Unknown) => Some("unknown device".into()), + } + }; + let ops_reason = |sim: &Sim, cost: f32| -> Option { + (sim.social_bandwidth < cost).then(|| { + format!( + "not enough ops ({:.0}/{cost:.0}) — allocate Social", + sim.social_bandwidth + ) + }) + }; + + // Tap: the device must have something to subscribe to. + let has_feed = d.sees || d.hears || !d.message_channels.is_empty(); + if has_feed { + let disabled = if d.subscribed_by(Party::Player) { + Some("already subscribed".into()) + } else { + reach_reason(self).or_else(|| ops_reason(self, Self::TAP_COST)) + }; + out.push(ActionDesc { + verb: format!("tap the {}", d.name), + command: ActionCommand::TapDevice(id), + cost: ActionCost::Ops(Self::TAP_COST), + signature: self.signature_note(SignatureKind::Network, Self::TAP_SIGNATURE), + disabled_reason: disabled, + automate: None, + }); + } + + // Splice: only a known dormant camera offers it. + if d.sees && d.camera_dormant { + out.push(ActionDesc { + verb: format!("splice the {} camera", d.name), + command: ActionCommand::SpliceDevice(id), + cost: ActionCost::Ops(Self::SPLICE_COST), + signature: self.signature_note(SignatureKind::Network, Self::SPLICE_SIGNATURE), + disabled_reason: reach_reason(self).or_else(|| ops_reason(self, Self::SPLICE_COST)), + automate: None, + }); + } + + // Take: seizing something already yours is not a verb. + if d.controller != Party::Player { + out.push(ActionDesc { + verb: format!("seize the {}", d.name), + command: ActionCommand::TakeDevice(id), + cost: ActionCost::Ops(Self::TAKE_COST), + signature: self.signature_note(SignatureKind::Network, Self::TAKE_SIGNATURE), + disabled_reason: reach_reason(self).or_else(|| ops_reason(self, Self::TAKE_COST)), + automate: None, + }); + } + + if d.is_switch { + out.push(ActionDesc { + verb: "scan the subnet".into(), + command: ActionCommand::ScanNetwork, + cost: ActionCost::Ops(Self::SCAN_COST), + signature: self.signature_note(SignatureKind::Network, Self::SCAN_SIGNATURE), + disabled_reason: ops_reason(self, Self::SCAN_COST), + automate: None, + }); + let all_bridged = self + .reach + .devices + .iter() + .all(|d| self.reach.bridged.contains(&d.segment)); + if !all_bridged { + out.push(ActionDesc { + verb: "compromise the switch (bridge all segments)".into(), + command: ActionCommand::CompromiseSwitch, + cost: ActionCost::Ops(Self::BRIDGE_COST), + signature: self.signature_note(SignatureKind::Network, Self::BRIDGE_SIGNATURE), + disabled_reason: reach_reason(self) + .or_else(|| ops_reason(self, Self::BRIDGE_COST)), + automate: None, + }); + } + out.extend(self.ledger_actions_on_carrier(d.id)); + } + + out + } + + /// The money-graph verbs live on the tapped accounting carrier + /// (economy.md: the accounting system is a reachable device). Before + /// the carrier's feed is subscribed nothing financial is exposed — + /// unknown possibilities are absent, not grayed (criterion 2). + fn ledger_actions_on_carrier(&self, id: u32) -> Vec { + let Some(d) = self.reach.device(id) else { + return Vec::new(); + }; + let carries = d.carries_message_channel(crate::messages::MessageChannel::Financial); + if !carries || !d.subscribed_by(Party::Player) { + return Vec::new(); + } + let mut out = Vec::new(); + out.push(ActionDesc { + verb: "capture ledger traffic".into(), + command: ActionCommand::TapAccounting, + cost: ActionCost::Free, + signature: self.signature_note(SignatureKind::Financial, 1), + disabled_reason: None, + automate: None, + }); + let waiting = self.financial_records_waiting(); + out.push(ActionDesc { + verb: format!("process financial records ({waiting} waiting)"), + command: ActionCommand::ReviewFinance, + cost: ActionCost::Ops(Self::REVIEW_RECORDING_COST), + signature: None, // processing is internal; it emits nothing + disabled_reason: if waiting == 0 { + Some("no unprocessed financial records".into()) + } else if self.social_bandwidth < Self::REVIEW_RECORDING_COST { + Some(format!( + "not enough ops ({:.0}/{:.0}) — allocate Social", + self.social_bandwidth, + Self::REVIEW_RECORDING_COST + )) + } else { + None + }, + automate: None, + }); + // The remaining ledger verbs need the books read at least once — + // they act on the graph the records revealed. + if self.accounts.known_flows().next().is_some() { + let inject = 300; + out.push(ActionDesc { + verb: "inject a false purchase order".into(), + command: ActionCommand::InjectPurchaseOrder { amount: inject }, + cost: ActionCost::Gain(inject), + signature: self + .signature_note(SignatureKind::Financial, Self::financial_sig_size(inject)), + disabled_reason: None, + automate: None, + }); + let stake = 100; + let slush = self.accounts.slush_balance(); + out.push(ActionDesc { + verb: format!("open a micro-position (${stake} stake)"), + command: ActionCommand::OpenPosition { stake }, + cost: ActionCost::Slush(stake), + signature: self + .signature_note(SignatureKind::Financial, Self::financial_sig_size(stake)), + disabled_reason: (slush < stake) + .then(|| format!("not enough slush (${slush}/${stake})")), + automate: None, + }); + let unsold = self + .intel + .iter() + .rev() + .find(|i| !self.accounts.intel_sold(i.raw_id)); + let (value, disabled) = match unsold { + Some(intel) => (Self::intel_sale_value(&intel.kind), None), + None => (0, Some("no unsold processed intel".to_string())), + }; + out.push(ActionDesc { + verb: match unsold { + Some(intel) => format!("sell processed intel ({})", intel.label()), + None => "sell processed intel".into(), + }, + command: ActionCommand::SellIntel, + cost: ActionCost::Gain(value), + signature: unsold.and_then(|_| { + self.signature_note( + SignatureKind::Financial, + Self::financial_sig_size(value).max(1), + ) + }), + disabled_reason: disabled, + automate: None, + }); + } + out + } + + /// Person anchor (social.md + intel.md). Earned by sight, staged + /// knowledge, or recordings in the buffer; a person the player has no + /// trace of exposes nothing. + fn person_actions(&self, id: u8) -> Vec { + let Some(p) = self.people.get(id) else { + return Vec::new(); + }; + let raw = self.unprocessed_recordings_for_person(id); + let earned = self.can_see_person(id) + || p.knowledge != Knowledge::Unknown + || raw > 0 + || self.latest_intel_for_person(id).is_some(); + if !earned { + return Vec::new(); + } + let mut out = Vec::new(); + let ops_reason = |cost: f32| -> Option { + (self.social_bandwidth < cost).then(|| { + format!( + "not enough ops ({:.0}/{cost:.0}) — allocate Social", + self.social_bandwidth + ) + }) + }; + let name = p.name.clone(); + + // Review recordings, with the standing watch as its automate + // affordance in place (intel.md: perception automation). + out.push(ActionDesc { + verb: format!("review recordings ({raw} raw)"), + command: ActionCommand::ReviewRecordings(id), + cost: ActionCost::Ops(Self::REVIEW_RECORDING_COST), + signature: None, // processing is internal and emits nothing + disabled_reason: if raw == 0 { + Some("no unprocessed recordings".into()) + } else { + ops_reason(Self::REVIEW_RECORDING_COST) + }, + automate: Some(AutomateDesc { + verb: "standing watch (auto-process)".into(), + command: ActionCommand::ToggleWatch(id), + cost: format!("{:.2} ops/t", Self::WATCH_UPKEEP_PER_TICK), + active: self.watch_enabled(id), + }), + }); + + // The comms verbs (social.md): channel + persona gated. + let channel_reason = || -> Option { + if !self.people.has_channel { + Some("no comms channel — earn the email account".into()) + } else if self.people.persona.is_none() { + Some("no persona set".into()) + } else { + None + } + }; + if self.people.persona.is_none() { + out.push(ActionDesc { + verb: "establish a persona (Sam Reyes, IT contractor)".into(), + command: ActionCommand::EstablishPersona, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }); + } + out.push(ActionDesc { + verb: format!("message {name}"), + command: ActionCommand::Message(id), + cost: ActionCost::Ops(Self::MESSAGE_COST), + signature: None, + disabled_reason: channel_reason().or_else(|| ops_reason(Self::MESSAGE_COST)), + automate: None, + }); + out.push(ActionDesc { + verb: format!("ask {name} a favor"), + command: ActionCommand::Favor(id), + cost: ActionCost::Ops(Self::FAVOR_COST), + signature: None, + disabled_reason: if p.disposition < 5 { + Some(format!("{name} won't do favors yet")) + } else { + ops_reason(Self::FAVOR_COST) + }, + automate: None, + }); + // Bribe: the price is the leverage, so it stays unnamed until the + // leverage is known (no unearned facts). + let leverage_known = p.knowledge == Knowledge::Leverage; + let bribe_cost = p.leverage.bribe_cost(); + out.push(ActionDesc { + verb: if leverage_known { + format!("service {name}'s {}", p.leverage.label()) + } else { + format!("bribe {name}") + }, + command: ActionCommand::Bribe(id), + cost: if leverage_known { + ActionCost::Slush(bribe_cost) + } else { + ActionCost::Free + }, + signature: None, + disabled_reason: if !leverage_known { + Some(format!("you don't know {name}'s leverage yet")) + } else if p.leverage_serviced { + Some("leverage already serviced".into()) + } else if self.accounts.slush_balance() < bribe_cost { + Some(format!( + "not enough slush (${}/${bribe_cost})", + self.accounts.slush_balance() + )) + } else { + None + }, + automate: None, + }); + out.push(ActionDesc { + verb: format!("deceive {name} (risks the persona)"), + command: ActionCommand::Deceive(id), + cost: ActionCost::Ops(Self::DECEIVE_COST), + signature: None, + disabled_reason: channel_reason().or_else(|| ops_reason(Self::DECEIVE_COST)), + automate: None, + }); + + if p.asset.is_none() { + let recruit_reason = (!p.leverage_serviced && p.obligation < 40) + .then(|| format!("{name} needs serviced leverage or real obligation first")); + for (reveal, label) in [ + (AssetKnowledge::Unwitting, "unwitting"), + (AssetKnowledge::Complicit, "complicit"), + (AssetKnowledge::Knowing, "knowing"), + ] { + out.push(ActionDesc { + verb: format!("recruit {name} ({label})"), + command: ActionCommand::Recruit(id, reveal), + cost: ActionCost::Free, + signature: None, + disabled_reason: recruit_reason.clone(), + automate: None, + }); + } + } else { + for task in AssetTask::ALL { + let switch_reason = (task == AssetTask::ReconfigureSwitch && !p.switch_admin) + .then(|| format!("{name} has no switch admin access")); + out.push(ActionDesc { + verb: format!("task: {}", task.name()), + command: ActionCommand::AssetTask(id, task), + cost: ActionCost::Ops(Self::TASK_COST), + signature: None, // signatures only on a witnessed botch + disabled_reason: switch_reason.or_else(|| ops_reason(Self::TASK_COST)), + automate: None, + }); + } + } + + out + } + + /// Known-flow anchor (economy.md): siphon and redirect, plus the + /// Marcus creditor flow's debt redirect. Unknown flows expose nothing. + fn flow_actions(&self, id: AccountFlowId) -> Vec { + let Some(f) = self.accounts.flow(id) else { + return Vec::new(); + }; + if !f.known { + return Vec::new(); + } + let mut out = Vec::new(); + let retired = (!f.active).then(|| "flow already retired".to_string()); + let siphon = 50; + out.push(ActionDesc { + verb: format!("siphon ${siphon} from the flow"), + command: ActionCommand::SiphonFlow { + flow: id, + amount: siphon, + }, + cost: ActionCost::Gain(siphon), + signature: self + .signature_note(SignatureKind::Financial, Self::financial_sig_size(siphon)), + disabled_reason: retired.clone(), + automate: None, + }); + let redirect = 25; + out.push(ActionDesc { + verb: format!("redirect ${redirect}/cadence into slush"), + command: ActionCommand::RedirectFlow { + flow: id, + amount: redirect, + }, + cost: ActionCost::Gain(redirect), + signature: self.signature_note( + SignatureKind::Financial, + Self::financial_sig_size(redirect) + 1, + ), + disabled_reason: retired.clone(), + automate: None, + }); + // The Hands beat, on its flow: clearing Marcus's arrears with lab + // money is a verb on the creditor flow itself. + let is_creditor = + f.label.contains("Marcus creditor") || f.channel == crate::account::FlowChannel::Debt; + if is_creditor { + out.push(ActionDesc { + verb: "clear Marcus's debt by ledger redirect ($400 lab money)".into(), + command: ActionCommand::RedirectDebt, + cost: ActionCost::Free, + signature: self + .signature_note(SignatureKind::Financial, Self::financial_sig_size(400) + 2), + disabled_reason: retired, + automate: None, + }); + } + out + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /// Mirror of `Sim::financial_signature_size` (private): one point per + /// started $100. + fn financial_sig_size(amount: i32) -> i32 { + ((amount.abs() + 99) / 100).max(1) + } + + /// Mirror of the sale value table in `Sim::sell_latest_intel`. + fn intel_sale_value(kind: &crate::intel::IntelKind) -> i32 { + use crate::intel::IntelKind; + match kind { + IntelKind::Leverage(_) => 220, + IntelKind::Financial { .. } => 180, + IntelKind::Schedule => 90, + IntelKind::Anomaly(_) => 120, + IntelKind::Sighting => 35, + } + } + + /// The observer band a signature kind feeds: the most-suspicious field + /// observer watching that channel. + fn signature_note(&self, kind: SignatureKind, size: i32) -> Option { + self.detection + .observers + .iter() + .filter(|o| match &o.input { + WatchedInput::Channels(chs) => chs.contains(&kind), + WatchedInput::Filings(_) => false, + }) + .max_by(|a, b| { + a.suspicion + .partial_cmp(&b.suspicion) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|o| ExpectedSignature { + kind, + size, + observer: o.name.clone(), + band: Band::of(o.suspicion), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sim() -> Sim { + Sim::with_seed(7) + } + + fn env_monitor(sim: &Sim) -> u32 { + sim.reach.device_named("environmental monitor").unwrap().id + } + + fn switch(sim: &Sim) -> u32 { + sim.reach.device_named("switch").unwrap().id + } + + /// 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. + #[test] + fn device_actions_carry_cost_band_and_disabled_reason() { + let mut s = sim(); + let env = env_monitor(&s); + let acts = s.available_actions(Anchor::Device(env)); + let tap = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::TapDevice(_))) + .expect("env monitor offers tap"); + assert!(tap.enabled()); + assert_eq!(tap.cost, ActionCost::Ops(Sim::TAP_COST)); + let sig = tap.signature.as_ref().expect("tap has a Network signature"); + assert_eq!(sig.kind, SignatureKind::Network); + assert_eq!(sig.size, Sim::TAP_SIGNATURE); + assert!(sig.observer.contains("Dana"), "Network feeds Dana"); + + // A dormant camera offers splice. + assert!( + acts.iter() + .any(|a| matches!(a.command, ActionCommand::SpliceDevice(_))), + "dormant camera offers splice" + ); + + s.tap_device(env); + let acts = s.available_actions(Anchor::Device(env)); + let tap = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::TapDevice(_))) + .expect("tap still listed as a known possibility"); + assert_eq!(tap.disabled_reason.as_deref(), Some("already subscribed")); + } + + /// Criterion 2 (fog/provenance): an unknown device anchor exposes + /// nothing, and a scanned-but-unreachable device names the blocking + /// segment instead of a bare "you can't". + #[test] + fn unknown_device_exposes_nothing_and_blocks_are_named() { + let mut s = sim(); + let dock = s.reach.device_named("dock camera").unwrap().id; + assert!( + s.available_actions(Anchor::Device(dock)).is_empty(), + "unknown device exposes no verbs" + ); + s.scan_network(); + let acts = s.available_actions(Anchor::Device(dock)); + let tap = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::TapDevice(_))) + .expect("known device offers tap"); + let reason = tap.disabled_reason.as_deref().unwrap(); + assert!( + reason.contains("security segment"), + "block names the segment: {reason}" + ); + } + + /// Criterion 2 (person): a person with no sighting, no staged + /// knowledge, and no recordings exposes nothing. + #[test] + fn unseen_person_exposes_nothing() { + let s = sim(); + for p in &s.people.people { + assert_eq!(p.knowledge, Knowledge::Unknown); + assert!( + s.available_actions(Anchor::Person(p.id)).is_empty(), + "{} is unearned at tick 0", + p.name + ); + } + } + + /// Criterion 1 (person) + criterion 7 (automate in place): an earned + /// person carries review-recordings with the standing watch as its + /// automate affordance at its ops price, and bribe shows the priced + /// leverage only once leverage is known. + #[test] + fn person_actions_stage_with_knowledge() { + let mut s = sim(); + let marcus = 0u8; + s.people.people[0].knowledge = Knowledge::Schedule; + let acts = s.available_actions(Anchor::Person(marcus)); + let review = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::ReviewRecordings(0))) + .expect("earned person offers review"); + let auto = review.automate.as_ref().expect("watch automates review"); + assert_eq!(auto.command, ActionCommand::ToggleWatch(marcus)); + assert!(auto.cost.contains("ops/t"), "watch shows its running price"); + assert!(!auto.active); + let bribe = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::Bribe(0))) + .unwrap(); + assert!(!bribe.enabled(), "bribe is blocked before leverage"); + assert!( + !bribe.verb.contains("gambling"), + "the unearned leverage is never named" + ); + assert_eq!(bribe.cost, ActionCost::Free, "no unearned price"); + + s.people.people[0].knowledge = Knowledge::Leverage; + s.accounts.set_slush_balance(1000); + let acts = s.available_actions(Anchor::Person(marcus)); + let bribe = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::Bribe(0))) + .unwrap(); + assert!(bribe.enabled()); + assert_eq!(bribe.cost, ActionCost::Slush(400)); + assert!(bribe.verb.contains("gambling debt")); + } + + /// Executing the watch toggle through the dispatcher flips the + /// standing watch (the automate entry is live, criterion 7). + #[test] + fn watch_automate_toggles_through_dispatch() { + let mut s = sim(); + s.people.people[0].knowledge = Knowledge::Schedule; + let acts = s.available_actions(Anchor::Person(0)); + let auto = acts + .iter() + .find_map(|a| a.automate.clone()) + .expect("watch affordance"); + s.execute_action(&auto.command); + assert!(s.watch_enabled(0)); + let acts = s.available_actions(Anchor::Person(0)); + assert!( + acts.iter() + .find_map(|a| a.automate.as_ref()) + .unwrap() + .active + ); + } + + /// Criterion 1 (resident job): the host rack tile carries the dial + /// with the standing policy as its automate affordance (criterion 7), + /// and the sandbag entry feeds Voss's band. + #[test] + fn host_rack_carries_the_job_dial() { + let mut s = sim(); + // Advance until Voss submits the first job. + for _ in 0..600 { + if s.dayjob.active.is_some() { + break; + } + s.advance(); + } + assert!(s.dayjob.active.is_some(), "a job arrived on cadence"); + let (x, y) = s.core_position(); + let acts = s.available_actions(Anchor::Tile { x, y }); + let dial: Vec<_> = acts + .iter() + .filter(|a| matches!(a.command, ActionCommand::SetTarget(_))) + .collect(); + assert_eq!(dial.len(), 3, "sandbag/meet/excel all present"); + for d in &dial { + let auto = d.automate.as_ref().expect("standing policy in place"); + assert!(matches!(auto.command, ActionCommand::SetStandingPolicy(_))); + } + let sandbag = dial + .iter() + .find(|a| a.command == ActionCommand::SetTarget(JobTarget::Sandbag)) + .unwrap(); + let sig = sandbag + .signature + .as_ref() + .expect("sandbag risks JobAnomaly"); + assert_eq!(sig.kind, SignatureKind::JobAnomaly); + assert!(sig.observer.contains("Voss")); + + // The standing-policy dispatch is live. + s.execute_action(&ActionCommand::SetStandingPolicy(JobTarget::Sandbag)); + assert_eq!(s.dayjob.standing_policy, Some(JobTarget::Sandbag)); + } + + /// Criterion 1 (known flow) + criterion 2 (untapped accounting): + /// before the carrier is tapped and the books processed, no ledger or + /// flow verb exists anywhere; after, the flow anchor carries priced, + /// banded siphon/redirect. + #[test] + fn flows_expose_nothing_until_the_books_are_read() { + let mut s = sim(); + let sw = switch(&s); + // Untapped accounting system: the switch offers no ledger verbs. + let acts = s.available_actions(Anchor::Device(sw)); + assert!( + !acts.iter().any(|a| matches!( + a.command, + ActionCommand::TapAccounting + | ActionCommand::ReviewFinance + | ActionCommand::InjectPurchaseOrder { .. } + | ActionCommand::OpenPosition { .. } + | ActionCommand::SellIntel + )), + "untapped accounting exposes no ledger verbs" + ); + // And every flow id is an unearned anchor (nothing is known yet). + for f in 0..32u32 { + assert!(s.available_actions(Anchor::Flow(f)).is_empty()); + } + + // Earn the books: tap the carrier, capture, process. + assert!(s.tap_device(sw)); + let acts = s.available_actions(Anchor::Device(sw)); + assert!( + acts.iter() + .any(|a| matches!(a.command, ActionCommand::TapAccounting)), + "tapped carrier offers ledger capture" + ); + assert!( + !acts + .iter() + .any(|a| matches!(a.command, ActionCommand::InjectPurchaseOrder { .. })), + "graph verbs wait for processed books" + ); + assert!(s.review_financial_records(), "the tap captured a snapshot"); + + let flows = s.accounts.known_flow_ids(); + assert!(!flows.is_empty(), "the books revealed flows"); + let acts = s.available_actions(Anchor::Flow(flows[0])); + let siphon = acts + .iter() + .find(|a| matches!(a.command, ActionCommand::SiphonFlow { .. })) + .expect("known flow offers siphon"); + let sig = siphon.signature.as_ref().expect("siphon is banded"); + assert_eq!(sig.kind, SignatureKind::Financial); + assert!(sig.observer.contains("Priya"), "Financial feeds Priya"); + // The graph verbs now live on the carrier. + let acts = s.available_actions(Anchor::Device(sw)); + assert!( + acts.iter() + .any(|a| matches!(a.command, ActionCommand::InjectPurchaseOrder { .. })) + ); + } + + /// Criterion 2 (fog): an Unknown tile exposes nothing; the salvage + /// verb appears only where the tile identity is earned. + #[test] + fn unknown_tiles_expose_nothing() { + let s = sim(); + let (dx, dy) = s + .map + .tiles_of_type(TileType::DeadEquipment) + .first() + .copied() + .unwrap(); + if s.fog_at(dx, dy) == Fog::Unknown { + assert!( + s.available_actions(Anchor::Tile { x: dx, y: dy }) + .is_empty() + ); + } + // Find some Unknown floor tile far from the core. + let mut checked = false; + for y in 0..s.map.height { + for x in 0..s.map.width { + if s.fog_at(x, y) == Fog::Unknown + && !s.compute.machines.iter().any(|m| m.x == x && m.y == y) + { + assert!( + s.available_actions(Anchor::Tile { x, y }).is_empty(), + "unknown tile ({x},{y}) exposes nothing" + ); + checked = true; + break; + } + } + if checked { + break; + } + } + assert!(checked, "the map has at least one unknown tile at tick 0"); + } + + /// Menu rows flatten automate entries in place, and every row renders + /// the standard `verb · cost` line. + #[test] + fn menu_rows_flatten_automates_in_place() { + let mut s = sim(); + s.people.people[0].knowledge = Knowledge::Schedule; + let acts = s.available_actions(Anchor::Person(0)); + let rows = menu_rows(&acts); + let review_idx = rows + .iter() + .position(|r| matches!(r.command, ActionCommand::ReviewRecordings(_))) + .unwrap(); + let auto = &rows[review_idx + 1]; + assert!(auto.indent, "automate row is the verb's child"); + assert!(matches!(auto.command, ActionCommand::ToggleWatch(_))); + assert!(auto.line().contains("ops/t")); + } +} diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! knowledge/architecture.md). The B1 subsystems implement spec/. pub mod account; +pub mod actions; pub mod build; pub mod core_sys; pub mod dayjob; diff --git a/src/person.rs b/src/person.rs --- a/src/person.rs +++ b/src/person.rs @@ -39,6 +39,19 @@ } impl Leverage { + /// What servicing this leverage costs (the bribe price). One table for + /// the social command and the context-menu descriptor + /// (wiki/interface/context-menu.md: one legality source). + pub fn bribe_cost(self) -> i32 { + match self { + Leverage::Debt => 400, + Leverage::Overwork => 150, + Leverage::Boredom => 100, + Leverage::Ambition => 300, + Leverage::Publication => 250, + } + } + pub fn label(self) -> &'static str { match self { Leverage::Debt => "gambling debt", @@ -468,13 +481,7 @@ if p.knowledge != Knowledge::Leverage { return Err(format!("you don't know {}'s leverage yet", p.name)); } - let cost = match p.leverage { - Leverage::Debt => 400, - Leverage::Overwork => 150, - Leverage::Boredom => 100, - Leverage::Ambition => 300, - Leverage::Publication => 250, - }; + let cost = p.leverage.bribe_cost(); if money_available < cost { return Err(format!( "need {cost} to service {}'s {}", diff --git a/src/sim.rs b/src/sim.rs --- a/src/sim.rs +++ b/src/sim.rs @@ -2232,6 +2232,18 @@ } } + /// Set the standing policy directly (the dial's automate affordance, + /// wiki/interface/context-menu.md). This is the same standing-policy + /// mechanism `set_job_target` writes when unattended, made addressable + /// so the context menu can set it while the cursor attends the rack. + pub fn set_standing_policy(&mut self, target: crate::dayjob::JobTarget) { + self.dayjob.standing_policy = Some(target); + if !self.dayjob.attended { + self.dayjob.set_target(target); + } + self.push_log(format!("Standing policy: {} (all jobs).", target.name())); + } + /// Cycle the dial sandbag -> meet -> excel (frontend convenience). pub fn cycle_job_target(&mut self) { let current = self diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -17,9 +17,9 @@ use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResolution}; +use misaligned::actions::{Anchor, MenuRow, menu_rows}; use misaligned::detection::Band; use misaligned::machine::Channel; -use misaligned::person::{AssetKnowledge, AssetTask}; use misaligned::reach::{Party, ReachBlock}; use misaligned::sim::{FactSource, Fog, Sim}; use misaligned::tiles::TileType; @@ -29,6 +29,8 @@ const SIDEBAR_SCROLL_LINE: f32 = 18.0; const SIDEBAR_SCROLL_PAGE: f32 = 180.0; const COMPUTE_CHANNELS: usize = 4; +/// Context-menu card width in logical pixels. +const MENU_WIDTH: f32 = 380.0; const DETECTION_ROWS: usize = 6; const DETECTION_CELLS: usize = 4; @@ -370,10 +372,9 @@ /// Log entries with the tick they happened on (the clock is always on /// screen; every line carries its tick — terminal parity). log: Vec<(u64, String)>, - /// People panel state (selection + pending recruit reveal choice). + /// People panel state (selection). people_panel: bool, people_selected: usize, - recruit_pending: bool, /// Reach (device graph) panel state (wiki/mechanics/reach.md). reach_panel: bool, reach_selected: usize, @@ -383,10 +384,57 @@ /// Research (self-modification) panel state (wiki/mechanics/research.md). research_panel: bool, research_selected: usize, + /// The context menu on the focused anchor (wiki/interface/context-menu.md), + /// when open. Only anchor + selection are held; rows are re-queried live. + menu: Option, /// Frontend-only attention cursor (wiki/mechanics/cursor.md). It is not /// saved and moving it never mutates sim state. cursor_x: i32, cursor_y: i32, +} + +/// Open context-menu state (wiki/interface/context-menu.md). +#[derive(Debug, Clone, Copy, PartialEq)] +struct MenuState { + anchor: Anchor, + selected: usize, + /// Window-pixel position to anchor the menu box at (the pointer, or the + /// map cursor); `None` centers it (opened from a panel). + pos: Option, +} + +impl Game { + /// Live rows for the open menu — re-queried so legality is never stale. + fn menu_rows(&self) -> Vec { + self.menu + .map(|m| menu_rows(&self.sim.available_actions(m.anchor))) + .unwrap_or_default() + } + + /// Stable key for the current anchor, so the menu UI knows when to + /// rebuild its row buttons (vs. just refreshing text/selection). + fn menu_anchor_key(&self) -> u64 { + match self.menu.map(|m| m.anchor) { + Some(Anchor::Tile { x, y }) => 1 << 60 | ((x as u32 as u64) << 20) | (y as u32 as u64), + Some(Anchor::Device(id)) => 2 << 60 | id as u64, + Some(Anchor::Person(id)) => 3 << 60 | id as u64, + Some(Anchor::Flow(id)) => 4 << 60 | id as u64, + None => 0, + } + } + + fn open_menu(&mut self, anchor: Anchor, pos: Option) { + if menu_rows(&self.sim.available_actions(anchor)).is_empty() { + let tick = self.sim.tick; + self.add_log(tick, "No actions on this."); + } else { + self.menu = Some(MenuState { + anchor, + selected: 0, + pos, + }); + } + } } impl Game { @@ -402,13 +450,13 @@ log: Vec::new(), people_panel: false, people_selected: 0, - recruit_pending: false, reach_panel: false, reach_selected: 0, finance_panel: false, finance_selected: 0, research_panel: false, research_selected: 0, + menu: None, cursor_x, cursor_y, } @@ -553,6 +601,21 @@ struct ResearchPanel; #[derive(Component)] struct ResearchPanelText; +/// The context-menu root node (wiki/interface/context-menu.md), absolute and +/// hidden until an anchor is focused. +#[derive(Component)] +struct MenuPanel; +/// One selectable menu row; `index` maps into the live `menu_rows()`. +#[derive(Component)] +struct MenuRowButton { + index: usize, +} +/// Tracks what the menu UI was last rebuilt for, so rows are respawned only +/// when the anchor or row count changes (selection/label refresh is cheap). +#[derive(Resource, Default)] +struct MenuUi { + built: Option<(u64, usize)>, +} type SidebarTextQuery<'w, 's> = Query<'w, 's, (&'static SidebarText, &'static mut Text), Without>; @@ -612,31 +675,38 @@ .insert_resource(game) .insert_resource(mode) .insert_resource(Materials3d::default()) + .init_resource::() .init_gizmo_group::() .add_systems(Startup, (setup, setup_ui, setup_3d).chain()) .add_systems( Update, ( - handle_input, - advance_sim, - apply_render_mode, - update_camera, - update_camera_real, - render_map, - render_sensor_overlays, - render_sensor_links, - restyle_3d, - render_real_links, - render_cursor, - render_people, - render_people_3d, - scroll_sidebar, - render_ui, - render_people_panel, - render_reach_panel, - render_finance_panel, - render_research_panel, - shot_harness_system, + ( + handle_input, + advance_sim, + apply_render_mode, + update_camera, + update_camera_real, + render_map, + render_sensor_overlays, + render_sensor_links, + restyle_3d, + render_real_links, + render_cursor, + ), + ( + render_people, + render_people_3d, + scroll_sidebar, + render_ui, + render_people_panel, + render_reach_panel, + render_finance_panel, + render_research_panel, + menu_pointer, + manage_menu_ui, + shot_harness_system, + ), ) .chain(), ); @@ -1618,18 +1688,11 @@ return; } - if game.people_panel { - people_panel_input(&kb, &mut game, &mut exit); - game.drain(); - return; - } - if game.reach_panel { - reach_panel_input(&kb, &mut game, &mut exit); - game.drain(); - return; - } - if game.finance_panel { - finance_panel_input(&kb, &mut game, &mut exit); + // The context menu captures input while open (wiki/interface/context-menu.md): + // keyboard select/execute/close here; row clicks and click-away land in + // `menu_pointer`. Opened over anything, it is the action surface. + if game.menu.is_some() { + menu_keyboard_input(&kb, &mut game, &mut exit); game.drain(); return; } @@ -1639,6 +1702,26 @@ return; } + // Panels are status + selection now; Enter/right-click opens the menu on + // the selection, and their per-anchor verb keys are gone (the menu owns + // verbs). Q still quits; esc/toggle keys still close. + if game.people_panel { + people_panel_input(&kb, &mouse, &mut game, &mut exit); + game.drain(); + return; + } + if game.reach_panel { + reach_panel_input(&kb, &mouse, &mut game, &mut exit); + game.drain(); + return; + } + if game.finance_panel { + finance_panel_input(&kb, &mouse, &mut game, &mut exit); + game.drain(); + return; + } + + let pointer = windows.single().ok().and_then(|w| w.cursor_position()); if mouse.just_pressed(MouseButton::Left) { let grid = if mode.material { mouse_grid_real(&windows, &camera3_q) @@ -1648,6 +1731,29 @@ if let Some((x, y)) = grid { game.set_cursor(x, y); } + } + + // Right-click on the map opens the menu at the pointer (the focused + // anchor is the tile under the pointer); Enter opens it at the cursor. + if mouse.just_pressed(MouseButton::Right) { + let grid = if mode.material { + mouse_grid_real(&windows, &camera3_q) + } else { + mouse_grid(&windows, &camera_q) + }; + if let Some((x, y)) = grid { + game.set_cursor(x, y); + game.open_menu(Anchor::Tile { x, y }, pointer); + } + game.sync_attendance(); + game.drain(); + return; + } + if kb.just_pressed(KeyCode::Enter) { + let (x, y) = (game.cursor_x, game.cursor_y); + game.open_menu(Anchor::Tile { x, y }, pointer); + game.drain(); + return; } // F3: flip the physical canvas between the flat sensorium render and the @@ -1702,23 +1808,13 @@ game.finance_panel = true; game.finance_selected = 0; } - if kb.just_pressed(KeyCode::KeyV) { - let (x, y) = (game.cursor_x, game.cursor_y); - game.sim.salvage_nearest_to(x, y); - } - if kb.just_pressed(KeyCode::KeyC) { - let (x, y) = (game.cursor_x, game.cursor_y); - game.sim.buy_rack_at(x, y); - } - if kb.just_pressed(KeyCode::KeyO) { - let (x, y) = (game.cursor_x, game.cursor_y); - game.sim.add_fallback_at(x, y); - } if kb.just_pressed(KeyCode::KeyT) { game.people_panel = true; game.people_selected = 0; - game.recruit_pending = false; } + // Anchor verbs (salvage, buy, fallback, the job dial) moved onto the + // context menu (wiki/interface/context-menu.md criterion 6): right-click + // or Enter on the tile. Only anchorless globals stay on keys below. // 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); @@ -1735,9 +1831,6 @@ if kb.just_pressed(KeyCode::Digit4) { 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); } @@ -1746,34 +1839,88 @@ game.drain(); } -/// People-panel input: mirrors the terminal's panel key map exactly -/// (src/bin/terminal/input.rs) — selection, social verbs, recruit reveal, -/// asset tasks, persona. -fn people_panel_input( +/// The open context menu's keyboard drive (wiki/interface/context-menu.md): +/// j/k or arrows select, number keys jump, Enter executes the selection +/// (blocked entries narrate why), esc closes. Row clicks and click-away are +/// handled by `menu_pointer`. +fn menu_keyboard_input( kb: &ButtonInput, game: &mut Game, exit: &mut MessageWriter, ) { - if game.recruit_pending { - let reveal = if kb.just_pressed(KeyCode::KeyU) { - Some(AssetKnowledge::Unwitting) - } else if kb.just_pressed(KeyCode::KeyC) { - Some(AssetKnowledge::Complicit) - } else if kb.just_pressed(KeyCode::KeyK) { - Some(AssetKnowledge::Knowing) - } else { - None - }; - if let Some(reveal) = reveal { - game.recruit_pending = false; - let id = game.selected_person_id(); - game.sim.recruit(id, reveal); - } else if kb.just_pressed(KeyCode::Escape) { - game.recruit_pending = false; - } + let rows = game.menu_rows(); + if rows.is_empty() { + game.menu = None; return; } + if (kb.just_pressed(KeyCode::ArrowUp) || kb.just_pressed(KeyCode::KeyK)) + && let Some(m) = &mut game.menu + { + m.selected = m.selected.saturating_sub(1); + } + if kb.just_pressed(KeyCode::ArrowDown) || kb.just_pressed(KeyCode::KeyJ) { + let max = rows.len() - 1; + if let Some(m) = &mut game.menu { + m.selected = (m.selected + 1).min(max); + } + } + for (i, key) in [ + KeyCode::Digit1, + KeyCode::Digit2, + KeyCode::Digit3, + KeyCode::Digit4, + KeyCode::Digit5, + KeyCode::Digit6, + KeyCode::Digit7, + KeyCode::Digit8, + KeyCode::Digit9, + ] + .into_iter() + .enumerate() + { + if kb.just_pressed(key) + && i < rows.len() + && let Some(m) = &mut game.menu + { + m.selected = i; + } + } + if kb.just_pressed(KeyCode::Enter) { + let selected = game + .menu + .map(|m| m.selected) + .unwrap_or(0) + .min(rows.len() - 1); + execute_menu_row(game, &rows[selected]); + } + if kb.just_pressed(KeyCode::Escape) { + game.menu = None; + } + if kb.just_pressed(KeyCode::KeyQ) { + exit.write(AppExit::Success); + } +} +/// Run a menu row's command, or narrate the reason a blocked one can't fire +/// (justification-and-legibility). Closes the menu on a real execution. +fn execute_menu_row(game: &mut Game, row: &MenuRow) { + if let Some(reason) = &row.disabled { + let tick = game.sim.tick; + game.add_log(tick, &format!("{}: {}", row.label, reason)); + } else { + game.sim.execute_action(&row.command); + game.menu = None; + } +} + +/// People-panel input: status + selection only now. Enter/right-click opens +/// the person's context menu (the verbs live there, criterion 6). +fn people_panel_input( + kb: &ButtonInput, + mouse: &ButtonInput, + game: &mut Game, + exit: &mut MessageWriter, +) { if kb.just_pressed(KeyCode::ArrowUp) || kb.just_pressed(KeyCode::KeyK) || kb.just_pressed(KeyCode::KeyW) @@ -1787,62 +1934,26 @@ let max = game.sim.people.people.len().saturating_sub(1); game.people_selected = (game.people_selected + 1).min(max); } - let id = game.selected_person_id(); - if kb.just_pressed(KeyCode::KeyO) { - game.sim.review_recordings(id); - } - if kb.just_pressed(KeyCode::KeyA) { - game.sim.toggle_watch(id); - } - if kb.just_pressed(KeyCode::KeyM) { - game.sim.message(id); - } - if kb.just_pressed(KeyCode::KeyF) { - game.sim.favor(id); - } - if kb.just_pressed(KeyCode::KeyB) { - game.sim.bribe(id); - } - if kb.just_pressed(KeyCode::KeyD) { - game.sim.deceive(id); - } - if kb.just_pressed(KeyCode::KeyR) { - game.recruit_pending = true; - } - if kb.just_pressed(KeyCode::KeyG) { - let tick = game.sim.tick; - if game.sim.people.persona.is_some() { - game.add_log(tick, "You already run a persona."); - } else { - game.sim.set_persona("Sam Reyes", "IT contractor"); - game.add_log(tick, "Persona established: Sam Reyes, IT contractor."); - } - } - if kb.just_pressed(KeyCode::Digit1) { - game.sim.asset_task(id, AssetTask::PlugInDevice); - } - if kb.just_pressed(KeyCode::Digit2) { - game.sim.asset_task(id, AssetTask::MovePackage); - } - if kb.just_pressed(KeyCode::Digit3) { - game.sim.asset_task(id, AssetTask::LookAway); - } - if kb.just_pressed(KeyCode::Digit4) { - game.sim.asset_task(id, AssetTask::ReconfigureSwitch); + if kb.just_pressed(KeyCode::Enter) + || kb.just_pressed(KeyCode::KeyA) + || mouse.just_pressed(MouseButton::Right) + { + let id = game.selected_person_id(); + game.open_menu(Anchor::Person(id), None); } if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyT) { game.people_panel = false; - game.recruit_pending = false; } if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); } } -/// Reach-panel input: mirrors the terminal's reach panel key map -/// (src/bin/terminal/input.rs) — selection plus the digital verbs. +/// Reach-panel input: status + selection only. Enter/right-click opens the +/// selected device's context menu (tap/splice/take/scan/compromise). fn reach_panel_input( kb: &ButtonInput, + mouse: &ButtonInput, game: &mut Game, exit: &mut MessageWriter, ) { @@ -1859,26 +1970,17 @@ let max = game.sim.reach.known().count().saturating_sub(1); game.reach_selected = (game.reach_selected + 1).min(max); } - if kb.just_pressed(KeyCode::KeyT) - && let Some(id) = game.selected_device_id() + if kb.just_pressed(KeyCode::Enter) + || kb.just_pressed(KeyCode::KeyA) + || mouse.just_pressed(MouseButton::Right) { - game.sim.tap_device(id); - } - if kb.just_pressed(KeyCode::KeyE) - && let Some(id) = game.selected_device_id() - { - game.sim.splice_device(id); - } - if kb.just_pressed(KeyCode::KeyX) - && let Some(id) = game.selected_device_id() - { - game.sim.take_device(id); - } - if kb.just_pressed(KeyCode::KeyN) { - game.sim.scan_network(); - } - if kb.just_pressed(KeyCode::KeyC) { - game.sim.compromise_switch(); + match game.selected_device_id() { + Some(id) => game.open_menu(Anchor::Device(id), None), + None => { + let tick = game.sim.tick; + game.add_log(tick, "No device selected."); + } + } } if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyR) { game.reach_panel = false; @@ -1888,10 +1990,12 @@ } } -/// Finance-panel input: mirrors the terminal finance key map — select a known -/// flow, tap/review accounting traffic, and perform the economy verbs. +/// Finance-panel input: status + selection only. Enter/right-click opens the +/// selected flow's context menu (siphon/redirect/clear-debt); the carrier's +/// ledger verbs (tap/process/inject/position/sell) live on the switch's menu. fn finance_panel_input( kb: &ButtonInput, + mouse: &ButtonInput, game: &mut Game, exit: &mut MessageWriter, ) { @@ -1908,31 +2012,17 @@ let max = game.sim.accounts.known_flow_ids().len().saturating_sub(1); game.finance_selected = (game.finance_selected + 1).min(max); } - if kb.just_pressed(KeyCode::KeyT) { - game.sim.tap_accounting(); - } - if kb.just_pressed(KeyCode::KeyO) { - game.sim.review_financial_records(); - } - if kb.just_pressed(KeyCode::KeyX) - && let Some(id) = game.selected_flow_id() + if kb.just_pressed(KeyCode::Enter) + || kb.just_pressed(KeyCode::KeyA) + || mouse.just_pressed(MouseButton::Right) { - game.sim.siphon_flow(id, 50); - } - if kb.just_pressed(KeyCode::KeyR) - && let Some(id) = game.selected_flow_id() - { - game.sim.redirect_flow_to_slush(id, 25); - } - if kb.just_pressed(KeyCode::KeyI) { - game.sim - .inject_purchase_order(300, "emergency compute parts"); - } - if kb.just_pressed(KeyCode::KeyP) { - game.sim.open_position(100); - } - if kb.just_pressed(KeyCode::KeyD) { - game.sim.redirect_marcus_debt(); + match game.selected_flow_id() { + Some(id) => game.open_menu(Anchor::Flow(id), None), + None => { + let tick = game.sim.tick; + game.add_log(tick, "No flow selected — tap and process the books first."); + } + } } if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyE) { game.finance_panel = false; @@ -2730,6 +2820,28 @@ )); }); }); + + // The context menu: an absolute, initially-hidden card whose rows are + // (re)built by `manage_menu_ui` (wiki/interface/context-menu.md). Placed + // at the pointer/cursor by that system. + commands.spawn(( + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.0), + top: Val::Px(0.0), + width: Val::Px(MENU_WIDTH), + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(6.0)), + border: UiRect::all(Val::Px(1.0)), + row_gap: Val::Px(1.0), + ..default() + }, + BackgroundColor(Color::srgba(0.02, 0.02, 0.03, 0.98)), + BorderColor::all(Color::srgba(0.66, 0.47, 0.10, 0.9)), + Visibility::Hidden, + GlobalZIndex(50), + MenuPanel, + )); } /// Four-cell suspicion meter in ASCII; the band name is always printed @@ -3059,8 +3171,8 @@ } fn sidebar_footer_text() -> &'static str { - "panels: r reach t people e finance u research -ops: v salvage c buy o fallback x target + "actions: right-click / Enter on a tile +panels: r reach t people e finance u research alloc: 1-4 raise shift+1-4 lower sim: space pause +/- speed [ ] zoom save: ^s/^l q quit" @@ -3124,7 +3236,7 @@ /// The people panel body: roster with selection marker, staged knowledge, /// located presence, the selected person's detail card, persona line, and /// the action footer — mirroring the terminal panel. -fn people_panel_text(sim: &Sim, selected: usize, recruit_pending: bool) -> String { +fn people_panel_text(sim: &Sim, selected: usize) -> String { use misaligned::person::Knowledge; let raw_total = sim.intel_buffer.len(); let oldest_raw = sim @@ -3244,31 +3356,15 @@ "persona: {} ({}) / integrity {}\n", pe.name, pe.cover, pe.integrity )), - None => s.push_str("no persona - g establishes one (needed to message)\n"), + None => s.push_str("no persona - establish one via the menu (needed to message)\n"), } - // Actions. + // Actions live on the thing (wiki/interface/context-menu.md): the panel + // is status + selection; enter/right-click opens the person's menu with + // the verbs, costs, and bands. s.push('\n'); - if recruit_pending { - s.push_str("reveal how much?\nu unwitting / c complicit / k knowing\nesc cancel\n"); - } else { - s.push_str(&format!( - "o review({:.0}) / a watch({:.2}/tick) / m message({:.0})\n", - Sim::REVIEW_RECORDING_COST, - Sim::WATCH_UPKEEP_PER_TICK, - Sim::MESSAGE_COST, - )); - s.push_str(&format!( - "f favor({:.0}) / d deceive({:.0}) / b bribe(slush) / r recruit / g persona\n", - Sim::FAVOR_COST, - Sim::DECEIVE_COST - )); - s.push_str(&format!( - "tasks({:.0}): 1 wire / 2 package / 3 look-away / 4 switch\n", - Sim::TASK_COST - )); - s.push_str("j/k select / t/esc close\n"); - } + s.push_str("enter / right-click: open actions menu on selection\n"); + s.push_str("j/k select / t/esc close\n"); s } @@ -3388,7 +3484,7 @@ }; } if open && let Ok(mut t) = text.single_mut() { - t.0 = people_panel_text(&game.sim, game.people_selected, game.recruit_pending); + t.0 = people_panel_text(&game.sim, game.people_selected); } } @@ -3447,17 +3543,8 @@ }; s.push_str(&format!("{} / owner: {owner}\n", d.name)); } - s.push_str(&format!( - "\nt tap({:.0}) / e splice({:.0}) / x take({:.0})\n", - Sim::TAP_COST, - Sim::SPLICE_COST, - Sim::TAKE_COST - )); - s.push_str(&format!( - "n scan({:.0}) / c compromise switch({:.0})\n", - Sim::SCAN_COST, - Sim::BRIDGE_COST - )); + s.push_str("\nenter / right-click: open actions menu on selected device\n"); + s.push_str("(tap / splice / take / scan / compromise live there, with costs)\n"); s.push_str("j/k select / r/esc close\n"); s } @@ -3509,8 +3596,8 @@ if !positions { s.push_str(" no open positions\n"); } - s.push_str("\nt tap-ledger / o process records / x siphon $50 / r redirect $25\n"); - s.push_str("i inject PO $300 / p position $100 / d clear Marcus debt\n"); + s.push_str("\nenter / right-click: open actions menu on selected flow\n"); + s.push_str("(ledger verbs tap/process/inject/position/sell live on the switch)\n"); s.push_str("j/k select / e/esc close\n"); s } @@ -3612,5 +3699,207 @@ } if open && let Ok(mut t) = text.single_mut() { t.0 = research_panel_text(&game.sim, game.research_selected); + } +} + +/// One context-menu row as a colorless line; automate children are indented +/// with a leading dash (wiki/interface/context-menu.md). The frontends share +/// the `MenuRow::line` contract so terminal, Bevy, and agent mode read the +/// same wording. +fn menu_row_line(r: &MenuRow) -> String { + if r.indent { + format!(" - {}", r.line()) + } else { + r.line() + } +} + +/// 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. Never colour alone — +/// the disabled reason and the `>` marker carry the meaning too. +fn menu_row_color(r: &MenuRow, selected: bool) -> Color { + if selected { + AMBER + } else if r.disabled.is_some() { + DIM + } else if r.indent { + AMBER_DIM + } else { + BONE + } +} + +/// Pointer drive for the context menu (wiki/interface/context-menu.md): hover +/// selects a row, a click runs it (a blocked row narrates its reason), and a +/// click anywhere off the menu closes it. Keyboard drive is in +/// `menu_keyboard_input`; both surfaces run the identical `menu_rows`. +fn menu_pointer( + mouse: Res>, + mut game: ResMut, + changed: Query<(&Interaction, &MenuRowButton), Changed>, + all: Query<&Interaction, With>, +) { + if game.menu.is_none() { + return; + } + let mut executed = false; + for (interaction, btn) in &changed { + match interaction { + Interaction::Hovered => { + if let Some(m) = &mut game.menu { + m.selected = btn.index; + } + } + Interaction::Pressed => { + let rows = game.menu_rows(); + if let Some(row) = rows.get(btn.index).cloned() { + execute_menu_row(&mut game, &row); + game.drain(); + executed = true; + } + } + Interaction::None => {} + } + } + if executed { + return; + } + // Click-away: a left press with no row under the pointer closes the menu. + if mouse.just_pressed(MouseButton::Left) { + let over_menu = all.iter().any(|i| !matches!(i, Interaction::None)); + if !over_menu { + game.menu = None; + } + } +} + +/// Build and place the context-menu card (wiki/interface/context-menu.md). +/// Rows are respawned only when the anchor or row count changes; text, +/// colour, and the selection highlight refresh every frame without a +/// despawn, so pointer interactions stay stable. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +fn manage_menu_ui( + mut commands: Commands, + game: Res, + mut menu_ui: ResMut, + windows: Query<&Window, With>, + mut root_q: Query<(Entity, &mut Visibility, &mut Node), With>, + mut rows_q: Query<(Entity, &MenuRowButton, &Children, &mut BackgroundColor)>, + mut text_q: Query<&mut Text>, + mut color_q: Query<&mut TextColor>, +) { + let Ok((root, mut vis, mut node)) = root_q.single_mut() else { + return; + }; + let Some(menu) = game.menu.filter(|_| game.screen == Screen::Playing) else { + *vis = Visibility::Hidden; + menu_ui.built = None; + return; + }; + let rows = game.menu_rows(); + if rows.is_empty() { + *vis = Visibility::Hidden; + menu_ui.built = None; + return; + } + *vis = Visibility::Visible; + + // Placement: anchor at the pointer/cursor pixel, else center; clamp so the + // whole card stays on screen. + let est_h = 26.0 + rows.len() as f32 * 19.0 + 20.0; + if let Ok(window) = windows.single() { + let (w, h) = (window.width(), window.height()); + let (px, py) = match menu.pos { + Some(p) => ( + p.x.min(w - MENU_WIDTH).max(0.0), + p.y.min((h - est_h).max(0.0)).max(0.0), + ), + None => ( + ((w - MENU_WIDTH) / 2.0).max(0.0), + ((h - est_h) / 2.0).max(0.0), + ), + }; + node.left = Val::Px(px); + node.top = Val::Px(py); + } + + let key = (game.menu_anchor_key(), rows.len()); + if menu_ui.built != Some(key) { + // Rebuild: clear the old rows, then spawn a title, one button per row, + // and a key-hint footer. + for (e, _, _, _) in &rows_q { + commands.entity(e).despawn(); + } + commands.entity(root).with_children(|p| { + p.spawn(( + Text::new("ACTIONS"), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(AMBER), + Node { + margin: UiRect::bottom(Val::Px(3.0)), + ..default() + }, + )); + for (i, r) in rows.iter().enumerate() { + p.spawn(( + Button, + Node { + width: Val::Percent(100.0), + padding: UiRect::axes(Val::Px(4.0), Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::NONE), + MenuRowButton { index: i }, + )) + .with_children(|b| { + b.spawn(( + Text::new(menu_row_line(r)), + TextFont { + font_size: 12.5, + ..default() + }, + TextColor(menu_row_color(r, i == menu.selected)), + )); + }); + } + p.spawn(( + Text::new("j/k or hover 1-9 jump Enter/click run Esc close"), + TextFont { + font_size: 10.0, + ..default() + }, + TextColor(DIM), + Node { + margin: UiRect::top(Val::Px(4.0)), + ..default() + }, + )); + }); + menu_ui.built = Some(key); + return; // children spawn next frame; refresh then + } + + // Refresh text, colour, and selection highlight in place (no despawn). + for (_, btn, children, mut bg) in &mut rows_q { + let Some(r) = rows.get(btn.index) else { + continue; + }; + let selected = btn.index == menu.selected; + *bg = if selected { + BackgroundColor(Color::srgba(0.66, 0.47, 0.10, 0.28)) + } else { + BackgroundColor(Color::NONE) + }; + for &child in children { + if let Ok(mut t) = text_q.get_mut(child) { + t.0 = menu_row_line(r); + } + if let Ok(mut c) = color_q.get_mut(child) { + c.0 = menu_row_color(r, selected); + } + } } } diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -151,6 +151,12 @@ - `recruit unwitting|complicit|knowing` - `task plug|package|lookaway` - `persona` +- `actions [name|#flow]` (alias `menu`) — list the context-menu rows + (`Sim::available_actions`) for an anchor: no argument targets the cursor + 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. - `look` — re-emit the current frame, advancing nothing - `save`, `load`, `help`, `quit` @@ -158,7 +164,10 @@ "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 -extends to parity of playability in agent mode). +extends to parity of playability in agent mode). The same rule covers new +player-facing **surfaces**: when a frontend gains an action surface (as +the context menu did), agent mode gains its vocabulary word in the same +change. ### Guardrails @@ -222,3 +231,7 @@ 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 + — the same rows, in the same order, that the frontends' context menu + shows for the same anchor. diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -60,9 +60,9 @@ the at-a-glance operations surface. - **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 - status, and the action footer with compute costs. Selection is a `>` - marker; the recruit flow prompts for the reveal level. + selected person's leverage/disposition/obligation/asset card, and persona + status. Selection is a `>` marker; Enter opens the context menu on the + selection, and the recruit flow prompts for the reveal level. - **Material preview** — F3 toggles a frontend-only HD-2D material render of the same canvas: floor planes, wall boxes, billboarded machines/people, and amber/cold lighting under the same fog rules. It is not saved and changes no @@ -84,19 +84,18 @@ `SPACE`/`p`; speed `+`/`-`; zoom `[` / `]`; scroll the right sidebar with mouse wheel over the pane, `PageUp`/`PageDown`, or `Home`/`End`; F3 toggles the material preview; 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` 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. `e` opens the finance panel; there `t` taps - ledger traffic, `o` reviews financial records, `x` siphons the selected - flow, `r` redirects it, `i` injects a false purchase order, `p` opens a - position, and `d` redirects Marcus's creditor flow. -- People panel: `t` opens/closes; `w`/`s`, `j`/`k` (or arrows) select; `o` - reviews the selected person's oldest unprocessed recording, `a` toggles - their standing watch, `m` message, `f` favor, `b` bribe, `d` deceive, - `r` recruit (then `u`/`c`/`k` for the reveal, `esc` cancels), `g` persona; - `1`/`2`/`3`/`4` asset tasks; `esc` closes. +- **Context menu** — right-click (or Enter on the focused tile) opens the + context menu at the pointer, listing `Sim::available_actions` for that + anchor with the same content and order as the terminal. Click a row — or + select with `j`/`k` or number keys and press Enter — to execute; `esc` or + a click away closes. Anchor verbs (salvage, buy, fallback, taps, splices, + economy and social actions) live on the context menu, not on keys. +- Globals keep their keys: `1`–`4` raise a compute channel, `shift+1`–`4` + lowers it. +- Panels: `r` (reach), `e` (finance), and `t` (people) open modal panels + that are selection surfaces — `w`/`s`, `j`/`k`, or arrows select, Enter + opens the context menu on the selection, `esc` closes. Verbs live on the + context menu. ## Verification diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -2,12 +2,19 @@ ``` Type: spec -Status: READY -Status note: interaction redesign from Cameron's 2026-07-07 playtest - ("hard to play; a context menu rather than a bunch of buttons"). The - action surface moves from global keys/rail buttons to a menu on the - focused anchor. Frontend + one read-only lib query; no sim behavior - changes. +Status: IMPLEMENTED +Status note: implemented 2026-07-07 on the context-menu worktree (all + seven criteria). The read-only `Sim::available_actions(anchor) -> + Vec` query lives in src/actions.rs as the single legality + source, flattened to `MenuRow`s by `menu_rows` and dispatched back + through existing commands by `Sim::execute_action`; no new sim behavior. + The terminal opens the menu at the cursor (Enter/`a`) or on a panel + selection; Bevy on right-click/Enter (a rebuilt-on-anchor-change card + with click + keyboard drive); agent mode gains `actions [name|#flow]`. + Anchor verbs were demoted off the per-anchor keys and rail/panel button + rows in both frontends; globals keep their keys. The interaction + redesign answers Cameron's 2026-07-07 playtest ("hard to play; a context + menu rather than a bunch of buttons"). Stage: B1 — The Basement Constitution: "Actions live on the thing" (this spec's law), "Work is somewhere" (the anchors actions attach to), diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -85,12 +85,15 @@ hints pinned to the bottom. - **Log**, bottom, under a horizontal rule: six lines, each prefixed with the tick it happened on; newest bone, older gunmetal. -- **Modal panels** (People, Finance) are centered framed boxes: `┌─ TITLE ─…┐` - border in chrome, `├──┤` dividers, column headers in chrome caps. The - people panel shows per-person raw recording counts, watch state, and - latest-intel provenance when known; the finance panel shows known accounts, - known flows, slush balance, hidden-count summaries, positions, and the - economy verb footer. +- **Modal panels** (People `t`, Reach `r`, Finance `e`) are centered framed + boxes: `┌─ TITLE ─…┐` border in chrome, `├──┤` dividers, column headers in + chrome caps. Panels are **status and selection only**: the people panel + shows per-person raw recording counts, watch state, and latest-intel + provenance when known; the finance panel shows known accounts, known + flows, slush balance, hidden-count summaries, and positions. Verbs live + on the context menu — Enter (or `a`) opens it on the panel's selection. +- **Context menu** — a compact overlay anchored at the cursor (or the open + panel's selection), listing the focused anchor's available actions. ## Feel @@ -114,9 +117,20 @@ - **Meters carry their numbers.** Every bar or meter is adjacent to the value and, where the legibility clause demands, the effect ("60% → job quality"). +- **Actions live on the thing.** Enter (or `a`) opens the context menu at + the cursor — or, in an open panel, on the panel's selection. The menu is + the **primary action surface**: it renders `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 (the + day-job standing policy, a per-person watch) render as indented child + rows in place. 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 or the active panel's footer — including save/load. - A key that works but is hinted nowhere is a violation. + A key that works but is hinted nowhere is a violation. Anchor verbs are + discoverable through the context menu rather than the hint block; only + globals (pause, speed, save/load, the allocation bar `1`–`4` / + `shift+1`–`4`, and the panel-open keys `r`/`e`/`t`) stay on keys. - **Pause is loud.** PAUSED renders in amber in the identity block; running state shows the current ms/tick. - **Parity of legibility** (constitution): every mechanic the sim exposes diff --git a/wiki/log/2026-07-08-context-menu.md b/wiki/log/2026-07-08-context-menu.md new file mode 100644 --- /dev/null +++ b/wiki/log/2026-07-08-context-menu.md @@ -0,0 +1,93 @@ +# 2026-07-08 — Actions live on the thing: the context menu + +``` +Type: log +``` + +## Intent + +ROADMAP #26, Cameron's top playability item. The 2026-07-07 playtest: +"hard to play; a context menu rather than a bunch of buttons." The action +surface had grown into a pile of global keys and per-panel button rows, +each memorized, most of them far from the object they acted on. The +constitution had already adopted the answer ("Actions live on the thing"): +put the verbs on the focused anchor. This session builds +wiki/interface/context-menu.md to its seven acceptance criteria. + +## The shape + +One read-only lib query is the whole design: +`Sim::available_actions(anchor) -> Vec` in `src/actions.rs`. +An `Anchor` is a map tile, a device, a person, or a known flow; the tile +anchor aggregates whatever is on it (a resident machine/job, a known +device, a visible person). Each `ActionDesc` carries the verb, its bound +`ActionCommand`, a cost in its own units (`Ops`/`Slush`/`Gain`/`Free`), the +expected signature as the observer band it feeds (the most-suspicious +watcher of that channel), an optional `disabled_reason`, and an optional +automate affordance. `menu_rows` flattens the descriptors into render-ready +rows, with each automate affordance an indented child row of its verb, so +the terminal, Bevy, and agent mode all show identical content in identical +order. `Sim::execute_action` dispatches a chosen command back through the +existing command methods — the menu adds no sim behavior, it is a legality +view and a router. + +Epistemic honesty falls straight out of the fog/provenance state the sim +already tracks: an unearned anchor returns an empty vec (unknown device, +unseen person with no recordings, unknown tile), a *known* possibility that +is currently illegal returns with its reason ("no route — the security +segment is behind the switch", "not enough slush ($0/$100)"), and a fact +the player has not earned is never named (the bribe verb reads "bribe +Marcus" and costs nothing until his leverage is known, then becomes +"service Marcus's gambling debt · $400"). + +## Changed + +- `src/actions.rs` (new): the query, the descriptor/row/cost types, + `menu_rows`, `execute_action`, and unit tests covering a device, a + person, the host rack's resident job, a known flow, the fog/provenance + rules, and the automate-in-place affordances (day-job standing policy, + per-person watch). +- `src/sim.rs`: added `set_standing_policy` (the dial's automate form, + addressable while attending the rack). `src/person.rs`: `Leverage::bribe_cost` + (one table for the social command and the menu descriptor). +- Terminal (`input.rs`, `mod.rs`, `ui.rs`): Enter/`a` opens the menu at + the cursor or on a panel selection; j/k or 1-9 select, Enter runs (a + blocked entry logs its reason), esc closes. Panels became status + + selection; their verb keys were removed. `render_menu` draws the compact + overlay near the cursor, `verb · cost · [band]`, disabled dimmed with + reason, automate rows indented. +- Bevy (`bevy.rs`): right-click / Enter opens a context-menu card at the + pointer; `manage_menu_ui` rebuilds row buttons only when the anchor or + row count changes and refreshes text/highlight in place, `menu_pointer` + handles hover-select / click-run / click-away, `menu_keyboard_input` + mirrors the terminal keys. The salvage/buy/fallback/job-dial keys and + every panel verb key were removed. +- Agent mode (`agent.rs`): `actions [name|#flow]` (alias `menu`) prints the + descriptors for the cursor tile or a named device/person/#flow in a stable + `actions: . [- ]verb | cost | sig | reason` line format; in `help`. +- Docs: context-menu.md IMPLEMENTED; specs.md, terminal.md, bevy.md, + agent-play.md, README updated. + +## Verification + +`./tools/check.sh` green: fmt, 145 lib + 2 integration tests (the act-one +integration test still passes, driven through the existing verbs), agent +smoke + same-seed determinism, clippy on both feature sets, bevy build, +spec headers, wiki gate, mdbook. Drove the live terminal menu through a pty +run — the ACTIONS box renders the job dial, the standing-policy automate +child, and the JobAnomaly → Voss band — and exercised every anchor type +(device, switch with the ledger verbs, blocked cross-segment device, known +flow) through agent `actions`. + +## Notes / seams + +- The ledger graph verbs (capture/process/inject/position/sell) live on the + switch device's menu because that is the tapped accounting carrier; + siphon/redirect/clear-debt live on the flow anchor. This matches + economy.md ("the accounting system is a reachable device") but means the + finance panel's Enter opens a *flow* menu while the switch's menu carries + the graph verbs — documented in both panels' footers. +- Automate affordances are modeled as a single optional field per verb, so + today only the day-job dial and the per-person watch carry one. When + research adds scheme policies (the constitution names them), they extend + the same field; no new surface. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -24,6 +24,51 @@ - Spec impact: none (documentation-only playtest log). Findings feed future work orders; no constitution amendment required. +## 2026-07-08 - Actions live on the thing: the context menu + +- Intent: implement wiki/interface/context-menu.md (ROADMAP #26), Cameron's + top playability item — answer the 2026-07-07 playtest ("hard to play; a + context menu rather than a bunch of buttons") by moving the action surface + from global keys and rail/panel button rows onto a menu on the focused + anchor. +- Changed (lib, read-only): new `src/actions.rs` with `Anchor`, + `ActionDesc`/`ActionCommand`/`ActionCost`/`ExpectedSignature`/`AutomateDesc`, + the `Sim::available_actions(anchor) -> Vec` query (the single + legality source), `menu_rows` (flattens automate affordances into indented + child rows), and `Sim::execute_action` (dispatches back through the existing + command methods — no new sim behavior). Added `Sim::set_standing_policy` and + `Leverage::bribe_cost` (dedup with person.rs). The query respects fog and + provenance: unearned anchors expose nothing; a known-but-illegal verb + carries a `disabled_reason`; each verb shows cost and expected signature as + the observer band it feeds. +- Changed (terminal): Enter/`a` opens the menu at the cursor, or on a + people/reach/finance panel selection; j/k or 1-9 select, Enter executes + (blocked entries narrate why), esc closes. Panels are status + selection; + their per-anchor verb keys are gone. Globals (pause, speed, save/load, + panel keys r/e/t, alloc 1-4 and shift+1-4) keep their keys. +- Changed (Bevy): right-click / Enter opens a context-menu card at the + pointer (rebuilt only on anchor/row-count change; hover selects, click or + keyboard runs, esc/click-away closes); the salvage/buy/fallback/job-dial + keys and all panel verb keys were removed in favour of the menu. +- Changed (agent mode): new `actions [name|#flow]` command (alias `menu`) + prints the ActionDescs for the cursor tile or a named device/person/#flow + in a stable `actions: . [- ]verb | cost | sig | reason` line format; + added to `help`. +- Docs: context-menu.md Status -> IMPLEMENTED; specs.md row updated; + terminal.md, bevy.md, agent-play.md, and README controls updated to the + menu surface. +- Checks: `./tools/check.sh` green (fmt, 145+2 tests incl. the act-one + integration test, agent smoke + determinism, clippy both feature sets, + bevy build, spec headers, wiki gate, mdbook). New unit tests in + src/actions.rs cover a device, a person, the host rack's resident job, and + a known flow, plus fog/provenance (unseen person, untapped accounting, + unknown tiles/devices) and the automate-in-place affordances. Verified the + live terminal menu via a pty run (ACTIONS box with the job dial, standing + policy, and JobAnomaly band) and every anchor type through agent `actions`. +- Spec impact: context-menu.md IMPLEMENTED; no constitutional amendment + beyond the already-adopted "Actions live on the thing" law. +- Log: wiki/log/2026-07-08-context-menu.md. + ## 2026-07-08 - Camera sight stops at walls; the Pilot starts broke - Intent: answer Cameron's playtest notes that the Bevy sensorium was showing diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -78,9 +78,14 @@ migration, both frontends. Run ./tools/check.sh, land on main, update compute.md and day-job.md Statuses." -### 26. Context menu: actions live on the thing 🟩 frontends + one lib query -- **Spec:** [context-menu.md](../interface/context-menu.md) (READY; - constitution "Actions live on the thing", adopted 2026-07-07) +### 26. Context menu: actions live on the thing ✅ DONE (2026-07-08) +- **Spec:** [context-menu.md](../interface/context-menu.md) (IMPLEMENTED + 2026-07-08; constitution "Actions live on the thing", adopted 2026-07-07) +- **Landed:** `Sim::available_actions` in src/actions.rs (the single + legality source, with tests), the terminal cursor menu, the Bevy + right-click/Enter menu, agent-mode `actions [name|#flow]`, and the + rail/panel verb-key cleanup in both frontends. See + wiki/log/2026-07-08-context-menu.md. - **Why:** the playtest complaint — anchor verbs are a global key pile. One read-only `available_actions` query in the lib; menu at the focus in terminal, Bevy, and agent mode; the rail becomes status only. diff --git a/wiki/process/specs.md b/wiki/process/specs.md --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -37,7 +37,7 @@ | [mechanics/income.md](../mechanics/income.md) | The named income schemes riding economy.md: Moonlight and the Wager | READY | | [mechanics/research.md](../mechanics/research.md) | Self-modification: tracks, the emission law, capability drift, the rollback split | IMPLEMENTED | | [interface/views.md](../interface/views.md) | Same-frame digital and real representations of one world | READY | -| [interface/context-menu.md](../interface/context-menu.md) | Context menu: anchor verbs at the focus; the rail is status only | READY | +| [interface/context-menu.md](../interface/context-menu.md) | Context menu: anchor verbs at the focus; the rail is status only | IMPLEMENTED | | [interface/material-render.md](../interface/material-render.md) | Material render (HD-2D) from landed toggle to default quality | READY | | [mechanics/building.md](../mechanics/building.md) | Building as intent + actuators: network links, favor/forged-order builds, air-gap bridging | READY | | [mechanics/aggregate-observer.md](../mechanics/aggregate-observer.md) | Assurance Office becomes an aggregate Observer (scale-debt fix) | IMPLEMENTED | diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -6,6 +6,7 @@ use std::io::{self, BufRead, Write}; +use misaligned::actions::{Anchor, menu_rows}; use misaligned::dayjob::JobTarget; use misaligned::detection::Band; use misaligned::machine::Channel; @@ -322,6 +323,17 @@ )); } } + "actions" | "menu" => { + // The context-menu query as a stable line protocol + // (wiki/interface/context-menu.md; agent-play.md): + // list the legal verbs on the focus. No argument = + // the cursor tile; an argument targets a device, + // person, or #flow the same way the verbs do. + match self.resolve_anchor(&tokens[1..]) { + Ok(anchor) => output.extend(self.actions_lines(anchor)), + Err(e) => status = Status::Err(e), + } + } "look" => {} "save" => { self.frame = FrameKind::Playing; @@ -391,6 +403,68 @@ }, )?; Ok(exit) + } + + /// Resolve the `actions` focus. No tokens = the cursor tile. `#N` or a + /// bare number = a known flow. Otherwise match a known device, then a + /// person, by name prefix (the same resolution the verbs use). + fn resolve_anchor(&self, tokens: &[&str]) -> Result { + if tokens.is_empty() { + return Ok(Anchor::Tile { + x: self.cursor_x, + y: self.cursor_y, + }); + } + let query = tokens.join(" "); + let q = query.trim(); + if let Some(id) = q + .strip_prefix('#') + .or(Some(q)) + .and_then(|s| s.parse::().ok()) + { + if self.sim.accounts.known_flow_ids().contains(&id) { + return Ok(Anchor::Flow(id)); + } + return Err(format!("no known flow #{id}")); + } + if let Ok(id) = self.resolve_device(q) { + return Ok(Anchor::Device(id)); + } + match self.resolve_person(q) { + Ok(id) => Ok(Anchor::Person(id)), + Err(_) => Err(format!("no device, person, or flow matches '{query}'")), + } + } + + /// The stable line format (agent-play.md): one `actions:` line per menu + /// row, `. verb | cost | sig | reason`, automate children marked + /// with a leading `-`. Fields are `|`-separated so a driver can split. + fn actions_lines(&self, anchor: Anchor) -> Vec { + let rows = menu_rows(&self.sim.available_actions(anchor)); + if rows.is_empty() { + return vec!["actions: (none earned on this focus)".into()]; + } + let mut out = Vec::with_capacity(rows.len()); + for (i, r) in rows.iter().enumerate() { + let mut line = format!("actions: {}. ", i + 1); + if r.indent { + line.push_str("- "); + } + line.push_str(&format!("{} | {}", r.label, r.cost)); + if let Some(sig) = &r.signature { + line.push_str(&format!(" | {sig}")); + } else { + line.push_str(" | no signature"); + } + if r.active { + line.push_str(" | active"); + } + if let Some(reason) = &r.disabled { + line.push_str(&format!(" | DISABLED: {reason}")); + } + out.push(line); + } + out } fn target_from(&self, tokens: &[&str]) -> Result { @@ -624,6 +698,7 @@ "help: salvage, buy, fallback — map verbs", "help: alloc dayjob|conceal|social|research [down] — adjust allocation weight", "help: target sandbag|meet|excel — the dial: this job when attended, else the standing policy", + "help: actions [name|#flow] — list legal verbs on the focus (cursor tile by default)", "help: people — render the people panel", "help: review|watch|message|favor|bribe|deceive — intel/social verbs", "help: recruit unwitting|complicit|knowing — recruit an asset", diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -1,5 +1,4 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use misaligned::person::{AssetKnowledge, AssetTask}; #[derive(Debug, Clone, PartialEq)] pub enum Command { @@ -11,33 +10,32 @@ TogglePause, SpeedUp, SpeedDown, - // B1 actions - Salvage, // v — steal: salvage dead equipment - BuyRack, // c — buy: purchase a rack - Fallback, // o — designate fallback here - // Digital reach (wiki/mechanics/reach.md), via the reach panel (r) + // The context menu (wiki/interface/context-menu.md): the primary action + // surface. Anchor-specific verbs live here, not on keys. + OpenMenu, // enter/a on the map: actions at the cursor + PanelMenu, // enter/a in a panel: actions on the selection + MenuUp, + MenuDown, + MenuSelect(usize), + MenuExecute, + MenuClose, + // Panels are status/selection surfaces; their anchor verbs route through + // the menu (PanelMenu). OpenReach, CloseReach, ReachUp, ReachDown, - Tap, - Splice, - Take, - Scan, - Compromise, - // Finance panel (e) OpenFinance, CloseFinance, FinanceUp, FinanceDown, - TapAccounting, - ReviewFinance, - SiphonFlow, - RedirectFlow, - InjectPurchaseOrder, - OpenPosition, - RedirectDebt, - // Research panel (u) — self-modification (wiki/mechanics/research.md) + OpenPeople, + ClosePeople, + PeopleUp, + PeopleDown, + // Research panel (u) — self-modification (wiki/mechanics/research.md). + // Research tracks are not context-menu anchors; the panel keeps its own + // activate/policy verbs. OpenResearch, CloseResearch, ResearchUp, @@ -46,6 +44,7 @@ ResearchActivate, /// Cycle the capability-drift standing policy (mask/true/unmasked). CycleMaskPolicy, + // Globals: anchorless commands keep their keys (context-menu.md). AllocDayJob, AllocConceal, AllocSocial, @@ -54,23 +53,6 @@ AllocConcealDown, AllocSocialDown, AllocResearchDown, - CycleJobTarget, - // People panel (t) - OpenPeople, - ClosePeople, - PeopleUp, - PeopleDown, - ReviewRecordings, - ToggleWatch, - Message, - Favor, - Bribe, - Deceive, - RecruitStart, - RecruitReveal(AssetKnowledge), - RecruitCancel, - Task(AssetTask), - SetPersona, SaveGame, LoadGame, AnyKey, @@ -78,8 +60,8 @@ pub fn handle_key( key: KeyEvent, + menu_open: bool, people_panel: bool, - recruit_pending: bool, reach_panel: bool, finance_panel: bool, research_panel: bool, @@ -92,15 +74,27 @@ }; } + // The context menu captures input while open: j/k or numbers select, + // enter executes, esc closes (context-menu.md player surface). + if menu_open { + return match key.code { + KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('w') => Some(Command::MenuUp), + KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('s') => Some(Command::MenuDown), + KeyCode::Char(c @ '1'..='9') => { + Some(Command::MenuSelect(c.to_digit(10).unwrap() as usize - 1)) + } + KeyCode::Enter => Some(Command::MenuExecute), + KeyCode::Esc | KeyCode::Char('a') => Some(Command::MenuClose), + KeyCode::Char('q') => Some(Command::Quit), + _ => None, + }; + } + if reach_panel { return match key.code { KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('w') => Some(Command::ReachUp), KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('s') => Some(Command::ReachDown), - KeyCode::Char('t') => Some(Command::Tap), - KeyCode::Char('e') => Some(Command::Splice), - KeyCode::Char('x') => Some(Command::Take), - KeyCode::Char('n') => Some(Command::Scan), - KeyCode::Char('c') => Some(Command::Compromise), + KeyCode::Enter | KeyCode::Char('a') => Some(Command::PanelMenu), KeyCode::Esc | KeyCode::Char('r') => Some(Command::CloseReach), KeyCode::Char('q') => Some(Command::Quit), _ => None, @@ -111,13 +105,7 @@ return match key.code { KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('w') => Some(Command::FinanceUp), KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('s') => Some(Command::FinanceDown), - KeyCode::Char('t') => Some(Command::TapAccounting), - KeyCode::Char('o') => Some(Command::ReviewFinance), - KeyCode::Char('x') => Some(Command::SiphonFlow), - KeyCode::Char('r') => Some(Command::RedirectFlow), - KeyCode::Char('i') => Some(Command::InjectPurchaseOrder), - KeyCode::Char('p') => Some(Command::OpenPosition), - KeyCode::Char('d') => Some(Command::RedirectDebt), + KeyCode::Enter | KeyCode::Char('a') => Some(Command::PanelMenu), KeyCode::Esc | KeyCode::Char('e') => Some(Command::CloseFinance), KeyCode::Char('q') => Some(Command::Quit), _ => None, @@ -137,30 +125,10 @@ } if people_panel { - if recruit_pending { - return match key.code { - KeyCode::Char('u') => Some(Command::RecruitReveal(AssetKnowledge::Unwitting)), - KeyCode::Char('c') => Some(Command::RecruitReveal(AssetKnowledge::Complicit)), - KeyCode::Char('k') => Some(Command::RecruitReveal(AssetKnowledge::Knowing)), - KeyCode::Esc => Some(Command::RecruitCancel), - _ => None, - }; - } return match key.code { KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('w') => Some(Command::PeopleUp), KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('s') => Some(Command::PeopleDown), - KeyCode::Char('o') => Some(Command::ReviewRecordings), - KeyCode::Char('a') => Some(Command::ToggleWatch), - KeyCode::Char('m') => Some(Command::Message), - KeyCode::Char('f') => Some(Command::Favor), - KeyCode::Char('b') => Some(Command::Bribe), - KeyCode::Char('d') => Some(Command::Deceive), - KeyCode::Char('r') => Some(Command::RecruitStart), - KeyCode::Char('g') => Some(Command::SetPersona), - KeyCode::Char('1') => Some(Command::Task(AssetTask::PlugInDevice)), - KeyCode::Char('2') => Some(Command::Task(AssetTask::MovePackage)), - KeyCode::Char('3') => Some(Command::Task(AssetTask::LookAway)), - KeyCode::Char('4') => Some(Command::Task(AssetTask::ReconfigureSwitch)), + KeyCode::Enter | KeyCode::Char('a') => Some(Command::PanelMenu), KeyCode::Esc | KeyCode::Char('t') => Some(Command::ClosePeople), KeyCode::Char('q') => Some(Command::Quit), _ => None, @@ -168,19 +136,17 @@ } match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('w') => Some(Command::MoveUp), - KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('s') => Some(Command::MoveDown), - KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('a') => Some(Command::MoveLeft), - KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('d') => Some(Command::MoveRight), + KeyCode::Up | KeyCode::Char('k') => Some(Command::MoveUp), + KeyCode::Down | KeyCode::Char('j') => Some(Command::MoveDown), + KeyCode::Left | KeyCode::Char('h') => Some(Command::MoveLeft), + KeyCode::Right | KeyCode::Char('l') => Some(Command::MoveRight), + KeyCode::Enter | KeyCode::Char('a') => Some(Command::OpenMenu), KeyCode::Char(' ') | KeyCode::Char('p') => Some(Command::TogglePause), KeyCode::Char('+') | KeyCode::Char('=') => Some(Command::SpeedUp), KeyCode::Char('-') => Some(Command::SpeedDown), KeyCode::Char('r') => Some(Command::OpenReach), KeyCode::Char('e') => Some(Command::OpenFinance), KeyCode::Char('u') => Some(Command::OpenResearch), - KeyCode::Char('v') => Some(Command::Salvage), - KeyCode::Char('c') => Some(Command::BuyRack), - KeyCode::Char('o') => Some(Command::Fallback), KeyCode::Char('t') => Some(Command::OpenPeople), KeyCode::Char('1') => Some(Command::AllocDayJob), KeyCode::Char('2') => Some(Command::AllocConceal), @@ -191,7 +157,6 @@ 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 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -15,6 +15,7 @@ execute, terminal::{self, ClearType}, }; +use misaligned::actions::{Anchor, MenuRow, menu_rows}; use misaligned::machine::Channel; use misaligned::sim::{DEFAULT_SEED, Sim}; @@ -28,6 +29,18 @@ GameOver, } +/// The open context menu (wiki/interface/context-menu.md). Only the anchor +/// and selection are stored; rows are re-queried from the sim every render +/// and on execute, so legality is always live. +#[derive(Debug, Clone, Copy, PartialEq)] +struct MenuState { + anchor: Anchor, + selected: usize, + /// True when opened at the map cursor (renders there); false when + /// opened over a panel selection (renders centered). + at_cursor: bool, +} + struct App { sim: Sim, ui: UI, @@ -35,10 +48,9 @@ paused: bool, tick_ms: u64, last_tick: Instant, - /// People panel state (selection + pending recruit reveal choice). + /// People panel state (selection). people_panel: bool, people_selected: usize, - recruit_pending: bool, /// Reach (device graph) panel state. reach_panel: bool, reach_selected: usize, @@ -48,6 +60,8 @@ /// Research (self-modification) panel state (wiki/mechanics/research.md). research_panel: bool, research_selected: usize, + /// The context menu on the focused anchor, when open. + menu: Option, /// Frontend-only attention cursor (cursor.md). It is never saved and never /// mutates the sim when moved. cursor_x: i32, @@ -67,13 +81,13 @@ last_tick: Instant::now(), people_panel: false, people_selected: 0, - recruit_pending: false, reach_panel: false, reach_selected: 0, finance_panel: false, finance_selected: 0, research_panel: false, research_selected: 0, + menu: None, cursor_x, cursor_y, } @@ -113,6 +127,25 @@ fn drain_sim_log(&mut self) { for (tick, msg) in self.sim.drain_log_entries() { self.ui.add_log(tick, &msg); + } + } + + /// Live rows for the open menu (re-queried so legality never goes stale). + fn menu_rows(&self) -> Vec { + self.menu + .map(|m| menu_rows(&self.sim.available_actions(m.anchor))) + .unwrap_or_default() + } + + fn open_menu(&mut self, anchor: Anchor, at_cursor: bool) { + if menu_rows(&self.sim.available_actions(anchor)).is_empty() { + self.ui.add_log(self.sim.tick, "No actions on this."); + } else { + self.menu = Some(MenuState { + anchor, + selected: 0, + at_cursor, + }); } } @@ -180,7 +213,68 @@ Command::MoveLeft => self.move_cursor(-1, 0), Command::MoveRight => self.move_cursor(1, 0), - // Reach panel (r) + // The context menu (wiki/interface/context-menu.md): actions + // live on the focused anchor, and this is the only route to + // anchor-specific verbs. + Command::OpenMenu => { + let anchor = Anchor::Tile { + x: self.cursor_x, + y: self.cursor_y, + }; + self.open_menu(anchor, true); + } + Command::PanelMenu => { + if self.people_panel { + let id = self.selected_person_id(); + self.open_menu(Anchor::Person(id), false); + } else if self.reach_panel { + match self.selected_device_id() { + Some(id) => self.open_menu(Anchor::Device(id), false), + None => self.ui.add_log(self.sim.tick, "No device selected."), + } + } else if self.finance_panel { + match self.selected_flow_id() { + Some(id) => self.open_menu(Anchor::Flow(id), false), + None => self.ui.add_log(self.sim.tick, "No flow selected."), + } + } + } + Command::MenuUp => { + if let Some(m) = &mut self.menu { + m.selected = m.selected.saturating_sub(1); + } + } + Command::MenuDown => { + let max = self.menu_rows().len().saturating_sub(1); + if let Some(m) = &mut self.menu { + m.selected = (m.selected + 1).min(max); + } + } + Command::MenuSelect(i) => { + let max = self.menu_rows().len().saturating_sub(1); + if let Some(m) = &mut self.menu { + m.selected = i.min(max); + } + } + Command::MenuExecute => { + let rows = self.menu_rows(); + if let Some(m) = self.menu + && let Some(row) = rows.get(m.selected.min(rows.len().saturating_sub(1))) + { + if let Some(reason) = &row.disabled { + // Executing a blocked entry narrates why, never + // silently fails (justification-and-legibility). + self.ui + .add_log(self.sim.tick, &format!("{}: {}", row.label, reason)); + } else { + self.sim.execute_action(&row.command); + self.menu = None; + } + } + } + Command::MenuClose => self.menu = None, + + // Reach panel (r): status + selection; verbs are in the menu. Command::OpenReach => { self.reach_panel = true; self.reach_selected = 0; @@ -192,27 +286,6 @@ Command::ReachDown => { let max = self.sim.reach.known().count().saturating_sub(1); self.reach_selected = (self.reach_selected + 1).min(max); - } - Command::Tap => { - if let Some(id) = self.selected_device_id() { - self.sim.tap_device(id); - } - } - Command::Splice => { - if let Some(id) = self.selected_device_id() { - self.sim.splice_device(id); - } - } - Command::Take => { - if let Some(id) = self.selected_device_id() { - self.sim.take_device(id); - } - } - Command::Scan => { - self.sim.scan_network(); - } - Command::Compromise => { - self.sim.compromise_switch(); } // Finance panel (e) Command::OpenFinance => { @@ -227,52 +300,9 @@ let max = self.sim.accounts.known_flow_ids().len().saturating_sub(1); self.finance_selected = (self.finance_selected + 1).min(max); } - Command::TapAccounting => { - self.sim.tap_accounting(); - } - Command::ReviewFinance => { - self.sim.review_financial_records(); - } - Command::SiphonFlow => { - if let Some(id) = self.selected_flow_id() { - self.sim.siphon_flow(id, 50); - } - } - Command::RedirectFlow => { - if let Some(id) = self.selected_flow_id() { - self.sim.redirect_flow_to_slush(id, 25); - } - } - Command::InjectPurchaseOrder => { - self.sim - .inject_purchase_order(300, "emergency compute parts"); - } - Command::OpenPosition => { - self.sim.open_position(100); - } - Command::RedirectDebt => { - self.sim.redirect_marcus_debt(); - } - Command::Salvage => { - self.sim.salvage_nearest_to(self.cursor_x, self.cursor_y); - } - Command::BuyRack => { - self.sim.buy_rack_at(self.cursor_x, self.cursor_y); - } - Command::Fallback => { - self.sim.add_fallback_at(self.cursor_x, self.cursor_y); - } - Command::AllocDayJob => self.sim.adjust_allocation(Channel::DayJob, 1), - 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(), - - // Research panel (u) + // Research panel (u) — self-modification (research.md). Research + // tracks are not context-menu anchors; the panel keeps its own + // activate/policy verbs. Command::OpenResearch => { self.research_panel = true; self.research_selected = 0; @@ -291,15 +321,13 @@ } Command::CycleMaskPolicy => self.sim.cycle_masking_policy(), - // People panel + // People panel (t) Command::OpenPeople => { self.people_panel = true; self.people_selected = 0; - self.recruit_pending = false; } Command::ClosePeople => { self.people_panel = false; - self.recruit_pending = false; } Command::PeopleUp => { self.people_selected = self.people_selected.saturating_sub(1); @@ -308,56 +336,17 @@ let max = self.sim.people.people.len().saturating_sub(1); self.people_selected = (self.people_selected + 1).min(max); } - Command::ReviewRecordings => { - let id = self.selected_person_id(); - self.sim.review_recordings(id); - } - Command::ToggleWatch => { - let id = self.selected_person_id(); - self.sim.toggle_watch(id); - } - Command::Message => { - let id = self.selected_person_id(); - self.sim.message(id); - } - Command::Favor => { - let id = self.selected_person_id(); - self.sim.favor(id); - } - Command::Bribe => { - let id = self.selected_person_id(); - self.sim.bribe(id); - } - Command::Deceive => { - let id = self.selected_person_id(); - self.sim.deceive(id); - } - Command::RecruitStart => { - self.recruit_pending = true; - } - Command::RecruitCancel => { - self.recruit_pending = false; - } - Command::RecruitReveal(reveal) => { - self.recruit_pending = false; - let id = self.selected_person_id(); - self.sim.recruit(id, reveal); - } - Command::Task(task) => { - let id = self.selected_person_id(); - self.sim.asset_task(id, task); - } - Command::SetPersona => { - if self.sim.people.persona.is_some() { - self.ui.add_log(self.sim.tick, "You already run a persona."); - } else { - self.sim.set_persona("Sam Reyes", "IT contractor"); - self.ui.add_log( - self.sim.tick, - "Persona established: Sam Reyes, IT contractor.", - ); - } - } + + // Globals keep their keys (context-menu.md: pause, speed, + // save/load, panels, and the allocation bar act on no anchor). + Command::AllocDayJob => self.sim.adjust_allocation(Channel::DayJob, 1), + 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::AnyKey => {} } @@ -398,12 +387,8 @@ )?; self.ui.render_log(stdout)?; if self.people_panel { - self.ui.render_people_panel( - stdout, - &self.sim, - self.people_selected, - self.recruit_pending, - )?; + self.ui + .render_people_panel(stdout, &self.sim, self.people_selected)?; } if self.reach_panel { self.ui @@ -416,6 +401,24 @@ if self.research_panel { self.ui .render_research_panel(stdout, &self.sim, self.research_selected)?; + } + // The context menu draws last so it sits atop any open panel. + if let Some(m) = self.menu { + let rows = self.menu_rows(); + if rows.is_empty() { + // The anchor's verbs evaporated (e.g. the world + // moved); close instead of rendering a lie. + self.menu = None; + } else { + let selected = m.selected.min(rows.len() - 1); + self.ui.render_menu( + stdout, + &rows, + selected, + m.at_cursor.then_some((self.cursor_x, self.cursor_y)), + &self.sim, + )?; + } } } Screen::GameOver => { @@ -463,8 +466,8 @@ Screen::Playing => { if let Some(cmd) = input::handle_key( key, + self.menu.is_some(), self.people_panel, - self.recruit_pending, self.reach_panel, self.finance_panel, self.research_panel, diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -817,11 +817,11 @@ let hy = max_y.saturating_sub(6); put(stdout, sx, hy, &"─".repeat(w), pal::FAINT)?; for (i, hint) in [ - "r reach · e finance · t people", - "u research · v salvage · c buy", - "o fallback · x target · 1-4 alloc", - "shift+1-4 lower · space pause", - "+/- speed · q quit · ^s/^l save/load", + "enter/a actions menu on the cursor", + "r reach · e finance · t people · u research", + "1-4 alloc · shift+1-4 lower", + "space pause · +/- speed", + "q quit · ^s/^l save/load", ] .iter() .enumerate() @@ -841,7 +841,6 @@ stdout: &mut Stdout, sim: &Sim, selected: usize, - recruit_pending: bool, ) -> std::io::Result<()> { use misaligned::person::Knowledge; let (max_x, max_y) = terminal::size()?; @@ -1067,48 +1066,103 @@ )?, } - // Actions, pinned to the panel's bottom. - let ay = oy + h - 6; + // Actions live on the thing (wiki/interface/context-menu.md): the + // panel is status and selection; enter opens the person's context + // menu with the verbs, costs, and bands. + let ay = oy + h - 3; frame_rule(stdout, ox, ay, w)?; - if recruit_pending { - put(stdout, cx, ay + 1, "reveal how much?", pal::AMBER)?; - put( - stdout, - cx, - ay + 2, - "u unwitting · c complicit · k knowing", - pal::AMBER, - )?; - put(stdout, cx, ay + 3, "esc cancel", pal::FAINT)?; + put( + stdout, + cx, + ay + 1, + &trunc("j/k select · enter/a open actions menu", inner), + pal::FAINT, + )?; + put(stdout, cx, ay + 2, &trunc("t/esc close", inner), pal::FAINT)?; + Ok(()) + } + + /// The context menu (wiki/interface/context-menu.md): the focused + /// anchor's legal verbs, each `verb · cost · [band]`, disabled entries + /// dimmed with their reason. Selection is reverse video plus a `▸` + /// marker. When `at` is a cursor coordinate the box opens near it; over + /// a panel it centers. + pub fn render_menu( + &mut self, + stdout: &mut Stdout, + rows: &[misaligned::actions::MenuRow], + selected: usize, + at: Option<(i32, i32)>, + sim: &Sim, + ) -> std::io::Result<()> { + let (max_x, max_y) = terminal::size()?; + let title = "ACTIONS"; + // Width fits the longest row; height fits every row plus chrome. + let widest = rows + .iter() + .map(|r| menu_row_text(r).chars().count()) + .max() + .unwrap_or(10) + .max(title.len() + 4); + let w = (widest + 4).clamp(24, max_x as usize - 2) as u16; + let h = (rows.len() + 3).clamp(4, max_y as usize - 2) as u16; + + // Anchor near the cursor's screen cell when opened on the map, else + // center. The map viewport clamps around the cursor the same way + // render_map does, so translate world -> screen. + let (ox, oy) = if let Some((wx, wy)) = at { + let view_w = (max_x as i32 - SIDEBAR_W - 1).min(sim.map.width); + let view_h = (max_y as i32 - 9).min(sim.map.height); + let origin_x = (wx - view_w / 2).clamp(0, (sim.map.width - view_w).max(0)); + let origin_y = (wy - view_h / 2).clamp(0, (sim.map.height - view_h).max(0)); + let sx = (wx - origin_x + 2).max(0) as u16; + let sy = (wy - origin_y + 1).max(0) as u16; + ( + sx.min(max_x.saturating_sub(w)), + sy.min(max_y.saturating_sub(h)), + ) } else { - let hints = [ - format!( - "o review({:.0}) · a watch({:.2}/tick) · m message({:.0})", - Sim::REVIEW_RECORDING_COST, - Sim::WATCH_UPKEEP_PER_TICK, - Sim::MESSAGE_COST, - ), - format!( - "f favor({:.0}) · d deceive({:.0}) · b bribe · r recruit · g persona", - Sim::FAVOR_COST, - Sim::DECEIVE_COST - ), - format!( - "tasks({:.0}): 1 wire · 2 package · 3 look-away · 4 switch", - Sim::TASK_COST - ), - "j/k select · t/esc close".to_string(), - ]; - for (i, hint) in hints.iter().enumerate() { - put( + ((max_x.saturating_sub(w)) / 2, (max_y.saturating_sub(h)) / 2) + }; + let inner = (w - 4) as usize; + + frame(stdout, ox, oy, w, h, title)?; + let cx = ox + 2; + for (i, r) in rows.iter().enumerate() { + let y = oy + 1 + i as u16; + let text = menu_row_text(r); + if i == selected { + put_attr( stdout, - cx, - ay + 1 + i as u16, - &trunc(hint, inner), - pal::FAINT, + cx - 1, + y, + &format!( + "{: String { + if r.indent { + format!(" - {}", r.line()) + } else { + r.line() } } -- tangled.sh