├── docs ├── demo.mp4 ├── sc1.png ├── sc10.png ├── sc11.png ├── sc12.png ├── sc13.png ├── sc14.png ├── sc2.png ├── sc3.png ├── sc4.png ├── sc5.png ├── sc6.png ├── sc7.png ├── sc8.png ├── sc9.png ├── splash.gif └── signed-toggle.gif ├── .gitignore ├── src ├── main.rs ├── main_screen_widget.rs ├── keybinds.rs ├── utils.rs ├── app.rs └── binary_numbers.rs ├── LICENSE ├── clippy.toml ├── Cargo.toml ├── .github └── workflows │ └── ci.yml ├── rustfmt.toml ├── README.md └── Cargo.lock /docs/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/demo.mp4 -------------------------------------------------------------------------------- /docs/sc1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc1.png -------------------------------------------------------------------------------- /docs/sc10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc10.png -------------------------------------------------------------------------------- /docs/sc11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc11.png -------------------------------------------------------------------------------- /docs/sc12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc12.png -------------------------------------------------------------------------------- /docs/sc13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc13.png -------------------------------------------------------------------------------- /docs/sc14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc14.png -------------------------------------------------------------------------------- /docs/sc2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc2.png -------------------------------------------------------------------------------- /docs/sc3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc3.png -------------------------------------------------------------------------------- /docs/sc4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc4.png -------------------------------------------------------------------------------- /docs/sc5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc5.png -------------------------------------------------------------------------------- /docs/sc6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc6.png -------------------------------------------------------------------------------- /docs/sc7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc7.png -------------------------------------------------------------------------------- /docs/sc8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc8.png -------------------------------------------------------------------------------- /docs/sc9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/sc9.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /binbreak_highscores.txt 3 | /.idea 4 | /executables 5 | -------------------------------------------------------------------------------- /docs/splash.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/splash.gif -------------------------------------------------------------------------------- /docs/signed-toggle.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epic-64/binbreak/HEAD/docs/signed-toggle.gif -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod binary_numbers; 3 | mod keybinds; 4 | mod main_screen_widget; 5 | mod utils; 6 | 7 | fn main() -> color_eyre::Result<()> { 8 | color_eyre::install()?; 9 | let mut terminal = ratatui::init(); 10 | let result = app::run_app(&mut terminal); 11 | ratatui::restore(); 12 | result 13 | } 14 | -------------------------------------------------------------------------------- /src/main_screen_widget.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyEvent; 2 | use ratatui::buffer::Buffer; 3 | use ratatui::layout::Rect; 4 | 5 | pub trait WidgetRef { 6 | fn render_ref(&self, area: Rect, buf: &mut Buffer); 7 | } 8 | 9 | pub trait MainScreenWidget: WidgetRef { 10 | fn run(&mut self, dt: f64) -> (); 11 | fn handle_input(&mut self, input: KeyEvent) -> (); 12 | fn is_exit_intended(&self) -> bool; 13 | } 14 | -------------------------------------------------------------------------------- /src/keybinds.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyEvent}; 2 | 3 | pub const fn is_up(key: KeyEvent) -> bool { 4 | matches!(key.code, KeyCode::Up | KeyCode::Char('k')) 5 | } 6 | 7 | pub const fn is_down(key: KeyEvent) -> bool { 8 | matches!(key.code, KeyCode::Down | KeyCode::Char('j')) 9 | } 10 | 11 | pub const fn is_left(key: KeyEvent) -> bool { 12 | matches!(key.code, KeyCode::Left | KeyCode::Char('h')) 13 | } 14 | 15 | pub const fn is_right(key: KeyEvent) -> bool { 16 | matches!(key.code, KeyCode::Right | KeyCode::Char('l')) 17 | } 18 | 19 | pub const fn is_select(key: KeyEvent) -> bool { 20 | matches!(key.code, KeyCode::Enter) 21 | } 22 | 23 | pub const fn is_exit(key: KeyEvent) -> bool { 24 | matches!(key.code, KeyCode::Esc | KeyCode::Char('q' | 'Q')) 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 William Raendchen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # Clippy Threshold Configuration 3 | # ============================================================================ 4 | # This file configures clippy threshold values that cannot be set in Cargo.toml 5 | # Lint levels (warn/deny/allow) are configured in Cargo.toml [lints.clippy] 6 | # https://doc.rust-lang.org/clippy/lint_configuration.html 7 | # ============================================================================ 8 | 9 | # Complexity thresholds 10 | cognitive-complexity-threshold = 30 # default: 25 11 | too-many-arguments-threshold = 8 # default: 7 12 | too-many-lines-threshold = 80 # default: 100 13 | type-complexity-threshold = 250 # default: 500 14 | 15 | # Boolean usage limits 16 | max-struct-bools = 4 # default: 3 17 | max-fn-params-bools = 3 # default: 3 18 | 19 | # Variable naming 20 | single-char-binding-names-threshold = 4 # default: 4 21 | allowed-idents-below-min-chars = [ 22 | "x", "y", "dx", "dy", "dt", # coordinates and delta time 23 | "id", "ui", "io", # common abbreviations 24 | "hs", # high scores (project-specific) 25 | ] 26 | 27 | # Other thresholds 28 | vec-box-size-threshold = 4096 # default: 4096 29 | max-trait-bounds = 3 # default: 3 30 | 31 | 32 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "binbreak" 3 | version = "0.3.1" 4 | description = "A terminal based binary number guessing game" 5 | authors = ["William Raendchen "] 6 | license = "MIT" 7 | edition = "2024" 8 | repository = "https://github.com/epic-64/binbreak" 9 | readme = "README.md" 10 | keywords = ["tui", "terminal", "game", "binary", "numbers"] 11 | categories = ["games", "command-line-utilities"] 12 | documentation = "https://docs.rs/binbreak" 13 | homepage = "https://github.com/epic-64/binbreak" 14 | exclude = ["binbreak_highscores.txt", "target/*", ".github/*"] 15 | 16 | [dependencies] 17 | crossterm = "0.29.0" 18 | ratatui = "0.29.0" 19 | indoc = "2.0.7" 20 | color-eyre = "0.6.3" 21 | rand = "0.9.1" 22 | 23 | [lints.rust] 24 | unsafe_code = "forbid" 25 | unused_must_use = "warn" 26 | unused_imports = "warn" 27 | dead_code = "warn" 28 | 29 | [lints.clippy] 30 | # Lint groups - enable comprehensive checking 31 | pedantic = { level = "allow", priority = -1 } 32 | nursery = { level = "allow", priority = -1 } 33 | correctness = { level = "deny", priority = -1 } 34 | all = { level = "warn", priority = -1 } 35 | 36 | # Allow certain common patterns 37 | match_same_arms = "allow" 38 | 39 | # Complexity warnings (thresholds in clippy.toml) 40 | cognitive_complexity = "warn" 41 | too_many_arguments = "warn" 42 | too_many_lines = "warn" 43 | type_complexity = "warn" 44 | struct_excessive_bools = "warn" 45 | fn_params_excessive_bools = "warn" 46 | 47 | # Style preferences - encourage safer code 48 | enum_glob_use = "warn" 49 | unwrap_used = "warn" 50 | expect_used = "warn" 51 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, windows-latest, macos-latest] 19 | rust: [stable] 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Rust 25 | uses: dtolnay/rust-toolchain@stable 26 | with: 27 | toolchain: ${{ matrix.rust }} 28 | 29 | - name: Setup Rust cache 30 | uses: Swatinem/rust-cache@v2 31 | 32 | - name: Run tests 33 | run: cargo test --verbose 34 | 35 | clippy: 36 | name: Clippy 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v4 41 | 42 | - name: Setup Rust 43 | uses: dtolnay/rust-toolchain@stable 44 | with: 45 | components: clippy 46 | 47 | - name: Setup Rust cache 48 | uses: Swatinem/rust-cache@v2 49 | 50 | - name: Run clippy 51 | run: cargo clippy 52 | 53 | fmt: 54 | name: Format 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Checkout code 58 | uses: actions/checkout@v4 59 | 60 | - name: Setup Rust 61 | uses: dtolnay/rust-toolchain@stable 62 | with: 63 | components: rustfmt 64 | 65 | - name: Setup Rust cache 66 | uses: Swatinem/rust-cache@v2 67 | 68 | - name: Check formatting 69 | run: cargo fmt --check 70 | 71 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Rustfmt configuration for binbreak project 2 | 3 | # Edition 4 | edition = "2024" 5 | 6 | # Maximum width of each line 7 | max_width = 100 8 | 9 | # Maximum width of the args of a function call before falling back to vertical formatting 10 | fn_call_width = 80 11 | 12 | # Maximum width of the args of a function-like attributes before falling back to vertical formatting 13 | attr_fn_like_width = 80 14 | 15 | # Maximum width in the body of a struct lit before falling back to vertical formatting 16 | struct_lit_width = 90 17 | 18 | # Maximum width in the body of a struct variant before falling back to vertical formatting 19 | struct_variant_width = 90 20 | 21 | # Maximum width of an array literal before falling back to vertical formatting 22 | array_width = 90 23 | 24 | # Maximum width of a chain to fit on a single line 25 | chain_width = 100 26 | 27 | # Maximum line length for single line if-else expressions 28 | single_line_if_else_max_width = 60 29 | 30 | # How to indent in files 31 | hard_tabs = false 32 | 33 | # Number of spaces per tab 34 | tab_spaces = 4 35 | 36 | # Remove nested parens 37 | remove_nested_parens = true 38 | 39 | # Reorder imports 40 | reorder_imports = true 41 | 42 | # Reorder modules 43 | reorder_modules = true 44 | 45 | # Use field init shorthand if possible 46 | use_field_init_shorthand = true 47 | 48 | # Use try shorthand 49 | use_try_shorthand = true 50 | 51 | # Force explicit types in let statements 52 | force_explicit_abi = true 53 | 54 | # Newline style 55 | newline_style = "Unix" 56 | 57 | # Merge derives 58 | merge_derives = true 59 | 60 | # Use small heuristics (Off, Max, or Default) 61 | # Max preserves more single-line expressions 62 | use_small_heuristics = "Max" 63 | 64 | # Match block trailing comma 65 | match_block_trailing_comma = true 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/epic-64/binbreak/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/epic-64/binbreak/actions) 2 | [![Built With Ratatui](https://ratatui.rs/built-with-ratatui/badge.svg)](https://ratatui.rs/) 3 | 4 | ![splash.gif](docs/splash.gif) 5 | 6 | Guess the correct number (from binary to decimal) before time runs out! 7 | ![sc10.png](docs/sc10.png) 8 | 9 | Or lose a life trying. 10 | ![sc11.png](docs/sc11.png) 11 | 12 | Includes up to 16-bit modes for the ultimate challenge. 13 | ![sc13.png](docs/sc13.png) 14 | 15 | Includes multiple 4-bit modes, to train individual nibbles. 16 | ![sc14.png](docs/sc14.png) 17 | 18 | All bit-modes can also be played in signed or unsigned mode. (toggle via left/right keys) 19 | ![signed-toggle.gif](docs/signed-toggle.gif) 20 | 21 | Signed mode includes negative numbers and requires knowledge of two's complement. 22 | ![sc12.png](docs/sc12.png) 23 | 24 | ## Can you crack the high score? 25 | The longer your streak, the more points you get, but the faster the timer runs out! 26 | 27 | High scores are tracked for each game-mode separately, and saved in a text file relative to the executable. 28 | 29 | ## Play 30 | Download the release for your platform, see [Releases](https://github.com/epic-64/binbreak/releases). 31 | There is one file for linux and one for windows (.exe). 32 | 33 | ## Linux 34 | - download the file `binbreak-linux` 35 | - open a terminal and navigate to the folder where you downloaded it, e.g. `cd ~/Downloads` 36 | - make it executable: `chmod +x binbreak-linux` 37 | - run the game: `./binbreak-linux` 38 | 39 | ## Controls 40 | - use the arrow or vim keys for navigation 41 | - use left/right to toggle signed/unsigned mode 42 | - press Enter to confirm choices 43 | - press Esc or Q to exit a game mode or the game. CTRL+C also works to exit the game. 44 | 45 | ## Recommended terminals 46 | The game should run fine in any terminal. If you want retro CRT effects, here are some recommendations: 47 | - Windows: Windows Terminal (enable experimental "retro mode") 48 | - Linux: Rio (with CRT shader), Cool Retro Term 49 | 50 | ## Build/Run from source 51 | You may be inclined to not run binaries from the internet, and want to build from source instead. 52 | 53 | - download the source code 54 | - make sure you have Rust and Cargo installed, see [rustup.rs](https://rustup.rs/) 55 | - open a terminal and navigate to the folder where you downloaded the source code, e.g. `cd ~/Downloads/binbreak` 56 | - build the project: `cargo build --release` 57 | 58 | ## Run 59 | ```bash 60 | cargo run --release 61 | ``` 62 | 63 | # Contributing 64 | 65 | All pull requests are automatically checked by GitHub Actions CI, which runs tests, 66 | clippy, and formatting checks on Linux, Windows, and macOS. 67 | 68 | ## Test 69 | ```bash 70 | cargo test 71 | ``` 72 | 73 | ## Lint 74 | ```bash 75 | cargo clippy 76 | ``` 77 | 78 | ## Format 79 | ```bash 80 | cargo fmt 81 | ``` 82 | 83 | ## License 84 | MIT license ([LICENSE](LICENSE) or http://opensource.org/licenses/MIT) 85 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::Flex; 2 | use ratatui::prelude::*; 3 | use std::time::{Duration, Instant}; 4 | 5 | /// Type alias for the color function used in procedural animations 6 | type ColorFn = Box Color>; 7 | 8 | /// Type alias for the character transformation function 9 | type CharFn = Box char>; 10 | 11 | /// A procedural animation widget that calculates colors on-the-fly 12 | /// This is much more memory efficient than storing multiple frames 13 | pub struct ProceduralAnimationWidget { 14 | art: String, 15 | width: u16, 16 | height: u16, 17 | num_frames: usize, 18 | frame_duration: Duration, 19 | pause_at_end: Duration, 20 | start_time: Instant, 21 | paused: bool, 22 | paused_progress: f32, 23 | paused_cycle: usize, 24 | highlight_color: Color, // The color for the animated strip 25 | color_fn: ColorFn, // (x, y, progress, cycle, highlight_color) -> Color 26 | char_fn: Option, // (x, y, progress, cycle, original_char) -> char 27 | } 28 | 29 | impl ProceduralAnimationWidget { 30 | pub fn new( 31 | art: String, 32 | num_frames: usize, 33 | frame_duration: Duration, 34 | color_fn: impl Fn(usize, usize, f32, usize, Color) -> Color + 'static, 35 | ) -> Self { 36 | let art_lines: Vec<&str> = art.lines().collect(); 37 | let height = art_lines.len() as u16; 38 | let width = art_lines.iter().map(|line| line.len()).max().unwrap_or(0) as u16; 39 | 40 | Self { 41 | art, 42 | width, 43 | height, 44 | num_frames, 45 | frame_duration, 46 | pause_at_end: Duration::ZERO, 47 | start_time: Instant::now(), 48 | paused: false, 49 | paused_progress: 0.0, 50 | paused_cycle: 0, 51 | highlight_color: Color::LightGreen, // Default color 52 | color_fn: Box::new(color_fn), 53 | char_fn: None, 54 | } 55 | } 56 | 57 | pub fn with_char_fn( 58 | mut self, 59 | char_fn: impl Fn(usize, usize, f32, usize, char) -> char + 'static, 60 | ) -> Self { 61 | self.char_fn = Some(Box::new(char_fn)); 62 | self 63 | } 64 | 65 | pub fn with_pause_at_end(mut self, pause: Duration) -> Self { 66 | self.pause_at_end = pause; 67 | self 68 | } 69 | 70 | pub fn pause(&mut self) { 71 | if !self.paused { 72 | let (progress, cycle) = self.get_animation_progress_and_cycle(); 73 | self.paused_progress = progress; 74 | self.paused_cycle = cycle; 75 | self.paused = true; 76 | } 77 | } 78 | 79 | pub fn unpause(&mut self) { 80 | if self.paused { 81 | // Adjust start_time so that the animation continues from paused_progress 82 | let animation_duration = self.frame_duration * self.num_frames as u32; 83 | let total_cycle_duration = animation_duration + self.pause_at_end; 84 | let elapsed_at_pause = Duration::from_millis( 85 | (self.paused_cycle as f32 * total_cycle_duration.as_millis() as f32 86 | + self.paused_progress * animation_duration.as_millis() as f32) 87 | as u64, 88 | ); 89 | self.start_time = Instant::now() - elapsed_at_pause; 90 | self.paused = false; 91 | } 92 | } 93 | 94 | pub fn toggle_pause(&mut self) { 95 | if self.paused { 96 | self.unpause(); 97 | } else { 98 | self.pause(); 99 | } 100 | } 101 | 102 | pub fn is_paused(&self) -> bool { 103 | self.paused 104 | } 105 | 106 | pub fn get_width(&self) -> u16 { 107 | self.width 108 | } 109 | 110 | pub fn get_height(&self) -> u16 { 111 | self.height 112 | } 113 | 114 | /// Set the highlight color for the animation 115 | pub fn set_highlight_color(&mut self, color: Color) { 116 | self.highlight_color = color; 117 | } 118 | 119 | fn get_animation_progress_and_cycle(&self) -> (f32, usize) { 120 | if self.paused { 121 | return (self.paused_progress, self.paused_cycle); 122 | } 123 | 124 | let elapsed = self.start_time.elapsed(); 125 | let animation_duration = self.frame_duration * self.num_frames as u32; 126 | let total_cycle_duration = animation_duration + self.pause_at_end; 127 | 128 | let cycle = (elapsed.as_millis() / total_cycle_duration.as_millis()) as usize; 129 | let cycle_time = elapsed.as_millis() % total_cycle_duration.as_millis(); 130 | 131 | // If we're in the pause period, return 1.0 (end of animation) 132 | if cycle_time >= animation_duration.as_millis() { 133 | return (1.0, cycle); 134 | } 135 | 136 | // Otherwise calculate progress through animation 137 | let progress = cycle_time as f32 / animation_duration.as_millis() as f32; 138 | (progress, cycle) 139 | } 140 | 141 | pub fn render_to_buffer(&self, area: Rect, buf: &mut Buffer) { 142 | let (progress, cycle) = self.get_animation_progress_and_cycle(); 143 | self.render_to_buffer_at_progress(area, buf, progress, cycle); 144 | } 145 | 146 | pub fn render_to_buffer_at_progress( 147 | &self, 148 | area: Rect, 149 | buf: &mut Buffer, 150 | progress: f32, 151 | cycle: usize, 152 | ) { 153 | for (y, line) in self.art.lines().enumerate() { 154 | for (x, ch) in line.chars().enumerate() { 155 | if ch == ' ' { 156 | continue; // Skip spaces 157 | } 158 | 159 | let color = (self.color_fn)(x, y, progress, cycle, self.highlight_color); 160 | 161 | // Apply character transformation if char_fn is provided 162 | let display_char = if let Some(ref char_fn) = self.char_fn { 163 | char_fn(x, y, progress, cycle, ch) 164 | } else { 165 | ch 166 | }; 167 | 168 | let position = Position::new(x as u16 + area.x, y as u16 + area.y); 169 | 170 | if area.contains(position) { 171 | #[allow(clippy::expect_used)] 172 | buf.cell_mut(position) 173 | .expect("Failed to get cell at position") 174 | .set_char(display_char) 175 | .set_fg(color); 176 | } 177 | } 178 | } 179 | } 180 | } 181 | 182 | pub fn center(area: Rect, horizontal: Constraint) -> Rect { 183 | let [area] = Layout::horizontal([horizontal]).flex(Flex::Center).areas(area); 184 | 185 | vertically_center(area) 186 | } 187 | 188 | pub fn vertically_center(area: Rect) -> Rect { 189 | let constraints = [Constraint::Fill(1), Constraint::Min(1), Constraint::Fill(1)]; 190 | let [_, center, _] = Layout::vertical(constraints).areas(area); 191 | center 192 | } 193 | 194 | pub trait When { 195 | fn when(self, condition: bool, action: impl FnOnce(Self) -> Self) -> Self 196 | where 197 | Self: Sized; 198 | } 199 | 200 | impl When for T { 201 | fn when(self, condition: bool, action: impl FnOnce(T) -> T) -> Self { 202 | if condition { action(self) } else { self } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::binary_numbers::{BinaryNumbersGame, Bits}; 2 | use crate::keybinds; 3 | use crate::main_screen_widget::MainScreenWidget; 4 | use crate::utils::ProceduralAnimationWidget; 5 | use crossterm::event; 6 | use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 7 | use indoc::indoc; 8 | use ratatui::buffer::Buffer; 9 | use ratatui::layout::Rect; 10 | use ratatui::prelude::{Color, Modifier, Span, Style}; 11 | use ratatui::widgets::{List, ListItem, ListState}; 12 | use std::cmp; 13 | use std::thread; 14 | use std::time::{Duration, Instant}; 15 | 16 | #[derive(Copy, Clone, PartialEq, Debug)] 17 | pub enum NumberMode { 18 | Unsigned, 19 | Signed, 20 | } 21 | 22 | impl NumberMode { 23 | pub const fn label(&self) -> &'static str { 24 | match self { 25 | Self::Unsigned => "UNSIGNED", 26 | Self::Signed => "SIGNED", 27 | } 28 | } 29 | } 30 | 31 | /// Persistent application preferences that survive across menu/game transitions 32 | #[derive(Copy, Clone, Debug)] 33 | struct AppPreferences { 34 | last_selected_index: usize, 35 | last_number_mode: NumberMode, 36 | } 37 | 38 | impl Default for AppPreferences { 39 | fn default() -> Self { 40 | Self { 41 | last_selected_index: 4, // Default to "byte 8 bit" 42 | last_number_mode: NumberMode::Unsigned, 43 | } 44 | } 45 | } 46 | 47 | /// Get the color associated with a specific difficulty level / game mode 48 | pub fn get_mode_color(bits: &Bits) -> Color { 49 | // Color scheme: progression from easy (green/cyan) to hard (yellow/red) 50 | match bits { 51 | Bits::Four => Color::Rgb(100, 255, 100), // green 52 | Bits::FourShift4 => Color::Rgb(100, 255, 180), // cyan 53 | Bits::FourShift8 => Color::Rgb(100, 220, 255), // light blue 54 | Bits::FourShift12 => Color::Rgb(100, 180, 255), // blue 55 | Bits::Eight => Color::Rgb(150, 120, 255), // royal blue 56 | Bits::Twelve => Color::Rgb(200, 100, 255), // purple 57 | Bits::Sixteen => Color::Rgb(255, 80, 150), // pink 58 | } 59 | } 60 | 61 | #[derive(Copy, Clone, PartialEq, Debug)] 62 | enum FpsMode { 63 | RealTime, // 30 FPS with polling 64 | Performance, // Block until input for minimal CPU 65 | } 66 | 67 | enum AppState { 68 | Start(StartMenuState, AppPreferences), 69 | Playing(BinaryNumbersGame, AppPreferences), 70 | Exit, 71 | } 72 | 73 | fn handle_start_input( 74 | state: &mut StartMenuState, 75 | key: KeyEvent, 76 | prefs: AppPreferences, 77 | ) -> Option<(AppState, AppPreferences)> { 78 | match key { 79 | x if keybinds::is_up(x) => state.select_previous(), 80 | x if keybinds::is_down(x) => state.select_next(), 81 | x if keybinds::is_left(x) | keybinds::is_right(x) => state.toggle_number_mode(), 82 | x if keybinds::is_select(x) => { 83 | let bits = state.selected_bits(); 84 | let number_mode = state.number_mode; 85 | // Update preferences with current selection 86 | let updated_prefs = AppPreferences { 87 | last_selected_index: state.selected_index(), 88 | last_number_mode: state.number_mode, 89 | }; 90 | return Some(( 91 | AppState::Playing(BinaryNumbersGame::new(bits, number_mode), updated_prefs), 92 | updated_prefs, 93 | )); 94 | }, 95 | x if keybinds::is_exit(x) => return Some((AppState::Exit, prefs)), 96 | KeyEvent { code: KeyCode::Char('a' | 'A'), .. } => state.toggle_animation(), 97 | _ => {}, 98 | } 99 | None 100 | } 101 | 102 | fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer) { 103 | // Get animation dimensions 104 | let ascii_width = state.animation.get_width(); 105 | let ascii_height = state.animation.get_height(); 106 | 107 | let selected = state.selected_index(); 108 | let upper_labels: Vec = state.items.iter().map(|(l, _)| l.to_uppercase()).collect(); 109 | #[allow(clippy::cast_possible_truncation)] 110 | let max_len = upper_labels.iter().map(|s| s.len() as u16).max().unwrap_or(0); 111 | 112 | // Calculate width for both columns: marker + space + label + spacing + mode 113 | let mode_label_width = 8; // "UNSIGNED" or "SIGNED " (8 chars for alignment) 114 | let column_spacing = 4; // spaces between difficulty and mode columns 115 | let list_width = 2 + max_len + column_spacing + mode_label_width; // marker + space + label + spacing + mode 116 | #[allow(clippy::cast_possible_truncation)] 117 | let list_height = upper_labels.len() as u16; 118 | 119 | // Vertical spacing between ASCII art and list 120 | let spacing: u16 = 3; 121 | let total_height = ascii_height + spacing + list_height; 122 | 123 | // Center vertically & horizontally 124 | let start_y = area.y + area.height.saturating_sub(total_height) / 2; 125 | let ascii_x = area.x + area.width.saturating_sub(ascii_width) / 2; 126 | let list_x = area.x + area.width.saturating_sub(list_width) / 2; 127 | let ascii_y = start_y; 128 | let list_y = ascii_y + ascii_height + spacing; 129 | 130 | // Define rects (clamp to area) 131 | let ascii_area = 132 | Rect::new(ascii_x, ascii_y, ascii_width.min(area.width), ascii_height.min(area.height)); 133 | let list_area = Rect::new( 134 | list_x, 135 | list_y, 136 | list_width.min(area.width), 137 | list_height.min(area.height.saturating_sub(list_y - area.y)), 138 | ); 139 | 140 | // Get color for the selected menu item 141 | let selected_color = get_mode_color(&state.items[selected].1); 142 | 143 | // Update animation color to match selected menu item 144 | state.animation.set_highlight_color(selected_color); 145 | 146 | // Render ASCII animation (handles paused state internally) 147 | state.animation.render_to_buffer(ascii_area, buf); 148 | 149 | let items: Vec = upper_labels 150 | .into_iter() 151 | .enumerate() 152 | .map(|(i, label)| { 153 | let is_selected = i == selected; 154 | let marker = if is_selected { '»' } else { ' ' }; 155 | let padded_label = format!("{:width$}", state.number_mode.label(), width = mode_label_width as usize) 160 | } else { 161 | " ".repeat(mode_label_width as usize) 162 | }; 163 | 164 | let line = format!("{marker} {padded_label} {mode_display}"); 165 | 166 | let item_color = get_mode_color(&state.items[i].1); 167 | let mut style = Style::default().fg(item_color).add_modifier(Modifier::BOLD); 168 | 169 | // Make selected item extra prominent with background highlight 170 | if is_selected { 171 | style = style.bg(Color::Rgb(40, 40, 40)); 172 | } 173 | 174 | ListItem::new(Span::styled(line, style)) 175 | }) 176 | .collect(); 177 | 178 | let list = List::new(items); 179 | ratatui::widgets::StatefulWidget::render(list, list_area, buf, &mut state.list_state); 180 | } 181 | 182 | fn handle_crossterm_events(app_state: &mut AppState) -> color_eyre::Result<()> { 183 | if let Event::Key(key) = event::read()? 184 | && key.kind == KeyEventKind::Press 185 | { 186 | match key.code { 187 | // global exit via Ctrl+C 188 | KeyCode::Char('c' | 'C') if key.modifiers == KeyModifiers::CONTROL => { 189 | *app_state = AppState::Exit; 190 | }, 191 | 192 | // state-specific input handling 193 | _ => { 194 | *app_state = match std::mem::replace(app_state, AppState::Exit) { 195 | AppState::Start(mut menu, prefs) => { 196 | if let Some((new_state, _)) = handle_start_input(&mut menu, key, prefs) { 197 | new_state 198 | } else { 199 | AppState::Start(menu, prefs) 200 | } 201 | }, 202 | AppState::Playing(mut game, prefs) => { 203 | game.handle_input(key); 204 | AppState::Playing(game, prefs) 205 | }, 206 | AppState::Exit => AppState::Exit, 207 | } 208 | }, 209 | } 210 | } 211 | Ok(()) 212 | } 213 | 214 | /// Determine the appropriate FPS mode based on the current game state 215 | fn get_fps_mode(game: &BinaryNumbersGame) -> FpsMode { 216 | if game.is_active() { 217 | FpsMode::RealTime // Timer running, needs continuous updates 218 | } else { 219 | FpsMode::Performance // All other cases, block for minimal CPU 220 | } 221 | } 222 | 223 | pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()> { 224 | let prefs = AppPreferences::default(); 225 | let mut app_state = AppState::Start(StartMenuState::new(prefs), prefs); 226 | let mut last_frame_time = Instant::now(); 227 | let target_frame_duration = std::time::Duration::from_millis(33); // ~30 FPS 228 | 229 | while !matches!(app_state, AppState::Exit) { 230 | let now = Instant::now(); 231 | let dt = now - last_frame_time; 232 | last_frame_time = now; 233 | 234 | // Advance game BEFORE drawing so stats are updated 235 | if let AppState::Playing(game, prefs) = &mut app_state { 236 | game.run(dt.as_secs_f64()); 237 | if game.is_exit_intended() { 238 | app_state = AppState::Start(StartMenuState::new(*prefs), *prefs); 239 | continue; 240 | } 241 | } 242 | 243 | terminal.draw(|f| match &mut app_state { 244 | AppState::Start(menu, _) => render_start_screen(menu, f.area(), f.buffer_mut()), 245 | AppState::Playing(game, _) => f.render_widget(&mut *game, f.area()), 246 | AppState::Exit => {}, 247 | })?; 248 | 249 | // handle input 250 | if let AppState::Playing(game, _) = &app_state { 251 | if get_fps_mode(game) == FpsMode::RealTime { 252 | let poll_timeout = cmp::min(dt, target_frame_duration); 253 | if event::poll(poll_timeout)? { 254 | handle_crossterm_events(&mut app_state)?; 255 | } 256 | } else { 257 | // performance mode: block thread until an input event occurs 258 | handle_crossterm_events(&mut app_state)?; 259 | } 260 | } else if let AppState::Start(menu, _) = &app_state { 261 | // For start menu, use real-time mode only if animation is running 262 | if !menu.animation.is_paused() { 263 | let poll_timeout = cmp::min(dt, target_frame_duration); 264 | if event::poll(poll_timeout)? { 265 | handle_crossterm_events(&mut app_state)?; 266 | } 267 | } else { 268 | // Animation paused, use performance mode to save CPU 269 | handle_crossterm_events(&mut app_state)?; 270 | } 271 | } 272 | 273 | // cap frame rate 274 | let frame_duration = last_frame_time.elapsed(); 275 | if frame_duration < target_frame_duration { 276 | thread::sleep(target_frame_duration - frame_duration); 277 | } 278 | } 279 | Ok(()) 280 | } 281 | 282 | fn ascii_animation() -> ProceduralAnimationWidget { 283 | let art = indoc! {r#" 284 | ,, ,, ,, 285 | *MM db *MM [a: toggle animation] `7MM 286 | MM MM MM 287 | MM,dMMb.`7MM `7MMpMMMb. MM,dMMb.`7Mb,od8 .gP"Ya ,6"Yb. MM ,MP' 288 | MM `Mb MM MM MM MM `Mb MM' "',M' Yb 8) MM MM ;Y 289 | MM M8 MM MM MM MM M8 MM 8M"""""" ,pm9MM MM;Mm 290 | MM. ,M9 MM MM MM MM. ,M9 MM YM. , 8M MM MM `Mb. 291 | P^YbmdP'.JMML..JMML JMML.P^YbmdP'.JMML. `Mbmmd' `Moo9^Yo..JMML. YA. 292 | "#} 293 | .to_string(); 294 | 295 | // Get dimensions for calculations 296 | let art_lines: Vec<&str> = art.lines().collect(); 297 | let height = art_lines.len(); 298 | let width = art_lines.iter().map(|line| line.len()).max().unwrap_or(0); 299 | 300 | let strip_width = 8.0; 301 | let start_offset = -strip_width; 302 | let end_offset = (width + height) as f32 + strip_width; 303 | let total_range = end_offset - start_offset; 304 | 305 | // Color function that calculates colors on-the-fly based on animation progress 306 | let color_fn = 307 | move |x: usize, y: usize, progress: f32, _cycle: usize, highlight_color: Color| -> Color { 308 | let offset = start_offset + progress * total_range; 309 | let diag_pos = (x + y) as f32; 310 | let dist_from_strip = (diag_pos - offset).abs(); 311 | 312 | if dist_from_strip < strip_width { 313 | highlight_color 314 | } else { 315 | Color::DarkGray 316 | } 317 | }; 318 | 319 | // Character function that permanently replaces characters with '0' or '1' on first pass, 320 | // then reverses them back to original on second pass, creating an infinite loop 321 | let char_fn = 322 | move |x: usize, y: usize, progress: f32, cycle: usize, original_char: char| -> char { 323 | let offset = start_offset + progress * total_range; 324 | let diag_pos = (x + y) as f32; 325 | 326 | // Hash function to determine if character is '0' or '1' 327 | let mut position_hash = x.wrapping_mul(2654435761); 328 | position_hash ^= y.wrapping_mul(2246822519); 329 | position_hash = position_hash.wrapping_mul(668265263); 330 | position_hash ^= position_hash >> 15; 331 | 332 | let mut binary_hash = position_hash.wrapping_mul(1597334677); 333 | binary_hash ^= binary_hash >> 16; 334 | let binary_char = if (binary_hash & 1) == 0 { '0' } else { '1' }; 335 | 336 | // Even cycles (0, 2, 4...): transform original -> binary 337 | // Odd cycles (1, 3, 5...): transform binary -> original 338 | let is_forward_pass = cycle.is_multiple_of(2); 339 | 340 | // Check if the strip has passed this character yet 341 | let has_strip_passed = diag_pos < offset; 342 | 343 | if is_forward_pass { 344 | // Forward pass: if strip has passed, show binary; otherwise show original 345 | if has_strip_passed { binary_char } else { original_char } 346 | } else { 347 | // Reverse pass: if strip has passed, show original; otherwise show binary 348 | if has_strip_passed { original_char } else { binary_char } 349 | } 350 | }; 351 | 352 | ProceduralAnimationWidget::new( 353 | art, 354 | 50, // 50 frames worth of timing 355 | Duration::from_millis(50), 356 | color_fn, 357 | ) 358 | .with_char_fn(char_fn) 359 | .with_pause_at_end(Duration::from_secs(2)) 360 | } 361 | 362 | // Start menu state 363 | struct StartMenuState { 364 | items: Vec<(String, Bits)>, 365 | list_state: ListState, 366 | animation: ProceduralAnimationWidget, 367 | number_mode: NumberMode, 368 | } 369 | 370 | impl StartMenuState { 371 | fn new(prefs: AppPreferences) -> Self { 372 | Self::with_preferences(prefs) 373 | } 374 | 375 | fn with_preferences(prefs: AppPreferences) -> Self { 376 | let items = vec![ 377 | ("nibble_0 4 bit".to_string(), Bits::Four), 378 | ("nibble_1 4 bit*16".to_string(), Bits::FourShift4), 379 | ("nibble_2 4 bit*256".to_string(), Bits::FourShift8), 380 | ("nibble_3 4 bit*4096".to_string(), Bits::FourShift12), 381 | ("byte 8 bit".to_string(), Bits::Eight), 382 | ("hexlet 12 bit".to_string(), Bits::Twelve), 383 | ("word 16 bit".to_string(), Bits::Sixteen), 384 | ]; 385 | 386 | Self { 387 | items, 388 | list_state: ListState::default().with_selected(Some(prefs.last_selected_index)), 389 | animation: ascii_animation(), 390 | number_mode: prefs.last_number_mode, 391 | } 392 | } 393 | 394 | fn selected_index(&self) -> usize { 395 | self.list_state.selected().unwrap_or(0) 396 | } 397 | fn selected_bits(&self) -> Bits { 398 | self.items[self.selected_index()].1.clone() 399 | } 400 | fn select_next(&mut self) { 401 | let current = self.selected_index(); 402 | let next = if current + 1 >= self.items.len() { 403 | current // stay at last item 404 | } else { 405 | current + 1 406 | }; 407 | self.list_state.select(Some(next)); 408 | } 409 | fn select_previous(&mut self) { 410 | let current = self.selected_index(); 411 | let prev = if current == 0 { 412 | 0 // stay at first item 413 | } else { 414 | current - 1 415 | }; 416 | self.list_state.select(Some(prev)); 417 | } 418 | fn toggle_animation(&mut self) { 419 | self.animation.toggle_pause(); 420 | } 421 | fn toggle_number_mode(&mut self) { 422 | self.number_mode = match self.number_mode { 423 | NumberMode::Unsigned => NumberMode::Signed, 424 | NumberMode::Signed => NumberMode::Unsigned, 425 | }; 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.25.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 19 | 20 | [[package]] 21 | name = "allocator-api2" 22 | version = "0.2.21" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 25 | 26 | [[package]] 27 | name = "backtrace" 28 | version = "0.3.76" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" 31 | dependencies = [ 32 | "addr2line", 33 | "cfg-if", 34 | "libc", 35 | "miniz_oxide", 36 | "object", 37 | "rustc-demangle", 38 | "windows-link", 39 | ] 40 | 41 | [[package]] 42 | name = "binbreak" 43 | version = "0.3.1" 44 | dependencies = [ 45 | "color-eyre", 46 | "crossterm 0.29.0", 47 | "indoc", 48 | "rand", 49 | "ratatui", 50 | ] 51 | 52 | [[package]] 53 | name = "bitflags" 54 | version = "2.10.0" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 57 | 58 | [[package]] 59 | name = "cassowary" 60 | version = "0.3.0" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 63 | 64 | [[package]] 65 | name = "castaway" 66 | version = "0.2.4" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 69 | dependencies = [ 70 | "rustversion", 71 | ] 72 | 73 | [[package]] 74 | name = "cfg-if" 75 | version = "1.0.4" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 78 | 79 | [[package]] 80 | name = "color-eyre" 81 | version = "0.6.5" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" 84 | dependencies = [ 85 | "backtrace", 86 | "color-spantrace", 87 | "eyre", 88 | "indenter", 89 | "once_cell", 90 | "owo-colors", 91 | "tracing-error", 92 | ] 93 | 94 | [[package]] 95 | name = "color-spantrace" 96 | version = "0.3.0" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" 99 | dependencies = [ 100 | "once_cell", 101 | "owo-colors", 102 | "tracing-core", 103 | "tracing-error", 104 | ] 105 | 106 | [[package]] 107 | name = "compact_str" 108 | version = "0.8.1" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 111 | dependencies = [ 112 | "castaway", 113 | "cfg-if", 114 | "itoa", 115 | "rustversion", 116 | "ryu", 117 | "static_assertions", 118 | ] 119 | 120 | [[package]] 121 | name = "convert_case" 122 | version = "0.7.1" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" 125 | dependencies = [ 126 | "unicode-segmentation", 127 | ] 128 | 129 | [[package]] 130 | name = "crossterm" 131 | version = "0.28.1" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 134 | dependencies = [ 135 | "bitflags", 136 | "crossterm_winapi", 137 | "mio", 138 | "parking_lot", 139 | "rustix 0.38.44", 140 | "signal-hook", 141 | "signal-hook-mio", 142 | "winapi", 143 | ] 144 | 145 | [[package]] 146 | name = "crossterm" 147 | version = "0.29.0" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" 150 | dependencies = [ 151 | "bitflags", 152 | "crossterm_winapi", 153 | "derive_more", 154 | "document-features", 155 | "mio", 156 | "parking_lot", 157 | "rustix 1.1.2", 158 | "signal-hook", 159 | "signal-hook-mio", 160 | "winapi", 161 | ] 162 | 163 | [[package]] 164 | name = "crossterm_winapi" 165 | version = "0.9.1" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 168 | dependencies = [ 169 | "winapi", 170 | ] 171 | 172 | [[package]] 173 | name = "darling" 174 | version = "0.20.11" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 177 | dependencies = [ 178 | "darling_core", 179 | "darling_macro", 180 | ] 181 | 182 | [[package]] 183 | name = "darling_core" 184 | version = "0.20.11" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 187 | dependencies = [ 188 | "fnv", 189 | "ident_case", 190 | "proc-macro2", 191 | "quote", 192 | "strsim", 193 | "syn", 194 | ] 195 | 196 | [[package]] 197 | name = "darling_macro" 198 | version = "0.20.11" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 201 | dependencies = [ 202 | "darling_core", 203 | "quote", 204 | "syn", 205 | ] 206 | 207 | [[package]] 208 | name = "derive_more" 209 | version = "2.0.1" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" 212 | dependencies = [ 213 | "derive_more-impl", 214 | ] 215 | 216 | [[package]] 217 | name = "derive_more-impl" 218 | version = "2.0.1" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" 221 | dependencies = [ 222 | "convert_case", 223 | "proc-macro2", 224 | "quote", 225 | "syn", 226 | ] 227 | 228 | [[package]] 229 | name = "document-features" 230 | version = "0.2.12" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" 233 | dependencies = [ 234 | "litrs", 235 | ] 236 | 237 | [[package]] 238 | name = "either" 239 | version = "1.15.0" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 242 | 243 | [[package]] 244 | name = "equivalent" 245 | version = "1.0.2" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 248 | 249 | [[package]] 250 | name = "errno" 251 | version = "0.3.14" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 254 | dependencies = [ 255 | "libc", 256 | "windows-sys 0.61.2", 257 | ] 258 | 259 | [[package]] 260 | name = "eyre" 261 | version = "0.6.12" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" 264 | dependencies = [ 265 | "indenter", 266 | "once_cell", 267 | ] 268 | 269 | [[package]] 270 | name = "fnv" 271 | version = "1.0.7" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 274 | 275 | [[package]] 276 | name = "foldhash" 277 | version = "0.1.5" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 280 | 281 | [[package]] 282 | name = "getrandom" 283 | version = "0.3.4" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 286 | dependencies = [ 287 | "cfg-if", 288 | "libc", 289 | "r-efi", 290 | "wasip2", 291 | ] 292 | 293 | [[package]] 294 | name = "gimli" 295 | version = "0.32.3" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" 298 | 299 | [[package]] 300 | name = "hashbrown" 301 | version = "0.15.5" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 304 | dependencies = [ 305 | "allocator-api2", 306 | "equivalent", 307 | "foldhash", 308 | ] 309 | 310 | [[package]] 311 | name = "heck" 312 | version = "0.5.0" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 315 | 316 | [[package]] 317 | name = "ident_case" 318 | version = "1.0.1" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 321 | 322 | [[package]] 323 | name = "indenter" 324 | version = "0.3.4" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" 327 | 328 | [[package]] 329 | name = "indoc" 330 | version = "2.0.7" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" 333 | dependencies = [ 334 | "rustversion", 335 | ] 336 | 337 | [[package]] 338 | name = "instability" 339 | version = "0.3.9" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" 342 | dependencies = [ 343 | "darling", 344 | "indoc", 345 | "proc-macro2", 346 | "quote", 347 | "syn", 348 | ] 349 | 350 | [[package]] 351 | name = "itertools" 352 | version = "0.13.0" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 355 | dependencies = [ 356 | "either", 357 | ] 358 | 359 | [[package]] 360 | name = "itoa" 361 | version = "1.0.15" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 364 | 365 | [[package]] 366 | name = "lazy_static" 367 | version = "1.5.0" 368 | source = "registry+https://github.com/rust-lang/crates.io-index" 369 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 370 | 371 | [[package]] 372 | name = "libc" 373 | version = "0.2.177" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 376 | 377 | [[package]] 378 | name = "linux-raw-sys" 379 | version = "0.4.15" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 382 | 383 | [[package]] 384 | name = "linux-raw-sys" 385 | version = "0.11.0" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 388 | 389 | [[package]] 390 | name = "litrs" 391 | version = "1.0.0" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" 394 | 395 | [[package]] 396 | name = "lock_api" 397 | version = "0.4.14" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 400 | dependencies = [ 401 | "scopeguard", 402 | ] 403 | 404 | [[package]] 405 | name = "log" 406 | version = "0.4.28" 407 | source = "registry+https://github.com/rust-lang/crates.io-index" 408 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 409 | 410 | [[package]] 411 | name = "lru" 412 | version = "0.12.5" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 415 | dependencies = [ 416 | "hashbrown", 417 | ] 418 | 419 | [[package]] 420 | name = "memchr" 421 | version = "2.7.6" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 424 | 425 | [[package]] 426 | name = "miniz_oxide" 427 | version = "0.8.9" 428 | source = "registry+https://github.com/rust-lang/crates.io-index" 429 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 430 | dependencies = [ 431 | "adler2", 432 | ] 433 | 434 | [[package]] 435 | name = "mio" 436 | version = "1.1.0" 437 | source = "registry+https://github.com/rust-lang/crates.io-index" 438 | checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" 439 | dependencies = [ 440 | "libc", 441 | "log", 442 | "wasi", 443 | "windows-sys 0.61.2", 444 | ] 445 | 446 | [[package]] 447 | name = "object" 448 | version = "0.37.3" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" 451 | dependencies = [ 452 | "memchr", 453 | ] 454 | 455 | [[package]] 456 | name = "once_cell" 457 | version = "1.21.3" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 460 | 461 | [[package]] 462 | name = "owo-colors" 463 | version = "4.2.3" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" 466 | 467 | [[package]] 468 | name = "parking_lot" 469 | version = "0.12.5" 470 | source = "registry+https://github.com/rust-lang/crates.io-index" 471 | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 472 | dependencies = [ 473 | "lock_api", 474 | "parking_lot_core", 475 | ] 476 | 477 | [[package]] 478 | name = "parking_lot_core" 479 | version = "0.9.12" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 482 | dependencies = [ 483 | "cfg-if", 484 | "libc", 485 | "redox_syscall", 486 | "smallvec", 487 | "windows-link", 488 | ] 489 | 490 | [[package]] 491 | name = "paste" 492 | version = "1.0.15" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 495 | 496 | [[package]] 497 | name = "pin-project-lite" 498 | version = "0.2.16" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 501 | 502 | [[package]] 503 | name = "ppv-lite86" 504 | version = "0.2.21" 505 | source = "registry+https://github.com/rust-lang/crates.io-index" 506 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 507 | dependencies = [ 508 | "zerocopy", 509 | ] 510 | 511 | [[package]] 512 | name = "proc-macro2" 513 | version = "1.0.103" 514 | source = "registry+https://github.com/rust-lang/crates.io-index" 515 | checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 516 | dependencies = [ 517 | "unicode-ident", 518 | ] 519 | 520 | [[package]] 521 | name = "quote" 522 | version = "1.0.42" 523 | source = "registry+https://github.com/rust-lang/crates.io-index" 524 | checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 525 | dependencies = [ 526 | "proc-macro2", 527 | ] 528 | 529 | [[package]] 530 | name = "r-efi" 531 | version = "5.3.0" 532 | source = "registry+https://github.com/rust-lang/crates.io-index" 533 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 534 | 535 | [[package]] 536 | name = "rand" 537 | version = "0.9.2" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 540 | dependencies = [ 541 | "rand_chacha", 542 | "rand_core", 543 | ] 544 | 545 | [[package]] 546 | name = "rand_chacha" 547 | version = "0.9.0" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 550 | dependencies = [ 551 | "ppv-lite86", 552 | "rand_core", 553 | ] 554 | 555 | [[package]] 556 | name = "rand_core" 557 | version = "0.9.3" 558 | source = "registry+https://github.com/rust-lang/crates.io-index" 559 | checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 560 | dependencies = [ 561 | "getrandom", 562 | ] 563 | 564 | [[package]] 565 | name = "ratatui" 566 | version = "0.29.0" 567 | source = "registry+https://github.com/rust-lang/crates.io-index" 568 | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 569 | dependencies = [ 570 | "bitflags", 571 | "cassowary", 572 | "compact_str", 573 | "crossterm 0.28.1", 574 | "indoc", 575 | "instability", 576 | "itertools", 577 | "lru", 578 | "paste", 579 | "strum", 580 | "unicode-segmentation", 581 | "unicode-truncate", 582 | "unicode-width 0.2.0", 583 | ] 584 | 585 | [[package]] 586 | name = "redox_syscall" 587 | version = "0.5.18" 588 | source = "registry+https://github.com/rust-lang/crates.io-index" 589 | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 590 | dependencies = [ 591 | "bitflags", 592 | ] 593 | 594 | [[package]] 595 | name = "rustc-demangle" 596 | version = "0.1.26" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" 599 | 600 | [[package]] 601 | name = "rustix" 602 | version = "0.38.44" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 605 | dependencies = [ 606 | "bitflags", 607 | "errno", 608 | "libc", 609 | "linux-raw-sys 0.4.15", 610 | "windows-sys 0.59.0", 611 | ] 612 | 613 | [[package]] 614 | name = "rustix" 615 | version = "1.1.2" 616 | source = "registry+https://github.com/rust-lang/crates.io-index" 617 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 618 | dependencies = [ 619 | "bitflags", 620 | "errno", 621 | "libc", 622 | "linux-raw-sys 0.11.0", 623 | "windows-sys 0.61.2", 624 | ] 625 | 626 | [[package]] 627 | name = "rustversion" 628 | version = "1.0.22" 629 | source = "registry+https://github.com/rust-lang/crates.io-index" 630 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 631 | 632 | [[package]] 633 | name = "ryu" 634 | version = "1.0.20" 635 | source = "registry+https://github.com/rust-lang/crates.io-index" 636 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 637 | 638 | [[package]] 639 | name = "scopeguard" 640 | version = "1.2.0" 641 | source = "registry+https://github.com/rust-lang/crates.io-index" 642 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 643 | 644 | [[package]] 645 | name = "sharded-slab" 646 | version = "0.1.7" 647 | source = "registry+https://github.com/rust-lang/crates.io-index" 648 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 649 | dependencies = [ 650 | "lazy_static", 651 | ] 652 | 653 | [[package]] 654 | name = "signal-hook" 655 | version = "0.3.18" 656 | source = "registry+https://github.com/rust-lang/crates.io-index" 657 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 658 | dependencies = [ 659 | "libc", 660 | "signal-hook-registry", 661 | ] 662 | 663 | [[package]] 664 | name = "signal-hook-mio" 665 | version = "0.2.5" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" 668 | dependencies = [ 669 | "libc", 670 | "mio", 671 | "signal-hook", 672 | ] 673 | 674 | [[package]] 675 | name = "signal-hook-registry" 676 | version = "1.4.6" 677 | source = "registry+https://github.com/rust-lang/crates.io-index" 678 | checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 679 | dependencies = [ 680 | "libc", 681 | ] 682 | 683 | [[package]] 684 | name = "smallvec" 685 | version = "1.15.1" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 688 | 689 | [[package]] 690 | name = "static_assertions" 691 | version = "1.1.0" 692 | source = "registry+https://github.com/rust-lang/crates.io-index" 693 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 694 | 695 | [[package]] 696 | name = "strsim" 697 | version = "0.11.1" 698 | source = "registry+https://github.com/rust-lang/crates.io-index" 699 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 700 | 701 | [[package]] 702 | name = "strum" 703 | version = "0.26.3" 704 | source = "registry+https://github.com/rust-lang/crates.io-index" 705 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 706 | dependencies = [ 707 | "strum_macros", 708 | ] 709 | 710 | [[package]] 711 | name = "strum_macros" 712 | version = "0.26.4" 713 | source = "registry+https://github.com/rust-lang/crates.io-index" 714 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 715 | dependencies = [ 716 | "heck", 717 | "proc-macro2", 718 | "quote", 719 | "rustversion", 720 | "syn", 721 | ] 722 | 723 | [[package]] 724 | name = "syn" 725 | version = "2.0.109" 726 | source = "registry+https://github.com/rust-lang/crates.io-index" 727 | checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" 728 | dependencies = [ 729 | "proc-macro2", 730 | "quote", 731 | "unicode-ident", 732 | ] 733 | 734 | [[package]] 735 | name = "thread_local" 736 | version = "1.1.9" 737 | source = "registry+https://github.com/rust-lang/crates.io-index" 738 | checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 739 | dependencies = [ 740 | "cfg-if", 741 | ] 742 | 743 | [[package]] 744 | name = "tracing" 745 | version = "0.1.41" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 748 | dependencies = [ 749 | "pin-project-lite", 750 | "tracing-core", 751 | ] 752 | 753 | [[package]] 754 | name = "tracing-core" 755 | version = "0.1.34" 756 | source = "registry+https://github.com/rust-lang/crates.io-index" 757 | checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 758 | dependencies = [ 759 | "once_cell", 760 | "valuable", 761 | ] 762 | 763 | [[package]] 764 | name = "tracing-error" 765 | version = "0.2.1" 766 | source = "registry+https://github.com/rust-lang/crates.io-index" 767 | checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" 768 | dependencies = [ 769 | "tracing", 770 | "tracing-subscriber", 771 | ] 772 | 773 | [[package]] 774 | name = "tracing-subscriber" 775 | version = "0.3.20" 776 | source = "registry+https://github.com/rust-lang/crates.io-index" 777 | checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 778 | dependencies = [ 779 | "sharded-slab", 780 | "thread_local", 781 | "tracing-core", 782 | ] 783 | 784 | [[package]] 785 | name = "unicode-ident" 786 | version = "1.0.22" 787 | source = "registry+https://github.com/rust-lang/crates.io-index" 788 | checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 789 | 790 | [[package]] 791 | name = "unicode-segmentation" 792 | version = "1.12.0" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 795 | 796 | [[package]] 797 | name = "unicode-truncate" 798 | version = "1.1.0" 799 | source = "registry+https://github.com/rust-lang/crates.io-index" 800 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 801 | dependencies = [ 802 | "itertools", 803 | "unicode-segmentation", 804 | "unicode-width 0.1.14", 805 | ] 806 | 807 | [[package]] 808 | name = "unicode-width" 809 | version = "0.1.14" 810 | source = "registry+https://github.com/rust-lang/crates.io-index" 811 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 812 | 813 | [[package]] 814 | name = "unicode-width" 815 | version = "0.2.0" 816 | source = "registry+https://github.com/rust-lang/crates.io-index" 817 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 818 | 819 | [[package]] 820 | name = "valuable" 821 | version = "0.1.1" 822 | source = "registry+https://github.com/rust-lang/crates.io-index" 823 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 824 | 825 | [[package]] 826 | name = "wasi" 827 | version = "0.11.1+wasi-snapshot-preview1" 828 | source = "registry+https://github.com/rust-lang/crates.io-index" 829 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 830 | 831 | [[package]] 832 | name = "wasip2" 833 | version = "1.0.1+wasi-0.2.4" 834 | source = "registry+https://github.com/rust-lang/crates.io-index" 835 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 836 | dependencies = [ 837 | "wit-bindgen", 838 | ] 839 | 840 | [[package]] 841 | name = "winapi" 842 | version = "0.3.9" 843 | source = "registry+https://github.com/rust-lang/crates.io-index" 844 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 845 | dependencies = [ 846 | "winapi-i686-pc-windows-gnu", 847 | "winapi-x86_64-pc-windows-gnu", 848 | ] 849 | 850 | [[package]] 851 | name = "winapi-i686-pc-windows-gnu" 852 | version = "0.4.0" 853 | source = "registry+https://github.com/rust-lang/crates.io-index" 854 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 855 | 856 | [[package]] 857 | name = "winapi-x86_64-pc-windows-gnu" 858 | version = "0.4.0" 859 | source = "registry+https://github.com/rust-lang/crates.io-index" 860 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 861 | 862 | [[package]] 863 | name = "windows-link" 864 | version = "0.2.1" 865 | source = "registry+https://github.com/rust-lang/crates.io-index" 866 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 867 | 868 | [[package]] 869 | name = "windows-sys" 870 | version = "0.59.0" 871 | source = "registry+https://github.com/rust-lang/crates.io-index" 872 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 873 | dependencies = [ 874 | "windows-targets", 875 | ] 876 | 877 | [[package]] 878 | name = "windows-sys" 879 | version = "0.61.2" 880 | source = "registry+https://github.com/rust-lang/crates.io-index" 881 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 882 | dependencies = [ 883 | "windows-link", 884 | ] 885 | 886 | [[package]] 887 | name = "windows-targets" 888 | version = "0.52.6" 889 | source = "registry+https://github.com/rust-lang/crates.io-index" 890 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 891 | dependencies = [ 892 | "windows_aarch64_gnullvm", 893 | "windows_aarch64_msvc", 894 | "windows_i686_gnu", 895 | "windows_i686_gnullvm", 896 | "windows_i686_msvc", 897 | "windows_x86_64_gnu", 898 | "windows_x86_64_gnullvm", 899 | "windows_x86_64_msvc", 900 | ] 901 | 902 | [[package]] 903 | name = "windows_aarch64_gnullvm" 904 | version = "0.52.6" 905 | source = "registry+https://github.com/rust-lang/crates.io-index" 906 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 907 | 908 | [[package]] 909 | name = "windows_aarch64_msvc" 910 | version = "0.52.6" 911 | source = "registry+https://github.com/rust-lang/crates.io-index" 912 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 913 | 914 | [[package]] 915 | name = "windows_i686_gnu" 916 | version = "0.52.6" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 919 | 920 | [[package]] 921 | name = "windows_i686_gnullvm" 922 | version = "0.52.6" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 925 | 926 | [[package]] 927 | name = "windows_i686_msvc" 928 | version = "0.52.6" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 931 | 932 | [[package]] 933 | name = "windows_x86_64_gnu" 934 | version = "0.52.6" 935 | source = "registry+https://github.com/rust-lang/crates.io-index" 936 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 937 | 938 | [[package]] 939 | name = "windows_x86_64_gnullvm" 940 | version = "0.52.6" 941 | source = "registry+https://github.com/rust-lang/crates.io-index" 942 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 943 | 944 | [[package]] 945 | name = "windows_x86_64_msvc" 946 | version = "0.52.6" 947 | source = "registry+https://github.com/rust-lang/crates.io-index" 948 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 949 | 950 | [[package]] 951 | name = "wit-bindgen" 952 | version = "0.46.0" 953 | source = "registry+https://github.com/rust-lang/crates.io-index" 954 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 955 | 956 | [[package]] 957 | name = "zerocopy" 958 | version = "0.8.27" 959 | source = "registry+https://github.com/rust-lang/crates.io-index" 960 | checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" 961 | dependencies = [ 962 | "zerocopy-derive", 963 | ] 964 | 965 | [[package]] 966 | name = "zerocopy-derive" 967 | version = "0.8.27" 968 | source = "registry+https://github.com/rust-lang/crates.io-index" 969 | checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" 970 | dependencies = [ 971 | "proc-macro2", 972 | "quote", 973 | "syn", 974 | ] 975 | -------------------------------------------------------------------------------- /src/binary_numbers.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{NumberMode, get_mode_color}; 2 | use crate::keybinds; 3 | use crate::main_screen_widget::{MainScreenWidget, WidgetRef}; 4 | use crate::utils::{When, center}; 5 | use crossterm::event::{KeyCode, KeyEvent}; 6 | use rand::Rng; 7 | use rand::prelude::SliceRandom; 8 | use ratatui::buffer::Buffer; 9 | use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; 10 | use ratatui::prelude::Alignment::Center; 11 | use ratatui::prelude::{Color, Line, Style, Stylize, Widget}; 12 | use ratatui::style::Modifier; 13 | use ratatui::text::Span; 14 | use ratatui::widgets::BorderType::Double; 15 | use ratatui::widgets::{Block, BorderType, Paragraph}; 16 | use std::collections::HashMap; 17 | use std::fmt::Write as _; 18 | use std::fs::File; 19 | use std::io::{Read, Write}; 20 | 21 | struct StatsSnapshot { 22 | score: u32, 23 | streak: u32, 24 | max_streak: u32, 25 | rounds: u32, 26 | lives: u32, 27 | bits: Bits, 28 | number_mode: NumberMode, 29 | hearts: String, 30 | game_state: GameState, 31 | prev_high_score: u32, 32 | new_high_score: bool, 33 | } 34 | 35 | impl WidgetRef for BinaryNumbersGame { 36 | fn render_ref(&self, area: Rect, buf: &mut Buffer) { 37 | let [game_column] = Layout::horizontal([Constraint::Length(65)]) 38 | .flex(Flex::Center) 39 | .horizontal_margin(1) 40 | .areas(area); 41 | 42 | self.puzzle.render_ref(game_column, buf); 43 | } 44 | } 45 | 46 | impl WidgetRef for BinaryNumbersPuzzle { 47 | fn render_ref(&self, area: Rect, buf: &mut Buffer) { 48 | let [middle] = 49 | Layout::horizontal([Constraint::Percentage(100)]).flex(Flex::Center).areas(area); 50 | 51 | let [stats_area, current_number_area, suggestions_area, progress_bar_area, result_area] = 52 | Layout::vertical([ 53 | Constraint::Length(4), 54 | Constraint::Length(5), 55 | Constraint::Length(3), 56 | Constraint::Length(4), 57 | Constraint::Length(5), 58 | ]) 59 | .flex(Flex::Center) 60 | .horizontal_margin(0) 61 | .areas(middle); 62 | 63 | self.render_stats_area(stats_area, buf); 64 | 65 | if let Some(stats) = &self.stats_snapshot 66 | && stats.game_state == GameState::GameOver 67 | { 68 | render_game_over( 69 | stats, 70 | current_number_area, 71 | suggestions_area, 72 | progress_bar_area, 73 | result_area, 74 | buf, 75 | ); 76 | return; 77 | } 78 | 79 | self.render_current_number(current_number_area, buf); 80 | self.render_suggestions(suggestions_area, buf); 81 | self.render_status_and_timer(progress_bar_area, buf); 82 | self.render_instructions(result_area, buf); 83 | } 84 | } 85 | 86 | impl BinaryNumbersPuzzle { 87 | fn render_stats_area(&self, area: Rect, buf: &mut Buffer) { 88 | Block::bordered().title_alignment(Center).dark_gray().render(area, buf); 89 | 90 | if let Some(stats) = &self.stats_snapshot { 91 | let high_label = if stats.new_high_score { 92 | let style = Style::default().fg(Color::LightGreen).add_modifier(Modifier::BOLD); 93 | Span::styled(format!("Hi-Score: {}* ", stats.score), style) 94 | } else { 95 | let style = Style::default().fg(Color::DarkGray); 96 | Span::styled(format!("Hi-Score: {} ", stats.prev_high_score), style) 97 | }; 98 | 99 | let mode_color = get_mode_color(&stats.bits); 100 | let mode_label = format!("{} {}", stats.bits.label(), stats.number_mode.label()); 101 | let line1 = Line::from(vec![ 102 | Span::styled(format!("Mode: {} ", mode_label), Style::default().fg(mode_color)), 103 | high_label, 104 | ]); 105 | 106 | let line2 = Line::from(vec![ 107 | Span::styled( 108 | format!("Score: {} ", stats.score), 109 | Style::default().fg(Color::Green), 110 | ), 111 | Span::styled( 112 | format!("Streak: {} ", stats.streak), 113 | Style::default().fg(Color::Cyan), 114 | ), 115 | Span::styled( 116 | format!("Max: {} ", stats.max_streak), 117 | Style::default().fg(Color::Blue), 118 | ), 119 | Span::styled( 120 | format!("Rounds: {} ", stats.rounds), 121 | Style::default().fg(Color::Magenta), 122 | ), 123 | Span::styled(format!("Lives: {} ", stats.hearts), Style::default().fg(Color::Red)), 124 | ]); 125 | 126 | #[allow(clippy::cast_possible_truncation)] 127 | let widest = line1.width().max(line2.width()) as u16; 128 | Paragraph::new(vec![line1, line2]) 129 | .alignment(Center) 130 | .render(center(area, Constraint::Length(widest)), buf); 131 | } 132 | } 133 | 134 | fn render_current_number(&self, area: Rect, buf: &mut Buffer) { 135 | let [inner] = 136 | Layout::horizontal([Constraint::Percentage(100)]).flex(Flex::Center).areas(area); 137 | 138 | Block::bordered() 139 | .border_type(Double) 140 | .border_style(Style::default().dark_gray()) 141 | .render(inner, buf); 142 | 143 | let binary_string = self.current_to_binary_string(); 144 | let scale_suffix = match self.bits { 145 | Bits::FourShift4 => Some(" x16"), 146 | Bits::FourShift8 => Some(" x256"), 147 | Bits::FourShift12 => Some(" x4096"), 148 | _ => None, 149 | }; 150 | let mut spans = vec![Span::raw(binary_string)]; 151 | if let Some(sfx) = scale_suffix { 152 | spans.push(Span::styled(sfx, Style::default().fg(Color::DarkGray))); 153 | } 154 | #[allow(clippy::cast_possible_truncation)] 155 | let total_width = spans.iter().map(ratatui::prelude::Span::width).sum::() as u16; 156 | let lines: Vec = vec![Line::from(spans)]; 157 | Paragraph::new(lines) 158 | .alignment(Center) 159 | .render(center(inner, Constraint::Length(total_width)), buf); 160 | } 161 | 162 | fn render_suggestions(&self, area: Rect, buf: &mut Buffer) { 163 | let suggestions = self.suggestions(); 164 | let suggestions_layout = Layout::default() 165 | .direction(Direction::Horizontal) 166 | .constraints(vec![Constraint::Min(6); suggestions.len()]) 167 | .split(area); 168 | 169 | for (i, suggestion) in suggestions.iter().enumerate() { 170 | let item_is_selected = self.selected_suggestion == Some(*suggestion); 171 | let show_correct_number = self.guess_result.is_some(); 172 | let is_correct_number = self.is_correct_guess(*suggestion); 173 | let area = suggestions_layout[i]; 174 | 175 | let border_type = if item_is_selected { 176 | BorderType::Double 177 | } else { 178 | BorderType::Plain 179 | }; 180 | 181 | let border_color = if item_is_selected { 182 | match self.guess_result { 183 | Some(GuessResult::Correct) => Color::Green, 184 | Some(GuessResult::Incorrect) => Color::Red, 185 | Some(GuessResult::Timeout) => Color::Yellow, 186 | None => Color::LightCyan, 187 | } 188 | } else { 189 | Color::DarkGray 190 | }; 191 | 192 | Block::bordered().border_type(border_type).fg(border_color).render(area, buf); 193 | 194 | let suggestion_str = format!("{suggestion}"); 195 | 196 | #[allow(clippy::cast_possible_truncation)] 197 | Paragraph::new(suggestion_str.to_string()) 198 | .white() 199 | .when(show_correct_number && is_correct_number, |p| p.light_green().underlined()) 200 | .alignment(Center) 201 | .render(center(area, Constraint::Length(suggestion_str.len() as u16)), buf); 202 | } 203 | } 204 | 205 | fn render_status_and_timer(&self, area: Rect, buf: &mut Buffer) { 206 | let [left, right] = Layout::default() 207 | .direction(Direction::Horizontal) 208 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 209 | .areas(area); 210 | 211 | self.render_status(left, buf); 212 | self.render_timer(right, buf); 213 | } 214 | 215 | fn render_status(&self, area: Rect, buf: &mut Buffer) { 216 | Block::bordered() 217 | .dark_gray() 218 | .title("Status") 219 | .title_alignment(Center) 220 | .title_style(Style::default().white()) 221 | .render(area, buf); 222 | 223 | if let Some(result) = &self.guess_result { 224 | let (icon, line1_text, color) = match result { 225 | GuessResult::Correct => (":)", "success", Color::Green), 226 | GuessResult::Incorrect => (":(", "incorrect", Color::Red), 227 | GuessResult::Timeout => (":(", "time's up", Color::Yellow), 228 | }; 229 | 230 | let gained_line = match result { 231 | GuessResult::Correct => format!("gained {} points", self.last_points_awarded), 232 | GuessResult::Incorrect => "lost a life".to_string(), 233 | GuessResult::Timeout => "timeout".to_string(), 234 | }; 235 | 236 | let text = vec![ 237 | Line::from(format!("{icon} {line1_text}").fg(color)), 238 | Line::from(gained_line.fg(color)), 239 | ]; 240 | #[allow(clippy::cast_possible_truncation)] 241 | let widest = text.iter().map(Line::width).max().unwrap_or(0) as u16; 242 | Paragraph::new(text) 243 | .alignment(Center) 244 | .style(Style::default().fg(color)) 245 | .render(center(area, Constraint::Length(widest)), buf); 246 | } 247 | } 248 | 249 | fn render_timer(&self, area: Rect, buf: &mut Buffer) { 250 | let ratio = self.time_left / self.time_total; 251 | let gauge_color = if ratio > 0.6 { 252 | Color::Green 253 | } else if ratio > 0.3 { 254 | Color::Yellow 255 | } else { 256 | Color::Red 257 | }; 258 | 259 | let time_block = Block::bordered() 260 | .dark_gray() 261 | .title("Time Remaining") 262 | .title_style(Style::default().white()) 263 | .title_alignment(Center); 264 | let inner_time = time_block.inner(area); 265 | time_block.render(area, buf); 266 | 267 | let [gauge_line, time_line] = 268 | Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner_time); 269 | 270 | render_ascii_gauge(gauge_line, buf, ratio, gauge_color); 271 | 272 | Paragraph::new(Line::from(Span::styled( 273 | format!("{:.2} seconds left", self.time_left), 274 | Style::default().fg(gauge_color), 275 | ))) 276 | .alignment(Center) 277 | .render(time_line, buf); 278 | } 279 | 280 | fn render_instructions(&self, area: Rect, buf: &mut Buffer) { 281 | Block::bordered().dark_gray().render(area, buf); 282 | 283 | let instruction_spans: Vec = [ 284 | hotkey_span("Left Right", "select "), 285 | hotkey_span("Enter", "confirm "), 286 | hotkey_span("S", "skip "), 287 | hotkey_span("Esc", "exit"), 288 | ] 289 | .iter() 290 | .flatten() 291 | .cloned() 292 | .collect(); 293 | 294 | Paragraph::new(vec![Line::from(instruction_spans)]) 295 | .alignment(Center) 296 | .render(center(area, Constraint::Length(65)), buf); 297 | } 298 | } 299 | 300 | fn hotkey_span<'a>(key: &'a str, description: &str) -> Vec> { 301 | vec![ 302 | Span::styled("<", Style::default().fg(Color::White)), 303 | Span::styled(key, Style::default().fg(Color::LightCyan)), 304 | Span::styled(format!("> {description}"), Style::default().fg(Color::White)), 305 | ] 306 | } 307 | 308 | fn render_game_over( 309 | stats: &StatsSnapshot, 310 | current_number_area: Rect, 311 | suggestions_area: Rect, 312 | progress_bar_area: Rect, 313 | result_area: Rect, 314 | buf: &mut Buffer, 315 | ) { 316 | let combined_rect = Rect { 317 | x: current_number_area.x, 318 | y: current_number_area.y, 319 | width: current_number_area.width, 320 | height: current_number_area.height 321 | + suggestions_area.height 322 | + progress_bar_area.height 323 | + result_area.height, 324 | }; 325 | Block::bordered().border_style(Style::default().fg(Color::DarkGray)).render(combined_rect, buf); 326 | 327 | let mut lines = vec![ 328 | Line::from(Span::styled( 329 | format!("Final Score: {}", stats.score), 330 | Style::default().fg(Color::Green), 331 | )), 332 | Line::from(Span::styled( 333 | format!("Previous High: {}", stats.prev_high_score), 334 | Style::default().fg(Color::Yellow), 335 | )), 336 | Line::from(Span::styled( 337 | format!("Rounds Played: {}", stats.rounds), 338 | Style::default().fg(Color::Magenta), 339 | )), 340 | Line::from(Span::styled( 341 | format!("Max Streak: {}", stats.max_streak), 342 | Style::default().fg(Color::Cyan), 343 | )), 344 | ]; 345 | if stats.new_high_score { 346 | lines.insert( 347 | 1, 348 | Line::from(Span::styled( 349 | "NEW HIGH SCORE!", 350 | Style::default().fg(Color::LightGreen).bold(), 351 | )), 352 | ); 353 | } 354 | if stats.lives == 0 { 355 | lines.push(Line::from(Span::styled( 356 | "You lost all your lives.", 357 | Style::default().fg(Color::Red), 358 | ))); 359 | } 360 | lines.push(Line::from(Span::styled( 361 | "Press Enter to restart or Esc to exit", 362 | Style::default().fg(Color::Yellow), 363 | ))); 364 | Paragraph::new(lines) 365 | .alignment(Center) 366 | .render(center(combined_rect, Constraint::Length(48)), buf); 367 | } 368 | 369 | pub struct BinaryNumbersGame { 370 | puzzle: BinaryNumbersPuzzle, 371 | bits: Bits, 372 | number_mode: NumberMode, 373 | exit_intended: bool, 374 | score: u32, 375 | streak: u32, 376 | rounds: u32, 377 | puzzle_resolved: bool, 378 | lives: u32, 379 | max_lives: u32, 380 | game_state: GameState, 381 | max_streak: u32, 382 | high_scores: HighScores, 383 | prev_high_score_for_display: u32, 384 | new_high_score_reached: bool, 385 | } 386 | 387 | #[derive(Copy, Clone, PartialEq, Debug)] 388 | enum GameState { 389 | Active, 390 | Result, 391 | PendingGameOver, 392 | GameOver, 393 | } 394 | 395 | impl MainScreenWidget for BinaryNumbersGame { 396 | fn run(&mut self, dt: f64) { 397 | self.refresh_stats_snapshot(); 398 | if self.game_state == GameState::GameOver { 399 | return; 400 | } 401 | self.puzzle.run(dt); 402 | if self.puzzle.guess_result.is_some() && !self.puzzle_resolved { 403 | self.finalize_round(); 404 | } 405 | self.refresh_stats_snapshot(); 406 | } 407 | 408 | fn handle_input(&mut self, input: KeyEvent) { 409 | self.handle_game_input(input); 410 | } 411 | fn is_exit_intended(&self) -> bool { 412 | self.exit_intended 413 | } 414 | } 415 | 416 | impl BinaryNumbersGame { 417 | pub fn new(bits: Bits, number_mode: NumberMode) -> Self { 418 | Self::new_with_max_lives(bits, number_mode, 3) 419 | } 420 | pub fn new_with_max_lives(bits: Bits, number_mode: NumberMode, max_lives: u32) -> Self { 421 | let hs = HighScores::load(); 422 | let high_score_key = Self::compute_high_score_key(&bits, number_mode); 423 | let starting_prev = hs.get(&high_score_key); 424 | let mut game = Self { 425 | bits: bits.clone(), 426 | number_mode, 427 | puzzle: Self::init_puzzle(bits, number_mode, 0), 428 | exit_intended: false, 429 | score: 0, 430 | streak: 0, 431 | rounds: 0, 432 | puzzle_resolved: false, 433 | lives: max_lives.min(3), 434 | max_lives, 435 | game_state: GameState::Active, 436 | max_streak: 0, 437 | high_scores: hs, 438 | prev_high_score_for_display: starting_prev, 439 | new_high_score_reached: false, 440 | }; 441 | // Initialize stats snapshot immediately so stats display on first render 442 | game.refresh_stats_snapshot(); 443 | game 444 | } 445 | 446 | pub fn init_puzzle(bits: Bits, number_mode: NumberMode, streak: u32) -> BinaryNumbersPuzzle { 447 | BinaryNumbersPuzzle::new(bits, number_mode, streak) 448 | } 449 | 450 | fn compute_high_score_key(bits: &Bits, number_mode: NumberMode) -> String { 451 | let bits_key = bits.high_score_key(); 452 | let mode_suffix = match number_mode { 453 | NumberMode::Unsigned => "u", 454 | NumberMode::Signed => "s", 455 | }; 456 | format!("{}{}", bits_key, mode_suffix) 457 | } 458 | 459 | pub fn is_active(&self) -> bool { 460 | self.game_state == GameState::Active 461 | } 462 | } 463 | 464 | impl BinaryNumbersGame { 465 | pub fn lives_hearts(&self) -> String { 466 | let full_count = self.lives.min(self.max_lives) as usize; 467 | let full = "♥".repeat(full_count); 468 | let empty_count = self.max_lives.saturating_sub(self.lives) as usize; 469 | let empty = "·".repeat(empty_count); 470 | format!("{full}{empty}") 471 | } 472 | 473 | fn finalize_round(&mut self) { 474 | if let Some(result) = self.puzzle.guess_result { 475 | self.rounds += 1; 476 | match result { 477 | GuessResult::Correct => { 478 | self.streak += 1; 479 | if self.streak > self.max_streak { 480 | self.max_streak = self.streak; 481 | } 482 | let streak_bonus = (self.streak - 1) * 2; 483 | let points = 10 + streak_bonus; 484 | self.score += points; 485 | self.puzzle.last_points_awarded = points; 486 | if self.streak.is_multiple_of(5) && self.lives < self.max_lives { 487 | self.lives += 1; 488 | } 489 | }, 490 | GuessResult::Incorrect | GuessResult::Timeout => { 491 | self.streak = 0; 492 | self.puzzle.last_points_awarded = 0; 493 | if self.lives > 0 { 494 | self.lives -= 1; 495 | } 496 | }, 497 | } 498 | // high score update 499 | let bits_key = Self::compute_high_score_key(&self.bits, self.number_mode); 500 | let prev = self.high_scores.get(&bits_key); 501 | if self.score > prev { 502 | if !self.new_high_score_reached { 503 | self.prev_high_score_for_display = prev; 504 | } 505 | self.high_scores.update(&bits_key, self.score); 506 | self.new_high_score_reached = true; 507 | let _ = self.high_scores.save(); 508 | } 509 | // set state after round resolution 510 | if self.lives == 0 { 511 | self.game_state = GameState::PendingGameOver; // defer summary until Enter 512 | } else { 513 | self.game_state = GameState::Result; 514 | } 515 | self.puzzle_resolved = true; 516 | } 517 | } 518 | 519 | pub fn handle_game_input(&mut self, input: KeyEvent) { 520 | if keybinds::is_exit(input) { 521 | self.exit_intended = true; 522 | return; 523 | } 524 | 525 | if self.game_state == GameState::GameOver { 526 | self.handle_game_over_input(input); 527 | return; 528 | } 529 | match self.puzzle.guess_result { 530 | None => self.handle_no_result_yet(input), 531 | Some(_) => self.handle_result_available(input), 532 | } 533 | } 534 | 535 | fn handle_game_over_input(&mut self, key: KeyEvent) { 536 | match key { 537 | x if keybinds::is_select(x) => { 538 | self.reset_game_state(); 539 | }, 540 | x if keybinds::is_exit(x) => { 541 | self.exit_intended = true; 542 | }, 543 | _ => {}, 544 | } 545 | } 546 | 547 | fn reset_game_state(&mut self) { 548 | self.score = 0; 549 | self.streak = 0; 550 | self.rounds = 0; 551 | self.lives = self.max_lives.min(3); 552 | self.game_state = GameState::Active; 553 | self.max_streak = 0; 554 | let high_score_key = Self::compute_high_score_key(&self.bits, self.number_mode); 555 | self.prev_high_score_for_display = self.high_scores.get(&high_score_key); 556 | self.new_high_score_reached = false; 557 | self.puzzle = Self::init_puzzle(self.bits.clone(), self.number_mode, 0); 558 | self.puzzle_resolved = false; 559 | self.refresh_stats_snapshot(); 560 | } 561 | 562 | fn handle_no_result_yet(&mut self, input: KeyEvent) { 563 | match input { 564 | x if keybinds::is_right(x) => { 565 | // select the next suggestion 566 | if let Some(selected) = self.puzzle.selected_suggestion { 567 | let current_index = self.puzzle.suggestions.iter().position(|&x| x == selected); 568 | if let Some(index) = current_index { 569 | let next_index = (index + 1) % self.puzzle.suggestions.len(); 570 | self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[next_index]); 571 | } 572 | } else { 573 | // if no suggestion is selected, select the first one 574 | self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[0]); 575 | } 576 | }, 577 | x if keybinds::is_left(x) => { 578 | // select the previous suggestion 579 | if let Some(selected) = self.puzzle.selected_suggestion { 580 | let current_index = self.puzzle.suggestions.iter().position(|&x| x == selected); 581 | if let Some(index) = current_index { 582 | let prev_index = if index == 0 { 583 | self.puzzle.suggestions.len() - 1 584 | } else { 585 | index - 1 586 | }; 587 | self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[prev_index]); 588 | } 589 | } 590 | }, 591 | x if keybinds::is_select(x) => { 592 | if let Some(selected) = self.puzzle.selected_suggestion { 593 | if self.puzzle.is_correct_guess(selected) { 594 | self.puzzle.guess_result = Some(GuessResult::Correct); 595 | } else { 596 | self.puzzle.guess_result = Some(GuessResult::Incorrect); 597 | } 598 | self.finalize_round(); 599 | } 600 | }, 601 | KeyEvent { code: KeyCode::Char('s' | 'S'), .. } => { 602 | // Skip puzzle counts as timeout 603 | self.puzzle.guess_result = Some(GuessResult::Timeout); 604 | self.finalize_round(); 605 | }, 606 | _ => {}, 607 | } 608 | } 609 | 610 | fn handle_result_available(&mut self, key: KeyEvent) { 611 | match key { 612 | x if keybinds::is_select(x) => { 613 | match self.game_state { 614 | GameState::PendingGameOver => { 615 | // reveal summary 616 | self.game_state = GameState::GameOver; 617 | }, 618 | GameState::Result => { 619 | // start next puzzle 620 | self.puzzle = 621 | Self::init_puzzle(self.bits.clone(), self.number_mode, self.streak); 622 | self.puzzle_resolved = false; 623 | self.game_state = GameState::Active; 624 | }, 625 | GameState::GameOver => { /* handled elsewhere */ }, 626 | GameState::Active => { /* shouldn't be here */ }, 627 | } 628 | }, 629 | x if keybinds::is_exit(x) => self.exit_intended = true, 630 | _ => {}, 631 | } 632 | } 633 | 634 | fn refresh_stats_snapshot(&mut self) { 635 | self.puzzle.stats_snapshot = Some(StatsSnapshot { 636 | score: self.score, 637 | streak: self.streak, 638 | max_streak: self.max_streak, 639 | rounds: self.rounds, 640 | lives: self.lives, 641 | bits: self.bits.clone(), 642 | number_mode: self.number_mode, 643 | hearts: self.lives_hearts(), 644 | game_state: self.game_state, 645 | prev_high_score: self.prev_high_score_for_display, 646 | new_high_score: self.new_high_score_reached, 647 | }); 648 | } 649 | } 650 | 651 | #[derive(PartialEq, Copy, Clone, Debug)] 652 | enum GuessResult { 653 | Correct, 654 | Incorrect, 655 | Timeout, 656 | } 657 | 658 | #[derive(Clone)] 659 | pub enum Bits { 660 | Four, 661 | FourShift4, 662 | FourShift8, 663 | FourShift12, 664 | Eight, 665 | Twelve, 666 | Sixteen, 667 | } 668 | 669 | impl Bits { 670 | pub const fn to_int(&self) -> u32 { 671 | match self { 672 | Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 4, 673 | Self::Eight => 8, 674 | Self::Twelve => 12, 675 | Self::Sixteen => 16, 676 | } 677 | } 678 | pub const fn scale_factor(&self) -> u32 { 679 | match self { 680 | Self::Four => 1, 681 | Self::FourShift4 => 16, 682 | Self::FourShift8 => 256, 683 | Self::FourShift12 => 4096, 684 | Self::Eight => 1, 685 | Self::Twelve => 1, 686 | Self::Sixteen => 1, 687 | } 688 | } 689 | pub const fn high_score_key(&self) -> u32 { 690 | match self { 691 | Self::Four => 4, 692 | Self::FourShift4 => 44, 693 | Self::FourShift8 => 48, 694 | Self::FourShift12 => 412, 695 | Self::Eight => 8, 696 | Self::Twelve => 12, 697 | Self::Sixteen => 16, 698 | } 699 | } 700 | pub const fn upper_bound(&self) -> u32 { 701 | (u32::pow(2, self.to_int()) - 1) * self.scale_factor() 702 | } 703 | pub const fn suggestion_count(&self) -> usize { 704 | match self { 705 | Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 3, 706 | Self::Eight => 4, 707 | Self::Twelve => 5, 708 | Self::Sixteen => 6, 709 | } 710 | } 711 | pub const fn label(&self) -> &'static str { 712 | match self { 713 | Self::Four => "4 bit", 714 | Self::FourShift4 => "4 bit*16", 715 | Self::FourShift8 => "4 bit*256", 716 | Self::FourShift12 => "4 bit*4096", 717 | Self::Eight => "8 bit", 718 | Self::Twelve => "12 bit", 719 | Self::Sixteen => "16 bit", 720 | } 721 | } 722 | } 723 | 724 | pub struct BinaryNumbersPuzzle { 725 | bits: Bits, 726 | /// Raw bit pattern (unscaled) for display as binary string. 727 | /// This is u32 (not i32) because it stores the BIT PATTERN, not the numeric value. 728 | /// In signed mode, negative numbers use two's complement representation: 729 | /// - For -1 in 4-bit: raw_current_number = 15 (0b1111), displayed as "1111" 730 | /// - For -8 in 4-bit: raw_current_number = 8 (0b1000), displayed as "1000" 731 | /// 732 | /// The same bit pattern has different meanings in signed vs unsigned mode. 733 | raw_current_number: u32, 734 | suggestions: Vec, 735 | correct_answer: i32, 736 | selected_suggestion: Option, 737 | time_total: f64, 738 | time_left: f64, 739 | guess_result: Option, 740 | last_points_awarded: u32, 741 | stats_snapshot: Option, 742 | skip_first_dt: bool, // Skip first dt to prevent timer jump when starting new puzzle 743 | } 744 | 745 | impl BinaryNumbersPuzzle { 746 | pub fn new(bits: Bits, number_mode: NumberMode, streak: u32) -> Self { 747 | let mut rng = rand::rng(); 748 | 749 | let mut suggestions = Vec::new(); 750 | let scale = bits.scale_factor(); 751 | let num_bits = bits.to_int(); 752 | 753 | match number_mode { 754 | NumberMode::Unsigned => { 755 | while suggestions.len() < bits.suggestion_count() { 756 | let raw = rng.random_range(0..u32::pow(2, num_bits)); 757 | let num = (raw * scale) as i32; 758 | if !suggestions.contains(&num) { 759 | suggestions.push(num); 760 | } 761 | } 762 | }, 763 | NumberMode::Signed => { 764 | // For signed mode, use two's complement representation 765 | // Range is from -(2^(n-1)) to 2^(n-1)-1 766 | while suggestions.len() < bits.suggestion_count() { 767 | let raw = rng.random_range(0..u32::pow(2, num_bits)); 768 | // Convert raw bits to signed value using two's complement 769 | let signed_val = if raw >= (1 << (num_bits - 1)) { 770 | // Negative number: raw - 2^n 771 | (raw as i32) - (1 << num_bits) 772 | } else { 773 | // Positive number 774 | raw as i32 775 | }; 776 | let num = signed_val * (scale as i32); 777 | if !suggestions.contains(&num) { 778 | suggestions.push(num); 779 | } 780 | } 781 | }, 782 | } 783 | 784 | // Pick a random suggestion as the current number 785 | let correct_index = rng.random_range(0..suggestions.len()); 786 | let current_number_signed = suggestions[correct_index]; 787 | 788 | // Shuffle suggestions so the correct answer is in a random position 789 | suggestions.shuffle(&mut rng); 790 | 791 | // Calculate raw_current_number based on mode 792 | let raw_current_number = match number_mode { 793 | NumberMode::Unsigned => { 794 | let current_number = current_number_signed.unsigned_abs(); 795 | current_number / scale 796 | }, 797 | NumberMode::Signed => { 798 | // For signed mode, we need to preserve the two's complement representation 799 | // Example: -1 in 4-bit two's complement is 0b1111 800 | // We cast i32 to u32 (preserving bit pattern), then mask to n-bits 801 | // Result: -1 becomes 15u32 (0b1111), which displays as "1111" 802 | let unscaled_signed = current_number_signed / (scale as i32); 803 | 804 | // Convert to unsigned bits using two's complement masking 805 | // Casting i32 to u32 reinterprets the bits (not a numeric conversion) 806 | // For n-bit number, mask is (2^n - 1) 807 | let mask = (1u32 << num_bits) - 1; 808 | (unscaled_signed as u32) & mask 809 | }, 810 | }; 811 | 812 | // Calculate time based on difficulty 813 | let time_total = 10.0 - (streak.min(8) as f64 * 0.5); 814 | let time_left = time_total; 815 | 816 | let selected_suggestion = Some(suggestions[0]); 817 | let guess_result = None; 818 | let last_points_awarded = 0; 819 | 820 | Self { 821 | bits, 822 | raw_current_number, 823 | suggestions, 824 | correct_answer: current_number_signed, 825 | time_total, 826 | time_left, 827 | selected_suggestion, 828 | guess_result, 829 | last_points_awarded, 830 | stats_snapshot: None, 831 | skip_first_dt: true, 832 | } 833 | } 834 | 835 | pub fn suggestions(&self) -> &[i32] { 836 | &self.suggestions 837 | } 838 | 839 | pub fn is_correct_guess(&self, guess: i32) -> bool { 840 | guess == self.correct_answer 841 | } 842 | 843 | pub fn current_to_binary_string(&self) -> String { 844 | let width = self.bits.to_int() as usize; 845 | let raw = format!("{:0width$b}", self.raw_current_number, width = width); 846 | raw.chars() 847 | .collect::>() 848 | .chunks(4) 849 | .map(|chunk| chunk.iter().collect::()) 850 | .collect::>() 851 | .join(" ") 852 | } 853 | 854 | pub fn run(&mut self, dt: f64) { 855 | if self.skip_first_dt { 856 | self.skip_first_dt = false; 857 | return; 858 | } 859 | if self.guess_result.is_some() { 860 | return; 861 | } 862 | self.time_left -= dt; 863 | if self.time_left <= 0.0 { 864 | self.guess_result = Some(GuessResult::Timeout); 865 | } 866 | } 867 | } 868 | 869 | impl Widget for &mut BinaryNumbersGame { 870 | fn render(self, area: Rect, buf: &mut Buffer) { 871 | self.render_ref(area, buf); 872 | } 873 | } 874 | 875 | // Simple ASCII gauge renderer to avoid variable glyph heights from Unicode block elements 876 | fn render_ascii_gauge(area: Rect, buf: &mut Buffer, ratio: f64, color: Color) { 877 | #[allow(clippy::cast_sign_loss)] 878 | #[allow(clippy::cast_possible_truncation)] 879 | let fill_width = 880 | (f64::from(area.width) * ratio.clamp(0.0, 1.0)).round().min(f64::from(area.width)) as u16; 881 | 882 | if area.height == 0 { 883 | return; 884 | } 885 | 886 | for x in 0..area.width { 887 | let filled = x < fill_width; 888 | let symbol = if filled { "=" } else { " " }; 889 | let style = if filled { 890 | Style::default().fg(color) 891 | } else { 892 | Style::default().fg(Color::DarkGray) 893 | }; 894 | 895 | if let Some(cell) = buf.cell_mut((area.x + x, area.y)) { 896 | cell.set_symbol(symbol); 897 | cell.set_style(style); 898 | } 899 | } 900 | } 901 | 902 | struct HighScores { 903 | scores: HashMap, 904 | } 905 | 906 | impl HighScores { 907 | const FILE: &'static str = "binbreak_highscores.txt"; 908 | 909 | fn empty() -> Self { 910 | Self { scores: HashMap::new() } 911 | } 912 | 913 | fn load() -> Self { 914 | let mut hs = Self::empty(); 915 | if let Ok(mut file) = File::open(Self::FILE) { 916 | let mut contents = String::new(); 917 | if file.read_to_string(&mut contents).is_ok() { 918 | for line in contents.lines() { 919 | if let Some((k, v)) = line.split_once('=') 920 | && let Ok(score) = v.trim().parse::() 921 | { 922 | hs.scores.insert(k.trim().to_string(), score); 923 | } 924 | } 925 | } 926 | } 927 | hs 928 | } 929 | 930 | fn save(&self) -> std::io::Result<()> { 931 | let mut data = String::new(); 932 | for key in [ 933 | "4u", "4s", "44u", "44s", "48u", "48s", "412u", "412s", "8u", "8s", "12u", "12s", 934 | "16u", "16s", 935 | ] { 936 | let val = self.get(key); 937 | let _ = writeln!(data, "{key}={val}"); 938 | } 939 | let mut file = File::create(Self::FILE)?; 940 | file.write_all(data.as_bytes()) 941 | } 942 | 943 | fn get(&self, bits: &str) -> u32 { 944 | *self.scores.get(bits).unwrap_or(&0) 945 | } 946 | 947 | fn update(&mut self, bits: &str, score: u32) { 948 | self.scores.insert(bits.to_string(), score); 949 | } 950 | } 951 | 952 | #[cfg(test)] 953 | mod tests { 954 | use super::*; 955 | use crossterm::event::{KeyEventKind, KeyEventState, KeyModifiers}; 956 | use std::fs; 957 | use std::sync::Mutex; 958 | 959 | static HS_LOCK: Mutex<()> = Mutex::new(()); 960 | 961 | fn with_high_score_file(f: F) { 962 | #[allow(clippy::expect_used)] 963 | let _guard = HS_LOCK.lock().expect("Failed to lock high score mutex"); 964 | let original = fs::read_to_string(HighScores::FILE).ok(); 965 | f(); 966 | // restore 967 | match original { 968 | Some(data) => { 969 | let _ = fs::write(HighScores::FILE, data); 970 | }, 971 | None => { 972 | let _ = fs::remove_file(HighScores::FILE); 973 | }, 974 | } 975 | } 976 | 977 | #[test] 978 | fn bits_properties() { 979 | assert_eq!(Bits::Four.to_int(), 4); 980 | assert_eq!(Bits::Four.upper_bound(), 15); 981 | assert_eq!(Bits::Four.suggestion_count(), 3); 982 | 983 | assert_eq!(Bits::FourShift4.scale_factor(), 16); 984 | assert_eq!(Bits::FourShift4.upper_bound(), 240); 985 | assert_eq!(Bits::FourShift4.suggestion_count(), 3); 986 | 987 | assert_eq!(Bits::FourShift8.scale_factor(), 256); 988 | assert_eq!(Bits::FourShift12.high_score_key(), 412); 989 | assert_eq!(Bits::Eight.upper_bound(), 255); 990 | 991 | assert_eq!(Bits::Sixteen.suggestion_count(), 6); 992 | } 993 | 994 | #[test] 995 | fn puzzle_generation_unique_and_scaled() { 996 | let p = BinaryNumbersPuzzle::new(Bits::FourShift4.clone(), NumberMode::Unsigned, 0); 997 | let scale = Bits::FourShift4.scale_factor(); 998 | assert_eq!(p.suggestions().len(), Bits::FourShift4.suggestion_count()); 999 | // uniqueness 1000 | let mut sorted = p.suggestions().to_vec(); 1001 | sorted.sort_unstable(); 1002 | for pair in sorted.windows(2) { 1003 | assert_ne!(pair[0], pair[1]); 1004 | } 1005 | // scaling property 1006 | for &s in p.suggestions() { 1007 | assert_eq!(s.unsigned_abs() % scale, 0); 1008 | } 1009 | // correct answer must be one of suggestions and raw_current_number * scale == correct_answer (unsigned) 1010 | assert!(p.suggestions().contains(&p.correct_answer)); 1011 | assert_eq!(p.raw_current_number * scale, p.correct_answer.unsigned_abs()); 1012 | } 1013 | 1014 | #[test] 1015 | fn binary_string_formatting_groups_every_four_bits() { 1016 | let mut p = BinaryNumbersPuzzle::new(Bits::Eight, NumberMode::Unsigned, 0); 1017 | p.raw_current_number = 0xAB; // 171 = 10101011 1018 | assert_eq!(p.current_to_binary_string(), "1010 1011"); 1019 | let mut p4 = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Unsigned, 0); 1020 | p4.raw_current_number = 0b0101; 1021 | assert_eq!(p4.current_to_binary_string(), "0101"); 1022 | } 1023 | 1024 | #[test] 1025 | fn signed_mode_negative_numbers_show_sign_bit() { 1026 | // Test 4-bit signed mode with a negative number 1027 | let mut p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Signed, 0); 1028 | // In 4-bit two's complement, -8 is represented as 1000 1029 | p.raw_current_number = 0b1000; // -8 in 4-bit two's complement 1030 | assert_eq!(p.current_to_binary_string(), "1000", "4-bit: -8 should be 1000"); 1031 | 1032 | // In 4-bit two's complement, -1 is represented as 1111 1033 | p.raw_current_number = 0b1111; // -1 in 4-bit two's complement 1034 | assert_eq!(p.current_to_binary_string(), "1111", "4-bit: -1 should be 1111"); 1035 | 1036 | // Test 8-bit signed mode with a negative number 1037 | let mut p8 = BinaryNumbersPuzzle::new(Bits::Eight, NumberMode::Signed, 0); 1038 | // In 8-bit two's complement, -128 is represented as 10000000 1039 | p8.raw_current_number = 0b10000000; // -128 in 8-bit two's complement 1040 | assert_eq!(p8.current_to_binary_string(), "1000 0000", "8-bit: -128 should be 1000 0000"); 1041 | 1042 | // In 8-bit two's complement, -1 is represented as 11111111 1043 | p8.raw_current_number = 0b11111111; // -1 in 8-bit two's complement 1044 | assert_eq!(p8.current_to_binary_string(), "1111 1111", "8-bit: -1 should be 1111 1111"); 1045 | } 1046 | 1047 | #[test] 1048 | fn signed_mode_puzzle_generates_correct_raw_bits_for_negative() { 1049 | // Generate many puzzles and check that when we have a negative number, 1050 | // the raw_current_number has the sign bit set correctly 1051 | for _ in 0..20 { 1052 | let p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Signed, 0); 1053 | let current_signed = p.correct_answer; 1054 | 1055 | if current_signed < 0 { 1056 | // For negative numbers in 4-bit two's complement, the MSB (bit 3) should be 1 1057 | // which means raw_current_number should be >= 8 (0b1000) 1058 | assert!( 1059 | p.raw_current_number >= 8, 1060 | "Negative number {} should have raw bits >= 8 (sign bit set), but got {}. Binary: {}", 1061 | current_signed, 1062 | p.raw_current_number, 1063 | p.current_to_binary_string() 1064 | ); 1065 | } else { 1066 | // For positive numbers (including 0), MSB should be 0 1067 | // which means raw_current_number should be < 8 1068 | assert!( 1069 | p.raw_current_number < 8, 1070 | "Positive number {} should have raw bits < 8 (sign bit clear), but got {}. Binary: {}", 1071 | current_signed, 1072 | p.raw_current_number, 1073 | p.current_to_binary_string() 1074 | ); 1075 | } 1076 | } 1077 | } 1078 | 1079 | #[test] 1080 | fn puzzle_timeout_sets_guess_result() { 1081 | let mut p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Unsigned, 0); 1082 | p.time_left = 0.5; 1083 | // First run() skips dt due to skip_first_dt flag 1084 | // The reason for this is to prevent timer jump when starting a new puzzle 1085 | p.run(1.0); 1086 | assert_eq!(p.guess_result, None, "First run should skip dt"); 1087 | // Second run() actually applies the dt and triggers timeout 1088 | p.run(1.0); // exceed remaining time 1089 | assert_eq!(p.guess_result, Some(GuessResult::Timeout)); 1090 | } 1091 | 1092 | #[test] 1093 | fn suggestions_are_randomized() { 1094 | // Verify that suggestions are properly randomized and the first suggestion 1095 | // is not always the correct answer 1096 | let mut first_is_correct_count = 0; 1097 | let trials = 100; 1098 | 1099 | for _ in 0..trials { 1100 | let p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Unsigned, 0); 1101 | // Check if the first suggestion happens to be the correct answer 1102 | if p.suggestions[0] == p.correct_answer { 1103 | first_is_correct_count += 1; 1104 | } 1105 | } 1106 | 1107 | // With 3 suggestions for 4-bit mode, we expect roughly 33% to be correct by chance 1108 | // Allow a range of 20-50% (which is generous for 100 trials to account for randomness) 1109 | // The key point is that it's NOT 100% (which would indicate no randomization) 1110 | assert!( 1111 | first_is_correct_count >= 20 && first_is_correct_count <= 50, 1112 | "First suggestion was correct {} times out of {}, expected around 33% (20-50 range). \ 1113 | If this is close to 100%, suggestions are not randomized!", 1114 | first_is_correct_count, 1115 | trials 1116 | ); 1117 | 1118 | // Also verify that the correct answer is actually one of the suggestions 1119 | for _ in 0..10 { 1120 | let p = BinaryNumbersPuzzle::new(Bits::Eight, NumberMode::Signed, 0); 1121 | assert!( 1122 | p.suggestions.contains(&p.correct_answer), 1123 | "correct_answer {} must be in suggestions {:?}", 1124 | p.correct_answer, 1125 | p.suggestions 1126 | ); 1127 | } 1128 | } 1129 | 1130 | #[test] 1131 | fn finalize_round_correct_increments_score_streak_and_sets_result_state() { 1132 | with_high_score_file(|| { 1133 | let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned); 1134 | // ensure deterministic: mark puzzle correct 1135 | let answer = g.puzzle.correct_answer; 1136 | g.puzzle.guess_result = Some(GuessResult::Correct); 1137 | g.finalize_round(); 1138 | assert_eq!(g.streak, 1); 1139 | assert_eq!(g.score, 10); // base points 1140 | assert_eq!(g.puzzle.last_points_awarded, 10); 1141 | assert_eq!(g.game_state, GameState::Result); 1142 | assert!(g.puzzle_resolved); 1143 | assert!(g.puzzle.is_correct_guess(answer)); 1144 | }); 1145 | } 1146 | 1147 | #[test] 1148 | fn life_awarded_every_five_streak() { 1149 | with_high_score_file(|| { 1150 | let mut g = BinaryNumbersGame::new_with_max_lives(Bits::Four, NumberMode::Unsigned, 3); 1151 | g.lives = 2; // below max 1152 | g.streak = 4; // about to become 5 1153 | g.puzzle.guess_result = Some(GuessResult::Correct); 1154 | g.finalize_round(); 1155 | assert_eq!(g.streak, 5); 1156 | assert_eq!(g.lives, 3); // gained life 1157 | }); 1158 | } 1159 | 1160 | #[test] 1161 | fn incorrect_guess_resets_streak_and_loses_life() { 1162 | with_high_score_file(|| { 1163 | let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned); 1164 | g.streak = 3; 1165 | let lives_before = g.lives; 1166 | g.puzzle.guess_result = Some(GuessResult::Incorrect); 1167 | g.finalize_round(); 1168 | assert_eq!(g.streak, 0); 1169 | assert_eq!(g.lives, lives_before - 1); 1170 | }); 1171 | } 1172 | 1173 | #[test] 1174 | fn pending_game_over_when_life_reaches_zero() { 1175 | with_high_score_file(|| { 1176 | let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned); 1177 | g.lives = 1; 1178 | g.puzzle.guess_result = Some(GuessResult::Incorrect); 1179 | g.finalize_round(); 1180 | assert_eq!(g.lives, 0); 1181 | assert_eq!(g.game_state, GameState::PendingGameOver); 1182 | }); 1183 | } 1184 | 1185 | #[test] 1186 | fn high_score_updates_and_flag_set() { 1187 | with_high_score_file(|| { 1188 | let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned); 1189 | // Force previous high score low 1190 | let key = BinaryNumbersGame::compute_high_score_key(&g.bits, g.number_mode); 1191 | g.high_scores.update(&key, 5); 1192 | g.prev_high_score_for_display = 5; 1193 | g.puzzle.guess_result = Some(GuessResult::Correct); 1194 | g.finalize_round(); 1195 | assert!(g.new_high_score_reached); 1196 | assert!(g.high_scores.get(&key) >= 10); 1197 | assert_eq!(g.prev_high_score_for_display, 5); // previous stored 1198 | }); 1199 | } 1200 | 1201 | #[test] 1202 | fn hearts_representation_matches_lives() { 1203 | let mut g = BinaryNumbersGame::new_with_max_lives(Bits::Four, NumberMode::Unsigned, 3); 1204 | g.lives = 2; 1205 | assert_eq!(g.lives_hearts(), "♥♥·"); 1206 | } 1207 | 1208 | #[test] 1209 | fn handle_input_navigation_changes_selected_suggestion() { 1210 | let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned); 1211 | let initial = g.puzzle.selected_suggestion; 1212 | // Simulate Right key 1213 | let right_event = KeyEvent { 1214 | code: KeyCode::Right, 1215 | modifiers: KeyModifiers::empty(), 1216 | kind: KeyEventKind::Press, 1217 | state: KeyEventState::NONE, 1218 | }; 1219 | g.handle_game_input(right_event); 1220 | assert_ne!(g.puzzle.selected_suggestion, initial); 1221 | // Simulate Left key should cycle back 1222 | let left_event = KeyEvent { 1223 | code: KeyCode::Left, 1224 | modifiers: KeyModifiers::empty(), 1225 | kind: KeyEventKind::Press, 1226 | state: KeyEventState::NONE, 1227 | }; 1228 | g.handle_game_input(left_event); 1229 | assert!(g.puzzle.selected_suggestion.is_some()); 1230 | } 1231 | } 1232 | --------------------------------------------------------------------------------