mod arena;
mod boss;
mod messages;
mod character;
mod display;
mod events;
mod help;
mod journal;
mod loot;
mod sage;
mod state;
mod zones;
use character::{Class, Race};
use clap::{Parser, Subcommand};
use colored::*;
use std::io::{self, IsTerminal, Write};
#[derive(Parser)]
#[command(
name = "sq",
version,
about = "A passive RPG that lives in your terminal",
disable_help_subcommand = true
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Help {
topic: Option<String>,
},
Init,
#[clap(alias = "stat")]
Status,
#[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 {
item: Vec<String>,
},
Drink {
name: Vec<String>,
},
Prestige,
Reset,
Update,
Arena,
Tournament,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Help { topic } => cmd_help(topic.as_deref()),
Commands::Init => cmd_init(),
Commands::Status => cmd_status(),
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 { item } => cmd_sell(&item.join(" ")),
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(),
Commands::Arena => cmd_arena(false),
Commands::Tournament => cmd_arena(true),
}
}
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() {
match state::load() {
Ok(game) => {
display::print_status(&game.character, game.permadeath);
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 format_help(topic: Option<&str>) -> String {
match topic {
None => help::render_index(),
Some(name) => match help::lookup_topic(name) {
help::LookupResult::Found(t) => help::render_topic(t),
help::LookupResult::Suggestions(s) => help::render_no_match(name, &s),
help::LookupResult::NoMatch => help::render_no_match(name, &[]),
},
}
}
fn cmd_help(topic: Option<&str>) {
print!("{}", format_help(topic));
}
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]* *//')
local first=$(printf '%s' "$cmd" | awk '{print $1}')
[ "$first" = "sq" ] && return
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)
local first=${cmd[(w)1]}
[[ "$first" == "sq" ]] && return
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
set -l first (string split -m1 ' ' $cmd)[1]
[ "$first" = "sq" ]; and return
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(query: &str) {
if query.is_empty() {
eprintln!(
"{} Usage: {} or {}",
"❌".bold(),
"sq sell <number>".cyan(),
"sq sell <item name>".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;
}
if query.eq_ignore_ascii_case("junk") {
cmd_sell_junk(&mut game);
return;
}
let idx = if let Ok(n) = query.parse::<usize>() {
if n == 0 || n > game.character.inventory.len() {
println!(
"{} No item at slot {}. You have {} item{}. Check {}.",
"⚠️".yellow(),
format!("{}", n).white().bold(),
format!("{}", game.character.inventory.len()).white().bold(),
if game.character.inventory.len() == 1 { "" } else { "s" },
"sq inventory".cyan()
);
return;
}
n - 1
} else {
match find_inventory_item(&game, query) {
Ok(Some(i)) => i,
Ok(None) => {
println!(
"{} No item matching {} in your inventory.",
"⚠️".yellow(),
format!("\"{}\"", query).white().bold()
);
return;
}
Err(msg) => {
println!("{} {}", "⚠️".yellow(), msg);
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 cmd_sell_junk(game: &mut state::GameState) {
let inv = std::mem::take(&mut game.character.inventory);
let result = sweep_junk(inv);
if result.sold_count == 0 {
game.character.inventory = result.kept;
println!(
"{} No junk in your inventory. {} and up are kept.",
"⚠️".yellow(),
"Rare".green().bold()
);
return;
}
let old_gold = game.character.gold;
game.character.inventory = result.kept;
game.character.gold += result.total_price;
println!(
"{} Sold {} junk item{} for {} gold.",
"💰".bold(),
format!("{}", result.sold_count).white().bold(),
if result.sold_count == 1 { "" } else { "s" },
format!("{}", result.total_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());
}
}
struct SweepResult {
kept: Vec<character::Item>,
sold_count: usize,
total_price: u32,
}
fn sweep_junk(items: Vec<character::Item>) -> SweepResult {
let mut kept = Vec::new();
let mut sold_count = 0;
let mut total_price: u32 = 0;
for item in items {
if matches!(item.rarity, character::Rarity::Common | character::Rarity::Uncommon) {
total_price += loot::item_price(&item) / 2;
sold_count += 1;
} else {
kept.push(item);
}
}
SweepResult { kept, sold_count, total_price }
}
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)]
#[test]
fn registry_covers_all_canonical_topics() {
let names: Vec<&str> = help::all_topics().iter().map(|t| t.name).collect();
let expected: Vec<&str> = help::CANONICAL_TOPIC_ORDER.to_vec();
assert_eq!(
names, expected,
"registry must contain exactly the canonical topics in canonical order"
);
}
#[cfg(test)]
#[test]
fn registry_related_topics_are_known() {
use std::collections::HashSet;
let canonical: HashSet<&str> = help::CANONICAL_TOPIC_ORDER.iter().copied().collect();
for topic in help::all_topics() {
for related in topic.related {
assert!(
canonical.contains(*related),
"topic '{}' references unknown related topic '{}'",
topic.name,
related
);
}
}
}
#[cfg(test)]
#[test]
fn lookup_prefers_primary_then_alias() {
use help::{lookup_topic, LookupResult};
match lookup_topic("status") {
LookupResult::Found(t) => assert_eq!(t.name, "status"),
other => panic!("expected Found(status) for canonical name, got {:?}", other),
}
match lookup_topic("stat") {
LookupResult::Found(t) => assert_eq!(
t.name, "status",
"alias 'stat' must resolve to canonical 'status'"
),
other => panic!("expected Found(status) via alias, got {:?}", other),
}
}
#[cfg(test)]
#[test]
fn lookup_typo_and_gibberish_paths() {
use help::{lookup_topic, LookupResult};
match lookup_topic("jounral") {
LookupResult::Suggestions(s) => {
assert!(!s.is_empty(), "expected at least one suggestion for 'jounral'");
assert_eq!(
s[0].name, "journal",
"first suggestion for 'jounral' must be 'journal'"
);
}
other => panic!("expected Suggestions for 'jounral', got {:?}", other),
}
match lookup_topic("xyzzy") {
LookupResult::NoMatch => {}
other => panic!("expected NoMatch for 'xyzzy', got {:?}", other),
}
}
#[cfg(test)]
#[test]
fn render_index_lists_topics_in_order() {
colored::control::set_override(false);
let out = help::render_index();
let mut search_start = 0usize;
for name in help::CANONICAL_TOPIC_ORDER {
let needle = format!(" {}", name);
let rel_pos = out[search_start..].find(&needle).unwrap_or_else(|| {
panic!(
"topic row '{}' not found at or after offset {} in render_index() output:\n{}",
name, search_start, out
)
});
search_start += rel_pos + needle.len();
}
assert!(
out.contains(help::INDEX_FOOTER),
"render_index must include the exact footer '{}'; got:\n{}",
help::INDEX_FOOTER,
out
);
}
#[cfg(test)]
#[test]
fn render_no_match_shows_suggestions() {
use help::{lookup_topic, LookupResult};
colored::control::set_override(false);
let suggestions = match lookup_topic("jounral") {
LookupResult::Suggestions(s) => s,
other => panic!("expected Suggestions for 'jounral', got {:?}", other),
};
let out = help::render_no_match("jounral", &suggestions);
assert!(
out.contains("jounral"),
"no-match output must echo the original query 'jounral':\n{}",
out
);
assert!(
out.contains("journal"),
"no-match output must surface 'journal' as a close match:\n{}",
out
);
assert!(
out.to_lowercase().contains("did you mean"),
"no-match output must clearly suggest close matches (looked for 'did you mean'):\n{}",
out
);
}
#[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)));
}
#[test]
fn parser_help_no_topic_yields_help_variant() {
let cli = Cli::try_parse_from(["sq", "help"]).expect("'sq help' must parse");
match cli.command {
Commands::Help { topic } => assert!(
topic.is_none(),
"'sq help' must parse with topic == None, got {:?}",
topic
),
_ => panic!("expected Commands::Help, got a different variant"),
}
}
#[test]
fn parser_help_with_arena_topic_yields_help_variant() {
let cli = Cli::try_parse_from(["sq", "help", "arena"]).expect("'sq help arena' must parse");
match cli.command {
Commands::Help { topic } => assert_eq!(
topic.as_deref(),
Some("arena"),
"'sq help arena' must carry topic Some(\"arena\")"
),
_ => panic!("expected Commands::Help"),
}
}
#[test]
fn parser_help_help_topic_yields_help_variant() {
let cli = Cli::try_parse_from(["sq", "help", "help"]).expect("'sq help help' must parse");
match cli.command {
Commands::Help { topic } => assert_eq!(
topic.as_deref(),
Some("help"),
"'sq help help' must carry topic Some(\"help\"), proving we own the help name"
),
_ => panic!("expected Commands::Help"),
}
}
#[test]
fn parser_dash_dash_help_still_routes_to_clap() {
let err = match Cli::try_parse_from(["sq", "--help"]) {
Ok(_) => panic!("'sq --help' must short-circuit on clap's DisplayHelp path, not parse"),
Err(e) => e,
};
assert_eq!(
err.kind(),
clap::error::ErrorKind::DisplayHelp,
"--help must trigger clap's auto-generated help, not our custom Help variant"
);
}
#[test]
fn format_help_no_topic_emits_index() {
colored::control::set_override(false);
let out = format_help(None);
assert!(
out.contains("sq Manual"),
"index header missing from no-topic output:\n{}",
out
);
assert!(
out.contains(help::INDEX_FOOTER),
"index footer '{}' missing:\n{}",
help::INDEX_FOOTER,
out
);
}
#[test]
fn format_help_arena_emits_authored_topic() {
colored::control::set_override(false);
let out = format_help(Some("arena"));
assert!(
out.contains("sq arena"),
"topic header missing from arena help:\n{}",
out
);
assert!(
out.contains("Usage:"),
"Usage section missing from arena help:\n{}",
out
);
assert!(
out.contains("Examples:"),
"Examples section missing from arena help:\n{}",
out
);
}
#[test]
fn format_help_help_emits_authored_topic_not_clap_help() {
colored::control::set_override(false);
let out = format_help(Some("help"));
assert!(
out.contains("sq help"),
"header 'sq help' missing — our Help variant must own the topic:\n{}",
out
);
assert!(
out.contains("Browse the in-game manual"),
"authored summary missing — output looks like clap's auto-help instead of our topic:\n{}",
out
);
}
#[test]
fn format_help_typo_emits_no_match_with_suggestion() {
colored::control::set_override(false);
let out = format_help(Some("jounral"));
assert!(
out.contains("jounral"),
"no-match output must echo the misspelled query:\n{}",
out
);
assert!(
out.contains("journal"),
"no-match output must surface 'journal' as a close suggestion:\n{}",
out
);
}
fn item_full(name: &str, power: i32, rarity: Rarity) -> Item {
Item { name: name.to_string(), slot: ItemSlot::Weapon, power, rarity }
}
#[test]
fn sweep_junk_empty_inventory_returns_empty_result() {
let r = sweep_junk(vec![]);
assert_eq!(r.sold_count, 0);
assert_eq!(r.total_price, 0);
assert!(r.kept.is_empty());
}
#[test]
fn sweep_junk_preserves_order_of_kept_items_and_sums_prices() {
let inv = vec![
item_full("Common Stick", 2, Rarity::Common),
item_full("Rare One", 10, Rarity::Rare),
item_full("Uncommon Tonic", 5, Rarity::Uncommon),
item_full("Epic Blade", 25, Rarity::Epic),
item_full("Rare Two", 12, Rarity::Rare),
];
let expected_price = loot::item_price(&inv[0]) / 2 + loot::item_price(&inv[2]) / 2;
let r = sweep_junk(inv);
assert_eq!(r.sold_count, 2);
assert_eq!(r.total_price, expected_price);
let kept_names: Vec<&str> = r.kept.iter().map(|i| i.name.as_str()).collect();
assert_eq!(kept_names, vec!["Rare One", "Epic Blade", "Rare Two"]);
}
#[test]
fn sweep_junk_never_sells_epic_or_legendary() {
let epic = item_full("Doombringer", 20, Rarity::Epic);
let legendary = item_full("Worldslayer", 50, Rarity::Legendary);
let r = sweep_junk(vec![epic, legendary]);
assert_eq!(r.sold_count, 0);
assert_eq!(r.total_price, 0);
assert_eq!(r.kept.len(), 2);
let names: Vec<&str> = r.kept.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"Doombringer"));
assert!(names.contains(&"Worldslayer"));
}
#[test]
fn sweep_junk_sells_an_uncommon_item_too() {
let item = item_full("Decent Mace", 6, Rarity::Uncommon);
let expected_price = loot::item_price(&item) / 2;
let r = sweep_junk(vec![item]);
assert_eq!(r.sold_count, 1);
assert!(r.kept.is_empty());
assert_eq!(r.total_price, expected_price);
}
#[test]
fn sweep_junk_keeps_a_single_rare_item() {
let rare = item_full("Rare Blade", 10, Rarity::Rare);
let r = sweep_junk(vec![rare]);
assert_eq!(r.sold_count, 0);
assert_eq!(r.total_price, 0);
assert_eq!(r.kept.len(), 1);
assert_eq!(r.kept[0].name, "Rare Blade");
}
#[test]
fn sweep_junk_sells_a_single_common_item() {
let item = item_full("Rusty Spoon", 4, Rarity::Common);
let expected_price = loot::item_price(&item) / 2;
let r = sweep_junk(vec![item]);
assert_eq!(r.sold_count, 1);
assert!(r.kept.is_empty());
assert_eq!(r.total_price, expected_price);
}
}
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()
);
}
}
}
fn cmd_arena(from_deprecated: bool) {
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
eprintln!("Arena requires an interactive terminal.");
std::process::exit(1);
}
if from_deprecated {
println!(
"{}",
"⚠️ The `tournament` command is deprecated. Use `sq arena` instead."
.yellow()
);
}
let mut game = match state::load() {
Ok(g) => g,
Err(e) => {
eprintln!("{} {}", "❌".bold(), e.red());
return;
}
};
let tier = match select_arena_tier(&game.character) {
Some(t) => t,
None => return,
};
if !tier.is_unlocked(&game.character) {
let req = if tier.or_unlock {
format!(
"Requires level {} or prestige {}.",
tier.min_level, tier.min_prestige
)
} else {
format!(
"Requires level {} and prestige {}.",
tier.min_level, tier.min_prestige
)
};
println!(
"{} {} is locked. {}",
"🔒".bold(),
tier.name.yellow().bold(),
req
);
return;
}
let entry = arena::ArenaEntrySnapshot::from_character(&game.character);
let fee = tier.compute_fee(&entry);
if game.character.gold < fee {
println!(
"{} Not enough gold! {} entry fee is {} gold, you have {}.",
"⚠️".yellow(),
tier.name,
format!("{}", fee).yellow().bold(),
format!("{}", game.character.gold).yellow()
);
return;
}
let confirm = prompt(&format!(
"{} Enter {} for {} gold? [y/N] ",
"🏟️".bold(),
tier.name.yellow().bold(),
format!("{}", fee).yellow().bold()
));
if confirm.to_lowercase() != "y" {
println!("{}", "Cancelled.".dimmed());
return;
}
match arena::run_arena_session(&game.character, tier, fee) {
Some(commit) => {
let deferred = arena::apply_arena_commit(&mut game, &commit);
if let Err(e) = state::save(&game) {
eprintln!("{} Failed to save arena results: {}", "❌".bold(), e.red());
return;
}
arena::render_arena_deferred_output(&deferred);
let (label, rounds) = match commit.outcome {
arena::ArenaOutcome::Defeat { rounds_cleared } => ("Knocked out", rounds_cleared),
arena::ArenaOutcome::CashOut { rounds_cleared } => ("Cashed out", rounds_cleared),
arena::ArenaOutcome::Victory { rounds_cleared } => ("Victory", rounds_cleared),
};
println!(
"{} {} after {} rounds.",
"🏁".bold(),
label,
rounds
);
}
None => {
println!("{}", "Arena run cancelled before round 1.".dimmed());
}
}
}
fn select_arena_tier(character: &character::Character) -> Option<arena::ArenaTier> {
println!();
println!("{}", "🏟️ Arena Tiers".bold().yellow());
println!("{}", "─".repeat(50).dimmed());
for (i, tier) in arena::ARENA_TIERS.iter().enumerate() {
let unlocked = tier.is_unlocked(character);
let status = if unlocked {
"✓".green().bold()
} else {
"🔒".dimmed()
};
let name = if unlocked {
tier.name.white().bold()
} else {
tier.name.dimmed()
};
let req = if tier.or_unlock {
format!("lvl {} or prestige {}", tier.min_level, tier.min_prestige)
} else {
format!("lvl {} & prestige {}", tier.min_level, tier.min_prestige)
};
println!(
" {}. {} {} — {} rounds — {}",
format!("{}", i + 1).dimmed(),
status,
name,
tier.max_rounds,
req.dimmed()
);
}
println!("{}", "─".repeat(50).dimmed());
loop {
let choice = prompt(" Select tier [1-5] (or press Enter to cancel): ");
if choice.is_empty() {
return None;
}
match choice.as_str() {
"1" => return Some(arena::TIER_PIT),
"2" => return Some(arena::TIER_GAUNTLET),
"3" => return Some(arena::TIER_COLOSSEUM),
"4" => return Some(arena::TIER_ABYSSAL),
"5" => return Some(arena::TIER_GODSLAYER),
_ => println!(" Invalid choice. Pick 1-5 or press Enter to cancel."),
}
}
}