533 lines
19 KiB
Rust
533 lines
19 KiB
Rust
//! 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<u8> {
|
|
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<u8> {
|
|
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);
|
|
}
|