use std::cell::RefCell;
use dotzuki_engine::battle::rng::{BattleRng, ScriptedRng};
use dotzuki_engine::battle::stack::{
collect_handlers, run_event, BattleCtx, Effect, EffectId, EffectProvider, EffectState, Event,
MoveContext, RelayVar,
};
use dotzuki_engine::battle::{
BattleProvider, BattleState, BattlerRef, BattlerState, DamageResult, EffectResult, EnumMap,
MoveEffect,
};
use crate::bindings::RuleBindings;
use crate::model::{LoadError, Ruleset};
use crate::registry::{CompiledRuleset, RulesHost, RulesProvider};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Stat {
Hp,
Atk,
SpD,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Status {
Burn,
Poison,
Sleep,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MType {
Normal,
Rock,
Fire,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Species {
mtype: MType,
level: u16,
}
#[derive(Debug, Clone, PartialEq)]
struct Move {
power: u8,
}
#[derive(Clone, Debug, PartialEq)]
#[allow(dead_code)] enum Kind {
None,
Substitute,
}
struct TestProvider;
impl BattleProvider for TestProvider {
type Monster = ();
type Move = Move;
type Ability = ();
type Status = Status;
type Stat = Stat;
type Species = Species;
type Type = MType;
type Item = ();
fn calculate_damage(
&self,
m: &Self::Move,
_a: &BattlerState<Self>,
_d: &BattlerState<Self>,
_r: u8,
_c: bool,
) -> DamageResult {
DamageResult {
damage: m.power as u16,
effectiveness: 1.0,
is_miss: false,
}
}
fn select_move(&self, b: &BattlerState<Self>, _s: &BattleState<Self>) -> Self::Move {
b.moves.first().cloned().unwrap_or(Move { power: 0 })
}
fn apply_move_effect(
&self,
_e: MoveEffect,
_u: &mut BattlerState<Self>,
_t: &mut BattlerState<Self>,
) -> EffectResult {
EffectResult::NoEffect
}
fn create_monster(&self, s: Self::Species, _l: u8) -> BattlerState<Self> {
BattlerState::new(s, 100, 100, EnumMap::new(), vec![])
}
}
impl EffectProvider for TestProvider {
type EffectStateKind = Kind;
fn effect_for_move(&self, _m: &Self::Move) -> Option<&'static Effect<Self>> {
None
}
fn effect_for_status(&self, _s: &Self::Status) -> Option<&'static Effect<Self>> {
None
}
fn turn_order_rank(
&self,
_s: &BattleState<Self>,
_w: BattlerRef,
_a: &Self::Move,
) -> (i32, i32) {
(0, 0)
}
}
struct TestBindings;
impl RuleBindings<TestProvider> for TestBindings {
fn apply_boost(
&self,
b: &mut BattlerState<TestProvider>,
stat_index: usize,
stages: i8,
) -> bool {
let stat = match stat_index {
0 => Stat::Hp,
1 => Stat::Atk,
2 => Stat::SpD,
_ => return false,
};
let cur = b.stat_stages.get(stat).copied().unwrap_or(0);
b.stat_stages.set(stat, cur + stages);
true
}
fn set_status(&self, b: &mut BattlerState<TestProvider>, status_index: usize) -> bool {
let s = match status_index {
0 => Status::Burn,
1 => Status::Poison,
2 => Status::Sleep,
_ => return false,
};
b.status = Some(s);
true
}
fn has_type(&self, b: &BattlerState<TestProvider>, type_index: usize) -> bool {
b.species.mtype.chart_index() == type_index
}
fn has_volatile(&self, ctx: &BattleCtx<'_, TestProvider>, who: BattlerRef, name: &str) -> bool {
name == "Substitute"
&& ctx
.effects
.iter()
.any(|e| e.host == who && e.kind == Kind::Substitute)
}
fn has_status(&self, b: &BattlerState<TestProvider>, status_index: usize) -> bool {
let want = match status_index {
0 => Status::Burn,
1 => Status::Poison,
2 => Status::Sleep,
_ => return false,
};
b.status == Some(want)
}
fn battler_level(&self, b: &BattlerState<TestProvider>) -> u16 {
b.species.level
}
fn type_chart_mult(
&self,
ctx: &BattleCtx<'_, TestProvider>,
move_type_index: usize,
defender: BattlerRef,
) -> (u32, u32) {
let def = ctx.battler(defender).species.mtype.chart_index();
match (move_type_index, def) {
(2, 1) => (1, 2), (2, 2) => (2, 1), _ => (1, 1),
}
}
}
impl MType {
fn chart_index(self) -> usize {
match self {
MType::Normal => 0,
MType::Rock => 1,
MType::Fire => 2,
}
}
}
thread_local! {
static HOST: RefCell<Option<&'static RulesHost<TestProvider>>> = const { RefCell::new(None) };
}
fn install_host(host: RulesHost<TestProvider>) {
let leaked: &'static RulesHost<TestProvider> = Box::leak(Box::new(host));
HOST.with(|h| *h.borrow_mut() = Some(leaked));
}
impl RulesProvider for TestProvider {
type Bindings = TestBindings;
fn compiled(&self) -> &CompiledRuleset {
&Self::rules_host().expect("host installed").compiled
}
fn bindings(&self) -> &Self::Bindings {
&Self::rules_host().expect("host installed").bindings
}
fn rules_host() -> Option<&'static RulesHost<TestProvider>> {
HOST.with(|h| *h.borrow())
}
}
fn status_index_of(name: &str) -> Option<usize> {
match name {
"burn" => Some(0),
"poison" => Some(1),
"sleep" => Some(2),
_ => None,
}
}
fn compile_and_install(ron: &str) -> Result<(), LoadError> {
let ruleset = Ruleset::from_ron(ron)?;
let compiled = CompiledRuleset::compile::<TestProvider, TestBindings>(
&ruleset,
0x1000,
&TestBindings,
status_index_of,
)?;
install_host(RulesHost::new(compiled, TestBindings));
Ok(())
}
struct Harness {
state: BattleState<TestProvider>,
effects: Vec<EffectState<TestProvider>>,
mv: MoveContext,
}
impl Harness {
fn new(player_type: MType, opp_type: MType) -> Self {
let p = BattlerState::new(
Species {
mtype: player_type,
level: 50,
},
100,
100,
EnumMap::new(),
vec![],
);
let o = BattlerState::new(
Species {
mtype: opp_type,
level: 50,
},
100,
100,
EnumMap::new(),
vec![],
);
Self {
state: BattleState::new(vec![p], vec![o]),
effects: Vec::new(),
mv: MoveContext::default(),
}
}
fn fire(&mut self, ev: Event, relay: RelayVar, rng: &mut dyn BattleRng) -> RelayVar {
let provider = TestProvider;
let data_effects: Vec<&'static Effect<TestProvider>> = TestProvider::rules_host()
.unwrap()
.compiled
.build_effects::<TestProvider>();
let mut ctx = BattleCtx {
state: &mut self.state,
effects: &mut self.effects,
mv: &mut self.mv,
rng,
};
let mut hs = Vec::new();
for eff in &data_effects {
collect_handlers(
&ctx,
&provider,
Some(eff),
ev,
BattlerRef::OPPONENT,
BattlerRef::PLAYER,
&mut hs,
);
}
run_event(&mut ctx, hs, relay, false)
}
fn set_last_damage(&mut self, dmg: u16) {
self.mv.last_damage = dmg;
}
fn give_substitute(&mut self, who: BattlerRef) {
self.effects.push(EffectState {
id: EffectId(900 + self.effects.len() as u32),
host: who,
effect_order: self.effects.len() as u64,
kind: Kind::Substitute,
});
self.effects.sort_by(|a, b| a.id.cmp(&b.id));
}
fn opp_hp(&self) -> u16 {
self.state.opponent_battlers[0].hp
}
fn player_hp(&self) -> u16 {
self.state.player_battlers[0].hp
}
fn opp_status(&self) -> Option<Status> {
self.state.opponent_battlers[0].status
}
fn opp_atk_stage(&self) -> i8 {
self.state.opponent_battlers[0]
.stat_stages
.get(Stat::Atk)
.copied()
.unwrap_or(0)
}
fn set_levels(&mut self, player: u16, opp: u16) {
self.state.player_battlers[0].species.level = player;
self.state.opponent_battlers[0].species.level = opp;
}
fn set_opp_hp(&mut self, hp: u16) {
self.state.opponent_battlers[0].hp = hp;
}
fn mv_damage(&self) -> u16 {
self.mv.damage
}
}
#[test]
fn scale_relay_doubles_damage() {
let ron = r#"Ruleset(
types: ["Normal"],
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"ModifyDamage", do:[ ScaleRelay(num:2, den:1) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
let out = h.fire(Event::ModifyDamage, RelayVar::Damage(50), &mut rng);
assert_eq!(out, RelayVar::Damage(100));
assert_eq!(rng.consumed(), 0);
}
#[test]
fn veto_if_relay_int_lt_fails() {
let ron = r#"Ruleset(
effects: [ Effect(id:"clearbody", kind:Ability, hooks:[
Hook(on:"TryBoost", order:5, do:[ VetoIf(cond:RelayIntLt(0)) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
let out = h.fire(Event::TryBoost, RelayVar::Int(-1), &mut rng);
assert_eq!(out, RelayVar::Bool(false));
let out2 = h.fire(Event::TryBoost, RelayVar::Int(1), &mut rng);
assert_eq!(out2, RelayVar::Int(1));
}
#[test]
fn damage_fraction_reduces_hp() {
let ron = r#"Ruleset(
effects: [ Effect(id:"psn", kind:Status, hooks:[
Hook(on:"Residual", order:10, do:[
DamageFraction(num:1, den:8, of:MaxHp, target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::Residual, RelayVar::Unit, &mut rng);
assert_eq!(h.opp_hp(), 88);
}
#[test]
fn damage_fraction_unless_predicate_skips() {
let ron = r#"Ruleset(
types: ["Normal","Rock","Fire"],
effects: [ Effect(id:"sand", kind:Weather, hooks:[
Hook(on:"FieldResidual", order:50, do:[
DamageFraction(num:1, den:16, of:MaxHp, target:Target, unless:HasType("Rock")) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut rock = Harness::new(MType::Normal, MType::Rock);
let mut rng = ScriptedRng::new(vec![]);
rock.fire(Event::FieldResidual, RelayVar::Unit, &mut rng);
assert_eq!(rock.opp_hp(), 100);
let mut normal = Harness::new(MType::Normal, MType::Normal);
normal.fire(Event::FieldResidual, RelayVar::Unit, &mut rng);
assert_eq!(normal.opp_hp(), 94);
}
#[test]
fn heal_fraction_restores_hp() {
let ron = r#"Ruleset(
effects: [ Effect(id:"left", kind:Item, hooks:[
Hook(on:"Residual", order:20, do:[
HealFraction(num:1, den:16, of:MaxHp, target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.state.opponent_battlers[0].hp = 50;
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::Residual, RelayVar::Unit, &mut rng);
assert_eq!(h.opp_hp(), 56);
}
#[test]
fn inflict_status_sets_status() {
let ron = r#"Ruleset(
effects: [ Effect(id:"ember", kind:Move, hooks:[
Hook(on:"DamagingHit", order:10, do:[
InflictStatus(status:"burn", target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
assert_eq!(h.opp_status(), None);
h.fire(Event::DamagingHit, RelayVar::Damage(40), &mut rng);
assert_eq!(h.opp_status(), Some(Status::Burn));
}
#[test]
fn rng_range_rejection_samples_skewed_tail() {
let ron = r#"Ruleset(
effects: [ Effect(id:"hypnosis", kind:Move, hooks:[
Hook(on:"DamagingHit", order:10, do:[
InflictStatus(status:"sleep", target:Target, amount: RngRange(lo:1, hi:7)) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![255u8, 3]);
h.fire(Event::DamagingHit, RelayVar::Damage(0), &mut rng);
assert_eq!(rng.consumed(), 2, "255 is in the skewed tail → re-drawn");
assert_eq!(h.opp_status(), Some(Status::Sleep));
let mut h2 = Harness::new(MType::Normal, MType::Normal);
let mut rng2 = ScriptedRng::new(vec![100u8, 200]);
h2.fire(Event::DamagingHit, RelayVar::Damage(0), &mut rng2);
assert_eq!(rng2.consumed(), 1, "in-range byte → single draw");
assert_eq!(h2.opp_status(), Some(Status::Sleep));
}
#[test]
fn boost_applies_stage_delta() {
let ron = r#"Ruleset(
stats: ["Hp","Atk","SpD"],
effects: [ Effect(id:"intimidate", kind:Ability, hooks:[
Hook(on:"SwitchIn", order:10, do:[
Boost(stat:"Atk", stages:-1, target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::SwitchIn, RelayVar::Unit, &mut rng);
assert_eq!(h.opp_atk_stage(), -1);
}
#[test]
fn chance_gate_consumes_exactly_one_draw() {
let ron = r#"Ruleset(
effects: [ Effect(id:"ember", kind:Move, hooks:[
Hook(on:"DamagingHit", order:10, chance:[30,100], do:[
InflictStatus(status:"burn", target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![0u8]);
h.fire(Event::DamagingHit, RelayVar::Damage(40), &mut rng);
assert_eq!(rng.consumed(), 1, "exactly one draw on PASS");
assert_eq!(h.opp_status(), Some(Status::Burn));
let mut h2 = Harness::new(MType::Normal, MType::Normal);
let mut rng2 = ScriptedRng::new(vec![99u8]);
h2.fire(Event::DamagingHit, RelayVar::Damage(40), &mut rng2);
assert_eq!(
rng2.consumed(),
1,
"exactly one draw on FAIL too (drawn unconditionally)"
);
assert_eq!(h2.opp_status(), None, "gate failed ⇒ status NOT set");
}
#[test]
fn numeric_relay_ops() {
let ron = r#"Ruleset(
effects: [
Effect(id:"set", kind:Move, hooks:[ Hook(on:"ModifyStat", do:[ SetRelay(7) ]) ]),
Effect(id:"add", kind:Move, hooks:[ Hook(on:"Accuracy", do:[ AddRelay(5) ]) ]),
Effect(id:"clamp", kind:Move, hooks:[ Hook(on:"ModifyCritRatio", do:[ ClampRelay(lo:0, hi:10) ]) ]),
],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
assert_eq!(
h.fire(Event::ModifyStat, RelayVar::Int(1), &mut rng),
RelayVar::Int(7)
);
assert_eq!(
h.fire(Event::Accuracy, RelayVar::Int(10), &mut rng),
RelayVar::Int(15)
);
assert_eq!(
h.fire(Event::ModifyCritRatio, RelayVar::Int(99), &mut rng),
RelayVar::Int(10)
);
}
#[test]
fn apply_type_chart_folds_multiplier() {
let ron = r#"Ruleset(
types: ["Normal","Rock","Fire"],
effects: [ Effect(id:"ember", kind:Move, type:"Fire", hooks:[
Hook(on:"Effectiveness", order:100, do:[ ApplyTypeChart ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut resisted = Harness::new(MType::Normal, MType::Rock);
let mut rng = ScriptedRng::new(vec![]);
let out = resisted.fire(Event::Effectiveness, RelayVar::Damage(80), &mut rng);
assert_eq!(out, RelayVar::Damage(40));
let mut se = Harness::new(MType::Normal, MType::Fire);
let out2 = se.fire(Event::Effectiveness, RelayVar::Damage(80), &mut rng);
assert_eq!(out2, RelayVar::Damage(160));
let mut neutral = Harness::new(MType::Normal, MType::Normal);
let out3 = neutral.fire(Event::Effectiveness, RelayVar::Damage(80), &mut rng);
assert_eq!(out3, RelayVar::Damage(80));
}
#[test]
fn scale_relay_when_has_type() {
let ron = r#"Ruleset(
types: ["Normal","Rock","Fire"],
effects: [ Effect(id:"sand", kind:Weather, hooks:[
Hook(on:"WeatherModifyStat", order:50, do:[
ScaleRelay(num:3, den:2, when:[ HasType("Rock") ]) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut rock = Harness::new(MType::Normal, MType::Rock);
let mut rng = ScriptedRng::new(vec![]);
let out = rock.fire(Event::WeatherModifyStat, RelayVar::Int(100), &mut rng);
assert_eq!(out, RelayVar::Int(150));
let mut normal = Harness::new(MType::Normal, MType::Normal);
let out2 = normal.fire(Event::WeatherModifyStat, RelayVar::Int(100), &mut rng);
assert_eq!(out2, RelayVar::Int(100));
}
#[test]
fn unknown_event_fails_at_load() {
let ron = r#"Ruleset(
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"NotAnEvent", do:[ DealMoveDamage ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::UnknownEvent(_)), "got {err:?}");
}
#[test]
fn unknown_op_fails_at_load() {
let ron = r#"Ruleset(
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"ModifyDamage", do:[ Frobnicate ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::Ron(_)), "got {err:?}");
}
#[test]
fn unknown_status_fails_at_load() {
let ron = r#"Ruleset(
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ InflictStatus(status:"sparkle", target:Target) ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::UnknownStatus(_)), "got {err:?}");
}
#[test]
fn unknown_type_fails_at_load() {
let ron = r#"Ruleset(
types: ["Normal"],
effects: [ Effect(id:"x", kind:Weather, hooks:[
Hook(on:"FieldResidual", do:[
DamageFraction(num:1, den:16, of:MaxHp, target:Target, unless:HasType("Lava")) ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::UnknownType(_)), "got {err:?}");
}
#[test]
fn unknown_stat_fails_at_load() {
let ron = r#"Ruleset(
stats: ["Hp","Atk"],
effects: [ Effect(id:"x", kind:Ability, hooks:[
Hook(on:"SwitchIn", do:[ Boost(stat:"Sass", stages:-1, target:Foe) ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::UnknownStat(_)), "got {err:?}");
}
#[test]
fn bad_chance_fails_at_load() {
let ron = r#"Ruleset(
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"DamagingHit", chance:[1,0], do:[ DealMoveDamage ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::BadChance(1, 0)), "got {err:?}");
}
#[test]
fn custom_event_parses() {
let ron = r#"Ruleset(
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"Custom(7)", do:[ SetRelay(1) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let host = TestProvider::rules_host().unwrap();
assert!(host
.compiled
.hooks
.values()
.any(|h| h.event == Event::Custom(7)));
}
#[test]
fn scripted_rng_replays_identically() {
let ron = r#"Ruleset(
effects: [
Effect(id:"a", kind:Move, hooks:[
Hook(on:"DamagingHit", order:10, chance:[50,100], do:[
InflictStatus(status:"burn", target:Target) ]) ]),
Effect(id:"b", kind:Move, hooks:[
Hook(on:"DamagingHit", order:20, chance:[50,100], do:[
InflictStatus(status:"poison", target:Source) ]) ]),
],
)"#;
compile_and_install(ron).unwrap();
let script = vec![10u8, 200u8];
let mut h1 = Harness::new(MType::Normal, MType::Normal);
let mut rng1 = ScriptedRng::new(script.clone());
h1.fire(Event::DamagingHit, RelayVar::Damage(40), &mut rng1);
let draws1 = rng1.consumed();
let burn1 = h1.opp_status();
let psn1 = h1.state.player_battlers[0].status;
let mut h2 = Harness::new(MType::Normal, MType::Normal);
let mut rng2 = ScriptedRng::new(script.clone());
h2.fire(Event::DamagingHit, RelayVar::Damage(40), &mut rng2);
let draws2 = rng2.consumed();
let burn2 = h2.opp_status();
let psn2 = h2.state.player_battlers[0].status;
assert_eq!(draws1, 2, "two chance gates ⇒ exactly two draws");
assert_eq!(draws1, draws2, "draw count identical across replays");
assert_eq!(burn1, burn2);
assert_eq!(psn1, psn2);
assert_eq!(burn1, Some(Status::Burn));
assert_eq!(psn1, Some(Status::Poison));
}
#[test]
fn trace_records_op_steps() {
let ron = r#"Ruleset(
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"ModifyDamage", do:[ ScaleRelay(num:2, den:1) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
crate::enable_trace();
h.fire(Event::ModifyDamage, RelayVar::Damage(50), &mut rng);
let trace = crate::take_trace().expect("trace enabled");
assert_eq!(trace.events.len(), 1);
let e = &trace.events[0];
assert_eq!(e.event, Event::ModifyDamage);
assert_eq!(e.before, RelayVar::Damage(50));
assert_eq!(e.after, RelayVar::Damage(100));
}
#[test]
fn interpreter_keys_off_source_effect() {
let ron = r#"Ruleset(
effects: [
Effect(id:"chip", kind:Status, hooks:[
Hook(on:"Residual", order:10, do:[
DamageFraction(num:1, den:8, of:MaxHp, target:Target) ]) ]),
Effect(id:"heal", kind:Item, hooks:[
Hook(on:"Residual", order:20, do:[
HealFraction(num:1, den:16, of:MaxHp, target:Target) ]) ]),
],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::Residual, RelayVar::Unit, &mut rng);
assert_eq!(
h.opp_hp(),
94,
"cross-source residual ordering via source_effect keys"
);
}
#[test]
fn canonical_rules_ron_loads_and_compiles() {
let ron = include_str!("../rules.ron");
let ruleset = Ruleset::from_ron(ron).expect("rules.ron parses");
assert_eq!(ruleset.effects.len(), 7);
assert_eq!(ruleset.type_chart.len(), 11);
let compiled = CompiledRuleset::compile::<TestProvider, TestBindings>(
&ruleset,
0x2000,
&TestBindings,
status_index_of,
)
.expect("rules.ron compiles — every name binds to the closed vocabulary");
assert!(!compiled.hooks.is_empty());
let ids: std::collections::HashSet<_> = compiled.hooks.keys().collect();
assert_eq!(
ids.len(),
compiled.hooks.len(),
"EffectIds are distinct per hook"
);
}
#[test]
fn compiled_chart_mult_interns_and_defaults_neutral() {
let ron = r#"Ruleset(
types: ["Metal","Wood","Fire"],
type_chart: [
( atk: "Metal", def: "Wood", mult: [2, 1] ), // super-effective
( atk: "Wood", def: "Metal", mult: [1, 2] ), // resisted
( atk: "Fire", def: "Wood", mult: [0, 1] ), // immune
],
effects: [],
)"#;
let ruleset = Ruleset::from_ron(ron).unwrap();
let compiled = CompiledRuleset::compile::<TestProvider, TestBindings>(
&ruleset,
0x3000,
&TestBindings,
status_index_of,
)
.unwrap();
assert_eq!(
compiled.chart_mult(0, 1),
(2, 1),
"Metal→Wood super-effective"
);
assert_eq!(compiled.chart_mult(1, 0), (1, 2), "Wood→Metal resisted");
assert_eq!(compiled.chart_mult(2, 1), (0, 1), "Fire→Wood immune");
assert_eq!(
compiled.chart_mult(1, 2),
(1, 1),
"omitted Wood→Fire defaults neutral"
);
assert_eq!(
compiled.chart_mult(0, 2),
(1, 1),
"omitted Metal→Fire defaults neutral"
);
}
#[test]
fn unknown_chart_type_fails_at_load() {
let ron = r#"Ruleset(
types: ["Metal"],
type_chart: [ ( atk: "Metal", def: "Plasma", mult: [2, 1] ) ],
effects: [],
)"#;
let ruleset = Ruleset::from_ron(ron).unwrap();
let err = CompiledRuleset::compile::<TestProvider, TestBindings>(
&ruleset,
0x4000,
&TestBindings,
status_index_of,
)
.unwrap_err();
assert!(matches!(err, LoadError::UnknownType(_)), "got {err:?}");
}
#[test]
fn move_cost_field_interns_resource_index_and_amount() {
let ron = r#"Ruleset(
resources: ["MP"],
effects: [ Effect(id:"move.fireball", kind:Move, cost:[ Cost(resource:"MP", amount:4) ],
hooks:[ Hook(on:"ModifyDamage", do:[ DealMoveDamage ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let host = TestProvider::rules_host().unwrap();
assert_eq!(
host.compiled.move_cost("move.fireball"),
&[(0usize, 4u16)],
"MP interned to resource index 0, amount 4"
);
assert_eq!(
host.compiled.move_cost("move.nonexistent"),
&[],
"a move with no cost: returns an empty cost slice (inert)"
);
}
#[test]
fn unknown_resource_in_cost_fails_at_load() {
let ron = r#"Ruleset(
resources: ["MP"],
effects: [ Effect(id:"x", kind:Move, cost:[ Cost(resource:"Stamina", amount:2) ],
hooks:[ Hook(on:"ModifyDamage", do:[ DealMoveDamage ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::UnknownResource(_)), "got {err:?}");
}
#[test]
fn unknown_resource_in_pay_resource_op_fails_at_load() {
let ron = r#"Ruleset(
resources: ["MP"],
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"BeforeMove", do:[ PayResource(resource:"Mana", amount:1, target:Source) ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::UnknownResource(_)), "got {err:?}");
}
#[test]
fn pay_resource_op_deducts_when_affordable_and_consumes_no_rng() {
let ron = r#"Ruleset(
resources: ["MP"],
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"BeforeMove", do:[ PayResource(resource:"MP", amount:4, target:Source) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.state.player_battlers[0].resources.set(0, 10, 10); let mut rng = ScriptedRng::new(vec![]);
let out = h.fire(Event::BeforeMove, RelayVar::Bool(true), &mut rng);
assert_eq!(
out,
RelayVar::Bool(true),
"affordable ⇒ relay passes (move allowed)"
);
assert_eq!(
h.state.player_battlers[0].resources.current(0),
Some(6),
"10 MP - 4 cost = 6 left"
);
assert_eq!(
rng.consumed(),
0,
"the PayResource op consumes NO randomness"
);
}
#[test]
fn pay_resource_op_vetoes_when_unaffordable_and_leaves_mp_unchanged() {
let ron = r#"Ruleset(
resources: ["MP"],
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"BeforeMove", do:[ PayResource(resource:"MP", amount:4, target:Source) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.state.player_battlers[0].resources.set(0, 3, 10);
let mut rng = ScriptedRng::new(vec![]);
let out = h.fire(Event::BeforeMove, RelayVar::Bool(true), &mut rng);
assert_eq!(
out,
RelayVar::Bool(false),
"unaffordable ⇒ Fail ⇒ move prevented"
);
assert_eq!(
h.state.player_battlers[0].resources.current(0),
Some(3),
"prevented move deducts NOTHING ⇒ MP unchanged at 3"
);
assert_eq!(rng.consumed(), 0, "the veto path consumes NO randomness");
}
#[test]
fn heal_fraction_of_last_damage_drains_half() {
let ron = r#"Ruleset(
effects: [ Effect(id:"absorb", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ HealFraction(num:1, den:2, of:LastDamage, target:Source) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.state.player_battlers[0].hp = 50; h.set_last_damage(60); let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::DamagingHit, RelayVar::Damage(60), &mut rng);
assert_eq!(h.player_hp(), 80, "drain heals half the dealt damage");
assert_eq!(rng.consumed(), 0, "LastDamage is a pure ctx read, no rng");
}
#[test]
fn heal_fraction_of_last_damage_floors_at_one() {
let ron = r#"Ruleset(
effects: [ Effect(id:"absorb", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ HealFraction(num:1, den:2, of:LastDamage, target:Source) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.state.player_battlers[0].hp = 50;
h.set_last_damage(1);
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::DamagingHit, RelayVar::Damage(1), &mut rng);
assert_eq!(h.player_hp(), 51, "1 dmg drains the floored 1");
let mut h0 = Harness::new(MType::Normal, MType::Normal);
h0.state.player_battlers[0].hp = 50;
h0.set_last_damage(0);
let mut rng0 = ScriptedRng::new(vec![]);
h0.fire(Event::DamagingHit, RelayVar::Damage(0), &mut rng0);
assert_eq!(h0.player_hp(), 50, "0 dmg drains nothing");
}
#[test]
fn damage_fraction_of_last_damage_recoils_quarter() {
let ron = r#"Ruleset(
effects: [ Effect(id:"recoil", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ DamageFraction(num:1, den:4, of:LastDamage, target:Source) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.set_last_damage(100); let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::DamagingHit, RelayVar::Damage(100), &mut rng);
assert_eq!(
h.player_hp(),
75,
"recoil hits the mover for a quarter the dealt"
);
}
#[test]
fn has_volatile_substitute_vetoes_status() {
let ron = r#"Ruleset(
effects: [ Effect(id:"psnpwd", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[
VetoIf(cond:HasVolatile("Substitute")),
InflictStatus(status:"poison", target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut blocked = Harness::new(MType::Normal, MType::Normal);
blocked.give_substitute(BattlerRef::OPPONENT);
let mut rng = ScriptedRng::new(vec![]);
blocked.fire(Event::DamagingHit, RelayVar::Damage(20), &mut rng);
assert_eq!(blocked.opp_status(), None, "Substitute blocks the status");
let mut open = Harness::new(MType::Normal, MType::Normal);
open.fire(Event::DamagingHit, RelayVar::Damage(20), &mut rng);
assert_eq!(
open.opp_status(),
Some(Status::Poison),
"no Substitute ⇒ poison sticks"
);
}
#[test]
fn move_type_is_defender_type_vetoes_same_type() {
let ron = r#"Ruleset(
types: ["Normal","Rock","Fire"],
effects: [ Effect(id:"ember", kind:Move, type:"Fire", hooks:[
Hook(on:"DamagingHit", do:[
VetoIf(cond:MoveTypeIsDefenderType),
InflictStatus(status:"burn", target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut immune = Harness::new(MType::Normal, MType::Fire);
let mut rng = ScriptedRng::new(vec![]);
immune.fire(Event::DamagingHit, RelayVar::Damage(40), &mut rng);
assert_eq!(
immune.opp_status(),
None,
"Fire can't be burned by a Fire move (#23)"
);
let mut burnt = Harness::new(MType::Normal, MType::Rock);
burnt.fire(Event::DamagingHit, RelayVar::Damage(40), &mut rng);
assert_eq!(
burnt.opp_status(),
Some(Status::Burn),
"non-Fire ⇒ burn sticks"
);
}
#[test]
fn target_has_status_reads_target_status() {
let ron = r#"Ruleset(
effects: [ Effect(id:"gate", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[
VetoIf(cond:TargetHasStatus("sleep")),
DamageFraction(num:1, den:2, of:CurHp, target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut awake = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
awake.fire(Event::DamagingHit, RelayVar::Damage(0), &mut rng);
assert_eq!(awake.opp_hp(), 50, "awake ⇒ predicate false ⇒ op runs");
let mut asleep = Harness::new(MType::Normal, MType::Normal);
asleep.state.opponent_battlers[0].status = Some(Status::Sleep);
asleep.fire(Event::DamagingHit, RelayVar::Damage(0), &mut rng);
assert_eq!(asleep.opp_hp(), 100, "asleep ⇒ TargetHasStatus true ⇒ veto");
}
#[test]
fn target_has_status_unknown_name_fails_at_load() {
let ron = r#"Ruleset(
effects: [ Effect(id:"x", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ VetoIf(cond:TargetHasStatus("dazzle")) ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::UnknownStatus(_)), "got {err:?}");
}
#[test]
fn set_hp_zeroes_target() {
let ron = r#"Ruleset(
effects: [ Effect(id:"ko", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ SetHp(target:Target, value:0) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::DamagingHit, RelayVar::Unit, &mut rng);
assert_eq!(h.opp_hp(), 0, "SetHp(0) zeroes the target");
}
#[test]
fn set_hp_when_levelge_gates_ohko() {
let ron = r#"Ruleset(
effects: [ Effect(id:"ohko", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ SetHp(target:Foe, value:0, when:[LevelGE]) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut equal = Harness::new(MType::Normal, MType::Normal);
equal.set_levels(50, 50);
let mut rng = ScriptedRng::new(vec![]);
equal.fire(Event::DamagingHit, RelayVar::Unit, &mut rng);
assert_eq!(
equal.player_hp(),
0,
"user level == foe level ⇒ OHKO connects (>=)"
);
let mut lower = Harness::new(MType::Normal, MType::Normal);
lower.set_levels(30, 50);
lower.fire(Event::DamagingHit, RelayVar::Unit, &mut rng);
assert_eq!(
lower.player_hp(),
100,
"user level < foe level ⇒ IMMUNE (bug #19)"
);
}
#[test]
fn set_damage_const_writes_fixed_damage() {
let ron = r#"Ruleset(
effects: [ Effect(id:"dragonrage", kind:Move, hooks:[
Hook(on:"ModifyDamage", do:[ SetDamage(value:Const(40)) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::ModifyDamage, RelayVar::Unit, &mut rng);
assert_eq!(h.mv_damage(), 40, "SetDamage(Const(40)) ⇒ mv.damage = 40");
assert_eq!(rng.consumed(), 0, "Const draws no rng byte");
}
#[test]
fn set_damage_user_level() {
let ron = r#"Ruleset(
effects: [ Effect(id:"seismic", kind:Move, hooks:[
Hook(on:"ModifyDamage", do:[ SetDamage(value:UserLevel, of:Source) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.set_levels(50, 99); let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::ModifyDamage, RelayVar::Unit, &mut rng);
assert_eq!(
h.mv_damage(),
50,
"SetDamage(UserLevel) ⇒ mv.damage = source level (50)"
);
}
#[test]
fn set_damage_rng_scaled_level_draws_one_byte() {
let ron = r#"Ruleset(
effects: [ Effect(id:"psywave", kind:Move, hooks:[
Hook(on:"ModifyDamage", do:[ SetDamage(value:RngScaledLevel(num:3, den:2), of:Source) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.set_levels(50, 50);
let mut rng = ScriptedRng::new(vec![255]);
h.fire(Event::ModifyDamage, RelayVar::Unit, &mut rng);
assert_eq!(h.mv_damage(), 74, "Psywave-shape: 255*3/2*50/256 = 74");
assert_eq!(rng.consumed(), 1, "RngScaledLevel draws EXACTLY one byte");
}
#[test]
fn damage_current_hp_fraction_halves_current_hp() {
let ron = r#"Ruleset(
effects: [ Effect(id:"superfang", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ DamageCurrentHpFraction(num:1, den:2, target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.set_opp_hp(80);
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::DamagingHit, RelayVar::Unit, &mut rng);
assert_eq!(h.opp_hp(), 40, "Super Fang: 80 cur → 40 dealt → 40 left");
assert_eq!(h.mv_damage(), 40, "Super Fang writes mv.damage = 40");
}
#[test]
fn damage_current_hp_fraction_floors_at_one() {
let ron = r#"Ruleset(
effects: [ Effect(id:"superfang", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ DamageCurrentHpFraction(num:1, den:2, target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.set_opp_hp(1);
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::DamagingHit, RelayVar::Unit, &mut rng);
assert_eq!(h.opp_hp(), 0, "1 cur → max(1) → faints");
assert_eq!(h.mv_damage(), 1, "floored damage is 1");
}
#[test]
fn self_hp_below_gates_scale_on_source_hp() {
let ron = r#"Ruleset(
effects: [ Effect(id:"berserk", kind:Ability, hooks:[
Hook(on:"ModifyDamage", do:[
ScaleRelay(num:3, den:2, when:[ SelfHpBelow(num:1, den:2) ]) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut full = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
let out = full.fire(Event::ModifyDamage, RelayVar::Damage(50), &mut rng);
assert_eq!(
out,
RelayVar::Damage(50),
"full HP ⇒ not below 1/2 ⇒ unchanged"
);
let mut low = Harness::new(MType::Normal, MType::Normal);
low.state.player_battlers[0].hp = 40;
let out2 = low.fire(Event::ModifyDamage, RelayVar::Damage(50), &mut rng);
assert_eq!(out2, RelayVar::Damage(75), "HP 40<50% ⇒ ×3/2 ⇒ 75");
assert_eq!(rng.consumed(), 0, "SelfHpBelow is a pure ctx read, no rng");
}
#[test]
fn source_has_status_vetoes_holders_own_move() {
let ron = r#"Ruleset(
effects: [ Effect(id:"stunned", kind:Status, hooks:[
Hook(on:"BeforeMove", do:[ VetoIf(cond:SourceHasStatus("sleep")) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut awake = Harness::new(MType::Normal, MType::Normal);
let mut rng = ScriptedRng::new(vec![]);
let out = awake.fire(Event::BeforeMove, RelayVar::Bool(true), &mut rng);
assert_eq!(out, RelayVar::Bool(true), "awake source ⇒ move proceeds");
let mut asleep = Harness::new(MType::Normal, MType::Normal);
asleep.state.player_battlers[0].status = Some(Status::Sleep);
let out2 = asleep.fire(Event::BeforeMove, RelayVar::Bool(true), &mut rng);
assert_eq!(
out2,
RelayVar::Bool(false),
"asleep source ⇒ VetoIf fires ⇒ Fail"
);
}
#[test]
fn source_has_status_unknown_name_fails_at_load() {
let ron = r#"Ruleset(
effects: [ Effect(id:"x", kind:Status, hooks:[
Hook(on:"BeforeMove", do:[ VetoIf(cond:SourceHasStatus("dazzle")) ]) ]) ],
)"#;
let err = compile_and_install(ron).unwrap_err();
assert!(matches!(err, LoadError::UnknownStatus(_)), "got {err:?}");
}
#[test]
fn remove_status_clears_target_status() {
let ron = r#"Ruleset(
effects: [ Effect(id:"cleanse", kind:Move, hooks:[
Hook(on:"DamagingHit", do:[ RemoveStatus(target:Target) ]) ]) ],
)"#;
compile_and_install(ron).unwrap();
let mut h = Harness::new(MType::Normal, MType::Normal);
h.state.opponent_battlers[0].status = Some(Status::Poison);
assert_eq!(h.opp_status(), Some(Status::Poison));
let mut rng = ScriptedRng::new(vec![]);
h.fire(Event::DamagingHit, RelayVar::Damage(10), &mut rng);
assert_eq!(h.opp_status(), None, "RemoveStatus cleared the poison");
assert_eq!(
rng.consumed(),
0,
"RemoveStatus is a pure field write, no rng"
);
}