//! 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::>(); 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); }