Add independent Rust engine, deterministic mod API and asset pipeline
This commit is contained in:
1 parent
f2cce6f51b
commit
dbb1d4596b
53 files changed
+18314
-56
No files matched your search
+390
@@ -0,0 +1,390 @@
|
||||
use raptor_replica::audio::{DigitalSample, Mixer, MusScore};
|
||||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
fn instruments(double: bool) -> Vec<u8> {
|
||||
let mut bytes = b"#OPL_II#".to_vec();
|
||||
for n in 0..175 {
|
||||
let mut instrument = [0u8; 36];
|
||||
instrument[0] = if n == 1 {
|
||||
1
|
||||
} else if double {
|
||||
4
|
||||
} else {
|
||||
0
|
||||
};
|
||||
instrument[2] = 128;
|
||||
instrument[3] = 48;
|
||||
for part in 0..2 {
|
||||
let start = 4 + part * 16;
|
||||
instrument[start..start + 6].copy_from_slice(&[0x21, 0xf2, 0x52, 0, 0, 32]);
|
||||
instrument[start + 6] = 0;
|
||||
instrument[start + 7..start + 13].copy_from_slice(&[0x21, 0xf2, 0x52, 0, 0, 0]);
|
||||
}
|
||||
bytes.extend(instrument);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
fn mus(body: &[u8], channels: u16) -> Vec<u8> {
|
||||
let mut bytes = b"MUS\x1a".to_vec();
|
||||
bytes.extend((body.len() as u16).to_le_bytes());
|
||||
bytes.extend(16u16.to_le_bytes());
|
||||
bytes.extend(channels.to_le_bytes());
|
||||
bytes.extend([0; 6]);
|
||||
bytes.extend(body);
|
||||
bytes
|
||||
}
|
||||
fn score() -> Vec<u8> {
|
||||
mus(
|
||||
&[
|
||||
0x40, 0, 0, 0x90, 188, 100, 10, 0xa0, 144, 5, 0xc0, 3, 90, 5, 0x80, 60, 10, 0x60,
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
fn sample(data: Vec<u8>) -> DigitalSample {
|
||||
DigitalSample { rate: 11025, data }
|
||||
}
|
||||
fn patch(data: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = vec![3, 0];
|
||||
bytes.extend(11025u16.to_le_bytes());
|
||||
bytes.extend(((data.len() + 32) as u32).to_le_bytes());
|
||||
bytes.extend([0xff; 16]);
|
||||
bytes.extend(data);
|
||||
bytes.extend([0; 16]);
|
||||
bytes
|
||||
}
|
||||
fn mixer() -> Mixer {
|
||||
Mixer::new(44100, &instruments(false)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_decoder_excludes_both_padding_regions() {
|
||||
let decoded = DigitalSample::decode(&patch(&[0, 64, 128, 192, 255])).unwrap();
|
||||
assert_eq!(decoded.rate, 11025);
|
||||
assert_eq!(decoded.data, vec![0, 64, 128, 192, 255]);
|
||||
assert!(DigitalSample::decode(&patch(&[])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_events_start_on_the_first_seventy_hertz_service_tick() {
|
||||
let mut mixer = mixer();
|
||||
mixer.music_volume = 127;
|
||||
mixer.play(MusScore::decode(&score()).unwrap(), false);
|
||||
let mut prefix = vec![1; 630 * 2];
|
||||
mixer.mix(&mut prefix);
|
||||
assert!(prefix.iter().all(|n| *n == 0));
|
||||
let mut following = vec![0; 3000 * 2];
|
||||
mixer.mix(&mut following);
|
||||
assert!(following.iter().any(|n| *n != 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fm_and_digital_output_do_not_depend_on_audio_callback_size() {
|
||||
let table = instruments(true);
|
||||
let mut whole = Mixer::new(44117, &table).unwrap();
|
||||
let mut chunked = Mixer::new(44117, &table).unwrap();
|
||||
for m in [&mut whole, &mut chunked] {
|
||||
m.play_with_fade(MusScore::decode(&score()).unwrap(), true, true);
|
||||
m.sample(
|
||||
sample((0..255).cycle().take(5000).collect()),
|
||||
37,
|
||||
192,
|
||||
100,
|
||||
1,
|
||||
);
|
||||
}
|
||||
let mut expected = vec![0; 44117 * 2];
|
||||
whole.mix(&mut expected);
|
||||
let mut actual = vec![0; expected.len()];
|
||||
let mut frame = 0;
|
||||
for count in [1usize, 3, 17, 512, 127].into_iter().cycle() {
|
||||
let count = count.min(actual.len() / 2 - frame);
|
||||
chunked.mix(&mut actual[frame * 2..(frame + count) * 2]);
|
||||
frame += count;
|
||||
if frame == actual.len() / 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_mixing_uses_the_original_11025_hertz_sample_hold() {
|
||||
let mut mixer = mixer();
|
||||
mixer.fx_volume = 127;
|
||||
mixer.sample(sample(vec![255, 255]), 128, 128, 127, 1);
|
||||
let mut out = [0; 24];
|
||||
mixer.mix(&mut out);
|
||||
assert_eq!(&out[..6], &[0; 6]);
|
||||
assert!(out[6] > 0 && out[7] > 0);
|
||||
assert_eq!(&out[6..8], &out[8..10]);
|
||||
assert_eq!(&out[6..8], &out[10..12]);
|
||||
assert_eq!(&out[6..8], &out[12..14]);
|
||||
assert_eq!(&out[22..24], &[0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pitch_zero_is_a_valid_quarter_speed_pitch() {
|
||||
let mut normal = mixer();
|
||||
let mut slow = mixer();
|
||||
normal.fx_volume = 127;
|
||||
slow.fx_volume = 127;
|
||||
normal.sample(sample(vec![255; 8]), 128, 128, 127, 1);
|
||||
slow.sample(sample(vec![255; 8]), 128, 0, 127, 1);
|
||||
let mut fast = [0; 100];
|
||||
let mut quarter = [0; 100];
|
||||
normal.mix(&mut fast);
|
||||
slow.mix(&mut quarter);
|
||||
assert!(fast[80..].iter().all(|v| *v == 0));
|
||||
assert!(quarter[80..].iter().any(|v| *v > 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_numeric_digital_priority_wins() {
|
||||
let mut mixer = mixer();
|
||||
mixer.fx_volume = 127;
|
||||
mixer.set_fx_channels(1);
|
||||
mixer.sample(sample(vec![255; 1000]), 128, 128, 127, 1);
|
||||
mixer.sample(sample(vec![0; 1000]), 128, 128, 127, 8);
|
||||
let mut protected = [0; 64];
|
||||
mixer.mix(&mut protected);
|
||||
assert!(protected[8..].iter().all(|v| *v > 0));
|
||||
mixer.sample(sample(vec![0; 1000]), 128, 128, 127, 0);
|
||||
let mut replaced = [0; 64];
|
||||
mixer.mix(&mut replaced);
|
||||
assert!(replaced[8..].iter().all(|v| *v < 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_voices_clip_together_before_being_added_to_fm() {
|
||||
let mut mixer = mixer();
|
||||
mixer.fx_volume = 127;
|
||||
mixer.set_fx_channels(8);
|
||||
for _ in 0..8 {
|
||||
mixer.sample(sample(vec![255; 100]), 128, 128, 127, 1);
|
||||
}
|
||||
let mut out = [0; 64];
|
||||
mixer.mix(&mut out);
|
||||
assert_eq!(out[20], 127 * 128);
|
||||
assert_eq!(out[21], 127 * 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_scores_controller_values_and_missing_end_events_are_rejected() {
|
||||
for body in [
|
||||
vec![0x90, 188],
|
||||
vec![0x90, 188, 200, 1, 0x60],
|
||||
vec![0xc0, 3, 255, 1, 0x60],
|
||||
vec![0x30, 9, 0x60],
|
||||
vec![0x90, 188, 127, 1],
|
||||
] {
|
||||
assert!(MusScore::decode(&mus(&body, 1)).is_err());
|
||||
}
|
||||
assert!(MusScore::decode(&mus(&[0x60], 16)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_duration_loop_and_an_odd_output_slice_terminate_cleanly() {
|
||||
let mut mixer = mixer();
|
||||
mixer.play(MusScore::decode(&mus(&[0x60], 1)).unwrap(), true);
|
||||
let mut out = [99; 2001];
|
||||
mixer.mix(&mut out);
|
||||
assert_eq!(out[2000], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the original untracked catalog"]
|
||||
fn original_music_and_sound_assets_decode_locally() {
|
||||
use raptor_replica::assets::Catalog;
|
||||
let root = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("data"));
|
||||
let catalog = Catalog::open(root).unwrap();
|
||||
let mut music = 0;
|
||||
let mut sounds = 0;
|
||||
for entry in catalog.entries.values() {
|
||||
let bytes = catalog.read(entry.id).unwrap();
|
||||
if bytes.starts_with(b"MUS\x1a") {
|
||||
MusScore::decode(&bytes).unwrap();
|
||||
music += 1;
|
||||
}
|
||||
if bytes.get(..4) == Some(&[3, 0, 0x11, 0x2b]) {
|
||||
DigitalSample::decode(&bytes).unwrap();
|
||||
sounds += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(music, 16);
|
||||
assert_eq!(sounds, 26, "digital samples");
|
||||
Mixer::new(44100, &catalog.named("GENMIDI_OP2").unwrap()).unwrap();
|
||||
}
|
||||
|
||||
const NATIVE_REFERENCE: &str = r#"
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include "SDL.h"
|
||||
#include "musapi.h"
|
||||
#include "i_oplmusic.h"
|
||||
#include "dspapi.h"
|
||||
#include "cards.h"
|
||||
int fx_freq=44100, gsshack=0, gssrate=0, sys_midi=0;
|
||||
musdevice_t mus_device_alsa={}, mus_device_tsf={};
|
||||
void SND_Lock(){} void SND_Unlock(){} void GSS_Service(){} void GSS_Poll(){}
|
||||
extern "C" Uint32 SDL_GetTicks(){return 0;}
|
||||
std::vector<char> read(const char *path){std::ifstream in(path,std::ios::binary);return {std::istreambuf_iterator<char>(in),{}};}
|
||||
int main(int argc,char **argv){
|
||||
if(argc!=9)return 2;
|
||||
auto gen=read(argv[1]),score=read(argv[2]),patch=read(argv[3]);
|
||||
int frames=std::atoi(argv[4]),mode=std::atoi(argv[5]),fade=std::atoi(argv[6]),loop=std::atoi(argv[7]);
|
||||
std::vector<int16_t> out(frames*2,0);
|
||||
if(mode!=1){LoadInstrumentTable(gen.data());MUS_Init(M_ADLIB,0);MUS_SetVolume(127);MUS_PlaySong(score.data(),loop,fade);}
|
||||
if(mode!=0){DSP_Init(8,11025);DSP_StartPatch((dsp_t*)patch.data(),37,192,85,1);}
|
||||
if(mode!=1)MUS_Mix(out.data(),frames);
|
||||
if(mode!=0)DSP_Mix(out.data(),frames);
|
||||
FILE *file=fopen(argv[8],"wb");if(!file)return 3;
|
||||
fwrite(out.data(),sizeof(int16_t),out.size(),file);fclose(file);return 0;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires g++ and SDL2 development headers; synthetic assets only"]
|
||||
fn rust_fm_mus_and_digital_pcm_match_the_retained_native_reference() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let binary = compile_reference(temp.path());
|
||||
let patch_data: Vec<_> = (0..255).cycle().take(2000).collect();
|
||||
let patch_path = temp.path().join("patch.bin");
|
||||
fs::write(&patch_path, patch(&patch_data)).unwrap();
|
||||
for (double, mode, fade, looping) in [
|
||||
(false, 0, false, false),
|
||||
(true, 0, false, false),
|
||||
(false, 1, false, false),
|
||||
(true, 2, true, true),
|
||||
] {
|
||||
let bank = instruments(double);
|
||||
let song = score();
|
||||
let frames = 44100;
|
||||
let gen_path = temp.path().join("genmidi.bin");
|
||||
fs::write(&gen_path, &bank).unwrap();
|
||||
let score_path = temp.path().join("score.bin");
|
||||
fs::write(&score_path, &song).unwrap();
|
||||
let output = temp.path().join("reference.pcm");
|
||||
let status = Command::new(&binary)
|
||||
.arg(&gen_path)
|
||||
.arg(&score_path)
|
||||
.arg(&patch_path)
|
||||
.arg(frames.to_string())
|
||||
.arg(mode.to_string())
|
||||
.arg(i32::from(fade).to_string())
|
||||
.arg(i32::from(looping).to_string())
|
||||
.arg(&output)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let expected = fs::read(output).unwrap();
|
||||
let mut mixer = Mixer::new(44100, &bank).unwrap();
|
||||
mixer.music_volume = 127;
|
||||
mixer.fx_volume = 127;
|
||||
mixer.set_fx_channels(8);
|
||||
if mode != 1 {
|
||||
mixer.play_with_fade(MusScore::decode(&song).unwrap(), looping, fade);
|
||||
}
|
||||
if mode != 0 {
|
||||
mixer.sample(sample(patch_data.clone()), 37, 192, 85, 1);
|
||||
}
|
||||
let mut out = vec![0; frames * 2];
|
||||
mixer.mix(&mut out);
|
||||
let actual: Vec<_> = out.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
if let Some(index) = actual
|
||||
.chunks_exact(2)
|
||||
.zip(expected.chunks_exact(2))
|
||||
.position(|(a, b)| a != b)
|
||||
{
|
||||
let want = i16::from_le_bytes(expected[index * 2..index * 2 + 2].try_into().unwrap());
|
||||
panic!(
|
||||
"PCM mismatch double={double} mode={mode} fade={fade} loop={looping}: frame={} channel={} got={} expected={want}",
|
||||
index / 2,
|
||||
index % 2,
|
||||
out[index]
|
||||
);
|
||||
}
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_reference(directory: &Path) -> std::path::PathBuf {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let source = directory.join("reference.cpp");
|
||||
fs::write(&source, NATIVE_REFERENCE).unwrap();
|
||||
let binary = directory.join("reference");
|
||||
let mut compile = Command::new("g++");
|
||||
compile
|
||||
.args(["-std=c++17", "-O2"])
|
||||
.arg("-I")
|
||||
.arg(root.join("raptor/src"));
|
||||
compile.arg("-I").arg("/usr/include/SDL2");
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
compile
|
||||
.arg("-I")
|
||||
.arg(Path::new(&home).join(".local/include/SDL2"));
|
||||
}
|
||||
compile.arg(&source);
|
||||
for path in ["i_oplmusic.cpp", "opl3.cpp", "musapi.cpp", "dspapi.cpp"] {
|
||||
compile.arg(root.join("raptor/src").join(path));
|
||||
}
|
||||
let result = compile.arg("-o").arg(&binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
binary
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original untracked music assets, g++ and SDL2 headers"]
|
||||
fn original_intro_pcm_matches_the_retained_native_reference() {
|
||||
use raptor_replica::assets::Catalog;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("data"));
|
||||
let catalog = Catalog::open(root).unwrap();
|
||||
let bank = catalog.named("GENMIDI_OP2").unwrap();
|
||||
let song = catalog.named("RINTRO_MUS").unwrap();
|
||||
let binary = compile_reference(temp.path());
|
||||
fs::write(temp.path().join("genmidi.bin"), &bank).unwrap();
|
||||
fs::write(temp.path().join("score.bin"), &song).unwrap();
|
||||
fs::write(temp.path().join("patch.bin"), patch(&[128; 33])).unwrap();
|
||||
let frames = 44100 * 4;
|
||||
let output = temp.path().join("reference.pcm");
|
||||
let status = Command::new(binary)
|
||||
.arg(temp.path().join("genmidi.bin"))
|
||||
.arg(temp.path().join("score.bin"))
|
||||
.arg(temp.path().join("patch.bin"))
|
||||
.arg(frames.to_string())
|
||||
.args(["0", "1", "1"])
|
||||
.arg(&output)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let expected = fs::read(output).unwrap();
|
||||
let mut mixer = Mixer::new(44100, &bank).unwrap();
|
||||
mixer.music_volume = 127;
|
||||
mixer.play_with_fade(MusScore::decode(&song).unwrap(), true, true);
|
||||
let mut actual = vec![0i16; frames * 2];
|
||||
mixer.mix(&mut actual);
|
||||
for (index, (got, bytes)) in actual.iter().zip(expected.chunks_exact(2)).enumerate() {
|
||||
let want = i16::from_le_bytes(bytes.try_into().unwrap());
|
||||
assert_eq!(
|
||||
*got,
|
||||
want,
|
||||
"original intro frame {} channel {}",
|
||||
index / 2,
|
||||
index % 2
|
||||
);
|
||||
}
|
||||
assert_eq!(actual.len() * 2, expected.len());
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, Sprite, TRANSPARENT, contained, i16_at, i32_at},
|
||||
audio::{DigitalSample, Mixer, MusScore},
|
||||
content::{ContentPack, Registry},
|
||||
persistence::{Config, Pilot},
|
||||
render::upscale,
|
||||
};
|
||||
|
||||
fn pilot_registry() -> Registry {
|
||||
let missions: Vec<_> = (1..=3).flat_map(|s| (1..=9).map(move |w| serde_json::json!({
|
||||
"id":format!("base:sector_{s}_wave_{w}"),"name":"Synthetic wave","duration":100,"legacy_map":100+s*9+w
|
||||
}))).collect();
|
||||
let pack: ContentPack = serde_json::from_value(serde_json::json!({
|
||||
"items":[
|
||||
{"id":"base:forward_guns","name":"Forward Guns","price":12000,"legacy":0},
|
||||
{"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"legacy":16}
|
||||
],"missions":missions
|
||||
})).unwrap();
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_graduation_and_sector_cycles_preserve_original_progression() {
|
||||
let registry = pilot_registry();
|
||||
let mut pilot = Pilot {
|
||||
difficulty: 0,
|
||||
credits: 900000,
|
||||
mission: "base:sector_2_wave_4".into(),
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:sector_1_wave_1");
|
||||
assert_eq!(pilot.difficulty, 1);
|
||||
assert_eq!(pilot.credits, 10000);
|
||||
assert_eq!(pilot.inventory[&"base:energy".into()], 75);
|
||||
assert!(pilot.legacy_progress.as_ref().unwrap().training_completed);
|
||||
pilot.mission = "base:sector_3_wave_9".into();
|
||||
pilot.difficulty = 2;
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:sector_1_wave_1");
|
||||
assert_eq!(pilot.legacy_progress.as_ref().unwrap().difficulties[2], 3);
|
||||
assert_eq!(pilot.difficulty, 1);
|
||||
pilot.mission = "base:sector_1_wave_2".into();
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:sector_1_wave_3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_save_import_retains_metadata_without_modifying_source() {
|
||||
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()
|
||||
}
|
||||
let mut header = vec![0u8; 88];
|
||||
header[..9].copy_from_slice(b"Synthetic");
|
||||
header[20..24].copy_from_slice(b"TEST");
|
||||
for (offset, value) in [
|
||||
(32, 65814i32),
|
||||
(36, 98765),
|
||||
(40, -1),
|
||||
(44, 1),
|
||||
(48, 3),
|
||||
(52, 8),
|
||||
(56, 1),
|
||||
(60, 2),
|
||||
(64, 0),
|
||||
(68, 2),
|
||||
(72, 1),
|
||||
(76, 3),
|
||||
(80, 0),
|
||||
(84, 1),
|
||||
] {
|
||||
header[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
let mut body = vec![0u8; 48];
|
||||
for (offset, value) in [(8, 1i32), (12, 0), (32, 71), (36, 16)] {
|
||||
body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
let bytes = [encrypt(&header), encrypt(&body)].concat();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("CHAR0000.FIL");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let registry = pilot_registry();
|
||||
let pilot = Pilot::import_legacy(&path, ®istry).unwrap();
|
||||
assert_eq!(pilot.callsign, "TEST");
|
||||
assert_eq!(pilot.credits, 98765);
|
||||
assert_eq!(pilot.mission.0, "base:sector_2_wave_9");
|
||||
assert_eq!(pilot.inventory[&"base:energy".into()], 71);
|
||||
assert_eq!(pilot.completed.len(), 12);
|
||||
let progress = pilot.legacy_progress.as_ref().unwrap();
|
||||
assert_eq!(progress.portrait, 65814);
|
||||
assert_eq!(progress.difficulties, [0, 2, 1, 3]);
|
||||
assert!(progress.training_completed);
|
||||
let output = dir.path().join("pilot.json");
|
||||
pilot.save(&output).unwrap();
|
||||
assert_eq!(Pilot::load(&output, ®istry, &[]).unwrap().credits, 98765);
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
}
|
||||
|
||||
fn header(kind: i32, width: i32, height: i32) -> Vec<u8> {
|
||||
[kind, 0, 0, width, height]
|
||||
.into_iter()
|
||||
.flat_map(i32::to_le_bytes)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_pictures_keep_zero_opaque_and_spans_keep_empty_pixels_transparent() {
|
||||
let mut raw = header(1, 2, 1);
|
||||
raw.extend([0, 255]);
|
||||
assert_eq!(Sprite::decode(&raw).unwrap().pixels, [0, 255]);
|
||||
let mut span = header(0, 3, 2);
|
||||
span.extend([1i32, 1, 4, 2].into_iter().flat_map(i32::to_le_bytes));
|
||||
span.extend([0, 17]);
|
||||
span.extend([0i32, 0, -1, 0].into_iter().flat_map(i32::to_le_bytes));
|
||||
assert_eq!(
|
||||
Sprite::decode(&span).unwrap().pixels,
|
||||
[TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, 0, 17]
|
||||
);
|
||||
for n in 0..span.len() {
|
||||
assert!(Sprite::decode(&span[..n]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_fields_and_spans_fail_without_panicking() {
|
||||
assert!(i16_at(&[], usize::MAX).is_err());
|
||||
assert!(i32_at(&[], usize::MAX).is_err());
|
||||
let mut span = header(0, 2, 2);
|
||||
span.extend([i32::MAX, 0, 0, 2].into_iter().flat_map(i32::to_le_bytes));
|
||||
span.extend([1, 2]);
|
||||
assert!(Sprite::decode(&span).is_err());
|
||||
assert!(Sprite::decode(&header(1, 4096, 4096)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_kernels_have_published_corner_results() {
|
||||
let input = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels: vec![9, 1, 8, 1, 2, 3, 7, 4, 6],
|
||||
};
|
||||
let two = upscale(&input, 2).unwrap();
|
||||
let four = upscale(&input, 4).unwrap();
|
||||
assert_eq!(
|
||||
[
|
||||
two.pixels[2 * 6 + 2],
|
||||
two.pixels[2 * 6 + 3],
|
||||
two.pixels[3 * 6 + 2],
|
||||
two.pixels[3 * 6 + 3]
|
||||
],
|
||||
[1, 2, 2, 2]
|
||||
);
|
||||
let three = upscale(&input, 3).unwrap();
|
||||
let center: Vec<_> = (3..6)
|
||||
.flat_map(|y| (3..6).map(move |x| y * 9 + x))
|
||||
.map(|n| three.pixels[n])
|
||||
.collect();
|
||||
assert_eq!(center, [1, 1, 2, 1, 2, 2, 2, 2, 2]);
|
||||
assert_eq!(four, upscale(&two, 2).unwrap());
|
||||
assert!(upscale(&input, 5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_paths_cannot_escape_or_follow_an_external_symlink() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
std::fs::write(other.path().join("outside"), b"outside").unwrap();
|
||||
assert!(contained(root.path(), std::path::Path::new("../outside")).is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(other.path().join("outside"), root.path().join("link")).unwrap();
|
||||
assert!(contained(root.path(), std::path::Path::new("link")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_settings_preserves_source_and_uses_sdl_key_names() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("SETUP.INI");
|
||||
let original = b"[Keyboard]\nFire=29\nFireSp=56\nMegaFire=54\n[Video]\nupscaler=3\n";
|
||||
std::fs::write(&path, original).unwrap();
|
||||
let config = Config::import_ini(&path).unwrap();
|
||||
assert_eq!(config.scale, 3);
|
||||
assert_eq!(config.bindings["fire"], "Left Ctrl");
|
||||
assert_eq!(config.bindings["special"], "Left Alt");
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
#[cfg(feature = "desktop")]
|
||||
for key in config.bindings.values() {
|
||||
assert!(
|
||||
sdl2::keyboard::Scancode::from_name(key).is_some(),
|
||||
"unknown SDL key {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Original pixels never enter the test fixtures or version control. Set this
|
||||
// variable only on a machine with the licensed extracted asset catalog.
|
||||
#[test]
|
||||
fn original_data_decoders_and_all_campaigns_when_available() {
|
||||
let Some(data) = std::env::var_os("RAPTOR_TEST_DATA") else {
|
||||
return;
|
||||
};
|
||||
let catalog = Catalog::open(data).unwrap();
|
||||
let registry = Registry::base(&catalog).unwrap();
|
||||
registry.validate().unwrap();
|
||||
assert_eq!(registry.missions.len(), 27);
|
||||
assert_eq!(registry.campaigns.len(), 3);
|
||||
let mut sprites = 0;
|
||||
for entry in catalog.entries.values() {
|
||||
let bytes = catalog.read(entry.id).unwrap();
|
||||
if bytes.len() >= 20
|
||||
&& (i32_at(&bytes, 0).ok() == Some(0)
|
||||
|| (i32_at(&bytes, 0).ok() == Some(1)
|
||||
&& i32_at(&bytes, 4).ok() == Some(1)
|
||||
&& i32_at(&bytes, 8).ok() == Some(0)))
|
||||
&& matches!(i32_at(&bytes, 12).ok(), Some(1..=4096))
|
||||
&& matches!(i32_at(&bytes, 16).ok(), Some(1..=4096))
|
||||
{
|
||||
Sprite::decode(&bytes).unwrap_or_else(|e| panic!("asset {:x}: {e}", entry.id));
|
||||
sprites += 1;
|
||||
}
|
||||
if bytes.starts_with(b"MUS\x1a") {
|
||||
MusScore::decode(&bytes).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
sprites > 1000,
|
||||
"format discovery must include unnamed graphics"
|
||||
);
|
||||
for sector in 0..3 {
|
||||
let pilot = Pilot::demo(sector, ®istry).unwrap();
|
||||
pilot.validate(®istry, &[]).unwrap();
|
||||
let selected = ®istry.items[pilot.selected.as_ref().unwrap()];
|
||||
assert_eq!(selected.legacy, Some([5, 8, 8][sector]));
|
||||
}
|
||||
let mut mixer = Mixer::new(44100, &catalog.named("GENMIDI_OP2").unwrap()).unwrap();
|
||||
mixer.play(
|
||||
MusScore::decode(&catalog.named("RAP1_MUS").unwrap()).unwrap(),
|
||||
false,
|
||||
);
|
||||
let mut output = vec![0i16; 44100 * 2];
|
||||
mixer.mix(&mut output);
|
||||
assert!(
|
||||
output.iter().any(|v| *v != 0),
|
||||
"FM playback must produce sound"
|
||||
);
|
||||
let sample = catalog
|
||||
.entries
|
||||
.values()
|
||||
.find_map(|e| DigitalSample::decode(&catalog.read(e.id).ok()?).ok())
|
||||
.unwrap();
|
||||
mixer.sample(sample, 128, 128, 127, 1);
|
||||
mixer.mix(&mut output);
|
||||
assert!(output.iter().any(|v| *v != 0));
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, LineMove, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> Registry {
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"weapons": [{"id":"base:projectile_forward_guns", "name":"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":"Guns","price":0,"weapon":"base:projectile_forward_guns","legacy":0},
|
||||
{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16},
|
||||
{"id":"base:super_shield","name":"Super Shield","price":0,"max_count":100,"legacy":15},
|
||||
{"id":"base:medkit","name":"Medkit","price":0,"max_count":5,"special":true,"consumable":true,"shield":25,"script":"medkit.rhai"}
|
||||
],
|
||||
"enemies":[{"id":"base:drone","name":"Drone","speed":1,"health":20,"path":[[0,200]]}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Synthetic Mission","duration":1000}]
|
||||
})).unwrap();
|
||||
let mut reg = Registry::default();
|
||||
reg.apply(pack, "base").unwrap();
|
||||
reg.validate().unwrap();
|
||||
reg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_guns_use_banked_muzzles_and_accelerate_after_position_snapshot() {
|
||||
let reg = fixture();
|
||||
let mut world = World::new(&"base:sector_1_wave_1".into(), &Pilot::default(), ®).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
demo_bank: Some(3),
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
// Center (160,176), gun offsets +6/-7, sprite half-size (2,4).
|
||||
assert_eq!(
|
||||
world
|
||||
.projectiles
|
||||
.iter()
|
||||
.map(|p| (p.x, p.y))
|
||||
.collect::<Vec<_>>(),
|
||||
[(164, 172), (151, 172)]
|
||||
);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.projectiles.len(), 2);
|
||||
assert_eq!(world.projectiles[0].y, 163); // Speed rose from 8 to 9 after the first position.
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.projectiles.len(), 4);
|
||||
assert_eq!(world.projectiles[0].y, 153);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enemy_paths_stop_at_endpoint_but_shots_cross_it() {
|
||||
let mut enemy = LineMove::new(0, 0, 2, 1);
|
||||
enemy.advance(5);
|
||||
assert_eq!((enemy.x, enemy.y), (2, 1));
|
||||
let mut shot = LineMove::new(0, 0, 2, 1);
|
||||
shot.advance_shot(5);
|
||||
assert_eq!((shot.x, shot.y), (5, 3));
|
||||
assert!(shot.done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mission_transition_preserves_remaining_super_shield() {
|
||||
let reg = fixture();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.inventory.insert("base:super_shield".into(), 31);
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®).unwrap();
|
||||
assert_eq!(world.player.super_shield, 31);
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 12,
|
||||
}],
|
||||
®,
|
||||
)
|
||||
.unwrap();
|
||||
let saved = world.pilot(&pilot);
|
||||
assert_eq!(saved.inventory[&ContentId::from("base:super_shield")], 19);
|
||||
assert_eq!(
|
||||
World::new(&pilot.mission, &saved, ®)
|
||||
.unwrap()
|
||||
.player
|
||||
.super_shield,
|
||||
19
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_an_entity_rebases_its_path() {
|
||||
let reg = fixture();
|
||||
let mut world = World::new(&"base:sector_1_wave_1".into(), &Pilot::default(), ®).unwrap();
|
||||
let entity = world.spawn(&"base:drone".into(), 100, 0, ®).unwrap();
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Move {
|
||||
entity,
|
||||
x: 20,
|
||||
y: 20,
|
||||
}],
|
||||
®,
|
||||
)
|
||||
.unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world.step(Input::default(), ®, &mut scripts).unwrap();
|
||||
assert_eq!((world.enemies[0].x, world.enemies[0].y), (20, 20));
|
||||
world.step(Input::default(), ®, &mut scripts).unwrap();
|
||||
assert_eq!((world.enemies[0].x, world.enemies[0].y), (20, 21));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_use_has_snapshot_and_only_fires_on_press() {
|
||||
let reg = fixture();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.inventory.insert("base:medkit".into(), 2);
|
||||
pilot.inventory.insert("base:energy".into(), 50);
|
||||
pilot.selected = Some("base:medkit".into());
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
scripts.add("base","medkit.rhai",r#"fn on_use(event,state,rng) {#{commands:[#{op:"credits",amount:7}], state:#{shield:event.player.shield,quantity:event.player.inventory["base:medkit"]}}}"#).unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
special: true,
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 75);
|
||||
assert_eq!(world.player.credits, 7);
|
||||
assert_eq!(world.player.inventory[&ContentId::from("base:medkit")], 1);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
special: true,
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.credits, 7);
|
||||
world.step(Input::default(), ®, &mut scripts).unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
special: true,
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 100);
|
||||
assert_eq!(world.player.credits, 14);
|
||||
assert_eq!(world.player.selected, None);
|
||||
}
|
||||
@@ -0,0 +1,949 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command, Manifest, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
fn pack(value: Value) -> ContentPack {
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
fn item(id: &str, price: u32) -> Value {
|
||||
json!({"id": id, "name": id, "price": price, "max_count": 100})
|
||||
}
|
||||
fn base() -> Registry {
|
||||
let mut reg = Registry::default();
|
||||
reg.apply(pack(json!({
|
||||
"weapons": [{"id":"base:projectile_forward_guns", "name":"Forward Guns", "damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}],
|
||||
"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":"Test Mission","duration":1000}]
|
||||
})), "base").unwrap();
|
||||
reg.validate().unwrap();
|
||||
reg
|
||||
}
|
||||
fn install(root: &Path, folder: &str, id: &str, content: Value) -> Manifest {
|
||||
let dir = root.join(folder);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let manifest = Manifest {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
version: "1.0.0".into(),
|
||||
api_version: 1,
|
||||
description: String::new(),
|
||||
dependencies: BTreeMap::new(),
|
||||
conflicts: vec![],
|
||||
content: "content.json".into(),
|
||||
scripts: vec![],
|
||||
};
|
||||
write_manifest(&dir, &manifest);
|
||||
fs::write(
|
||||
dir.join("content.json"),
|
||||
serde_json::to_vec_pretty(&content).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
manifest
|
||||
}
|
||||
fn write_manifest(dir: &Path, manifest: &Manifest) {
|
||||
fs::write(dir.join("mod.toml"), toml::to_string(manifest).unwrap()).unwrap();
|
||||
}
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|v| (*v).into()).collect()
|
||||
}
|
||||
fn error<T>(result: anyhow::Result<T>) -> String {
|
||||
match result {
|
||||
Ok(_) => panic!("expected failure"),
|
||||
Err(e) => format!("{e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependencies_load_before_dependents_and_keep_independent_priority() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "alpha", "alpha", json!({}));
|
||||
let mut beta = install(temp.path(), "beta", "beta", json!({}));
|
||||
beta.dependencies.insert("alpha".into(), "^1.0".into());
|
||||
write_manifest(&temp.path().join("beta"), &beta);
|
||||
install(temp.path(), "gamma", "gamma", json!({}));
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(d.errors.is_empty());
|
||||
assert_eq!(
|
||||
modding::order(&d, &ids(&["gamma", "beta", "alpha"])).unwrap(),
|
||||
ids(&["gamma", "alpha", "beta"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_disabled_wrong_version_and_cyclic_dependencies_are_actionable() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut alpha = install(temp.path(), "alpha", "alpha", json!({}));
|
||||
let mut beta = install(temp.path(), "beta", "beta", json!({}));
|
||||
beta.dependencies.insert("alpha".into(), "^2".into());
|
||||
write_manifest(&temp.path().join("beta"), &beta);
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(error(modding::order(&d, &ids(&["beta"]))).contains("requires enabled mod alpha"));
|
||||
assert!(error(modding::order(&d, &ids(&["alpha", "beta"]))).contains("installed 1.0.0"));
|
||||
beta.dependencies.insert("alpha".into(), "^1".into());
|
||||
alpha.dependencies.insert("beta".into(), "^1".into());
|
||||
write_manifest(&temp.path().join("beta"), &beta);
|
||||
write_manifest(&temp.path().join("alpha"), &alpha);
|
||||
assert!(
|
||||
error(modding::order(
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha", "beta"])
|
||||
))
|
||||
.contains("cyclic")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_conflicts_and_duplicate_selected_ids_reject_activation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut alpha = install(temp.path(), "alpha", "alpha", json!({}));
|
||||
install(temp.path(), "beta", "beta", json!({}));
|
||||
alpha.api_version = 99;
|
||||
write_manifest(&temp.path().join("alpha"), &alpha);
|
||||
assert!(
|
||||
error(modding::order(
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"])
|
||||
))
|
||||
.contains("requires API 99")
|
||||
);
|
||||
alpha.api_version = 1;
|
||||
alpha.conflicts.push("beta".into());
|
||||
write_manifest(&temp.path().join("alpha"), &alpha);
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(error(modding::order(&d, &ids(&["alpha", "beta"]))).contains("conflicts with beta"));
|
||||
assert!(error(modding::order(&d, &ids(&["beta", "beta"]))).contains("duplicate mod"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_duplicate_installed_namespace_is_unusable() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
for folder in ["first", "second", "third"] {
|
||||
install(temp.path(), folder, "collision", json!({}));
|
||||
}
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(!d.mods.contains_key("collision"));
|
||||
assert_eq!(d.errors.len(), 2);
|
||||
assert!(
|
||||
error(ActiveMods::build(&base(), &d, &ids(&["collision"]), None)).contains("not installed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_duplicate_after_discovery_requires_rescanning() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "first", "alpha", json!({}));
|
||||
let original = modding::discover(temp.path());
|
||||
install(temp.path(), "second", "alpha", json!({}));
|
||||
let message = error(ActiveMods::build(
|
||||
&base(),
|
||||
&original,
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
));
|
||||
assert!(message.contains("duplicated since discovery") && message.contains("rescan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_files_require_a_new_discovery_snapshot() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[item("alpha:cell", 10)]}),
|
||||
);
|
||||
let original = modding::discover(temp.path());
|
||||
let before = ActiveMods::build(&base(), &original, &ids(&["alpha"]), None).unwrap();
|
||||
fs::write(
|
||||
temp.path().join("alpha/content.json"),
|
||||
serde_json::to_vec(&json!({"items":[item("alpha:cell", 12)]})).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
error(ActiveMods::build(
|
||||
&base(),
|
||||
&original,
|
||||
&ids(&["alpha"]),
|
||||
None
|
||||
))
|
||||
.contains("rescan")
|
||||
);
|
||||
let after = ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(before.pins[0].hash, after.pins[0].hash);
|
||||
assert_eq!(
|
||||
after.registry.items[&ContentId::from("alpha:cell")].price,
|
||||
12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_hash_covers_added_assets_and_manifest_changes() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut manifest = install(temp.path(), "alpha", "alpha", json!({}));
|
||||
let d = modding::discover(temp.path());
|
||||
fs::write(temp.path().join("alpha/readme.txt"), "Extra package file").unwrap();
|
||||
assert!(error(ActiveMods::build(&base(), &d, &ids(&["alpha"]), None)).contains("rescan"));
|
||||
let d = modding::discover(temp.path());
|
||||
manifest.version = "1.0.1".into();
|
||||
write_manifest(&temp.path().join("alpha"), &manifest);
|
||||
assert!(error(ActiveMods::build(&base(), &d, &ids(&["alpha"]), None)).contains("rescan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_paths_depth_and_size_are_bounded() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut m = install(temp.path(), "traversal", "traversal", json!({}));
|
||||
m.content = "../outside.json".into();
|
||||
write_manifest(&temp.path().join("traversal"), &m);
|
||||
install(temp.path(), "deep", "deep", json!({}));
|
||||
let mut nested = temp.path().join("deep");
|
||||
for _ in 0..17 {
|
||||
nested = nested.join("nested");
|
||||
}
|
||||
fs::create_dir_all(nested).unwrap();
|
||||
install(temp.path(), "large", "large", json!({}));
|
||||
fs::File::create(temp.path().join("large/huge.bin"))
|
||||
.unwrap()
|
||||
.set_len(64 * 1024 * 1024 + 1)
|
||||
.unwrap();
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(d.mods.is_empty());
|
||||
assert!(d.errors.iter().any(|e| e.contains("parent traversal")));
|
||||
assert!(d.errors.iter().any(|e| e.contains("depth exceeds")));
|
||||
assert!(d.errors.iter().any(|e| e.contains("64 MiB")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_folders_and_files_are_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "alpha", "alpha", json!({}));
|
||||
symlink("/etc/passwd", temp.path().join("alpha/link")).unwrap();
|
||||
symlink(temp.path().join("alpha"), temp.path().join("alias")).unwrap();
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(d.mods.is_empty());
|
||||
assert_eq!(d.errors.len(), 2);
|
||||
assert!(d.errors.iter().all(|e| e.contains("symlink")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_manifest_versions_and_duplicate_sources_fail_discovery() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut m = install(temp.path(), "alpha", "alpha", json!({}));
|
||||
m.dependencies
|
||||
.insert("beta".into(), "obviously invalid".into());
|
||||
write_manifest(&temp.path().join("alpha"), &m);
|
||||
assert!(modding::discover(temp.path()).mods.is_empty());
|
||||
m.dependencies.clear();
|
||||
m.scripts = ids(&["script.rhai", "script.rhai"]);
|
||||
fs::write(temp.path().join("alpha/script.rhai"), "").unwrap();
|
||||
write_manifest(&temp.path().join("alpha"), &m);
|
||||
assert!(modding::discover(temp.path()).errors[0].contains("duplicate script"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_explicit_overrides_win_and_report_the_previous_owner() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[item("base:energy", 10)], "overrides":["base:energy"]}),
|
||||
);
|
||||
install(
|
||||
temp.path(),
|
||||
"beta",
|
||||
"beta",
|
||||
json!({"items":[item("base:energy", 20)], "overrides":["base:energy"]}),
|
||||
);
|
||||
let d = modding::discover(temp.path());
|
||||
let active = ActiveMods::build(&base(), &d, &ids(&["alpha", "beta"]), None).unwrap();
|
||||
assert_eq!(
|
||||
active.registry.items[&ContentId::from("base:energy")].price,
|
||||
20
|
||||
);
|
||||
assert_eq!(
|
||||
active.registry.owners[&ContentId::from("base:energy")],
|
||||
"beta"
|
||||
);
|
||||
assert_eq!(
|
||||
active.registry.overrides,
|
||||
ids(&["base:energy: base -> alpha", "base:energy: alpha -> beta"])
|
||||
);
|
||||
let reversed = ActiveMods::build(&base(), &d, &ids(&["beta", "alpha"]), None).unwrap();
|
||||
assert_eq!(
|
||||
reversed.registry.items[&ContentId::from("base:energy")].price,
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accidental_duplicates_type_changes_and_failed_packs_leave_registry_intact() {
|
||||
let mut registry = base();
|
||||
let previous = registry.items.len();
|
||||
assert!(
|
||||
registry
|
||||
.apply(
|
||||
pack(json!({"items":[item("alpha:valid", 1), item("base:energy", 2)]})),
|
||||
"alpha"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(registry.items.len(), previous);
|
||||
assert!(
|
||||
!registry
|
||||
.owners
|
||||
.contains_key(&ContentId::from("alpha:valid"))
|
||||
);
|
||||
assert!(
|
||||
registry
|
||||
.apply(
|
||||
pack(json!({"items":[item("alpha:twice", 1), item("alpha:twice", 2)]})),
|
||||
"alpha"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(registry.apply(pack(json!({"enemies":[{"id":"base:energy","name":"Wrong type"}],"overrides":["base:energy"]})), "alpha").is_err());
|
||||
assert!(
|
||||
registry
|
||||
.apply(pack(json!({"items":[item("foreign:cell", 1)]})), "alpha")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
registry
|
||||
.apply(
|
||||
pack(json!({"items":[item("alpha:new", 1)],"overrides":["alpha:new"]})),
|
||||
"alpha"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_references_and_undeclared_scripts_reject_the_whole_mod() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"weapon":"alpha:missing"}]}),
|
||||
);
|
||||
assert!(
|
||||
error(ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None
|
||||
))
|
||||
.contains("missing weapon")
|
||||
);
|
||||
fs::write(temp.path().join("alpha/content.json"), serde_json::to_vec(&json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"script":"undeclared.rhai"}]})).unwrap()).unwrap();
|
||||
assert!(
|
||||
error(ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None
|
||||
))
|
||||
.contains("manifest.scripts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_failure_rolls_back_world_state_and_deterministic_rng() {
|
||||
let mut registry = base();
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&ContentId::from("base:sector_1_wave_1"))
|
||||
.unwrap()
|
||||
.script = Some("mission.rhai".into());
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime
|
||||
.add(
|
||||
"base",
|
||||
"mission.rhai",
|
||||
r#"fn on_mission_start(e,s,r) {
|
||||
#{commands:[#{op:"credits",amount:7}, #{op:"move",entity:999,x:0,y:0}],state:#{random:r[0]}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let before = world.digest();
|
||||
assert!(
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(world.digest(), before);
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
let source = r#"fn on_mission_start(e,s,r) { #{commands:[#{op:"credits",amount:r[0]}],state:#{random:r[0]}} }"#;
|
||||
runtime.add("base", "mission.rhai", source).unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
let mut fresh_runtime = ScriptRuntime::new();
|
||||
fresh_runtime.add("base", "mission.rhai", source).unwrap();
|
||||
let mut fresh_world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
fresh_world
|
||||
.step(Input::default(), ®istry, &mut fresh_runtime)
|
||||
.unwrap();
|
||||
assert_eq!(world.digest(), fresh_world.digest());
|
||||
assert_eq!(runtime.states, fresh_runtime.states);
|
||||
assert_eq!(runtime.rng, fresh_runtime.rng);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_batches_are_atomic_even_after_an_earlier_valid_command() {
|
||||
let registry = base();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let before = world.digest();
|
||||
let commands = [
|
||||
Command::Credits { amount: 100 },
|
||||
Command::Spawn {
|
||||
enemy: "missing:enemy".into(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
];
|
||||
assert!(world.apply_commands(&commands, ®istry).is_err());
|
||||
assert_eq!(world.digest(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_result_excessive_commands_and_oversized_state_are_rejected() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
for (source, expected) in [
|
||||
(
|
||||
r#"fn on_tick(e,s,r) { #{commands:[#{op:"unknown"}],state:#{}} }"#,
|
||||
"invalid result",
|
||||
),
|
||||
(
|
||||
r#"fn on_tick(e,s,r) { let a=[]; for i in 0..257 { a.push(#{op:"complete"}); } #{commands:a,state:#{}} }"#,
|
||||
"too many commands",
|
||||
),
|
||||
(
|
||||
r#"fn on_tick(e,s,r) { let a=[]; for i in 0..4000 { a.push(999999999999999999); } #{commands:[],state:a} }"#,
|
||||
"state exceeds",
|
||||
),
|
||||
] {
|
||||
runtime.begin_tick();
|
||||
runtime.add("test", "limits.rhai", source).unwrap();
|
||||
let message = error(runtime.invoke("test", "limits.rhai", "on_tick", &json!({}), 17));
|
||||
assert!(
|
||||
message.contains(expected),
|
||||
"expected {expected}, got {message}"
|
||||
);
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_budget_is_shared_across_callbacks_and_resets_per_tick() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime.add("test", "budget.rhai", r#"fn on_tick(e,s,r) { let x=0; for n in 0..2500 { x+=1; } #{commands:[],state:#{iterations:x}} }"#).unwrap();
|
||||
runtime.begin_tick();
|
||||
let mut success = 0;
|
||||
for _ in 0..100 {
|
||||
if runtime
|
||||
.invoke("test", "budget.rhai", "on_tick", &json!({}), 1)
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
success += 1;
|
||||
}
|
||||
assert!(
|
||||
success > 1 && success < 100,
|
||||
"successful callbacks: {success}"
|
||||
);
|
||||
runtime.begin_tick();
|
||||
runtime
|
||||
.invoke("test", "budget.rhai", "on_tick", &json!({}), 1)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_hook_does_not_advance_rng_or_state() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"empty.rhai",
|
||||
"fn other(e,s,r) { #{commands:[],state:s} }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
runtime
|
||||
.invoke("test", "empty.rhai", "on_tick", &json!({}), 1)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_save_pins_and_disabled_equipment_do_not_modify_the_save() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[item("alpha:cell", 1)]}),
|
||||
);
|
||||
let active = ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.mods = active.pins.clone();
|
||||
pilot.inventory.insert("alpha:cell".into(), 1);
|
||||
let path = temp.path().join("pilot.json");
|
||||
pilot.save(&path).unwrap();
|
||||
let before = fs::read(&path).unwrap();
|
||||
Pilot::load(&path, &active.registry, &active.pins).unwrap();
|
||||
assert!(error(Pilot::load(&path, &base(), &[])).contains("enable the matching mods"));
|
||||
let mut changed = active.pins.clone();
|
||||
changed[0].hash = "different-content-hash".into();
|
||||
assert!(Pilot::load(&path, &active.registry, &changed).is_err());
|
||||
assert!(
|
||||
error(Pilot::load(&path, &base(), &active.pins))
|
||||
.contains("saved item alpha:cell is unavailable")
|
||||
);
|
||||
assert_eq!(fs::read(path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_script_state_and_rng_resume_the_same_callback_sequence() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
let source = r#"fn on_tick(e,s,r) { let n=if s.contains("n") {s.n+1} else {1}; #{commands:[#{op:"credits",amount:r[0]}],state:#{n:n,last:r[0]}} }"#;
|
||||
runtime.add("test", "state.rhai", source).unwrap();
|
||||
runtime
|
||||
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
|
||||
.unwrap();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.script_state = runtime.states.clone();
|
||||
pilot.script_rng = runtime.rng.clone();
|
||||
let restored_pilot: Pilot =
|
||||
serde_json::from_slice(&serde_json::to_vec(&pilot).unwrap()).unwrap();
|
||||
let mut restored = ScriptRuntime::new();
|
||||
restored.add("test", "state.rhai", source).unwrap();
|
||||
restored.states = restored_pilot.script_state;
|
||||
restored.rng = restored_pilot.script_rng;
|
||||
let a = runtime
|
||||
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
|
||||
.unwrap();
|
||||
let b = restored
|
||||
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(a).unwrap(),
|
||||
serde_json::to_value(b).unwrap()
|
||||
);
|
||||
assert_eq!(runtime.states, restored.states);
|
||||
assert_eq!(runtime.rng, restored.rng);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_mod_compiles_and_its_weapon_enemy_item_and_mission_hooks_execute() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mods");
|
||||
let mut active =
|
||||
ActiveMods::build(&base(), &modding::discover(&dir), &ids(&["aurora"]), None).unwrap();
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.weapons
|
||||
.contains_key(&ContentId::from("aurora:pulse_projectile"))
|
||||
);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.items
|
||||
.contains_key(&ContentId::from("aurora:pulse_cannon"))
|
||||
);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.enemies
|
||||
.contains_key(&ContentId::from("aurora:weaver"))
|
||||
);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.campaigns
|
||||
.contains_key(&ContentId::from("aurora:trials"))
|
||||
);
|
||||
let mission = ContentId::from("aurora:field_trials");
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&mission, &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.get(&ContentId::from("aurora:pulse_cannon")),
|
||||
Some(&1)
|
||||
);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
active.scripts.states["aurora/scripts/weapon.rhai"]["shots"],
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| p.weapon.0 == "aurora:pulse_projectile")
|
||||
.count()
|
||||
>= 3
|
||||
);
|
||||
let entity = world
|
||||
.spawn(&ContentId::from("aurora:weaver"), 60, 30, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == entity)
|
||||
.unwrap()
|
||||
.vx,
|
||||
1
|
||||
);
|
||||
let commands = active
|
||||
.scripts
|
||||
.invoke(
|
||||
"aurora",
|
||||
"scripts/cell.rhai",
|
||||
"on_pickup",
|
||||
&json!({"entity":1}),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
world.apply_commands(&commands, &active.registry).unwrap();
|
||||
assert_eq!(world.player.credits, 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_strips_are_validated_without_the_original_catalog() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let art = json!({"png":"cell.png","logical_width":1,"logical_height":1,"frames":2});
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"art":art}]}),
|
||||
);
|
||||
image::RgbaImage::from_pixel(3, 1, image::Rgba([20, 40, 60, 255]))
|
||||
.save(temp.path().join("alpha/cell.png"))
|
||||
.unwrap();
|
||||
let message = error(ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
));
|
||||
assert!(message.contains("strip width"));
|
||||
image::RgbaImage::from_pixel(4, 2, image::Rgba([20, 40, 60, 255]))
|
||||
.save(temp.path().join("alpha/cell.png"))
|
||||
.unwrap();
|
||||
ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_arity_and_source_size_are_checked_before_activation() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
assert!(
|
||||
error(runtime.add("test", "bad.rhai", "fn on_fire(event,state) { state }"))
|
||||
.contains("callbacks require")
|
||||
);
|
||||
assert!(
|
||||
error(runtime.add("test", "huge.rhai", &" ".repeat(1024 * 1024 + 1)))
|
||||
.contains("source exceeds 1 MiB")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callbacks_can_query_player_entities_projectiles_pickups_and_objectives() {
|
||||
let mut registry = base();
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&ContentId::from("base:sector_1_wave_1"))
|
||||
.unwrap()
|
||||
.script = Some("queries.rhai".into());
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime.add("base", "queries.rhai", r#"fn on_mission_start(e,s,r) {
|
||||
#{commands:[],state:#{shield:e.player.shield,inventory:e.player.inventory,
|
||||
enemies:e.entities.len,shots:e.projectiles.len,pickups:e.pickups.len,objectives:e.objectives}}
|
||||
}"#).unwrap();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
let state = &runtime.states["base/queries.rhai"];
|
||||
assert_eq!(state["shield"], 100);
|
||||
assert_eq!(state["inventory"]["base:forward_guns"], 1);
|
||||
assert_eq!(state["enemies"], 0);
|
||||
assert_eq!(state["shots"], 0);
|
||||
assert_eq!(state["pickups"], 0);
|
||||
assert_eq!(state["objectives"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_special_items_run_on_use_once_per_button_press() {
|
||||
let mut registry = base();
|
||||
registry
|
||||
.apply(
|
||||
pack(json!({"items":[{
|
||||
"id":"test:repair","name":"Repair","price":5,"max_count":10,"shield":7,
|
||||
"special":true,"consumable":true,"script":"repair.rhai"
|
||||
}]})),
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"repair.rhai",
|
||||
r#"fn on_use(e,s,r) {
|
||||
let n=if s.contains("n") {s.n+1} else {1};
|
||||
#{commands:[#{op:"credits",amount:5}],state:#{n:n}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.inventory.insert("base:energy".into(), 50);
|
||||
pilot.inventory.insert("test:repair".into(), 2);
|
||||
pilot.selected = Some("test:repair".into());
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let input = Input {
|
||||
special: true,
|
||||
..Input::default()
|
||||
};
|
||||
world.step(input, ®istry, &mut runtime).unwrap();
|
||||
world.step(input, ®istry, &mut runtime).unwrap();
|
||||
assert_eq!(world.player.shield, 57);
|
||||
assert_eq!(world.player.inventory[&ContentId::from("test:repair")], 1);
|
||||
assert_eq!(runtime.states["test/repair.rhai"]["n"], 1);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
world.step(input, ®istry, &mut runtime).unwrap();
|
||||
assert_eq!(world.player.shield, 64);
|
||||
assert_eq!(world.player.credits, 10);
|
||||
assert_eq!(runtime.states["test/repair.rhai"]["n"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_sleep_and_indirect_native_calls_are_not_exposed() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
for source in [
|
||||
"fn on_tick(e,s,r) { timestamp(); #{commands:[],state:s} }",
|
||||
"fn on_tick(e,s,r) { sleep(0); #{commands:[],state:s} }",
|
||||
"fn on_tick(e,s,r) { let f=Fn(\"sleep\"); f.call(0); #{commands:[],state:s} }",
|
||||
"fn on_tick(e,s,r) { let f=Fn(\"timestamp\"); f.call(); #{commands:[],state:s} }",
|
||||
] {
|
||||
runtime.begin_tick();
|
||||
runtime.add("test", "unsafe.rhai", source).unwrap();
|
||||
let message = error(runtime.invoke("test", "unsafe.rhai", "on_tick", &json!({}), 1));
|
||||
assert!(message.contains("Function not found"), "{message}");
|
||||
}
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_eval_and_imports_cannot_be_reintroduced_by_function_pointers() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
assert!(
|
||||
runtime
|
||||
.add("test", "eval.rhai", "fn on_tick(e,s,r) { eval(\"42\"); }")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.add("test", "imports.rhai", "import \"external\" as x;")
|
||||
.is_err()
|
||||
);
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"indirect.rhai",
|
||||
"fn on_tick(e,s,r) { let f=Fn(\"eval\"); f.call(\"42\"); #{commands:[],state:s} }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
runtime
|
||||
.invoke("test", "indirect.rhai", "on_tick", &json!({}), 1)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_inventory_commands_preserve_the_twenty_item_capacity() {
|
||||
let mut registry = base();
|
||||
let extra: Vec<_> = (0..19)
|
||||
.map(|n| item(&format!("test:item_{n}"), 1))
|
||||
.collect();
|
||||
registry
|
||||
.apply(pack(json!({"items":extra})), "test")
|
||||
.unwrap();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
for n in 0..18 {
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::GiveItem {
|
||||
item: format!("test:item_{n}"),
|
||||
count: 1,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(world.player.inventory.len(), 20);
|
||||
let before = world.digest();
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::GiveItem {
|
||||
item: "test:item_18".into(),
|
||||
count: 1
|
||||
}],
|
||||
®istry
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(before, world.digest());
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::GiveItem {
|
||||
item: "test:item_0".into(),
|
||||
count: 1,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.inventory[&ContentId::from("test:item_0")], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_objectives_remain_updatable_at_the_capacity_limit() {
|
||||
let registry = base();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
for n in 0..1024 {
|
||||
world.objectives.insert(format!("test:{n}"), 0);
|
||||
}
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Objective {
|
||||
key: "test:0".into(),
|
||||
value: 10,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.objectives["test:0"], 10);
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Objective {
|
||||
key: "test:new".into(),
|
||||
value: 1
|
||||
}],
|
||||
®istry
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(world.objectives.len(), 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enemy_weapons_and_enemies_receive_fire_and_damage_hooks() {
|
||||
let mut registry = base();
|
||||
registry.apply(pack(json!({
|
||||
"weapons":[{"id":"test:bolt","name":"Bolt","damage":1,"speed":1,"cooldown":2,"behavior":"straight","target":"all","script":"weapon.rhai"}],
|
||||
"enemies":[{"id":"test:fighter","name":"Fighter","health":100,"speed":1,"cooldown":2,"weapon":"test:bolt","script":"enemy.rhai"}]
|
||||
})), "test").unwrap();
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"weapon.rhai",
|
||||
r#"fn on_fire(e,s,r) {
|
||||
#{commands:[],state:#{hostile:e.hostile,entity:e.entity}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"enemy.rhai",
|
||||
r#"fn on_fire(e,s,r) { #{commands:[],state:#{fired:true}} }
|
||||
fn on_hit(e,s,r) { s.hit=e.damage; s.scripted=e.scripted; #{commands:[],state:s} }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let entity = world
|
||||
.spawn(&ContentId::from("test:fighter"), 30, 30, ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert_eq!(runtime.states["test/weapon.rhai"]["hostile"], true);
|
||||
assert_eq!(runtime.states["test/weapon.rhai"]["entity"], entity);
|
||||
assert_eq!(runtime.states["test/enemy.rhai"]["fired"], true);
|
||||
world
|
||||
.apply_commands(&[Command::Damage { entity, amount: 7 }], ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert_eq!(runtime.states["test/enemy.rhai"]["hit"], 7);
|
||||
assert_eq!(runtime.states["test/enemy.rhai"]["scripted"], true);
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
sound,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn wav(channels: u16, bits: u16, data: &[u8]) -> Vec<u8> {
|
||||
let align = channels * bits / 8;
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend((36u32 + data.len() as u32 + (data.len() as u32 % 2)).to_le_bytes());
|
||||
bytes.extend(b"WAVEfmt ");
|
||||
bytes.extend(16u32.to_le_bytes());
|
||||
bytes.extend(1u16.to_le_bytes());
|
||||
bytes.extend(channels.to_le_bytes());
|
||||
bytes.extend(11025u32.to_le_bytes());
|
||||
bytes.extend((11025u32 * align as u32).to_le_bytes());
|
||||
bytes.extend(align.to_le_bytes());
|
||||
bytes.extend(bits.to_le_bytes());
|
||||
bytes.extend(b"data");
|
||||
bytes.extend((data.len() as u32).to_le_bytes());
|
||||
bytes.extend(data);
|
||||
if data.len() % 2 == 1 {
|
||||
bytes.push(0);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
fn registry() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let pack: ContentPack=serde_json::from_value(json!({
|
||||
"items":[{"id":"base:energy","name":"Shield","price":0,"max_count":100},
|
||||
{"id":"base:forward_guns","name":"Guns","price":0}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":1,"auto_complete":false,"script":"mission.rhai"}],
|
||||
"sounds":[{"id":"base:test","name":"Test","file":"test.wav"}]
|
||||
})).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
#[test]
|
||||
fn wav_handles_padding_unsigned_eight_bit_and_stereo_sixteen_bit() {
|
||||
let decoded = sound::decode(&wav(1, 8, &[0, 128, 255])).unwrap();
|
||||
assert_eq!(decoded.data, [0, 128, 255]);
|
||||
assert_eq!(decoded.rate, 11025);
|
||||
let samples: Vec<_> = [-32768i16, 32767, -32768, -32768, 32767, 32767]
|
||||
.into_iter()
|
||||
.flat_map(i16::to_le_bytes)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sound::decode(&wav(2, 16, &samples)).unwrap().data,
|
||||
[128, 0, 255]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_wave_ranges_formats_and_padding_fail() {
|
||||
let bytes = wav(1, 8, &[128, 200, 0]);
|
||||
for end in 0..bytes.len() {
|
||||
assert!(sound::decode(&bytes[..end]).is_err());
|
||||
}
|
||||
assert!(sound::decode(&wav(3, 8, &[0, 0, 0])).is_err());
|
||||
assert!(sound::decode(&wav(1, 32, &[0, 0, 0, 0])).is_err());
|
||||
let mut huge = bytes.clone();
|
||||
huge[40..44].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(sound::decode(&huge).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn scripted_completion_emits_end_hook_once_and_sound_ids_are_checked() {
|
||||
let registry = registry();
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
scripts.add("base","mission.rhai",r#"
|
||||
fn on_tick(event,state,random) { #{commands:[#{op:"complete"}],state:state} }
|
||||
fn on_mission_end(event,state,random) { #{commands:[#{op:"play_sound",sound:"base:test"}],state:#{ended:1}} }
|
||||
"#).unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Complete);
|
||||
assert_eq!(world.mod_sounds.len(), 1);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.mod_sounds.len(), 1);
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::PlaySound {
|
||||
sound: "base:missing".into()
|
||||
}],
|
||||
®istry
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn package_local_sounds_validate_and_missing_files_reject_activation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let package = dir.path().join("tone");
|
||||
std::fs::create_dir_all(&package).unwrap();
|
||||
std::fs::write(
|
||||
package.join("mod.toml"),
|
||||
"id='tone'\nname='Tone'\nversion='1.0.0'\napi_version=1\ncontent='content.json'\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
package.join("content.json"),
|
||||
serde_json::to_vec(&json!({"sounds":[{"id":"tone:shot","name":"Shot","file":"shot.wav"}]}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(package.join("shot.wav"), wav(1, 8, &[128, 200, 50])).unwrap();
|
||||
let found = modding::discover(dir.path());
|
||||
assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_ok());
|
||||
std::fs::remove_file(package.join("shot.wav")).unwrap();
|
||||
let found = modding::discover(dir.path());
|
||||
assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_err());
|
||||
}
|
||||
Reference in new issue
Block a user