Files
RustRaptor/mods/iron_coast_boss_5/validation/src/lib.rs
T

255 lines
9.5 KiB
Rust

//! Meaningful callback checks without original assets or desktop services.
#[cfg(test)]
mod tests {
use raptor_replica::modding::{Command, ScriptRuntime};
use serde_json::{Value, json};
const OWNER: &str = "iron_coast_boss_5";
const SCRIPT: &str = "scripts/ai.rhai";
const KEY: &str = "iron_coast_boss_5/scripts/ai.rhai";
fn runtime() -> ScriptRuntime {
let mut scripts = ScriptRuntime::new();
scripts
.add(OWNER, SCRIPT, include_str!("../../scripts/ai.rhai"))
.unwrap();
scripts
}
fn event(entity: u64, tick: u64) -> Value {
json!({"entity":entity,"tick":tick,"x":112,"y":24,"health":160,
"entities":[{"entity":1,"health":160},{"entity":2,"health":160}],
"objectives":{}})
}
fn invoke(scripts: &mut ScriptRuntime, hook: &str, event: &Value) -> Vec<Command> {
scripts.begin_tick();
scripts.invoke(OWNER, SCRIPT, hook, event, 1024).unwrap()
}
#[test]
fn warnings_precede_the_staggered_54_tick_attack_cycle() {
let mut scripts = runtime();
invoke(&mut scripts, "on_spawn", &event(1, 0));
let mut warnings = vec![];
let mut shots = vec![];
for tick in 1..=120 {
let commands = invoke(&mut scripts, "on_tick", &event(1, tick));
for command in commands {
match command {
Command::PlaySound { sound } if sound.ends_with(":warning") => {
warnings.push(tick)
}
Command::Projectile {
weapon,
vx,
vy,
hostile,
..
} => {
assert!(hostile);
assert_eq!(vy, 4);
shots.push((tick, weapon, vx));
}
Command::Complete | Command::Fail => {
panic!("boss must not decide mission outcome")
}
_ => {}
}
}
}
assert_eq!(warnings, [36, 90]);
assert_eq!(
shots.iter().map(|x| x.0).collect::<Vec<_>>(),
[48, 54, 60, 102, 108, 114]
);
assert_eq!(
shots.iter().map(|x| x.2).collect::<Vec<_>>(),
[-2, 0, 2, -2, 0, 2]
);
assert!(shots[1].1.ends_with(":mortar"));
assert_eq!(scripts.states[KEY]["units"]["1"]["shots"], 6);
}
#[test]
fn simultaneous_enemies_keep_independent_serializable_clocks_and_cleanup() {
let mut scripts = runtime();
invoke(&mut scripts, "on_spawn", &event(1, 0));
invoke(&mut scripts, "on_spawn", &event(2, 0));
for tick in 1..=70 {
invoke(&mut scripts, "on_tick", &event(1, tick));
if tick <= 20 {
invoke(&mut scripts, "on_tick", &event(2, tick));
}
}
let serialized = serde_json::to_vec(&scripts.states).unwrap();
let restored: std::collections::BTreeMap<String, Value> =
serde_json::from_slice(&serialized).unwrap();
assert_eq!(restored[KEY]["units"]["1"]["age"], 70);
assert_eq!(restored[KEY]["units"]["2"]["age"], 20);
let mut death = event(1, 71);
death["objectives"] = json!({"iron_coast_boss_5:defeated":2});
let commands = invoke(&mut scripts, "on_death", &death);
assert!(commands.iter().any(|command| matches!(command,
Command::Objective { key, value:3 } if key == "iron_coast_boss_5:defeated")));
assert!(scripts.states[KEY]["units"].get("1").is_none());
assert_eq!(scripts.states[KEY]["units"]["2"]["age"], 20);
assert_eq!(scripts.states[KEY]["destroyed"], 1);
// A new mission may reuse entity IDs; its attack age must begin at zero.
invoke(&mut scripts, "on_spawn", &event(2, 0));
assert_eq!(scripts.states[KEY]["units"]["2"]["age"], 0);
}
#[test]
fn removed_or_lethally_hit_enemies_emit_no_ghost_commands() {
let mut scripts = runtime();
invoke(&mut scripts, "on_spawn", &event(1, 0));
let before = scripts.states.clone();
let mut dead = event(1, 48);
dead["health"] = json!(0);
assert!(invoke(&mut scripts, "on_tick", &dead).is_empty());
dead["health"] = json!(160);
dead["entities"] = json!([]);
assert!(invoke(&mut scripts, "on_tick", &dead).is_empty());
assert!(invoke(&mut scripts, "on_spawn", &dead).is_empty());
assert_eq!(scripts.states, before);
}
#[test]
fn entrance_and_patrol_turns_preserve_the_y24_band() {
let mut scripts = runtime();
let mut e = event(1, 0);
e["y"] = json!(-24);
let commands = invoke(&mut scripts, "on_tick", &e);
assert!(matches!(
commands[0],
Command::Velocity {
entity: 1,
vx: 0,
vy: 2
}
));
e["y"] = json!(24);
e["x"] = json!(144);
let commands = invoke(&mut scripts, "on_tick", &e);
assert!(
commands
.iter()
.any(|c| matches!(c, Command::Velocity { vx: -1, vy: 0, .. }))
);
assert!(
commands
.iter()
.any(|c| matches!(c, Command::Move { y: 24, .. }))
);
e["x"] = json!(80);
let commands = invoke(&mut scripts, "on_tick", &e);
assert!(
commands
.iter()
.any(|c| matches!(c, Command::Velocity { vx: 1, vy: 0, .. }))
);
}
#[test]
fn real_simulation_keeps_the_boss_alive_until_damage_and_counts_death_once() {
use raptor_replica::{
content::{ContentPack, Registry},
modding::{self, ActiveMods},
persistence::Pilot,
simulation::{Input, Outcome, World},
};
let pack: ContentPack = serde_json::from_value(json!({
"weapons":[{"id":"base:test_gun","name":"Fixture gun","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}],
"items":[{"id":"base:forward_guns","name":"Fixture guns","price":0,"max_count":1,"weapon":"base:test_gun"},
{"id":"base:energy","name":"Shield","price":10000,"max_count":100}],
"missions":[{"id":"base:sector_1_wave_1","name":"Boss validation","duration":2,"auto_complete":false}]
})).unwrap();
let mut base = Registry::default();
base.apply(pack, "base").unwrap();
base.validate().unwrap();
let mods = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap();
let discovery = modding::discover(mods);
let mut active = ActiveMods::build(&base, &discovery, &[OWNER.into()], None).unwrap();
let pilot = Pilot::default();
let mut world =
World::new(&"base:sector_1_wave_1".into(), &pilot, &active.registry).unwrap();
world.player.x = 0;
world
.apply_commands(
&[Command::Spawn {
enemy: format!("{OWNER}:unit"),
x: 112,
y: -24,
}],
&active.registry,
)
.unwrap();
let mut warning_count = 0;
let mut shot_count = 0;
for _ in 0..600 {
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
warning_count += world
.mod_sounds
.iter()
.filter(|s| s.0.ends_with(":warning"))
.count();
shot_count += world
.mod_sounds
.iter()
.filter(|s| s.0.ends_with(":shot"))
.count();
}
assert_eq!(
world.enemies.len(),
1,
"timer and scrolling cannot retire the boss"
);
assert_eq!(world.outcome, Outcome::Playing);
assert_eq!(world.enemies[0].y, 24);
assert!((80..=144).contains(&world.enemies[0].x));
assert!(warning_count >= 10 && shot_count >= 30);
assert!(!world.objectives.contains_key("iron_coast_boss_5:defeated"));
let entity = world.enemies[0].entity;
world
.apply_commands(
&[Command::Damage {
entity,
amount: 160,
}],
&active.registry,
)
.unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert!(world.enemies.is_empty());
assert_eq!(world.objectives["iron_coast_boss_5:defeated"], 1);
assert_eq!(world.player.credits, pilot.credits + 1800);
assert!(world.mod_sounds.iter().any(|s| s.0.ends_with(":death")));
assert!(
active.scripts.states[KEY]["units"]
.get(entity.to_string())
.is_none()
);
for _ in 0..80 {
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert!(!world.mod_sounds.iter().any(|s| s.0.ends_with(":shot")));
}
assert_eq!(world.objectives["iron_coast_boss_5:defeated"], 1);
assert_eq!(
world.outcome,
Outcome::Playing,
"boss never completes a mission itself"
);
}
}