Add Iron Coast II Black Tide eight-mission campaign

This commit is contained in:
raptorreplica committed 2026-10-11 20:08:01 +11:00
1 parent a08b932beb
commit 71cb7cdba3
4492 files changed
+164577 -45

No files matched your search

+540
View File
@@ -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(&registry, &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 = &registry.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();
}
}
+625
View File
@@ -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}");
}
+481
View File
@@ -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(&registry, &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(&registry, &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"
);
}
}
}
}