├── rpg-cli.png ├── .gitignore ├── Cargo.toml ├── .github └── workflows │ ├── tests.yml │ └── release.yml ├── LICENSE ├── shell ├── example.sh ├── example.fish ├── example.ps1 └── README.md ├── src ├── quest │ ├── level.rs │ ├── ring.rs │ ├── tutorial.rs │ ├── beat_enemy.rs │ └── mod.rs ├── item │ ├── equipment.rs │ ├── ring.rs │ ├── stone.rs │ ├── mod.rs │ ├── key.rs │ ├── shop.rs │ └── chest.rs ├── main.rs ├── character │ ├── classes.yaml │ ├── class.rs │ └── enemy.rs ├── datafile.rs ├── location.rs ├── randomizer.rs ├── command.rs ├── log.rs └── game.rs ├── CHANGELOG.md ├── README.md └── Cargo.lock /rpg-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facundoolano/rpg-cli/HEAD/rpg-cli.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rpg-cli" 3 | version = "1.2.0" 4 | authors = ["facundo "] 5 | edition = "2021" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | serde = { version = "1.0.137", features = ["derive"] } 11 | bincode = "1.3.3" 12 | dirs = "4.0" 13 | rand = { version = "0.8.5", features = ["alloc"] } 14 | colored = "2" 15 | clap = { version = "^4", features = ["derive", "cargo", "deprecated"] } 16 | typetag = "0.1" 17 | dunce = "1.0.2" 18 | once_cell = "1.12.0" 19 | serde_json = "1.0.81" 20 | serde_yaml = "0.8" 21 | anyhow = "1.0" 22 | strum = "0.24.1" 23 | strum_macros = "0.24.0" 24 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | tests: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, windows-latest, macos-latest] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions-rs/toolchain@v1 16 | with: 17 | toolchain: nightly 18 | components: rustfmt, clippy 19 | - uses: Swatinem/rust-cache@v1 20 | - run: cargo test --verbose --workspace 21 | - run: cargo +nightly clippy 22 | - run: cargo fmt -- --check 23 | - run: cargo run 24 | - run: cargo run -- cd -f . 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Facundo Olano 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /shell/example.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # An example setup to use the game in the bash shell. Check the guide for more options. 3 | 4 | # FIXME override this with your own path to rpg-cli 5 | RPG=/your/path/to/facundoolano/rpg-cli/target/release/rpg-cli 6 | 7 | # Use the rpg as the command to do non fs related tasks such as print 8 | # status and buy items. 9 | rpg () { 10 | $RPG "$@" 11 | sync_rpg 12 | } 13 | 14 | # Try to move the hero to the given destination, and cd match the shell pwd 15 | # to that of the hero's location: 16 | # - the one supplied as parameter, if there weren't any battles 17 | # - the one where the battle took place, if the hero wins 18 | # - the home dir, if the hero dies 19 | cd () { 20 | $RPG cd "$@" 21 | sync_rpg 22 | } 23 | 24 | # Generate dungeon levels on the fly and look for treasures while moving down. 25 | # Will start by creating dungeon/1 at the current directory, and /2, /3, etc. 26 | # on subsequent runs. 27 | dn () { 28 | current=$(basename $PWD) 29 | number_re='^[0-9]+$' 30 | 31 | if [[ $current =~ $number_re ]]; then 32 | next=$(($current + 1)) 33 | command mkdir -p $next && cd $next && rpg ls 34 | elif [[ -d 1 ]] ; then 35 | cd 1 && rpg ls 36 | else 37 | command mkdir -p dungeon/1 && cd dungeon/1 && rpg ls 38 | fi 39 | } 40 | 41 | # This helper is used to make the pwd match the tracked internally by the game 42 | sync_rpg () { 43 | builtin cd "$($RPG pwd)" 44 | } 45 | -------------------------------------------------------------------------------- /shell/example.fish: -------------------------------------------------------------------------------- 1 | # This helper is used to make the pwd match the tracked internally by the game 2 | function sync_rpg 3 | builtin cd (rpg-cli pwd) 4 | end 5 | 6 | # Use the rpg as the command to do non fs related tasks such as print 7 | # status and buy items. 8 | function rpg 9 | rpg-cli $argv 10 | sync_rpg 11 | end 12 | 13 | # Try to move the hero to the given destination, and cd match the shell pwd 14 | # to that of the hero's location: 15 | # - the one supplied as parameter, if there weren't any battles 16 | # - the one where the battle took place, if the hero wins 17 | # - the home dir, if the hero dies 18 | function cd 19 | if count $argv > /dev/null 20 | rpg-cli cd "$argv" 21 | else 22 | rpg-cli cd $HOME 23 | end 24 | sync_rpg 25 | end 26 | 27 | # Some directories have hidden treasure chests that you can find with ls 28 | function ls 29 | if count $argv > /dev/null 30 | rpg-cli cd -f $argv[1] 31 | rpg-cli ls 32 | command ls $argv[1] 33 | rpg-cli cd -f (pwd) 34 | else 35 | rpg-cli ls 36 | command ls 37 | end 38 | end 39 | 40 | # Create dungeon 41 | function dn 42 | set regex '^[0-9]+$' 43 | set location (basename (pwd)) 44 | if string match -r -q $regex $location 45 | set next_dir (math $location + 1) 46 | command mkdir -p $next_dir && cd $next_dir && rpg ls 47 | else if string match -r -q '^dungeon$' $location 48 | command mkdir -p 1 && cd 1 && rpg ls 49 | else 50 | command mkdir -p dungeon && rpg-cli cd dungeon && rpg ls 51 | end 52 | end -------------------------------------------------------------------------------- /shell/example.ps1: -------------------------------------------------------------------------------- 1 | # Shell integration for Windows Terminal 2 | # 3 | # Note: 4 | # - I couldn't figure out how to override `cd`, so I used `cdir` instead. 5 | # - To avoid hitting the absolute path length limit, I made the created directories single-digit numbers. 6 | # - It seems that `&&` can be used in PowerShell 7, but it's not available in version 5, so I omitted the error handling. 7 | # 8 | # See: 9 | # - [about_Profiles - PowerShell | Microsoft Learn](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles?view=powershell-5.1) 10 | # - [Migrating from Windows PowerShell 5.1 to PowerShell 7 - PowerShell | Microsoft Learn](https://learn.microsoft.com/en-us/powershell/scripting/whats-new/migrating-from-windows-powershell-51-to-powershell-7?view=powershell-7.4#separate-profiles) 11 | # 12 | Set-Variable -Option Constant -Name RPG -Value "C:\your\path\to\rpg-cli.exe" 13 | 14 | function rpg() { 15 | & $RPG $args 16 | sync_rpg 17 | } 18 | 19 | function cdir() { 20 | & $RPG cd $args 21 | sync_rpg 22 | } 23 | 24 | function dn() { 25 | $current = (Get-Item $PWD).BaseName 26 | if ($current -match "^[0-9]+$") { 27 | $next = (([int]$current) + 1) % 10 28 | New-Item -ItemType Directory -ErrorAction SilentlyContinue $next > $null 29 | cdir $next 30 | } elseif (Test-Path "1") { 31 | cdir 1 32 | } else { 33 | New-Item -ItemType Directory -ErrorAction SilentlyContinue "dungeon\1" > $null 34 | cdir "dungeon/1" 35 | } 36 | rpg ls 37 | } 38 | 39 | function sync_rpg() { 40 | $pwd = & $RPG pwd 41 | Set-Location -Path $pwd 42 | } 43 | -------------------------------------------------------------------------------- /src/quest/level.rs: -------------------------------------------------------------------------------- 1 | use super::{Event, Quest}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize, Debug, Clone)] 5 | pub struct ReachLevel { 6 | target: i32, 7 | } 8 | 9 | impl ReachLevel { 10 | pub fn new(target: i32) -> Self { 11 | Self { target } 12 | } 13 | } 14 | 15 | #[typetag::serde] 16 | impl Quest for ReachLevel { 17 | fn description(&self) -> String { 18 | format!("reach level {}", self.target) 19 | } 20 | 21 | fn handle(&mut self, event: &Event) -> bool { 22 | if let Event::LevelUp { current, .. } = event { 23 | return *current >= self.target; 24 | } 25 | false 26 | } 27 | } 28 | 29 | const TOTAL_LEVELS: i32 = 5; 30 | 31 | #[derive(Serialize, Deserialize, Debug, Clone)] 32 | pub struct RaiseClassLevels { 33 | remaining: i32, 34 | class_name: String, 35 | } 36 | 37 | #[typetag::serde] 38 | impl Quest for RaiseClassLevels { 39 | fn description(&self) -> String { 40 | let progress = TOTAL_LEVELS - self.remaining; 41 | format!( 42 | "raise {} levels with class {} {}/{}", 43 | TOTAL_LEVELS, self.class_name, progress, TOTAL_LEVELS 44 | ) 45 | } 46 | 47 | fn handle(&mut self, event: &Event) -> bool { 48 | if let Event::LevelUp { count, class, .. } = event { 49 | if *class == self.class_name { 50 | self.remaining -= count; 51 | } 52 | } else if let Event::GameReset = event { 53 | self.remaining = TOTAL_LEVELS 54 | } 55 | self.remaining <= 0 56 | } 57 | } 58 | 59 | impl RaiseClassLevels { 60 | pub fn new(class_name: &str) -> Self { 61 | Self { 62 | remaining: TOTAL_LEVELS, 63 | class_name: class_name.to_string(), 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/quest/ring.rs: -------------------------------------------------------------------------------- 1 | use super::beat_enemy; 2 | use super::{Event, Quest}; 3 | use crate::item::key::Key; 4 | use crate::item::ring::Ring; 5 | use serde::{Deserialize, Serialize}; 6 | use std::collections::HashSet; 7 | 8 | #[derive(Serialize, Deserialize, Debug, Clone)] 9 | pub struct EquipRing; 10 | 11 | #[typetag::serde] 12 | impl Quest for EquipRing { 13 | fn description(&self) -> String { 14 | "equip a ring".to_string() 15 | } 16 | 17 | fn handle(&mut self, event: &Event) -> bool { 18 | if let Event::ItemUsed { item: Key::Ring(_) } = event { 19 | return true; 20 | } 21 | false 22 | } 23 | } 24 | 25 | #[derive(Serialize, Deserialize, Debug, Clone)] 26 | pub struct FindAllRings { 27 | to_find: HashSet, 28 | } 29 | 30 | impl FindAllRings { 31 | pub fn new() -> Self { 32 | Self { 33 | to_find: Ring::set(), 34 | } 35 | } 36 | } 37 | 38 | #[typetag::serde] 39 | impl Quest for FindAllRings { 40 | fn description(&self) -> String { 41 | let total = Ring::set().len(); 42 | let already_found = total - self.to_find.len(); 43 | format!("find all rings {}/{}", already_found, total) 44 | } 45 | 46 | fn handle(&mut self, event: &Event) -> bool { 47 | if let Event::ItemAdded { 48 | item: Key::Ring(ring), 49 | } = event 50 | { 51 | self.to_find.remove(ring); 52 | } 53 | self.to_find.is_empty() 54 | } 55 | } 56 | 57 | pub fn gorthaur() -> Box { 58 | let mut to_beat = HashSet::new(); 59 | to_beat.insert(String::from("gorthaur")); 60 | 61 | Box::new(beat_enemy::BeatEnemyClass { 62 | to_beat, 63 | total: 1, 64 | description: String::from("carry the ruling ring to the deeps to meet its maker"), 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /src/item/equipment.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | use super::key::Key; 4 | use crate::character::class::Class; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | /// Equipment piece with a strength contribution based on 8 | /// a level. Used to generically represent swords and shields. 9 | #[derive(Serialize, Deserialize, Debug, Clone)] 10 | pub struct Equipment(Key, i32); 11 | 12 | impl Equipment { 13 | pub fn sword(level: i32) -> Self { 14 | Self(Key::Sword, level) 15 | } 16 | 17 | pub fn shield(level: i32) -> Self { 18 | Self(Key::Shield, level) 19 | } 20 | 21 | pub fn level(&self) -> i32 { 22 | self.1 23 | } 24 | 25 | pub fn key(&self) -> Key { 26 | self.0.clone() 27 | } 28 | 29 | /// How many strength points get added to the player when 30 | /// the item is equipped. 31 | pub fn strength(&self) -> i32 { 32 | // get the base strength of the hero at this level 33 | let player_strength = Class::player_first().strength.at(self.level()); 34 | 35 | // calculate the added strength as a function of the player strength 36 | (player_strength as f64 * 0.5).round() as i32 37 | } 38 | 39 | /// Return true if the other weapon either is None or has lower level than this one. 40 | pub fn is_upgrade_from(&self, maybe_other: &Option) -> bool { 41 | if let Some(equip) = maybe_other { 42 | self.level() > equip.level() 43 | } else { 44 | true 45 | } 46 | } 47 | 48 | pub fn describe(&self) -> String { 49 | let stat = if let Key::Sword = self.key() { 50 | "physical attack" 51 | } else { 52 | "defense" 53 | }; 54 | format!("increases {} by {}", stat, self.strength()) 55 | } 56 | } 57 | 58 | impl fmt::Display for Equipment { 59 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 60 | write!(f, "{}[{}]", self.key(), self.level()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use game::Game; 2 | 3 | mod character; 4 | mod command; 5 | mod datafile; 6 | mod game; 7 | mod item; 8 | mod location; 9 | mod log; 10 | mod quest; 11 | mod randomizer; 12 | 13 | use anyhow::Result; 14 | use clap::{crate_version, Parser}; 15 | 16 | /// Your filesystem as a dungeon! 17 | #[derive(Parser)] 18 | #[command(version = crate_version!(), author = "Facundo Olano ")] 19 | struct Opts { 20 | #[clap(subcommand)] 21 | cmd: Option, 22 | 23 | /// Print succinct output when possible. 24 | #[arg(long, short, global = true)] 25 | quiet: bool, 26 | 27 | /// Print machine-readable output when possible. 28 | #[arg(long, global = true)] 29 | plain: bool, 30 | } 31 | 32 | fn main() { 33 | if let Err(err) = run_game() { 34 | // don't print a new line if error message is empty 35 | if !err.to_string().is_empty() { 36 | println!("{}", err); 37 | }; 38 | 39 | std::process::exit(1); 40 | } 41 | } 42 | 43 | /// Loads or creates a new game, executes the received command and saves. 44 | /// Inner errors are bubbled up. 45 | fn run_game() -> Result<()> { 46 | let opts: Opts = Opts::parse(); 47 | log::init(opts.quiet, opts.plain); 48 | datafile::load_classes(); 49 | 50 | // reset --hard is a special case, it needs to work when we 51 | // fail to deserialize the game data -- e.g. on backward 52 | // incompatible changes 53 | if let Some(command::Command::Reset { hard: true }) = opts.cmd { 54 | datafile::remove(); 55 | } 56 | 57 | let mut game = datafile::load()?.unwrap_or_else(Game::new); 58 | 59 | let result = command::run(opts.cmd, &mut game); 60 | 61 | // save the file regardless of the success of the command. 62 | // E.g. if the player dies it's an error / exit code 1 63 | // and that needs to be reflected in the game state. 64 | datafile::save(&game).unwrap(); 65 | 66 | result 67 | } 68 | -------------------------------------------------------------------------------- /src/quest/tutorial.rs: -------------------------------------------------------------------------------- 1 | use super::{Event, Quest}; 2 | use crate::item::key::Key; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct WinBattle; 7 | 8 | #[typetag::serde] 9 | impl Quest for WinBattle { 10 | fn description(&self) -> String { 11 | "win a battle".to_string() 12 | } 13 | 14 | fn handle(&mut self, event: &Event) -> bool { 15 | matches!(event, Event::BattleWon { .. }) 16 | } 17 | } 18 | 19 | #[derive(Serialize, Deserialize, Debug, Clone)] 20 | pub struct BuySword; 21 | 22 | #[typetag::serde] 23 | impl Quest for BuySword { 24 | fn description(&self) -> String { 25 | "buy a sword".to_string() 26 | } 27 | 28 | fn handle(&mut self, event: &Event) -> bool { 29 | if let Event::ItemBought { item: Key::Sword } = event { 30 | return true; 31 | } 32 | false 33 | } 34 | } 35 | 36 | #[derive(Serialize, Deserialize, Debug, Clone)] 37 | pub struct UsePotion; 38 | 39 | #[typetag::serde] 40 | impl Quest for UsePotion { 41 | fn description(&self) -> String { 42 | "use a potion".to_string() 43 | } 44 | 45 | fn handle(&mut self, event: &Event) -> bool { 46 | if let Event::ItemUsed { item: Key::Potion } = event { 47 | return true; 48 | } 49 | false 50 | } 51 | } 52 | 53 | #[derive(Serialize, Deserialize, Debug, Clone)] 54 | pub struct FindChest; 55 | 56 | #[typetag::serde] 57 | impl Quest for FindChest { 58 | fn description(&self) -> String { 59 | "find a chest".to_string() 60 | } 61 | 62 | fn handle(&mut self, event: &Event) -> bool { 63 | matches!(event, Event::ChestFound) 64 | } 65 | } 66 | 67 | #[derive(Serialize, Deserialize, Debug, Clone)] 68 | pub struct VisitTomb; 69 | 70 | #[typetag::serde] 71 | impl Quest for VisitTomb { 72 | fn description(&self) -> String { 73 | "visit the tomb of a fallen hero".to_string() 74 | } 75 | 76 | fn handle(&mut self, event: &Event) -> bool { 77 | matches!(event, Event::TombtsoneFound) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: publish-release 2 | on: 3 | push: 4 | tags: 5 | - "*.*.*" 6 | jobs: 7 | changelog: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Get version from tag 11 | id: tag_name 12 | run: | 13 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/} 14 | shell: bash 15 | - uses: actions/checkout@v2 16 | - name: Get Changelog Entry 17 | id: changelog_reader 18 | uses: mindsers/changelog-reader-action@v2 19 | with: 20 | version: ${{ steps.tag_name.outputs.current_version }} 21 | - name: Release body 22 | uses: softprops/action-gh-release@v1 23 | with: 24 | body: ${{ steps.changelog_reader.outputs.changes }} 25 | 26 | artifact: 27 | runs-on: ${{ matrix.os }} 28 | strategy: 29 | matrix: 30 | os: [ubuntu-latest, windows-latest, macos-latest] 31 | include: 32 | - os: macos-latest 33 | OS_NAME: macOS 34 | TARGET: x86_64-apple-darwin 35 | 36 | - os: ubuntu-latest 37 | OS_NAME: linux 38 | TARGET: x86_64-unknown-linux-musl 39 | 40 | - os: windows-latest 41 | OS_NAME: windows 42 | TARGET: x86_64-pc-windows-gnu 43 | EXTENSION: .exe 44 | steps: 45 | - name: Get version from tag 46 | id: tag_name 47 | run: | 48 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/} 49 | shell: bash 50 | - uses: actions/checkout@v2 51 | - uses: actions-rs/toolchain@v1 52 | with: 53 | toolchain: stable 54 | target: ${{ matrix.TARGET }} 55 | - uses: Swatinem/rust-cache@v1 56 | - run: cargo build --release --target ${{ matrix.TARGET }} 57 | - run: cp target/${{ matrix.TARGET }}/release/rpg-cli${{ matrix.EXTENSION }} rpg-cli-${{ steps.tag_name.outputs.current_version }}-${{ matrix.OS_NAME }}${{ matrix.EXTENSION }} 58 | 59 | - name: Release files 60 | uses: softprops/action-gh-release@v1 61 | with: 62 | files: rpg-cli-${{ steps.tag_name.outputs.current_version }}-${{ matrix.OS_NAME }}${{ matrix.EXTENSION }} 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/quest/beat_enemy.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use super::{Event, Quest}; 4 | use crate::character::class; 5 | use crate::character::class::Class; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | pub fn of_class(category: class::Category, description: &str) -> Box { 9 | let to_beat = Class::names(category); 10 | let total = to_beat.len(); 11 | Box::new(BeatEnemyClass { 12 | to_beat, 13 | total, 14 | description: description.to_string(), 15 | }) 16 | } 17 | 18 | pub fn shadow() -> Box { 19 | let mut to_beat = HashSet::new(); 20 | to_beat.insert(String::from("shadow")); 21 | 22 | Box::new(BeatEnemyClass { 23 | to_beat, 24 | total: 1, 25 | description: String::from("beat your own shadow"), 26 | }) 27 | } 28 | 29 | pub fn dev() -> Box { 30 | let mut to_beat = HashSet::new(); 31 | to_beat.insert(String::from("dev")); 32 | 33 | Box::new(BeatEnemyClass { 34 | to_beat, 35 | total: 1, 36 | description: String::from("beat the dev"), 37 | }) 38 | } 39 | 40 | pub fn at_distance(distance: i32) -> Box { 41 | Box::new(BeatEnemyDistance { distance }) 42 | } 43 | 44 | #[derive(Serialize, Deserialize, Debug, Clone)] 45 | pub struct BeatEnemyClass { 46 | pub to_beat: HashSet, 47 | pub total: usize, 48 | pub description: String, 49 | } 50 | 51 | #[typetag::serde] 52 | impl Quest for BeatEnemyClass { 53 | fn description(&self) -> String { 54 | if self.total == 1 { 55 | self.description.to_string() 56 | } else { 57 | let already_beat = self.total - self.to_beat.len(); 58 | format!("{} {}/{}", self.description, already_beat, self.total) 59 | } 60 | } 61 | 62 | fn handle(&mut self, event: &Event) -> bool { 63 | if let Event::BattleWon { enemy, .. } = event { 64 | self.to_beat.remove(&enemy.name()); 65 | } 66 | self.to_beat.is_empty() 67 | } 68 | } 69 | 70 | #[derive(Serialize, Deserialize, Debug, Clone)] 71 | pub struct BeatEnemyDistance { 72 | distance: i32, 73 | } 74 | 75 | #[typetag::serde] 76 | impl Quest for BeatEnemyDistance { 77 | fn description(&self) -> String { 78 | format!("defeat an enemy {} steps away from home", self.distance) 79 | } 80 | 81 | fn handle(&mut self, event: &Event) -> bool { 82 | if let Event::BattleWon { location, .. } = event { 83 | if location.distance_from_home().len() >= self.distance { 84 | return true; 85 | } 86 | } 87 | false 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/character/classes.yaml: -------------------------------------------------------------------------------- 1 | - name: warrior 2 | hp: [50, 10] 3 | strength: [12, 3] 4 | speed: [11, 2] 5 | category: player 6 | - name: mage 7 | hp: [30, 6] 8 | mp: [10, 4] 9 | strength: [10, 3] 10 | speed: [10, 2] 11 | category: player 12 | - name: thief 13 | hp: [35, 7] 14 | strength: [7, 2] 15 | speed: [19, 4] 16 | category: player 17 | - name: rat 18 | hp: [15, 5] 19 | strength: [5, 2] 20 | speed: [16, 2] 21 | category: common 22 | - name: wolf 23 | hp: [25, 5] 24 | strength: [8, 2] 25 | speed: [12, 2] 26 | category: common 27 | - name: snake 28 | hp: [23, 7] 29 | strength: [7, 2] 30 | speed: [6, 2] 31 | inflicts: [poison, 5] 32 | category: common 33 | - name: slime 34 | hp: [80, 4] 35 | strength: [3, 2] 36 | speed: [4, 2] 37 | inflicts: [poison, 10] 38 | category: common 39 | - name: spider 40 | hp: [16, 5] 41 | strength: [9, 2] 42 | speed: [12, 2] 43 | inflicts: [poison, 20] 44 | category: common 45 | - name: zombie 46 | hp: [80, 5] 47 | strength: [8, 2] 48 | speed: [6, 2] 49 | category: rare 50 | - name: orc 51 | hp: [60, 5] 52 | strength: [13, 2] 53 | speed: [12, 2] 54 | category: rare 55 | - name: skeleton 56 | hp: [45, 5] 57 | strength: [10, 2] 58 | speed: [10, 2] 59 | category: rare 60 | - name: demon 61 | hp: [70, 5] 62 | strength: [10, 2] 63 | speed: [18, 2] 64 | inflicts: [burn, 10] 65 | category: rare 66 | - name: vampire 67 | hp: [70, 5] 68 | strength: [13, 2] 69 | speed: [10, 2] 70 | category: rare 71 | - name: dragon 72 | hp: [110, 5] 73 | strength: [25, 2] 74 | speed: [8, 2] 75 | inflicts: [burn, 2] 76 | category: rare 77 | - name: golem 78 | hp: [70, 5] 79 | strength: [45, 2] 80 | speed: [2, 1] 81 | category: rare 82 | - name: sorcerer 83 | hp: [45, 5] 84 | mp: [13, 1] 85 | strength: [10, 2] 86 | speed: [8, 2] 87 | inflicts: [burn, 5] 88 | category: rare 89 | - name: chimera 90 | hp: [250, 2] 91 | strength: [90, 2] 92 | speed: [16, 2] 93 | inflicts: [poison, 3] 94 | category: legendary 95 | - name: basilisk 96 | hp: [180, 3] 97 | strength: [100, 2] 98 | speed: [18, 2] 99 | inflicts: [poison, 2] 100 | category: legendary 101 | - name: minotaur 102 | hp: [120, 3] 103 | strength: [60, 2] 104 | speed: [40, 2] 105 | category: legendary 106 | - name: balrog 107 | hp: [270, 3] 108 | strength: [200, 2] 109 | speed: [14, 2] 110 | inflicts: [burn, 3] 111 | category: legendary 112 | - name: phoenix 113 | hp: [500, 3] 114 | strength: [180, 2] 115 | speed: [28, 2] 116 | inflicts: [burn, 3] 117 | category: legendary 118 | -------------------------------------------------------------------------------- /src/datafile.rs: -------------------------------------------------------------------------------- 1 | use crate::character::class; 2 | use crate::game; 3 | use anyhow::{bail, Result}; 4 | use std::{fs, io, path}; 5 | 6 | struct NotFound; 7 | 8 | pub fn load() -> Result> { 9 | match read(data_file()) { 10 | Err(NotFound) => Ok(None), 11 | Ok(data) => { 12 | if let Ok(game) = serde_json::from_slice(&data) { 13 | Ok(Some(game)) 14 | } else { 15 | bail!("Invalid game data file. If it was generated with a previous version please run `reset --hard` to restart."); 16 | } 17 | } 18 | } 19 | } 20 | 21 | pub fn save(game: &game::Game) -> Result<(), io::Error> { 22 | let data = serde_json::to_vec(game).unwrap(); 23 | write(data_file(), data) 24 | } 25 | 26 | pub fn remove() { 27 | let rpg_dir = rpg_dir(); 28 | if rpg_dir.exists() { 29 | fs::remove_file(data_file()).unwrap(); 30 | } 31 | } 32 | 33 | pub fn load_classes() { 34 | if let Ok(bytes) = read(classes_file()) { 35 | class::Class::load(&bytes) 36 | } 37 | } 38 | 39 | fn read(file: path::PathBuf) -> Result, NotFound> { 40 | fs::read(file).map_err(|_| NotFound) 41 | } 42 | 43 | fn write(file: path::PathBuf, data: Vec) -> Result<(), io::Error> { 44 | let rpg_dir = rpg_dir(); 45 | if !rpg_dir.exists() { 46 | fs::create_dir(&rpg_dir).unwrap(); 47 | } 48 | fs::write(file, data) 49 | } 50 | 51 | pub fn rpg_dir() -> path::PathBuf { 52 | //Home is checked first because that was the default in a previous version 53 | let home_dir = dirs::home_dir().unwrap().join(".rpg"); 54 | let data_dir = dirs::data_dir().unwrap(); 55 | if home_dir.exists() || !data_dir.exists() { 56 | home_dir 57 | } else { 58 | data_dir.join("rpg") 59 | } 60 | } 61 | 62 | fn data_file() -> path::PathBuf { 63 | rpg_dir().join("data") 64 | } 65 | 66 | fn classes_file() -> path::PathBuf { 67 | rpg_dir().join("classes.yaml") 68 | } 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | use super::*; 73 | use crate::item::key; 74 | use crate::item::ring; 75 | 76 | #[test] 77 | fn serialize_ring() { 78 | // rings have a compound enum variant Key::Ring(Ring::_) 79 | // that doesn't work with the default enum serialization setup 80 | // this verifies the try_from = String workaround 81 | let mut game = game::Game::new(); 82 | game.add_item(Box::new(ring::Ring::Void)); 83 | let data = serde_json::to_vec(&game).unwrap(); 84 | let mut game: game::Game = serde_json::from_slice(&data).unwrap(); 85 | assert!(game.use_item(key::Key::Ring(ring::Ring::Void)).is_ok()); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/item/ring.rs: -------------------------------------------------------------------------------- 1 | use super::{key, Item}; 2 | use crate::game; 3 | use core::fmt; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::HashSet; 6 | use strum::IntoEnumIterator; 7 | use strum_macros::EnumIter; 8 | 9 | /// Rings are a wearable item that produce arbitrary effects hooked in 10 | /// different places of the game, e.g. increase a stat, double gold gained, etc. 11 | #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash, EnumIter, Debug, Default)] 12 | pub enum Ring { 13 | #[default] 14 | Void, 15 | Attack, 16 | Deffense, 17 | Speed, 18 | Magic, 19 | MP, 20 | HP, 21 | Evade, 22 | RegenHP, 23 | RegenMP, 24 | Ruling, 25 | Protect, 26 | Fire, 27 | Poison, 28 | Double, 29 | Counter, 30 | Revive, 31 | Chest, 32 | Gold, 33 | Diamond, 34 | } 35 | 36 | impl Ring { 37 | pub fn set() -> HashSet { 38 | Ring::iter().collect() 39 | } 40 | 41 | /// For stat modifying stats, return the factor that should be 42 | /// applied to the base character stat. 43 | pub fn factor(&self) -> f64 { 44 | match self { 45 | Ring::Attack => 0.5, 46 | Ring::Deffense => 0.5, 47 | Ring::Speed => 0.5, 48 | Ring::Magic => 0.5, 49 | Ring::MP => 0.5, 50 | Ring::HP => 0.5, 51 | _ => 0.0, 52 | } 53 | } 54 | } 55 | 56 | impl fmt::Display for Ring { 57 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 58 | write!(f, "{}", self.key()) 59 | } 60 | } 61 | 62 | #[typetag::serde] 63 | impl Item for Ring { 64 | /// When the ring is used, equip in the player. If the player was already 65 | /// wearing two rings, move the second one back to the inventory. 66 | fn apply(&mut self, game: &mut game::Game) { 67 | if let Some(removed) = game.player.equip_ring(self.clone()) { 68 | game.add_item(Box::new(removed)); 69 | } 70 | } 71 | 72 | fn key(&self) -> key::Key { 73 | key::Key::Ring(self.clone()) 74 | } 75 | 76 | fn describe(&self) -> String { 77 | let str = match self { 78 | Ring::Void => "no-effect ring", 79 | Ring::Attack => "increases physical attack", 80 | Ring::Deffense => "increases defense", 81 | Ring::Speed => "increases speed", 82 | Ring::Magic => "increases magical attack", 83 | Ring::MP => "increases max mp", 84 | Ring::HP => "increases max hp", 85 | Ring::Evade => "reduces enemy appearance frequency", 86 | Ring::RegenHP => "recovers hp on every turn", 87 | Ring::RegenMP => "recovers mp on every turn", 88 | Ring::Ruling => "one ring to rule them all", 89 | Ring::Protect => "prevents status ailments", 90 | Ring::Fire => "inflicts burn status on attack", 91 | Ring::Poison => "inflicts poison status on attack", 92 | Ring::Double => "strike twice per turn", 93 | Ring::Counter => "counter-attack when an attack is received", 94 | Ring::Revive => "come back from dead during battle", 95 | Ring::Chest => "doubles chest finding frequency", 96 | Ring::Gold => "doubles gold gained in battles and chests", 97 | Ring::Diamond => "looks expensive", 98 | }; 99 | str.to_string() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/item/stone.rs: -------------------------------------------------------------------------------- 1 | use super::{key, Item}; 2 | use crate::game; 3 | use crate::log; 4 | use crate::quest; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Serialize, Deserialize, Debug, Clone)] 8 | pub struct Health; 9 | 10 | #[derive(Serialize, Deserialize, Debug, Clone)] 11 | pub struct Magic; 12 | 13 | #[derive(Serialize, Deserialize, Debug, Clone)] 14 | pub struct Power; 15 | 16 | #[derive(Serialize, Deserialize, Debug, Clone)] 17 | pub struct Speed; 18 | 19 | #[derive(Serialize, Deserialize, Debug, Clone)] 20 | pub struct Level; 21 | 22 | #[typetag::serde] 23 | impl Item for Health { 24 | fn apply(&mut self, game: &mut game::Game) { 25 | let inc = game.player.raise_hp(); 26 | log(game, "hp", inc); 27 | } 28 | 29 | fn key(&self) -> key::Key { 30 | key::Key::HealthStone 31 | } 32 | 33 | fn describe(&self) -> String { 34 | String::from("raises hp") 35 | } 36 | } 37 | 38 | #[typetag::serde] 39 | impl Item for Magic { 40 | fn apply(&mut self, game: &mut game::Game) { 41 | let inc = game.player.raise_mp(); 42 | log(game, "mp", inc); 43 | } 44 | 45 | fn key(&self) -> key::Key { 46 | key::Key::MagicStone 47 | } 48 | 49 | fn describe(&self) -> String { 50 | String::from("raises mp") 51 | } 52 | } 53 | 54 | #[typetag::serde] 55 | impl Item for Power { 56 | fn apply(&mut self, game: &mut game::Game) { 57 | let inc = game.player.raise_strength(); 58 | log(game, "str", inc); 59 | } 60 | 61 | fn key(&self) -> key::Key { 62 | key::Key::PowerStone 63 | } 64 | 65 | fn describe(&self) -> String { 66 | String::from("raises strength") 67 | } 68 | } 69 | 70 | #[typetag::serde] 71 | impl Item for Speed { 72 | fn apply(&mut self, game: &mut game::Game) { 73 | let inc = game.player.raise_speed(); 74 | log(game, "spd", inc); 75 | } 76 | 77 | fn key(&self) -> key::Key { 78 | key::Key::SpeedStone 79 | } 80 | 81 | fn describe(&self) -> String { 82 | String::from("raises speed") 83 | } 84 | } 85 | 86 | #[typetag::serde] 87 | impl Item for Level { 88 | fn apply(&mut self, game: &mut game::Game) { 89 | game.player.raise_level(); 90 | log(game, "level", 1); 91 | quest::level_up(game, 1); 92 | } 93 | 94 | fn key(&self) -> key::Key { 95 | key::Key::LevelStone 96 | } 97 | 98 | fn describe(&self) -> String { 99 | String::from("raises the player level") 100 | } 101 | } 102 | 103 | fn log(game: &mut game::Game, stat: &'static str, increase: i32) { 104 | log::stat_increase(&game.player, stat, increase); 105 | } 106 | 107 | // TODO too much duplication 108 | impl std::fmt::Display for Health { 109 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 110 | write!(f, "{}", self.key()) 111 | } 112 | } 113 | 114 | impl std::fmt::Display for Magic { 115 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 116 | write!(f, "{}", self.key()) 117 | } 118 | } 119 | 120 | impl std::fmt::Display for Speed { 121 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 122 | write!(f, "{}", self.key()) 123 | } 124 | } 125 | 126 | impl std::fmt::Display for Power { 127 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 128 | write!(f, "{}", self.key()) 129 | } 130 | } 131 | 132 | impl std::fmt::Display for Level { 133 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 134 | write!(f, "{}", self.key()) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/item/mod.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | use crate::character::class as character; 4 | use crate::game; 5 | use crate::location; 6 | use crate::log; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | pub mod chest; 10 | pub mod equipment; 11 | pub mod key; 12 | pub mod ring; 13 | pub mod shop; 14 | pub mod stone; 15 | 16 | #[typetag::serde(tag = "type")] 17 | pub trait Item: fmt::Display { 18 | fn apply(&mut self, game: &mut game::Game); 19 | fn key(&self) -> key::Key; 20 | fn describe(&self) -> String; 21 | } 22 | 23 | #[derive(Serialize, Deserialize, Debug, Clone)] 24 | pub struct Potion { 25 | level: i32, 26 | } 27 | 28 | impl Potion { 29 | pub fn new(level: i32) -> Self { 30 | Self { level } 31 | } 32 | 33 | fn restores(&self) -> i32 { 34 | character::Class::player_first().hp.at(self.level) / 2 35 | } 36 | } 37 | 38 | impl fmt::Display for Potion { 39 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 40 | write!(f, "potion[{}]", self.level) 41 | } 42 | } 43 | 44 | #[typetag::serde] 45 | impl Item for Potion { 46 | fn apply(&mut self, game: &mut game::Game) { 47 | let recovered = game.player.update_hp(self.restores()).unwrap(); 48 | log::heal_item(&game.player, "potion", recovered, 0, false); 49 | } 50 | 51 | fn key(&self) -> key::Key { 52 | key::Key::Potion 53 | } 54 | 55 | fn describe(&self) -> String { 56 | format!("restores {}hp", self.restores()) 57 | } 58 | } 59 | 60 | #[derive(Serialize, Deserialize, Debug, Clone)] 61 | pub struct Escape {} 62 | 63 | impl Escape { 64 | pub fn new() -> Self { 65 | Self {} 66 | } 67 | } 68 | 69 | #[typetag::serde] 70 | impl Item for Escape { 71 | fn apply(&mut self, game: &mut game::Game) { 72 | game.visit(location::Location::home()).unwrap_or_default(); 73 | } 74 | 75 | fn key(&self) -> key::Key { 76 | key::Key::Escape 77 | } 78 | 79 | fn describe(&self) -> String { 80 | String::from("transports the player safely back home") 81 | } 82 | } 83 | 84 | impl fmt::Display for Escape { 85 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 86 | write!(f, "escape") 87 | } 88 | } 89 | 90 | #[derive(Serialize, Deserialize, Debug, Clone)] 91 | pub struct Remedy {} 92 | 93 | impl Remedy { 94 | pub fn new() -> Self { 95 | Self {} 96 | } 97 | } 98 | 99 | #[typetag::serde] 100 | impl Item for Remedy { 101 | fn apply(&mut self, game: &mut game::Game) { 102 | let healed = game.player.status_effect.take().is_some(); 103 | log::heal_item(&game.player, "remedy", 0, 0, healed); 104 | } 105 | 106 | fn key(&self) -> key::Key { 107 | key::Key::Remedy 108 | } 109 | 110 | fn describe(&self) -> String { 111 | String::from("removes status ailments") 112 | } 113 | } 114 | 115 | impl fmt::Display for Remedy { 116 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 117 | write!(f, "remedy") 118 | } 119 | } 120 | 121 | #[derive(Serialize, Deserialize, Debug, Clone)] 122 | pub struct Ether { 123 | level: i32, 124 | } 125 | 126 | impl Ether { 127 | pub fn new(level: i32) -> Self { 128 | Self { level } 129 | } 130 | } 131 | 132 | impl fmt::Display for Ether { 133 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 134 | write!(f, "ether[{}]", self.level) 135 | } 136 | } 137 | 138 | #[typetag::serde] 139 | impl Item for Ether { 140 | fn apply(&mut self, game: &mut game::Game) { 141 | let to_restore = game 142 | .player 143 | .class 144 | .mp 145 | .as_ref() 146 | .map_or(0, |mp| mp.at(self.level)); 147 | let recovered_mp = game.player.update_mp(to_restore); 148 | 149 | log::heal_item(&game.player, "ether", 0, recovered_mp, false); 150 | } 151 | 152 | fn key(&self) -> key::Key { 153 | key::Key::Ether 154 | } 155 | 156 | fn describe(&self) -> String { 157 | format!("restores level {} amount mp", self.level) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/character/class.rs: -------------------------------------------------------------------------------- 1 | use crate::randomizer::{random, Randomizer}; 2 | use once_cell::sync::OnceCell; 3 | use rand::prelude::SliceRandom; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::{HashMap, HashSet}; 6 | 7 | /// A stat represents an attribute of a character, such as strength or speed. 8 | /// This struct contains a stat starting value and the amount that should be 9 | /// applied when the level increases. 10 | #[derive(Debug, Serialize, Deserialize, Clone)] 11 | pub struct Stat(pub i32, pub i32); 12 | 13 | impl Stat { 14 | pub fn base(&self) -> i32 { 15 | // Instead of returning the base level as-is, simulate a randomized 16 | // zero to one level increase of the stat 17 | let floor = self.0 - self.1; 18 | floor + self.increase() 19 | } 20 | 21 | pub fn increase(&self) -> i32 { 22 | random().stat_increase(self.1) 23 | } 24 | 25 | pub fn at(&self, level: i32) -> i32 { 26 | self.0 + (level - 1) * self.1 27 | } 28 | } 29 | 30 | /// Classes are archetypes for characters. 31 | /// The struct contains a specific stat configuration such that all instances of 32 | /// the class have a similar combat behavior. 33 | #[derive(Debug, Serialize, Deserialize, Clone)] 34 | pub struct Class { 35 | pub name: String, 36 | 37 | pub hp: Stat, 38 | pub mp: Option, 39 | pub strength: Stat, 40 | pub speed: Stat, 41 | 42 | pub category: Category, 43 | 44 | pub inflicts: Option<(super::StatusEffect, u32)>, 45 | } 46 | 47 | /// Determines whether the class is intended for a Player or, if it's for an enemy, 48 | /// How rare it is (how frequently it should appear). 49 | /// Enables easier customization of the classes via an external file. 50 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, std::hash::Hash)] 51 | #[serde(rename_all = "snake_case")] 52 | pub enum Category { 53 | Player, 54 | Common, 55 | Rare, 56 | Legendary, 57 | } 58 | 59 | static CLASSES: OnceCell>> = OnceCell::new(); 60 | 61 | impl Class { 62 | /// Returns whether this is a magic class, i.e. it can inflict 63 | /// magic damage. 64 | pub fn is_magic(&self) -> bool { 65 | self.mp.is_some() 66 | } 67 | 68 | /// Customize the classes definitions based on an input yaml byte array. 69 | pub fn load(bytes: &[u8]) { 70 | CLASSES.set(from_bytes(bytes)).unwrap(); 71 | } 72 | 73 | /// The default player class, exposed for initialization and parameterization of 74 | /// items and equipment. 75 | pub fn player_first() -> &'static Self { 76 | Self::of(Category::Player).first().unwrap() 77 | } 78 | 79 | pub fn player_by_name(name: &str) -> Option<&'static Self> { 80 | Self::of(Category::Player) 81 | .iter() 82 | .filter(|class| class.name == name) 83 | .collect::>() 84 | .first() 85 | .copied() 86 | } 87 | 88 | pub fn random(category: Category) -> &'static Self { 89 | let mut rng = rand::thread_rng(); 90 | Self::of(category).choose(&mut rng).unwrap() 91 | } 92 | 93 | pub fn names(category: Category) -> HashSet { 94 | Self::of(category) 95 | .iter() 96 | .map(|class| class.name.clone()) 97 | .collect() 98 | } 99 | 100 | fn of(category: Category) -> &'static Vec { 101 | CLASSES.get_or_init(default_classes).get(&category).unwrap() 102 | } 103 | } 104 | 105 | fn default_classes() -> HashMap> { 106 | from_bytes(include_bytes!("classes.yaml")) 107 | } 108 | 109 | fn from_bytes(bytes: &[u8]) -> HashMap> { 110 | // it would arguably be better for these module not to deal with deserialization 111 | // and yaml, but at this stage it's easier allow it to pick up defaults from 112 | // the local file when it hasn't been customized (especially for tests) 113 | let mut classes: Vec = serde_yaml::from_slice(bytes).unwrap(); 114 | 115 | let mut class_groups = HashMap::new(); 116 | for class in classes.drain(..) { 117 | let entry = class_groups 118 | .entry(class.category.clone()) 119 | .or_insert_with(Vec::new); 120 | entry.push(class); 121 | } 122 | class_groups 123 | } 124 | -------------------------------------------------------------------------------- /shell/README.md: -------------------------------------------------------------------------------- 1 | # Shell integration 2 | 3 | To get the most out of rpg-cli, it is suggested to define aliases or wrapper functions so the game can be integrated into a regular shell session, with enemies appearing along the way. 4 | 5 | This guide describes the basic building blocks to write such functions and shows some examples. See also [the recommended setup](shell/example.sh). 6 | 7 | ## Basic `cd` alternative 8 | 9 | The default rpg-cli command works as `cd`, changing the hero's location from 10 | one directory to another. Since the program itself can't affect your shell session, 11 | you need to write a function so the working directory is changed to match that of the hero: 12 | 13 | ```sh 14 | rpg () { 15 | rpg-cli "$@" 16 | cd "$(rpg-cli pwd)" 17 | } 18 | ``` 19 | 20 | This assumes `rpg-cli` is in your path, update with the specific location if not. You can define it directly in your current session, add it to `~/.bashrc`, source it from another script, etc. 21 | 22 | If you use fish shell, update `~/.config/fish/config.fish` instead: 23 | 24 | ```fish 25 | function rpg 26 | rpg-cli $argv 27 | cd (rpg-cli pwd) 28 | end 29 | ``` 30 | 31 | ## Full `cd` override 32 | 33 | If you like having enemies popping up while using `cd`, you can override that instead of using a separate function: 34 | 35 | ```sh 36 | cd () { 37 | rpg-cli cd "$@" 38 | builtin cd "$(rpg-cli pwd)" 39 | } 40 | ``` 41 | 42 | ## `ls` override 43 | 44 | The `rpg-cli ls` command looks for chests at the current location. 45 | It can be integrated to the regular ls like this: 46 | 47 | ``` sh 48 | ls () { 49 | command ls "$@" 50 | if [ $# -eq 0 ] ; then 51 | rpg-cli cd -f . 52 | rpg-cli ls 53 | fi 54 | } 55 | ``` 56 | 57 | ## Arbitrary dungeon levels 58 | 59 | After some time it can become tedious to find deep enough directories to level-up your character. This function will create new dungeon directories, cd into them and ls to 60 | look for chests: 61 | 62 | ``` sh 63 | dn () { 64 | current=$(basename $PWD) 65 | number_re='^[0-9]+$' 66 | 67 | if [[ $current =~ $number_re ]]; then 68 | next=$(($current + 1)) 69 | command mkdir -p $next && cd $next && rpg ls 70 | elif [[ -d 1 ]] ; then 71 | cd 1 && rpg ls 72 | else 73 | command mkdir -p dungeon/1 && cd dungeon/1 && rpg ls 74 | fi 75 | } 76 | ``` 77 | 78 | Having this function setup, the game can be played very conveniently with a combination of `dn` (to go down), `cd` (to go back up, or back home) and `rpg` (to show stats, use items, etc.). 79 | 80 | ## Other customizations 81 | ### Low-level commands 82 | 83 | To better adapt for different usage patterns, finer-grained commands are provided: 84 | 85 | * `rpg-cli cd --force ` will set the hero's location to `` without initiating battles. 86 | * `rpg-cli pwd` will print the hero's current location. 87 | * `rpg-cli battle` will initiate a battle with a probability that changes based on the distance from home. If the battle is lost the exit code of the program will be non-negative. 88 | * `rpg-cli stat --quiet` will return hero stats in a succinct format. 89 | * `rpg-cli stat --plain` will return hero stats as tab separated fields, to facilitate parsing (e.g. to integrate to the prompt). 90 | 91 | ### Aliasing other commands 92 | 93 | Another way to use rpg-cli is to initiate battles when attempting to execute file-modifying operations. Only when the battle is won the operation is allowed: 94 | 95 | ```sh 96 | alias rpg-battle="rpg-cli cd -f . && rpg-cli battle" 97 | 98 | alias rm="rpg-battle && rm" 99 | alias rmdir="rpg-battle && rmdir" 100 | alias mkdir="rpg-battle && mkdir" 101 | alias touch="rpg-battle && touch" 102 | alias mv="rpg-battle && mv" 103 | alias cp="rpg-battle && cp" 104 | alias chown="rpg-battle && chown" 105 | alias chmod="rpg-battle && chmod" 106 | ``` 107 | 108 | ### Show rpg status at prompt 109 | 110 | A simple of showing the hero status at the bash prompt is: 111 | 112 | $ PS1='`rpg -q | xargs` ' 113 | hero[4][xxxx][x---]@home 114 | 115 | `rpg --plain` can be used as a building block for more sophisticated display. 116 | 117 | ### Customize the home directory 118 | 119 | If for some reason the system's default home directory is not practical for the game, it can be overridden by setting the `$HOME` environment variable. More details and examples [here](https://github.com/facundoolano/rpg-cli/issues/100). 120 | 121 | ### Preventing intermediate battles 122 | 123 | Note that the logic of the default rpg command is this: the hero moves one directory at a time, and enemies can appear at each step: 124 | 125 | * If the hero dies, the game is restarted and you go back home. 126 | * If the hero wins the battle, it will stop at the battle's location instead of keep moving to the initial destination. The rationale for this behavior is that you may need to adjust your strategy after each battle: use a potion, return home, try to escape battles, etc. 127 | 128 | Having `cd` not consistently set the pwd to the intended destination may not be acceptable if the program is used casually while doing other work. 129 | A better alternative for this usage pattern is enabled by the other integration commands, for example: 130 | 131 | ```sh 132 | cd () { 133 | builtin cd "$@" 134 | rpg-cli cd -f . 135 | rpg-cli battle 136 | } 137 | ``` 138 | 139 | ### Staying in the current work directory on death 140 | 141 | By default the shell integrations will send the user back to the home directory after death. To prevent this, we can change back to the current directory after each battle 142 | 143 | For example for cd: 144 | ```sh 145 | cd () { 146 | builtin cd "$@" 147 | rpg-cli cd -f . 148 | rpg-cli battle; rpg-cli cd -f . 149 | } 150 | ``` 151 | -------------------------------------------------------------------------------- /src/character/enemy.rs: -------------------------------------------------------------------------------- 1 | use super::{class::Category, class::Class, Character}; 2 | use crate::item::ring::Ring; 3 | use crate::location; 4 | use crate::log; 5 | use crate::randomizer::{random, Randomizer}; 6 | use rand::prelude::SliceRandom; 7 | use rand::Rng; 8 | 9 | /// Randomly spawn an enemy character at the given location, based on the 10 | /// current character stats. 11 | /// The distance from home will influence the enemy frequency and level. 12 | /// Under certain conditions, special (quest-related) enemies may be spawned. 13 | pub fn spawn(location: &location::Location, player: &Character) -> Option { 14 | if player.enemies_evaded() { 15 | return None; 16 | } 17 | 18 | let distance = location.distance_from_home(); 19 | if random().should_enemy_appear(&distance) { 20 | // try spawning "special" enemies if conditions are met, otherwise 21 | // a random one for the current location 22 | let (class, level) = spawn_gorthaur(player, location) 23 | .or_else(|| spawn_shadow(player, location)) 24 | .or_else(|| spawn_dev(player, location)) 25 | .unwrap_or_else(|| spawn_random(player, &distance)); 26 | 27 | let level = random().enemy_level(level); 28 | let enemy = Character::new(class, level); 29 | log::enemy_appears(&enemy, location); 30 | Some(enemy) 31 | } else { 32 | None 33 | } 34 | } 35 | 36 | /// Final boss, only appears at level +100 when wearing the ruling ring 37 | fn spawn_gorthaur(player: &Character, location: &location::Location) -> Option<(Class, i32)> { 38 | let wearing_ring = 39 | player.left_ring == Some(Ring::Ruling) || player.right_ring == Some(Ring::Ruling); 40 | 41 | if wearing_ring && location.distance_from_home().len() >= 100 { 42 | let mut class = Class::player_first().clone(); 43 | class.name = String::from("gorthaur"); 44 | class.hp.0 *= 2; 45 | class.strength.0 *= 2; 46 | class.category = Category::Legendary; 47 | Some((class, player.level)) 48 | } else { 49 | None 50 | } 51 | } 52 | 53 | /// Player shadow, appears at home directory 54 | fn spawn_shadow(player: &Character, location: &location::Location) -> Option<(Class, i32)> { 55 | let mut rng = rand::thread_rng(); 56 | if location.is_home() && rng.gen_ratio(1, 10) { 57 | let mut class = player.class.clone(); 58 | class.name = String::from("shadow"); 59 | class.category = Category::Rare; 60 | Some((class, player.level + 3)) 61 | } else { 62 | None 63 | } 64 | } 65 | 66 | /// Easter egg, appears at rpg data dir 67 | fn spawn_dev(player: &Character, location: &location::Location) -> Option<(Class, i32)> { 68 | let mut rng = rand::thread_rng(); 69 | 70 | if location.is_rpg_dir() && rng.gen_ratio(1, 10) { 71 | let mut class = Class::player_first().clone(); 72 | class.name = String::from("dev"); 73 | class.hp.0 /= 2; 74 | class.strength.0 /= 2; 75 | class.speed.0 /= 2; 76 | class.category = Category::Rare; 77 | Some((class, player.level)) 78 | } else { 79 | None 80 | } 81 | } 82 | 83 | /// Choose an enemy randomly, with higher chance to difficult enemies the further from home. 84 | fn spawn_random(player: &Character, distance: &location::Distance) -> (Class, i32) { 85 | // the weights for each group of enemies are different depending on the distance 86 | // the further from home, the bigger the chance to find difficult enemies 87 | let (w_common, w_rare, w_legendary) = match distance { 88 | location::Distance::Near(_) => (10, 2, 0), 89 | location::Distance::Mid(_) => (8, 10, 1), 90 | location::Distance::Far(_) => (0, 8, 2), 91 | }; 92 | 93 | let mut rng = rand::thread_rng(); 94 | 95 | // assign weights to each group and select one 96 | let weights = vec![ 97 | (Category::Common, w_common), 98 | (Category::Rare, w_rare), 99 | (Category::Legendary, w_legendary), 100 | ]; 101 | 102 | let category = weights 103 | .as_slice() 104 | .choose_weighted(&mut rng, |(_c, weight)| *weight) 105 | .unwrap() 106 | .0 107 | .clone(); 108 | 109 | let level = std::cmp::max(player.level / 10 + distance.len() - 1, 1); 110 | (Class::random(category).clone(), level) 111 | } 112 | 113 | #[cfg(test)] 114 | mod tests { 115 | use super::*; 116 | 117 | #[test] 118 | fn test_enemy_level() { 119 | let mut player = Character::player(); 120 | let d1 = location::Distance::from(1); 121 | let d2 = location::Distance::from(2); 122 | let d3 = location::Distance::from(3); 123 | let d10 = location::Distance::from(10); 124 | 125 | assert_eq!(1, spawn_random(&player, &d1).1); 126 | assert_eq!(1, spawn_random(&player, &d2).1); 127 | assert_eq!(2, spawn_random(&player, &d3).1); 128 | assert_eq!(9, spawn_random(&player, &d10).1); 129 | 130 | player.level = 5; 131 | assert_eq!(1, spawn_random(&player, &d1).1); 132 | assert_eq!(1, spawn_random(&player, &d2).1); 133 | assert_eq!(2, spawn_random(&player, &d3).1); 134 | assert_eq!(9, spawn_random(&player, &d10).1); 135 | 136 | player.level = 10; 137 | assert_eq!(1, spawn_random(&player, &d1).1); 138 | assert_eq!(2, spawn_random(&player, &d2).1); 139 | assert_eq!(3, spawn_random(&player, &d3).1); 140 | assert_eq!(10, spawn_random(&player, &d10).1); 141 | } 142 | 143 | #[test] 144 | fn test_run_ring() { 145 | let mut player = Character::player(); 146 | let location = location::tests::location_from("~/1/"); 147 | assert!(spawn(&location, &player).is_some()); 148 | 149 | player.equip_ring(Ring::Evade); 150 | assert!(spawn(&location, &player).is_none()); 151 | 152 | player.equip_ring(Ring::Void); 153 | assert!(spawn(&location, &player).is_none()); 154 | 155 | player.equip_ring(Ring::Void); 156 | assert!(spawn(&location, &player).is_some()); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/item/key.rs: -------------------------------------------------------------------------------- 1 | use super::ring::Ring; 2 | use anyhow::{bail, Result}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::convert::From; 5 | use std::fmt; 6 | use strum_macros::EnumIter; 7 | 8 | #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash, Debug, EnumIter)] 9 | #[serde(try_from = "String", into = "String")] 10 | pub enum Key { 11 | Potion, 12 | Escape, 13 | Remedy, 14 | Ether, 15 | HealthStone, 16 | MagicStone, 17 | PowerStone, 18 | SpeedStone, 19 | LevelStone, 20 | Sword, 21 | Shield, 22 | Ring(Ring), 23 | } 24 | 25 | impl Key { 26 | pub fn from(name: &str) -> Result { 27 | let key = match name.to_lowercase().as_str() { 28 | "potion" | "p" => Key::Potion, 29 | "ether" | "e" => Key::Ether, 30 | "remedy" | "r" => Key::Remedy, 31 | "escape" | "es" => Key::Escape, 32 | "sword" | "sw" => Key::Sword, 33 | "shield" | "sh" => Key::Shield, 34 | "hp-stone" | "hp" => Key::HealthStone, 35 | "mp-stone" | "mp" => Key::MagicStone, 36 | "str-stone" | "str" | "strength" => Key::PowerStone, 37 | "spd-stone" | "spd" | "speed" => Key::SpeedStone, 38 | "lvl-stone" | "level" | "lv" | "lvl" => Key::LevelStone, 39 | "void-rng" | "void" => Key::Ring(Ring::Void), 40 | "att-rng" | "att-ring" | "att" | "attack" | "attack-ring" | "attack-rng" => { 41 | Key::Ring(Ring::Attack) 42 | } 43 | "def-rng" | "def-ring" | "def" | "deffense" | "deffense-ring" | "deffense-rng" => { 44 | Key::Ring(Ring::Deffense) 45 | } 46 | "spd-rng" | "spd-ring" | "speed-ring" | "speed-rng" => Key::Ring(Ring::Speed), 47 | "mag-rng" | "mag-ring" | "mag" | "magic-ring" | "magic-rng" => Key::Ring(Ring::Magic), 48 | "mp-rng" | "mp-ring" => Key::Ring(Ring::MP), 49 | "hp-rng" | "hp-ring" => Key::Ring(Ring::HP), 50 | "evade-rng" | "evade" | "evade-ring" => Key::Ring(Ring::Evade), 51 | "hgen-rng" | "hgen" | "hgen-ring" => Key::Ring(Ring::RegenHP), 52 | "mgen-rng" | "mgen" | "mgen-ring" => Key::Ring(Ring::RegenMP), 53 | "ruling-rng" | "ruling" | "ruling-ring" => Key::Ring(Ring::Ruling), 54 | "protect-rng" | "protect" | "protect-ring" => Key::Ring(Ring::Protect), 55 | "fire-rng" | "fire" | "fire-ring" => Key::Ring(Ring::Fire), 56 | "poison-rng" | "poison" | "poison-ring" => Key::Ring(Ring::Poison), 57 | "double-rng" | "double" | "double-ring" => Key::Ring(Ring::Double), 58 | "counter-rng" | "counter" | "counter-ring" => Key::Ring(Ring::Counter), 59 | "revive-rng" | "revive" | "revive-ring" => Key::Ring(Ring::Revive), 60 | "chest-rng" | "chest" | "chest-ring" => Key::Ring(Ring::Chest), 61 | "gold-rng" | "gold" | "gold-ring" => Key::Ring(Ring::Gold), 62 | "diamond-rng" | "diamond" | "diamond-ring" => Key::Ring(Ring::Diamond), 63 | key => bail!("item {} not found", key), 64 | }; 65 | Ok(key) 66 | } 67 | } 68 | 69 | impl fmt::Display for Key { 70 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 71 | let name = match self { 72 | Key::Potion => "potion", 73 | Key::Escape => "escape", 74 | Key::Remedy => "remedy", 75 | Key::Ether => "ether", 76 | Key::HealthStone => "hp-stone", 77 | Key::MagicStone => "mp-stone", 78 | Key::PowerStone => "str-stone", 79 | Key::SpeedStone => "spd-stone", 80 | Key::LevelStone => "lvl-stone", 81 | Key::Sword => "sword", 82 | Key::Shield => "shield", 83 | Key::Ring(Ring::Void) => "void-rng", 84 | Key::Ring(Ring::Attack) => "att-rng", 85 | Key::Ring(Ring::Deffense) => "def-rng", 86 | Key::Ring(Ring::Speed) => "spd-rng", 87 | Key::Ring(Ring::Magic) => "mag-rng", 88 | Key::Ring(Ring::MP) => "mp-rng", 89 | Key::Ring(Ring::HP) => "hp-rng", 90 | Key::Ring(Ring::Evade) => "evade-rng", 91 | Key::Ring(Ring::RegenHP) => "hgen-rng", 92 | Key::Ring(Ring::RegenMP) => "mgen-rng", 93 | Key::Ring(Ring::Ruling) => "ruling-rng", 94 | Key::Ring(Ring::Protect) => "protect-rng", 95 | Key::Ring(Ring::Fire) => "fire-rng", 96 | Key::Ring(Ring::Poison) => "poison-rng", 97 | Key::Ring(Ring::Double) => "double-rng", 98 | Key::Ring(Ring::Counter) => "counter-rng", 99 | Key::Ring(Ring::Revive) => "revive-rng", 100 | Key::Ring(Ring::Chest) => "chest-rng", 101 | Key::Ring(Ring::Gold) => "gold-rng", 102 | Key::Ring(Ring::Diamond) => "diamond-rng", 103 | }; 104 | 105 | write!(f, "{}", name) 106 | } 107 | } 108 | 109 | // these From impls together with the serde try_from/into config 110 | // allow Key variants to be used as keys in JSON objects for serialization 111 | impl From for Key { 112 | fn from(key: String) -> Self { 113 | Key::from(&key).unwrap() 114 | } 115 | } 116 | 117 | impl From for String { 118 | fn from(key_str: Key) -> Self { 119 | key_str.to_string() 120 | } 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use super::*; 126 | use strum::IntoEnumIterator; 127 | 128 | #[test] 129 | fn from_into() { 130 | // verify that all existing keys can be parsed from strings 131 | // otherwise deserialization wouldn't be possible 132 | for key in Key::iter() { 133 | if let Key::Ring(_) = key { 134 | for ring in Ring::iter() { 135 | let ring_key = Key::Ring(ring); 136 | let parsed = Key::from(String::from(ring_key.clone()).as_str()).unwrap(); 137 | assert_eq!(ring_key, parsed); 138 | } 139 | } else { 140 | let parsed = Key::from(String::from(key.clone()).as_str()).unwrap(); 141 | assert_eq!(key, parsed); 142 | } 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## [1.2.0](https://github.com/facundoolano/rpg-cli/releases/tag/1.2.0) - 2024-09-24 6 | ### Changed 7 | * Bump rust edition from 2018 -> 2021 #142 8 | 9 | ### Fixed 10 | * Now uses $XDG_DATA_HOME/rpg (~/.local/share/rpg) for userdata. Will still use ~/.rpg if folder exist #141 11 | * Fix clap compilation errors #142 12 | * Fixed misplaced magic_attack and physical_attack #147 13 | 14 | 15 | ## [1.0.1](https://github.com/facundoolano/rpg-cli/releases/tag/1.0.1) - 2022-02-10 16 | ### Fixed 17 | * `cd --force` now applies directory side effects (status effects and heal at home) #128 18 | * Tombstone was not generated when dead by status effect #136 19 | 20 | ## [1.0.0](https://github.com/facundoolano/rpg-cli/releases/tag/1.0.0) - 2021-09-07 21 | ### Fixed 22 | * Don't reward items, gold or xp for cheap victories 6dc970a 23 | 24 | ## [1.0.0-beta](https://github.com/facundoolano/rpg-cli/releases/tag/1.0.0-beta) - 2021-09-05 25 | ### Added 26 | * Quest to beat your own shadow #86 27 | * Easter egg quest #87 28 | * Sorcerer enemy class #88 29 | * Stat increasing stones #95 30 | * Effect rings as items and equipment, initial set of stat-based rings #98 31 | * RegenHP and RegenMP rings #109 32 | * Status effect rings #110 33 | * Battle related rings #113 34 | * Treasure related rings #114 35 | * Ring related quests #115 36 | 37 | ### Changed 38 | * Remember unlocked quests and todo list order #89 39 | * Cheaper ether 62bb9ed 40 | * Renamed status effects "poisoned" to "poison" and "burning" to "burn" #92 41 | * Doubled ether restored mp 0e01209 42 | * Tweaked enemy levels to be based primarily on distance from home rather than player level 0798d53 43 | * Changed internal representation of equipment #99 44 | * When a magic-using character runs out of mp, its physical attacks incorporate the weapon contribution 39c5e01 45 | * Changed internal representation of items #105 46 | * Fail gracefully on data breaking changes #107 47 | * Show items bought and money spent in the buy command output #108 48 | * Show mp cost in magic attacks 9f92efc 49 | * The stat command can be used to describe items and equipment #117 50 | * Equipment level found in chest based on distance instead of player level d22d3b9 51 | * Game balance related tweaks #118 52 | 53 | ### Fixed 54 | * Reach level 50 and 100 unlock and reward 4128f75 55 | * Properly report raise class levels quest progress e7d73f9 56 | * Reach level quests rewarded when multiple levels raised in a single event 60f5fb2 57 | * Give base mp when switching to a magic class from a non base level 96c2de6 58 | * Missed levels with class quest completion 1ec760 59 | * Tweak gold found in chests 0317979 83691fa 60 | * Don't add xp beyond the actual inflicted damage (prevents high xp when beating weaker enemies) 812a5f1 61 | * Continue moving through dirs after successful bribe/run away 570a0de 62 | 63 | ## [0.6.0](https://github.com/facundoolano/rpg-cli/releases/tag/0.6.0) - 2021-08-04 64 | ### Added 65 | * Customizable classes file #76 66 | * Thief class and command to select player class #77 67 | * Mage class, magic attacks and ether item #78 68 | * Quests to raise 5 levels on each available player class #81 69 | * Reach level 50 and level 100 quests #81 70 | * Items rewarded on battle won #82 71 | 72 | ### Removed 73 | * Backwards compatibility code for binary game data from v0.4.0 #75 74 | 75 | ### Changed 76 | * `rpg reset --hard` removes datafile instead of entire .rpg dir 5adfb87 77 | * Character speed contributes to run away success probability 4d6e1a3 78 | * Initial stats are randomized 50af983 79 | * Use GitHub actions instead of travis for CI and release building #80 80 | * Change xp gained based on enemy class category #83 81 | * Accept multiple items in buy and use commands #84 82 | 83 | ### Fixed 84 | * Find chest quest not rewarded when finding a tombstone c0d62aa 85 | 86 | ## [0.5.0](https://github.com/facundoolano/rpg-cli/releases/tag/0.5.0) - 2021-06-26 87 | ### Added 88 | * a `rpg reset --hard` flag to remove data files and forget information from previous plays #46 89 | * Quest system #47 90 | * Tutorial quests #49 91 | * `rpg ls` command to look for chests at the current location #51 92 | * Example sh file #54 93 | * Poisoned and burning status effects #48 94 | 95 | ### Changed 96 | * Tombstones are found with `rpg ls` instead of automatically #52 97 | 98 | ### Fixed 99 | * When hero dies twice in the same location, tombstone chest contents 100 | are merged instead of overridden #73 101 | 102 | ## [0.4.1](https://github.com/facundoolano/rpg-cli/releases/tag/0.4.1) - 2021-06-14 103 | ### Changed 104 | * Game data is now serialized to JSON to allow extending it without breaking backwards compatibility. 105 | 106 | ## [0.4.0](https://github.com/facundoolano/rpg-cli/releases/tag/0.4.0) - 2021-06-05 107 | ### Added 108 | * This Changelog 109 | * `rpg cd -f` sets the hero location without initiating battles, intended for custom shell integrations 110 | * `rpg battle` initiates a battle (with a probability) at the hero's current location. 111 | * --quiet,-q option to reduce output while changing directories and printing the hero status. 112 | * --plain to facilitate scripting around the hero stats. 113 | * Documentation for shell integrations. 114 | 115 | ### Changed 116 | * General command overhaul, now all actions are done via a subcommand: `rpg cd`, `rpg stat`, etc., with status printing being the default. 117 | * `rpg cd` without args moves the hero to home and `rpg cd -` moves it to `$OLDPWD` (when present) to match the `cd` behavior 4ba4c59 118 | * --shop,-s renamed to buy,b and --inventory,-i renamed to use,u f737a81 119 | * Removed most empty lines from output. 120 | 121 | ## [0.3.0](https://github.com/facundoolano/rpg-cli/releases/tag/0.3.0) - 2021-05-28 122 | ### Added 123 | * Binary upload from travis on GitHub releases #36 124 | * Experimental support for windows #35 125 | * Different OS tests in travis 3a7eb6b 126 | 127 | ### Changed 128 | * Print version number in help 8efdead 129 | * Rebalancing of character stats to prevent overgrowth #33 130 | * Several updates to the README instructions 131 | 132 | ### Fixed 133 | * Prevent overflow bug at high levels #33 134 | * Keep items sorted when printing the character status #15 135 | * Missing Cargo.lock checked into the repository #26 136 | 137 | ## [0.2.0](https://github.com/facundoolano/rpg-cli/releases/tag/0.2.0) - 2021-05-23 138 | 139 | ## [0.1.0](https://github.com/facundoolano/rpg-cli/releases/tag/0.1.0) - 2021-05-06 140 | -------------------------------------------------------------------------------- /src/location.rs: -------------------------------------------------------------------------------- 1 | use crate::datafile::rpg_dir; 2 | use serde::{Deserialize, Serialize}; 3 | use std::path; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Eq, Clone)] 6 | pub struct Location { 7 | path: path::PathBuf, 8 | } 9 | 10 | impl Location { 11 | /// Build a location from the given path string. 12 | /// The path is validated to exist and converted to it's canonical form. 13 | pub fn from(path: &str) -> Result { 14 | // if input doesn't come from shell, we want to interpret ~ as home ourselves 15 | let mut path = patch_oldpwd(path); 16 | if path.starts_with('~') { 17 | // TODO figure out these string lossy stuff 18 | let home_str = dirs::home_dir().unwrap().to_string_lossy().to_string(); 19 | path = path.replacen('~', &home_str, 1) 20 | } 21 | 22 | let path = path::Path::new(&path); 23 | // this is a replacement to std::fs::canonicalize() 24 | // that circumvents windows quirks with paths 25 | let path = dunce::canonicalize(path)?; 26 | Ok(Self { path }) 27 | } 28 | 29 | pub fn path_string(&self) -> String { 30 | self.path.to_string_lossy().to_string() 31 | } 32 | 33 | pub fn home() -> Self { 34 | Self { 35 | path: dirs::home_dir().unwrap(), 36 | } 37 | } 38 | 39 | pub fn is_home(&self) -> bool { 40 | self.path == dirs::home_dir().unwrap() 41 | } 42 | 43 | pub fn is_rpg_dir(&self) -> bool { 44 | self.path == rpg_dir() 45 | } 46 | 47 | /// Return a new location that it's one dir closer to the given destination. 48 | pub fn go_to(&self, dest: &Self) -> Self { 49 | let next = if dest.path.starts_with(&self.path) { 50 | let self_len = self.path.components().count(); 51 | dest.path.components().take(self_len + 1).collect() 52 | } else { 53 | self.path.parent().unwrap().to_path_buf() 54 | }; 55 | Self { path: next } 56 | } 57 | 58 | fn distance_from(&self, other: &Self) -> Distance { 59 | let mut current = self.path.as_path(); 60 | let dest = other.path.as_path(); 61 | 62 | let mut distance = 0; 63 | while !dest.starts_with(current) { 64 | current = current.parent().unwrap(); 65 | distance += 1; 66 | } 67 | let dest = dest.strip_prefix(current).unwrap(); 68 | let len = distance + dest.components().count() as i32; 69 | Distance::from(len) 70 | } 71 | 72 | pub fn distance_from_home(&self) -> Distance { 73 | self.distance_from(&Location::home()) 74 | } 75 | } 76 | 77 | /// To match the `cd` behavior, when the path '-' is passed try to 78 | /// go to the previous location based on $OLDPWD. 79 | /// If that env var is missing go home. 80 | fn patch_oldpwd(path: &str) -> String { 81 | if path == "-" { 82 | if let Ok(val) = std::env::var("OLDPWD") { 83 | val 84 | } else { 85 | String::from("~") 86 | } 87 | } else { 88 | path.to_string() 89 | } 90 | } 91 | 92 | impl PartialEq for Location { 93 | fn eq(&self, other: &Self) -> bool { 94 | self.path == other.path 95 | } 96 | } 97 | 98 | impl std::hash::Hash for Location { 99 | fn hash(&self, state: &mut H) { 100 | self.path.hash(state) 101 | } 102 | } 103 | 104 | impl std::fmt::Display for Location { 105 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 106 | let home = dirs::home_dir().unwrap().to_string_lossy().to_string(); 107 | let mut loc = self.path.to_string_lossy().replace(&home, "~"); 108 | if loc == "~" { 109 | loc = "home".to_string(); 110 | } 111 | write!(f, "{}", loc) 112 | } 113 | } 114 | 115 | /// Some decisions are made branching on whether the distance from the home dir 116 | /// is small, medium or large. This enum encapsulate the definition of those. 117 | pub enum Distance { 118 | Near(i32), 119 | Mid(i32), 120 | Far(i32), 121 | } 122 | 123 | impl Distance { 124 | pub fn from(len: i32) -> Self { 125 | match len { 126 | n if n <= 6 => Self::Near(len), 127 | n if n <= 15 => Self::Mid(len), 128 | _ => Self::Far(len), 129 | } 130 | } 131 | 132 | pub fn len(&self) -> i32 { 133 | match self { 134 | Distance::Near(s) => *s, 135 | Distance::Mid(s) => *s, 136 | Distance::Far(s) => *s, 137 | } 138 | } 139 | } 140 | 141 | #[cfg(test)] 142 | pub mod tests { 143 | use super::*; 144 | 145 | #[test] 146 | fn test_from() { 147 | assert_ne!(Location::from("/").unwrap(), Location::home()); 148 | assert_eq!(Location::from("~").unwrap(), Location::from("~/").unwrap()); 149 | assert_eq!( 150 | Location::from("~/.").unwrap(), 151 | Location::from("~/").unwrap() 152 | ); 153 | // FIXME this only works if /usr/bin exists 154 | // assert_eq!( 155 | // Location::from("/usr").unwrap(), 156 | // Location::from("/usr/bin/../").unwrap() 157 | // ); 158 | } 159 | 160 | #[test] 161 | fn test_walk_towards() { 162 | let source = location_from("/Users/facundo/dev/"); 163 | let dest = location_from("/"); 164 | 165 | let source = source.go_to(&dest); 166 | assert_eq!(location_from("/Users/facundo/"), source); 167 | let source = source.go_to(&dest); 168 | assert_eq!(location_from("/Users/"), source); 169 | let source = source.go_to(&dest); 170 | assert_eq!(location_from("/"), source); 171 | let source = source.go_to(&dest); 172 | assert_eq!(location_from("/"), source); 173 | 174 | let source = location_from("/Users/facundo/rust/rpg"); 175 | let dest = location_from("/Users/facundo/erlang/app"); 176 | 177 | let source = source.go_to(&dest); 178 | assert_eq!(location_from("/Users/facundo/rust/"), source); 179 | let source = source.go_to(&dest); 180 | assert_eq!(location_from("/Users/facundo/"), source); 181 | let source = source.go_to(&dest); 182 | assert_eq!(location_from("/Users/facundo/erlang"), source); 183 | let source = source.go_to(&dest); 184 | assert_eq!(location_from("/Users/facundo/erlang/app"), source); 185 | } 186 | 187 | #[test] 188 | fn test_distance() { 189 | let distance = |from, to| location_from(from).distance_from(&location_from(to)); 190 | 191 | assert_eq!(distance("/Users/facundo", "/Users/facundo").len(), 0); 192 | assert_eq!(distance("/Users/facundo", "/Users/facundo/other").len(), 1); 193 | assert_eq!(distance("/Users/facundo/other", "/Users/facundo/").len(), 1); 194 | assert_eq!(distance("/Users/facundo/other", "/").len(), 3); 195 | assert_eq!(distance("/", "/Users/facundo/other").len(), 3); 196 | assert_eq!( 197 | distance("/Users/rusty/cage", "/Users/facundo/other").len(), 198 | 4 199 | ); 200 | assert_eq!( 201 | distance("/Users/facundo/other", "/Users/rusty/cage").len(), 202 | 4 203 | ); 204 | assert_eq!(Location::home().distance_from_home().len(), 0); 205 | } 206 | 207 | /// test-only equivalent for Location::from, specifically to bypass 208 | /// path existence checks. 209 | pub fn location_from(path: &str) -> Location { 210 | let path = path::Path::new(path); 211 | Location { 212 | path: path.to_path_buf(), 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/item/shop.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use super::equipment::Equipment; 4 | use super::key::Key; 5 | use super::ring::Ring; 6 | use super::Item; 7 | use crate::character::Character; 8 | use crate::game::Game; 9 | use crate::log; 10 | use crate::quest; 11 | use anyhow::{bail, Result}; 12 | use std::collections::HashMap; 13 | 14 | /// Print the list of available items and their price. 15 | pub fn list(game: &Game) -> Result<()> { 16 | if !game.location.is_home() { 17 | bail!("Shop is only allowed at home."); 18 | } 19 | 20 | let items = available_items(&game.player) 21 | .iter() 22 | .map(|s| (s.cost(), s.to_string())) 23 | .collect(); 24 | log::shop_list(game, items); 25 | Ok(()) 26 | } 27 | 28 | /// Buy as much as possible from the given item list. 29 | /// Will stop buying if there's an error (ran out of money or requested item is 30 | /// not available), but will keep the shopped items so far. 31 | /// Will bail on error only after reporting what was bought. 32 | pub fn buy(game: &mut Game, item_keys: &[Key]) -> Result<()> { 33 | if !game.location.is_home() { 34 | bail!("Shop is only allowed at home."); 35 | } 36 | 37 | let mut item_counts = HashMap::new(); 38 | let mut total_cost = 0; 39 | let mut error = String::from(""); 40 | 41 | // Buy one at a time and break on first error 42 | for key in item_keys { 43 | // get list every time to prevent e.g. buying the sword twice 44 | let item = available_items(&game.player) 45 | .into_iter() 46 | .find(|s| s.to_key() == *key); 47 | 48 | if let Some(item) = item { 49 | let item_cost = item.cost(); 50 | 51 | if game.gold < item_cost { 52 | error = "Not enough gold.".to_string(); 53 | break; 54 | } 55 | game.gold -= item_cost; 56 | item.add_to(game); 57 | 58 | total_cost += item_cost; 59 | *item_counts.entry(key.clone()).or_insert(0) += 1; 60 | quest::item_bought(game, item.to_key()); 61 | } else { 62 | error = format!("{} not available.", key); 63 | break; 64 | } 65 | } 66 | 67 | // log what could be bought even if there was an error 68 | log::shop_buy(total_cost, &item_counts); 69 | if !error.is_empty() { 70 | bail!(error); 71 | } 72 | Ok(()) 73 | } 74 | 75 | /// Build a list of items currently available at the shop 76 | fn available_items(player: &Character) -> Vec> { 77 | let mut items = Vec::>::new(); 78 | let level = player.rounded_level(); 79 | 80 | let sword = Equipment::sword(level); 81 | if sword.is_upgrade_from(&player.sword) { 82 | items.push(Box::new(sword)); 83 | } 84 | 85 | let shield = Equipment::shield(level); 86 | if shield.is_upgrade_from(&player.shield) { 87 | items.push(Box::new(shield)); 88 | } 89 | 90 | let potion = super::Potion::new(level); 91 | items.push(Box::new(potion)); 92 | 93 | let ether = super::Ether::new(level); 94 | items.push(Box::new(ether)); 95 | 96 | let remedy = super::Remedy::new(); 97 | items.push(Box::new(remedy)); 98 | 99 | let escape = super::Escape::new(); 100 | items.push(Box::new(escape)); 101 | 102 | if player.level >= 25 { 103 | items.push(Box::new(Ring::Diamond)); 104 | } 105 | 106 | items 107 | } 108 | 109 | trait Shoppable: Display { 110 | fn cost(&self) -> i32; 111 | fn add_to(&self, game: &mut Game); 112 | fn to_key(&self) -> Key; 113 | } 114 | 115 | impl Shoppable for Equipment { 116 | fn cost(&self) -> i32 { 117 | self.level() * 500 118 | } 119 | 120 | fn add_to(&self, game: &mut Game) { 121 | match self.key() { 122 | Key::Sword => game.player.sword = Some(self.clone()), 123 | Key::Shield => game.player.shield = Some(self.clone()), 124 | _ => {} 125 | } 126 | } 127 | 128 | fn to_key(&self) -> Key { 129 | self.key() 130 | } 131 | } 132 | 133 | impl Shoppable for super::Potion { 134 | fn cost(&self) -> i32 { 135 | self.level * 200 136 | } 137 | 138 | fn add_to(&self, game: &mut Game) { 139 | game.add_item(Box::new(self.clone())); 140 | } 141 | 142 | fn to_key(&self) -> Key { 143 | self.key() 144 | } 145 | } 146 | 147 | impl Shoppable for super::Escape { 148 | fn cost(&self) -> i32 { 149 | 1000 150 | } 151 | 152 | fn add_to(&self, game: &mut Game) { 153 | game.add_item(Box::new(self.clone())); 154 | } 155 | 156 | fn to_key(&self) -> Key { 157 | self.key() 158 | } 159 | } 160 | 161 | impl Shoppable for super::Remedy { 162 | fn cost(&self) -> i32 { 163 | 400 164 | } 165 | 166 | fn add_to(&self, game: &mut Game) { 167 | game.add_item(Box::new(self.clone())); 168 | } 169 | 170 | fn to_key(&self) -> Key { 171 | self.key() 172 | } 173 | } 174 | 175 | impl Shoppable for super::Ether { 176 | fn cost(&self) -> i32 { 177 | self.level * 250 178 | } 179 | 180 | fn add_to(&self, game: &mut Game) { 181 | game.add_item(Box::new(self.clone())); 182 | } 183 | 184 | fn to_key(&self) -> Key { 185 | self.key() 186 | } 187 | } 188 | 189 | impl Shoppable for Ring { 190 | fn cost(&self) -> i32 { 191 | 50_000 192 | } 193 | 194 | fn add_to(&self, game: &mut Game) { 195 | game.add_item(Box::new(self.clone())); 196 | } 197 | 198 | fn to_key(&self) -> Key { 199 | self.key() 200 | } 201 | } 202 | 203 | #[cfg(test)] 204 | mod tests { 205 | use super::super::Potion; 206 | use super::*; 207 | 208 | #[test] 209 | fn buy_one() { 210 | let potion = Potion::new(1); 211 | assert_eq!(200, potion.cost()); 212 | 213 | let mut game = Game::new(); 214 | game.gold = 1000; 215 | 216 | let result = buy(&mut game, &[Key::Potion]); 217 | assert!(result.is_ok()); 218 | assert_eq!(800, game.gold); 219 | assert_eq!(1, *game.inventory().get(&Key::Potion).unwrap()); 220 | } 221 | 222 | #[test] 223 | fn buy_multiple() { 224 | let mut game = Game::new(); 225 | game.gold = 1000; 226 | 227 | let result = buy(&mut game, &[Key::Potion, Key::Potion, Key::Potion]); 228 | assert!(result.is_ok()); 229 | assert_eq!(400, game.gold); 230 | assert_eq!(3, *game.inventory().get(&Key::Potion).unwrap()); 231 | } 232 | 233 | #[test] 234 | fn buy_until_no_money() { 235 | let mut game = Game::new(); 236 | game.gold = 500; 237 | 238 | let result = buy(&mut game, &[Key::Potion, Key::Potion, Key::Potion]); 239 | assert!(result.is_err()); 240 | assert_eq!(100, game.gold); 241 | assert_eq!(2, *game.inventory().get(&Key::Potion).unwrap()); 242 | } 243 | 244 | #[test] 245 | fn buy_until_not_available() { 246 | let mut game = Game::new(); 247 | game.gold = 1000; 248 | 249 | // not sellable 250 | let result = buy(&mut game, &[Key::Potion, Key::MagicStone, Key::Potion]); 251 | assert!(result.is_err()); 252 | assert_eq!(800, game.gold); 253 | assert_eq!(1, *game.inventory().get(&Key::Potion).unwrap()); 254 | 255 | // sellable once, then unavailable 256 | let mut game = Game::new(); 257 | game.gold = 2000; 258 | let result = buy( 259 | &mut game, 260 | &[Key::Potion, Key::Shield, Key::Shield, Key::Potion], 261 | ); 262 | assert!(result.is_err()); 263 | // 200 from potion - 500 from shield (once) 264 | assert_eq!(1300, game.gold); 265 | assert_eq!(1, *game.inventory().get(&Key::Potion).unwrap()); 266 | assert!(game.player.shield.is_some()); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/randomizer.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use crate::character::StatusEffect; 4 | use crate::location; 5 | use rand::Rng; 6 | use std::cmp::max; 7 | 8 | /// This trait exposes functions to deal with any element of the game that 9 | /// needs to incorporate randomness. 10 | /// It basically wraps all calls to the rand crate, allowing to replace it with a 11 | /// noop implementation in tests to make the logic deterministic. 12 | pub trait Randomizer { 13 | fn should_enemy_appear(&self, distance: &location::Distance) -> bool; 14 | 15 | fn bribe_succeeds(&self) -> bool; 16 | 17 | fn run_away_succeeds( 18 | &self, 19 | player_level: i32, 20 | enemy_level: i32, 21 | player_speed: i32, 22 | enemy_speed: i32, 23 | ) -> bool; 24 | 25 | fn enemy_level(&self, level: i32) -> i32; 26 | 27 | fn damage(&self, value: i32) -> i32; 28 | 29 | fn is_miss(&self, attacker_speed: i32, receiver_speed: i32) -> bool; 30 | 31 | fn is_critical(&self) -> bool; 32 | 33 | fn counter_attack(&self) -> bool; 34 | 35 | fn inflicted(&self, status: Option<(StatusEffect, u32)>) -> Option; 36 | 37 | fn gold_gained(&self, base: i32) -> i32; 38 | 39 | fn stat_increase(&self, increase: i32) -> i32; 40 | 41 | fn range(&self, max: i32) -> i32; 42 | 43 | fn gold_chest(&self, distance: &location::Distance) -> bool; 44 | fn equipment_chest(&self, distance: &location::Distance) -> bool; 45 | fn ring_chest(&self, distance: &location::Distance) -> bool; 46 | fn item_chest(&self, distance: &location::Distance) -> bool; 47 | } 48 | 49 | #[cfg(not(test))] 50 | /// Get the randomizer instance. This function provides indirection 51 | /// so randomness can be turned off during tests to make them deterministic 52 | pub fn random() -> DefaultRandomizer { 53 | DefaultRandomizer {} 54 | } 55 | 56 | #[cfg(test)] 57 | pub fn random() -> TestRandomizer { 58 | TestRandomizer {} 59 | } 60 | 61 | pub struct DefaultRandomizer; 62 | 63 | impl Randomizer for DefaultRandomizer { 64 | fn should_enemy_appear(&self, distance: &location::Distance) -> bool { 65 | let mut rng = rand::thread_rng(); 66 | 67 | match distance { 68 | location::Distance::Near(_) => rng.gen_ratio(1, 3), 69 | location::Distance::Mid(_) => rng.gen_ratio(1, 2), 70 | location::Distance::Far(_) => rng.gen_ratio(2, 3), 71 | } 72 | } 73 | 74 | fn bribe_succeeds(&self) -> bool { 75 | let mut rng = rand::thread_rng(); 76 | rng.gen_ratio(1, 2) 77 | } 78 | 79 | fn run_away_succeeds( 80 | &self, 81 | player_level: i32, 82 | enemy_level: i32, 83 | player_speed: i32, 84 | enemy_speed: i32, 85 | ) -> bool { 86 | let level_contrib = if player_level > enemy_level { 1 } else { 0 }; 87 | 88 | let speed_contrib = if player_speed > enemy_speed { 2 } else { 0 }; 89 | 90 | let mut rng = rand::thread_rng(); 91 | rng.gen_ratio(1 + level_contrib + speed_contrib, 5) 92 | } 93 | 94 | fn enemy_level(&self, level: i32) -> i32 { 95 | let mut rng = rand::thread_rng(); 96 | max(1, level + rng.gen_range(-4..5)) 97 | } 98 | 99 | /// add +/- 20% variance to a the damage 100 | fn damage(&self, value: i32) -> i32 { 101 | let value = value as f64; 102 | 103 | let mut rng = rand::thread_rng(); 104 | let min_val = (value * 0.8).floor() as i32; 105 | let max_val = (value * 1.2).ceil() as i32; 106 | max(1, rng.gen_range(min_val..=max_val)) 107 | } 108 | 109 | fn is_miss(&self, attacker_speed: i32, receiver_speed: i32) -> bool { 110 | if receiver_speed > attacker_speed { 111 | let ratio = receiver_speed / attacker_speed; 112 | let ratio = max(1, 5 - ratio) as u32; 113 | let mut rng = rand::thread_rng(); 114 | return rng.gen_ratio(1, ratio); 115 | } 116 | false 117 | } 118 | 119 | fn is_critical(&self) -> bool { 120 | let mut rng = rand::thread_rng(); 121 | rng.gen_ratio(1, 20) 122 | } 123 | 124 | fn counter_attack(&self) -> bool { 125 | let mut rng = rand::thread_rng(); 126 | rng.gen_ratio(1, 2) 127 | } 128 | 129 | fn inflicted(&self, status: Option<(StatusEffect, u32)>) -> Option { 130 | if let Some((status, ratio)) = status { 131 | let mut rng = rand::thread_rng(); 132 | if rng.gen_ratio(1, ratio) { 133 | return Some(status); 134 | } 135 | } 136 | None 137 | } 138 | 139 | fn gold_gained(&self, base: i32) -> i32 { 140 | let mut rng = rand::thread_rng(); 141 | let min = (base as f64 * 0.6) as i32; 142 | let max = (base as f64 * 1.3) as i32; 143 | rng.gen_range(min..=max) 144 | } 145 | 146 | fn stat_increase(&self, increase: i32) -> i32 { 147 | let min_value = max(1, increase / 2); 148 | let max_value = 3 * increase / 2; 149 | 150 | let mut rng = rand::thread_rng(); 151 | rng.gen_range(min_value..=max_value) 152 | } 153 | 154 | fn range(&self, max: i32) -> i32 { 155 | let mut rng = rand::thread_rng(); 156 | rng.gen_range(0..max) 157 | } 158 | 159 | fn gold_chest(&self, distance: &location::Distance) -> bool { 160 | let mut rng = rand::thread_rng(); 161 | 162 | match distance { 163 | location::Distance::Near(_) => rng.gen_ratio(6, 30), 164 | location::Distance::Mid(_) => rng.gen_ratio(7, 30), 165 | location::Distance::Far(_) => rng.gen_ratio(4, 30), 166 | } 167 | } 168 | 169 | fn equipment_chest(&self, distance: &location::Distance) -> bool { 170 | let mut rng = rand::thread_rng(); 171 | 172 | match distance { 173 | location::Distance::Near(_) => rng.gen_ratio(1, 30), 174 | location::Distance::Mid(_) => rng.gen_ratio(3, 30), 175 | location::Distance::Far(_) => rng.gen_ratio(5, 30), 176 | } 177 | } 178 | 179 | fn ring_chest(&self, distance: &location::Distance) -> bool { 180 | let mut rng = rand::thread_rng(); 181 | 182 | match distance { 183 | location::Distance::Near(_) => false, 184 | location::Distance::Mid(_) => rng.gen_ratio(3, 30), 185 | location::Distance::Far(_) => rng.gen_ratio(5, 30), 186 | } 187 | } 188 | 189 | fn item_chest(&self, distance: &location::Distance) -> bool { 190 | let mut rng = rand::thread_rng(); 191 | 192 | match distance { 193 | location::Distance::Near(_) => rng.gen_ratio(1, 50), 194 | location::Distance::Mid(_) => rng.gen_ratio(5, 50), 195 | location::Distance::Far(_) => rng.gen_ratio(10, 50), 196 | } 197 | } 198 | } 199 | 200 | /// The test randomizer just exposes the same functions as the default one 201 | /// but return deterministic results. 202 | pub struct TestRandomizer; 203 | 204 | impl Randomizer for TestRandomizer { 205 | fn should_enemy_appear(&self, _distance: &location::Distance) -> bool { 206 | true 207 | } 208 | 209 | fn bribe_succeeds(&self) -> bool { 210 | false 211 | } 212 | 213 | fn run_away_succeeds( 214 | &self, 215 | _player_level: i32, 216 | _enemy_level: i32, 217 | _player_speed: i32, 218 | _enemy_speed: i32, 219 | ) -> bool { 220 | false 221 | } 222 | 223 | fn enemy_level(&self, level: i32) -> i32 { 224 | level 225 | } 226 | 227 | fn damage(&self, value: i32) -> i32 { 228 | value 229 | } 230 | 231 | fn is_miss(&self, _attacker_speed: i32, _receiver_speed: i32) -> bool { 232 | false 233 | } 234 | 235 | fn is_critical(&self) -> bool { 236 | false 237 | } 238 | 239 | fn counter_attack(&self) -> bool { 240 | true 241 | } 242 | 243 | fn inflicted(&self, _status: Option<(StatusEffect, u32)>) -> Option { 244 | None 245 | } 246 | 247 | fn gold_gained(&self, base: i32) -> i32 { 248 | base 249 | } 250 | 251 | fn stat_increase(&self, increase: i32) -> i32 { 252 | increase 253 | } 254 | 255 | fn range(&self, max: i32) -> i32 { 256 | max 257 | } 258 | 259 | fn gold_chest(&self, _distance: &location::Distance) -> bool { 260 | false 261 | } 262 | 263 | fn equipment_chest(&self, _distance: &location::Distance) -> bool { 264 | false 265 | } 266 | 267 | fn item_chest(&self, _distance: &location::Distance) -> bool { 268 | false 269 | } 270 | 271 | fn ring_chest(&self, _distance: &location::Distance) -> bool { 272 | false 273 | } 274 | } 275 | 276 | #[cfg(test)] 277 | mod tests { 278 | use super::*; 279 | 280 | #[test] 281 | fn test_increase_stat() { 282 | let rand = DefaultRandomizer {}; 283 | 284 | // current hp lvl1 285 | let value = rand.stat_increase(7); 286 | assert!((3..=10).contains(&value), "value was {}", value); 287 | 288 | // current strength lvl1 289 | let value = rand.stat_increase(3); 290 | assert!((1..=4).contains(&value), "value was {}", value); 291 | 292 | // current speed lvl1 293 | let value = rand.stat_increase(2); 294 | assert!((1..=3).contains(&value), "value was {}", value); 295 | 296 | // small increase 297 | let value = rand.stat_increase(1); 298 | assert!((1..=2).contains(&value), "value was {}", value); 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rpg-cli — your filesystem as a dungeon! 2 | 3 | rpg-cli is a minimalist [computer RPG](https://en.wikipedia.org/wiki/Role-playing_video_game) written in Rust. Its command-line interface can be used as a `cd` replacement where you randomly encounter enemies as you change directories. 4 | 5 | ![](rpg-cli.png) 6 | 7 | Features: 8 | 9 | * Character stats and leveling system. 10 | * Automatic turn-based combat. 11 | * Item and equipment support. 12 | * Warrior, thief and mage player classes. 13 | * 15+ Enemy classes. 14 | * Extensible player and enemy classes via configuration. 15 | * Permadeath with item recovering. 16 | * Quests to-do list. 17 | * Chests hidden in directories. 18 | 19 | See [this blog post](https://olano.dev/blog/deconstructing-the-role-playing-videogame/) for background on the development process. 20 | 21 | ## Installation 22 | 23 | ### From binary 24 | 25 | Just download the binary for your platform (linux/macOS/windows) from the [GitHub releases page](https://github.com/facundoolano/rpg-cli/releases/latest). 26 | 27 | ### Using Cargo 28 | Assuming you have [Rust and Cargo installed](https://doc.rust-lang.org/cargo/getting-started/installation.html#install-rust-and-cargo): 29 | 30 | $ cargo install --git https://github.com/facundoolano/rpg-cli --force --tag 1.2.0 31 | 32 | The binary should be available as `rpg-cli` (assuming you have `~/.cargo/bin` in your `$PATH`). 33 | 34 | ### Other installation methods 35 |
36 | Show details 37 | 38 | #### Homebrew (macOS) 39 | You can use homebrew to install the binary on macOS:: 40 | 41 | $ brew install rpg-cli 42 | 43 | #### Nixpkgs 44 | If you use Nix/NixOS you can get rpg-cli from nixpkgs, either install it by adding it to your system config, with `nix-env -i rpg-cli`/`nix profile install nixpkgs#rpg-cli` or try it in a ephemeral shell with `nix-shell -p rpg-cli`/`nix shell nixpkgs#rpg-cli`. 45 | 46 | #### Portage (Gentoo) 47 | If you use Gentoo, you can get rpg-cli from portage: 48 | 49 | # emerge -av games-rpg/rpg-cli 50 | 51 | #### Pacman (Arch Linux) 52 | 53 | rpg-cli can be installed from the [extra repository](https://archlinux.org/packages/extra/x86_64/rpg-cli/) for Arch Linux: 54 | 55 | $ pacman -S rpg-cli 56 |
57 | 58 | ## Shell integration 59 | 60 | The game is designed to integrate with common file system operations, such as changing directories or deleting files. 61 | The most basic type of integration consists in wrapping rpg-cli in a shell function, such that the working directory is updated to match the hero's progress, effectively working as a `cd` alternative: 62 | 63 | ```sh 64 | rpg () { 65 | rpg-cli "$@" 66 | cd "$(rpg-cli pwd)" 67 | } 68 | ``` 69 | 70 | If you want to go all the way and *really* use it in place of `cd`: 71 | 72 | ```sh 73 | cd () { 74 | rpg-cli cd "$@" 75 | builtin cd "$(rpg-cli pwd)" 76 | } 77 | ``` 78 | 79 | Other commands like `rm`, `mkdir`, `touch`, etc. can also be aliased. Check [this example](shell/example.sh) and the [shell integration guide](shell/README.md) for more sophisticated examples, as well as their fish shell equivalents. 80 | 81 | ## Gameplay 82 | 83 | This example session assumes a basic `rpg` function as described in the previous section. 84 | 85 | ### Character setup 86 | The first time you run the program, a new hero is created at the user's home directory. 87 | 88 | ~ $ rpg 89 | warrior[1]@home 90 | hp:[xxxxxxxxxx] 48/48 91 | mp:[----------] 0/0 92 | xp:[----------] 0/30 93 | att:10 mag:0 def:0 spd:10 94 | equip:{} 95 | item:{} 96 | 0g 97 | 98 | When running without parameters, as above, the hero status is printed (health points, accumulated experience, etc.). 99 | The stats are randomized: if you run `rpg reset` you will get a slightly different character every time: 100 | 101 | ~ $ rpg reset; rpg 102 | warrior[1]@home 103 | hp:[xxxxxxxxxx] 50/50 104 | mp:[----------] 0/0 105 | xp:[----------] 0/30 106 | att:13 mag:0 def:0 spd:12 107 | equip:{} 108 | item:{} 109 | 0g 110 | 111 | You can also pick a different class (default options are `warrior`, `thief` and `mage`, but [more can be added](#customize-character-classes)). 112 | For example, the `mage` class enables magic attacks: 113 | 114 | ~ $ rpg class mage; rpg 115 | mage[1]@home 116 | hp:[xxxxxxxxxx] 32/32 117 | mp:[xxxxxxxxxx] 12/12 118 | xp:[----------] 0/30 119 | att:3 mag:27 def:0 spd:9 120 | equip:{} 121 | item:{} 122 | 0g 123 | 124 | ### Movement and battles 125 | If you use the `cd` subcommand with a path as parameter, it will instruct the hero to move: 126 | 127 | ~ $ rpg cd dev/ 128 | ~/dev $ rpg 129 | warrior[1]@~/dev 130 | hp:[xxxxxxxxxx] 47/47 131 | mp:[----------] 0/0 132 | xp:[----------] 0/30 133 | att:10 mag:0 def:0 spd:12 134 | equip:{} 135 | item:{} 136 | 0g 137 | 138 | In this case, the warrior moved to `~/dev`. Sometimes enemies will appear as you move through the directories, 139 | and both characters will engage in battle: 140 | 141 | ~/dev $ rpg cd facundoolano/ 142 | snake[3][xxxx][----]@~/dev/facundoolano 143 | snake[3][xxx-] -10hp 144 | warrior[1][xxxx] -8hp 145 | snake[3][xxx-] -9hp 146 | warrior[1][xxx-] -10hp 147 | snake[3][x---] -12hp 148 | warrior[1][xx--] -9hp 149 | snake[3][----] -14hp 150 | warrior[3][xxx-] +117xp ++level +275g 151 | warrior[3][xxx-][----][x---]@~/dev/facundoolano 152 | 153 | Each character attacks in turn (the frequency being determined by their `spd` stat). 154 | Whenever you win a fight, your hero gains experience points and eventually raises its level, along with its other stats. 155 | 156 | When you return to the home directory, the hero's health points are restored and status effects are removed: 157 | 158 | ~/dev/facundoolano/rpg-cli $ rpg cd ~ 159 | warrior[3][xxxx][----][x---]@home +27hp 160 | 161 | The further from home you move the hero, the tougher the enemies will get. If you go to far or too long without restoring your health, your hero is likely to die in battle, causing the game to restart at the home directory. 162 | 163 | ~ $ rpg cd ~/dev/facundoolano/rpg-cli/target/debug/examples/ 164 | zombie[3][xxxx][----]@~/dev/facundoolano/rpg-cli/target/debug 165 | zombie[3][xxxx] -14hp 166 | warrior[1][xxx-] -14hp 167 | zombie[3][xxx-] -16hp 168 | warrior[1][xxx-] -11hp 169 | zombie[3][xx--] -16hp 170 | warrior[1][xx--] -9hp 171 | zombie[3][xx--] -15hp 172 | warrior[1][x---] -9hp 173 | zombie[3][x---] -12hp 174 | warrior[1][----] -20hp critical! 175 | warrior[1][----] 💀 176 | 177 | Death is permanent: you can't save your progress and reload after dying, but if you take your new hero to the location of the previous one's death, 178 | you can recover gold, items and equipment: 179 | 180 | ~ $ rpg cd ~/dev/facundoolano/rpg-cli/target/debug/ 181 | 🪦 +potionx1 +275g 182 | 183 | ### Items and equipment 184 | 185 | In addition to winning items as battle rewards, some directories have hidden treasure chests that you can find with `rpg ls`: 186 | 187 | ~ $ rpg ls 188 | 📦 +potionx2 189 | 190 | Finally, some items can be bought at the game directory running `rpg buy`: 191 | 192 | ~ $ rpg buy 193 | sword[1] 500g 194 | shield[1] 500g 195 | potion[1] 200g 196 | remedy 400g 197 | escape 1000g 198 | 199 | funds: 275g 200 | ~ $ rpg buy potion 201 | -200g +potionx1 202 | 203 | The shortcut `rpg b p` would also work above. An item can be described with the `stat` subcommand and used with `use`: 204 | 205 | ~ $ rpg stat potion 206 | potion[1]: restores 25hp 207 | ~ $ rpg use potion 208 | warrior[3][xxxx] +25hp potion 209 | 210 | ### Quests and late game 211 | 212 | The `rpg todo` command will display a list of quest for your hero: 213 | 214 | ~ $ rpg todo 215 | □ buy a sword 216 | ✔ use a potion 217 | ✔ reach level 2 218 | ✔ win a battle 219 | 220 | Each time you complete an item on the list, you will receive a reward. The quests renew as your level raises, so be sure to check often! 221 | 222 | The game difficulty increases as you go deeper in the dungeon; to raise your level, encounter the tougher enemies, find the rarest items 223 | and complete all the quests, it's necessary to go as far as possible from the `$HOME` directory. One option to ease the gameplay 224 | is to [use a shell function](https://github.com/facundoolano/rpg-cli/blob/main/shell/README.md#arbitrary-dungeon-levels) that creates directories "on-demand". 225 | 226 | Try `rpg --help` for more options and check the [shell integration guide](shell/README.md) for ideas to adapt the game to your preferences. 227 | 228 | ## Customize character classes 229 | 230 | The character class determines a character's initial stats and at what pace they increase when leveling up. By default, rpg-cli will use classes as defined by [this file](src/character/classes.yaml), but these definitions can be overridden by placing a YAML file with that same structure at `~/.local/share/rpg/classes.yaml`. Check the [dirs crate doc](https://docs.rs/dirs/3.0.2/dirs/fn.data_dir.html) to find the data path on your OS. 231 | 232 | The `category` field is used to distinguish between player and enemy classes, and in the latter case how likely a given enemy class is likely to appear (e.g. `legendary` classes will appear less frequently, and only when far away from home). 233 | 234 | The hero's class can be changed at the home directory using `rpg-cli class `. If the hero is at level 1 it will effectively work as a character re-roll with fresh stats; at higher levels the stats are preserved and the class change will start taking effect on the next level increment. 235 | 236 | ## Troubleshooting 237 | 238 | * The release binary for macOS [is not signed](https://github.com/facundoolano/rpg-cli/issues/27). To open it for the first time, right click on the binary and select "Open" from the menu. 239 | -------------------------------------------------------------------------------- /src/item/chest.rs: -------------------------------------------------------------------------------- 1 | use super::equipment::Equipment; 2 | use super::key::Key; 3 | use super::ring; 4 | use super::stone; 5 | use super::{Escape, Ether, Item, Potion, Remedy}; 6 | use crate::game; 7 | use crate::randomizer::random; 8 | use crate::randomizer::Randomizer; 9 | use rand::prelude::{IteratorRandom, SliceRandom}; 10 | use serde::{Deserialize, Serialize}; 11 | use std::collections::HashMap; 12 | 13 | /// A chest is a bag of items that can be picked up by the hero. 14 | /// It can randomly appear at a location upon inspection, or dropped 15 | /// by the hero when they die. 16 | #[derive(Serialize, Deserialize, Default)] 17 | pub struct Chest { 18 | items: Vec>, 19 | sword: Option, 20 | shield: Option, 21 | gold: i32, 22 | } 23 | 24 | impl Chest { 25 | /// Randomly generate a chest at the current location. 26 | pub fn generate(game: &mut game::Game) -> Option { 27 | // if the evade ring is equipped, don't generate chests 28 | // otherwise player can go arbitrarily deep and break the game 29 | // by finding all treasure contents 30 | if game.player.enemies_evaded() { 31 | return None; 32 | } 33 | 34 | let distance = &game.location.distance_from_home(); 35 | 36 | // don't reward cheap victories 37 | if game.player.level > distance.len() + 10 { 38 | return None; 39 | } 40 | 41 | // To give the impression of "dynamic" chest contents, each content type 42 | // is randomized separately, and what's found is combined into a single 43 | // chest at the end 44 | let mut gold_chest = random().gold_chest(distance); 45 | let mut equipment_chest = random().equipment_chest(distance); 46 | let mut ring_chest = random().ring_chest(distance); 47 | let mut item_chest_attempts = 3; 48 | 49 | // If the chest ring is equipped, double the likelyhood of finding a chest 50 | if game.player.double_chests() { 51 | gold_chest = gold_chest || random().gold_chest(distance); 52 | equipment_chest = equipment_chest || random().equipment_chest(distance); 53 | ring_chest = ring_chest || random().ring_chest(distance); 54 | item_chest_attempts *= 2; 55 | } 56 | 57 | let mut chest = Self::default(); 58 | 59 | if gold_chest { 60 | chest.gold = game.player.gold_gained(game.player.level + distance.len()); 61 | } 62 | if equipment_chest { 63 | let (sword, shield) = random_equipment(distance.len()); 64 | chest.sword = sword; 65 | chest.shield = shield; 66 | } 67 | 68 | if ring_chest { 69 | // Because of the ring pool (only one instance per ring type), it's 70 | // easier to handle this case separate from the rest of the items 71 | // --only remove from the pool if we are positive a ring should be 72 | // be included in the chest 73 | if let Some(ring) = random_ring(game) { 74 | chest.items.push(Box::new(ring)); 75 | } else { 76 | // only show chest found if there are rings left to be found 77 | ring_chest = false; 78 | } 79 | } 80 | 81 | // Items should be more frequent and can be multiple 82 | let mut item_chest = false; 83 | for _ in 0..item_chest_attempts { 84 | if random().item_chest(distance) { 85 | item_chest = true; 86 | let item = random_item(game.player.rounded_level()); 87 | chest.items.push(item); 88 | } 89 | } 90 | 91 | // Return None instead of an empty chest if none was found 92 | if gold_chest || equipment_chest || item_chest || ring_chest { 93 | Some(chest) 94 | } else { 95 | None 96 | } 97 | } 98 | 99 | pub fn battle_loot(game: &mut game::Game) -> Option { 100 | // reuse item % from chests, but don't add extra gold 101 | // kind of hacky but does for now 102 | Self::generate(game).map(|mut c| { 103 | c.gold = 0; 104 | c 105 | }) 106 | } 107 | 108 | /// Remove the gold, items and equipment from a hero and return them as a new chest. 109 | pub fn drop(game: &mut game::Game) -> Self { 110 | let items: HashMap>> = game.inventory.drain().collect(); 111 | let mut items: Vec> = items.into_values().flatten().collect(); 112 | let sword = game.player.sword.take(); 113 | let shield = game.player.shield.take(); 114 | 115 | // equipped rings should be dropped as items 116 | if let Some(ring) = game.player.left_ring.take() { 117 | items.push(Box::new(ring)); 118 | } 119 | if let Some(ring) = game.player.right_ring.take() { 120 | items.push(Box::new(ring)); 121 | } 122 | let gold = game.gold; 123 | 124 | game.gold = 0; 125 | 126 | Self { 127 | items, 128 | sword, 129 | shield, 130 | gold, 131 | } 132 | } 133 | 134 | /// Add the items of this chest to the current game/hero 135 | /// Return a picked up (item counts, gold) tuple. 136 | pub fn pick_up(&mut self, game: &mut game::Game) -> (HashMap, i32) { 137 | let mut item_counts = HashMap::new(); 138 | 139 | // the equipment is picked up only if it's better than the current one 140 | if maybe_upgrade(&mut game.player.sword, &mut self.sword) { 141 | item_counts.insert(Key::Sword, 1); 142 | } 143 | if maybe_upgrade(&mut game.player.shield, &mut self.shield) { 144 | item_counts.insert(Key::Shield, 1); 145 | } 146 | 147 | // items and gold are always picked up 148 | for item in self.items.drain(..) { 149 | *item_counts.entry(item.key()).or_insert(0) += 1; 150 | game.add_item(item); 151 | } 152 | 153 | game.gold += self.gold; 154 | (item_counts, self.gold) 155 | } 156 | 157 | /// Add the elements of `other` to this chest 158 | pub fn extend(&mut self, mut other: Self) { 159 | // keep the best of each equipment 160 | maybe_upgrade(&mut self.sword, &mut other.sword); 161 | maybe_upgrade(&mut self.shield, &mut other.shield); 162 | self.items.append(&mut other.items); 163 | self.gold += other.gold; 164 | } 165 | } 166 | 167 | /// Upgrades current with the other equipment if it has a better level (or current is None). 168 | /// Return whether there was an upgrade. 169 | fn maybe_upgrade(current: &mut Option, other: &mut Option) -> bool { 170 | if let Some(shield) = other.take() { 171 | if shield.is_upgrade_from(current) { 172 | current.replace(shield); 173 | return true; 174 | } 175 | } 176 | false 177 | } 178 | 179 | fn random_equipment(distance: i32) -> (Option, Option) { 180 | let mut rng = rand::thread_rng(); 181 | 182 | let level = std::cmp::max(1, (distance / 5) * 5); 183 | 184 | [ 185 | (100, (Some(Equipment::sword(level)), None)), 186 | (80, (None, Some(Equipment::shield(level)))), 187 | (30, (Some(Equipment::sword(level + 5)), None)), 188 | (20, (None, Some(Equipment::shield(level + 5)))), 189 | (1, (Some(Equipment::sword(100)), None)), 190 | ] 191 | .choose_weighted_mut(&mut rng, |c| c.0) 192 | .unwrap() 193 | .to_owned() 194 | .1 195 | } 196 | 197 | /// Return a weigthed random item. 198 | fn random_item(level: i32) -> Box { 199 | let mut choices: Vec<(i32, Box)> = vec![ 200 | (150, Box::new(Potion::new(level))), 201 | (10, Box::new(Remedy::new())), 202 | (10, Box::new(Escape::new())), 203 | (50, Box::new(Ether::new(level))), 204 | (5, Box::new(stone::Health)), 205 | (5, Box::new(stone::Magic)), 206 | (5, Box::new(stone::Power)), 207 | (5, Box::new(stone::Speed)), 208 | (1, Box::new(stone::Level)), 209 | ]; 210 | 211 | // make a separate vec with enumerated weights, then remove from the item vec 212 | // with the resulting index 213 | let indexed_weights: Vec<_> = choices.iter().map(|(w, _)| w).enumerate().collect(); 214 | 215 | let mut rng = rand::thread_rng(); 216 | let index = indexed_weights 217 | .choose_weighted(&mut rng, |c| c.1) 218 | .unwrap() 219 | .0; 220 | choices.remove(index).1 221 | } 222 | 223 | fn random_ring(game: &mut game::Game) -> Option { 224 | let mut rng = rand::thread_rng(); 225 | if let Some(ring) = game.ring_pool.iter().choose(&mut rng).cloned() { 226 | game.ring_pool.take(&ring) 227 | } else { 228 | None 229 | } 230 | } 231 | 232 | #[cfg(test)] 233 | mod tests { 234 | use super::super::equipment::Equipment; 235 | use super::*; 236 | use super::{Escape, Potion}; 237 | 238 | #[test] 239 | fn test_empty_drop_pickup() { 240 | let mut game = game::Game::new(); 241 | let mut tomb = Chest::drop(&mut game); 242 | 243 | assert_eq!(0, tomb.gold); 244 | assert!(tomb.sword.is_none()); 245 | assert!(tomb.shield.is_none()); 246 | assert!(tomb.items.is_empty()); 247 | 248 | let mut game = game::Game::new(); 249 | tomb.pick_up(&mut game); 250 | 251 | assert_eq!(0, game.gold); 252 | assert!(game.player.sword.is_none()); 253 | assert!(game.player.shield.is_none()); 254 | assert!(game.inventory().is_empty()); 255 | } 256 | 257 | #[test] 258 | fn test_full_drop_pickup() { 259 | let mut game = game::Game::new(); 260 | game.add_item(Box::new(Potion::new(1))); 261 | game.add_item(Box::new(Potion::new(1))); 262 | game.player.sword = Some(Equipment::sword(1)); 263 | game.player.shield = Some(Equipment::shield(1)); 264 | game.gold = 100; 265 | 266 | let mut tomb = Chest::drop(&mut game); 267 | 268 | assert_eq!(100, tomb.gold); 269 | assert!(tomb.sword.is_some()); 270 | assert!(tomb.shield.is_some()); 271 | assert_eq!(2, tomb.items.len()); 272 | 273 | let mut game = game::Game::new(); 274 | tomb.pick_up(&mut game); 275 | 276 | assert_eq!(100, game.gold); 277 | assert!(game.player.sword.is_some()); 278 | assert!(game.player.shield.is_some()); 279 | assert_eq!(2, *game.inventory().get(&Key::Potion).unwrap()); 280 | } 281 | 282 | #[test] 283 | fn test_pickup_extends() { 284 | let mut game = game::Game::new(); 285 | game.add_item(Box::new(Potion::new(1))); 286 | game.add_item(Box::new(Potion::new(1))); 287 | game.player.sword = Some(Equipment::sword(1)); 288 | game.player.shield = Some(Equipment::shield(10)); 289 | game.gold = 100; 290 | 291 | let mut tomb = Chest::drop(&mut game); 292 | 293 | // set some defaults for the new game before picking up 294 | let mut game = game::Game::new(); 295 | game.add_item(Box::new(Potion::new(1))); 296 | game.player.sword = Some(Equipment::sword(5)); 297 | game.player.shield = Some(Equipment::shield(5)); 298 | game.gold = 50; 299 | 300 | tomb.pick_up(&mut game); 301 | 302 | assert_eq!(150, game.gold); 303 | 304 | // the sword was upgrade, picked it up 305 | assert_eq!(5, game.player.sword.as_ref().unwrap().level()); 306 | 307 | // the shield was downgrade, kept the current one 308 | assert_eq!(10, game.player.shield.as_ref().unwrap().level()); 309 | 310 | assert_eq!(3, *game.inventory().get(&Key::Potion).unwrap()); 311 | } 312 | 313 | #[test] 314 | fn test_merge() { 315 | let items: Vec> = vec![Box::new(Potion::new(1)), Box::new(Potion::new(1))]; 316 | let mut chest1 = Chest { 317 | items, 318 | sword: Some(Equipment::sword(1)), 319 | shield: Some(Equipment::shield(10)), 320 | gold: 100, 321 | }; 322 | 323 | let items: Vec> = vec![Box::new(Potion::new(1)), Box::new(Escape::new())]; 324 | let chest2 = Chest { 325 | items, 326 | sword: Some(Equipment::sword(10)), 327 | shield: Some(Equipment::shield(1)), 328 | gold: 100, 329 | }; 330 | 331 | chest1.extend(chest2); 332 | assert_eq!(200, chest1.gold); 333 | assert_eq!(10, chest1.sword.as_ref().unwrap().level()); 334 | assert_eq!(10, chest1.shield.as_ref().unwrap().level()); 335 | let item_keys = chest1.items.iter().map(|i| i.key()).collect::>(); 336 | assert_eq!( 337 | vec![Key::Potion, Key::Potion, Key::Potion, Key::Escape], 338 | item_keys 339 | ); 340 | } 341 | 342 | #[test] 343 | fn test_take_random_ring() { 344 | let mut game = game::Game::new(); 345 | let total = game.ring_pool.len(); 346 | assert!(total > 0); 347 | 348 | for i in 0..total { 349 | assert_eq!(total - i, game.ring_pool.len()); 350 | assert!(random_ring(&mut game).is_some()); 351 | } 352 | 353 | assert!(game.ring_pool.is_empty()); 354 | assert!(random_ring(&mut game).is_none()); 355 | } 356 | 357 | #[test] 358 | fn test_drop_equipped_rings() { 359 | let mut game = game::Game::new(); 360 | game.add_item(Box::new(Potion::new(1))); 361 | game.player.left_ring = Some(ring::Ring::Speed); 362 | game.player.right_ring = Some(ring::Ring::Magic); 363 | 364 | let mut chest = Chest::drop(&mut game); 365 | assert!(game.player.left_ring.is_none()); 366 | assert!(game.player.right_ring.is_none()); 367 | let item_keys = chest.items.iter().map(|i| i.key()).collect::>(); 368 | assert_eq!( 369 | vec![ 370 | Key::Potion, 371 | Key::Ring(ring::Ring::Speed), 372 | Key::Ring(ring::Ring::Magic) 373 | ], 374 | item_keys 375 | ); 376 | 377 | chest.pick_up(&mut game); 378 | assert!(game.inventory.contains_key(&Key::Ring(ring::Ring::Speed))); 379 | assert!(game.inventory.contains_key(&Key::Ring(ring::Ring::Magic))); 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /src/quest/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::character::class; 2 | use crate::character::Character; 3 | use crate::game; 4 | use crate::item::key::Key; 5 | use crate::location::Location; 6 | use crate::log; 7 | use core::fmt; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | mod beat_enemy; 11 | mod level; 12 | mod ring; 13 | mod tutorial; 14 | 15 | /// A task that is assigned to the player when certain conditions are met. 16 | /// New quests should implement this trait and be added to QuestList.setup method. 17 | #[typetag::serde(tag = "type")] 18 | pub trait Quest { 19 | /// What to show in the TODO quests list 20 | fn description(&self) -> String; 21 | 22 | /// Update the quest progress based on the given event and 23 | /// return whether the quest was finished. 24 | fn handle(&mut self, event: &Event) -> bool; 25 | } 26 | 27 | impl fmt::Display for dyn Quest { 28 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 29 | write!(f, "{}", self.description()) 30 | } 31 | } 32 | 33 | /// Keeps a TODO list of quests for the game. 34 | /// Each quest is unlocked at a certain level and has completion reward. 35 | #[derive(Serialize, Deserialize, Default)] 36 | pub struct QuestList { 37 | quests: Vec<(Status, i32, Box)>, 38 | } 39 | 40 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 41 | enum Status { 42 | /// The quest won't be visible until the player reaches a specific level 43 | Locked(i32), 44 | 45 | /// The quest is visible 46 | Unlocked, 47 | 48 | /// The quest was finished 49 | Completed, 50 | } 51 | 52 | // EVENT TRIGGERING FUNCTIONS 53 | 54 | pub fn battle_won(game: &mut game::Game, enemy: &Character, levels_up: i32) { 55 | handle( 56 | game, 57 | Event::BattleWon { 58 | enemy, 59 | location: game.location.clone(), 60 | }, 61 | ); 62 | 63 | if levels_up > 0 { 64 | level_up(game, levels_up); 65 | } 66 | } 67 | 68 | pub fn level_up(game: &mut game::Game, count: i32) { 69 | handle( 70 | game, 71 | Event::LevelUp { 72 | count, 73 | current: game.player.level, 74 | class: game.player.name(), 75 | }, 76 | ); 77 | } 78 | 79 | pub fn item_bought(game: &mut game::Game, item: Key) { 80 | handle(game, Event::ItemBought { item }); 81 | } 82 | 83 | pub fn item_used(game: &mut game::Game, item: Key) { 84 | handle(game, Event::ItemUsed { item }); 85 | } 86 | 87 | pub fn item_added(game: &mut game::Game, item: Key) { 88 | handle(game, Event::ItemAdded { item }); 89 | } 90 | 91 | pub fn chest(game: &mut game::Game) { 92 | handle(game, Event::ChestFound); 93 | } 94 | 95 | pub fn tombstone(game: &mut game::Game) { 96 | handle(game, Event::TombtsoneFound); 97 | } 98 | 99 | pub fn game_reset(game: &mut game::Game) { 100 | handle(game, Event::GameReset); 101 | } 102 | 103 | fn handle(game: &mut game::Game, event: Event) { 104 | // it would be preferable to have quests decoupled from the game struct 105 | // but that makes event handling much more complicated 106 | game.gold += game.quests.handle(&event); 107 | } 108 | 109 | pub enum Event<'a> { 110 | BattleWon { 111 | enemy: &'a Character, 112 | location: Location, 113 | }, 114 | LevelUp { 115 | count: i32, 116 | current: i32, 117 | class: String, 118 | }, 119 | ItemBought { 120 | item: Key, 121 | }, 122 | ItemUsed { 123 | item: Key, 124 | }, 125 | ItemAdded { 126 | item: Key, 127 | }, 128 | ChestFound, 129 | TombtsoneFound, 130 | GameReset, 131 | } 132 | 133 | impl QuestList { 134 | pub fn new() -> Self { 135 | let mut quests = Self { quests: Vec::new() }; 136 | 137 | quests.setup(); 138 | quests 139 | } 140 | 141 | /// Load the quests for a new game 142 | fn setup(&mut self) { 143 | self.quests 144 | .push((Status::Unlocked, 100, Box::new(tutorial::WinBattle))); 145 | self.quests 146 | .push((Status::Unlocked, 100, Box::new(tutorial::BuySword))); 147 | self.quests 148 | .push((Status::Unlocked, 100, Box::new(tutorial::UsePotion))); 149 | self.quests 150 | .push((Status::Unlocked, 100, Box::new(level::ReachLevel::new(2)))); 151 | 152 | self.quests 153 | .push((Status::Locked(2), 200, Box::new(tutorial::FindChest))); 154 | self.quests 155 | .push((Status::Locked(2), 500, Box::new(level::ReachLevel::new(5)))); 156 | self.quests.push(( 157 | Status::Locked(2), 158 | 1000, 159 | beat_enemy::of_class(class::Category::Common, "beat all common creatures"), 160 | )); 161 | 162 | self.quests 163 | .push((Status::Locked(5), 200, Box::new(tutorial::VisitTomb))); 164 | self.quests 165 | .push((Status::Locked(5), 300, Box::new(ring::EquipRing))); 166 | self.quests.push(( 167 | Status::Locked(5), 168 | 1000, 169 | Box::new(level::ReachLevel::new(10)), 170 | )); 171 | self.quests.push(( 172 | Status::Locked(5), 173 | 5000, 174 | beat_enemy::of_class(class::Category::Rare, "beat all rare creatures"), 175 | )); 176 | self.quests 177 | .push((Status::Locked(5), 1000, beat_enemy::at_distance(10))); 178 | 179 | self.quests.push(( 180 | Status::Locked(10), 181 | 10000, 182 | beat_enemy::of_class(class::Category::Legendary, "beat all legendary creatures"), 183 | )); 184 | 185 | self.quests.push(( 186 | Status::Locked(10), 187 | 10000, 188 | Box::new(level::ReachLevel::new(50)), 189 | )); 190 | 191 | for name in class::Class::names(class::Category::Player) { 192 | self.quests.push(( 193 | Status::Locked(10), 194 | 5000, 195 | Box::new(level::RaiseClassLevels::new(&name)), 196 | )); 197 | } 198 | 199 | self.quests.push(( 200 | Status::Locked(15), 201 | 30000, 202 | Box::new(ring::FindAllRings::new()), 203 | )); 204 | self.quests 205 | .push((Status::Locked(15), 20000, beat_enemy::shadow())); 206 | self.quests 207 | .push((Status::Locked(15), 20000, beat_enemy::dev())); 208 | 209 | self.quests.push(( 210 | Status::Locked(50), 211 | 100000, 212 | Box::new(level::ReachLevel::new(100)), 213 | )); 214 | self.quests 215 | .push((Status::Locked(50), 1000000, ring::gorthaur())); 216 | } 217 | 218 | /// Pass the event to each of the quests, moving the completed ones to DONE. 219 | /// The total gold reward is returned. 220 | fn handle(&mut self, event: &Event) -> i32 { 221 | self.unlock_quests(event); 222 | 223 | let mut total_reward = 0; 224 | 225 | for (status, reward, quest) in &mut self.quests { 226 | if let Status::Completed = status { 227 | continue; 228 | } 229 | 230 | let is_done = quest.handle(event); 231 | if is_done { 232 | total_reward += *reward; 233 | log::quest_done(*reward); 234 | *status = Status::Completed 235 | } 236 | } 237 | 238 | total_reward 239 | } 240 | 241 | /// If the event is a level up, unlock quests for that level. 242 | fn unlock_quests(&mut self, event: &Event) { 243 | if let Event::LevelUp { current, .. } = event { 244 | for (status, _, _) in &mut self.quests { 245 | if let Status::Locked(level) = status { 246 | if *level <= *current { 247 | *status = Status::Unlocked; 248 | } 249 | } 250 | } 251 | } 252 | } 253 | 254 | pub fn list(&self) -> Vec<(bool, String)> { 255 | let mut result = Vec::new(); 256 | 257 | for (status, _, q) in &self.quests { 258 | match status { 259 | Status::Locked(_) => {} 260 | Status::Unlocked => result.push((false, q.description())), 261 | Status::Completed => result.push((true, q.description())), 262 | }; 263 | } 264 | result 265 | } 266 | } 267 | 268 | #[cfg(test)] 269 | mod tests { 270 | use super::*; 271 | use crate::character::enemy; 272 | use crate::character::Character; 273 | use crate::item; 274 | use crate::item::Item; 275 | use crate::location::tests::location_from; 276 | 277 | #[test] 278 | fn test_quest_status() { 279 | let mut quests = QuestList { quests: Vec::new() }; 280 | quests 281 | .quests 282 | .push((Status::Unlocked, 10, Box::new(level::ReachLevel::new(2)))); 283 | quests 284 | .quests 285 | .push((Status::Locked(2), 20, Box::new(level::ReachLevel::new(3)))); 286 | quests 287 | .quests 288 | .push((Status::Locked(3), 30, Box::new(level::ReachLevel::new(4)))); 289 | quests 290 | .quests 291 | .push((Status::Locked(4), 40, Box::new(level::ReachLevel::new(5)))); 292 | 293 | assert_eq!(1, count_status(&quests, Status::Unlocked)); 294 | assert_eq!(0, count_status(&quests, Status::Completed)); 295 | 296 | let reward = quests.handle(&Event::LevelUp { 297 | count: 1, 298 | current: 2, 299 | class: "warrior".to_string(), 300 | }); 301 | assert_eq!(1, count_status(&quests, Status::Unlocked)); 302 | assert_eq!(1, count_status(&quests, Status::Completed)); 303 | assert_eq!(10, reward); 304 | 305 | let reward = quests.handle(&Event::LevelUp { 306 | count: 2, 307 | current: 4, 308 | class: "warrior".to_string(), 309 | }); 310 | assert_eq!(1, count_status(&quests, Status::Unlocked)); 311 | assert_eq!(3, count_status(&quests, Status::Completed)); 312 | assert_eq!(50, reward); 313 | } 314 | 315 | #[test] 316 | fn test_game_quests() { 317 | let mut game = game::Game::new(); 318 | let fake_enemy = Character::player(); 319 | 320 | let initial_quests = count_status(&game.quests, Status::Unlocked); 321 | assert!(initial_quests > 0); 322 | assert_eq!(0, count_status(&game.quests, Status::Completed)); 323 | 324 | // first quest is to win a battle 325 | battle_won(&mut game, &fake_enemy, 0); 326 | assert_eq!( 327 | initial_quests - 1, 328 | count_status(&game.quests, Status::Unlocked) 329 | ); 330 | assert_eq!(1, count_status(&game.quests, Status::Completed)); 331 | 332 | game.gold = 10; 333 | game.reset(); 334 | // verify that the reset did something 335 | assert_eq!(0, game.gold); 336 | 337 | // verify that quests are preserved 338 | assert_eq!( 339 | initial_quests - 1, 340 | count_status(&game.quests, Status::Unlocked) 341 | ); 342 | assert_eq!(1, count_status(&game.quests, Status::Completed)); 343 | 344 | // verify that it doesn't reward twice 345 | battle_won(&mut game, &fake_enemy, 0); 346 | assert_eq!(0, game.gold); 347 | assert_eq!( 348 | initial_quests - 1, 349 | count_status(&game.quests, Status::Unlocked) 350 | ); 351 | assert_eq!(1, count_status(&game.quests, Status::Completed)); 352 | } 353 | 354 | #[test] 355 | fn test_level_up() { 356 | let mut game = game::Game::new(); 357 | game.quests.quests = vec![ 358 | (Status::Unlocked, 10, Box::new(level::ReachLevel::new(2))), 359 | (Status::Unlocked, 10, Box::new(level::ReachLevel::new(3))), 360 | ]; 361 | 362 | game.player.level = 2; 363 | level_up(&mut game, 1); 364 | 365 | assert_eq!(Status::Completed, game.quests.quests[0].0); 366 | assert_eq!(Status::Unlocked, game.quests.quests[1].0); 367 | 368 | let mut stone = item::stone::Level; 369 | stone.apply(&mut game); 370 | assert_eq!(Status::Completed, game.quests.quests[0].0); 371 | assert_eq!(Status::Completed, game.quests.quests[1].0); 372 | } 373 | 374 | #[test] 375 | fn equip_ring() { 376 | let mut game = game::Game::new(); 377 | game.quests.quests = vec![(Status::Unlocked, 1, Box::new(ring::EquipRing))]; 378 | 379 | game.add_item(Box::new(item::ring::Ring::Void)); 380 | game.use_item(Key::Ring(item::ring::Ring::Void)).unwrap(); 381 | 382 | assert_eq!(Status::Completed, game.quests.quests[0].0); 383 | } 384 | 385 | #[test] 386 | fn find_all_rings() { 387 | let mut game = game::Game::new(); 388 | game.quests.quests = vec![(Status::Unlocked, 1, Box::new(ring::FindAllRings::new()))]; 389 | 390 | for ring in item::ring::Ring::set() { 391 | game.add_item(Box::new(ring)); 392 | } 393 | 394 | assert_eq!(Status::Completed, game.quests.quests[0].0); 395 | } 396 | 397 | #[test] 398 | fn gorthaur() { 399 | let mut game = game::Game::new(); 400 | game.quests.quests = vec![(Status::Unlocked, 1, ring::gorthaur())]; 401 | 402 | // fake a +100 distance location 403 | let mut fake_path = String::from("~"); 404 | for n in 0..103 { 405 | fake_path += &format!("/{}", n); 406 | } 407 | game.location = location_from(&fake_path); 408 | 409 | // ruling ring required to spawn the enemy 410 | game.player.left_ring = Some(item::ring::Ring::Ruling); 411 | 412 | let mut enemy = enemy::spawn(&game.location, &game.player).unwrap(); 413 | 414 | // increase many levels to force the player's victory 415 | for _ in 0..200 { 416 | game.player.add_experience(game.player.xp_for_next()); 417 | } 418 | enemy.current_hp = 10; 419 | 420 | game.battle(&mut enemy, false, false).unwrap(); 421 | 422 | assert_eq!(Status::Completed, game.quests.quests[0].0); 423 | } 424 | 425 | fn count_status(quests: &QuestList, status: Status) -> usize { 426 | quests 427 | .quests 428 | .iter() 429 | .filter(|(q_status, _, _)| *q_status == status) 430 | .count() 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /src/command.rs: -------------------------------------------------------------------------------- 1 | use crate::character; 2 | use crate::character::enemy; 3 | use crate::game::Game; 4 | use crate::item; 5 | use crate::item::key::Key; 6 | use crate::location::Location; 7 | use crate::log; 8 | use anyhow::{anyhow, bail, Result}; 9 | 10 | use clap::Parser; 11 | 12 | #[derive(Parser)] 13 | #[command()] 14 | pub enum Command { 15 | /// Display stats for the given items. Defaults to displaying hero stats if no item is specified. [default] 16 | #[command(aliases=&["s", "status"], display_order=0)] 17 | Stat { items: Vec }, 18 | 19 | /// Moves the hero to the supplied destination, potentially initiating battles along the way. 20 | #[command(name = "cd", display_order = 1)] 21 | ChangeDir { 22 | /// Directory to move to. 23 | #[arg(default_value = "~")] 24 | destination: String, 25 | 26 | /// Attempt to avoid battles by running away. 27 | #[arg(long)] 28 | run: bool, 29 | 30 | /// Attempt to avoid battles by bribing the enemy. 31 | #[arg(long)] 32 | bribe: bool, 33 | 34 | /// Move the hero's to a different location without spawning enemies. 35 | /// Intended for scripts and shell integration. 36 | #[arg(short, long)] 37 | force: bool, 38 | }, 39 | 40 | /// Inspect the directory contents, possibly finding treasure chests and hero tombstones. 41 | #[command(name = "ls", display_order = 1)] 42 | Inspect, 43 | 44 | /// Buys an item from the shop. 45 | /// If name is omitted lists the items available for sale. 46 | #[command(alias = "b", display_order = 2)] 47 | Buy { items: Vec }, 48 | 49 | /// Uses an item from the inventory. 50 | #[command(alias = "u", display_order = 3)] 51 | Use { items: Vec }, 52 | 53 | /// Prints the quest todo list. 54 | #[command(alias = "t", display_order = 4)] 55 | Todo, 56 | 57 | /// Resets the current game. 58 | Reset { 59 | /// Reset data files, losing cross-hero progress. 60 | #[arg(long)] 61 | hard: bool, 62 | }, 63 | 64 | /// Change the character class. 65 | /// If name is omitted lists the available character classes. 66 | Class { name: Option }, 67 | 68 | /// Prints the hero's current location 69 | #[command(name = "pwd")] 70 | PrintWorkDir, 71 | 72 | /// Potentially initiates a battle in the hero's current location. 73 | Battle { 74 | /// Attempt to avoid battles by running away. 75 | #[arg(long)] 76 | run: bool, 77 | 78 | /// Attempt to avoid battles by bribing the enemy. 79 | #[arg(long)] 80 | bribe: bool, 81 | }, 82 | 83 | #[command(hide = true)] 84 | Idkfa { level: i32 }, 85 | } 86 | 87 | pub fn run(cmd: Option, game: &mut Game) -> Result<()> { 88 | match cmd.unwrap_or(Command::Stat { items: vec![] }) { 89 | Command::Stat { items } => stat(game, &items)?, 90 | Command::ChangeDir { 91 | destination, 92 | run, 93 | bribe, 94 | force, 95 | } => change_dir(game, &destination, run, bribe, force)?, 96 | Command::Inspect => game.inspect(), 97 | Command::Class { name } => class(game, &name)?, 98 | Command::Battle { run, bribe } => battle(game, run, bribe)?, 99 | Command::PrintWorkDir => println!("{}", game.location.path_string()), 100 | Command::Reset { .. } => game.reset(), 101 | Command::Buy { items } => shop(game, &items)?, 102 | Command::Use { items } => use_item(game, &items)?, 103 | Command::Todo => { 104 | log::quest_list(game.quests.list()); 105 | } 106 | Command::Idkfa { level } => debug_command(game, level), 107 | }; 108 | 109 | Ok(()) 110 | } 111 | 112 | /// Attempt to move the hero to the supplied location, possibly engaging 113 | /// in combat along the way. 114 | fn change_dir(game: &mut Game, dest: &str, run: bool, bribe: bool, force: bool) -> Result<()> { 115 | let dest = Location::from(dest)?; 116 | let result = if force { 117 | // When change is force, skip enemies along the way 118 | // but still apply side-effects at destination 119 | game.visit(dest) 120 | } else { 121 | game.go_to(&dest, run, bribe) 122 | }; 123 | 124 | if let Err(character::Dead) = result { 125 | game.reset(); 126 | bail!(""); 127 | } 128 | 129 | Ok(()) 130 | } 131 | 132 | /// Potentially run a battle at the current location, independently from 133 | /// the hero's movement. 134 | fn battle(game: &mut Game, run: bool, bribe: bool) -> Result<()> { 135 | if let Some(mut enemy) = enemy::spawn(&game.location, &game.player) { 136 | if let Err(character::Dead) = game.battle(&mut enemy, run, bribe) { 137 | game.reset(); 138 | bail!(""); 139 | } 140 | } 141 | Ok(()) 142 | } 143 | 144 | /// Set the class for the player character 145 | fn class(game: &mut Game, class_name: &Option) -> Result<()> { 146 | if !game.location.is_home() { 147 | bail!("Class change is only allowed at home.") 148 | } 149 | 150 | if let Some(class_name) = class_name { 151 | let class_name = class_name.to_lowercase(); 152 | game.player 153 | .change_class(&class_name) 154 | .map_err(|_| anyhow!("Unknown class name.")) 155 | } else { 156 | let player_classes: Vec = 157 | character::class::Class::names(character::class::Category::Player) 158 | .iter() 159 | .cloned() 160 | .collect(); 161 | println!("Options: {}", player_classes.join(", ")); 162 | Ok(()) 163 | } 164 | } 165 | 166 | /// Buy an item from the shop or list the available items if no item name is provided. 167 | /// Shopping is only allowed when the player is at the home directory. 168 | fn shop(game: &mut Game, items: &[String]) -> Result<()> { 169 | if items.is_empty() { 170 | item::shop::list(game) 171 | } else { 172 | // parse items and break if any is invalid/unknown 173 | let mut keys = Vec::new(); 174 | for item in items { 175 | keys.push(Key::from(item)?); 176 | } 177 | 178 | item::shop::buy(game, &keys) 179 | } 180 | } 181 | 182 | fn stat(game: &mut Game, items: &[String]) -> Result<()> { 183 | if items.is_empty() { 184 | log::status(game); 185 | Ok(()) 186 | } else { 187 | for item_name in items { 188 | let item_name = Key::from(item_name)?; 189 | let (display, description) = game.describe(item_name)?; 190 | println!("{}: {}", display, description); 191 | } 192 | Ok(()) 193 | } 194 | } 195 | 196 | /// Use an item from the inventory or list the inventory contents if no item name is provided. 197 | fn use_item(game: &mut Game, items: &[String]) -> Result<()> { 198 | if items.is_empty() { 199 | println!("{}", log::format_inventory(game)); 200 | } else { 201 | for item_name in items { 202 | let item_name = Key::from(item_name)?; 203 | game.use_item(item_name)? 204 | } 205 | } 206 | Ok(()) 207 | } 208 | 209 | fn debug_command(game: &mut Game, level: i32) { 210 | game.reset(); 211 | game.gold = 5000 * level; 212 | for _ in 1..level { 213 | game.player.add_experience(game.player.xp_for_next()); 214 | } 215 | } 216 | 217 | #[cfg(test)] 218 | mod tests { 219 | use super::*; 220 | 221 | #[test] 222 | fn change_dir_battle() { 223 | let mut game = Game::new(); 224 | let cmd = Command::ChangeDir { 225 | destination: "~/..".to_string(), 226 | run: false, 227 | bribe: false, 228 | force: false, 229 | }; 230 | 231 | // increase level to ensure win 232 | for _ in 0..5 { 233 | game.player.add_experience(game.player.xp_for_next()); 234 | } 235 | 236 | let result = run(Some(cmd), &mut game); 237 | 238 | assert!(result.is_ok()); 239 | assert!(game.player.xp > 0); 240 | assert!(game.gold > 0); 241 | } 242 | 243 | #[test] 244 | fn change_dir_dead() { 245 | let mut game = Game::new(); 246 | let cmd = Command::ChangeDir { 247 | destination: "~/..".to_string(), 248 | run: false, 249 | bribe: false, 250 | force: false, 251 | }; 252 | 253 | // reduce stats to ensure loss 254 | let weak_class = character::class::Class { 255 | hp: character::class::Stat(1, 1), 256 | speed: character::class::Stat(1, 1), 257 | ..game.player.class 258 | }; 259 | game.player = character::Character::new(weak_class, 1); 260 | game.gold = 100; 261 | game.player.xp = 100; 262 | 263 | let result = run(Some(cmd), &mut game); 264 | 265 | assert!(result.is_err()); 266 | 267 | // game reset 268 | assert_eq!(game.player.max_hp(), game.player.current_hp); 269 | assert_eq!(0, game.gold); 270 | assert_eq!(0, game.player.xp); 271 | assert!(!game.tombstones.is_empty()); 272 | } 273 | 274 | #[test] 275 | fn status_effect_dead() { 276 | let mut game = Game::new(); 277 | 278 | // using force prevents battle but effects should apply anyway 279 | let cmd = Command::ChangeDir { 280 | destination: "~/..".to_string(), 281 | run: false, 282 | bribe: false, 283 | force: true, 284 | }; 285 | 286 | // reduce stats to ensure loss 287 | let weak_class = character::class::Class { 288 | hp: character::class::Stat(1, 1), 289 | speed: character::class::Stat(1, 1), 290 | ..game.player.class 291 | }; 292 | game.player = character::Character::new(weak_class, 1); 293 | game.player.status_effect = Some(character::StatusEffect::Burn); 294 | game.gold = 100; 295 | game.player.xp = 100; 296 | 297 | let result = run(Some(cmd), &mut game); 298 | 299 | assert!(result.is_err()); 300 | 301 | // game reset 302 | assert_eq!(game.player.max_hp(), game.player.current_hp); 303 | assert_eq!(0, game.gold); 304 | assert_eq!(0, game.player.xp); 305 | assert!(!game.tombstones.is_empty()); 306 | } 307 | 308 | #[test] 309 | fn change_dir_home() { 310 | let mut game = Game::new(); 311 | 312 | assert!(game.location.is_home()); 313 | 314 | // force move to a non home location 315 | let cmd = Command::ChangeDir { 316 | destination: "~/..".to_string(), 317 | run: false, 318 | bribe: false, 319 | force: true, 320 | }; 321 | 322 | let result = run(Some(cmd), &mut game); 323 | assert!(result.is_ok()); 324 | assert!(!game.location.is_home()); 325 | 326 | game.player.current_hp = 1; 327 | 328 | // back home (without forcing) 329 | let cmd = Command::ChangeDir { 330 | destination: "~".to_string(), 331 | run: false, 332 | bribe: false, 333 | force: false, 334 | }; 335 | 336 | let result = run(Some(cmd), &mut game); 337 | assert!(result.is_ok()); 338 | assert!(game.location.is_home()); 339 | assert_eq!(game.player.max_hp(), game.player.current_hp); 340 | } 341 | 342 | #[test] 343 | fn change_dir_home_force() { 344 | let mut game = Game::new(); 345 | 346 | assert!(game.location.is_home()); 347 | 348 | // force move to a non home location 349 | let cmd = Command::ChangeDir { 350 | destination: "~/..".to_string(), 351 | run: false, 352 | bribe: false, 353 | force: true, 354 | }; 355 | 356 | let result = run(Some(cmd), &mut game); 357 | assert!(result.is_ok()); 358 | assert!(!game.location.is_home()); 359 | 360 | game.player.current_hp = 1; 361 | 362 | // force back home should restore hp 363 | let cmd = Command::ChangeDir { 364 | destination: "~".to_string(), 365 | run: false, 366 | bribe: false, 367 | force: true, 368 | }; 369 | 370 | let result = run(Some(cmd), &mut game); 371 | assert!(result.is_ok()); 372 | assert!(game.location.is_home()); 373 | assert_eq!(game.player.max_hp(), game.player.current_hp); 374 | } 375 | 376 | #[test] 377 | fn inspect_tombstone() { 378 | // die at non home with some gold 379 | let mut game = Game::new(); 380 | assert!(game.tombstones.is_empty()); 381 | 382 | let cmd = Command::ChangeDir { 383 | destination: "~/..".to_string(), 384 | run: false, 385 | bribe: false, 386 | force: false, 387 | }; 388 | 389 | // reduce stats to ensure loss 390 | game.player.current_hp = 1; 391 | 392 | game.gold = 100; 393 | assert!(run(Some(cmd), &mut game).is_err()); 394 | 395 | assert_eq!(0, game.gold); 396 | assert!(!game.tombstones.is_empty()); 397 | 398 | // force move to the previous dead location 399 | let cmd = Command::ChangeDir { 400 | destination: "~/..".to_string(), 401 | run: false, 402 | bribe: false, 403 | force: true, 404 | }; 405 | run(Some(cmd), &mut game).unwrap(); 406 | 407 | // inspect to pick up lost gold 408 | let cmd = Command::Inspect; 409 | let result = run(Some(cmd), &mut game); 410 | assert!(result.is_ok()); 411 | assert!(game.tombstones.is_empty()); 412 | 413 | // includes +200g for visit tombstone quest 414 | assert_eq!(300, game.gold); 415 | } 416 | 417 | #[test] 418 | fn buy_use_item() { 419 | let mut game = Game::new(); 420 | assert!(game.inventory().is_empty()); 421 | 422 | // not buy if not enough money 423 | let cmd = Command::Buy { 424 | items: vec![String::from("potion")], 425 | }; 426 | let result = run(Some(cmd), &mut game); 427 | assert!(result.is_err()); 428 | assert!(game.inventory().is_empty()); 429 | 430 | // buy potion 431 | game.gold = 200; 432 | let cmd = Command::Buy { 433 | items: vec![String::from("potion")], 434 | }; 435 | let result = run(Some(cmd), &mut game); 436 | assert!(result.is_ok()); 437 | assert!(!game.inventory().is_empty()); 438 | assert_eq!(0, game.gold); 439 | 440 | // use potion 441 | game.player.current_hp -= 1; 442 | let cmd = Command::Use { 443 | items: vec![String::from("potion")], 444 | }; 445 | let result = run(Some(cmd), &mut game); 446 | assert!(result.is_ok()); 447 | assert!(game.inventory().is_empty()); 448 | assert_eq!(game.player.max_hp(), game.player.current_hp); 449 | 450 | // not buy if not home 451 | let cmd = Command::ChangeDir { 452 | destination: "~/..".to_string(), 453 | run: false, 454 | bribe: false, 455 | force: true, 456 | }; 457 | run(Some(cmd), &mut game).unwrap(); 458 | 459 | game.gold = 200; 460 | let cmd = Command::Buy { 461 | items: vec![String::from("potion")], 462 | }; 463 | let result = run(Some(cmd), &mut game); 464 | assert!(result.is_err()); 465 | assert!(game.inventory().is_empty()); 466 | } 467 | } 468 | -------------------------------------------------------------------------------- /src/log.rs: -------------------------------------------------------------------------------- 1 | use crate::character::AttackType; 2 | use crate::character::{Character, StatusEffect}; 3 | use crate::game::Game; 4 | use crate::item::key::Key; 5 | use crate::location::Location; 6 | use colored::*; 7 | use once_cell::sync::OnceCell; 8 | use std::collections::HashMap; 9 | 10 | // This are initialized based on input args and then act as constants 11 | // this prevents having to pass around the flags or lazily parsing the opts 12 | static QUIET: OnceCell = OnceCell::new(); 13 | static PLAIN: OnceCell = OnceCell::new(); 14 | 15 | /// Set the global output preferences 16 | pub fn init(quiet: bool, plain: bool) { 17 | QUIET.set(quiet).unwrap(); 18 | PLAIN.set(plain).unwrap(); 19 | } 20 | 21 | fn quiet() -> bool { 22 | *QUIET.get().unwrap_or(&false) 23 | } 24 | 25 | fn plain() -> bool { 26 | *PLAIN.get().unwrap_or(&false) 27 | } 28 | 29 | pub fn enemy_appears(enemy: &Character, location: &Location) { 30 | log(enemy, location, ""); 31 | } 32 | 33 | pub fn attack(character: &Character, attack: &AttackType, damage: i32, mp_cost: i32) { 34 | if !quiet() { 35 | battle_log( 36 | character, 37 | &format_attack(character, attack, damage, mp_cost), 38 | ); 39 | } 40 | } 41 | 42 | pub fn status_effect(character: &Character, hp: i32, mp: i32) { 43 | if hp != 0 || mp != 0 { 44 | let emoji = character 45 | .status_effect 46 | .map_or("", |s| status_effect_params(s).1); 47 | 48 | battle_log( 49 | character, 50 | &format_stat_change(character, hp, mp, false, emoji), 51 | ); 52 | } 53 | } 54 | 55 | pub fn battle_won(game: &Game, xp: i32, levels_up: i32, gold: i32, items: &HashMap) { 56 | battle_log( 57 | &game.player, 58 | &format!( 59 | "{}{}{}", 60 | format!("+{}xp", xp).bold(), 61 | level_up(levels_up), 62 | format_ls("", items, gold) 63 | ), 64 | ); 65 | short_status(game); 66 | } 67 | 68 | pub fn battle_lost(player: &Character) { 69 | battle_log(player, "\u{1F480}"); 70 | } 71 | 72 | pub fn chest(items: &HashMap, gold: i32) { 73 | println!("{}", format_ls("\u{1F4E6}", items, gold)); 74 | } 75 | 76 | pub fn tombstone(items: &HashMap, gold: i32) { 77 | println!("{}", format_ls("\u{1FAA6} ", items, gold)); 78 | } 79 | 80 | pub fn bribe(player: &Character, amount: i32) { 81 | if amount > 0 { 82 | let suffix = format!("bribed {}", format_gold_signed(-amount)); 83 | battle_log(player, &suffix); 84 | } else { 85 | battle_log(player, "can't bribe!"); 86 | } 87 | } 88 | 89 | pub fn run_away(player: &Character, success: bool) { 90 | if success { 91 | battle_log(player, "fled!"); 92 | } else { 93 | battle_log(player, "can't run!"); 94 | } 95 | } 96 | 97 | pub fn heal_item( 98 | player: &Character, 99 | item: &str, 100 | recovered_hp: i32, 101 | recovered_mp: i32, 102 | healed: bool, 103 | ) { 104 | let color = if recovered_mp > 0 { "purple" } else { "green" }; 105 | 106 | if recovered_hp > 0 || recovered_mp > 0 || healed { 107 | battle_log( 108 | player, 109 | &format_stat_change( 110 | player, 111 | recovered_hp, 112 | recovered_mp, 113 | healed, 114 | &item.color(color), 115 | ), 116 | ); 117 | } 118 | } 119 | 120 | pub fn heal( 121 | player: &Character, 122 | location: &Location, 123 | recovered_hp: i32, 124 | recovered_mp: i32, 125 | healed: bool, 126 | ) { 127 | if recovered_hp > 0 || recovered_mp > 0 || healed { 128 | log( 129 | player, 130 | location, 131 | &format_stat_change(player, recovered_hp, recovered_mp, healed, ""), 132 | ); 133 | } 134 | } 135 | 136 | pub fn change_class(player: &Character, lost_xp: i32) { 137 | if lost_xp > 0 { 138 | battle_log(player, &format!("-{}xp", lost_xp).bright_red()); 139 | } 140 | } 141 | 142 | pub fn stat_increase(player: &Character, stat: &str, increase: i32) { 143 | let suffix = if stat == "level" { 144 | level_up(increase) 145 | } else { 146 | format!("+{}{}", increase, stat).cyan().to_string() 147 | }; 148 | battle_log(player, &suffix); 149 | } 150 | 151 | /// Print the hero status according to options 152 | pub fn status(game: &Game) { 153 | if plain() { 154 | plain_status(game); 155 | } else if quiet() { 156 | short_status(game); 157 | } else { 158 | long_status(game) 159 | } 160 | } 161 | 162 | pub fn shop_list(game: &Game, items: Vec<(i32, String)>) { 163 | for (cost, item) in items { 164 | println!(" {:<10} {}", item, format_gold(cost)); 165 | } 166 | 167 | println!("\n funds: {}", format_gold(game.gold)); 168 | } 169 | 170 | pub fn shop_buy(cost: i32, items: &HashMap) { 171 | if !items.is_empty() { 172 | println!(" {}", format_ls("", items, -cost)); 173 | } 174 | } 175 | 176 | pub fn quest_list(quests: Vec<(bool, String)>) { 177 | for (completed, quest) in quests { 178 | if completed { 179 | println!(" {} {}", "✔".green(), quest.dimmed()); 180 | } else { 181 | println!(" {} {}", "□".dimmed(), quest); 182 | } 183 | } 184 | } 185 | 186 | pub fn quest_done(reward: i32) { 187 | if !quiet() { 188 | println!(" {} quest completed!", format_gold_signed(reward)); 189 | } 190 | } 191 | 192 | fn level_up(levels_up: i32) -> String { 193 | if levels_up > 0 { 194 | let plus = (0..levels_up).map(|_| "+").collect::(); 195 | format!(" {}level", plus).cyan().to_string() 196 | } else { 197 | "".to_string() 198 | } 199 | } 200 | 201 | fn long_status(game: &Game) { 202 | let player = &game.player; 203 | let location = &game.location; 204 | 205 | println!("{}@{}", format_character(player), location); 206 | println!( 207 | " hp:{} {}/{}", 208 | hp_display(player, 10), 209 | player.current_hp, 210 | player.max_hp() 211 | ); 212 | 213 | let (current_mp, max_mp) = if player.class.is_magic() { 214 | (player.current_mp, player.max_mp()) 215 | } else { 216 | (0, 0) 217 | }; 218 | println!( 219 | " mp:{} {}/{}", 220 | mp_display(player, 10), 221 | current_mp, 222 | max_mp 223 | ); 224 | 225 | println!( 226 | " xp:{} {}/{}", 227 | xp_display(player, 10), 228 | player.xp, 229 | player.xp_for_next() 230 | ); 231 | if let Some(status) = player.status_effect { 232 | println!(" status: {}", format_status_effect(status).bright_red()); 233 | } 234 | println!( 235 | " att:{} mag:{} def:{} spd:{}", 236 | player.physical_attack(), 237 | player.magic_attack(), 238 | player.deffense(), 239 | player.speed() 240 | ); 241 | println!(" {}", format_equipment(player)); 242 | println!(" {}", format_inventory(game)); 243 | println!(" {}", format_gold(game.gold)); 244 | } 245 | 246 | fn short_status(game: &Game) { 247 | let player = &game.player; 248 | 249 | let suffix = if let Some(status) = player.status_effect { 250 | let (_, emoji) = status_effect_params(status); 251 | emoji 252 | } else { 253 | "" 254 | }; 255 | log(player, &game.location, suffix); 256 | } 257 | 258 | fn plain_status(game: &Game) { 259 | let player = &game.player; 260 | 261 | let status_effect = if let Some(status) = player.status_effect { 262 | let (name, _) = status_effect_params(status); 263 | format!("status:{}\t", name) 264 | } else { 265 | String::new() 266 | }; 267 | 268 | println!( 269 | "{}[{}]\t@{}\thp:{}/{}\tmp:{}/{}\txp:{}/{}\tatt:{}\tmag:{}\tdef:{}\tspd:{}\t{}{}\t{}\tg:{}", 270 | player.name(), 271 | player.level, 272 | game.location, 273 | player.current_hp, 274 | player.max_hp(), 275 | player.current_mp, 276 | player.max_mp(), 277 | player.xp, 278 | player.xp_for_next(), 279 | player.physical_attack(), 280 | player.magic_attack(), 281 | player.deffense(), 282 | player.speed(), 283 | status_effect, 284 | format_equipment(player), 285 | format_inventory(game), 286 | game.gold 287 | ); 288 | } 289 | 290 | fn format_ls(emoji: &str, items: &HashMap, gold: i32) -> String { 291 | let mut string = format!("{} ", emoji); 292 | 293 | if gold != 0 { 294 | string.push_str(&format!("{} ", format_gold_signed(gold))); 295 | } 296 | for (key, count) in items { 297 | string.push_str(&format!("+{}x{} ", key, count)); 298 | } 299 | string 300 | } 301 | 302 | // HELPERS 303 | 304 | /// Generic log function. At the moment all output of the game is structured as 305 | /// of a player status at some location, with an optional event suffix. 306 | fn log(character: &Character, location: &Location, suffix: &str) { 307 | println!( 308 | "{}{}{}{}@{} {}", 309 | format_character(character), 310 | hp_display(character, 4), 311 | mp_display(character, 4), 312 | xp_display(character, 4), 313 | location, 314 | suffix 315 | ); 316 | } 317 | 318 | fn battle_log(character: &Character, suffix: &str) { 319 | println!( 320 | "{}{} {}", 321 | format_character(character), 322 | hp_display(character, 4), 323 | suffix 324 | ); 325 | } 326 | 327 | fn format_character(character: &Character) -> String { 328 | let name = format!("{:>8}", character.name()); 329 | let name = if character.name() == "shadow" { 330 | name.dimmed() 331 | } else if character.is_player() { 332 | name.bold() 333 | } else { 334 | name.yellow().bold() 335 | }; 336 | format!("{}[{}]", name, character.level) 337 | } 338 | 339 | fn format_equipment(character: &Character) -> String { 340 | let mut fragments = Vec::new(); 341 | 342 | if let Some(sword) = &character.sword { 343 | fragments.push(sword.to_string()); 344 | } 345 | 346 | if let Some(shield) = &character.shield { 347 | fragments.push(shield.to_string()); 348 | } 349 | 350 | if let Some(ring) = &character.left_ring { 351 | fragments.push(ring.to_string()); 352 | } 353 | 354 | if let Some(ring) = &character.right_ring { 355 | fragments.push(ring.to_string()); 356 | } 357 | 358 | format!("equip:{{{}}}", fragments.join(",")) 359 | } 360 | 361 | pub fn format_inventory(game: &Game) -> String { 362 | let mut items = game 363 | .inventory() 364 | .iter() 365 | .map(|(k, v)| format!("{}x{}", k, v)) 366 | .collect::>(); 367 | 368 | items.sort(); 369 | format!("item:{{{}}}", items.join(",")) 370 | } 371 | 372 | fn format_attack(receiver: &Character, attack: &AttackType, damage: i32, mp_cost: i32) -> String { 373 | let magic_effect = if mp_cost > 0 { 374 | format!("\u{2728} -{}mp ", mp_cost).purple().to_string() 375 | } else { 376 | String::from("") 377 | }; 378 | 379 | match attack { 380 | AttackType::Regular => format_hp_change(receiver, -damage, &magic_effect), 381 | AttackType::Critical => { 382 | format_hp_change(receiver, -damage, &format!("{}critical!", magic_effect)) 383 | } 384 | AttackType::Effect(status_effect) => { 385 | format_hp_change(receiver, -damage, &format_status_effect(*status_effect)) 386 | } 387 | AttackType::Miss => format!("{}dodged!", magic_effect), 388 | } 389 | } 390 | 391 | fn format_stat_change( 392 | receiver: &Character, 393 | hp: i32, 394 | mp: i32, 395 | healed: bool, 396 | suffix: &str, 397 | ) -> String { 398 | let mut healed_text = String::new(); 399 | let mut mp_text = String::new(); 400 | 401 | if mp != 0 { 402 | mp_text = format!("{:+}mp ", mp); 403 | } 404 | if healed { 405 | healed_text = String::from("+healed "); 406 | } 407 | 408 | format!( 409 | "{}{}{}{}", 410 | &format_hp_change(receiver, hp, ""), 411 | mp_text.purple(), 412 | healed_text.green(), 413 | suffix 414 | ) 415 | } 416 | 417 | fn format_hp_change(receiver: &Character, amount: i32, suffix: &str) -> String { 418 | if amount != 0 { 419 | let color = if receiver.is_player() { 420 | if amount < 0 { 421 | "bright red" 422 | } else { 423 | "green" 424 | } 425 | } else { 426 | "white" 427 | }; 428 | format!("{:+}hp {}", amount, suffix) 429 | .color(color) 430 | .to_string() 431 | } else { 432 | String::from("") 433 | } 434 | } 435 | 436 | fn format_status_effect(status_effect: StatusEffect) -> String { 437 | let (name, emoji) = status_effect_params(status_effect); 438 | format!("{} {}!", emoji, name) 439 | } 440 | 441 | fn status_effect_params(status_effect: StatusEffect) -> (&'static str, &'static str) { 442 | match status_effect { 443 | StatusEffect::Burn => ("burn", "\u{1F525}"), 444 | StatusEffect::Poison => ("poison", "\u{2620}\u{FE0F} "), 445 | } 446 | } 447 | 448 | fn hp_display(character: &Character, slots: i32) -> String { 449 | bar_display( 450 | slots, 451 | character.current_hp, 452 | character.max_hp(), 453 | "green", 454 | "red", 455 | ) 456 | } 457 | 458 | fn mp_display(character: &Character, slots: i32) -> String { 459 | let current_mp = if character.class.is_magic() { 460 | character.current_mp 461 | } else { 462 | 0 463 | }; 464 | 465 | bar_display( 466 | slots, 467 | current_mp, 468 | character.max_mp(), 469 | "purple", 470 | "bright black", 471 | ) 472 | } 473 | 474 | fn xp_display(character: &Character, slots: i32) -> String { 475 | if character.is_player() { 476 | bar_display( 477 | slots, 478 | character.xp, 479 | character.xp_for_next(), 480 | "cyan", 481 | "bright black", 482 | ) 483 | } else { 484 | // enemies don't have experience 485 | String::new() 486 | } 487 | } 488 | 489 | fn bar_display( 490 | slots: i32, 491 | current: i32, 492 | total: i32, 493 | current_color: &str, 494 | missing_color: &str, 495 | ) -> String { 496 | let (filled, rest) = bar_slots(slots, total, current); 497 | let current = (0..filled) 498 | .map(|_| "x") 499 | .collect::() 500 | .color(current_color); 501 | let missing = (0..rest) 502 | .map(|_| "-") 503 | .collect::() 504 | .color(missing_color); 505 | format!("[{}{}]", current, missing) 506 | } 507 | 508 | fn bar_slots(slots: i32, total: i32, current: i32) -> (i32, i32) { 509 | let units = (current as f64 * slots as f64 / total as f64).ceil() as i32; 510 | (units, slots - units) 511 | } 512 | 513 | fn format_gold(gold: i32) -> ColoredString { 514 | format!("{}g", gold).yellow() 515 | } 516 | 517 | fn format_gold_signed(gold: i32) -> ColoredString { 518 | format!("{:+}g", gold).yellow() 519 | } 520 | 521 | #[cfg(test)] 522 | mod tests { 523 | use super::*; 524 | 525 | #[test] 526 | fn test_bar_slots() { 527 | // simple case 1:1 between points and slots 528 | let slots = 4; 529 | let total = 4; 530 | assert_eq!((0, 4), bar_slots(slots, total, 0)); 531 | assert_eq!((1, 3), bar_slots(slots, total, 1)); 532 | assert_eq!((2, 2), bar_slots(slots, total, 2)); 533 | assert_eq!((3, 1), bar_slots(slots, total, 3)); 534 | assert_eq!((4, 0), bar_slots(slots, total, 4)); 535 | 536 | let total = 10; 537 | assert_eq!((0, 4), bar_slots(slots, total, 0)); 538 | assert_eq!((1, 3), bar_slots(slots, total, 1)); 539 | assert_eq!((1, 3), bar_slots(slots, total, 2)); 540 | assert_eq!((2, 2), bar_slots(slots, total, 3)); 541 | assert_eq!((2, 2), bar_slots(slots, total, 4)); 542 | assert_eq!((2, 2), bar_slots(slots, total, 5)); 543 | assert_eq!((3, 1), bar_slots(slots, total, 6)); 544 | assert_eq!((3, 1), bar_slots(slots, total, 7)); 545 | // this one I would maybe like to show as 3, 1 546 | assert_eq!((4, 0), bar_slots(slots, total, 8)); 547 | assert_eq!((4, 0), bar_slots(slots, total, 9)); 548 | assert_eq!((4, 0), bar_slots(slots, total, 10)); 549 | } 550 | } 551 | -------------------------------------------------------------------------------- /src/game.rs: -------------------------------------------------------------------------------- 1 | use crate::character; 2 | use crate::character::enemy; 3 | use crate::character::Character; 4 | use crate::item::chest::Chest; 5 | use crate::item::key::Key; 6 | use crate::item::ring::Ring; 7 | use crate::item::Item; 8 | use crate::location::Location; 9 | use crate::log; 10 | use crate::quest; 11 | use crate::quest::QuestList; 12 | use crate::randomizer::random; 13 | use crate::randomizer::Randomizer; 14 | use anyhow::{bail, Result}; 15 | use serde::{Deserialize, Serialize}; 16 | use std::collections::{HashMap, HashSet}; 17 | 18 | /// Carries all the game state that is saved between commands and exposes 19 | /// the high-level interface for gameplay: moving across directories and 20 | /// engaging in battles. 21 | #[derive(Serialize, Deserialize)] 22 | #[serde(default)] 23 | pub struct Game { 24 | pub player: Character, 25 | pub location: Location, 26 | pub gold: i32, 27 | 28 | /// Items currently carried and unequipped 29 | pub inventory: HashMap>>, 30 | 31 | /// Locations where chest have already been looked for, and therefore 32 | /// can't be found again. 33 | inspected: HashSet, 34 | 35 | /// Chests left at the location where the player dies. 36 | pub tombstones: HashMap, 37 | 38 | /// There's one instance of each type of ring in the game. 39 | /// This set starts with all rings and they are moved to the inventory as 40 | /// they are found in chests. 41 | pub ring_pool: HashSet, 42 | 43 | pub quests: QuestList, 44 | } 45 | 46 | impl Game { 47 | pub fn new() -> Self { 48 | let quests = QuestList::new(); 49 | 50 | // There's one instance of each ring exiting per game. 51 | // The diamond ring is the only one that's found in the shop 52 | // instead of chests 53 | let mut ring_pool = Ring::set(); 54 | ring_pool.remove(&Ring::Diamond); 55 | 56 | Self { 57 | location: Location::home(), 58 | player: Character::player(), 59 | gold: 0, 60 | inventory: HashMap::new(), 61 | tombstones: HashMap::new(), 62 | inspected: HashSet::new(), 63 | quests, 64 | ring_pool, 65 | } 66 | } 67 | 68 | /// Remove the game data and reset this reference. 69 | /// Progress is preserved across games. 70 | pub fn reset(&mut self) { 71 | let mut new_game = Self::new(); 72 | // preserve tombstones and quests across hero's lifes 73 | std::mem::swap(&mut new_game.tombstones, &mut self.tombstones); 74 | std::mem::swap(&mut new_game.quests, &mut self.quests); 75 | std::mem::swap(&mut new_game.ring_pool, &mut self.ring_pool); 76 | 77 | // remember last selected class 78 | new_game.player = character::Character::new(self.player.class.clone(), 1); 79 | 80 | // replace the current, finished game with the new one 81 | *self = new_game; 82 | 83 | quest::game_reset(self); 84 | } 85 | 86 | /// Move the hero's location towards the given destination, one directory 87 | /// at a time, with some chance of enemies appearing on each one. 88 | pub fn go_to( 89 | &mut self, 90 | dest: &Location, 91 | run: bool, 92 | bribe: bool, 93 | ) -> Result<(), character::Dead> { 94 | while self.location != *dest { 95 | self.visit(self.location.go_to(dest))?; 96 | 97 | if !self.location.is_home() { 98 | if let Some(mut enemy) = enemy::spawn(&self.location, &self.player) { 99 | if self.battle(&mut enemy, run, bribe)? { 100 | return Ok(()); 101 | } 102 | } 103 | } 104 | } 105 | Ok(()) 106 | } 107 | 108 | /// Set the hero's location to the one given, and apply related side effects. 109 | pub fn visit(&mut self, location: Location) -> Result<(), character::Dead> { 110 | self.location = location; 111 | if self.location.is_home() { 112 | let (recovered_hp, recovered_mp, healed) = self.player.restore(); 113 | log::heal( 114 | &self.player, 115 | &self.location, 116 | recovered_hp, 117 | recovered_mp, 118 | healed, 119 | ); 120 | } 121 | 122 | // In location is home, already healed of negative status 123 | let result = self.player.apply_status_effects(); 124 | 125 | if let Err(character::Dead) = result { 126 | // drops tombstone 127 | self.battle_lost(); 128 | } 129 | result 130 | } 131 | 132 | /// Look for chests and tombstones at the current location. 133 | /// Remembers previously visited locations for consistency. 134 | pub fn inspect(&mut self) { 135 | if let Some(mut chest) = self.tombstones.remove(&self.location.to_string()) { 136 | let (items, gold) = chest.pick_up(self); 137 | log::tombstone(&items, gold); 138 | quest::tombstone(self); 139 | } 140 | 141 | if !self.inspected.contains(&self.location) { 142 | self.inspected.insert(self.location.clone()); 143 | if let Some(mut chest) = Chest::generate(self) { 144 | let (items, gold) = chest.pick_up(self); 145 | log::chest(&items, gold); 146 | quest::chest(self); 147 | } 148 | } 149 | } 150 | 151 | pub fn add_item(&mut self, item: Box) { 152 | let key = item.key(); 153 | let entry = self.inventory.entry(item.key()).or_default(); 154 | entry.push(item); 155 | quest::item_added(self, key); 156 | } 157 | 158 | pub fn use_item(&mut self, name: Key) -> Result<()> { 159 | // get all items of that type and use one 160 | // if there are no remaining, drop the type from the inventory 161 | if let Some(mut items) = self.inventory.remove(&name) { 162 | if let Some(mut item) = items.pop() { 163 | item.apply(self); 164 | quest::item_used(self, item.key()); 165 | } 166 | 167 | if !items.is_empty() { 168 | self.inventory.insert(name, items); 169 | } 170 | 171 | Ok(()) 172 | } else if let Some(ring) = self.player.unequip_ring(&name) { 173 | // Rings are a special case of item in that they can be "used" while being 174 | // equipped, that is, while not being in the inventory. 175 | // The effect of using them is unequipping them. 176 | // This bit of complexity enables a cleaner command api. 177 | quest::item_used(self, ring.key()); 178 | self.add_item(Box::new(ring)); 179 | Ok(()) 180 | } else { 181 | bail!("item not found.") 182 | } 183 | } 184 | 185 | pub fn inventory(&self) -> HashMap<&Key, usize> { 186 | self.inventory 187 | .iter() 188 | .map(|(k, v)| (k, v.len())) 189 | .collect::>() 190 | } 191 | 192 | pub fn describe(&self, key: Key) -> Result<(String, String)> { 193 | let (display, description) = match key { 194 | Key::Sword if self.player.sword.is_some() => self 195 | .player 196 | .sword 197 | .as_ref() 198 | .map(|s| (s.to_string(), s.describe())) 199 | .unwrap(), 200 | Key::Shield if self.player.shield.is_some() => self 201 | .player 202 | .shield 203 | .as_ref() 204 | .map(|s| (s.to_string(), s.describe())) 205 | .unwrap(), 206 | Key::Ring(ref ring) if self.player.left_ring.as_ref() == Some(ring) => { 207 | (ring.to_string(), ring.describe()) 208 | } 209 | Key::Ring(ref ring) if self.player.right_ring.as_ref() == Some(ring) => { 210 | (ring.to_string(), ring.describe()) 211 | } 212 | _ => { 213 | if let Some(items) = self.inventory.get(&key) { 214 | let item = items.first().unwrap(); 215 | (item.to_string(), item.describe()) 216 | } else { 217 | bail!("item {} not found.", key) 218 | } 219 | } 220 | }; 221 | 222 | Ok((display, description)) 223 | } 224 | 225 | /// Attempt to bribe or run away according to the given options, 226 | /// and start a battle if that fails. 227 | /// Return Ok(true) if a battle took place, Ok(false) if it was avoided, 228 | /// Err if the character dies. 229 | pub fn battle( 230 | &mut self, 231 | enemy: &mut Character, 232 | run: bool, 233 | bribe: bool, 234 | ) -> Result { 235 | // don't attempt bribe and run in the same turn 236 | if bribe { 237 | let bribe_cost = self.player.gold_gained(enemy.level) / 2; 238 | if self.gold >= bribe_cost && random().bribe_succeeds() { 239 | self.gold -= bribe_cost; 240 | log::bribe(&self.player, bribe_cost); 241 | return Ok(false); 242 | }; 243 | log::bribe(&self.player, 0); 244 | } else if run { 245 | let success = random().run_away_succeeds( 246 | self.player.level, 247 | enemy.level, 248 | self.player.speed(), 249 | enemy.speed(), 250 | ); 251 | log::run_away(&self.player, success); 252 | if success { 253 | return Ok(false); 254 | } 255 | } 256 | 257 | if let Ok(xp) = self.run_battle(enemy) { 258 | self.battle_won(enemy, xp); 259 | Ok(true) 260 | } else { 261 | self.battle_lost(); 262 | Err(character::Dead) 263 | } 264 | } 265 | 266 | /// Runs a turn-based combat between the game's player and the given enemy. 267 | /// The frequency of the turns is determined by the speed stat of each 268 | /// character. 269 | /// 270 | /// Some special abilities are enabled by the player's equipped rings: 271 | /// Double-beat, counter-attack and revive. 272 | /// 273 | /// Returns Ok(xp gained) if the player wins, or Err(()) if it loses. 274 | fn run_battle(&mut self, enemy: &mut Character) -> Result { 275 | // Player's using the revive ring can come back to life at most once per battle 276 | let mut already_revived = false; 277 | 278 | // These accumulators get increased based on the character's speed: 279 | // the faster will get more frequent turns. 280 | let (mut pl_accum, mut en_accum) = (0, 0); 281 | let mut xp = 0; 282 | 283 | while enemy.current_hp > 0 { 284 | pl_accum += self.player.speed(); 285 | en_accum += enemy.speed(); 286 | 287 | if pl_accum >= en_accum { 288 | // In some urgent circumstances, it's preferable to use the turn to 289 | // recover mp or hp than attacking 290 | if !self.autopotion(enemy) && !self.autoether(enemy) { 291 | let (new_xp, _) = self.player.attack(enemy); 292 | xp += new_xp; 293 | 294 | self.player.maybe_double_beat(enemy); 295 | } 296 | 297 | // Status effects are applied after each turn. The player may die 298 | // during its own turn because of status ailment damage 299 | let died = self.player.apply_status_effects(); 300 | already_revived = self.player.maybe_revive(died, already_revived)?; 301 | 302 | pl_accum = -1; 303 | } else { 304 | let (_, died) = enemy.attack(&mut self.player); 305 | already_revived = self.player.maybe_revive(died, already_revived)?; 306 | 307 | self.player.maybe_counter_attack(enemy); 308 | 309 | enemy.apply_status_effects().unwrap_or_default(); 310 | 311 | en_accum = -1; 312 | } 313 | } 314 | 315 | Ok(xp) 316 | } 317 | 318 | fn battle_won(&mut self, enemy: &Character, xp: i32) { 319 | let gold = self.player.gold_gained(enemy.level); 320 | self.gold += gold; 321 | let levels_up = self.player.add_experience(xp); 322 | 323 | let reward_items = 324 | Chest::battle_loot(self).map_or(HashMap::new(), |mut chest| chest.pick_up(self).0); 325 | 326 | log::battle_won(self, xp, levels_up, gold, &reward_items); 327 | quest::battle_won(self, enemy, levels_up); 328 | } 329 | 330 | fn battle_lost(&mut self) { 331 | // Drop hero items in the location. If there was a previous tombstone 332 | // merge the contents of both chests 333 | let mut tombstone = Chest::drop(self); 334 | let location = self.location.to_string(); 335 | if let Some(previous) = self.tombstones.remove(&location) { 336 | tombstone.extend(previous); 337 | } 338 | self.tombstones.insert(location, tombstone); 339 | 340 | log::battle_lost(&self.player); 341 | } 342 | 343 | /// If the player is low on hp and has a potion available use it 344 | /// instead of attacking in the current turn. 345 | fn autopotion(&mut self, enemy: &Character) -> bool { 346 | if self.player.current_hp > self.player.max_hp() / 3 { 347 | return false; 348 | } 349 | 350 | // If there's a good chance of winning the battle on the next attack, 351 | // don't use the potion. 352 | let (potential_damage, _) = self.player.damage(enemy); 353 | if potential_damage >= enemy.current_hp { 354 | return false; 355 | } 356 | 357 | self.use_item(Key::Potion).is_ok() 358 | } 359 | 360 | fn autoether(&mut self, enemy: &Character) -> bool { 361 | if !self.player.class.is_magic() || self.player.can_magic_attack() { 362 | return false; 363 | } 364 | 365 | // If there's a good chance of winning the battle on the next attack, 366 | // don't use the ether. 367 | let (potential_damage, _) = self.player.damage(enemy); 368 | if potential_damage >= enemy.current_hp { 369 | return false; 370 | } 371 | 372 | self.use_item(Key::Ether).is_ok() 373 | } 374 | } 375 | 376 | impl Default for Game { 377 | fn default() -> Self { 378 | Self::new() 379 | } 380 | } 381 | 382 | #[cfg(test)] 383 | mod tests { 384 | use super::*; 385 | use crate::character::class; 386 | use crate::item; 387 | 388 | #[test] 389 | fn test_inventory() { 390 | let mut game = Game::new(); 391 | 392 | assert_eq!(0, game.inventory().len()); 393 | 394 | let potion = item::Potion::new(1); 395 | game.add_item(Box::new(potion)); 396 | assert_eq!(1, game.inventory().len()); 397 | assert_eq!(1, *game.inventory().get(&Key::Potion).unwrap()); 398 | 399 | let potion = item::Potion::new(1); 400 | game.add_item(Box::new(potion)); 401 | assert_eq!(1, game.inventory().len()); 402 | assert_eq!(2, *game.inventory().get(&Key::Potion).unwrap()); 403 | 404 | game.player.current_hp -= 3; 405 | assert_ne!(game.player.max_hp(), game.player.current_hp); 406 | 407 | assert!(game.use_item(Key::Potion).is_ok()); 408 | 409 | // check it actually restores the hp 410 | assert_eq!(game.player.max_hp(), game.player.current_hp); 411 | 412 | // check item was consumed 413 | assert_eq!(1, game.inventory().len()); 414 | assert_eq!(1, *game.inventory().get(&Key::Potion).unwrap()); 415 | 416 | assert!(game.use_item(Key::Potion).is_ok()); 417 | assert_eq!(0, game.inventory().len()); 418 | assert!(game.use_item(Key::Potion).is_err()); 419 | } 420 | 421 | #[test] 422 | fn test_ring_equip() { 423 | let mut game = Game::new(); 424 | 425 | assert!(game.player.left_ring.is_none()); 426 | assert!(game.player.right_ring.is_none()); 427 | 428 | game.add_item(Box::new(Ring::Void)); 429 | game.add_item(Box::new(Ring::Void)); 430 | game.add_item(Box::new(Ring::Void)); 431 | assert_eq!(3, *game.inventory().get(&Key::Ring(Ring::Void)).unwrap()); 432 | 433 | game.use_item(Key::Ring(Ring::Void)).unwrap(); 434 | assert_eq!(2, *game.inventory().get(&Key::Ring(Ring::Void)).unwrap()); 435 | assert_eq!(Some(Ring::Void), game.player.left_ring); 436 | assert!(game.player.right_ring.is_none()); 437 | 438 | game.use_item(Key::Ring(Ring::Void)).unwrap(); 439 | assert_eq!(1, *game.inventory().get(&Key::Ring(Ring::Void)).unwrap()); 440 | assert_eq!(Some(Ring::Void), game.player.left_ring); 441 | assert_eq!(Some(Ring::Void), game.player.right_ring); 442 | 443 | game.use_item(Key::Ring(Ring::Void)).unwrap(); 444 | assert_eq!(1, *game.inventory().get(&Key::Ring(Ring::Void)).unwrap()); 445 | assert_eq!(Some(Ring::Void), game.player.left_ring); 446 | assert_eq!(Some(Ring::Void), game.player.right_ring); 447 | 448 | game.add_item(Box::new(Ring::Speed)); 449 | game.use_item(Key::Ring(Ring::Speed)).unwrap(); 450 | assert_eq!(2, *game.inventory().get(&Key::Ring(Ring::Void)).unwrap()); 451 | assert_eq!(Some(Ring::Speed), game.player.left_ring); 452 | assert_eq!(Some(Ring::Void), game.player.right_ring); 453 | } 454 | 455 | #[test] 456 | fn test_ring_unequip() { 457 | let mut game = Game::new(); 458 | 459 | game.add_item(Box::new(Ring::Void)); 460 | game.add_item(Box::new(Ring::HP)); 461 | game.use_item(Key::Ring(Ring::Void)).unwrap(); 462 | assert!(!game.inventory().contains_key(&Key::Ring(Ring::Void))); 463 | assert_eq!(Some(Ring::Void), game.player.left_ring); 464 | 465 | game.use_item(Key::Ring(Ring::Void)).unwrap(); 466 | assert!(game.inventory().contains_key(&Key::Ring(Ring::Void))); 467 | assert!(game.player.left_ring.is_none()); 468 | 469 | let base_hp = game.player.max_hp(); 470 | game.use_item(Key::Ring(Ring::Void)).unwrap(); 471 | game.use_item(Key::Ring(Ring::HP)).unwrap(); 472 | assert!(!game.inventory().contains_key(&Key::Ring(Ring::Void))); 473 | assert!(!game.inventory().contains_key(&Key::Ring(Ring::HP))); 474 | assert_eq!(Some(Ring::HP), game.player.left_ring); 475 | assert_eq!(Some(Ring::Void), game.player.right_ring); 476 | assert!(game.player.max_hp() > base_hp); 477 | 478 | game.use_item(Key::Ring(Ring::HP)).unwrap(); 479 | assert!(game.inventory().contains_key(&Key::Ring(Ring::HP))); 480 | assert_eq!(Some(Ring::Void), game.player.left_ring); 481 | assert!(game.player.right_ring.is_none()); 482 | assert_eq!(base_hp, game.player.max_hp()); 483 | } 484 | 485 | #[test] 486 | fn battle_won() { 487 | let enemy_base = class::Class::random(class::Category::Common); 488 | let enemy_class = class::Class { 489 | speed: class::Stat(1, 1), 490 | hp: class::Stat(16, 1), 491 | strength: class::Stat(5, 1), 492 | ..enemy_base.clone() 493 | }; 494 | let mut enemy = character::Character::new(enemy_class.clone(), 1); 495 | 496 | let mut game = Game::new(); 497 | let player_class = class::Class { 498 | speed: class::Stat(2, 1), 499 | hp: class::Stat(20, 1), 500 | strength: class::Stat(10, 1), // each hit will take 10hp 501 | ..game.player.class.clone() 502 | }; 503 | game.player = character::Character::new(player_class, 1); 504 | 505 | // expected turns 506 | // enemy - 10hp 507 | // player - 5 hp 508 | // enemy - 10hp (but has 3 remaining) 509 | 510 | let result = game.battle(&mut enemy, false, false); 511 | assert!(result.is_ok()); 512 | assert_eq!(15, game.player.current_hp); 513 | assert_eq!(1, game.player.level); 514 | assert_eq!(16, game.player.xp); 515 | // extra 100g for the completed quest 516 | assert_eq!(150, game.gold); 517 | 518 | let mut enemy = character::Character::new(enemy_class, 1); 519 | 520 | // same turns, added xp increases level 521 | 522 | let result = game.battle(&mut enemy, false, false); 523 | assert!(result.is_ok()); 524 | assert_eq!(2, game.player.level); 525 | assert_eq!(2, game.player.xp); 526 | // extra 100g for level up quest 527 | assert_eq!(300, game.gold); 528 | } 529 | 530 | #[test] 531 | fn battle_lost() { 532 | let mut game = Game::new(); 533 | let enemy_class = class::Class::random(class::Category::Common); 534 | let mut enemy = character::Character::new(enemy_class.clone(), 10); 535 | let result = game.battle(&mut enemy, false, false); 536 | assert!(result.is_err()); 537 | } 538 | } 539 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "anstream" 7 | version = "0.6.15" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" 10 | dependencies = [ 11 | "anstyle", 12 | "anstyle-parse", 13 | "anstyle-query", 14 | "anstyle-wincon", 15 | "colorchoice", 16 | "is_terminal_polyfill", 17 | "utf8parse", 18 | ] 19 | 20 | [[package]] 21 | name = "anstyle" 22 | version = "1.0.8" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" 25 | 26 | [[package]] 27 | name = "anstyle-parse" 28 | version = "0.2.5" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" 31 | dependencies = [ 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle-query" 37 | version = "1.1.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" 40 | dependencies = [ 41 | "windows-sys 0.52.0", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-wincon" 46 | version = "3.0.4" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" 49 | dependencies = [ 50 | "anstyle", 51 | "windows-sys 0.52.0", 52 | ] 53 | 54 | [[package]] 55 | name = "anyhow" 56 | version = "1.0.89" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" 59 | 60 | [[package]] 61 | name = "autocfg" 62 | version = "1.3.0" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" 65 | 66 | [[package]] 67 | name = "bincode" 68 | version = "1.3.3" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" 71 | dependencies = [ 72 | "serde", 73 | ] 74 | 75 | [[package]] 76 | name = "bitflags" 77 | version = "2.6.0" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 80 | 81 | [[package]] 82 | name = "byteorder" 83 | version = "1.5.0" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 86 | 87 | [[package]] 88 | name = "cfg-if" 89 | version = "1.0.0" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 92 | 93 | [[package]] 94 | name = "clap" 95 | version = "4.5.18" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" 98 | dependencies = [ 99 | "clap_builder", 100 | "clap_derive", 101 | ] 102 | 103 | [[package]] 104 | name = "clap_builder" 105 | version = "4.5.18" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" 108 | dependencies = [ 109 | "anstream", 110 | "anstyle", 111 | "clap_lex", 112 | "strsim", 113 | ] 114 | 115 | [[package]] 116 | name = "clap_derive" 117 | version = "4.5.18" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 120 | dependencies = [ 121 | "heck 0.5.0", 122 | "proc-macro2", 123 | "quote", 124 | "syn 2.0.77", 125 | ] 126 | 127 | [[package]] 128 | name = "clap_lex" 129 | version = "0.7.2" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 132 | 133 | [[package]] 134 | name = "colorchoice" 135 | version = "1.0.2" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" 138 | 139 | [[package]] 140 | name = "colored" 141 | version = "2.1.0" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" 144 | dependencies = [ 145 | "lazy_static", 146 | "windows-sys 0.48.0", 147 | ] 148 | 149 | [[package]] 150 | name = "ctor" 151 | version = "0.1.26" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" 154 | dependencies = [ 155 | "quote", 156 | "syn 1.0.109", 157 | ] 158 | 159 | [[package]] 160 | name = "dirs" 161 | version = "4.0.0" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" 164 | dependencies = [ 165 | "dirs-sys", 166 | ] 167 | 168 | [[package]] 169 | name = "dirs-sys" 170 | version = "0.3.7" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" 173 | dependencies = [ 174 | "libc", 175 | "redox_users", 176 | "winapi", 177 | ] 178 | 179 | [[package]] 180 | name = "dunce" 181 | version = "1.0.5" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 184 | 185 | [[package]] 186 | name = "erased-serde" 187 | version = "0.3.31" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" 190 | dependencies = [ 191 | "serde", 192 | ] 193 | 194 | [[package]] 195 | name = "getrandom" 196 | version = "0.2.15" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 199 | dependencies = [ 200 | "cfg-if", 201 | "libc", 202 | "wasi", 203 | ] 204 | 205 | [[package]] 206 | name = "ghost" 207 | version = "0.1.17" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "b0e085ded9f1267c32176b40921b9754c474f7dd96f7e808d4a982e48aa1e854" 210 | dependencies = [ 211 | "proc-macro2", 212 | "quote", 213 | "syn 2.0.77", 214 | ] 215 | 216 | [[package]] 217 | name = "hashbrown" 218 | version = "0.12.3" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 221 | 222 | [[package]] 223 | name = "heck" 224 | version = "0.4.1" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 227 | 228 | [[package]] 229 | name = "heck" 230 | version = "0.5.0" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 233 | 234 | [[package]] 235 | name = "indexmap" 236 | version = "1.9.3" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" 239 | dependencies = [ 240 | "autocfg", 241 | "hashbrown", 242 | ] 243 | 244 | [[package]] 245 | name = "inventory" 246 | version = "0.2.3" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | checksum = "84344c6e0b90a9e2b6f3f9abe5cc74402684e348df7b32adca28747e0cef091a" 249 | dependencies = [ 250 | "ctor", 251 | "ghost", 252 | ] 253 | 254 | [[package]] 255 | name = "is_terminal_polyfill" 256 | version = "1.70.1" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 259 | 260 | [[package]] 261 | name = "itoa" 262 | version = "1.0.11" 263 | source = "registry+https://github.com/rust-lang/crates.io-index" 264 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 265 | 266 | [[package]] 267 | name = "lazy_static" 268 | version = "1.5.0" 269 | source = "registry+https://github.com/rust-lang/crates.io-index" 270 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 271 | 272 | [[package]] 273 | name = "libc" 274 | version = "0.2.158" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" 277 | 278 | [[package]] 279 | name = "libredox" 280 | version = "0.1.3" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 283 | dependencies = [ 284 | "bitflags", 285 | "libc", 286 | ] 287 | 288 | [[package]] 289 | name = "linked-hash-map" 290 | version = "0.5.6" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 293 | 294 | [[package]] 295 | name = "memchr" 296 | version = "2.7.4" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 299 | 300 | [[package]] 301 | name = "once_cell" 302 | version = "1.19.0" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 305 | 306 | [[package]] 307 | name = "ppv-lite86" 308 | version = "0.2.20" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" 311 | dependencies = [ 312 | "zerocopy", 313 | ] 314 | 315 | [[package]] 316 | name = "proc-macro2" 317 | version = "1.0.86" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 320 | dependencies = [ 321 | "unicode-ident", 322 | ] 323 | 324 | [[package]] 325 | name = "quote" 326 | version = "1.0.37" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 329 | dependencies = [ 330 | "proc-macro2", 331 | ] 332 | 333 | [[package]] 334 | name = "rand" 335 | version = "0.8.5" 336 | source = "registry+https://github.com/rust-lang/crates.io-index" 337 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 338 | dependencies = [ 339 | "libc", 340 | "rand_chacha", 341 | "rand_core", 342 | ] 343 | 344 | [[package]] 345 | name = "rand_chacha" 346 | version = "0.3.1" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 349 | dependencies = [ 350 | "ppv-lite86", 351 | "rand_core", 352 | ] 353 | 354 | [[package]] 355 | name = "rand_core" 356 | version = "0.6.4" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 359 | dependencies = [ 360 | "getrandom", 361 | ] 362 | 363 | [[package]] 364 | name = "redox_users" 365 | version = "0.4.6" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 368 | dependencies = [ 369 | "getrandom", 370 | "libredox", 371 | "thiserror", 372 | ] 373 | 374 | [[package]] 375 | name = "rpg-cli" 376 | version = "1.2.0" 377 | dependencies = [ 378 | "anyhow", 379 | "bincode", 380 | "clap", 381 | "colored", 382 | "dirs", 383 | "dunce", 384 | "once_cell", 385 | "rand", 386 | "serde", 387 | "serde_json", 388 | "serde_yaml", 389 | "strum", 390 | "strum_macros", 391 | "typetag", 392 | ] 393 | 394 | [[package]] 395 | name = "rustversion" 396 | version = "1.0.17" 397 | source = "registry+https://github.com/rust-lang/crates.io-index" 398 | checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" 399 | 400 | [[package]] 401 | name = "ryu" 402 | version = "1.0.18" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 405 | 406 | [[package]] 407 | name = "serde" 408 | version = "1.0.210" 409 | source = "registry+https://github.com/rust-lang/crates.io-index" 410 | checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" 411 | dependencies = [ 412 | "serde_derive", 413 | ] 414 | 415 | [[package]] 416 | name = "serde_derive" 417 | version = "1.0.210" 418 | source = "registry+https://github.com/rust-lang/crates.io-index" 419 | checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" 420 | dependencies = [ 421 | "proc-macro2", 422 | "quote", 423 | "syn 2.0.77", 424 | ] 425 | 426 | [[package]] 427 | name = "serde_json" 428 | version = "1.0.128" 429 | source = "registry+https://github.com/rust-lang/crates.io-index" 430 | checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" 431 | dependencies = [ 432 | "itoa", 433 | "memchr", 434 | "ryu", 435 | "serde", 436 | ] 437 | 438 | [[package]] 439 | name = "serde_yaml" 440 | version = "0.8.26" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" 443 | dependencies = [ 444 | "indexmap", 445 | "ryu", 446 | "serde", 447 | "yaml-rust", 448 | ] 449 | 450 | [[package]] 451 | name = "strsim" 452 | version = "0.11.1" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 455 | 456 | [[package]] 457 | name = "strum" 458 | version = "0.24.1" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" 461 | 462 | [[package]] 463 | name = "strum_macros" 464 | version = "0.24.3" 465 | source = "registry+https://github.com/rust-lang/crates.io-index" 466 | checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" 467 | dependencies = [ 468 | "heck 0.4.1", 469 | "proc-macro2", 470 | "quote", 471 | "rustversion", 472 | "syn 1.0.109", 473 | ] 474 | 475 | [[package]] 476 | name = "syn" 477 | version = "1.0.109" 478 | source = "registry+https://github.com/rust-lang/crates.io-index" 479 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 480 | dependencies = [ 481 | "proc-macro2", 482 | "quote", 483 | "unicode-ident", 484 | ] 485 | 486 | [[package]] 487 | name = "syn" 488 | version = "2.0.77" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" 491 | dependencies = [ 492 | "proc-macro2", 493 | "quote", 494 | "unicode-ident", 495 | ] 496 | 497 | [[package]] 498 | name = "thiserror" 499 | version = "1.0.64" 500 | source = "registry+https://github.com/rust-lang/crates.io-index" 501 | checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" 502 | dependencies = [ 503 | "thiserror-impl", 504 | ] 505 | 506 | [[package]] 507 | name = "thiserror-impl" 508 | version = "1.0.64" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" 511 | dependencies = [ 512 | "proc-macro2", 513 | "quote", 514 | "syn 2.0.77", 515 | ] 516 | 517 | [[package]] 518 | name = "typetag" 519 | version = "0.1.8" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "4080564c5b2241b5bff53ab610082234e0c57b0417f4bd10596f183001505b8a" 522 | dependencies = [ 523 | "erased-serde", 524 | "inventory", 525 | "once_cell", 526 | "serde", 527 | "typetag-impl", 528 | ] 529 | 530 | [[package]] 531 | name = "typetag-impl" 532 | version = "0.1.8" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "e60147782cc30833c05fba3bab1d9b5771b2685a2557672ac96fa5d154099c0e" 535 | dependencies = [ 536 | "proc-macro2", 537 | "quote", 538 | "syn 1.0.109", 539 | ] 540 | 541 | [[package]] 542 | name = "unicode-ident" 543 | version = "1.0.13" 544 | source = "registry+https://github.com/rust-lang/crates.io-index" 545 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 546 | 547 | [[package]] 548 | name = "utf8parse" 549 | version = "0.2.2" 550 | source = "registry+https://github.com/rust-lang/crates.io-index" 551 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 552 | 553 | [[package]] 554 | name = "wasi" 555 | version = "0.11.0+wasi-snapshot-preview1" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 558 | 559 | [[package]] 560 | name = "winapi" 561 | version = "0.3.9" 562 | source = "registry+https://github.com/rust-lang/crates.io-index" 563 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 564 | dependencies = [ 565 | "winapi-i686-pc-windows-gnu", 566 | "winapi-x86_64-pc-windows-gnu", 567 | ] 568 | 569 | [[package]] 570 | name = "winapi-i686-pc-windows-gnu" 571 | version = "0.4.0" 572 | source = "registry+https://github.com/rust-lang/crates.io-index" 573 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 574 | 575 | [[package]] 576 | name = "winapi-x86_64-pc-windows-gnu" 577 | version = "0.4.0" 578 | source = "registry+https://github.com/rust-lang/crates.io-index" 579 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 580 | 581 | [[package]] 582 | name = "windows-sys" 583 | version = "0.48.0" 584 | source = "registry+https://github.com/rust-lang/crates.io-index" 585 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 586 | dependencies = [ 587 | "windows-targets 0.48.5", 588 | ] 589 | 590 | [[package]] 591 | name = "windows-sys" 592 | version = "0.52.0" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 595 | dependencies = [ 596 | "windows-targets 0.52.6", 597 | ] 598 | 599 | [[package]] 600 | name = "windows-targets" 601 | version = "0.48.5" 602 | source = "registry+https://github.com/rust-lang/crates.io-index" 603 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 604 | dependencies = [ 605 | "windows_aarch64_gnullvm 0.48.5", 606 | "windows_aarch64_msvc 0.48.5", 607 | "windows_i686_gnu 0.48.5", 608 | "windows_i686_msvc 0.48.5", 609 | "windows_x86_64_gnu 0.48.5", 610 | "windows_x86_64_gnullvm 0.48.5", 611 | "windows_x86_64_msvc 0.48.5", 612 | ] 613 | 614 | [[package]] 615 | name = "windows-targets" 616 | version = "0.52.6" 617 | source = "registry+https://github.com/rust-lang/crates.io-index" 618 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 619 | dependencies = [ 620 | "windows_aarch64_gnullvm 0.52.6", 621 | "windows_aarch64_msvc 0.52.6", 622 | "windows_i686_gnu 0.52.6", 623 | "windows_i686_gnullvm", 624 | "windows_i686_msvc 0.52.6", 625 | "windows_x86_64_gnu 0.52.6", 626 | "windows_x86_64_gnullvm 0.52.6", 627 | "windows_x86_64_msvc 0.52.6", 628 | ] 629 | 630 | [[package]] 631 | name = "windows_aarch64_gnullvm" 632 | version = "0.48.5" 633 | source = "registry+https://github.com/rust-lang/crates.io-index" 634 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 635 | 636 | [[package]] 637 | name = "windows_aarch64_gnullvm" 638 | version = "0.52.6" 639 | source = "registry+https://github.com/rust-lang/crates.io-index" 640 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 641 | 642 | [[package]] 643 | name = "windows_aarch64_msvc" 644 | version = "0.48.5" 645 | source = "registry+https://github.com/rust-lang/crates.io-index" 646 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 647 | 648 | [[package]] 649 | name = "windows_aarch64_msvc" 650 | version = "0.52.6" 651 | source = "registry+https://github.com/rust-lang/crates.io-index" 652 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 653 | 654 | [[package]] 655 | name = "windows_i686_gnu" 656 | version = "0.48.5" 657 | source = "registry+https://github.com/rust-lang/crates.io-index" 658 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 659 | 660 | [[package]] 661 | name = "windows_i686_gnu" 662 | version = "0.52.6" 663 | source = "registry+https://github.com/rust-lang/crates.io-index" 664 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 665 | 666 | [[package]] 667 | name = "windows_i686_gnullvm" 668 | version = "0.52.6" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 671 | 672 | [[package]] 673 | name = "windows_i686_msvc" 674 | version = "0.48.5" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 677 | 678 | [[package]] 679 | name = "windows_i686_msvc" 680 | version = "0.52.6" 681 | source = "registry+https://github.com/rust-lang/crates.io-index" 682 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 683 | 684 | [[package]] 685 | name = "windows_x86_64_gnu" 686 | version = "0.48.5" 687 | source = "registry+https://github.com/rust-lang/crates.io-index" 688 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 689 | 690 | [[package]] 691 | name = "windows_x86_64_gnu" 692 | version = "0.52.6" 693 | source = "registry+https://github.com/rust-lang/crates.io-index" 694 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 695 | 696 | [[package]] 697 | name = "windows_x86_64_gnullvm" 698 | version = "0.48.5" 699 | source = "registry+https://github.com/rust-lang/crates.io-index" 700 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 701 | 702 | [[package]] 703 | name = "windows_x86_64_gnullvm" 704 | version = "0.52.6" 705 | source = "registry+https://github.com/rust-lang/crates.io-index" 706 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 707 | 708 | [[package]] 709 | name = "windows_x86_64_msvc" 710 | version = "0.48.5" 711 | source = "registry+https://github.com/rust-lang/crates.io-index" 712 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 713 | 714 | [[package]] 715 | name = "windows_x86_64_msvc" 716 | version = "0.52.6" 717 | source = "registry+https://github.com/rust-lang/crates.io-index" 718 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 719 | 720 | [[package]] 721 | name = "yaml-rust" 722 | version = "0.4.5" 723 | source = "registry+https://github.com/rust-lang/crates.io-index" 724 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 725 | dependencies = [ 726 | "linked-hash-map", 727 | ] 728 | 729 | [[package]] 730 | name = "zerocopy" 731 | version = "0.7.35" 732 | source = "registry+https://github.com/rust-lang/crates.io-index" 733 | checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" 734 | dependencies = [ 735 | "byteorder", 736 | "zerocopy-derive", 737 | ] 738 | 739 | [[package]] 740 | name = "zerocopy-derive" 741 | version = "0.7.35" 742 | source = "registry+https://github.com/rust-lang/crates.io-index" 743 | checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" 744 | dependencies = [ 745 | "proc-macro2", 746 | "quote", 747 | "syn 2.0.77", 748 | ] 749 | --------------------------------------------------------------------------------