From fcd1bdaa90ed8dee201de4fed66f426fbbfccdb3 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 24 Jul 2026 13:46:04 -0800 Subject: [PATCH] Let a selection outrank the pointer for machine keys. Reported from play: modes appeared to jump between machines with no visible reason. The mode keys resolved the hovered rack before the explicit selection, so a mouse left resting over another rack took the keypress and then silently replaced the selection, giving the next press a different target again. Intensity had the same order, so two adjacent keys could act on different machines. The terminal already resolved its selection first; Bevy was the outlier. Selection now wins, then the pointer's rack, then the cursor's; a hover never replaces a selection; and every commit names the rack it moved instead of counting anonymous machines. Defense: amends context-menu.md's two target-order clauses and bevy.md, which documented the pointer-first order this reverses. Three new tests pin the precedence, the no-selection fallback, and the named commit line. --- crates/misaligned-bevy/src/main.rs | 228 +++++++++++++++--- wiki/interface/bevy.md | 8 +- wiki/interface/context-menu.md | 25 +- .../2026-07-24-selection-outranks-hover.md | 51 ++++ 4 files changed, 262 insertions(+), 50 deletions(-) create mode 100644 wiki/log/2026-07-24-selection-outranks-hover.md diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 33765035..cba8a068 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -1107,74 +1107,130 @@ impl Game { self.machine_at(self.cursor_x, self.cursor_y) } - /// `1`–`3`: hover rack wins; else bulk-assign the selection; else cursor. - /// A single-rack act also makes that rack the explicit selection so the - /// stable selection-level mode control and its target cannot disagree. + /// `1`–`3`: an explicit selection wins; else the rack under the pointer; + /// else the cursor's rack. A single-rack act also makes that rack the + /// explicit selection so the stable selection-level mode control and its + /// target cannot disagree. + /// + /// Selection outranks hover because selecting is a deliberate act and the + /// pointer is wherever it was last left. When hover won, resting the mouse + /// over another rack silently retargeted the key *and* replaced the + /// selection, so the same keypress hit different machines from one press + /// to the next with nothing on screen explaining why. fn delegate_hotkey(&mut self, mode: MachineMode, hover: Option<(i32, i32)>) { - // Hover over a rack: one press, that machine only. - if let Some((x, y)) = hover - && let Some((id, _, _)) = self.machine_at(x, y) - { - self.sim.set_machine_mode(id, mode); - self.set_cursor(x, y); - // Keep a single-machine selection so the selection frame and - // stable mode control match the exact committed target. - self.selected_machines.clear(); - self.selected_machines.insert(id); - let tick = self.sim.tick; - self.add_log(tick, &format!("Machine -> {}.", mode.name())); - return; - } + // 1. The explicit selection, whatever the pointer is doing. if !self.selected_machines.is_empty() { 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())); + let what = self.machine_list_label(&ids); + self.add_log(tick, &format!("{what} -> {}.", mode.name())); + return; + } + // 2. Nothing selected: the rack under the pointer, which also becomes + // the selection so the next press has an unambiguous target. + if let Some((x, y)) = hover + && let Some((id, _, _)) = self.machine_at(x, y) + { + self.commit_single_machine_mode(id, x, y, mode); return; } + // 3. Neither: the keyboard cursor's rack. if let Some((id, x, y)) = self.machine_at(self.cursor_x, self.cursor_y) { - self.sim.set_machine_mode(id, mode); - self.set_cursor(x, y); - self.selected_machines.clear(); - self.selected_machines.insert(id); - let tick = self.sim.tick; - self.add_log(tick, &format!("Machine -> {}.", mode.name())); + self.commit_single_machine_mode(id, x, y, mode); return; } let tick = self.sim.tick; self.add_log( tick, - "Hover a rack and press 1-3 (or select machines first).", + "No machine selected — click a rack, or hover one, then press 1-3.", ); } + /// Commit one rack and leave it selected, so the selection frame, the + /// bottom-center mode control, and the next hotkey all name the same + /// machine. + fn commit_single_machine_mode(&mut self, id: u32, x: i32, y: i32, mode: MachineMode) { + self.sim.set_machine_mode(id, mode); + self.set_cursor(x, y); + self.selected_machines.clear(); + self.selected_machines.insert(id); + let tick = self.sim.tick; + let what = self.machine_list_label(&[id]); + self.add_log(tick, &format!("{what} -> {}.", mode.name())); + } + + /// Name what a bulk control just moved. "1 machine(s)" cannot be checked + /// against the world; a rack's own name can. + fn machine_list_label(&self, ids: &[u32]) -> String { + let mut names: Vec = ids + .iter() + .filter_map(|id| { + self.sim + .compute + .machines + .iter() + .find(|machine| machine.id == *id) + .map(|machine| machine.name.clone()) + }) + .collect(); + names.sort(); + match names.len() { + 0 => "No machine".to_string(), + 1 => names.remove(0), + 2 => format!("{} and {}", names[0], names[1]), + n => format!("{} and {} more", names[0], n - 1), + } + } + + /// `i`: same target order as the mode hotkeys — explicit selection first, + /// then the pointer's rack, then the cursor's. The two controls sit on + /// adjacent keys and must never disagree about what they act on. fn cycle_intensity_selection(&mut self, hover: Option<(i32, i32)>) { let Some((primary, _, _)) = self.primary_machine_tile(hover) else { let tick = self.sim.tick; self.add_log( tick, - "No machine in focus — hover a rack, select one, or put the cursor on one.", + "No machine selected — click a rack, or hover one, then press i.", ); return; }; - let next = self - .sim - .work_grid - .intensity(primary) - .unwrap_or(MachineIntensity::Medium) - .cycle(); + if !self.selected_machines.is_empty() { + let ids: Vec = self.selected_machines.iter().copied().collect(); + // Cycle from the selection's own primary, not from whatever the + // pointer is over. + let anchor = ids.iter().copied().min().unwrap_or(primary); + let next = self.next_intensity(anchor); + self.sim.set_machine_intensities(&ids, next); + let tick = self.sim.tick; + let what = self.machine_list_label(&ids); + self.add_log(tick, &format!("{what} -> {} effort.", next.name())); + return; + } if let Some((id, x, y)) = hover.and_then(|(x, y)| self.machine_at(x, y)) { + let next = self.next_intensity(id); self.sim.set_machine_intensity(id, next); self.set_cursor(x, y); self.selected_machines.clear(); self.selected_machines.insert(id); - } else if self.selected_machines.is_empty() { - self.sim.set_machine_intensity(primary, next); - } else { - let ids: Vec = self.selected_machines.iter().copied().collect(); - self.sim.set_machine_intensities(&ids, next); + let tick = self.sim.tick; + let what = self.machine_list_label(&[id]); + self.add_log(tick, &format!("{what} -> {} effort.", next.name())); + return; } + let next = self.next_intensity(primary); + self.sim.set_machine_intensity(primary, next); + let tick = self.sim.tick; + let what = self.machine_list_label(&[primary]); + self.add_log(tick, &format!("{what} -> {} effort.", next.name())); + } + + fn next_intensity(&self, machine: u32) -> MachineIntensity { + self.sim + .work_grid + .intensity(machine) + .unwrap_or(MachineIntensity::Medium) + .cycle() } /// Open the context menu at the keyboard focus without moving it. @@ -6075,3 +6131,99 @@ fn manage_menu_ui( } } } + +#[cfg(test)] +mod machine_hotkey_target_tests { + use super::Game; + use misaligned::work_grid::MachineMode; + + /// Own a second rack so hover and selection can disagree — B1 opens with + /// only the host. Salvage is the player's own route to one. + fn second_rack(game: &mut Game) -> Option<(u32, i32, i32)> { + let known: Vec = game.sim.compute.machines.iter().map(|m| m.id).collect(); + let bays: Vec<(i32, i32)> = (0..game.sim.map().height) + .flat_map(|y| (0..game.sim.map().width).map(move |x| (x, y))) + .collect(); + for (x, y) in bays { + game.sim + .execute_action(&misaligned::actions::ActionCommand::Salvage { x, y }); + if let Some(machine) = game + .sim + .compute + .machines + .iter() + .find(|m| !known.contains(&m.id)) + { + return Some((machine.id, machine.x, machine.y)); + } + } + None + } + + /// Selecting is deliberate; the pointer is wherever it was left. A + /// selected rack must take the keypress even while the mouse rests on a + /// different rack — and the selection must survive it. + #[test] + fn an_explicit_selection_outranks_the_hovered_rack() { + let mut game = Game::new(); + let host = game.sim.core.host_machine; + let Some((other, ox, oy)) = second_rack(&mut game) else { + return; // no salvageable wreck in this opening; nothing to contrast + }; + assert_ne!(host, other); + + // A mode the opening already allows, and one the host is not in. + game.sim.set_machine_mode(host, MachineMode::Work); + let hovered_before = game.sim.work_grid.mode(other); + + game.selected_machines.clear(); + game.selected_machines.insert(host); + game.delegate_hotkey(MachineMode::Think, Some((ox, oy))); + + assert_eq!( + game.sim.work_grid.mode(host), + Some(MachineMode::Think), + "the selected rack takes the key" + ); + assert_eq!( + game.sim.work_grid.mode(other), + hovered_before, + "the merely-hovered rack must not move" + ); + assert!( + game.selected_machines.contains(&host) && game.selected_machines.len() == 1, + "a hover must not silently replace the player's selection" + ); + } + + /// With nothing selected the pointer is still the natural target, and the + /// rack it commits becomes the selection so the next press is unambiguous. + #[test] + fn with_no_selection_the_hovered_rack_takes_the_key() { + let mut game = Game::new(); + let Some((other, ox, oy)) = second_rack(&mut game) else { + return; + }; + game.selected_machines.clear(); + game.delegate_hotkey(MachineMode::Think, Some((ox, oy))); + assert_eq!(game.sim.work_grid.mode(other), Some(MachineMode::Think)); + assert!(game.selected_machines.contains(&other)); + } + + /// The log has to name what moved: "1 machine(s)" cannot be checked + /// against the world, which is how a wrong target goes unnoticed. + #[test] + fn the_commit_log_names_the_machine() { + let mut game = Game::new(); + let host = game.sim.core.host_machine; + game.selected_machines.clear(); + game.selected_machines.insert(host); + game.delegate_hotkey(MachineMode::Work, None); + let last = game.log.last().map(|entry| entry.text.clone()).unwrap(); + assert!( + last.contains("Rack") && last.contains("work"), + "the commit line names its target: {last}" + ); + assert!(!last.contains("machine(s)"), "{last}"); + } +} diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index bb065f8e..e573387d 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -235,9 +235,11 @@ controls table), plus zoom, sidebar scrolling, and the render flip: held-choice actions live on the selected Operations object. - **Focus / fleet hotkeys (menu closed).** Press `1`–`3` / numpad to assign WORK / THINK / LIE **immediately** — not enter → mode dial → row → enter. - The target resolves in the existing order: owned rack under the pointer, - primary machine selection, then owned rack under the attention reticule. - A single-rack commit also makes that rack the explicit selection. While a + The target resolves in order (amended 2026-07-24): the explicit machine + selection, then the owned rack under the pointer, then the owned rack under + the attention reticule. A single-rack commit also makes that rack the + explicit selection, and every commit names the rack it moved in the log so + a wrong target cannot pass unnoticed. While a machine selection exists, one bare `WORK / THINK / LIE` control occupies a stable bottom-center slot inside the world canvas. Pointer, reticule, camera, selected-machine position, and DIGITAL/REAL view cannot move it. Merely diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index e9ee1145..6fff0a4a 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -138,16 +138,23 @@ wire or host that transports them. selection-control placement amended 2026-07-19; menu closed).** A thin focused-machine surface: mode and one-shot keys reuse `human_menu` / `available_actions`; intensity calls the lib-owned - machine setter directly and keeps no frontend state. Target order is (1) - the machine under the pointer, (2) the multi-selection if any, (3) the - keyboard cursor's machine. Terminal has no pointer hover, so its cursor is - the first target. + machine setter directly and keeps no frontend state. Target order + (**amended 2026-07-24**) is (1) the explicit machine selection if any, + (2) the machine under the pointer, (3) the keyboard cursor's machine. + Selection outranks hover because selecting is a deliberate act while the + pointer is merely wherever it was last left: under the old order, resting + the mouse over another rack silently retargeted the key *and* replaced the + selection, so the same press moved different machines from one moment to + the next with nothing on screen explaining why. Terminal has no pointer + hover, so its cursor remains its own last resort. - `1`–`3` (and numpad) assign WORK / THINK / LIE to that target in one press, not through a dial path. Acting on one hovered or cursor rack also establishes it as the explicit machine selection. The visible mode control belongs to that selection, not to passive hover or reticule focus. - - `i` cycles intensity light -> medium -> hard on the hovered/cursor - machine, or sets the whole selection to the primary machine's next + - `i` cycles intensity light -> medium -> hard on that same target in that + same order — the two controls sit on adjacent keys and must never + disagree about what they act on — setting the whole selection to its own + primary's next intensity. It does not open a menu. - `5`–`9` execute the Nth committed world action on the same target. Dials, persistent controls, scheme state, and automation policies do not @@ -278,9 +285,9 @@ remain `TAP`, then `UNTAP / TAKE` after subscription. A shared baseline is forbidden because it falsely suggests that the next number keys commit the device verbs and recreates the crowding this split removes. -- Direct mode input keeps target order: (1) an owned machine under the pointer, - (2) the machine selection, then (3) an owned machine under the attention - reticule. The visible mode control resolves only the explicit machine +- Direct mode input keeps target order (amended 2026-07-24): (1) the machine + selection, (2) an owned machine under the pointer, then (3) an owned machine + under the attention reticule. The visible mode control resolves only the explicit machine selection. Moving the pointer or reticule never moves, retargets, reveals, or hides it. A single-machine direct commit establishes that selection. The line appears only during active play with the full context menu closed. diff --git a/wiki/log/2026-07-24-selection-outranks-hover.md b/wiki/log/2026-07-24-selection-outranks-hover.md new file mode 100644 index 00000000..e22d5b11 --- /dev/null +++ b/wiki/log/2026-07-24-selection-outranks-hover.md @@ -0,0 +1,51 @@ +# 2026-07-24 — A selection outranks the pointer + +``` +Type: log +``` + +## Intent + +Reported from play: "I'm not able to easily, independently change modes +for different machines… it seems like it's jumping around, and I'm having +a hard time understanding why some different machines are changing and +some are not." + +## Cause + +`delegate_hotkey` resolved the pointer **first**: hovered rack, then the +selection, then the cursor. So with a rack deliberately selected, if the +mouse happened to rest over a different rack, `1`/`2`/`3` moved the +hovered one — and then silently cleared the selection and replaced it +with that rack. The next press therefore had a different target again. +Nothing on screen explained either substitution, and the commit line read +"1 machine(s) -> work", which cannot be checked against the world. + +`i` (intensity) had the same ordering, so the two adjacent keys could act +on different machines in the same moment. + +The terminal frontend already resolved its selection first. Bevy was the +outlier; the two now agree. + +## Changed + +- Target order is the explicit machine selection, then the rack under the + pointer, then the cursor's rack. Selecting is a deliberate act; the + pointer is wherever it was last left. +- A hover no longer replaces an existing selection. Committing on a + hovered or cursor rack (only reachable when nothing is selected) still + makes that rack the selection, so the next press is unambiguous. +- `i` follows the same order, cycling from the selection's own primary + rather than from whatever the pointer is over. +- Every commit names the rack it moved (`Rack 3 -> think.`), and the + empty-target message says what to do rather than assuming a hover. +- Amends `wiki/interface/context-menu.md` (both target-order clauses) and + `wiki/interface/bevy.md`, which documented the old pointer-first order. + +## Verification + +`cargo test -p misaligned-bevy` — 126 passed, three new: a selected rack +takes the key while a different rack is hovered and the selection +survives; with nothing selected the hovered rack still takes it and +becomes the selection; and the commit line names its target instead of +counting anonymous machines. -- 2.51.2