Files

354 lines
11 KiB
Rust

//! Package-local acceptance using the actual Rust simulation and Rhai runtime.
use raptor_replica::{
assets::Catalog,
content::{ContentId, Registry},
modding::{self, ActiveMods, Command},
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use std::path::PathBuf;
const OWNER: &str = "iron_coast_boss_4";
const UNIT: &str = "iron_coast_boss_4:unit";
const PROJECTILE: &str = "iron_coast_boss_4:projectile";
const MISSION: &str = "base:sector_1_wave_1";
const STATE: &str = "iron_coast_boss_4/scripts/ai.rhai";
fn active() -> ActiveMods {
let project = PathBuf::from(std::env::var("RAPTOR_PROJECT").unwrap());
let catalog = Catalog::open(project.join("data")).unwrap();
let base = Registry::base(&catalog).unwrap();
let discovery = modding::discover(&project.join("RustRaptor/mods"));
let mut active =
ActiveMods::build(&base, &discovery, &[OWNER.to_owned()], Some(&catalog)).unwrap();
// Make an isolated non-legacy arena while retaining the real original guns.
let mission = active.registry.missions.get_mut(&MISSION.into()).unwrap();
mission.legacy_map = None;
mission.spawns.clear();
mission.tiles.clear();
mission.layout.clear();
mission.columns = 0;
mission.script = None;
mission.auto_complete = false;
mission.duration = 10_000;
active
}
fn world(active: &ActiveMods) -> World {
let mut pilot = Pilot::default();
pilot.difficulty = 3;
pilot.inventory.insert("base:energy".into(), 100);
World::new(&MISSION.into(), &pilot, &active.registry).unwrap()
}
fn step(world: &mut World, active: &mut ActiveMods) {
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
}
#[test]
fn warning_precedes_three_shells_and_batteries_alternate() {
let mut active = active();
let mut world = world(&active);
let id = world
.spawn(&UNIT.into(), 112, 24, &active.registry)
.unwrap();
for tick in 0..12 {
step(&mut world, &mut active);
assert!(world.projectiles.iter().all(|p| p.weapon.0 != PROJECTILE));
let unit = world.enemies.iter().find(|e| e.entity == id).unwrap();
assert_eq!((unit.y, unit.vx, unit.vy), (24, 0, 0));
if tick == 0 {
assert!(
world
.mod_sounds
.iter()
.any(|id| id.0 == "iron_coast_boss_4:warning")
);
}
}
step(&mut world, &mut active);
let shells = world
.projectiles
.iter()
.filter(|p| p.weapon.0 == PROJECTILE)
.collect::<Vec<_>>();
assert_eq!(shells.len(), 3);
assert_eq!(
shells.iter().filter(|p| p.vx == 0).next().unwrap().x,
112 + 28
);
assert!(shells.iter().all(|p| p.hostile && p.vy == 4));
assert!(
world
.mod_sounds
.iter()
.any(|id| id.0 == "iron_coast_boss_4:shot")
);
for _ in 13..67 {
step(&mut world, &mut active);
}
let unit = world.enemies.iter().find(|e| e.entity == id).unwrap();
let latest = world
.projectiles
.iter()
.filter(|p| p.weapon.0 == PROJECTILE && p.age == 0)
.collect::<Vec<_>>();
assert_eq!(latest.len(), 3);
assert_eq!(
latest.iter().filter(|p| p.vx == 0).next().unwrap().x,
unit.x + 68
);
assert_eq!(active.scripts.states[STATE]["salvos"].as_i64(), Some(2));
assert_eq!(world.outcome, Outcome::Playing);
}
#[test]
fn two_bosses_have_independent_state_and_death_counts_once() {
let mut active = active();
let mut world = world(&active);
let a = world.spawn(&UNIT.into(), 80, 24, &active.registry).unwrap();
for _ in 0..20 {
step(&mut world, &mut active);
}
let b = world
.spawn(&UNIT.into(), 144, 24, &active.registry)
.unwrap();
step(&mut world, &mut active);
assert_eq!(
active.scripts.states[STATE]["units"][a.to_string()]["age"].as_i64(),
Some(21)
);
assert_eq!(
active.scripts.states[STATE]["units"][b.to_string()]["age"].as_i64(),
Some(1)
);
world
.apply_commands(
&[Command::Damage {
entity: a,
amount: 100_000,
}],
&active.registry,
)
.unwrap();
step(&mut world, &mut active);
assert_eq!(world.objectives["iron_coast_boss_4:defeated"], 1);
assert!(
active.scripts.states[STATE]["units"]
.get(a.to_string())
.is_none()
);
assert!(
world
.mod_sounds
.iter()
.any(|id| id.0 == "iron_coast_boss_4:death")
);
for _ in 0..10 {
step(&mut world, &mut active);
}
assert_eq!(world.objectives["iron_coast_boss_4:defeated"], 1);
assert!(world.enemies.iter().any(|e| e.entity == b && e.health > 0));
world
.apply_commands(
&[Command::Damage {
entity: b,
amount: 100_000,
}],
&active.registry,
)
.unwrap();
step(&mut world, &mut active);
assert_eq!(world.objectives["iron_coast_boss_4:defeated"], 2);
assert_eq!(world.outcome, Outcome::Playing);
assert!(
active.scripts.states[STATE]["units"]
.as_object()
.unwrap()
.is_empty()
);
}
#[test]
fn saved_script_state_and_rng_reproduce_identical_future() {
let mut a = active();
let mut first = world(&a);
first.spawn(&UNIT.into(), 112, -40, &a.registry).unwrap();
for _ in 0..90 {
step(&mut first, &mut a);
}
let mut second = first.clone();
let mut b = active();
b.scripts.restore(a.scripts.checkpoints());
for _ in 0..100 {
step(&mut first, &mut a);
step(&mut second, &mut b);
assert_eq!(first.digest(), second.digest());
assert_eq!(a.scripts.states, b.scripts.states);
assert_eq!(
format!("{:?}", a.scripts.rng),
format!("{:?}", b.scripts.rng)
);
}
}
#[test]
fn a_new_sortie_clears_reused_entity_death_guards() {
let mut active = active();
let mut first = world(&active);
let a = first
.spawn(&UNIT.into(), 112, 24, &active.registry)
.unwrap();
step(&mut first, &mut active);
first
.apply_commands(
&[Command::Damage {
entity: a,
amount: 100_000,
}],
&active.registry,
)
.unwrap();
step(&mut first, &mut active);
let mut second = world(&active);
let b = second
.spawn(&UNIT.into(), 112, 24, &active.registry)
.unwrap();
assert_eq!(a, b);
step(&mut second, &mut active);
second
.apply_commands(
&[Command::Damage {
entity: b,
amount: 100_000,
}],
&active.registry,
)
.unwrap();
step(&mut second, &mut active);
assert_eq!(second.objectives["iron_coast_boss_4:defeated"], 1);
}
#[test]
fn restored_dead_guard_does_not_hide_a_lethal_hit_on_the_reused_spawn_step() {
let mut prior = active();
let mut previous_world = world(&prior);
let old = previous_world
.spawn(&UNIT.into(), 112, 24, &prior.registry)
.unwrap();
step(&mut previous_world, &mut prior);
previous_world
.apply_commands(
&[Command::Damage {
entity: old,
amount: 100_000,
}],
&prior.registry,
)
.unwrap();
step(&mut previous_world, &mut prior);
assert_eq!(
prior.scripts.states[STATE]["dead"][old.to_string()].as_bool(),
Some(true)
);
let saved_script_checkpoint = prior.scripts.checkpoints();
let mut restored = active();
restored.scripts.restore(saved_script_checkpoint);
restored
.registry
.weapons
.get_mut(&PROJECTILE.into())
.unwrap()
.damage = 100_000;
let mut replay = world(&restored);
let reused = replay
.spawn(&UNIT.into(), 112, 24, &restored.registry)
.unwrap();
assert_eq!(old, reused);
// Preserve the actual queued spawn event to invoke duplicate death later.
let duplicate_event = replay
.events
.iter()
.find(|e| e.hook == "on_spawn")
.unwrap()
.event
.clone();
let credits = replay.player.credits;
replay
.apply_commands(
&[Command::Projectile {
weapon: PROJECTILE.into(),
x: 160,
y: 56,
vx: 0,
vy: 0,
hostile: false,
}],
&restored.registry,
)
.unwrap();
// Advance hits before the queued on_spawn callback. No dead entity may
// receive velocity, a warning or a projectile from that callback.
step(&mut replay, &mut restored);
assert!(
replay
.enemies
.iter()
.all(|e| e.entity != reused || e.health <= 0)
);
assert!(
restored.scripts.states[STATE]["units"]
.get(reused.to_string())
.is_none()
);
assert!(
replay
.mod_sounds
.iter()
.all(|id| id.0 != "iron_coast_boss_4:warning" && id.0 != "iron_coast_boss_4:shot")
);
assert!(replay.projectiles.iter().all(|p| !p.hostile));
step(&mut replay, &mut restored);
assert_eq!(replay.objectives["iron_coast_boss_4:defeated"], 1);
assert_eq!(replay.player.credits, credits + 1_800);
assert!(replay.enemies.iter().all(|e| e.entity != reused));
let state = restored.scripts.states[STATE].clone();
let duplicate_commands = restored
.scripts
.invoke(OWNER, "scripts/ai.rhai", "on_death", &duplicate_event, 1)
.unwrap();
assert!(duplicate_commands.is_empty());
assert_eq!(restored.scripts.states[STATE], state);
for _ in 0..60 {
step(&mut replay, &mut restored);
}
assert_eq!(replay.objectives["iron_coast_boss_4:defeated"], 1);
assert_eq!(replay.player.credits, credits + 1_800);
assert!(replay.projectiles.iter().all(|p| !p.hostile));
assert_eq!(replay.outcome, Outcome::Playing);
}
#[test]
fn live_boss_enters_and_persists_in_safe_bounds_without_auto_completion() {
let mut active = active();
let mut world = world(&active);
let boss = world
.spawn(&ContentId::from(UNIT), 112, -64, &active.registry)
.unwrap();
for _ in 0..600 {
// Keep the player outside the cannon lane so the test measures persistence.
world.player.x = 0;
step(&mut world, &mut active);
assert_eq!(world.outcome, Outcome::Playing);
}
let unit = world.enemies.iter().find(|e| e.entity == boss).unwrap();
assert_eq!(unit.y, 24);
assert!((80..=145).contains(&unit.x));
assert_eq!(unit.health, 160);
assert!(!world.objectives.contains_key("iron_coast_boss_4:defeated"));
}