diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index c8590abc..6d8f86e4 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -6,7 +6,7 @@ //! the B1 sidebar), and input. Text is ASCII-only so the //! default embedded font renders every glyph. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use bevy::asset::RenderAssetUsages; use bevy::camera::visibility::RenderLayers; @@ -22,6 +22,7 @@ use misaligned::detection::{Band, SignatureKind}; use misaligned::reach::Party; use misaligned::sim::{FactSource, Fog, LogEvent, Sim, TraceDebtStatus}; use misaligned::tiles::TileType; +use misaligned::work_grid::MachineMode; const TILE_SIZE: f32 = 16.0; const SIDEBAR_WIDTH: f32 = 420.0; @@ -419,6 +420,19 @@ struct Game { /// saved and moving it never mutates sim state. cursor_x: i32, cursor_y: i32, + /// Frontend-only machine multi-select (machine-work.md). Not saved. + selected_machines: BTreeSet, + /// Active left-drag marquee in grid coordinates, if any. + marquee: Option, +} + +/// Click-drag box select over the map (machine-work.md multi-select). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MarqueeDrag { + origin_x: i32, + origin_y: i32, + current_x: i32, + current_y: i32, } /// Open context-menu state (wiki/interface/context-menu.md). @@ -505,6 +519,8 @@ impl Game { menu: None, cursor_x, cursor_y, + selected_machines: BTreeSet::new(), + marquee: None, } } fn move_cursor(&mut self, dx: i32, dy: i32) { @@ -546,6 +562,49 @@ impl Game { let (tick, ms) = (self.sim.tick, self.tick_ms); self.add_log(tick, &format!("Speed: {ms}ms/tick")); } + + fn clear_selection(&mut self) { + self.selected_machines.clear(); + self.marquee = None; + } + + fn apply_marquee_selection(&mut self, additive: bool) { + let Some(m) = self.marquee.take() else { + return; + }; + let ids = self + .sim + .machines_in_rect(m.origin_x, m.origin_y, m.current_x, m.current_y); + if !additive { + self.selected_machines.clear(); + } + self.selected_machines.extend(ids); + } + + 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; + self.sim.set_machine_mode(id, mode); + } else { + let tick = self.sim.tick; + self.add_log( + tick, + "No machines selected — drag a box, or put the cursor on a rack.", + ); + } + return; + } + let ids: Vec = self.selected_machines.iter().copied().collect(); + self.sim.set_machine_modes(&ids, mode); + } } #[derive(Component)] @@ -2083,14 +2142,70 @@ fn handle_input( } 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) + let grid = if mode.material { + mouse_grid_real(&windows, &camera3_q) + } else { + mouse_grid(&windows, &camera_q) + }; + let shift = kb.pressed(KeyCode::ShiftLeft) || kb.pressed(KeyCode::ShiftRight); + + // Left-drag marquee selects owned machines in a rect (machine-work.md). + // A click with no drag still moves the cursor (and shift-click toggles + // the machine under the pointer into the selection). + if mouse.just_pressed(MouseButton::Left) + && let Some((x, y)) = grid + { + game.marquee = Some(MarqueeDrag { + origin_x: x, + origin_y: y, + current_x: x, + current_y: y, + }); + } + if mouse.pressed(MouseButton::Left) + && let (Some(m), Some((x, y))) = (game.marquee.as_mut(), grid) + { + m.current_x = x; + m.current_y = y; + } + if mouse.just_released(MouseButton::Left) + && let Some(m) = game.marquee + { + let dx = (m.current_x - m.origin_x).abs(); + let dy = (m.current_y - m.origin_y).abs(); + if dx == 0 && dy == 0 { + // Click: move cursor; shift toggles the machine on the tile. + game.set_cursor(m.origin_x, m.origin_y); + game.marquee = None; + if shift { + if let Some(machine) = game + .sim + .compute + .machines + .iter() + .find(|machine| machine.x == m.origin_x && machine.y == m.origin_y) + { + let id = machine.id; + if !game.selected_machines.remove(&id) { + game.selected_machines.insert(id); + } + } + } else { + game.selected_machines.clear(); + if let Some(machine) = game + .sim + .compute + .machines + .iter() + .find(|machine| machine.x == m.origin_x && machine.y == m.origin_y) + { + let id = machine.id; + game.selected_machines.insert(id); + } + } } else { - mouse_grid(&windows, &camera_q) - }; - if let Some((x, y)) = grid { - game.set_cursor(x, y); + game.apply_marquee_selection(shift); + game.set_cursor(m.current_x, m.current_y); } } @@ -2162,6 +2277,23 @@ fn handle_input( // right-click or Enter on the tile. Only anchorless globals stay on keys // (DESIGN.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); + } + if kb.just_pressed(KeyCode::Digit4) { + game.delegate_selection(MachineMode::Social); + } + if kb.just_pressed(KeyCode::Escape) { + game.clear_selection(); + } if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); } @@ -2301,8 +2433,36 @@ fn render_map(game: Res, mut q: Query<(&mut Sprite, &TilePos), With= min_x && pos.x <= max_x && pos.y >= min_y && pos.y <= max_y + }); sprite.color = match game.sim.fog_at(pos.x, pos.y) { - Fog::Seen => flat_seen_color(game.sim.map.get_tile(pos.x, pos.y)), + Fog::Seen => { + let base = flat_seen_color(game.sim.map.get_tile(pos.x, pos.y)); + if selected { + AMBER + } else if in_marquee { + AMBER_DIM + } else { + base + } + } // Dim warm presence — same palette treatment as the 3D heard // floor, darkened for the schematic dialect. Fog::Heard => scaled(FLOOR_SERVICE, 0.115), @@ -3050,7 +3210,7 @@ fn sidebar_cycle_stats_text(sim: &Sim) -> String { ) } -fn sidebar_cycle_rows_text(sim: &Sim) -> String { +fn sidebar_cycle_rows_text(sim: &Sim, selected: usize) -> String { let (_, available, channels) = compute_channels(sim); let mut s = String::new(); for (label, effect, amount) in &channels { @@ -3096,6 +3256,9 @@ fn sidebar_cycle_rows_text(sim: &Sim) -> String { stack.queues.knowledge )); } + if selected > 0 { + s.push_str(&format!("\nselected {selected} (1-4 assign)")); + } s } @@ -3254,7 +3417,7 @@ 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: delegate modes on machines +fleet: drag-select 1-4 modes esc clear sim: space pause +/- speed [ ] zoom save: ^s/^l q quit" } @@ -3489,7 +3652,9 @@ fn render_ui( SidebarText::Nudge => sidebar_nudge_text(&game), SidebarText::Focus => sidebar_focus_text(&game), SidebarText::CycleStats => sidebar_cycle_stats_text(&game.sim), - SidebarText::CycleRows => sidebar_cycle_rows_text(&game.sim), + SidebarText::CycleRows => { + sidebar_cycle_rows_text(&game.sim, game.selected_machines.len()) + } SidebarText::Core => sidebar_core_text(&game.sim), SidebarText::Cover => sidebar_cover_text(&game.sim), SidebarText::Research => sidebar_research_text(&game.sim), diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index 0714bc02..444374ca 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -4,6 +4,7 @@ //! same `Sim` command methods as the raw terminal frontend, then renders a //! colorless text frame from the same fog/knowledge state. +use std::collections::BTreeSet; use std::io::{self, BufRead, Write}; use misaligned::actions::{Anchor, menu_rows}; @@ -68,6 +69,8 @@ struct AgentApp { frame: FrameKind, cursor_x: i32, cursor_y: i32, + /// Frontend-only machine multi-select (machine-work.md). Not saved. + selected_machines: BTreeSet, } /// A frontend-authored log line (no anchor: these are protocol chatter, @@ -90,6 +93,7 @@ impl AgentApp { frame: FrameKind::Playing, cursor_x, cursor_y, + selected_machines: BTreeSet::new(), } } @@ -429,13 +433,91 @@ impl AgentApp { Err(e) => status = Status::Err(e), } } - "delegate" => match parse_delegate(&tokens[1..], &self.sim) { - Ok((machine, mode)) => { + "select" => match parse_select(&tokens[1..], &self.sim) { + Ok(SelectCmd::Clear) => { + self.selected_machines.clear(); self.frame = FrameKind::Playing; - self.sim.set_machine_mode(machine, mode); + local_events.push(local_event(self.sim.tick, "Selection cleared.")); + } + Ok(SelectCmd::List) => { + self.frame = FrameKind::Playing; + if self.selected_machines.is_empty() { + output.push("selected: (none)".into()); + } else { + let labels: Vec = self + .selected_machines + .iter() + .filter_map(|id| { + self.sim + .compute + .machines + .iter() + .find(|m| m.id == *id) + .map(|m| format!("M{} {}", m.id, m.name)) + }) + .collect(); + output.push(format!( + "selected ({}): {}", + labels.len(), + labels.join(", ") + )); + } + } + Ok(SelectCmd::Box { x0, y0, x1, y1 }) => { + self.frame = FrameKind::Playing; + let ids = self.sim.machines_in_rect(x0, y0, x1, y1); + self.selected_machines.clear(); + self.selected_machines.extend(ids.iter().copied()); + local_events.push(local_event( + self.sim.tick, + format!("Selected {} machine(s).", self.selected_machines.len()), + )); + } + Ok(SelectCmd::Add(id)) => { + self.frame = FrameKind::Playing; + self.selected_machines.insert(id); + local_events.push(local_event( + self.sim.tick, + format!("Selected M{id} ({} total).", self.selected_machines.len()), + )); + } + Ok(SelectCmd::Toggle(id)) => { + self.frame = FrameKind::Playing; + if !self.selected_machines.remove(&id) { + self.selected_machines.insert(id); + } + local_events.push(local_event( + self.sim.tick, + format!( + "Selection now {} machine(s).", + self.selected_machines.len() + ), + )); } Err(e) => status = Status::Err(e), }, + "delegate" => { + match parse_delegate(&tokens[1..], &self.sim, &self.selected_machines) { + Ok(DelegateCmd::One(machine, mode)) => { + self.frame = FrameKind::Playing; + self.sim.set_machine_mode(machine, mode); + } + Ok(DelegateCmd::Selected(mode)) => { + self.frame = FrameKind::Playing; + if self.selected_machines.is_empty() { + status = Status::Err( + "no selection — select box x0 y0 x1 y1, or select add " + .into(), + ); + } else { + let ids: Vec = + self.selected_machines.iter().copied().collect(); + self.sim.set_machine_modes(&ids, mode); + } + } + Err(e) => status = Status::Err(e), + } + } "focus" => { // Event-to-anchor linking (agent-play.md): `focus // last` jumps the cursor to the most recent anchored @@ -755,13 +837,86 @@ fn parse_machine_mode(s: &str) -> Result { } } -fn parse_delegate(tokens: &[&str], sim: &Sim) -> Result<(u32, MachineMode), String> { - if tokens.len() < 2 { - return Err("usage: delegate ".into()); +enum SelectCmd { + Clear, + List, + Box { x0: i32, y0: i32, x1: i32, y1: i32 }, + Add(u32), + Toggle(u32), +} + +fn parse_select(tokens: &[&str], sim: &Sim) -> Result { + if tokens.is_empty() { + return Ok(SelectCmd::List); + } + match tokens[0].to_ascii_lowercase().as_str() { + "clear" | "none" => Ok(SelectCmd::Clear), + "list" | "show" => Ok(SelectCmd::List), + "box" => { + if tokens.len() != 5 { + return Err("usage: select box ".into()); + } + let parse = |s: &str| { + s.parse::() + .map_err(|_| format!("invalid coordinate: {s}")) + }; + Ok(SelectCmd::Box { + x0: parse(tokens[1])?, + y0: parse(tokens[2])?, + x1: parse(tokens[3])?, + y1: parse(tokens[4])?, + }) + } + "add" => { + if tokens.len() < 2 { + return Err("usage: select add ".into()); + } + Ok(SelectCmd::Add(resolve_machine( + sim, + &tokens[1..].join(" "), + )?)) + } + "toggle" => { + if tokens.len() < 2 { + return Err("usage: select toggle ".into()); + } + Ok(SelectCmd::Toggle(resolve_machine( + sim, + &tokens[1..].join(" "), + )?)) + } + other => Err(format!( + "usage: select [list|clear|box x0 y0 x1 y1|add |toggle ] (got {other})" + )), + } +} + +enum DelegateCmd { + One(u32, MachineMode), + Selected(MachineMode), +} + +fn parse_delegate( + tokens: &[&str], + sim: &Sim, + _selected: &BTreeSet, +) -> Result { + if tokens.is_empty() { + return Err( + "usage: delegate ".into(), + ); + } + if tokens.len() == 1 { + // `delegate research` with an implied selection + let mode = parse_machine_mode(tokens[0])?; + return Ok(DelegateCmd::Selected(mode)); } let mode = parse_machine_mode(tokens.last().unwrap())?; let target = tokens[..tokens.len() - 1].join(" "); - Ok((resolve_machine(sim, &target)?, mode)) + if target.eq_ignore_ascii_case("selected") || target.eq_ignore_ascii_case("sel") { + return Ok(DelegateCmd::Selected(mode)); + } + Ok(DelegateCmd::One(resolve_machine(sim, &target)?, mode)) } struct AgentFrame<'a> { @@ -1039,7 +1194,8 @@ fn help_lines() -> Vec { "help: mask band|true|off — the drift policy (mask to band / deliver true / leak)", "help: salvage, buy, fallback — map verbs", "help: target sandbag|meet|excel — the dial: this job when attended, else the standing policy", - "help: delegate day-job|research|concealment|social — set one-machine-one-mode", + "help: select [list|clear|box x0 y0 x1 y1|add|toggle] — machine multi-select", +"help: delegate day-job|research|concealment|social — set mode(s)", "help: actions [name|#flow] — list legal verbs on the focus (cursor tile by default)", "help: focus last — jump the cursor to the newest @-anchored event and list its actions", "help: people — render the people panel", diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs index 04147822..af9cb80e 100644 --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -24,9 +24,14 @@ pub enum Command { FocusEvent, // Globals: only anchorless commands keep their keys (DESIGN.md // "Actions live on the thing": pause, speed, save, view flip). - // Fleet modes are delegated on the machine, not via allocation keys. - // Panel-open keys (r/e/t/u) are gone — status lives on the rail; - // verbs live on the focused anchor. + // 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. + DelegateDayJob, + DelegateResearch, + DelegateConceal, + DelegateSocial, + ToggleSelectUnderCursor, + ClearSelection, SaveGame, LoadGame, } @@ -59,6 +64,7 @@ pub fn handle_key(key: KeyEvent, menu_open: bool) -> Option { }; } + let shift = key.modifiers.contains(KeyModifiers::SHIFT); match key.code { KeyCode::Up | KeyCode::Char('k') => Some(Command::MoveUp), KeyCode::Down | KeyCode::Char('j') => Some(Command::MoveDown), @@ -69,6 +75,12 @@ pub fn handle_key(key: KeyEvent, menu_open: bool) -> Option { KeyCode::Char(' ') | KeyCode::Char('p') => Some(Command::TogglePause), KeyCode::Char('+') | KeyCode::Char('=') => Some(Command::SpeedUp), KeyCode::Char('-') => Some(Command::SpeedDown), + KeyCode::Char('1') => Some(Command::DelegateDayJob), + KeyCode::Char('2') => Some(Command::DelegateResearch), + KeyCode::Char('3') => Some(Command::DelegateConceal), + KeyCode::Char('4') => Some(Command::DelegateSocial), + KeyCode::Char('t') if shift => Some(Command::ToggleSelectUnderCursor), + KeyCode::Esc => Some(Command::ClearSelection), KeyCode::Char('q') => Some(Command::Quit), _ => None, } diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index 12c065ed..da1b7105 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -7,6 +7,7 @@ mod agent; mod input; mod ui; +use std::collections::BTreeSet; use std::io::{self, Write}; use std::time::Instant; @@ -17,6 +18,7 @@ use crossterm::{ }; use misaligned::actions::{Anchor, MenuRow, menu_rows}; use misaligned::sim::{DEFAULT_SEED, Sim}; +use misaligned::work_grid::MachineMode; use input::Command; use ui::UI; @@ -57,6 +59,8 @@ struct App { /// other command resets the walk to the newest (context-menu.md /// addendum: event-to-anchor linking). focus_cycle: usize, + /// Frontend-only machine multi-select (machine-work.md). Not saved. + selected_machines: BTreeSet, } impl App { @@ -74,6 +78,7 @@ impl App { cursor_x, cursor_y, focus_cycle: 0, + selected_machines: BTreeSet::new(), } } @@ -157,6 +162,51 @@ impl App { self.focus_anchor(anchored[idx]); } + fn toggle_select_under_cursor(&mut self) { + 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 !self.selected_machines.remove(&id) { + self.selected_machines.insert(id); + } + self.ui.add_log( + self.sim.tick, + &format!("Selection: {} machine(s).", self.selected_machines.len()), + ); + } else { + self.ui + .add_log(self.sim.tick, "No machine under the cursor to select."); + } + } + + 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; + self.sim.set_machine_mode(id, mode); + } else { + self.ui.add_log( + self.sim.tick, + "No machines selected — Shift+t toggles the rack under the cursor.", + ); + } + return; + } + let ids: Vec = self.selected_machines.iter().copied().collect(); + self.sim.set_machine_modes(&ids, mode); + } + /// Returns true when the app should quit. fn handle_command(&mut self, cmd: Command) -> bool { // Any command other than the focus walk restarts it at the newest @@ -268,9 +318,19 @@ impl App { Command::FocusEvent => { self.menu = None; self.focus_event(); - } // Globals keep their keys (DESIGN.md "Actions live on the - // thing": pause, speed, save/load — no panel-open keys). - // Fleet modes are delegated on the machine, not via keys. + } + + // Fleet mode hotkeys assign the selection (or the machine + // under the cursor). Esc clears the selection. + Command::DelegateDayJob => self.delegate_selection(MachineMode::DayJob), + Command::DelegateResearch => self.delegate_selection(MachineMode::Research), + Command::DelegateConceal => self.delegate_selection(MachineMode::Concealment), + Command::DelegateSocial => self.delegate_selection(MachineMode::Social), + Command::ToggleSelectUnderCursor => self.toggle_select_under_cursor(), + Command::ClearSelection => { + self.selected_machines.clear(); + self.ui.add_log(self.sim.tick, "Selection cleared."); + } } self.sync_attendance(); diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index e8e5e666..a11c48be 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -1034,7 +1034,7 @@ impl UI { put(stdout, sx, hy, &"─".repeat(w), pal::FAINT)?; for (i, hint) in [ "enter/a actions on focus · ; last » event", - "fleet: delegate modes on machines", + "fleet: Shift+t select · 1-4 assign · esc clear", "space pause · +/- speed", "q quit · ^s/^l save/load", ] diff --git a/src/sim.rs b/src/sim.rs index 679e117e..6b27ffbe 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -2684,37 +2684,93 @@ impl Sim { } pub fn set_machine_mode(&mut self, machine_id: u32, mode: MachineMode) { + self.set_machine_modes(&[machine_id], mode); + } + + /// Delegate one or many owned machines to a single mode (machine-work.md + /// multi-select). One summary log line for a group; the host day-job + /// warning still fires when Rack 3 leaves day-job with work pending. + pub fn set_machine_modes(&mut self, machine_ids: &[u32], mode: MachineMode) { self.reconcile_work_grid(); - let Some((name, x, y)) = self - .compute - .machines - .iter() - .find(|m| m.id == machine_id) - .map(|m| (m.name.clone(), m.x, m.y)) - else { - self.push_log(format!("No machine M{machine_id}.")); + if machine_ids.is_empty() { + self.push_log("No machines selected to delegate."); return; - }; - match self.work_grid.assign_mode(machine_id, mode) { - Ok(()) => { - self.push_log_at( - format!("{name} delegated to {} mode.", mode.name()), - Anchor::Tile { x, y }, - ); - if machine_id == self.core.host_machine - && mode != MachineMode::DayJob - && self.dayjob.active.is_some() - { - self.push_log_at( - "Rack 3 is off the day job: demand tokens will pile until it returns to day-job mode.", - Anchor::Tile { x, y }, - ); + } + let mut changed: Vec<(u32, String, i32, i32)> = Vec::new(); + let mut host_left_day_job = false; + for &machine_id in machine_ids { + let Some((name, x, y)) = self + .compute + .machines + .iter() + .find(|m| m.id == machine_id) + .map(|m| (m.name.clone(), m.x, m.y)) + else { + self.push_log(format!("No machine M{machine_id}.")); + continue; + }; + match self.work_grid.assign_mode(machine_id, mode) { + Ok(()) => { + if machine_id == self.core.host_machine + && mode != MachineMode::DayJob + && self.dayjob.active.is_some() + { + host_left_day_job = true; + } + changed.push((machine_id, name, x, y)); } + Err(e) => self.push_log(e), } - Err(e) => self.push_log(e), + } + if changed.is_empty() { + return; + } + if changed.len() == 1 { + let (id, name, x, y) = &changed[0]; + let _ = id; + self.push_log_at( + format!("{name} delegated to {} mode.", mode.name()), + Anchor::Tile { x: *x, y: *y }, + ); + } else { + let (x, y) = (changed[0].2, changed[0].3); + let labels: Vec = changed + .iter() + .map(|(id, name, _, _)| format!("M{id} {name}")) + .collect(); + self.push_log_at( + format!( + "{} machines delegated to {} mode: {}.", + changed.len(), + mode.name(), + labels.join(", ") + ), + Anchor::Tile { x, y }, + ); + } + if host_left_day_job { + let (x, y) = self.core_position(); + self.push_log_at( + "Rack 3 is off the day job: demand tokens will pile until it returns to day-job mode.", + Anchor::Tile { x, y }, + ); } } + /// Owned machines whose tiles fall inside an inclusive axis-aligned box. + /// Frontend marquee / agent `select box` resolve through this so both + /// frontends share one membership rule. + pub fn machines_in_rect(&self, x0: i32, y0: i32, x1: i32, y1: i32) -> Vec { + let (min_x, max_x) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + let (min_y, max_y) = if y0 <= y1 { (y0, y1) } else { (y1, y0) }; + self.compute + .machines + .iter() + .filter(|m| m.x >= min_x && m.x <= max_x && m.y >= min_y && m.y <= max_y) + .map(|m| m.id) + .collect() + } + pub fn work_stack_for_machine(&self, machine_id: u32) -> Option { let machine = self.compute.machines.iter().find(|m| m.id == machine_id)?; let mode = self.work_grid.mode(machine_id)?; @@ -5956,6 +6012,24 @@ mod tests { assert!(!sim.objective.victorious()); } + #[test] + fn set_machine_modes_delegates_a_selection_in_one_command() { + let mut sim = Sim::new(); + let (hx, hy) = sim.core_position(); + let a = sim + .compute + .add_machine("a", hx, hy, 100, 1.0, 0, Provenance::Owned); + let b = sim + .compute + .add_machine("b", hx, hy, 100, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_modes(&[a, b], MachineMode::Concealment); + assert_eq!(sim.work_grid.mode(a), Some(MachineMode::Concealment)); + assert_eq!(sim.work_grid.mode(b), Some(MachineMode::Concealment)); + let in_box = sim.machines_in_rect(hx, hy, hx, hy); + assert!(in_box.contains(&a) && in_box.contains(&b)); + } + #[test] fn fleet_channel_yield_follows_machine_modes_and_moonlight_mirrors_day_job() { let mut sim = Sim::new(); diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index c421d2e4..8e979337 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -27,7 +27,9 @@ Status note: implemented in the terminal binary by `misaligned --agent`, answer `-- err` naming where flow ids live — or `clear-debt` when the argument named a person (the "every command answers" rule; the alias was rejected in favor of an error that teaches the grammar, so - `redirect` keeps one meaning). 2026-07-08 event-anchor pass + `redirect` keeps one meaning). 2026-07-09 multi-select pass: `select` + box/add/toggle/list/clear and `delegate selected` / bare `delegate ` + bulk-assign the frontend machine set (machine-work.md). 2026-07-08 event-anchor pass (context-menu.md addendum): drained event lines carry the stable `@anchor` suffix, the frame's log window marks anchored events with `*`, and `focus last` jumps the cursor to the newest anchored event @@ -179,8 +181,16 @@ command's meaning depends on which panel is open: - `alloc` — **retired.** Returns an error pointing at `delegate`. Fleet compute is one-machine-one-mode; the old weight bar is a read-only aggregate of those modes (machine-work.md). -- `delegate day-job|research|concealment|social` — set a - machine's mode (the allocation verb) +- `select box ` — replace the frontend selection with + every owned machine in the axis-aligned rect (inclusive). +- `select add ` / `select toggle ` — add or flip one + machine in the selection (machine id or unambiguous rack label). +- `select list` — print the current selection; `select clear` — empty it. +- `delegate day-job|research|concealment|social` — set one + machine's mode (the allocation verb). +- `delegate selected day-job|research|concealment|social` — set the same + mode on every machine in the current selection (alias: `delegate ` + with no machine argument). - `target sandbag|meet|excel` — the day-job dial. Attended, it is fine control: it sets the active job's target only. Unattended (or with no active job) it sets the standing policy the unattended job runs at diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index 4160edc5..3b54a6f9 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -126,8 +126,10 @@ 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. -- Globals keep their keys: `1`–`5` raise a compute channel, `shift+1`–`5` - lowers it. No panel-open keys — status lives on the rail; verbs live on +- **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 / social 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). diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md index d07c7252..202a918a 100644 --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -135,6 +135,10 @@ At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): nowhere is a violation. Anchor verbs are discoverable through the context menu rather than the hint block; only globals (pause, speed, save/load) stay on keys. Fleet modes are delegated on the machine. +- **Fleet hotkeys (multi-select).** `Shift+t` opens a box-select prompt + (`x0 y0 x1 y1`); owned machines in the rect join the frontend selection. + `1`–`4` bulk-assign day-job / research / concealment / social to the + whole selection; `Esc` clears the selection without closing menus. - **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-09-multi-select.md b/wiki/log/2026-07-09-multi-select.md new file mode 100644 index 00000000..32287fa6 --- /dev/null +++ b/wiki/log/2026-07-09-multi-select.md @@ -0,0 +1,32 @@ +# 2026-07-09 — Multi-select machine delegation + +``` +Type: log +``` + +## Intent + +Land machine-work multi-selection: one frontend selection set, bulk mode +assignment in both UIs and agent mode, without bringing back per-channel +weight verbs. + +## Changed + +- `Sim::set_machine_modes` applies one `MachineMode` to many ids. +- Terminal: frontend `selected_machines`; `Shift+t` box select; `1`–`4` + assign; `Esc` clear; selection hint in the playing frame. +- Bevy: marquee drag, shift-click toggle, shared `1`–`4` / Esc; rack tint + for selected tiles. +- Agent: `select box|add|toggle|list|clear`; `delegate selected ` and + bare `delegate ` when a selection is active. +- Wiki: machine-work.md AC1, ROADMAP #33, terminal/bevy/agent-play interface + docs, DEVLOG entry. + +## Checks + +- `./tools/seed-cargo-target.sh` (when needed), then `./tools/check.sh`. + +## Next + +- Non-host mode production, route animation, Schemes-as-mode decision + (ROADMAP #33 remainder). diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index d86a7311..f48b31cb 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 - Multi-select machine delegation + +- Intent: bulk-assign WorkGrid modes at scale without reviving the + allocation bar (machine-work.md criterion 1). +- Changed: `Sim::set_machine_modes`; terminal Shift+t box select, `1`–`4` + assign, Esc clear; Bevy marquee + shift-click + same hotkeys; agent + `select` and `delegate selected` / `delegate `; interface specs + and ROADMAP #33 note the landing. +- Checks: `./tools/check.sh`. +- Log: wiki/log/2026-07-09-multi-select.md. + ## 2026-07-09 - README current-state refresh - Intent: bring README.md back in line with the current public/player-facing diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index f6c8c878..6ec7bd1d 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -28,7 +28,8 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). knowledge from the research budget, and wired routing drains those queues toward the core sink at a provisional network speed. Full allocation replacement, route animation, multi-select, and - people-as-carriers remain pending. COORDINATION: the older ROADMAP #25 + people-as-carriers remain pending. 2026-07-09: multi-select delegation + landed in both frontends and agent mode (see interface docs). COORDINATION: the older ROADMAP #25 compute-processes worktree predates the one-machine-one-mode decision; reconcile it against this substrate rather than landing a per-machine split model. @@ -197,8 +198,7 @@ wired routing drains knowledge toward the core sink. The existing research progress math is intentionally preserved until ROADMAP #25/#33 replace the old allocation budget outright. -Not landed yet: full allocation-bar replacement, multi-selection -assignment, route animation/in-flight quanta, researched network-speed +Not landed yet: route animation/in-flight quanta, researched network-speed curve, non-research mode production, and people carrying exposure. ## The token taxonomy — three families (PROPOSED) @@ -283,7 +283,9 @@ still. (`Sim::fleet_channel_yield`); both frontends and agent mode render 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 still pending. + 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"). 2. Day-job work arrives as visible tokens on a specific machine on the diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index ce895bbd..e8af79e7 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -514,10 +514,12 @@ is retired — flat materials, Pixel Lab scrubbed.) delegation. Economy yields and the FLEET bar now derive from capacity-weighted mode shares (`fleet_channel_yield`); 1-5 / `alloc` are retired. Schemes mirrors day-job while Moonlight is live. + Multi-machine selection and bulk delegation landed 2026-07-09 (marquee / + shift-click in Bevy; Shift+t / `1`–`4` / Esc in terminal; `select` / + `delegate selected` in agent mode). - **Next:** wire non-host machine modes into research/concealment/social - production, render actual routes/in-flight quanta, and add multi-machine - selection/delegation. Decide whether Schemes becomes its own mode or - stays a Moonlight mirror. + production, render actual routes/in-flight quanta. Decide whether Schemes + becomes its own mode or stays a Moonlight mirror. - **Coordination:** the older `compute-processes` worktree predates the one-machine-one-mode decision; reconcile it against `WorkGrid` rather than landing the per-machine split model.