Add Iron Coast II Black Tide eight-mission campaign
This commit is contained in:
1 parent
a08b932beb
commit
71cb7cdba3
4492 files changed
+164577
-45
No files matched your search
@@ -0,0 +1,540 @@
|
||||
//! Portable assembly/progression checks use synthetic resources; the ignored
|
||||
//! playable route requires the original game catalog.
|
||||
use raptor_replica::{
|
||||
assets::Catalog,
|
||||
content::{ContentPack, Registry},
|
||||
mod_selection,
|
||||
modding::{self, ActiveMods, Manifest},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
const CAMPAIGN: &str = "iron_coast_stage2_campaign";
|
||||
const MISSIONS: [&str; 8] = [
|
||||
"iron_coast_stage2_mission_1:mission_01",
|
||||
"iron_coast_stage2_mission_2:mission_02",
|
||||
"iron_coast_stage2_mission_3:mission_03",
|
||||
"iron_coast_stage2_mission_4:mission_04",
|
||||
"iron_coast_stage2_mission_5:mission_05",
|
||||
"iron_coast_stage2_mission_6:mission_06",
|
||||
"iron_coast_stage2_mission_7:mission_07",
|
||||
"iron_coast_stage2_mission_8:mission_08",
|
||||
];
|
||||
|
||||
fn fixture_resources(value: &Value, root: &Path, original: &Path) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let (Some(width), Some(height)) = (
|
||||
object.get("logical_width").and_then(Value::as_u64),
|
||||
object.get("logical_height").and_then(Value::as_u64),
|
||||
) {
|
||||
let frames = object.get("frames").and_then(Value::as_u64).unwrap_or(1);
|
||||
// Use the minimum valid HD resolution in portable tests. Actual
|
||||
// generated 4x artwork is validated separately by the CLI.
|
||||
for (key, scale) in [("png", 1), ("hd_png", 2)] {
|
||||
if let Some(file) = object.get(key).and_then(Value::as_str) {
|
||||
let path = root.join(file);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
image::RgbaImage::from_pixel(
|
||||
(width * frames * scale) as u32,
|
||||
(height * scale) as u32,
|
||||
image::Rgba([80, 96, 104, 255]),
|
||||
)
|
||||
.save(path)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(file) = object.get("file").and_then(Value::as_str) {
|
||||
let path = root.join(file);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
if Path::new(file)
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("mus"))
|
||||
{
|
||||
// The new soundtrack is original mod content, independent
|
||||
// of the game catalog, so its bounded score can be tested.
|
||||
fs::copy(original.join(file), path).unwrap();
|
||||
} else {
|
||||
let mut wav = Vec::new();
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&100_u32.to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1_u16.to_le_bytes());
|
||||
wav.extend_from_slice(&1_u16.to_le_bytes());
|
||||
wav.extend_from_slice(&22050_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&44100_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&2_u16.to_le_bytes());
|
||||
wav.extend_from_slice(&16_u16.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&64_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&[0; 64]);
|
||||
fs::write(path, wav).unwrap();
|
||||
}
|
||||
}
|
||||
for child in object.values() {
|
||||
fixture_resources(child, root, original);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
fixture_resources(child, root, original);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_directory() -> &'static Path {
|
||||
static FIXTURE: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||
FIXTURE
|
||||
.get_or_init(|| {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods");
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut packages = vec![CAMPAIGN.to_string()];
|
||||
while let Some(id) = packages.pop() {
|
||||
if !visited.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let original = source.join(&id);
|
||||
let root = temporary.path().join(&id);
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let manifest_data = fs::read(original.join("mod.toml")).unwrap();
|
||||
let manifest: Manifest =
|
||||
toml::from_str(std::str::from_utf8(&manifest_data).unwrap()).unwrap();
|
||||
packages.extend(manifest.dependencies.keys().cloned());
|
||||
fs::write(root.join("mod.toml"), manifest_data).unwrap();
|
||||
let content = fs::read(original.join(&manifest.content)).unwrap();
|
||||
let value: Value = serde_json::from_slice(&content).unwrap();
|
||||
fs::write(root.join(&manifest.content), content).unwrap();
|
||||
for script in manifest.scripts {
|
||||
let path = root.join(&script);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::copy(original.join(script), path).unwrap();
|
||||
}
|
||||
fixture_resources(&value, &root, &original);
|
||||
}
|
||||
temporary
|
||||
})
|
||||
.path()
|
||||
}
|
||||
|
||||
fn base() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let mut value = json!({
|
||||
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
|
||||
"items":[
|
||||
{"id":"base:forward_guns","name":"Forward Guns","price":0,"max_count":1,"weapon":"base:projectile_forward_guns","legacy":0},
|
||||
{"id":"base:energy","name":"Shield","price":10000,"initial_count":25,"max_count":100,"legacy":16},
|
||||
{"id":"base:super_shield","name":"Super Shield","price":78500,"initial_count":100,"max_count":100,"legacy":15}
|
||||
],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Base fixture","duration":1000}]
|
||||
});
|
||||
for n in 0..18 {
|
||||
value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1}));
|
||||
}
|
||||
let pack: ContentPack = serde_json::from_value(value).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn discovery() -> modding::Discovery {
|
||||
let discovery = modding::discover(fixture_directory());
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
discovery
|
||||
}
|
||||
|
||||
/// Native IDs are present only to validate the portable dependency graph and
|
||||
/// first-step inventory grants. These placeholders supply no native behavior.
|
||||
/// The playable campaign test below uses the original catalog, not this base.
|
||||
fn structural_base(discovery: &modding::Discovery) -> Registry {
|
||||
let mut registry = base();
|
||||
let mut native = BTreeSet::new();
|
||||
for package in discovery.mods.values() {
|
||||
let pack: ContentPack = serde_json::from_slice(
|
||||
&fs::read(package.root.join(&package.manifest.content)).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for mission in pack.missions {
|
||||
for spawn in mission.spawns {
|
||||
if spawn.enemy.0.starts_with("base:enemy_") {
|
||||
native.insert(spawn.enemy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let enemies = native
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
json!({
|
||||
"id":id,
|
||||
"name":"Structural native-ID placeholder; no gameplay acceptance",
|
||||
"health":1,
|
||||
"speed":0
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
registry
|
||||
.apply(
|
||||
serde_json::from_value(json!({"enemies":enemies})).unwrap(),
|
||||
"base",
|
||||
)
|
||||
.unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn active() -> ActiveMods {
|
||||
let discovery = discovery();
|
||||
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
|
||||
ActiveMods::build(&structural_base(&discovery), &discovery, &enabled, None).unwrap()
|
||||
}
|
||||
|
||||
fn active_original() -> ActiveMods {
|
||||
let data = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"));
|
||||
let catalog = Catalog::open(data).unwrap();
|
||||
let registry = Registry::base(&catalog).unwrap();
|
||||
let discovery = modding::discover(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods"));
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
|
||||
ActiveMods::build(®istry, &discovery, &enabled, Some(&catalog)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_selects_complete_dependencies_and_prunes_unused_packages() {
|
||||
let discovery = discovery();
|
||||
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
|
||||
assert_eq!(enabled.len(), 23);
|
||||
let positions = enabled
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, id)| (id.clone(), n))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
for id in &enabled {
|
||||
for dependency in discovery.mods[id].manifest.dependencies.keys() {
|
||||
assert!(
|
||||
positions[dependency] < positions[id],
|
||||
"{dependency} must precede {id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let active = active();
|
||||
let campaign = &active.registry.campaigns[&"iron_coast_stage2_campaign:campaign".into()];
|
||||
assert_eq!(
|
||||
campaign
|
||||
.missions
|
||||
.iter()
|
||||
.map(|id| id.0.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
MISSIONS
|
||||
);
|
||||
assert!(active.registry.overrides.is_empty());
|
||||
let disabled = mod_selection::plan_disable(&discovery, &enabled, CAMPAIGN);
|
||||
assert!(disabled.enabled.is_empty());
|
||||
for n in 1..=8 {
|
||||
let mission = format!("iron_coast_stage2_mission_{n}");
|
||||
let standalone = mod_selection::plan_enable(&discovery, &[], &mission).unwrap();
|
||||
assert!(
|
||||
standalone
|
||||
.iter()
|
||||
.all(|id| !id.starts_with("iron_coast_stage2_mission_") || id == &mission)
|
||||
);
|
||||
assert!(!standalone.contains(&CAMPAIGN.into()));
|
||||
assert!(!standalone.contains(&"iron_coast_railburst".into()));
|
||||
assert!(!standalone.contains(&"iron_coast_depth_charge".into()));
|
||||
ActiveMods::build(&structural_base(&discovery), &discovery, &standalone, None).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_distinct_objective_missions_have_native_waves_and_own_bosses() {
|
||||
let active = active();
|
||||
let mut names = BTreeSet::new();
|
||||
for (n, id) in MISSIONS.iter().enumerate() {
|
||||
let mission = &active.registry.missions[&(*id).into()];
|
||||
assert!(names.insert(&mission.name));
|
||||
assert!(!mission.auto_complete);
|
||||
assert!(mission.script.is_some());
|
||||
assert!(
|
||||
mission.layout.len() / mission.columns as usize * 32
|
||||
>= mission.duration as usize + 2000,
|
||||
"{id} runs out of terrain during a delayed objective fight"
|
||||
);
|
||||
assert!(
|
||||
mission
|
||||
.spawns
|
||||
.iter()
|
||||
.any(|spawn| spawn.enemy.0.starts_with("base:enemy_"))
|
||||
);
|
||||
let bosses = mission
|
||||
.spawns
|
||||
.iter()
|
||||
.filter(|spawn| active.registry.enemies[&spawn.enemy].boss)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!bosses.is_empty());
|
||||
let boss = format!("iron_coast_stage2_boss_{}:unit", n + 1);
|
||||
assert!(bosses.iter().all(|spawn| spawn.enemy.0 == boss));
|
||||
assert!(bosses.iter().all(|spawn| spawn.tick < mission.duration));
|
||||
assert!(
|
||||
bosses.iter().all(|spawn| spawn.difficulty == 0),
|
||||
"{id} omits its required boss on easy difficulties"
|
||||
);
|
||||
assert!(
|
||||
mission
|
||||
.music_track
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.0
|
||||
.starts_with("iron_coast_stage2_music:")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progression_pins_state_and_optional_equipment_round_trip_without_overrides() {
|
||||
let active = active();
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let path = temporary.path().join("stage2-pilot.json");
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.mission = MISSIONS[0].into();
|
||||
pilot.mods = active.pins.clone();
|
||||
pilot
|
||||
.inventory
|
||||
.insert("iron_coast_railburst:equipment".into(), 1);
|
||||
pilot.script_state.insert(
|
||||
"iron_coast_stage2_interceptor/scripts/ai.rhai".into(),
|
||||
json!({"shots":17}),
|
||||
);
|
||||
pilot.script_rng.insert(
|
||||
"iron_coast_stage2_interceptor/scripts/ai.rhai".into(),
|
||||
raptor_replica::simulation::LegacyRng::new(12345),
|
||||
);
|
||||
for (n, id) in MISSIONS.iter().enumerate() {
|
||||
assert_eq!(pilot.mission.0, *id);
|
||||
pilot.complete_mission(&active.registry).unwrap();
|
||||
assert_eq!(pilot.completed.len(), n + 1);
|
||||
assert_eq!(pilot.mission.0, MISSIONS[(n + 1).min(7)]);
|
||||
pilot.save(&path).unwrap();
|
||||
let recovered = Pilot::load(&path, &active.registry, &active.pins).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&pilot).unwrap(),
|
||||
serde_json::to_value(&recovered).unwrap()
|
||||
);
|
||||
pilot = recovered;
|
||||
}
|
||||
pilot.complete_mission(&active.registry).unwrap();
|
||||
assert_eq!(pilot.completed.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_never_replaces_owned_equipment_or_breaks_full_inventories() {
|
||||
let mut active = active();
|
||||
for mission in MISSIONS {
|
||||
for reserves in [0, 4] {
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.mission = mission.into();
|
||||
pilot
|
||||
.inventory
|
||||
.insert("iron_coast_railburst:equipment".into(), 1);
|
||||
pilot
|
||||
.inventory
|
||||
.insert("iron_coast_depth_charge:equipment".into(), 1);
|
||||
if reserves > 0 {
|
||||
pilot.inventory.insert("base:super_shield".into(), 100);
|
||||
pilot.super_shield_reserves = vec![100; reserves];
|
||||
}
|
||||
for n in 0..18 {
|
||||
if pilot.inventory_slots() == 20 {
|
||||
break;
|
||||
}
|
||||
pilot
|
||||
.inventory
|
||||
.insert(format!("base:fixture_item_{n}").as_str().into(), 1);
|
||||
}
|
||||
pilot.validate(&active.registry, &[]).unwrap();
|
||||
assert_eq!(pilot.inventory_slots(), 20);
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.inventory, pilot.inventory, "{mission}");
|
||||
assert_eq!(
|
||||
world.player.super_shield_reserves,
|
||||
pilot.super_shield_reserves
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn route_input(world: &World, registry: &Registry) -> Input {
|
||||
let namespace = world.mission.0.split_once(':').unwrap().0;
|
||||
let target = world
|
||||
.enemies
|
||||
.iter()
|
||||
.filter(|enemy| enemy.health > 0 && enemy.y >= 0 && enemy.y < 145)
|
||||
.min_by_key(|enemy| {
|
||||
let definition = ®istry.enemies[&enemy.definition];
|
||||
let priority = if enemy.definition.0.starts_with(&format!("{namespace}:")) {
|
||||
0
|
||||
} else if definition.boss {
|
||||
1
|
||||
} else if definition.ground {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
(priority, enemy.entity)
|
||||
});
|
||||
let goal = target
|
||||
.map(|enemy| enemy.x + enemy.width / 2 - 16)
|
||||
.unwrap_or(144)
|
||||
.clamp(5, 282);
|
||||
let aimed = world.player.x + (goal - world.player.x).clamp(-4, 4);
|
||||
// Ordinary four-pixel mouse steps evade imminent projectile lanes. This
|
||||
// controller changes input only; enemies, health and damage stay real.
|
||||
let score = |x: i32| {
|
||||
let mut danger = 0_i32;
|
||||
for shot in world
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|shot| shot.hostile && shot.y >= 100 && shot.y <= 196)
|
||||
{
|
||||
let dy = (shot.y - shot.py).max(shot.vy);
|
||||
if dy <= 0 {
|
||||
continue;
|
||||
}
|
||||
let ticks = ((160 - shot.y) / dy).clamp(0, 8);
|
||||
let predicted = shot.x + (shot.x - shot.px) * ticks;
|
||||
danger += (24 - (x + 16 - predicted).abs()).max(0) * (10 - ticks);
|
||||
}
|
||||
danger * 1000 + (goal - x).abs()
|
||||
};
|
||||
let x = [
|
||||
aimed,
|
||||
(world.player.x - 4).max(5),
|
||||
(world.player.x + 4).min(282),
|
||||
]
|
||||
.into_iter()
|
||||
.min_by_key(|x| score(*x))
|
||||
.unwrap();
|
||||
Input {
|
||||
fire: true,
|
||||
absolute: Some([x, 160]),
|
||||
..Input::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original catalog; full real eight-sortie gameplay and pilot continuation"]
|
||||
fn starter_ship_completes_all_eight_missions_with_paid_repairs_and_finale_replay() {
|
||||
let mut active = active_original();
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let path = temporary.path().join("route.json");
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.name = "Black Tide Acceptance".into();
|
||||
pilot.credits = 10_000;
|
||||
pilot.inventory.insert("base:energy".into(), 75);
|
||||
pilot.difficulty = std::env::var("RAPTOR_STAGE2_DIFFICULTY")
|
||||
.map(|value| value.parse().unwrap())
|
||||
.unwrap_or(2);
|
||||
assert!(pilot.difficulty <= 3);
|
||||
pilot.mission = MISSIONS[0].into();
|
||||
pilot.mods = active.pins.clone();
|
||||
let mut reports = Vec::new();
|
||||
let mut paid_refills = 0;
|
||||
for (n, mission) in MISSIONS
|
||||
.iter()
|
||||
.chain(std::iter::once(&MISSIONS[7]))
|
||||
.enumerate()
|
||||
{
|
||||
assert_eq!(pilot.mission.0, *mission);
|
||||
while pilot.inventory[&"base:energy".into()] < 90 && pilot.credits >= 10_000 {
|
||||
pilot.buy(&"base:energy".into(), &active.registry).unwrap();
|
||||
paid_refills += 1;
|
||||
}
|
||||
let start_shield = pilot.inventory[&"base:energy".into()];
|
||||
let start_credits = pilot.credits;
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
let mut native_motion = BTreeSet::new();
|
||||
let mut seen_bosses = BTreeSet::new();
|
||||
let limit = active.registry.missions[&pilot.mission].duration + 1800;
|
||||
while world.tick < limit && world.outcome == Outcome::Playing {
|
||||
let input = route_input(&world, &active.registry);
|
||||
world
|
||||
.step(input, &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
for enemy in &world.enemies {
|
||||
let definition = &active.registry.enemies[&enemy.definition];
|
||||
if definition.boss {
|
||||
seen_bosses.insert(enemy.definition.clone());
|
||||
}
|
||||
if enemy.definition.0.starts_with("base:enemy_")
|
||||
&& (enemy.x != enemy.sx || enemy.y != enemy.sy)
|
||||
{
|
||||
assert!(definition.legacy.is_some());
|
||||
native_motion.insert(enemy.definition.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
world.outcome,
|
||||
Outcome::Complete,
|
||||
"{mission}: tick={},shield={},objectives={:?}",
|
||||
world.tick,
|
||||
world.player.shield,
|
||||
world.objectives
|
||||
);
|
||||
assert!(world.player.shield > 0);
|
||||
assert!(
|
||||
!native_motion.is_empty(),
|
||||
"{mission} did not show classic native movement"
|
||||
);
|
||||
assert!(!seen_bosses.is_empty());
|
||||
let namespace = mission.split_once(':').unwrap().0;
|
||||
assert_eq!(world.objectives[&format!("{namespace}:complete")], 1);
|
||||
assert_eq!(world.objectives[&format!("{namespace}:debrief")], 1);
|
||||
let credits = world.player.credits;
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.credits, credits, "{mission} paid twice");
|
||||
eprintln!(
|
||||
"{mission}: complete {} ticks, shields {start_shield}-> {}, credits {}",
|
||||
world.tick, world.player.shield, world.player.credits
|
||||
);
|
||||
reports.push(json!({"mission":mission,"replay":n==8,"ticks":world.tick,"start_shield":start_shield,"shield":world.player.shield,"start_credits":start_credits,"credits":credits,"native_motion":native_motion,"objectives":world.objectives,"digest":world.digest()}));
|
||||
pilot = world.pilot(&pilot);
|
||||
pilot.merge_runtime_snapshot(&active.pins, &active.scripts.states, &active.scripts.rng);
|
||||
pilot.complete_mission(&active.registry).unwrap();
|
||||
assert_eq!(pilot.completed.len(), (n + 1).min(8));
|
||||
pilot.save(&path).unwrap();
|
||||
let recovered = Pilot::load(&path, &active.registry, &active.pins).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&pilot).unwrap(),
|
||||
serde_json::to_value(&recovered).unwrap()
|
||||
);
|
||||
active
|
||||
.scripts
|
||||
.restore((recovered.script_state.clone(), recovered.script_rng.clone()));
|
||||
pilot = recovered;
|
||||
}
|
||||
if let Ok(path) = std::env::var("RAPTOR_STAGE2_REPORT") {
|
||||
let path = PathBuf::from(path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path,serde_json::to_vec_pretty(&json!({"original_catalog":true,"difficulty":pilot.difficulty,"pins":active.pins,"paid_refills":paid_refills,"reports":reports})).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
//! API-level tests use synthetic artwork/audio; the ignored balance run uses
|
||||
//! the installed original catalog and actual public mod packages.
|
||||
use raptor_replica::{
|
||||
assets::Catalog,
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods},
|
||||
persistence::Pilot,
|
||||
simulation::{EffectKind, Input, Outcome, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const PACKAGES: [&str; 2] = ["iron_coast_railburst", "iron_coast_depth_charge"];
|
||||
const HEALTH: i32 = 100_000;
|
||||
|
||||
fn range(registry: &mut Registry) {
|
||||
let definitions: ContentPack = serde_json::from_value(json!({
|
||||
"missions": [{"id":"stage2range:range","name":"Controlled range","duration":10000,"auto_complete":false}],
|
||||
"enemies": [
|
||||
{"id":"stage2range:air","name":"Air fixture","health":HEALTH,"speed":0},
|
||||
{"id":"stage2range:ground","name":"Ground fixture","health":HEALTH,"speed":0,"ground":true,"script":"ground.rhai"}
|
||||
]
|
||||
})).unwrap();
|
||||
registry.apply(definitions, "stage2range").unwrap();
|
||||
}
|
||||
|
||||
fn attach_range_script(active: &mut ActiveMods) {
|
||||
active
|
||||
.scripts
|
||||
.add(
|
||||
"stage2range",
|
||||
"ground.rhai",
|
||||
r#"
|
||||
// Cancel terrain scroll after collision processing, without changing weapons.
|
||||
fn on_tick(event,state,random) {
|
||||
#{commands:[#{op:"move",entity:event.entity,x:event.x,y:event.y-1}],state:state}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn synthetic_install(root: &Path, id: &str) {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods").join(id);
|
||||
let target = root.join(id);
|
||||
for directory in ["scripts", "art", "audio"] {
|
||||
fs::create_dir_all(target.join(directory)).unwrap();
|
||||
}
|
||||
for file in ["mod.toml", "content.json", "scripts/weapon.rhai"] {
|
||||
fs::copy(source.join(file), target.join(file)).unwrap();
|
||||
}
|
||||
let content: Value =
|
||||
serde_json::from_slice(&fs::read(target.join("content.json")).unwrap()).unwrap();
|
||||
for entry in content["weapons"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.chain(content["items"].as_array().unwrap())
|
||||
{
|
||||
let art = &entry["art"];
|
||||
for (property, scale) in [("png", 1), ("hd_png", 4)] {
|
||||
image::RgbaImage::from_pixel(
|
||||
art["logical_width"].as_u64().unwrap() as u32 * scale,
|
||||
art["logical_height"].as_u64().unwrap() as u32 * scale,
|
||||
image::Rgba([80, 100, 120, 255]),
|
||||
)
|
||||
.save(target.join(art[property].as_str().unwrap()))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend(164u32.to_le_bytes());
|
||||
wav.extend(b"WAVEfmt ");
|
||||
wav.extend(16u32.to_le_bytes());
|
||||
wav.extend(1u16.to_le_bytes());
|
||||
wav.extend(1u16.to_le_bytes());
|
||||
wav.extend(11025u32.to_le_bytes());
|
||||
wav.extend(22050u32.to_le_bytes());
|
||||
wav.extend(2u16.to_le_bytes());
|
||||
wav.extend(16u16.to_le_bytes());
|
||||
wav.extend(b"data");
|
||||
wav.extend(128u32.to_le_bytes());
|
||||
wav.extend([0; 128]);
|
||||
for sound in content["sounds"].as_array().unwrap() {
|
||||
fs::write(target.join(sound["file"].as_str().unwrap()), &wav).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn activate(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for id in ids {
|
||||
synthetic_install(root.path(), id);
|
||||
}
|
||||
let discovery = modding::discover(root.path());
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let mut base = Registry::default();
|
||||
base.apply(
|
||||
serde_json::from_value(
|
||||
json!({"items":[{"id":"base:energy","name":"Shield","price":100,"max_count":100}]}),
|
||||
)
|
||||
.unwrap(),
|
||||
"base",
|
||||
)
|
||||
.unwrap();
|
||||
let mut active = ActiveMods::build(
|
||||
&base,
|
||||
&discovery,
|
||||
&ids.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
range(&mut active.registry);
|
||||
attach_range_script(&mut active);
|
||||
(root, active)
|
||||
}
|
||||
|
||||
fn world(item: &str, registry: &Registry) -> World {
|
||||
let id = ContentId::from(item);
|
||||
let pilot = Pilot {
|
||||
mission: "stage2range:range".into(),
|
||||
difficulty: 2,
|
||||
inventory: [(id.clone(), 1), ("base:energy".into(), 100)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
selected: registry.items[&id].special.then_some(id),
|
||||
..Pilot::default()
|
||||
};
|
||||
World::new(&pilot.mission, &pilot, registry).unwrap()
|
||||
}
|
||||
|
||||
fn step(world: &mut World, active: &mut ActiveMods, fire: bool) {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn spawn(world: &mut World, active: &ActiveMods, targets: &[(&str, i32, i32)]) -> Vec<u64> {
|
||||
targets
|
||||
.iter()
|
||||
.map(|(kind, x, y)| {
|
||||
world
|
||||
.spawn(
|
||||
&format!("stage2range:{kind}").as_str().into(),
|
||||
*x,
|
||||
*y,
|
||||
&active.registry,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn damage(world: &World, entities: &[u64]) -> Vec<i32> {
|
||||
entities
|
||||
.iter()
|
||||
.map(|id| {
|
||||
HEALTH
|
||||
- world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == *id)
|
||||
.unwrap()
|
||||
.health
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_activation_coexistence_and_real_shop_resale() {
|
||||
for ids in [vec![PACKAGES[0]], vec![PACKAGES[1]], PACKAGES.to_vec()] {
|
||||
let (_root, active) = activate(&ids);
|
||||
assert_eq!(active.pins.len(), ids.len());
|
||||
assert!(active.registry.overrides.is_empty());
|
||||
for id in ids {
|
||||
let item: ContentId = format!("{id}:equipment").as_str().into();
|
||||
let definition = &active.registry.items[&item];
|
||||
assert!(definition.special && !definition.consumable);
|
||||
assert!(definition.description.contains("Selectable"));
|
||||
let mut pilot = Pilot {
|
||||
credits: definition.price,
|
||||
inventory: Default::default(),
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.buy(&item, &active.registry).unwrap();
|
||||
assert_eq!(pilot.credits, 0);
|
||||
assert_eq!(pilot.inventory[&item], 1);
|
||||
assert!(pilot.buy(&item, &active.registry).is_err());
|
||||
pilot.selected = Some(item.clone());
|
||||
pilot.sell(&item, &active.registry).unwrap();
|
||||
assert_eq!(pilot.credits, definition.price / 2);
|
||||
assert!(!pilot.inventory.contains_key(&item));
|
||||
assert!(pilot.selected.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn railburst_contact_penetrates_only_three_aligned_targets_with_falling_damage() {
|
||||
let (_root, mut active) = activate(&[PACKAGES[0]]);
|
||||
let mut world = world("iron_coast_railburst:equipment", &active.registry);
|
||||
let ids = spawn(
|
||||
&mut world,
|
||||
&active,
|
||||
&[
|
||||
("air", 144, 104),
|
||||
("ground", 144, 64),
|
||||
("air", 144, 24),
|
||||
("air", 144, 0),
|
||||
("air", 200, 64),
|
||||
],
|
||||
);
|
||||
step(&mut world, &mut active, true);
|
||||
assert!(
|
||||
world.effects.is_empty(),
|
||||
"no muzzle explosion before real contact"
|
||||
);
|
||||
for _ in 0..3 {
|
||||
step(&mut world, &mut active, false);
|
||||
}
|
||||
assert_eq!(damage(&world, &ids), [42, 32, 22, 0, 0]);
|
||||
assert_eq!(
|
||||
active.scripts.states["iron_coast_railburst/scripts/weapon.rhai"]["hits"],
|
||||
3
|
||||
);
|
||||
assert!(world.effects.iter().any(|e| e.kind == EffectKind::SmallAir));
|
||||
assert!(
|
||||
world
|
||||
.effects
|
||||
.iter()
|
||||
.any(|e| e.kind == EffectKind::SmallGround)
|
||||
);
|
||||
assert_eq!(
|
||||
world.player.inventory[&"iron_coast_railburst:equipment".into()],
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_charge_has_contact_only_ground_splash_falloff_radius_and_neighbor_cap() {
|
||||
let (_root, mut active) = activate(&[PACKAGES[1]]);
|
||||
let mut world = world("iron_coast_depth_charge:equipment", &active.registry);
|
||||
let ids = spawn(
|
||||
&mut world,
|
||||
&active,
|
||||
&[
|
||||
("ground", 144, 96),
|
||||
("ground", 154, 96),
|
||||
("ground", 169, 96),
|
||||
("ground", 179, 96),
|
||||
("ground", 180, 96),
|
||||
("ground", 181, 96),
|
||||
("air", 144, 96),
|
||||
],
|
||||
);
|
||||
step(&mut world, &mut active, true);
|
||||
assert!(world.effects.is_empty());
|
||||
for _ in 0..8 {
|
||||
step(&mut world, &mut active, false);
|
||||
}
|
||||
assert_eq!(damage(&world, &ids), [72, 36, 20, 20, 0, 0, 0]);
|
||||
assert_eq!(world.effects.len(), 1);
|
||||
assert_eq!(world.effects[0].kind, EffectKind::GroundBlast);
|
||||
assert!(world.effects[0].ground);
|
||||
assert_eq!(
|
||||
active.scripts.states["iron_coast_depth_charge/scripts/weapon.rhai"]["hits"],
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unselected_idle_and_missed_shots_do_not_inflict_damage_or_make_explosions() {
|
||||
let (_root, mut active) = activate(&PACKAGES);
|
||||
for id in PACKAGES {
|
||||
active
|
||||
.scripts
|
||||
.restore((Default::default(), Default::default()));
|
||||
let mut world = world(&format!("{id}:equipment"), &active.registry);
|
||||
world.player.inventory.insert(
|
||||
format!(
|
||||
"{}:equipment",
|
||||
if id == PACKAGES[0] {
|
||||
PACKAGES[1]
|
||||
} else {
|
||||
PACKAGES[0]
|
||||
}
|
||||
)
|
||||
.as_str()
|
||||
.into(),
|
||||
1,
|
||||
);
|
||||
let ids = spawn(
|
||||
&mut world,
|
||||
&active,
|
||||
&[("air", 220, 80), ("ground", 220, 80)],
|
||||
);
|
||||
for _ in 0..60 {
|
||||
step(&mut world, &mut active, false);
|
||||
}
|
||||
assert!(active.scripts.states.keys().all(|s| !s.starts_with(id)));
|
||||
step(&mut world, &mut active, true);
|
||||
for _ in 0..35 {
|
||||
step(&mut world, &mut active, false);
|
||||
}
|
||||
assert_eq!(damage(&world, &ids), [0, 0]);
|
||||
assert!(world.effects.is_empty());
|
||||
let other = if id == PACKAGES[0] {
|
||||
PACKAGES[1]
|
||||
} else {
|
||||
PACKAGES[0]
|
||||
};
|
||||
assert!(
|
||||
!active
|
||||
.scripts
|
||||
.states
|
||||
.contains_key(&format!("{other}/scripts/weapon.rhai"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_script_state_and_rng_resume_identically_and_failed_callbacks_roll_back() {
|
||||
for id in PACKAGES {
|
||||
let (_root, mut active) = activate(&[id]);
|
||||
let mut original = world(&format!("{id}:equipment"), &active.registry);
|
||||
spawn(
|
||||
&mut original,
|
||||
&active,
|
||||
&[("ground", 144, 96), ("ground", 154, 96), ("air", 144, 64)],
|
||||
);
|
||||
for _ in 0..75 {
|
||||
step(&mut original, &mut active, true);
|
||||
}
|
||||
let mut resumed = original.clone();
|
||||
let (_other, mut replay) = activate(&[id]);
|
||||
let serialized = serde_json::to_vec(&active.scripts.checkpoints()).unwrap();
|
||||
replay
|
||||
.scripts
|
||||
.restore(serde_json::from_slice(&serialized).unwrap());
|
||||
for _ in 0..100 {
|
||||
step(&mut original, &mut active, true);
|
||||
step(&mut resumed, &mut replay, true);
|
||||
}
|
||||
assert_eq!(original.digest(), resumed.digest());
|
||||
assert_eq!(
|
||||
serde_json::to_value(active.scripts.checkpoints()).unwrap(),
|
||||
serde_json::to_value(replay.scripts.checkpoints()).unwrap()
|
||||
);
|
||||
active.scripts.add(id,"scripts/weapon.rhai",r#"fn on_fire(event,state,random){#{commands:[#{op:"credits",amount:500},#{op:"damage",entity:999999999,amount:1}],state:#{counter:random[0]}}}"#).unwrap();
|
||||
let mut bad = world(&format!("{id}:equipment"), &active.registry);
|
||||
let before = bad.digest();
|
||||
let scripts = serde_json::to_value(active.scripts.checkpoints()).unwrap();
|
||||
let error = bad
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("target"));
|
||||
assert_eq!(before, bad.digest());
|
||||
assert_eq!(
|
||||
scripts,
|
||||
serde_json::to_value(active.scripts.checkpoints()).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cadence_is_explicit_and_equipment_is_reusable() {
|
||||
for (id, cooldown, shots) in [(PACKAGES[0], 22, 4), (PACKAGES[1], 48, 2)] {
|
||||
let (_root, mut active) = activate(&[id]);
|
||||
let mut world = world(&format!("{id}:equipment"), &active.registry);
|
||||
for _ in 0..70 {
|
||||
step(&mut world, &mut active, true);
|
||||
}
|
||||
assert_eq!(
|
||||
active.registry.weapons[&format!("{id}:projectile").as_str().into()].cooldown,
|
||||
cooldown
|
||||
);
|
||||
assert_eq!(
|
||||
active.scripts.states[&format!("{id}/scripts/weapon.rhai")]["shots"],
|
||||
shots
|
||||
);
|
||||
assert_eq!(
|
||||
world.player.inventory[&format!("{id}:equipment").as_str().into()],
|
||||
1
|
||||
);
|
||||
assert_eq!(world.player.shield, 100);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lethal_primary_contacts_still_trigger_finite_secondary_damage() {
|
||||
for id in PACKAGES {
|
||||
let (_root, mut active) = activate(&[id]);
|
||||
let mut world = world(&format!("{id}:equipment"), &active.registry);
|
||||
let targets = if id == PACKAGES[0] {
|
||||
vec![
|
||||
("ground", 144, 104),
|
||||
("air", 144, 64),
|
||||
("ground", 144, 24),
|
||||
("air", 220, 64),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
("ground", 144, 96),
|
||||
("ground", 154, 96),
|
||||
("air", 144, 96),
|
||||
("ground", 220, 96),
|
||||
]
|
||||
};
|
||||
let entities = spawn(&mut world, &active, &targets);
|
||||
world.enemies[0].health = 1;
|
||||
step(&mut world, &mut active, true);
|
||||
for _ in 0..15 {
|
||||
step(&mut world, &mut active, false);
|
||||
}
|
||||
assert!(
|
||||
!world.enemies.iter().any(|e| e.entity == entities[0]),
|
||||
"lethal primary retires normally"
|
||||
);
|
||||
assert_eq!(
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == entities[1])
|
||||
.unwrap()
|
||||
.health,
|
||||
HEALTH - if id == PACKAGES[0] { 32 } else { 36 }
|
||||
);
|
||||
assert_eq!(
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == *entities.last().unwrap())
|
||||
.unwrap()
|
||||
.health,
|
||||
HEALTH
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_inventory_rejects_purchase_without_changing_owned_equipment() {
|
||||
let (_root, active) = activate(&PACKAGES);
|
||||
for id in PACKAGES {
|
||||
let mut pilot = Pilot {
|
||||
credits: 2_000_000,
|
||||
inventory: (0..20)
|
||||
.map(|n| (ContentId::new(format!("unavailable:slot{n}")).unwrap(), 1))
|
||||
.collect(),
|
||||
..Pilot::default()
|
||||
};
|
||||
let before = serde_json::to_value(&pilot).unwrap();
|
||||
assert!(
|
||||
pilot
|
||||
.buy(&format!("{id}:equipment").as_str().into(), &active.registry)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(before, serde_json::to_value(&pilot).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_and_reenabling_keeps_saved_equipment_mod_pins_and_script_state() {
|
||||
let (_root, mut active) = activate(&PACKAGES);
|
||||
let mut flight = world("iron_coast_railburst:equipment", &active.registry);
|
||||
step(&mut flight, &mut active, true);
|
||||
let pilot = Pilot {
|
||||
mission: "stage2range:range".into(),
|
||||
inventory: [
|
||||
("iron_coast_railburst:equipment".into(), 1),
|
||||
("iron_coast_depth_charge:equipment".into(), 1),
|
||||
("base:energy".into(), 100),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
selected: Some("iron_coast_railburst:equipment".into()),
|
||||
mods: active.pins.clone(),
|
||||
script_state: active.scripts.states.clone(),
|
||||
script_rng: active.scripts.rng.clone(),
|
||||
..Pilot::default()
|
||||
};
|
||||
let save = tempfile::tempdir().unwrap();
|
||||
let path = save.path().join("pilot.json");
|
||||
pilot.save(&path).unwrap();
|
||||
let original = fs::read(&path).unwrap();
|
||||
let (_disabled, disabled) = activate(&[]);
|
||||
let missing = Pilot::inspect_load(&path, &disabled.registry, &disabled.pins).unwrap();
|
||||
assert!(!missing.compatibility.is_compatible());
|
||||
assert_eq!(
|
||||
serde_json::to_value(&missing.pilot).unwrap(),
|
||||
serde_json::to_value(&pilot).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
original,
|
||||
fs::read(&path).unwrap(),
|
||||
"read-only compatibility inspection must preserve the original save"
|
||||
);
|
||||
let (_restored, reenabled) = activate(&PACKAGES);
|
||||
let compatible = Pilot::inspect_load(&path, &reenabled.registry, &reenabled.pins).unwrap();
|
||||
assert!(
|
||||
compatible.compatibility.is_compatible(),
|
||||
"{:?}",
|
||||
compatible.compatibility.messages()
|
||||
);
|
||||
assert_eq!(compatible.pilot.inventory, pilot.inventory);
|
||||
assert_eq!(compatible.pilot.script_state, pilot.script_state);
|
||||
assert_eq!(
|
||||
serde_json::to_value(compatible.pilot.script_rng).unwrap(),
|
||||
serde_json::to_value(pilot.script_rng).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires installed original catalog and completed PNG/WAV mod packages"]
|
||||
fn real_catalog_700_tick_balance_and_coexistence_report() {
|
||||
let package = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let catalog = Catalog::open(
|
||||
std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| package.join("data")),
|
||||
)
|
||||
.unwrap();
|
||||
let base = Registry::base(&catalog).unwrap();
|
||||
let enabled = [PACKAGES[0], PACKAGES[1], "ion_lance", "lightning_cannon"].map(String::from);
|
||||
// Freeze only the packages under observation, so unrelated authors can work
|
||||
// while this slow original-data integration test scans and builds packages.
|
||||
fn copy_tree(from: &Path, to: &Path) {
|
||||
fs::create_dir_all(to).unwrap();
|
||||
for entry in fs::read_dir(from).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_tree(&entry.path(), &to.join(entry.file_name()));
|
||||
} else {
|
||||
fs::copy(entry.path(), to.join(entry.file_name())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let frozen = tempfile::tempdir().unwrap();
|
||||
for id in &enabled {
|
||||
copy_tree(&package.join("mods").join(id), &frozen.path().join(id));
|
||||
}
|
||||
let discovery = modding::discover(frozen.path());
|
||||
let mut active = ActiveMods::build(&base, &discovery, &enabled, Some(&catalog)).unwrap();
|
||||
range(&mut active.registry);
|
||||
attach_range_script(&mut active);
|
||||
let scenes: Vec<(&str, Vec<(&str, i32, i32)>)> = vec![
|
||||
("aligned_air", vec![("air", 144, 80)]),
|
||||
("aligned_ground", vec![("ground", 144, 80)]),
|
||||
("off_axis_air", vec![("air", 220, 80)]),
|
||||
("off_axis_ground", vec![("ground", 220, 80)]),
|
||||
(
|
||||
"aligned_column",
|
||||
vec![
|
||||
("ground", 144, 104),
|
||||
("air", 144, 64),
|
||||
("ground", 144, 24),
|
||||
("air", 144, 0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"ground_cluster",
|
||||
vec![
|
||||
("ground", 144, 80),
|
||||
("ground", 154, 80),
|
||||
("ground", 169, 80),
|
||||
("ground", 179, 80),
|
||||
("ground", 181, 80),
|
||||
("air", 144, 80),
|
||||
],
|
||||
),
|
||||
];
|
||||
let mut rows = Vec::new();
|
||||
for item in [
|
||||
"iron_coast_railburst:equipment",
|
||||
"iron_coast_depth_charge:equipment",
|
||||
"ion_lance:equipment",
|
||||
"lightning_cannon:equipment",
|
||||
"base:forward_guns",
|
||||
"base:death_ray",
|
||||
"base:forward_laser",
|
||||
] {
|
||||
for combo in [false, true] {
|
||||
if combo && item.starts_with("base:") {
|
||||
continue;
|
||||
}
|
||||
for (scene, targets) in &scenes {
|
||||
active
|
||||
.scripts
|
||||
.restore((Default::default(), Default::default()));
|
||||
let mut world = world(item, &active.registry);
|
||||
world.player.selected = active.registry.items[&ContentId::from(item)]
|
||||
.special
|
||||
.then(|| item.into());
|
||||
if combo {
|
||||
world.player.inventory.insert("base:forward_guns".into(), 1);
|
||||
}
|
||||
let entities = spawn(&mut world, &active, targets);
|
||||
for _ in 0..700 {
|
||||
step(&mut world, &mut active, true);
|
||||
}
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
assert_eq!(world.player.shield, 100);
|
||||
let damage = damage(&world, &entities);
|
||||
rows.push(json!({"item":item,"price":active.registry.items[&ContentId::from(item)].price,"scene":scene,"with_forward_guns":combo,"ticks":700,"seconds":30,"damage":damage,"group_dps":damage.iter().sum::<i32>() as f64/30.0}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let report = json!({"fixture_health":HEALTH,"target_size":[32,32],"difficulty":2,"rate":"70/3 Hz","rows":rows});
|
||||
if let Some(path) = std::env::var_os("IRON_COAST_STAGE2_BALANCE_REPORT") {
|
||||
fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap();
|
||||
}
|
||||
println!("{report}");
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! Three independent Stage 2 shared enemy packages; synthetic asset tests run everywhere.
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{fs, path::Path};
|
||||
const IDS: [&str; 3] = [
|
||||
"iron_coast_stage2_interceptor",
|
||||
"iron_coast_stage2_siege",
|
||||
"iron_coast_stage2_skimmer",
|
||||
];
|
||||
fn base() -> Registry {
|
||||
let mut r = Registry::default();
|
||||
let p:ContentPack=serde_json::from_value(json!({"weapons":[
|
||||
{"id":"base:projectile_forward_guns","name":"Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"},
|
||||
{"id":"base:air_probe","name":"Air probe","damage":5,"speed":0,"cooldown":1,"behavior":"straight","target":"air"},
|
||||
{"id":"base:ground_probe","name":"Ground probe","damage":5,"speed":0,"cooldown":1,"behavior":"straight","target":"ground"}],
|
||||
"items":[{"id":"base:forward_guns","name":"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 shared enemies range","duration":10000,"auto_complete":false}]})).unwrap();
|
||||
r.apply(p, "base").unwrap();
|
||||
r.validate().unwrap();
|
||||
r
|
||||
}
|
||||
fn fixture(parent: &Path, id: &str) {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods").join(id);
|
||||
let dest = parent.join(id);
|
||||
for sub in ["scripts", "art", "audio"] {
|
||||
fs::create_dir_all(dest.join(sub)).unwrap();
|
||||
}
|
||||
for file in ["mod.toml", "content.json", "scripts/ai.rhai"] {
|
||||
fs::copy(source.join(file), dest.join(file)).unwrap();
|
||||
}
|
||||
if source.join("scripts/gun.rhai").exists() {
|
||||
fs::copy(
|
||||
source.join("scripts/gun.rhai"),
|
||||
dest.join("scripts/gun.rhai"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let content: ContentPack =
|
||||
serde_json::from_slice(&fs::read(source.join("content.json")).unwrap()).unwrap();
|
||||
for a in content
|
||||
.enemies
|
||||
.iter()
|
||||
.filter_map(|d| d.art.as_ref())
|
||||
.chain(content.weapons.iter().filter_map(|d| d.art.as_ref()))
|
||||
{
|
||||
for (path, scale) in [
|
||||
(a.png.as_ref().unwrap(), 1),
|
||||
(a.hd_png.as_ref().unwrap(), 2),
|
||||
] {
|
||||
let mut im =
|
||||
image::RgbaImage::new(a.logical_width * a.frames * scale, a.logical_height * scale);
|
||||
for frame in 0..a.frames {
|
||||
for y in scale..(a.logical_height - 1) * scale {
|
||||
for x in scale..(a.logical_width - 1) * scale {
|
||||
im.put_pixel(
|
||||
frame * a.logical_width * scale + x,
|
||||
y,
|
||||
image::Rgba([94, 105, 113, 255]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
im.save(dest.join(path)).unwrap();
|
||||
}
|
||||
}
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend(40_u32.to_le_bytes());
|
||||
wav.extend(b"WAVEfmt ");
|
||||
wav.extend(16_u32.to_le_bytes());
|
||||
wav.extend(1_u16.to_le_bytes());
|
||||
wav.extend(1_u16.to_le_bytes());
|
||||
wav.extend(22050_u32.to_le_bytes());
|
||||
wav.extend(44100_u32.to_le_bytes());
|
||||
wav.extend(2_u16.to_le_bytes());
|
||||
wav.extend(16_u16.to_le_bytes());
|
||||
wav.extend(b"data");
|
||||
wav.extend(4_u32.to_le_bytes());
|
||||
wav.extend([0; 4]);
|
||||
for s in content.sounds {
|
||||
fs::write(dest.join(s.file), &wav).unwrap();
|
||||
}
|
||||
}
|
||||
fn active(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
for id in ids {
|
||||
fixture(temp.path(), id);
|
||||
}
|
||||
let discovery = modding::discover(temp.path());
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let a = ActiveMods::build(
|
||||
&base(),
|
||||
&discovery,
|
||||
&ids.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
(temp, a)
|
||||
}
|
||||
fn world(a: &ActiveMods) -> World {
|
||||
let mut p = Pilot::default();
|
||||
p.credits = 0;
|
||||
let mut w = World::new(&p.mission, &p, &a.registry).unwrap();
|
||||
w.player.shield = 100;
|
||||
w
|
||||
}
|
||||
fn spawn(w: &mut World, a: &ActiveMods, id: &str, x: i32, y: i32) -> u64 {
|
||||
w.spawn(
|
||||
&ContentId::new(format!("{id}:unit")).unwrap(),
|
||||
x,
|
||||
y,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn stage2_packages_activate_independently_and_coexist_with_stage1() {
|
||||
for id in IDS {
|
||||
let (_t, a) = active(&[id]);
|
||||
assert_eq!(a.pins.len(), 1);
|
||||
assert_eq!(a.registry.enemies.len(), 1);
|
||||
assert_eq!(a.registry.sounds.len(), 3);
|
||||
assert_eq!(a.registry.weapons.len(), 5);
|
||||
assert!(a.registry.overrides.is_empty());
|
||||
}
|
||||
let (_t, a) = active(&[
|
||||
IDS[0],
|
||||
IDS[1],
|
||||
IDS[2],
|
||||
"iron_coast_scout",
|
||||
"iron_coast_turret",
|
||||
"iron_coast_boss",
|
||||
]);
|
||||
assert_eq!(a.pins.len(), 6);
|
||||
assert_eq!(a.registry.enemies.len(), 6);
|
||||
assert!(a.registry.overrides.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn stage2_engine_cooldowns_produce_finite_bursts_and_warnings() {
|
||||
for (idx, id) in IDS.iter().enumerate() {
|
||||
let (_t, mut a) = active(&[*id]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 240;
|
||||
spawn(&mut w, &a, id, 100, 0);
|
||||
let mut shots = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for tick in 0..200 {
|
||||
let e = &mut w.enemies[0];
|
||||
e.x = 100;
|
||||
e.y = 0;
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
let new = w
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| p.age == 0)
|
||||
.collect::<Vec<_>>();
|
||||
if !new.is_empty() {
|
||||
assert!(new.iter().all(|p| p.hostile
|
||||
&& p.weapon.0 == format!("{id}:projectile")
|
||||
&& p.vy >= 1
|
||||
&& p.vx.abs() <= 3));
|
||||
shots.push((tick, new.len()));
|
||||
}
|
||||
if w.mod_sounds.iter().any(|s| s.0 == format!("{id}:warning")) {
|
||||
warnings.push(tick);
|
||||
}
|
||||
assert_eq!(w.enemies.len(), 1, "AI must never spawn extra enemies");
|
||||
assert!(w.projectiles.len() < 32);
|
||||
}
|
||||
let expected = match idx {
|
||||
0 => vec![
|
||||
(1, 1),
|
||||
(5, 1),
|
||||
(9, 1),
|
||||
(71, 1),
|
||||
(75, 1),
|
||||
(79, 1),
|
||||
(141, 1),
|
||||
(145, 1),
|
||||
(149, 1),
|
||||
],
|
||||
1 => vec![(10, 2), (97, 2), (184, 2)],
|
||||
_ => vec![(6, 2), (88, 2), (170, 2)],
|
||||
};
|
||||
assert_eq!(shots, expected, "{id}");
|
||||
assert_eq!(
|
||||
warnings,
|
||||
match idx {
|
||||
0 => vec![],
|
||||
1 => vec![0, 87, 174],
|
||||
_ => vec![0, 82, 164],
|
||||
},
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stage2_movement_has_distinct_bounded_patterns_and_units_escape_without_rewards() {
|
||||
for (idx, id) in IDS.iter().enumerate() {
|
||||
let (_t, mut a) = active(&[*id]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
spawn(&mut w, &a, id, 150, -24);
|
||||
let mut left = false;
|
||||
let mut right = false;
|
||||
let mut stopped = false;
|
||||
for _ in 0..400 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
if w.enemies.is_empty() {
|
||||
break;
|
||||
}
|
||||
let e = &w.enemies[0];
|
||||
left |= e.vx < 0;
|
||||
right |= e.vx > 0;
|
||||
stopped |= e.vx == 0;
|
||||
assert!(e.x >= 6 && e.x + e.width <= 314);
|
||||
assert!((1..=2).contains(&e.vy));
|
||||
}
|
||||
assert!(w.enemies.is_empty(), "{id} should leave naturally");
|
||||
assert_eq!(w.player.credits, 0);
|
||||
assert!(!w.objectives.contains_key(&format!("{id}:defeated")));
|
||||
if idx != 1 {
|
||||
assert!(left && right, "{id} must weave in both directions");
|
||||
} else {
|
||||
assert!(stopped && (left || right));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stage2_air_ground_filtering_and_siege_damage_disable_second_shell() {
|
||||
for (idx, id) in IDS.iter().enumerate() {
|
||||
for ground in [false, true] {
|
||||
let (_t, mut a) = active(&[*id]);
|
||||
let mut w = world(&a);
|
||||
spawn(&mut w, &a, id, 120, 40);
|
||||
let initial = w.enemies[0].health;
|
||||
w.apply_commands(
|
||||
&[Command::Projectile {
|
||||
weapon: if ground {
|
||||
"base:ground_probe"
|
||||
} else {
|
||||
"base:air_probe"
|
||||
}
|
||||
.into(),
|
||||
x: 128,
|
||||
y: 48,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
hostile: false,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
w.enemies[0].health,
|
||||
initial - if ground == (idx != 0) { 5 } else { 0 },
|
||||
"{id} ground={ground}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let (_t, mut a) = active(&[IDS[1]]);
|
||||
let mut w = world(&a);
|
||||
spawn(&mut w, &a, IDS[1], 120, 0);
|
||||
w.enemies[0].health = 24;
|
||||
for tick in 0..=10 {
|
||||
w.enemies[0].y = 0;
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
if tick == 10 {
|
||||
assert_eq!(w.projectiles.iter().filter(|p| p.age == 0).count(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stage2_deaths_reward_count_once_and_replayed_entity_ids_reset_guards() {
|
||||
let (_t, mut a) = active(&IDS);
|
||||
for _ in 0..3 {
|
||||
let mut w = world(&a);
|
||||
for (idx, id) in IDS.iter().enumerate() {
|
||||
let entity = spawn(&mut w, &a, id, 24 + idx as i32 * 92, 0);
|
||||
w.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// on_spawn is queued before on_death even when every entity is already gone.
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(w.player.credits, 460);
|
||||
assert!(w.enemies.is_empty());
|
||||
for id in IDS {
|
||||
assert_eq!(w.objectives.get(&format!("{id}:defeated")), Some(&1));
|
||||
assert!(w.mod_sounds.iter().any(|s| s.0 == format!("{id}:death")));
|
||||
}
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(w.player.credits, 460);
|
||||
assert!(w.mod_sounds.is_empty());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stage2_rng_and_full_world_state_continue_identically_after_serialization() {
|
||||
let (_t, mut a) = active(&IDS);
|
||||
let (_u, mut b) = active(&IDS);
|
||||
let mut x = world(&a);
|
||||
x.player.x = 0;
|
||||
for (idx, id) in IDS.iter().enumerate() {
|
||||
spawn(&mut x, &a, id, 60 + idx as i32 * 75, -24);
|
||||
}
|
||||
let mut y = x.clone();
|
||||
for _ in 0..93 {
|
||||
x.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
y.step(Input::default(), &b.registry, &mut b.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(x.digest(), y.digest());
|
||||
assert_eq!(a.scripts.checkpoints(), b.scripts.checkpoints());
|
||||
}
|
||||
y = serde_json::from_slice(&serde_json::to_vec(&x).unwrap()).unwrap();
|
||||
b.scripts.restore(a.scripts.checkpoints());
|
||||
for _ in 0..210 {
|
||||
x.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
y.step(Input::default(), &b.registry, &mut b.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&x).unwrap(),
|
||||
serde_json::to_value(&y).unwrap()
|
||||
);
|
||||
assert_eq!(a.scripts.checkpoints(), b.scripts.checkpoints());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stage2_death_history_and_escaped_unit_state_are_bounded() {
|
||||
for id in IDS {
|
||||
let (_t, mut a) = active(&[id]);
|
||||
let mut w = world(&a);
|
||||
for _ in 0..260 {
|
||||
let entity = spawn(&mut w, &a, id, 100, 0);
|
||||
w.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(w.objectives.get(&format!("{id}:defeated")), Some(&260));
|
||||
let states = a.scripts.checkpoints().0;
|
||||
let s = &states[&format!("{id}/scripts/ai.rhai")];
|
||||
assert!(s["dead"].as_object().unwrap().len() <= 128);
|
||||
assert!(s["dead_order"].as_array().unwrap().len() <= 128);
|
||||
assert!(s["units"].as_object().unwrap().is_empty());
|
||||
assert!(serde_json::to_vec(s).unwrap().len() < 16000);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_shipped_art_is_aligned_and_every_sound_reaches_the_real_mixer() {
|
||||
use raptor_replica::{audio::Mixer, sound::SoundBank};
|
||||
use sha2::{Digest, Sha256};
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut registry = base();
|
||||
for id in IDS {
|
||||
let dir = root.join("mods").join(id);
|
||||
let pack: ContentPack =
|
||||
serde_json::from_slice(&fs::read(dir.join("content.json")).unwrap()).unwrap();
|
||||
registry.apply(pack, id).unwrap();
|
||||
registry.asset_roots.insert(id.to_string(), dir.clone());
|
||||
let qa: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(dir.join("art-qa.json")).unwrap()).unwrap();
|
||||
for frame in qa["frames"].as_array().unwrap() {
|
||||
let kind = frame["kind"].as_str().unwrap();
|
||||
for (mode, scale) in [("sd", 1), ("hd", 8)] {
|
||||
let bytes = fs::read(dir.join(format!("art/{kind}-{mode}.png"))).unwrap();
|
||||
let image = image::load_from_memory(&bytes).unwrap().into_rgba8();
|
||||
let (w, h) = image.dimensions();
|
||||
assert_eq!(
|
||||
[w, h],
|
||||
[
|
||||
frame["logical_canvas"][0].as_u64().unwrap() as u32 * scale,
|
||||
frame["logical_canvas"][1].as_u64().unwrap() as u32 * scale
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:x}", Sha256::digest(&bytes)),
|
||||
frame[format!("{mode}_sha256")].as_str().unwrap()
|
||||
);
|
||||
assert!(image.pixels().any(|p| p[3] >= 128));
|
||||
assert!(image.pixels().any(|p| p[3] == 0));
|
||||
for (x, y, p) in image.enumerate_pixels() {
|
||||
if p[3] >= 128 {
|
||||
assert!(
|
||||
x > 0 && y > 0 && x + 1 < w && y + 1 < h,
|
||||
"{id}:{kind}:{mode} clipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = fs::read(dir.join(format!("art/{kind}-generated-v1.png"))).unwrap();
|
||||
assert_eq!(
|
||||
format!("{:x}", Sha256::digest(&raw)),
|
||||
frame["raw_sha256"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
registry.validate().unwrap();
|
||||
let mut bank = SoundBank::default();
|
||||
let mut genmidi = b"#OPL_II#".to_vec();
|
||||
genmidi.resize(8 + 175 * 36, 0);
|
||||
for id in IDS {
|
||||
for cue in ["shot", "warning", "death"] {
|
||||
let sound: ContentId = format!("{id}:{cue}").as_str().into();
|
||||
let sample = bank.mod_sample(®istry, &sound).unwrap();
|
||||
assert_eq!(sample.rate, 22050);
|
||||
assert!(sample.data.len() >= 2000);
|
||||
assert!(sample.data.iter().any(|s| *s != 128));
|
||||
let mut mixer = Mixer::new(44100, &genmidi).unwrap();
|
||||
bank.queue_mod(®istry, &mut mixer, &sound).unwrap();
|
||||
let mut output = vec![0i16; 44100 * 2];
|
||||
mixer.mix(&mut output);
|
||||
assert!(
|
||||
output.iter().any(|s| *s != 0),
|
||||
"{sound} was silent at real queue/mix path"
|
||||
);
|
||||
assert!(
|
||||
output.iter().all(|s| *s != i16::MAX && *s != i16::MIN),
|
||||
"{sound} clipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_invisible_or_passed_units_never_warn_or_fire_from_muzzle_coordinates() {
|
||||
for (idx, id) in IDS.iter().enumerate() {
|
||||
let width = if idx == 0 { 24 } else { 32 };
|
||||
for (x, y, player_y) in [
|
||||
(-8, 20, 170),
|
||||
(320 - width + 4, 20, 170),
|
||||
(100, -6, 170),
|
||||
(100, 190, 170),
|
||||
(100, 80, 80),
|
||||
] {
|
||||
let (_t, mut a) = active(&[*id]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.player.y = player_y;
|
||||
spawn(&mut w, &a, id, x, y);
|
||||
for _ in 0..16 {
|
||||
w.enemies[0].x = x;
|
||||
w.enemies[0].y = y;
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert!(
|
||||
w.projectiles.is_empty(),
|
||||
"{id} invisible/passed x{x},y{y} fired"
|
||||
);
|
||||
assert!(
|
||||
w.mod_sounds.is_empty(),
|
||||
"{id} invisible/passed x{x},y{y} warned"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user