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() } }