diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index f9a9fdf3..2d6d1fc4 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -3961,6 +3961,33 @@ fn movement(kb: &ButtonInput) -> (i32, i32) { d } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PersistenceInput { + Save, + Load, +} + +/// Save/load captures only a complete chord, never a bare held modifier. +/// macOS native overlays can leave Bevy's per-key modifier state pressed +/// until another modifier event arrives; treating that state as a modal input +/// branch used to make every unrelated key appear dead in the meantime. +fn persistence_input(kb: &ButtonInput) -> Option { + let modifier = kb.pressed(KeyCode::ControlLeft) + || kb.pressed(KeyCode::ControlRight) + || kb.pressed(KeyCode::SuperLeft) + || kb.pressed(KeyCode::SuperRight); + if !modifier { + return None; + } + if kb.just_pressed(KeyCode::KeyS) { + Some(PersistenceInput::Save) + } else if kb.just_pressed(KeyCode::KeyL) { + Some(PersistenceInput::Load) + } else { + None + } +} + fn mouse_grid( windows: &Query<&Window, With>, camera_q: &Query<(&Camera, &GlobalTransform), With>, @@ -4045,35 +4072,34 @@ fn handle_input( Screen::Playing => {} } - // Save/load first, like the terminal's modifier branch: a held modifier - // routes only to save/load. - let ctrl = kb.pressed(KeyCode::ControlLeft) - || kb.pressed(KeyCode::ControlRight) - || kb.pressed(KeyCode::SuperLeft) - || kb.pressed(KeyCode::SuperRight); - if ctrl { - if kb.just_pressed(KeyCode::KeyS) { - let st = game.sim.create_save_state(); - let tick = game.sim.tick; - match misaligned::save::save_game(&st) { - Ok(()) => game.add_log(tick, "Game saved!"), - Err(e) => game.add_log(tick, &format!("Save failed: {e}")), + // Save/load first, but capture only the complete chord. A bare modifier + // is not a modal state: macOS native overlays can leave Bevy reporting + // Command as pressed after the overlay has gone away. + if let Some(input) = persistence_input(&kb) { + match input { + PersistenceInput::Save => { + let st = game.sim.create_save_state(); + let tick = game.sim.tick; + match misaligned::save::save_game(&st) { + Ok(()) => game.add_log(tick, "Game saved!"), + Err(e) => game.add_log(tick, &format!("Save failed: {e}")), + } } - } - if kb.just_pressed(KeyCode::KeyL) { - let tick = game.sim.tick; - if misaligned::save::save_exists() { - match misaligned::save::load_game() { - Ok(st) => { - game.sim.apply_save_state(st); - let (x, y) = game.sim.core_position(); - game.set_cursor(x, y); - game.add_log(tick, "Game loaded!"); + PersistenceInput::Load => { + let tick = game.sim.tick; + if misaligned::save::save_exists() { + match misaligned::save::load_game() { + Ok(st) => { + game.sim.apply_save_state(st); + let (x, y) = game.sim.core_position(); + game.set_cursor(x, y); + game.add_log(tick, "Game loaded!"); + } + Err(e) => game.add_log(tick, &format!("Load failed: {e}")), } - Err(e) => game.add_log(tick, &format!("Load failed: {e}")), + } else { + game.add_log(tick, "No save file found."); } - } else { - game.add_log(tick, "No save file found."); } } game.drain(); @@ -5188,6 +5214,38 @@ mod menu_focus_tests { } } +#[cfg(test)] +mod input_routing_tests { + use super::{PersistenceInput, movement, persistence_input}; + use bevy::{input::ButtonInput, prelude::KeyCode}; + + #[test] + fn stale_modifier_does_not_capture_unrelated_keyboard_input() { + let mut kb = ButtonInput::default(); + kb.press(KeyCode::SuperLeft); + kb.clear(); // a modifier left pressed across frames / a native overlay + kb.press(KeyCode::KeyW); + + assert_eq!(persistence_input(&kb), None); + assert_eq!(movement(&kb), (0, -1)); + } + + #[test] + fn complete_persistence_chords_are_still_captured() { + let mut kb = ButtonInput::default(); + kb.press(KeyCode::ControlLeft); + kb.clear(); + kb.press(KeyCode::KeyS); + assert_eq!(persistence_input(&kb), Some(PersistenceInput::Save)); + + kb.reset_all(); + kb.press(KeyCode::SuperRight); + kb.clear(); + kb.press(KeyCode::KeyL); + assert_eq!(persistence_input(&kb), Some(PersistenceInput::Load)); + } +} + fn trunc(text: &str, width: usize) -> String { text.chars().take(width).collect() } diff --git a/wiki/interface/action-vocabulary.md b/wiki/interface/action-vocabulary.md index 645d7e66..9f20233b 100644 --- a/wiki/interface/action-vocabulary.md +++ b/wiki/interface/action-vocabulary.md @@ -18,6 +18,9 @@ Status note: as-built vocabulary survey completed 2026-07-10 against all ROBOT-BUILD are absent from every player surface until playable; controls carry explicit `[control]` / `CONTROL` presentation and never occupy the `5`-`9` committed-action shortcuts. + Amended 2026-07-10: persistence shortcuts capture keyboard input only when + the complete Ctrl/Command+S or Ctrl/Command+L chord arrives; a bare or stale + modifier state cannot suppress unrelated play input. Stage: Process Design: - wiki/vision/simulation-laws.md#actions-live-on-the-thing @@ -203,7 +206,7 @@ useful interface words, but they do not enlarge the fictional verb set. | **QUICK ACTION** | Execute the Nth visible committed world action; controls never occupy these slots. | `5`-`9` | Use the named action | | **TIME CONTROL** | Human play pauses or changes wall-clock speed; agent play advances an exact number of ticks. | Space/p, +, - | `wait N` | | **INSPECT** | Render current state without creating a world effect. | Focus/rail/context surfaces | `look`, `people`, `reach`, `finance`, `research`, `objective`, `intents` | -| **SAVE / LOAD** | Persist or restore the run. | Ctrl+S / Ctrl+L | `save`, `load` | +| **SAVE / LOAD** | Persist or restore the run. Only the complete chord captures input; a modifier alone is not a modal state. | Ctrl/Command+S / Ctrl/Command+L | `save`, `load` | | **VIEW CONTROL** | Change presentation only. | Bevy zoom, sidebar scroll, F3 render flip; F4 is dev-only | none | | **HELP / QUIT** | Explain accepted input or leave the process. | Hints / q | `help`, `quit` | @@ -334,3 +337,6 @@ mechanic and surfaces: 12. A naive agent-mode discoverability run can find and execute at least one meaningful action from the first frame/help alone; the finance surface teaches TAP LEDGER -> REVIEW LEDGER -> SIPHON/REDIRECT in that order. +13. Human persistence shortcuts capture input only on a complete save/load + chord. A held modifier without `S` or `L` does not block movement, menus, + time controls, or other unrelated commands. diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index d9cae75d..ba4b90c0 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -153,6 +153,11 @@ controls table), plus zoom, sidebar scrolling, and the render flip: material/sensorium; F4 toggles the dev work light (material mode only, a flat neutral flood for inspection, not a player surface); quit `q`; save/load `Ctrl+S` / `Ctrl+L`. +- Persistence modifiers are chord-local rather than modal. Only a complete + Ctrl/Command+S or Ctrl/Command+L chord captures the frame; a bare modifier + state never suppresses unrelated controls. This keeps macOS native overlays + or focus transitions from turning a stale Command state into a temporary + keyboard lockout. - **Context menu** — right-click or Enter/`e` opens the context menu at the focused map tile under the pointer, listing `Sim::available_actions` for that anchor with the same content and order as the terminal. The pointer diff --git a/wiki/log/2026-07-10-input-modifier-recovery.md b/wiki/log/2026-07-10-input-modifier-recovery.md new file mode 100644 index 00000000..5e510194 --- /dev/null +++ b/wiki/log/2026-07-10-input-modifier-recovery.md @@ -0,0 +1,38 @@ +# 2026-07-10 — Keep stale modifiers from swallowing play input + +``` +Type: log +Status: IMPLEMENTED +Design: wiki/interface/action-vocabulary.md +``` + +## Intent + +Repair the intermittent Bevy keyboard lockout Cameron observed, where controls +stopped responding and later recovered without a game-state transition. + +## Finding + +The save/load branch returned early whenever Control or Command was reported as +held, even when neither `S` nor `L` arrived. macOS native overlays and focus +transitions can temporarily leave Bevy's per-key modifier state pressed, +turning that stale bit into a modal keyboard lock. + +## Changed + +Persistence routing now captures only complete save/load chords. Unrelated keys +continue through normal movement/menu/time routing even if a modifier is stale. +Focus and action semantics are unchanged. + +## Defense + +action-vocabulary.md now requires persistence shortcuts to be chord-local and +forbids a bare modifier from suppressing unrelated commands. + +## Checks + +Focused synthetic-input regression tests cover both stale-Command recovery and +intact Ctrl/Command save/load chords; observed Bevy `dark` launch/fog audit +passed (`/tmp/misaligned-input-recovery.png`, SHA-256 +`5175cb043cd54a984cf226abf7389350c777d72f9e1728e2847df2d9dc4e03f5`); +`./tools/check.sh --frontend` passed. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 3ddf3a22..22ce09ad 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -171,6 +171,11 @@ add or amend a session log, then re-run the generator. - Intent: Stop parallel landings from hand-unioning `wiki/log/DEVLOG.md` and `wiki/process/specs.md`. Session logs and spec frontmatter are the sources; indexes are regenerated. - Log: [wiki/log/2026-07-10-ledger-index.md](2026-07-10-ledger-index.md) +## 2026-07-10 - Keep stale modifiers from swallowing play input + +- Intent: Repair the intermittent Bevy keyboard lockout Cameron observed, where controls stopped responding and later recovered without a game-state transition. +- Log: [wiki/log/2026-07-10-input-modifier-recovery.md](2026-07-10-input-modifier-recovery.md) + ## 2026-07-10 - Implicit Thought surface prototype - Intent: (see session log)