//! Portable assembly/progression checks use synthetic resources; the ignored //! playable route requires the original untracked 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_campaign"; const MISSIONS: [&str; 6] = [ "iron_coast_mission_1:mission_01", "iron_coast_mission_2:mission_02", "iron_coast_mission_3:mission_03", "iron_coast_mission_4:mission_04", "iron_coast_mission_5:mission_05", "iron_coast_mission_6:mission_06", ]; 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 = 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::>(); 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 explicit_mission_next_wins_over_campaign_order_and_standalone_stays_replayable() { let mut registry = base(); registry .apply( serde_json::from_value(json!({ "missions": [ {"id":"base:test_a","name":"Explicit route","duration":20,"next":"base:test_c"}, {"id":"base:test_b","name":"Campaign route","duration":20}, {"id":"base:test_c","name":"Standalone destination","duration":20} ], "campaigns":[{"id":"base:test_campaign","name":"Test route","missions":["base:test_a","base:test_b"]}] })) .unwrap(), "base", ) .unwrap(); registry.validate().unwrap(); let mut pilot = Pilot::default(); pilot.mission = "base:test_a".into(); pilot.complete_mission(®istry).unwrap(); assert_eq!(pilot.mission.0, "base:test_c"); pilot.complete_mission(®istry).unwrap(); assert_eq!(pilot.mission.0, "base:test_c"); assert_eq!(pilot.completed.len(), 2); } #[test] fn campaign_order_fallback_uses_stable_campaign_id_order() { let mut registry = base(); registry .apply( serde_json::from_value(json!({ "missions": [ {"id":"base:test_a","name":"Shared mission","duration":20}, {"id":"base:test_b","name":"First route","duration":20}, {"id":"base:test_c","name":"Second route","duration":20} ], "campaigns":[ {"id":"base:z_route","name":"Later ID","missions":["base:test_a","base:test_c"]}, {"id":"base:a_route","name":"Earlier ID","missions":["base:test_a","base:test_b"]} ] })) .unwrap(), "base", ) .unwrap(); registry.validate().unwrap(); let mut pilot = Pilot::default(); pilot.mission = "base:test_a".into(); pilot.complete_mission(®istry).unwrap(); assert_eq!(pilot.mission.0, "base:test_b"); pilot.complete_mission(®istry).unwrap(); assert_eq!(pilot.mission.0, "base:test_b"); } #[test] fn selecting_campaign_selects_nineteen_packages_and_respects_shared_dependencies() { let discovery = discovery(); let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap(); assert_eq!(enabled.len(), 19); assert_eq!(enabled.last().unwrap(), CAMPAIGN); for number in 1..=6 { let id = format!("iron_coast_mission_{number}"); assert!(enabled.contains(&id)); let standalone = mod_selection::plan_enable(&discovery, &[], &id).unwrap(); assert_eq!(standalone.len(), 8, "{id} must remain standalone"); assert!(!standalone.iter().any(|id| id == CAMPAIGN)); ActiveMods::build(&structural_base(&discovery), &discovery, &standalone, None).unwrap(); } let disable = mod_selection::plan_disable(&discovery, &enabled, CAMPAIGN); assert_eq!(disable.removed.len(), 19); assert!(disable.enabled.is_empty()); // Two independent missions share content. Disabling one keeps the other's // shared content while removing the disabled mission's unused stage boss. let standalone = mod_selection::plan_enable(&discovery, &[], "iron_coast_mission_1").unwrap(); let standalone = mod_selection::plan_enable(&discovery, &standalone, "iron_coast_mission_6").unwrap(); let disable = mod_selection::plan_disable(&discovery, &standalone, "iron_coast_mission_6"); assert_eq!(disable.removed.len(), 2); assert!( disable .removed .contains(&"iron_coast_mission_6".to_string()) ); assert!(disable.removed.contains(&"iron_coast_boss_6".to_string())); assert!( disable .enabled .contains(&"iron_coast_mission_1".to_string()) ); assert!(disable.enabled.contains(&"pulse_cannon".to_string())); } #[test] fn campaign_order_is_complete_and_owns_only_its_campaign_definition() { let active = active(); let campaign = &active.registry.campaigns[&"iron_coast_mission_1:campaign".into()]; assert_eq!( campaign .missions .iter() .map(|id| id.0.as_str()) .collect::>(), MISSIONS ); assert_eq!(active.registry.overrides.len(), 1); assert_eq!( active.registry.owners[&"iron_coast_music:iron_coast".into()], "iron_coast_music" ); for (number, id) in MISSIONS.iter().enumerate() { let owner = format!("iron_coast_mission_{}", number + 1); assert_eq!(&active.registry.owners[&(*id).into()], &owner); let mission = &active.registry.missions[&(*id).into()]; assert!(!mission.auto_complete); assert_eq!( serde_json::to_value(mission).unwrap()["music_track"], "iron_coast_music:iron_coast" ); let boss_id = if number == 0 { "iron_coast_boss:unit".to_string() } else { format!("iron_coast_boss_{}:unit", number + 1) }; let bosses = mission .spawns .iter() .filter(|spawn| active.registry.enemies[&spawn.enemy].boss) .collect::>(); assert_eq!(bosses.len(), if number == 5 { 2 } else { 1 }); assert!(bosses.iter().all(|spawn| spawn.enemy.0 == boss_id)); if number > 0 { assert!( mission .spawns .iter() .any(|spawn| spawn.enemy.0.starts_with("base:enemy_")) ); } } } #[test] fn campaign_progression_round_trips_pilot_inventory_and_script_state() { let active = active(); let temporary = tempfile::tempdir().unwrap(); let path = temporary.path().join("pilot.json"); let mut pilot = Pilot::default(); pilot.mission = MISSIONS[0].into(); pilot.mods = active.pins.clone(); pilot.inventory.insert("seeker_swarm:equipment".into(), 1); pilot.script_state.insert( "iron_coast_scout/scripts/ai.rhai".into(), json!({"destroyed":4,"shots":17}), ); pilot.script_rng.insert( "iron_coast_scout/scripts/ai.rhai".into(), raptor_replica::simulation::LegacyRng::new(12345), ); for (number, id) in MISSIONS.iter().enumerate() { assert_eq!(pilot.mission.0, *id); pilot.complete_mission(&active.registry).unwrap(); assert_eq!(pilot.completed.len(), number + 1); let expected = MISSIONS[(number + 1).min(5)]; assert_eq!(pilot.mission.0, expected); pilot.save(&path).unwrap(); let recovered = Pilot::load(&path, &active.registry, &active.pins).unwrap(); assert_eq!( serde_json::to_value(&recovered).unwrap(), serde_json::to_value(&pilot).unwrap() ); pilot = recovered; } // Revisiting the finale does not duplicate completed progression records. pilot.complete_mission(&active.registry).unwrap(); assert_eq!(pilot.completed.len(), 6); assert!(fs::metadata(path).unwrap().len() > 0); } #[test] fn every_campaign_mission_accepts_a_full_twenty_slot_ship() { let mut active = active(); for id in MISSIONS { 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]; } for index in 0..18 { if pilot.inventory_slots() == 20 { break; } pilot .inventory .insert(format!("base:fixture_item_{index}").as_str().into(), 1); } assert_eq!(pilot.inventory_slots(), 20); pilot.validate(&active.registry, &[]).unwrap(); let mut world = World::new(&id.into(), &pilot, &active.registry).unwrap(); world .step(Input::default(), &active.registry, &mut active.scripts) .unwrap(); assert_eq!( world.player.inventory, pilot.inventory, "{id} changed a full inventory" ); assert_eq!( world.player.super_shield_reserves, pilot.super_shield_reserves ); assert_eq!(world.outcome, Outcome::Playing); } } } /// Controlled mouse input moves at most four horizontal pixels per simulation /// step, fires continuously and cycles equipment using ordinary input edges. /// It targets mission-local structures before shared turrets, then bosses and /// scouts. Repairs are activated through their actual item callbacks. fn campaign_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 priority = if enemy.definition.0.starts_with(&format!("{namespace}:")) { 0 } else if enemy.definition.0 == "iron_coast_turret:unit" { 1 } else if registry.enemies[&enemy.definition].boss { 2 } else { 3 }; (priority, enemy.entity) }); let goal = target .map(|enemy| enemy.x + enemy.width / 2 - 16) .unwrap_or(144) .clamp(5, 282); let x = world.player.x + (goal - world.player.x).clamp(-4, 4); let special = world .player .inventory .iter() .filter_map(|(id, count)| { let item = registry.items.get(id)?; (*count > 0 && item.special).then_some((id, item)) }) .collect::>(); let repair = special .iter() .find(|(_, item)| item.consumable && item.weapon.is_none()) .map(|(id, _)| *id); let weapon = special .iter() .filter(|(_, item)| item.weapon.is_some()) .max_by_key(|(_, item)| item.price) .map(|(id, _)| *id); let wanted = if world.player.shield <= 70 { repair.or(weapon) } else { weapon.or(repair) }; let cycle = wanted.is_some_and(|id| world.player.selected.as_ref() != Some(id)) && !world.cycle_held; let consume = world.player.shield <= 70 && repair.is_some_and(|id| world.player.selected.as_ref() == Some(id)) && !world.special_held; Input { fire: true, cycle, special: consume, absolute: Some([x, 160]), ..Input::default() } } #[test] #[ignore = "requires the original untracked catalog; native enemy paths and weapon behavior"] fn registered_pilot_can_complete_six_real_missions_and_save_round_trips() { let mut active = active_original(); assert_eq!(active.pins.len(), 19); assert_eq!(active.registry.overrides.len(), 1); let temporary = tempfile::tempdir().unwrap(); let path = temporary.path().join("campaign-pilot.json"); let mut pilot = Pilot::default(); pilot.name = "Campaign Acceptance".into(); pilot.callsign = "COAST".into(); pilot.credits = 10_000; pilot.inventory.insert("base:energy".into(), 75); pilot.mission = MISSIONS[0].into(); pilot.mods = active.pins.clone(); let mut reports = Vec::new(); let mut paid_refills = 0; for (number, mission) in MISSIONS.iter().enumerate() { assert_eq!(pilot.mission.0, *mission); let refills_before = paid_refills; // The hangar replenishes through the real purchase operation and its // actual price; this test never inserts free shield or premium weapons. while pilot.inventory[&"base:energy".into()] < 75 && 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_seen = BTreeSet::new(); let mut native_motion = BTreeSet::new(); let limit = active.registry.missions[&pilot.mission].duration + 1600; while world.tick < limit && world.outcome == Outcome::Playing { let input = campaign_input(&world, &active.registry); world .step(input, &active.registry, &mut active.scripts) .unwrap(); for enemy in &world.enemies { if enemy.definition.0.starts_with("base:enemy_") { assert!(active.registry.enemies[&enemy.definition].legacy.is_some()); native_seen.insert(enemy.definition.clone()); if enemy.x != enemy.sx || enemy.y != enemy.sy { 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); if number > 0 { assert!( !native_seen.is_empty(), "{mission} never spawned a classic enemy" ); assert!( !native_motion.is_empty(), "{mission} showed no classic enemy movement" ); } let namespace = mission.split_once(':').unwrap().0; assert_eq!(world.objectives[&format!("{namespace}:complete")], 1); assert_eq!(world.objectives[&format!("{namespace}:debrief")], 1); let final_boss_tick = active.registry.missions[&pilot.mission] .spawns .iter() .filter(|spawn| active.registry.enemies[&spawn.enemy].boss) .map(|spawn| spawn.tick) .max() .unwrap(); assert!(world.tick > final_boss_tick); let credits = world.player.credits; world .step(Input::default(), &active.registry, &mut active.scripts) .unwrap(); assert_eq!( world.player.credits, credits, "{mission} paid victory twice" ); eprintln!( "{mission}: complete at {}, shield {start_shield} -> {}, credits {}, classic types {}, moving types {}", world.tick, world.player.shield, world.player.credits, native_seen.len(), native_motion.len() ); let native_definitions = native_seen .iter() .map(|id| { let definition = &active.registry.enemies[id]; let legacy = definition.legacy.as_ref().unwrap(); json!({ "id":id, "name":definition.name, "native_path_points":legacy.fields[19], "native_move_type":legacy.fields[21], "native_guns":legacy.fields[22] }) }) .collect::>(); reports.push(json!({ "mission":mission, "completion_tick":world.tick, "start_shield":start_shield, "start_credits":start_credits, "energy_refills":paid_refills - refills_before, "final_shield":world.player.shield, "credits":world.player.credits, "objectives":world.objectives, "native_enemies":native_definitions, "native_motion_seen":native_motion, "digest":world.digest() })); pilot = world.pilot(&pilot); assert!( !pilot .inventory .contains_key(&"pulse_cannon:equipment".into()), "{mission} retained a mission-only Pulse loan" ); assert!( !pilot.inventory.contains_key(&"ion_lance:equipment".into()), "{mission} retained a mission-only Ion loan" ); pilot.merge_runtime_snapshot(&active.pins, &active.scripts.states, &active.scripts.rng); pilot.complete_mission(&active.registry).unwrap(); assert_eq!(pilot.completed.len(), number + 1); 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; } assert_eq!(pilot.mission.0, MISSIONS[5]); assert_eq!(pilot.completed.len(), 6); // A completed finale must work again with its serialized mission and boss // state, rather than inheriting completion gates or dead entity cursors. let mut replay = World::new(&pilot.mission, &pilot, &active.registry).unwrap(); let replay_limit = active.registry.missions[&pilot.mission].duration + 1600; while replay.tick < replay_limit && replay.outcome == Outcome::Playing { let input = campaign_input(&replay, &active.registry); replay .step(input, &active.registry, &mut active.scripts) .unwrap(); } assert_eq!( replay.outcome, Outcome::Complete, "finale replay: {:?}", replay.objectives ); assert_eq!(replay.objectives["iron_coast_boss_6:defeated"], 2); assert_eq!(replay.objectives["iron_coast_mission_6:complete"], 1); assert_eq!(replay.objectives["iron_coast_mission_6:debrief"], 1); assert!(replay.player.shield > 0); let replay_report = json!({ "completion_tick":replay.tick, "final_shield":replay.player.shield, "credits":replay.player.credits, "objectives":replay.objectives, "digest":replay.digest() }); pilot = replay.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(), 6); assert_eq!(pilot.mission.0, MISSIONS[5]); 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() ); eprintln!( "Finale replay: {} ticks, {} shield", replay.tick, replay.player.shield ); let report = serde_json::to_string_pretty(&json!({ "original_catalog":true, "package_count":active.pins.len(), "package_pins":active.pins, "paid_refills":paid_refills, "reports":reports, "finale_replay":replay_report })) .unwrap(); if let Ok(report_path) = std::env::var("RAPTOR_IRON_COAST_REPORT") { let report_path = PathBuf::from(report_path); if let Some(parent) = report_path.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(&report_path, format!("{report}\n")).unwrap(); eprintln!("Campaign acceptance report: {}", report_path.display()); } eprintln!( "Six-stage route and recovered finale replay passed; {paid_refills} paid Energy refills" ); }