shellquest 1.17.0

A passive RPG that lives in your terminal — your shell is the dungeon
#![allow(dead_code)]

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Boss {
    pub name: String,
    pub hp: i32,
    pub max_hp: i32,
    pub attack: i32,
    pub xp_reward: u32,
    pub gold_reward: u32,
    pub spawned_at: DateTime<Utc>,
}

pub const BOSS_ROSTER: &[(&str, i32, i32, u32, u32)] = &[
    ("The Kernel Panic", 100, 22, 900, 350),
    ("Lord of /dev/null", 85, 18, 700, 280),
    ("SIGKILL Supreme", 90, 25, 800, 320),
    ("The Infinite Loop", 110, 15, 950, 300),
    ("The Memory Corruption", 95, 20, 850, 310),
];

pub fn spawn_boss() -> Boss {
    use rand::Rng;

    let mut rng = rand::thread_rng();
    let (name, hp, attack, xp_reward, gold_reward) =
        BOSS_ROSTER[rng.gen_range(0..BOSS_ROSTER.len())];

    Boss {
        name: name.to_string(),
        hp,
        max_hp: hp,
        attack,
        xp_reward,
        gold_reward,
        spawned_at: Utc::now(),
    }
}

impl Boss {
    pub fn is_stale(&self) -> bool {
        let age = Utc::now() - self.spawned_at;
        age.num_hours() >= 24
    }
}

pub fn maybe_spawn(state: &mut crate::state::GameState) {
    use rand::Rng;

    if state.active_boss.is_some() {
        return;
    }

    let mut rng = rand::thread_rng();
    if rng.gen_ratio(1, 1000) {
        let boss = spawn_boss();
        crate::display::print_boss_spawn(&boss);
        state.active_boss = Some(boss);
    }
}

pub fn tick_boss(state: &mut crate::state::GameState) {
    use crate::journal::{EventType, JournalEntry};
    use rand::Rng;

    let boss_is_stale = state.active_boss.as_ref().is_some_and(|boss| boss.is_stale());
    if boss_is_stale {
        let name = state.active_boss.take().unwrap().name;
        crate::display::print_boss_flee(&name, "grows bored waiting and retreats. It will return");
        return;
    }

    if state.active_boss.is_none() {
        return;
    }

    let mut rng = rand::thread_rng();
    let player_power = state.character.attack_power();
    let player_defense = state.character.defense();
    let player_int = state.character.intelligence;
    let player_str = state.character.strength;
    let player_hp = state.character.hp;
    let player_max_hp = state.character.max_hp;
    let player_class = state.character.class.clone();

    let boss_at_full_hp = state.active_boss.as_ref().is_some_and(|b| b.hp == b.max_hp);

    let hit_roll: i32 = rng.gen_range(1..=20);
    let crit_threshold = (20 - player_int / 4).max(15);
    let mut signature_label: Option<&'static str> = None;
    let player_dmg: Option<(i32, bool)> = {
        let boss = state.active_boss.as_mut().unwrap();
        let hit_landed = match player_class {
            crate::character::Class::Rogue => hit_roll + player_power > 10 || hit_roll == 1,
            _ => hit_roll + player_power > 10,
        };
        if hit_landed {
            if matches!(player_class, crate::character::Class::Rogue) && hit_roll == 1 {
                signature_label = Some("shadow strike");
            }
            let mut raw_dmg = rng.gen_range((player_power / 2).max(1)..=player_power.max(1));
            let (sig_bonus, sig_label) = crate::character::signature_bonus(
                &player_class,
                player_int,
                player_str,
                player_hp,
                player_max_hp,
                boss_at_full_hp,
            );
            if sig_bonus > 0 {
                raw_dmg += sig_bonus;
                if signature_label.is_none() {
                    signature_label = sig_label;
                }
            }
            let is_crit = hit_roll >= crit_threshold;
            let dmg = if is_crit { raw_dmg * 2 } else { raw_dmg };
            boss.hp -= dmg;
            Some((dmg, is_crit))
        } else {
            None
        }
    };

    let boss_hp_after = state.active_boss.as_ref().unwrap().hp;
    let boss_max_hp = state.active_boss.as_ref().unwrap().max_hp;
    let boss_atk = state.active_boss.as_ref().unwrap().attack;
    let boss_name = state.active_boss.as_ref().unwrap().name.clone();
    let boss_xp = state.active_boss.as_ref().unwrap().xp_reward;
    let boss_gold = state.active_boss.as_ref().unwrap().gold_reward;

    if boss_hp_after <= 0 {
        crate::display::print_boss_tick(state.active_boss.as_ref().unwrap(), player_dmg, None);
        print_signature_line(signature_label);
        crate::display::print_boss_victory(state.active_boss.as_ref().unwrap(), boss_xp, boss_gold);

        let loot = crate::loot::roll_boss_loot();
        let loot_msg = format!(
            "Boss loot: {} (+{} {}) [{}]",
            loot.name, loot.power, loot.slot, loot.rarity
        );
        crate::display::print_loot(&loot_msg, &loot.rarity);

        state.add_journal(JournalEntry::new(
            EventType::Combat,
            format!("Defeated {}! +{} XP +{} gold", boss_name, boss_xp, boss_gold),
        ));

        state.active_boss = None;

        let leveled = state.character.gain_xp(boss_xp);
        state.character.gold += boss_gold;
        crate::events::add_to_inventory_pub(state, loot);

        let drained = state.character.signature_on_kill();
        if drained > 0 {
            crate::display::print_soul_drain(drained, state.character.hp, state.character.max_hp);
            state.add_journal(JournalEntry::new(
                EventType::Combat,
                format!("Soul drained from {}: +{} HP.", boss_name, drained),
            ));
        }

        if leveled {
            crate::events::emit_level_up(state);
        }
        return;
    }

    let dodge_roll: i32 = rng.gen_range(1..=20);
    let boss_dmg = if dodge_roll > 10 + player_defense {
        let dmg = (boss_atk - player_defense).max(1);
        let gold_before = state.character.gold;
        let died = state.character.take_damage(dmg);
        if died {
            if state.permadeath {
                crate::display::print_boss_tick(state.active_boss.as_ref().unwrap(), player_dmg, Some(dmg));
                print_signature_line(signature_label);
                crate::display::print_permadeath_eulogy(&state.character, &boss_name);
                let path = crate::state::save_path();
                let _ = std::fs::remove_file(&path);
                std::process::exit(0);
            } else {
                state.character.die();
                let gold_loss = gold_before * 15 / 100;
                crate::display::print_boss_tick(state.active_boss.as_ref().unwrap(), player_dmg, Some(dmg));
                print_signature_line(signature_label);
                crate::display::print_boss_flee(
                    &boss_name,
                    "laughs as you fall... and vanishes into the void",
                );
                state.add_journal(crate::journal::JournalEntry::new(
                    crate::journal::EventType::Death,
                    format!("{} fled after you fell. XP reset, -{} gold.", boss_name, gold_loss),
                ));
                state.active_boss = None;
                return;
            }
        }
        Some(dmg)
    } else {
        None
    };

    crate::display::print_boss_tick(state.active_boss.as_ref().unwrap(), player_dmg, boss_dmg);
    print_signature_line(signature_label);
    state.add_journal(JournalEntry::new(
        EventType::Combat,
        format!("[BOSS] {} — HP: {}/{}", boss_name, boss_hp_after.max(0), boss_max_hp),
    ));
}

fn print_signature_line(label: Option<&'static str>) {
    use colored::Colorize;
    if let Some(label) = label {
        eprintln!("   {} {}", "".cyan(), label.cyan().italic());
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn boss_roster_has_five_entries() {
        assert_eq!(BOSS_ROSTER.len(), 5);
    }

    #[test]
    fn all_bosses_have_positive_hp_and_attack() {
        for (_, hp, atk, _, _) in BOSS_ROSTER.iter() {
            assert!(*hp > 0);
            assert!(*atk > 0);
        }
    }

    #[test]
    fn spawn_boss_returns_boss_with_full_hp() {
        let boss = spawn_boss();
        assert_eq!(boss.hp, boss.max_hp);
        assert!(boss.hp > 0);
    }

    #[test]
    fn spawn_boss_xp_reward_is_substantial() {
        let boss = spawn_boss();
        assert!(boss.xp_reward >= 500);
    }

    #[test]
    fn is_stale_returns_false_for_fresh_boss() {
        let boss = spawn_boss();
        assert!(!boss.is_stale());
    }

    #[test]
    fn maybe_spawn_does_not_spawn_if_boss_active() {
        use crate::character::{Character, Class, Race};
        use crate::state::GameState;

        let _: fn(&mut GameState) = maybe_spawn;
        let mut state =
            GameState::new(Character::new("T".to_string(), Class::Warrior, Race::Human));
        let existing = spawn_boss();
        state.active_boss = Some(existing);
        let boss_name_before = state.active_boss.as_ref().unwrap().name.clone();

        maybe_spawn(&mut state);

        assert_eq!(state.active_boss.as_ref().unwrap().name, boss_name_before);
    }

    #[test]
    fn stale_boss_is_detected_correctly() {
        let _: fn(&mut crate::state::GameState) = tick_boss;
        let mut boss = spawn_boss();
        boss.spawned_at = Utc::now() - chrono::Duration::hours(25);
        assert!(boss.is_stale());
    }
}