705 lines
28 KiB
Rust
705 lines
28 KiB
Rust
//! 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<tempfile::TempDir> = OnceLock::new();
|
|
FIXTURE
|
|
.get_or_init(|| {
|
|
let temporary = tempfile::tempdir().unwrap();
|
|
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods");
|
|
let mut visited = BTreeSet::new();
|
|
let mut packages = vec![CAMPAIGN.to_string()];
|
|
while let Some(id) = packages.pop() {
|
|
if !visited.insert(id.clone()) {
|
|
continue;
|
|
}
|
|
let original = source.join(&id);
|
|
let root = temporary.path().join(&id);
|
|
fs::create_dir_all(&root).unwrap();
|
|
let manifest_data = fs::read(original.join("mod.toml")).unwrap();
|
|
let manifest: Manifest =
|
|
toml::from_str(std::str::from_utf8(&manifest_data).unwrap()).unwrap();
|
|
packages.extend(manifest.dependencies.keys().cloned());
|
|
fs::write(root.join("mod.toml"), manifest_data).unwrap();
|
|
let content = fs::read(original.join(&manifest.content)).unwrap();
|
|
let value: Value = serde_json::from_slice(&content).unwrap();
|
|
fs::write(root.join(&manifest.content), content).unwrap();
|
|
for script in manifest.scripts {
|
|
let path = root.join(&script);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::copy(original.join(script), path).unwrap();
|
|
}
|
|
fixture_resources(&value, &root, &original);
|
|
}
|
|
temporary
|
|
})
|
|
.path()
|
|
}
|
|
|
|
fn base() -> Registry {
|
|
let mut registry = Registry::default();
|
|
let mut value = json!({
|
|
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
|
|
"items":[
|
|
{"id":"base:forward_guns","name":"Forward Guns","price":0,"max_count":1,"weapon":"base:projectile_forward_guns","legacy":0},
|
|
{"id":"base:energy","name":"Shield","price":10000,"initial_count":25,"max_count":100,"legacy":16},
|
|
{"id":"base:super_shield","name":"Super Shield","price":78500,"initial_count":100,"max_count":100,"legacy":15}
|
|
],
|
|
"missions":[{"id":"base:sector_1_wave_1","name":"Base fixture","duration":1000}]
|
|
});
|
|
for n in 0..18 {
|
|
value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1}));
|
|
}
|
|
let pack: ContentPack = serde_json::from_value(value).unwrap();
|
|
registry.apply(pack, "base").unwrap();
|
|
registry.validate().unwrap();
|
|
registry
|
|
}
|
|
|
|
fn discovery() -> modding::Discovery {
|
|
let discovery = modding::discover(fixture_directory());
|
|
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
|
discovery
|
|
}
|
|
|
|
/// Native IDs are present only to validate the portable dependency graph and
|
|
/// first-step inventory grants. These placeholders supply no native behavior.
|
|
/// The playable campaign test below uses the original catalog, not this base.
|
|
fn structural_base(discovery: &modding::Discovery) -> Registry {
|
|
let mut registry = base();
|
|
let mut native = BTreeSet::new();
|
|
for package in discovery.mods.values() {
|
|
let pack: ContentPack = serde_json::from_slice(
|
|
&fs::read(package.root.join(&package.manifest.content)).unwrap(),
|
|
)
|
|
.unwrap();
|
|
for mission in pack.missions {
|
|
for spawn in mission.spawns {
|
|
if spawn.enemy.0.starts_with("base:enemy_") {
|
|
native.insert(spawn.enemy);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let enemies = native
|
|
.into_iter()
|
|
.map(|id| {
|
|
json!({
|
|
"id":id,
|
|
"name":"Structural native-ID placeholder; no gameplay acceptance",
|
|
"health":1,
|
|
"speed":0
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
registry
|
|
.apply(
|
|
serde_json::from_value(json!({"enemies":enemies})).unwrap(),
|
|
"base",
|
|
)
|
|
.unwrap();
|
|
registry.validate().unwrap();
|
|
registry
|
|
}
|
|
|
|
fn active() -> ActiveMods {
|
|
let discovery = discovery();
|
|
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
|
|
ActiveMods::build(&structural_base(&discovery), &discovery, &enabled, None).unwrap()
|
|
}
|
|
|
|
fn active_original() -> ActiveMods {
|
|
let data = std::env::var_os("RAPTOR_TEST_DATA")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data"));
|
|
let catalog = Catalog::open(data).unwrap();
|
|
let registry = Registry::base(&catalog).unwrap();
|
|
let discovery = modding::discover(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods"));
|
|
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
|
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
|
|
ActiveMods::build(®istry, &discovery, &enabled, Some(&catalog)).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn 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::<Vec<_>>(),
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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"
|
|
);
|
|
}
|