//! Original shield-module inventory and recharge behavior, with synthetic data. use raptor_replica::{ content::{ContentId, ContentPack, Registry}, modding::{Command, ScriptRuntime}, persistence::Pilot, simulation::{Input, Pickup, World}, }; use serde_json::json; fn fixture() -> Registry { let mut items = vec![ json!({"id":"base:forward_guns","name":"Guns","price":12000,"weapon":"base:shot","legacy":0}), json!({"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"shield":25,"legacy":16}), json!({"id":"base:super_shield","name":"Super Shield","price":78500,"max_count":100,"initial_count":100,"legacy":15}), ]; for n in 0..20 { items.push(json!({"id":format!("base:filler_{n}"),"name":"Inventory slot","price":1})); } let pack: ContentPack = serde_json::from_value(json!({ "items":items, "weapons":[{"id":"base:shot","name":"Gun","damage":1,"speed":8,"cooldown":5,"behavior":"straight","target":"all"}], "missions":[{"id":"base:sector_1_wave_1","name":"Shield fixture","duration":100000}] })).unwrap(); let mut registry = Registry::default(); registry.apply(pack, "base").unwrap(); registry.validate().unwrap(); registry } fn super_id() -> ContentId { "base:super_shield".into() } fn shielded(active: u32, reserves: &[u32]) -> Pilot { let mut pilot = Pilot { credits: 2_000_000, difficulty: 2, ..Pilot::default() }; pilot.inventory.insert(super_id(), active); pilot.super_shield_reserves = reserves.to_vec(); pilot } fn mission_world(pilot: &Pilot, registry: &Registry) -> World { World::new(&pilot.mission, pilot, registry).unwrap() } fn hit(world: &mut World, damage: i32, registry: &Registry) { world .apply_commands( &[Command::Damage { entity: 0, amount: damage, }], registry, ) .unwrap(); } fn idle(world: &mut World, ticks: usize, registry: &Registry) { let mut scripts = ScriptRuntime::new(); for _ in 0..ticks { world .step(Input::default(), registry, &mut scripts) .unwrap(); } } fn collect(world: &mut World, item: &str, registry: &Registry) { world.pickups.push(Pickup { entity: 9000, item: item.into(), x: world.player.x, y: world.player.y, legacy: None, }); idle(world, 1, registry); assert!(world.pickups.is_empty()); } #[test] fn purchases_preserve_active_charge_and_enforce_five_modules_and_twenty_slots() { let registry = fixture(); let mut pilot = shielded(17, &[]); for _ in 0..4 { pilot.buy(&super_id(), ®istry).unwrap(); } assert_eq!(pilot.inventory[&super_id()], 17); assert_eq!(pilot.super_shield_reserves, vec![100; 4]); assert_eq!(pilot.item_count(&super_id()), 5); assert_eq!(pilot.inventory_slots(), 7); let before = serde_json::to_value(&pilot).unwrap(); assert!(pilot.buy(&super_id(), ®istry).is_err()); assert_eq!(serde_json::to_value(&pilot).unwrap(), before); let mut full = shielded(100, &[100]); for n in 0..16 { full.inventory .insert(format!("base:filler_{n}").as_str().into(), 1); } assert_eq!(full.inventory_slots(), 20); assert!(full.buy(&super_id(), ®istry).is_err()); full.inventory.insert("base:energy".into(), 75); full.buy(&"base:energy".into(), ®istry).unwrap(); assert_eq!(full.inventory[&ContentId::from("base:energy")], 100); full.validate(®istry, &[]).unwrap(); } #[test] fn selling_removes_full_equipped_module_and_selects_first_reserve() { let registry = fixture(); let mut pilot = shielded(100, &[37, 100]); let credits = pilot.credits; pilot.sell(&super_id(), ®istry).unwrap(); assert_eq!(pilot.credits, credits + 39250); assert_eq!(pilot.inventory[&super_id()], 37); assert_eq!(pilot.super_shield_reserves, vec![100]); assert_eq!(pilot.item_count(&super_id()), 2); let before = serde_json::to_value(&pilot).unwrap(); assert!( pilot.sell(&super_id(), ®istry).is_err(), "partial active module cannot sell a reserve instead" ); assert_eq!(serde_json::to_value(&pilot).unwrap(), before); pilot.inventory.insert(super_id(), 100); pilot.sell(&super_id(), ®istry).unwrap(); pilot.sell(&super_id(), ®istry).unwrap(); assert_eq!(pilot.item_count(&super_id()), 0); assert!(!pilot.inventory.contains_key(&super_id())); } #[test] fn normal_energy_sales_remove_one_purchase_and_retain_positive_shield() { let registry = fixture(); let mut pilot = Pilot::default(); pilot.sell(&"base:energy".into(), ®istry).unwrap(); assert_eq!(pilot.inventory[&ContentId::from("base:energy")], 75); assert_eq!(pilot.credits, 5000); pilot.inventory.insert("base:energy".into(), 26); pilot.sell(&"base:energy".into(), ®istry).unwrap(); assert_eq!(pilot.inventory[&ContentId::from("base:energy")], 1); assert!(pilot.sell(&"base:energy".into(), ®istry).is_err()); } #[test] fn hits_do_not_spill_into_reserves_or_normal_shields_and_exact_zero_remains_equipped() { let registry = fixture(); let pilot = shielded(5, &[37, 0, 100]); let mut world = mission_world(&pilot, ®istry); hit(&mut world, 5, ®istry); assert_eq!(world.player.super_shield, 0); assert!(world.player.inventory.contains_key(&super_id())); assert_eq!(world.player.super_shield_reserves, vec![37, 0, 100]); hit(&mut world, 9000, ®istry); assert_eq!(world.player.super_shield, 37); assert_eq!(world.player.shield, 100); hit(&mut world, 38, ®istry); assert_eq!( world.player.super_shield, 0, "a zero-charge reserve may become equipped" ); assert!(world.player.inventory.contains_key(&super_id())); hit(&mut world, 1, ®istry); assert_eq!(world.player.super_shield, 100); hit(&mut world, 101, ®istry); assert!(!world.player.inventory.contains_key(&super_id())); assert!(world.player.super_shield_reserves.is_empty()); assert_eq!(world.player.shield, 100); hit(&mut world, 3, ®istry); assert_eq!(world.player.shield, 97); let saved = world.pilot(&pilot); assert!(saved.super_shield_reserves.is_empty()); assert_eq!(saved.inventory[&ContentId::from("base:energy")], 97); } #[test] fn rookie_damage_is_halved_before_module_depletion_and_round_trips() { let registry = fixture(); let mut pilot = shielded(5, &[71, 99]); pilot.difficulty = 0; let mut world = mission_world(&pilot, ®istry); hit(&mut world, 7, ®istry); assert_eq!(world.player.super_shield, 2); hit(&mut world, 4, ®istry); assert_eq!(world.player.super_shield, 0); hit(&mut world, 5, ®istry); assert_eq!(world.player.super_shield, 71); let saved = world.pilot(&pilot); saved.validate(®istry, &[]).unwrap(); let restored = mission_world(&saved, ®istry); assert_eq!(restored.player.super_shield, 71); assert_eq!(restored.player.super_shield_reserves, vec![99]); } #[test] fn pickups_add_modules_without_refilling_active_charge_and_energy_only_charges_active() { let registry = fixture(); let pilot = shielded(20, &[100; 4]); let mut world = mission_world(&pilot, ®istry); collect(&mut world, "base:super_shield", ®istry); assert_eq!(world.player.super_shield, 20); assert_eq!( world.player.super_shield_reserves, vec![100; 5], "native pickup capacity is slots; five is the shop limit" ); collect(&mut world, "base:energy", ®istry); assert_eq!(world.player.super_shield, 26); assert_eq!(world.player.super_shield_reserves, vec![100; 5]); world.player.shield = 90; collect(&mut world, "base:energy", ®istry); assert_eq!(world.player.shield, 100); assert_eq!( world.player.super_shield, 26, "normal-shield overflow is discarded, not charged into a module" ); world.player.super_shield = 0; world.player.inventory.insert(super_id(), 0); collect(&mut world, "base:energy", ®istry); assert_eq!( world.player.super_shield, 0, "normal energy cannot revive an empty equipped module" ); let mut full = shielded(20, &[99]); for n in 0..16 { full.inventory .insert(format!("base:filler_{n}").as_str().into(), 1); } let mut world = mission_world(&full, ®istry); collect(&mut world, "base:super_shield", ®istry); assert_eq!(world.player.super_shield, 20); assert_eq!(world.player.super_shield_reserves, vec![99]); } #[test] fn idle_recharge_uses_97_steps_pauses_on_weapon_attempts_and_preserves_rng() { let registry = fixture(); let mut pilot = shielded(44, &[88]); pilot.inventory.insert("base:energy".into(), 75); let mut world = mission_world(&pilot, ®istry); let rng = world.rng; idle(&mut world, 96, ®istry); assert_eq!(world.player.shield, 75); assert_eq!(world.shield_recharge_ticks, 96); let checkpoint = world.pilot(&pilot); let mut world = mission_world(&checkpoint, ®istry); idle(&mut world, 1, ®istry); assert_eq!(world.player.shield, 76); assert_eq!(world.player.super_shield, 44); assert_eq!(world.player.super_shield_reserves, vec![88]); assert_eq!(world.rng, rng); idle(&mut world, 96, ®istry); world .step( Input { fire: true, ..Input::default() }, ®istry, &mut ScriptRuntime::new(), ) .unwrap(); assert_eq!(world.shield_recharge_ticks, 0); idle(&mut world, 96, ®istry); assert_eq!(world.player.shield, 76); idle(&mut world, 1, ®istry); assert_eq!(world.player.shield, 77); world.player.shield = 100; idle(&mut world, 97, ®istry); assert_eq!( world.player.super_shield, 44, "one-unit idle recharge rounds to zero for super shields" ); let mut ace = pilot.clone(); ace.difficulty = 3; let mut ace = mission_world(&ace, ®istry); idle(&mut ace, 200, ®istry); assert_eq!(ace.player.shield, 75); let mut ending = mission_world(&pilot, ®istry); ending.end_timer = Some(500); idle(&mut ending, 97, ®istry); assert_eq!(ending.player.shield, 75); } #[test] fn charge_unit_script_commands_preserve_reserves_and_rollback_all_module_state() { let registry = fixture(); let pilot = shielded(12, &[44, 100]); let mut world = mission_world(&pilot, ®istry); world .apply_commands( &[ Command::GiveItem { item: super_id().0, count: 16, }, Command::ConsumeItem { item: super_id().0, count: 18, }, ], ®istry, ) .unwrap(); assert_eq!(world.player.super_shield, 10); assert_eq!(world.player.inventory[&super_id()], 10); assert_eq!(world.player.super_shield_reserves, vec![44, 100]); let before = serde_json::to_value(&world).unwrap(); assert!( world .apply_commands( &[ Command::Damage { entity: 0, amount: 15 }, Command::Velocity { entity: 999, vx: 1, vy: 1 } ], ®istry ) .is_err() ); assert_eq!(serde_json::to_value(&world).unwrap(), before); world .apply_commands( &[ Command::ConsumeItem { item: super_id().0, count: 10, }, Command::ConsumeItem { item: "base:energy".into(), count: 100, }, ], ®istry, ) .unwrap(); assert_eq!(world.player.super_shield, 0); assert_eq!(world.player.shield, 0); assert_eq!(world.player.inventory[&super_id()], 0); assert_eq!(world.player.inventory[&ContentId::from("base:energy")], 0); assert_eq!(world.player.super_shield_reserves, vec![44, 100]); } #[test] fn consumed_equipment_releases_slot_selects_next_special_and_rolls_back_atomically() { let mut registry = fixture(); for n in 0..2 { let item = registry .items .get_mut(&ContentId::from(format!("base:filler_{n}").as_str())) .unwrap(); item.special = true; item.weapon = Some("base:shot".into()); } let first = ContentId::from("base:filler_0"); let second = ContentId::from("base:filler_1"); let mut pilot = shielded(17, &[100]); for n in 0..16 { pilot .inventory .insert(format!("base:filler_{n}").as_str().into(), 1); } pilot.selected = Some(first.clone()); assert_eq!(pilot.inventory_slots(), 20); let mut world = mission_world(&pilot, ®istry); let before = serde_json::to_value(&world).unwrap(); let consume = Command::ConsumeItem { item: first.0.clone(), count: 1, }; assert!( world .apply_commands( &[ consume.clone(), Command::Velocity { entity: 999, vx: 1, vy: 1, }, ], ®istry, ) .is_err() ); assert_eq!(serde_json::to_value(&world).unwrap(), before); world.apply_commands(&[consume], ®istry).unwrap(); assert!(!world.player.inventory.contains_key(&first)); assert_eq!(world.player.selected, Some(second.clone())); assert_eq!(world.pilot(&pilot).inventory_slots(), 19); world .apply_commands( &[ Command::GiveItem { item: "base:filler_16".into(), count: 1, }, Command::ConsumeItem { item: second.0, count: 1, }, ], ®istry, ) .unwrap(); assert_eq!(world.player.selected, None); assert_eq!(world.pilot(&pilot).inventory_slots(), 19); world.pilot(&pilot).validate(®istry, &[]).unwrap(); } #[test] fn version_one_saves_without_extended_fields_remain_readable_without_source_changes() { let registry = fixture(); let pilot = shielded(31, &[]); let mut old = serde_json::to_value(&pilot).unwrap(); old.as_object_mut().unwrap().remove("super_shield_reserves"); old.as_object_mut().unwrap().remove("shield_recharge_ticks"); let bytes = serde_json::to_vec(&old).unwrap(); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("old-v1.json"); std::fs::write(&path, &bytes).unwrap(); let saved = Pilot::load(&path, ®istry, &[]).unwrap(); assert_eq!(saved.item_count(&super_id()), 1); assert!(saved.super_shield_reserves.is_empty()); assert_eq!(saved.shield_recharge_ticks, 0); let mut world = mission_world(&saved, ®istry); hit(&mut world, 12, ®istry); assert_eq!(world.pilot(&saved).inventory[&super_id()], 19); assert_eq!(std::fs::read(path).unwrap(), bytes); let mut bad = shielded(100, &[101]); assert!(bad.validate(®istry, &[]).is_err()); bad.super_shield_reserves = vec![100]; bad.inventory.remove(&super_id()); assert!(bad.validate(®istry, &[]).is_err()); } #[test] fn failed_callback_restores_module_order_charge_recharge_and_script_state() { let mut registry = fixture(); registry .missions .get_mut(&ContentId::from("base:sector_1_wave_1")) .unwrap() .script = Some("failed.rhai".into()); let mut pilot = shielded(12, &[44, 100]); pilot.shield_recharge_ticks = 95; let mut world = mission_world(&pilot, ®istry); let mut scripts = ScriptRuntime::new(); scripts .add( "base", "failed.rhai", r#" fn on_mission_start(e, s, r) { #{commands:[#{op:"damage",entity:0,amount:15}, #{op:"velocity",entity:999,vx:1,vy:1}],state:#{fired:true}} } "#, ) .unwrap(); let before = serde_json::to_value(&world).unwrap(); let states = scripts.states.clone(); let rng = scripts.rng.clone(); assert!( world .step(Input::default(), ®istry, &mut scripts) .is_err() ); assert_eq!(serde_json::to_value(&world).unwrap(), before); assert_eq!(scripts.states, states); assert_eq!(scripts.rng, rng); } fn encrypt(bytes: &[u8]) -> Vec { let key = b"CASTLE"; let mut index = 25 % key.len(); let mut previous = key[index]; bytes .iter() .map(|clear| { let byte = clear.wrapping_add(key[index]).wrapping_add(previous); previous = byte; index = (index + 1) % key.len(); byte }) .collect() } fn legacy_module_save(modules: &[(i32, i32)]) -> Vec { let mut header = vec![0_u8; 88]; for (offset, value) in [ (40, -1_i32), (60, modules.len() as i32 + 2), (64, 2), (68, 2), (72, 2), (76, 2), ] { header[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } let mut records = vec![(1_i32, 0_i32, 1_i32), (75, 16, 1)]; records.extend(modules.iter().map(|&(charge, inuse)| (charge, 15, inuse))); let mut body = vec![]; for (charge, kind, inuse) in records { let mut record = vec![0_u8; 24]; for (offset, value) in [(8, charge), (12, kind), (20, inuse)] { record[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } body.extend(record); } [encrypt(&header), encrypt(&body)].concat() } #[test] fn legacy_import_preserves_independent_charges_equipped_flag_and_inventory_order() { let registry = fixture(); let bytes = legacy_module_save(&[(11, 0), (22, 0), (100, 1)]); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("CHAR0000.FIL"); std::fs::write(&path, &bytes).unwrap(); let pilot = Pilot::import_legacy(&path, ®istry).unwrap(); assert_eq!(pilot.inventory[&super_id()], 100); assert_eq!(pilot.super_shield_reserves, vec![11, 22]); assert_eq!(pilot.item_count(&super_id()), 3); let mut world = mission_world(&pilot, ®istry); hit(&mut world, 101, ®istry); assert_eq!(world.player.super_shield, 11); assert_eq!(world.player.super_shield_reserves, vec![22]); let output = temp.path().join("rust.json"); world.pilot(&pilot).save(&output).unwrap(); let restored = Pilot::load(&output, ®istry, &[]).unwrap(); assert_eq!(restored.item_count(&super_id()), 2); assert_eq!(std::fs::read(path).unwrap(), bytes); }