use crate::character::{Character, Item};
use colored::*;
use rand::Rng;
use std::io::{self, Write};
#[derive(Debug, Clone, Copy)]
pub struct ArenaTier {
pub index: u32,
pub name: &'static str,
pub max_rounds: u32,
pub min_level: u32,
pub min_prestige: u32,
pub or_unlock: bool,
pub reward_bands: &'static [(u32, u32, u32)],
pub chest_milestones: &'static [(u32, u32)],
pub awards_crown: bool,
}
pub const TIER_PIT: ArenaTier = ArenaTier {
index: 0,
name: "The Pit",
max_rounds: 5,
min_level: 0,
min_prestige: 0,
or_unlock: false,
reward_bands: &[
(1, 10, 5),
(2, 25, 12),
(3, 45, 24),
(4, 70, 40),
(5, 110, 60),
],
chest_milestones: &[(5, 2)],
awards_crown: false,
};
pub const TIER_GAUNTLET: ArenaTier = ArenaTier {
index: 1,
name: "The Gauntlet",
max_rounds: 10,
min_level: 25,
min_prestige: 1,
or_unlock: true,
reward_bands: &[
(5, 35, 22),
(10, 145, 90),
],
chest_milestones: &[(5, 2), (10, 4)],
awards_crown: false,
};
pub const TIER_COLOSSEUM: ArenaTier = ArenaTier {
index: 2,
name: "The Colosseum",
max_rounds: 15,
min_level: 60,
min_prestige: 1,
or_unlock: true,
reward_bands: &[
(5, 30, 20),
(10, 90, 55),
(15, 185, 120),
],
chest_milestones: &[(5, 4), (10, 4), (15, 6)],
awards_crown: false,
};
pub const TIER_ABYSSAL: ArenaTier = ArenaTier {
index: 3,
name: "The Abyssal Arena",
max_rounds: 25,
min_level: 100,
min_prestige: 2,
or_unlock: true,
reward_bands: &[
(5, 20, 12),
(10, 55, 30),
(15, 100, 60),
(20, 165, 105),
(25, 240, 160),
],
chest_milestones: &[(10, 4), (20, 6), (25, 6)],
awards_crown: false,
};
pub const TIER_GODSLAYER: ArenaTier = ArenaTier {
index: 4,
name: "Godslayer's Court",
max_rounds: 50,
min_level: 150,
min_prestige: 3,
or_unlock: false,
reward_bands: &[
(10, 25, 15),
(20, 70, 40),
(30, 130, 85),
(40, 210, 150),
(50, 320, 220),
],
chest_milestones: &[(10, 4), (20, 6), (40, 8), (50, 9)],
awards_crown: true,
};
pub const ARENA_TIERS: &[ArenaTier] = &[
TIER_PIT,
TIER_GAUNTLET,
TIER_COLOSSEUM,
TIER_ABYSSAL,
TIER_GODSLAYER,
];
impl ArenaTier {
pub fn is_unlocked(&self, character: &Character) -> bool {
let level_ok = character.level >= self.min_level;
let prestige_ok = character.total_prestiges >= self.min_prestige;
if self.or_unlock {
level_ok || prestige_ok
} else {
level_ok && prestige_ok
}
}
pub fn compute_fee(&self, entry: &ArenaEntrySnapshot) -> u32 {
let level = entry.level;
let prestige = entry.prestige;
let gold = entry.gold;
match self.index {
0 => {
let a = 40;
let b = level * 12;
let c = gold / 10;
a.max(b).max(c)
}
1 => {
let a = 100;
let b = level * 18 + prestige * 50;
let c = gold / 8;
a.max(b).max(c)
}
2 => {
let a = 300;
let b = level * 28 + prestige * 150;
let c = gold / 6;
a.max(b).max(c)
}
3 => {
let a = 800;
let b = level * 40 + prestige * 250;
let c = gold / 5;
a.max(b).max(c)
}
4 => {
let a = 2500;
let b = level * 60 + prestige * 400;
let c = gold / 4;
a.max(b).max(c)
}
_ => 0,
}
}
pub fn reward_percentages_at_round(&self, round: u32) -> (u32, u32) {
if round == 0 {
return (0, 0);
}
let bands = self.reward_bands;
if bands.is_empty() {
return (0, 0);
}
if round < bands[0].0 {
let (r2, g2, x2) = bands[0];
let span = r2; let step = round;
let gold_pct = (g2 * step) / span;
let xp_pct = (x2 * step) / span;
return (gold_pct, xp_pct);
}
for i in 0..bands.len() {
if round == bands[i].0 {
return (bands[i].1, bands[i].2);
}
if i + 1 < bands.len() && round < bands[i + 1].0 {
let (r1, g1, x1) = bands[i];
let (r2, g2, x2) = bands[i + 1];
let span = r2 - r1;
let step = round - r1;
let gold_pct = g1 + (g2 - g1) * step / span;
let xp_pct = x1 + (x2 - x1) * step / span;
return (gold_pct, xp_pct);
}
}
let last = bands.last().unwrap();
(last.1, last.2)
}
pub fn compute_rewards(
&self,
entry_fee: u32,
xp_to_next: u32,
rounds_cleared: u32,
) -> (u32, u32) {
let (gold_pct, xp_pct) = self.reward_percentages_at_round(rounds_cleared);
let gold_reward = (entry_fee as u64 * gold_pct as u64 / 100) as u32;
let xp_reward = (xp_to_next as u64 * xp_pct as u64 / 100) as u32;
(gold_reward, xp_reward)
}
pub fn collect_chests(&self, round: u32) -> Vec<PendingChest> {
self.chest_milestones
.iter()
.filter(|(r, _)| *r <= round)
.map(|(r, danger)| PendingChest {
round: *r,
danger: *danger,
})
.collect()
}
}
#[derive(Debug, Clone)]
pub struct ArenaEntrySnapshot {
pub level: u32,
pub xp_to_next: u32,
pub hp: i32,
pub max_hp: i32,
pub attack_power: i32,
pub defense: i32,
pub prestige: u32,
pub gold: u32,
pub intelligence: i32,
pub strength: i32,
pub dexterity: i32,
}
impl ArenaEntrySnapshot {
pub fn from_character(character: &Character) -> Self {
Self {
level: character.level,
xp_to_next: character.xp_to_next,
hp: character.hp,
max_hp: character.max_hp,
attack_power: character.attack_power(),
defense: character.defense(),
prestige: character.total_prestiges,
gold: character.gold,
intelligence: character.intelligence,
strength: character.strength,
dexterity: character.dexterity,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PendingChest {
pub round: u32,
pub danger: u32,
}
#[derive(Debug, Clone)]
pub struct ArenaRun {
pub tier: ArenaTier,
pub entry: ArenaEntrySnapshot,
pub entry_fee: u32,
pub rounds_cleared: u32,
pub current_hp: i32,
}
#[derive(Debug, Clone)]
pub enum ArenaOutcome {
Defeat { rounds_cleared: u32 },
CashOut { rounds_cleared: u32 },
Victory { rounds_cleared: u32 },
}
#[derive(Debug, Clone)]
pub struct ArenaCommit {
pub outcome: ArenaOutcome,
pub fee: u32,
pub gold_reward: u32,
pub xp_reward: u32,
pub items: Vec<Item>,
pub kills: u32,
pub best_round: Option<u32>,
pub tournament_wins_increment: u32,
pub hp_set: Option<i32>,
pub tier_name: String,
}
#[derive(Debug, Clone)]
pub(crate) enum ArenaDeferredOutput {
LevelUp(String),
InventoryReplaced {
dropped_name: String,
dropped_rarity: crate::character::Rarity,
new_name: String,
new_rarity: crate::character::Rarity,
},
OverflowConverted {
item_name: String,
sell_value: u32,
},
}
pub(crate) fn render_arena_deferred_output(items: &[ArenaDeferredOutput]) {
for item in items {
match item {
ArenaDeferredOutput::LevelUp(s) => crate::display::print_level_up(s),
ArenaDeferredOutput::InventoryReplaced {
dropped_name,
dropped_rarity,
new_name,
new_rarity,
} => {
eprintln!(
"{} {} [{}] was discarded to make room for {}!",
"🗑️ ".bold(),
dropped_name.dimmed(),
format!("{}", dropped_rarity).dimmed(),
crate::display::color_item_inline(new_name, new_rarity),
);
}
ArenaDeferredOutput::OverflowConverted { item_name, sell_value } => {
eprintln!(
" {} Arena chest item {} converted to {} gold (inventory full).",
"💰".yellow(),
item_name.dimmed(),
sell_value,
);
}
}
}
}
#[derive(Debug)]
enum ChestApplyResult {
Added,
Replaced { dropped: Item },
Rejected,
}
const ARENA_INVENTORY_CAP: usize = 20;
fn apply_chest_item_to_inventory(
game: &mut crate::state::GameState,
item: Item,
) -> ChestApplyResult {
if game.character.inventory.len() < ARENA_INVENTORY_CAP {
game.character.inventory.push(item);
return ChestApplyResult::Added;
}
let weakest = game
.character
.inventory
.iter()
.enumerate()
.filter(|(_, i)| i.rarity.is_droppable())
.min_by_key(|(_, i)| i.power)
.map(|(idx, i)| (idx, i.power));
if let Some((idx, weakest_power)) = weakest {
if item.power > weakest_power {
let dropped = game.character.inventory.remove(idx);
game.character.inventory.push(item);
return ChestApplyResult::Replaced { dropped };
}
}
ChestApplyResult::Rejected
}
pub fn format_cash_out_preview(
tier: &ArenaTier,
entry_fee: u32,
xp_to_next: u32,
rounds_cleared: u32,
) -> (String, String) {
let (gold, xp) = tier.compute_rewards(entry_fee, xp_to_next, rounds_cleared);
let plain = format!("Cash Out now: +{} gold, +{} XP", gold, xp);
let colored = format!(
"{} {}, {}",
"💰 Cash Out now:".yellow().bold(),
format!("+{} gold", gold).yellow().bold(),
format!("+{} XP", xp).cyan().bold()
);
(plain, colored)
}
fn format_arena_journal_msg(
outcome: &ArenaOutcome,
tier_name: &str,
entry_fee: u32,
total_gold: u32,
xp_reward: u32,
) -> String {
match outcome {
ArenaOutcome::Defeat { rounds_cleared } => format!(
"Arena KO in {} after {} rounds. Fee: {} gold.",
tier_name, rounds_cleared, entry_fee
),
ArenaOutcome::CashOut { rounds_cleared } => format!(
"Arena cash-out in {} after {} rounds. +{} gold, +{} XP.",
tier_name, rounds_cleared, total_gold, xp_reward
),
ArenaOutcome::Victory { rounds_cleared } => format!(
"Arena VICTORY in {}! Cleared all {} rounds! +{} gold, +{} XP.",
tier_name, rounds_cleared, total_gold, xp_reward
),
}
}
pub struct ArenaCombatTuning {
pub enemy_hp_base: i32,
pub enemy_hp_per_round: i32,
pub enemy_hp_max_hp_divisor: i32,
pub enemy_hp_per_prestige: i32,
pub enemy_attack_base: i32,
pub enemy_attack_per_round: i32,
pub enemy_attack_power_divisor: i32,
pub enemy_attack_per_prestige: i32,
pub player_dmg_power_divisor: i32,
pub enemy_hit_threshold_base: i32,
pub enemy_hit_defense_divisor: i32,
pub enemy_hit_crit: i32,
pub enemy_dmg_defense_divisor: i32,
pub recovery_base: i32,
pub recovery_max_hp_divisor: i32,
pub max_turns: u32,
}
pub const DEFAULT_COMBAT_PACING_MS: u64 = 1500;
pub const NO_PACING_ENV: &str = "SQ_NO_PACING";
fn resolve_combat_pacing_ms(no_pacing_env: Option<&str>) -> u64 {
if no_pacing_env.is_some() {
0
} else {
DEFAULT_COMBAT_PACING_MS
}
}
pub fn combat_pacing_ms() -> u64 {
let env_value = std::env::var(NO_PACING_ENV).ok();
resolve_combat_pacing_ms(env_value.as_deref())
}
pub fn pause_between_combat_lines() {
let ms = combat_pacing_ms();
if ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(ms));
}
}
pub const PLAYER_ACCURACY_FLOOR: i32 = 6;
pub const PLAYER_DEX_ACCURACY_DIVISOR: i32 = 4;
#[derive(Debug, PartialEq, Eq)]
pub enum PlayerHitOutcome {
Hit,
Miss,
FumbleSavedByRogue,
}
pub fn compute_player_hit(
hit_roll: i32,
dexterity: i32,
class: &crate::character::Class,
) -> PlayerHitOutcome {
let is_rogue = matches!(class, crate::character::Class::Rogue);
if hit_roll == 1 {
return if is_rogue {
PlayerHitOutcome::FumbleSavedByRogue
} else {
PlayerHitOutcome::Miss
};
}
let dex_bonus = dexterity / PLAYER_DEX_ACCURACY_DIVISOR;
if hit_roll + dex_bonus > PLAYER_ACCURACY_FLOOR {
PlayerHitOutcome::Hit
} else {
PlayerHitOutcome::Miss
}
}
pub const ENEMY_DEX_DODGE_DIVISOR: i32 = 5;
#[derive(Debug, PartialEq, Eq)]
pub enum EnemyHitOutcome {
Hit,
Miss,
}
pub fn compute_enemy_hit(
dodge_roll: i32,
defense: i32,
dexterity: i32,
) -> EnemyHitOutcome {
let t = &ARENA_TUNING;
let threshold = t.enemy_hit_threshold_base
+ defense / t.enemy_hit_defense_divisor
+ dexterity / ENEMY_DEX_DODGE_DIVISOR;
if dodge_roll > threshold || dodge_roll == t.enemy_hit_crit {
EnemyHitOutcome::Hit
} else {
EnemyHitOutcome::Miss
}
}
pub const ARENA_TUNING: ArenaCombatTuning = ArenaCombatTuning {
enemy_hp_base: 20,
enemy_hp_per_round: 10,
enemy_hp_max_hp_divisor: 4,
enemy_hp_per_prestige: 20,
enemy_attack_base: 4,
enemy_attack_per_round: 3,
enemy_attack_power_divisor: 2,
enemy_attack_per_prestige: 8,
player_dmg_power_divisor: 2,
enemy_hit_threshold_base: 8,
enemy_hit_defense_divisor: 2,
enemy_hit_crit: 20,
enemy_dmg_defense_divisor: 3,
recovery_base: 4,
recovery_max_hp_divisor: 10,
max_turns: 1000,
};
const ENEMY_NAMES: &[&str] = &[
"Segmentation Fault Sprite",
"Buffer Overflow Beast",
"Null Pointer Imp",
"Race Condition Raider",
"Deadlock Demon",
"Memory Leach",
"Stack Smasher",
"Heap Corruptor",
"Infinite Loop Lich",
"Divide by Zero Demon",
"Off-by-One Assassin",
"Deprecated Daemon",
"Legacy Code Lurker",
"Dependency Hell Hound",
"Merge Conflict Monster",
"Git Rebase Revenant",
"Compilation Error Centaur",
"Syntax Error Serpent",
"Type Mismatch Troll",
"Unhandled Exception Entity",
"Floating Point Phantom",
"Integer Overflow Ogre",
"Cache Miss Wraith",
"Page Fault Phantom",
"Bus Error Banshee",
];
#[derive(Debug)]
struct ArenaEnemy {
name: String,
hp: i32,
max_hp: i32,
attack: i32,
}
fn generate_enemy(round: u32, entry: &ArenaEntrySnapshot, rng: &mut impl Rng) -> ArenaEnemy {
let name = ENEMY_NAMES[rng.gen_range(0..ENEMY_NAMES.len())].to_string();
let t = &ARENA_TUNING;
let max_hp = t.enemy_hp_base
+ (round as i32) * t.enemy_hp_per_round
+ entry.max_hp / t.enemy_hp_max_hp_divisor
+ (entry.prestige as i32) * t.enemy_hp_per_prestige;
let attack = t.enemy_attack_base
+ (round as i32) * t.enemy_attack_per_round
+ entry.attack_power / t.enemy_attack_power_divisor
+ (entry.prestige as i32) * t.enemy_attack_per_prestige;
ArenaEnemy {
name,
hp: max_hp,
max_hp,
attack,
}
}
#[derive(Debug)]
struct CombatExchange {
colored: String,
}
#[derive(Debug)]
struct CombatResult {
player_won: bool,
final_player_hp: i32,
exchanges: Vec<CombatExchange>,
#[allow(dead_code)]
total_turns: u32,
}
fn run_compact_combat(
entry: &ArenaEntrySnapshot,
current_hp: i32,
enemy: &mut ArenaEnemy,
class: &crate::character::Class,
rng: &mut impl Rng,
) -> CombatResult {
let mut player_hp = current_hp;
let mut total_turns = 0u32;
let mut all_exchanges: Vec<CombatExchange> = Vec::new();
loop {
total_turns += 1;
let mut turn_lines: Vec<CombatExchange> = Vec::new();
if total_turns > ARENA_TUNING.max_turns {
eprintln!(
" {} Combat timed out after {} turns — forcing defeat.",
"⏱️".dimmed(),
total_turns - 1
);
all_exchanges.extend(turn_lines);
return CombatResult {
player_won: false,
final_player_hp: player_hp,
exchanges: all_exchanges,
total_turns,
};
}
let t = &ARENA_TUNING;
let player_power = entry.attack_power;
let hit_roll: i32 = rng.gen_range(1..=20);
let outcome = compute_player_hit(hit_roll, entry.dexterity, class);
let nat_one_saved_by_rogue = matches!(outcome, PlayerHitOutcome::FumbleSavedByRogue);
let landed = !matches!(outcome, PlayerHitOutcome::Miss);
if landed {
let mut raw_dmg = rng.gen_range(
(player_power / t.player_dmg_power_divisor).max(1)
..=player_power.max(1),
);
let (sig_bonus, sig_label) = crate::character::signature_bonus(
class,
entry.intelligence,
entry.strength,
player_hp,
entry.max_hp,
total_turns == 1,
);
let mut signature_label: Option<&'static str> = if nat_one_saved_by_rogue {
Some("shadow strike")
} else {
None
};
if sig_bonus > 0 {
raw_dmg += sig_bonus;
if signature_label.is_none() {
signature_label = sig_label;
}
}
let crit_threshold = (20 - entry.intelligence / 4).max(15);
let is_crit = hit_roll >= crit_threshold;
let dmg = if is_crit { raw_dmg * 2 } else { raw_dmg };
enemy.hp -= dmg;
let (_plain, colored) = if is_crit {
crate::messages::tournament_player_crit(
class,
&enemy.name,
dmg,
enemy.hp.max(0),
enemy.max_hp,
)
} else {
crate::messages::tournament_player_hit(
class,
&enemy.name,
dmg,
enemy.hp.max(0),
enemy.max_hp,
)
};
turn_lines.push(CombatExchange { colored });
if let Some(label) = signature_label {
let line = format!(" {} {}", "✨".cyan(), label.cyan().italic());
turn_lines.push(CombatExchange { colored: line });
}
} else {
let (_plain, colored) =
crate::messages::tournament_player_miss(class, &enemy.name);
turn_lines.push(CombatExchange { colored });
}
if enemy.hp <= 0 {
if matches!(class, crate::character::Class::Necromancer) {
let heal = (entry.intelligence / 3).max(1);
let before = player_hp;
player_hp = (player_hp + heal).min(entry.max_hp);
let restored = player_hp - before;
if restored > 0 {
let line = format!(
" {} {} {}",
"🩸".bold(),
format!("Soul drained — +{} HP", restored).magenta().bold(),
format!("(HP: {}/{})", player_hp, entry.max_hp).magenta().dimmed()
);
turn_lines.push(CombatExchange { colored: line });
}
}
all_exchanges.extend(turn_lines);
return CombatResult {
player_won: true,
final_player_hp: player_hp,
exchanges: all_exchanges,
total_turns,
};
}
let player_defense = entry.defense;
let dodge_roll: i32 = rng.gen_range(1..=20);
match compute_enemy_hit(dodge_roll, player_defense, entry.dexterity) {
EnemyHitOutcome::Hit => {
let dmg = (enemy.attack - player_defense / t.enemy_dmg_defense_divisor).max(1);
player_hp -= dmg;
let (_plain, colored) = crate::messages::tournament_enemy_hit(
&enemy.name,
dmg,
player_hp.max(0),
entry.max_hp,
total_turns,
);
turn_lines.push(CombatExchange { colored });
}
EnemyHitOutcome::Miss => {
let (_plain, colored) =
crate::messages::tournament_enemy_miss(&enemy.name, total_turns);
turn_lines.push(CombatExchange { colored });
}
}
if player_hp <= 0 {
all_exchanges.extend(turn_lines);
return CombatResult {
player_won: false,
final_player_hp: player_hp,
exchanges: all_exchanges,
total_turns,
};
}
all_exchanges.extend(turn_lines);
}
}
fn read_line_trimmed() -> Option<String> {
let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(0) => None,
Ok(_) => Some(input.trim().to_string()),
Err(_) => None,
}
}
fn prompt_choice(msg: &str) -> Option<String> {
print!("{}", msg);
io::stdout().flush().unwrap();
read_line_trimmed()
}
fn build_commit(
character: &Character,
run: &ArenaRun,
outcome: ArenaOutcome,
final_hp: i32,
) -> ArenaCommit {
let (gold_reward, xp_reward, items, kills) = match outcome {
ArenaOutcome::Defeat { .. } => (0, 0, Vec::new(), 0),
_ => {
let (g, x) = run
.tier
.compute_rewards(run.entry_fee, run.entry.xp_to_next, run.rounds_cleared);
let chests = run.tier.collect_chests(run.rounds_cleared);
let mut chest_items = Vec::new();
for chest in &chests {
chest_items.push(crate::loot::roll_loot_scaled(chest.danger));
}
(g, x, chest_items, run.rounds_cleared)
}
};
let xp_reward = if run.entry.level >= crate::character::MAX_LEVEL {
0
} else {
xp_reward
};
let best_round = if run.rounds_cleared > character.best_tournament_round {
Some(run.rounds_cleared)
} else {
None
};
let tournament_wins_increment = match outcome {
ArenaOutcome::Victory { .. } if run.tier.awards_crown => 1,
_ => 0,
};
ArenaCommit {
outcome,
fee: run.entry_fee,
gold_reward,
xp_reward,
items,
kills,
best_round,
tournament_wins_increment,
hp_set: Some(final_hp),
tier_name: run.tier.name.to_string(),
}
}
pub(crate) fn apply_arena_commit(
game: &mut crate::state::GameState,
commit: &ArenaCommit,
) -> Vec<ArenaDeferredOutput> {
let mut deferred: Vec<ArenaDeferredOutput> = Vec::new();
game.character.gold = game.character.gold.saturating_sub(commit.fee);
let final_total_gold: u32 = match commit.outcome {
ArenaOutcome::Defeat { .. } => {
game.character.hp = commit.hp_set.unwrap_or(1);
if let Some(best) = commit.best_round {
if best > game.character.best_tournament_round {
game.character.best_tournament_round = best;
}
}
0
}
ArenaOutcome::CashOut { .. } | ArenaOutcome::Victory { .. } => {
let mut total_gold = commit.gold_reward;
let leveled = game.character.gain_xp_arena_safe(commit.xp_reward);
if leveled {
let (_plain, colored) = crate::messages::level_up(
&game.character.class,
game.character.level,
&game.character.title,
);
deferred.push(ArenaDeferredOutput::LevelUp(colored));
}
game.character.kills = game.character.kills.saturating_add(commit.kills);
let mut overflow_gold: u32 = 0;
for item in &commit.items {
match apply_chest_item_to_inventory(game, item.clone()) {
ChestApplyResult::Added => {}
ChestApplyResult::Replaced { dropped } => {
deferred.push(ArenaDeferredOutput::InventoryReplaced {
dropped_name: dropped.name,
dropped_rarity: dropped.rarity,
new_name: item.name.clone(),
new_rarity: item.rarity,
});
}
ChestApplyResult::Rejected => {
let sell_value = crate::loot::item_price(item) / 2;
overflow_gold = overflow_gold.saturating_add(sell_value);
deferred.push(ArenaDeferredOutput::OverflowConverted {
item_name: item.name.clone(),
sell_value,
});
}
}
}
if overflow_gold > 0 {
total_gold = total_gold.saturating_add(overflow_gold);
}
if let Some(best) = commit.best_round {
if best > game.character.best_tournament_round {
game.character.best_tournament_round = best;
}
}
game.character.tournament_wins = game
.character
.tournament_wins
.saturating_add(commit.tournament_wins_increment);
game.character.gold = game.character.gold.saturating_add(total_gold);
game.character.hp = commit.hp_set.unwrap_or(game.character.hp);
total_gold
}
};
let journal_msg = format_arena_journal_msg(
&commit.outcome,
&commit.tier_name,
commit.fee,
final_total_gold,
commit.xp_reward,
);
game.add_journal(crate::journal::JournalEntry::new(
crate::journal::EventType::Tournament,
journal_msg,
));
deferred
}
pub fn run_arena_session(
character: &Character,
tier: ArenaTier,
entry_fee: u32,
) -> Option<ArenaCommit> {
let mut rng = rand::thread_rng();
let entry = ArenaEntrySnapshot::from_character(character);
let class = &character.class;
let mut run = ArenaRun {
tier,
entry: entry.clone(),
entry_fee,
rounds_cleared: 0,
current_hp: entry.hp,
};
eprintln!();
eprintln!(
"{}",
"╔══════════════════════════════════════════════╗"
.yellow()
.bold()
);
eprintln!(
"{} {} {}",
"║".yellow().bold(),
format!("🏟️ {}", tier.name).yellow().bold(),
"║".yellow().bold()
);
eprintln!(
"{} {} {}",
"║".yellow().bold(),
format!(
" Entry fee: {} gold HP: {}/{}",
entry_fee, entry.hp, entry.max_hp
)
.yellow(),
"║".yellow().bold()
);
eprintln!(
"{}",
"╚══════════════════════════════════════════════╝"
.yellow()
.bold()
);
eprintln!();
loop {
let round = run.rounds_cleared + 1;
if round > tier.max_rounds {
return Some(build_commit(
character,
&run,
ArenaOutcome::Victory {
rounds_cleared: run.rounds_cleared,
},
run.current_hp,
));
}
let mut enemy = generate_enemy(round, &entry, &mut rng);
let (_plain, colored) =
crate::messages::tournament_round_intro(&class, round, &enemy.name);
eprintln!("{} {}", "⚔️".bold(), colored);
eprintln!("{}", "─".repeat(40).dimmed());
let combat = run_compact_combat(&entry, run.current_hp, &mut enemy, &class, &mut rng);
run.current_hp = combat.final_player_hp;
for ex in &combat.exchanges {
pause_between_combat_lines();
eprintln!(" {}", ex.colored);
}
if !combat.player_won {
eprintln!("{}", "─".repeat(40).dimmed());
let (_plain, colored) =
crate::messages::tournament_ko(run.rounds_cleared, 0, 0);
eprintln!("{} {}", "💀".bold(), colored);
return Some(build_commit(
character,
&run,
ArenaOutcome::Defeat {
rounds_cleared: run.rounds_cleared,
},
(entry.max_hp / 4).max(1),
));
}
run.rounds_cleared = round;
if run.rounds_cleared >= tier.max_rounds {
return Some(build_commit(
character,
&run,
ArenaOutcome::Victory {
rounds_cleared: run.rounds_cleared,
},
run.current_hp,
));
}
let t = &ARENA_TUNING;
let recovery = t.recovery_base.max(entry.max_hp / t.recovery_max_hp_divisor);
let healed = recovery.min(entry.max_hp - run.current_hp);
run.current_hp = (run.current_hp + recovery).min(entry.max_hp);
if healed > 0 {
eprintln!(
" {} Recovered {} HP. HP: {}/{}",
"🩹".dimmed(),
healed,
run.current_hp,
entry.max_hp
);
}
eprintln!("{}", "─".repeat(40).dimmed());
eprintln!(
" Round {} cleared! Current HP: {}/{}",
run.rounds_cleared, run.current_hp, entry.max_hp
);
let (_plain_preview, colored_preview) = format_cash_out_preview(
&tier,
entry_fee,
entry.xp_to_next,
run.rounds_cleared,
);
eprintln!(" {}", colored_preview);
eprintln!(" 1) Continue");
eprintln!(" 2) Cash Out");
loop {
let choice = prompt_choice(" Choose [1-2]: ");
match choice.as_deref() {
Some("1") => break,
Some("2") => {
return Some(build_commit(
character,
&run,
ArenaOutcome::CashOut {
rounds_cleared: run.rounds_cleared,
},
run.current_hp,
));
}
Some(_) => {
eprintln!(" Invalid choice. Enter 1 or 2.");
continue;
}
None => {
eprintln!(" (EOF detected — cashing out)");
return Some(build_commit(
character,
&run,
ArenaOutcome::CashOut {
rounds_cleared: run.rounds_cleared,
},
run.current_hp,
));
}
}
}
eprintln!();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::character::{Class, Item, ItemSlot, Race, Rarity};
fn make_character(level: u32, total_prestiges: u32, gold: u32) -> Character {
let mut c = Character::new("Test".to_string(), Class::Warrior, Race::Human);
c.level = level;
c.total_prestiges = total_prestiges;
c.gold = gold;
if level > 1 {
c.xp_to_next = 25;
for lvl in 1..level {
c.xp_to_next = match lvl {
1..=10 => lvl * 15 + 10,
11..=30 => lvl * 25 + 30,
31..=60 => lvl * 45 + 80,
61..=100 => lvl * 80 + 200,
101..=130 => lvl * 120 + 400,
_ => lvl * 170 + 800,
};
}
}
c
}
fn make_snapshot(level: u32, prestige: u32, gold: u32, xp_to_next: u32) -> ArenaEntrySnapshot {
ArenaEntrySnapshot {
level,
xp_to_next,
hp: 100,
max_hp: 100,
attack_power: 20,
defense: 10,
prestige,
gold,
intelligence: 10,
strength: 10,
dexterity: 10,
}
}
#[test]
fn pit_always_unlocked() {
let c = make_character(1, 0, 10);
assert!(TIER_PIT.is_unlocked(&c));
}
#[test]
fn gauntlet_unlocked_by_level() {
let c = make_character(25, 0, 100);
assert!(TIER_GAUNTLET.is_unlocked(&c));
}
#[test]
fn gauntlet_unlocked_by_prestige() {
let c = make_character(1, 1, 100);
assert!(TIER_GAUNTLET.is_unlocked(&c));
}
#[test]
fn gauntlet_locked_for_low_level_no_prestige() {
let c = make_character(24, 0, 100);
assert!(!TIER_GAUNTLET.is_unlocked(&c));
}
#[test]
fn colosseum_unlocked_by_level() {
let c = make_character(60, 0, 100);
assert!(TIER_COLOSSEUM.is_unlocked(&c));
}
#[test]
fn colosseum_unlocked_by_prestige() {
let c = make_character(1, 1, 100);
assert!(TIER_COLOSSEUM.is_unlocked(&c));
}
#[test]
fn colosseum_locked_for_low_level_no_prestige() {
let c = make_character(59, 0, 100);
assert!(!TIER_COLOSSEUM.is_unlocked(&c));
}
#[test]
fn abyssal_unlocked_by_level() {
let c = make_character(100, 0, 100);
assert!(TIER_ABYSSAL.is_unlocked(&c));
}
#[test]
fn abyssal_unlocked_by_prestige() {
let c = make_character(1, 2, 100);
assert!(TIER_ABYSSAL.is_unlocked(&c));
}
#[test]
fn abyssal_locked_for_low_level_no_prestige() {
let c = make_character(99, 0, 100);
assert!(!TIER_ABYSSAL.is_unlocked(&c));
}
#[test]
fn abyssal_locked_for_one_prestige() {
let c = make_character(1, 1, 100);
assert!(!TIER_ABYSSAL.is_unlocked(&c));
}
#[test]
fn godslayer_locked_without_prestige() {
let c = make_character(150, 0, 100);
assert!(!TIER_GODSLAYER.is_unlocked(&c));
}
#[test]
fn godslayer_locked_without_level() {
let c = make_character(149, 3, 100);
assert!(!TIER_GODSLAYER.is_unlocked(&c));
}
#[test]
fn godslayer_unlocked_at_max_with_prestige() {
let c = make_character(150, 3, 100);
assert!(TIER_GODSLAYER.is_unlocked(&c));
}
#[test]
fn pit_fee_level_based() {
let entry = make_snapshot(12, 0, 200, 330);
assert_eq!(TIER_PIT.compute_fee(&entry), 144);
}
#[test]
fn pit_fee_gold_based() {
let entry = make_snapshot(12, 0, 3000, 330);
assert_eq!(TIER_PIT.compute_fee(&entry), 300);
}
#[test]
fn pit_fee_floor() {
let entry = make_snapshot(1, 0, 10, 25);
assert_eq!(TIER_PIT.compute_fee(&entry), 40);
}
#[test]
fn gauntlet_fee_formula() {
let entry = make_snapshot(30, 1, 1000, 1000);
assert_eq!(TIER_GAUNTLET.compute_fee(&entry), 590);
}
#[test]
fn colosseum_fee_formula() {
let entry = make_snapshot(60, 1, 2000, 2000);
assert_eq!(TIER_COLOSSEUM.compute_fee(&entry), 1830);
}
#[test]
fn abyssal_fee_formula() {
let entry = make_snapshot(100, 2, 5000, 5000);
assert_eq!(TIER_ABYSSAL.compute_fee(&entry), 4500);
}
#[test]
fn godslayer_fee_formula() {
let entry = make_snapshot(150, 3, 10000, 10000);
assert_eq!(TIER_GODSLAYER.compute_fee(&entry), 10200);
}
#[test]
fn pit_rewards_at_milestones() {
assert_eq!(TIER_PIT.reward_percentages_at_round(1), (10, 5));
assert_eq!(TIER_PIT.reward_percentages_at_round(3), (45, 24));
assert_eq!(TIER_PIT.reward_percentages_at_round(5), (110, 60));
}
#[test]
fn gauntlet_rewards_at_milestones() {
assert_eq!(TIER_GAUNTLET.reward_percentages_at_round(5), (35, 22));
assert_eq!(TIER_GAUNTLET.reward_percentages_at_round(10), (145, 90));
}
#[test]
fn godslayer_rewards_at_milestones() {
assert_eq!(TIER_GODSLAYER.reward_percentages_at_round(10), (25, 15));
assert_eq!(TIER_GODSLAYER.reward_percentages_at_round(50), (320, 220));
}
#[test]
fn interpolation_gauntlet_round_7() {
assert_eq!(TIER_GAUNTLET.reward_percentages_at_round(7), (79, 49));
}
#[test]
fn interpolation_colosseum_round_12() {
assert_eq!(TIER_COLOSSEUM.reward_percentages_at_round(12), (128, 81));
}
#[test]
fn interpolation_godslayer_round_35() {
assert_eq!(TIER_GODSLAYER.reward_percentages_at_round(35), (170, 117));
}
#[test]
fn interpolation_before_first_milestone() {
assert_eq!(TIER_GAUNTLET.reward_percentages_at_round(3), (21, 13));
}
#[test]
fn clamp_past_last_milestone() {
assert_eq!(TIER_PIT.reward_percentages_at_round(10), (110, 60));
assert_eq!(TIER_GAUNTLET.reward_percentages_at_round(50), (145, 90));
}
#[test]
fn round_zero_returns_zero() {
assert_eq!(TIER_PIT.reward_percentages_at_round(0), (0, 0));
assert_eq!(TIER_GODSLAYER.reward_percentages_at_round(0), (0, 0));
}
#[test]
fn level_12_pit_full_clear_net_gold_bound() {
let entry = make_snapshot(12, 0, 200, 330);
let fee = TIER_PIT.compute_fee(&entry);
let (gold_reward, xp_reward) = TIER_PIT.compute_rewards(fee, entry.xp_to_next, 5);
let net_gold = gold_reward as i64 - fee as i64;
assert!(
net_gold >= 0 && net_gold <= 25,
"net_gold = {} (fee={}, reward={})",
net_gold,
fee,
gold_reward
);
let xp_pct = xp_reward as f64 / entry.xp_to_next as f64;
assert!(
xp_pct >= 0.50 && xp_pct <= 0.70,
"xp_pct = {} (xp_reward={}, threshold={})",
xp_pct,
xp_reward,
entry.xp_to_next
);
}
#[test]
fn pit_chests_at_round_5() {
let chests = TIER_PIT.collect_chests(5);
assert_eq!(chests.len(), 1);
assert_eq!(chests[0].round, 5);
assert_eq!(chests[0].danger, 2);
}
#[test]
fn pit_no_chests_before_round_5() {
let chests = TIER_PIT.collect_chests(4);
assert!(chests.is_empty());
}
#[test]
fn gauntlet_chests_progression() {
let c5 = TIER_GAUNTLET.collect_chests(5);
assert_eq!(c5.len(), 1);
assert_eq!(c5[0].danger, 2);
let c10 = TIER_GAUNTLET.collect_chests(10);
assert_eq!(c10.len(), 2);
assert_eq!(c10[1].danger, 4);
}
#[test]
fn godslayer_chest_milestones() {
let c = TIER_GODSLAYER.collect_chests(50);
assert_eq!(c.len(), 4);
assert_eq!(c[0], PendingChest { round: 10, danger: 4 });
assert_eq!(c[1], PendingChest { round: 20, danger: 6 });
assert_eq!(c[2], PendingChest { round: 40, danger: 8 });
assert_eq!(c[3], PendingChest { round: 50, danger: 9 });
}
#[test]
fn chests_past_last_milestone_no_duplicates() {
let c = TIER_GODSLAYER.collect_chests(100);
assert_eq!(c.len(), 4);
}
#[test]
fn snapshot_copies_character_fields() {
let mut c = make_character(10, 1, 500);
c.hp = 80;
c.max_hp = 100;
let snap = ArenaEntrySnapshot::from_character(&c);
assert_eq!(snap.level, 10);
assert_eq!(snap.prestige, 1);
assert_eq!(snap.gold, 500);
assert_eq!(snap.hp, 80);
assert_eq!(snap.max_hp, 100);
assert_eq!(snap.xp_to_next, c.xp_to_next);
assert_eq!(snap.attack_power, c.attack_power());
assert_eq!(snap.defense, c.defense());
}
#[test]
fn snapshot_copies_dexterity() {
let mut c = make_character(10, 0, 100);
c.dexterity = 17;
let snap = ArenaEntrySnapshot::from_character(&c);
assert_eq!(snap.dexterity, 17);
}
#[test]
fn pacing_ms_returns_zero_when_env_disables_pacing() {
assert_eq!(resolve_combat_pacing_ms(Some("1")), 0);
assert_eq!(resolve_combat_pacing_ms(Some("")), 0);
assert_eq!(resolve_combat_pacing_ms(Some("anything")), 0);
}
#[test]
fn pacing_ms_returns_default_when_env_unset() {
assert_eq!(resolve_combat_pacing_ms(None), DEFAULT_COMBAT_PACING_MS);
}
#[test]
fn pacing_default_is_one_point_five_seconds() {
assert_eq!(DEFAULT_COMBAT_PACING_MS, 1500);
}
#[test]
fn pause_actually_sleeps_when_pacing_enabled() {
std::env::remove_var(NO_PACING_ENV);
let start = std::time::Instant::now();
pause_between_combat_lines();
let elapsed = start.elapsed();
assert!(
elapsed >= std::time::Duration::from_millis(DEFAULT_COMBAT_PACING_MS - 50),
"expected sleep >= ~{}ms, got {:?}",
DEFAULT_COMBAT_PACING_MS - 50,
elapsed
);
}
#[test]
fn player_nat_one_misses_for_non_rogue() {
assert_eq!(
compute_player_hit(1, 30, &Class::Warrior),
PlayerHitOutcome::Miss
);
assert_eq!(
compute_player_hit(1, 30, &Class::Wizard),
PlayerHitOutcome::Miss
);
}
#[test]
fn player_nat_one_saved_by_rogue() {
assert_eq!(
compute_player_hit(1, 8, &Class::Rogue),
PlayerHitOutcome::FumbleSavedByRogue
);
}
#[test]
fn player_low_roll_misses_with_low_dex() {
assert_eq!(
compute_player_hit(3, 0, &Class::Warrior),
PlayerHitOutcome::Miss
);
assert_eq!(
compute_player_hit(5, 4, &Class::Wizard),
PlayerHitOutcome::Miss
);
}
#[test]
fn player_low_roll_hits_with_high_dex() {
assert_eq!(
compute_player_hit(3, 20, &Class::Warrior),
PlayerHitOutcome::Hit
);
assert_eq!(
compute_player_hit(5, 12, &Class::Wizard),
PlayerHitOutcome::Hit
);
}
#[test]
fn player_high_roll_always_hits() {
assert_eq!(
compute_player_hit(20, 0, &Class::Warrior),
PlayerHitOutcome::Hit
);
assert_eq!(
compute_player_hit(15, 0, &Class::Necromancer),
PlayerHitOutcome::Hit
);
}
#[test]
fn player_accuracy_boundary_uses_strict_greater_than() {
assert_eq!(
compute_player_hit(6, 0, &Class::Warrior),
PlayerHitOutcome::Miss
);
assert_eq!(
compute_player_hit(7, 0, &Class::Warrior),
PlayerHitOutcome::Hit
);
}
#[test]
fn enemy_misses_against_low_defense_low_dex_low_roll() {
assert_eq!(compute_enemy_hit(5, 10, 0), EnemyHitOutcome::Miss);
}
#[test]
fn enemy_hits_against_low_defense_low_dex_high_roll() {
assert_eq!(compute_enemy_hit(17, 10, 0), EnemyHitOutcome::Hit);
}
#[test]
fn high_player_dex_makes_enemy_miss_mid_roll() {
let with_low_dex = compute_enemy_hit(15, 10, 0);
let with_high_dex = compute_enemy_hit(15, 10, 25);
assert_eq!(with_low_dex, EnemyHitOutcome::Hit);
assert_eq!(with_high_dex, EnemyHitOutcome::Miss);
}
#[test]
fn nat_twenty_always_hits_through_dex_and_defense() {
assert_eq!(compute_enemy_hit(20, 200, 200), EnemyHitOutcome::Hit);
}
#[test]
fn enemy_dodge_threshold_uses_strict_greater_than() {
let threshold = ARENA_TUNING.enemy_hit_threshold_base
+ 10 / ARENA_TUNING.enemy_hit_defense_divisor
+ 0 / ENEMY_DEX_DODGE_DIVISOR;
assert_eq!(threshold, 13);
assert_eq!(compute_enemy_hit(13, 10, 0), EnemyHitOutcome::Miss);
assert_eq!(compute_enemy_hit(14, 10, 0), EnemyHitOutcome::Hit);
}
#[test]
fn cash_out_preview_includes_gold_and_xp_amounts() {
let (plain, _colored) = format_cash_out_preview(&TIER_PIT, 100, 200, 5);
assert!(plain.contains("+110 gold"), "plain: {}", plain);
assert!(plain.contains("+120 XP"), "plain: {}", plain);
}
#[test]
fn cash_out_preview_labels_the_choice() {
let (plain, _colored) = format_cash_out_preview(&TIER_PIT, 100, 200, 1);
assert!(plain.to_lowercase().contains("cash out"), "plain: {}", plain);
}
#[test]
fn cash_out_preview_round_zero_shows_zero_rewards() {
let (plain, _colored) = format_cash_out_preview(&TIER_PIT, 100, 200, 0);
assert!(plain.contains("+0 gold"), "plain: {}", plain);
assert!(plain.contains("+0 XP"), "plain: {}", plain);
}
#[test]
fn cash_out_preview_colored_contains_amounts() {
let (_plain, colored) = format_cash_out_preview(&TIER_GAUNTLET, 200, 300, 10);
assert!(colored.contains("290"), "colored: {}", colored);
assert!(colored.contains("270"), "colored: {}", colored);
}
#[test]
fn arena_tiers_has_five_tiers() {
assert_eq!(ARENA_TIERS.len(), 5);
assert_eq!(ARENA_TIERS[0].name, "The Pit");
assert_eq!(ARENA_TIERS[4].name, "Godslayer's Court");
}
use crate::state::GameState;
#[test]
fn apply_commit_defeat_subtracts_fee_and_sets_hp() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
game.character.hp = 50;
game.character.best_tournament_round = 0;
let commit = ArenaCommit {
outcome: ArenaOutcome::Defeat { rounds_cleared: 2 },
fee: 50,
gold_reward: 0,
xp_reward: 0,
items: vec![],
kills: 0,
best_round: Some(2),
tournament_wins_increment: 0,
hp_set: Some(25),
tier_name: "Test Tier".to_string(),
};
let deferred = apply_arena_commit(&mut game, &commit);
assert!(deferred.is_empty(), "Defeat must emit no deferred output");
assert_eq!(game.character.gold, 450);
assert_eq!(game.character.hp, 25);
assert_eq!(game.character.best_tournament_round, 2);
assert_eq!(game.character.deaths, 0);
assert_eq!(game.character.kills, 0);
assert_eq!(game.character.xp, 0);
}
#[test]
fn apply_commit_cash_out_adds_rewards() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
game.character.hp = 40;
game.character.best_tournament_round = 0;
game.character.kills = 5;
let commit = ArenaCommit {
outcome: ArenaOutcome::CashOut { rounds_cleared: 3 },
fee: 50,
gold_reward: 100,
xp_reward: 30,
items: vec![],
kills: 3,
best_round: Some(3),
tournament_wins_increment: 0,
hp_set: Some(45),
tier_name: "Test Tier".to_string(),
};
let _deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.character.gold, 550);
assert_eq!(game.character.hp, 45);
assert_eq!(game.character.best_tournament_round, 3);
assert_eq!(game.character.kills, 8);
assert_eq!(game.character.deaths, 0);
}
#[test]
fn apply_commit_victory_godslayer_increments_wins() {
let mut game = GameState::new(make_character(150, 3, 5000));
game.character.best_tournament_round = 0;
game.character.tournament_wins = 0;
let commit = ArenaCommit {
outcome: ArenaOutcome::Victory { rounds_cleared: 50 },
fee: 1000,
gold_reward: 200,
xp_reward: 50,
items: vec![],
kills: 50,
best_round: Some(50),
tournament_wins_increment: 1,
hp_set: Some(80),
tier_name: "Godslayer's Court".to_string(),
};
let _deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.character.tournament_wins, 1);
assert_eq!(game.character.best_tournament_round, 50);
}
#[test]
fn apply_commit_victory_pit_does_not_increment_wins() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.tournament_wins = 0;
let commit = ArenaCommit {
outcome: ArenaOutcome::Victory { rounds_cleared: 5 },
fee: 50,
gold_reward: 100,
xp_reward: 30,
items: vec![],
kills: 5,
best_round: Some(5),
tournament_wins_increment: 0,
hp_set: Some(80),
tier_name: "The Pit".to_string(),
};
let _deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.character.tournament_wins, 0);
}
#[test]
fn apply_commit_no_heal_on_level_up() {
let mut game = GameState::new(make_character(1, 0, 100));
game.character.xp = 20;
game.character.xp_to_next = 25;
game.character.max_hp = 34;
game.character.hp = 10;
let commit = ArenaCommit {
outcome: ArenaOutcome::CashOut { rounds_cleared: 1 },
fee: 10,
gold_reward: 20,
xp_reward: 10,
items: vec![],
kills: 1,
best_round: Some(1),
tournament_wins_increment: 0,
hp_set: Some(10),
tier_name: "Test Tier".to_string(),
};
let deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.character.level, 2);
assert_eq!(game.character.hp, 10);
assert!(game.character.max_hp > 34);
assert!(
deferred
.iter()
.any(|d| matches!(d, ArenaDeferredOutput::LevelUp(_))),
"expected level-up to be deferred, got {:?}",
deferred
);
}
#[test]
fn apply_commit_level_up_adds_only_arena_journal_entry() {
let mut game = GameState::new(make_character(1, 0, 100));
game.character.xp = 20;
game.character.xp_to_next = 25;
game.character.max_hp = 34;
game.character.hp = 10;
let commit = ArenaCommit {
outcome: ArenaOutcome::CashOut { rounds_cleared: 1 },
fee: 10,
gold_reward: 20,
xp_reward: 10,
items: vec![],
kills: 1,
best_round: Some(1),
tournament_wins_increment: 0,
hp_set: Some(10),
tier_name: "Test Tier".to_string(),
};
let _deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.character.level, 2);
assert_eq!(game.journal.len(), 1);
assert!(matches!(
game.journal[0].event_type,
crate::journal::EventType::Tournament
));
}
#[test]
fn build_commit_defeat_zeros_rewards() {
let c = make_character(10, 0, 500);
let entry = ArenaEntrySnapshot::from_character(&c);
let run = ArenaRun {
tier: TIER_PIT,
entry: entry.clone(),
entry_fee: 100,
rounds_cleared: 3,
current_hp: 30,
};
let commit = build_commit(&c, &run, ArenaOutcome::Defeat { rounds_cleared: 3 }, 25);
assert_eq!(commit.gold_reward, 0);
assert_eq!(commit.xp_reward, 0);
assert!(commit.items.is_empty());
assert_eq!(commit.kills, 0);
assert_eq!(commit.tournament_wins_increment, 0);
}
#[test]
fn build_commit_victory_godslayer_increments_wins() {
let c = make_character(150, 3, 5000);
let entry = ArenaEntrySnapshot::from_character(&c);
let run = ArenaRun {
tier: TIER_GODSLAYER,
entry: entry.clone(),
entry_fee: 2500,
rounds_cleared: 50,
current_hp: 80,
};
let commit = build_commit(&c, &run, ArenaOutcome::Victory { rounds_cleared: 50 }, 80);
assert_eq!(commit.tournament_wins_increment, 1);
}
#[test]
fn build_commit_victory_pit_no_win_increment() {
let c = make_character(10, 0, 500);
let entry = ArenaEntrySnapshot::from_character(&c);
let run = ArenaRun {
tier: TIER_PIT,
entry: entry.clone(),
entry_fee: 100,
rounds_cleared: 5,
current_hp: 80,
};
let commit = build_commit(&c, &run, ArenaOutcome::Victory { rounds_cleared: 5 }, 80);
assert_eq!(commit.tournament_wins_increment, 0);
}
#[test]
fn apply_commit_chest_overflow_converts_to_half_gold() {
use crate::character::{Item, ItemSlot, Rarity};
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
game.character.hp = 80;
game.character.best_tournament_round = 0;
for i in 0..20 {
game.character.inventory.push(Item {
name: format!("Legendary {}", i),
slot: ItemSlot::Weapon,
power: 50 + i as i32,
rarity: Rarity::Legendary,
});
}
let chest_item = Item {
name: "Rusty Dagger".to_string(),
slot: ItemSlot::Weapon,
power: 2,
rarity: Rarity::Common,
};
let sell_value = crate::loot::item_price(&chest_item) / 2;
let commit = ArenaCommit {
outcome: ArenaOutcome::CashOut { rounds_cleared: 3 },
fee: 50,
gold_reward: 100,
xp_reward: 0,
items: vec![chest_item],
kills: 3,
best_round: Some(3),
tournament_wins_increment: 0,
hp_set: Some(80),
tier_name: "Test Tier".to_string(),
};
let deferred = apply_arena_commit(&mut game, &commit);
let expected_gold = 500u32.saturating_sub(50).saturating_add(100).saturating_add(sell_value);
assert_eq!(game.character.gold, expected_gold, "expected {} gold (base 500 - fee 50 + reward 100 + overflow {}), got {}", expected_gold, sell_value, game.character.gold);
assert_eq!(game.character.inventory.len(), 20);
assert!(
deferred
.iter()
.any(|d| matches!(d, ArenaDeferredOutput::OverflowConverted { .. })),
"expected OverflowConverted in deferred output, got {:?}",
deferred
);
}
#[test]
fn godslayer_locked_at_150_prestige_2() {
let c = make_character(150, 2, 100);
assert!(!TIER_GODSLAYER.is_unlocked(&c));
}
#[test]
fn fee_invalid_index_returns_zero() {
let fake = ArenaTier {
index: 99,
name: "Fake Tier",
max_rounds: 1,
min_level: 0,
min_prestige: 0,
or_unlock: false,
reward_bands: &[],
chest_milestones: &[],
awards_crown: false,
};
let entry = make_snapshot(1, 0, 10, 25);
assert_eq!(fake.compute_fee(&entry), 0);
}
#[test]
fn colosseum_rewards_before_first_milestone() {
assert_eq!(TIER_COLOSSEUM.reward_percentages_at_round(3), (18, 12));
}
#[test]
fn abyssal_rewards_interpolation_round_12() {
assert_eq!(TIER_ABYSSAL.reward_percentages_at_round(12), (73, 42));
}
#[test]
fn godslayer_rewards_before_first_milestone() {
assert_eq!(TIER_GODSLAYER.reward_percentages_at_round(5), (12, 7));
}
#[test]
fn compute_rewards_round_zero() {
let (g, x) = TIER_PIT.compute_rewards(100, 200, 0);
assert_eq!(g, 0);
assert_eq!(x, 0);
}
#[test]
fn snapshot_copies_equipped_stats() {
let mut c = make_character(10, 1, 500);
c.equip(Item {
name: "Sword".to_string(),
slot: ItemSlot::Weapon,
power: 15,
rarity: Rarity::Rare,
});
c.equip(Item {
name: "Plate".to_string(),
slot: ItemSlot::Armor,
power: 10,
rarity: Rarity::Uncommon,
});
c.equip(Item {
name: "Ring".to_string(),
slot: ItemSlot::Ring,
power: 5,
rarity: Rarity::Common,
});
let snap = ArenaEntrySnapshot::from_character(&c);
assert_eq!(snap.attack_power, c.attack_power());
assert_eq!(snap.defense, c.defense());
assert!(snap.attack_power > 20);
assert!(snap.defense > 10);
}
#[test]
fn chests_collect_at_round_zero() {
let c = TIER_PIT.collect_chests(0);
assert!(c.is_empty());
}
#[test]
fn apply_commit_defeat_no_best_round_update() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
game.character.best_tournament_round = 5;
let commit = ArenaCommit {
outcome: ArenaOutcome::Defeat { rounds_cleared: 3 },
fee: 50,
gold_reward: 0,
xp_reward: 0,
items: vec![],
kills: 0,
best_round: None,
tournament_wins_increment: 0,
hp_set: Some(25),
tier_name: "Test Tier".to_string(),
};
let _deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.character.best_tournament_round, 5);
assert_eq!(game.character.gold, 450);
}
#[test]
fn apply_commit_defeat_does_not_grant_rewards() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
game.character.xp = 50;
game.character.kills = 10;
let commit = ArenaCommit {
outcome: ArenaOutcome::Defeat { rounds_cleared: 2 },
fee: 50,
gold_reward: 100,
xp_reward: 50,
items: vec![Item {
name: "Sword".to_string(),
slot: ItemSlot::Weapon,
power: 5,
rarity: Rarity::Common,
}],
kills: 5,
best_round: None,
tournament_wins_increment: 0,
hp_set: Some(25),
tier_name: "Test Tier".to_string(),
};
let _deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.character.xp, 50);
assert_eq!(game.character.kills, 10);
assert_eq!(game.character.inventory.len(), 0);
assert_eq!(game.character.gold, 450);
}
#[test]
fn apply_commit_preserves_existing_best_round() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
game.character.best_tournament_round = 10;
let commit = ArenaCommit {
outcome: ArenaOutcome::CashOut { rounds_cleared: 5 },
fee: 50,
gold_reward: 100,
xp_reward: 0,
items: vec![],
kills: 5,
best_round: Some(5),
tournament_wins_increment: 0,
hp_set: Some(80),
tier_name: "Test Tier".to_string(),
};
let _deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.character.best_tournament_round, 10);
}
#[test]
fn apply_commit_cash_out_items_fit_no_overflow() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
game.character.hp = 80;
game.character.best_tournament_round = 0;
let item = Item {
name: "Iron Sword".to_string(),
slot: ItemSlot::Weapon,
power: 10,
rarity: Rarity::Uncommon,
};
let commit = ArenaCommit {
outcome: ArenaOutcome::CashOut { rounds_cleared: 3 },
fee: 50,
gold_reward: 100,
xp_reward: 0,
items: vec![item.clone()],
kills: 3,
best_round: Some(3),
tournament_wins_increment: 0,
hp_set: Some(80),
tier_name: "Test Tier".to_string(),
};
let deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.character.inventory.len(), 1);
assert_eq!(game.character.inventory[0].name, "Iron Sword");
assert_eq!(game.character.gold, 550);
assert!(
deferred.is_empty(),
"items that fit must not produce deferred output, got {:?}",
deferred
);
}
#[test]
fn apply_commit_appends_tournament_journal_entry() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
let commit = ArenaCommit {
outcome: ArenaOutcome::CashOut { rounds_cleared: 3 },
fee: 50,
gold_reward: 100,
xp_reward: 0,
items: vec![],
kills: 3,
best_round: Some(3),
tournament_wins_increment: 0,
hp_set: Some(80),
tier_name: "The Pit".to_string(),
};
let _deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(game.journal.len(), 1);
assert!(matches!(game.journal[0].event_type, crate::journal::EventType::Tournament));
assert_eq!(
game.journal[0].message,
"Arena cash-out in The Pit after 3 rounds. +100 gold, +0 XP."
);
}
#[test]
fn seeded_long_combat_returns_all_exchanges_without_summary() {
use rand::SeedableRng;
use rand::rngs::StdRng;
use crate::character::Class;
let mut rng = StdRng::seed_from_u64(42);
let entry = ArenaEntrySnapshot {
level: 1,
xp_to_next: 25,
hp: 100,
max_hp: 100,
attack_power: 1,
defense: 20,
prestige: 0,
gold: 10,
intelligence: 6,
strength: 10,
dexterity: 8,
};
let mut enemy = ArenaEnemy {
name: "Test Bug".to_string(),
hp: 55,
max_hp: 55,
attack: 7,
};
let result = run_compact_combat(&entry, entry.hp, &mut enemy, &Class::Warrior, &mut rng);
assert!(
result.total_turns >= 10,
"Expected long fight, got {} turns",
result.total_turns
);
assert!(
result.exchanges.len() >= result.total_turns as usize,
"Expected at least one exchange per turn, got {} exchanges for {} turns",
result.exchanges.len(),
result.total_turns
);
let has_summary = result
.exchanges
.iter()
.any(|ex| ex.colored.contains("more exchanges"));
assert!(
!has_summary,
"Combat log must not be compacted; pacing replaces noise control"
);
}
#[test]
fn turn_cap_forces_defeat_on_pathological_input() {
use rand::SeedableRng;
use rand::rngs::StdRng;
use crate::character::Class;
let mut rng = StdRng::seed_from_u64(0);
let entry = ArenaEntrySnapshot {
level: 1, xp_to_next: 25,
hp: 1_000_000, max_hp: 1_000_000,
attack_power: 1, defense: 200,
prestige: 0, gold: 0,
intelligence: 6, strength: 10,
dexterity: 8,
};
let mut enemy = ArenaEnemy {
name: "Stress Test".to_string(),
hp: 1_000_000, max_hp: 1_000_000, attack: 1,
};
let result = run_compact_combat(&entry, 1_000_000, &mut enemy, &Class::Warrior, &mut rng);
assert!(!result.player_won, "Expected forced defeat on turn-cap, got victory");
assert!(
result.total_turns > ARENA_TUNING.max_turns,
"Expected total_turns > {} (max_turns), got {}",
ARENA_TUNING.max_turns, result.total_turns
);
}
#[test]
fn build_commit_zeros_xp_reward_at_max_level_entry() {
let c = make_character(crate::character::MAX_LEVEL, 3, 5000);
let entry = ArenaEntrySnapshot::from_character(&c);
let run = ArenaRun {
tier: TIER_GODSLAYER,
entry: entry.clone(),
entry_fee: 2500,
rounds_cleared: 50,
current_hp: 80,
};
let commit = build_commit(&c, &run, ArenaOutcome::Victory { rounds_cleared: 50 }, 80);
assert_eq!(
commit.xp_reward, 0,
"MAX_LEVEL entrants must not receive an XP reward"
);
assert!(
commit.gold_reward > 0,
"Gold reward must remain unaffected at MAX_LEVEL entry, got {}",
commit.gold_reward
);
}
#[test]
fn build_commit_preserves_xp_reward_just_below_max_level() {
let c = make_character(crate::character::MAX_LEVEL - 1, 3, 5000);
let entry = ArenaEntrySnapshot::from_character(&c);
let run = ArenaRun {
tier: TIER_GODSLAYER,
entry: entry.clone(),
entry_fee: 2500,
rounds_cleared: 10,
current_hp: 80,
};
let commit = build_commit(&c, &run, ArenaOutcome::CashOut { rounds_cleared: 10 }, 80);
assert!(
commit.xp_reward > 0,
"level-149 entrant must keep a positive xp_reward, got {}",
commit.xp_reward
);
}
#[test]
fn apply_commit_journal_message_is_synthesized_from_tier_name() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
let commit = ArenaCommit {
outcome: ArenaOutcome::Victory { rounds_cleared: 5 },
fee: 50,
gold_reward: 200,
xp_reward: 30,
items: vec![],
kills: 5,
best_round: Some(5),
tournament_wins_increment: 0,
hp_set: Some(80),
tier_name: "The Pit".to_string(),
};
let _deferred = apply_arena_commit(&mut game, &commit);
assert_eq!(
game.journal[0].message,
"Arena VICTORY in The Pit! Cleared all 5 rounds! +200 gold, +30 XP."
);
}
#[test]
fn apply_commit_journal_total_includes_overflow_gold() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
game.character.hp = 80;
for i in 0..20 {
game.character.inventory.push(Item {
name: format!("Legendary {}", i),
slot: ItemSlot::Weapon,
power: 50 + i as i32,
rarity: Rarity::Legendary,
});
}
let chest_item = Item {
name: "Rusty Dagger".to_string(),
slot: ItemSlot::Weapon,
power: 2,
rarity: Rarity::Common,
};
let sell_value = crate::loot::item_price(&chest_item) / 2;
assert!(sell_value > 0, "test fixture sanity: overflow value must be > 0");
let commit = ArenaCommit {
outcome: ArenaOutcome::CashOut { rounds_cleared: 3 },
fee: 50,
gold_reward: 100,
xp_reward: 0,
items: vec![chest_item],
kills: 3,
best_round: Some(3),
tournament_wins_increment: 0,
hp_set: Some(80),
tier_name: "Test Tier".to_string(),
};
let deferred = apply_arena_commit(&mut game, &commit);
let expected_total = 100u32 + sell_value;
let expected_msg = format!(
"Arena cash-out in Test Tier after 3 rounds. +{} gold, +0 XP.",
expected_total
);
assert_eq!(game.journal[0].message, expected_msg);
assert!(
deferred
.iter()
.any(|d| matches!(d, ArenaDeferredOutput::OverflowConverted { sell_value: sv, .. } if *sv == sell_value)),
"expected OverflowConverted with sell_value={}, got {:?}",
sell_value,
deferred
);
}
#[test]
fn apply_commit_emits_inventory_replaced_when_chest_evicts_weakest() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
game.character.hp = 80;
for i in 0..20 {
game.character.inventory.push(Item {
name: format!("Weak {}", i),
slot: ItemSlot::Weapon,
power: 1,
rarity: Rarity::Common,
});
}
let chest_item = Item {
name: "Strong Sword".to_string(),
slot: ItemSlot::Weapon,
power: 100,
rarity: Rarity::Rare,
};
let commit = ArenaCommit {
outcome: ArenaOutcome::CashOut { rounds_cleared: 3 },
fee: 0,
gold_reward: 0,
xp_reward: 0,
items: vec![chest_item],
kills: 0,
best_round: None,
tournament_wins_increment: 0,
hp_set: Some(80),
tier_name: "Test Tier".to_string(),
};
let deferred = apply_arena_commit(&mut game, &commit);
assert!(
deferred.iter().any(|d| matches!(
d,
ArenaDeferredOutput::InventoryReplaced { new_name, .. } if new_name == "Strong Sword"
)),
"expected InventoryReplaced with new_name='Strong Sword', got {:?}",
deferred
);
assert_eq!(game.character.inventory.len(), 20);
assert!(
game.character
.inventory
.iter()
.any(|i| i.name == "Strong Sword"),
"stronger chest item should now be in inventory"
);
}
#[test]
fn apply_commit_returns_no_deferred_output_for_defeat() {
let mut game = GameState::new(make_character(10, 0, 500));
game.character.max_hp = 100;
let commit = ArenaCommit {
outcome: ArenaOutcome::Defeat { rounds_cleared: 2 },
fee: 50,
gold_reward: 0,
xp_reward: 0,
items: vec![],
kills: 0,
best_round: None,
tournament_wins_increment: 0,
hp_set: Some(25),
tier_name: "Test Tier".to_string(),
};
let deferred = apply_arena_commit(&mut game, &commit);
assert!(
deferred.is_empty(),
"Defeat must never emit deferred output, got {:?}",
deferred
);
}
}