From efc981b829eaa1bbcff30d94b78c3d3d4d32e77b Mon Sep 17 00:00:00 2001 From: Robin Candau Date: Thu, 5 Nov 2026 22:45:38 +0100 Subject: [PATCH] feat: Implement game mechanics - Game mechanics: - Handle game_state updates - Add compilation loop - Handle credits reward on successful compilation - Handle chances of compilation failures - Add CI/CD system perk handling - Add global parameters definition - Add global economy definition - Add upgrade menu - Add manage menu - Add Auto-Scaling perk handling - QOL features: - Add usual keyboard shortcuts handling for the game prompt via the `rustyline` crate (arrow keys to navigate through commands history, Ctrl+D to quit / disconnect, etc...) - Add Ctrl+C interruption handler to interrupt compilations and quite submenus via the `ctrlc` crate - Show compilation parameters during status and compile - Add auto-completion support in the game prompts - Use separate prompt and command history for upgrade and manage menus - Add prompt coloring - Miscellaneous: - Rename `menu` module to `prompt` - Split compilation mechanic into its own module - Improve error handling - Always round credits to 2 decimals - Various refactoring --- Cargo.lock | 228 +++++++++++++++++++++++++++++++++++ Cargo.toml | 3 + README.md | 28 ++++- doc/man/incrust.1.scd | 8 +- src/command.rs | 114 +++++++++++++----- src/compile.rs | 127 ++++++++++++++++++++ src/economy.rs | 42 +++++++ src/main.rs | 83 +++++++++++-- src/manage.rs | 114 ++++++++++++++++++ src/menu.rs | 22 ---- src/parameters.rs | 71 +++++++++++ src/prompt.rs | 111 +++++++++++++++++ src/splash.rs | 22 ++-- src/statefile.rs | 19 ++- src/upgrade.rs | 272 ++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 1181 insertions(+), 83 deletions(-) create mode 100644 src/compile.rs create mode 100644 src/economy.rs create mode 100644 src/manage.rs delete mode 100644 src/menu.rs create mode 100644 src/parameters.rs create mode 100644 src/prompt.rs create mode 100644 src/upgrade.rs diff --git a/Cargo.lock b/Cargo.lock index 9877ac1..892663e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,21 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bstr" version = "1.13.1" @@ -99,6 +114,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clap" version = "4.6.7" @@ -139,6 +177,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -154,6 +201,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys", +] + [[package]] name = "difflib" version = "0.4.0" @@ -181,6 +248,30 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "float-cmp" version = "0.10.0" @@ -200,12 +291,33 @@ dependencies = [ "winapi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys", +] + [[package]] name = "incrust" version = "0.0.1" @@ -214,9 +326,12 @@ dependencies = [ "assert_cmd", "clap", "colored", + "ctrlc", "dirs", "fs2", "predicates", + "rand", + "rustyline", "serde", "serde_json", ] @@ -248,12 +363,39 @@ dependencies = [ "libc", ] +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -269,6 +411,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -329,6 +486,39 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_users" version = "0.5.3" @@ -368,6 +558,26 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustyline" +version = "19.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e1d6bd81c2d5feafd2419138df9183c45a10a492c4e873b8b0acb8eb2b549e" +dependencies = [ + "bitflags", + "clipboard-win", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys", +] + [[package]] name = "serde" version = "1.0.229" @@ -411,6 +621,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + [[package]] name = "strsim" version = "0.11.1" @@ -460,6 +676,18 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index d1e2f70..053645e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,11 @@ license = "GPL-3.0-or-later" anyhow = "1.0.104" clap = { version = "4.6.7", features = ["derive"] } colored = "3.1.1" +ctrlc = "3.5.2" dirs = "7.0.0" fs2 = "0.4.3" +rand = "0.10.3" +rustyline = "19.0.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" diff --git a/README.md b/README.md index b19745e..812379d 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,33 @@ There are also shell completions available in the [`res/completions/`](https://g ## Usage Run `incrust` to start the game. -Then type 'help' to see the list of available commands. +Then type 'help' to see the list of available commands and 'guide' to see the game guide (shown below). + +```text +Start compiling stuff by running the 'compile' command. +You'll earn credits for each completed (and successful) compilation. +Compilations have a small chance to fail upon completion, in which case +you won't get rewarded with credits for it. +Ongoing compilation can be interrupted by pressing Ctrl+C, but note that +you won't get any credit for it either. + +Spend credits to upgrade your system resources and buy different perks by running +the 'upgrade' command: + +- Buying CPU cores speeds up compile time + but also slightly increases chances of compilation failures +- Buying RAM increases the credit reward per completed (and successful) compilation + but also slightly increases the compile time +- Buying Disk space reduces chances of compilation failures + but also slightly reduces the credit reward per completed (and successful) compilation +- Other bonuses can be acquired through different one-time purchase perks + +Unlocked perks can be enabled or disabled by running the 'manage' command. + +You can check the current status of your resources and perks by running the 'status' command. + +Progression is automatically saved and resumed between game sessions. +``` ## Documentation diff --git a/doc/man/incrust.1.scd b/doc/man/incrust.1.scd index 0134264..08c51eb 100644 --- a/doc/man/incrust.1.scd +++ b/doc/man/incrust.1.scd @@ -28,9 +28,13 @@ Then type 'help' to see the list of available commands and 'guide' to see the ga *1* Failed to create or acquire the lockfile. -*2* Failed to create or read the statefile. +*2* Failed to create, read or write the statefile. -*3* Failed to read the player's command. +*3* Failed to initialize the command prompt editor. + +*4* Failed to register the interruption shortcut (Ctrl+C) handler for compilation. + +*5* Failed to retrieve the player's command. # BUGS diff --git a/src/command.rs b/src/command.rs index 28e42a1..e20d8f8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,61 +1,110 @@ //! Execute game commands use colored::Colorize; -use std::{thread, time::Duration}; - +use std::sync::mpsc::Receiver; +use std::thread; +use std::time::Duration; + +use crate::compile; +use crate::manage; +use crate::parameters; +use crate::prompt::PromptEditor; use crate::statefile::GameState; +use crate::upgrade; + +pub fn compile(game_state: &mut GameState, interrupt_rx: &Receiver<()>) -> anyhow::Result<()> { + // Discard pending interruption signals + while interrupt_rx.try_recv().is_ok() {} + + // Start the fake compile process and loop over it + // Break the loop if it gets interrupted or if the CI/CD perk is not enabled + loop { + let interrupted = compile::compile_proc(game_state, interrupt_rx)?; + + if interrupted || !game_state.ci_cd_enabled { + break; + } + println!(); + } -pub fn compile() { - println!("Compiling..."); + Ok(()) } -pub fn upgrade() { - println!("Upgrading..."); +pub fn upgrade( + game_state: &mut GameState, + submenu_editor: &mut PromptEditor, +) -> anyhow::Result<()> { + upgrade::upgrade_shop(game_state, submenu_editor)?; + Ok(()) +} + +pub fn manage(game_state: &mut GameState, submenu_editor: &mut PromptEditor) -> anyhow::Result<()> { + manage::manage_menu(game_state, submenu_editor)?; + Ok(()) } pub fn status(game_state: &GameState) { - println!("SYSTEM RESOURCES"); + println!("[RESOURCES]"); println!(); - println!("CPU {} CORE(S)", game_state.cpu); - println!("RAM {} GB", game_state.ram); - println!("DISK {} GB", game_state.disk); - println!("CREDITS {}", game_state.credits); + println!("CPU {} CORE(S)", game_state.cpu); + println!("RAM {} GB", game_state.ram); + println!("DISK {} GB", game_state.disk); + println!("CREDITS {:.2}", game_state.credits); println!(); - println!("SYSTEM PERKS"); + println!("[PERKS]"); println!(); println!( - "CI/CD {}", - if game_state.ci_cd { "TRUE" } else { "FALSE" } + "CI/CD {}", + if !game_state.ci_cd_unlocked { + "LOCKED" + } else if game_state.ci_cd_enabled { + "ENABLED" + } else { + "DISABLED" + } + ); + println!( + "Auto-Scaling {}", + if !game_state.auto_scaling_unlocked { + "LOCKED" + } else if game_state.auto_scaling_enabled { + "ENABLED" + } else { + "DISABLED" + } ); + println!(); + println!("[COMPILATION]"); + println!(); + parameters::show_compilation_parameters(game_state); } pub fn guide() { println!("Start compiling stuff by running the 'compile' command."); println!("You'll earn credits for each completed (and successful) compilation."); - println!("Compilations have a small chance to fail upon completion due to eventual"); - println!("lack of free space, in which case you won't be rewarded with credits for it."); - println!("You can interrupt an ongoing compilation before the end by pressing any key,"); - println!("but note that you won't get any credit for it."); + println!("Compilations have a small chance to fail upon completion, in which case"); + println!("you won't get rewarded with credits for it."); + println!("Ongoing compilation can be interrupted by pressing Ctrl+C, but note that"); + println!("you won't get any credit for it either."); println!(); - println!("Spend credits to upgrade your system in order to optimize compilation time,"); - println!( - "reduce chances of compilation failures and increase credits rate (as well as other bonuses)" - ); - println!("by running the 'upgrade' command:"); + println!("Spend credits to upgrade your system resources and buy different perks by running"); + println!("the 'upgrade' command:"); println!(); - println!("- Buying CPU cores speeds up compilation time"); + println!("- Buying CPU cores speeds up compile time"); + println!(" but also slightly increases chances of compilation failures"); + println!("- Buying RAM increases the credit reward per completed (and successful) compilation"); + println!(" but also slightly increases the compile time"); + println!("- Buying Disk space reduces chances of compilation failures"); println!( - "- Buying RAM increases the number of credits earned per completed (and successful) compilation" + " but also slightly reduces the credit reward per completed (and successful) compilation" ); - println!( - "- Buying Disk space reduces chances of compilation failures due to lack of free space" - ); - println!("- Other bonuses can be acquired through different one-time purchase system perks"); + println!("- Other bonuses can be acquired through different one-time purchase perks"); + println!(); + println!("Unlocked perks can be enabled or disabled by running the 'manage' command."); println!(); println!( - "You can check the current status of your system resources, system perks and credits by running" + "You can check the current status of your resources and perks by running the 'status' command." ); - println!("the 'status' command."); println!(); println!("Progression is automatically saved and resumed between game sessions."); } @@ -65,7 +114,8 @@ pub fn help() { println!(); println!("compile <== Start compiling to earn credits"); println!("upgrade <== Upgrade the system to speed up compilation or earn more credits"); - println!("status <== Check the current status of your system resources and credits"); + println!("manage <== Enable or disable unlocked perks"); + println!("status <== Check the current status of your resources and perks"); println!("guide <== Show the game guide"); println!("help <== Show this message"); println!("exit <== Save and exit the game"); diff --git a/src/compile.rs b/src/compile.rs new file mode 100644 index 0000000..f012228 --- /dev/null +++ b/src/compile.rs @@ -0,0 +1,127 @@ +//! Start the fake compilation process + +use anyhow::Context; +use colored::Colorize; +use rand::random; +use std::io::{self, Write}; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::parameters; +use crate::statefile::{self, GameState}; +use crate::upgrade; + +pub fn compile_proc( + game_state: &mut GameState, + interrupt_rx: &Receiver<()>, +) -> anyhow::Result { + // Set timestamps to calculate compilation remaining time + let start = Instant::now(); + let mut last_remaining = u64::MAX; + + // Silly messages + println!( + "{} Synchronizing imaginary dependencies...", + "[ OK ]".green() + ); + thread::sleep(Duration::from_millis(125)); + + println!( + "{} Flushing the cache of past build failures...", + "[ OK ]".green() + ); + thread::sleep(Duration::from_millis(125)); + + println!("{} Starting compilation...", "[ OK ]".green()); + thread::sleep(Duration::from_millis(125)); + + println!(); + + // Run auto-scaling if enabled + upgrade::auto_scale(game_state)?; + + // Get and show compilation parameters + let (compile_time, credit_reward, failure_probability) = + parameters::show_compilation_parameters(game_state); + println!(); + + // Convert compile time to a Duration (used later to calculate remaining time) + let compile_time = Duration::from_secs_f64(compile_time); + + // Main compilation loop + loop { + // Check for interruption request via Ctrl+C + match interrupt_rx.try_recv() { + Ok(()) => { + println!(); + println!( + "\n{} Compilation interrupted, no credits earned...", + "[INFO]".blue() + ); + return Ok(true); + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => { + eprintln!("\nWarning: Interruption handler disconnected."); + return Ok(true); + } + } + + // Calculate remaining time + let remaining = compile_time.saturating_sub(start.elapsed()); + + // Check whether compilation is complete + if remaining.is_zero() { + break; + } + + // Display countdown + let remaining_secs = remaining.as_secs_f64().ceil() as u64; + + if remaining_secs != last_remaining { + let minutes = remaining_secs / 60; + let seconds = remaining_secs % 60; + + print!("\rCompiling... {minutes:02}:{seconds:02} remaining "); + let _ = io::stdout().flush(); + + last_remaining = remaining_secs; + } + + // Wait before checking again + thread::sleep(Duration::from_millis(100)); + } + + // Compilation completion message + println!("\rCompilation completed! "); + println!(); + + // Roll for compilation failure + if random::() < failure_probability { + // Failure message + println!( + "{} Build failed: insufficient disk space...", + "[FAIL]".red() + ); + thread::sleep(Duration::from_millis(125)); + + println!( + "{} Cleaning up old build chroots to free up space for next attempts...", + "[INFO]".blue() + ); + } else { + // Award credits and save state + game_state.credits += credit_reward; + game_state.credits = (game_state.credits * 100.0).round() / 100.0; + statefile::save(game_state).context("Failed to save game")?; + + // Success message + println!("{} Compilation successful!", "[INFO]".blue()); + thread::sleep(Duration::from_millis(125)); + + println!("{} You earned {credit_reward} credits!", "[INFO]".blue()); + } + + Ok(false) +} diff --git a/src/economy.rs b/src/economy.rs new file mode 100644 index 0000000..f3a777c --- /dev/null +++ b/src/economy.rs @@ -0,0 +1,42 @@ +//! General economy definition + +// Base price and price multiplier for CPU +const CPU_BASE_PRICE: f64 = 1.0; +const CPU_PRICE_MULTIPLIER: f64 = 1.5; + +// Base price and price multiplier for RAM +const RAM_BASE_PRICE: f64 = 1.0; +const RAM_PRICE_MULTIPLIER: f64 = 1.75; + +// Base price and price multiplier for Disk +const DISK_BASE_PRICE: f64 = 1.0; +const DISK_PRICE_MULTIPLIER: f64 = 1.625; + +// Perks price +pub const CI_CD_PRICE: f64 = 10.0; +pub const AUTO_SCALING_PRICE: f64 = 50.0; + +// Helper to round values to the nearest 0.05 +fn round_value(value: f64) -> f64 { + (value * 20.0).round() / 20.0 +} + +// Calculate resource upgrade price +pub fn upgrade_price(level: u64, base_price: f64, multiplier: f64) -> f64 { + round_value(base_price * multiplier.powf(level.saturating_sub(1) as f64)) +} + +// Calculate CPU price +pub fn cpu_price(level: u64) -> f64 { + upgrade_price(level, CPU_BASE_PRICE, CPU_PRICE_MULTIPLIER) +} + +// Calculate RAM price +pub fn ram_price(level: u64) -> f64 { + upgrade_price(level, RAM_BASE_PRICE, RAM_PRICE_MULTIPLIER) +} + +// Calculate Disk price +pub fn disk_price(level: u64) -> f64 { + upgrade_price(level, DISK_BASE_PRICE, DISK_PRICE_MULTIPLIER) +} diff --git a/src/main.rs b/src/main.rs index a153121..adbf173 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,15 +2,24 @@ use clap::Parser; use std::process; +use std::sync::mpsc; mod command; +mod compile; +mod economy; mod help; mod lockfile; -mod menu; +mod manage; +mod parameters; +mod prompt; mod splash; mod statefile; +mod upgrade; mod version; +use crate::prompt::MAIN_COMMANDS; +use crate::prompt::SUBMENU_COMMANDS; + // Arguments definition #[derive(Parser)] #[command(disable_help_flag = true, disable_version_flag = true)] @@ -47,8 +56,8 @@ fn main() { }); // Initialize / update statefile and load state value - // Exit on error - let game_state = statefile::init_statefile().unwrap_or_else(|error| { + // Exit if there was an issue when creating, reading, deserializing or writing the statefile + let mut game_state = statefile::init_statefile().unwrap_or_else(|error| { eprintln!("{error:?}"); process::exit(2); }); @@ -56,12 +65,72 @@ fn main() { // Show splash screen splash::splash_screen(&game_state); + // Initialize rustyline editors + // Used to handle usual keyboard shortcuts within the game prompt + // like arrow keys to navigate through commands history and move the cursor, + // Ctrl+D to quit / disconnect, etc... + // We have one editor dedicated to the main game prompt and one for the submenus prompt, + // so they get a separate PS1 and command history + // Exit if there's an error when initializing the editor + let mut main_editor = + prompt::create_rl_editor(MAIN_COMMANDS.to_vec()).unwrap_or_else(|error| { + eprintln!("{error:?}"); + process::exit(3); + }); + + let mut submenu_editor = + prompt::create_rl_editor(SUBMENU_COMMANDS.to_vec()).unwrap_or_else(|error| { + eprintln!("{error:?}"); + process::exit(3); + }); + + // Create interruption channel and register the Ctrl+C handler + // Used to allow interrupting ongoing compilations by pressing Ctrl+C + let (interrupt_tx, interrupt_rx) = mpsc::channel(); + + ctrlc::set_handler(move || { + interrupt_tx.send(()).unwrap_or_else(|error| { + eprintln!("Failed to send interruption signal:\n{error}"); + process::exit(4); + }) + }) + .unwrap_or_else(|error| { + eprintln!("Failed to register Ctrl+C handler:\n{error}"); + process::exit(4); + }); + // Main game loop loop { - // Show game menu and execute command - match menu::show_menu().trim() { - "compile" => command::compile(), - "upgrade" => command::upgrade(), + // Show game prompt and retrieve command + // Exit if there was an issue when reading stdin + let command = prompt::show_prompt(&mut main_editor, "incrust").unwrap_or_else(|error| { + eprintln!("{error:?}"); + process::exit(5); + }); + + // Allow empty command (handled below, just re-print the prompt) + let Some(command) = command else { + continue; + }; + + // Execute command + match command.trim() { + "compile" => command::compile(&mut game_state, &interrupt_rx).unwrap_or_else(|error| { + eprintln!("{error:?}"); + process::exit(2); + }), + "upgrade" => { + command::upgrade(&mut game_state, &mut submenu_editor).unwrap_or_else(|error| { + eprintln!("{error:?}"); + process::exit(2); + }) + } + "manage" => { + command::manage(&mut game_state, &mut submenu_editor).unwrap_or_else(|error| { + eprintln!("{error:?}"); + process::exit(2); + }) + } "status" => command::status(&game_state), "guide" => command::guide(), "help" => command::help(), diff --git a/src/manage.rs b/src/manage.rs new file mode 100644 index 0000000..4aa93af --- /dev/null +++ b/src/manage.rs @@ -0,0 +1,114 @@ +//! Manage menu to enable or disable unlocked perks + +use anyhow::Context; +use std::thread; +use std::time::Duration; + +use crate::prompt::{self, PromptEditor}; +use crate::statefile::{self, GameState}; + +// Define ID for each perks toggling option +// Perks +const CI_CD_ID: &str = "1"; +const AUTO_SCALING_ID: &str = "2"; + +// Manage menu +pub fn manage_menu( + game_state: &mut GameState, + submenu_editor: &mut PromptEditor, +) -> anyhow::Result<()> { + loop { + // Manage menu title + println!("[MANAGE]"); + println!(); + + // Perks section + println!("Perks:"); + + // CI/CD + println!( + "{CI_CD_ID} - CI/CD {}", + if !game_state.ci_cd_unlocked { + "LOCKED" + } else if game_state.ci_cd_enabled { + "ENABLED" + } else { + "DISABLED" + } + ); + + // Auto-Scaling + println!( + "{AUTO_SCALING_ID} - Auto-Scaling {}", + if !game_state.auto_scaling_unlocked { + "LOCKED" + } else if game_state.auto_scaling_enabled { + "ENABLED" + } else { + "DISABLED" + } + ); + + println!(); + + // User guide + println!("Select the number associated to an unlocked perk to enable or disable it."); + println!("Type \"exit\" or press \"Ctrl+C\" to exit the manage menu."); + + // Show manage menu prompt and retrieve user selection + let selection = prompt::show_prompt(submenu_editor, "manage-menu")?; + + // Exit on Ctrl+C + let Some(selection) = selection else { + return Ok(()); + }; + + // Execute user selection + match selection.trim() { + // Perks + CI_CD_ID => toggle_perk(game_state, CI_CD_ID, "CI/CD")?, + AUTO_SCALING_ID => toggle_perk(game_state, AUTO_SCALING_ID, "Auto-Scaling")?, + + // Others + "exit" => return Ok(()), + _ => println!("Invalid selection"), + } + + // Formatting and little delay for better UI + thread::sleep(Duration::from_millis(750)); + println!(); + println!("-----------------------------------"); + println!(); + } +} + +// Helper to toggle perks +fn toggle_perk(game_state: &mut GameState, perk_id: &str, perk_name: &str) -> anyhow::Result<()> { + match perk_id { + CI_CD_ID => { + if !game_state.ci_cd_unlocked { + println!("{perk_name} is not unlocked!"); + return Ok(()); + } + + game_state.ci_cd_enabled = !game_state.ci_cd_enabled; + } + + AUTO_SCALING_ID => { + if !game_state.auto_scaling_unlocked { + println!("{perk_name} is not unlocked!"); + return Ok(()); + } + + game_state.auto_scaling_enabled = !game_state.auto_scaling_enabled; + } + + _ => return Ok(()), + } + + statefile::save(game_state).context("Failed to save game")?; + + println!("{perk_name} status successfully updated!"); + + Ok(()) +} diff --git a/src/menu.rs b/src/menu.rs deleted file mode 100644 index 62be6a2..0000000 --- a/src/menu.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Show game menu - -use std::io::{self, Write}; -use std::{env, process}; - -pub fn show_menu() -> String { - let username = env::var("USER").unwrap_or_else(|_| "user".to_string()); - print!("[{username}@incrust ~]$ "); - - io::stdout().flush().unwrap_or_else(|error| { - eprintln!("Error: Can't flush stdout\n{error}"); - process::exit(3); - }); - - let mut command = String::new(); - io::stdin().read_line(&mut command).unwrap_or_else(|error| { - eprintln!("Error: Can't read stdin\n{error}"); - process::exit(3); - }); - - command -} diff --git a/src/parameters.rs b/src/parameters.rs new file mode 100644 index 0000000..44e4561 --- /dev/null +++ b/src/parameters.rs @@ -0,0 +1,71 @@ +//! General parameters definition + +use crate::statefile::GameState; + +// Base and minimum compile time (in seconds) +const BASE_COMPILE_TIME: f64 = 600.0; +const MIN_COMPILE_TIME: f64 = 10.0; + +// Base and minimum credit reward for a successful compilation +const BASE_CREDIT_REWARD: f64 = 1.0; +const MIN_CREDIT_REWARD: f64 = 1.0; + +// Base and minimum / maximum probability of compilation failure +const BASE_COMPILATION_FAILURE_PROB: f64 = 0.20; +const MIN_COMPILATION_FAILURE_PROB: f64 = 0.10; +const MAX_COMPILATION_FAILURE_PROB: f64 = 0.90; + +// Calculate compile time based on CPU and RAM +// - Each additional CPU core reduces the compile time by 5 seconds +// - Each additional GB of RAM increases the compile time by 2.5 seconds +// - Minimum possible compile time is capped at `MIN_COMPILE_TIME` +pub fn compile_time(cpu: u64, ram: u64) -> f64 { + let time = BASE_COMPILE_TIME - 5.0 * (cpu.saturating_sub(1) as f64) + + 2.5 * (ram.saturating_sub(1) as f64); + + time.max(MIN_COMPILE_TIME) +} + +// Calculate the credit reward after a successful compilation based on RAM and Disk +// - Each additional GB of RAM increases the number of credits earned by 0.5 +// - Each additional GB of Disk reduces the number of credits earned by 0.25 +// - Minimum possible credit reward is capped at `MIN_CREDIT_REWARD` +pub fn credit_reward(ram: u64, disk: u64) -> f64 { + let reward = BASE_CREDIT_REWARD + 0.5 * (ram.saturating_sub(1) as f64) + - 0.25 * (disk.saturating_sub(1) as f64); + + reward.max(MIN_CREDIT_REWARD) +} + +// Calculate compilation failure probability based on CPU and Disk +// - Each additional CPU core increases the probability of compilation failure by 0.025 +// - Each additional GB of Disk reduces the probability of compilation failure by 0.05 +// - Minimum probability is capped at `MIN_COMPILATION_FAILURE_PROB` +// - Maximum probability is capped at `MAX_COMPILATION_FAILURE_PROB` +pub fn failure_probability(cpu: u64, disk: u64) -> f64 { + let probability = BASE_COMPILATION_FAILURE_PROB + 0.025 * (cpu.saturating_sub(1) as f64) + - 0.05 * (disk.saturating_sub(1) as f64); + + probability.clamp(MIN_COMPILATION_FAILURE_PROB, MAX_COMPILATION_FAILURE_PROB) +} + +// Helper to retrieve and show the different compilation parameters: +// compile time, credit reward and failure percentage +pub fn show_compilation_parameters(game_state: &GameState) -> (f64, f64, f64) { + // Calculate values + let compile_time = compile_time(game_state.cpu, game_state.ram); + let credit_reward = credit_reward(game_state.ram, game_state.disk); + let failure_probability = failure_probability(game_state.cpu, game_state.disk); + + // Show values + println!( + "Compile time {:02}:{:02}", + (compile_time / 60.0) as u64, + compile_time as u64 % 60 + ); + println!("Credit reward {credit_reward:.2}"); + println!("Failure {:.1}%", failure_probability * 100.0); + + // Return values + (compile_time, credit_reward, failure_probability) +} diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..2e75a93 --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,111 @@ +//! Show game prompt and return user input + +use anyhow::Context; +use colored::Colorize; +use rustyline::completion::{Completer, Pair}; +use rustyline::config::CompletionType; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::history::DefaultHistory; +use rustyline::validate::Validator; +use rustyline::{Editor, Helper}; +use std::env; + +// Auto completion +pub const MAIN_COMMANDS: &[&str] = &[ + "compile", "upgrade", "manage", "status", "guide", "help", "sudo", "hack", "exit", +]; + +pub const SUBMENU_COMMANDS: &[&str] = &["exit"]; + +pub struct CommandCompleter { + commands: Vec<&'static str>, +} + +impl Completer for CommandCompleter { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let input = &line[..pos]; + + let matches = self + .commands + .iter() + .filter(|cmd| cmd.starts_with(input)) + .map(|cmd| Pair { + display: cmd.to_string(), + replacement: format!("{cmd} "), + }) + .collect(); + + Ok((0, matches)) + } +} + +impl Hinter for CommandCompleter { + type Hint = String; +} + +impl Highlighter for CommandCompleter {} +impl Validator for CommandCompleter {} +impl Helper for CommandCompleter {} + +pub type PromptEditor = Editor; + +// Create rustyline editor +pub fn create_rl_editor(auto_complete_commands: Vec<&'static str>) -> anyhow::Result { + let config = rustyline::Config::builder() + .completion_type(CompletionType::List) + .build(); + + let mut rl_editor = + Editor::with_config(config).context("Failed to initialize command prompt")?; + + rl_editor.set_helper(Some(CommandCompleter { + commands: auto_complete_commands, + })); + + Ok(rl_editor) +} + +// Game prompt +pub fn show_prompt( + rl_editor: &mut PromptEditor, + prompt_context: &str, +) -> anyhow::Result> { + let username = env::var("USER").unwrap_or_else(|_| "user".to_string()); + + let prompt = format!( + "{}{}{}{}{}{}", + "[".yellow(), + username.blue(), + "@".yellow(), + match prompt_context { + "incrust" => prompt_context.blue(), + "upgrade-shop" => prompt_context.green(), + "manage-menu" => prompt_context.red(), + _ => prompt_context.normal(), + }, + " ~]".yellow(), + "$ ".yellow() + ); + + match rl_editor.readline(&prompt) { + Ok(command) => { + if !command.trim().is_empty() { + let _ = rl_editor.add_history_entry(&command); + } + + Ok(Some(command)) + } + Err(ReadlineError::Interrupted) => Ok(None), + Err(ReadlineError::Eof) => Ok(Some("exit".to_string())), + Err(error) => Err(error).context("Failed to read stdin"), + } +} diff --git a/src/splash.rs b/src/splash.rs index 52af5f4..3fcd3c8 100644 --- a/src/splash.rs +++ b/src/splash.rs @@ -1,8 +1,10 @@ //! Splash screen use colored::Colorize; -use std::{thread, time::Duration}; +use std::thread; +use std::time::Duration; +use crate::command; use crate::statefile::GameState; pub fn splash_screen(game_state: &GameState) { @@ -37,6 +39,7 @@ pub fn splash_screen(game_state: &GameState) { thread::sleep(Duration::from_millis(1000)); println!( + "{}", r#" ___ _ |_ _|_ __ ___ _ __ _ _ ___| |_ @@ -44,25 +47,14 @@ pub fn splash_screen(game_state: &GameState) { | || | | | (__| | | |_| \__ \ |_ |___|_| |_|\___|_| \__,_|___/\__| - "# + "# + .blue() ); println!("Version {}", env!("CARGO_PKG_VERSION")); println!(); println!("-----------------------------------"); println!(); - println!("SYSTEM RESOURCES"); - println!(); - println!("CPU {} CORE(S)", game_state.cpu); - println!("RAM {} GB", game_state.ram); - println!("DISK {} GB", game_state.disk); - println!("CREDITS {}", game_state.credits); - println!(); - println!("SYSTEM PERKS"); - println!(); - println!( - "CI/CD {}", - if game_state.ci_cd { "TRUE" } else { "FALSE" } - ); + command::status(game_state); println!(); println!("-----------------------------------"); println!(); diff --git a/src/statefile.rs b/src/statefile.rs index 02b303d..d3dae0c 100644 --- a/src/statefile.rs +++ b/src/statefile.rs @@ -1,5 +1,6 @@ //! Create (if needed) and read statefile -//! Acts as a save file, containing the system resources, system perks and credits +//! It contains the resources and perks +//! Also acts as a save file use anyhow::Context; use serde::{Deserialize, Serialize}; @@ -16,7 +17,10 @@ pub struct GameState { pub disk: u64, pub credits: f64, // Perks - pub ci_cd: bool, + pub ci_cd_unlocked: bool, + pub ci_cd_enabled: bool, + pub auto_scaling_unlocked: bool, + pub auto_scaling_enabled: bool, } // Default values @@ -29,7 +33,10 @@ impl Default for GameState { disk: 1, credits: 0.0, // Perks - ci_cd: false, + ci_cd_unlocked: false, + ci_cd_enabled: false, + auto_scaling_unlocked: false, + auto_scaling_enabled: false, } } } @@ -55,7 +62,7 @@ pub fn init_statefile() -> anyhow::Result { let statefile_path = statefile_path()?; // If statefile exists, load values from it - let game_state = if statefile_path.exists() { + let mut game_state = if statefile_path.exists() { let statefile = File::open(&statefile_path).with_context(|| { format!("Failed to open the {} state file", statefile_path.display()) })?; @@ -71,6 +78,10 @@ pub fn init_statefile() -> anyhow::Result { GameState::default() }; + // Ensure perks cannot be enabled unless they are unlocked + game_state.ci_cd_enabled &= game_state.ci_cd_unlocked; + game_state.auto_scaling_enabled &= game_state.auto_scaling_unlocked; + // Save / write the state to the statefile, // to initialize it if it doesn't exist or to update it if needed save(&game_state)?; diff --git a/src/upgrade.rs b/src/upgrade.rs new file mode 100644 index 0000000..c7cf8b7 --- /dev/null +++ b/src/upgrade.rs @@ -0,0 +1,272 @@ +//! Shop menu to buy system upgrades and perks + +use anyhow::Context; +use std::thread; +use std::time::Duration; + +use crate::economy; +use crate::prompt::{self, PromptEditor}; +use crate::statefile::{self, GameState}; + +// Define ID for each upgrade option +// Resources +const CPU_ID: &str = "1"; +const RAM_ID: &str = "2"; +const DISK_ID: &str = "3"; +// Perks +const CI_CD_ID: &str = "4"; +const AUTO_SCALING_ID: &str = "5"; + +// Upgrade shop +pub fn upgrade_shop( + game_state: &mut GameState, + submenu_editor: &mut PromptEditor, +) -> anyhow::Result<()> { + loop { + // Upgrade shop title and credits status + println!("[UPGRADE]"); + println!(); + println!("Credits: {:.2}", game_state.credits); + println!(); + + // Resources section + println!("Resources:"); + + // CPU + println!( + "{CPU_ID} - CPU {} -> {} {:.2} credits", + game_state.cpu, + game_state.cpu + 1, + economy::cpu_price(game_state.cpu + 1), + ); + + // RAM + println!( + "{RAM_ID} - RAM {} -> {} {:.2} credits", + game_state.ram, + game_state.ram + 1, + economy::ram_price(game_state.ram + 1), + ); + + // Disk + println!( + "{DISK_ID} - Disk {} -> {} {:.2} credits", + game_state.disk, + game_state.disk + 1, + economy::disk_price(game_state.disk + 1), + ); + + println!(); + + // Perks section + println!("Perks:"); + + // CI/CD + println!( + "{CI_CD_ID} - CI/CD {:.2} credits", + economy::CI_CD_PRICE + ); + println!( + " {}", + if game_state.ci_cd_unlocked { + "Already unlocked" + } else { + "Automatically restarts compilation when an attempt ends." + } + ); + + // Auto-Scaling + println!( + "{AUTO_SCALING_ID} - Auto-Scalling {:.2} credits", + economy::AUTO_SCALING_PRICE + ); + println!( + " {}", + if game_state.auto_scaling_unlocked { + "Already unlocked" + } else { + "Automatically buy resources as soon as enough credits have been earned." + } + ); + + println!(); + + // User guide + println!("Select the number associated to the resource or perk you want to buy."); + println!("Type \"exit\" or press \"Ctrl+C\" to exit the upgrade shop."); + + // Show upgrade shop prompt and retrieve user selection + let selection = prompt::show_prompt(submenu_editor, "upgrade-shop")?; + + // Exit on Ctrl+C + let Some(selection) = selection else { + return Ok(()); + }; + + // Execute user selection + match selection.trim() { + // Resources + CPU_ID => upgrade_resource( + game_state, + CPU_ID, + "CPU", + economy::cpu_price(game_state.cpu + 1), + false, + )?, + RAM_ID => upgrade_resource( + game_state, + RAM_ID, + "RAM", + economy::ram_price(game_state.ram + 1), + false, + )?, + DISK_ID => upgrade_resource( + game_state, + DISK_ID, + "Disk", + economy::disk_price(game_state.disk + 1), + false, + )?, + + // Perks + CI_CD_ID => buy_perk(game_state, CI_CD_ID, "CI/CD", economy::CI_CD_PRICE)?, + AUTO_SCALING_ID => buy_perk( + game_state, + AUTO_SCALING_ID, + "Auto-Scaling", + economy::AUTO_SCALING_PRICE, + )?, + + // Others + "exit" => return Ok(()), + _ => println!("Invalid selection"), + } + + // Formatting and little delay for better UI + thread::sleep(Duration::from_millis(750)); + println!(); + println!("-----------------------------------"); + println!(); + } +} + +// Helper to upgrade resources +fn upgrade_resource( + game_state: &mut GameState, + resource_id: &str, + resource_name: &str, + price: f64, + auto_scale: bool, +) -> anyhow::Result<()> { + if game_state.credits < price { + // Don't clutter the output with a warning if the upgrade is made from the Auto-Scaling + if !auto_scale { + println!("Not enough credits!"); + } + return Ok(()); + } + + game_state.credits -= price; + game_state.credits = (game_state.credits * 100.0).round() / 100.0; + + match resource_id { + CPU_ID => game_state.cpu += 1, + RAM_ID => game_state.ram += 1, + DISK_ID => game_state.disk += 1, + _ => return Ok(()), + } + + statefile::save(game_state).context("Failed to save game")?; + + // Don't clutter the output with a success message if the upgrade is made from the Auto-Scaling + if !auto_scale { + println!("{resource_name} upgraded successfully!"); + } + + Ok(()) +} + +// Helper to buy perk +fn buy_perk( + game_state: &mut GameState, + perk_id: &str, + perk_name: &str, + price: f64, +) -> anyhow::Result<()> { + if game_state.credits < price { + println!("Not enough credits!"); + return Ok(()); + } + + match perk_id { + CI_CD_ID if !game_state.ci_cd_unlocked => { + game_state.ci_cd_unlocked = true; + game_state.ci_cd_enabled = true + } + + AUTO_SCALING_ID if !game_state.auto_scaling_unlocked => { + game_state.auto_scaling_unlocked = true; + game_state.auto_scaling_enabled = true + } + _ => { + println!("{perk_name} already unlocked!"); + return Ok(()); + } + } + + game_state.credits -= price; + game_state.credits = (game_state.credits * 100.0).round() / 100.0; + + statefile::save(game_state).context("Failed to save game")?; + + println!("{perk_name} unlocked successfully!"); + + Ok(()) +} + +// Helper to automatically buy resource upgrades via the Auto-Scaling +pub fn auto_scale(game_state: &mut GameState) -> anyhow::Result<()> { + // Don't do anything if Auto-Scaling is not enabled + if !game_state.auto_scaling_enabled { + return Ok(()); + } + + // Track number of upgrades + let mut upgrades_count = 0; + + println!("Run Auto-Scaling..."); + + // Loop around the various resources, check and sort their upgrade prices in ascending order and + // automatically upgrade what we can + loop { + let cpu_price = economy::cpu_price(game_state.cpu + 1); + let ram_price = economy::ram_price(game_state.ram + 1); + let disk_price = economy::disk_price(game_state.disk + 1); + + let cheapest_upgrade = [ + (CPU_ID, "CPU", cpu_price), + (RAM_ID, "RAM", ram_price), + (DISK_ID, "Disk", disk_price), + ] + .into_iter() + .filter(|(_, _, price)| game_state.credits >= *price) + .min_by(|a, b| a.2.total_cmp(&b.2)); + + let Some((resource_id, resource_name, price)) = cheapest_upgrade else { + break; + }; + + upgrade_resource(game_state, resource_id, resource_name, price, true)?; + upgrades_count += 1; + + println!("{resource_name} automatically upgraded!"); + } + + // Show a message if no upgrades were made + if upgrades_count == 0 { + println!("No upgrades affordable."); + } + + println!(); + Ok(()) +}