mod boss;
mod messages;
mod character;
mod display;
mod events;
mod journal;
mod loot;
mod sage;
mod state;
mod zones;
use character::{Class, Race};
use clap::{Parser, Subcommand};
use colored::*;
use std::io::{self, Write};
#[derive(Parser)]
#[command(name = "sq", version, about = "A passive RPG that lives in your terminal")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init,
#[clap(alias = "stat")]
Status {
#[clap(long)]
full: bool,
},
#[clap(alias = "inv")]
Inventory,
Journal,
Tick {
#[arg(long, default_value = "")]
cmd: String,
#[arg(long, default_value = ".")]
cwd: String,
#[arg(long, default_value_t = 0)]
exit_code: i32,
#[arg(long, hide = true)]
test_sage: bool,
},
Hook {
#[arg(long, default_value = "zsh")]
shell: String,
#[arg(long)]
install: bool,
#[arg(long)]
file: Option<String>,
},
#[clap(alias = "wear")]
Equip {
name: Vec<String>,
},
Wield {
name: Vec<String>,
},
#[clap(alias = "unequip")]
Remove {
name: Vec<String>,
},
Drop {
name: Vec<String>,
},
Shop,
Buy {
number: usize,
},
Sell {
number: usize,
},
Drink {
name: Vec<String>,
},
Prestige,
Reset,
Update,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Init => cmd_init(),
Commands::Status { full } => cmd_status(full),
Commands::Inventory => cmd_inventory(),
Commands::Journal => cmd_journal(),
Commands::Tick {
cmd,
cwd,
exit_code,
test_sage,
} => cmd_tick(&cmd, &cwd, exit_code, test_sage),
Commands::Hook { shell, install, file } => cmd_hook(&shell, install || file.is_some(), file),
Commands::Shop => cmd_shop(),
Commands::Buy { number } => cmd_buy(number),
Commands::Sell { number } => cmd_sell(number),
Commands::Equip { name } => cmd_equip(&name.join(" ")),
Commands::Wield { name } => cmd_wield(&name.join(" ")),
Commands::Remove { name } => cmd_remove(&name.join(" ")),
Commands::Drop { name } => cmd_drop_item(&name.join(" ")),
Commands::Drink { name } => cmd_drink(&name.join(" ")),
Commands::Prestige => cmd_prestige(),
Commands::Reset => cmd_reset(),
Commands::Update => cmd_update(),
}
}
fn prompt(msg: &str) -> String {
print!("{}", msg);
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
fn cmd_init() {
if state::save_path().exists() {
let answer = prompt(&format!(
"{} A character already exists! Overwrite? [y/N] ",
"⚠️".yellow()
));
if answer.to_lowercase() != "y" {
println!("{}", "Cancelled.".dimmed());
return;
}
}
println!();
println!(
"{}",
"⚔️ Welcome to sq — The Passive Terminal RPG ⚔️"
.bold()
.cyan()
);
println!(
"{}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
);
println!();
let name = loop {
let n = prompt(&format!("{} What is your name, adventurer? ", "📝".bold()));
if !n.is_empty() {
break n;
}
println!("{}", " Please enter a name.".red());
};
println!();
println!("{}", "Choose your class:".bold().yellow());
println!(
" {} {} — High INT, arcane power",
"1.".dimmed(),
"Wizard".blue().bold()
);
println!(
" {} {} — High STR, melee combat",
"2.".dimmed(),
"Warrior".red().bold()
);
println!(
" {} {} — High DEX, critical strikes",
"3.".dimmed(),
"Rogue".green().bold()
);
println!(
" {} {} — Balanced DEX/STR, versatile",
"4.".dimmed(),
"Ranger".yellow().bold()
);
println!(
" {} {} — Highest INT, dark arts",
"5.".dimmed(),
"Necromancer".magenta().bold()
);
let class = loop {
let c = prompt(&format!("{} Choose [1-5]: ", "🎭".bold()));
match c.as_str() {
"1" => break Class::Wizard,
"2" => break Class::Warrior,
"3" => break Class::Rogue,
"4" => break Class::Ranger,
"5" => break Class::Necromancer,
_ => println!("{}", " Pick 1-5.".red()),
}
};
println!();
println!("{}", "Choose your race:".bold().yellow());
println!(
" {} {} — Balanced stats (+1/+1/+1)",
"1.".dimmed(),
"Human".white().bold()
);
println!(
" {} {} — Agile & wise (+0/+2/+2)",
"2.".dimmed(),
"Elf".cyan().bold()
);
println!(
" {} {} — Tough & sturdy (+3/+0/+1)",
"3.".dimmed(),
"Dwarf".yellow().bold()
);
println!(
" {} {} — Raw strength (+4/+1/-1)",
"4.".dimmed(),
"Orc".red().bold()
);
println!(
" {} {} — Quick & clever (-1/+3/+1)",
"5.".dimmed(),
"Goblin".green().bold()
);
let race = loop {
let r = prompt(&format!("{} Choose [1-5]: ", "🧬".bold()));
match r.as_str() {
"1" => break Race::Human,
"2" => break Race::Elf,
"3" => break Race::Dwarf,
"4" => break Race::Orc,
"5" => break Race::Goblin,
_ => println!("{}", " Pick 1-5.".red()),
}
};
let character = character::Character::new(name.clone(), class, race);
let mut game_state = state::GameState::new(character);
println!();
println!("{}", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".red().bold());
println!("{} {}", "💀".bold(), "PERMADEATH MODE".red().bold());
println!(" If you die, your character is gone forever. All is lost.");
println!(" In standard mode, death resets your XP to 0 and costs 15% gold.");
println!("{}", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".red().bold());
let pd_answer = prompt("Enable permadeath? [y/N] ");
game_state.permadeath = pd_answer.trim().to_lowercase() == "y";
if game_state.permadeath {
println!(
"{} {}",
"☠".red().bold(),
"Permadeath enabled. May the void be merciful."
.red()
.dimmed()
);
} else {
println!(
"{} {}",
"✓".green(),
"Standard mode. Death is a setback, not the end.".dimmed()
);
}
match state::save(&game_state) {
Ok(()) => {
println!();
println!(
"{}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
);
println!(
"{} {} has entered the terminal realm!",
"🎉".bold(),
name.bold().green()
);
println!();
println!(" Run {} to install the shell hook.", "sq hook --shell zsh".cyan());
println!(
" Run {} to see your character.",
"sq status".cyan()
);
println!(
"{}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
);
println!();
}
Err(e) => {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
}
fn cmd_status(full: bool) {
match state::load() {
Ok(game) => {
display::print_status(&game.character, game.permadeath);
if full {
display::print_inventory(&game.character);
}
}
Err(e) => eprintln!("{} {}", "❌".bold(), e.red()),
}
}
fn cmd_inventory() {
match state::load() {
Ok(game) => display::print_inventory(&game.character),
Err(e) => eprintln!("{} {}", "❌".bold(), e.red()),
}
}
fn cmd_journal() {
match state::load() {
Ok(game) => display::print_journal(&game.journal),
Err(e) => eprintln!("{} {}", "❌".bold(), e.red()),
}
}
fn cmd_tick(cmd: &str, cwd: &str, exit_code: i32, test_sage: bool) {
let mut game = match state::load() {
Ok(g) => g,
Err(_) => return, };
events::tick(&mut game, cmd, cwd, exit_code);
if test_sage {
sage::force_show_sage(&mut game);
} else {
sage::maybe_show_sage(&mut game);
}
game.last_tick = chrono::Utc::now();
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
fn hook_code(shell: &str) -> Option<String> {
match shell {
"bash" => Some(r#"
# shellquest (sq) — passive terminal RPG hook
__sq_hook() {
local exit_code=$?
local cmd=$(HISTTIMEFORMAT= history 1 | sed 's/^ *[0-9]* *//')
sq tick --cmd "$cmd" --cwd "$PWD" --exit-code "$exit_code"
}
PROMPT_COMMAND="__sq_hook;$PROMPT_COMMAND"
"#.to_string()),
"zsh" => Some(r#"
# shellquest (sq) — passive terminal RPG hook
__sq_hook() {
local exit_code=$?
local cmd=$(fc -ln -1)
sq tick --cmd "$cmd" --cwd "$PWD" --exit-code "$exit_code"
}
precmd_functions+=(__sq_hook)
"#.to_string()),
"fish" => Some(r#"
# shellquest (sq) — passive terminal RPG hook
function __sq_hook --on-event fish_postexec
set -l cmd $argv[1]
set -l exit_code $status
sq tick --cmd "$cmd" --cwd "$PWD" --exit-code "$exit_code"
end
"#.to_string()),
_ => None,
}
}
fn default_rc_file(shell: &str) -> Option<String> {
let home = dirs::home_dir()?;
match shell {
"bash" => Some(home.join(".bashrc").to_string_lossy().to_string()),
"zsh" => Some(home.join(".zshrc").to_string_lossy().to_string()),
"fish" => Some(home.join(".config/fish/config.fish").to_string_lossy().to_string()),
_ => None,
}
}
fn cmd_hook(shell: &str, install: bool, file: Option<String>) {
let code = match hook_code(shell) {
Some(c) => c,
None => {
eprintln!(
"{} Unknown shell: {}. Supported: bash, zsh, fish",
"❌".bold(),
shell.red()
);
return;
}
};
if !install {
print!("{}", code);
return;
}
let target = file.or_else(|| default_rc_file(shell));
let target = match target {
Some(t) => t,
None => {
eprintln!("{} Could not determine rc file for shell: {}", "❌".bold(), shell.red());
return;
}
};
if let Ok(contents) = std::fs::read_to_string(&target) {
if contents.contains("__sq_hook") {
println!(
"{} Hook already installed in {}",
"✓".green().bold(),
target.cyan()
);
return;
}
}
use std::fs::OpenOptions;
match OpenOptions::new().create(true).append(true).open(&target) {
Ok(mut f) => {
use std::io::Write;
if let Err(e) = f.write_all(code.as_bytes()) {
eprintln!("{} Failed to write hook: {}", "❌".bold(), e.to_string().red());
return;
}
println!(
"{} Hook installed to {}",
"✓".green().bold(),
target.cyan()
);
println!(
" Run {} or restart your terminal to activate.",
format!("source {}", target).dimmed()
);
}
Err(e) => {
eprintln!("{} Failed to open {}: {}", "❌".bold(), target, e.to_string().red());
}
}
}
fn cmd_prestige() {
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
if !game.character.can_prestige() {
println!(
"{} You must reach level {} to prestige. Current level: {}",
"⚠️".yellow(),
format!("{}", character::MAX_LEVEL).cyan().bold(),
format!("{}", game.character.level).white().bold()
);
return;
}
println!();
println!(
"{}",
"✨ PRESTIGE ✨"
.yellow()
.bold()
.on_black()
);
println!(
"{}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".yellow()
);
println!();
println!(
" You will {} to level {} but gain:",
"reset".red().bold(),
"1".white().bold()
);
println!(
" {} {} to all stats per prestige tier",
"•".yellow(),
"+2".green().bold()
);
println!(
" {} A {} with unique stat bonuses",
"•".yellow(),
"subclass".magenta().bold()
);
println!(
" {} {} HP per prestige tier",
"•".yellow(),
"+10".green().bold()
);
println!(
" {} You {} your gold, gear, kills, and inventory",
"•".yellow(),
"keep".green().bold()
);
println!();
let subclasses = character::Subclass::available_for(&game.character.class);
println!("{}", "Choose your subclass:".bold().yellow());
for (i, sub) in subclasses.iter().enumerate() {
let (s, d, int) = sub.stat_bonus();
println!(
" {} {} — STR:{} DEX:{} INT:{}",
format!("{}.", i + 1).dimmed(),
format!("{}", sub).magenta().bold(),
format!("+{}", s).red(),
format!("+{}", d).green(),
format!("+{}", int).blue()
);
}
let subclass = loop {
let choice = prompt(&format!("{} Choose [1-{}]: ", "🎭".bold(), subclasses.len()));
if let Ok(n) = choice.parse::<usize>() {
if n >= 1 && n <= subclasses.len() {
break subclasses[n - 1].clone();
}
}
println!("{}", format!(" Pick 1-{}.", subclasses.len()).red());
};
let confirm = prompt(&format!(
"{} Prestige as {}? This resets your level! [y/N] ",
"⚠️".yellow(),
format!("{}", subclass).magenta().bold()
));
if confirm.to_lowercase() != "y" {
println!("{}", "Cancelled.".dimmed());
return;
}
let sub_name = format!("{}", subclass);
game.character.prestige(subclass);
match state::save(&game) {
Ok(()) => {
println!();
println!(
"{}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".yellow()
);
println!(
"{} {} has ascended as a {} {}! Prestige tier: {}",
"✨".bold(),
game.character.name.bold().green(),
sub_name.magenta().bold(),
format!("{}", game.character.class).cyan(),
format!("{}", game.character.prestige).yellow().bold()
);
println!(
"{}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".yellow()
);
println!();
}
Err(e) => {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
}
fn refresh_shop_if_needed(game: &mut state::GameState) {
use chrono::Utc;
let now = Utc::now();
let today_midnight = now.date_naive().and_hms_opt(0, 0, 0).unwrap();
let needs_refresh = match game.shop_refreshed {
None => true,
Some(last) => last.date_naive() < today_midnight.date(),
};
if needs_refresh {
game.shop_items.clear();
for _ in 0..6 {
game.shop_items.push(loot::roll_shop_loot());
}
game.shop_refreshed = Some(now);
}
}
fn cmd_shop() {
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if cwd != home {
println!(
"{} The shop is only accessible from your {}. You are in {}",
"🏠".bold(),
"home directory".cyan().bold(),
cwd.dimmed()
);
println!(
" Run {} to return home first.",
"cd ~".cyan()
);
return;
}
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
refresh_shop_if_needed(&mut game);
println!();
println!(
"{}",
"🏪 The Terminal Bazaar".bold().yellow()
);
println!("{}", "─".repeat(50).dimmed());
println!(
" {} {}",
"Your gold:".bold(),
format!("{}", game.character.gold).yellow().bold()
);
println!("{}", "─".repeat(50).dimmed());
if game.shop_items.is_empty() {
println!("{}", " The shop is empty... come back tomorrow.".dimmed());
} else {
for (i, item) in game.shop_items.iter().enumerate() {
let price = loot::item_price(item);
let rarity_str = match item.rarity {
character::Rarity::Common => format!("{}", "[Common]".dimmed()),
character::Rarity::Uncommon => format!("{}", "[Uncommon]".dimmed().bold()),
character::Rarity::Rare => format!("{}", "[Rare]".green().bold()),
_ => format!("{}", item.rarity),
};
let affordable = if game.character.gold >= price {
"".to_string()
} else {
format!(" {}", "(can't afford)".red().dimmed())
};
println!(
" {}. {} (+{} {}) {} — {} gold{}",
format!("{}", i + 1).dimmed(),
item.name.white().bold(),
item.power,
format!("{}", item.slot).dimmed(),
rarity_str,
format!("{}", price).yellow().bold(),
affordable
);
}
}
println!("{}", "─".repeat(50).dimmed());
println!(
" Use {} to purchase an item.",
"sq buy <number>".cyan()
);
println!(
" Shop refreshes daily at {}.",
"midnight UTC".dimmed()
);
println!();
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
fn cmd_buy(number: usize) {
if number == 0 {
eprintln!(
"{} Usage: {} (see {} for numbered list)",
"❌".bold(),
"sq buy <number>".cyan(),
"sq shop".cyan()
);
return;
}
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if cwd != home {
println!(
"{} The shop is only accessible from your {}.",
"🏠".bold(),
"home directory".cyan().bold()
);
return;
}
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
refresh_shop_if_needed(&mut game);
let idx = number - 1;
if idx >= game.shop_items.len() {
println!(
"{} Invalid item number {}. The shop has {} items. Run {} to see the list.",
"⚠️".yellow(),
format!("{}", number).white().bold(),
format!("{}", game.shop_items.len()).white().bold(),
"sq shop".cyan()
);
return;
}
let price = loot::item_price(&game.shop_items[idx]);
if game.character.gold < price {
println!(
"{} Not enough gold! {} costs {} gold, you have {}.",
"⚠️".yellow(),
game.shop_items[idx].name.white().bold(),
format!("{}", price).yellow().bold(),
format!("{}", game.character.gold).yellow()
);
return;
}
let item = game.shop_items.remove(idx);
let item_name = item.name.clone();
game.character.gold -= price;
game.character.inventory.push(item);
println!(
"{} Purchased {} for {} gold! ({} gold remaining)",
"💰".bold(),
item_name.green().bold(),
format!("{}", price).yellow().bold(),
format!("{}", game.character.gold).yellow()
);
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
fn cmd_sell(number: usize) {
if number == 0 {
eprintln!(
"{} Usage: {} (see {} for numbered list)",
"❌".bold(),
"sq sell <number>".cyan(),
"sq inventory".cyan()
);
return;
}
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if cwd != home {
println!(
"{} The shop is only accessible from your {}.",
"🏠".bold(),
"home directory".cyan().bold()
);
return;
}
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
if game.character.inventory.is_empty() {
println!(
"{} Nothing to sell. Check {}.",
"⚠️".yellow(),
"sq inventory".cyan()
);
return;
}
let idx = number - 1;
if idx >= game.character.inventory.len() {
println!(
"{} No item at slot {}. You have {} item{}. Check {}.",
"⚠️".yellow(),
format!("{}", number).white().bold(),
format!("{}", game.character.inventory.len()).white().bold(),
if game.character.inventory.len() == 1 {
""
} else {
"s"
},
"sq inventory".cyan()
);
return;
}
let sell_price = loot::item_price(&game.character.inventory[idx]) / 2;
let item = game.character.inventory.remove(idx);
let old_gold = game.character.gold;
game.character.gold += sell_price;
println!(
"{} Sold {} (+{} {}) [{}] for {} gold.",
"💰".bold(),
item.name.white().bold(),
item.power,
format!("{}", item.slot).dimmed(),
format!("{}", item.rarity).dimmed(),
format!("{}", sell_price).yellow().bold(),
);
println!(
" Gold: {} → {}",
format!("{}", old_gold).dimmed(),
format!("{}", game.character.gold).yellow().bold(),
);
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
fn fuzzy_match_name(item_name: &str, query: &str) -> bool {
let name_lower = item_name.to_lowercase();
query
.to_lowercase()
.split_whitespace()
.all(|token| name_lower.contains(token))
}
fn find_inventory_items(game: &state::GameState, query: &str) -> Vec<usize> {
let query_lower = query.to_lowercase();
let inv = &game.character.inventory;
let mut matched: Vec<usize> = (0..inv.len())
.filter(|&i| {
let name_lower = inv[i].name.to_lowercase();
name_lower == query_lower
|| name_lower.contains(&query_lower)
|| fuzzy_match_name(&inv[i].name, query)
})
.collect();
matched.dedup();
matched
}
fn find_inventory_item(game: &state::GameState, name: &str) -> Result<Option<usize>, String> {
let (query, n) = if let Some(dot_pos) = name.rfind('.') {
let suffix = &name[dot_pos + 1..];
match suffix.parse::<usize>() {
Ok(0) => {
return Err("Item index must be 1 or higher (e.g. potion.1)".to_string());
}
Ok(n) => (&name[..dot_pos], n),
Err(_) => (name, 1usize),
}
} else {
(name, 1usize)
};
let matches = find_inventory_items(game, query);
if matches.is_empty() {
return Ok(None);
}
match matches.get(n - 1) {
Some(&idx) => Ok(Some(idx)),
None => Err(format!(
"Only {} '{}' item(s) found — use {}.1 … {}.{}",
matches.len(),
query,
query,
query,
matches.len()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::character::{Character, Class, Item, ItemSlot, Race, Rarity};
fn make_state_with_items(items: Vec<Item>) -> state::GameState {
let mut s = state::GameState::new(Character::new("T".to_string(), Class::Rogue, Race::Human));
s.character.inventory = items;
s
}
fn item(name: &str) -> Item {
Item { name: name.to_string(), slot: ItemSlot::Weapon, power: 1, rarity: Rarity::Common }
}
#[test]
fn fuzzy_match_two_tokens_both_present() {
assert!(fuzzy_match_name("Big Sword of Awesome", "big of"));
}
#[test]
fn fuzzy_match_partial_word_token() {
assert!(fuzzy_match_name("Big Sword of Awesome", "big sw"));
}
#[test]
fn fuzzy_match_case_insensitive() {
assert!(fuzzy_match_name("Big Sword of Awesome", "BIG SWORD"));
}
#[test]
fn fuzzy_match_single_token_prefix() {
assert!(fuzzy_match_name("Big Sword of Awesome", "awe"));
}
#[test]
fn fuzzy_match_full_name_exact() {
assert!(fuzzy_match_name("Big Sword of Awesome", "Big Sword of Awesome"));
}
#[test]
fn fuzzy_match_token_missing_returns_false() {
assert!(!fuzzy_match_name("Big Sword of Awesome", "xyz"));
}
#[test]
fn fuzzy_match_one_token_absent_returns_false() {
assert!(!fuzzy_match_name("Big Sword of Awesome", "big xyz"));
}
#[test]
fn fuzzy_match_empty_query_returns_true() {
assert!(fuzzy_match_name("Big Sword of Awesome", ""));
}
#[test]
fn find_inventory_item_exact_match() {
let state = make_state_with_items(vec![item("Big Sword of Awesome")]);
assert_eq!(find_inventory_item(&state, "Big Sword of Awesome"), Ok(Some(0)));
}
#[test]
fn find_inventory_item_case_insensitive_exact() {
let state = make_state_with_items(vec![item("Big Sword of Awesome")]);
assert_eq!(find_inventory_item(&state, "big sword of awesome"), Ok(Some(0)));
}
#[test]
fn find_inventory_item_substring_match() {
let state = make_state_with_items(vec![item("Big Sword of Awesome")]);
assert_eq!(find_inventory_item(&state, "big sw"), Ok(Some(0)));
}
#[test]
fn find_inventory_item_fuzzy_non_contiguous_tokens() {
let state = make_state_with_items(vec![item("Big Sword of Awesome")]);
assert_eq!(find_inventory_item(&state, "big of"), Ok(Some(0)));
}
#[test]
fn find_inventory_item_fuzzy_case_insensitive() {
let state = make_state_with_items(vec![item("Big Sword of Awesome")]);
assert_eq!(find_inventory_item(&state, "BIG OF"), Ok(Some(0)));
}
#[test]
fn find_inventory_item_no_match_returns_none() {
let state = make_state_with_items(vec![item("Big Sword of Awesome")]);
assert_eq!(find_inventory_item(&state, "hammer"), Ok(None));
}
#[test]
fn find_inventory_item_exact_wins_over_fuzzy() {
let state = make_state_with_items(vec![
item("Small Shield"),
item("Big Sword of Awesome"),
]);
assert_eq!(find_inventory_item(&state, "Big Sword of Awesome"), Ok(Some(1)));
}
#[test]
fn find_inventory_item_fuzzy_picks_first_among_multiple() {
let state = make_state_with_items(vec![
item("Big Dagger of Doom"),
item("Big Sword of Awesome"),
]);
assert_eq!(find_inventory_item(&state, "big of"), Ok(Some(0)));
}
#[test]
fn find_all_empty_inventory_returns_empty() {
let state = make_state_with_items(vec![]);
assert_eq!(find_inventory_items(&state, "potion"), Vec::<usize>::new());
}
#[test]
fn find_all_single_word_matches_substring() {
let state = make_state_with_items(vec![
item("Potion of Coffee"),
item("Rusty Pipe"),
item("Potion of Sorrow"),
]);
assert_eq!(find_inventory_items(&state, "potion"), vec![0, 2]);
}
#[test]
fn find_all_multi_token_requires_all_tokens() {
let state = make_state_with_items(vec![
item("Big Sword of Awesome"),
item("Small Dagger"),
item("Big Shield"),
]);
assert_eq!(find_inventory_items(&state, "big sword"), vec![0]);
}
#[test]
fn find_all_case_insensitive() {
let state = make_state_with_items(vec![item("Potion of Coffee"), item("Rusty Pipe")]);
assert_eq!(find_inventory_items(&state, "POTION"), vec![0]);
}
#[test]
fn find_all_no_match_returns_empty() {
let state = make_state_with_items(vec![item("Rusty Pipe")]);
assert_eq!(find_inventory_items(&state, "xyz"), Vec::<usize>::new());
}
#[test]
fn find_all_exact_and_partial_both_included_in_order() {
let state = make_state_with_items(vec![
item("Rusty Pipe"),
item("Pipewright Gauntlets"),
item("Sword"),
]);
let result = find_inventory_items(&state, "pipe");
assert_eq!(result, vec![0, 1]);
}
#[test]
fn find_all_returns_stable_inventory_order() {
let state = make_state_with_items(vec![
item("Potion of Sorrow"),
item("Potion of Coffee"),
item("Rusty Pipe"),
]);
let result = find_inventory_items(&state, "potion");
assert_eq!(result, vec![0, 1]);
}
#[test]
fn selector_no_suffix_returns_first_match() {
let state = make_state_with_items(vec![item("Potion of Coffee"), item("Potion of Sorrow")]);
assert_eq!(find_inventory_item(&state, "potion"), Ok(Some(0)));
}
#[test]
fn selector_explicit_dot_one_returns_first_match() {
let state = make_state_with_items(vec![item("Potion of Coffee"), item("Potion of Sorrow")]);
assert_eq!(find_inventory_item(&state, "potion.1"), Ok(Some(0)));
}
#[test]
fn selector_dot_two_returns_second_match() {
let state = make_state_with_items(vec![item("Potion of Coffee"), item("Potion of Sorrow")]);
assert_eq!(find_inventory_item(&state, "potion.2"), Ok(Some(1)));
}
#[test]
fn selector_dot_zero_returns_err() {
let state = make_state_with_items(vec![item("Potion of Coffee")]);
let result = find_inventory_item(&state, "potion.0");
assert!(result.is_err());
assert!(result.unwrap_err().contains("1 or higher"));
}
#[test]
fn selector_n_exceeds_match_count_returns_err() {
let state = make_state_with_items(vec![item("Potion of Coffee"), item("Potion of Sorrow")]);
let result = find_inventory_item(&state, "potion.5");
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("Only 2"), "expected 'Only 2' in: {msg}");
assert!(msg.contains("potion"), "expected query name in: {msg}");
}
#[test]
fn selector_non_numeric_suffix_treated_as_query() {
let state = make_state_with_items(vec![item("Potion of Coffee")]);
assert_eq!(find_inventory_item(&state, "Potion.of.Coffee"), Ok(None));
}
#[test]
fn selector_no_match_no_suffix_returns_ok_none() {
let state = make_state_with_items(vec![item("Rusty Pipe")]);
assert_eq!(find_inventory_item(&state, "xyz"), Ok(None));
}
#[test]
fn selector_no_match_with_valid_suffix_returns_ok_none() {
let state = make_state_with_items(vec![item("Rusty Pipe")]);
assert_eq!(find_inventory_item(&state, "xyz.2"), Ok(None));
}
#[test]
fn selector_dot_n_on_exact_match_works() {
let state = make_state_with_items(vec![item("Rusty Pipe"), item("Rusty Sword")]);
assert_eq!(find_inventory_item(&state, "rusty.2"), Ok(Some(1)));
}
}
fn cmd_equip(name: &str) {
if name.is_empty() {
eprintln!(
"{} Usage: {} or {}",
"❌".bold(),
"sq equip <armor name>".cyan(),
"sq equip <ring name>".cyan()
);
return;
}
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
let idx = match find_inventory_item(&game, name) {
Ok(Some(i)) => i,
Ok(None) => {
println!(
"{} No item matching {} in your inventory.",
"⚠️".yellow(),
format!("\"{}\"", name).white().bold()
);
return;
}
Err(msg) => {
println!("{} {}", "⚠️".yellow(), msg);
return;
}
};
let item = &game.character.inventory[idx];
match item.slot {
character::ItemSlot::Weapon => {
println!(
"{} {} is a weapon. Use {} instead.",
"⚠️".yellow(),
item.name.cyan().bold(),
"sq wield".cyan()
);
return;
}
character::ItemSlot::Potion => {
println!(
"{} {} is a potion and cannot be equipped.",
"⚠️".yellow(),
item.name.cyan().bold()
);
return;
}
character::ItemSlot::Armor | character::ItemSlot::Ring => {}
}
let item = game.character.inventory.remove(idx);
let item_name = item.name.clone();
let slot_name = format!("{}", item.slot);
if let Some(old) = game.character.equip(item) {
let old_name = old.name.clone();
game.character.inventory.push(old);
println!(
"{} Equipped {}! (replaced {})",
"🛡️".bold(),
item_name.green().bold(),
old_name.dimmed()
);
} else {
println!(
"{} Equipped {} in {} slot!",
"🛡️".bold(),
item_name.green().bold(),
slot_name.cyan()
);
}
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
fn cmd_wield(name: &str) {
if name.is_empty() {
eprintln!(
"{} Usage: {}",
"❌".bold(),
"sq wield <weapon name>".cyan()
);
return;
}
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
let idx = match find_inventory_item(&game, name) {
Ok(Some(i)) => i,
Ok(None) => {
println!(
"{} No item matching {} in your inventory.",
"⚠️".yellow(),
format!("\"{}\"", name).white().bold()
);
return;
}
Err(msg) => {
println!("{} {}", "⚠️".yellow(), msg);
return;
}
};
let item = &game.character.inventory[idx];
if item.slot != character::ItemSlot::Weapon {
println!(
"{} {} is not a weapon. Use {} to wear armor or rings.",
"⚠️".yellow(),
item.name.cyan().bold(),
"sq equip".cyan()
);
return;
}
let item = game.character.inventory.remove(idx);
let item_name = item.name.clone();
if let Some(old) = game.character.equip(item) {
let old_name = old.name.clone();
game.character.inventory.push(old);
println!(
"{} Now wielding {}! (sheathed {})",
"⚔️".bold(),
item_name.green().bold(),
old_name.dimmed()
);
} else {
println!(
"{} Now wielding {}!",
"⚔️".bold(),
item_name.green().bold()
);
}
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
fn cmd_remove(name: &str) {
if name.is_empty() {
eprintln!(
"{} Usage: {} (or use slot keyword: weapon, armor, ring)",
"❌".bold(),
"sq remove <item name>".cyan()
);
return;
}
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
let query = name.to_lowercase();
let slot = if fuzzy_match_name(
game.character.weapon.as_ref().map_or("", |i| &i.name),
name,
) || query == "weapon"
{
"weapon"
} else if fuzzy_match_name(
game.character.armor.as_ref().map_or("", |i| &i.name),
name,
) || query == "armor"
|| query == "armour"
{
"armor"
} else if fuzzy_match_name(
game.character.ring.as_ref().map_or("", |i| &i.name),
name,
) || query == "ring"
{
"ring"
} else {
let equipped: Vec<&str> = [
game.character.weapon.as_ref().map(|i| i.name.as_str()),
game.character.armor.as_ref().map(|i| i.name.as_str()),
game.character.ring.as_ref().map(|i| i.name.as_str()),
]
.iter()
.filter_map(|x| *x)
.collect();
if equipped.is_empty() {
println!("{} Nothing equipped. Use {} to see your gear.", "⚠️".yellow(), "sq status".cyan());
} else {
println!(
"{} No equipped item matching {}. Equipped: {}",
"⚠️".yellow(),
format!("\"{}\"", name).white().bold(),
equipped.join(", ").dimmed()
);
}
return;
};
if game.character.inventory.len() >= 20 {
println!(
"{} Inventory full (20/20). Drop an item first with {}.",
"⚠️".yellow(),
"sq drop <name>".cyan()
);
return;
}
let item = match slot {
"weapon" => game.character.weapon.take(),
"armor" => game.character.armor.take(),
"ring" => game.character.ring.take(),
_ => None,
};
if let Some(item) = item {
let item_name = item.name.clone();
let slot_name = format!("{}", item.slot);
game.character.inventory.push(item);
println!(
"{} Removed {} from {} slot → moved to inventory.",
"📦".bold(),
item_name.white().bold(),
slot_name.dimmed()
);
}
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
fn cmd_drink(name: &str) {
if name.is_empty() {
eprintln!(
"{} Usage: {}",
"❌".bold(),
"sq drink <potion name>".cyan()
);
return;
}
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
let idx = match find_inventory_item(&game, name) {
Ok(Some(i)) => i,
Ok(None) => {
println!(
"{} No item matching {} in your inventory.",
"⚠️".yellow(),
format!("\"{}\"", name).white().bold()
);
return;
}
Err(msg) => {
println!("{} {}", "⚠️".yellow(), msg);
return;
}
};
let item = &game.character.inventory[idx];
if item.slot != character::ItemSlot::Potion {
println!(
"{} {} is not drinkable.",
"⚠️".yellow(),
item.name.cyan().bold()
);
return;
}
let item = game.character.inventory.remove(idx);
let heal = item.power;
let item_name = item.name.clone();
game.character.heal(heal);
println!(
"{} You drink the {}! Restored {} HP. HP: {}/{}",
"🧪".bold(),
item_name.green().bold(),
format!("+{}", heal).green().bold(),
format!("{}", game.character.hp).white().bold(),
game.character.max_hp
);
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
fn cmd_drop_item(name: &str) {
if name.is_empty() {
eprintln!(
"{} Usage: {}",
"❌".bold(),
"sq drop <item name>".cyan()
);
return;
}
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
let idx = match find_inventory_item(&game, name) {
Ok(Some(i)) => i,
Ok(None) => {
println!(
"{} No item matching {} in your inventory.",
"⚠️".yellow(),
format!("\"{}\"", name).white().bold()
);
return;
}
Err(msg) => {
println!("{} {}", "⚠️".yellow(), msg);
return;
}
};
let item = game.character.inventory.remove(idx);
println!(
"{} Dropped {} forever. It vanishes into the void.",
"🗑️".bold(),
item.name.red().bold()
);
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save: {}", "❌".bold(), e.red());
}
}
fn cmd_reset() {
let answer = prompt(&format!(
"{} This will delete your character permanently! Are you sure? [y/N] ",
"💀".red().bold()
));
if answer.to_lowercase() == "y" {
let path = state::save_path();
if path.exists() {
match std::fs::remove_file(&path) {
Ok(()) => println!(
"{} Character deleted. Run {} to start over.",
"🗑️".bold(),
"sq init".cyan()
),
Err(e) => eprintln!("{} Failed to delete: {}", "❌".bold(), e.to_string().red()),
}
} else {
println!("{}", "No character found.".dimmed());
}
} else {
println!("{}", "Cancelled.".dimmed());
}
}
fn cmd_update() {
use std::process::Command;
println!();
println!(
"{}",
"⬆️ Updating shellquest...".bold().cyan()
);
println!(
"{}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
);
println!(
" {} Installing latest version from {}...",
"📦".bold(),
"crates.io".cyan()
);
let status = Command::new("cargo")
.args(["install", "shellquest", "--force"])
.status();
match status {
Ok(s) if s.success() => {
println!();
println!(
"{}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
);
println!(
"{} {} Restart your shell or run {} to use the new version.",
"✅".bold(),
"Update complete!".green().bold(),
"sq status".cyan()
);
println!(
"{}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
);
println!();
}
Ok(_) => {
eprintln!(
"{} {} Try manually: {}",
"❌".bold(),
"Update failed.".red(),
"cargo install shellquest --force".dimmed()
);
}
Err(e) => {
eprintln!(
"{} Failed to run cargo: {}",
"❌".bold(),
e.to_string().red()
);
eprintln!(
" Make sure {} is installed: {}",
"cargo".bold(),
"https://rustup.rs".dimmed()
);
}
}
}