Add separate Iron Coast mission, weapon and enemy mods with SD HD artwork and audio
This commit is contained in:
1 parent
dbb1d4596b
commit
967ba69ee8
573 files changed
+6515
No files matched your search
@@ -0,0 +1,452 @@
|
||||
//! Enemy package tests use synthetic PNG/WAV fixtures, never original/generated art.
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const ENEMIES: [&str; 3] = ["iron_coast_scout", "iron_coast_turret", "iron_coast_boss"];
|
||||
|
||||
fn base() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"weapons": [{"id":"base:projectile_forward_guns", "name":"Forward Guns",
|
||||
"damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all",
|
||||
"offsets":[[-8,0],[8,0]]}],
|
||||
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,
|
||||
"weapon":"base:projectile_forward_guns"},
|
||||
{"id":"base:energy","name":"Shield","price":0,"max_count":100}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Synthetic Range","duration":5000}]
|
||||
}))
|
||||
.unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn wav() -> Vec<u8> {
|
||||
let mut data = b"RIFF".to_vec();
|
||||
data.extend(40_u32.to_le_bytes());
|
||||
data.extend(b"WAVEfmt ");
|
||||
data.extend(16_u32.to_le_bytes());
|
||||
data.extend(1_u16.to_le_bytes());
|
||||
data.extend(1_u16.to_le_bytes());
|
||||
data.extend(22050_u32.to_le_bytes());
|
||||
data.extend(44100_u32.to_le_bytes());
|
||||
data.extend(2_u16.to_le_bytes());
|
||||
data.extend(16_u16.to_le_bytes());
|
||||
data.extend(b"data");
|
||||
data.extend(4_u32.to_le_bytes());
|
||||
data.extend([0, 0, 0, 0]);
|
||||
data
|
||||
}
|
||||
|
||||
fn install_fixture(root: &Path, id: &str) {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("examples/mods")
|
||||
.join(id);
|
||||
let output = root.join(id);
|
||||
fs::create_dir_all(output.join("scripts")).unwrap();
|
||||
fs::create_dir_all(output.join("art")).unwrap();
|
||||
fs::create_dir_all(output.join("audio")).unwrap();
|
||||
for name in ["mod.toml", "content.json", "scripts/ai.rhai"] {
|
||||
fs::copy(source.join(name), output.join(name)).unwrap();
|
||||
}
|
||||
let content: ContentPack =
|
||||
serde_json::from_slice(&fs::read(source.join("content.json")).unwrap()).unwrap();
|
||||
for art in content
|
||||
.enemies
|
||||
.iter()
|
||||
.filter_map(|e| e.art.as_ref())
|
||||
.chain(content.weapons.iter().filter_map(|w| w.art.as_ref()))
|
||||
{
|
||||
for (path, scale) in [
|
||||
(art.png.as_ref().unwrap(), 1),
|
||||
(art.hd_png.as_ref().unwrap(), 2),
|
||||
] {
|
||||
let mut image = image::RgbaImage::new(
|
||||
art.logical_width * art.frames * scale,
|
||||
art.logical_height * scale,
|
||||
);
|
||||
for frame in 0..art.frames {
|
||||
for y in scale..(art.logical_height - 1) * scale {
|
||||
for x in scale..(art.logical_width - 1) * scale {
|
||||
image.put_pixel(
|
||||
frame * art.logical_width * scale + x,
|
||||
y,
|
||||
image::Rgba([80, 90, 100, 255]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
image.save(output.join(path)).unwrap();
|
||||
}
|
||||
}
|
||||
for sound in content.sounds {
|
||||
fs::write(output.join(sound.file), wav()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn active(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
for id in ids {
|
||||
install_fixture(temp.path(), id);
|
||||
}
|
||||
let discovered = modding::discover(temp.path());
|
||||
assert!(discovered.errors.is_empty(), "{:?}", discovered.errors);
|
||||
let selected: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
|
||||
let active = ActiveMods::build(&base(), &discovered, &selected, None).unwrap();
|
||||
(temp, active)
|
||||
}
|
||||
|
||||
fn world(active: &ActiveMods) -> World {
|
||||
World::new(
|
||||
&ContentId::from("base:sector_1_wave_1"),
|
||||
&Pilot::default(),
|
||||
&active.registry,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_enemy_packages_validate_independently_and_together() {
|
||||
for id in ENEMIES {
|
||||
let (_temp, a) = active(&[id]);
|
||||
assert_eq!(a.pins.len(), 1);
|
||||
assert_eq!(a.registry.enemies.len(), 1);
|
||||
assert!(
|
||||
a.registry
|
||||
.weapons
|
||||
.contains_key(&ContentId::new(format!("{id}:projectile")).unwrap())
|
||||
);
|
||||
assert_eq!(a.registry.sounds.len(), 2);
|
||||
assert_eq!(
|
||||
a.registry.enemies[&ContentId::new(format!("{id}:unit")).unwrap()]
|
||||
.script
|
||||
.as_deref(),
|
||||
Some("scripts/ai.rhai")
|
||||
);
|
||||
}
|
||||
let (_temp, a) = active(&ENEMIES);
|
||||
assert_eq!(a.pins.len(), 3);
|
||||
assert_eq!(a.registry.enemies.len(), 3);
|
||||
assert!(a.registry.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scout_weaves_in_both_directions_and_aims_hostile_fire() {
|
||||
let (_temp, mut a) = active(&["iron_coast_scout"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_scout:unit"),
|
||||
120,
|
||||
0,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut right = false;
|
||||
let mut left = false;
|
||||
let mut sounds = 0;
|
||||
for _ in 0..95 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
let e = &w.enemies[0];
|
||||
right |= e.vx > 0;
|
||||
left |= e.vx < 0;
|
||||
assert!(e.x >= 24 && e.x <= 272);
|
||||
assert_eq!(e.vy, 1);
|
||||
sounds += w
|
||||
.mod_sounds
|
||||
.iter()
|
||||
.filter(|id| id.0 == "iron_coast_scout:shot")
|
||||
.count();
|
||||
}
|
||||
assert!(right && left);
|
||||
assert_eq!(w.enemies[0].y, 94);
|
||||
assert!(sounds >= 2);
|
||||
assert!(
|
||||
w.projectiles
|
||||
.iter()
|
||||
.any(|p| p.hostile && p.weapon.0 == "iron_coast_scout:projectile" && p.vx < 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turret_scrolls_with_terrain_and_aims_at_player() {
|
||||
let (_temp, mut a) = active(&["iron_coast_turret"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_turret:unit"),
|
||||
220,
|
||||
0,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut sounds = 0;
|
||||
for _ in 0..80 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
sounds += w
|
||||
.mod_sounds
|
||||
.iter()
|
||||
.filter(|id| id.0 == "iron_coast_turret:shot")
|
||||
.count();
|
||||
}
|
||||
assert_eq!((w.enemies[0].x, w.enemies[0].y), (220, 80));
|
||||
assert!(a.registry.enemies[&w.enemies[0].definition].ground);
|
||||
assert_eq!(sounds, 2);
|
||||
assert!(
|
||||
w.projectiles
|
||||
.iter()
|
||||
.any(|p| p.hostile && p.vx < 0 && p.vy >= 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_enters_patrols_fires_fans_and_remains_on_screen() {
|
||||
let (_temp, mut a) = active(&["iron_coast_boss"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_boss:unit"),
|
||||
112,
|
||||
-64,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut left = false;
|
||||
let mut right = false;
|
||||
let mut fans = 0;
|
||||
for tick in 0..1500 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(w.outcome, Outcome::Playing);
|
||||
assert_eq!(w.enemies.len(), 1, "boss retired at{tick}");
|
||||
let e = &w.enemies[0];
|
||||
assert_eq!((e.width, e.height), (96, 64));
|
||||
if tick > 46 {
|
||||
assert_eq!(e.y, 24);
|
||||
assert!((80..=144).contains(&e.x));
|
||||
left |= e.vx < 0;
|
||||
right |= e.vx > 0;
|
||||
}
|
||||
if w.mod_sounds
|
||||
.contains(&ContentId::from("iron_coast_boss:shot"))
|
||||
{
|
||||
let bullets: Vec<_> = w
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| p.age == 0 && p.hostile)
|
||||
.collect();
|
||||
assert_eq!(bullets.len(), 3);
|
||||
assert_eq!(bullets.iter().map(|p| p.vx).collect::<Vec<_>>(), [-2, 0, 2]);
|
||||
fans += 1;
|
||||
}
|
||||
}
|
||||
assert!(left && right);
|
||||
assert!(fans >= 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_can_be_defeated_with_starter_guns_without_healing() {
|
||||
let (_temp, mut a) = active(&["iron_coast_boss"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 128;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_boss:unit"),
|
||||
112,
|
||||
-64,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut defeated_at = None;
|
||||
for tick in 0..600 {
|
||||
w.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&a.registry,
|
||||
&mut a.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
if w.objectives.get("iron_coast_boss:defeated") == Some(&1) {
|
||||
defeated_at = Some(tick);
|
||||
break;
|
||||
}
|
||||
assert_eq!(w.outcome, Outcome::Playing, "starter pilot died at{tick}");
|
||||
}
|
||||
assert!(
|
||||
defeated_at.is_some(),
|
||||
"boss cannot be killed with starter guns"
|
||||
);
|
||||
assert!(w.enemies.is_empty());
|
||||
assert!(w.player.shield > 0);
|
||||
assert_eq!(w.player.credits, 1800);
|
||||
assert!(
|
||||
w.mod_sounds
|
||||
.contains(&ContentId::from("iron_coast_boss:death"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn death_rewards_objectives_and_sounds_are_emitted_exactly_once() {
|
||||
let (_temp, mut a) = active(&ENEMIES);
|
||||
let mut w = world(&a);
|
||||
for (index, id) in ENEMIES.iter().enumerate() {
|
||||
let unit = ContentId::new(format!("{id}:unit")).unwrap();
|
||||
let entity = w
|
||||
.spawn(&unit, 30 + index as i32 * 100, 0, &a.registry)
|
||||
.unwrap();
|
||||
w.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert!(w.enemies.is_empty());
|
||||
assert_eq!(w.player.credits, 75 + 120 + 1800);
|
||||
for id in ENEMIES {
|
||||
assert_eq!(w.objectives.get(&format!("{id}:defeated")), Some(&1));
|
||||
assert!(
|
||||
w.mod_sounds
|
||||
.contains(&ContentId::new(format!("{id}:death")).unwrap())
|
||||
);
|
||||
}
|
||||
let previous = w.player.credits;
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(w.player.credits, previous);
|
||||
assert!(w.mod_sounds.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enemy_scripts_replay_identically_and_restore_serialized_state() {
|
||||
let (_temp, mut first) = active(&ENEMIES);
|
||||
let (_other, mut second) = active(&ENEMIES);
|
||||
let mut a = world(&first);
|
||||
for (index, id) in ENEMIES.iter().enumerate() {
|
||||
a.spawn(
|
||||
&ContentId::new(format!("{id}:unit")).unwrap(),
|
||||
60 + index as i32 * 30,
|
||||
-24,
|
||||
&first.registry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
a.player.x = 0;
|
||||
let mut b = a.clone();
|
||||
for _ in 0..75 {
|
||||
a.step(Input::default(), &first.registry, &mut first.scripts)
|
||||
.unwrap();
|
||||
b.step(Input::default(), &second.registry, &mut second.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(a.digest(), b.digest());
|
||||
assert_eq!(first.scripts.checkpoints(), second.scripts.checkpoints());
|
||||
}
|
||||
let snapshot = serde_json::to_vec(&a).unwrap();
|
||||
b = serde_json::from_slice(&snapshot).unwrap();
|
||||
let checkpoint = first.scripts.checkpoints();
|
||||
second.scripts.restore(checkpoint);
|
||||
for _ in 0..25 {
|
||||
a.step(Input::default(), &first.registry, &mut first.scripts)
|
||||
.unwrap();
|
||||
b.step(Input::default(), &second.registry, &mut second.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(a.digest(), b.digest());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mission_defeat_counts_reset_while_persistent_script_statistics_continue() {
|
||||
let (_temp, mut a) = active(&ENEMIES);
|
||||
for id in ENEMIES {
|
||||
let unit = ContentId::new(format!("{id}:unit")).unwrap();
|
||||
let mut first = world(&a);
|
||||
for _ in 0..2 {
|
||||
let entity = first.spawn(&unit, 20, 0, &a.registry).unwrap();
|
||||
first
|
||||
.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
first
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
first
|
||||
.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(first.objectives.get(&format!("{id}:defeated")), Some(&2));
|
||||
let mut next = world(&a);
|
||||
let entity = next.spawn(&unit, 20, 0, &a.registry).unwrap();
|
||||
next.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
next.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
next.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(next.objectives.get(&format!("{id}:defeated")), Some(&1));
|
||||
let state = a.scripts.checkpoints().0;
|
||||
assert_eq!(state[&format!("{id}/scripts/ai.rhai")]["destroyed"], 3);
|
||||
if id == "iron_coast_boss" {
|
||||
assert!(
|
||||
state[&format!("{id}/scripts/ai.rhai")]["directions"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_enemy_assets_are_valid_and_aligned() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mods");
|
||||
let selected: Vec<_> = ENEMIES.iter().map(|id| id.to_string()).collect();
|
||||
let discovery = modding::discover(&root);
|
||||
ActiveMods::build(&base(), &discovery, &selected, None).unwrap();
|
||||
for id in ENEMIES {
|
||||
let content: ContentPack =
|
||||
serde_json::from_slice(&fs::read(root.join(id).join("content.json")).unwrap()).unwrap();
|
||||
let art = content.enemies[0].art.as_ref().unwrap();
|
||||
for path in [art.png.as_ref().unwrap(), art.hd_png.as_ref().unwrap()] {
|
||||
let image = image::open(root.join(id).join(path)).unwrap().into_rgba8();
|
||||
assert_eq!(
|
||||
image.width() / art.frames * art.logical_height,
|
||||
image.height() * art.logical_width
|
||||
);
|
||||
for frame in 0..art.frames {
|
||||
let width = image.width() / art.frames;
|
||||
for x in frame * width..(frame + 1) * width {
|
||||
assert!(image.get_pixel(x, 0)[3] < 128);
|
||||
assert!(image.get_pixel(x, image.height() - 1)[3] < 128);
|
||||
}
|
||||
for y in 0..image.height() {
|
||||
assert!(image.get_pixel(frame * width, y)[3] < 128);
|
||||
assert!(image.get_pixel((frame + 1) * width - 1, y)[3] < 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Campaign acceptance uses synthetic PNG/WAV assets; original data is unnecessary.
|
||||
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; 7] = [
|
||||
"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 pack:ContentPack=serde_json::from_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}]
|
||||
})).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn fixture_resources(value: &Value, root: &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();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
fixture_resources(child, root);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn active() -> (tempfile::TempDir, ActiveMods) {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/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 seven Iron Coast 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);
|
||||
}
|
||||
let discovery = modding::discover(temporary.path());
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let enabled = PACKAGES.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let active = ActiveMods::build(&base(), &discovery, &enabled, None).unwrap();
|
||||
(temporary, active)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_dependencies_and_generated_footprints_validate_together() {
|
||||
let (_temporary, active) = active();
|
||||
assert_eq!(active.pins.len(), 7);
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
(world, seen)
|
||||
}
|
||||
|
||||
#[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_can_complete_real_waves_and_boss() {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Validate the shipped weapon definitions/scripts using synthetic PNG and WAV inputs.
|
||||
//! No original or locally generated game artwork is needed in CI.
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const PACKAGES: [&str; 3] = ["pulse_cannon", "seeker_swarm", "ion_lance"];
|
||||
fn content(value: Value) -> ContentPack {
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
fn base() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(content(json!({
|
||||
"items": [{"id":"base:energy","name":"Shield","price":100,"max_count":100}],
|
||||
"missions": [{"id":"base:practice","name":"Practice","duration":2000}],
|
||||
"enemies": [
|
||||
{"id":"base:target","name":"Stationary target","health":100,"speed":0},
|
||||
{"id":"base:ground_target","name":"Ground target","health":100,"speed":0,"ground":true}
|
||||
]
|
||||
})), "base").unwrap();
|
||||
registry
|
||||
}
|
||||
fn wav() -> Vec<u8> {
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend(164u32.to_le_bytes());
|
||||
bytes.extend(b"WAVEfmt ");
|
||||
bytes.extend(16u32.to_le_bytes());
|
||||
bytes.extend(1u16.to_le_bytes());
|
||||
bytes.extend(1u16.to_le_bytes());
|
||||
bytes.extend(11025u32.to_le_bytes());
|
||||
bytes.extend(22050u32.to_le_bytes());
|
||||
bytes.extend(2u16.to_le_bytes());
|
||||
bytes.extend(16u16.to_le_bytes());
|
||||
bytes.extend(b"data");
|
||||
bytes.extend(128u32.to_le_bytes());
|
||||
bytes.extend([0; 128]);
|
||||
bytes
|
||||
}
|
||||
fn install_synthetic(root: &Path, id: &str) {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("examples/mods")
|
||||
.join(id);
|
||||
let target = root.join(id);
|
||||
fs::create_dir_all(target.join("scripts")).unwrap();
|
||||
fs::create_dir_all(target.join("art")).unwrap();
|
||||
fs::create_dir_all(target.join("audio")).unwrap();
|
||||
for file in [
|
||||
"mod.toml",
|
||||
"content.json",
|
||||
"scripts/weapon.rhai",
|
||||
"scripts/equipment.rhai",
|
||||
] {
|
||||
fs::copy(source.join(file), target.join(file)).unwrap();
|
||||
}
|
||||
let definitions: Value =
|
||||
serde_json::from_slice(&fs::read(target.join("content.json")).unwrap()).unwrap();
|
||||
for entry in definitions["weapons"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.chain(definitions["items"].as_array().unwrap())
|
||||
{
|
||||
let art = &entry["art"];
|
||||
for (property, scale) in [("png", 1), ("hd_png", 4)] {
|
||||
let image = image::RgbaImage::from_pixel(
|
||||
art["logical_width"].as_u64().unwrap() as u32 * scale,
|
||||
art["logical_height"].as_u64().unwrap() as u32 * scale,
|
||||
image::Rgba([60, 90, 120, 255]),
|
||||
);
|
||||
image
|
||||
.save(target.join(art[property].as_str().unwrap()))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
for sound in definitions["sounds"].as_array().unwrap() {
|
||||
fs::write(target.join(sound["file"].as_str().unwrap()), wav()).unwrap();
|
||||
}
|
||||
}
|
||||
fn activate(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for id in ids {
|
||||
install_synthetic(root.path(), id);
|
||||
}
|
||||
let discovery = modding::discover(root.path());
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let active = ActiveMods::build(
|
||||
&base(),
|
||||
&discovery,
|
||||
&ids.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
(root, active)
|
||||
}
|
||||
fn pilot(id: &str, registry: &Registry) -> Pilot {
|
||||
let equipment = ContentId::new(format!("{id}:equipment")).unwrap();
|
||||
Pilot {
|
||||
inventory: [(equipment.clone(), 1), ("base:energy".into(), 100)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
selected: registry.items[&equipment].special.then_some(equipment),
|
||||
mission: "base:practice".into(),
|
||||
..Pilot::default()
|
||||
}
|
||||
}
|
||||
fn world(id: &str, registry: &Registry) -> World {
|
||||
World::new(&"base:practice".into(), &pilot(id, registry), registry).unwrap()
|
||||
}
|
||||
fn state<'a>(active: &'a ActiveMods, id: &str) -> &'a Value {
|
||||
&active.scripts.states[&format!("{id}/scripts/weapon.rhai")]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_weapon_package_is_independent_and_the_three_can_coexist() {
|
||||
for id in PACKAGES {
|
||||
let (_root, active) = activate(&[id]);
|
||||
assert_eq!(active.pins.len(), 1);
|
||||
assert_eq!(active.pins[0].version, "1.0.0");
|
||||
assert_eq!(active.registry.weapons.len(), 1);
|
||||
assert_eq!(active.registry.sounds.len(), 2);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.items
|
||||
.contains_key(&format!("{id}:equipment").as_str().into())
|
||||
);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.weapons
|
||||
.contains_key(&format!("{id}:projectile").as_str().into())
|
||||
);
|
||||
}
|
||||
let (_root, all) = activate(&PACKAGES);
|
||||
assert_eq!(all.pins.len(), 3);
|
||||
assert_eq!(all.registry.weapons.len(), 3);
|
||||
assert_eq!(all.registry.sounds.len(), 6);
|
||||
assert!(all.registry.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shop_charges_each_price_and_rejects_duplicate_equipment() {
|
||||
let (_root, active) = activate(&PACKAGES);
|
||||
for id in PACKAGES {
|
||||
let item: ContentId = format!("{id}:equipment").as_str().into();
|
||||
let price = active.registry.items[&item].price;
|
||||
let mut pilot = Pilot {
|
||||
credits: price,
|
||||
inventory: Default::default(),
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.buy(&item, &active.registry).unwrap();
|
||||
assert_eq!(pilot.credits, 0);
|
||||
assert_eq!(pilot.inventory[&item], 1);
|
||||
assert!(pilot.buy(&item, &active.registry).is_err());
|
||||
pilot.sell(&item, &active.registry).unwrap();
|
||||
assert!(pilot.credits > 0);
|
||||
assert_eq!(pilot.inventory.get(&item).copied().unwrap_or(0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_cannon_emits_paired_rounds_and_every_third_burst_is_scripted() {
|
||||
let (_root, mut active) = activate(&["pulse_cannon"]);
|
||||
let mut world = world("pulse_cannon", &active.registry);
|
||||
for _ in 0..19 {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(state(&active, "pulse_cannon")["bursts"], 3);
|
||||
assert_eq!(world.projectiles.len(), 8);
|
||||
assert_eq!(world.projectiles.iter().filter(|p| p.vx != 0).count(), 2);
|
||||
assert_eq!(world.mod_sounds, vec![ContentId::from("pulse_cannon:fire")]);
|
||||
assert!(!world.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_hits_deal_damage_and_run_effect_audio_and_hit_state_callbacks() {
|
||||
let (_root, mut active) = activate(&["pulse_cannon"]);
|
||||
let mut world = world("pulse_cannon", &active.registry);
|
||||
world
|
||||
.spawn(&"base:target".into(), 140, 100, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
let mut hit_sound = false;
|
||||
for _ in 0..8 {
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
hit_sound |= world.mod_sounds.contains(&"pulse_cannon:impact".into());
|
||||
}
|
||||
assert_eq!(world.enemies[0].health, 92);
|
||||
assert_eq!(state(&active, "pulse_cannon")["hits"], 2);
|
||||
assert!(hit_sound);
|
||||
assert!(!world.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeker_swarm_homes_and_fragmentation_damages_nearby_enemies() {
|
||||
let (_root, mut active) = activate(&["seeker_swarm"]);
|
||||
let mut world = world("seeker_swarm", &active.registry);
|
||||
let primary = world
|
||||
.spawn(&"base:target".into(), 180, 110, &active.registry)
|
||||
.unwrap();
|
||||
let neighbor = world
|
||||
.spawn(&"base:ground_target".into(), 180, 110, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.projectiles.len(), 3);
|
||||
assert!(world.projectiles.iter().all(|p| p.vx > 0));
|
||||
for _ in 0..12 {
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
let target = world.enemies.iter().find(|e| e.entity == primary).unwrap();
|
||||
let nearby = world.enemies.iter().find(|e| e.entity == neighbor).unwrap();
|
||||
assert_eq!(target.health, 82);
|
||||
assert_eq!(nearby.health, 94);
|
||||
assert_eq!(state(&active, "seeker_swarm")["hits"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ion_lance_pierces_air_and_ground_targets_in_its_column_only() {
|
||||
let (_root, mut active) = activate(&["ion_lance"]);
|
||||
let mut world = world("ion_lance", &active.registry);
|
||||
world
|
||||
.spawn(&"base:target".into(), 144, 100, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.spawn(&"base:ground_target".into(), 144, 60, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.spawn(&"base:target".into(), 220, 60, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
|
||||
[84, 84, 100]
|
||||
);
|
||||
assert_eq!(state(&active, "ion_lance")["hits"], 4);
|
||||
assert!(world.mod_sounds.contains(&"ion_lance:impact".into()));
|
||||
assert!(!world.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipped_specials_and_primary_upgrade_fire_without_consuming_equipment() {
|
||||
let (_root, mut active) = activate(&PACKAGES);
|
||||
let mut pilot = pilot("pulse_cannon", &active.registry);
|
||||
pilot.inventory.insert("seeker_swarm:equipment".into(), 1);
|
||||
pilot.inventory.insert("ion_lance:equipment".into(), 1);
|
||||
pilot.selected = Some("ion_lance:equipment".into());
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state(&active, "pulse_cannon")["bursts"], 1);
|
||||
assert_eq!(state(&active, "ion_lance")["bursts"], 1);
|
||||
assert!(
|
||||
!active
|
||||
.scripts
|
||||
.states
|
||||
.contains_key("seeker_swarm/scripts/weapon.rhai")
|
||||
);
|
||||
for id in PACKAGES {
|
||||
assert_eq!(
|
||||
world.player.inventory[&format!("{id}:equipment").as_str().into()],
|
||||
1
|
||||
);
|
||||
}
|
||||
world.player.selected = Some("seeker_swarm:equipment".into());
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
special: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state(&active, "seeker_swarm")["bursts"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_bursts_and_state_resume_deterministically() {
|
||||
let (_root, mut active) = activate(&["pulse_cannon"]);
|
||||
let mut world = world("pulse_cannon", &active.registry);
|
||||
for _ in 0..10 {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut resumed = world.clone();
|
||||
let (_other_root, mut other) = activate(&["pulse_cannon"]);
|
||||
other.scripts.states = active.scripts.states.clone();
|
||||
other.scripts.rng = active.scripts.rng.clone();
|
||||
for _ in 0..20 {
|
||||
let input = Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
};
|
||||
world
|
||||
.step(input, &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
resumed
|
||||
.step(input, &other.registry, &mut other.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(world.digest(), resumed.digest());
|
||||
assert_eq!(active.scripts.states, other.scripts.states);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&active.scripts.rng).unwrap(),
|
||||
serde_json::to_value(&other.scripts.rng).unwrap()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user