use super::*;
fn skill(name: &str, power: u32, accuracy: u32, category: SkillCategory, cost: u32) -> Skill {
Skill {
id: name.to_lowercase(),
name: name.to_string(),
power,
accuracy,
element: None,
category,
stat: "attack".to_string(),
cost,
ron: false,
}
}
fn combatant(
name: &str,
hp: u32,
atk: u32,
def: u32,
spd: u32,
mp: u32,
skills: Vec<Skill>,
) -> Combatant {
Combatant {
id: name.to_lowercase(),
name: name.to_string(),
element: None,
hp,
max_hp: hp,
attack: atk,
defense: def,
speed: spd,
level: 1,
base: BaseStats {
max_hp: hp,
max_mp: mp,
attack: atk,
defense: def,
speed: spd,
},
exp: 0,
exp_reward: 0,
stages: Stages::default(),
mp,
max_mp: mp,
skills,
status: None,
ability: None,
held_item: None,
}
}
fn aria_vs_slime() -> (Combatant, Combatant) {
let aria = combatant("Aria", 60, 12, 10, 15, 20, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)]);
let slime = combatant("Slime", 90, 8, 8, 5, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)]);
(aria, slime)
}
fn bryn() -> Combatant {
combatant("Bryn", 80, 14, 12, 10, 60, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)])
}
fn party_battle(bytes: &[u8]) -> Battle {
let (aria, slime) = aria_vs_slime();
Battle::full(
vec![aria, bryn()],
0,
slime,
Vec::new(),
HashMap::new(),
TypeChart::default(),
scripted(bytes),
None,
)
}
fn scripted(bytes: &[u8]) -> Box<dyn BattleRng> {
Box::new(ScriptedRng::new(bytes.to_vec()))
}
fn press(b: &mut Battle, mask: u8) {
let mut input = InputState::new();
input.set_from_bitmask(mask);
b.update(&input);
}
fn confirm(b: &mut Battle) {
press(b, GbButton::A.bit_mask());
}
fn fight(b: &mut Battle, pick: usize) {
confirm(b); for _ in 0..pick {
press(b, GbButton::Down.bit_mask());
}
confirm(b);
}
fn party_switch(b: &mut Battle, idx: usize) {
press(b, GbButton::Down.bit_mask()); confirm(b);
let start = b.first_switchable();
for _ in 0..(idx + b.party().len() - start) % b.party().len().max(1) {
press(b, GbButton::Down.bit_mask());
}
confirm(b);
}
fn item_use(b: &mut Battle, pick: usize) {
press(b, GbButton::Down.bit_mask());
press(b, GbButton::Down.bit_mask()); confirm(b);
for _ in 0..pick {
press(b, GbButton::Down.bit_mask());
}
confirm(b);
}
fn settle(b: &mut Battle) {
for _ in 0..40 {
if b.outcome().is_some() || b.in_menu() {
return;
}
confirm(b);
}
panic!("battle did not settle after 40 A presses");
}
fn play_to_end(b: &mut Battle, pick: usize) {
for _ in 0..40 {
if b.outcome().is_some() {
return;
}
fight(b, pick);
settle(b);
}
panic!("battle did not end after 40 rounds");
}
#[test]
fn stage_multipliers() {
assert_eq!(stage_multiplier(12, 0), 12);
assert_eq!(stage_multiplier(12, 1), 15, "+1 = ×1.25");
assert_eq!(stage_multiplier(12, 4), 24, "+4 = ×2");
assert_eq!(stage_multiplier(12, -1), 9, "-1 = ×0.8 (12×4/5)");
assert_eq!(stage_multiplier(12, -4), 6, "-4 = ×0.5");
assert_eq!(stage_multiplier(12, 9), 24);
assert_eq!(stage_multiplier(12, -9), 6);
}
#[test]
fn damage_roll_exact_numbers() {
let roll = damage_roll(40, 12, 8, (1, 1), &mut *scripted(&[100, 1]));
assert_eq!(roll.damage, 53);
assert!(!roll.crit);
let roll = damage_roll(40, 12, 8, (1, 1), &mut *scripted(&[100, 0]));
assert_eq!(roll.damage, 79);
assert!(roll.crit);
let roll = damage_roll(40, 12, 8, (2, 1), &mut *scripted(&[100, 1]));
assert_eq!(roll.damage, 106);
let roll = damage_roll(40, 12, 8, (1, 2), &mut *scripted(&[100, 1]));
assert_eq!(roll.damage, 26);
let roll = damage_roll(40, 12, 8, (1, 1), &mut *scripted(&[0]));
assert_eq!(roll.damage, 76);
assert!(roll.crit);
let roll = damage_roll(0, 12, 8, (1, 1), &mut *scripted(&[100, 1]));
assert_eq!(roll.damage, 1);
}
#[test]
fn accuracy_roll_threshold() {
assert!(accuracy_roll(100, &mut *scripted(&[99])));
assert!(accuracy_roll(50, &mut *scripted(&[149]))); assert!(!accuracy_roll(50, &mut *scripted(&[50])));
assert!(!accuracy_roll(0, &mut *scripted(&[0])));
}
#[test]
fn type_chart_from_ruleset() {
let ruleset = dotzuki_rules::Ruleset::from_ron(
r#"Ruleset(
types: ["fire", "grass"],
type_chart: [
(atk: "fire", def: "grass", mult: [2, 1]),
(atk: "grass", def: "fire", mult: [1, 2]),
],
)"#,
)
.expect("parse ruleset");
let chart = TypeChart::from_ruleset(&ruleset);
assert_eq!(chart.mult(Some("fire"), Some("grass")), (2, 1));
assert_eq!(chart.mult(Some("grass"), Some("fire")), (1, 2));
assert_eq!(chart.mult(Some("Fire"), Some("GRASS")), (2, 1), "case-insensitive");
assert_eq!(chart.mult(Some("water"), Some("fire")), (1, 1), "no edge = neutral");
assert_eq!(chart.mult(None, Some("fire")), (1, 1), "untyped attack = neutral");
assert_eq!(chart.mult(Some("fire"), None), (1, 1), "untyped defender = neutral");
}
#[test]
fn win_path_narrates_and_ends() {
let (aria, slime) = aria_vs_slime();
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
fight(&mut b, 0);
assert_eq!(b.current_line(), Some("Aria used Slash!"));
settle(&mut b);
assert_eq!(b.enemy().hp, 37);
assert_eq!(b.player().hp, 32);
assert!(b.in_menu(), "round over, back to the menu");
assert!(b.outcome().is_none());
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.outcome(), Some(BattleOutcome::Win));
assert_eq!(
b.log(),
&[
"Aria used Slash!",
"53 damage!",
"Slime used Tackle!",
"28 damage!",
"Aria used Slash!",
"53 damage!",
"Slime fainted!",
"You won the battle!",
]
);
}
#[test]
fn lose_path_narrates_and_ends() {
let weak = combatant("Aria", 20, 5, 5, 15, 0, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)]);
let strong = combatant("Golem", 200, 30, 10, 5, 0, vec![skill("Smash", 40, 100, SkillCategory::Damage, 0)]);
let mut b = Battle::new(weak, strong, TypeChart::default(), scripted(&[50, 100, 1]));
play_to_end(&mut b, 0);
assert_eq!(b.outcome(), Some(BattleOutcome::Lose));
assert!(b.log().contains(&"Aria fainted!".to_string()));
assert_eq!(b.log().last(), Some(&"You lost the battle…".to_string()));
}
#[test]
fn faster_enemy_acts_first() {
let (aria, mut slime) = aria_vs_slime();
slime.speed = 20; let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
fight(&mut b, 0);
assert_eq!(b.current_line(), Some("Slime used Tackle!"));
}
#[test]
fn speed_tie_goes_to_the_player() {
let (aria, mut slime) = aria_vs_slime();
slime.speed = 15; let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
fight(&mut b, 0);
assert_eq!(b.current_line(), Some("Aria used Slash!"));
}
#[test]
fn mp_gate_blocks_unaffordable_player_skill() {
let mut aria = combatant(
"Aria",
60,
12,
10,
15,
3, vec![
skill("Slash", 40, 100, SkillCategory::Damage, 0),
skill("Fire Bolt", 50, 100, SkillCategory::Damage, 5),
],
);
aria.max_mp = 3;
let slime = combatant("Slime", 90, 8, 8, 5, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)]);
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
assert_eq!(
b.menu_items(),
vec!["Fight".to_string(), "Party".to_string(), "Run".to_string()]
);
confirm(&mut b);
assert_eq!(b.menu_items(), vec!["Slash".to_string(), "× Fire Bolt 5MP".to_string()]);
press(&mut b, GbButton::Down.bit_mask());
confirm(&mut b);
assert!(b.in_menu(), "unaffordable skill is unselectable");
assert!(b.log().is_empty());
press(&mut b, GbButton::Up.bit_mask());
confirm(&mut b);
settle(&mut b);
assert_eq!(b.player().mp, 3);
}
#[test]
fn mp_is_spent_per_use() {
let mut aria = combatant("Aria", 60, 12, 10, 15, 12, vec![skill("Fire Bolt", 50, 100, SkillCategory::Damage, 5)]);
aria.max_mp = 12;
let slime = combatant("Slime", 900, 8, 8, 5, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)]);
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.player().mp, 7);
}
#[test]
fn enemy_falls_back_to_basic_attack_when_broke() {
let (aria, mut slime) = aria_vs_slime();
slime.skills = vec![skill("Fire Bolt", 50, 100, SkillCategory::Damage, 5)];
slime.mp = 0; let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
play_to_end(&mut b, 0);
assert!(
b.log().contains(&"Slime used Attack!".to_string()),
"broke enemy uses the built-in Attack: {:?}",
b.log()
);
}
#[test]
fn buff_raises_attack_stage_and_damage() {
let aria = combatant(
"Aria",
60,
12,
10,
15,
20,
vec![
skill("Slash", 40, 100, SkillCategory::Damage, 0),
skill("Focus", 0, 100, SkillCategory::Buff, 0),
],
);
let slime = combatant("Slime", 900, 8, 8, 5, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)]);
let mut b = Battle::new(
aria,
slime,
TypeChart::default(),
scripted(&[50, 50, 100, 1, 50, 100, 1]),
);
fight(&mut b, 1);
settle(&mut b);
assert_eq!(b.player().stages.attack, 1);
assert!(b.log().contains(&"Aria's Attack rose!".to_string()));
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"66 damage!".to_string()), "{:?}", b.log());
}
#[test]
fn debuff_lowers_defense_stage() {
let mut weaken = skill("Weaken", 0, 100, SkillCategory::Debuff, 0);
weaken.stat = "defense".to_string();
let aria = combatant("Aria", 60, 12, 10, 15, 20, vec![weaken]);
let slime = combatant("Slime", 90, 8, 8, 5, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)]);
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 50, 100, 1]));
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.enemy().stages.defense, -1);
assert!(b.log().contains(&"Slime's Defense fell!".to_string()));
assert_eq!(b.enemy().eff_defense(), 6);
}
#[test]
fn heal_restores_up_to_the_cap() {
let mut aria = combatant("Aria", 60, 12, 10, 15, 20, vec![skill("Heal", 25, 100, SkillCategory::Heal, 0)]);
aria.hp = 30; let slime = combatant("Slime", 90, 8, 8, 5, 0, vec![skill("Tackle", 40, 0, SkillCategory::Damage, 0)]);
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50]));
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.player().hp, 55);
assert!(b.log().contains(&"Aria recovered 25 HP!".to_string()));
assert!(b.log().contains(&"But it missed!".to_string()));
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.player().hp, 60);
assert!(b.log().contains(&"Aria recovered 5 HP!".to_string()));
}
#[test]
fn effectiveness_is_narrated() {
let mut bolt = skill("Fire Bolt", 50, 100, SkillCategory::Damage, 0);
bolt.element = Some("fire".to_string());
let mut aria = combatant("Aria", 60, 12, 10, 15, 20, vec![bolt]);
aria.element = Some("grass".to_string());
let mut slime = combatant("Slime", 900, 8, 8, 5, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)]);
slime.element = Some("grass".to_string());
let ruleset = dotzuki_rules::Ruleset::from_ron(
r#"Ruleset(
types: ["fire", "grass"],
type_chart: [(atk: "fire", def: "grass", mult: [2, 1])],
)"#,
)
.unwrap();
let chart = TypeChart::from_ruleset(&ruleset);
let mut b = Battle::new(aria, slime, chart, scripted(&[50, 100, 1]));
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"It's super effective!".to_string()));
assert!(b.log().contains(&"132 damage!".to_string()), "{:?}", b.log());
}
#[test]
fn switch_consumes_the_turn_and_resets_stages() {
let aria = combatant(
"Aria",
60,
12,
10,
15,
20,
vec![
skill("Slash", 40, 100, SkillCategory::Damage, 0),
skill("Focus", 0, 100, SkillCategory::Buff, 0),
],
);
let slime = combatant("Slime", 900, 8, 8, 5, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)]);
let mut b = Battle::full(
vec![aria, bryn()],
0,
slime,
Vec::new(),
HashMap::new(),
TypeChart::default(),
scripted(&[50, 50, 100, 1, 50, 100, 1, 50, 100, 1, 50, 100, 1]),
None,
);
fight(&mut b, 1);
settle(&mut b);
assert_eq!(b.player().stages.attack, 1);
let aria_hp = b.player().hp;
party_switch(&mut b, 1);
assert_eq!(b.current_line(), Some("Come back, Aria!"));
settle(&mut b);
assert_eq!(b.active_index(), 1);
assert_eq!(b.player().name, "Bryn");
assert_eq!(b.party()[1].hp, 57, "{:?}", b.log());
assert_eq!(b.party()[0].hp, aria_hp, "benched Aria took no damage");
assert!(b.log().contains(&"Go, Bryn!".to_string()));
party_switch(&mut b, 0);
settle(&mut b);
assert_eq!(b.active_index(), 0);
assert_eq!(b.player().stages.attack, 0, "stages reset on switch-in");
assert_eq!(b.player().eff_attack(), 12);
}
#[test]
fn forced_switch_on_faint_and_win_still_possible() {
let mut aria = combatant("Aria", 60, 12, 10, 15, 20, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)]);
aria.hp = 1;
let mut strong_bryn = bryn();
strong_bryn.attack = 200; let slime = combatant("Slime", 90, 8, 8, 20, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)]);
let mut b = Battle::full(
vec![aria, strong_bryn],
0,
slime,
Vec::new(),
HashMap::new(),
TypeChart::default(),
scripted(&[50, 100, 1]),
None,
);
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"Aria fainted!".to_string()));
assert!(b.outcome().is_none(), "Bryn still stands");
assert!(matches!(b.phase, Phase::ForcedSwitch));
assert_eq!(
b.menu_items(),
vec!["× Aria 0/60".to_string(), "Bryn 80/80".to_string()]
);
confirm(&mut b);
settle(&mut b);
assert_eq!(b.player().name, "Bryn");
assert_eq!(b.player().hp, 80, "the Slime had already acted");
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.outcome(), Some(BattleOutcome::Win), "{:?}", b.log());
}
#[test]
fn all_members_fainted_is_a_loss() {
let weak = |name: &str| {
combatant(name, 10, 5, 5, 15, 0, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)])
};
let golem = combatant("Golem", 500, 30, 10, 20, 0, vec![skill("Smash", 40, 100, SkillCategory::Damage, 0)]);
let mut b = Battle::full(
vec![weak("Aria"), weak("Bryn")],
0,
golem,
Vec::new(),
HashMap::new(),
TypeChart::default(),
scripted(&[50, 100, 1]),
None,
);
fight(&mut b, 0);
settle(&mut b);
assert!(matches!(b.phase, Phase::ForcedSwitch));
confirm(&mut b); settle(&mut b);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.outcome(), Some(BattleOutcome::Lose), "{:?}", b.log());
assert_eq!(b.log().last(), Some(&"You lost the battle…".to_string()));
}
#[test]
fn party_state_reflects_the_battle_for_write_back() {
let mut b = party_battle(&[50, 100, 1]);
fight(&mut b, 0);
settle(&mut b);
let state = b.party_state();
assert_eq!(state.len(), 2);
assert_eq!((state[0].id.as_str(), state[0].hp), ("aria", 32));
assert_eq!((state[1].id.as_str(), state[1].hp, state[1].mp), ("bryn", 80, 60));
assert_eq!(state[0].status, None);
}
fn potion() -> BattleItem {
BattleItem {
id: "potion".to_string(),
name: "Potion".to_string(),
heal: 50,
}
}
fn item_battle(hp: u32, count: u32) -> Battle {
let mut aria = combatant("Aria", 60, 12, 10, 15, 20, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)]);
aria.hp = hp;
let slime = combatant("Slime", 900, 8, 8, 5, 0, vec![skill("Tackle", 40, 0, SkillCategory::Damage, 0)]);
Battle::full(
vec![aria],
0,
slime,
vec![potion()],
HashMap::from([("potion".to_string(), count)]),
TypeChart::default(),
scripted(&[50]),
None,
)
}
#[test]
fn item_heals_caps_decrements_and_consumes_the_turn() {
let mut b = item_battle(30, 2);
assert_eq!(
b.menu_items(),
vec!["Fight".to_string(), "Party".to_string(), "Item".to_string(), "Run".to_string()]
);
item_use(&mut b, 0);
settle(&mut b);
assert_eq!(b.player().hp, 60);
assert_eq!(b.inventory().get("potion"), Some(&1));
assert_eq!(
b.log(),
&[
"Aria used Potion!",
"Aria recovered 30 HP!",
"Slime used Tackle!",
"But it missed!",
]
);
}
#[test]
fn item_disappears_at_zero_count() {
let mut b = item_battle(10, 1);
item_use(&mut b, 0);
settle(&mut b);
assert_eq!(b.player().hp, 60);
assert_eq!(b.inventory().get("potion"), None, "count 0 removes the entry");
press(&mut b, GbButton::Down.bit_mask());
press(&mut b, GbButton::Down.bit_mask());
confirm(&mut b);
assert!(b.menu_items().is_empty(), "count-0 items are not listed");
}
fn ron_skill(name: &str, power: u32, cost: u32) -> Skill {
let mut s = skill(name, power, 100, SkillCategory::Damage, cost);
s.ron = true;
s
}
const POISON_RULES: &str = r#"Ruleset(
stats: ["hp", "attack", "defense", "speed"],
types: [],
resources: ["mp"],
effects: [
Effect(id: "venom-sting", kind: Move, power: 15, accuracy: 100, hooks: [
Hook(on: "DamagingHit", chance: [30, 100], do: [
InflictStatus(status: "poison", target: Target),
]),
]),
Effect(id: "poison", kind: Status, hooks: [
Hook(on: "Residual", do: [
DamageFraction(num: 1, den: 8, of: MaxHp, target: Target),
]),
]),
],
)"#;
fn ron_battle(player: Combatant, enemy: Combatant, rules: &str, bytes: &[u8]) -> Battle {
let ruleset = dotzuki_rules::Ruleset::from_ron(rules).expect("parse ruleset");
let compiled = hooks::compile_ruleset(&ruleset).expect("compile ruleset");
hooks::install_compiled(compiled.clone());
let registry = compiled.build_effects::<GenericProvider>();
let stat_names = ruleset.stats.clone();
let status_names = hooks::status_names(&ruleset);
let state = HookState {
state: BattleState::new(
vec![hooks::mirror_of(&player, &stat_names, &status_names, true)],
vec![hooks::mirror_of(&enemy, &stat_names, &status_names, true)],
),
effects: Vec::new(),
mv: dotzuki_engine::battle::stack::MoveContext::default(),
registry,
move_records: hooks::ron_moves(&ruleset, Some("mp")),
status_names,
stat_names,
has_resource: true,
};
let chart = TypeChart::from_ruleset(&ruleset);
Battle::with_hooks(player, enemy, chart, scripted(bytes), Some(state))
}
fn venom_aria() -> Combatant {
combatant("Aria", 60, 12, 10, 15, 20, vec![ron_skill("venom-sting", 15, 0)])
}
fn tackle_slime(hp: u32) -> Combatant {
combatant("Slime", hp, 8, 8, 5, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)])
}
#[test]
fn ron_poison_inflicts_and_chips_exact_hp() {
let mut b = ron_battle(venom_aria(), tackle_slime(96), POISON_RULES, &[50, 100, 1, 29, 50, 100, 1]);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.enemy().hp, 65, "{:?}", b.log());
assert_eq!(b.player().hp, 32);
assert_eq!(
b.hooks().unwrap().battler(Side::Enemy).status,
Some(hooks::StatusId(0)),
);
assert_eq!(
b.log(),
&[
"Aria used venom-sting!",
"19 damage!",
"Slime was afflicted with poison!",
"Slime used Tackle!",
"28 damage!",
"Slime is hurt by poison!",
]
);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.enemy().hp, 34);
assert_eq!(b.player().hp, 4);
assert_eq!(
b.log()
.iter()
.filter(|l| l.as_str() == "Slime was afflicted with poison!")
.count(),
1,
"{:?}",
b.log()
);
}
#[test]
fn ron_chance_gate_compares_the_rng_byte() {
let mut b = ron_battle(venom_aria(), tackle_slime(96), POISON_RULES, &[50, 100, 1, 30, 50, 100, 1]);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.hooks().unwrap().battler(Side::Enemy).status, None);
assert!(!b.log().iter().any(|l| l.contains("afflicted")), "{:?}", b.log());
let mut b = ron_battle(venom_aria(), tackle_slime(96), POISON_RULES, &[50, 100, 1, 29, 50, 100, 1]);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(
b.hooks().unwrap().battler(Side::Enemy).status,
Some(hooks::StatusId(0)),
);
}
#[test]
fn ron_boost_matches_the_builtin_buff() {
let rules = r#"Ruleset(
stats: ["hp", "attack", "defense", "speed"],
resources: ["mp"],
effects: [
Effect(id: "focus", kind: Move, power: 0, hooks: [
Hook(on: "DamagingHit", do: [Boost(stat: "attack", stages: 1, target: Source)]),
]),
],
)"#;
let mut focus = skill("Focus", 0, 100, SkillCategory::Buff, 0);
focus.ron = true; let aria = combatant(
"Aria",
60,
12,
10,
15,
20,
vec![skill("Slash", 40, 100, SkillCategory::Damage, 0), focus],
);
let mut b = ron_battle(aria, tackle_slime(900), rules, &[50, 50, 100, 1, 50, 100, 1]);
fight(&mut b, 1);
settle(&mut b);
assert_eq!(b.player().stages.attack, 1);
assert!(b.log().contains(&"Aria's Attack rose!".to_string()), "{:?}", b.log());
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"66 damage!".to_string()), "{:?}", b.log());
}
#[test]
fn ron_scale_relay_scales_the_precomputed_damage() {
let rules = r#"Ruleset(
stats: ["hp", "attack", "defense", "speed"],
resources: ["mp"],
effects: [
Effect(id: "slash", kind: Move, hooks: [
Hook(on: "ModifyDamage", do: [DealMoveDamage, ScaleRelay(num: 3, den: 2)]),
]),
],
)"#;
let aria = combatant("Aria", 60, 12, 10, 15, 20, vec![ron_skill("Slash", 40, 0)]);
let mut b = ron_battle(aria, tackle_slime(900), rules, &[50, 100, 1]);
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"79 damage!".to_string()), "{:?}", b.log());
}
#[test]
fn ron_before_move_veto_blocks_the_action() {
let rules = r#"Ruleset(
stats: ["hp", "attack", "defense", "speed"],
resources: ["mp"],
effects: [
Effect(id: "venom-sting", kind: Move, power: 15, hooks: [
Hook(on: "BeforeMove", do: [VetoIf(cond: SourceHasStatus("poison"))]),
]),
Effect(id: "poison", kind: Status, hooks: []),
],
)"#;
let mut aria = venom_aria();
aria.status = Some("poison".to_string());
let mut b = ron_battle(aria, tackle_slime(96), rules, &[50, 100, 1, 50, 100, 1]);
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"But it failed!".to_string()), "{:?}", b.log());
assert_eq!(b.enemy().hp, 96, "the vetoed sting dealt nothing");
}
#[test]
fn ron_residual_chip_can_knock_out() {
let mut slime = tackle_slime(96);
slime.hp = 60;
slime.status = Some("poison".to_string());
let aria = combatant("Aria", 60, 12, 10, 15, 20, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)]);
let mut b = ron_battle(aria, slime, POISON_RULES, &[50, 100, 1]);
play_to_end(&mut b, 0);
assert_eq!(b.outcome(), Some(BattleOutcome::Win), "{:?}", b.log());
let log = b.log();
let chip = log.iter().position(|l| l == "Slime is hurt by poison!").unwrap();
let faint = log.iter().position(|l| l == "Slime fainted!").unwrap();
assert!(chip < faint, "{log:?}");
}
#[test]
fn ron_poison_switch_keeps_status_and_resolves_the_deferred_enemy_action() {
let mut aria = combatant("Aria", 60, 12, 10, 15, 20, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)]);
aria.hp = 7; aria.status = Some("poison".to_string());
let slime = tackle_slime(900);
let player = vec![aria, bryn()];
let ruleset = dotzuki_rules::Ruleset::from_ron(POISON_RULES).expect("parse ruleset");
let compiled = hooks::compile_ruleset(&ruleset).expect("compile ruleset");
hooks::install_compiled(compiled.clone());
let stat_names = ruleset.stats.clone();
let status_names = hooks::status_names(&ruleset);
let state = HookState {
state: BattleState::new(
vec![hooks::mirror_of(&player[0], &stat_names, &status_names, true)],
vec![hooks::mirror_of(&slime, &stat_names, &status_names, true)],
),
effects: Vec::new(),
mv: dotzuki_engine::battle::stack::MoveContext::default(),
registry: compiled.build_effects::<GenericProvider>(),
move_records: hooks::ron_moves(&ruleset, Some("mp")),
status_names,
stat_names,
has_resource: true,
};
let mut b = Battle::full(
player,
0,
slime,
Vec::new(),
HashMap::new(),
TypeChart::default(),
scripted(&[50, 100, 1]),
Some(state),
);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.enemy().hp, 900 - 53);
assert_eq!(b.party()[0].hp, 0);
assert!(matches!(b.phase, Phase::ForcedSwitch), "{:?}", b.log());
assert!(b.log().contains(&"Aria is hurt by poison!".to_string()));
assert!(b.log().contains(&"Aria fainted!".to_string()));
confirm(&mut b);
settle(&mut b);
assert_eq!(b.player().name, "Bryn");
assert_eq!(b.party()[1].hp, 57, "{:?}", b.log());
assert_eq!(b.party()[1].stages.attack, 0, "stages reset on switch-in");
assert_eq!(b.party()[0].status.as_deref(), Some("poison"));
assert_eq!(b.player().status, None, "Bryn was never poisoned");
assert_eq!(b.hooks().unwrap().battler(Side::Player).hp, 57);
}
#[test]
fn validate_ruleset_reports_unknown_names() {
assert!(hooks::validate_ruleset(POISON_RULES).is_empty());
let bad_stat = r#"Ruleset(
stats: ["attack"],
effects: [Effect(id: "x", kind: Move, hooks: [
Hook(on: "DamagingHit", do: [Boost(stat: "speed", stages: 1, target: Source)]),
])],
)"#;
let diags = hooks::validate_ruleset(bad_stat);
assert!(diags.iter().any(|d| d.contains("speed")), "{diags:?}");
let bad_event = r#"Ruleset(effects: [Effect(id: "x", kind: Move, hooks: [Hook(on: "OnHit")])])"#;
let diags = hooks::validate_ruleset(bad_event);
assert!(diags.iter().any(|d| d.contains("OnHit")), "{diags:?}");
let bad_status = r#"Ruleset(effects: [Effect(id: "x", kind: Move, hooks: [
Hook(on: "DamagingHit", do: [InflictStatus(status: "burn", target: Target)]),
])])"#;
let diags = hooks::validate_ruleset(bad_status);
assert!(diags.iter().any(|d| d.contains("burn")), "{diags:?}");
assert!(!hooks::validate_ruleset("Ruleset(types: [").is_empty());
}
fn ron_party_battle(party: Vec<Combatant>, enemy: Combatant, rules: &str, bytes: &[u8]) -> Battle {
let ruleset = dotzuki_rules::Ruleset::from_ron(rules).expect("parse ruleset");
let compiled = hooks::compile_ruleset(&ruleset).expect("compile ruleset");
hooks::install_compiled(compiled.clone());
let stat_names = ruleset.stats.clone();
let status_names = hooks::status_names(&ruleset);
let state = HookState {
state: BattleState::new(
vec![hooks::mirror_of(&party[0], &stat_names, &status_names, true)],
vec![hooks::mirror_of(&enemy, &stat_names, &status_names, true)],
),
effects: Vec::new(),
mv: dotzuki_engine::battle::stack::MoveContext::default(),
registry: compiled.build_effects::<GenericProvider>(),
move_records: hooks::ron_moves(&ruleset, Some("mp")),
status_names,
stat_names,
has_resource: true,
};
let chart = TypeChart::from_ruleset(&ruleset);
Battle::full(party, 0, enemy, Vec::new(), HashMap::new(), chart, scripted(bytes), Some(state))
}
const INTIMIDATE_RULES: &str = r#"Ruleset(
stats: ["hp", "attack", "defense", "speed"],
resources: ["mp"],
effects: [
Effect(id: "intimidate", kind: Ability, hooks: [
Hook(on: "SwitchIn", do: [Boost(stat: "attack", stages: -1, target: Foe)]),
]),
],
)"#;
fn intimidate_aria() -> Combatant {
let mut aria = combatant("Aria", 60, 12, 10, 15, 20, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)]);
aria.ability = Some("intimidate".to_string());
aria
}
#[test]
fn ability_fires_on_begin_and_re_fires_on_switch_benched_inert() {
let mut bryn = bryn();
bryn.ability = Some("intimidate".to_string());
let slime = tackle_slime(900);
let mut b = ron_party_battle(vec![intimidate_aria(), bryn], slime, INTIMIDATE_RULES, &[50, 100, 1]);
b.begin();
assert_eq!(b.enemy().stages.attack, -1, "{:?}", b.log());
assert_eq!(b.player().stages.attack, 0, "the foe has no ability");
assert_eq!(
b.log(),
&["Aria's Intimidate!", "Slime's Attack fell!"],
"{:?}",
b.log()
);
settle(&mut b);
party_switch(&mut b, 1);
settle(&mut b);
assert_eq!(b.enemy().stages.attack, -2, "{:?}", b.log());
assert_eq!(b.player().name, "Bryn");
assert_eq!(b.player().hp, 80 - 14, "{:?}", b.log());
assert!(b.log().contains(&"Bryn's Intimidate!".to_string()), "{:?}", b.log());
party_switch(&mut b, 0);
settle(&mut b);
assert_eq!(b.enemy().stages.attack, -3, "{:?}", b.log());
assert_eq!(
b.log().iter().filter(|l| l.as_str() == "Aria's Intimidate!").count(),
2,
"{:?}",
b.log()
);
}
#[test]
fn ability_enemy_fires_at_battle_start_and_softens_its_damage_taken() {
let mut slime = tackle_slime(900);
slime.ability = Some("intimidate".to_string());
let aria = intimidate_aria(); let mut b = ron_battle(aria, slime, INTIMIDATE_RULES, &[50, 100, 1]);
b.begin();
assert_eq!(
b.log(),
&[
"Aria's Intimidate!",
"Slime's Attack fell!",
"Slime's Intimidate!",
"Aria's Attack fell!",
],
"{:?}",
b.log()
);
assert_eq!(b.player().stages.attack, -1);
assert_eq!(b.enemy().stages.attack, -1);
settle(&mut b);
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"40 damage!".to_string()), "{:?}", b.log());
}
#[test]
fn ability_fires_when_the_encounter_sends_out_the_next_enemy() {
let first = tackle_slime(50);
let mut second = tackle_slime(900);
second.ability = Some("intimidate".to_string());
let mut b = ron_party_battle(vec![intimidate_aria()], first, INTIMIDATE_RULES, &[50, 100, 1]);
b.set_enemy_party(vec![second], false, 0);
b.begin(); settle(&mut b);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.enemy().hp, 900, "fresh replacement");
assert_eq!(b.player().stages.attack, -1, "{:?}", b.log());
assert!(b.log().contains(&"Foe sent out Slime!".to_string()), "{:?}", b.log());
assert!(b.log().contains(&"Slime's Intimidate!".to_string()), "{:?}", b.log());
}
#[test]
fn ability_modify_damage_hook_joins_the_action_sequence() {
let rules = r#"Ruleset(
stats: ["hp", "attack", "defense", "speed"],
resources: ["mp"],
effects: [
Effect(id: "slash", kind: Move, hooks: []),
Effect(id: "warrior-spirit", kind: Ability, hooks: [
Hook(on: "ModifyDamage", do: [ScaleRelay(num: 3, den: 2)]),
]),
],
)"#;
let mut aria = combatant("Aria", 60, 12, 10, 15, 20, vec![ron_skill("Slash", 40, 0)]);
aria.ability = Some("warrior-spirit".to_string());
let mut b = ron_battle(aria, tackle_slime(900), rules, &[50, 100, 1]);
b.begin(); assert!(b.in_menu(), "{:?}", b.log());
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"79 damage!".to_string()), "{:?}", b.log());
assert!(b.log().contains(&"28 damage!".to_string()), "{:?}", b.log());
}
#[test]
fn held_item_leftovers_heals_after_the_holders_action_only() {
let rules = r#"Ruleset(
stats: ["hp", "attack", "defense", "speed"],
resources: ["mp"],
effects: [
Effect(id: "leftovers", kind: Item, hooks: [
Hook(on: "Residual", do: [HealFraction(num: 1, den: 16, of: MaxHp, target: Target)]),
]),
],
)"#;
let mut bryn = bryn(); bryn.held_item = Some("leftovers".to_string());
bryn.hp = 50;
let mut b = ron_battle(bryn, tackle_slime(90), rules, &[50, 100, 1]);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.enemy().hp, 28, "{:?}", b.log());
assert_eq!(b.player().hp, 32, "{:?}", b.log());
assert!(b.log().contains(&"Bryn recovered 5 HP!".to_string()), "{:?}", b.log());
assert!(
!b.log().iter().any(|l| l.starts_with("Slime recovered")),
"the enemy without heldItem heals nothing: {:?}",
b.log()
);
}
#[test]
fn weather_chips_both_sides_per_round_until_cleared() {
let rules = r#"Ruleset(
stats: ["hp", "attack", "defense", "speed"],
resources: ["mp"],
effects: [
Effect(id: "sandstorm", kind: Weather, hooks: [
Hook(on: "FieldResidual", do: [DamageFraction(num: 1, den: 16, of: MaxHp, target: Target)]),
]),
],
)"#;
let aria = combatant("Aria", 60, 12, 10, 15, 20, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)]);
let slime = tackle_slime(900); let mut b = ron_battle(aria, slime, rules, &[50, 100, 1]);
b.set_weather(Some("sandstorm".to_string()));
b.begin();
assert_eq!(b.log(), &["A sandstorm rages!"], "{:?}", b.log());
settle(&mut b);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.player().hp, 29, "{:?}", b.log());
assert_eq!(b.enemy().hp, 791, "{:?}", b.log());
assert!(b.log().contains(&"Aria lost 3 HP!".to_string()), "{:?}", b.log());
assert!(b.log().contains(&"Slime lost 56 HP!".to_string()), "{:?}", b.log());
b.set_weather(None);
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.player().hp, 1, "no chip after clear: {:?}", b.log());
assert_eq!(b.enemy().hp, 738, "{:?}", b.log());
assert_eq!(
b.log().iter().filter(|l| l.contains("lost")).count(),
2,
"only round 1 chipped: {:?}",
b.log()
);
}
#[test]
fn unknown_weather_id_is_dropped_with_a_warning() {
let (aria, slime) = aria_vs_slime();
let mut b = ron_battle(aria, slime, POISON_RULES, &[50, 100, 1]);
b.set_weather(Some("bogus".to_string()));
b.begin();
assert_eq!(b.weather(), None, "an id with no compiled hooks is ignored");
assert!(b.log().is_empty(), "no intro line for unknown weather: {:?}", b.log());
assert!(b.in_menu());
}
#[test]
fn begin_is_a_noop_without_abilities_or_weather() {
let (aria, slime) = aria_vs_slime();
let mut b = ron_battle(aria, slime, POISON_RULES, &[50, 100, 1]);
b.begin();
assert!(b.log().is_empty(), "{:?}", b.log());
assert!(b.in_menu());
}
#[test]
fn prettify_id_title_cases_record_ids() {
assert_eq!(prettify_id("intimidate"), "Intimidate");
assert_eq!(prettify_id("swift-swim"), "Swift Swim");
assert_eq!(prettify_id("battle_armor"), "Battle Armor");
}
fn levels(max_level: u8) -> LevelsSetup {
LevelsSetup {
exp_field: "exp".to_string(),
level_field: "level".to_string(),
curve_base: 8,
curve_exponent: 3,
growth: 0.05,
max_level,
}
}
#[test]
fn growth_multiplier_exact_integers() {
assert_eq!(growth_stat(100, 1, 0.05), 100, "level 1 = ×1 (unchanged)");
assert_eq!(growth_stat(100, 2, 0.05), 105);
assert_eq!(growth_stat(100, 10, 0.05), 145, "×1.45");
assert_eq!(growth_stat(20, 1, 0.05), 20);
assert_eq!(growth_stat(20, 2, 0.05), 21);
assert_eq!(growth_stat(20, 10, 0.05), 29);
assert_eq!(growth_stat(33, 1, 0.25), 33);
assert_eq!(growth_stat(33, 0, 0.25), 33, "level 0 also clamps to ×1");
assert_eq!(growth_stat(100, 5, 0.25), 200);
}
#[test]
fn exp_curve_exact_thresholds() {
assert_eq!(exp_to_next(8, 3, 1), 8);
assert_eq!(exp_to_next(8, 3, 2), 64);
assert_eq!(exp_to_next(8, 3, 3), 216);
assert_eq!(exp_to_next(8, 3, 100), 8_000_000);
}
fn win_levels_battle(reward: u32, max_level: u8, lang: &str) -> Battle {
let mut b = party_battle(&[50, 100, 1]);
b.enemy.exp_reward = reward;
b.set_levels(Some(levels(max_level)));
b.set_lang(lang);
play_to_end(&mut b, 0);
assert_eq!(b.outcome(), Some(BattleOutcome::Win));
b
}
#[test]
fn win_awards_exp_to_living_members_only_and_heals_the_delta() {
let mut b = party_battle(&[50, 100, 1]);
b.party[1].hp = 0; b.enemy.exp_reward = 8;
b.set_levels(Some(levels(100)));
play_to_end(&mut b, 0);
assert_eq!(b.outcome(), Some(BattleOutcome::Win));
let aria = &b.party()[0];
assert_eq!(aria.level, 2);
assert_eq!(aria.exp, 0, "8 gained − 8 spent on the level-up");
assert_eq!((aria.hp, aria.max_hp), (35, 63));
assert_eq!((aria.mp, aria.max_mp), (21, 21));
assert_eq!(aria.attack, 12, "floor(12 × 1.05) = 12");
let bryn = &b.party()[1];
assert_eq!((bryn.level, bryn.exp), (1, 0));
let log = b.log();
let win = log.iter().position(|l| l == "You won the battle!").unwrap();
assert_eq!(log[win + 1], "Aria gained 8 EXP!");
assert_eq!(log[win + 2], "Aria grew to level 2!");
assert!(!log.iter().any(|l| l.contains("Bryn gained")), "{log:?}");
let state = b.party_state();
assert_eq!((state[0].level, state[0].exp), (2, 0));
assert_eq!((state[1].level, state[1].exp), (1, 0));
}
#[test]
fn multi_level_up_from_one_award() {
let b = win_levels_battle(80, 100, "en");
let aria = &b.party()[0];
assert_eq!(aria.level, 3);
assert_eq!(aria.exp, 8);
assert_eq!(aria.max_hp, 66, "60 × 1.10");
let ups: Vec<&str> = b
.log()
.iter()
.filter(|l| l.starts_with("Aria grew to level"))
.map(String::as_str)
.collect();
assert_eq!(ups, ["Aria grew to level 2!", "Aria grew to level 3!"]);
}
#[test]
fn max_level_caps_level_ups() {
let b = win_levels_battle(80, 2, "en");
let aria = &b.party()[0];
assert_eq!(aria.level, 2, "level-ups stop at the cap");
assert_eq!(aria.exp, 72, "unspent progress stays (80 − 8)");
}
#[test]
fn zh_narration_for_exp_and_level_up() {
let b = win_levels_battle(8, 100, "zh");
let log = b.log();
assert!(log.contains(&"Aria 获得了 8 点经验!".to_string()), "{log:?}");
assert!(log.contains(&"Aria 升到了 2 级!".to_string()), "{log:?}");
}
#[test]
fn no_levels_block_no_exp_narration() {
let mut b = party_battle(&[50, 100, 1]);
b.enemy.exp_reward = 8;
play_to_end(&mut b, 0);
assert_eq!(b.outcome(), Some(BattleOutcome::Win));
assert_eq!(b.log().last(), Some(&"You won the battle!".to_string()));
assert!(!b.log().iter().any(|l| l.contains("EXP")), "{:?}", b.log());
assert_eq!((b.party()[0].level, b.party()[0].exp), (1, 0));
}
fn bat() -> Combatant {
combatant("Bat", 200, 6, 4, 12, 0, vec![skill("Bite", 20, 100, SkillCategory::Damage, 0)])
}
fn encounter_battle(bytes: &[u8]) -> Battle {
let aria = combatant("Aria", 60, 12, 10, 15, 20, vec![skill("Slash", 40, 100, SkillCategory::Damage, 0)]);
let slime = combatant("Slime", 40, 8, 8, 5, 0, vec![skill("Tackle", 40, 100, SkillCategory::Damage, 0)]);
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(bytes));
b.set_enemy_party(vec![bat()], false, 0);
b
}
fn run(b: &mut Battle) {
press(b, GbButton::Down.bit_mask());
press(b, GbButton::Down.bit_mask());
confirm(b);
}
#[test]
fn encounter_queue_sends_out_next_enemy_and_exp_sums() {
let mut b = encounter_battle(&[50, 100, 1]);
b.enemy.exp_reward = 8;
b.enemies[0].exp_reward = 5;
b.set_levels(Some(levels(100)));
assert_eq!(b.enemies_remaining(), 1);
fight(&mut b, 0);
settle(&mut b);
assert!(b.outcome().is_none());
assert!(b.in_menu(), "round over after the send-out");
assert_eq!(b.enemy().name, "Bat");
assert_eq!(b.enemy().hp, 200, "a fresh combatant at full HP");
assert_eq!(b.enemies_remaining(), 0);
let log = b.log();
let faint = log.iter().position(|l| l == "Slime fainted!").unwrap();
assert_eq!(log[faint + 1], "Foe sent out Bat!");
assert!(!log.iter().any(|l| l == "Bat used Bite!"), "{log:?}");
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"Bat used Bite!".to_string()), "{:?}", b.log());
fight(&mut b, 0);
settle(&mut b);
assert_eq!(b.outcome(), Some(BattleOutcome::Win));
let log = b.log();
let win = log.iter().position(|l| l == "You won the battle!").unwrap();
assert_eq!(log[win - 1], "Bat fainted!");
assert_eq!(log[win + 1], "Aria gained 13 EXP!", "{log:?}");
assert_eq!(b.party()[0].exp, 5, "13 gained − 8 spent on the level-up");
assert_eq!(b.party()[0].level, 2);
}
#[test]
fn encounter_send_out_narrates_in_zh() {
let mut b = encounter_battle(&[50, 100, 1]);
b.set_lang("zh");
fight(&mut b, 0);
settle(&mut b);
assert!(b.log().contains(&"对方派出了 Bat!".to_string()), "{:?}", b.log());
}
#[test]
fn trainer_battle_blocks_run_without_consuming_the_turn() {
let (aria, slime) = aria_vs_slime();
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
b.set_enemy_party(Vec::new(), true, 80);
assert!(b.is_trainer());
run(&mut b);
settle(&mut b);
assert!(b.outcome().is_none(), "a blocked Run never ends the battle");
assert!(b.in_menu(), "back at the root menu — the turn was NOT consumed");
assert_eq!(b.log(), &["Can't escape from a trainer battle!".to_string()]);
assert_eq!(b.player().hp, 60, "the enemy never acted");
play_to_end(&mut b, 0);
assert_eq!(b.outcome(), Some(BattleOutcome::Win));
assert_eq!(b.trainer_money(), 80);
let log = b.log();
let win = log.iter().position(|l| l == "You won the battle!").unwrap();
assert_eq!(log[win + 1], "Got 80 G for winning!");
}
#[test]
fn wild_run_ends_the_battle_with_the_run_outcome() {
let (aria, slime) = aria_vs_slime();
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
b.enemy.exp_reward = 8;
b.set_levels(Some(levels(100)));
assert!(!b.is_trainer(), "a plain battle is wild");
run(&mut b);
assert_eq!(b.current_line(), Some("Got away safely!"));
settle(&mut b);
assert_eq!(b.outcome(), Some(BattleOutcome::Run));
assert_eq!(b.log(), &["Got away safely!".to_string()], "no EXP/money, no faint lines");
assert_eq!(b.party()[0].exp, 0, "a run awards no EXP");
assert_eq!(b.party()[0].hp, 60, "the party state carries over untouched");
}
#[test]
fn run_lines_narrate_in_zh() {
let (aria, slime) = aria_vs_slime();
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
b.set_lang("zh");
run(&mut b);
settle(&mut b);
assert_eq!(b.outcome(), Some(BattleOutcome::Run));
assert_eq!(b.log(), &["顺利逃走了!".to_string()]);
let (aria, slime) = aria_vs_slime();
let mut b = Battle::new(aria, slime, TypeChart::default(), scripted(&[50, 100, 1]));
b.set_enemy_party(Vec::new(), true, 0);
b.set_lang("zh");
run(&mut b);
settle(&mut b);
assert_eq!(b.log(), &["无法从训练家的战斗中逃走!".to_string()]);
}