├── .gitignore ├── .cargo ├── config.toml └── config-nightly.toml ├── assets └── demo.gif ├── .gitattributes ├── balatro_tui_core ├── src │ ├── lib.rs │ ├── enum_property_ext.rs │ ├── run.rs │ ├── deck.rs │ ├── error.rs │ ├── round.rs │ ├── blind.rs │ ├── card.rs │ └── scorer.rs └── Cargo.toml ├── typos.toml ├── .vscode ├── settings.json ├── snippets.code-snippets └── launch.json ├── balatro_tui_widgets ├── src │ ├── utility.rs │ ├── lib.rs │ ├── error.rs │ ├── round_score.rs │ ├── blind_badge.rs │ ├── run_stats.rs │ ├── card.rs │ ├── scorer_preview.rs │ ├── round_info.rs │ ├── splash_screen.rs │ ├── text_box.rs │ └── card_list.rs └── Cargo.toml ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── ci.yml ├── balatro_tui ├── src │ ├── main.rs │ ├── iter_index_ext.rs │ ├── tui.rs │ ├── event.rs │ └── game.rs └── Cargo.toml ├── rustfmt.toml ├── README.md ├── CONTRIBUTING.md ├── TODO ├── Cargo.toml └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = [] 3 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Passeriform/BalatroTUI/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Default behavior 2 | * text=auto 3 | 4 | # Binary files to be excluded from eol normalization. 5 | *.png binary 6 | *.jpg binary -------------------------------------------------------------------------------- /balatro_tui_core/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Core definitions and implementations for running a game of Balatro TUI 2 | 3 | pub mod blind; 4 | pub mod card; 5 | pub mod deck; 6 | pub mod enum_property_ext; 7 | pub mod error; 8 | pub mod round; 9 | pub mod run; 10 | pub mod scorer; 11 | -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-identifiers] 2 | balatro = "balatro" 3 | clippy = "clippy" 4 | conv = "conv" 5 | crossterm = "crossterm" 6 | cursorvec = "cursorvec" 7 | debuffed = "debuffed" 8 | itertools = "itertools" 9 | libc = "libc" 10 | peekable = "peekable" 11 | ratatui = "ratatui" 12 | repr = "repr" 13 | rustc = "rustc" 14 | seekable = "seekable" 15 | sigtstp = "sigtstp" 16 | structs = "structs" 17 | thiserror = "thiserror" 18 | usize = "usize" 19 | zeroable = "zeroable" 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "balatro", 4 | "clippy", 5 | "conv", 6 | "crossterm", 7 | "cursorvec", 8 | "debuffed", 9 | "itertools", 10 | "libc", 11 | "peekable", 12 | "ratatui", 13 | "repr", 14 | "rustc", 15 | "seekable", 16 | "sigtstp", 17 | "structs", 18 | "thiserror", 19 | "usize", 20 | "zeroable" 21 | ] 22 | } -------------------------------------------------------------------------------- /balatro_tui_widgets/src/utility.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | style::{Color, Style, Styled}, 3 | text::{Line, Span}, 4 | }; 5 | 6 | /// Returns line widget with chip icon prepended 7 | pub(crate) fn get_line_with_chips<'widget, T: Into>>( 8 | content: T, 9 | color: Color, 10 | ) -> Line<'widget> { 11 | Line::from(vec![ 12 | "\u{26c0}".set_style(Style::new().fg(color)), 13 | " ".into(), 14 | content.into(), 15 | ]) 16 | } 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /balatro_tui/src/main.rs: -------------------------------------------------------------------------------- 1 | //! Balatro TUI 2 | //! 3 | //! A toy TUI based implementation for the game `Balatro` by [LocalThunk](https://x.com/LocalThunk). 4 | //! 5 | //! All rights are reserved by `LocalThunk` for the original game. 6 | 7 | use color_eyre::{Result, eyre::Context}; 8 | use game::Game; 9 | 10 | pub mod event; 11 | pub mod game; 12 | pub mod iter_index_ext; 13 | pub mod tui; 14 | 15 | #[tokio::main] 16 | async fn main() -> Result<()> { 17 | // Start Game 18 | let mut game = Game::new()?; 19 | game.start() 20 | .await 21 | .wrap_err("Error encountered while running the game.")?; 22 | 23 | Ok(()) 24 | } 25 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Disable breaking after assignment (https://github.com/rust-lang/rustfmt/issues/3514) 2 | 3 | combine_control_expr = false 4 | edition = "2021" 5 | error_on_line_overflow = true 6 | format_code_in_doc_comments = true 7 | format_macro_matchers = true 8 | group_imports = "StdExternalCrate" 9 | hex_literal_case = "Lower" 10 | imports_granularity = "Crate" 11 | newline_style = "Auto" 12 | normalize_comments = true 13 | normalize_doc_attributes = true 14 | overflow_delimited_expr = true 15 | reorder_impl_items = true 16 | style_edition = "2024" 17 | unstable_features = true 18 | use_field_init_shorthand = true 19 | use_try_shorthand = true 20 | wrap_comments = true 21 | -------------------------------------------------------------------------------- /.cargo/config-nightly.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = [ 3 | # Enable nightly lints 4 | "-Zcrate-attr=feature(ref_pat_eat_one_layer_2024)", 5 | "-Zcrate-attr=feature(non_exhaustive_omitted_patterns_lint)", 6 | "-Zcrate-attr=feature(must_not_suspend)", 7 | "-Zcrate-attr=feature(multiple_supertrait_upcastable)", 8 | "-Zcrate-attr=feature(strict_provenance)", 9 | "-Zcrate-attr=feature(async_closure)", 10 | "-Wrust-2024-incompatible-pat", 11 | "-Wnon-exhaustive-omitted-patterns", 12 | "-Wmust-not-suspend", 13 | "-Wmultiple-supertrait-upcastable", 14 | "-Wlossy-provenance-casts", 15 | "-Wfuzzy-provenance-casts", 16 | "-Wclosure-returning-async-block", 17 | ] 18 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Widgets and components for displaying elements of `BalatroTUI` on the 2 | //! terminal. 3 | 4 | #![expect( 5 | clippy::missing_docs_in_private_items, 6 | reason = "Intended: This module's contents are re-exported." 7 | )] 8 | 9 | mod blind_badge; 10 | mod card; 11 | mod card_list; 12 | pub mod error; 13 | mod round_info; 14 | mod round_score; 15 | mod run_stats; 16 | mod scorer_preview; 17 | mod splash_screen; 18 | mod text_box; 19 | mod utility; 20 | 21 | pub use blind_badge::*; 22 | pub use card::*; 23 | pub use card_list::*; 24 | pub use round_info::*; 25 | pub use round_score::*; 26 | pub use run_stats::*; 27 | pub use scorer_preview::*; 28 | pub use splash_screen::*; 29 | pub use text_box::*; 30 | -------------------------------------------------------------------------------- /balatro_tui_core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "balatro_tui_core" 3 | description = "Core modules for Balatro CLI game" 4 | documentation = "https://docs.rs/balatro_tui_core/latest/balatro_tui_core/" 5 | authors.workspace = true 6 | categories.workspace = true 7 | edition.workspace = true 8 | homepage.workspace = true 9 | keywords.workspace = true 10 | license-file.workspace = true 11 | readme.workspace = true 12 | repository.workspace = true 13 | version.workspace = true 14 | 15 | [lints] 16 | workspace = true 17 | 18 | [lib] 19 | doctest = true 20 | test = true 21 | 22 | [dependencies] 23 | itertools = "0.13.0" 24 | rand = "0.8.5" 25 | thiserror = "1.0.64" 26 | strum = { version = "0.26.3", features = ["derive"] } 27 | unicode-segmentation = "1.11.0" 28 | 29 | [dev-dependencies] 30 | -------------------------------------------------------------------------------- /.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | // Place your BalatroTUI workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 7 | // Placeholders with the same ids are connected. 8 | // Example: 9 | // "Print to console": { 10 | // "scope": "javascript,typescript", 11 | // "prefix": "log", 12 | // "body": [ 13 | // "console.log('$1');", 14 | // "$2" 15 | // ], 16 | // "description": "Log output to console" 17 | // } 18 | } -------------------------------------------------------------------------------- /balatro_tui_widgets/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "balatro_tui_widgets" 3 | description = "UI widgets for Balatro CLI game" 4 | documentation = "https://docs.rs/balatro_tui_widgets/latest/balatro_tui_widgets/" 5 | authors.workspace = true 6 | categories.workspace = true 7 | edition.workspace = true 8 | homepage.workspace = true 9 | keywords.workspace = true 10 | license-file.workspace = true 11 | readme.workspace = true 12 | repository.workspace = true 13 | version.workspace = true 14 | 15 | [lints] 16 | workspace = true 17 | 18 | # Enable when overriding is added to cargo. 19 | # 20 | # [lints.clippy] 21 | # module_name_repetitions = "allow" 22 | # missing_errors_doc = "allow" 23 | # pub_use = "allow" 24 | 25 | [lib] 26 | doctest = true 27 | test = true 28 | 29 | [dependencies] 30 | balatro_tui_core = { path = "../balatro_tui_core", version = "0.1.1" } 31 | bit-set = "0.8.0" 32 | itertools = "0.13.0" 33 | ratatui = "0.28.1" 34 | thiserror = "1.0.64" 35 | tui-big-text = "0.6.0" 36 | 37 | [dev-dependencies] 38 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | inputs: 4 | release-type: 5 | type: choice 6 | description: Choose major for API breaking changes, minor for functionality addition and patch for bugfixes or refactoring 7 | options: 8 | - major 9 | - minor 10 | - patch 11 | 12 | name: Publish 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | ssh-key: ${{ secrets.DEPLOY_KEY }} 22 | 23 | - uses: dtolnay/rust-toolchain@stable 24 | 25 | - name: Install cargo-workspaces 26 | uses: taiki-e/install-action@v2 27 | with: 28 | tool: cargo-workspaces 29 | 30 | - name: Publish to crates.io 31 | run: | 32 | git config --global user.name "Github Action" 33 | git config --global user.email "<>" 34 | 35 | cargo workspaces publish ${{ inputs.release-type }} \ 36 | --yes \ 37 | --all \ 38 | --token ${{ secrets.CARGO_TOKEN }} \ 39 | --message "[Release] Release version v%v! 🚀" \ 40 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'balatro_tui'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=balatro_tui", 15 | "--package=balatro_tui" 16 | ], 17 | }, 18 | "args": [], 19 | "cwd": "${workspaceFolder}" 20 | }, 21 | { 22 | "type": "lldb", 23 | "request": "launch", 24 | "name": "Debug unit tests in executable 'balatro_tui'", 25 | "cargo": { 26 | "args": [ 27 | "test", 28 | "--no-run", 29 | "--bin=balatro_tui", 30 | "--package=balatro_tui" 31 | ] 32 | }, 33 | "args": [], 34 | "cwd": "${workspaceFolder}" 35 | } 36 | ] 37 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Balatro TUI 2 | 3 | A minimal clone of Balatro built in Rust that runs in your terminal. 4 | 5 | ![Demo Video](assets/demo.gif) 6 | 7 | > NOTE: This project is WIP. Please check [TODO](TODO) for feature tracking list. 8 | 9 | --- 10 | 11 | ## Features 12 | 13 | - Play a game of Balatro in your terminal 🃏 14 | - Fast, minimal, and cross-platform 15 | - Modular codebase with core logic and widgets 16 | 17 | ## Project Structure 18 | 19 | - `balatro_tui/` - Main TUI application 20 | - `balatro_tui_core/` - Game logic and core types 21 | - `balatro_tui_widgets/` - UI widgets for the TUI 22 | 23 | ## Getting Started 24 | 25 | ### Prerequisites 26 | 27 | - [Rust](https://www.rust-lang.org/tools/install) (latest stable recommended) 28 | 29 | ### Build and Run 30 | 31 | ```sh 32 | cargo run --release --package balatro_tui 33 | ``` 34 | 35 | ### Format and Lint 36 | 37 | ```sh 38 | cargo fmt 39 | cargo clippy 40 | ``` 41 | 42 | ### Run Tests 43 | 44 | ```sh 45 | cargo test --workspace 46 | ``` 47 | 48 | ## Contributing 49 | 50 | See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. 51 | 52 | ## License 53 | 54 | This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. 55 | 56 | *NOTE: All rights of Balatro the game reside with the original developer LocalThunk.* 57 | -------------------------------------------------------------------------------- /balatro_tui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "balatro_tui" 3 | description = "Balatro game clone in CLI" 4 | documentation = "https://docs.rs/balatro_tui/latest/balatro_tui/" 5 | authors.workspace = true 6 | categories.workspace = true 7 | edition.workspace = true 8 | homepage.workspace = true 9 | keywords.workspace = true 10 | license-file.workspace = true 11 | readme.workspace = true 12 | repository.workspace = true 13 | version.workspace = true 14 | 15 | [lints] 16 | workspace = true 17 | 18 | # Enable when overriding is added to cargo. 19 | # 20 | # [lints.rust] 21 | # unused_extern_crates = "allow" # Required for `human_panic` 22 | # 23 | # [lints.clippy] 24 | # module_name_repetitions = "allow" 25 | # missing_errors_doc = "allow" 26 | # pub_use = "allow" 27 | 28 | [dependencies] 29 | balatro_tui_core = { path = "../balatro_tui_core", version = "0.1.1" } 30 | balatro_tui_widgets = { path = "../balatro_tui_widgets", version = "0.1.1" } 31 | better-panic = "0.3.0" 32 | color-eyre = "0.6.3" 33 | crossterm = { version = "0.28.1", default-features = false, features = ["event-stream"] } 34 | futures = "0.3.30" 35 | human-panic = "2.0.1" 36 | itertools = "0.13.0" 37 | libc = "0.2.158" 38 | rand = "0.8.5" 39 | ratatui = "0.28.1" 40 | tokio = { version = "1.40.0", features = ["full"] } 41 | tokio-util = "0.7.12" 42 | tracing = "0.1.40" 43 | strip-ansi-escapes = "0.2.0" 44 | bit-set = "0.8.0" 45 | 46 | [dev-dependencies] 47 | -------------------------------------------------------------------------------- /balatro_tui_core/src/enum_property_ext.rs: -------------------------------------------------------------------------------- 1 | //! This module exposes traits that extend strum to provide crate compatible 2 | //! [`Result`] 3 | 4 | use strum::EnumProperty; 5 | 6 | use crate::error::StrumError; 7 | 8 | /// Helper extension for strum crate. 9 | /// 10 | /// Getting string property returns an option and int property is not yet 11 | /// stabilized. [`EnumPropertyExt`] trait provides additional wrappers over 12 | /// [`EnumProperty`] trait to convert bad calls into internal [`StrumError`] 13 | /// instead. 14 | pub trait EnumPropertyExt { 15 | /// Gets an `&str` property from an enum with [`EnumProperty`] definition, 16 | /// by name, if it exists. Returns [`StrumError`] otherwise. 17 | fn get_property(&self, property: &str) -> Result<&str, StrumError>; 18 | /// Gets an `int` property from an enum with [`EnumProperty`] definition, by 19 | /// name, if it exists. Returns [`StrumError`] otherwise. 20 | fn get_int_property(&self, property: &str) -> Result; 21 | } 22 | 23 | impl EnumPropertyExt for T 24 | where 25 | T: EnumProperty + ToString, 26 | { 27 | #[inline] 28 | fn get_property(&self, property: &str) -> Result<&str, StrumError> { 29 | self.get_str(property) 30 | .ok_or_else(|| StrumError::PropertyNotFound { 31 | property: property.to_owned(), 32 | variant: self.to_string(), 33 | }) 34 | } 35 | 36 | #[inline] 37 | fn get_int_property(&self, property: &str) -> Result { 38 | Ok(str::parse::(self.get_property(property)?)?) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Balatro TUI 2 | 3 | Thank you for your interest in contributing to Balatro TUI! Your help is greatly appreciated. Please follow these guidelines to make the process smooth for everyone. 4 | 5 | ## How to Contribute 6 | 7 | 1. **Fork the repository** and create your branch from `master`. 8 | 2. **Clone your fork** and set up the project: 9 | 10 | ```sh 11 | git clone https://github.com/Passeriform/BalatroTUI.git 12 | cd BalatroTUI 13 | cargo build 14 | ``` 15 | 16 | 3. **Make your changes** in a new branch: 17 | 18 | ```sh 19 | git checkout -b my-feature 20 | ``` 21 | 22 | 4. **Test your changes**: 23 | 24 | ```sh 25 | cargo test 26 | ``` 27 | 28 | 5. **Commit and push** your changes: 29 | 30 | ```sh 31 | git add . 32 | git commit -m "[Meta] Describe your change" 33 | git push origin my-feature 34 | ``` 35 | 36 | > This repository doesn't use conventional commits. Consider formatting the commit message by category `[] ` 37 | 38 | 6. **Open a Pull Request** on GitHub and describe your changes. 39 | 40 | ## Code Style 41 | 42 | - Follow Rust's standard formatting: `cargo fmt` 43 | - Run `cargo clippy` to catch common mistakes 44 | - Write clear, concise commit messages 45 | 46 | ## Reporting Issues 47 | 48 | If you find a bug or have a feature request, please open an issue with details and steps to reproduce. 49 | 50 | ## Code of Conduct 51 | 52 | Be respectful and constructive. Harassment or abusive behavior will not be tolerated. 53 | 54 | --- 55 | 56 | Thank you for helping make Balatro TUI better! 57 | -------------------------------------------------------------------------------- /balatro_tui/src/iter_index_ext.rs: -------------------------------------------------------------------------------- 1 | //! This module extends the standard [`Iterator`] by adding arbitrary indexing 2 | //! based operations. 3 | 4 | use bit_set::BitSet; 5 | use color_eyre::eyre::{OptionExt, Result}; 6 | use itertools::{Either, Itertools}; 7 | 8 | /// Provides methods to perform container/iterator methods based on index set. 9 | pub(crate) trait IterIndexExt 10 | where 11 | Self: IntoIterator + Sized, 12 | { 13 | /// Returns a cloned [`Vec`] based on arbitrary indices set. 14 | fn peek_at_index_set(&self, index_set: &BitSet) -> Result; 15 | /// Drains the iterator based on arbitrary indices (see [`Vec::drain()`] for 16 | /// equivalent usage with contiguous range) and returns the drained items in 17 | /// a [`Vec`]. 18 | fn drain_from_index_set(&mut self, index_set: &BitSet) -> Result; 19 | } 20 | 21 | impl IterIndexExt for Vec { 22 | fn peek_at_index_set(&self, index_set: &BitSet) -> Result { 23 | index_set 24 | .iter() 25 | .map(|idx| { 26 | self.get(idx) 27 | .copied() 28 | .ok_or_eyre("Invalid index accessed. Index set may be invalid.") 29 | }) 30 | .process_results(|iter| iter.collect()) 31 | } 32 | 33 | fn drain_from_index_set(&mut self, index_set: &BitSet) -> Result { 34 | let (selected, leftover): (Self, Self) = self 35 | .iter() 36 | .enumerate() 37 | .map(|(idx, &card)| (idx, card)) 38 | .partition_map(|(idx, card)| { 39 | if index_set.contains(idx) { 40 | Either::Left(card) 41 | } else { 42 | Either::Right(card) 43 | } 44 | }); 45 | 46 | *self = leftover; 47 | 48 | Ok(selected) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/error.rs: -------------------------------------------------------------------------------- 1 | //! This module provides error definitions for this crate. 2 | 3 | use std::sync::{RwLockReadGuard, TryLockError}; 4 | 5 | use thiserror::Error; 6 | 7 | /// Defines errors relating to arithmetic operation failures. 8 | #[derive(Clone, Copy, Debug, Error)] 9 | pub enum ArithmeticError { 10 | /// Signifies that an arithmetic operation has overflown. Error message 11 | /// provides which kind of operation is overflowing. 12 | #[error("Arithmetic operation {0} overflowed")] 13 | Overflow(&'static str), 14 | } 15 | 16 | /// Defines errors relating to card list widget. 17 | #[derive(Clone, Debug, Error)] 18 | pub enum WidgetError { 19 | /// Provides conversion from [`ArithmeticError`] to [`WidgetError`]. 20 | #[error("Arithmetic error occurred in widget")] 21 | ArithmeticError(#[from] ArithmeticError), 22 | 23 | /// Signifies that the selection limit has overflown the source container 24 | /// size. Error message provides attempted selection limit and maximum 25 | /// allowed limit. 26 | #[error("Cannot reduce selection limit if number of selected cards is more than it.")] 27 | SelectionLimitOverflow { 28 | /// The attempted value of selection limit 29 | attempted_selection_limit: usize, 30 | /// The maximum allowed valid value for selection limit 31 | max_allowed: usize, 32 | }, 33 | 34 | /// Signifies inability to acquire write lock on shared widget state. This 35 | /// should result in immediate exit and cleanup. 36 | #[error("Could not acquire write lock on widget state")] 37 | WidgetStateLockError(String), 38 | } 39 | 40 | impl<'guard, T> From>> for WidgetError { 41 | fn from(source: TryLockError>) -> Self { 42 | Self::WidgetStateLockError(format!("{source:?}")) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | ☐ Add compatibility with non-tui solution 2 | ☐ Move cached widget instances into `GameWidgetCache` struct 3 | ☐ Infer widget constraints from content length 4 | ☐ Split and move `handle_game_events` into separate event handler + render traits 5 | ☐ Rework panic handler to append issue template 6 | ☐ Only enable backtrace on panic when RUSTBACKTRACE is set to 1 7 | ☐ Add power description for `Bosses` enum in enum description to show on widget 8 | ☐ Implement endless mode (ante calculation and bumping target scores) 9 | ☐ Shuffle should be deterministic based on the seed (accept argument to enable true shuffle) 10 | ☐ Use `get_str()` and `get_int()` from strum when stabilized (https://github.com/Peternator7/strum/issues/313) 11 | ☐ Remove `ScoreError::AnteExceeded` when infinite ante is implemented 12 | ☐ Add animations to `Scorer` 13 | ☐ Remove deep variable access, access depth on self should be always one-level 14 | ☐ Make fields private for `Game`, `Run`, `Round`, etc 15 | ☐ Make round container optional and generic to be replaced between `RoundSelection`, `Round` and `Shop` in `Run` struct 16 | ☐ Only hold references to contained structs like `Run` and `Round`. It should be managed only at `Game` level (Arc) 17 | ☐ Add more tests. Determine by coverage report. 18 | ☐ Use `ratatui-image` for blind badge display instead of canvas. 19 | ☐ Use `&'a str` for state variables in widgets instead of `String`. Use `Arc` for shared usage if required. 20 | ☐ Add features to enable conditional compilation for: 21 | `no-std` => Use `core` and `alloc` if enabled, `std` otherwise 22 | `tokio` => Use `tokio` if enabled, `mpsc` otherwise 23 | `multithreading` => Use `Arc` if enabled, `Rc` otherwise 24 | ☐ CardWidget should mimic actual card layout (counted suit unicode characters in body) 25 | ☐ Create macro for creating widget with documentation 26 | ☐ Use pub(crate)/pub(self)/private wherever required 27 | ☐ Use `ratatui-big-text` where required 28 | RoundScore text 29 | Chips text 30 | Multiplier text 31 | Chips unicode symbol 32 | Multiply symbol 33 | ☐ Add `level` system for `ScoringHand` 34 | ☐ Add `RunInfoButtonWidget` 35 | ☐ Add mouse support (hover/click) 36 | ☐ Add gif for README 37 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/round_score.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Constraint, Flex, Layout, Rect}, 4 | style::Color, 5 | text::Line, 6 | widgets::{StatefulWidget, Widget}, 7 | }; 8 | 9 | use super::{text_box::TextBoxWidget, utility::get_line_with_chips}; 10 | 11 | /// Content height for [`RoundScoreWidget`] 12 | pub const ROUND_SCORE_CONTENT_HEIGHT: u16 = 5; 13 | 14 | /// [`Widget`] to show current score in the running round. 15 | /// 16 | /// Widget construction uses builder pattern which can be started using the 17 | /// [`Self::new()`] method. 18 | /// 19 | /// ``` 20 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::StatefulWidget}; 21 | /// # use balatro_tui_widgets::RoundScoreWidget; 22 | /// let area = Rect::new(0, 0, 100, 100); 23 | /// let mut buffer = Buffer::empty(area); 24 | /// let mut score = 2000; 25 | /// 26 | /// RoundScoreWidget::new().render(area, &mut buffer, &mut score); 27 | /// ``` 28 | #[derive(Clone, Copy, Debug, Default)] 29 | pub struct RoundScoreWidget; 30 | 31 | impl RoundScoreWidget { 32 | /// Create new instance of [`RoundScoreWidget`] 33 | #[must_use = "Created round score widget instance must be used."] 34 | #[inline] 35 | pub const fn new() -> Self { 36 | Self {} 37 | } 38 | } 39 | 40 | impl StatefulWidget for RoundScoreWidget { 41 | type State = usize; 42 | 43 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 44 | // Prepare widgets 45 | let round_score_content = [Line::from("Round Score").centered()]; 46 | 47 | // Prepare areas 48 | let [inner_area] = Layout::vertical([Constraint::Length(ROUND_SCORE_CONTENT_HEIGHT)]) 49 | .flex(Flex::Center) 50 | .areas(area); 51 | let [mut round_score_text_area, round_score_value_area] = 52 | Layout::horizontal([Constraint::Fill(2), Constraint::Fill(5)]).areas(inner_area); 53 | round_score_text_area = Layout::vertical([Constraint::Length(1)]) 54 | .flex(Flex::SpaceAround) 55 | .areas::<1>(round_score_text_area)[0]; 56 | 57 | // Render widgets 58 | TextBoxWidget::new(round_score_content).render(round_score_text_area, buf); 59 | TextBoxWidget::bordered([get_line_with_chips(state.to_string(), Color::Red).centered()]) 60 | .render(round_score_value_area, buf); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /balatro_tui_core/src/run.rs: -------------------------------------------------------------------------------- 1 | //! Run is a complete play-through of the game until game over. 2 | //! 3 | //! Across a run, there are multiple rounds played. If any round is failed, the 4 | //! run is over. 5 | 6 | use std::{ 7 | num::NonZeroUsize, 8 | sync::{Arc, RwLock}, 9 | }; 10 | 11 | use super::{deck::Deck, round::Round}; 12 | use crate::error::CoreError; 13 | 14 | /// Tracks the active state of the run 15 | #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] 16 | pub enum RunState { 17 | /// Represents that the run is ongoing 18 | #[default] 19 | Running, 20 | /// Represents that the run is over. If the run was won, variant value is 21 | /// set to true, else false 22 | Finished(bool), 23 | } 24 | 25 | /// Persistent details about the run. 26 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] 27 | pub struct RunProperties { 28 | /// The number of cards to be fetched in hand during the round. 29 | pub hand_size: usize, 30 | /// Maximum discards available per round. 31 | pub max_discards: usize, 32 | /// Maximum hands that can be made per round. 33 | pub max_hands: usize, 34 | /// Random seed for the run. 35 | pub seed: String, 36 | /// Initial amount of money that the run starts with. 37 | pub starting_money: usize, 38 | } 39 | 40 | /// [`Run`] struct maintains the working state of a run, along with the rounds 41 | /// that are selected. 42 | /// 43 | /// A single run is maintained from the point a deck is selected to the point of 44 | /// game over. 45 | #[derive(Clone, Debug)] 46 | pub struct Run { 47 | /// Persistent properties for the run. 48 | pub properties: RunProperties, 49 | /// Holds the operational state of the run. 50 | pub run_state: RunState, 51 | /// Current money held by the user. 52 | pub money: usize, 53 | /// Shared deck of cards across rounds. [`Run`] simply passes this on to the 54 | /// [`Round`] instance. 55 | pub deck: Arc>, 56 | /// An instance of a [`Round`]. 57 | pub round: Round, 58 | /// Used to keep track of the last played [`Round`] number. 59 | pub upcoming_round_number: NonZeroUsize, 60 | } 61 | 62 | impl Run { 63 | /// Main entrypoint of the run. It initializes the internal state and spawns 64 | /// a round. 65 | #[inline] 66 | pub fn start(&mut self) -> Result<(), CoreError> { 67 | self.round.start() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /balatro_tui_core/src/deck.rs: -------------------------------------------------------------------------------- 1 | //! This module provides [`Deck`] as a primitive alias. 2 | //! 3 | //! This module also provides deck management methods and card tracking for UI 4 | //! states. To utilize methods described on [`Deck`], 5 | //! [`DeckConstExt`] and [`DeckExt`] traits must be brought into scope. 6 | 7 | use std::sync::LazyLock; 8 | 9 | use itertools::Itertools; 10 | use rand::{seq::SliceRandom, thread_rng}; 11 | use strum::IntoEnumIterator; 12 | 13 | use super::card::{Card, Rank, Suit}; 14 | use crate::error::{ArithmeticError, CoreError}; 15 | 16 | /// Lazy initializer for default deck. 17 | /// 18 | /// More decks can be added using lazy initialization with use of 19 | /// [`super::card::SuitIter`] and [`super::card::RankIter`]. 20 | pub static DEFAULT_DECK: LazyLock = LazyLock::new(|| { 21 | Rank::iter() 22 | .cartesian_product(Suit::iter()) 23 | .map(|(rank, suit)| Card { rank, suit }) 24 | .collect() 25 | }); 26 | 27 | /// [`Deck`] type is re-exported as an alias to [`Vec`] for better 28 | /// contextual understanding. 29 | pub type Deck = Vec; 30 | 31 | /// Constructor extension trait for [`Deck`]. 32 | /// 33 | /// This trait only consists of construction function for various decks. Since, 34 | /// this trait is implemented directly on foreign type [`Vec`], it gains 35 | /// static methods to create decks on import. 36 | pub trait DeckConstExt { 37 | /// Return a standard 52 card deck. 38 | #[must_use = "Created deck must be used."] 39 | fn standard() -> Deck; 40 | } 41 | 42 | /// Extension methods for [`Deck`], directly implemented on top of 43 | /// [`Vec`]. 44 | pub trait DeckExt { 45 | /// In-place shuffle a deck based on thread/seed rng. 46 | fn shuffle(&mut self); 47 | /// Draw random cards from the deck and return new deck. 48 | #[must_use = "Drawn cards must be used."] 49 | fn draw_random(&mut self, draw_size: usize) -> Result; 50 | } 51 | 52 | impl DeckConstExt for Deck { 53 | #[inline] 54 | fn standard() -> Self { 55 | DEFAULT_DECK.to_vec() 56 | } 57 | } 58 | 59 | impl DeckExt for Deck { 60 | #[inline] 61 | fn shuffle(&mut self) { 62 | self.as_mut_slice().shuffle(&mut thread_rng()); 63 | } 64 | 65 | fn draw_random(&mut self, draw_size: usize) -> Result { 66 | if draw_size > self.len() { 67 | return Err(CoreError::HandsExhaustedError); 68 | } 69 | self.shuffle(); 70 | 71 | let drain_size = self 72 | .len() 73 | .checked_sub(draw_size) 74 | .ok_or(ArithmeticError::Overflow("subtraction"))?; 75 | let drawn_cards = self.drain(drain_size..).collect(); 76 | 77 | Ok(drawn_cards) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/blind_badge.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::Rect, 4 | style::{Color, Stylize}, 5 | symbols::Marker, 6 | text::Line, 7 | widgets::{ 8 | Widget, 9 | canvas::{Canvas, Circle}, 10 | }, 11 | }; 12 | 13 | /// [`Widget`] for depicting [`balatro_tui_core::blind::Blind`] with text 14 | /// inside. 15 | /// 16 | /// Widget construction uses builder pattern which can be started using the 17 | /// [`Self::new()`] method. 18 | /// 19 | /// ``` 20 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::Widget, style::Color, text::Line}; 21 | /// # use balatro_tui_widgets::BlindBadgeWidget; 22 | /// let area = Rect::new(0, 0, 100, 100); 23 | /// let mut buffer = Buffer::empty(area); 24 | /// 25 | /// BlindBadgeWidget::new() 26 | /// .color(Color::Green) 27 | /// .content(Line::from("Small Blind")) 28 | /// .render(area, &mut buffer); 29 | /// ``` 30 | #[derive(Clone, Debug, Default)] 31 | pub struct BlindBadgeWidget { 32 | content: String, 33 | color: Color, 34 | } 35 | 36 | impl BlindBadgeWidget { 37 | /// Create new instance of [`BlindBadgeWidget`]. 38 | #[must_use = "Created blind badge widget instance must be used."] 39 | #[inline] 40 | pub const fn new() -> Self { 41 | Self { 42 | color: Color::White, 43 | content: String::new(), 44 | } 45 | } 46 | 47 | /// Update the color to be used for chip icon and return the 48 | /// [`BlindBadgeWidget`] instance. 49 | #[must_use = "Blind badge widget builder returned instance must be used."] 50 | #[inline] 51 | pub const fn color(mut self, color: Color) -> Self { 52 | self.color = color; 53 | self 54 | } 55 | 56 | /// Update the content to be displayed next to chip icon and return the 57 | /// [`BlindBadgeWidget`] instance. 58 | #[must_use = "Blind badge widget builder returned instance must be used."] 59 | #[inline] 60 | pub fn content(mut self, content: C) -> Self 61 | where 62 | String: From, 63 | { 64 | self.content = content.into(); 65 | self 66 | } 67 | } 68 | 69 | impl Widget for BlindBadgeWidget { 70 | fn render(self, area: Rect, buf: &mut Buffer) { 71 | // Prepare variables 72 | let bound = f64::from(area.height); 73 | 74 | // Render widgets 75 | let canvas = Canvas::default() 76 | .marker(Marker::Braille) 77 | .paint(|ctx| { 78 | ctx.draw(&Circle { 79 | x: 0.0, 80 | y: 0.0, 81 | radius: bound, 82 | color: self.color, 83 | }); 84 | 85 | self.content 86 | .split_whitespace() 87 | .map(String::from) 88 | .map(|text_chunk| Line::from(text_chunk).centered().yellow()) 89 | .rev() 90 | .enumerate() 91 | .for_each(|(idx, line)| { 92 | ctx.print(-1.0, idx as f64, line); 93 | }); 94 | }) 95 | .x_bounds([-bound, bound]) 96 | .y_bounds([-bound, bound]); 97 | canvas.render(area, buf); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: 7 | - "**" 8 | 9 | name: CI 10 | 11 | jobs: 12 | audit: 13 | name: Audit 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: bp3d-actions/audit-check@9c23bd47e5e7b15b824739e0862cb878a52cc211 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | fmt: 23 | name: Rustfmt 24 | runs-on: ubuntu-latest 25 | timeout-minutes: 10 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: dtolnay/rust-toolchain@master 29 | with: 30 | toolchain: nightly 31 | components: rustfmt 32 | 33 | - run: cargo +nightly fmt --all -- --check 34 | 35 | docs_and_spell_check: 36 | name: Docs and Spell Check 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 20 39 | env: 40 | RUSTDOCFLAGS: "-Dwarnings" 41 | steps: 42 | - name: Checkout Actions Repository 43 | uses: actions/checkout@v4 44 | - uses: dtolnay/rust-toolchain@master 45 | with: 46 | toolchain: nightly 47 | 48 | - name: Check spelling 49 | uses: crate-ci/typos@master 50 | 51 | - run: cargo +nightly doc --no-deps 52 | 53 | clippy: 54 | name: Clippy 55 | runs-on: ubuntu-latest 56 | timeout-minutes: 10 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: dtolnay/rust-toolchain@master 60 | with: 61 | toolchain: nightly 62 | components: clippy 63 | 64 | - name: "Run clippy" 65 | # we cannot use `--all-features` because `envman` has features that are mutually exclusive. 66 | run: | 67 | cargo +nightly clippy --workspace --all-features -- -D warnings 68 | 69 | # coverage: 70 | # name: Coverage 71 | # runs-on: ubuntu-latest 72 | # timeout-minutes: 30 73 | # steps: 74 | # - uses: actions/checkout@v4 75 | # - uses: dtolnay/rust-toolchain@master 76 | # with: 77 | # toolchain: nightly 78 | # components: llvm-tools-preview 79 | 80 | # - uses: taiki-e/install-action@cargo-llvm-cov 81 | # - uses: taiki-e/install-action@nextest 82 | 83 | # - name: "Configure build to remove debuginfo" 84 | # run: echo $"\n[profile.dev]\ndebug = false" >> Cargo.toml 85 | 86 | # - name: "Collect coverage" 87 | # run: ./coverage.sh 88 | 89 | # - name: "Print directory sizes" 90 | # run: du -sh target/coverage target/llvm-cov-target 91 | 92 | # - name: Upload to codecov.io 93 | # uses: codecov/codecov-action@v3 94 | # with: 95 | # files: ./target/coverage/lcov.info 96 | 97 | build_and_test: 98 | name: Build and Test 99 | timeout-minutes: 20 100 | strategy: 101 | matrix: 102 | os: [ubuntu-latest, windows-latest] 103 | runs-on: ${{ matrix.os }} 104 | steps: 105 | - uses: actions/checkout@v4 106 | - uses: dtolnay/rust-toolchain@master 107 | with: 108 | toolchain: nightly 109 | 110 | - uses: taiki-e/install-action@nextest 111 | - name: "Build and test" 112 | run: cargo +nightly nextest run --workspace --all-features 113 | 114 | # TODO: Add examples build 115 | # TODO: Add benchmark tests 116 | # TODO: Add integration tests 117 | # TODO: Add udeps check 118 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/run_stats.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroUsize; 2 | 3 | use ratatui::{ 4 | buffer::Buffer, 5 | layout::{Constraint, Flex, Layout, Margin, Rect}, 6 | text::Line, 7 | widgets::{StatefulWidget, Widget}, 8 | }; 9 | 10 | use super::text_box::TextBoxWidget; 11 | 12 | /// Content height for [`RunStatsWidget`]. 13 | const RUN_STATS_CONTENT_HEIGHT: u16 = 15; 14 | 15 | /// Render state for [`RunStatsWidget`]. 16 | #[derive(Clone, Copy, Debug)] 17 | pub struct RunStatsWidgetState { 18 | /// Number of remaining hands 19 | pub hands: usize, 20 | /// Number of remaining discards 21 | pub discards: usize, 22 | /// Money available in the run 23 | pub money: usize, 24 | /// Current run ante 25 | pub ante: NonZeroUsize, 26 | /// Current round number 27 | pub round: NonZeroUsize, 28 | } 29 | 30 | /// [`Widget`] to show stats for a run. 31 | /// 32 | /// Widget construction uses builder pattern which can be started using the 33 | /// [`Self::new()`] method. 34 | /// 35 | /// ``` 36 | /// # use std::num::NonZeroUsize; 37 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::StatefulWidget}; 38 | /// # use balatro_tui_widgets::{RunStatsWidgetState, RunStatsWidget}; 39 | /// let area = Rect::new(0, 0, 100, 100); 40 | /// let mut buffer = Buffer::empty(area); 41 | /// let mut state = RunStatsWidgetState { 42 | /// hands: 3, 43 | /// discards: 3, 44 | /// money: 20, 45 | /// ante: NonZeroUsize::new(2).unwrap(), 46 | /// round: NonZeroUsize::new(5).unwrap(), 47 | /// }; 48 | /// 49 | /// RunStatsWidget::new().render(area, &mut buffer, &mut state); 50 | /// ``` 51 | #[derive(Clone, Copy, Debug, Default)] 52 | pub struct RunStatsWidget; 53 | 54 | impl RunStatsWidget { 55 | /// Create new instance of [`RunStatsWidget`] 56 | #[must_use = "Created run stats widget instance must be used."] 57 | #[inline] 58 | pub const fn new() -> Self { 59 | Self {} 60 | } 61 | } 62 | 63 | impl StatefulWidget for RunStatsWidget { 64 | type State = RunStatsWidgetState; 65 | 66 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 67 | // Prepare areas 68 | let [inner_area] = Layout::vertical([Constraint::Length(RUN_STATS_CONTENT_HEIGHT)]) 69 | .flex(Flex::Center) 70 | .areas(area.inner(Margin::new(1, 1))); 71 | let [_, run_stats_area] = 72 | Layout::horizontal([Constraint::Fill(1), Constraint::Fill(2)]).areas(inner_area); 73 | let [round_functional_info_area, money_area, round_meta_info_area] = 74 | Layout::vertical([Constraint::Fill(1); 3]) 75 | .flex(Flex::Center) 76 | .areas(run_stats_area); 77 | let [hands_count_area, discards_count_area] = 78 | Layout::horizontal([Constraint::Fill(1); 2]).areas(round_functional_info_area); 79 | let [ante_area, round_number_area] = 80 | Layout::horizontal([Constraint::Fill(1); 2]).areas(round_meta_info_area); 81 | 82 | // Render widgets 83 | TextBoxWidget::bordered([Line::from(state.hands.to_string()).centered()]) 84 | .title("Hands") 85 | .render(hands_count_area, buf); 86 | TextBoxWidget::bordered([Line::from(state.discards.to_string()).centered()]) 87 | .title("Discards") 88 | .render(discards_count_area, buf); 89 | TextBoxWidget::bordered([Line::from(format!("{}$", state.money)).centered()]) 90 | .title("Money") 91 | .render(money_area, buf); 92 | TextBoxWidget::bordered([Line::from(state.ante.to_string()).centered()]) 93 | .title("Ante") 94 | .render(ante_area, buf); 95 | TextBoxWidget::bordered([Line::from(state.round.to_string()).centered()]) 96 | .title("Round") 97 | .render(round_number_area, buf); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/card.rs: -------------------------------------------------------------------------------- 1 | use std::default::Default; 2 | 3 | use balatro_tui_core::card::Card; 4 | use ratatui::{ 5 | buffer::Buffer, 6 | layout::{Constraint, Layout, Margin, Rect}, 7 | symbols::border::{self, Set}, 8 | text::Line, 9 | widgets::{Block, Paragraph, StatefulWidget, Widget}, 10 | }; 11 | 12 | use super::text_box::TextBoxWidget; 13 | 14 | /// Content width for [`CardWidget`]. 15 | pub const CARD_CONTENT_WIDTH: u16 = 12; 16 | /// Content height for [`CardWidget`]. 17 | pub const CARD_CONTENT_HEIGHT: u16 = 9; 18 | 19 | /// [`Widget`] to display a [`Card`]. 20 | /// 21 | /// Widget construction uses builder pattern which can be started using the 22 | /// [`Self::new()`] method. 23 | /// 24 | /// ``` 25 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::StatefulWidget, symbols::border}; 26 | /// # use balatro_tui_core::card::{Card, Rank, Suit}; 27 | /// # use balatro_tui_widgets::CardWidget; 28 | /// let area = Rect::new(0, 0, 100, 100); 29 | /// let mut buffer = Buffer::empty(area); 30 | /// let mut card = Card { 31 | /// rank: Rank::Ace, 32 | /// suit: Suit::Club, 33 | /// }; 34 | /// 35 | /// CardWidget::bordered(border::THICK).render(area, &mut buffer, &mut card); 36 | /// ``` 37 | /// 38 | /// A hovered card is represented with border as [`border::THICK`], otherwise 39 | /// border is set to [`border::ROUNDED`]. 40 | #[derive(Clone, Copy, Debug, Default)] 41 | pub struct CardWidget { 42 | /// Type of border to display on card 43 | border_set: Set, 44 | } 45 | 46 | impl CardWidget { 47 | /// Create new instance of [`CardWidget`]. 48 | #[must_use = "Created card widget state instance must be used."] 49 | #[inline] 50 | pub const fn new() -> Self { 51 | Self { 52 | border_set: border::ROUNDED, 53 | } 54 | } 55 | 56 | /// Create new instance of [`CardWidget`] with specified border set. 57 | #[must_use = "Card widget builder returned instance must be used."] 58 | #[inline] 59 | pub const fn bordered(border_set: Set) -> Self { 60 | Self { border_set } 61 | } 62 | 63 | /// Update the border set of the card and return the [`CardWidget`] 64 | /// instance. 65 | #[must_use = "Card widget builder returned instance must be used."] 66 | #[inline] 67 | pub const fn border(mut self, border_set: Set) -> Self { 68 | self.border_set = border_set; 69 | self 70 | } 71 | } 72 | 73 | impl StatefulWidget for CardWidget { 74 | type State = Card; 75 | 76 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 77 | // Prepare areas 78 | let mut inner_area = 79 | Layout::vertical([Constraint::Length(CARD_CONTENT_HEIGHT)]).areas::<1>(area)[0]; 80 | inner_area = 81 | Layout::horizontal([Constraint::Length(CARD_CONTENT_WIDTH)]).areas::<1>(inner_area)[0]; 82 | let [top_area, middle_area, bottom_area] = Layout::vertical([ 83 | Constraint::Length(2), 84 | Constraint::Fill(1), 85 | Constraint::Length(2), 86 | ]) 87 | .areas(inner_area.inner(Margin::new(1, 1))); 88 | 89 | // Render containers 90 | Block::bordered() 91 | .border_set(self.border_set) 92 | .render(inner_area, buf); 93 | 94 | // Render widgets 95 | Paragraph::new(format!( 96 | "{}\r\n{}", 97 | state.rank.get_display(), 98 | state.suit.get_display() 99 | )) 100 | .left_aligned() 101 | .render(top_area, buf); 102 | TextBoxWidget::new([Line::from(format!( 103 | "{}{}", 104 | state.rank.get_display(), 105 | state.suit.get_display() 106 | )) 107 | .centered()]) 108 | .render(middle_area, buf); 109 | Paragraph::new(format!( 110 | "{}\r\n{}", 111 | state.suit.get_display(), 112 | state.rank.get_display() 113 | )) 114 | .right_aligned() 115 | .render(bottom_area, buf); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /balatro_tui_core/src/error.rs: -------------------------------------------------------------------------------- 1 | //! This module provides error definitions for this crate. 2 | 3 | use std::{ 4 | num::ParseIntError, 5 | sync::{RwLockWriteGuard, TryLockError}, 6 | }; 7 | 8 | use strum::ParseError; 9 | use thiserror::Error; 10 | 11 | /// Defines errors relating to arithmetic operation failures. 12 | #[derive(Clone, Copy, Debug, Error)] 13 | pub enum ArithmeticError { 14 | /// Signifies that an arithmetic operation has overflown. Error message 15 | /// provides which kind of operation is overflowing. 16 | #[error("Arithmetic operation {0} overflowed")] 17 | Overflow(&'static str), 18 | } 19 | 20 | /// Defines errors relating to conversion and parsing between enum, string and 21 | /// integers for the target enum and its properties. 22 | #[derive(Clone, Debug, Error)] 23 | pub enum StrumError { 24 | /// Provides conversion from [`ParseError`] to [`StrumError`] used in 25 | /// [`std::str::FromStr`] trait. 26 | #[error("Parsing enum from string failed: {0:?}")] 27 | FromStringError(#[from] ParseError), 28 | 29 | /// Provides conversion from [`ParseError`] to [`StrumError`] using 30 | /// [`str::parse()`]. 31 | #[error("Parsing integer property from enum failed: {0:?}")] 32 | ParseIntError(#[from] ParseIntError), 33 | 34 | /// Signifies that a property defined using [`strum::EnumProperty`] was not 35 | /// found on a queried variant. 36 | #[error("Enum property {property} not found on variant: {variant}")] 37 | PropertyNotFound { 38 | /// The queried enum property. 39 | property: String, 40 | /// The associated queried variant which did not specify the property. 41 | variant: String, 42 | }, 43 | 44 | /// Signifies failure to parse the suit when parsing a card from a string. 45 | #[error("Unpacking suit failed when parsing card: {0:?}")] 46 | SuitUnpackError(String), 47 | } 48 | 49 | /// Defines errors related to scorer and scoring methods. 50 | #[derive(Clone, Debug, Error)] 51 | pub enum ScorerError { 52 | /// Signifies that the current `ante` has exceeded the maximum possible 53 | /// `ante` count of 8. 54 | #[error("Current ante has crossed maximum computable ante: {0}")] 55 | AnteExceeded(usize), 56 | 57 | /// Provides conversion from [`ArithmeticError`] to [`ScorerError`]. 58 | #[error("Arithmetic error occurred in scorer")] 59 | ArithmeticError(#[from] ArithmeticError), 60 | 61 | /// Signifies that an empty hand (no cards) were passed in the scorer. 62 | #[error("Attempted to score a hand with no cards")] 63 | EmptyHandScoredError, 64 | 65 | /// Provides conversion from [`StrumError`] to [`ScorerError`]. 66 | #[error("Error occurred in parsing enum")] 67 | StrumError(#[from] StrumError), 68 | } 69 | 70 | /// Defines top-level errors for the crate. 71 | #[derive(Clone, Debug, Error)] 72 | pub enum CoreError { 73 | /// Signifies inability to acquire write lock on shared `deck`. This should 74 | /// result in immediate exit and cleanup. 75 | #[error("Could not acquire write lock on deck: {0:?}")] 76 | DeckLockError(String), 77 | 78 | /// Signifies that a hand discard was attempted when discards were not 79 | /// available. 80 | #[error("Attempted to discard hand but no discards remaining")] 81 | DiscardsExhaustedError, 82 | 83 | /// Signifies that a hand play was attempted when hands were not available. 84 | #[error("Attempted to play hand but no hands remaining")] 85 | HandsExhaustedError, 86 | 87 | /// Provides conversion from [`ArithmeticError`] to [`ScorerError`]. 88 | #[error("Arithmetic error occurred in core")] 89 | ArithmeticError(#[from] ArithmeticError), 90 | 91 | /// Provides conversion from [`ScorerError`] to [`ScorerError`]. 92 | #[error("Scoring error occurred in scorer")] 93 | ScorerError(#[from] ScorerError), 94 | 95 | /// Provides conversion from [`StrumError`] to [`ScorerError`]. 96 | #[error("Strum error occurred in parsing enum")] 97 | StrumError(#[from] StrumError), 98 | } 99 | 100 | impl<'guard, T> From>> for CoreError { 101 | #[inline] 102 | fn from(source: TryLockError>) -> Self { 103 | Self::DeckLockError(format!("{source:?}")) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/scorer_preview.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroUsize; 2 | 3 | use ratatui::{ 4 | buffer::Buffer, 5 | layout::{Constraint, Flex, Layout, Rect}, 6 | text::Line, 7 | widgets::{StatefulWidget, Widget}, 8 | }; 9 | 10 | use super::text_box::TextBoxWidget; 11 | 12 | /// Content height for [`ScorerPreviewWidget`]. 13 | const SCORER_PREVIEW_CONTENT_HEIGHT: u16 = 10; 14 | 15 | /// Render state for [`ScorerPreviewWidget`]. 16 | #[derive(Clone, Debug)] 17 | pub struct ScorerPreviewWidgetState { 18 | /// Number of chips counted for the scoring hand. 19 | pub chips: usize, 20 | /// Level of the scored hand. 21 | pub level: NonZeroUsize, 22 | /// Multiplier for the scoring hand. 23 | pub multiplier: usize, 24 | /// Text content representing the scoring hand. If [`None`], 25 | /// [`ScorerPreviewWidget`] does not display the scoring hand text. 26 | pub scoring_hand_text: Option, 27 | } 28 | 29 | /// [`Widget`] to show live scorer preview. 30 | /// 31 | /// Widget construction uses builder pattern which can be started using the 32 | /// [`Self::new()`] method. 33 | /// 34 | /// ``` 35 | /// # use std::num::NonZeroUsize; 36 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::StatefulWidget}; 37 | /// # use balatro_tui_core::scorer::ScoringHand; 38 | /// # use balatro_tui_widgets::{ScorerPreviewWidget, ScorerPreviewWidgetState}; 39 | /// let area = Rect::new(0, 0, 100, 100); 40 | /// let mut buffer = Buffer::empty(area); 41 | /// let mut cards = ScorerPreviewWidgetState { 42 | /// chips: 10, 43 | /// level: NonZeroUsize::new(2).unwrap(), 44 | /// multiplier: 5, 45 | /// scoring_hand_text: Some(ScoringHand::FourOfAKind.to_string()), 46 | /// }; 47 | /// 48 | /// ScorerPreviewWidget::new().render(area, &mut buffer, &mut cards) 49 | /// ``` 50 | /// 51 | /// ``` 52 | /// # use std::num::NonZeroUsize; 53 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::StatefulWidget}; 54 | /// # use balatro_tui_widgets::{ScorerPreviewWidget, ScorerPreviewWidgetState}; 55 | /// let area = Rect::new(0, 0, 100, 100); 56 | /// let mut buffer = Buffer::empty(area); 57 | /// let mut cards = ScorerPreviewWidgetState { 58 | /// chips: 10, 59 | /// level: NonZeroUsize::new(2).unwrap(), 60 | /// multiplier: 5, 61 | /// scoring_hand_text: None, 62 | /// }; 63 | /// 64 | /// ScorerPreviewWidget::new().render(area, &mut buffer, &mut cards) 65 | /// ``` 66 | #[derive(Clone, Copy, Debug, Default)] 67 | pub struct ScorerPreviewWidget; 68 | 69 | impl ScorerPreviewWidget { 70 | /// Create new instance of [`ScorerPreviewWidget`] 71 | #[must_use = "Created score preview widget instance must be used."] 72 | #[inline] 73 | pub const fn new() -> Self { 74 | Self {} 75 | } 76 | } 77 | 78 | impl StatefulWidget for ScorerPreviewWidget { 79 | type State = ScorerPreviewWidgetState; 80 | 81 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 82 | // Prepare areas 83 | let [inner_area] = Layout::vertical([Constraint::Length(SCORER_PREVIEW_CONTENT_HEIGHT)]) 84 | .flex(Flex::Center) 85 | .areas(area); 86 | let [scoring_hand_text_area, scoring_area] = 87 | Layout::vertical([Constraint::Length(5), Constraint::Length(5)]) 88 | .flex(Flex::Center) 89 | .areas(inner_area); 90 | let [chips_area, multiply_sign_area, multiplier_area] = Layout::horizontal([ 91 | Constraint::Fill(1), 92 | Constraint::Length(3), 93 | Constraint::Fill(1), 94 | ]) 95 | .areas(scoring_area); 96 | 97 | // Render widgets 98 | if let Some(hand) = state.scoring_hand_text.as_ref() { 99 | TextBoxWidget::new([Line::from(format!("{} [lvl. {}]", hand, state.level)).centered()]) 100 | .constraints([Constraint::Length(1)]) 101 | .render(scoring_hand_text_area, buf); 102 | } 103 | TextBoxWidget::bordered([Line::from(state.chips.to_string()).centered()]) 104 | .render(chips_area, buf); 105 | TextBoxWidget::new([Line::from("\u{d7}".to_owned()).centered()]) 106 | .render(multiply_sign_area, buf); 107 | TextBoxWidget::bordered([Line::from(state.multiplier.to_string()).centered()]) 108 | .render(multiplier_area, buf); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /balatro_tui_core/src/round.rs: -------------------------------------------------------------------------------- 1 | //! Round comprises of one single game with a selected blind. 2 | //! 3 | //! Once the [`Round::score`] crosses the target score of [`Round::blind`], the 4 | //! round is considered to be won and the reward from [`Round::blind`] is added 5 | //! to the enclosing [`super::run::RunProperties`]. If [`Round::hands_count`] 6 | //! reaches zero and the [`Round::score`] does not cross the target score of 7 | //! [`Round::blind`], the round is considered as lost, returning the user to 8 | //! game over screen. 9 | 10 | use std::{ 11 | num::NonZeroUsize, 12 | sync::{Arc, RwLock}, 13 | }; 14 | 15 | use super::{ 16 | blind::Blind, 17 | card::{Card, Sortable}, 18 | deck::{Deck, DeckExt}, 19 | scorer::Scorer, 20 | }; 21 | use crate::error::{ArithmeticError, CoreError}; 22 | 23 | /// Abstracts properties that remain persistent across played hands within a 24 | /// round. 25 | #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] 26 | pub struct RoundProperties { 27 | /// Current ante of the rounds. Game ends after beating ante `8`. 28 | pub ante: NonZeroUsize, 29 | /// Number of total cards that will be available in hand in a fresh turn. 30 | pub hand_size: usize, 31 | /// Current round number. Game ends after round beating `24`. 32 | pub round_number: NonZeroUsize, 33 | } 34 | 35 | /// [`Round`] struct carries the running state of a particular round. 36 | /// 37 | /// Once the round is over, this struct is destroyed and a new one is created 38 | /// when a blind is selected again. 39 | #[derive(Clone, Debug)] 40 | pub struct Round { 41 | /// Persistent properties for the round. 42 | pub properties: RoundProperties, 43 | /// Shared deck of cards across rounds. Round will start by drawing random 44 | /// cards from this deck. 45 | pub deck: Arc>, 46 | /// An instance of a [`Blind`]. 47 | pub blind: Blind, 48 | /// Number of hands that can be discarded and replaced with newly drawn 49 | /// cards in the round. 50 | pub discards_count: usize, 51 | /// Number of hands that can be played in the round. 52 | pub hands_count: usize, 53 | /// Score accumulated in a round. 54 | pub score: usize, 55 | /// An internal state for handling the hover and selection of cards in hand. 56 | pub hand: Arc>, 57 | /// A drainage for played cards; to be flushed into the main deck at the end 58 | /// of the round. 59 | pub history: Deck, 60 | } 61 | 62 | impl Round { 63 | /// Main entrypoint of the round. Once called, this method prepares the 64 | /// initial state of the round and initializes internal states. 65 | pub fn start(&mut self) -> Result<(), CoreError> { 66 | self.hand = Arc::from(RwLock::from( 67 | self.deck 68 | .try_write()? 69 | .draw_random(self.properties.hand_size)?, 70 | )); 71 | self.hand.try_write()?.sort_by_rank(); 72 | 73 | Ok(()) 74 | } 75 | 76 | /// Draws new cards at the end of a hand played or discarded and adds 77 | /// previous cards to history drain. 78 | fn deal_cards(&mut self, last_cards: &mut Vec) -> Result<(), CoreError> { 79 | let mut new_cards = self.deck.try_write()?.draw_random(last_cards.len())?; 80 | self.history.append(last_cards); 81 | self.hand.try_write()?.append(&mut new_cards); 82 | self.hand.try_write()?.sort_by_rank(); 83 | 84 | Ok(()) 85 | } 86 | 87 | /// Plays the selected cards and scores the hand. 88 | pub fn play_hand(&mut self, played_cards: &mut Vec) -> Result<(), CoreError> { 89 | if self.hands_count == 0 { 90 | return Err(CoreError::HandsExhaustedError); 91 | } 92 | 93 | self.hands_count = self 94 | .hands_count 95 | .checked_sub(1) 96 | .ok_or(ArithmeticError::Overflow("subtraction"))?; 97 | 98 | self.score = self 99 | .score 100 | .checked_add(Scorer::score_cards(played_cards)?) 101 | .ok_or(ArithmeticError::Overflow("addition"))?; 102 | 103 | self.deal_cards(played_cards)?; 104 | 105 | Ok(()) 106 | } 107 | 108 | /// Discards the selected cards and draws equal number of cards as the ones 109 | /// discarded. 110 | pub fn discard_hand(&mut self, discarded_cards: &mut Vec) -> Result<(), CoreError> { 111 | if self.discards_count == 0 { 112 | return Err(CoreError::DiscardsExhaustedError); 113 | } 114 | 115 | self.discards_count = self 116 | .discards_count 117 | .checked_sub(1) 118 | .ok_or(ArithmeticError::Overflow("subtraction"))?; 119 | 120 | self.deal_cards(discarded_cards)?; 121 | 122 | Ok(()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/round_info.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Constraint, Flex, Layout, Margin, Rect}, 4 | style::{Color, Stylize}, 5 | text::Line, 6 | widgets::Widget, 7 | }; 8 | 9 | use super::{blind_badge::BlindBadgeWidget, text_box::TextBoxWidget, utility::get_line_with_chips}; 10 | 11 | /// Content height for [`RoundInfoWidget`]. 12 | pub const ROUND_INFO_CONTENT_HEIGHT: u16 = 9; 13 | /// Kerning multiplier for horizontal and vertical print-space equality. 14 | const KERNING_MULTIPLIER: u16 = 2; 15 | 16 | /// [`Widget`] to show information about the running round. 17 | /// 18 | /// Following details are shown: 19 | /// - Blind chip 20 | /// - Target score 21 | /// - Reward for defeating blind 22 | /// 23 | /// Widget construction uses builder pattern which can be started using the 24 | /// [`Self::new()`] method. 25 | /// 26 | /// ``` 27 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::Widget, style::Color}; 28 | /// # use balatro_tui_widgets::RoundInfoWidget; 29 | /// let area = Rect::new(0, 0, 100, 100); 30 | /// let mut buffer = Buffer::empty(area); 31 | /// 32 | /// RoundInfoWidget::new() 33 | /// .blind_color(Color::Red) 34 | /// .blind_text("Small Blind".to_string()) 35 | /// .reward(5) 36 | /// .target_score(500) 37 | /// .render(area, &mut buffer); 38 | /// ``` 39 | #[derive(Clone, Debug, Default)] 40 | pub struct RoundInfoWidget { 41 | /// Text to show on blind bade 42 | blind_text: String, 43 | /// Color of blind badge 44 | blind_color: Color, 45 | /// Reward for clearing the blind 46 | reward: usize, 47 | /// Target score required to clear the blind 48 | target_score: usize, 49 | } 50 | 51 | impl RoundInfoWidget { 52 | /// Create new instance of [`RoundInfoWidget`] 53 | #[must_use = "Created round info widget instance must be used."] 54 | #[inline] 55 | pub const fn new() -> Self { 56 | Self { 57 | blind_color: Color::White, 58 | blind_text: String::new(), 59 | reward: 0, 60 | target_score: 0, 61 | } 62 | } 63 | 64 | /// Update the text to be used for blind and return the [`RoundInfoWidget`] 65 | /// instance. 66 | #[must_use = "Round info widget builder returned instance must be used."] 67 | #[inline] 68 | pub fn blind_text(mut self, text: String) -> Self { 69 | self.blind_text = text; 70 | self 71 | } 72 | 73 | /// Update the color to be used for blind and return the [`RoundInfoWidget`] 74 | /// instance. 75 | #[must_use = "Round info widget builder returned instance must be used."] 76 | #[inline] 77 | pub const fn blind_color(mut self, color: Color) -> Self { 78 | self.blind_color = color; 79 | self 80 | } 81 | 82 | /// Update the target score and return the [`RoundInfoWidget`] instance. 83 | #[must_use = "Round info widget builder returned instance must be used."] 84 | #[inline] 85 | pub const fn target_score(mut self, target_score: usize) -> Self { 86 | self.target_score = target_score; 87 | self 88 | } 89 | 90 | /// Update the reward text and return the [`RoundInfoWidget`] instance. 91 | #[must_use = "Round info widget builder returned instance must be used."] 92 | #[inline] 93 | pub const fn reward(mut self, reward: usize) -> Self { 94 | self.reward = reward; 95 | self 96 | } 97 | } 98 | 99 | impl Widget for RoundInfoWidget { 100 | fn render(self, area: Rect, buf: &mut Buffer) { 101 | // Prepare variables 102 | let round_info_content = [ 103 | Line::from("Score at least").centered(), 104 | get_line_with_chips(self.target_score.to_string(), Color::Red).centered(), 105 | Line::from(vec![ 106 | "Reward: ".into(), 107 | "$".repeat(self.reward).yellow().bold(), 108 | ]) 109 | .centered(), 110 | ]; 111 | 112 | // Prepare areas 113 | let [inner_area] = Layout::vertical([Constraint::Length(ROUND_INFO_CONTENT_HEIGHT)]) 114 | .flex(Flex::Center) 115 | .areas(area); 116 | let [blind_badge_area, round_info_area] = Layout::horizontal([ 117 | Constraint::Length(ROUND_INFO_CONTENT_HEIGHT * KERNING_MULTIPLIER), 118 | Constraint::Fill(1), 119 | ]) 120 | .areas(inner_area); 121 | 122 | // Render widgets 123 | BlindBadgeWidget::new() 124 | .color(self.blind_color) 125 | .content(self.blind_text) 126 | .render(blind_badge_area.inner(Margin::new(2, 1)), buf); 127 | TextBoxWidget::bordered(round_info_content) 128 | .flex(Flex::SpaceAround) 129 | .render(round_info_area, buf); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/splash_screen.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::max; 2 | 3 | use ratatui::{ 4 | buffer::Buffer, 5 | layout::{Constraint, Flex, Layout, Rect}, 6 | style::Stylize, 7 | symbols::border, 8 | text::Line, 9 | widgets::{Block, Clear, StatefulWidget, Widget}, 10 | }; 11 | use tui_big_text::{BigText, PixelSize}; 12 | 13 | use crate::TextBoxWidget; 14 | 15 | const FULL_PIXEL_WIDTH: usize = 8; 16 | const QUADRANT_PIXEL_WIDTH: usize = 4; 17 | 18 | /// [`Widget`] to display end splash screen. 19 | /// 20 | /// Widget construction uses builder pattern which can be started using the 21 | /// [`Self::new()`] method. 22 | /// 23 | /// ``` 24 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::StatefulWidget}; 25 | /// # use balatro_tui_widgets::SplashScreenWidget; 26 | /// let area = Rect::new(0, 0, 100, 100); 27 | /// let mut buffer = Buffer::empty(area); 28 | /// 29 | /// SplashScreenWidget::new() 30 | /// .splash("Some title") 31 | /// .message("This is some message.") 32 | /// .render(area, &mut buffer, &mut vec![ 33 | /// ("stat-1", "4"), 34 | /// ("stat-2", "7"), 35 | /// ]); 36 | /// ``` 37 | #[derive(Copy, Clone, Debug, Default)] 38 | pub struct SplashScreenWidget<'widget> { 39 | /// Text to be displayed as title of the splash screen. 40 | splash: &'widget str, 41 | /// Supporting message text to be displayed on the splash screen. 42 | message: &'widget str, 43 | } 44 | 45 | impl<'widget> SplashScreenWidget<'widget> { 46 | /// Create new instance of [`SplashScreenWidget`]. 47 | #[must_use = "Created splash screen widget state instance must be used."] 48 | #[inline] 49 | pub const fn new() -> Self { 50 | Self { 51 | splash: "", 52 | message: "", 53 | } 54 | } 55 | 56 | /// Update the splash text and return the [`SplashScreenWidget`] instance. 57 | #[must_use = "Splash screen widget builder returned instance must be used."] 58 | #[inline] 59 | pub const fn splash(mut self, splash: &'widget str) -> Self { 60 | self.splash = splash; 61 | self 62 | } 63 | 64 | /// Update the message text and return the [`SplashScreenWidget`] instance. 65 | #[must_use = "Splash screen widget builder returned instance must be used."] 66 | #[inline] 67 | pub const fn message(mut self, message: &'widget str) -> Self { 68 | self.message = message; 69 | self 70 | } 71 | } 72 | 73 | impl<'widget> StatefulWidget for SplashScreenWidget<'widget> { 74 | type State = Vec<(&'widget str, &'widget str)>; 75 | 76 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 77 | // Prepare variables 78 | let splash_line = self.splash.bold().into_centered_line(); 79 | let message_line = self.message.italic().into_centered_line(); 80 | let stat_lines = state 81 | .iter() 82 | .map(|&(key, value)| vec![key.bold(), "\t\t".into(), value.yellow()].into()) 83 | .collect::>>(); 84 | let render_big = (area.width as usize) 85 | > max( 86 | splash_line 87 | .width() 88 | .saturating_add(1) 89 | .saturating_mul(FULL_PIXEL_WIDTH), 90 | message_line 91 | .width() 92 | .saturating_add(1) 93 | .saturating_mul(QUADRANT_PIXEL_WIDTH), 94 | ); 95 | 96 | // Prepare areas 97 | let [splash_area, message_area, mut details_area] = Layout::vertical([ 98 | Constraint::Length(if render_big { 8 } else { 4 }), 99 | Constraint::Length(if render_big { 4 } else { 1 }), 100 | Constraint::Length( 101 | stat_lines 102 | .len() 103 | .saturating_mul(2) 104 | .saturating_add(3) 105 | .try_into() 106 | .unwrap_or(u16::MAX), 107 | ), 108 | ]) 109 | .flex(Flex::SpaceAround) 110 | .areas(area); 111 | details_area = Layout::horizontal([Constraint::Length(40)]) 112 | .flex(Flex::SpaceAround) 113 | .areas::<1>(details_area)[0]; 114 | 115 | // Render widgets 116 | Clear.render(area, buf); 117 | Block::bordered() 118 | .border_set(border::DOUBLE) 119 | .render(area, buf); 120 | BigText::builder() 121 | .lines([splash_line]) 122 | .pixel_size( 123 | if render_big { 124 | PixelSize::Full 125 | } else { 126 | PixelSize::Quadrant 127 | }, 128 | ) 129 | .centered() 130 | .build() 131 | .render(splash_area, buf); 132 | 133 | if render_big { 134 | BigText::builder() 135 | .lines([message_line]) 136 | .pixel_size(PixelSize::Quadrant) 137 | .centered() 138 | .build() 139 | .render(message_area, buf); 140 | } else { 141 | TextBoxWidget::new([message_line]).render(message_area, buf); 142 | } 143 | 144 | // TODO: Convert to table 145 | TextBoxWidget::bordered(stat_lines) 146 | .padding(4) 147 | .render(details_area, buf); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /balatro_tui/src/tui.rs: -------------------------------------------------------------------------------- 1 | //! This module provides a [`Tui`] wrapper, implementing standard entry and 2 | //! exit procedures to prepare rendering on the terminal. 3 | 4 | use std::{ 5 | io::{Stderr, stderr}, 6 | ops::{Deref, DerefMut}, 7 | panic::set_hook, 8 | process::exit, 9 | }; 10 | 11 | use color_eyre::{Result, config::HookBuilder, eyre::Context}; 12 | use crossterm::{ 13 | cursor, 14 | event::{DisableMouseCapture, EnableMouseCapture}, 15 | terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 16 | }; 17 | use ratatui::{Terminal, backend::CrosstermBackend as Backend}; 18 | use tracing::error; 19 | 20 | /// [`Tui`] is a thin wrapper over [`ratatui`] with [`crossterm`] backend 21 | /// providing methods to handle terminal based operations. 22 | #[derive(Debug)] 23 | pub struct Tui { 24 | /// Crossterm backend terminal instance. 25 | pub(self) terminal: Terminal>, 26 | } 27 | 28 | impl Tui { 29 | /// Creates new Tui instance with crossterm backend. 30 | #[inline] 31 | pub fn new() -> Result { 32 | Ok(Self { 33 | terminal: Terminal::new(Backend::new(stderr())) 34 | .wrap_err("Unable to create a terminal instance using crossterm backend")?, 35 | }) 36 | } 37 | 38 | /// Activates Tui instance and registers panic handlers. 39 | pub fn enter(&self) -> Result<()> { 40 | enable_raw_mode()?; 41 | init_panic_hook()?; 42 | crossterm::execute!( 43 | stderr(), 44 | EnterAlternateScreen, 45 | EnableMouseCapture, 46 | cursor::Hide 47 | )?; 48 | Ok(()) 49 | } 50 | 51 | /// Deactivates Tui instance. 52 | pub fn exit(&self) -> Result<()> { 53 | crossterm::execute!( 54 | stderr(), 55 | LeaveAlternateScreen, 56 | DisableMouseCapture, 57 | cursor::Show 58 | )?; 59 | disable_raw_mode()?; 60 | Ok(()) 61 | } 62 | 63 | /// Suspends Tui instance. 64 | pub fn suspend(&self) -> Result<()> { 65 | self.exit()?; 66 | #[expect( 67 | unsafe_code, 68 | reason = "Intended: Instead of directly exiting, this allows terminal to cleanup and return back to normal mode." 69 | )] 70 | #[cfg(not(windows))] 71 | // SAFETY: Sending terminal stop signal marks the end of the terminal access operations. 72 | // There should be no operation sent after this point. 73 | unsafe { 74 | _ = libc::raise(libc::SIGTSTP); 75 | } 76 | Ok(()) 77 | } 78 | 79 | /// Resumes Tui instance. 80 | pub fn resume(&self) -> Result<()> { 81 | self.enter()?; 82 | Ok(()) 83 | } 84 | } 85 | 86 | impl Deref for Tui { 87 | type Target = Terminal>; 88 | 89 | #[inline] 90 | fn deref(&self) -> &Self::Target { 91 | &self.terminal 92 | } 93 | } 94 | 95 | impl DerefMut for Tui { 96 | #[inline] 97 | fn deref_mut(&mut self) -> &mut Self::Target { 98 | &mut self.terminal 99 | } 100 | } 101 | 102 | impl Drop for Tui { 103 | #[inline] 104 | fn drop(&mut self) { 105 | assert!( 106 | self.exit().is_ok(), 107 | "Failed to drop tui as exit call failed." 108 | ); 109 | } 110 | } 111 | 112 | /// Installs custom panic hook to work with `color_eyre`, `human_panic` and 113 | /// `better_panic`. 114 | fn init_panic_hook() -> Result<()> { 115 | let (panic_hook, eyre_hook) = HookBuilder::default() 116 | .panic_section(format!( 117 | "This is a bug. Consider reporting it at {}.", 118 | env!("CARGO_PKG_REPOSITORY") 119 | )) 120 | .display_location_section(true) 121 | .display_env_section(true) 122 | .into_hooks(); 123 | 124 | eyre_hook.install()?; 125 | 126 | set_hook(Box::new(move |panic_info| { 127 | if let Ok(tui) = Tui::new() { 128 | if let Err(err) = tui.exit() { 129 | error!("Unable to exit Terminal: {:?}.", err); 130 | } 131 | } 132 | 133 | let msg = format!("{}", panic_hook.panic_report(panic_info)); 134 | 135 | #[cfg(not(debug_assertions))] 136 | { 137 | eprintln!("{}", msg); // prints color-eyre stack trace to stderr 138 | use human_panic::{Metadata, handle_dump, print_msg}; 139 | let meta = Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) 140 | .authors(env!("CARGO_PKG_AUTHORS").replace(':', ", ")) 141 | .homepage(env!("CARGO_PKG_HOMEPAGE")); 142 | 143 | let file_path = handle_dump(&meta, panic_info); 144 | // prints human-panic message 145 | print_msg(file_path, &meta) 146 | .expect("human-panic: printing error message to console failed"); 147 | } 148 | 149 | error!("Error: {}", strip_ansi_escapes::strip_str(msg)); 150 | 151 | #[cfg(debug_assertions)] 152 | { 153 | use better_panic::{Settings, Verbosity}; 154 | // Better Panic stacktrace that is only enabled when debugging. 155 | Settings::auto() 156 | .most_recent_first(false) 157 | .lineno_suffix(true) 158 | .verbosity(Verbosity::Full) 159 | .create_panic_handler()(panic_info); 160 | } 161 | 162 | #[expect( 163 | clippy::exit, 164 | reason = "Intended: Calling exit inside panic hook is acceptable." 165 | )] 166 | exit(libc::EXIT_FAILURE); 167 | })); 168 | 169 | Ok(()) 170 | } 171 | -------------------------------------------------------------------------------- /balatro_tui_core/src/blind.rs: -------------------------------------------------------------------------------- 1 | //! This module contains the implementation of different blind types and their 2 | //! variants. 3 | //! 4 | //! The [`Blind`] enum is the entrypoint, data carrier and defines property 5 | //! access methods. 6 | 7 | use std::num::NonZeroUsize; 8 | 9 | use strum::{ 10 | Display as EnumDisplay, EnumCount, EnumIter, EnumProperty, EnumString, IntoStaticStr, 11 | VariantArray, 12 | }; 13 | 14 | use crate::{ 15 | enum_property_ext::EnumPropertyExt, 16 | error::{ArithmeticError, ScorerError, StrumError}, 17 | }; 18 | 19 | /// Blind type can be either small blind, big blind or boss blind. 20 | /// 21 | /// Within boss blind, the boss can be one of the valid [`Bosses`]. 22 | /// 23 | /// A blind type has associated `score_multiplier`, `color` and `reward` 24 | /// properties that can be fetched using [`EnumProperty::get_str()`]. 25 | #[derive( 26 | Clone, 27 | Copy, 28 | Debug, 29 | Default, 30 | EnumDisplay, 31 | EnumCount, 32 | EnumProperty, 33 | Eq, 34 | Hash, 35 | IntoStaticStr, 36 | Ord, 37 | PartialEq, 38 | PartialOrd, 39 | )] 40 | #[repr(usize)] 41 | pub enum Blind { 42 | /// The default blind, small blind is the first blind faced in the run. It 43 | /// is an optional blind and can be skipped. 44 | #[default] 45 | #[strum( 46 | serialize = "Small Blind", 47 | props(score_multiplier = "2", color = "blue", reward = "3") 48 | )] 49 | Small, 50 | /// The big blind is the second blind faced in the run. It is an optional 51 | /// blind and can be skipped. 52 | #[strum( 53 | serialize = "Big Blind", 54 | props(score_multiplier = "3", color = "green", reward = "4") 55 | )] 56 | Big, 57 | /// The boss blind is required to be played. Associated is one of the 58 | /// [`Bosses`] that has a special power. 59 | #[strum( 60 | serialize = "Boss Blind", 61 | props(score_multiplier = "4", color = "red", reward = "5") 62 | )] 63 | Boss(Bosses), 64 | } 65 | 66 | /// Bosses are different blinds that can be randomly show up during a run as 67 | /// boss blind. Each boss has a unique associated power that plays out during 68 | /// the boss blind round. 69 | #[derive( 70 | Clone, 71 | Copy, 72 | Debug, 73 | EnumDisplay, 74 | EnumCount, 75 | EnumIter, 76 | EnumString, 77 | Eq, 78 | Hash, 79 | IntoStaticStr, 80 | Ord, 81 | PartialEq, 82 | PartialOrd, 83 | VariantArray, 84 | )] 85 | #[strum(prefix = "The ")] 86 | pub enum Bosses { 87 | /// Discards 2 random cards from your hand, after each hand played 88 | Hook, 89 | /// Playing your most played hand this run sets money to $0 90 | Ox, 91 | /// First hand is drawn face down 92 | House, 93 | /// Extra large blind (2x base target) 94 | Wall, 95 | /// 1 in 7 cards get drawn face-down throughout the round 96 | Wheel, 97 | /// Decreases the level of Hand you play by 1 (hand levels can go to Level 98 | /// 1, and are permanently reduced before scoring) 99 | Arm, 100 | /// All Club cards are debuffed 101 | Club, 102 | /// Cards are drawn face down after each hand played 103 | Fish, 104 | /// Must play 5 cards (they do not need to be scoring) 105 | Psychic, 106 | /// All Spade cards are debuffed 107 | Goad, 108 | /// Start with 0 discards 109 | Water, 110 | /// All Diamond cards are debuffed 111 | Window, 112 | /// -1 Hand Size 113 | Manacle, 114 | /// Every hand played this round must be of a different type and not 115 | /// previously played this round 116 | Eye, 117 | /// Only one hand type can be played this round 118 | Mouth, 119 | /// All face cards are debuffed 120 | Plant, 121 | /// After playing a hand or discarding cards, you always draw 3 cards (hand 122 | /// size is ignored) 123 | Serpent, 124 | /// Cards played previously this Ante (during Small and Big Blinds) are 125 | /// debuffed 126 | Pillar, 127 | /// Play only 1 hand (0.5x base target) 128 | Needle, 129 | /// All Heart cards are debuffed 130 | Head, 131 | /// Lose $1 per card played 132 | Tooth, 133 | /// The base Chips and Multiplier for playing a poker hand are halved this 134 | /// round 135 | Flint, 136 | /// All face cards are drawn face down 137 | Mark, 138 | } 139 | 140 | const BLIND_BASE_AMOUNTS: [usize; 8] = [3, 8, 20, 50, 110, 200, 350, 500]; 141 | 142 | impl Blind { 143 | /// Returns the target score required to cross the round with this blind. 144 | #[inline] 145 | pub fn get_target_score(&self, ante: NonZeroUsize) -> Result { 146 | if ante.get() >= BLIND_BASE_AMOUNTS.len() { 147 | return Err(ScorerError::AnteExceeded(ante.get())); 148 | } 149 | 150 | let blind_multiple = self.get_int_property("score_multiplier")?; 151 | 152 | let chips_multiplier: usize = 25; 153 | 154 | let boss_blind_multiplier = if let &Self::Boss(boss) = self { 155 | if boss == Bosses::Wall { 156 | 4 157 | } else if boss == Bosses::Needle { 158 | 1 159 | } else { 160 | 2 161 | } 162 | } else { 163 | 2 164 | }; 165 | 166 | Ok(chips_multiplier 167 | .checked_mul(blind_multiple) 168 | .ok_or(ArithmeticError::Overflow("multiplication"))? 169 | .checked_mul(boss_blind_multiplier) 170 | .ok_or(ArithmeticError::Overflow("multiplication"))? 171 | .checked_mul( 172 | *BLIND_BASE_AMOUNTS 173 | .get( 174 | ante.get() 175 | .checked_sub(1) 176 | .ok_or(ArithmeticError::Overflow("subtraction"))?, 177 | ) 178 | .ok_or_else(|| ScorerError::AnteExceeded(ante.get()))?, 179 | ) 180 | .ok_or(ArithmeticError::Overflow("multiplication"))?) 181 | } 182 | 183 | /// Returns color used to represent the blind. 184 | #[inline] 185 | pub fn get_color(&self) -> Result<&str, StrumError> { 186 | self.get_property("color") 187 | } 188 | 189 | /// Returns the reward obtained after defeating the blind. 190 | #[inline] 191 | pub fn get_reward(&self) -> Result { 192 | self.get_int_property("reward") 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /balatro_tui/src/event.rs: -------------------------------------------------------------------------------- 1 | //! This module provides [`EventHandler`] struct that handles sending and 2 | //! receiving events asynchronously. 3 | //! 4 | //! The event handler provides a multi-read, multi-write wrapper that returns 5 | //! [`Event`] using [`EventHandler::next()`]. 6 | 7 | use std::time::Duration; 8 | 9 | use color_eyre::eyre::{Context, OptionExt, Result}; 10 | use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent}; 11 | use futures::{FutureExt, StreamExt}; 12 | use tokio::{ 13 | select, spawn, 14 | sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}, 15 | task::JoinHandle, 16 | time::interval, 17 | }; 18 | use tokio_util::sync::CancellationToken; 19 | 20 | /// This enum specifies the different events that can be sent over the 21 | /// [`EventHandler`]. 22 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 23 | pub enum Event { 24 | /// [`Event::Tick`] event corresponds to a tick of the underlying game loop. 25 | /// This can be used for constant evaluation per tick. Consider creating a 26 | /// `select!` arm for this when making arbitrary checks on the state of the 27 | /// game loop. 28 | Tick, 29 | /// [`Event::Key`] event is sent when a key is pressed or released on the 30 | /// event-accepting interface. 31 | Key(KeyEvent), 32 | /// [`Event::Mouse`] event is sent when a mouse is moved or mouse buttons 33 | /// are pressed or released on the event-accepting interface. 34 | Mouse(MouseEvent), 35 | /// [`Event::Resize`] event is sent when the event-accepting interface is 36 | /// resized. Use this for recomputing render requirements. 37 | Resize(u16, u16), 38 | /// [`Event::Exit`] event is the last event sent automatically by the event 39 | /// handler. Use this for gracefully exiting the game loop. 40 | Exit, 41 | } 42 | 43 | /// [`EventHandler`] is a wrapper interface that keeps track of and propagates 44 | /// events from an event-accepting interface to the game loop. 45 | #[derive(Debug)] 46 | pub struct EventHandler { 47 | /// Sender allows asynchronous, thread-safe sends to the event handler. 48 | sender: UnboundedSender, 49 | /// Receiver allows asynchronous, thread-safe consumption from the event 50 | /// handler. 51 | receiver: UnboundedReceiver, 52 | /// Taking from handler marks the end of the [`EventHandler`]. It safely 53 | /// closes both sender and receiver objects. 54 | handler: Option>>, 55 | /// Signals cancellation from consumer to exit the [`EventHandler`]. 56 | cancellation_token: CancellationToken, 57 | } 58 | 59 | impl EventHandler { 60 | /// Creates a new [`EventHandler`] instance and creates a handler for 61 | /// sending [`Event`] instances. 62 | #[must_use = "Created event handler instance must be used."] 63 | pub fn new(tick_rate: u64) -> Self { 64 | let tick_duration = Duration::from_millis(tick_rate); 65 | 66 | let (sender, receiver) = unbounded_channel(); 67 | 68 | let cancellation_token = CancellationToken::new(); 69 | 70 | let handler = spawn(Self::event_handler_future( 71 | tick_duration, 72 | sender.clone(), 73 | cancellation_token.clone(), 74 | )); 75 | 76 | Self { 77 | sender, 78 | receiver, 79 | handler: Some(handler), 80 | cancellation_token, 81 | } 82 | } 83 | 84 | /// Send an event to the event handler 85 | #[inline] 86 | pub fn send_event(&mut self, event: Event) -> Result<()> { 87 | self.sender 88 | .send(event) 89 | .wrap_err("Failed to send message into the event handler sender") 90 | } 91 | 92 | /// Event handler future to be spawned as a tokio task. 93 | async fn event_handler_future( 94 | tick_duration: Duration, 95 | sender: UnboundedSender, 96 | cancellation_token: CancellationToken, 97 | ) -> Result<()> { 98 | let mut reader = EventStream::new(); 99 | let mut tick = interval(tick_duration); 100 | 101 | #[expect( 102 | clippy::pattern_type_mismatch, 103 | clippy::ignored_unit_patterns, 104 | clippy::integer_division_remainder_used, 105 | reason = "False positive: Tokio's select! macro has different semantics than match statements." 106 | )] 107 | loop { 108 | let tick_delay = tick.tick(); 109 | let crossterm_event = reader.next().fuse(); 110 | select! { 111 | _ = sender.closed() => { 112 | break Ok(()); 113 | } 114 | _ = tick_delay => { 115 | sender.send(Event::Tick).wrap_err("Unable to send Tick event over sender channel.")?; 116 | } 117 | _ = cancellation_token.cancelled() => { 118 | break Ok(()); 119 | } 120 | Some(Ok(event)) = crossterm_event => { 121 | match event { 122 | CrosstermEvent::Key(key) => { 123 | if key.kind == KeyEventKind::Press { 124 | sender.send(Event::Key(key)).wrap_err("Unable to send Key event over sender channel.")?; 125 | } 126 | }, 127 | CrosstermEvent::Mouse(mouse) => { 128 | sender.send(Event::Mouse(mouse)).wrap_err("Unable to send Mouse event over sender channel.")?; 129 | }, 130 | CrosstermEvent::Resize(x, y) => { 131 | sender.send(Event::Resize(x, y)).wrap_err("Unable to send Resize event over sender channel.")?; 132 | }, 133 | CrosstermEvent::FocusLost 134 | | CrosstermEvent::FocusGained 135 | | CrosstermEvent::Paste(_) => { }, 136 | } 137 | } 138 | }; 139 | } 140 | } 141 | 142 | /// Sends the next asynchronous [`Event`] instance. 143 | /// 144 | /// This requires an `await` call on the returning [`Event`] instance or it 145 | /// can be chained with other `async` tasks. 146 | pub async fn next(&mut self) -> Result { 147 | self.receiver 148 | .recv() 149 | .await 150 | .ok_or_eyre("Cannot receive next event.") 151 | } 152 | 153 | /// Halts the [`Event`] stream via a cancellation token and sends an 154 | /// [`Event::Exit`] event to gracefully exit the game loop. 155 | pub async fn stop(&mut self) -> Result<()> { 156 | self.cancellation_token.cancel(); 157 | if let Some(handle) = self.handler.take() { 158 | return handle 159 | .await 160 | .wrap_err("Cannot attain join on event listener task handle.")? 161 | .wrap_err("An error occurred in the event handler future"); 162 | } 163 | Ok(()) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "balatro_tui", 5 | "balatro_tui_core", 6 | "balatro_tui_widgets", 7 | ] 8 | exclude = [ 9 | "HomebrewFormula", 10 | "/.github/", 11 | "/ci/", 12 | "/pkg/brew", 13 | "/benchsuite/", 14 | "/scripts/", 15 | ] 16 | 17 | [workspace.package] 18 | authors = ["Utkarsh Bhardwaj (Passeriform) "] 19 | categories = ["command-line-utilities", "config", "data-structures"] 20 | edition = "2021" 21 | homepage = "https://www.passeriform.com/prod/BalatroTUI" 22 | keywords = ["game", "command-line", "cli", "cards"] 23 | license-file = "LICENSE" 24 | readme = "README.md" 25 | repository = "https://github.com/Passeriform/BalatroTUI" 26 | version = "0.1.4" 27 | 28 | [workspace.metadata.release] 29 | 30 | [workspace.metadata.workspaces] 31 | exact = true 32 | no_individual_tags = true 33 | 34 | [workspace.lints.rust] 35 | warnings = { priority = -1, level = "warn" } 36 | deprecated-safe = { priority = -1, level = "warn" } 37 | future-incompatible = { priority = -1, level = "warn" } 38 | keyword-idents = { priority = -1, level = "warn" } 39 | let-underscore = { priority = -1, level = "warn" } 40 | nonstandard-style = { priority = -1, level = "warn" } 41 | refining-impl-trait = { priority = -1, level = "warn" } 42 | rust-2018-compatibility = { priority = -1, level = "warn" } 43 | rust-2018-idioms = { priority = -1, level = "warn" } 44 | rust-2021-compatibility = { priority = -1, level = "warn" } 45 | rust-2024-compatibility = { priority = -1, level = "warn" } 46 | unused = { priority = -1, level = "warn" } 47 | 48 | ambiguous_negative_literals = "warn" 49 | deprecated_in_future = "warn" 50 | ffi_unwind_calls = "warn" 51 | macro_use_extern_crate = "warn" 52 | meta_variable_misuse = "warn" 53 | missing_abi = "warn" 54 | missing_copy_implementations = "warn" 55 | missing_debug_implementations = "warn" 56 | missing_docs = "warn" 57 | non_ascii_idents = "warn" 58 | non_local_definitions = "warn" 59 | redundant_imports = "warn" 60 | redundant_lifetimes = "warn" 61 | single_use_lifetimes = "warn" 62 | trivial_casts = "warn" 63 | trivial_numeric_casts = "warn" 64 | unit_bindings = "warn" 65 | unnameable_types = "warn" 66 | unreachable_pub = "warn" 67 | unsafe_code = "warn" 68 | unstable_features = "allow" # Allowed for nightly 69 | unused_crate_dependencies = "allow" # Using cargo-udeps (https://github.com/rust-lang/rust/issues/95513) 70 | unused_extern_crates = "warn" 71 | unused_import_braces = "warn" 72 | unused_lifetimes = "warn" 73 | unused_qualifications = "warn" 74 | unused_results = "warn" 75 | variant_size_differences = "warn" 76 | 77 | [workspace.lints.clippy] 78 | all = { priority = -1, level = "warn" } 79 | cargo = { priority = -1, level = "warn" } 80 | complexity = { priority = -1, level = "warn" } 81 | correctness = { priority = -1, level = "warn" } 82 | pedantic = { priority = -1, level = "warn" } 83 | perf = { priority = -1, level = "warn" } 84 | nursery = { priority = -1, level = "warn" } 85 | style = { priority = -1, level = "warn" } 86 | suspicious = { priority = -1, level = "warn" } 87 | 88 | absolute_paths = "warn" 89 | alloc_instead_of_core = "warn" 90 | allow_attributes = "warn" 91 | allow_attributes_without_reason = "warn" 92 | arithmetic_side_effects = "warn" 93 | as_underscore = "warn" 94 | assertions_on_result_states = "warn" 95 | big_endian_bytes = "warn" 96 | cfg_not_test = "warn" 97 | clone_on_ref_ptr = "warn" 98 | create_dir = "warn" 99 | dbg_macro = "warn" 100 | decimal_literal_representation = "warn" 101 | default_numeric_fallback = "warn" 102 | default_union_representation = "warn" 103 | deref_by_slicing = "warn" 104 | disallowed_script_idents = "warn" 105 | else_if_without_else = "warn" 106 | empty_drop = "warn" 107 | empty_enum_variants_with_brackets = "warn" 108 | empty_structs_with_brackets = "warn" 109 | error_impl_error = "warn" 110 | exit = "warn" 111 | expect_used = "warn" 112 | field_scoped_visibility_modifiers = "warn" 113 | filetype_is_file = "warn" 114 | float_cmp_const = "warn" 115 | fn_to_numeric_cast_any = "warn" 116 | format_push_string = "warn" 117 | get_unwrap = "warn" 118 | host_endian_bytes = "warn" 119 | if_then_some_else_none = "warn" 120 | impl_trait_in_params = "warn" 121 | implicit_return = "allow" 122 | indexing_slicing = "warn" 123 | infinite_loop = "warn" 124 | inline_asm_x86_att_syntax = "warn" 125 | inline_asm_x86_intel_syntax = "warn" 126 | integer_division = "warn" 127 | integer_division_remainder_used = "warn" 128 | iter_over_hash_type = "warn" 129 | large_include_file = "warn" 130 | let_underscore_must_use = "warn" 131 | let_underscore_untyped = "warn" 132 | little_endian_bytes = "warn" 133 | lossy_float_literal = "warn" 134 | map_err_ignore = "warn" 135 | mem_forget = "warn" 136 | missing_assert_message = "warn" 137 | missing_asserts_for_indexing = "warn" 138 | missing_docs_in_private_items = "warn" 139 | missing_trait_methods = "allow" 140 | mixed_read_write_in_expression = "warn" 141 | mod_module_files = "allow" 142 | modulo_arithmetic = "warn" 143 | multiple_crate_versions = "allow" # Targets are also pulled now as dependencies (https://github.com/rust-lang/cargo/issues/13519) 144 | multiple_inherent_impl = "warn" 145 | multiple_unsafe_ops_per_block = "warn" 146 | mutex_atomic = "warn" 147 | needless_raw_strings = "warn" 148 | new_without_default = "warn" 149 | non_ascii_literal = "warn" 150 | panic = "warn" 151 | panic_in_result_fn = "warn" 152 | partial_pub_fields = "warn" 153 | pathbuf_init_then_push = "warn" 154 | pattern_type_mismatch = "warn" 155 | print_stderr = "warn" 156 | print_stdout = "warn" 157 | pub_without_shorthand = "warn" 158 | rc_buffer = "warn" 159 | rc_mutex = "warn" 160 | redundant_type_annotations = "warn" 161 | redundant_pub_crate = "allow" 162 | ref_patterns = "warn" 163 | renamed_function_params = "warn" 164 | rest_pat_in_fully_bound_structs = "warn" 165 | same_name_method = "warn" 166 | self_named_module_files = "warn" 167 | semicolon_inside_block = "warn" 168 | separated_literal_suffix = "allow" 169 | shadow_reuse = "warn" 170 | shadow_same = "warn" 171 | shadow_unrelated = "warn" 172 | single_call_fn = "allow" 173 | single_char_lifetime_names = "warn" 174 | str_to_string = "warn" 175 | string_add = "warn" 176 | string_lit_chars_any = "warn" 177 | string_slice = "warn" 178 | string_to_string = "warn" 179 | suspicious_xor_used_as_pow = "warn" 180 | tests_outside_test_module = "warn" 181 | todo = "warn" 182 | try_err = "warn" 183 | undocumented_unsafe_blocks = "warn" 184 | unimplemented = "warn" 185 | unnecessary_safety_comment = "warn" 186 | unnecessary_safety_doc = "warn" 187 | unnecessary_self_imports = "warn" 188 | unneeded_field_pattern = "warn" 189 | unreachable = "warn" 190 | unseparated_literal_suffix = "warn" 191 | unused_result_ok = "warn" 192 | unwrap_in_result = "warn" 193 | unwrap_used = "warn" 194 | use_debug = "warn" 195 | verbose_file_reads = "warn" 196 | wildcard_enum_match_arm = "warn" 197 | 198 | # Enable when refactoring 199 | missing_inline_in_public_items = "allow" 200 | as_conversions = "allow" 201 | min_ident_chars = "allow" # https://github.com/rust-lang/rust-clippy/issues/13396 202 | float_arithmetic = "allow" 203 | cast_precision_loss = "allow" 204 | 205 | # Move core to a lib crate and enable these lints 206 | module_name_repetitions = "allow" 207 | missing_errors_doc = "allow" 208 | 209 | # Enable if moving to no_std 210 | std_instead_of_core = "allow" 211 | std_instead_of_alloc = "allow" 212 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/text_box.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Alignment, Constraint, Flex, Layout, Margin, Rect}, 4 | text::Line, 5 | widgets::{Block, BorderType, Widget, block::Title}, 6 | }; 7 | 8 | /// [`Widget`] to render vertically and horizontally centered text. 9 | /// 10 | /// Widget construction uses builder pattern which can be started using the 11 | /// [`Self::new()`] method. 12 | /// 13 | /// ``` 14 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::Widget, text::Line}; 15 | /// # use balatro_tui_widgets::TextBoxWidget; 16 | /// let area = Rect::new(0, 0, 100, 100); 17 | /// let mut buffer = Buffer::empty(area); 18 | /// let lines: Vec = vec!["Some text".into(), "Some other text".into()]; 19 | /// 20 | /// TextBoxWidget::new(lines).render(area, &mut buffer); 21 | /// ``` 22 | /// 23 | /// Additionally border and title can be specified to set style for text box. 24 | /// Constraints and flex can also be specified to modify layout alignment for 25 | /// content. 26 | /// 27 | /// ``` 28 | /// # use ratatui::{buffer::Buffer, layout::{Constraint, Flex, Rect}, prelude::Widget, text::Line, widgets::{Block, BorderType}}; 29 | /// # use balatro_tui_widgets::TextBoxWidget; 30 | /// let area = Rect::new(0, 0, 100, 100); 31 | /// let mut buffer = Buffer::empty(area); 32 | /// let lines: Vec = vec!["Some text".into(), "Some other text".into()]; 33 | /// 34 | /// TextBoxWidget::new(lines) 35 | /// .border_block(Block::bordered().border_type(BorderType::Rounded)) 36 | /// .title("Title") 37 | /// .constraints([Constraint::Length(1), Constraint::Length(1)]) 38 | /// .flex(Flex::SpaceAround) 39 | /// .render(area, &mut buffer); 40 | /// ``` 41 | /// 42 | /// [`TextBoxWidget`] also provides [`Self::bordered()`] utility method as a 43 | /// shorthand to create bordered text boxes. 44 | /// 45 | /// ``` 46 | /// # use ratatui::{buffer::Buffer, layout::{Constraint, Flex, Rect}, prelude::Widget, text::Line}; 47 | /// # use balatro_tui_widgets::TextBoxWidget; 48 | /// let area = Rect::new(0, 0, 100, 100); 49 | /// let mut buffer = Buffer::empty(area); 50 | /// let lines: Vec = vec!["Some text".into(), "Some other text".into()]; 51 | /// 52 | /// TextBoxWidget::bordered(lines) 53 | /// .title("Title") 54 | /// .constraints([Constraint::Length(1), Constraint::Length(1)]) 55 | /// .flex(Flex::SpaceAround) 56 | /// .render(area, &mut buffer); 57 | /// ``` 58 | #[derive(Clone, Debug, Default)] 59 | pub struct TextBoxWidget<'widget> { 60 | /// Padding for content inside [`TextBoxWidget`] 61 | padding: u16, 62 | /// Optional [`Block`] widget that surrounds the content. 63 | border_block: Option>, 64 | /// Overridable constraints for aligning content. 65 | constraints: Option>, 66 | /// Text content to be displayed. 67 | content: Vec>, 68 | /// Overridable [`Flex`] layout. 69 | flex: Flex, 70 | /// Optional title to be displayed on the border. If 71 | /// [`TextBoxWidget::border_block`] property is not set, this property will 72 | /// be ignored. 73 | title: Option>, 74 | } 75 | 76 | impl<'widget> TextBoxWidget<'widget> { 77 | /// Create new instance of [`TextBoxWidget`]. 78 | #[must_use = "Created text box widget instance must be used."] 79 | #[inline] 80 | pub fn new(content: C) -> Self 81 | where 82 | C: Into>>, 83 | { 84 | TextBoxWidget { 85 | padding: 1, 86 | border_block: None, 87 | constraints: None, 88 | content: content.into(), 89 | flex: Flex::SpaceAround, 90 | title: None, 91 | } 92 | } 93 | 94 | /// Create a bordered instance of [`TextBoxWidget`]. By default the 95 | /// borders are rounded. The style can be overridden using 96 | /// [`Self::border_block()`] method. 97 | #[must_use = "Text box widget builder returned instance must be used."] 98 | #[inline] 99 | pub fn bordered(content: C) -> Self 100 | where 101 | C: Into>>, 102 | { 103 | let mut text_box = Self::new(content); 104 | text_box.border_block = Some(Block::bordered().border_type(BorderType::Rounded)); 105 | text_box 106 | } 107 | 108 | /// Update the [`Self::border_block`] with a new [`Block`] and return the 109 | /// [`TextBoxWidget`] instance. 110 | #[must_use = "Text box widget builder returned instance must be used."] 111 | #[inline] 112 | pub fn border_block(mut self, border_block: Block<'widget>) -> Self { 113 | self.border_block = Some(border_block); 114 | self 115 | } 116 | 117 | /// Update the layout constraints to be used to align content and return the 118 | /// [`TextBoxWidget`] instance. 119 | #[must_use = "Text box widget builder returned instance must be used."] 120 | #[inline] 121 | pub fn constraints(mut self, constraints: I) -> Self 122 | where 123 | I: IntoIterator, 124 | I::Item: Into, 125 | Vec: From, 126 | { 127 | self.constraints = Some(constraints.into()); 128 | self 129 | } 130 | 131 | /// Update the content of the text box and return the [`TextBoxWidget`] 132 | /// instance. 133 | #[must_use = "Text box widget builder returned instance must be used."] 134 | #[inline] 135 | pub fn content(mut self, content: C) -> Self 136 | where 137 | C: IntoIterator, 138 | C::Item: Into> + Widget, 139 | Vec>: From, 140 | { 141 | self.content = content.into(); 142 | self 143 | } 144 | 145 | /// Update the layout flex justify content and return the [`TextBoxWidget`] 146 | /// instance. 147 | #[must_use = "Text box widget builder returned instance must be used."] 148 | #[inline] 149 | pub const fn flex(mut self, flex: Flex) -> Self { 150 | self.flex = flex; 151 | self 152 | } 153 | 154 | /// Update the padding and return the [`TextBoxWidget`] instance. 155 | #[must_use = "Text box widget builder returned instance must be used."] 156 | #[inline] 157 | pub const fn padding(mut self, padding: u16) -> Self { 158 | self.padding = padding; 159 | self 160 | } 161 | 162 | /// Update the title of the block for the text box and return the 163 | /// [`TextBoxWidget`] instance. If a [`Self::border_block`] is not set, this 164 | /// property is ignored when rendering. 165 | #[must_use = "Text box widget builder returned instance must be used."] 166 | #[inline] 167 | pub fn title(mut self, title: T) -> Self 168 | where 169 | T: Into>, 170 | { 171 | self.title = Some(title.into()); 172 | self 173 | } 174 | } 175 | 176 | impl Widget for TextBoxWidget<'_> { 177 | fn render(self, area: Rect, buf: &mut Buffer) { 178 | let inner_area = if let Some(mut border_block) = self.border_block { 179 | if let Some(title) = self.title { 180 | border_block = border_block.title(title.alignment(Alignment::Center)); 181 | } 182 | 183 | border_block.render(area, buf); 184 | area.inner(Margin::new(self.padding, 1)) 185 | } else { 186 | area 187 | }; 188 | 189 | let text_areas = Layout::vertical( 190 | self.constraints 191 | .unwrap_or_else(|| vec![Constraint::Length(1); self.content.iter().len()]), 192 | ) 193 | .flex(self.flex) 194 | .split(inner_area); 195 | 196 | self.content 197 | .iter() 198 | .zip(text_areas.iter()) 199 | .for_each(|(line, &text_area)| { 200 | line.render(text_area, buf); 201 | }); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /balatro_tui_widgets/src/card_list.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, RwLock}; 2 | 3 | use balatro_tui_core::card::Card; 4 | use bit_set::BitSet; 5 | use itertools::Itertools; 6 | use ratatui::{ 7 | buffer::Buffer, 8 | layout::{Constraint, Layout, Offset, Rect}, 9 | symbols::border, 10 | widgets::StatefulWidget, 11 | }; 12 | 13 | use super::CardWidget; 14 | use crate::error::{ArithmeticError, WidgetError}; 15 | 16 | /// Provide bidirectional circular cursor for iterators with ability to select 17 | /// items. 18 | /// 19 | /// By default the list is unfocussed. To move the cursor to the first element, 20 | /// call [`SelectableList::move_next()`]. 21 | pub trait SelectableList { 22 | /// Move the cursor to next item. If next item doesn't exist, cycle back to 23 | /// the first item. 24 | fn move_next(&mut self) -> Result<(), WidgetError>; 25 | /// Move the cursor to previous item. If previous item doesn't exist, cycle 26 | /// to the last item. 27 | fn move_prev(&mut self) -> Result<(), WidgetError>; 28 | /// Add item at current cursor position to the selection list. No-op if the 29 | /// index is already selected. 30 | fn select(&mut self) -> Result; 31 | /// Remove item at current cursor position from the selection list. No-op if 32 | /// the index is already deselected. 33 | fn deselect(&mut self) -> Result; 34 | /// Unfocus (blur) the list. This method removes the cursor from the list. 35 | fn blur(&mut self); 36 | } 37 | 38 | /// Render state for [`CardListWidget`]. 39 | /// 40 | /// Holds a atomic mutable reference to a [`Vec`]. Tracks the current 41 | /// cursor position and selected [`Card`] set. 42 | /// 43 | /// [`CardListWidget`] can be created out of a [`Vec`] reference using the 44 | /// [`Self::from()`] implementation. 45 | /// 46 | /// ``` 47 | /// # use std::sync::{Arc, RwLock}; 48 | /// # use balatro_tui_core::card::{Card, Rank, Suit}; 49 | /// # use balatro_tui_widgets::CardListWidgetState; 50 | /// let cards = vec![ 51 | /// Card { 52 | /// rank: Rank::Ace, 53 | /// suit: Suit::Diamond, 54 | /// }, 55 | /// Card { 56 | /// rank: Rank::Ten, 57 | /// suit: Suit::Heart, 58 | /// }, 59 | /// ]; 60 | /// 61 | /// let list_state = CardListWidgetState::from(Arc::from(RwLock::from(cards))); 62 | /// ``` 63 | #[derive(Clone, Debug)] 64 | pub struct CardListWidgetState { 65 | /// Holds a atomic mutable reference to a [`Vec`]. 66 | pub cards: Arc>>, 67 | /// Cursor position over the [`Self::cards`]. 68 | pub pos: Option, 69 | /// A cache of selected card indices. 70 | pub selected: BitSet, 71 | /// Optional limit defines the maximum cards that can be selected. 72 | pub selection_limit: Option, 73 | } 74 | 75 | impl CardListWidgetState { 76 | /// Update the [`Self::selection_limit`] and return the 77 | /// [`CardListWidgetState`] instance. 78 | #[must_use = "Card list widget state builder returned instance must be used."] 79 | #[inline] 80 | pub fn selection_limit(mut self, selection_limit: Option) -> Result { 81 | if let Some(limit) = selection_limit { 82 | if limit < self.selected.len() { 83 | return Err(WidgetError::SelectionLimitOverflow { 84 | attempted_selection_limit: limit, 85 | max_allowed: self.selected.len(), 86 | }); 87 | } 88 | } 89 | 90 | self.selection_limit = selection_limit; 91 | Ok(self) 92 | } 93 | 94 | /// Updates the [`Self::cards`] and reset selected and focussed positions. 95 | #[inline] 96 | pub fn set_cards(&mut self, cards: Arc>>) { 97 | self.cards = cards; 98 | self.pos = None; 99 | self.selected.clear(); 100 | } 101 | } 102 | 103 | impl From>>> for CardListWidgetState { 104 | fn from(value: Arc>>) -> Self { 105 | Self { 106 | cards: value, 107 | pos: None, 108 | selected: BitSet::new(), 109 | selection_limit: None, 110 | } 111 | } 112 | } 113 | 114 | impl SelectableList for CardListWidgetState { 115 | fn move_next(&mut self) -> Result<(), WidgetError> { 116 | if let Some(pos) = self.pos { 117 | let last_index = self 118 | .cards 119 | .try_read()? 120 | .len() 121 | .checked_sub(1) 122 | .ok_or(ArithmeticError::Overflow("subtraction"))?; 123 | self.pos = Some( 124 | if pos == last_index { 125 | 0 126 | } else { 127 | pos.checked_add(1) 128 | .ok_or(ArithmeticError::Overflow("addition"))? 129 | }, 130 | ); 131 | } else { 132 | self.pos = Some(0); 133 | } 134 | 135 | Ok(()) 136 | } 137 | 138 | fn move_prev(&mut self) -> Result<(), WidgetError> { 139 | if let Some(pos) = self.pos { 140 | self.pos = Some( 141 | (if pos == 0 { 142 | self.cards.try_read()?.len() 143 | } else { 144 | pos 145 | }) 146 | .checked_sub(1) 147 | .ok_or(ArithmeticError::Overflow("subtraction"))?, 148 | ); 149 | } else { 150 | self.pos = Some(0); 151 | } 152 | 153 | Ok(()) 154 | } 155 | 156 | #[inline] 157 | fn select(&mut self) -> Result { 158 | if self 159 | .selection_limit 160 | .is_some_and(|limit| limit <= self.selected.len()) 161 | { 162 | return Ok(false); 163 | } 164 | 165 | if let Some(pos) = self.pos { 166 | return Ok(self.selected.insert(pos)); 167 | } 168 | 169 | Ok(false) 170 | } 171 | 172 | #[inline] 173 | fn deselect(&mut self) -> Result { 174 | if let Some(pos) = self.pos { 175 | return Ok(self.selected.remove(pos)); 176 | } 177 | 178 | Ok(false) 179 | } 180 | 181 | #[inline] 182 | fn blur(&mut self) { 183 | self.pos = None; 184 | } 185 | } 186 | 187 | /// [`StatefulWidget`] to display a list of [`Card`]. 188 | /// 189 | /// Widget construction uses builder pattern which can be started using the 190 | /// [`Self::new()`] method. 191 | /// 192 | /// ``` 193 | /// # use std::sync::{Arc, RwLock}; 194 | /// # use ratatui::{buffer::Buffer, layout::Rect, prelude::StatefulWidget}; 195 | /// # use balatro_tui_core::card::{Card, Rank, Suit}; 196 | /// # use balatro_tui_widgets::{CardListWidget, CardListWidgetState}; 197 | /// let area = Rect::new(0, 0, 100, 100); 198 | /// let mut buffer = Buffer::empty(area); 199 | /// let mut card_list = CardListWidgetState::from(Arc::from(RwLock::from(vec![ 200 | /// Card { 201 | /// rank: Rank::Ace, 202 | /// suit: Suit::Club, 203 | /// }, 204 | /// Card { 205 | /// rank: Rank::Two, 206 | /// suit: Suit::Heart, 207 | /// }, 208 | /// Card { 209 | /// rank: Rank::Ten, 210 | /// suit: Suit::Diamond, 211 | /// }, 212 | /// ]))); 213 | /// 214 | /// CardListWidget::new().render(area, &mut buffer, &mut card_list); 215 | /// ``` 216 | #[derive(Clone, Copy, Debug, Default)] 217 | pub struct CardListWidget; 218 | 219 | impl CardListWidget { 220 | /// Create new instance of [`CardListWidget`]. 221 | #[must_use] 222 | #[inline] 223 | pub const fn new() -> Self { 224 | Self {} 225 | } 226 | } 227 | 228 | impl StatefulWidget for CardListWidget { 229 | type State = CardListWidgetState; 230 | 231 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 232 | // Cards 233 | #[expect( 234 | clippy::unwrap_used, 235 | reason = "Intended: Read lock acquisition failure at this point should panic." 236 | )] 237 | let cards = state.cards.try_read().unwrap(); 238 | 239 | // Prepare areas 240 | let deck_areas = Layout::horizontal(vec![Constraint::Fill(1); cards.len()]).split(area); 241 | 242 | // Render widgets 243 | cards 244 | .clone() 245 | .into_iter() 246 | .zip_eq(deck_areas.iter().copied()) 247 | .enumerate() 248 | .for_each(|(idx, (mut card, mut card_area))| { 249 | if state.selected.contains(idx) { 250 | card_area = card_area.offset(Offset { x: 0, y: -5 }); 251 | } 252 | 253 | CardWidget::bordered( 254 | if state.pos == Some(idx) { 255 | border::THICK 256 | } else { 257 | border::ROUNDED 258 | }, 259 | ) 260 | .render(card_area, buf, &mut card); 261 | }); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /balatro_tui/src/game.rs: -------------------------------------------------------------------------------- 1 | //! [`Game`] is a logical wrapper around the main flow of the game, ie, [`Run`]. 2 | //! [`Game`] provides additional functionalities outside of the lifetime of an 3 | //! instance of [`Run`]. 4 | //! 5 | //! The entrypoint of game is [`Game::new()`] to create the instance of a new 6 | //! game and [`Game::start()`] to spawn a new instance of a running game. 7 | 8 | use std::{ 9 | num::NonZeroUsize, 10 | str::FromStr, 11 | sync::{Arc, RwLock}, 12 | }; 13 | 14 | use balatro_tui_core::{ 15 | blind::Blind, 16 | card::Card, 17 | deck::{Deck, DeckConstExt}, 18 | round::{Round, RoundProperties}, 19 | run::{Run, RunProperties, RunState}, 20 | scorer::Scorer, 21 | }; 22 | use balatro_tui_widgets::{ 23 | CardListWidget, CardListWidgetState, RoundInfoWidget, RoundScoreWidget, RunStatsWidget, 24 | RunStatsWidgetState, ScorerPreviewWidget, ScorerPreviewWidgetState, SelectableList, 25 | SplashScreenWidget, 26 | }; 27 | use color_eyre::{ 28 | Result, 29 | eyre::{Context, OptionExt, bail}, 30 | }; 31 | use crossterm::event::{KeyCode, KeyModifiers}; 32 | use rand::{ 33 | distributions::{Alphanumeric, DistString}, 34 | thread_rng, 35 | }; 36 | use ratatui::{ 37 | Frame, 38 | layout::{Constraint, Flex, Layout, Margin, Rect}, 39 | style::Color, 40 | widgets::{Block, BorderType, Borders}, 41 | }; 42 | 43 | use crate::{ 44 | event::{Event, EventHandler}, 45 | iter_index_ext::IterIndexExt, 46 | tui::Tui, 47 | }; 48 | 49 | /// Tick rate at which the game runs/receives updates. 50 | pub const TICK_RATE: u64 = 144; 51 | 52 | /// Maximum selectable cards to form a hand. 53 | /// 54 | /// As per standard rules this is set to `5`. 55 | pub const MAXIMUM_SELECTABLE_CARDS: usize = 5; 56 | 57 | /// [`Game`] struct holds the state for the running game, including [`Run`] 58 | /// surrounding states, that allow early closure of a run. 59 | #[derive(Clone, Debug)] 60 | pub struct Game { 61 | /// An instance of a [`Run`]. The run is the actual handler for most 62 | /// operations. [`Game`] simply forwards the requests to [`Run`] to handle. 63 | run: Run, 64 | /// A cached card list widget state. This caching is required for showing 65 | /// selection and hovering for [`CardListWidget`]. 66 | card_list_widget_state: Option, 67 | } 68 | 69 | impl Game { 70 | /// Create a new instance of a game. 71 | /// 72 | /// This acts as a no-parameter initialization point and should be placed 73 | /// between user initialization and persistent on-disk configurations. 74 | #[must_use = "Created game instance must be used."] 75 | #[inline] 76 | pub fn new() -> Result { 77 | let deck = Arc::new(RwLock::new(Deck::standard())); 78 | let max_discards = 3; 79 | let max_hands = 3; 80 | let run_properties = RunProperties { 81 | hand_size: 10, 82 | max_discards, 83 | max_hands, 84 | seed: Alphanumeric.sample_string(&mut thread_rng(), 16), 85 | starting_money: 10, 86 | }; 87 | let round_properties = RoundProperties { 88 | hand_size: 10, 89 | ante: NonZeroUsize::new(1).ok_or_eyre("Could not create ante number")?, 90 | round_number: NonZeroUsize::new(1).ok_or_eyre("Could not create round number")?, 91 | }; 92 | Ok(Self { 93 | run: Run { 94 | deck: Arc::clone(&deck), 95 | run_state: RunState::Running, 96 | money: run_properties.starting_money, 97 | properties: run_properties, 98 | round: Round { 99 | blind: Blind::Small, 100 | deck: Arc::clone(&deck), 101 | discards_count: max_discards, 102 | hand: Arc::new(RwLock::new(vec![])), 103 | hands_count: max_hands, 104 | history: vec![], 105 | properties: round_properties, 106 | score: 0, 107 | }, 108 | upcoming_round_number: NonZeroUsize::new(1) 109 | .ok_or_eyre("Could not create upcoming round number")?, 110 | }, 111 | card_list_widget_state: None, 112 | }) 113 | } 114 | 115 | /// Main entrypoint of the game. 116 | /// 117 | /// Creates a new [`Tui`] instance and initializes the [`EventHandler`]. 118 | /// Runs the round initialization routine and the game `update` loop 119 | pub async fn start(&mut self) -> Result<()> { 120 | // Enter TUI 121 | let mut tui = Tui::new()?; 122 | tui.enter().wrap_err("Error occurred while entering Tui")?; 123 | 124 | // Spawn EventHandler 125 | let mut event_handler = EventHandler::new(TICK_RATE); 126 | 127 | // Start a run 128 | self.run.start()?; 129 | 130 | // Cached card state 131 | self.card_list_widget_state = Some( 132 | CardListWidgetState::from(Arc::>>::clone(&self.run.round.hand)) 133 | .selection_limit(Some(MAXIMUM_SELECTABLE_CARDS))?, 134 | ); 135 | 136 | // Draw loop 137 | loop { 138 | let mut send_result: Result<()> = Ok(()); 139 | 140 | let event = event_handler.next().await?; 141 | 142 | let continue_game = Self::evaluate_exit(event, |ev: Event| { 143 | send_result = event_handler.send_event(ev); 144 | })?; 145 | 146 | send_result?; 147 | 148 | self.handle_run_events(event)?; 149 | self.handle_round_events(event)?; 150 | self.handle_deck_events(event)?; 151 | 152 | let mut draw_result: Result<()> = Ok(()); 153 | 154 | _ = tui 155 | .draw(|frame| { 156 | draw_result = self.draw(frame, frame.area()); 157 | }) 158 | .wrap_err("Could not draw on Tui buffer.")?; 159 | 160 | draw_result?; 161 | 162 | if !continue_game { 163 | break; 164 | } 165 | } 166 | 167 | // Exit TUI 168 | tui.exit()?; 169 | 170 | Ok(()) 171 | } 172 | 173 | /// Draw loop for game state 174 | /// 175 | /// Runs every tick provided by the rendering interface. 176 | #[expect( 177 | clippy::too_many_lines, 178 | reason = "Refactor: Create CoreRenderer structs to render core widgets." 179 | )] 180 | fn draw(&mut self, frame: &mut Frame<'_>, area: Rect) -> Result<()> { 181 | // Prepare variables 182 | let scoring_hand_opt = Scorer::get_scoring_hand( 183 | &self 184 | .run 185 | .round 186 | .hand 187 | .try_read() 188 | .or_else(|err| bail!("Could not attain read lock for hand: {err}."))? 189 | .peek_at_index_set( 190 | &self 191 | .card_list_widget_state 192 | .as_ref() 193 | .ok_or_eyre("Card list widget state not initialized yet.")? 194 | .selected, 195 | )?, 196 | )? 197 | .0; 198 | let (chips, multiplier) = if let Some(scoring_hand) = scoring_hand_opt { 199 | Scorer::get_chips_and_multiplier(scoring_hand)? 200 | } else { 201 | (0, 0) 202 | }; 203 | 204 | // Prepare areas 205 | let mut splash_state_area = Layout::vertical([Constraint::Ratio(2, 3)]) 206 | .flex(Flex::Center) 207 | .areas::<1>(area)[0]; 208 | splash_state_area = Layout::horizontal([Constraint::Ratio(2, 3)]) 209 | .flex(Flex::Center) 210 | .areas::<1>(splash_state_area)[0]; 211 | let [meta_area, play_area] = 212 | Layout::horizontal([Constraint::Percentage(25), Constraint::Fill(1)]).areas(area); 213 | let [ 214 | round_info_area, 215 | round_score_area, 216 | scoring_area, 217 | run_stats_area, 218 | ] = Layout::vertical([ 219 | Constraint::Length(15), 220 | Constraint::Length(9), 221 | Constraint::Length(12), 222 | Constraint::Length(17), 223 | ]) 224 | .flex(Flex::Center) 225 | .areas(meta_area.inner(Margin::new(1, 0))); 226 | let [_, deck_area] = 227 | Layout::vertical([Constraint::Fill(1), Constraint::Length(10)]).areas(play_area); 228 | 229 | // Render containers 230 | frame.render_widget( 231 | Block::new().borders(Borders::LEFT | Borders::RIGHT), 232 | meta_area, 233 | ); 234 | frame.render_widget( 235 | Block::bordered().border_type(BorderType::Rounded), 236 | round_info_area, 237 | ); 238 | frame.render_widget( 239 | Block::bordered().border_type(BorderType::Rounded), 240 | round_score_area, 241 | ); 242 | frame.render_widget( 243 | Block::bordered().border_type(BorderType::Rounded), 244 | scoring_area, 245 | ); 246 | 247 | // Render widgets 248 | frame.render_widget( 249 | RoundInfoWidget::new() 250 | .blind_color(Color::from_str(self.run.round.blind.get_color()?)?) 251 | .blind_text(self.run.round.blind.to_string()) 252 | .reward(self.run.round.blind.get_reward()?) 253 | .target_score( 254 | self.run 255 | .round 256 | .blind 257 | .get_target_score(self.run.round.properties.ante)?, 258 | ), 259 | round_info_area.inner(Margin::new(1, 1)), 260 | ); 261 | frame.render_stateful_widget( 262 | RoundScoreWidget::new(), 263 | round_score_area.inner(Margin::new(1, 1)), 264 | &mut self.run.round.score, 265 | ); 266 | frame.render_stateful_widget( 267 | ScorerPreviewWidget::new(), 268 | scoring_area.inner(Margin::new(1, 1)), 269 | &mut ScorerPreviewWidgetState { 270 | chips, 271 | level: NonZeroUsize::new(1) 272 | .ok_or_eyre("Unable to create a non zero usize for level")?, 273 | multiplier, 274 | scoring_hand_text: scoring_hand_opt.map(|scoring_hand| scoring_hand.to_string()), 275 | }, 276 | ); 277 | frame.render_stateful_widget( 278 | RunStatsWidget::new(), 279 | run_stats_area, 280 | &mut RunStatsWidgetState { 281 | hands: self.run.round.hands_count, 282 | discards: self.run.round.discards_count, 283 | money: self.run.money, 284 | ante: self.run.round.properties.ante, 285 | round: self.run.round.properties.round_number, 286 | }, 287 | ); 288 | frame.render_stateful_widget( 289 | CardListWidget::new(), 290 | deck_area, 291 | self.card_list_widget_state 292 | .as_mut() 293 | .ok_or_eyre("Card list widget state not initialized yet.")?, 294 | ); 295 | 296 | match self.run.run_state { 297 | RunState::Running => (), 298 | RunState::Finished(win) => { 299 | if win { 300 | frame.render_stateful_widget( 301 | SplashScreenWidget::new() 302 | .splash("Congratulations!") 303 | .message("You won the game!"), 304 | splash_state_area, 305 | &mut vec![("Money collected", &self.run.money.to_string())], 306 | ); 307 | } else { 308 | frame.render_stateful_widget( 309 | SplashScreenWidget::new() 310 | .splash("Game Over") 311 | .message("You lost the game!"), 312 | splash_state_area, 313 | &mut vec![ 314 | ( 315 | "Last round reached", 316 | &self.run.round.properties.round_number.to_string(), 317 | ), 318 | ( 319 | "Last ante reached", 320 | &self.run.round.properties.ante.to_string(), 321 | ), 322 | ], 323 | ); 324 | } 325 | } 326 | } 327 | 328 | Ok(()) 329 | } 330 | 331 | /// Event handler for handling game-specific input interface events. 332 | /// 333 | /// Returns a [`Result`] where the boolean value indicates whether to 334 | /// continue the game loop. 335 | fn evaluate_exit(event: Event, send: S) -> Result 336 | where 337 | S: FnOnce(Event), 338 | { 339 | #[expect( 340 | clippy::wildcard_enum_match_arm, 341 | reason = "Intended: Unused events may skip implementation as required." 342 | )] 343 | match event { 344 | Event::Key(key_event) => match key_event.code { 345 | KeyCode::Esc | KeyCode::Char('q') => { 346 | send(Event::Exit); 347 | } 348 | KeyCode::Char('c' | 'C') => { 349 | if key_event.modifiers == KeyModifiers::CONTROL { 350 | send(Event::Exit); 351 | } 352 | } 353 | _ => (), 354 | }, 355 | Event::Resize(x_size, y_size) => { 356 | if y_size < 40 || x_size < 150 { 357 | bail!( 358 | "Terminal size was less than required to render game. Need at least 150x40 character screen to render." 359 | ); 360 | } 361 | } 362 | Event::Exit => return Ok(false), 363 | _ => (), 364 | } 365 | 366 | Ok(true) 367 | } 368 | 369 | /// Event handler for handling run-specific input interface events. 370 | fn handle_run_events(&mut self, event: Event) -> Result<()> { 371 | if event == Event::Tick { 372 | if self.run.round.hands_count == 0 373 | && self.run.round.score 374 | < self 375 | .run 376 | .round 377 | .blind 378 | .get_target_score(self.run.round.properties.ante)? 379 | { 380 | self.run.run_state = RunState::Finished(false); 381 | } 382 | 383 | if self.run.round.score 384 | >= self 385 | .run 386 | .round 387 | .blind 388 | .get_target_score(self.run.round.properties.ante)? 389 | { 390 | self.run.run_state = RunState::Finished(true); 391 | } 392 | } 393 | 394 | Ok(()) 395 | } 396 | 397 | /// Event handler for handling round-specific input interface events. 398 | fn handle_round_events(&mut self, event: Event) -> Result<()> { 399 | #[expect( 400 | clippy::wildcard_enum_match_arm, 401 | reason = "Intended: Unused events may skip implementation as required." 402 | )] 403 | if let Event::Key(key_event) = event { 404 | match key_event.code { 405 | KeyCode::Enter => { 406 | if self.run.round.hands_count != 0 { 407 | let mut selected = self 408 | .run 409 | .round 410 | .hand 411 | .try_write() 412 | .or_else(|err| bail!("Could not attain read lock for hand: {err}."))? 413 | .drain_from_index_set( 414 | &self 415 | .card_list_widget_state 416 | .as_ref() 417 | .ok_or_eyre("Card list widget state not initialized yet.")? 418 | .selected, 419 | )?; 420 | 421 | if selected.is_empty() { 422 | return Ok(()); 423 | } 424 | 425 | self.run.round.play_hand(&mut selected)?; 426 | self.card_list_widget_state 427 | .as_mut() 428 | .ok_or_eyre("Card list widget state not initialized yet.")? 429 | .set_cards(Arc::>>::clone(&self.run.round.hand)); 430 | } 431 | } 432 | KeyCode::Char('x') => { 433 | if self.run.round.discards_count != 0 { 434 | let mut selected = self 435 | .run 436 | .round 437 | .hand 438 | .try_write() 439 | .or_else(|err| bail!("Could not attain write lock for hand: {err}."))? 440 | .drain_from_index_set( 441 | &self 442 | .card_list_widget_state 443 | .as_ref() 444 | .ok_or_eyre("Card list widget state not initialized yet.")? 445 | .selected, 446 | )?; 447 | 448 | if selected.is_empty() { 449 | return Ok(()); 450 | } 451 | 452 | self.run.round.discard_hand(&mut selected)?; 453 | self.card_list_widget_state 454 | .as_mut() 455 | .ok_or_eyre("Card list widget state not initialized yet.")? 456 | .set_cards(Arc::>>::clone(&self.run.round.hand)); 457 | } 458 | } 459 | _ => (), 460 | } 461 | } 462 | 463 | Ok(()) 464 | } 465 | 466 | /// Event handler for handling deck-specific input interface events. 467 | fn handle_deck_events(&mut self, event: Event) -> Result<()> { 468 | #[expect( 469 | clippy::wildcard_enum_match_arm, 470 | reason = "Intended: Unused events may skip implementation as required." 471 | )] 472 | if let Event::Key(key_event) = event { 473 | match key_event.code { 474 | KeyCode::Right => { 475 | if let Some(state) = self.card_list_widget_state.as_mut() { 476 | state.move_next()?; 477 | } 478 | } 479 | KeyCode::Left => { 480 | if let Some(state) = self.card_list_widget_state.as_mut() { 481 | state.move_prev()?; 482 | } 483 | } 484 | KeyCode::Up => { 485 | _ = self 486 | .card_list_widget_state 487 | .as_mut() 488 | .ok_or_eyre("Card list widget state not initialized yet.")? 489 | .select()?; 490 | } 491 | KeyCode::Down => { 492 | _ = self 493 | .card_list_widget_state 494 | .as_mut() 495 | .ok_or_eyre("Card list widget state not initialized yet.")? 496 | .deselect()?; 497 | } 498 | _ => (), 499 | } 500 | } 501 | 502 | Ok(()) 503 | } 504 | } 505 | -------------------------------------------------------------------------------- /balatro_tui_core/src/card.rs: -------------------------------------------------------------------------------- 1 | //! This module contains implementation of a card and its corresponding 2 | //! attributes. 3 | //! 4 | //! This module does not provide Joker as a card. Jokers are 5 | //! provided in their own module and are not expected to be used with cards. A 6 | //! deck can still be created by enum composition that can contain these 7 | //! variants. 8 | 9 | use std::{ 10 | cmp::{Ordering, Reverse}, 11 | collections::HashMap, 12 | fmt::{Display, Formatter, Result as FmtResult}, 13 | str::FromStr, 14 | }; 15 | 16 | use itertools::Itertools; 17 | use strum::{Display as EnumDisplay, EnumCount, EnumIter, EnumProperty, EnumString, IntoStaticStr}; 18 | use unicode_segmentation::UnicodeSegmentation; 19 | 20 | use crate::{ 21 | enum_property_ext::EnumPropertyExt, 22 | error::{ArithmeticError, StrumError}, 23 | }; 24 | 25 | /// Represents the suit of a card. 26 | /// 27 | /// There are conversion methods for creating a [`Suit`] instance from unicode 28 | /// and first letter notation. 29 | /// 30 | /// ``` 31 | /// # use std::str::FromStr; 32 | /// # use balatro_tui_core::card::Suit; 33 | /// let parsed_suits = ["♣", "♦", "♥", "♠"].map(|suit| Suit::from_str(suit).unwrap()); 34 | /// let expected_suits = [Suit::Club, Suit::Diamond, Suit::Heart, Suit::Spade]; 35 | /// 36 | /// assert_eq!(parsed_suits, expected_suits); 37 | /// ``` 38 | /// 39 | /// ``` 40 | /// # use std::str::FromStr; 41 | /// # use balatro_tui_core::card::Suit; 42 | /// let parsed_suits = ["C", "D", "H", "S"].map(|suit| Suit::from_str(suit).unwrap()); 43 | /// let expected_suits = [Suit::Club, Suit::Diamond, Suit::Heart, Suit::Spade]; 44 | /// 45 | /// assert_eq!(parsed_suits, expected_suits); 46 | /// ``` 47 | /// 48 | /// Suit provides `Suit::iter()` method that can be used to create an iterator 49 | /// over suit values. 50 | /// 51 | /// ``` 52 | /// # use strum::IntoEnumIterator; 53 | /// # use balatro_tui_core::card::Suit; 54 | /// assert_eq!(Suit::iter().collect::>(), vec![ 55 | /// Suit::Club, 56 | /// Suit::Diamond, 57 | /// Suit::Heart, 58 | /// Suit::Spade 59 | /// ]); 60 | /// ``` 61 | #[derive( 62 | Clone, 63 | Copy, 64 | Debug, 65 | EnumDisplay, 66 | EnumCount, 67 | EnumIter, 68 | EnumProperty, 69 | EnumString, 70 | Eq, 71 | Hash, 72 | IntoStaticStr, 73 | Ord, 74 | PartialEq, 75 | PartialOrd, 76 | )] 77 | pub enum Suit { 78 | /// Club suit (♣/C) 79 | #[strum(serialize = "\u{2663}", serialize = "C", props(display = "\u{2663}"))] 80 | Club, 81 | /// Diamond suit (♦/D) 82 | #[strum(serialize = "\u{2666}", serialize = "D", props(display = "\u{2666}"))] 83 | Diamond, 84 | /// Heart suit (♥/H) 85 | #[strum(serialize = "\u{2665}", serialize = "H", props(display = "\u{2665}"))] 86 | Heart, 87 | /// Spade suit (♠/S) 88 | #[strum(serialize = "\u{2660}", serialize = "S", props(display = "\u{2660}"))] 89 | Spade, 90 | } 91 | 92 | impl Suit { 93 | /// Returns deterministic display value for the rank. 94 | #[inline] 95 | pub fn get_display(&self) -> String { 96 | self.get_str("display") 97 | .map_or_else(|| self.to_string(), Into::into) 98 | } 99 | } 100 | 101 | /// Represents the rank of the card. 102 | /// 103 | /// There are conversion method to create a rank instance from serialized 104 | /// representation. 105 | /// 106 | /// ``` 107 | /// # use std::str::FromStr; 108 | /// # use balatro_tui_core::card::Rank; 109 | /// let parsed_ranks = ["A", "3", "10", "J", "Q", "K"].map(|rank| Rank::from_str(rank).unwrap()); 110 | /// let expected_ranks = [ 111 | /// Rank::Ace, 112 | /// Rank::Three, 113 | /// Rank::Ten, 114 | /// Rank::Jack, 115 | /// Rank::Queen, 116 | /// Rank::King, 117 | /// ]; 118 | /// 119 | /// assert_eq!(parsed_ranks, expected_ranks); 120 | /// ``` 121 | /// 122 | /// There are different ordering and properties attached that can be used for 123 | /// comparing, sorting and scoring. 124 | /// 125 | /// ## Ordering and Representation 126 | /// The ordinal representation represents the underlying index of the rank. In 127 | /// this context, [`Rank::Ace`] has ordinal `1`, [`Rank::Jack`] has ordinal 128 | /// `11`, [`Rank::Queen`] has ordinal `12` and [`Rank::King`] has ordinal `13`. 129 | /// 130 | /// This ordinal is only used for representation purposes. For comparison and 131 | /// sorting, a custom comparison is implemented that keeps [`Rank::Ace`] greater 132 | /// than [`Rank::King`]. 133 | /// 134 | /// This is useful for sorting in most games. Although scoring must implement 135 | /// custom checks for wrap-around instances (like straights). 136 | /// 137 | /// ``` 138 | /// # use balatro_tui_core::card::Rank; 139 | /// let mut unsorted_ranks = [Rank::Seven, Rank::King, Rank::Two, Rank::Ace]; 140 | /// let sorted_ranks = [Rank::Ace, Rank::King, Rank::Seven, Rank::Two]; 141 | /// 142 | /// unsorted_ranks.sort(); 143 | /// unsorted_ranks.reverse(); 144 | /// 145 | /// assert_eq!(unsorted_ranks, sorted_ranks); 146 | /// ``` 147 | /// 148 | /// Since the scoring is independent of the ordinal, the rank carries additional 149 | /// property of score that equates to ordinal value of a card from 2 through 10. 150 | /// Ace and all face cards score for 10 points. The score for a rank can be 151 | /// fetched using [`Self::get_score()`]. 152 | /// 153 | /// Since, the cards are comparable values, [`std::ops::Add`] and 154 | /// [`std::ops::Sub`] implementations are provided for rank using their ordinal 155 | /// representation. `High Ace` must be considered by scoring implementation as 156 | /// it won't be wrapping. 157 | /// 158 | ///
The rank ordinals are only used for internal use. For 159 | /// parsing use [`Rank::from_str()`] instead
160 | #[derive( 161 | Clone, 162 | Copy, 163 | Debug, 164 | EnumDisplay, 165 | EnumCount, 166 | EnumIter, 167 | EnumProperty, 168 | EnumString, 169 | Eq, 170 | Hash, 171 | IntoStaticStr, 172 | PartialEq, 173 | )] 174 | pub enum Rank { 175 | /// Ace rank (A) 176 | #[strum(serialize = "A", serialize = "1", props(score = "10", display = "A"))] 177 | Ace = 0, 178 | /// Two rank (2) 179 | #[strum(serialize = "2", props(score = "2"))] 180 | Two, 181 | /// Three rank (3) 182 | #[strum(serialize = "3", props(score = "3"))] 183 | Three, 184 | /// Four rank (4) 185 | #[strum(serialize = "4", props(score = "4"))] 186 | Four, 187 | /// Five rank (5) 188 | #[strum(serialize = "5", props(score = "5"))] 189 | Five, 190 | /// Six rank (6) 191 | #[strum(serialize = "6", props(score = "6"))] 192 | Six, 193 | /// Seven rank (7) 194 | #[strum(serialize = "7", props(score = "7"))] 195 | Seven, 196 | /// Eight rank (8) 197 | #[strum(serialize = "8", props(score = "8"))] 198 | Eight, 199 | /// Nine rank (9) 200 | #[strum(serialize = "9", props(score = "9"))] 201 | Nine, 202 | /// Ten rank (10) 203 | #[strum(serialize = "10", props(score = "10"))] 204 | Ten, 205 | /// Jack rank (11) 206 | #[strum(serialize = "J", serialize = "11", props(score = "10", display = "J"))] 207 | Jack, 208 | /// Queen rank (12) 209 | #[strum(serialize = "Q", serialize = "12", props(score = "10", display = "Q"))] 210 | Queen, 211 | /// King rank (13) 212 | #[strum(serialize = "K", serialize = "13", props(score = "10", display = "K"))] 213 | King, 214 | } 215 | 216 | impl Rank { 217 | /// Returns score for given rank to be used in card scoring. 218 | #[inline] 219 | pub fn get_score(&self) -> Result { 220 | self.get_int_property("score") 221 | } 222 | 223 | /// Returns deterministic display value for the rank. 224 | #[inline] 225 | pub fn get_display(&self) -> String { 226 | self.get_str("display") 227 | .map_or_else(|| self.to_string(), Into::into) 228 | } 229 | 230 | /// Finds the ordinal distance between two ranks. 231 | #[inline] 232 | pub fn distance(&self, other: &Self) -> Result { 233 | Ok((*self as isize) 234 | .checked_sub(*other as isize) 235 | .ok_or(ArithmeticError::Overflow("subtraction"))? 236 | .unsigned_abs()) 237 | } 238 | } 239 | 240 | impl PartialOrd for Rank { 241 | #[inline] 242 | fn partial_cmp(&self, other: &Self) -> Option { 243 | Some(self.cmp(other)) 244 | } 245 | } 246 | 247 | impl Ord for Rank { 248 | #[inline] 249 | fn cmp(&self, other: &Self) -> Ordering { 250 | if self == &Self::Ace { 251 | return Ordering::Greater; 252 | } 253 | if other == &Self::Ace { 254 | return Ordering::Less; 255 | } 256 | (*self as usize).cmp(&(*other as usize)) 257 | } 258 | } 259 | 260 | /// Represents a card unit. Card is made of a [`Rank`] and a [`Suit`]. 261 | /// 262 | /// A standard pack of 52 cards can be expressed using this representation. 263 | /// 264 | /// Card can also be created by parsing from unicode or representational string. 265 | /// 266 | /// ``` 267 | /// # use std::str::FromStr; 268 | /// # use balatro_tui_core::card::{Card, Rank, Suit}; 269 | /// assert_eq!(Card::from_str("J♣").unwrap(), Card { 270 | /// rank: Rank::Jack, 271 | /// suit: Suit::Club, 272 | /// }); 273 | /// assert_eq!(Card::from_str("10♥").unwrap(), Card { 274 | /// rank: Rank::Ten, 275 | /// suit: Suit::Heart, 276 | /// }); 277 | /// assert_eq!(Card::from_str("12♣").unwrap(), Card { 278 | /// rank: Rank::Queen, 279 | /// suit: Suit::Club, 280 | /// }); 281 | /// assert_eq!(Card::from_str("5H").unwrap(), Card { 282 | /// rank: Rank::Five, 283 | /// suit: Suit::Heart, 284 | /// }); 285 | /// assert_eq!(Card::from_str("7S").unwrap(), Card { 286 | /// rank: Rank::Seven, 287 | /// suit: Suit::Spade, 288 | /// }); 289 | /// assert_eq!(Card::from_str("11D").unwrap(), Card { 290 | /// rank: Rank::Jack, 291 | /// suit: Suit::Diamond, 292 | /// }); 293 | /// ``` 294 | #[derive(Clone, Copy, Debug, Ord, PartialOrd, PartialEq, Eq, Hash)] 295 | pub struct Card { 296 | /// Rank of the card 297 | pub rank: Rank, 298 | /// Suit of the card 299 | pub suit: Suit, 300 | } 301 | 302 | impl Display for Card { 303 | #[inline] 304 | fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { 305 | write!(f, "{}{}", self.suit, self.rank) 306 | } 307 | } 308 | 309 | impl FromStr for Card { 310 | type Err = StrumError; 311 | 312 | fn from_str(s: &str) -> Result { 313 | let mut chars = s.graphemes(true).collect::>(); 314 | let suit_str = chars 315 | .pop() 316 | .ok_or_else(|| StrumError::SuitUnpackError(s.to_owned()))?; 317 | Ok(Self { 318 | rank: Rank::from_str(&chars.join(""))?, 319 | suit: Suit::from_str(suit_str)?, 320 | }) 321 | } 322 | } 323 | 324 | /// Trait that defines sorting methods for cards. This trait is implemented over 325 | /// a slice of cards and thus methods can be used over [`\[Card;N\]`], 326 | /// [`&\[Card\]`] and [`Vec`] 327 | pub trait Sortable { 328 | /// In-place sorts the cards by [`Suit`] first and then by descending order 329 | /// of [`Rank`]. 330 | fn sort_by_suit(&mut self); 331 | /// In-place sorts the cards by descending order of [`Rank`] first and then 332 | /// by [`Suit`]. 333 | fn sort_by_rank(&mut self); 334 | /// Creates a new sorted [`Vec`] using the rules from 335 | /// [`Sortable::sort_by_suit()`]. 336 | #[must_use = "Sorted cards must be used."] 337 | fn sorted_by_suit(&self) -> Vec; 338 | /// Creates a new sorted [`Vec`] using the rules from 339 | /// [`Sortable::sort_by_rank()`]. 340 | #[must_use = "Sorted cards must be used."] 341 | fn sorted_by_rank(&self) -> Vec; 342 | /// Groups the played cards by their [`Suit`], gets the count of each group, 343 | /// sorts them in descending order based on the count, and returns the 344 | /// [`Vec<(Suit, usize)`] 345 | #[must_use = "Grouped suits must be used."] 346 | fn grouped_by_suit(&self) -> Result, ArithmeticError>; 347 | /// Groups the played cards by their [`Rank`], gets the count of each group, 348 | /// sorts them in descending order based on the count, and returns the 349 | /// [`Vec<(Rank, usize)`] 350 | #[must_use = "Grouped ranks must be used."] 351 | fn grouped_by_rank(&self) -> Result, ArithmeticError>; 352 | } 353 | 354 | impl Sortable for [Card] { 355 | #[inline] 356 | fn sort_by_suit(&mut self) { 357 | self.sort_by_key(|card| (card.suit, Reverse(card.rank))); 358 | } 359 | 360 | #[inline] 361 | fn sort_by_rank(&mut self) { 362 | self.sort_by_key(|card| (Reverse(card.rank), card.suit)); 363 | } 364 | 365 | #[inline] 366 | fn sorted_by_suit(&self) -> Vec { 367 | let mut cards = self.to_vec(); 368 | cards.sort_by_suit(); 369 | cards 370 | } 371 | 372 | #[inline] 373 | fn sorted_by_rank(&self) -> Vec { 374 | let mut cards = self.to_vec(); 375 | cards.sort_by_rank(); 376 | cards 377 | } 378 | 379 | #[expect( 380 | clippy::unwrap_used, 381 | clippy::unwrap_in_result, 382 | reason = "Refactor: Cannot propagate error out of `HashMap::and_modify`" 383 | )] 384 | fn grouped_by_suit(&self) -> Result, ArithmeticError> { 385 | Ok(self 386 | .iter() 387 | .fold(HashMap::new(), |mut groups, card| { 388 | _ = groups 389 | .entry(card.suit) 390 | .and_modify(|entry: &mut usize| { 391 | *entry = entry 392 | .checked_add(1) 393 | .ok_or(ArithmeticError::Overflow("addition")) 394 | .unwrap(); 395 | }) 396 | .or_insert(1); 397 | groups 398 | }) 399 | .into_iter() 400 | .sorted_by_key(|group| group.1) 401 | .rev() 402 | .collect()) 403 | } 404 | 405 | #[expect( 406 | clippy::unwrap_used, 407 | clippy::unwrap_in_result, 408 | reason = "Refactor: Cannot propagate error out of `HashMap::and_modify`" 409 | )] 410 | fn grouped_by_rank(&self) -> Result, ArithmeticError> { 411 | Ok(self 412 | .iter() 413 | .fold(HashMap::new(), |mut groups, card| { 414 | _ = groups 415 | .entry(card.rank) 416 | .and_modify(|entry: &mut usize| { 417 | *entry = entry 418 | .checked_add(1) 419 | .ok_or(ArithmeticError::Overflow("addition")) 420 | .unwrap(); 421 | }) 422 | .or_insert(1); 423 | groups 424 | }) 425 | .into_iter() 426 | .sorted_by_key(|group| group.1) 427 | .rev() 428 | .collect()) 429 | } 430 | } 431 | 432 | #[cfg(test)] 433 | mod tests { 434 | use super::*; 435 | 436 | #[test] 437 | fn suit_from_unicode() { 438 | let parsed_suits = ["♣", "♦", "♥", "♠"].map(|suit| Suit::from_str(suit).unwrap()); 439 | 440 | let expected_suits = [Suit::Club, Suit::Diamond, Suit::Heart, Suit::Spade]; 441 | 442 | assert_eq!(parsed_suits, expected_suits); 443 | } 444 | 445 | #[test] 446 | fn suit_from_str() { 447 | let parsed_suits = ["C", "D", "H", "S"].map(|suit| Suit::from_str(suit).unwrap()); 448 | 449 | let expected_suits = [Suit::Club, Suit::Diamond, Suit::Heart, Suit::Spade]; 450 | 451 | assert_eq!(parsed_suits, expected_suits); 452 | } 453 | 454 | #[test] 455 | fn rank_from_str() { 456 | let parsed_ranks = [ 457 | "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", 458 | ] 459 | .map(|rank| Rank::from_str(rank).unwrap()); 460 | 461 | let expected_ranks = [ 462 | Rank::Ace, 463 | Rank::Two, 464 | Rank::Three, 465 | Rank::Four, 466 | Rank::Five, 467 | Rank::Six, 468 | Rank::Seven, 469 | Rank::Eight, 470 | Rank::Nine, 471 | Rank::Ten, 472 | Rank::Jack, 473 | Rank::Queen, 474 | Rank::King, 475 | ]; 476 | 477 | assert_eq!(parsed_ranks, expected_ranks); 478 | } 479 | 480 | #[test] 481 | fn rank_from_repr() { 482 | let parsed_ranks = [ 483 | "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", 484 | ] 485 | .map(|rank| Rank::from_str(rank).unwrap()); 486 | 487 | let expected_ranks = [ 488 | Rank::Ace, 489 | Rank::Two, 490 | Rank::Three, 491 | Rank::Four, 492 | Rank::Five, 493 | Rank::Six, 494 | Rank::Seven, 495 | Rank::Eight, 496 | Rank::Nine, 497 | Rank::Ten, 498 | Rank::Jack, 499 | Rank::Queen, 500 | Rank::King, 501 | ]; 502 | 503 | assert_eq!(parsed_ranks, expected_ranks); 504 | } 505 | 506 | #[test] 507 | fn card_from_repr_rank_str_suit() { 508 | let parsed_cards = [ 509 | "1C", "2D", "3H", "4S", "5C", "6D", "7H", "8S", "9C", "10D", "11H", "12S", "13C", 510 | ] 511 | .map(|card| Card::from_str(card).unwrap()); 512 | 513 | let expected_cards = [ 514 | Card { 515 | rank: Rank::Ace, 516 | suit: Suit::Club, 517 | }, 518 | Card { 519 | rank: Rank::Two, 520 | suit: Suit::Diamond, 521 | }, 522 | Card { 523 | rank: Rank::Three, 524 | suit: Suit::Heart, 525 | }, 526 | Card { 527 | rank: Rank::Four, 528 | suit: Suit::Spade, 529 | }, 530 | Card { 531 | rank: Rank::Five, 532 | suit: Suit::Club, 533 | }, 534 | Card { 535 | rank: Rank::Six, 536 | suit: Suit::Diamond, 537 | }, 538 | Card { 539 | rank: Rank::Seven, 540 | suit: Suit::Heart, 541 | }, 542 | Card { 543 | rank: Rank::Eight, 544 | suit: Suit::Spade, 545 | }, 546 | Card { 547 | rank: Rank::Nine, 548 | suit: Suit::Club, 549 | }, 550 | Card { 551 | rank: Rank::Ten, 552 | suit: Suit::Diamond, 553 | }, 554 | Card { 555 | rank: Rank::Jack, 556 | suit: Suit::Heart, 557 | }, 558 | Card { 559 | rank: Rank::Queen, 560 | suit: Suit::Spade, 561 | }, 562 | Card { 563 | rank: Rank::King, 564 | suit: Suit::Club, 565 | }, 566 | ]; 567 | 568 | assert_eq!(parsed_cards, expected_cards); 569 | } 570 | 571 | #[test] 572 | fn card_from_repr_rank_unicode_suit() { 573 | let parsed_cards = [ 574 | "1♣", "2♦", "3♥", "4♠", "5♣", "6♦", "7♥", "8♠", "9♣", "10♦", "11♥", "12♠", "13♣", 575 | ] 576 | .map(|card| Card::from_str(card).unwrap()); 577 | 578 | let expected_cards = [ 579 | Card { 580 | rank: Rank::Ace, 581 | suit: Suit::Club, 582 | }, 583 | Card { 584 | rank: Rank::Two, 585 | suit: Suit::Diamond, 586 | }, 587 | Card { 588 | rank: Rank::Three, 589 | suit: Suit::Heart, 590 | }, 591 | Card { 592 | rank: Rank::Four, 593 | suit: Suit::Spade, 594 | }, 595 | Card { 596 | rank: Rank::Five, 597 | suit: Suit::Club, 598 | }, 599 | Card { 600 | rank: Rank::Six, 601 | suit: Suit::Diamond, 602 | }, 603 | Card { 604 | rank: Rank::Seven, 605 | suit: Suit::Heart, 606 | }, 607 | Card { 608 | rank: Rank::Eight, 609 | suit: Suit::Spade, 610 | }, 611 | Card { 612 | rank: Rank::Nine, 613 | suit: Suit::Club, 614 | }, 615 | Card { 616 | rank: Rank::Ten, 617 | suit: Suit::Diamond, 618 | }, 619 | Card { 620 | rank: Rank::Jack, 621 | suit: Suit::Heart, 622 | }, 623 | Card { 624 | rank: Rank::Queen, 625 | suit: Suit::Spade, 626 | }, 627 | Card { 628 | rank: Rank::King, 629 | suit: Suit::Club, 630 | }, 631 | ]; 632 | 633 | assert_eq!(parsed_cards, expected_cards); 634 | } 635 | 636 | #[test] 637 | fn card_from_str_rank_and_str_suit() { 638 | let parsed_cards = ["AC", "JD", "QH", "KS"].map(|card| Card::from_str(card).unwrap()); 639 | 640 | let expected_cards = [ 641 | Card { 642 | rank: Rank::Ace, 643 | suit: Suit::Club, 644 | }, 645 | Card { 646 | rank: Rank::Jack, 647 | suit: Suit::Diamond, 648 | }, 649 | Card { 650 | rank: Rank::Queen, 651 | suit: Suit::Heart, 652 | }, 653 | Card { 654 | rank: Rank::King, 655 | suit: Suit::Spade, 656 | }, 657 | ]; 658 | 659 | assert_eq!(parsed_cards, expected_cards); 660 | } 661 | 662 | #[test] 663 | fn card_from_str_rank_and_unicode_suit() { 664 | let parsed_cards = ["A♣", "J♦", "Q♥", "K♠"].map(|card| Card::from_str(card).unwrap()); 665 | 666 | let expected_cards = [ 667 | Card { 668 | rank: Rank::Ace, 669 | suit: Suit::Club, 670 | }, 671 | Card { 672 | rank: Rank::Jack, 673 | suit: Suit::Diamond, 674 | }, 675 | Card { 676 | rank: Rank::Queen, 677 | suit: Suit::Heart, 678 | }, 679 | Card { 680 | rank: Rank::King, 681 | suit: Suit::Spade, 682 | }, 683 | ]; 684 | 685 | assert_eq!(parsed_cards, expected_cards); 686 | } 687 | 688 | #[test] 689 | fn sort_ranks() { 690 | let mut unsorted_ranks = [Rank::Seven, Rank::King, Rank::Two, Rank::Ace]; 691 | 692 | let sorted_ranks = [Rank::Ace, Rank::King, Rank::Seven, Rank::Two]; 693 | 694 | unsorted_ranks.sort(); 695 | unsorted_ranks.reverse(); 696 | 697 | assert_eq!(unsorted_ranks, sorted_ranks); 698 | } 699 | } 700 | -------------------------------------------------------------------------------- /balatro_tui_core/src/scorer.rs: -------------------------------------------------------------------------------- 1 | //! Scorer provides generic scoring mechanism for a set of cards played. 2 | //! 3 | //! It extends the normal deck scoring to scoring hands available in biased 4 | //! decks as well with [`ScoringHand::FlushFive`], [`ScoringHand::FlushHouse`] 5 | //! and [`ScoringHand::FiveOfAKind`]. 6 | 7 | use strum::{Display, EnumCount, EnumIter, EnumProperty, EnumString, IntoStaticStr}; 8 | 9 | use super::card::{Card, Rank, Sortable}; 10 | use crate::{ 11 | enum_property_ext::EnumPropertyExt, 12 | error::{ArithmeticError, ScorerError, StrumError}, 13 | }; 14 | 15 | /// Bit masks for scoring a straight. 16 | /// 17 | /// 0th mask represents a high ace straight, ie, A-K-Q-J-10 18 | /// 1st mask represents a low ace straight, ie, A-2-3-4-5 19 | /// 20 | ///
Straight scoring operation relies on consistency of 21 | /// this constant and thus must not be changed
22 | const STRAIGHT_BIT_MASKS: [u16; 10] = [ 23 | 0b0001_1110_0000_0001, 24 | 0b0000_0000_0001_1111, 25 | 0b0000_0000_0011_1110, 26 | 0b0000_0000_0111_1100, 27 | 0b0000_0000_1111_1000, 28 | 0b0000_0001_1111_0000, 29 | 0b0000_0011_1110_0000, 30 | 0b0000_0111_1100_0000, 31 | 0b0000_1111_1000_0000, 32 | 0b0001_1111_0000_0000, 33 | ]; 34 | 35 | /// [`ScoringHand`] represents which kind of hand is made when playing a set of 36 | /// cards. 37 | /// 38 | /// A scoring hand has associated values of base `chips` and `multiplier` to be 39 | /// used when scoring the hand. 40 | /// 41 | /// [`ScoringHand`] also implements conversion from string representation. 42 | /// 43 | /// ``` 44 | /// # use std::str::FromStr; 45 | /// # use balatro_tui_core::scorer::ScoringHand; 46 | /// assert_eq!(ScoringHand::from_str("Flush").unwrap(), ScoringHand::Flush); 47 | /// assert_eq!( 48 | /// ScoringHand::from_str("Four of a Kind").unwrap(), 49 | /// ScoringHand::FourOfAKind 50 | /// ); 51 | /// assert_eq!( 52 | /// ScoringHand::from_str("Two Pair").unwrap(), 53 | /// ScoringHand::TwoPair, 54 | /// ); 55 | /// ``` 56 | /// 57 | /// The scoring hands are provided in order of scoring precedence (reverse in 58 | /// ordinal). 59 | /// 60 | /// When scoring, the order of cards doesn't matter. It is internally sorted by 61 | /// [`Rank`] and [`super::card::Suit`] as necessary. 62 | #[derive( 63 | Clone, 64 | Copy, 65 | Debug, 66 | Display, 67 | EnumCount, 68 | EnumIter, 69 | EnumProperty, 70 | EnumString, 71 | Eq, 72 | Hash, 73 | IntoStaticStr, 74 | Ord, 75 | PartialEq, 76 | PartialOrd, 77 | )] 78 | pub enum ScoringHand { 79 | /// [`ScoringHand::FlushFive`] is scored when played cards have five cards 80 | /// of the same [`Rank`] and same [`super::card::Suit`]. 81 | /// 82 | /// ## Examples 83 | /// - A♥, A♥, A♥, A♥, A♥ 84 | /// - 9♣, 9♣, 9♣, 9♣, 9♣ 85 | #[strum(serialize = "Flush Five", props(chips = "160", multiplier = "16"))] 86 | FlushFive, 87 | /// [`ScoringHand::FlushHouse`] is scored when played cards have five cards 88 | /// of the same [`super::card::Suit`] which have two of same [`Rank`] and 89 | /// three of same [`Rank`]. 90 | /// 91 | /// ## Examples 92 | /// - K♥, K♥, K♥, 10♥, 10♥ 93 | /// - 5♣, 7♣, 5♣, 7♣, 5♣ 94 | #[strum(serialize = "Flush House", props(chips = "140", multiplier = "14"))] 95 | FlushHouse, 96 | /// [`ScoringHand::FiveOfAKind`] is scored when played cards have five cards 97 | /// of the same [`Rank`] regardless of the [`super::card::Suit`]. 98 | /// 99 | /// ## Examples 100 | /// - Q♥, Q♣, Q♦, Q♠, Q♥ 101 | /// - 6♣, 6♣, 6♣, 6♣, 6♣ 102 | #[strum(serialize = "Five of a Kind", props(chips = "120", multiplier = "12"))] 103 | FiveOfAKind, 104 | /// [`ScoringHand::RoyalFlush`] is scored when played cards have five cards 105 | /// of the same [`super::card::Suit`] and they form a straight with a high 106 | /// ace. 107 | /// 108 | /// ## Examples 109 | /// - A♥, K♥, Q♥, J♥, 10♥ 110 | #[strum(serialize = "Royal Flush", props(chips = "100", multiplier = "8"))] 111 | RoyalFlush, 112 | /// [`ScoringHand::StraightFlush`] is scored when played cards have five 113 | /// cards of the same [`super::card::Suit`] and they form a straight. 114 | /// 115 | /// ## Examples 116 | /// - 7♣, 6♣, 8♣, 5♣, 4♣ 117 | /// - K♥, Q♥, J♥, 10♥, 9♥ 118 | #[strum(serialize = "Straight Flush", props(chips = "60", multiplier = "7"))] 119 | StraightFlush, 120 | /// [`ScoringHand::FourOfAKind`] is scored when played cards have four cards 121 | /// of the same [`Rank`]. The remaining card isn't scored. 122 | /// 123 | /// ## Examples 124 | /// - 7♣, 7♥, 7♦, 7♣, 4♦ 125 | /// - 6♦, 6♥, 5♦, 6♣, 6♥ 126 | #[strum(serialize = "Four of a Kind", props(chips = "40", multiplier = "4"))] 127 | FourOfAKind, 128 | /// [`ScoringHand::FullHouse`] is scored when played cards have two cards of 129 | /// the same [`Rank`] and another three of the same [`Rank`]. 130 | /// 131 | /// ## Examples 132 | /// 6♣, 5♥, 5♦, 6♣, 5♦ 133 | /// 3♥, A♥, A♦, A♣, 3♣ 134 | #[strum(serialize = "Full House", props(chips = "35", multiplier = "4"))] 135 | FullHouse, 136 | /// [`ScoringHand::Flush`] is scored when played cards have five cards of 137 | /// the same [`super::card::Suit`] regardless of their [`Rank`]. 138 | /// 139 | /// ## Examples 140 | /// A♦, 3♦, 5♦, 8♦, 10♦ 141 | #[strum(serialize = "Flush", props(chips = "30", multiplier = "4"))] 142 | Flush, 143 | /// [`ScoringHand::Straight`] is scored when played cards have five cards 144 | /// that form a sequence of consecutive [`Rank`] regardless of their 145 | /// [`super::card::Suit`]. 146 | /// 147 | /// ## Examples 148 | /// 4♣, 6♥, 5♦, 3♣, 7♦ 149 | #[strum(serialize = "Straight", props(chips = "30", multiplier = "3"))] 150 | Straight, 151 | #[strum(serialize = "Three of a Kind", props(chips = "20", multiplier = "2"))] 152 | /// [`ScoringHand::ThreeOfAKind`] is scored when played cards have three 153 | /// cards that have the same [`Rank`]. Rest of the cards are not scored. 154 | /// 155 | /// ## Examples 156 | /// K♥, 6♣, 6♦, 6♥, 10♥ 157 | ThreeOfAKind, 158 | #[strum(serialize = "Two Pair", props(chips = "20", multiplier = "2"))] 159 | /// [`ScoringHand::TwoPair`] is scored when played cards have two cards of 160 | /// the same [`Rank`] and another two of the same [`Rank`]. Remaining card 161 | /// isn't scored. 162 | /// 163 | /// ## Examples 164 | /// 9♣, 9♥, 5♦, J♣, J♦ 165 | TwoPair, 166 | /// [`ScoringHand::Pair`] is scored when played cards have two cards of the 167 | /// same [`Rank`]. Remaining cards are not scored. 168 | /// 169 | /// ## Examples 170 | /// 6♣, 6♥, 5♦, 8♣, K♦ 171 | #[strum(serialize = "Pair", props(chips = "10", multiplier = "2"))] 172 | Pair, 173 | /// [`ScoringHand::HighCard`] is scored when played cards does not satisfy 174 | /// any other scoring criteria. Only the card with highest [`Rank`] is 175 | /// scored. In this case, [`Rank::Ace`] is always scored as a high ace. 176 | /// 177 | /// ## Examples 178 | /// 2♥, 8♣, 7♦, K♥, 4♥ 179 | #[strum(serialize = "High Card", props(chips = "5", multiplier = "1"))] 180 | HighCard, 181 | } 182 | 183 | /// Holds information regarding testing for a straight in the played hand. 184 | #[derive(Clone, Debug)] 185 | struct StraightTestReport { 186 | /// A optional boolean that can indicate the following: 187 | /// - [`None`]: No ace was counted in the the straight scoring. 188 | /// - [`Some(false)`]: Ace was counted in the the straight scoring and was a 189 | /// low ace, ie, straight was made from ranks `5, 4, 3, 2, A` 190 | /// - [`Some(true)`]: Ace was counted in the the straight scoring and was a 191 | /// high ace, ie, straight was made from ranks `A, K, Q, J, 10` 192 | pub high_ace: Option, 193 | /// Collection of scored ranks. 194 | pub scored_ranks: Vec, 195 | } 196 | 197 | /// Container for static scoring methods. 198 | /// 199 | /// [`Scorer::score_cards`] is a wrapper that handles scoring for cards. It 200 | /// should satisfy most requirements. 201 | #[derive(Clone, Copy, Debug, Default, Ord, PartialOrd, Eq, Hash, PartialEq)] 202 | pub struct Scorer; 203 | 204 | impl Scorer { 205 | /// Returns chips and multiplier for a [`ScoringHand`]. 206 | #[inline] 207 | pub fn get_chips_and_multiplier( 208 | scoring_hand: ScoringHand, 209 | ) -> Result<(usize, usize), StrumError> { 210 | Ok(( 211 | scoring_hand.get_int_property("chips")?, 212 | scoring_hand.get_int_property("multiplier")?, 213 | )) 214 | } 215 | 216 | /// Tests for a straight in a slice of [`Card`] and returns a 217 | /// [`StraightTestReport`]. 218 | fn test_straight(cards: &[Card]) -> Option { 219 | let ranks = cards.iter().map(|card| card.rank).collect::>(); 220 | let rank_bit_mask = ranks 221 | .iter() 222 | .fold(0, |bit_mask, rank| bit_mask | (1 << (*rank as usize))); 223 | 224 | let is_straight = STRAIGHT_BIT_MASKS 225 | .iter() 226 | .any(|matcher| matcher == &rank_bit_mask); 227 | 228 | let high_ace = if rank_bit_mask == STRAIGHT_BIT_MASKS[0] { 229 | Some(true) 230 | } else if rank_bit_mask == STRAIGHT_BIT_MASKS[1] { 231 | Some(false) 232 | } else { 233 | None 234 | }; 235 | 236 | is_straight.then_some(StraightTestReport { 237 | high_ace, 238 | scored_ranks: ranks, 239 | }) 240 | } 241 | 242 | /// Returns [`ScoringHand`] for played cards. 243 | #[expect( 244 | clippy::indexing_slicing, 245 | reason = "Refactor: Current implementation guarantees index accesses are safe, but this can be refactored." 246 | )] 247 | pub fn get_scoring_hand( 248 | cards: &[Card], 249 | ) -> Result<(Option, Vec), ScorerError> { 250 | let sorted_cards = cards.sorted_by_rank(); 251 | let suit_groups = sorted_cards.grouped_by_suit()?; 252 | let rank_groups = sorted_cards.grouped_by_rank()?; 253 | let straight_test_result = Self::test_straight(&sorted_cards); 254 | 255 | if suit_groups.is_empty() || rank_groups.is_empty() { 256 | return Ok((None, vec![])); 257 | } 258 | 259 | if suit_groups[0].1 == 5 && rank_groups[0].1 == 5 { 260 | return Ok((Some(ScoringHand::FlushFive), vec![ 261 | rank_groups[0].0; 262 | rank_groups[0].1 263 | ])); 264 | } 265 | 266 | if rank_groups.len() >= 2 267 | && suit_groups[0].1 == 5 268 | && rank_groups[0].1 == 3 269 | && rank_groups[1].1 == 2 270 | { 271 | let mut played_ranks = vec![]; 272 | played_ranks.append(&mut vec![rank_groups[0].0; rank_groups[0].1]); 273 | played_ranks.append(&mut vec![rank_groups[1].0; rank_groups[1].1]); 274 | return Ok((Some(ScoringHand::FlushHouse), played_ranks)); 275 | } 276 | 277 | if rank_groups[0].1 == 5 { 278 | return Ok((Some(ScoringHand::FiveOfAKind), vec![ 279 | rank_groups[0].0; 280 | rank_groups[0].1 281 | ])); 282 | } 283 | 284 | if suit_groups[0].1 == 5 { 285 | if let Some(result) = straight_test_result { 286 | if result.high_ace.unwrap_or(false) { 287 | return Ok((Some(ScoringHand::RoyalFlush), result.scored_ranks)); 288 | } 289 | 290 | return Ok((Some(ScoringHand::StraightFlush), result.scored_ranks)); 291 | } 292 | } 293 | 294 | if rank_groups[0].1 == 4 { 295 | return Ok((Some(ScoringHand::FourOfAKind), vec![ 296 | rank_groups[0].0; 297 | rank_groups[0].1 298 | ])); 299 | } 300 | 301 | if rank_groups.len() >= 2 && rank_groups[0].1 == 3 && rank_groups[1].1 == 2 { 302 | let mut played_ranks = vec![]; 303 | played_ranks.append(&mut vec![rank_groups[0].0; rank_groups[0].1]); 304 | played_ranks.append(&mut vec![rank_groups[1].0; rank_groups[1].1]); 305 | return Ok((Some(ScoringHand::FullHouse), played_ranks)); 306 | } 307 | 308 | if suit_groups[0].1 == 5 { 309 | return Ok(( 310 | Some(ScoringHand::Flush), 311 | cards.iter().map(|card| card.rank).collect(), 312 | )); 313 | } 314 | 315 | if let Some(result) = straight_test_result { 316 | return Ok((Some(ScoringHand::Straight), result.scored_ranks)); 317 | } 318 | 319 | if rank_groups[0].1 == 3 { 320 | return Ok((Some(ScoringHand::ThreeOfAKind), vec![ 321 | rank_groups[0].0; 322 | rank_groups[0].1 323 | ])); 324 | } 325 | 326 | if rank_groups.len() >= 2 && rank_groups[0].1 == 2 && rank_groups[1].1 == 2 { 327 | let mut played_ranks = vec![]; 328 | played_ranks.append(&mut vec![rank_groups[0].0; rank_groups[0].1]); 329 | played_ranks.append(&mut vec![rank_groups[1].0; rank_groups[1].1]); 330 | return Ok((Some(ScoringHand::TwoPair), played_ranks)); 331 | } 332 | 333 | if rank_groups[0].1 == 2 { 334 | return Ok((Some(ScoringHand::Pair), vec![ 335 | rank_groups[0].0; 336 | rank_groups[0].1 337 | ])); 338 | } 339 | 340 | Ok((Some(ScoringHand::HighCard), vec![ 341 | rank_groups[0].0; 342 | rank_groups[0].1 343 | ])) 344 | } 345 | 346 | /// Score played cards and return the computed score. 347 | pub fn score_cards(cards: &[Card]) -> Result { 348 | let (scoring_hand, scored_ranks) = Self::get_scoring_hand(cards)?; 349 | let (base_chips, multiplier) = 350 | Self::get_chips_and_multiplier(scoring_hand.ok_or(ScorerError::EmptyHandScoredError)?)?; 351 | let chips_increment = Self::score_chips_from_ranks(&scored_ranks)?; 352 | Ok((base_chips 353 | .checked_add(chips_increment) 354 | .ok_or(ArithmeticError::Overflow("addition"))?) 355 | .checked_mul(multiplier) 356 | .ok_or(ArithmeticError::Overflow("multiplication"))?) 357 | } 358 | 359 | /// Return total score from [`Rank`] from cards. 360 | #[inline] 361 | fn score_chips_from_ranks(ranks: &[Rank]) -> Result { 362 | ranks.iter().try_fold(0, |acc, rank| { 363 | let score = rank.get_score()?; 364 | Ok::( 365 | score 366 | .checked_add(acc) 367 | .ok_or(ArithmeticError::Overflow("addition"))?, 368 | ) 369 | }) 370 | } 371 | } 372 | 373 | #[cfg(test)] 374 | mod tests { 375 | use super::*; 376 | use crate::card::Suit; 377 | 378 | #[test] 379 | fn score_flush_five() { 380 | let test_cards = [ 381 | Card { 382 | rank: Rank::Ten, 383 | suit: Suit::Club, 384 | }, 385 | Card { 386 | rank: Rank::Ten, 387 | suit: Suit::Club, 388 | }, 389 | Card { 390 | rank: Rank::Ten, 391 | suit: Suit::Club, 392 | }, 393 | Card { 394 | rank: Rank::Ten, 395 | suit: Suit::Club, 396 | }, 397 | Card { 398 | rank: Rank::Ten, 399 | suit: Suit::Club, 400 | }, 401 | ]; 402 | 403 | assert_eq!( 404 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 405 | ScoringHand::FlushFive 406 | ); 407 | } 408 | 409 | #[test] 410 | fn score_flush_house() { 411 | let test_cards = vec![ 412 | Card { 413 | rank: Rank::Eight, 414 | suit: Suit::Club, 415 | }, 416 | Card { 417 | rank: Rank::Eight, 418 | suit: Suit::Club, 419 | }, 420 | Card { 421 | rank: Rank::Eight, 422 | suit: Suit::Club, 423 | }, 424 | Card { 425 | rank: Rank::Three, 426 | suit: Suit::Club, 427 | }, 428 | Card { 429 | rank: Rank::Three, 430 | suit: Suit::Club, 431 | }, 432 | ]; 433 | 434 | assert_eq!( 435 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 436 | ScoringHand::FlushHouse 437 | ); 438 | } 439 | 440 | #[test] 441 | fn score_five_of_a_kind() { 442 | let test_cards = vec![ 443 | Card { 444 | rank: Rank::Ten, 445 | suit: Suit::Club, 446 | }, 447 | Card { 448 | rank: Rank::Ten, 449 | suit: Suit::Heart, 450 | }, 451 | Card { 452 | rank: Rank::Ten, 453 | suit: Suit::Diamond, 454 | }, 455 | Card { 456 | rank: Rank::Ten, 457 | suit: Suit::Spade, 458 | }, 459 | Card { 460 | rank: Rank::Ten, 461 | suit: Suit::Club, 462 | }, 463 | ]; 464 | 465 | assert_eq!( 466 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 467 | ScoringHand::FiveOfAKind 468 | ); 469 | } 470 | 471 | #[test] 472 | fn score_royal_flush() { 473 | let test_cards = vec![ 474 | Card { 475 | rank: Rank::Queen, 476 | suit: Suit::Club, 477 | }, 478 | Card { 479 | rank: Rank::Ten, 480 | suit: Suit::Club, 481 | }, 482 | Card { 483 | rank: Rank::Ace, 484 | suit: Suit::Club, 485 | }, 486 | Card { 487 | rank: Rank::Jack, 488 | suit: Suit::Club, 489 | }, 490 | Card { 491 | rank: Rank::King, 492 | suit: Suit::Club, 493 | }, 494 | ]; 495 | 496 | assert_eq!( 497 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 498 | ScoringHand::RoyalFlush 499 | ); 500 | } 501 | 502 | #[test] 503 | fn score_straight_flush() { 504 | let test_cards = vec![ 505 | Card { 506 | rank: Rank::Eight, 507 | suit: Suit::Club, 508 | }, 509 | Card { 510 | rank: Rank::Five, 511 | suit: Suit::Club, 512 | }, 513 | Card { 514 | rank: Rank::Four, 515 | suit: Suit::Club, 516 | }, 517 | Card { 518 | rank: Rank::Six, 519 | suit: Suit::Club, 520 | }, 521 | Card { 522 | rank: Rank::Seven, 523 | suit: Suit::Club, 524 | }, 525 | ]; 526 | 527 | assert_eq!( 528 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 529 | ScoringHand::StraightFlush 530 | ); 531 | } 532 | 533 | #[test] 534 | fn score_four_of_a_kind() { 535 | let test_cards = vec![ 536 | Card { 537 | rank: Rank::Seven, 538 | suit: Suit::Club, 539 | }, 540 | Card { 541 | rank: Rank::Seven, 542 | suit: Suit::Heart, 543 | }, 544 | Card { 545 | rank: Rank::Seven, 546 | suit: Suit::Diamond, 547 | }, 548 | Card { 549 | rank: Rank::Seven, 550 | suit: Suit::Spade, 551 | }, 552 | Card { 553 | rank: Rank::Three, 554 | suit: Suit::Club, 555 | }, 556 | ]; 557 | 558 | assert_eq!( 559 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 560 | ScoringHand::FourOfAKind 561 | ); 562 | } 563 | 564 | #[test] 565 | fn score_full_house() { 566 | let test_cards = vec![ 567 | Card { 568 | rank: Rank::Eight, 569 | suit: Suit::Club, 570 | }, 571 | Card { 572 | rank: Rank::Eight, 573 | suit: Suit::Club, 574 | }, 575 | Card { 576 | rank: Rank::Eight, 577 | suit: Suit::Club, 578 | }, 579 | Card { 580 | rank: Rank::Three, 581 | suit: Suit::Diamond, 582 | }, 583 | Card { 584 | rank: Rank::Three, 585 | suit: Suit::Diamond, 586 | }, 587 | ]; 588 | 589 | assert_eq!( 590 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 591 | ScoringHand::FullHouse 592 | ); 593 | } 594 | 595 | #[test] 596 | fn score_flush() { 597 | let test_cards = vec![ 598 | Card { 599 | rank: Rank::Eight, 600 | suit: Suit::Club, 601 | }, 602 | Card { 603 | rank: Rank::Five, 604 | suit: Suit::Club, 605 | }, 606 | Card { 607 | rank: Rank::Jack, 608 | suit: Suit::Club, 609 | }, 610 | Card { 611 | rank: Rank::Seven, 612 | suit: Suit::Club, 613 | }, 614 | Card { 615 | rank: Rank::Three, 616 | suit: Suit::Club, 617 | }, 618 | ]; 619 | 620 | assert_eq!( 621 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 622 | ScoringHand::Flush 623 | ); 624 | } 625 | 626 | #[test] 627 | fn score_non_ace_straight() { 628 | let test_cards = vec![ 629 | Card { 630 | rank: Rank::Eight, 631 | suit: Suit::Diamond, 632 | }, 633 | Card { 634 | rank: Rank::Five, 635 | suit: Suit::Club, 636 | }, 637 | Card { 638 | rank: Rank::Four, 639 | suit: Suit::Spade, 640 | }, 641 | Card { 642 | rank: Rank::Six, 643 | suit: Suit::Heart, 644 | }, 645 | Card { 646 | rank: Rank::Seven, 647 | suit: Suit::Club, 648 | }, 649 | ]; 650 | 651 | assert_eq!( 652 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 653 | ScoringHand::Straight 654 | ); 655 | } 656 | 657 | #[test] 658 | fn score_low_ace_straight() { 659 | let test_cards = vec![ 660 | Card { 661 | rank: Rank::Four, 662 | suit: Suit::Diamond, 663 | }, 664 | Card { 665 | rank: Rank::Three, 666 | suit: Suit::Club, 667 | }, 668 | Card { 669 | rank: Rank::Ace, 670 | suit: Suit::Spade, 671 | }, 672 | Card { 673 | rank: Rank::Two, 674 | suit: Suit::Heart, 675 | }, 676 | Card { 677 | rank: Rank::Five, 678 | suit: Suit::Club, 679 | }, 680 | ]; 681 | 682 | assert_eq!( 683 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 684 | ScoringHand::Straight 685 | ); 686 | } 687 | 688 | #[test] 689 | fn score_high_ace_straight() { 690 | let test_cards = vec![ 691 | Card { 692 | rank: Rank::Ten, 693 | suit: Suit::Diamond, 694 | }, 695 | Card { 696 | rank: Rank::Queen, 697 | suit: Suit::Club, 698 | }, 699 | Card { 700 | rank: Rank::Ace, 701 | suit: Suit::Spade, 702 | }, 703 | Card { 704 | rank: Rank::King, 705 | suit: Suit::Heart, 706 | }, 707 | Card { 708 | rank: Rank::Jack, 709 | suit: Suit::Club, 710 | }, 711 | ]; 712 | 713 | assert_eq!( 714 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 715 | ScoringHand::Straight 716 | ); 717 | } 718 | 719 | #[test] 720 | fn score_mid_ace_straight_false_positive() { 721 | let test_cards = vec![ 722 | Card { 723 | rank: Rank::Two, 724 | suit: Suit::Diamond, 725 | }, 726 | Card { 727 | rank: Rank::Ace, 728 | suit: Suit::Club, 729 | }, 730 | Card { 731 | rank: Rank::Three, 732 | suit: Suit::Spade, 733 | }, 734 | Card { 735 | rank: Rank::King, 736 | suit: Suit::Heart, 737 | }, 738 | Card { 739 | rank: Rank::Queen, 740 | suit: Suit::Club, 741 | }, 742 | ]; 743 | 744 | assert_eq!( 745 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 746 | ScoringHand::HighCard 747 | ); 748 | } 749 | 750 | #[test] 751 | fn score_three_of_a_kind() { 752 | let test_cards = vec![ 753 | Card { 754 | rank: Rank::Eight, 755 | suit: Suit::Club, 756 | }, 757 | Card { 758 | rank: Rank::Eight, 759 | suit: Suit::Diamond, 760 | }, 761 | Card { 762 | rank: Rank::Eight, 763 | suit: Suit::Heart, 764 | }, 765 | Card { 766 | rank: Rank::Six, 767 | suit: Suit::Spade, 768 | }, 769 | Card { 770 | rank: Rank::Three, 771 | suit: Suit::Diamond, 772 | }, 773 | ]; 774 | 775 | assert_eq!( 776 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 777 | ScoringHand::ThreeOfAKind 778 | ); 779 | } 780 | 781 | #[test] 782 | fn score_two_pair() { 783 | let test_cards = vec![ 784 | Card { 785 | rank: Rank::Eight, 786 | suit: Suit::Club, 787 | }, 788 | Card { 789 | rank: Rank::Eight, 790 | suit: Suit::Diamond, 791 | }, 792 | Card { 793 | rank: Rank::Six, 794 | suit: Suit::Heart, 795 | }, 796 | Card { 797 | rank: Rank::Six, 798 | suit: Suit::Spade, 799 | }, 800 | Card { 801 | rank: Rank::Three, 802 | suit: Suit::Diamond, 803 | }, 804 | ]; 805 | 806 | assert_eq!( 807 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 808 | ScoringHand::TwoPair 809 | ); 810 | } 811 | 812 | #[test] 813 | fn score_pair() { 814 | let test_cards = vec![ 815 | Card { 816 | rank: Rank::Eight, 817 | suit: Suit::Club, 818 | }, 819 | Card { 820 | rank: Rank::Eight, 821 | suit: Suit::Diamond, 822 | }, 823 | Card { 824 | rank: Rank::Seven, 825 | suit: Suit::Heart, 826 | }, 827 | Card { 828 | rank: Rank::Six, 829 | suit: Suit::Spade, 830 | }, 831 | Card { 832 | rank: Rank::Three, 833 | suit: Suit::Diamond, 834 | }, 835 | ]; 836 | 837 | assert_eq!( 838 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 839 | ScoringHand::Pair 840 | ); 841 | } 842 | 843 | #[test] 844 | fn score_high_card() { 845 | let test_cards = vec![ 846 | Card { 847 | rank: Rank::Jack, 848 | suit: Suit::Club, 849 | }, 850 | Card { 851 | rank: Rank::Eight, 852 | suit: Suit::Diamond, 853 | }, 854 | Card { 855 | rank: Rank::Seven, 856 | suit: Suit::Heart, 857 | }, 858 | Card { 859 | rank: Rank::Six, 860 | suit: Suit::Spade, 861 | }, 862 | Card { 863 | rank: Rank::Three, 864 | suit: Suit::Diamond, 865 | }, 866 | ]; 867 | 868 | assert_eq!( 869 | Scorer::get_scoring_hand(&test_cards).unwrap().0.unwrap(), 870 | ScoringHand::HighCard 871 | ); 872 | } 873 | } 874 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . --------------------------------------------------------------------------------