diff --git a/assets/data/keymap.toml b/assets/data/keymap.toml new file mode 100644 index 0000000..0474a9b --- /dev/null +++ b/assets/data/keymap.toml @@ -0,0 +1,44 @@ +# HEXWORK canonical keymap (docs/01 §7, docs/03 §3.10, docs/04 §4.6). +# Parsed by hexwork_term::remap without a general TOML dependency. +# Action names correspond to current Input variants; an unbound key is Unknown. + +[move] +up = ["Up", "w", "k"] +down = ["Down", "s", "j"] +left = ["Left", "a", "h"] +right = ["Right", "d", "l"] +# Lowercase q/c belong to Quit/Contracts, so Q/C are deliberately uppercase. +up_left = ["Q", "y"] +up_right = ["e", "u"] +down_left = ["z", "b"] +down_right = ["C", "n"] + +[step] +# Shift + any movement binding advances exactly one tick. +pause_step_modifier = "Shift" + +[action] +look = ["x"] +inventory = ["i"] +contracts = ["c"] +map_tactical = ["m"] +chapel = ["p"] +camera_toggle = ["Tab"] +wait = ["Space", "."] +quit = ["Esc", "q"] +confirm = ["Enter"] + +[chargen] +chargen_pick_1 = ["1"] +chargen_pick_2 = ["2"] +chargen_pick_3 = ["3"] +chargen_pick_4 = ["4"] +chargen_pick_5 = ["5"] +chargen_pick_6 = ["6"] + +[mouse] +# Mouse application remains parent-side; each consequential mouse action has +# a keyboard twin. These exact marker strings are validated by the parser. +drag_orbit = "CameraToggle" +click = "reserved-for-menus" +scroll = "reserved-for-z-slider" diff --git a/crates/hexwork-audio/src/lib.rs b/crates/hexwork-audio/src/lib.rs index 6b57a8e..2cf011a 100644 --- a/crates/hexwork-audio/src/lib.rs +++ b/crates/hexwork-audio/src/lib.rs @@ -1,7 +1,25 @@ -//! hexwork-audio: terminal-bell synth (docs/03 §3.10, Phase 12). -//! Off by default over SSH; all sounds mutable and capped. +//! Pure terminal-bell synthesis (docs/03 §3.10, Phase 12). +//! +//! This crate never prints, flushes, reads environment variables, or opens an +//! audio device. [`Synth::pattern`] returns a bounded string for the terminal +//! backend to append to its next flush. Patterns contain ASCII BEL (`0x07`) +//! bytes only: sound identity is a count, never a delay or terminal control +//! sequence that could collide with prompt input. +//! +//! SSH state is an input, not a hidden environment probe. An SSH session always +//! suppresses output, while the user's mutable mute preference is preserved so +//! leaving SSH restores that preference. Rich sampled synthesis (including any +//! optional audio backend) is an honest Phase 13+ hook; Phase 12 has no `rodio` +//! dependency and no pretending that repeated terminal bells have pitch. -/// A sound event. Real synthesis lands in Phase 12; slice uses bell/no-op. +/// ASCII terminal bell, the only byte emitted in a Phase 12 pattern. +pub const TERMINAL_BELL: char = '\x07'; +/// Hard safety cap on bell bytes returned for one event. +pub const MAX_BELLS_PER_PATTERN: usize = 4; +/// Since every pattern byte is one ASCII BEL, this is also the byte-length cap. +pub const MAX_PATTERN_LEN: usize = MAX_BELLS_PER_PATTERN; + +/// A semantic sound event. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Sound { BellToll, @@ -10,10 +28,224 @@ pub enum Sound { Silence, } -/// Emit a sound. Slice stub: terminal bell for tolls, nothing otherwise. -pub fn play(sound: Sound) { - if matches!(sound, Sound::BellToll) { - print!("\x07"); +impl Sound { + /// Canonical count-only terminal pattern before caller/config caps. + pub const fn bell_count(self) -> usize { + match self { + Self::BellToll => 3, + Self::Clack => 1, + Self::Hum => 2, + Self::Silence => 0, + } + } +} + +/// Pure startup inputs supplied by CLI/config and terminal capability probing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SynthConfig { + /// User/config mute preference. + pub muted: bool, + /// Whether terminal probing identified an SSH session. + pub ssh: bool, + /// Per-event preference, clamped to [`MAX_BELLS_PER_PATTERN`]. + pub max_bells: usize, +} + +impl Default for SynthConfig { + fn default() -> Self { + Self { + muted: false, + ssh: false, + max_bells: MAX_BELLS_PER_PATTERN, + } + } +} + +/// Build a deterministic, bounded count-only bell pattern. +/// +/// `max_bells` is untrusted configuration and is always clamped to the global +/// safety cap. [`Sound::Silence`] always yields an empty string. +#[allow(clippy::manual_repeat_n)] // repeat_n stabilized after the Rust 1.75 MSRV. +pub fn bell_pattern(sound: Sound, max_bells: usize) -> String { + let count = sound.bell_count().min(max_bells).min(MAX_BELLS_PER_PATTERN); + std::iter::repeat(TERMINAL_BELL).take(count).collect() +} + +/// Mutable pure synth state. The caller owns all actual terminal I/O. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Synth { + muted: bool, + ssh: bool, + max_bells: usize, +} + +impl Default for Synth { + fn default() -> Self { + Self::new(SynthConfig::default()) + } +} + +impl Synth { + /// Construct from explicit config/probe inputs. + pub fn new(config: SynthConfig) -> Self { + Self { + muted: config.muted, + ssh: config.ssh, + max_bells: config.max_bells.min(MAX_BELLS_PER_PATTERN), + } + } + + /// Effective mute state. SSH cannot be overridden by toggling user mute. + pub const fn is_muted(&self) -> bool { + self.muted || self.ssh + } + + /// User/config mute preference (separate from mandatory SSH suppression). + pub const fn user_muted(&self) -> bool { + self.muted + } + + /// Current SSH-probe input. + pub const fn is_ssh(&self) -> bool { + self.ssh + } + + /// Effective bounded per-event bell cap. + pub const fn max_bells(&self) -> usize { + self.max_bells + } + + /// Change the user's mute preference without bypassing SSH suppression. + pub fn set_muted(&mut self, muted: bool) { + self.muted = muted; + } + + /// Update the terminal probe result. Leaving SSH restores user preference. + pub fn set_ssh(&mut self, ssh: bool) { + self.ssh = ssh; + } + + /// Change the preference, clamped to the global safety cap. + pub fn set_max_bells(&mut self, max_bells: usize) { + self.max_bells = max_bells.min(MAX_BELLS_PER_PATTERN); + } + + /// Return the next pure output pattern; never performs I/O. + pub fn pattern(&self, sound: Sound) -> String { + if self.is_muted() { + String::new() + } else { + bell_pattern(sound, self.max_bells) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_patterns_are_count_only_and_distinct() { + assert_eq!(bell_pattern(Sound::BellToll, usize::MAX), "\x07\x07\x07"); + assert_eq!(bell_pattern(Sound::Clack, usize::MAX), "\x07"); + assert_eq!(bell_pattern(Sound::Hum, usize::MAX), "\x07\x07"); + assert_eq!(bell_pattern(Sound::Silence, usize::MAX), ""); + } + + #[test] + fn pattern_builder_is_deterministic() { + for sound in [Sound::BellToll, Sound::Clack, Sound::Hum, Sound::Silence] { + assert_eq!(bell_pattern(sound, 4), bell_pattern(sound, 4)); + } + } + + #[test] + fn untrusted_cap_is_globally_bounded() { + let pattern = bell_pattern(Sound::BellToll, usize::MAX); + assert!(pattern.len() <= MAX_PATTERN_LEN); + assert!(pattern.chars().count() <= MAX_BELLS_PER_PATTERN); + } + + #[test] + fn caller_cap_truncates_pattern() { + assert_eq!(bell_pattern(Sound::BellToll, 2), "\x07\x07"); + assert_eq!(bell_pattern(Sound::BellToll, 0), ""); + } + + #[test] + fn patterns_contain_no_terminal_control_language_except_bell() { + for sound in [Sound::BellToll, Sound::Clack, Sound::Hum] { + assert!(bell_pattern(sound, 4).bytes().all(|byte| byte == 0x07)); + } + } + + #[test] + fn default_synth_is_local_and_audible() { + let synth = Synth::default(); + assert!(!synth.is_muted()); + assert!(!synth.pattern(Sound::Clack).is_empty()); + } + + #[test] + fn explicit_mute_suppresses_every_sound() { + let mut synth = Synth::default(); + synth.set_muted(true); + for sound in [Sound::BellToll, Sound::Clack, Sound::Hum, Sound::Silence] { + assert_eq!(synth.pattern(sound), ""); + } + } + + #[test] + fn unmuting_is_mutable() { + let mut synth = Synth::new(SynthConfig { + muted: true, + ..SynthConfig::default() + }); + synth.set_muted(false); + assert_eq!(synth.pattern(Sound::Hum), "\x07\x07"); + } + + #[test] + fn ssh_forces_sound_off_even_after_unmute() { + let mut synth = Synth::new(SynthConfig { + ssh: true, + ..SynthConfig::default() + }); + synth.set_muted(false); + assert!(synth.is_muted()); + assert_eq!(synth.pattern(Sound::BellToll), ""); + } + + #[test] + fn leaving_ssh_restores_user_preference() { + let mut synth = Synth::new(SynthConfig { + muted: false, + ssh: true, + max_bells: 4, + }); + synth.set_ssh(false); + assert!(!synth.is_muted()); + assert_eq!(synth.pattern(Sound::Clack), "\x07"); + } + + #[test] + fn leaving_ssh_does_not_clear_user_mute() { + let mut synth = Synth::new(SynthConfig { + muted: true, + ssh: true, + max_bells: 4, + }); + synth.set_ssh(false); + assert!(synth.is_muted()); + } + + #[test] + fn mutable_cap_remains_globally_bounded() { + let mut synth = Synth::default(); + synth.set_max_bells(1); + assert_eq!(synth.pattern(Sound::BellToll), "\x07"); + synth.set_max_bells(usize::MAX); + assert_eq!(synth.max_bells(), MAX_BELLS_PER_PATTERN); + assert!(synth.pattern(Sound::BellToll).len() <= MAX_PATTERN_LEN); } - let _ = sound; } diff --git a/crates/hexwork-cli/src/loop_.rs b/crates/hexwork-cli/src/loop_.rs index fc01605..01c9411 100644 --- a/crates/hexwork-cli/src/loop_.rs +++ b/crates/hexwork-cli/src/loop_.rs @@ -138,6 +138,7 @@ pub fn apply_input(state: &mut State, input: Input, player: u64) -> bool { | Input::Map | Input::Chapel | Input::CameraToggle + | Input::Confirm | Input::ChargenPick(_) | Input::Unknown => true, } diff --git a/crates/hexwork-render/tests/a11y_cert.rs b/crates/hexwork-render/tests/a11y_cert.rs new file mode 100644 index 0000000..7883483 --- /dev/null +++ b/crates/hexwork-render/tests/a11y_cert.rs @@ -0,0 +1,246 @@ +//! Phase 12 accessibility certification (docs/05 §5.4–5.5). +//! +//! These are measurements over public renderer APIs, not palette-name smoke +//! tests. Every certified palette renders the same scene, its lit cues clear a +//! WCAG-style 3:1 non-text threshold, and its three fog strata remain measurably +//! distinct. Threat/exit/loot also retain different glyph shapes and brightness +//! after RGB is reduced to grayscale, so no assertion depends on hue alone. + +use hexwork_core::actor::Stance; +use hexwork_core::voxel::{material, ChunkedGrid, Voxel}; +use hexwork_render::camera::first_person; +use hexwork_render::palette::{self, Palette}; +use hexwork_render::ray::{Face, RayHit}; +use hexwork_render::scene::{apply_fog, fog_for_z, render}; +use hexwork_render::shade::{actor_glyph, edge_glyph, shade}; +use hexwork_render::{density_glyph, FrameBuffer}; +use std::collections::BTreeSet; + +const MIN_SIGNAL_CONTRAST: f64 = 3.0; +const MIN_FOG_RGB_DISTANCE: f64 = 10.0; + +fn linear(channel: u8) -> f64 { + let value = f64::from(channel) / 255.0; + if value <= 0.03928 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } +} + +fn luminance(rgb: [u8; 3]) -> f64 { + 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2]) +} + +fn contrast(a: [u8; 3], b: [u8; 3]) -> f64 { + let (bright, dark) = { + let (a, b) = (luminance(a), luminance(b)); + if a >= b { + (a, b) + } else { + (b, a) + } + }; + (bright + 0.05) / (dark + 0.05) +} + +fn rgb_distance(a: [u8; 3], b: [u8; 3]) -> f64 { + let dr = f64::from(a[0]) - f64::from(b[0]); + let dg = f64::from(a[1]) - f64::from(b[1]); + let db = f64::from(a[2]) - f64::from(b[2]); + (dr * dr + dg * dg + db * db).sqrt() +} + +fn test_hit(material: u8) -> RayHit { + RayHit { + dist: 6.0, + x: 4, + y: 0, + z: 0, + face: Face::XNeg, + material, + } +} + +/// Lit cue colors derived through the production shade + post-fog functions. +fn cue_colors(palette: &Palette) -> [([u8; 3], [u8; 3]); 3] { + [material::FLESH, material::STONE, material::SAINT_BONE].map(|material| { + let (_, foreground, background) = shade(&test_hit(material), 24, 0.05, false); + apply_fog(foreground, background, palette.fog_shallows, 0.05) + }) +} + +fn corridor() -> ChunkedGrid { + let mut grid = ChunkedGrid::new(); + for y in -8..=8 { + for z in -2..=6 { + for x in [-4, 4] { + grid.set_world( + x, + y, + z, + Voxel { + material: material::STONE, + ..Voxel::default() + }, + ); + } + } + } + for x in -4..=4 { + for y in -8..=8 { + grid.set_world( + x, + y, + 0, + Voxel { + material: material::COPPER, + ..Voxel::default() + }, + ); + } + } + grid +} + +fn corridor_lights() -> Vec<(i32, i32, i32, u8)> { + let mut lights = vec![(0, 0, 2, 24)]; + for y in -8..=8 { + for z in -2..=6 { + for x in [-4, 4] { + lights.push((x, y, z, 24)); + } + } + } + for x in -4..=4 { + lights.push((x, 0, 0, 12)); + } + lights +} + +fn render_cert_scene(palette: &Palette) -> FrameBuffer { + let grid = corridor(); + let view = first_person([0.0, 0.0, 2.0], 0.0, 0.0); + let actors = [("rat", [2.5, 0.5, 1.0], Stance::Stand, true, 0)]; + let mut frame = FrameBuffer::new(40, 12); + render( + &grid, + &view, + palette, + &mut frame, + &corridor_lights(), + &actors, + ); + frame +} + +#[test] +fn certificate_covers_exactly_six_named_palettes() { + assert_eq!(palette::ALL.len(), 6); + let names: BTreeSet<_> = palette::ALL.iter().map(|palette| palette.name).collect(); + assert_eq!( + names, + BTreeSet::from([ + "default", + "deutan", + "high-contrast", + "no-flicker", + "protan", + "tritan", + ]) + ); +} + +#[test] +fn every_palette_lit_cues_clear_measured_contrast_threshold() { + for palette in &palette::ALL { + for (index, (foreground, background)) in cue_colors(palette).into_iter().enumerate() { + let ratio = contrast(foreground, background); + assert!( + ratio >= MIN_SIGNAL_CONTRAST, + "{} cue {index} contrast {ratio:.3} is below {MIN_SIGNAL_CONTRAST}: {foreground:?} on {background:?}", + palette.name + ); + } + } +} + +#[test] +fn every_palette_has_three_measurably_distinct_fog_strata() { + for palette in &palette::ALL { + let fogs = [palette.fog_shallows, palette.fog_works, palette.fog_deep]; + for pair in [(0, 1), (0, 2), (1, 2)] { + let distance = rgb_distance(fogs[pair.0], fogs[pair.1]); + assert!( + distance >= MIN_FOG_RGB_DISTANCE, + "{} fog strata {:?} distance {distance:.3} is below {MIN_FOG_RGB_DISTANCE}", + palette.name, + pair + ); + } + } +} + +#[test] +fn fog_band_selection_reaches_each_palette_stratum() { + for palette in &palette::ALL { + assert_eq!(fog_for_z(palette, 0.0), palette.fog_shallows); + assert_eq!(fog_for_z(palette, -50.0), palette.fog_works); + assert_eq!(fog_for_z(palette, -100.0), palette.fog_deep); + } +} + +#[test] +fn all_six_palettes_render_threat_and_exit_shape_channels() { + for palette in &palette::ALL { + let frame = render_cert_scene(palette); + assert!( + frame.cells.iter().any(|cell| cell.glyph == 'r'), + "{} lost the threat actor glyph", + palette.name + ); + assert!( + frame + .cells + .iter() + .any(|cell| matches!(cell.glyph, '│' | '─' | '+')), + "{} lost lit exit-compatible edge art", + palette.name + ); + } +} + +#[test] +fn threat_loot_exit_survive_complete_hue_removal() { + let threat_glyph = actor_glyph("rat", Stance::Stand, true, 0); + let exit_glyph = edge_glyph(Face::XNeg, 0.5).expect("edge cue"); + let loot_glyph = density_glyph(0.8); + assert_eq!(threat_glyph, 'r'); + assert_eq!(exit_glyph, '│'); + assert_eq!(loot_glyph, '.'); + assert_eq!( + BTreeSet::from([threat_glyph, exit_glyph, loot_glyph]).len(), + 3 + ); + + for palette in &palette::ALL { + let colors = cue_colors(palette); + // Luminance is the grayscale channel: if these thresholds pass, hue + // can be discarded and each shape still has brightness redundancy. + for (foreground, background) in colors { + assert!(contrast(foreground, background) >= MIN_SIGNAL_CONTRAST); + assert_ne!(luminance(foreground), luminance(background)); + } + assert!(palette::threat_loot_exit_redundant()); + } +} + +#[test] +fn no_flicker_certificate_is_unique_and_other_palettes_remain_unflagged() { + let flagged: Vec<_> = palette::ALL + .iter() + .filter(|palette| palette.no_flicker) + .map(|palette| palette.name) + .collect(); + assert_eq!(flagged, ["no-flicker"]); +} diff --git a/crates/hexwork-term/src/keymap.rs b/crates/hexwork-term/src/keymap.rs index 19747d7..d29215d 100644 --- a/crates/hexwork-term/src/keymap.rs +++ b/crates/hexwork-term/src/keymap.rs @@ -31,7 +31,7 @@ //! | `Tab` / `Shift+Tab` | `CameraToggle` | chase-orbit twin of mouse-drag | //! | `Space`, `.` | `Wait` | pass one tick | //! | `Esc`, `q` | `Quit` | | -//! | `Enter` | `Unknown` (deliberate no-op) | see [`map_key`] docs | +//! | `Enter` | `Confirm` | contextual interaction/menu confirmation | //! | everything else | `Unknown` | reserved for parent shortcuts / menus | //! //! # Lowercase-wins collisions (documented, tested) @@ -73,47 +73,7 @@ pub const KEYMAP_PATH: &str = "assets/data/keymap.toml"; /// (not parsed here) so this crate stays dependency-free beyond crossterm — /// parsing/validation belongs to the parent-side loader. pub fn default_keymap_toml() -> &'static str { - r#"# HEXWORK default keymap — shipped defaults (docs/04 §4.6). -# Path: assets/data/keymap.toml. Loading + in-game remap application is -# parent-side (Phase 12); this text only pins the defaults. -# Rule: every mouse action has a key twin (docs/01 §7). - -[move] -up = ["Up", "w", "k"] -down = ["Down", "s", "j"] -left = ["Left", "a", "h"] -right = ["Right", "d", "l"] -up_left = ["Q", "y"] -up_right = ["e", "u"] -down_left = ["z", "b"] -down_right = ["C", "n"] - -[step] -# Shift + any [move] key advances exactly one tick (docs/04 §4.2). -pause_step_modifier = "Shift" - -[action] -look = ["x"] -inventory = ["i"] -contracts = ["c"] -map_tactical = ["m"] -chapel = ["p"] -camera_toggle = ["Tab"] -wait = ["Space", "."] -quit = ["Esc", "q"] - -[reserved] -# Enter confirms in menus (handled parent-side via a raw-key passthrough -# hook); on the Play screen it is a deliberate no-op (Unknown) so it can -# never advance the sim or quit the game by accident. -confirm = ["Enter"] - -[mouse] -# Each mouse action names its key twin. -drag_orbit = "CameraToggle" -click = "reserved-for-menus" -scroll = "reserved-for-z-slider" -"# + crate::remap::DEFAULT_KEYMAP_TOML } /// Map a key press to normalized [`Input`]. @@ -135,16 +95,9 @@ scroll = "reserved-for-z-slider" /// Case policy: letters match case-insensitively, except `q`/`Q` and `c`/`C` /// (see "Lowercase-wins collisions" above) — and except `Enter`, below. /// -/// # Why `Enter` maps to `Unknown` -/// -/// `Enter` is UI-confirm: context-dependent, meaningful only inside menus. -/// Mapping it to `Wait` would be *wrong* — `Wait` advances the simulation, -/// and an accidental Enter must never burn a tick (docs/01 §7 kindness). -/// No new `Input` variant (e.g. `Confirm`) can be added here — the enum is -/// parent-owned in `lib.rs`, which this workstream must not edit. So `Enter` -/// → [`Input::Unknown`], which the Play screen treats as a no-op, while -/// menus consume confirmation parent-side via a raw-key passthrough hook -/// (parent adds it if needed). Honest and safe. +/// `Enter` maps to [`Input::Confirm`], a consequence-free contextual request. +/// The parent controller decides whether it accepts a selected menu entry, +/// uses a transition/service, turns in a contract, or attacks a nearby hostile. pub fn map_key(code: KeyCode, modifiers: KeyModifiers) -> Input { if modifiers.intersects( KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::HYPER, @@ -193,6 +146,7 @@ pub fn map_key(code: KeyCode, modifiers: KeyModifiers) -> Input { KeyCode::Char('p') | KeyCode::Char('P') => Input::Chapel, KeyCode::Tab | KeyCode::BackTab => Input::CameraToggle, KeyCode::Char(' ') | KeyCode::Char('.') => Input::Wait, + KeyCode::Enter => Input::Confirm, // Chargen + menu digits: number keys select in menus; Play treats // them as no-ops (Unknown), so picks never leak into movement. KeyCode::Char('1') => Input::ChargenPick(0), @@ -202,7 +156,6 @@ pub fn map_key(code: KeyCode, modifiers: KeyModifiers) -> Input { KeyCode::Char('5') => Input::ChargenPick(4), KeyCode::Char('6') => Input::ChargenPick(5), KeyCode::Char('7') | KeyCode::Char('8') | KeyCode::Char('9') => Input::Unknown, - // Enter deliberately falls through to Unknown (see "Why Enter" above). _ => Input::Unknown, } } @@ -335,10 +288,9 @@ mod tests { } #[test] - fn enter_is_unknown_by_design() { - // Enter must never advance the sim (Wait) nor quit: menus consume it - // parent-side; Play treats Unknown as a no-op. See map_key docs. - assert_eq!(map_key(KeyCode::Enter, NO_MOD), Input::Unknown); + fn enter_is_contextual_confirm() { + // Confirm itself has no sim consequence; the parent resolves context. + assert_eq!(map_key(KeyCode::Enter, NO_MOD), Input::Confirm); } #[test] @@ -410,7 +362,7 @@ mod tests { fn default_toml_pins_every_binding() { let t = default_keymap_toml(); // Sections. - for section in ["[move]", "[step]", "[action]", "[reserved]", "[mouse]"] { + for section in ["[move]", "[step]", "[action]", "[mouse]"] { assert!(t.contains(section), "missing {section}"); } // Movement + diagonals. diff --git a/crates/hexwork-term/src/lib.rs b/crates/hexwork-term/src/lib.rs index 03c2e48..c785825 100644 --- a/crates/hexwork-term/src/lib.rs +++ b/crates/hexwork-term/src/lib.rs @@ -3,6 +3,7 @@ pub mod backend; pub mod keymap; +pub mod remap; /// Terminal capabilities probed at boot. #[derive(Clone, Debug)] @@ -52,6 +53,8 @@ pub enum Input { Chapel, CameraToggle, PauseStep, + /// Contextual interaction/menu confirmation (Enter). + Confirm, /// Menu/chargen digit pick (1-6 → 0-5). Play treats as no-op; menus /// consume. Digits 7-9 map to Unknown (reserved). ChargenPick(usize), diff --git a/crates/hexwork-term/src/remap.rs b/crates/hexwork-term/src/remap.rs new file mode 100644 index 0000000..0eaf6f7 --- /dev/null +++ b/crates/hexwork-term/src/remap.rs @@ -0,0 +1,673 @@ +//! Strict dependency-free loader for the canonical keymap (docs/03 §3.10). +//! +//! This is intentionally a small parser for the shipped TOML subset, not a +//! claim to implement TOML generally. It accepts named sections, quoted strings, +//! and arrays of quoted strings; malformed syntax, unknown actions/keys, +//! duplicate actions/bindings, and incomplete maps are errors with line data. +//! Missing files explicitly fall back to [`DEFAULT_KEYMAP_TOML`]. Live rebinding +//! and persistence are honest Phase 13+ hooks; Phase 12 loads once at boot. + +use crate::Input; +use crossterm::event::{KeyCode, KeyModifiers}; +use std::fmt; +use std::path::Path; + +/// Canonical map embedded for deterministic missing-file fallback. +pub const DEFAULT_KEYMAP_TOML: &str = include_str!("../../../assets/data/keymap.toml"); + +const REQUIRED_ACTIONS: [Action; 24] = [ + Action::Up, + Action::Down, + Action::Left, + Action::Right, + Action::UpLeft, + Action::UpRight, + Action::DownLeft, + Action::DownRight, + Action::Wait, + Action::Quit, + Action::Look, + Action::Inventory, + Action::Contracts, + Action::Map, + Action::Chapel, + Action::CameraToggle, + Action::PauseStep, + Action::Confirm, + Action::ChargenPick(0), + Action::ChargenPick(1), + Action::ChargenPick(2), + Action::ChargenPick(3), + Action::ChargenPick(4), + Action::ChargenPick(5), +]; + +/// Bindable counterparts of every actionable [`Input`] variant. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Up, + Down, + Left, + Right, + UpLeft, + UpRight, + DownLeft, + DownRight, + Wait, + Quit, + Look, + Inventory, + Contracts, + Map, + Chapel, + CameraToggle, + PauseStep, + Confirm, + ChargenPick(usize), +} + +impl Action { + fn is_movement(self) -> bool { + matches!( + self, + Self::Up + | Self::Down + | Self::Left + | Self::Right + | Self::UpLeft + | Self::UpRight + | Self::DownLeft + | Self::DownRight + ) + } + + fn input(self) -> Input { + match self { + Self::Up => Input::Up, + Self::Down => Input::Down, + Self::Left => Input::Left, + Self::Right => Input::Right, + Self::UpLeft => Input::UpLeft, + Self::UpRight => Input::UpRight, + Self::DownLeft => Input::DownLeft, + Self::DownRight => Input::DownRight, + Self::Wait => Input::Wait, + Self::Quit => Input::Quit, + Self::Look => Input::Look, + Self::Inventory => Input::Inventory, + Self::Contracts => Input::Contracts, + Self::Map => Input::Map, + Self::Chapel => Input::Chapel, + Self::CameraToggle => Input::CameraToggle, + Self::PauseStep => Input::PauseStep, + Self::Confirm => Input::Confirm, + Self::ChargenPick(index) => Input::ChargenPick(index), + } + } +} + +/// A validated startup keymap. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Keymap { + bindings: Vec<(KeyCode, Action)>, + pause_step_modifier: KeyModifiers, +} + +impl Keymap { + /// Resolve one key event. Unbound/chorded keys are safe no-ops. + pub fn map_key(&self, code: KeyCode, modifiers: KeyModifiers) -> Input { + if modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::HYPER, + ) { + return Input::Unknown; + } + + let action = self.find(code).or_else(|| match code { + KeyCode::BackTab => self.find(KeyCode::Tab), + KeyCode::Char(c) if c.is_ascii_uppercase() => { + self.find(KeyCode::Char(c.to_ascii_lowercase())) + } + _ => None, + }); + match action { + Some(action) + if action.is_movement() && modifiers.contains(self.pause_step_modifier) => + { + Input::PauseStep + } + Some(action) => action.input(), + None => Input::Unknown, + } + } + + /// Physical binding count (the step modifier is not a physical binding). + pub fn binding_count(&self) -> usize { + self.bindings.len() + } + + /// Whether the validated map contains at least one key for an action. + pub fn contains_action(&self, action: Action) -> bool { + action == Action::PauseStep || self.bindings.iter().any(|(_, a)| *a == action) + } + + fn find(&self, code: KeyCode) -> Option { + self.bindings + .iter() + .find_map(|(bound, action)| (*bound == code).then_some(*action)) + } +} + +/// Whether startup loaded the requested file or used the missing-file fallback. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LoadSource { + File, + MissingFileFallback, +} + +/// A keymap together with its explicit startup provenance. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LoadedKeymap { + pub keymap: Keymap, + pub source: LoadSource, +} + +/// Strict parse failure category. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ParseErrorKind { + Syntax(String), + UnknownSection(String), + UnknownAction(String), + UnknownKey(String), + DuplicateAction(String), + DuplicateBinding(String), + MissingAction(Action), +} + +/// Parse failure with a one-based line (`0` means whole-map validation). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParseError { + pub line: usize, + pub kind: ParseErrorKind, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.line == 0 { + write!(f, "keymap validation error: {:?}", self.kind) + } else { + write!(f, "keymap line {}: {:?}", self.line, self.kind) + } + } +} + +impl std::error::Error for ParseError {} + +/// File-loading failure. `NotFound` is never returned: it uses the fallback. +#[derive(Debug)] +pub enum LoadError { + Read(std::io::Error), + Parse(ParseError), +} + +impl fmt::Display for LoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Read(err) => write!(f, "cannot read keymap: {err}"), + Self::Parse(err) => err.fmt(f), + } + } +} + +impl std::error::Error for LoadError {} + +/// Load `path`; only a genuinely missing file falls back to shipped defaults. +pub fn load_keymap(path: impl AsRef) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => Ok(LoadedKeymap { + keymap: parse_keymap(&text).map_err(LoadError::Parse)?, + source: LoadSource::File, + }), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(LoadedKeymap { + keymap: parse_keymap(DEFAULT_KEYMAP_TOML).map_err(LoadError::Parse)?, + source: LoadSource::MissingFileFallback, + }), + Err(err) => Err(LoadError::Read(err)), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Section { + Move, + Step, + Action, + Chargen, + Mouse, +} + +/// Parse and validate the supported canonical TOML subset. +pub fn parse_keymap(text: &str) -> Result { + let mut section = None; + let mut bindings = Vec::new(); + let mut actions_seen: Vec = Vec::new(); + let mut labels_seen: Vec = Vec::new(); + let mut pause_step_modifier = None; + + for (zero_line, raw) in text.lines().enumerate() { + let line_no = zero_line + 1; + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + if line.starts_with('[') { + if !line.ends_with(']') || line[1..line.len() - 1].contains('[') { + return syntax(line_no, "malformed section header"); + } + let name = &line[1..line.len() - 1]; + section = Some(match name { + "move" => Section::Move, + "step" => Section::Step, + "action" => Section::Action, + "chargen" => Section::Chargen, + "mouse" => Section::Mouse, + _ => { + return Err(ParseError { + line: line_no, + kind: ParseErrorKind::UnknownSection(name.to_owned()), + }) + } + }); + continue; + } + + let current = section.ok_or_else(|| ParseError { + line: line_no, + kind: ParseErrorKind::Syntax("assignment before a section".to_owned()), + })?; + let (label, value) = line.split_once('=').ok_or_else(|| ParseError { + line: line_no, + kind: ParseErrorKind::Syntax("expected key = value".to_owned()), + })?; + let label = label.trim(); + let value = value.trim(); + if label.is_empty() || labels_seen.iter().any(|seen| seen == label) { + return Err(ParseError { + line: line_no, + kind: if label.is_empty() { + ParseErrorKind::Syntax("empty action name".to_owned()) + } else { + ParseErrorKind::DuplicateAction(label.to_owned()) + }, + }); + } + labels_seen.push(label.to_owned()); + + match current { + Section::Step => { + if label != "pause_step_modifier" { + return unknown_action(line_no, label); + } + let modifier = parse_string(value, line_no)?; + if modifier != "Shift" { + return Err(ParseError { + line: line_no, + kind: ParseErrorKind::UnknownKey(modifier), + }); + } + pause_step_modifier = Some(KeyModifiers::SHIFT); + actions_seen.push(Action::PauseStep); + } + Section::Mouse => validate_mouse(label, value, line_no)?, + Section::Move | Section::Action | Section::Chargen => { + let action = action_for(current, label).ok_or_else(|| ParseError { + line: line_no, + kind: ParseErrorKind::UnknownAction(label.to_owned()), + })?; + if actions_seen.contains(&action) { + return Err(ParseError { + line: line_no, + kind: ParseErrorKind::DuplicateAction(label.to_owned()), + }); + } + let keys = parse_array(value, line_no)?; + if keys.is_empty() { + return syntax(line_no, "binding array must not be empty"); + } + for key_name in keys { + let key = parse_key(&key_name, line_no)?; + if bindings.iter().any(|(bound, _)| *bound == key) { + return Err(ParseError { + line: line_no, + kind: ParseErrorKind::DuplicateBinding(key_name), + }); + } + bindings.push((key, action)); + } + actions_seen.push(action); + } + } + } + + if pause_step_modifier.is_none() { + return Err(ParseError { + line: 0, + kind: ParseErrorKind::MissingAction(Action::PauseStep), + }); + } + for required in REQUIRED_ACTIONS { + if !actions_seen.contains(&required) { + return Err(ParseError { + line: 0, + kind: ParseErrorKind::MissingAction(required), + }); + } + } + Ok(Keymap { + bindings, + pause_step_modifier: pause_step_modifier.unwrap_or(KeyModifiers::SHIFT), + }) +} + +fn action_for(section: Section, label: &str) -> Option { + match (section, label) { + (Section::Move, "up") => Some(Action::Up), + (Section::Move, "down") => Some(Action::Down), + (Section::Move, "left") => Some(Action::Left), + (Section::Move, "right") => Some(Action::Right), + (Section::Move, "up_left") => Some(Action::UpLeft), + (Section::Move, "up_right") => Some(Action::UpRight), + (Section::Move, "down_left") => Some(Action::DownLeft), + (Section::Move, "down_right") => Some(Action::DownRight), + (Section::Action, "wait") => Some(Action::Wait), + (Section::Action, "quit") => Some(Action::Quit), + (Section::Action, "look") => Some(Action::Look), + (Section::Action, "inventory") => Some(Action::Inventory), + (Section::Action, "contracts") => Some(Action::Contracts), + (Section::Action, "map_tactical") => Some(Action::Map), + (Section::Action, "chapel") => Some(Action::Chapel), + (Section::Action, "camera_toggle") => Some(Action::CameraToggle), + (Section::Action, "confirm") => Some(Action::Confirm), + (Section::Chargen, "chargen_pick_1") => Some(Action::ChargenPick(0)), + (Section::Chargen, "chargen_pick_2") => Some(Action::ChargenPick(1)), + (Section::Chargen, "chargen_pick_3") => Some(Action::ChargenPick(2)), + (Section::Chargen, "chargen_pick_4") => Some(Action::ChargenPick(3)), + (Section::Chargen, "chargen_pick_5") => Some(Action::ChargenPick(4)), + (Section::Chargen, "chargen_pick_6") => Some(Action::ChargenPick(5)), + _ => None, + } +} + +fn parse_key(name: &str, line: usize) -> Result { + let code = match name { + "Up" => KeyCode::Up, + "Down" => KeyCode::Down, + "Left" => KeyCode::Left, + "Right" => KeyCode::Right, + "Tab" => KeyCode::Tab, + "BackTab" => KeyCode::BackTab, + "Space" => KeyCode::Char(' '), + "Esc" => KeyCode::Esc, + "Enter" => KeyCode::Enter, + _ => { + let mut chars = name.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if !c.is_control() => KeyCode::Char(c), + _ => { + return Err(ParseError { + line, + kind: ParseErrorKind::UnknownKey(name.to_owned()), + }) + } + } + } + }; + Ok(code) +} + +fn parse_array(value: &str, line: usize) -> Result, ParseError> { + if !value.starts_with('[') || !value.ends_with(']') { + return syntax(line, "expected an array of quoted key names"); + } + let inner = value[1..value.len() - 1].trim(); + if inner.is_empty() { + return Ok(Vec::new()); + } + inner + .split(',') + .map(|item| parse_string(item.trim(), line)) + .collect() +} + +fn parse_string(value: &str, line: usize) -> Result { + if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') { + return syntax(line, "expected a quoted string"); + } + let inner = &value[1..value.len() - 1]; + if inner.contains(['"', '\\']) { + return syntax(line, "escapes and embedded quotes are unsupported"); + } + Ok(inner.to_owned()) +} + +fn validate_mouse(label: &str, value: &str, line: usize) -> Result<(), ParseError> { + let marker = parse_string(value, line)?; + let expected = match label { + "drag_orbit" => "CameraToggle", + "click" => "reserved-for-menus", + "scroll" => "reserved-for-z-slider", + _ => return unknown_action(line, label), + }; + if marker != expected { + return Err(ParseError { + line, + kind: ParseErrorKind::UnknownKey(marker), + }); + } + Ok(()) +} + +fn strip_comment(line: &str) -> &str { + let mut quoted = false; + for (index, byte) in line.bytes().enumerate() { + if byte == b'"' { + quoted = !quoted; + } else if byte == b'#' && !quoted { + return &line[..index]; + } + } + line +} + +fn syntax(line: usize, message: &str) -> Result { + Err(ParseError { + line, + kind: ParseErrorKind::Syntax(message.to_owned()), + }) +} + +fn unknown_action(line: usize, label: &str) -> Result { + Err(ParseError { + line, + kind: ParseErrorKind::UnknownAction(label.to_owned()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn canonical() -> Keymap { + parse_keymap(DEFAULT_KEYMAP_TOML).expect("shipped map validates") + } + + #[test] + fn canonical_asset_parses_and_covers_every_action() { + let map = canonical(); + for action in REQUIRED_ACTIONS { + assert!(map.contains_action(action), "missing {action:?}"); + } + assert!(map.binding_count() >= REQUIRED_ACTIONS.len() - 1); + } + + #[test] + fn canonical_cardinals_and_actions_resolve() { + let map = canonical(); + assert_eq!(map.map_key(KeyCode::Up, KeyModifiers::NONE), Input::Up); + assert_eq!( + map.map_key(KeyCode::Char('h'), KeyModifiers::NONE), + Input::Left + ); + assert_eq!( + map.map_key(KeyCode::Char('x'), KeyModifiers::NONE), + Input::Look + ); + assert_eq!( + map.map_key(KeyCode::Tab, KeyModifiers::NONE), + Input::CameraToggle + ); + assert_eq!( + map.map_key(KeyCode::BackTab, KeyModifiers::SHIFT), + Input::CameraToggle + ); + } + + #[test] + fn case_policy_preserves_q_and_c_collisions() { + let map = canonical(); + assert_eq!( + map.map_key(KeyCode::Char('q'), KeyModifiers::NONE), + Input::Quit + ); + assert_eq!( + map.map_key(KeyCode::Char('Q'), KeyModifiers::NONE), + Input::UpLeft + ); + assert_eq!( + map.map_key(KeyCode::Char('c'), KeyModifiers::NONE), + Input::Contracts + ); + assert_eq!( + map.map_key(KeyCode::Char('C'), KeyModifiers::NONE), + Input::DownRight + ); + assert_eq!( + map.map_key(KeyCode::Char('X'), KeyModifiers::NONE), + Input::Look + ); + } + + #[test] + fn shifted_movement_maps_to_pause_step() { + let map = canonical(); + assert_eq!( + map.map_key(KeyCode::Up, KeyModifiers::SHIFT), + Input::PauseStep + ); + assert_eq!( + map.map_key(KeyCode::Char('w'), KeyModifiers::SHIFT), + Input::PauseStep + ); + assert_eq!( + map.map_key(KeyCode::Char('Q'), KeyModifiers::SHIFT), + Input::PauseStep + ); + } + + #[test] + fn all_chargen_picks_resolve() { + let map = canonical(); + for digit in 1..=6usize { + assert_eq!( + map.map_key( + KeyCode::Char(char::from_digit(digit as u32, 10).unwrap()), + KeyModifiers::NONE + ), + Input::ChargenPick(digit - 1) + ); + } + } + + #[test] + fn confirm_is_bound_and_unbound_control_chords_are_unknown() { + let map = canonical(); + assert_eq!( + map.map_key(KeyCode::Enter, KeyModifiers::NONE), + Input::Confirm + ); + assert_eq!( + map.map_key(KeyCode::F(1), KeyModifiers::NONE), + Input::Unknown + ); + assert_eq!( + map.map_key(KeyCode::Char('x'), KeyModifiers::CONTROL), + Input::Unknown + ); + } + + #[test] + fn unknown_action_has_clear_name_and_line() { + let bad = DEFAULT_KEYMAP_TOML.replace("look =", "teleport ="); + let err = parse_keymap(&bad).unwrap_err(); + assert!(err.line > 0); + assert_eq!( + err.kind, + ParseErrorKind::UnknownAction("teleport".to_owned()) + ); + } + + #[test] + fn malformed_arrays_are_rejected() { + let bad = DEFAULT_KEYMAP_TOML.replace("wait = [\"Space\", \".\"]", "wait = Space"); + assert!(matches!( + parse_keymap(&bad).unwrap_err().kind, + ParseErrorKind::Syntax(_) + )); + } + + #[test] + fn duplicate_physical_bindings_are_rejected() { + let bad = DEFAULT_KEYMAP_TOML.replace("look = [\"x\"]", "look = [\"i\"]"); + assert_eq!( + parse_keymap(&bad).unwrap_err().kind, + ParseErrorKind::DuplicateBinding("i".to_owned()) + ); + } + + #[test] + fn incomplete_maps_name_the_missing_action() { + let bad = DEFAULT_KEYMAP_TOML.replace("look = [\"x\"]\n", ""); + assert_eq!( + parse_keymap(&bad).unwrap_err().kind, + ParseErrorKind::MissingAction(Action::Look) + ); + } + + #[test] + fn missing_file_uses_explicit_embedded_fallback() { + let path = std::env::temp_dir().join(format!( + "hexwork-keymap-definitely-missing-{}-{}.toml", + std::process::id(), + u64::MAX + )); + let _ = std::fs::remove_file(&path); + let loaded = load_keymap(path).expect("missing file falls back"); + assert_eq!(loaded.source, LoadSource::MissingFileFallback); + assert_eq!( + loaded + .keymap + .map_key(KeyCode::Char('i'), KeyModifiers::NONE), + Input::Inventory + ); + } + + #[test] + fn existing_invalid_file_is_an_error_not_a_fallback() { + let path = std::env::temp_dir().join(format!( + "hexwork-keymap-invalid-{}.toml", + std::process::id() + )); + std::fs::write(&path, "[action]\nwarp = [\"x\"]\n").unwrap(); + let result = load_keymap(&path); + let _ = std::fs::remove_file(&path); + assert!(matches!(result, Err(LoadError::Parse(_)))); + } +} diff --git a/crates/hexwork-ui/src/hud.rs b/crates/hexwork-ui/src/hud.rs index 612767f..f7bffe3 100644 --- a/crates/hexwork-ui/src/hud.rs +++ b/crates/hexwork-ui/src/hud.rs @@ -69,7 +69,7 @@ fn fit(s: &str, w: usize) -> String { /// docs/01 §2, something moves *above* you — checked first); /// - dark → `"x look · Tab orbit · M map"`; /// - shop / store / stall → `"i inventory · c contracts"`; -/// - anything else → `"arrows/WASD/hjkl move · x look · ? help"`. +/// - anything else → `"arrows/WASD/hjkl move · Enter interact/fight · x look"`. #[must_use] pub fn hint_ribbon(context: &str) -> String { let c = context.to_lowercase(); @@ -80,7 +80,7 @@ pub fn hint_ribbon(context: &str) -> String { } else if c.contains("shop") || c.contains("store") || c.contains("stall") { "i inventory · c contracts" } else { - "arrows/WASD/hjkl move · x look · ? help" + "arrows/WASD/hjkl move · Enter interact/fight · x look" } .to_owned() } @@ -168,9 +168,12 @@ mod tests { fn ribbon_defaults_to_movement() { assert_eq!( hint_ribbon("town square"), - "arrows/WASD/hjkl move · x look · ? help" + "arrows/WASD/hjkl move · Enter interact/fight · x look" + ); + assert_eq!( + hint_ribbon(""), + "arrows/WASD/hjkl move · Enter interact/fight · x look" ); - assert_eq!(hint_ribbon(""), "arrows/WASD/hjkl move · x look · ? help"); } #[test] diff --git a/crates/hexwork-ui/src/lib.rs b/crates/hexwork-ui/src/lib.rs index 9872a2a..9f21d4a 100644 --- a/crates/hexwork-ui/src/lib.rs +++ b/crates/hexwork-ui/src/lib.rs @@ -7,10 +7,14 @@ pub mod death_ui; pub mod explore; pub mod hud; pub mod identify_ui; +pub mod logfmt; pub mod look; pub mod screens; +pub mod settings; pub mod sheet; +pub mod stream; pub mod town; +pub mod undo; /// Screens in the stack: Title → Chargen → Play → … → Death/Replay. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/hexwork-ui/src/logfmt.rs b/crates/hexwork-ui/src/logfmt.rs new file mode 100644 index 0000000..5694e7e --- /dev/null +++ b/crates/hexwork-ui/src/logfmt.rs @@ -0,0 +1,223 @@ +//! Dyslexia-friendly log layout (docs/01 §7, docs/05 §5.5). +//! +//! The terminal font belongs to the user, so "font rendering" here means +//! layout, not glyphs (docs/05 §5.5 "Dyslexia/log options"): +//! +//! - normal mode: word-wrap each entry at width `w`; +//! - dyslexia mode (the `dyslexia` flag on the accessibility `Settings`): +//! `min(w, 50)` (shorter lines), emit a BLANK line between entries +//! (spacing), and strip `*` / `_` emphasis characters (no italics in +//! critical warnings — *"no wrong answer"* must read as plain words). +//! +//! Stripping is meaning-preserving: emphasis chars are removed, every other +//! character (including order) survives, pinned by test. Width `0` yields an +//! empty vec — never panic, whatever the terminal reports. +//! +//! Pure string logic: the parent owns the log render path (feeds `Log` +//! lines in, blits the returned rows out). + +/// Cap for dyslexia-mode line width (docs/05 §5.5 "shorter lines"). +pub const DYSLEXIA_MAX_WIDTH: usize = 50; + +/// Lay out log `lines` for a `w`-wide view. +/// +/// - `dyslexia = false`: each entry word-wrapped at `w`. +/// - `dyslexia = true`: each entry stripped of `*`/`_` and wrapped at +/// `min(w, 50)`, with a blank `""` row between entries. +/// - `w == 0` (or empty input): empty vec, never a panic. +#[must_use] +pub fn format_log(lines: &[String], w: usize, dyslexia: bool) -> Vec { + if w == 0 || lines.is_empty() { + return Vec::new(); + } + let width = if dyslexia { + w.min(DYSLEXIA_MAX_WIDTH) + } else { + w + }; + let mut out = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if dyslexia && i > 0 { + out.push(String::new()); + } + let text = if dyslexia { + strip_emphasis(line) + } else { + line.clone() + }; + out.extend(wrap(&text, width)); + } + out +} + +/// Remove `*` / `_` emphasis characters, preserving everything else in order. +fn strip_emphasis(s: &str) -> String { + s.chars().filter(|&c| c != '*' && c != '_').collect() +} + +/// Greedy word-wrap at `width` chars (char-counted, never byte-split). +/// Overlong words are hard-split. `width == 0` yields nothing. +fn wrap(text: &str, width: usize) -> Vec { + if width == 0 { + return Vec::new(); + } + let mut out: Vec = Vec::new(); + let mut cur = String::new(); + let mut cur_len: usize = 0; + for word in text.split(' ') { + if word.is_empty() { + continue; // collapse runs of spaces; breaks re-emit single gaps + } + let wl = word.chars().count(); + if wl > width { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + cur_len = 0; + } + let chars: Vec = word.chars().collect(); + for chunk in chars.chunks(width) { + out.push(chunk.iter().collect()); + } + } else if cur_len == 0 { + cur.push_str(word); + cur_len = wl; + } else if cur_len + 1 + wl <= width { + cur.push(' '); + cur.push_str(word); + cur_len += 1 + wl; + } else { + out.push(std::mem::take(&mut cur)); + cur.push_str(word); + cur_len = wl; + } + } + if !cur.is_empty() || out.is_empty() { + out.push(cur); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(v: &[&str]) -> Vec { + v.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn normal_wraps_at_width() { + let out = format_log(&lines(&["aaa bbb ccc"]), 7, false); + assert_eq!(out, vec!["aaa bbb".to_owned(), "ccc".to_owned()]); + for row in &out { + assert!(row.chars().count() <= 7); + } + } + + #[test] + fn dyslexia_wraps_narrower_than_requested() { + let long = "word ".repeat(30); + let out = format_log(&lines(&[long.as_str()]), 80, true); + assert!( + out.iter() + .filter(|l| !l.is_empty()) + .all(|l| l.chars().count() <= DYSLEXIA_MAX_WIDTH), + "dyslexia caps at min(w, 50): {out:?}" + ); + assert_eq!(DYSLEXIA_MAX_WIDTH, 50); + } + + #[test] + fn dyslexia_inserts_blank_separators() { + let out = format_log(&lines(&["first entry", "second entry"]), 40, true); + assert_eq!( + out, + vec![ + "first entry".to_owned(), + String::new(), + "second entry".to_owned() + ] + ); + } + + #[test] + fn normal_has_no_blank_separators() { + let out = format_log(&lines(&["first entry", "second entry"]), 40, false); + assert_eq!( + out, + vec!["first entry".to_owned(), "second entry".to_owned()] + ); + } + + #[test] + fn emphasis_stripped_in_dyslexia() { + let out = format_log(&lines(&["*danger* _do not_ drink"]), 40, true); + let joined = out.join("\n"); + assert!(!joined.contains('*'), "no italics: {joined:?}"); + assert!(!joined.contains('_'), "no italics: {joined:?}"); + assert!(joined.contains("danger")); + assert!(joined.contains("do not")); + } + + #[test] + fn emphasis_kept_in_normal_mode() { + let out = format_log(&lines(&["*danger*"]), 40, false); + assert_eq!(out, vec!["*danger*".to_owned()]); + } + + #[test] + fn empty_input_is_empty() { + assert!(format_log(&[], 80, false).is_empty()); + assert!(format_log(&[], 80, true).is_empty()); + } + + #[test] + fn zero_width_never_panics() { + assert!(format_log(&lines(&["something"]), 0, false).is_empty()); + assert!(format_log(&lines(&["something"]), 0, true).is_empty()); + } + + #[test] + fn meaning_preserved_after_strip_and_wrap() { + // Every non-emphasis char survives in order: char-stream equality + // modulo whitespace re-flow (wrapping only moves spaces). + let entries = lines(&["*Copper* wire _hums_ faintly by the dark grate"]); + let out = format_log(&entries, 12, true); + let stripped: String = strip_emphasis(&entries[0]); + let no_space = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::(); + let got: String = no_space(&out.join("")); + assert_eq!(got, no_space(&stripped), "chars preserved: {out:?}"); + } + + #[test] + fn overlong_word_hard_splits() { + let out = format_log(&lines(&["abcdefghij"]), 4, false); + assert_eq!( + out, + vec!["abcd".to_owned(), "efgh".to_owned(), "ij".to_owned()] + ); + } + + #[test] + fn unicode_is_split_only_on_scalar_boundaries() { + let out = format_log(&lines(&["火竜🦀é"]), 2, false); + assert_eq!(out, vec!["火竜".to_owned(), "🦀é".to_owned()]); + assert_eq!(out.concat(), "火竜🦀é"); + } + + #[test] + fn unicode_emphasis_strip_preserves_non_markup() { + let out = format_log(&lines(&["*火竜* _café_ 🦀"]), 4, true); + let joined = out.join(""); + assert_eq!(joined.replace(' ', ""), "火竜café🦀"); + } + + #[test] + fn blank_entries_still_receive_dyslexia_spacing() { + let out = format_log(&lines(&["", "warning"]), 20, true); + assert_eq!( + out, + vec![String::new(), String::new(), "warning".to_owned()] + ); + } +} diff --git a/crates/hexwork-ui/src/settings.rs b/crates/hexwork-ui/src/settings.rs new file mode 100644 index 0000000..b077d32 --- /dev/null +++ b/crates/hexwork-ui/src/settings.rs @@ -0,0 +1,291 @@ +//! Accessibility settings: gamma / FOV / shake / mute / dyslexia +//! (docs/01 §7 kindness layer, docs/05 §5.4 law 4, docs/05 §5.5). +//! +//! Sliders the player owns; the sim never reads them. Parent contracts: +//! +//! - [`Settings::apply_gamma`] is applied **post-shade** by the parent: the +//! renderer shades (docs/05 §5.2 step 3), then maps the result through this +//! curve. This module only computes the curve, purely. +//! - FOV mirrors the renderer cap (`camera::MAX_FOV_DEG` = 75, docs/05 §5.4 +//! law 4); `hexwork-ui` does not depend on `hexwork-render`, so the bound +//! is restated here as [`FOV_MAX_DEG`] and pinned by test. +//! - [`Settings::reduced_motion`] (`!shake`) tells the parent to kill +//! head-bob and clamp the damage flash (docs/05 §5.1–§5.2); shake defaults +//! **off** (no-shake default, docs/05 §5.4 law 4). +//! - `mute` defaults off; on SSH / no-bell terminals the parent forces mute +//! side (docs/05 §5.3 capability probe) and persists that choice itself. +//! - `dyslexia` switches the log render path to dyslexia-friendly spacing +//! (see `logfmt::format_log`, wired by the parent). +//! +//! Pure data logic only: no I/O, no persistence here — the parent owns CLI +//! sliders and save files. + +/// Minimum gamma (docs/01 §7 slider floor). +pub const GAMMA_MIN: f32 = 0.5; +/// Maximum gamma (docs/01 §7 slider ceiling). +pub const GAMMA_MAX: f32 = 2.0; +/// Default gamma: identity curve. +pub const DEFAULT_GAMMA: f32 = 1.0; +/// Minimum sane FOV in degrees (mirrors `camera::MIN_FOV_DEG`). +pub const FOV_MIN_DEG: f32 = 1.0; +/// Hard FOV cap in degrees (mirrors `camera::MAX_FOV_DEG`, docs/05 §5.4 law 4). +pub const FOV_MAX_DEG: f32 = 75.0; +/// Default FOV: the cap itself (docs/05 §5.1 camera 1). +pub const DEFAULT_FOV_DEG: f32 = 75.0; + +/// Player-owned accessibility settings (docs/01 §7). +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + gamma: f32, + fov_deg: f32, + shake: bool, + mute: bool, + dyslexia: bool, +} + +impl Settings { + /// Default settings: identity gamma, capped FOV, no shake, sound on, + /// standard log layout. + pub fn new() -> Self { + Self { + gamma: DEFAULT_GAMMA, + fov_deg: DEFAULT_FOV_DEG, + shake: false, + mute: false, + dyslexia: false, + } + } + + /// Validated display gamma. + #[must_use] + pub fn gamma(&self) -> f32 { + self.gamma + } + + /// Set gamma, clamped to 0.5–2.0; non-finite input resets to 1.0. + pub fn set_gamma(&mut self, gamma: f32) { + self.gamma = validate_gamma(gamma); + } + + /// Validated vertical FOV in degrees. + #[must_use] + pub fn fov_deg(&self) -> f32 { + self.fov_deg + } + + /// Set FOV, clamped to 1–75°; non-finite input resets to 75°. + pub fn set_fov(&mut self, fov_deg: f32) { + self.fov_deg = validate_fov(fov_deg); + } + + /// Whether screen shake and head-bob are enabled. + #[must_use] + pub fn shake_enabled(&self) -> bool { + self.shake + } + + /// Enable or disable screen shake and head-bob. + pub fn set_shake(&mut self, enabled: bool) { + self.shake = enabled; + } + + /// Set the reduced-motion preference directly. + pub fn set_reduced_motion(&mut self, enabled: bool) { + self.shake = !enabled; + } + + /// Whether bells are muted. + #[must_use] + pub fn mute(&self) -> bool { + self.mute + } + + /// Set the mute preference. + pub fn set_mute(&mut self, muted: bool) { + self.mute = muted; + } + + /// Whether dyslexia-friendly log layout is enabled. + #[must_use] + pub fn dyslexia(&self) -> bool { + self.dyslexia + } + + /// Set the dyslexia-friendly log-layout preference. + pub fn set_dyslexia(&mut self, enabled: bool) { + self.dyslexia = enabled; + } + + /// Apply the gamma curve to a shaded RGB triple (parent: post-shade). + /// + /// Formula: `out = (in / 255) ^ (1 / gamma) * 255`, rounded and clamped + /// to `0–255`. The private gamma field is always validated by construction + /// or [`Settings::set_gamma`]. + #[must_use] + pub fn apply_gamma(&self, rgb: [u8; 3]) -> [u8; 3] { + let g = if self.gamma.is_finite() && self.gamma > 0.0 { + self.gamma + } else { + DEFAULT_GAMMA + }; + let inv = 1.0 / g; + rgb.map(|c| { + let v = f32::from(c) / 255.0; + (v.powf(inv) * 255.0).round().clamp(0.0, 255.0) as u8 + }) + } + + /// True when the parent must kill head-bob and clamp the damage flash + /// (docs/05 §5.1, §5.2 step 5, §5.4 law 4): exactly `!shake`. + #[must_use] + pub fn reduced_motion(&self) -> bool { + !self.shake + } +} + +impl Default for Settings { + fn default() -> Self { + Self::new() + } +} + +fn validate_gamma(gamma: f32) -> f32 { + if gamma.is_finite() { + gamma.clamp(GAMMA_MIN, GAMMA_MAX) + } else { + DEFAULT_GAMMA + } +} + +fn validate_fov(fov_deg: f32) -> f32 { + if fov_deg.is_finite() { + fov_deg.clamp(FOV_MIN_DEG, FOV_MAX_DEG) + } else { + DEFAULT_FOV_DEG + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_kindness_layer() { + let s = Settings::default(); + assert_eq!(s.gamma(), 1.0); + assert_eq!(s.fov_deg(), 75.0); + assert!(!s.shake_enabled(), "no-shake default (docs/05 §5.4 law 4)"); + assert!(s.reduced_motion()); + assert!(!s.mute()); + assert!(!s.dyslexia()); + } + + #[test] + fn gamma_clamps_low() { + let mut s = Settings::default(); + s.set_gamma(0.1); + assert_eq!(s.gamma(), GAMMA_MIN); + s.set_gamma(f32::NEG_INFINITY); + assert_eq!(s.gamma(), DEFAULT_GAMMA, "infinite is non-finite → reset"); + } + + #[test] + fn gamma_clamps_high() { + let mut s = Settings::default(); + s.set_gamma(9.0); + assert_eq!(s.gamma(), GAMMA_MAX); + } + + #[test] + fn gamma_nan_resets_to_identity() { + let mut s = Settings::default(); + s.set_gamma(f32::NAN); + assert_eq!(s.gamma(), DEFAULT_GAMMA); + } + + #[test] + fn fov_clamps_to_75_cap() { + let mut s = Settings::default(); + s.set_fov(120.0); + assert_eq!(s.fov_deg(), FOV_MAX_DEG); + assert_eq!(FOV_MAX_DEG, 75.0, "pinned to camera::MAX_FOV_DEG"); + } + + #[test] + fn fov_clamps_to_floor() { + let mut s = Settings::default(); + s.set_fov(-10.0); + assert_eq!(s.fov_deg(), FOV_MIN_DEG); + } + + #[test] + fn fov_nan_resets_to_75() { + let mut s = Settings::default(); + s.set_fov(f32::NAN); + assert_eq!(s.fov_deg(), DEFAULT_FOV_DEG); + } + + #[test] + fn gamma_identity_at_1() { + let s = Settings::default(); + for rgb in [[0, 0, 0], [255, 255, 255], [18, 200, 90], [1, 2, 3]] { + assert_eq!(s.apply_gamma(rgb), rgb, "gamma 1.0 is identity"); + } + } + + #[test] + fn gamma_above_one_brightens_midtones() { + let mut s = Settings::default(); + s.set_gamma(2.0); + let out = s.apply_gamma([64, 64, 64]); + assert!(out[0] > 64, "gamma > 1 lifts shadows: 64 → {}", out[0]); + // Endpoints are fixed points of the curve. + assert_eq!(s.apply_gamma([0, 0, 0]), [0, 0, 0]); + assert_eq!(s.apply_gamma([255, 255, 255]), [255, 255, 255]); + } + + #[test] + fn gamma_below_one_darkens_midtones() { + let mut s = Settings::default(); + s.set_gamma(0.5); + let out = s.apply_gamma([64, 64, 64]); + assert!(out[0] < 64, "gamma < 1 deepens midtones: 64 → {}", out[0]); + } + + #[test] + fn reduced_motion_iff_no_shake() { + let mut s = Settings::default(); + assert!(s.reduced_motion()); + s.set_shake(true); + assert!(s.shake_enabled()); + assert!(!s.reduced_motion()); + s.set_reduced_motion(true); + assert!(!s.shake_enabled()); + assert!(s.reduced_motion()); + s.set_reduced_motion(false); + assert!(s.shake_enabled()); + assert!(!s.reduced_motion()); + } + + #[test] + fn mute_and_dyslexia_are_explicit_preferences() { + let mut s = Settings::default(); + s.set_mute(true); + s.set_dyslexia(true); + assert!(s.mute()); + assert!(s.dyslexia()); + s.set_mute(false); + s.set_dyslexia(false); + assert!(!s.mute()); + assert!(!s.dyslexia()); + } + + #[test] + fn both_infinities_reset_fov_to_default() { + let mut s = Settings::default(); + for fov in [f32::NEG_INFINITY, f32::INFINITY] { + s.set_fov(fov); + assert_eq!(s.fov_deg(), DEFAULT_FOV_DEG); + } + } +} diff --git a/crates/hexwork-ui/src/stream.rs b/crates/hexwork-ui/src/stream.rs new file mode 100644 index 0000000..1434f69 --- /dev/null +++ b/crates/hexwork-ui/src/stream.rs @@ -0,0 +1,372 @@ +//! Screenreader event stream (docs/05 §5.5, docs/03 §3.10). +//! +//! In screenreader mode the renderer bypasses the raycaster and emits a +//! **prioritized text event stream**: threat > exit > loot > flavor, +//! throttled and interruptible. A blind delver must be able to win from this +//! stream alone, so the ordering contract is load-bearing: +//! +//! - [`Stream::drain`] always emits in priority order +//! Threat → Exit → Loot → Flavor, regardless of push order (the +//! three-glance rule, docs/05 §5.4 law 1, rendered as text: a threat is +//! never buried under flavor). +//! - Output is budget-capped at [`Stream::budget_per_tick`] events per drain; +//! over-budget flavor is **dropped**, never backlogged (throttling by event +//! count, not wall-clock — deterministic, no timers). +//! - [`Stream::interrupt`] drops queued Loot + Flavor but keeps Threat + +//! Exit: a new threat interrupts flavor, never the reverse. +//! +//! Pure data logic only: no I/O, no time, no RNG. The parent feeds sim events +//! into [`Stream::push`] and flushes once per tick via [`Stream::drain`]. + +/// Default per-tick event budget (docs/05 §5.5 "throttled"). +pub const DEFAULT_BUDGET_PER_TICK: usize = 8; + +/// One screenreader event (docs/05 §5.5). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StreamEvent { + /// Danger: monsters, traps, draft-gas warnings. Spoken first, always. + Threat(String), + /// Exits / vertical connections: stairs, shafts, doors, vents. + Exit(String), + /// Loot glints: wire, relics, corpses worth hauling. + Loot(String), + /// Atmosphere: smells, flavor, room dressing. Dropped first under load. + Flavor(String), +} + +impl StreamEvent { + /// Priority rank: lower drains first (Threat 0 → Flavor 3). + fn priority(&self) -> u8 { + match self { + StreamEvent::Threat(_) => 0, + StreamEvent::Exit(_) => 1, + StreamEvent::Loot(_) => 2, + StreamEvent::Flavor(_) => 3, + } + } +} + +/// Format one event for the screenreader (docs/05 §5.5). +/// +/// Threat/Exit/Loot carry a spoken prefix so the listener can triage without +/// hearing the whole line; flavor is verbatim (it is already prose). +#[must_use] +pub fn format_event(ev: &StreamEvent) -> String { + match ev { + StreamEvent::Threat(s) => format!("THREAT: {s}"), + StreamEvent::Exit(s) => format!("EXIT: {s}"), + StreamEvent::Loot(s) => format!("LOOT: {s}"), + StreamEvent::Flavor(s) => s.clone(), + } +} + +/// Prioritized, throttled, interruptible event queue (docs/05 §5.5). +#[derive(Clone, Debug)] +pub struct Stream { + queue: Vec, + budget_per_tick: usize, +} + +impl Stream { + /// New stream with the default per-tick budget ([`DEFAULT_BUDGET_PER_TICK`]). + pub fn new() -> Self { + Self { + queue: Vec::new(), + budget_per_tick: DEFAULT_BUDGET_PER_TICK, + } + } + + /// New stream with an explicit per-tick budget (for tests and tunables). + pub fn with_budget(budget_per_tick: usize) -> Self { + Self { + queue: Vec::new(), + budget_per_tick, + } + } + + /// Enqueue one sim event. Order of pushing does not matter; + /// [`Stream::drain`] re-sorts by priority. + pub fn push(&mut self, ev: StreamEvent) { + self.queue.push(ev); + } + + /// Drain up to `budget_per_tick` events in Threat → Exit → Loot → Flavor + /// order (FIFO within a tier), formatted via [`format_event`]. + /// + /// The `tick` parameter documents the cadence contract (docs/04 §4.2: + /// call once per 10 Hz sim tick); the budget resets on every call and + /// `tick` is otherwise unused, honestly: there is no wall-clock here. + /// Events beyond budget are **dropped, lowest priority first** — the queue + /// is empty after every drain, so a flavor flood can never backlog a + /// later threat. + pub fn drain(&mut self, tick: u64) -> Vec { + // Cadence marker only: budget resets each call, no carryover, no clock. + let _ = tick; + let mut order: Vec = (0..self.queue.len()).collect(); + // `sort_by_key` is stable: FIFO within a priority tier. + order.sort_by_key(|&i| self.queue[i].priority()); + let out: Vec = order + .into_iter() + .take(self.budget_per_tick) + .map(|i| format_event(&self.queue[i])) + .collect(); + self.queue.clear(); + out + } + + /// Interrupt flavor: drop queued Loot + Flavor, keep Threat + Exit. + /// + /// A new threat interrupts flavor, never the reverse (docs/05 §5.5 + /// "interruptible"): the parent calls this when a threat arrives so the + /// next drain speaks danger first. + pub fn interrupt(&mut self) { + self.queue + .retain(|ev| matches!(ev, StreamEvent::Threat(_) | StreamEvent::Exit(_))); + } + + /// Number of events currently queued (pre-drain depth). + pub fn len(&self) -> usize { + self.queue.len() + } + + /// True when nothing is queued. + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + /// The per-tick budget this stream drains with. + pub fn budget(&self) -> usize { + self.budget_per_tick + } +} + +impl Default for Stream { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn demo_room() -> Stream { + // Fixed demo-room script, pushed deliberately out of order: + // 2 flavor, 1 loot, 1 exit, 1 threat (docs/05 §5.5 winnability). + let mut s = Stream::new(); + s.push(StreamEvent::Flavor( + "Damp air. Water ticks somewhere below.".to_owned(), + )); + s.push(StreamEvent::Loot( + "Copper wire glints by the grate.".to_owned(), + )); + s.push(StreamEvent::Flavor( + "Your lantern gutters in a side draft.".to_owned(), + )); + s.push(StreamEvent::Exit( + "Stone stair climbs north, draft rising.".to_owned(), + )); + s.push(StreamEvent::Threat( + "Cable-rat in the rafters, amber spark building.".to_owned(), + )); + s + } + + #[test] + fn default_budget_is_8() { + assert_eq!(Stream::new().budget(), 8); + assert_eq!(DEFAULT_BUDGET_PER_TICK, 8); + } + + #[test] + fn empty_drain_is_empty() { + let mut s = Stream::new(); + assert!(s.is_empty()); + assert!(s.drain(0).is_empty()); + } + + #[test] + fn format_threat_prefix() { + assert_eq!( + format_event(&StreamEvent::Threat("rat above".to_owned())), + "THREAT: rat above" + ); + } + + #[test] + fn format_exit_prefix() { + assert_eq!( + format_event(&StreamEvent::Exit("stair north".to_owned())), + "EXIT: stair north" + ); + } + + #[test] + fn format_loot_prefix() { + assert_eq!( + format_event(&StreamEvent::Loot("wire".to_owned())), + "LOOT: wire" + ); + } + + #[test] + fn format_flavor_verbatim() { + let line = "The dark breathes back.".to_owned(); + assert_eq!(format_event(&StreamEvent::Flavor(line.clone())), line); + } + + #[test] + fn drain_orders_threat_exit_loot_flavor() { + let out = demo_room().drain(1); + assert_eq!(out.len(), 5); + assert!(out[0].starts_with("THREAT: "), "threat first: {out:?}"); + assert!(out[1].starts_with("EXIT: "), "exit second: {out:?}"); + assert!(out[2].starts_with("LOOT: "), "loot third: {out:?}"); + assert_eq!( + out, + vec![ + "THREAT: Cable-rat in the rafters, amber spark building.".to_owned(), + "EXIT: Stone stair climbs north, draft rising.".to_owned(), + "LOOT: Copper wire glints by the grate.".to_owned(), + "Damp air. Water ticks somewhere below.".to_owned(), + "Your lantern gutters in a side draft.".to_owned(), + ], + "priority order regardless of push order, flavor verbatim" + ); + } + + #[test] + fn winnability_demo_room_threat_first_all_within_budget() { + // Blind-delver-winnable (docs/05 §5.5): the whole survival-relevant + // set fits one tick, threat spoken before anything else. + let out = demo_room().drain(7); + assert_eq!(out.len(), 5, "all 5 demo events fit the default budget"); + assert!(out[0].starts_with("THREAT: ")); + assert!(out.iter().any(|l| l.starts_with("EXIT: "))); + assert!(out.iter().any(|l| l.starts_with("LOOT: "))); + } + + #[test] + fn over_budget_drops_flavor_first() { + let mut s = Stream::new(); + for i in 0..12 { + s.push(StreamEvent::Flavor(format!("mote drift {i}"))); + } + s.push(StreamEvent::Threat("bell tolls below".to_owned())); + let out = s.drain(2); + assert_eq!(out.len(), 8, "drain is capped at exactly budget"); + assert!( + out.iter().any(|l| l.starts_with("THREAT: ")), + "threat survives the cut: {out:?}" + ); + assert_eq!( + out[0], "THREAT: bell tolls below", + "threat jumps the flavor queue" + ); + } + + #[test] + fn over_budget_keeps_threat_exit_loot() { + let mut s = Stream::with_budget(4); + for i in 0..10 { + s.push(StreamEvent::Flavor(format!("dust {i}"))); + } + s.push(StreamEvent::Loot("phial".to_owned())); + s.push(StreamEvent::Exit("door east".to_owned())); + s.push(StreamEvent::Threat("rust saint wakes".to_owned())); + let out = s.drain(3); + assert_eq!(out.len(), 4); + assert!(out[0].starts_with("THREAT: ")); + assert!(out[1].starts_with("EXIT: ")); + assert!(out[2].starts_with("LOOT: ")); + } + + #[test] + fn interrupt_keeps_threat_and_exit_only() { + let mut s = Stream::new(); + s.push(StreamEvent::Flavor("moss glows softly".to_owned())); + s.push(StreamEvent::Threat("rat king stirs".to_owned())); + s.push(StreamEvent::Loot("wire".to_owned())); + s.push(StreamEvent::Exit("hatch above".to_owned())); + s.interrupt(); + assert_eq!(s.len(), 2); + let out = s.drain(4); + assert_eq!(out.len(), 2); + assert!(out[0].starts_with("THREAT: ")); + assert!(out[1].starts_with("EXIT: ")); + } + + #[test] + fn interrupt_on_empty_stays_empty() { + let mut s = Stream::new(); + s.interrupt(); + assert!(s.is_empty()); + assert!(s.drain(0).is_empty()); + } + + #[test] + fn drain_empties_queue_so_flavor_cannot_backlog() { + let mut s = Stream::with_budget(2); + s.push(StreamEvent::Flavor("a".to_owned())); + s.push(StreamEvent::Flavor("b".to_owned())); + s.push(StreamEvent::Flavor("c".to_owned())); + let out = s.drain(0); + assert_eq!(out.len(), 2); + assert!(s.is_empty(), "over-budget remainder is dropped, not queued"); + } + + #[test] + fn fifo_within_every_priority_tier() { + let mut s = Stream::new(); + s.push(StreamEvent::Flavor("flavor 1".to_owned())); + s.push(StreamEvent::Threat("threat 1".to_owned())); + s.push(StreamEvent::Loot("loot 1".to_owned())); + s.push(StreamEvent::Exit("exit 1".to_owned())); + s.push(StreamEvent::Flavor("flavor 2".to_owned())); + s.push(StreamEvent::Exit("exit 2".to_owned())); + s.push(StreamEvent::Threat("threat 2".to_owned())); + s.push(StreamEvent::Loot("loot 2".to_owned())); + assert_eq!( + s.drain(0), + vec![ + "THREAT: threat 1".to_owned(), + "THREAT: threat 2".to_owned(), + "EXIT: exit 1".to_owned(), + "EXIT: exit 2".to_owned(), + "LOOT: loot 1".to_owned(), + "LOOT: loot 2".to_owned(), + "flavor 1".to_owned(), + "flavor 2".to_owned(), + ] + ); + } + + #[test] + fn zero_budget_drops_everything_without_backlog() { + let mut s = Stream::with_budget(0); + s.push(StreamEvent::Threat("danger".to_owned())); + s.push(StreamEvent::Exit("stairs".to_owned())); + assert!(s.drain(0).is_empty()); + assert!(s.is_empty()); + } + + #[test] + fn interrupt_preserves_fifo_for_survival_tiers() { + let mut s = Stream::new(); + s.push(StreamEvent::Exit("first exit".to_owned())); + s.push(StreamEvent::Threat("first threat".to_owned())); + s.push(StreamEvent::Loot("discard".to_owned())); + s.push(StreamEvent::Threat("second threat".to_owned())); + s.push(StreamEvent::Exit("second exit".to_owned())); + s.interrupt(); + assert_eq!( + s.drain(0), + vec![ + "THREAT: first threat".to_owned(), + "THREAT: second threat".to_owned(), + "EXIT: first exit".to_owned(), + "EXIT: second exit".to_owned(), + ] + ); + } +} diff --git a/crates/hexwork-ui/src/undo.rs b/crates/hexwork-ui/src/undo.rs new file mode 100644 index 0000000..3b4153c --- /dev/null +++ b/crates/hexwork-ui/src/undo.rs @@ -0,0 +1,180 @@ +//! Undo for INTERFACE mistakes, never sim mistakes (docs/03 §3.10). +//! +//! Kindness removes *friction*, never *consequence* (docs/01 §7): a delver +//! who mis-clicks a loot filter or mistypes a chalk mark gets it back; a +//! delver who drinks a mystery phial does not. This stack is generic over +//! interface state (`T`) and knows nothing about the sim. Snapshots are moved +//! into and out of the stack, so even non-`Clone` interface state is supported. +//! +//! Hook example (parent-side, NOT code — the CLI owns chalk): before applying +//! a chalk-mark edit on the tactical planner (`M`, docs/05 §5.1 camera 3), +//! the CLI pushes `undo.push("chalk: bridge?", marks.clone())`; on `u` it +//! pops with [`UndoStack::undo`] and restores the marks. Same hook fits loot +//! filters and menu selections. The sim tick, inventory, and world state must +//! NEVER flow through this stack. + +/// Bounded LIFO stack of labeled interface snapshots. +#[derive(Clone, Debug)] +pub struct UndoStack { + stack: Vec<(String, T)>, + cap: usize, +} + +impl UndoStack { + /// New stack holding at most `cap` snapshots. `cap == 0` stores nothing + /// (pushes are no-ops, [`UndoStack::undo`] always returns `None`). + pub fn new(cap: usize) -> Self { + Self { + stack: Vec::new(), + cap, + } + } + + /// Default capacity for casual interface state (16 snapshots). + pub const DEFAULT_CAP: usize = 16; + + /// Push a labeled snapshot; drops the oldest beyond capacity. + pub fn push(&mut self, label: &str, state: T) { + if self.cap == 0 { + return; + } + if self.stack.len() >= self.cap { + self.stack.remove(0); + } + self.stack.push((label.to_owned(), state)); + } + + /// Pop the most recent snapshot (LIFO), with its label. + pub fn undo(&mut self) -> Option<(String, T)> { + self.stack.pop() + } + + /// Number of snapshots currently held. + pub fn len(&self) -> usize { + self.stack.len() + } + + /// True when there is nothing to undo. + pub fn is_empty(&self) -> bool { + self.stack.is_empty() + } + + /// Maximum snapshots held. + pub fn cap(&self) -> usize { + self.cap + } + + /// Discard all snapshots (e.g. on screen change). + pub fn clear(&mut self) { + self.stack.clear(); + } +} + +impl Default for UndoStack { + fn default() -> Self { + Self::new(Self::DEFAULT_CAP) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lifo_order() { + let mut u: UndoStack> = UndoStack::new(8); + u.push("first", vec!["a".to_owned()]); + u.push("second", vec!["a".to_owned(), "b".to_owned()]); + let (label, state) = u.undo().expect("second undo target"); + assert_eq!(label, "second"); + assert_eq!(state, vec!["a".to_owned(), "b".to_owned()]); + let (label, state) = u.undo().expect("first undo target"); + assert_eq!(label, "first"); + assert_eq!(state, vec!["a".to_owned()]); + } + + #[test] + fn cap_eviction_drops_oldest() { + let mut u: UndoStack = UndoStack::new(2); + u.push("one", 1); + u.push("two", 2); + u.push("three", 3); + assert_eq!(u.len(), 2); + assert_eq!(u.undo(), Some(("three".to_owned(), 3))); + assert_eq!(u.undo(), Some(("two".to_owned(), 2))); + assert_eq!(u.undo(), None, "evicted 'one' is gone"); + } + + #[test] + fn empty_undo_is_none() { + let mut u: UndoStack = UndoStack::new(4); + assert!(u.is_empty()); + assert_eq!(u.undo(), None); + } + + #[test] + fn labels_preserved() { + let mut u: UndoStack = UndoStack::new(4); + u.push("chalk: bridge?", 'b'); + u.push("filter: wire only", 'w'); + assert_eq!(u.undo(), Some(("filter: wire only".to_owned(), 'w'))); + } + + #[test] + fn len_and_is_empty_track() { + let mut u: UndoStack = UndoStack::new(4); + assert!(u.is_empty()); + u.push("a", 1); + assert_eq!(u.len(), 1); + assert!(!u.is_empty()); + u.clear(); + assert!(u.is_empty()); + } + + #[test] + fn zero_cap_stores_nothing() { + let mut u: UndoStack = UndoStack::new(0); + u.push("x", 1); + assert!(u.is_empty()); + assert_eq!(u.undo(), None); + } + + #[test] + fn undo_returns_owned_snapshot() { + let mut u: UndoStack> = UndoStack::new(4); + let marks = vec![1, 2, 3]; + u.push("chalk", marks.clone()); + let (_, back) = u.undo().expect("snapshot"); + assert_eq!(back, marks); + } + + #[test] + fn supports_non_clone_interface_state() { + #[derive(Debug, PartialEq, Eq)] + struct CursorSelection(usize); + + let mut u = UndoStack::new(1); + u.push("cursor", CursorSelection(7)); + assert_eq!(u.undo(), Some(("cursor".to_owned(), CursorSelection(7)))); + } + + #[test] + fn default_capacity_is_bounded() { + let mut u = UndoStack::default(); + for state in 0..(UndoStack::::DEFAULT_CAP + 3) { + u.push("selection", state); + } + assert_eq!(u.cap(), UndoStack::::DEFAULT_CAP); + assert_eq!(u.len(), UndoStack::::DEFAULT_CAP); + } + + #[test] + fn clear_preserves_capacity_for_reuse() { + let mut u = UndoStack::new(2); + u.push("one", 1); + u.clear(); + assert_eq!(u.cap(), 2); + u.push("two", 2); + assert_eq!(u.undo(), Some(("two".to_owned(), 2))); + } +} diff --git a/docs/phase12-wiring-plan.md b/docs/phase12-wiring-plan.md new file mode 100644 index 0000000..a71c286 --- /dev/null +++ b/docs/phase12-wiring-plan.md @@ -0,0 +1,51 @@ +# Phase 12 Wiring Plan (parent integration, executes when both workstreams return) + +Two workstreams, disjoint files, one merge point. WS1 (a11y) owns +`ui/stream.rs` + `ui/settings.rs` + `ui/undo.rs` + `ui/logfmt.rs`. WS2 +(audio) owns `audio/lib.rs` (rewrite) + `assets/data/keymap.toml` + +`term/remap.rs` + `render/tests/a11y_cert.rs`. Parent owns `lib.rs` +wiring + stream feed + settings persistence + CLI sliders + undo wiring ++ synth flush + keymap load + signoff checklist. + +## 1. Pre-merge conformance check (parent runs FIRST) + +- keymap.toml actions ⊆ keymap.rs Input variants (lowercased); every + default_keymap_toml() key appears in the file. +- Stream priority Threat>Exit>Loot>Flavor matches sound/light sim order + (threats from actors, exits from edge-art, loot from glint — feed order + is sim-tick order, stream re-sorts). +- Bell patterns don't collide with prompt reads (counts only, documented). +- No rodio anywhere (grep) — the no-rodio rationale stands. + +## 2. Wiring (parent edits) + +- `ui/lib.rs`: `pub mod logfmt; pub mod settings; pub mod stream; pub mod undo;` +- `audio/lib.rs`: already owned by WS2 (rewrite in place). +- `term/lib.rs`: `pub mod remap;` +- Stream feed: Sound phase → Threat (loud hostile), Light phase → Exit + (newly lit edge-art), loot glint hook → Loot, log lines → Flavor. + Budget 8/tick, interrupt on new Threat. +- Settings: CLI flags --gamma/--fov/--no-shake/--mute/--dyslexia + + persist to config file (location parent-side); gamma applies post-shade + (scene::apply_fog analog — new apply_gamma step, no shade change); + FOV feeds Frustum (≤75 enforced); shake=false default (no head-bob); + SSH forces mute (probe_caps ssh path). +- Undo: chalk marks + loot filters push UndoStack states (cap 32); + interface undo key (u) pops; SIM NEVER UNDONE (grep-guaranteed: undo + touches ui/cli state only). +- Synth flush: poll Synth.pattern output into backend flush (capped, + mutable, SSH-muted default). +- Keymap load at boot (KEYMAP_PATH, fallback to defaults on missing); + live rebind is a Phase-13 hook (documented, not wired). +- Log render path honors dyslexia flag (logfmt::format_log). + +## 3. Gate acceptance (a11y signoff checklist, §3.10 + §5.4–5.5 + §1.7) + +- [ ] Palettes certified (a11y_cert green: 6 palettes × ratios + distinct fogs). +- [ ] Threat/loot/exit never hue-only (redundant() + ramp glyphs + tests). +- [ ] Screenreader stream ordered/throttled/interruptible + winnability demo. +- [ ] Reduced-motion default (shake=false, bob off, flash clamped). +- [ ] Dyslexia layout (shorter lines, spacing, no-italics). +- [ ] Remappable keys (file loads, validation Err names unknowns). +- [ ] Audio capped/mutable/SSH-off (synth tests + manual SSH check). +- [ ] Kindness layer complete (pause-step, undo-interface, tooltips, sliders).