Files
RustRaptor/tests/iron_coast.rs
T

444 lines
16 KiB
Rust

//! Scripted mission acceptance uses synthetic PNG/WAV and native-ID placeholders.
//! This does not validate original-catalog enemy paths, guns, or native-wave balance.
use raptor_replica::{
content::{ContentPack, Registry},
modding::{self, ActiveMods, Manifest},
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use serde_json::{Value, json};
use std::{
fs,
path::{Path, PathBuf},
};
const PACKAGES: [&str; 8] = [
"iron_coast_music",
"iron_coast_scout",
"iron_coast_turret",
"iron_coast_boss",
"pulse_cannon",
"seeker_swarm",
"ion_lance",
"iron_coast_mission_1",
];
const MISSION: &str = "iron_coast_mission_1:mission_01";
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":0,"max_count":100,"legacy":16}],
"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}));
}
// Native wave references were added by the retune. Keep these portable
// fixtures explicitly synthetic; real native behavior belongs to catalog tests.
value["enemies"] = json!([21, 22, 73, 74].map(|id| json!({
"id":format!("base:enemy_0_{id}"), "name":"Synthetic native-ID placeholder",
"health":1, "speed":1
})));
let pack: ContentPack = serde_json::from_value(value).unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
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);
for (key, scale) in [("png", 1), ("hd_png", 4)] {
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();
let image = image::RgbaImage::from_pixel(
(width * frames * scale) as u32,
(height * scale) as u32,
image::Rgba([80, 96, 104, 255]),
);
image.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(|ext| ext == "mus") {
fs::copy(original.join(file), &path).unwrap();
return;
}
// Small valid mono PCM16 WAV; integration checks use the actual cues.
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 active() -> (tempfile::TempDir, ActiveMods) {
let started = std::time::Instant::now();
let temporary = tempfile::tempdir().unwrap();
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods");
for id in PACKAGES {
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"))
.expect("all eight Breakwater packages must be installed");
let manifest: Manifest =
toml::from_str(std::str::from_utf8(&manifest_data).unwrap()).unwrap();
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);
}
let discovery = modding::discover(temporary.path());
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
let enabled = PACKAGES.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let active = ActiveMods::build(&base(), &discovery, &enabled, None).unwrap();
eprintln!(
"Iron Coast synthetic eight-package activation: {:.3}s",
started.elapsed().as_secs_f64()
);
(temporary, active)
}
#[test]
fn campaign_dependencies_and_generated_footprints_validate_together() {
let (_temporary, active) = active();
assert_eq!(active.pins.len(), 8);
let mission = &active.registry.missions[&MISSION.into()];
assert!(!mission.auto_complete);
assert_eq!(mission.columns, 9);
assert_eq!(mission.layout.len(), 72 * 9);
assert!(
mission
.spawns
.iter()
.any(|s| s.enemy.0 == "iron_coast_boss:unit" && s.tick == 720)
);
assert_eq!(mission.art.as_ref().unwrap().logical_width, 320);
assert!(
active.registry.campaigns[&"iron_coast_mission_1:campaign".into()]
.missions
.contains(&MISSION.into())
);
}
#[test]
fn boss_alone_cannot_complete_and_reward_is_paid_once() {
let (_temporary, mut active) = active();
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
world
.objectives
.insert("iron_coast_boss:defeated".into(), 1);
world
.objectives
.insert("iron_coast_turret:defeated".into(), 1);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Playing);
world
.objectives
.insert("iron_coast_turret:defeated".into(), 2);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Complete);
assert_eq!(world.objectives["iron_coast_mission_1:complete"], 1);
assert_eq!(world.player.credits, 5000);
assert_eq!(world.objectives["iron_coast_mission_1:debrief"], 1);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.player.credits, 5000);
}
#[test]
fn missed_breakwater_objective_turrets_stay_reachable() {
let (_temporary, mut active) = active();
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
let target = world
.spawn(&"iron_coast_turret:unit".into(), 40, 71, &active.registry)
.unwrap();
for _ in 0..60 {
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
let turret = world
.enemies
.iter()
.find(|unit| unit.entity == target)
.unwrap();
assert_eq!(turret.y, 72);
assert!(turret.health > 0);
assert_eq!(world.outcome, Outcome::Playing);
}
}
fn play(use_trial: bool) -> (World, Vec<(u64, String)>) {
let (_temporary, mut active) = active();
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
let mut seen = Vec::new();
for tick in 0..2000 {
let input = Input {
fire: true,
special: tick % 90 == 60,
cycle: tick == 1,
absolute: Some([144, 160]),
..Input::default()
};
world
.step(input, &active.registry, &mut active.scripts)
.unwrap();
if tick == 0 && !use_trial {
world
.player
.inventory
.remove(&"pulse_cannon:equipment".into());
}
for enemy in &world.enemies {
if !seen.iter().any(|(_, id)| id == &enemy.definition.0) {
seen.push((world.tick, enemy.definition.0.clone()));
}
}
if world.outcome != Outcome::Playing {
break;
}
}
if use_trial {
let weapon_state = &active.scripts.states["pulse_cannon/scripts/weapon.rhai"];
assert!(weapon_state["bursts"].as_i64().unwrap() > 0);
assert!(weapon_state["hits"].as_i64().unwrap() > 0);
let serialized = serde_json::to_string(&active.scripts.states).unwrap();
let recovered: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(recovered["pulse_cannon/scripts/weapon.rhai"], *weapon_state);
}
eprintln!(
"Iron Coast {} run: tick={} outcome={:?} shield={} credits={} repairs_remaining={}",
if use_trial {
"trial Pulse"
} else {
"starter guns"
},
world.tick,
world.outcome,
world.player.shield,
world.player.credits,
world
.player
.inventory
.get(&"iron_coast_mission_1:repair".into())
.copied()
.unwrap_or(0)
);
(world, seen)
}
#[test]
fn full_inventory_can_start_without_deleting_saved_equipment() {
let (_temporary, mut active) = active();
for reserves in [0, 4] {
let mut pilot = Pilot::default();
if reserves > 0 {
pilot.inventory.insert("base:super_shield".into(), 100);
pilot.super_shield_reserves = vec![100; reserves];
}
while pilot.inventory_slots() < 20 {
let n = pilot.inventory.len();
pilot
.inventory
.insert(format!("base:fixture_item_{n}").as_str().into(), 1);
}
assert_eq!(pilot.inventory_slots(), 20);
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.player.inventory, pilot.inventory);
assert_eq!(
world.player.super_shield_reserves,
pilot.super_shield_reserves
);
assert_eq!(world.objectives["iron_coast_mission_1:trial_issued"], 0);
assert_eq!(world.outcome, Outcome::Playing);
}
}
#[test]
fn restarted_mission_counts_new_turret_kills_independently_of_saved_statistics() {
let (_temporary, mut active) = active();
for _ in 0..2 {
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
let entity = world
.spawn(&"iron_coast_turret:unit".into(), 144, 0, &active.registry)
.unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
world
.apply_commands(
&[modding::Command::Damage {
entity,
amount: 1000,
}],
&active.registry,
)
.unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.objectives["iron_coast_turret:defeated"], 1);
assert_eq!(world.outcome, Outcome::Playing);
}
assert_eq!(
active.scripts.states["iron_coast_turret/scripts/ai.rhai"]["destroyed"],
2
);
}
#[test]
fn starter_guns_complete_scripted_waves_and_boss_with_synthetic_native_fillers() {
let (world, seen) = play(false);
assert_eq!(
world.outcome,
Outcome::Complete,
"tick={} shield={} objectives={:?}",
world.tick,
world.player.shield,
world.objectives
);
assert!(
seen.iter()
.any(|(tick, id)| *tick >= 720 && id == "iron_coast_boss:unit")
);
assert!(world.objectives["iron_coast_turret:defeated"] >= 2);
assert!(world.objectives["iron_coast_boss:defeated"] >= 1);
assert_eq!(world.objectives["iron_coast_mission_1:phase"], 4);
assert!(world.player.shield > 0);
assert!(world.tick > 720 && world.tick < 2000);
}
#[test]
fn trial_pulse_cannon_completes_and_preserves_scripted_weapon_state() {
let (world, _seen) = play(true);
assert_eq!(
world.outcome,
Outcome::Complete,
"tick={} shield={} objectives={:?}",
world.tick,
world.player.shield,
world.objectives
);
assert!(
!world
.player
.inventory
.contains_key(&"pulse_cannon:equipment".into())
);
assert!(world.player.credits >= 5000);
}
#[test]
fn trial_is_a_loan_and_cannot_be_repeatedly_sold_after_victory() {
let (_temporary, mut active) = active();
let mut pilot = Pilot::default();
for _ in 0..2 {
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.player.inventory[&"pulse_cannon:equipment".into()], 1);
world
.objectives
.insert("iron_coast_turret:defeated".into(), 2);
world
.objectives
.insert("iron_coast_boss:defeated".into(), 1);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Complete);
pilot = world.pilot(&pilot);
assert!(
!pilot
.inventory
.contains_key(&"pulse_cannon:equipment".into())
);
let credits = pilot.credits;
assert!(
pilot
.sell(&"pulse_cannon:equipment".into(), &active.registry)
.is_err()
);
assert_eq!(pilot.credits, credits);
}
}
#[test]
fn owned_cannon_survives_trial_completion_without_replacement() {
let (_temporary, mut active) = active();
let mut pilot = Pilot::default();
pilot.inventory.insert("pulse_cannon:equipment".into(), 1);
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
world
.objectives
.insert("iron_coast_turret:defeated".into(), 2);
world
.objectives
.insert("iron_coast_boss:defeated".into(), 1);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Complete);
assert_eq!(world.player.inventory[&"pulse_cannon:equipment".into()], 1);
}