diff --git a/README.md b/README.md index 415e467..241d202 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,13 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). | Key | Action | |-----|--------| | `WASD` / `hjkl` / arrows | Move cursor (attention, not a body) | -| Left click | Move cursor (Bevy) | +| Left click | Move cursor; on a rack, select it (Bevy). Shift-click toggles. Drag marquee multi-selects. | | `SPACE` / `p` | Pause / resume time | | `+` / `-` | Simulation speed | -| `Enter` / `a` | Open the context menu on the cursor tile (right-click in Bevy) | +| `Enter` / `e` / `a` | Open the context menu on the selected machine (or cursor tile); right-click in Bevy | +| With machine selected (menu closed): `1`–`4` | Day Job / Research / Concealment / Operations (numpad too) | +| With machine selected (menu closed): `5`–`9` | Nth one-shot action on the primary selected machine | +| `Esc` | Clear selection (or close menu / dial when open) | | In menu: `j`/`k` or numbers, `Enter`, `ESC` | Select a row / execute it / close the menu | | `;` | Focus the most recent anchored (`»`) log event — cursor jumps there, menu opens; repeat to walk older ones (terminal; in Bevy click a `>` trace row) | | `[` / `]` | Zoom (Bevy) | @@ -139,7 +142,7 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). The Bevy right rail keeps status/nudge and core controls pinned while the middle cards scroll for secondary detail. The FLEET bar is read-only; to move -compute, delegate machines from their context menu. +compute, select rack(s) and press `1`–`4`, or open the machine's context menu. Parking the cursor on the host rack attends the current day job; moving it away leaves the job on its standing policy. The host rack's menu also diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index eb6e89d..1d7c6f7 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -781,29 +781,83 @@ impl Game { self.selected_machines.extend(ids); } + /// Primary machine for selection hotkeys: first selected id, else the + /// owned machine under the cursor (context-menu.md selection hotkeys). + fn primary_machine_tile(&self) -> Option<(u32, i32, i32)> { + if let Some(&id) = self.selected_machines.iter().next() + && let Some(m) = self.sim.compute.machines.iter().find(|m| m.id == id) + { + return Some((m.id, m.x, m.y)); + } + self.sim + .compute + .machines + .iter() + .find(|m| m.x == self.cursor_x && m.y == self.cursor_y) + .map(|m| (m.id, m.x, m.y)) + } + fn delegate_selection(&mut self, mode: MachineMode) { if self.selected_machines.is_empty() { // No selection: delegate the machine under the cursor, if any. - if let Some(m) = self - .sim - .compute - .machines - .iter() - .find(|m| m.x == self.cursor_x && m.y == self.cursor_y) - { - let id = m.id; + if let Some((id, _, _)) = self.primary_machine_tile() { self.sim.set_machine_mode(id, mode); + let tick = self.sim.tick; + self.add_log(tick, &format!("Machine -> {}.", mode.name())); } else { let tick = self.sim.tick; self.add_log( tick, - "No machines selected — drag a box, or put the cursor on a rack.", + "No machines selected — click a rack, drag a box, or put the cursor on one.", ); } return; } let ids: Vec = self.selected_machines.iter().copied().collect(); + let n = ids.len(); self.sim.set_machine_modes(&ids, mode); + let tick = self.sim.tick; + self.add_log(tick, &format!("{n} machine(s) -> {}.", mode.name())); + } + + /// Open the context menu on the primary selected machine (or cursor tile). + fn open_menu_on_selection_or_cursor(&mut self, pointer: Option) { + if let Some((_, x, y)) = self.primary_machine_tile() { + self.set_cursor(x, y); + self.open_menu(Anchor::Tile { x, y }, pointer); + } else { + let (x, y) = (self.cursor_x, self.cursor_y); + self.open_menu(Anchor::Tile { x, y }, pointer); + } + } + + /// `5`–`9`: Nth root one-shot action on the primary machine (menu closed). + fn quick_one_shot(&mut self, index: usize) { + let Some((_, x, y)) = self.primary_machine_tile() else { + let tick = self.sim.tick; + self.add_log(tick, "Select a machine first (click a rack)."); + return; + }; + let rows = self.sim.human_menu(Anchor::Tile { x, y }, None); + let one_shots: Vec = rows + .into_iter() + .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent)) + .collect(); + match one_shots.get(index) { + Some(row) => execute_menu_row(self, row), + None => { + let tick = self.sim.tick; + self.add_log( + tick, + &format!( + "No action slot {} on that machine ({} one-shot{}).", + index + 5, + one_shots.len(), + if one_shots.len() == 1 { "" } else { "s" } + ), + ); + } + } } } @@ -3561,9 +3615,9 @@ fn handle_input( 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); + if kb.just_pressed(KeyCode::Enter) || kb.just_pressed(KeyCode::KeyE) { + // Selection hotkeys: open on the primary selected machine when set. + game.open_menu_on_selection_or_cursor(pointer); game.drain(); return; } @@ -3625,23 +3679,42 @@ fn handle_input( let ms = game.tick_ms.saturating_add(50).min(500); game.set_speed(ms); } - // Anchor verbs live on the context menu (wiki/interface/context-menu.md): - // right-click or Enter on the tile. Only anchorless globals stay on keys - // (wiki/vision/simulation-laws.md "Actions live on the thing": pause, speed, save, view flip — - // no panel-open keys). Fleet modes are delegated on the machine. - // Fleet mode hotkeys (machine-work.md): 1-4 assign the selection - // (or the machine under the cursor) when the context menu is closed. - if kb.just_pressed(KeyCode::Digit1) { - game.delegate_selection(MachineMode::DayJob); - } - if kb.just_pressed(KeyCode::Digit2) { - game.delegate_selection(MachineMode::Research); - } - if kb.just_pressed(KeyCode::Digit3) { - game.delegate_selection(MachineMode::Concealment); + // Selection hotkeys (context-menu.md): menu closed only. + // 1-4 modes (numpad too); 5-9 one-shot actions on the primary machine; + // Enter/e open that machine's menu (handled above). + for (key, mode) in [ + (KeyCode::Digit1, MachineMode::DayJob), + (KeyCode::Numpad1, MachineMode::DayJob), + (KeyCode::Digit2, MachineMode::Research), + (KeyCode::Numpad2, MachineMode::Research), + (KeyCode::Digit3, MachineMode::Concealment), + (KeyCode::Numpad3, MachineMode::Concealment), + (KeyCode::Digit4, MachineMode::Operations), + (KeyCode::Numpad4, MachineMode::Operations), + ] { + if kb.just_pressed(key) { + game.delegate_selection(mode); + } } - if kb.just_pressed(KeyCode::Digit4) { - game.delegate_selection(MachineMode::Operations); + for (i, key) in [ + KeyCode::Digit5, + KeyCode::Numpad5, + KeyCode::Digit6, + KeyCode::Numpad6, + KeyCode::Digit7, + KeyCode::Numpad7, + KeyCode::Digit8, + KeyCode::Numpad8, + KeyCode::Digit9, + KeyCode::Numpad9, + ] + .into_iter() + .enumerate() + { + // Digit5/Numpad5 -> index 0; pairs share the same slot. + if kb.just_pressed(key) { + game.quick_one_shot(i / 2); + } } if kb.just_pressed(KeyCode::Escape) { game.clear_selection(); @@ -4856,11 +4929,12 @@ fn log_row_text(ev: &LogEvent) -> String { } fn sidebar_footer_text() -> &'static str { - "actions: right-click / Enter on a tile -trace: click a > event to go there -fleet: drag-select 1-4 modes esc clear -sim: space pause +/- speed [ ] zoom -save: ^s/^l q quit" + "select rack: click / drag +modes: 1-4 actions: 5-9 / e +menu: right-click / Enter / e +trace: click a > event +esc clear sel space pause ++/- speed [ ] zoom ^s/^l q" } fn update_compute_bar(sim: &Sim, bars: &mut ComputeBarQuery) { diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs index 8c705e6..dab2e3a 100644 --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -24,12 +24,14 @@ pub enum Command { FocusEvent, // Globals: only anchorless commands keep their keys (wiki/vision/simulation-laws.md // "Actions live on the thing": pause, speed, save, view flip). - // Fleet mode hotkeys assign the selection (or the machine under the - // cursor). Panel-open keys (r/e/t/u) are gone — status lives on the rail. + // Selection hotkeys (context-menu.md): modes 1-4; one-shots 5-9 on the + // primary selected machine (or cursor rack). Panel-open keys are gone. DelegateDayJob, DelegateResearch, DelegateConceal, DelegateOperations, + /// Execute the Nth root one-shot action (0 = key `5`) on the primary machine. + QuickAction(usize), ToggleSelectUnderCursor, ClearSelection, SaveGame, @@ -70,7 +72,7 @@ pub fn handle_key(key: KeyEvent, menu_open: bool) -> Option { 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::Enter | KeyCode::Char('a') | KeyCode::Char('e') => Some(Command::OpenMenu), KeyCode::Char(';') => Some(Command::FocusEvent), KeyCode::Char(' ') | KeyCode::Char('p') => Some(Command::TogglePause), KeyCode::Char('+') | KeyCode::Char('=') => Some(Command::SpeedUp), @@ -79,6 +81,9 @@ pub fn handle_key(key: KeyEvent, menu_open: bool) -> Option { KeyCode::Char('2') => Some(Command::DelegateResearch), KeyCode::Char('3') => Some(Command::DelegateConceal), KeyCode::Char('4') => Some(Command::DelegateOperations), + KeyCode::Char(c) if ('5'..='9').contains(&c) => { + Some(Command::QuickAction((c as u8 - b'5') as usize)) + } KeyCode::Char('t') if shift => Some(Command::ToggleSelectUnderCursor), KeyCode::Esc => Some(Command::ClearSelection), KeyCode::Char('q') => Some(Command::Quit), diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index d05e484..b6eef32 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -188,17 +188,27 @@ impl App { } } + /// Primary machine for selection hotkeys: first selected id, else cursor. + fn primary_machine_tile(&self) -> Option<(u32, i32, i32)> { + if let Some(&id) = self.selected_machines.iter().next() + && let Some(m) = self.sim.compute.machines.iter().find(|m| m.id == id) + { + return Some((m.id, m.x, m.y)); + } + self.sim + .compute + .machines + .iter() + .find(|m| m.x == self.cursor_x && m.y == self.cursor_y) + .map(|m| (m.id, m.x, m.y)) + } + fn delegate_selection(&mut self, mode: MachineMode) { if self.selected_machines.is_empty() { - if let Some(m) = self - .sim - .compute - .machines - .iter() - .find(|m| m.x == self.cursor_x && m.y == self.cursor_y) - { - let id = m.id; + if let Some((id, _, _)) = self.primary_machine_tile() { self.sim.set_machine_mode(id, mode); + self.ui + .add_log(self.sim.tick, &format!("Machine -> {}.", mode.name())); } else { self.ui.add_log( self.sim.tick, @@ -208,7 +218,48 @@ impl App { return; } let ids: Vec = self.selected_machines.iter().copied().collect(); + let n = ids.len(); self.sim.set_machine_modes(&ids, mode); + self.ui.add_log( + self.sim.tick, + &format!("{n} machine(s) -> {}.", mode.name()), + ); + } + + /// `5`–`9`: Nth root one-shot action on the primary machine (menu closed). + fn quick_one_shot(&mut self, index: usize) { + let Some((_, x, y)) = self.primary_machine_tile() else { + self.ui + .add_log(self.sim.tick, "Select a machine first (Shift+t on a rack)."); + return; + }; + let rows = self.sim.human_menu(Anchor::Tile { x, y }, None); + let one_shots: Vec<_> = rows + .into_iter() + .filter(|r| matches!(r, HumanMenuRow::Action(a) if !a.indent)) + .collect(); + match one_shots.get(index) { + Some(HumanMenuRow::Action(action)) => { + if let Some(reason) = &action.disabled { + self.ui + .add_log(self.sim.tick, &format!("{}: {}", action.label, reason)); + } else { + self.sim.execute_action(&action.command); + } + } + Some(HumanMenuRow::Dial { .. }) => {} + None => { + self.ui.add_log( + self.sim.tick, + &format!( + "No action slot {} on that machine ({} one-shot{}).", + index + 5, + one_shots.len(), + if one_shots.len() == 1 { "" } else { "s" } + ), + ); + } + } } /// Returns true when the app should quit. @@ -275,11 +326,18 @@ impl App { // 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); + // Prefer the primary selected machine when a selection is set. + if let Some((_, x, y)) = self.primary_machine_tile() { + self.cursor_x = x; + self.cursor_y = y; + self.open_menu(Anchor::Tile { x, y }, true); + } else { + let anchor = Anchor::Tile { + x: self.cursor_x, + y: self.cursor_y, + }; + self.open_menu(anchor, true); + } } Command::MenuUp => { if let Some(m) = &mut self.menu { @@ -346,12 +404,12 @@ impl App { self.focus_event(); } - // Fleet mode hotkeys assign the selection (or the machine - // under the cursor). Esc clears the selection. + // Selection hotkeys (context-menu.md): modes 1-4, one-shots 5-9. Command::DelegateDayJob => self.delegate_selection(MachineMode::DayJob), Command::DelegateResearch => self.delegate_selection(MachineMode::Research), Command::DelegateConceal => self.delegate_selection(MachineMode::Concealment), Command::DelegateOperations => self.delegate_selection(MachineMode::Operations), + Command::QuickAction(i) => self.quick_one_shot(i), Command::ToggleSelectUnderCursor => self.toggle_select_under_cursor(), Command::ClearSelection => { self.selected_machines.clear(); diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index cf0276e..ac35e20 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -1247,8 +1247,8 @@ impl UI { let hy = max_y.saturating_sub(6); put(stdout, sx, hy, &"─".repeat(w), pal::FAINT)?; for (i, hint) in [ - "enter/a actions on focus · ; last » event", - "fleet: Shift+t select · 1-4 assign · esc clear", + "enter/e/a menu · ; last » event", + "select: Shift+t · 1-4 modes · 5-9 acts · esc", "space pause · +/- speed", "q quit · ^s/^l save/load", ] diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index 101d2f9..32501ba 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -149,12 +149,16 @@ controls table), plus zoom, sidebar scrolling, and the render flip: 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. -- **Fleet hotkeys (multi-select).** Drag a marquee on the map to select - owned machines in the box; shift-click toggles one machine; `1`–`4` - assign Day Job / Research / Concealment / Operations to the selection; `Esc` - clears it. No panel-open keys — status lives on the rail; verbs live on - the focused anchor (host rack for research / off-map people, device - tiles and the switch for reach / flows). +- **Fleet / selection hotkeys (menu closed).** Drag a marquee on the map + to select owned machines in the box; plain click selects the rack under + the pointer; shift-click toggles. With a selection (or the cursor on a + rack): `1`–`4` / numpad assign Day Job / Research / Concealment / + Operations; `5`–`9` fire the Nth root one-shot action on the primary + selected machine; `Enter` / `e` open the context menu on that machine; + `Esc` clears the selection. No panel-open keys — status lives on the + rail; verbs still come from `human_menu` / `available_actions` (host + rack for research / off-map people, device tiles and the switch for + reach / flows). ## Verification diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index 8decb96..45969e6 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -32,6 +32,9 @@ Status note: implemented 2026-07-07 on the context-menu worktree (all `Sim::human_menu` collapses mutually exclusive standing choices into dial rows on terminal/Bevy; agent `actions` stays a flat dump. ROADMAP #36. + 2026-07-09 selection hotkeys: with a machine selected (or under cursor), + `1`–`4` assign modes, `5`–`9` fire root one-shots, `Enter`/`e` open that + machine's menu — thin keyboard surface over the same human_menu source. Stage: B1 — The Basement Design: - wiki/vision/simulation-laws.md#actions-live-on-the-thing @@ -79,25 +82,40 @@ is status and telemetry only. drift policy, known-flow verbs, person verbs, and machine mode delegation — lives on the focused anchor's menu. There are no panel-open keys. +- **Selection hotkeys (DECIDED 2026-07-09, menu closed).** A thin + keyboard surface over the same `human_menu` / `available_actions` + source — it does not invent verbs. When the fleet selection is + non-empty **or** the cursor sits on an owned machine: + - `1`–`4` (and numpad) assign Day Job / Research / Concealment / + Operations to the selection (cursor machine as fallback). + - `5`–`9` execute the Nth root one-shot action (non-dial, non-automate + indent) on the **primary** selected machine, else the machine under + the cursor. Disabled rows still fire so the reason narrates. + - `Enter` / `e` (and terminal `a`) open the context menu on that same + primary machine (cursor moves there when the selection owns the + focus). While the menu is open, number keys select rows as before; + Esc clears the selection only when the menu is closed. ## Player surface -- **Terminal:** cursor + Enter (or `a`) opens a compact overlay list at - the cursor; j/k or numbers select; Enter executes (or opens a dial +- **Terminal:** cursor + Enter (or `a`/`e`) opens a compact overlay list + at the cursor (or the primary selected machine when a selection is + active); j/k or numbers select; Enter executes (or opens a dial picker); esc closes the picker or the menu. One-shot entries render `verb cost [band]` with disabled entries dimmed plus reason. Dial - rows follow the status-dials addendum below. The host rack's menu - carries the day-job / research / drift dials, machine mode, and earned - off-map people; a known device's tile (or the switch) carries digital - and ledger/flow verbs; a visible person on a tile carries their social - verbs. -- **Bevy:** right-click (or Enter on the focused tile) opens the menu at - the pointer; same content, same order, same dial chrome; click to - execute or open a dial; esc/click-away closes. Bevy ASCII-folds the - shared `MenuRow::line` separators (`·`/`—` -> `|`/`-`) because the - embedded default font lacks those glyphs; selection is a solid amber - wash with a leading `>` (terminal reverse-video parity). See - wiki/interface/bevy.md. + rows follow the status-dials addendum below. Selection hotkeys above + apply with the menu closed. The host rack's menu carries the day-job / + research / drift dials, machine mode, and earned off-map people; a + known device's tile (or the switch) carries digital and ledger/flow + verbs; a visible person on a tile carries their social verbs. +- **Bevy:** right-click (or Enter/`e` on the focused / primary-selected + machine) opens the menu at the pointer; same content, same order, same + dial chrome; click to execute or open a dial; esc/click-away closes. + Bevy ASCII-folds the shared `MenuRow::line` separators (`·`/`—` -> + `|`/`-`) because the embedded default font lacks those glyphs; + selection is a solid amber wash with a leading `>` (terminal + reverse-video parity). Selection hotkeys above apply with the menu + closed. See wiki/interface/bevy.md. - **Agent mode:** an `actions` command prints the ActionDescs for the current focus in a stable flat line format (no dial chrome — scripting wants the legality list); execution stays the existing verbs. diff --git a/wiki/log/2026-07-09-machine-hotkeys.md b/wiki/log/2026-07-09-machine-hotkeys.md new file mode 100644 index 0000000..c47ffaf --- /dev/null +++ b/wiki/log/2026-07-09-machine-hotkeys.md @@ -0,0 +1,33 @@ +# 2026-07-09 — Selection keyboard surface for machines + +``` +Type: log +``` + +## Intent + +Cameron asked for keyboard shortcuts when a machine is selected: change +modes and do actions without hunting the context menu. + +## Changed + +- Spec: `context-menu.md` selection-hotkeys clause; `bevy.md` fleet section; + `machine-work.md` criterion 1 multi-select note; README controls table. +- Bevy + terminal (menu closed): + - `1`–`4` / numpad assign Day Job / Research / Concealment / Operations + to the selection (cursor machine fallback), with a short log line. + - `5`–`9` execute the Nth root one-shot action on the primary selected + machine (or the rack under the cursor) via `human_menu` — same + legality source as the menu; disabled rows narrate their reason. + - `Enter` / `e` (terminal also `a`) open the context menu on that + primary machine, moving the cursor there when the selection owns it. +- Sidebar / footer hints updated to name the map. + +## Checks + +`./tools/check.sh` (frontend-impacting). + +## Spec impact + +context-menu.md IMPLEMENTED gains a selection-hotkeys clause; machine-work +multi-select criterion notes the keyboard surface. No new sim verbs. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 7e78b3e..6b64c33 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,17 @@ Type: log ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-09 - Machine selection hotkeys (modes + actions) + +- Intent: keyboard surface when a rack is selected — change modes and fire + one-shot actions without opening the menu first. +- Changed: Bevy + terminal `1`–`4`/numpad modes with feedback; `5`–`9` + quick one-shots on the primary selected machine; `Enter`/`e` open that + machine's menu; context-menu.md / bevy.md / machine-work / README. +- Checks: ./tools/check.sh (frontend). +- Spec impact: context-menu.md selection-hotkeys clause; no new sim verbs. +- Log: wiki/log/2026-07-09-machine-hotkeys.md. + ## 2026-07-09 - Review copy says "waiting", not "raw" - Intent: person menu `(N raw)` read as pipeline slang; player should diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index 8bcc690..3614e49 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -508,10 +508,13 @@ still. the bar as a read-only fleet aggregate (no 1-5 / `alloc` verbs); single-machine reassignment is exposed through the shared context menu and agent `delegate`. Multi-selection is implemented (2026-07-09): - Bevy marquee drag and shift-click toggle; terminal Shift+t box select, - `1`–`4` bulk assign, Esc clear; agent `select` and `delegate selected`. - Schemes has no machine mode yet — while Moonlight is live it mirrors - the day-job share ("same work, sold twice"). + Bevy marquee drag / click / shift-click; terminal Shift+t toggle; + `1`–`4` (numpad too) bulk assign; `5`–`9` quick one-shot actions on the + primary selected machine; `Enter`/`e` open that machine's menu; Esc + clear; agent `select` and `delegate selected`. Selection hotkeys are + the thin keyboard surface in context-menu.md (2026-07-09). Schemes has + no machine mode yet — while Moonlight is live it mirrors the day-job + share ("same work, sold twice"). 2. Day-job work arrives as visible tokens on a specific machine on the Lab's schedule; leaving them unconsumed has the day-job consequence (trust/suspicion), and the stack is visible without any panel.