Files
RustRaptor/tests/enemies_mods.rs
T

451 lines
15 KiB
Rust

//! Enemy package tests use synthetic PNG/WAV fixtures, never original/generated art.
use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{self, ActiveMods, Command},
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use serde_json::json;
use std::{fs, path::Path};
const ENEMIES: [&str; 3] = ["iron_coast_scout", "iron_coast_turret", "iron_coast_boss"];
fn base() -> Registry {
let mut registry = Registry::default();
let pack: ContentPack = serde_json::from_value(json!({
"weapons": [{"id":"base:projectile_forward_guns", "name":"Forward Guns",
"damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all",
"offsets":[[-8,0],[8,0]]}],
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,
"weapon":"base:projectile_forward_guns"},
{"id":"base:energy","name":"Shield","price":0,"max_count":100}],
"missions":[{"id":"base:sector_1_wave_1","name":"Synthetic Range","duration":5000}]
}))
.unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
fn wav() -> Vec<u8> {
let mut data = b"RIFF".to_vec();
data.extend(40_u32.to_le_bytes());
data.extend(b"WAVEfmt ");
data.extend(16_u32.to_le_bytes());
data.extend(1_u16.to_le_bytes());
data.extend(1_u16.to_le_bytes());
data.extend(22050_u32.to_le_bytes());
data.extend(44100_u32.to_le_bytes());
data.extend(2_u16.to_le_bytes());
data.extend(16_u16.to_le_bytes());
data.extend(b"data");
data.extend(4_u32.to_le_bytes());
data.extend([0, 0, 0, 0]);
data
}
fn install_fixture(root: &Path, id: &str) {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods").join(id);
let output = root.join(id);
fs::create_dir_all(output.join("scripts")).unwrap();
fs::create_dir_all(output.join("art")).unwrap();
fs::create_dir_all(output.join("audio")).unwrap();
for name in ["mod.toml", "content.json", "scripts/ai.rhai"] {
fs::copy(source.join(name), output.join(name)).unwrap();
}
let content: ContentPack =
serde_json::from_slice(&fs::read(source.join("content.json")).unwrap()).unwrap();
for art in content
.enemies
.iter()
.filter_map(|e| e.art.as_ref())
.chain(content.weapons.iter().filter_map(|w| w.art.as_ref()))
{
for (path, scale) in [
(art.png.as_ref().unwrap(), 1),
(art.hd_png.as_ref().unwrap(), 2),
] {
let mut image = image::RgbaImage::new(
art.logical_width * art.frames * scale,
art.logical_height * scale,
);
for frame in 0..art.frames {
for y in scale..(art.logical_height - 1) * scale {
for x in scale..(art.logical_width - 1) * scale {
image.put_pixel(
frame * art.logical_width * scale + x,
y,
image::Rgba([80, 90, 100, 255]),
);
}
}
}
image.save(output.join(path)).unwrap();
}
}
for sound in content.sounds {
fs::write(output.join(sound.file), wav()).unwrap();
}
}
fn active(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
let temp = tempfile::tempdir().unwrap();
for id in ids {
install_fixture(temp.path(), id);
}
let discovered = modding::discover(temp.path());
assert!(discovered.errors.is_empty(), "{:?}", discovered.errors);
let selected: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
let active = ActiveMods::build(&base(), &discovered, &selected, None).unwrap();
(temp, active)
}
fn world(active: &ActiveMods) -> World {
World::new(
&ContentId::from("base:sector_1_wave_1"),
&Pilot::default(),
&active.registry,
)
.unwrap()
}
#[test]
fn all_enemy_packages_validate_independently_and_together() {
for id in ENEMIES {
let (_temp, a) = active(&[id]);
assert_eq!(a.pins.len(), 1);
assert_eq!(a.registry.enemies.len(), 1);
assert!(
a.registry
.weapons
.contains_key(&ContentId::new(format!("{id}:projectile")).unwrap())
);
assert_eq!(a.registry.sounds.len(), 2);
assert_eq!(
a.registry.enemies[&ContentId::new(format!("{id}:unit")).unwrap()]
.script
.as_deref(),
Some("scripts/ai.rhai")
);
}
let (_temp, a) = active(&ENEMIES);
assert_eq!(a.pins.len(), 3);
assert_eq!(a.registry.enemies.len(), 3);
assert!(a.registry.overrides.is_empty());
}
#[test]
fn scout_weaves_in_both_directions_and_aims_hostile_fire() {
let (_temp, mut a) = active(&["iron_coast_scout"]);
let mut w = world(&a);
w.player.x = 0;
w.spawn(
&ContentId::from("iron_coast_scout:unit"),
120,
0,
&a.registry,
)
.unwrap();
let mut right = false;
let mut left = false;
let mut sounds = 0;
for _ in 0..95 {
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
let e = &w.enemies[0];
right |= e.vx > 0;
left |= e.vx < 0;
assert!(e.x >= 24 && e.x <= 272);
assert_eq!(e.vy, 1);
sounds += w
.mod_sounds
.iter()
.filter(|id| id.0 == "iron_coast_scout:shot")
.count();
}
assert!(right && left);
assert_eq!(w.enemies[0].y, 94);
assert!(sounds >= 2);
assert!(
w.projectiles
.iter()
.any(|p| p.hostile && p.weapon.0 == "iron_coast_scout:projectile" && p.vx < 0)
);
}
#[test]
fn turret_scrolls_with_terrain_and_aims_at_player() {
let (_temp, mut a) = active(&["iron_coast_turret"]);
let mut w = world(&a);
w.player.x = 0;
w.spawn(
&ContentId::from("iron_coast_turret:unit"),
220,
0,
&a.registry,
)
.unwrap();
let mut sounds = 0;
for _ in 0..80 {
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
sounds += w
.mod_sounds
.iter()
.filter(|id| id.0 == "iron_coast_turret:shot")
.count();
}
assert_eq!((w.enemies[0].x, w.enemies[0].y), (220, 80));
assert!(a.registry.enemies[&w.enemies[0].definition].ground);
assert_eq!(sounds, 2);
assert!(
w.projectiles
.iter()
.any(|p| p.hostile && p.vx < 0 && p.vy >= 0)
);
}
#[test]
fn boss_enters_patrols_fires_fans_and_remains_on_screen() {
let (_temp, mut a) = active(&["iron_coast_boss"]);
let mut w = world(&a);
w.player.x = 0;
w.spawn(
&ContentId::from("iron_coast_boss:unit"),
112,
-64,
&a.registry,
)
.unwrap();
let mut left = false;
let mut right = false;
let mut fans = 0;
for tick in 0..1500 {
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
assert_eq!(w.outcome, Outcome::Playing);
assert_eq!(w.enemies.len(), 1, "boss retired at{tick}");
let e = &w.enemies[0];
assert_eq!((e.width, e.height), (96, 64));
if tick > 46 {
assert_eq!(e.y, 24);
assert!((80..=144).contains(&e.x));
left |= e.vx < 0;
right |= e.vx > 0;
}
if w.mod_sounds
.contains(&ContentId::from("iron_coast_boss:shot"))
{
let bullets: Vec<_> = w
.projectiles
.iter()
.filter(|p| p.age == 0 && p.hostile)
.collect();
assert_eq!(bullets.len(), 3);
assert_eq!(bullets.iter().map(|p| p.vx).collect::<Vec<_>>(), [-2, 0, 2]);
fans += 1;
}
}
assert!(left && right);
assert!(fans >= 25);
}
#[test]
fn boss_can_be_defeated_with_starter_guns_without_healing() {
let (_temp, mut a) = active(&["iron_coast_boss"]);
let mut w = world(&a);
w.player.x = 128;
w.spawn(
&ContentId::from("iron_coast_boss:unit"),
112,
-64,
&a.registry,
)
.unwrap();
let mut defeated_at = None;
for tick in 0..600 {
w.step(
Input {
fire: true,
..Input::default()
},
&a.registry,
&mut a.scripts,
)
.unwrap();
if w.objectives.get("iron_coast_boss:defeated") == Some(&1) {
defeated_at = Some(tick);
break;
}
assert_eq!(w.outcome, Outcome::Playing, "starter pilot died at{tick}");
}
assert!(
defeated_at.is_some(),
"boss cannot be killed with starter guns"
);
assert!(w.enemies.is_empty());
assert!(w.player.shield > 0);
assert_eq!(w.player.credits, 1800);
assert!(
w.mod_sounds
.contains(&ContentId::from("iron_coast_boss:death"))
);
}
#[test]
fn death_rewards_objectives_and_sounds_are_emitted_exactly_once() {
let (_temp, mut a) = active(&ENEMIES);
let mut w = world(&a);
for (index, id) in ENEMIES.iter().enumerate() {
let unit = ContentId::new(format!("{id}:unit")).unwrap();
let entity = w
.spawn(&unit, 30 + index as i32 * 100, 0, &a.registry)
.unwrap();
w.apply_commands(
&[Command::Damage {
entity,
amount: 100000,
}],
&a.registry,
)
.unwrap();
}
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
assert!(w.enemies.is_empty());
assert_eq!(w.player.credits, 75 + 120 + 1800);
for id in ENEMIES {
assert_eq!(w.objectives.get(&format!("{id}:defeated")), Some(&1));
assert!(
w.mod_sounds
.contains(&ContentId::new(format!("{id}:death")).unwrap())
);
}
let previous = w.player.credits;
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
assert_eq!(w.player.credits, previous);
assert!(w.mod_sounds.is_empty());
}
#[test]
fn enemy_scripts_replay_identically_and_restore_serialized_state() {
let (_temp, mut first) = active(&ENEMIES);
let (_other, mut second) = active(&ENEMIES);
let mut a = world(&first);
for (index, id) in ENEMIES.iter().enumerate() {
a.spawn(
&ContentId::new(format!("{id}:unit")).unwrap(),
60 + index as i32 * 30,
-24,
&first.registry,
)
.unwrap();
}
a.player.x = 0;
let mut b = a.clone();
for _ in 0..75 {
a.step(Input::default(), &first.registry, &mut first.scripts)
.unwrap();
b.step(Input::default(), &second.registry, &mut second.scripts)
.unwrap();
assert_eq!(a.digest(), b.digest());
assert_eq!(first.scripts.checkpoints(), second.scripts.checkpoints());
}
let snapshot = serde_json::to_vec(&a).unwrap();
b = serde_json::from_slice(&snapshot).unwrap();
let checkpoint = first.scripts.checkpoints();
second.scripts.restore(checkpoint);
for _ in 0..25 {
a.step(Input::default(), &first.registry, &mut first.scripts)
.unwrap();
b.step(Input::default(), &second.registry, &mut second.scripts)
.unwrap();
assert_eq!(a.digest(), b.digest());
}
}
#[test]
fn mission_defeat_counts_reset_while_persistent_script_statistics_continue() {
let (_temp, mut a) = active(&ENEMIES);
for id in ENEMIES {
let unit = ContentId::new(format!("{id}:unit")).unwrap();
let mut first = world(&a);
for _ in 0..2 {
let entity = first.spawn(&unit, 20, 0, &a.registry).unwrap();
first
.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
first
.apply_commands(
&[Command::Damage {
entity,
amount: 100000,
}],
&a.registry,
)
.unwrap();
first
.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
}
assert_eq!(first.objectives.get(&format!("{id}:defeated")), Some(&2));
let mut next = world(&a);
let entity = next.spawn(&unit, 20, 0, &a.registry).unwrap();
next.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
next.apply_commands(
&[Command::Damage {
entity,
amount: 100000,
}],
&a.registry,
)
.unwrap();
next.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
assert_eq!(next.objectives.get(&format!("{id}:defeated")), Some(&1));
let state = a.scripts.checkpoints().0;
assert_eq!(state[&format!("{id}/scripts/ai.rhai")]["destroyed"], 3);
if id == "iron_coast_boss" {
assert!(
state[&format!("{id}/scripts/ai.rhai")]["directions"]
.as_object()
.unwrap()
.is_empty()
);
}
}
}
#[test]
fn shipped_enemy_assets_are_valid_and_aligned() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods");
let selected: Vec<_> = ENEMIES.iter().map(|id| id.to_string()).collect();
let discovery = modding::discover(&root);
ActiveMods::build(&base(), &discovery, &selected, None).unwrap();
for id in ENEMIES {
let content: ContentPack =
serde_json::from_slice(&fs::read(root.join(id).join("content.json")).unwrap()).unwrap();
let art = content.enemies[0].art.as_ref().unwrap();
for path in [art.png.as_ref().unwrap(), art.hd_png.as_ref().unwrap()] {
let image = image::open(root.join(id).join(path)).unwrap().into_rgba8();
assert_eq!(
image.width() / art.frames * art.logical_height,
image.height() * art.logical_width
);
for frame in 0..art.frames {
let width = image.width() / art.frames;
for x in frame * width..(frame + 1) * width {
assert!(image.get_pixel(x, 0)[3] < 128);
assert!(image.get_pixel(x, image.height() - 1)[3] < 128);
}
for y in 0..image.height() {
assert!(image.get_pixel(frame * width, y)[3] < 128);
assert!(image.get_pixel((frame + 1) * width - 1, y)[3] < 128);
}
}
}
}
}