Make RustRaptor the repository root and track its playable bundle
This commit is contained in:
1 parent
4b1edefeec
commit
8f459796b1
9860 files changed
+21084
-61019
No files matched your search
+487
@@ -0,0 +1,487 @@
|
||||
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 changing_fx_volume_mutes_and_restores_already_playing_samples() {
|
||||
let mut mixer = mixer();
|
||||
mixer.fx_volume = 127;
|
||||
mixer.sample(sample(vec![255; 4000]), 127, 128, 127, 0);
|
||||
let mut before = [0; 128];
|
||||
mixer.mix(&mut before);
|
||||
assert!(before[16..].iter().all(|v| *v > 0));
|
||||
mixer.fx_volume = 0;
|
||||
let mut muted = [99; 256];
|
||||
mixer.mix(&mut muted);
|
||||
assert!(muted.iter().all(|v| *v == 0));
|
||||
mixer.fx_volume = 127;
|
||||
let mut restored = [0; 128];
|
||||
mixer.mix(&mut restored);
|
||||
assert!(restored[16..].iter().all(|v| *v > 0));
|
||||
mixer.fx_volume = 32;
|
||||
let mut reduced = [0; 128];
|
||||
mixer.mix(&mut reduced);
|
||||
assert!(reduced[16..].iter().all(|v| *v > 0));
|
||||
assert!(reduced[16..].iter().max().unwrap() < &(restored[16..].iter().max().unwrap() / 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_mute_is_immediate_and_restores_an_active_score_without_muting_effects() {
|
||||
let mut mixer = mixer();
|
||||
mixer.music_volume = 127;
|
||||
mixer.play(
|
||||
MusScore::decode(&mus(&[0x90, 188, 100, 127, 0x60], 1)).unwrap(),
|
||||
false,
|
||||
);
|
||||
let mut before = vec![0; 3000 * 2];
|
||||
mixer.mix(&mut before);
|
||||
assert!(before.iter().any(|v| *v != 0));
|
||||
mixer.music_volume = 0;
|
||||
let mut muted = [99; 512];
|
||||
mixer.mix(&mut muted);
|
||||
assert!(muted.iter().all(|v| *v == 0));
|
||||
mixer.music_volume = 127;
|
||||
let mut restored = vec![0; 3000 * 2];
|
||||
mixer.mix(&mut restored);
|
||||
assert!(restored.iter().any(|v| *v != 0));
|
||||
mixer.music_volume = 0;
|
||||
mixer.fx_volume = 127;
|
||||
mixer.sample(sample(vec![255; 4000]), 127, 128, 127, 0);
|
||||
mixer.mix(&mut muted);
|
||||
assert!(muted[16..].iter().all(|v| *v > 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_capture_records_exact_callback_output_without_changing_it() {
|
||||
let mut captured = mixer();
|
||||
let mut normal = mixer();
|
||||
captured.start_capture(1).unwrap();
|
||||
assert!(normal.start_capture(0).is_err());
|
||||
assert!(normal.start_capture(121).is_err());
|
||||
for mixer in [&mut captured, &mut normal] {
|
||||
mixer.fx_volume = 127;
|
||||
mixer.sample(
|
||||
sample((0..255).cycle().take(12000).collect()),
|
||||
0,
|
||||
128,
|
||||
100,
|
||||
0,
|
||||
);
|
||||
}
|
||||
let mut actual = vec![0; 44100 * 4];
|
||||
let mut expected = vec![0; actual.len()];
|
||||
captured.mix(&mut actual);
|
||||
normal.mix(&mut expected);
|
||||
assert_eq!(actual, expected);
|
||||
let capture = captured.take_capture().unwrap();
|
||||
assert_eq!(capture.rate, 44100);
|
||||
assert_eq!(capture.samples.len(), 44100 * 2);
|
||||
assert_eq!(capture.samples, actual[..44100 * 2]);
|
||||
assert!(capture.samples.chunks_exact(2).any(|s| s[0] != 0));
|
||||
// Native DSP panning deliberately retains a small opposite-channel bleed.
|
||||
let left: i64 = capture
|
||||
.samples
|
||||
.chunks_exact(2)
|
||||
.map(|s| i64::from(s[0]).abs())
|
||||
.sum();
|
||||
let right: i64 = capture
|
||||
.samples
|
||||
.chunks_exact(2)
|
||||
.map(|s| i64::from(s[1]).abs())
|
||||
.sum();
|
||||
assert!(left > right * 4);
|
||||
let wav = raptor_replica::sound::encode_capture(&capture).unwrap();
|
||||
assert_eq!(&wav[..4], b"RIFF");
|
||||
assert_eq!(u16::from_le_bytes(wav[22..24].try_into().unwrap()), 2);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(wav[40..44].try_into().unwrap()),
|
||||
44100 * 4
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Original-data integration coverage. Run explicitly with --ignored; no game data is tracked.
|
||||
//! The assisted campaign fixture is described in tools/campaign_check.rs.
|
||||
#[path = "../tools/campaign_check.rs"]
|
||||
mod campaign_check;
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, decode_demo},
|
||||
content::ContentId,
|
||||
modding::ScriptRuntime,
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use std::{collections::BTreeSet, path::PathBuf};
|
||||
|
||||
fn catalog_registry() -> (Catalog, raptor_replica::content::Registry) {
|
||||
let root = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data"));
|
||||
campaign_check::catalog_registry(&root).unwrap()
|
||||
}
|
||||
fn registry() -> raptor_replica::content::Registry {
|
||||
catalog_registry().1
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original untracked catalog; assisted inspection, not ordinary gameplay wins"]
|
||||
fn every_original_wave_reaches_its_end_on_all_four_difficulties() {
|
||||
let registry = registry();
|
||||
assert_eq!(registry.missions.len(), 27);
|
||||
let mut failures = vec![];
|
||||
let mut reports = vec![];
|
||||
for difficulty in 0..=3 {
|
||||
for sector in 1..=3 {
|
||||
for wave in 1..=9 {
|
||||
let report =
|
||||
campaign_check::run_wave(®istry, sector, wave, difficulty, 12000).unwrap();
|
||||
eprintln!("{}", report.csv());
|
||||
if !report.completed()
|
||||
|| report.visited_spawns != report.total_spawns
|
||||
|| report.bosses_seen != report.expected_bosses
|
||||
{
|
||||
failures.push(format!("{report:?}"));
|
||||
}
|
||||
reports.push(report.csv());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(path) = std::env::var_os("RAPTOR_CAMPAIGN_REPORT") {
|
||||
std::fs::write(
|
||||
path,
|
||||
format!("{}\n{}\n", campaign_check::csv_header(), reports.join("\n")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"Assisted fixture blockers:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original untracked catalog; progression fixture bypasses combat"]
|
||||
fn all_original_campaign_and_training_progression_paths_are_available() {
|
||||
let registry = registry();
|
||||
for difficulty in 0..=3 {
|
||||
let mut pilot = Pilot {
|
||||
difficulty,
|
||||
..Pilot::default()
|
||||
};
|
||||
let waves = if difficulty == 0 { 4 } else { 9 };
|
||||
let sectors = if difficulty == 0 { 1 } else { 3 };
|
||||
for sector in 1..=sectors {
|
||||
for wave in 1..=waves {
|
||||
let expected = ContentId::new(format!("base:sector_{sector}_wave_{wave}")).unwrap();
|
||||
assert_eq!(pilot.mission, expected);
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
pilot.validate(®istry, &[]).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(pilot.mission, ContentId::from("base:sector_1_wave_1"));
|
||||
assert_eq!(pilot.difficulty, (difficulty + 1).min(3));
|
||||
let progress = pilot.legacy_progress.as_ref().unwrap();
|
||||
assert!(progress.waves.iter().all(|wave| *wave == 0));
|
||||
if difficulty == 0 {
|
||||
assert!(progress.training_completed);
|
||||
assert_eq!(pilot.credits, 10000);
|
||||
assert_eq!(pilot.inventory[&"base:energy".into()], 75);
|
||||
} else {
|
||||
assert_eq!(pilot.completed.len(), 27);
|
||||
assert_eq!(&progress.difficulties[..3], &[(difficulty + 1).min(3); 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original untracked catalog; assisted weapon exercise, no weapon parity claim"]
|
||||
fn every_original_player_weapon_emits_with_live_mission_targets() {
|
||||
let registry = registry();
|
||||
let weapons: Vec<_> = registry
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| i.weapon.is_some() && i.legacy.is_some())
|
||||
.collect();
|
||||
assert_eq!(weapons.len(), 15);
|
||||
let mut exercised = BTreeSet::new();
|
||||
for item in weapons {
|
||||
let id = item.weapon.clone().unwrap();
|
||||
let mut pilot = Pilot {
|
||||
difficulty: 3,
|
||||
selected: Some(item.id.clone()),
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.inventory = [
|
||||
(item.id.clone(), item.max_count),
|
||||
("base:energy".into(), 100),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for tick in 0..1500 {
|
||||
world.player.shield = 100;
|
||||
world.player.super_shield = 100;
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
absolute: Some([144, 160]),
|
||||
bomb: tick % 180 == 0,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
if world.projectiles.iter().any(|p| p.weapon == id) {
|
||||
exercised.insert(id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
exercised.contains(&id),
|
||||
"original weapon {id} produced no projectile with mission targets"
|
||||
);
|
||||
}
|
||||
assert_eq!(exercised.len(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original catalog and rebuilt C++ full-demo reference trace"]
|
||||
fn complete_demo_1_matches_every_reference_gameplay_snapshot() {
|
||||
let path = std::env::var_os("RAPTOR_REFERENCE_DEMO1")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.reference/cpp-demo1-full.jsonl")
|
||||
});
|
||||
let reference: Vec<serde_json::Value> = std::fs::read_to_string(&path)
|
||||
.expect("produce the trace using the command in docs/campaign-validation.md")
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
reference.len(),
|
||||
1889,
|
||||
"expected the entire available DEMO1G1 recording"
|
||||
);
|
||||
let (catalog, registry) = catalog_registry();
|
||||
let (sector, wave, frames) = decode_demo(&catalog.named("DEMO1G1_REC").unwrap()).unwrap();
|
||||
assert_eq!(frames.len(), reference.len());
|
||||
let mut pilot = Pilot::demo(sector, ®istry).unwrap();
|
||||
pilot.mission = format!("base:sector_{}_wave_{}", sector + 1, wave + 1)
|
||||
.as_str()
|
||||
.into();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for (frame, expected) in frames.into_iter().zip(reference) {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: frame.buttons[0],
|
||||
cycle: frame.buttons[1],
|
||||
bomb: frame.buttons[2],
|
||||
absolute: Some([frame.x as i32, frame.y as i32]),
|
||||
demo_bank: Some(frame.bank as i32),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.reference_state(®istry),
|
||||
expected,
|
||||
"reference mismatch at simulation step {}",
|
||||
world.tick
|
||||
);
|
||||
}
|
||||
assert_eq!(world.tick, 1889);
|
||||
assert_eq!(world.player.shield, 3);
|
||||
assert_eq!(world.player.credits, 60825);
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
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 shop_pack_sales_return_half_the_unit_price_without_creating_credits() {
|
||||
let mut registry = pilot_registry();
|
||||
let pack = serde_json::from_value(serde_json::json!({
|
||||
"items":[{"id":"fixture:repair_pack", "name":"Three repairs", "price":900,
|
||||
"initial_count":3, "max_count":8, "consumable":true}]
|
||||
}))
|
||||
.unwrap();
|
||||
registry.apply(pack, "fixture").unwrap();
|
||||
registry.validate().unwrap();
|
||||
let id = "fixture:repair_pack".into();
|
||||
let item = ®istry.items[&id];
|
||||
assert_eq!(item.resale_price(), 150);
|
||||
let mut pilot = Pilot {
|
||||
credits: 10_000,
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.buy(&id, ®istry).unwrap();
|
||||
pilot.buy(&id, ®istry).unwrap();
|
||||
assert_eq!(pilot.inventory[&id], 6);
|
||||
for remaining in (0..6).rev() {
|
||||
pilot.sell(&id, ®istry).unwrap();
|
||||
assert_eq!(pilot.item_count(&id), remaining);
|
||||
}
|
||||
assert_eq!(pilot.credits, 9_100);
|
||||
assert!(!pilot.inventory.contains_key(&id));
|
||||
let credits = pilot.credits;
|
||||
assert!(pilot.sell(&id, ®istry).is_err());
|
||||
assert_eq!(pilot.credits, credits);
|
||||
assert_eq!(registry.items[&"base:energy".into()].resale_price(), 5_000);
|
||||
}
|
||||
|
||||
#[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"], "Z");
|
||||
assert_eq!(config.bindings["cycle"], "Left Alt");
|
||||
assert_eq!(config.bindings["bomb"], "Space");
|
||||
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,450 @@
|
||||
//! 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("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("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,379 @@
|
||||
//! Opt-in ordinary-input victory check against the installed original catalog.
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, decode_demo},
|
||||
content::Registry,
|
||||
modding::ScriptRuntime,
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
const MISSION: &str = "base:sector_1_wave_1";
|
||||
const ORIGINAL_FLYBY_SOUND: u32 = 129;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct DepartureObservation {
|
||||
tick: u64,
|
||||
timer: Option<u32>,
|
||||
x: i32,
|
||||
y: i32,
|
||||
draw_player: bool,
|
||||
control_paused: bool,
|
||||
sounds: Vec<u32>,
|
||||
}
|
||||
|
||||
fn target_x(world: &World, registry: &Registry) -> i32 {
|
||||
let center = world.player.x + 16;
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.filter(|e| e.health > 0 && e.y + e.height > 0 && e.y < world.player.y)
|
||||
.min_by_key(|e| {
|
||||
let def = ®istry.enemies[&e.definition];
|
||||
(
|
||||
!def.boss,
|
||||
(e.x + e.width / 2 - center).abs() + (160 - e.y).max(0) / 3,
|
||||
)
|
||||
})
|
||||
.map_or(160, |e| {
|
||||
// Lead the current observed motion by the forward projectile's travel time.
|
||||
let travel = ((world.player.y - e.y - e.height / 2) / 8).clamp(0, 24);
|
||||
(e.x + e.width / 2 + (e.x - e.px).clamp(-4, 4) * travel).clamp(21, 298)
|
||||
})
|
||||
}
|
||||
|
||||
fn controller(world: &World, registry: &Registry) -> Input {
|
||||
let aim = target_x(world, registry);
|
||||
if world.enemies.is_empty() && !world.projectiles.iter().any(|p| p.hostile) {
|
||||
return Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
};
|
||||
}
|
||||
let mut best = None;
|
||||
for dy in [0, -1, 1] {
|
||||
for dx in [0, -1, 1] {
|
||||
let input = Input {
|
||||
dx,
|
||||
dy,
|
||||
fire: true,
|
||||
..Input::default()
|
||||
};
|
||||
// These are disposable predictions. The actual world changes only
|
||||
// through normal keyboard movement and the held fire button.
|
||||
let mut prediction = world.clone();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for future in 0..12 {
|
||||
let projected = if future < 3 {
|
||||
input
|
||||
} else {
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
}
|
||||
};
|
||||
prediction.step(projected, registry, &mut scripts).unwrap();
|
||||
}
|
||||
let lost = (world.player.shield + world.player.super_shield
|
||||
- prediction.player.shield
|
||||
- prediction.player.super_shield)
|
||||
.max(0) as i64;
|
||||
let position = prediction.player.x + 16 + prediction.player.vx * 2;
|
||||
let mut cost = lost * 1_000_000
|
||||
+ (position - aim).abs() as i64 * 80
|
||||
+ (160 - prediction.player.y).abs() as i64 * 6;
|
||||
if prediction.outcome == Outcome::Failed {
|
||||
cost += 1_000_000_000;
|
||||
}
|
||||
for p in prediction.projectiles.iter().filter(|p| p.hostile) {
|
||||
let distance = (p.x - prediction.player.x - 16)
|
||||
.abs()
|
||||
.max((p.y - prediction.player.y - 16).abs());
|
||||
cost += (40 - distance).max(0) as i64 * 30;
|
||||
}
|
||||
let gained = prediction
|
||||
.player
|
||||
.credits
|
||||
.saturating_sub(world.player.credits);
|
||||
cost -= gained as i64;
|
||||
if best.as_ref().is_none_or(|(previous, _)| cost < *previous) {
|
||||
best = Some((cost, input));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.unwrap().1
|
||||
}
|
||||
|
||||
fn demo(frames: &[[u8; 12]]) -> Vec<u8> {
|
||||
assert!(frames.len() <= i16::MAX as usize);
|
||||
let mut data = vec![0_u8; 12];
|
||||
data[8..10].copy_from_slice(&(frames.len() as i16).to_le_bytes());
|
||||
for frame in frames {
|
||||
data.extend_from_slice(frame);
|
||||
}
|
||||
let (sector, wave, decoded) = decode_demo(&data).unwrap();
|
||||
assert_eq!((sector, wave, decoded.len()), (0, 0, frames.len()));
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires locally installed original data; ordinary-input first mission victory"]
|
||||
fn original_first_mission_is_won_with_a_fresh_pilot() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let path = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| root.join("../data"));
|
||||
let catalog = Catalog::open(&path).unwrap();
|
||||
let registry = Registry::base(&catalog).unwrap();
|
||||
let reference = root.join("../.reference");
|
||||
fs::create_dir_all(&reference).unwrap();
|
||||
for difficulty in [1, 2] {
|
||||
let pilot = Pilot {
|
||||
difficulty,
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.validate(®istry, &[]).unwrap();
|
||||
let mut world = World::new(&MISSION.into(), &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
let mut bosses = BTreeMap::new();
|
||||
let mut visible_bosses = BTreeMap::new();
|
||||
let mut killed = BTreeMap::new();
|
||||
// A removed entity with positive prior health is not credited as a
|
||||
// kill. This is conservative around same-step collision deaths.
|
||||
let mut positive_retirements = BTreeMap::new();
|
||||
let mut frames = Vec::new();
|
||||
let mut controls = Vec::new();
|
||||
let mut observations = Vec::new();
|
||||
let mut snapshots = Vec::new();
|
||||
let mut departure = Vec::new();
|
||||
for _ in 0..12_000 {
|
||||
if world.outcome != Outcome::Playing {
|
||||
break;
|
||||
}
|
||||
let input = controller(&world, ®istry);
|
||||
let prior: Vec<_> = world
|
||||
.enemies
|
||||
.iter()
|
||||
.map(|e| (e.entity, e.definition.clone(), e.health))
|
||||
.collect();
|
||||
let before = world.player.shield;
|
||||
let before_credits = world.player.credits;
|
||||
let before_timer = world.end_timer;
|
||||
world.step(input, ®istry, &mut scripts).unwrap();
|
||||
if world.end_timer.is_some() || before_timer.is_some() {
|
||||
departure.push(DepartureObservation {
|
||||
tick: world.tick,
|
||||
timer: world.end_timer,
|
||||
x: world.player.x,
|
||||
y: world.player.y,
|
||||
draw_player: world.draw_player,
|
||||
control_paused: world.control_paused,
|
||||
sounds: world.sounds.clone(),
|
||||
});
|
||||
}
|
||||
for e in &world.enemies {
|
||||
if registry.enemies[&e.definition].boss {
|
||||
bosses
|
||||
.entry(e.entity)
|
||||
.or_insert_with(|| (e.definition.clone(), world.tick));
|
||||
if e.health > 0
|
||||
&& e.y + e.height > 0
|
||||
&& e.y < 200
|
||||
&& e.x + e.width > 0
|
||||
&& e.x < 320
|
||||
{
|
||||
visible_bosses.entry(e.entity).or_insert(world.tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (entity, definition, health) in prior {
|
||||
if !world.enemies.iter().any(|e| e.entity == entity) {
|
||||
if health <= 0 {
|
||||
let reward = registry.enemies[&definition].reward;
|
||||
if bosses.contains_key(&entity) {
|
||||
assert!(
|
||||
world.player.credits >= before_credits + reward,
|
||||
"original boss death did not award its reward"
|
||||
);
|
||||
}
|
||||
killed.insert(entity, json!({"definition":definition,"tick":world.tick,"last_health":health,"reward":reward}));
|
||||
} else {
|
||||
positive_retirements.insert(entity, (definition, health));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut frame = [0_u8; 12];
|
||||
frame[0] = u8::from(input.fire);
|
||||
frame[1] = u8::from(input.cycle);
|
||||
frame[2] = u8::from(input.bomb);
|
||||
frame[4..6].copy_from_slice(&(world.player.x as i16).to_le_bytes());
|
||||
frame[6..8].copy_from_slice(&(world.player.y as i16).to_le_bytes());
|
||||
frame[8..10].copy_from_slice(&(world.player.bank as i16).to_le_bytes());
|
||||
frames.push(frame);
|
||||
controls.push(json!({"tick":world.tick,"dx":input.dx,"dy":input.dy,"fire":input.fire}));
|
||||
snapshots.push(world.reference_state(®istry));
|
||||
if world.tick % 100 == 0
|
||||
|| before != world.player.shield
|
||||
|| world.outcome != Outcome::Playing
|
||||
{
|
||||
observations.push(json!({"tick":world.tick,"x":world.player.x,"y":world.player.y,"shield":world.player.shield,"credits":world.player.credits,"enemies":world.enemies.iter().map(|e| json!({"entity":e.entity,"id":e.definition,"health":e.health,"x":e.x,"y":e.y})).collect::<Vec<_>>()}));
|
||||
}
|
||||
}
|
||||
let cue = departure
|
||||
.iter()
|
||||
.find(|observation| observation.timer == Some(40))
|
||||
.expect("successful original mission skipped its departure cue");
|
||||
let report = json!({"mission":MISSION,"initial_pilot":pilot,"difficulty":difficulty,"ticks":world.tick,"demo_recorded_steps":cue.tick,"outcome":world.outcome,"shield":world.player.shield,"credits":world.player.credits,"spawns":world.spawn_cursor,"total_spawns":registry.missions[&world.mission].spawns.len(),"bosses":bosses,"bosses_first_visible":visible_bosses,"killed":killed,"positive_health_retirements":positive_retirements,"departure":departure,"observations":observations});
|
||||
let name = format!("first-mission-fresh-difficulty-{difficulty}");
|
||||
// The original DEMO_Think records before the stats-stage forced exit
|
||||
// movement. End-of-step coordinates are equivalent until timer40.
|
||||
// Keep this legacy recording to that boundary; the keyboard log below
|
||||
// independently reproduces the complete ordinary departure.
|
||||
let recorded = demo(&frames[..cue.tick as usize]);
|
||||
fs::write(reference.join(format!("{name}.dem")), &recorded).unwrap();
|
||||
fs::write(
|
||||
reference.join(format!("{name}-controls.json")),
|
||||
serde_json::to_vec(&controls).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
reference.join(format!("{name}-report.json")),
|
||||
serde_json::to_vec_pretty(&report).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut trace =
|
||||
BufWriter::new(fs::File::create(reference.join(format!("{name}.jsonl"))).unwrap());
|
||||
for snapshot in &snapshots {
|
||||
writeln!(&mut trace, "{snapshot}").unwrap();
|
||||
}
|
||||
trace.flush().unwrap();
|
||||
eprintln!(
|
||||
"First original mission difficulty {difficulty}: {:?}, {} steps, shield {}, credits {}, bosses {:?}, confirmed kills {}, positive-health retirements {}",
|
||||
world.outcome,
|
||||
world.tick,
|
||||
world.player.shield,
|
||||
world.player.credits,
|
||||
bosses,
|
||||
killed.len(),
|
||||
positive_retirements.len()
|
||||
);
|
||||
assert_eq!(world.outcome, Outcome::Complete, "{report}");
|
||||
let mission = ®istry.missions[&world.mission];
|
||||
assert_eq!(world.spawn_cursor, mission.spawns.len());
|
||||
let expected_bosses = mission
|
||||
.spawns
|
||||
.iter()
|
||||
.filter(|spawn| {
|
||||
registry.enemies[&spawn.enemy].boss
|
||||
&& match spawn.difficulty {
|
||||
3 => true,
|
||||
4 => difficulty >= 2,
|
||||
5 => difficulty >= 3,
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
expected_bosses > 0,
|
||||
"catalog did not contain an original boss"
|
||||
);
|
||||
assert_eq!(
|
||||
bosses.len(),
|
||||
expected_bosses,
|
||||
"some original bosses never appeared"
|
||||
);
|
||||
assert_eq!(
|
||||
visible_bosses.len(),
|
||||
bosses.len(),
|
||||
"some bosses never entered the playfield alive"
|
||||
);
|
||||
assert!(
|
||||
bosses.keys().all(|entity| killed.contains_key(entity)),
|
||||
"an original boss escaped alive: {report}"
|
||||
);
|
||||
assert!(
|
||||
world.player.credits > pilot.credits,
|
||||
"victory must award original rewards"
|
||||
);
|
||||
assert!(cue.sounds.contains(&ORIGINAL_FLYBY_SOUND));
|
||||
assert!(cue.control_paused);
|
||||
let remaining_24 = departure
|
||||
.iter()
|
||||
.find(|observation| observation.timer == Some(24))
|
||||
.expect("successful original mission skipped its departure animation");
|
||||
assert!(
|
||||
remaining_24.draw_player,
|
||||
"a surviving pilot was hidden like a dead pilot"
|
||||
);
|
||||
assert!(
|
||||
world.player.y < 0,
|
||||
"surviving pilot never flew off the top of the screen"
|
||||
);
|
||||
// Legacy playback must preserve every snapshot through the boss kill
|
||||
// and flyby cue, before automatic departure changes the record phase.
|
||||
let (_, _, replay) = decode_demo(&recorded).unwrap();
|
||||
let mut replay_world = World::new(&MISSION.into(), &pilot, ®istry).unwrap();
|
||||
let mut replay_scripts = ScriptRuntime::new();
|
||||
for (frame, expected) in replay.into_iter().zip(&snapshots) {
|
||||
replay_world
|
||||
.step(
|
||||
Input {
|
||||
fire: frame.buttons[0],
|
||||
cycle: frame.buttons[1],
|
||||
bomb: frame.buttons[2],
|
||||
absolute: Some([frame.x as i32, frame.y as i32]),
|
||||
demo_bank: Some(frame.bank as i32),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut replay_scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
replay_world.reference_state(®istry),
|
||||
*expected,
|
||||
"recorded first-mission input diverged at step {}",
|
||||
replay_world.tick
|
||||
);
|
||||
}
|
||||
assert_eq!(replay_world.tick, cue.tick);
|
||||
assert_eq!(replay_world.end_timer, Some(40));
|
||||
assert!(replay_world.enemies.is_empty());
|
||||
// The keyboard log is also independently decoded and replayed. Unlike
|
||||
// the original demo format, it includes ordinary departure movement.
|
||||
let keyboard: Vec<serde_json::Value> = serde_json::from_slice(
|
||||
&fs::read(reference.join(format!("{name}-controls.json"))).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut keyboard_world = World::new(&MISSION.into(), &pilot, ®istry).unwrap();
|
||||
let mut keyboard_scripts = ScriptRuntime::new();
|
||||
assert_eq!(keyboard.len(), snapshots.len());
|
||||
for (control, expected) in keyboard.into_iter().zip(snapshots) {
|
||||
keyboard_world
|
||||
.step(
|
||||
Input {
|
||||
dx: control["dx"].as_i64().unwrap() as i32,
|
||||
dy: control["dy"].as_i64().unwrap() as i32,
|
||||
fire: control["fire"].as_bool().unwrap(),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut keyboard_scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
keyboard_world.reference_state(®istry),
|
||||
expected,
|
||||
"keyboard recording diverged at step {}",
|
||||
keyboard_world.tick
|
||||
);
|
||||
}
|
||||
assert_eq!(keyboard_world.outcome, Outcome::Complete);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, LegacyProjectile, LineMove, Projectile, 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 demo_coordinates_bypass_normal_input_bounds() {
|
||||
let reg = fixture();
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([284, 162]),
|
||||
demo_bank: Some(3),
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((world.player.x, world.player.y), (284, 162));
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([284, 162]),
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((world.player.x, world.player.y), (282, 160));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
fn loss_fixture() -> Registry {
|
||||
let mut registry = fixture();
|
||||
registry.apply(serde_json::from_value(json!({"items":[
|
||||
{"id":"base:dumb_missile","name":"Dumb Missile","price":0,"special":true,"legacy":3},
|
||||
{"id":"base:mini_gun","name":"Mini Gun","price":0,"special":true,"legacy":4},
|
||||
{"id":"base:death_ray","name":"Death Ray","price":0,"special":true,"legacy":14},
|
||||
{"id":"base:detect","name":"Detector","price":0,"legacy":17},
|
||||
{"id":"base:itembuy6","name":"Cash Cargo","price":0,"max_count":50,"legacy":23}
|
||||
]})).unwrap(), "base").unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn low_shield_world(registry: &Registry) -> (World, ScriptRuntime) {
|
||||
let mut pilot = Pilot {
|
||||
difficulty: 3,
|
||||
..Pilot::default()
|
||||
};
|
||||
for id in [
|
||||
"base:dumb_missile",
|
||||
"base:mini_gun",
|
||||
"base:death_ray",
|
||||
"base:detect",
|
||||
] {
|
||||
pilot.inventory.insert(id.into(), 1);
|
||||
}
|
||||
pilot.inventory.insert("base:itembuy6".into(), 50);
|
||||
let mut world = World::new(&pilot.mission, &pilot, registry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(Input::default(), registry, &mut scripts)
|
||||
.unwrap();
|
||||
(world, scripts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_shield_damage_loses_selected_weapon_then_cycles_in_legacy_order() {
|
||||
let registry = loss_fixture();
|
||||
let (mut world, mut scripts) = low_shield_world(®istry);
|
||||
world.player.selected = Some("base:death_ray".into());
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 92,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
let seed = world.rng.seed;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 8);
|
||||
assert!(
|
||||
!world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:death_ray".into())
|
||||
);
|
||||
assert_eq!(world.player.selected, Some("base:dumb_missile".into()));
|
||||
assert_eq!(
|
||||
world.player.inventory[&ContentId::from("base:itembuy6")],
|
||||
50
|
||||
);
|
||||
assert!(world.sounds.contains(&119)); // FX_CRASH has no random pitch.
|
||||
assert_eq!(world.rng.seed, seed);
|
||||
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 1,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:dumb_missile".into())
|
||||
);
|
||||
assert_eq!(world.player.selected, Some("base:mini_gun".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_shield_without_selection_loses_highest_legacy_item_and_protects_essentials() {
|
||||
let registry = loss_fixture();
|
||||
let (mut world, mut scripts) = low_shield_world(®istry);
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 92,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(!world.player.inventory.contains_key(&"base:itembuy6".into()));
|
||||
for id in [
|
||||
"base:forward_guns",
|
||||
"base:energy",
|
||||
"base:detect",
|
||||
"base:death_ray",
|
||||
] {
|
||||
assert!(
|
||||
world.player.inventory.contains_key(&id.into()),
|
||||
"protected or lower-numbered item {id} lost"
|
||||
);
|
||||
}
|
||||
assert_eq!(world.player.selected, None);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:death_ray".into())
|
||||
);
|
||||
assert!(!world.sounds.contains(&119)); // Remaining at low shield does not lose another item.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_energy_with_super_shield_and_damage_above_threshold_preserve_equipment() {
|
||||
let registry = loss_fixture();
|
||||
let (mut world, mut scripts) = low_shield_world(®istry);
|
||||
world.player.selected = Some("base:death_ray".into());
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 89,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 11);
|
||||
assert!(
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:death_ray".into())
|
||||
);
|
||||
world.player.super_shield = 5;
|
||||
world
|
||||
.apply_commands(&[Command::Shield { amount: -3 }], ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 8);
|
||||
assert!(
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:death_ray".into())
|
||||
);
|
||||
assert!(!world.sounds.contains(&119));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_kamikaze_arms_chase_when_final_leg_starts_and_crosses_target() {
|
||||
let mut registry = fixture();
|
||||
let enemy = registry
|
||||
.enemies
|
||||
.get_mut(&ContentId::from("base:drone"))
|
||||
.unwrap();
|
||||
enemy.health = 10000;
|
||||
enemy.speed = 4;
|
||||
enemy.path = vec![[0, -114], [0, -96]];
|
||||
let mut fields = vec![0; 26];
|
||||
fields[7] = 1;
|
||||
fields[9] = 1;
|
||||
fields[18] = 4;
|
||||
fields[19] = 2;
|
||||
fields[21] = 2; // Original F_KAMI.
|
||||
enemy.legacy = Some(raptor_replica::assets::LegacyEnemy {
|
||||
name: "Synthetic Kamikaze".into(),
|
||||
fields,
|
||||
shoot_type: vec![0; 24],
|
||||
engine_x: vec![0; 24],
|
||||
engine_y: vec![0; 24],
|
||||
engine_width: vec![0; 24],
|
||||
gun_x: vec![0; 24],
|
||||
gun_y: vec![0; 24],
|
||||
flight_x: vec![0; 32],
|
||||
flight_y: vec![0; 32],
|
||||
});
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
let entity = world
|
||||
.spawn(&"base:drone".into(), 48, -131, ®istry)
|
||||
.unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
let mut armed = false;
|
||||
let mut crossed = false;
|
||||
for _ in 0..120 {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([96, 136]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
let Some(enemy) = world.enemies.iter().find(|e| e.entity == entity) else {
|
||||
break;
|
||||
};
|
||||
if enemy.kami == 1 {
|
||||
armed = true;
|
||||
assert_eq!(enemy.movement.ty, -12);
|
||||
assert!(!enemy.movement.done);
|
||||
assert_eq!(enemy.path_index, 1);
|
||||
}
|
||||
if enemy.kami == 2 {
|
||||
assert_eq!((enemy.movement.tx, enemy.movement.ty), (112, 152));
|
||||
crossed |= enemy.movement.y > 152;
|
||||
}
|
||||
}
|
||||
assert!(armed, "last authored leg never armed chase");
|
||||
assert!(crossed, "terminal chase stopped at its target");
|
||||
assert!(
|
||||
!world.enemies.iter().any(|e| e.entity == entity),
|
||||
"terminal kamikaze failed to retire offscreen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_beam_hits_launch_column_before_following_player_for_display() {
|
||||
let mut registry = fixture();
|
||||
registry.apply(serde_json::from_value(json!({
|
||||
"weapons":[{"id":"base:projectile_death_ray","name":"Death Ray","damage":6,"speed":0,"cooldown":7,"behavior":"beam","target":"all","legacy":14,"art":{"asset":50,"logical_width":2,"logical_height":8,"frames":4}}],
|
||||
"items":[{"id":"base:death_ray","name":"Death Ray","price":0,"special":true,"legacy":14,"weapon":"base:projectile_death_ray"}]
|
||||
})).unwrap(), "base").unwrap();
|
||||
let pilot = Pilot {
|
||||
selected: Some("base:death_ray".into()),
|
||||
inventory: [("base:death_ray".into(), 1), ("base:energy".into(), 100)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Pilot::default()
|
||||
};
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
world
|
||||
.spawn(&"base:drone".into(), 157, 40, ®istry)
|
||||
.unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.enemies[0].health, 14);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([141, 160]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.projectiles[0].x, 156); // Followed artwork is outside the enemy.
|
||||
assert_eq!(world.enemies[0].health, 8); // Damage still tests launch column159.
|
||||
}
|
||||
|
||||
fn collision_fixture() -> Registry {
|
||||
let mut registry = fixture();
|
||||
registry.apply(serde_json::from_value(json!({"weapons":[
|
||||
{"id":"base:projectile_energy_grab","name":"Energy Grab","damage":3,"speed":4,"cooldown":2,"behavior":"straight","target":"air","legacy":10,"art":{"asset":51,"logical_width":8,"logical_height":8,"frames":6}},
|
||||
{"id":"base:projectile_ground_bomb","name":"Ground Bomb","damage":50,"speed":1,"cooldown":30,"behavior":"straight","target":"ground","legacy":9,"art":{"asset":52,"logical_width":8,"logical_height":8}},
|
||||
{"id":"base:projectile_mega_bomb","name":"Mega Bomb","damage":50,"speed":2,"cooldown":60,"behavior":"straight","target":"all","legacy":11,"art":{"asset":53,"logical_width":8,"logical_height":8,"frames":4}},
|
||||
{"id":"base:hostile","name":"Hostile","damage":2,"speed":2,"cooldown":1,"behavior":"straight","target":"all"}
|
||||
],"enemies":[
|
||||
{"id":"base:ground","name":"Ground Target","health":30,"ground":true},
|
||||
{"id":"base:air","name":"Air Target","health":64,"speed":0,"path":[[0,200]],"weapon":"base:hostile"}
|
||||
]})).unwrap(), "base").unwrap();
|
||||
let air = registry
|
||||
.enemies
|
||||
.get_mut(&ContentId::from("base:air"))
|
||||
.unwrap();
|
||||
let mut fields = vec![0; 26];
|
||||
fields[5] = 1; // Native suck susceptibility.
|
||||
fields[7] = 1;
|
||||
fields[9] = 1;
|
||||
fields[13] = 64;
|
||||
fields[16] = 1;
|
||||
fields[21] = 3;
|
||||
fields[22] = 1;
|
||||
air.legacy = Some(raptor_replica::assets::LegacyEnemy {
|
||||
name: "Synthetic Susceptible Air Target".into(),
|
||||
fields,
|
||||
shoot_type: vec![1; 24],
|
||||
engine_x: vec![0; 24],
|
||||
engine_y: vec![0; 24],
|
||||
engine_width: vec![0; 24],
|
||||
gun_x: vec![16; 24],
|
||||
gun_y: vec![16; 24],
|
||||
flight_x: vec![0; 32],
|
||||
flight_y: vec![0; 32],
|
||||
});
|
||||
registry
|
||||
}
|
||||
|
||||
fn inject_legacy_shot(
|
||||
world: &mut World,
|
||||
id: &str,
|
||||
x: i32,
|
||||
y: i32,
|
||||
done: bool,
|
||||
registry: &Registry,
|
||||
) -> u64 {
|
||||
let weapon = ®istry.weapons[&ContentId::from(id)];
|
||||
let art = weapon.art.as_ref().unwrap();
|
||||
let mut movement = LineMove::new(
|
||||
x + art.logical_width as i32 / 2,
|
||||
y + art.logical_height as i32 / 2,
|
||||
x,
|
||||
0,
|
||||
);
|
||||
movement.done = done;
|
||||
let entity = world.next_entity;
|
||||
world.next_entity += 1;
|
||||
world.projectiles.push(Projectile {
|
||||
entity,
|
||||
weapon: id.into(),
|
||||
x,
|
||||
y,
|
||||
px: x,
|
||||
py: y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
hostile: false,
|
||||
age: 0,
|
||||
legacy_enemy: None,
|
||||
legacy: Some(LegacyProjectile {
|
||||
movement,
|
||||
speed: weapon.speed,
|
||||
frame: 0,
|
||||
art_frame: 0,
|
||||
delay: false,
|
||||
hit: false,
|
||||
start: [world.player.x + 16, world.player.y + 16],
|
||||
}),
|
||||
});
|
||||
entity
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn energy_grab_hits_air_by_one_and_suppresses_fire_after_resistance_is_exhausted() {
|
||||
let registry = collision_fixture();
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
world
|
||||
.spawn(&"base:ground".into(), 64, 64, ®istry)
|
||||
.unwrap();
|
||||
let entity = world.spawn(&"base:air".into(), 64, 64, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for (expected_health, expected_resistance, disabled) in
|
||||
[(63, 1, false), (62, -2, false), (61, -2, true)]
|
||||
{
|
||||
inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_energy_grab",
|
||||
80,
|
||||
80,
|
||||
false,
|
||||
®istry,
|
||||
);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
let air = world.enemies.iter().find(|e| e.entity == entity).unwrap();
|
||||
assert_eq!(air.health, expected_health);
|
||||
assert_eq!(air.energy_resistance, expected_resistance);
|
||||
assert_eq!(air.shoot_disabled, disabled);
|
||||
assert_eq!(world.enemies[0].health, 30); // Ground target overlaps the shot.
|
||||
}
|
||||
assert!(world.sounds.contains(&134)); // FX_EGRAB.
|
||||
let hostile = world.projectiles.iter().filter(|p| p.hostile).count();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.projectiles.iter().filter(|p| p.hostile).count(),
|
||||
hostile
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == entity)
|
||||
.unwrap()
|
||||
.shoot_disabled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn energy_grab_does_not_disable_an_immune_air_enemy() {
|
||||
let mut registry = collision_fixture();
|
||||
registry
|
||||
.enemies
|
||||
.get_mut(&"base:air".into())
|
||||
.unwrap()
|
||||
.legacy
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.fields[5] = 0;
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
world.spawn(&"base:air".into(), 64, 64, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for _ in 0..5 {
|
||||
inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_energy_grab",
|
||||
80,
|
||||
80,
|
||||
false,
|
||||
®istry,
|
||||
);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(world.enemies[0].health, 59);
|
||||
assert_eq!(world.enemies[0].energy_resistance, 4);
|
||||
assert!(!world.enemies[0].shoot_disabled);
|
||||
}
|
||||
|
||||
fn terrain_fixture() -> Registry {
|
||||
let mut registry = collision_fixture();
|
||||
let pack = serde_json::from_value(json!({"missions":[{
|
||||
"id":"base:sector_1_wave_1","name":"Terrain Fixture","duration":1000,"auto_complete":false,"legacy_map":123,"columns":3,"layout":vec![0;30],
|
||||
"tiles":[
|
||||
{"art":{"asset":60,"logical_width":32,"logical_height":32},"health":200,"destroyed":{"asset":61,"logical_width":32,"logical_height":32}},
|
||||
{"art":{"asset":62,"logical_width":32,"logical_height":32},"health":200}
|
||||
]
|
||||
}],"overrides":["base:sector_1_wave_1"]})).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ground_bomb_damages_tile_neighbors_and_overlapping_ground_enemy_together() {
|
||||
let registry = terrain_fixture();
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
world
|
||||
.spawn(&"base:ground".into(), 48, 73, ®istry)
|
||||
.unwrap();
|
||||
let shot = inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_ground_bomb",
|
||||
64,
|
||||
89,
|
||||
false,
|
||||
®istry,
|
||||
);
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.enemies[0].health, 25);
|
||||
assert!(!world.projectiles.iter().any(|p| p.entity == shot));
|
||||
for position in [19, 18, 16, 20] {
|
||||
assert_eq!(
|
||||
world.terrain_health[position], 150,
|
||||
"primary neighbor {position}"
|
||||
);
|
||||
}
|
||||
for position in [15, 13, 17] {
|
||||
assert_eq!(
|
||||
world.terrain_health[position], 175,
|
||||
"upper neighbor {position}"
|
||||
);
|
||||
}
|
||||
assert_eq!(world.terrain_health[22], 200);
|
||||
assert_eq!(world.terrain_health[21], 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mega_bomb_uses_visible_destructible_tiles_at_fixed_twenty_damage_and_clears_shots() {
|
||||
let mut registry = terrain_fixture();
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&Pilot::default().mission)
|
||||
.unwrap()
|
||||
.layout[8] = 1;
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
world.terrain_dead[7] = true;
|
||||
world
|
||||
.spawn(&"base:ground".into(), 64, 64, ®istry)
|
||||
.unwrap();
|
||||
let hostile = world.next_entity;
|
||||
world.next_entity += 1;
|
||||
world.projectiles.push(Projectile {
|
||||
entity: hostile,
|
||||
weapon: "base:hostile".into(),
|
||||
x: 100,
|
||||
y: 100,
|
||||
px: 100,
|
||||
py: 100,
|
||||
vx: 0,
|
||||
vy: 1,
|
||||
hostile: true,
|
||||
age: 0,
|
||||
legacy: None,
|
||||
legacy_enemy: None,
|
||||
});
|
||||
inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_mega_bomb",
|
||||
100,
|
||||
100,
|
||||
true,
|
||||
®istry,
|
||||
);
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.enemies[0].health, -20); // Original enemy damage50, separate from tiles.
|
||||
assert!(world.projectiles.is_empty());
|
||||
for position in 0..30 {
|
||||
let expected = if position < 6 || position == 7 || position == 8 {
|
||||
200
|
||||
} else {
|
||||
180
|
||||
};
|
||||
assert_eq!(
|
||||
world.terrain_health[position], expected,
|
||||
"terrain position{position}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1142
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,436 @@
|
||||
//! 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; 8] = [
|
||||
"iron_coast_music",
|
||||
"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 mut value = json!({
|
||||
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
|
||||
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,"max_count":1,"weapon":"base:projectile_forward_guns","legacy":0},{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Base fixture","duration":1000}]
|
||||
});
|
||||
for n in 0..18 {
|
||||
value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1}));
|
||||
}
|
||||
let pack: ContentPack = serde_json::from_value(value).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn fixture_resources(value: &Value, root: &Path, original: &Path) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let (Some(width), Some(height)) = (
|
||||
object.get("logical_width").and_then(Value::as_u64),
|
||||
object.get("logical_height").and_then(Value::as_u64),
|
||||
) {
|
||||
let frames = object.get("frames").and_then(Value::as_u64).unwrap_or(1);
|
||||
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();
|
||||
if Path::new(file).extension().is_some_and(|ext| ext == "mus") {
|
||||
fs::copy(original.join(file), &path).unwrap();
|
||||
return;
|
||||
}
|
||||
// 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, original);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
fixture_resources(child, root, original);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn active() -> (tempfile::TempDir, ActiveMods) {
|
||||
let started = std::time::Instant::now();
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("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 eight Breakwater 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, &original);
|
||||
}
|
||||
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();
|
||||
eprintln!(
|
||||
"Iron Coast synthetic eight-package activation: {:.3}s",
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
(temporary, active)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_dependencies_and_generated_footprints_validate_together() {
|
||||
let (_temporary, active) = active();
|
||||
assert_eq!(active.pins.len(), 8);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missed_breakwater_objective_turrets_stay_reachable() {
|
||||
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();
|
||||
let target = world
|
||||
.spawn(&"iron_coast_turret:unit".into(), 40, 71, &active.registry)
|
||||
.unwrap();
|
||||
for _ in 0..60 {
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
let turret = world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|unit| unit.entity == target)
|
||||
.unwrap();
|
||||
assert_eq!(turret.y, 72);
|
||||
assert!(turret.health > 0);
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
eprintln!(
|
||||
"Iron Coast {} run: tick={} outcome={:?} shield={} credits={} repairs_remaining={}",
|
||||
if use_trial {
|
||||
"trial Pulse"
|
||||
} else {
|
||||
"starter guns"
|
||||
},
|
||||
world.tick,
|
||||
world.outcome,
|
||||
world.player.shield,
|
||||
world.player.credits,
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.get(&"iron_coast_mission_1:repair".into())
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
);
|
||||
(world, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_inventory_can_start_without_deleting_saved_equipment() {
|
||||
let (_temporary, mut active) = active();
|
||||
for reserves in [0, 4] {
|
||||
let mut pilot = Pilot::default();
|
||||
if reserves > 0 {
|
||||
pilot.inventory.insert("base:super_shield".into(), 100);
|
||||
pilot.super_shield_reserves = vec![100; reserves];
|
||||
}
|
||||
while pilot.inventory_slots() < 20 {
|
||||
let n = pilot.inventory.len();
|
||||
pilot
|
||||
.inventory
|
||||
.insert(format!("base:fixture_item_{n}").as_str().into(), 1);
|
||||
}
|
||||
assert_eq!(pilot.inventory_slots(), 20);
|
||||
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.inventory, pilot.inventory);
|
||||
assert_eq!(
|
||||
world.player.super_shield_reserves,
|
||||
pilot.super_shield_reserves
|
||||
);
|
||||
assert_eq!(world.objectives["iron_coast_mission_1:trial_issued"], 0);
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trial_is_a_loan_and_cannot_be_repeatedly_sold_after_victory() {
|
||||
let (_temporary, mut active) = active();
|
||||
let mut pilot = Pilot::default();
|
||||
for _ in 0..2 {
|
||||
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.inventory[&"pulse_cannon:equipment".into()], 1);
|
||||
world
|
||||
.objectives
|
||||
.insert("iron_coast_turret:defeated".into(), 2);
|
||||
world
|
||||
.objectives
|
||||
.insert("iron_coast_boss:defeated".into(), 1);
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Complete);
|
||||
pilot = world.pilot(&pilot);
|
||||
assert!(
|
||||
!pilot
|
||||
.inventory
|
||||
.contains_key(&"pulse_cannon:equipment".into())
|
||||
);
|
||||
let credits = pilot.credits;
|
||||
assert!(
|
||||
pilot
|
||||
.sell(&"pulse_cannon:equipment".into(), &active.registry)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(pilot.credits, credits);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_cannon_survives_trial_completion_without_replacement() {
|
||||
let (_temporary, mut active) = active();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.inventory.insert("pulse_cannon:equipment".into(), 1);
|
||||
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
world
|
||||
.objectives
|
||||
.insert("iron_coast_turret:defeated".into(), 2);
|
||||
world
|
||||
.objectives
|
||||
.insert("iron_coast_boss:defeated".into(), 1);
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Complete);
|
||||
assert_eq!(world.player.inventory[&"pulse_cannon:equipment".into()], 1);
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
//! Portable assembly/progression checks use synthetic resources; the ignored
|
||||
//! playable route requires the original untracked game catalog.
|
||||
use raptor_replica::{
|
||||
assets::Catalog,
|
||||
content::{ContentPack, Registry},
|
||||
mod_selection,
|
||||
modding::{self, ActiveMods, Manifest},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
const CAMPAIGN: &str = "iron_coast_campaign";
|
||||
const MISSIONS: [&str; 6] = [
|
||||
"iron_coast_mission_1:mission_01",
|
||||
"iron_coast_mission_2:mission_02",
|
||||
"iron_coast_mission_3:mission_03",
|
||||
"iron_coast_mission_4:mission_04",
|
||||
"iron_coast_mission_5:mission_05",
|
||||
"iron_coast_mission_6:mission_06",
|
||||
];
|
||||
|
||||
fn fixture_resources(value: &Value, root: &Path, original: &Path) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let (Some(width), Some(height)) = (
|
||||
object.get("logical_width").and_then(Value::as_u64),
|
||||
object.get("logical_height").and_then(Value::as_u64),
|
||||
) {
|
||||
let frames = object.get("frames").and_then(Value::as_u64).unwrap_or(1);
|
||||
// Use the minimum valid HD resolution in portable tests. Actual
|
||||
// generated 4x artwork is validated separately by the CLI.
|
||||
for (key, scale) in [("png", 1), ("hd_png", 2)] {
|
||||
if let Some(file) = object.get(key).and_then(Value::as_str) {
|
||||
let path = root.join(file);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
image::RgbaImage::from_pixel(
|
||||
(width * frames * scale) as u32,
|
||||
(height * scale) as u32,
|
||||
image::Rgba([80, 96, 104, 255]),
|
||||
)
|
||||
.save(path)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(file) = object.get("file").and_then(Value::as_str) {
|
||||
let path = root.join(file);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
if Path::new(file)
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("mus"))
|
||||
{
|
||||
// The new soundtrack is original mod content, independent
|
||||
// of the game catalog, so its bounded score can be tested.
|
||||
fs::copy(original.join(file), path).unwrap();
|
||||
} else {
|
||||
let mut wav = Vec::new();
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&100_u32.to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1_u16.to_le_bytes());
|
||||
wav.extend_from_slice(&1_u16.to_le_bytes());
|
||||
wav.extend_from_slice(&22050_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&44100_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&2_u16.to_le_bytes());
|
||||
wav.extend_from_slice(&16_u16.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&64_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&[0; 64]);
|
||||
fs::write(path, wav).unwrap();
|
||||
}
|
||||
}
|
||||
for child in object.values() {
|
||||
fixture_resources(child, root, original);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
fixture_resources(child, root, original);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_directory() -> &'static Path {
|
||||
static FIXTURE: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||
FIXTURE
|
||||
.get_or_init(|| {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods");
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut packages = vec![CAMPAIGN.to_string()];
|
||||
while let Some(id) = packages.pop() {
|
||||
if !visited.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let original = source.join(&id);
|
||||
let root = temporary.path().join(&id);
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let manifest_data = fs::read(original.join("mod.toml")).unwrap();
|
||||
let manifest: Manifest =
|
||||
toml::from_str(std::str::from_utf8(&manifest_data).unwrap()).unwrap();
|
||||
packages.extend(manifest.dependencies.keys().cloned());
|
||||
fs::write(root.join("mod.toml"), manifest_data).unwrap();
|
||||
let content = fs::read(original.join(&manifest.content)).unwrap();
|
||||
let value: Value = serde_json::from_slice(&content).unwrap();
|
||||
fs::write(root.join(&manifest.content), content).unwrap();
|
||||
for script in manifest.scripts {
|
||||
let path = root.join(&script);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::copy(original.join(script), path).unwrap();
|
||||
}
|
||||
fixture_resources(&value, &root, &original);
|
||||
}
|
||||
temporary
|
||||
})
|
||||
.path()
|
||||
}
|
||||
|
||||
fn base() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let mut value = json!({
|
||||
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
|
||||
"items":[
|
||||
{"id":"base:forward_guns","name":"Forward Guns","price":0,"max_count":1,"weapon":"base:projectile_forward_guns","legacy":0},
|
||||
{"id":"base:energy","name":"Shield","price":10000,"initial_count":25,"max_count":100,"legacy":16},
|
||||
{"id":"base:super_shield","name":"Super Shield","price":78500,"initial_count":100,"max_count":100,"legacy":15}
|
||||
],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Base fixture","duration":1000}]
|
||||
});
|
||||
for n in 0..18 {
|
||||
value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1}));
|
||||
}
|
||||
let pack: ContentPack = serde_json::from_value(value).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn discovery() -> modding::Discovery {
|
||||
let discovery = modding::discover(fixture_directory());
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
discovery
|
||||
}
|
||||
|
||||
/// Native IDs are present only to validate the portable dependency graph and
|
||||
/// first-step inventory grants. These placeholders supply no native behavior.
|
||||
/// The playable campaign test below uses the original catalog, not this base.
|
||||
fn structural_base(discovery: &modding::Discovery) -> Registry {
|
||||
let mut registry = base();
|
||||
let mut native = BTreeSet::new();
|
||||
for package in discovery.mods.values() {
|
||||
let pack: ContentPack = serde_json::from_slice(
|
||||
&fs::read(package.root.join(&package.manifest.content)).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for mission in pack.missions {
|
||||
for spawn in mission.spawns {
|
||||
if spawn.enemy.0.starts_with("base:enemy_") {
|
||||
native.insert(spawn.enemy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let enemies = native
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
json!({
|
||||
"id":id,
|
||||
"name":"Structural native-ID placeholder; no gameplay acceptance",
|
||||
"health":1,
|
||||
"speed":0
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
registry
|
||||
.apply(
|
||||
serde_json::from_value(json!({"enemies":enemies})).unwrap(),
|
||||
"base",
|
||||
)
|
||||
.unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn active() -> ActiveMods {
|
||||
let discovery = discovery();
|
||||
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
|
||||
ActiveMods::build(&structural_base(&discovery), &discovery, &enabled, None).unwrap()
|
||||
}
|
||||
|
||||
fn active_original() -> ActiveMods {
|
||||
let data = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data"));
|
||||
let catalog = Catalog::open(data).unwrap();
|
||||
let registry = Registry::base(&catalog).unwrap();
|
||||
let discovery = modding::discover(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods"));
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
|
||||
ActiveMods::build(®istry, &discovery, &enabled, Some(&catalog)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_mission_next_wins_over_campaign_order_and_standalone_stays_replayable() {
|
||||
let mut registry = base();
|
||||
registry
|
||||
.apply(
|
||||
serde_json::from_value(json!({
|
||||
"missions": [
|
||||
{"id":"base:test_a","name":"Explicit route","duration":20,"next":"base:test_c"},
|
||||
{"id":"base:test_b","name":"Campaign route","duration":20},
|
||||
{"id":"base:test_c","name":"Standalone destination","duration":20}
|
||||
],
|
||||
"campaigns":[{"id":"base:test_campaign","name":"Test route","missions":["base:test_a","base:test_b"]}]
|
||||
}))
|
||||
.unwrap(),
|
||||
"base",
|
||||
)
|
||||
.unwrap();
|
||||
registry.validate().unwrap();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.mission = "base:test_a".into();
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:test_c");
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:test_c");
|
||||
assert_eq!(pilot.completed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_order_fallback_uses_stable_campaign_id_order() {
|
||||
let mut registry = base();
|
||||
registry
|
||||
.apply(
|
||||
serde_json::from_value(json!({
|
||||
"missions": [
|
||||
{"id":"base:test_a","name":"Shared mission","duration":20},
|
||||
{"id":"base:test_b","name":"First route","duration":20},
|
||||
{"id":"base:test_c","name":"Second route","duration":20}
|
||||
],
|
||||
"campaigns":[
|
||||
{"id":"base:z_route","name":"Later ID","missions":["base:test_a","base:test_c"]},
|
||||
{"id":"base:a_route","name":"Earlier ID","missions":["base:test_a","base:test_b"]}
|
||||
]
|
||||
}))
|
||||
.unwrap(),
|
||||
"base",
|
||||
)
|
||||
.unwrap();
|
||||
registry.validate().unwrap();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.mission = "base:test_a".into();
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:test_b");
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:test_b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_campaign_selects_nineteen_packages_and_respects_shared_dependencies() {
|
||||
let discovery = discovery();
|
||||
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
|
||||
assert_eq!(enabled.len(), 19);
|
||||
assert_eq!(enabled.last().unwrap(), CAMPAIGN);
|
||||
for number in 1..=6 {
|
||||
let id = format!("iron_coast_mission_{number}");
|
||||
assert!(enabled.contains(&id));
|
||||
let standalone = mod_selection::plan_enable(&discovery, &[], &id).unwrap();
|
||||
assert_eq!(standalone.len(), 8, "{id} must remain standalone");
|
||||
assert!(!standalone.iter().any(|id| id == CAMPAIGN));
|
||||
ActiveMods::build(&structural_base(&discovery), &discovery, &standalone, None).unwrap();
|
||||
}
|
||||
let disable = mod_selection::plan_disable(&discovery, &enabled, CAMPAIGN);
|
||||
assert_eq!(disable.removed.len(), 19);
|
||||
assert!(disable.enabled.is_empty());
|
||||
|
||||
// Two independent missions share content. Disabling one keeps the other's
|
||||
// shared content while removing the disabled mission's unused stage boss.
|
||||
let standalone = mod_selection::plan_enable(&discovery, &[], "iron_coast_mission_1").unwrap();
|
||||
let standalone =
|
||||
mod_selection::plan_enable(&discovery, &standalone, "iron_coast_mission_6").unwrap();
|
||||
let disable = mod_selection::plan_disable(&discovery, &standalone, "iron_coast_mission_6");
|
||||
assert_eq!(disable.removed.len(), 2);
|
||||
assert!(
|
||||
disable
|
||||
.removed
|
||||
.contains(&"iron_coast_mission_6".to_string())
|
||||
);
|
||||
assert!(disable.removed.contains(&"iron_coast_boss_6".to_string()));
|
||||
assert!(
|
||||
disable
|
||||
.enabled
|
||||
.contains(&"iron_coast_mission_1".to_string())
|
||||
);
|
||||
assert!(disable.enabled.contains(&"pulse_cannon".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_order_is_complete_and_owns_only_its_campaign_definition() {
|
||||
let active = active();
|
||||
let campaign = &active.registry.campaigns[&"iron_coast_mission_1:campaign".into()];
|
||||
assert_eq!(
|
||||
campaign
|
||||
.missions
|
||||
.iter()
|
||||
.map(|id| id.0.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
MISSIONS
|
||||
);
|
||||
assert_eq!(active.registry.overrides.len(), 1);
|
||||
assert_eq!(
|
||||
active.registry.owners[&"iron_coast_music:iron_coast".into()],
|
||||
"iron_coast_music"
|
||||
);
|
||||
for (number, id) in MISSIONS.iter().enumerate() {
|
||||
let owner = format!("iron_coast_mission_{}", number + 1);
|
||||
assert_eq!(&active.registry.owners[&(*id).into()], &owner);
|
||||
let mission = &active.registry.missions[&(*id).into()];
|
||||
assert!(!mission.auto_complete);
|
||||
assert_eq!(
|
||||
serde_json::to_value(mission).unwrap()["music_track"],
|
||||
"iron_coast_music:iron_coast"
|
||||
);
|
||||
let boss_id = if number == 0 {
|
||||
"iron_coast_boss:unit".to_string()
|
||||
} else {
|
||||
format!("iron_coast_boss_{}:unit", number + 1)
|
||||
};
|
||||
let bosses = mission
|
||||
.spawns
|
||||
.iter()
|
||||
.filter(|spawn| active.registry.enemies[&spawn.enemy].boss)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(bosses.len(), if number == 5 { 2 } else { 1 });
|
||||
assert!(bosses.iter().all(|spawn| spawn.enemy.0 == boss_id));
|
||||
if number > 0 {
|
||||
assert!(
|
||||
mission
|
||||
.spawns
|
||||
.iter()
|
||||
.any(|spawn| spawn.enemy.0.starts_with("base:enemy_"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_progression_round_trips_pilot_inventory_and_script_state() {
|
||||
let active = active();
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let path = temporary.path().join("pilot.json");
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.mission = MISSIONS[0].into();
|
||||
pilot.mods = active.pins.clone();
|
||||
pilot.inventory.insert("seeker_swarm:equipment".into(), 1);
|
||||
pilot.script_state.insert(
|
||||
"iron_coast_scout/scripts/ai.rhai".into(),
|
||||
json!({"destroyed":4,"shots":17}),
|
||||
);
|
||||
pilot.script_rng.insert(
|
||||
"iron_coast_scout/scripts/ai.rhai".into(),
|
||||
raptor_replica::simulation::LegacyRng::new(12345),
|
||||
);
|
||||
for (number, id) in MISSIONS.iter().enumerate() {
|
||||
assert_eq!(pilot.mission.0, *id);
|
||||
pilot.complete_mission(&active.registry).unwrap();
|
||||
assert_eq!(pilot.completed.len(), number + 1);
|
||||
let expected = MISSIONS[(number + 1).min(5)];
|
||||
assert_eq!(pilot.mission.0, expected);
|
||||
pilot.save(&path).unwrap();
|
||||
let recovered = Pilot::load(&path, &active.registry, &active.pins).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&recovered).unwrap(),
|
||||
serde_json::to_value(&pilot).unwrap()
|
||||
);
|
||||
pilot = recovered;
|
||||
}
|
||||
// Revisiting the finale does not duplicate completed progression records.
|
||||
pilot.complete_mission(&active.registry).unwrap();
|
||||
assert_eq!(pilot.completed.len(), 6);
|
||||
assert!(fs::metadata(path).unwrap().len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_campaign_mission_accepts_a_full_twenty_slot_ship() {
|
||||
let mut active = active();
|
||||
for id in MISSIONS {
|
||||
for reserves in [0, 4] {
|
||||
let mut pilot = Pilot::default();
|
||||
if reserves > 0 {
|
||||
pilot.inventory.insert("base:super_shield".into(), 100);
|
||||
pilot.super_shield_reserves = vec![100; reserves];
|
||||
}
|
||||
for index in 0..18 {
|
||||
if pilot.inventory_slots() == 20 {
|
||||
break;
|
||||
}
|
||||
pilot
|
||||
.inventory
|
||||
.insert(format!("base:fixture_item_{index}").as_str().into(), 1);
|
||||
}
|
||||
assert_eq!(pilot.inventory_slots(), 20);
|
||||
pilot.validate(&active.registry, &[]).unwrap();
|
||||
let mut world = World::new(&id.into(), &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.player.inventory, pilot.inventory,
|
||||
"{id} changed a full inventory"
|
||||
);
|
||||
assert_eq!(
|
||||
world.player.super_shield_reserves,
|
||||
pilot.super_shield_reserves
|
||||
);
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Controlled mouse input moves at most four horizontal pixels per simulation
|
||||
/// step, fires continuously and cycles equipment using ordinary input edges.
|
||||
/// It targets mission-local structures before shared turrets, then bosses and
|
||||
/// scouts. Repairs are activated through their actual item callbacks.
|
||||
fn campaign_input(world: &World, registry: &Registry) -> Input {
|
||||
let namespace = world.mission.0.split_once(':').unwrap().0;
|
||||
let target = world
|
||||
.enemies
|
||||
.iter()
|
||||
.filter(|enemy| enemy.health > 0 && enemy.y >= 0 && enemy.y < 145)
|
||||
.min_by_key(|enemy| {
|
||||
let priority = if enemy.definition.0.starts_with(&format!("{namespace}:")) {
|
||||
0
|
||||
} else if enemy.definition.0 == "iron_coast_turret:unit" {
|
||||
1
|
||||
} else if registry.enemies[&enemy.definition].boss {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
(priority, enemy.entity)
|
||||
});
|
||||
let goal = target
|
||||
.map(|enemy| enemy.x + enemy.width / 2 - 16)
|
||||
.unwrap_or(144)
|
||||
.clamp(5, 282);
|
||||
let x = world.player.x + (goal - world.player.x).clamp(-4, 4);
|
||||
let special = world
|
||||
.player
|
||||
.inventory
|
||||
.iter()
|
||||
.filter_map(|(id, count)| {
|
||||
let item = registry.items.get(id)?;
|
||||
(*count > 0 && item.special).then_some((id, item))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let repair = special
|
||||
.iter()
|
||||
.find(|(_, item)| item.consumable && item.weapon.is_none())
|
||||
.map(|(id, _)| *id);
|
||||
let weapon = special
|
||||
.iter()
|
||||
.filter(|(_, item)| item.weapon.is_some())
|
||||
.max_by_key(|(_, item)| item.price)
|
||||
.map(|(id, _)| *id);
|
||||
let wanted = if world.player.shield <= 70 {
|
||||
repair.or(weapon)
|
||||
} else {
|
||||
weapon.or(repair)
|
||||
};
|
||||
let cycle =
|
||||
wanted.is_some_and(|id| world.player.selected.as_ref() != Some(id)) && !world.cycle_held;
|
||||
let consume = world.player.shield <= 70
|
||||
&& repair.is_some_and(|id| world.player.selected.as_ref() == Some(id))
|
||||
&& !world.special_held;
|
||||
Input {
|
||||
fire: true,
|
||||
cycle,
|
||||
special: consume,
|
||||
absolute: Some([x, 160]),
|
||||
..Input::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the original untracked catalog; native enemy paths and weapon behavior"]
|
||||
fn registered_pilot_can_complete_six_real_missions_and_save_round_trips() {
|
||||
let mut active = active_original();
|
||||
assert_eq!(active.pins.len(), 19);
|
||||
assert_eq!(active.registry.overrides.len(), 1);
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let path = temporary.path().join("campaign-pilot.json");
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.name = "Campaign Acceptance".into();
|
||||
pilot.callsign = "COAST".into();
|
||||
pilot.credits = 10_000;
|
||||
pilot.inventory.insert("base:energy".into(), 75);
|
||||
pilot.mission = MISSIONS[0].into();
|
||||
pilot.mods = active.pins.clone();
|
||||
let mut reports = Vec::new();
|
||||
let mut paid_refills = 0;
|
||||
for (number, mission) in MISSIONS.iter().enumerate() {
|
||||
assert_eq!(pilot.mission.0, *mission);
|
||||
let refills_before = paid_refills;
|
||||
// The hangar replenishes through the real purchase operation and its
|
||||
// actual price; this test never inserts free shield or premium weapons.
|
||||
while pilot.inventory[&"base:energy".into()] < 75 && pilot.credits >= 10_000 {
|
||||
pilot.buy(&"base:energy".into(), &active.registry).unwrap();
|
||||
paid_refills += 1;
|
||||
}
|
||||
let start_shield = pilot.inventory[&"base:energy".into()];
|
||||
let start_credits = pilot.credits;
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
let mut native_seen = BTreeSet::new();
|
||||
let mut native_motion = BTreeSet::new();
|
||||
let limit = active.registry.missions[&pilot.mission].duration + 1600;
|
||||
while world.tick < limit && world.outcome == Outcome::Playing {
|
||||
let input = campaign_input(&world, &active.registry);
|
||||
world
|
||||
.step(input, &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
for enemy in &world.enemies {
|
||||
if enemy.definition.0.starts_with("base:enemy_") {
|
||||
assert!(active.registry.enemies[&enemy.definition].legacy.is_some());
|
||||
native_seen.insert(enemy.definition.clone());
|
||||
if enemy.x != enemy.sx || enemy.y != enemy.sy {
|
||||
native_motion.insert(enemy.definition.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
world.outcome,
|
||||
Outcome::Complete,
|
||||
"{mission}: tick={}, shield={}, objectives={:?}",
|
||||
world.tick,
|
||||
world.player.shield,
|
||||
world.objectives
|
||||
);
|
||||
assert!(world.player.shield > 0);
|
||||
if number > 0 {
|
||||
assert!(
|
||||
!native_seen.is_empty(),
|
||||
"{mission} never spawned a classic enemy"
|
||||
);
|
||||
assert!(
|
||||
!native_motion.is_empty(),
|
||||
"{mission} showed no classic enemy movement"
|
||||
);
|
||||
}
|
||||
let namespace = mission.split_once(':').unwrap().0;
|
||||
assert_eq!(world.objectives[&format!("{namespace}:complete")], 1);
|
||||
assert_eq!(world.objectives[&format!("{namespace}:debrief")], 1);
|
||||
let final_boss_tick = active.registry.missions[&pilot.mission]
|
||||
.spawns
|
||||
.iter()
|
||||
.filter(|spawn| active.registry.enemies[&spawn.enemy].boss)
|
||||
.map(|spawn| spawn.tick)
|
||||
.max()
|
||||
.unwrap();
|
||||
assert!(world.tick > final_boss_tick);
|
||||
let credits = world.player.credits;
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.player.credits, credits,
|
||||
"{mission} paid victory twice"
|
||||
);
|
||||
eprintln!(
|
||||
"{mission}: complete at {}, shield {start_shield} -> {}, credits {}, classic types {}, moving types {}",
|
||||
world.tick,
|
||||
world.player.shield,
|
||||
world.player.credits,
|
||||
native_seen.len(),
|
||||
native_motion.len()
|
||||
);
|
||||
let native_definitions = native_seen
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let definition = &active.registry.enemies[id];
|
||||
let legacy = definition.legacy.as_ref().unwrap();
|
||||
json!({
|
||||
"id":id,
|
||||
"name":definition.name,
|
||||
"native_path_points":legacy.fields[19],
|
||||
"native_move_type":legacy.fields[21],
|
||||
"native_guns":legacy.fields[22]
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
reports.push(json!({
|
||||
"mission":mission,
|
||||
"completion_tick":world.tick,
|
||||
"start_shield":start_shield,
|
||||
"start_credits":start_credits,
|
||||
"energy_refills":paid_refills - refills_before,
|
||||
"final_shield":world.player.shield,
|
||||
"credits":world.player.credits,
|
||||
"objectives":world.objectives,
|
||||
"native_enemies":native_definitions,
|
||||
"native_motion_seen":native_motion,
|
||||
"digest":world.digest()
|
||||
}));
|
||||
pilot = world.pilot(&pilot);
|
||||
assert!(
|
||||
!pilot
|
||||
.inventory
|
||||
.contains_key(&"pulse_cannon:equipment".into()),
|
||||
"{mission} retained a mission-only Pulse loan"
|
||||
);
|
||||
assert!(
|
||||
!pilot.inventory.contains_key(&"ion_lance:equipment".into()),
|
||||
"{mission} retained a mission-only Ion loan"
|
||||
);
|
||||
pilot.merge_runtime_snapshot(&active.pins, &active.scripts.states, &active.scripts.rng);
|
||||
pilot.complete_mission(&active.registry).unwrap();
|
||||
assert_eq!(pilot.completed.len(), number + 1);
|
||||
pilot.save(&path).unwrap();
|
||||
let recovered = Pilot::load(&path, &active.registry, &active.pins).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&pilot).unwrap(),
|
||||
serde_json::to_value(&recovered).unwrap()
|
||||
);
|
||||
active
|
||||
.scripts
|
||||
.restore((recovered.script_state.clone(), recovered.script_rng.clone()));
|
||||
pilot = recovered;
|
||||
}
|
||||
assert_eq!(pilot.mission.0, MISSIONS[5]);
|
||||
assert_eq!(pilot.completed.len(), 6);
|
||||
// A completed finale must work again with its serialized mission and boss
|
||||
// state, rather than inheriting completion gates or dead entity cursors.
|
||||
let mut replay = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
let replay_limit = active.registry.missions[&pilot.mission].duration + 1600;
|
||||
while replay.tick < replay_limit && replay.outcome == Outcome::Playing {
|
||||
let input = campaign_input(&replay, &active.registry);
|
||||
replay
|
||||
.step(input, &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
replay.outcome,
|
||||
Outcome::Complete,
|
||||
"finale replay: {:?}",
|
||||
replay.objectives
|
||||
);
|
||||
assert_eq!(replay.objectives["iron_coast_boss_6:defeated"], 2);
|
||||
assert_eq!(replay.objectives["iron_coast_mission_6:complete"], 1);
|
||||
assert_eq!(replay.objectives["iron_coast_mission_6:debrief"], 1);
|
||||
assert!(replay.player.shield > 0);
|
||||
let replay_report = json!({
|
||||
"completion_tick":replay.tick,
|
||||
"final_shield":replay.player.shield,
|
||||
"credits":replay.player.credits,
|
||||
"objectives":replay.objectives,
|
||||
"digest":replay.digest()
|
||||
});
|
||||
pilot = replay.pilot(&pilot);
|
||||
pilot.merge_runtime_snapshot(&active.pins, &active.scripts.states, &active.scripts.rng);
|
||||
pilot.complete_mission(&active.registry).unwrap();
|
||||
assert_eq!(pilot.completed.len(), 6);
|
||||
assert_eq!(pilot.mission.0, MISSIONS[5]);
|
||||
pilot.save(&path).unwrap();
|
||||
let recovered = Pilot::load(&path, &active.registry, &active.pins).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&pilot).unwrap(),
|
||||
serde_json::to_value(&recovered).unwrap()
|
||||
);
|
||||
eprintln!(
|
||||
"Finale replay: {} ticks, {} shield",
|
||||
replay.tick, replay.player.shield
|
||||
);
|
||||
let report = serde_json::to_string_pretty(&json!({
|
||||
"original_catalog":true,
|
||||
"package_count":active.pins.len(),
|
||||
"package_pins":active.pins,
|
||||
"paid_refills":paid_refills,
|
||||
"reports":reports,
|
||||
"finale_replay":replay_report
|
||||
}))
|
||||
.unwrap();
|
||||
if let Ok(report_path) = std::env::var("RAPTOR_IRON_COAST_REPORT") {
|
||||
let report_path = PathBuf::from(report_path);
|
||||
if let Some(parent) = report_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&report_path, format!("{report}\n")).unwrap();
|
||||
eprintln!("Campaign acceptance report: {}", report_path.display());
|
||||
}
|
||||
eprintln!(
|
||||
"Six-stage route and recovered finale replay passed; {paid_refills} paid Energy refills"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Starter forward guns must damage ground enemies and destructible terrain.
|
||||
use raptor_replica::{
|
||||
assets::Catalog,
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
mod_selection,
|
||||
modding::{self, ActiveMods, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn starter() -> Pilot {
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.inventory.insert("base:energy".into(), 75);
|
||||
pilot.credits = 10_000;
|
||||
pilot
|
||||
}
|
||||
|
||||
fn fixture(legacy_terrain: bool) -> Registry {
|
||||
let mut layout = vec![0; 90];
|
||||
layout[4 * 9 + 4] = 1;
|
||||
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,"weapon":"base:projectile_forward_guns","legacy":0},
|
||||
{"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"legacy":16}
|
||||
],
|
||||
"enemies":[{"id":"base:ground","name":"Ground emplacement","health":12,"speed":0,"ground":true,"reward":120}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Ground range","duration":10000,"auto_complete":false,"columns":9,"layout":layout,"legacy_map":if legacy_terrain {Some(123)} else {None},
|
||||
"tiles":[
|
||||
{"art":{"asset":60,"logical_width":32,"logical_height":32}},
|
||||
{"art":{"asset":61,"logical_width":32,"logical_height":32},"health":2,"reward":230,"destroyed":{"asset":62,"logical_width":32,"logical_height":32}}
|
||||
]}
|
||||
]
|
||||
})).unwrap();
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn shoot(world: &mut World, registry: &Registry, scripts: &mut ScriptRuntime, ticks: usize) {
|
||||
assert_eq!(world.player.selected, None);
|
||||
assert_eq!(
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.keys()
|
||||
.filter(|id| registry.items[*id].weapon.is_some())
|
||||
.count(),
|
||||
1,
|
||||
"the test must exercise only the default machine gun"
|
||||
);
|
||||
for _ in 0..ticks {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
registry,
|
||||
scripts,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_machine_gun_destroys_ground_enemy_and_awards_its_reward() {
|
||||
let mut registry = fixture(false);
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&Pilot::default().mission)
|
||||
.unwrap()
|
||||
.layout
|
||||
.fill(0);
|
||||
let pilot = starter();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let entity = world
|
||||
.spawn(&"base:ground".into(), 144, 72, ®istry)
|
||||
.unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
shoot(&mut world, ®istry, &mut scripts, 30);
|
||||
assert!(!world.enemies.iter().any(|enemy| enemy.entity == entity));
|
||||
assert_eq!(world.player.credits, pilot.credits + 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_machine_gun_destroys_json_terrain_and_rewards_it_once() {
|
||||
let registry = fixture(false);
|
||||
let pilot = starter();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
let mut explosion_sounds = 0;
|
||||
for _ in 0..60 {
|
||||
shoot(&mut world, ®istry, &mut scripts, 1);
|
||||
explosion_sounds += world
|
||||
.sound_events
|
||||
.iter()
|
||||
.filter(|sound| sound.asset == 139)
|
||||
.count();
|
||||
}
|
||||
let position = 4 * 9 + 4;
|
||||
assert!(
|
||||
world.terrain_dead[position],
|
||||
"forward gun damage never destroys mod terrain (health {})",
|
||||
world.terrain_health[position]
|
||||
);
|
||||
assert_eq!(world.player.credits, pilot.credits + 230);
|
||||
assert_eq!(
|
||||
explosion_sounds, 1,
|
||||
"terrain destruction should emit one original ground-explosion sound"
|
||||
);
|
||||
let health = world.terrain_health[position];
|
||||
shoot(&mut world, ®istry, &mut scripts, 30);
|
||||
assert_eq!(world.terrain_health[position], health);
|
||||
assert_eq!(world.player.credits, pilot.credits + 230);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_json_maps_use_their_actual_screen_position_for_forward_gun_hits() {
|
||||
let mut registry = fixture(false);
|
||||
let mission = registry
|
||||
.missions
|
||||
.get_mut(&Pilot::default().mission)
|
||||
.unwrap();
|
||||
mission.layout = vec![0; 36];
|
||||
mission.layout[9 + 4] = 1;
|
||||
let pilot = starter();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
shoot(&mut world, ®istry, &mut ScriptRuntime::new(), 25);
|
||||
assert!(world.terrain_dead[9 + 4]);
|
||||
assert_eq!(world.player.credits, pilot.credits + 230);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_machine_gun_keeps_native_delayed_terrain_destruction() {
|
||||
let registry = fixture(true);
|
||||
let pilot = starter();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
shoot(&mut world, ®istry, &mut scripts, 60);
|
||||
assert!(world.terrain_dead[4 * 9 + 4]);
|
||||
assert!(world.terrain_settled[4 * 9 + 4]);
|
||||
assert_eq!(world.player.credits, pilot.credits + 230);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_terrain_collision_matches_its_visible_rows_after_the_native_scroll_limit() {
|
||||
let mut registry = fixture(false);
|
||||
let mission = registry
|
||||
.missions
|
||||
.get_mut(&Pilot::default().mission)
|
||||
.unwrap();
|
||||
mission.layout.fill(0);
|
||||
mission.layout[4] = 1;
|
||||
let pilot = starter();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for _ in 0..180 {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
}
|
||||
// The first row is now at screen y=60, rather than the repeating native
|
||||
// terrain-window y=-4. It must be hittable at its displayed position.
|
||||
assert_eq!(world.scroll, 180);
|
||||
shoot(&mut world, ®istry, &mut scripts, 25);
|
||||
assert!(world.terrain_dead[4]);
|
||||
assert_eq!(world.player.credits, pilot.credits + 230);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_terrain_that_scrolled_offscreen_cannot_absorb_forward_gun_shots() {
|
||||
let mut registry = fixture(false);
|
||||
let mission = registry
|
||||
.missions
|
||||
.get_mut(&Pilot::default().mission)
|
||||
.unwrap();
|
||||
mission.layout.fill(0);
|
||||
mission.layout[9 + 4] = 1;
|
||||
let pilot = starter();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for _ in 0..400 {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
}
|
||||
shoot(&mut world, ®istry, &mut scripts, 60);
|
||||
assert_eq!(world.terrain_health[9 + 4], 2);
|
||||
assert!(!world.terrain_dead[9 + 4]);
|
||||
assert_eq!(world.player.credits, pilot.credits);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_based_mod_terrain_does_not_require_replacement_artwork_to_be_destructible() {
|
||||
let mut registry = fixture(false);
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&Pilot::default().mission)
|
||||
.unwrap()
|
||||
.tiles[1]
|
||||
.destroyed = None;
|
||||
let pilot = starter();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
shoot(&mut world, ®istry, &mut ScriptRuntime::new(), 60);
|
||||
assert!(world.terrain_dead[4 * 9 + 4]);
|
||||
assert_eq!(world.player.credits, pilot.credits + 230);
|
||||
}
|
||||
|
||||
fn catalog() -> Catalog {
|
||||
let path = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data"));
|
||||
Catalog::open(&path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires locally installed original catalog and packaged campaign definitions"]
|
||||
fn real_forward_guns_damage_classic_and_iron_coast_ground_units() {
|
||||
let catalog = catalog();
|
||||
let base = Registry::base(&catalog).unwrap();
|
||||
let discovery = modding::discover(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods"));
|
||||
let mut enabled = Vec::new();
|
||||
for mission in [
|
||||
"iron_coast_mission_1",
|
||||
"iron_coast_mission_2",
|
||||
"iron_coast_mission_3",
|
||||
"iron_coast_mission_5",
|
||||
] {
|
||||
enabled = mod_selection::plan_enable(&discovery, &enabled, mission).unwrap();
|
||||
}
|
||||
// Asset decoding is covered by package validation. This combat check loads
|
||||
// actual definitions and callbacks without requantizing every unrelated
|
||||
// terrain PNG before it can exercise a single enemy collision.
|
||||
let mut active = ActiveMods {
|
||||
registry: base.clone(),
|
||||
pins: Vec::new(),
|
||||
scripts: ScriptRuntime::new(),
|
||||
};
|
||||
for id in &enabled {
|
||||
let package = &discovery.mods[id];
|
||||
let content: ContentPack = serde_json::from_slice(
|
||||
&std::fs::read(package.root.join(&package.manifest.content)).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
active
|
||||
.registry
|
||||
.asset_roots
|
||||
.insert(id.clone(), package.root.clone());
|
||||
active.registry.apply(content, id).unwrap();
|
||||
for script in &package.manifest.scripts {
|
||||
active
|
||||
.scripts
|
||||
.add(
|
||||
id,
|
||||
script,
|
||||
&std::fs::read_to_string(package.root.join(script)).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
active.registry.validate().unwrap();
|
||||
// A quiet JSON mission leaves enemy definitions, original gun art, damage,
|
||||
// movement and scripts intact while isolating their actual collisions.
|
||||
let mission: ContentPack = serde_json::from_value(json!({"missions":[{"id":"test:ground_range","name":"Ground range","duration":10000,"auto_complete":false}]})).unwrap();
|
||||
active.registry.apply(mission, "test").unwrap();
|
||||
let first = &base.missions[&ContentId::from("base:sector_1_wave_1")];
|
||||
let mut ground: Vec<_> = first
|
||||
.spawns
|
||||
.iter()
|
||||
.map(|spawn| spawn.enemy.clone())
|
||||
.filter(|id| base.enemies[id].ground && !base.enemies[id].boss)
|
||||
.collect();
|
||||
ground.sort();
|
||||
ground.dedup();
|
||||
assert!(!ground.is_empty());
|
||||
ground.extend(
|
||||
[
|
||||
"iron_coast_turret:unit",
|
||||
"iron_coast_mission_1:depot",
|
||||
"iron_coast_mission_2:relay",
|
||||
"iron_coast_mission_3:convoy",
|
||||
"iron_coast_mission_5:foundry",
|
||||
]
|
||||
.map(ContentId::from),
|
||||
);
|
||||
let mut observations = Vec::new();
|
||||
for id in ground {
|
||||
let pilot = Pilot {
|
||||
mission: "test:ground_range".into(),
|
||||
..starter()
|
||||
};
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
let entity = world.spawn(&id, 144, 24, &active.registry).unwrap();
|
||||
let initial = world.enemies[0].health;
|
||||
let mut lowest = initial;
|
||||
for _ in 0..120 {
|
||||
// Normal bounded absolute mouse movement aligns the ship with the
|
||||
// observed ground target; health and projectiles are never injected.
|
||||
let target = world.enemies.iter().find(|enemy| enemy.entity == entity);
|
||||
let Some(enemy) = target else {
|
||||
break;
|
||||
};
|
||||
let x = (enemy.x + enemy.width / 2 - 16).clamp(5, 282);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
absolute: Some([x, 160]),
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(enemy) = world.enemies.iter().find(|enemy| enemy.entity == entity) {
|
||||
lowest = lowest.min(enemy.health);
|
||||
}
|
||||
}
|
||||
assert!(lowest < initial, "starter machine gun did not damage {id}");
|
||||
assert!(
|
||||
!world.enemies.iter().any(|enemy| enemy.entity == entity),
|
||||
"starter machine gun did not destroy {id}; lowest health {lowest}"
|
||||
);
|
||||
assert_eq!(
|
||||
world.player.credits,
|
||||
pilot.credits + active.registry.enemies[&id].reward,
|
||||
"incorrect kill reward for {id}"
|
||||
);
|
||||
observations.push(json!({
|
||||
"id": id,
|
||||
"name": active.registry.enemies[&id].name,
|
||||
"initial_health": initial,
|
||||
"lowest_observed_health": lowest,
|
||||
"destroyed": true,
|
||||
"ticks": world.tick,
|
||||
"reward": world.player.credits - pilot.credits,
|
||||
"remaining_shield": world.player.shield
|
||||
}));
|
||||
}
|
||||
if let Some(path) = std::env::var_os("RAPTOR_GROUND_REPORT") {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&observations).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
use raptor_replica::{
|
||||
mod_selection::{plan_disable, plan_enable},
|
||||
modding::{self, Discovery, InstalledMod, Manifest},
|
||||
};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|id| (*id).into()).collect()
|
||||
}
|
||||
fn package(id: &str, dependencies: &[(&str, &str)]) -> InstalledMod {
|
||||
InstalledMod {
|
||||
root: PathBuf::from(id),
|
||||
hash: String::new(),
|
||||
manifest: Manifest {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
version: "1.0.0".into(),
|
||||
api_version: 1,
|
||||
description: String::new(),
|
||||
content: "content.json".into(),
|
||||
scripts: vec![],
|
||||
conflicts: vec![],
|
||||
dependencies: dependencies
|
||||
.iter()
|
||||
.map(|(id, req)| ((*id).into(), (*req).into()))
|
||||
.collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn graph() -> Discovery {
|
||||
Discovery {
|
||||
mods: BTreeMap::from([
|
||||
("shared".into(), package("shared", &[])),
|
||||
("weapon".into(), package("weapon", &[("shared", "^1")])),
|
||||
("enemy".into(), package("enemy", &[("shared", "^1")])),
|
||||
(
|
||||
"mission".into(),
|
||||
package("mission", &[("weapon", "^1"), ("enemy", "^1")]),
|
||||
),
|
||||
("sequel".into(), package("sequel", &[("mission", "^1")])),
|
||||
("other".into(), package("other", &[])),
|
||||
]),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn enabling_a_mission_selects_recursive_shared_dependencies_once() {
|
||||
let selected = ids(&["other"]);
|
||||
let planned = plan_enable(&graph(), &selected, "mission").unwrap();
|
||||
assert_eq!(
|
||||
planned,
|
||||
ids(&["other", "shared", "enemy", "weapon", "mission"])
|
||||
);
|
||||
assert_eq!(selected, ids(&["other"]));
|
||||
assert_eq!(plan_enable(&graph(), &planned, "mission").unwrap(), planned);
|
||||
}
|
||||
#[test]
|
||||
fn unavailable_incompatible_api_cycles_and_conflicts_leave_selection_unchanged() {
|
||||
let initial = ids(&["other"]);
|
||||
let check = |graph: &Discovery, expected: &str| {
|
||||
let error = plan_enable(graph, &initial, "mission")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(expected), "{error}");
|
||||
assert_eq!(initial, ids(&["other"]));
|
||||
};
|
||||
let mut g = graph();
|
||||
g.mods.remove("shared");
|
||||
check(&g, "install it");
|
||||
let mut g = graph();
|
||||
g.mods.get_mut("shared").unwrap().manifest.version = "2.0.0".into();
|
||||
check(&g, "installed 2.0.0");
|
||||
let mut g = graph();
|
||||
g.mods.get_mut("shared").unwrap().manifest.api_version = 2;
|
||||
check(&g, "requires API 2");
|
||||
let mut g = graph();
|
||||
g.mods
|
||||
.get_mut("shared")
|
||||
.unwrap()
|
||||
.manifest
|
||||
.dependencies
|
||||
.insert("mission".into(), "^1".into());
|
||||
check(&g, "cyclic");
|
||||
let mut g = graph();
|
||||
g.mods
|
||||
.get_mut("other")
|
||||
.unwrap()
|
||||
.manifest
|
||||
.conflicts
|
||||
.push("weapon".into());
|
||||
check(&g, "conflicts");
|
||||
}
|
||||
#[test]
|
||||
fn disable_plan_removes_transitive_dependents_but_preserves_unrelated_mods() {
|
||||
let initial = ids(&["other", "shared", "enemy", "weapon", "mission", "sequel"]);
|
||||
let plan = plan_disable(&graph(), &initial, "weapon");
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
ids(&["shared", "enemy", "weapon", "mission", "sequel"])
|
||||
);
|
||||
assert_eq!(plan.enabled, ids(&["other"]));
|
||||
// Merely opening the confirmation must not change the pending selection.
|
||||
assert_eq!(
|
||||
initial,
|
||||
ids(&["other", "shared", "enemy", "weapon", "mission", "sequel"])
|
||||
);
|
||||
assert_eq!(
|
||||
modding::order(&graph(), &plan.enabled).unwrap(),
|
||||
plan.enabled
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn disabling_a_shared_dependency_cascades_across_both_branches() {
|
||||
let initial = plan_enable(&graph(), &ids(&["other"]), "sequel").unwrap();
|
||||
let plan = plan_disable(&graph(), &initial, "shared");
|
||||
assert_eq!(plan.enabled, ids(&["other"]));
|
||||
assert_eq!(plan.removed.len(), 5);
|
||||
assert!(
|
||||
plan_disable(&graph(), &initial, "absent")
|
||||
.removed
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn disabling_a_missing_installed_package_can_still_remove_its_dependents() {
|
||||
let mut g = graph();
|
||||
g.mods.remove("shared");
|
||||
let initial = ids(&["shared", "weapon", "other"]);
|
||||
let plan = plan_disable(&g, &initial, "shared");
|
||||
assert_eq!(plan.removed, ids(&["shared", "weapon"]));
|
||||
assert_eq!(plan.enabled, ids(&["other"]));
|
||||
}
|
||||
#[test]
|
||||
fn selecting_shipped_iron_coast_mission_selects_all_seven_real_dependencies() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods");
|
||||
let discovery = modding::discover(&root);
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let enabled = plan_enable(&discovery, &[], "iron_coast_mission_1").unwrap();
|
||||
assert_eq!(enabled.len(), 8);
|
||||
assert_eq!(enabled.last().unwrap(), "iron_coast_mission_1");
|
||||
for id in [
|
||||
"iron_coast_scout",
|
||||
"iron_coast_turret",
|
||||
"iron_coast_boss",
|
||||
"pulse_cannon",
|
||||
"seeker_swarm",
|
||||
"ion_lance",
|
||||
"iron_coast_music",
|
||||
] {
|
||||
assert!(enabled.contains(&id.to_string()));
|
||||
let removal = plan_disable(&discovery, &enabled, id);
|
||||
assert_eq!(removal.removed, enabled);
|
||||
assert!(removal.enabled.is_empty());
|
||||
}
|
||||
assert_eq!(
|
||||
plan_disable(&discovery, &enabled, "iron_coast_mission_1").removed,
|
||||
enabled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_remaining_campaign_preserves_the_shared_dependencies_it_needs() {
|
||||
let mut g = graph();
|
||||
g.mods.insert(
|
||||
"other_campaign".into(),
|
||||
package("other_campaign", &[("enemy", "^1")]),
|
||||
);
|
||||
let mut enabled = plan_enable(&g, &[], "mission").unwrap();
|
||||
enabled = plan_enable(&g, &enabled, "other_campaign").unwrap();
|
||||
let plan = plan_disable(&g, &enabled, "mission");
|
||||
assert_eq!(plan.removed, ids(&["weapon", "mission"]));
|
||||
assert_eq!(plan.enabled, ids(&["shared", "enemy", "other_campaign"]));
|
||||
assert_eq!(modding::order(&g, &plan.enabled).unwrap(), plan.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standalone_mod_has_exactly_one_removal_for_the_instant_toggle() {
|
||||
let enabled = plan_enable(&graph(), &ids(&["other"]), "mission").unwrap();
|
||||
let plan = plan_disable(&graph(), &enabled, "other");
|
||||
assert_eq!(plan.removed, ids(&["other"]));
|
||||
assert_eq!(plan.enabled, ids(&["shared", "enemy", "weapon", "mission"]));
|
||||
}
|
||||
+1245
File diff suppressed because it is too large.
Load diff
+294
@@ -0,0 +1,294 @@
|
||||
use raptor_replica::{
|
||||
audio::{DigitalSample, Mixer},
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods},
|
||||
sound::{self, SoundBank},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
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(16_u16.to_le_bytes());
|
||||
bytes.extend(channels.to_le_bytes());
|
||||
bytes.extend([0; 6]);
|
||||
bytes.extend(body);
|
||||
bytes
|
||||
}
|
||||
fn theme(key: u8) -> Vec<u8> {
|
||||
mus(
|
||||
&[0x40, 0, 0, 0x90, 128 | key, 100, 4, 0x80, key, 4, 0x60],
|
||||
1,
|
||||
)
|
||||
}
|
||||
fn pack(value: Value) -> ContentPack {
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
fn install(root: &Path, id: &str, content: Value, score: &[u8]) {
|
||||
let path = root.join(id);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
fs::write(
|
||||
path.join("mod.toml"),
|
||||
format!("id = \"{id}\"\nname = \"{id}\"\nversion = \"1.0.0\"\napi_version = 1\n"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
path.join("content.json"),
|
||||
serde_json::to_vec_pretty(&content).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(path.join("theme.mus"), score).unwrap();
|
||||
}
|
||||
fn selected(ids: &[&str]) -> Vec<String> {
|
||||
ids.iter().map(|id| (*id).to_owned()).collect()
|
||||
}
|
||||
fn error<T>(result: anyhow::Result<T>) -> String {
|
||||
match result {
|
||||
Ok(_) => panic!("expected failure"),
|
||||
Err(error) => format!("{error:#}"),
|
||||
}
|
||||
}
|
||||
fn music_pack() -> Value {
|
||||
json!({
|
||||
"music":[{"id":"demo:theme","name":"Demo theme","file":"theme.mus"}],
|
||||
"missions":[{"id":"demo:mission","name":"Demo mission","duration":100,"music_track":"demo:theme"}]
|
||||
})
|
||||
}
|
||||
fn build(root: &Path, enabled: &[&str]) -> anyhow::Result<ActiveMods> {
|
||||
ActiveMods::build(
|
||||
&Registry::default(),
|
||||
&modding::discover(root),
|
||||
&selected(enabled),
|
||||
None,
|
||||
)
|
||||
}
|
||||
fn instruments() -> Vec<u8> {
|
||||
let mut bytes = b"#OPL_II#".to_vec();
|
||||
for _ in 0..175 {
|
||||
let mut instrument = [0; 36];
|
||||
instrument[2] = 128;
|
||||
for part in 0..2 {
|
||||
let start = 4 + part * 16;
|
||||
instrument[start..start + 6].copy_from_slice(&[0x21, 0xf2, 0x0f, 0, 0, 32]);
|
||||
instrument[start + 7..start + 13].copy_from_slice(&[0x21, 0xf2, 0x0f, 0, 0, 0]);
|
||||
}
|
||||
bytes.extend(instrument);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_registry_references_defaults_and_selection_are_checked() {
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(pack(music_pack()), "demo").unwrap();
|
||||
registry.validate().unwrap();
|
||||
assert!(registry.music[&ContentId::from("demo:theme")].looping);
|
||||
let mission = registry.missions.get_mut(&"demo:mission".into()).unwrap();
|
||||
mission.music = Some(86);
|
||||
assert!(error(registry.validate()).contains("both music and music_track"));
|
||||
let mission = registry.missions.get_mut(&"demo:mission".into()).unwrap();
|
||||
mission.music = None;
|
||||
mission.music_track = Some("missing:theme".into());
|
||||
assert!(error(registry.validate()).contains("missing music track missing:theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_additions_and_explicit_overrides_use_the_winning_package_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "demo", music_pack(), &theme(60));
|
||||
install(
|
||||
temp.path(),
|
||||
"override",
|
||||
json!({
|
||||
"overrides":["demo:theme"],
|
||||
"music":[{"id":"demo:theme","name":"Replacement","file":"theme.mus","looping":false}]
|
||||
}),
|
||||
&theme(67),
|
||||
);
|
||||
let active = build(temp.path(), &["demo", "override"]).unwrap();
|
||||
assert_eq!(active.registry.owners[&"demo:theme".into()], "override");
|
||||
assert!(!active.registry.music[&"demo:theme".into()].looping);
|
||||
let mut bank = SoundBank::default();
|
||||
let score = bank
|
||||
.mod_music(&active.registry, &"demo:theme".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
score.groups,
|
||||
sound::decode_music(&theme(67)).unwrap().groups
|
||||
);
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(pack(music_pack()), "demo").unwrap();
|
||||
assert!(
|
||||
error(registry.apply(
|
||||
pack(json!({"music":[{"id":"demo:theme","name":"Duplicate","file":"theme.mus"}]})),
|
||||
"other"
|
||||
))
|
||||
.contains("explicit override")
|
||||
);
|
||||
assert!(error(registry.apply(pack(json!({"overrides":["demo:theme"],"sounds":[{"id":"demo:theme","name":"Wrong type","file":"theme.wav"}]})), "other")).contains("cannot change content type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_files_are_part_of_package_hashes_and_cache_clears_with_active_mods() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "demo", music_pack(), &theme(60));
|
||||
let discovery = modding::discover(temp.path());
|
||||
let active =
|
||||
ActiveMods::build(&Registry::default(), &discovery, &selected(&["demo"]), None).unwrap();
|
||||
let mut bank = SoundBank::default();
|
||||
let first = bank
|
||||
.mod_music(&active.registry, &"demo:theme".into())
|
||||
.unwrap();
|
||||
fs::write(temp.path().join("demo/theme.mus"), theme(65)).unwrap();
|
||||
assert_ne!(
|
||||
discovery.mods["demo"].hash,
|
||||
modding::discover(temp.path()).mods["demo"].hash
|
||||
);
|
||||
assert!(
|
||||
error(ActiveMods::build(
|
||||
&Registry::default(),
|
||||
&discovery,
|
||||
&selected(&["demo"]),
|
||||
None
|
||||
))
|
||||
.contains("changed since discovery")
|
||||
);
|
||||
let cached = bank
|
||||
.mod_music(&active.registry, &"demo:theme".into())
|
||||
.unwrap();
|
||||
assert_eq!(first.groups, cached.groups);
|
||||
bank.clear_mods();
|
||||
let replaced = bank
|
||||
.mod_music(&active.registry, &"demo:theme".into())
|
||||
.unwrap();
|
||||
assert_ne!(first.groups, replaced.groups);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_malformed_oversized_and_escaping_music_reject_activation() {
|
||||
for (file, expected) in [
|
||||
("missing.mus", "missing asset"),
|
||||
("../outside.mus", "parent traversal"),
|
||||
("theme.wav", ".mus file"),
|
||||
] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut content = music_pack();
|
||||
content["music"][0]["file"] = json!(file);
|
||||
install(temp.path(), "demo", content, &theme(60));
|
||||
assert!(error(build(temp.path(), &["demo"])).contains(expected));
|
||||
}
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "demo", music_pack(), b"MThd");
|
||||
assert!(error(build(temp.path(), &["demo"])).contains("invalid MUS signature"));
|
||||
fs::write(
|
||||
temp.path().join("demo/theme.mus"),
|
||||
vec![0; sound::MAX_MUSIC_BYTES + 1],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(error(build(temp.path(), &["demo"])).contains("128 KiB"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn music_symlinks_cannot_escape_package_ownership() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "demo", music_pack(), &theme(60));
|
||||
let outside = temp.path().join("outside.mus");
|
||||
fs::write(&outside, theme(67)).unwrap();
|
||||
fs::remove_file(temp.path().join("demo/theme.mus")).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, temp.path().join("demo/theme.mus")).unwrap();
|
||||
assert!(sound::load_music(&temp.path().join("demo"), "theme.mus").is_err());
|
||||
assert!(!modding::discover(temp.path()).errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_mus_has_bounded_events_density_channels_and_duration() {
|
||||
assert!(sound::decode_music(&mus(&[0x60], 1)).is_err());
|
||||
assert!(sound::decode_music(&mus(&[0x90, 188, 100, 1, 0x60], 16)).is_err());
|
||||
let mut simultaneous = vec![0x10, 60].repeat(256);
|
||||
simultaneous.extend([0x90, 60, 1, 0x60]);
|
||||
assert!(error(sound::decode_music(&mus(&simultaneous, 1))).contains("256 events"));
|
||||
let mut events = vec![0x90, 60, 1].repeat(16385);
|
||||
events.push(0x60);
|
||||
assert!(error(sound::decode_music(&mus(&events, 1))).contains("16384 events"));
|
||||
// 126001 encoded as a MUS base-128 time delta.
|
||||
assert!(
|
||||
error(sound::decode_music(&mus(
|
||||
&[0x90, 188, 100, 0x87, 0xd8, 0x31, 0x60],
|
||||
1
|
||||
)))
|
||||
.contains("30 minutes")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_music_uses_original_fm_music_volume_and_its_declared_loop() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut content = music_pack();
|
||||
content["music"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"id":"demo:once","name":"One play","file":"theme.mus","looping":false}));
|
||||
install(temp.path(), "demo", content, &theme(60));
|
||||
let active = build(temp.path(), &["demo"]).unwrap();
|
||||
let mut bank = SoundBank::default();
|
||||
let score = bank
|
||||
.mod_music(&active.registry, &"demo:theme".into())
|
||||
.unwrap();
|
||||
let mut looping = Mixer::new(44100, &instruments()).unwrap();
|
||||
looping.music_volume = 127;
|
||||
looping.fx_volume = 0;
|
||||
looping.play_with_fade(
|
||||
score.clone(),
|
||||
active.registry.music[&"demo:theme".into()].looping,
|
||||
true,
|
||||
);
|
||||
let mut first = vec![0; 44100 * 2];
|
||||
looping.mix(&mut first);
|
||||
assert!(first.iter().any(|value| *value != 0));
|
||||
looping.music_volume = 0;
|
||||
let mut muted = vec![1; 4096];
|
||||
looping.mix(&mut muted);
|
||||
assert!(muted.iter().all(|value| *value == 0));
|
||||
looping.fx_volume = 127;
|
||||
looping.sample(
|
||||
DigitalSample {
|
||||
rate: 11025,
|
||||
data: vec![220; 500],
|
||||
},
|
||||
127,
|
||||
128,
|
||||
100,
|
||||
1,
|
||||
);
|
||||
looping.mix(&mut muted);
|
||||
assert!(muted.iter().any(|value| *value != 0));
|
||||
looping.music_volume = 127;
|
||||
looping.fx_volume = 0;
|
||||
let mut repeating_tail = vec![0; 44100 * 2];
|
||||
looping.mix(&mut repeating_tail);
|
||||
assert!(repeating_tail.iter().any(|value| *value != 0));
|
||||
let mut once = Mixer::new(44100, &instruments()).unwrap();
|
||||
once.music_volume = 127;
|
||||
once.play_with_fade(
|
||||
score,
|
||||
active.registry.music[&"demo:once".into()].looping,
|
||||
true,
|
||||
);
|
||||
once.mix(&mut first);
|
||||
let mut ended_tail = vec![0; 44100 * 2];
|
||||
once.mix(&mut ended_tail);
|
||||
let repeating_energy: u64 = repeating_tail
|
||||
.iter()
|
||||
.map(|value| value.unsigned_abs() as u64)
|
||||
.sum();
|
||||
let ended_energy: u64 = ended_tail
|
||||
.iter()
|
||||
.map(|value| value.unsigned_abs() as u64)
|
||||
.sum();
|
||||
assert!(
|
||||
ended_energy < repeating_energy / 20,
|
||||
"ended energy {ended_energy}, loop energy {repeating_energy}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! A confirmed changed-capacity save retains equipment without increasing the
|
||||
//! original combat shield limits or losing quantities on additive operations.
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Pickup, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn registry() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"items": [
|
||||
{"id":"base:forward_guns","name":"Guns","price":0},
|
||||
{"id":"base:energy","name":"Energy","price":0,"max_count":100,"initial_count":25,"shield":25,"legacy":16},
|
||||
{"id":"base:super_shield","name":"Super Shield","price":0,"max_count":100,"initial_count":100,"legacy":15},
|
||||
{"id":"base:ammo","name":"Ammo","price":0,"max_count":3}
|
||||
],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":10000}]
|
||||
})).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
fn id(value: &str) -> ContentId {
|
||||
value.into()
|
||||
}
|
||||
fn overflowing() -> Pilot {
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.inventory.insert(id("base:energy"), 105);
|
||||
pilot.inventory.insert(id("base:super_shield"), 125);
|
||||
pilot.super_shield_reserves = vec![130];
|
||||
pilot
|
||||
}
|
||||
fn command(world: &mut World, registry: &Registry, command: Command) {
|
||||
world.apply_commands(&[command], registry).unwrap();
|
||||
}
|
||||
fn collect(world: &mut World, registry: &Registry, item: &str) {
|
||||
world.pickups.push(Pickup {
|
||||
entity: 123,
|
||||
item: item.into(),
|
||||
x: world.player.x,
|
||||
y: world.player.y,
|
||||
legacy: None,
|
||||
});
|
||||
world
|
||||
.step(Input::default(), registry, &mut ScriptRuntime::new())
|
||||
.unwrap();
|
||||
assert!(world.pickups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_load_steps_and_snapshots_keep_full_charge_with_bounded_usable_shields() {
|
||||
let registry = registry();
|
||||
let saved = overflowing();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("pilot.json");
|
||||
saved.save(&path).unwrap();
|
||||
let loaded = Pilot::inspect_load(&path, ®istry, &[]).unwrap();
|
||||
assert!(!loaded.compatibility.is_compatible());
|
||||
let pilot = loaded.confirm_load();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
assert_eq!((world.player.shield, world.player.super_shield), (100, 100));
|
||||
for _ in 0..10 {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut ScriptRuntime::new())
|
||||
.unwrap();
|
||||
}
|
||||
let after = world.pilot(&pilot);
|
||||
assert_eq!(after.inventory, saved.inventory);
|
||||
assert_eq!(after.super_shield_reserves, saved.super_shield_reserves);
|
||||
// A serialized in-progress world also keeps its retained charge.
|
||||
let world: World = serde_json::from_value(serde_json::to_value(world).unwrap()).unwrap();
|
||||
assert_eq!(world.pilot(&pilot).inventory, saved.inventory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_world_serialization_omits_overflow_and_old_worlds_default_to_zero() {
|
||||
let registry = registry();
|
||||
let pilot = Pilot::default();
|
||||
let world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let value = serde_json::to_value(world).unwrap();
|
||||
assert!(value.get("energy_overflow").is_none());
|
||||
assert!(value.get("super_shield_overflow").is_none());
|
||||
let decoded: World = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(
|
||||
(decoded.energy_overflow, decoded.super_shield_overflow),
|
||||
(0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_consumes_usable_charge_and_destroyed_modules_do_not_resurrect_overflow() {
|
||||
let registry = registry();
|
||||
let pilot = overflowing();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::Damage {
|
||||
entity: 0,
|
||||
amount: 20,
|
||||
},
|
||||
);
|
||||
assert_eq!(world.player.super_shield, 80);
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:super_shield")], 105);
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::Damage {
|
||||
entity: 0,
|
||||
amount: 81,
|
||||
},
|
||||
);
|
||||
assert_eq!(world.player.super_shield, 100);
|
||||
assert_eq!(world.super_shield_overflow, 30);
|
||||
assert!(world.player.super_shield_reserves.is_empty());
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:super_shield")], 130);
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::Damage {
|
||||
entity: 0,
|
||||
amount: 101,
|
||||
},
|
||||
);
|
||||
assert_eq!(world.super_shield_overflow, 0);
|
||||
assert!(
|
||||
!world
|
||||
.pilot(&pilot)
|
||||
.inventory
|
||||
.contains_key(&id("base:super_shield"))
|
||||
);
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::Damage {
|
||||
entity: 0,
|
||||
amount: 10,
|
||||
},
|
||||
);
|
||||
assert_eq!(world.player.shield, 90);
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:energy")], 95);
|
||||
collect(&mut world, ®istry, "base:energy");
|
||||
assert_eq!(world.player.shield, 100);
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:energy")], 105);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additive_pickup_and_script_grants_never_reduce_quantities_above_current_capacity() {
|
||||
let registry = registry();
|
||||
let mut pilot = overflowing();
|
||||
pilot.inventory.insert(id("base:ammo"), 7);
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
for item in ["base:ammo", "base:energy", "base:super_shield"] {
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::GiveItem {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
assert_eq!(world.pilot(&pilot).inventory, pilot.inventory);
|
||||
collect(&mut world, ®istry, "base:ammo");
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:ammo")], 7);
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::ConsumeItem {
|
||||
item: "base:ammo".into(),
|
||||
count: 2,
|
||||
},
|
||||
);
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::GiveItem {
|
||||
item: "base:ammo".into(),
|
||||
count: 1,
|
||||
},
|
||||
);
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:ammo")], 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_consumption_reduces_retained_charge_without_restoring_spent_charge() {
|
||||
let registry = registry();
|
||||
let pilot = overflowing();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::ConsumeItem {
|
||||
item: "base:energy".into(),
|
||||
count: 10,
|
||||
},
|
||||
);
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::ConsumeItem {
|
||||
item: "base:super_shield".into(),
|
||||
count: 110,
|
||||
},
|
||||
);
|
||||
assert_eq!(world.player.shield, 90);
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:energy")], 95);
|
||||
assert_eq!(world.player.super_shield, 0);
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:super_shield")], 15);
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::ConsumeItem {
|
||||
item: "base:super_shield".into(),
|
||||
count: 15,
|
||||
},
|
||||
);
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::ConsumeItem {
|
||||
item: "base:energy".into(),
|
||||
count: 90,
|
||||
},
|
||||
);
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:energy")], 5);
|
||||
assert_eq!(world.super_shield_overflow, 0);
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::Damage {
|
||||
entity: 0,
|
||||
amount: 1,
|
||||
},
|
||||
);
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id("base:super_shield")], 130);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_large_grants_are_split_once_without_duplicating_default_energy() {
|
||||
let mut registry = registry();
|
||||
for item in ["base:energy", "base:super_shield"] {
|
||||
registry.items.get_mut(&id(item)).unwrap().max_count = 200;
|
||||
}
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.inventory.remove(&id("base:energy"));
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
for item in ["base:energy", "base:super_shield"] {
|
||||
command(
|
||||
&mut world,
|
||||
®istry,
|
||||
Command::GiveItem {
|
||||
item: item.into(),
|
||||
count: 125,
|
||||
},
|
||||
);
|
||||
assert_eq!(world.pilot(&pilot).inventory[&id(item)], 125);
|
||||
}
|
||||
assert_eq!((world.player.shield, world.player.super_shield), (100, 100));
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Effect, EffectKind, Input, VisualEffect, VisualEffectKind, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> (Registry, World) {
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"weapons":[{"id":"base:gun","name":"Gun","damage":1,"speed":8,"cooldown":8,"behavior":"straight","target":"all",
|
||||
"legacy":0,"art":{"asset":0x101c3,"logical_width":4,"logical_height":8,"frames":4}}],
|
||||
"items":[{"id":"base:forward_guns","name":"Guns","price":0,"legacy":0,"weapon":"base:gun"},
|
||||
{"id":"base:energy","name":"Energy","price":0,"legacy":16,"max_count":100},
|
||||
{"id":"base:super_shield","name":"Shield","price":0,"legacy":15,"max_count":100}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Effects fixture","duration":1000}]
|
||||
})).unwrap();
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
let pilot = Pilot::default();
|
||||
let world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
(registry, world)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_forward_gun_casings_stay_in_world_space_and_do_not_create_explosions() {
|
||||
let (registry, mut world) = fixture();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
demo_bank: Some(3),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(world.effects.is_empty());
|
||||
assert_eq!(world.visual_effects.len(), 2);
|
||||
assert!(
|
||||
world
|
||||
.visual_effects
|
||||
.iter()
|
||||
.all(|e| e.kind == VisualEffectKind::Casing)
|
||||
);
|
||||
let positions: Vec<_> = world.visual_effects.iter().map(|e| (e.x, e.y)).collect();
|
||||
assert_eq!(positions, [(166, 176), (153, 176)]);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([180, 150]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.visual_effects
|
||||
.iter()
|
||||
.map(|e| (e.x, e.y))
|
||||
.collect::<Vec<_>>(),
|
||||
[(166, 177), (153, 177)]
|
||||
);
|
||||
for _ in 0..3 {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
world.visual_effects.is_empty(),
|
||||
"native casing animation lasts four simulation steps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_smoke_uses_two_pixel_drift_and_exact_frame_lifetimes() {
|
||||
let (registry, mut world) = fixture();
|
||||
world
|
||||
.visual_effects
|
||||
.push(VisualEffect::new(VisualEffectKind::SmokeDown, 100, 100));
|
||||
world
|
||||
.visual_effects
|
||||
.push(VisualEffect::new(VisualEffectKind::SmokeTrail, 120, 100));
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for age in 1..=4 {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(world.visual_effects[0].age, world.visual_effects[0].y),
|
||||
(age, 100 + 2 * (age as i32 - 1))
|
||||
);
|
||||
assert_eq!(
|
||||
(world.visual_effects[1].age, world.visual_effects[1].y),
|
||||
(age, 100 - 2 * (age as i32 - 1))
|
||||
);
|
||||
}
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.visual_effects.len(), 1);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(world.visual_effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shield_hit_follows_player_and_secondary_animations_do_not_change_gameplay_hashes() {
|
||||
let (registry, mut world) = fixture();
|
||||
world.player.super_shield = 20;
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.insert("base:super_shield".into(), 20);
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 3,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.visual_effects.len(), 1);
|
||||
assert_eq!(world.visual_effects[0].kind, VisualEffectKind::Shield);
|
||||
assert_eq!(world.visual_effects[0].anchor, Some(0));
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([100, 100]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(world.visual_effects[0].x, world.visual_effects[0].y),
|
||||
(116, 116)
|
||||
);
|
||||
let digest = world.digest();
|
||||
let snapshot = world.reference_state(®istry);
|
||||
let seed = world.rng.seed;
|
||||
world.visual_effects.clear();
|
||||
assert_eq!(world.digest(), digest);
|
||||
assert_eq!(world.reference_state(®istry), snapshot);
|
||||
assert_eq!(world.rng.seed, seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturated_native_animation_pool_shares_one_limit_without_changing_fire_or_rng() {
|
||||
let (registry, initial) = fixture();
|
||||
let mut snapshots = Vec::new();
|
||||
for existing in [98, 99, 100] {
|
||||
let mut world = initial.clone();
|
||||
world.effects = vec![
|
||||
Effect {
|
||||
x: 40,
|
||||
y: 40,
|
||||
px: 40,
|
||||
py: 40,
|
||||
age: 0,
|
||||
kind: EffectKind::AirBlast,
|
||||
ground: false
|
||||
};
|
||||
existing
|
||||
];
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
demo_bank: Some(3),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut ScriptRuntime::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.effects.len() + world.visual_effects.len(), 100);
|
||||
assert_eq!(world.visual_effects.len(), 100 - existing);
|
||||
assert_eq!(
|
||||
world.projectiles.len(),
|
||||
2,
|
||||
"visual allocation failure must not prevent real gun rounds"
|
||||
);
|
||||
snapshots.push(world.reference_state(®istry));
|
||||
}
|
||||
assert!(snapshots.windows(2).all(|pair| pair[0] == pair[1]));
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Original shield-module inventory and recharge behavior, with synthetic data.
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Pickup, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> Registry {
|
||||
let mut items = vec![
|
||||
json!({"id":"base:forward_guns","name":"Guns","price":12000,"weapon":"base:shot","legacy":0}),
|
||||
json!({"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"shield":25,"legacy":16}),
|
||||
json!({"id":"base:super_shield","name":"Super Shield","price":78500,"max_count":100,"initial_count":100,"legacy":15}),
|
||||
];
|
||||
for n in 0..20 {
|
||||
items.push(json!({"id":format!("base:filler_{n}"),"name":"Inventory slot","price":1}));
|
||||
}
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"items":items,
|
||||
"weapons":[{"id":"base:shot","name":"Gun","damage":1,"speed":8,"cooldown":5,"behavior":"straight","target":"all"}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Shield fixture","duration":100000}]
|
||||
})).unwrap();
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
fn super_id() -> ContentId {
|
||||
"base:super_shield".into()
|
||||
}
|
||||
fn shielded(active: u32, reserves: &[u32]) -> Pilot {
|
||||
let mut pilot = Pilot {
|
||||
credits: 2_000_000,
|
||||
difficulty: 2,
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.inventory.insert(super_id(), active);
|
||||
pilot.super_shield_reserves = reserves.to_vec();
|
||||
pilot
|
||||
}
|
||||
fn mission_world(pilot: &Pilot, registry: &Registry) -> World {
|
||||
World::new(&pilot.mission, pilot, registry).unwrap()
|
||||
}
|
||||
fn hit(world: &mut World, damage: i32, registry: &Registry) {
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: damage,
|
||||
}],
|
||||
registry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
fn idle(world: &mut World, ticks: usize, registry: &Registry) {
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for _ in 0..ticks {
|
||||
world
|
||||
.step(Input::default(), registry, &mut scripts)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
fn collect(world: &mut World, item: &str, registry: &Registry) {
|
||||
world.pickups.push(Pickup {
|
||||
entity: 9000,
|
||||
item: item.into(),
|
||||
x: world.player.x,
|
||||
y: world.player.y,
|
||||
legacy: None,
|
||||
});
|
||||
idle(world, 1, registry);
|
||||
assert!(world.pickups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchases_preserve_active_charge_and_enforce_five_modules_and_twenty_slots() {
|
||||
let registry = fixture();
|
||||
let mut pilot = shielded(17, &[]);
|
||||
for _ in 0..4 {
|
||||
pilot.buy(&super_id(), ®istry).unwrap();
|
||||
}
|
||||
assert_eq!(pilot.inventory[&super_id()], 17);
|
||||
assert_eq!(pilot.super_shield_reserves, vec![100; 4]);
|
||||
assert_eq!(pilot.item_count(&super_id()), 5);
|
||||
assert_eq!(pilot.inventory_slots(), 7);
|
||||
let before = serde_json::to_value(&pilot).unwrap();
|
||||
assert!(pilot.buy(&super_id(), ®istry).is_err());
|
||||
assert_eq!(serde_json::to_value(&pilot).unwrap(), before);
|
||||
let mut full = shielded(100, &[100]);
|
||||
for n in 0..16 {
|
||||
full.inventory
|
||||
.insert(format!("base:filler_{n}").as_str().into(), 1);
|
||||
}
|
||||
assert_eq!(full.inventory_slots(), 20);
|
||||
assert!(full.buy(&super_id(), ®istry).is_err());
|
||||
full.inventory.insert("base:energy".into(), 75);
|
||||
full.buy(&"base:energy".into(), ®istry).unwrap();
|
||||
assert_eq!(full.inventory[&ContentId::from("base:energy")], 100);
|
||||
full.validate(®istry, &[]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selling_removes_full_equipped_module_and_selects_first_reserve() {
|
||||
let registry = fixture();
|
||||
let mut pilot = shielded(100, &[37, 100]);
|
||||
let credits = pilot.credits;
|
||||
pilot.sell(&super_id(), ®istry).unwrap();
|
||||
assert_eq!(pilot.credits, credits + 39250);
|
||||
assert_eq!(pilot.inventory[&super_id()], 37);
|
||||
assert_eq!(pilot.super_shield_reserves, vec![100]);
|
||||
assert_eq!(pilot.item_count(&super_id()), 2);
|
||||
let before = serde_json::to_value(&pilot).unwrap();
|
||||
assert!(
|
||||
pilot.sell(&super_id(), ®istry).is_err(),
|
||||
"partial active module cannot sell a reserve instead"
|
||||
);
|
||||
assert_eq!(serde_json::to_value(&pilot).unwrap(), before);
|
||||
pilot.inventory.insert(super_id(), 100);
|
||||
pilot.sell(&super_id(), ®istry).unwrap();
|
||||
pilot.sell(&super_id(), ®istry).unwrap();
|
||||
assert_eq!(pilot.item_count(&super_id()), 0);
|
||||
assert!(!pilot.inventory.contains_key(&super_id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_energy_sales_remove_one_purchase_and_retain_positive_shield() {
|
||||
let registry = fixture();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.sell(&"base:energy".into(), ®istry).unwrap();
|
||||
assert_eq!(pilot.inventory[&ContentId::from("base:energy")], 75);
|
||||
assert_eq!(pilot.credits, 5000);
|
||||
pilot.inventory.insert("base:energy".into(), 26);
|
||||
pilot.sell(&"base:energy".into(), ®istry).unwrap();
|
||||
assert_eq!(pilot.inventory[&ContentId::from("base:energy")], 1);
|
||||
assert!(pilot.sell(&"base:energy".into(), ®istry).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hits_do_not_spill_into_reserves_or_normal_shields_and_exact_zero_remains_equipped() {
|
||||
let registry = fixture();
|
||||
let pilot = shielded(5, &[37, 0, 100]);
|
||||
let mut world = mission_world(&pilot, ®istry);
|
||||
hit(&mut world, 5, ®istry);
|
||||
assert_eq!(world.player.super_shield, 0);
|
||||
assert!(world.player.inventory.contains_key(&super_id()));
|
||||
assert_eq!(world.player.super_shield_reserves, vec![37, 0, 100]);
|
||||
hit(&mut world, 9000, ®istry);
|
||||
assert_eq!(world.player.super_shield, 37);
|
||||
assert_eq!(world.player.shield, 100);
|
||||
hit(&mut world, 38, ®istry);
|
||||
assert_eq!(
|
||||
world.player.super_shield, 0,
|
||||
"a zero-charge reserve may become equipped"
|
||||
);
|
||||
assert!(world.player.inventory.contains_key(&super_id()));
|
||||
hit(&mut world, 1, ®istry);
|
||||
assert_eq!(world.player.super_shield, 100);
|
||||
hit(&mut world, 101, ®istry);
|
||||
assert!(!world.player.inventory.contains_key(&super_id()));
|
||||
assert!(world.player.super_shield_reserves.is_empty());
|
||||
assert_eq!(world.player.shield, 100);
|
||||
hit(&mut world, 3, ®istry);
|
||||
assert_eq!(world.player.shield, 97);
|
||||
let saved = world.pilot(&pilot);
|
||||
assert!(saved.super_shield_reserves.is_empty());
|
||||
assert_eq!(saved.inventory[&ContentId::from("base:energy")], 97);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rookie_damage_is_halved_before_module_depletion_and_round_trips() {
|
||||
let registry = fixture();
|
||||
let mut pilot = shielded(5, &[71, 99]);
|
||||
pilot.difficulty = 0;
|
||||
let mut world = mission_world(&pilot, ®istry);
|
||||
hit(&mut world, 7, ®istry);
|
||||
assert_eq!(world.player.super_shield, 2);
|
||||
hit(&mut world, 4, ®istry);
|
||||
assert_eq!(world.player.super_shield, 0);
|
||||
hit(&mut world, 5, ®istry);
|
||||
assert_eq!(world.player.super_shield, 71);
|
||||
let saved = world.pilot(&pilot);
|
||||
saved.validate(®istry, &[]).unwrap();
|
||||
let restored = mission_world(&saved, ®istry);
|
||||
assert_eq!(restored.player.super_shield, 71);
|
||||
assert_eq!(restored.player.super_shield_reserves, vec![99]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pickups_add_modules_without_refilling_active_charge_and_energy_only_charges_active() {
|
||||
let registry = fixture();
|
||||
let pilot = shielded(20, &[100; 4]);
|
||||
let mut world = mission_world(&pilot, ®istry);
|
||||
collect(&mut world, "base:super_shield", ®istry);
|
||||
assert_eq!(world.player.super_shield, 20);
|
||||
assert_eq!(
|
||||
world.player.super_shield_reserves,
|
||||
vec![100; 5],
|
||||
"native pickup capacity is slots; five is the shop limit"
|
||||
);
|
||||
collect(&mut world, "base:energy", ®istry);
|
||||
assert_eq!(world.player.super_shield, 26);
|
||||
assert_eq!(world.player.super_shield_reserves, vec![100; 5]);
|
||||
world.player.shield = 90;
|
||||
collect(&mut world, "base:energy", ®istry);
|
||||
assert_eq!(world.player.shield, 100);
|
||||
assert_eq!(
|
||||
world.player.super_shield, 26,
|
||||
"normal-shield overflow is discarded, not charged into a module"
|
||||
);
|
||||
world.player.super_shield = 0;
|
||||
world.player.inventory.insert(super_id(), 0);
|
||||
collect(&mut world, "base:energy", ®istry);
|
||||
assert_eq!(
|
||||
world.player.super_shield, 0,
|
||||
"normal energy cannot revive an empty equipped module"
|
||||
);
|
||||
let mut full = shielded(20, &[99]);
|
||||
for n in 0..16 {
|
||||
full.inventory
|
||||
.insert(format!("base:filler_{n}").as_str().into(), 1);
|
||||
}
|
||||
let mut world = mission_world(&full, ®istry);
|
||||
collect(&mut world, "base:super_shield", ®istry);
|
||||
assert_eq!(world.player.super_shield, 20);
|
||||
assert_eq!(world.player.super_shield_reserves, vec![99]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_recharge_uses_97_steps_pauses_on_weapon_attempts_and_preserves_rng() {
|
||||
let registry = fixture();
|
||||
let mut pilot = shielded(44, &[88]);
|
||||
pilot.inventory.insert("base:energy".into(), 75);
|
||||
let mut world = mission_world(&pilot, ®istry);
|
||||
let rng = world.rng;
|
||||
idle(&mut world, 96, ®istry);
|
||||
assert_eq!(world.player.shield, 75);
|
||||
assert_eq!(world.shield_recharge_ticks, 96);
|
||||
let checkpoint = world.pilot(&pilot);
|
||||
let mut world = mission_world(&checkpoint, ®istry);
|
||||
idle(&mut world, 1, ®istry);
|
||||
assert_eq!(world.player.shield, 76);
|
||||
assert_eq!(world.player.super_shield, 44);
|
||||
assert_eq!(world.player.super_shield_reserves, vec![88]);
|
||||
assert_eq!(world.rng, rng);
|
||||
idle(&mut world, 96, ®istry);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut ScriptRuntime::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.shield_recharge_ticks, 0);
|
||||
idle(&mut world, 96, ®istry);
|
||||
assert_eq!(world.player.shield, 76);
|
||||
idle(&mut world, 1, ®istry);
|
||||
assert_eq!(world.player.shield, 77);
|
||||
world.player.shield = 100;
|
||||
idle(&mut world, 97, ®istry);
|
||||
assert_eq!(
|
||||
world.player.super_shield, 44,
|
||||
"one-unit idle recharge rounds to zero for super shields"
|
||||
);
|
||||
let mut ace = pilot.clone();
|
||||
ace.difficulty = 3;
|
||||
let mut ace = mission_world(&ace, ®istry);
|
||||
idle(&mut ace, 200, ®istry);
|
||||
assert_eq!(ace.player.shield, 75);
|
||||
let mut ending = mission_world(&pilot, ®istry);
|
||||
ending.end_timer = Some(500);
|
||||
idle(&mut ending, 97, ®istry);
|
||||
assert_eq!(ending.player.shield, 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charge_unit_script_commands_preserve_reserves_and_rollback_all_module_state() {
|
||||
let registry = fixture();
|
||||
let pilot = shielded(12, &[44, 100]);
|
||||
let mut world = mission_world(&pilot, ®istry);
|
||||
world
|
||||
.apply_commands(
|
||||
&[
|
||||
Command::GiveItem {
|
||||
item: super_id().0,
|
||||
count: 16,
|
||||
},
|
||||
Command::ConsumeItem {
|
||||
item: super_id().0,
|
||||
count: 18,
|
||||
},
|
||||
],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.super_shield, 10);
|
||||
assert_eq!(world.player.inventory[&super_id()], 10);
|
||||
assert_eq!(world.player.super_shield_reserves, vec![44, 100]);
|
||||
let before = serde_json::to_value(&world).unwrap();
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(
|
||||
&[
|
||||
Command::Damage {
|
||||
entity: 0,
|
||||
amount: 15
|
||||
},
|
||||
Command::Velocity {
|
||||
entity: 999,
|
||||
vx: 1,
|
||||
vy: 1
|
||||
}
|
||||
],
|
||||
®istry
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(serde_json::to_value(&world).unwrap(), before);
|
||||
world
|
||||
.apply_commands(
|
||||
&[
|
||||
Command::ConsumeItem {
|
||||
item: super_id().0,
|
||||
count: 10,
|
||||
},
|
||||
Command::ConsumeItem {
|
||||
item: "base:energy".into(),
|
||||
count: 100,
|
||||
},
|
||||
],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.super_shield, 0);
|
||||
assert_eq!(world.player.shield, 0);
|
||||
assert_eq!(world.player.inventory[&super_id()], 0);
|
||||
assert_eq!(world.player.inventory[&ContentId::from("base:energy")], 0);
|
||||
assert_eq!(world.player.super_shield_reserves, vec![44, 100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumed_equipment_releases_slot_selects_next_special_and_rolls_back_atomically() {
|
||||
let mut registry = fixture();
|
||||
for n in 0..2 {
|
||||
let item = registry
|
||||
.items
|
||||
.get_mut(&ContentId::from(format!("base:filler_{n}").as_str()))
|
||||
.unwrap();
|
||||
item.special = true;
|
||||
item.weapon = Some("base:shot".into());
|
||||
}
|
||||
let first = ContentId::from("base:filler_0");
|
||||
let second = ContentId::from("base:filler_1");
|
||||
let mut pilot = shielded(17, &[100]);
|
||||
for n in 0..16 {
|
||||
pilot
|
||||
.inventory
|
||||
.insert(format!("base:filler_{n}").as_str().into(), 1);
|
||||
}
|
||||
pilot.selected = Some(first.clone());
|
||||
assert_eq!(pilot.inventory_slots(), 20);
|
||||
let mut world = mission_world(&pilot, ®istry);
|
||||
let before = serde_json::to_value(&world).unwrap();
|
||||
let consume = Command::ConsumeItem {
|
||||
item: first.0.clone(),
|
||||
count: 1,
|
||||
};
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(
|
||||
&[
|
||||
consume.clone(),
|
||||
Command::Velocity {
|
||||
entity: 999,
|
||||
vx: 1,
|
||||
vy: 1,
|
||||
},
|
||||
],
|
||||
®istry,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(serde_json::to_value(&world).unwrap(), before);
|
||||
world.apply_commands(&[consume], ®istry).unwrap();
|
||||
assert!(!world.player.inventory.contains_key(&first));
|
||||
assert_eq!(world.player.selected, Some(second.clone()));
|
||||
assert_eq!(world.pilot(&pilot).inventory_slots(), 19);
|
||||
world
|
||||
.apply_commands(
|
||||
&[
|
||||
Command::GiveItem {
|
||||
item: "base:filler_16".into(),
|
||||
count: 1,
|
||||
},
|
||||
Command::ConsumeItem {
|
||||
item: second.0,
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.selected, None);
|
||||
assert_eq!(world.pilot(&pilot).inventory_slots(), 19);
|
||||
world.pilot(&pilot).validate(®istry, &[]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_saves_without_extended_fields_remain_readable_without_source_changes() {
|
||||
let registry = fixture();
|
||||
let pilot = shielded(31, &[]);
|
||||
let mut old = serde_json::to_value(&pilot).unwrap();
|
||||
old.as_object_mut().unwrap().remove("super_shield_reserves");
|
||||
old.as_object_mut().unwrap().remove("shield_recharge_ticks");
|
||||
let bytes = serde_json::to_vec(&old).unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("old-v1.json");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let saved = Pilot::load(&path, ®istry, &[]).unwrap();
|
||||
assert_eq!(saved.item_count(&super_id()), 1);
|
||||
assert!(saved.super_shield_reserves.is_empty());
|
||||
assert_eq!(saved.shield_recharge_ticks, 0);
|
||||
let mut world = mission_world(&saved, ®istry);
|
||||
hit(&mut world, 12, ®istry);
|
||||
assert_eq!(world.pilot(&saved).inventory[&super_id()], 19);
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
let mut bad = shielded(100, &[101]);
|
||||
assert!(bad.validate(®istry, &[]).is_err());
|
||||
bad.super_shield_reserves = vec![100];
|
||||
bad.inventory.remove(&super_id());
|
||||
assert!(bad.validate(®istry, &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_callback_restores_module_order_charge_recharge_and_script_state() {
|
||||
let mut registry = fixture();
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&ContentId::from("base:sector_1_wave_1"))
|
||||
.unwrap()
|
||||
.script = Some("failed.rhai".into());
|
||||
let mut pilot = shielded(12, &[44, 100]);
|
||||
pilot.shield_recharge_ticks = 95;
|
||||
let mut world = mission_world(&pilot, ®istry);
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
scripts
|
||||
.add(
|
||||
"base",
|
||||
"failed.rhai",
|
||||
r#"
|
||||
fn on_mission_start(e, s, r) {
|
||||
#{commands:[#{op:"damage",entity:0,amount:15},
|
||||
#{op:"velocity",entity:999,vx:1,vy:1}],state:#{fired:true}}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let before = serde_json::to_value(&world).unwrap();
|
||||
let states = scripts.states.clone();
|
||||
let rng = scripts.rng.clone();
|
||||
assert!(
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(serde_json::to_value(&world).unwrap(), before);
|
||||
assert_eq!(scripts.states, states);
|
||||
assert_eq!(scripts.rng, rng);
|
||||
}
|
||||
|
||||
fn encrypt(bytes: &[u8]) -> Vec<u8> {
|
||||
let key = b"CASTLE";
|
||||
let mut index = 25 % key.len();
|
||||
let mut previous = key[index];
|
||||
bytes
|
||||
.iter()
|
||||
.map(|clear| {
|
||||
let byte = clear.wrapping_add(key[index]).wrapping_add(previous);
|
||||
previous = byte;
|
||||
index = (index + 1) % key.len();
|
||||
byte
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn legacy_module_save(modules: &[(i32, i32)]) -> Vec<u8> {
|
||||
let mut header = vec![0_u8; 88];
|
||||
for (offset, value) in [
|
||||
(40, -1_i32),
|
||||
(60, modules.len() as i32 + 2),
|
||||
(64, 2),
|
||||
(68, 2),
|
||||
(72, 2),
|
||||
(76, 2),
|
||||
] {
|
||||
header[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
let mut records = vec![(1_i32, 0_i32, 1_i32), (75, 16, 1)];
|
||||
records.extend(modules.iter().map(|&(charge, inuse)| (charge, 15, inuse)));
|
||||
let mut body = vec![];
|
||||
for (charge, kind, inuse) in records {
|
||||
let mut record = vec![0_u8; 24];
|
||||
for (offset, value) in [(8, charge), (12, kind), (20, inuse)] {
|
||||
record[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
body.extend(record);
|
||||
}
|
||||
[encrypt(&header), encrypt(&body)].concat()
|
||||
}
|
||||
#[test]
|
||||
fn legacy_import_preserves_independent_charges_equipped_flag_and_inventory_order() {
|
||||
let registry = fixture();
|
||||
let bytes = legacy_module_save(&[(11, 0), (22, 0), (100, 1)]);
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("CHAR0000.FIL");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let pilot = Pilot::import_legacy(&path, ®istry).unwrap();
|
||||
assert_eq!(pilot.inventory[&super_id()], 100);
|
||||
assert_eq!(pilot.super_shield_reserves, vec![11, 22]);
|
||||
assert_eq!(pilot.item_count(&super_id()), 3);
|
||||
let mut world = mission_world(&pilot, ®istry);
|
||||
hit(&mut world, 101, ®istry);
|
||||
assert_eq!(world.player.super_shield, 11);
|
||||
assert_eq!(world.player.super_shield_reserves, vec![22]);
|
||||
let output = temp.path().join("rust.json");
|
||||
world.pilot(&pilot).save(&output).unwrap();
|
||||
let restored = Pilot::load(&output, ®istry, &[]).unwrap();
|
||||
assert_eq!(restored.item_count(&super_id()), 2);
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
sound,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn digital_patch() -> Vec<u8> {
|
||||
let data: Vec<_> = (0..4000)
|
||||
.map(|i| if i % 20 < 10 { 240 } else { 16 })
|
||||
.collect();
|
||||
let mut bytes = vec![3, 0];
|
||||
bytes.extend(11025_u16.to_le_bytes());
|
||||
bytes.extend(((data.len() + 32) as i32).to_le_bytes());
|
||||
bytes.extend([0; 16]);
|
||||
bytes.extend(data);
|
||||
bytes.extend([0; 16]);
|
||||
bytes
|
||||
}
|
||||
fn original_catalog() -> (tempfile::TempDir, raptor_replica::assets::Catalog) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("marker"), []).unwrap();
|
||||
let bytes = digital_patch();
|
||||
std::fs::write(root.path().join("digital"), &bytes).unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("INDEX"),
|
||||
format!("00090 0 marker\n00094 {} digital\n", bytes.len()),
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = raptor_replica::assets::Catalog::open(root.path()).unwrap();
|
||||
(root, catalog)
|
||||
}
|
||||
fn mixer() -> raptor_replica::audio::Mixer {
|
||||
let mut genmidi = b"#OPL_II#".to_vec();
|
||||
genmidi.resize(8 + 175 * 36, 0);
|
||||
let mut mixer = raptor_replica::audio::Mixer::new(44100, &genmidi).unwrap();
|
||||
mixer.fx_volume = 127;
|
||||
mixer
|
||||
}
|
||||
#[test]
|
||||
fn original_marker_resolves_digital_variant_and_keeps_decoded_cache() {
|
||||
let (root, catalog) = original_catalog();
|
||||
assert!(raptor_replica::audio::DigitalSample::decode(&catalog.read(144).unwrap()).is_err());
|
||||
let mut bank = sound::SoundBank::default();
|
||||
let resolved = bank.original(&catalog, 144).unwrap();
|
||||
assert_eq!(resolved.data.len(), 4000);
|
||||
assert_eq!(bank.original_count(), 1);
|
||||
std::fs::remove_file(root.path().join("digital")).unwrap();
|
||||
assert_eq!(bank.original(&catalog, 144).unwrap().data, resolved.data);
|
||||
assert!(bank.original(&catalog, 999).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_world_firing_reaches_the_native_digital_mixer() {
|
||||
let (_root, catalog) = original_catalog();
|
||||
let mut registry = registry();
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&Pilot::default().mission)
|
||||
.unwrap()
|
||||
.script = None;
|
||||
let content: ContentPack = serde_json::from_value(json!({
|
||||
"weapons":[{"id":"base:test_guns","name":"Test guns","damage":2,"speed":8,"cooldown":2,"target":"all","behavior":"straight","legacy":0}]
|
||||
})).unwrap();
|
||||
registry.apply(content, "base").unwrap();
|
||||
registry
|
||||
.items
|
||||
.get_mut(&"base:forward_guns".into())
|
||||
.unwrap()
|
||||
.weapon = Some("base:test_guns".into());
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut ScriptRuntime::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let event = world
|
||||
.sound_events
|
||||
.iter()
|
||||
.find(|s| s.asset == 144)
|
||||
.expect("firing did not emit native gun sound");
|
||||
let mut mixer = mixer();
|
||||
let mut bank = sound::SoundBank::default();
|
||||
bank.queue_original(&catalog, &mut mixer, event).unwrap();
|
||||
let mut pcm = vec![0; 4096];
|
||||
mixer.mix(&mut pcm);
|
||||
assert!(pcm.iter().any(|s| *s != 0));
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
#[test]
|
||||
fn production_sdl_callback_outputs_and_captures_resolved_original_effect() {
|
||||
use sdl2::audio::AudioCallback;
|
||||
let (_root, catalog) = original_catalog();
|
||||
let mut mixer = mixer();
|
||||
mixer.start_capture(1).unwrap();
|
||||
let mut bank = sound::SoundBank::default();
|
||||
bank.queue_original(&catalog, &mut mixer, &sound::ui_event("FX_GUN").unwrap())
|
||||
.unwrap();
|
||||
let mut output = [99; 4096];
|
||||
AudioCallback::callback(&mut mixer, &mut output);
|
||||
assert!(output.iter().any(|s| *s != 0));
|
||||
assert!(output.chunks_exact(2).any(|s| s[0] != 0 && s[1] != 0));
|
||||
assert_eq!(mixer.take_capture().unwrap().samples, output);
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
#[test]
|
||||
#[ignore = "requires the original untracked catalog; uses only the Rust audio callback"]
|
||||
fn all_original_effect_cues_reach_the_production_callback_locally() {
|
||||
use raptor_replica::{assets::Catalog, audio::Mixer};
|
||||
use sdl2::audio::AudioCallback;
|
||||
let root = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../data"));
|
||||
let catalog = Catalog::open(root).unwrap();
|
||||
let instruments = catalog.named("GENMIDI_OP2").unwrap();
|
||||
let mut bank = sound::SoundBank::default();
|
||||
bank.preload_originals(&catalog).unwrap();
|
||||
assert_eq!(bank.original_count(), 26);
|
||||
let names = [
|
||||
"FX_MON1",
|
||||
"FX_MON2",
|
||||
"FX_MON3",
|
||||
"FX_MON4",
|
||||
"FX_MON5",
|
||||
"FX_MON6",
|
||||
"FX_DAVE",
|
||||
"FX_THEME",
|
||||
"FX_AIREXPLO",
|
||||
"FX_AIREXPLO2",
|
||||
"FX_BONUS",
|
||||
"FX_CRASH",
|
||||
"FX_DOOR",
|
||||
"FX_FLYBY",
|
||||
"FX_EGRAB",
|
||||
"FX_GEXPLO",
|
||||
"FX_GUN",
|
||||
"FX_JETSND",
|
||||
"FX_LASER",
|
||||
"FX_MISSLE",
|
||||
"FX_SWEP",
|
||||
"FX_TURRET",
|
||||
"FX_WARNING",
|
||||
"FX_BOSS1",
|
||||
"FX_IJETSND",
|
||||
"FX_EJETSND",
|
||||
"FX_INTROHIT",
|
||||
"FX_INTROGUN",
|
||||
"FX_ENEMYSHOT",
|
||||
"FX_ENEMYLASER",
|
||||
"FX_ENEMYMISSLE",
|
||||
"FX_ENEMYPLASMA",
|
||||
"FX_SHIT",
|
||||
"FX_HIT",
|
||||
"FX_NOSHOOT",
|
||||
"FX_PULSE",
|
||||
];
|
||||
for name in names {
|
||||
let mut mixer = Mixer::new(44100, &instruments).unwrap();
|
||||
mixer.fx_volume = 127;
|
||||
bank.queue_original(&catalog, &mut mixer, &sound::ui_event(name).unwrap())
|
||||
.unwrap();
|
||||
let mut output = vec![0; 44100 * 2];
|
||||
AudioCallback::callback(&mut mixer, &mut output);
|
||||
assert!(output.iter().any(|s| *s != 0), "silent original cue {name}");
|
||||
}
|
||||
println!(
|
||||
"36 original cue variants produced PCM through the Rust SDL callback; 26 cached digital assets"
|
||||
);
|
||||
}
|
||||
|
||||
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 scripted_mod_cue_outputs_pcm_and_cache_is_cleared_when_mods_change() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut registry = registry();
|
||||
registry
|
||||
.asset_roots
|
||||
.insert("base".into(), root.path().to_path_buf());
|
||||
let path = root.path().join("test.wav");
|
||||
std::fs::write(&path, wav(1, 8, &vec![240; 2000])).unwrap();
|
||||
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:state} }
|
||||
"#).unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.mod_sounds.len(), 1);
|
||||
let id = &world.mod_sounds[0];
|
||||
let mut bank = sound::SoundBank::default();
|
||||
let mut audible = mixer();
|
||||
bank.queue_mod(®istry, &mut audible, id).unwrap();
|
||||
let mut pcm = [0; 1024];
|
||||
audible.mix(&mut pcm);
|
||||
assert!(pcm.iter().any(|s| *s != 0));
|
||||
std::fs::write(&path, wav(1, 8, &vec![128; 2000])).unwrap();
|
||||
assert!(
|
||||
bank.mod_sample(®istry, id)
|
||||
.unwrap()
|
||||
.data
|
||||
.iter()
|
||||
.any(|v| *v != 128)
|
||||
);
|
||||
bank.clear_mods();
|
||||
let mut replaced = mixer();
|
||||
bank.queue_mod(®istry, &mut replaced, id).unwrap();
|
||||
replaced.mix(&mut pcm);
|
||||
assert!(pcm.iter().all(|s| *s == 0));
|
||||
}
|
||||
#[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());
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{EffectKind, Input, LegacyRng, Outcome, Projectile, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture(legacy: bool, automatic: bool) -> (Registry, World, ScriptRuntime) {
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"weapons":[{"id":"base:shot","name":"Shot","damage":8,"speed":0,"cooldown":1,"behavior":"straight","target":"all"}],
|
||||
"items":[{"id":"base:forward_guns","name":"Guns","price":0,"weapon":"base:shot","legacy":0},{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16}],
|
||||
"enemies":[{"id":"base:target","name":"Final Target","health":1,"ground":true,"reward":10}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Transition Fixture","duration":1,"auto_complete":automatic,"legacy_map":if legacy {Some(123)} else {None}}]
|
||||
})).unwrap();
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
let world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
(registry, world, ScriptRuntime::new())
|
||||
}
|
||||
|
||||
fn after_calls(mut rng: LegacyRng, calls: usize) -> i32 {
|
||||
for _ in 0..calls {
|
||||
rng.next_value();
|
||||
}
|
||||
rng.seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_departure_pauses_at_forty_and_flies_off_before_completion() {
|
||||
let (registry, mut world, mut scripts) = fixture(true, false);
|
||||
world.end_timer = Some(60);
|
||||
for step in 1..=20 {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.end_timer, Some(60 - step));
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
assert!(world.draw_player);
|
||||
assert_eq!((world.player.x, world.player.y), (144, 160));
|
||||
assert_eq!(world.control_paused, step == 20);
|
||||
assert_eq!(world.sounds.contains(&129), step == 20); // FX_FLYBY.
|
||||
}
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([280, 0]),
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((world.player.x, world.player.y), (152, 156));
|
||||
assert!(world.projectiles.is_empty()); // Paused input cannot fire.
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!((world.player.x, world.player.y), (160, 148)); // Input+stats movement.
|
||||
while world.outcome == Outcome::Playing {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(world.tick, 61);
|
||||
assert_eq!(world.outcome, Outcome::Complete);
|
||||
assert_eq!((world.player.x, world.player.y), (160, -160));
|
||||
assert!(world.draw_player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_departure_positions_are_not_moved_a_second_time() {
|
||||
let (registry, mut world, mut scripts) = fixture(true, false);
|
||||
world.end_timer = Some(39);
|
||||
world.control_paused = true;
|
||||
world.departure_delta = [8, -4];
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([155, -8]),
|
||||
demo_bank: Some(2),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(world.player.x, world.player.y, world.player.bank),
|
||||
(155, -8, 2)
|
||||
);
|
||||
assert_eq!(world.end_timer, Some(38));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_enemy_removal_arms_immunity_before_hostile_shot_updates() {
|
||||
let (registry, mut world, mut scripts) = fixture(true, true);
|
||||
let entity = world
|
||||
.spawn(&"base:target".into(), 20, 20, ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.apply_commands(&[Command::Damage { entity, amount: 1 }], ®istry)
|
||||
.unwrap();
|
||||
world.projectiles.push(Projectile {
|
||||
entity: world.next_entity,
|
||||
weapon: "base:shot".into(),
|
||||
x: 160,
|
||||
y: 176,
|
||||
px: 160,
|
||||
py: 176,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
hostile: true,
|
||||
age: 0,
|
||||
legacy: None,
|
||||
legacy_enemy: None,
|
||||
});
|
||||
world.next_entity += 1;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.end_timer, Some(60));
|
||||
assert_eq!(world.player.shield, 100);
|
||||
assert_eq!(world.player.credits, 10);
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
assert!(world.projectiles.is_empty());
|
||||
let seed = world.rng.seed;
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 500,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 100);
|
||||
assert_eq!(world.rng.seed, seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_death_delays_failure_hides_ship_at_twenty_four_and_consumes_full_burst_rng() {
|
||||
let (registry, mut world, mut scripts) = fixture(true, false);
|
||||
world.player.shield = 0;
|
||||
let seed = world.rng;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.tick, 1);
|
||||
assert_eq!(world.end_timer, Some(60));
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
assert!(world.draw_player);
|
||||
assert_eq!(world.rng.seed, after_calls(seed, 4)); // Two x/y positions, no early sound.
|
||||
assert_eq!(world.effects.len(), 2);
|
||||
assert_eq!(world.effects[0].kind, EffectKind::MediumAir);
|
||||
assert_eq!(world.effects[1].kind, EffectKind::SmallAir);
|
||||
let mut positions = seed;
|
||||
let y = 160 + positions.next_value() % 32;
|
||||
let x = 144 + positions.next_value() % 32;
|
||||
assert_eq!((world.effects[0].x, world.effects[0].y), (x, y));
|
||||
let seed = world.rng;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.rng.seed, after_calls(seed, 6)); // Two positions, sound pan and pitch.
|
||||
assert!(
|
||||
world
|
||||
.sound_events
|
||||
.iter()
|
||||
.any(|s| s.asset == 104 && matches!(s.pan, 30 | 225))
|
||||
);
|
||||
while world.end_timer != Some(25) {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(world.draw_player);
|
||||
}
|
||||
let seed = world.rng;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.end_timer, Some(24));
|
||||
assert!(!world.draw_player);
|
||||
assert_eq!(world.effects.len(), 100); // Original pool cap, despite512requests.
|
||||
assert_eq!(world.rng.seed, after_calls(seed, 1032)); // Palette1,positions4,pitches3,burst1024.
|
||||
assert_eq!(world.sounds.iter().filter(|s| **s == 104).count(), 1);
|
||||
assert_eq!(world.sounds.iter().filter(|s| **s == 109).count(), 2);
|
||||
while world.outcome == Outcome::Playing {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(world.tick <= 62);
|
||||
}
|
||||
assert_eq!(world.tick, 62);
|
||||
assert_eq!(world.outcome, Outcome::Failed);
|
||||
assert!(!world.draw_player);
|
||||
assert!(!world.control_paused);
|
||||
let stopped = world.digest();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.digest(), stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_mod_missions_keep_their_immediate_outcome_contract() {
|
||||
let (registry, mut world, mut scripts) = fixture(false, true);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Complete);
|
||||
assert_eq!(world.end_timer, None);
|
||||
let (registry, mut world, mut scripts) = fixture(false, false);
|
||||
world.player.shield = 0;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Failed);
|
||||
assert_eq!(world.end_timer, None);
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
//! Indexed visual contracts and independent ScaleNx specification oracle.
|
||||
//! Original-data contact sheets are opt-in and remain under ignored .reference/.
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, Sprite, TRANSPARENT},
|
||||
content::Registry,
|
||||
render::{
|
||||
BitmapFont, Canvas, SpriteCache, legacy_light_table, nearest, prefers_nearest,
|
||||
shade_air_shadow, shade_ground_shadow, upscale,
|
||||
},
|
||||
};
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
|
||||
// Uses independent per-corner consensus rules rather than the renderer's shared
|
||||
// optimized guard. Source specification: https://www.scale2x.it/algorithm
|
||||
fn oracle(source: &Sprite, factor: usize) -> Sprite {
|
||||
if factor == 1 {
|
||||
return source.clone();
|
||||
}
|
||||
if factor == 4 {
|
||||
return oracle(&oracle(source, 2), 2);
|
||||
}
|
||||
let mut result = Sprite::blank(source.width * factor, source.height * factor);
|
||||
for y in 0..source.height {
|
||||
for x in 0..source.width {
|
||||
let read = |dx: i32, dy: i32| {
|
||||
let column = (x as i32 + dx).max(0).min(source.width as i32 - 1) as usize;
|
||||
let row = (y as i32 + dy).max(0).min(source.height as i32 - 1) as usize;
|
||||
source.pixels[row * source.width + column]
|
||||
};
|
||||
let [a, b, c, d, e, f, g, h, i] = [
|
||||
read(-1, -1),
|
||||
read(0, -1),
|
||||
read(1, -1),
|
||||
read(-1, 0),
|
||||
read(0, 0),
|
||||
read(1, 0),
|
||||
read(-1, 1),
|
||||
read(0, 1),
|
||||
read(1, 1),
|
||||
];
|
||||
let northwest = d == b && b != f && d != h;
|
||||
let northeast = b == f && b != d && f != h;
|
||||
let southwest = d == h && d != b && h != f;
|
||||
let southeast = h == f && d != h && b != f;
|
||||
let select = |condition, replacement| if condition { replacement } else { e };
|
||||
let values = if factor == 2 {
|
||||
vec![
|
||||
select(northwest, d),
|
||||
select(northeast, f),
|
||||
select(southwest, d),
|
||||
select(southeast, f),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
select(northwest, d),
|
||||
select((northwest && e != c) || (northeast && e != a), b),
|
||||
select(northeast, f),
|
||||
select((northwest && e != g) || (southwest && e != a), d),
|
||||
e,
|
||||
select((northeast && e != i) || (southeast && e != c), f),
|
||||
select(southwest, d),
|
||||
select((southwest && e != i) || (southeast && e != g), h),
|
||||
select(southeast, f),
|
||||
]
|
||||
};
|
||||
for yy in 0..factor {
|
||||
for xx in 0..factor {
|
||||
result.pixels[(y * factor + yy) * result.width + x * factor + xx] =
|
||||
values[yy * factor + xx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn crop(source: &Sprite, x: usize, y: usize, width: usize, height: usize) -> Sprite {
|
||||
let mut out = Sprite::blank(width, height);
|
||||
for row in 0..height {
|
||||
out.pixels[row * width..(row + 1) * width].copy_from_slice(
|
||||
&source.pixels[(y + row) * source.width + x..(y + row) * source.width + x + width],
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn pattern(width: usize, height: usize, mut seed: u32) -> Sprite {
|
||||
let pixels = (0..width * height)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
[0, 1, 245, 255, TRANSPARENT][(seed >> 16) as usize % 5]
|
||||
})
|
||||
.collect();
|
||||
Sprite {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_symbol_neighborhoods_match_unoptimized_published_oracle() {
|
||||
// 3^9 arrangements cover every local equality class for two colors plus
|
||||
// transparency; additional multi-color/border patterns are tested below.
|
||||
for mut code in 0..3_usize.pow(9) {
|
||||
let pixels = (0..9)
|
||||
.map(|_| {
|
||||
let value = [0, 245, TRANSPARENT][code % 3];
|
||||
code /= 3;
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
let source = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels,
|
||||
};
|
||||
for factor in [2, 3, 4] {
|
||||
assert_eq!(upscale(&source, factor).unwrap(), oracle(&source, factor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_borders_and_single_pixel_dimensions_match_oracle() {
|
||||
for (width, height) in [(1, 1), (1, 9), (9, 1), (2, 2), (7, 11)] {
|
||||
for seed in 0..20 {
|
||||
let source = pattern(width, height, seed);
|
||||
for factor in [1, 2, 3, 4] {
|
||||
assert_eq!(upscale(&source, factor).unwrap(), oracle(&source, factor));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_copies_palette_labels_and_keeps_opaque_black_and_white() {
|
||||
let source = Sprite {
|
||||
width: 3,
|
||||
height: 2,
|
||||
pixels: vec![0, 255, TRANSPARENT, 245, 0, 255],
|
||||
};
|
||||
let colors: BTreeSet<_> = source.pixels.iter().copied().collect();
|
||||
for factor in [1, 2, 3, 4] {
|
||||
let scaled = upscale(&source, factor).unwrap();
|
||||
assert!(scaled.pixels.iter().all(|value| colors.contains(value)));
|
||||
assert!(scaled.pixels.contains(&0));
|
||||
assert!(scaled.pixels.contains(&255));
|
||||
assert!(scaled.pixels.contains(&TRANSPARENT));
|
||||
assert_eq!(source.width, 3);
|
||||
assert_eq!(source.height, 2);
|
||||
}
|
||||
let mut canvas = Canvas::new(1);
|
||||
canvas.clear(37);
|
||||
canvas.blit(&source, 4, 5);
|
||||
let mut first = [[0; 3]; 256];
|
||||
first[0] = [1, 2, 3];
|
||||
first[255] = [250, 251, 252];
|
||||
first[245] = [200, 90, 10];
|
||||
first[37] = [8, 9, 10];
|
||||
let pixels_before = canvas.image.pixels.clone();
|
||||
let rgba_first = canvas.rgba(&first, false);
|
||||
first[245] = [10, 90, 200];
|
||||
let rgba_second = canvas.rgba(&first, false);
|
||||
assert_eq!(canvas.image.pixels, pixels_before);
|
||||
assert_ne!(rgba_first, rgba_second);
|
||||
assert_eq!(
|
||||
&rgba_second[((5 * 320 + 4) * 4)..((5 * 320 + 4) * 4 + 3)],
|
||||
&[1, 2, 3]
|
||||
);
|
||||
assert_eq!(canvas.image.pixels[5 * 320 + 6], 37);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_transparent_sprites_clip_cleanly_at_every_viewport_edge() {
|
||||
let source = pattern(7, 9, 19);
|
||||
for factor in [1, 2, 3, 4] {
|
||||
let sprite = upscale(&source, factor).unwrap();
|
||||
let width = 320 * factor;
|
||||
let height = 200 * factor;
|
||||
for (x, y) in [
|
||||
(-(sprite.width as i32) + 1, -(sprite.height as i32) + 1),
|
||||
(-3, 20),
|
||||
(30, -4),
|
||||
(width as i32 - 1, height as i32 - 1),
|
||||
(width as i32 - 4, 30),
|
||||
(40, height as i32 - 4),
|
||||
(-100, -100),
|
||||
] {
|
||||
let mut canvas = Canvas::new(factor);
|
||||
canvas.clear(37);
|
||||
canvas.blit(&sprite, x, y);
|
||||
for yy in 0..height {
|
||||
for xx in 0..width {
|
||||
let sx = xx as i32 - x;
|
||||
let sy = yy as i32 - y;
|
||||
let expected = if sx >= 0
|
||||
&& sy >= 0
|
||||
&& sx < sprite.width as i32
|
||||
&& sy < sprite.height as i32
|
||||
{
|
||||
let pixel = sprite.pixels[sy as usize * sprite.width + sx as usize];
|
||||
if pixel == TRANSPARENT { 37 } else { pixel }
|
||||
} else {
|
||||
37
|
||||
};
|
||||
assert_eq!(canvas.image.pixels[yy * width + xx], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_pixel_terrain_context_matches_full_joined_filter_at_all_scales() {
|
||||
let source = pattern(18, 18, 28);
|
||||
let with_gutter = crop(&source, 4, 4, 10, 10);
|
||||
for factor in [2, 3, 4] {
|
||||
let full = upscale(&source, factor).unwrap();
|
||||
let gutter = upscale(&with_gutter, factor).unwrap();
|
||||
assert_eq!(
|
||||
crop(&full, 6 * factor, 6 * factor, 6 * factor, 6 * factor),
|
||||
crop(&gutter, 2 * factor, 2 * factor, 6 * factor, 6 * factor)
|
||||
);
|
||||
assert_eq!(gutter, oracle(&with_gutter, factor));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_tile_filtering_is_detected_by_joined_seam_regression() {
|
||||
let source = pattern(8, 6, 1);
|
||||
let left = crop(&source, 0, 0, 4, 6);
|
||||
let right = crop(&source, 4, 0, 4, 6);
|
||||
for factor in [2, 3, 4] {
|
||||
let joined = upscale(&source, factor).unwrap();
|
||||
let a = upscale(&left, factor).unwrap();
|
||||
let b = upscale(&right, factor).unwrap();
|
||||
let mut tiled = Sprite::blank(8 * factor, 6 * factor);
|
||||
for row in 0..tiled.height {
|
||||
tiled.pixels[row * tiled.width..row * tiled.width + a.width]
|
||||
.copy_from_slice(&a.pixels[row * a.width..(row + 1) * a.width]);
|
||||
tiled.pixels[row * tiled.width + a.width..(row + 1) * tiled.width]
|
||||
.copy_from_slice(&b.pixels[row * b.width..(row + 1) * b.width]);
|
||||
}
|
||||
assert_ne!(joined, tiled, "fixture must expose a seam at scale{factor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_text_keeps_exact_square_pixel_blocks_at_every_scale() {
|
||||
let mut bytes = vec![0; 772];
|
||||
bytes[..4].copy_from_slice(&2_i32.to_le_bytes());
|
||||
bytes[516 + b'A' as usize] = 3;
|
||||
bytes.extend([1, 0, 1, 0, 1, 0]);
|
||||
let font = BitmapFont::decode(&bytes).unwrap();
|
||||
assert_eq!(font.height(), 2);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut canvas = Canvas::new(scale);
|
||||
canvas.clear(37);
|
||||
font.text(&mut canvas, 10, 20, "A", 100);
|
||||
for y in 0..2 * scale {
|
||||
for x in 0..3 * scale {
|
||||
let expected = if [1, 0, 1, 0, 1, 0][y / scale * 3 + x / scale] == 0 {
|
||||
37
|
||||
} else {
|
||||
100
|
||||
};
|
||||
assert_eq!(
|
||||
canvas.image.pixels[(20 * scale + y) * canvas.image.width + 10 * scale + x],
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
canvas
|
||||
.image
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|pixel| **pixel == 100)
|
||||
.count(),
|
||||
3 * scale * scale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_shadow_masks_clip_without_shading_transparent_pixels() {
|
||||
let mask = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels: vec![
|
||||
TRANSPARENT,
|
||||
1,
|
||||
TRANSPARENT,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
TRANSPARENT,
|
||||
1,
|
||||
TRANSPARENT,
|
||||
],
|
||||
};
|
||||
let mut canvas = Canvas::new(2);
|
||||
canvas.clear(10);
|
||||
let mut lookup = std::array::from_fn(|index| index as u8);
|
||||
lookup[10] = 11;
|
||||
let y = canvas.image.height as i32 - 2;
|
||||
canvas.shade(&mask, -1, y, &lookup);
|
||||
for yy in 0..canvas.image.height {
|
||||
for x in 0..canvas.image.width {
|
||||
let source_x = x + 1;
|
||||
let source_y = yy as i32 - y;
|
||||
let affected = source_x < 3
|
||||
&& (0..3).contains(&source_y)
|
||||
&& mask.pixels[source_y as usize * 3 + source_x] != TRANSPARENT;
|
||||
assert_eq!(
|
||||
canvas.image.pixels[yy * canvas.image.width + x],
|
||||
if affected { 11 } else { 10 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vga_light_tables_clamp_six_bit_channels_and_choose_last_equal_match() {
|
||||
let palette = std::array::from_fn(|index| [(index % 64) as u8; 3]);
|
||||
let identity = legacy_light_table(&palette, 0);
|
||||
let dark = legacy_light_table(&palette, -9);
|
||||
let light = legacy_light_table(&palette, 9);
|
||||
for index in 0..256 {
|
||||
assert_eq!(identity[index], 192 + (index % 64) as u8);
|
||||
assert_eq!(dark[index], 192 + (index % 64).saturating_sub(9) as u8);
|
||||
assert_eq!(light[index], 192 + ((index % 64) + 9).min(63) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vga_remap_uses_manhattan_rgb_distance_instead_of_squared_distance() {
|
||||
let mut palette = [[63; 3]; 256];
|
||||
palette[0] = [20; 3];
|
||||
palette[1] = [14, 10, 10]; // Manhattan 4, squared distance 16.
|
||||
palette[2] = [12, 12, 11]; // Manhattan 5, squared distance 9.
|
||||
assert_eq!(legacy_light_table(&palette, -10)[0], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ground_shadows_keep_native_mask_offsets_and_clip_at_every_scale() {
|
||||
let mask = pattern(7, 9, 31);
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for (x, y) in [(3, -4), (-2, 40), (320, 190), (100, 197), (-20, -30)] {
|
||||
let mut native = Canvas::new(1);
|
||||
native.clear(10);
|
||||
// Reference inverse mask coordinates include SHADOW_GAdd's (-3,+4).
|
||||
for yy in 0..200 {
|
||||
for xx in 0..320 {
|
||||
let sx = xx as i32 - x + 3;
|
||||
let sy = yy as i32 - y - 4;
|
||||
if (0..mask.width as i32).contains(&sx)
|
||||
&& (0..mask.height as i32).contains(&sy)
|
||||
&& mask.pixels[sy as usize * mask.width + sx as usize] != TRANSPARENT
|
||||
{
|
||||
native.image.pixels[yy * 320 + xx] = 11;
|
||||
}
|
||||
}
|
||||
}
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_ground_shadow(&mut actual, &mask, x as f32, y as f32, &lookup);
|
||||
assert_eq!(actual.image, nearest(&native.image, scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reference SHADOW_Draw at native integer coordinates. Find mask runs from
|
||||
// occupied coordinates, project their endpoints, then expand the final native
|
||||
// raster. This keeps the oracle independent of high-resolution span drawing.
|
||||
fn native_air_shadow(mask: &Sprite, x: i32, y: i32, lookup: &[u8; 256]) -> Sprite {
|
||||
let project = |value: i32, center: i32| {
|
||||
let fixed = ((value - center) as i64 * 409_600) / 280;
|
||||
(fixed >> 11) as i32 + center
|
||||
};
|
||||
let mut result = Sprite {
|
||||
width: 320,
|
||||
height: 200,
|
||||
pixels: vec![10; 320 * 200],
|
||||
};
|
||||
let mut seen_rows = BTreeSet::new();
|
||||
for row in 0..mask.height {
|
||||
let occupied: Vec<_> = (0..mask.width)
|
||||
.filter(|&column| mask.pixels[row * mask.width + column] != TRANSPARENT)
|
||||
.collect();
|
||||
if occupied.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let yy = project(y + 20 + row as i32, 100);
|
||||
if !seen_rows.insert(yy) || !(0..200).contains(&yy) {
|
||||
continue;
|
||||
}
|
||||
let mut starts = vec![0];
|
||||
for index in 1..occupied.len() {
|
||||
if occupied[index] != occupied[index - 1] + 1 {
|
||||
starts.push(index);
|
||||
}
|
||||
}
|
||||
starts.push(occupied.len());
|
||||
for endpoints in starts.windows(2) {
|
||||
let start = project(x - 10 + occupied[endpoints[0]] as i32, 160);
|
||||
let end = project(x - 10 + occupied[endpoints[1] - 1] as i32, 160);
|
||||
for xx in start.max(0)..=end.min(319) {
|
||||
let pixel = &mut result.pixels[yy as usize * 320 + xx as usize];
|
||||
*pixel = lookup[*pixel as usize] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_shadows_project_signed_spans_and_suppress_collapsed_rows() {
|
||||
let mask = Sprite {
|
||||
width: 7,
|
||||
height: 7,
|
||||
pixels: vec![1; 49],
|
||||
};
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_air_shadow(&mut actual, &mask, 170.0, 80.0, &lookup);
|
||||
assert_eq!(
|
||||
actual.image,
|
||||
nearest(&native_air_shadow(&mask, 170, 80, &lookup), scale)
|
||||
);
|
||||
assert_eq!(
|
||||
actual.image.pixels.iter().filter(|&&p| p == 11).count(),
|
||||
25 * scale * scale
|
||||
);
|
||||
assert!(
|
||||
!actual.image.pixels.contains(&12),
|
||||
"collapsed rows must not shade twice"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.image.pixels[100 * scale * actual.image.width + 160 * scale],
|
||||
11
|
||||
);
|
||||
assert_eq!(
|
||||
actual.image.pixels[105 * scale * actual.image.width + 160 * scale],
|
||||
10
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_shadows_preserve_sparse_mask_spans_and_clip_projected_edges() {
|
||||
let mask = pattern(21, 17, 76);
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for (x, y) in [
|
||||
(12, -48),
|
||||
(-55, 30),
|
||||
(389, 198),
|
||||
(165, 64),
|
||||
(303, 223),
|
||||
(-80, -70),
|
||||
] {
|
||||
let expected = native_air_shadow(&mask, x, y, &lookup);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_air_shadow(&mut actual, &mask, x as f32, y as f32, &lookup);
|
||||
assert_eq!(
|
||||
actual.image,
|
||||
nearest(&expected, scale),
|
||||
"air position ({x},{y}) at{scale}x"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolated_shadows_translate_masks_without_changing_coverage_or_transparency() {
|
||||
let mask = Sprite {
|
||||
width: 7,
|
||||
height: 7,
|
||||
pixels: vec![1; 49],
|
||||
};
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
let mut integer = Canvas::new(4);
|
||||
integer.clear(10);
|
||||
shade_air_shadow(&mut integer, &mask, 170.0, 80.0, &lookup);
|
||||
let mut half = Canvas::new(4);
|
||||
half.clear(10);
|
||||
shade_air_shadow(&mut half, &mask, 170.5, 80.5, &lookup);
|
||||
assert_eq!(half.image.pixels.iter().filter(|&&p| p == 11).count(), 400);
|
||||
for y in 100 * 4..105 * 4 {
|
||||
for x in 160 * 4..165 * 4 {
|
||||
assert_eq!(
|
||||
integer.image.pixels[y * integer.image.width + x],
|
||||
half.image.pixels[(y + 1) * half.image.width + x + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut empty = Canvas::new(4);
|
||||
empty.clear(TRANSPARENT);
|
||||
shade_air_shadow(&mut empty, &mask, 170.0, 80.0, &lookup);
|
||||
assert!(empty.image.pixels.iter().all(|&p| p == TRANSPARENT));
|
||||
shade_ground_shadow(&mut empty, &mask, 10.0, 10.0, &lookup);
|
||||
assert!(empty.image.pixels.iter().all(|&p| p == TRANSPARENT));
|
||||
}
|
||||
|
||||
fn png(sprite: &Sprite, palette: &[[u8; 3]; 256], path: &Path) {
|
||||
let mut image = image::RgbaImage::new(sprite.width as u32, sprite.height as u32);
|
||||
for (index, &value) in sprite.pixels.iter().enumerate() {
|
||||
let color = if value == TRANSPARENT {
|
||||
[0, 0, 0, 0]
|
||||
} else {
|
||||
let [r, g, b] = palette[value as usize];
|
||||
[r, g, b, 255]
|
||||
};
|
||||
image.put_pixel(
|
||||
(index % sprite.width) as u32,
|
||||
(index / sprite.width) as u32,
|
||||
image::Rgba(color),
|
||||
);
|
||||
}
|
||||
image.save(path).unwrap();
|
||||
}
|
||||
|
||||
fn sheet(sprites: &[Sprite], columns: usize, palette: &[[u8; 3]; 256], path: &Path) {
|
||||
let width = sprites.iter().map(|s| s.width).max().unwrap() + 12;
|
||||
let height = sprites.iter().map(|s| s.height).max().unwrap() + 12;
|
||||
let rows = sprites.len().div_ceil(columns);
|
||||
let mut canvas = Canvas::new(1);
|
||||
canvas.image = Sprite {
|
||||
width: columns * width,
|
||||
height: rows * height,
|
||||
pixels: vec![0; columns * width * rows * height],
|
||||
};
|
||||
for (index, sprite) in sprites.iter().enumerate() {
|
||||
canvas.blit(
|
||||
sprite,
|
||||
((index % columns) * width + 6) as i32,
|
||||
((index / columns) * height + 6) as i32,
|
||||
);
|
||||
}
|
||||
png(&canvas.image, palette, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires local original palette and sprites; emits private shadow contacts"]
|
||||
fn original_shadow_tables_and_banking_masks_match_indexed_projection() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let catalog = Catalog::open(root.join("../data")).unwrap();
|
||||
let palette = catalog.palette().unwrap();
|
||||
let bytes = catalog.named("PALETTE_DAT").unwrap();
|
||||
let vga: [[u8; 3]; 256] = std::array::from_fn(|i| bytes[i * 3..i * 3 + 3].try_into().unwrap());
|
||||
assert_eq!(palette.map(|rgb| rgb.map(|component| component >> 2)), vga);
|
||||
let output = root.join("../.reference/visuals");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
// Independent argmin over (distance, reverse index) preserves GFX_Remap's
|
||||
// last-equal tie rule without the renderer's imperative running minimum.
|
||||
for level in [-9, -6, 9] {
|
||||
let expected: [u8; 256] = std::array::from_fn(|index| {
|
||||
let target = vga[index].map(|component| (component as i32 + level).clamp(0, 63));
|
||||
(0..256)
|
||||
.min_by_key(|&candidate| {
|
||||
let distance: i32 = vga[candidate]
|
||||
.iter()
|
||||
.zip(target)
|
||||
.map(|(&a, b)| (a as i32 - b).abs())
|
||||
.sum();
|
||||
(distance, std::cmp::Reverse(candidate))
|
||||
})
|
||||
.unwrap() as u8
|
||||
});
|
||||
assert_eq!(legacy_light_table(&vga, level), expected);
|
||||
}
|
||||
let air = legacy_light_table(&vga, -6);
|
||||
let ground = legacy_light_table(&vga, -9);
|
||||
let mut banking = vec![];
|
||||
let base = catalog.id("LPLAYER_PIC").unwrap();
|
||||
let terrain_id = Registry::base(&catalog).unwrap().missions[&"base:sector_1_wave_1".into()]
|
||||
.tiles[0]
|
||||
.art
|
||||
.asset
|
||||
.unwrap();
|
||||
let tile = catalog.sprite(terrain_id).unwrap();
|
||||
for frame in 0..7 {
|
||||
let mask = catalog.sprite(base + frame).unwrap();
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_air_shadow(&mut actual, &mask, 144.0, 160.0, &lookup);
|
||||
assert_eq!(
|
||||
actual.image,
|
||||
nearest(&native_air_shadow(&mask, 144, 160, &lookup), scale)
|
||||
);
|
||||
}
|
||||
let mut preview = Canvas::new(4);
|
||||
for y in (0..200).step_by(tile.height) {
|
||||
for x in (0..320).step_by(tile.width) {
|
||||
preview.blit(&upscale(&tile, 4).unwrap(), (x * 4) as i32, (y * 4) as i32);
|
||||
}
|
||||
}
|
||||
shade_air_shadow(&mut preview, &mask, 144.0, 160.0, &air);
|
||||
preview.blit(&upscale(&mask, 4).unwrap(), 144 * 4, 160 * 4);
|
||||
banking.push(crop(&preview.image, 112 * 4, 132 * 4, 80 * 4, 68 * 4));
|
||||
}
|
||||
sheet(
|
||||
&banking,
|
||||
7,
|
||||
&palette,
|
||||
&output.join("player-shadow-banking-4x.png"),
|
||||
);
|
||||
let mask = catalog.sprite(catalog.id("SHIP37G1_PIC").unwrap()).unwrap();
|
||||
let mut preview = Canvas::new(4);
|
||||
for y in (0..200).step_by(tile.height) {
|
||||
for x in (0..320).step_by(tile.width) {
|
||||
preview.blit(&upscale(&tile, 4).unwrap(), (x * 4) as i32, (y * 4) as i32);
|
||||
}
|
||||
}
|
||||
shade_ground_shadow(&mut preview, &mask, 144.0, 80.0, &ground);
|
||||
preview.blit(&upscale(&mask, 4).unwrap(), 144 * 4, 80 * 4);
|
||||
png(
|
||||
&crop(&preview.image, 128 * 4, 64 * 4, 64 * 4, 64 * 4),
|
||||
&palette,
|
||||
&output.join("ground-shadow-4x.png"),
|
||||
);
|
||||
fs::write(
|
||||
output.join("shadow-tables.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"ground_offset":[-3,4],"ground_level":-9,"air_offset":[-10,20],"air_level":-6,
|
||||
"air_view":[160,100,1000],"air_depth":1280,"distance":200,
|
||||
"ground_table":ground.to_vec(),"air_table":air.to_vec()
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires local original catalog; emits private .reference/visuals contact sheets"]
|
||||
fn original_sequences_preserve_canvases_palette_origins_and_terrain_context() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let catalog = Catalog::open(root.join("../data")).unwrap();
|
||||
let palette = catalog.palette().unwrap();
|
||||
let output = root.join("../.reference/visuals");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
let sequences = [
|
||||
("LPLAYER_PIC", 7),
|
||||
("DPLAYER_PIC", 7),
|
||||
("FPLAYER_PIC", 7),
|
||||
("GEXPLO_BLK", 42),
|
||||
("AIRBOOM_PIC", 16),
|
||||
("EXPLO2_BLK", 13),
|
||||
("NMSHOT_BLK", 4),
|
||||
("GUNSTR_BLK", 4),
|
||||
("SHIP22G1_PIC", 6),
|
||||
("SHIP08G1_PIC", 4),
|
||||
];
|
||||
let mut report = vec![];
|
||||
let mut runtime_cache = SpriteCache::new(None);
|
||||
for (name, count) in sequences {
|
||||
let base = catalog.id(name).unwrap();
|
||||
let first = catalog.sprite(base).unwrap();
|
||||
let mut original = vec![];
|
||||
let mut filtered = vec![];
|
||||
let mut runtime = vec![];
|
||||
let mut metadata = vec![];
|
||||
for frame in 0..count {
|
||||
let sprite = catalog.sprite(base + frame).unwrap();
|
||||
assert_eq!(
|
||||
(sprite.width, sprite.height),
|
||||
(first.width, first.height),
|
||||
"{name} frame{frame} shifts animation canvas"
|
||||
);
|
||||
let labels: BTreeSet<_> = sprite.pixels.iter().copied().collect();
|
||||
let mut bounds = vec![];
|
||||
for factor in [1, 2, 3, 4] {
|
||||
let scaled = upscale(&sprite, factor).unwrap();
|
||||
assert_eq!(
|
||||
scaled,
|
||||
oracle(&sprite, factor),
|
||||
"{name} frame{frame} scale{factor}"
|
||||
);
|
||||
assert!(scaled.pixels.iter().all(|value| labels.contains(value)));
|
||||
assert_eq!(
|
||||
(scaled.width, scaled.height),
|
||||
(sprite.width * factor, sprite.height * factor)
|
||||
);
|
||||
let use_nearest = prefers_nearest(base + frame);
|
||||
let presented = runtime_cache.get(&sprite, factor, use_nearest).unwrap();
|
||||
if use_nearest {
|
||||
assert_eq!(*presented, nearest(&sprite, factor));
|
||||
assert_eq!(
|
||||
presented
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|pixel| **pixel != TRANSPARENT)
|
||||
.count(),
|
||||
sprite
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|pixel| **pixel != TRANSPARENT)
|
||||
.count()
|
||||
* factor
|
||||
* factor,
|
||||
"{name} frame{frame} loses opacity under nearest fallback"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(*presented, scaled);
|
||||
}
|
||||
if factor == 4 {
|
||||
runtime.push(presented.clone());
|
||||
}
|
||||
// C++ centers registered animations using the full frame canvas,
|
||||
// never its changing opaque bounds. Keep that anchor at every scale.
|
||||
let anchor = (sprite.width as i32 / 2, sprite.height as i32 / 2);
|
||||
let mut canvas = Canvas::new(factor);
|
||||
canvas.clear(37);
|
||||
canvas.blit(
|
||||
&scaled,
|
||||
(160 - anchor.0) * factor as i32,
|
||||
(100 - anchor.1) * factor as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
(160 - anchor.0) * factor as i32 + anchor.0 * factor as i32,
|
||||
160 * factor as i32
|
||||
);
|
||||
let mut minx = scaled.width;
|
||||
let mut miny = scaled.height;
|
||||
let mut maxx = 0;
|
||||
let mut maxy = 0;
|
||||
for (index, &pixel) in scaled.pixels.iter().enumerate() {
|
||||
if pixel != TRANSPARENT {
|
||||
minx = minx.min(index % scaled.width);
|
||||
miny = miny.min(index / scaled.width);
|
||||
maxx = maxx.max(index % scaled.width + 1);
|
||||
maxy = maxy.max(index / scaled.width + 1);
|
||||
}
|
||||
}
|
||||
bounds.push(serde_json::json!({"factor":factor,"bounds":[minx,miny,maxx,maxy],
|
||||
"opaque_pixels":scaled.pixels.iter().filter(|pixel|**pixel!=TRANSPARENT).count()}));
|
||||
if factor == 4 {
|
||||
filtered.push(scaled);
|
||||
}
|
||||
}
|
||||
original.push(nearest(&sprite, 4));
|
||||
metadata.push(serde_json::json!({"frame":frame,"asset":format!("{:05x}",base+frame),"logical_canvas":[sprite.width,sprite.height],"center_anchor":[sprite.width/2,sprite.height/2],"nearest_fallback":prefers_nearest(base+frame),"bounds":bounds}));
|
||||
}
|
||||
sheet(
|
||||
&original,
|
||||
7.min(count as usize),
|
||||
&palette,
|
||||
&output.join(format!("{name}-nearest4.png")),
|
||||
);
|
||||
sheet(
|
||||
&filtered,
|
||||
7.min(count as usize),
|
||||
&palette,
|
||||
&output.join(format!("{name}-scale4.png")),
|
||||
);
|
||||
sheet(
|
||||
&runtime,
|
||||
7.min(count as usize),
|
||||
&palette,
|
||||
&output.join(format!("{name}-runtime4.png")),
|
||||
);
|
||||
report.push(serde_json::json!({"name":name,"frames":metadata}));
|
||||
}
|
||||
let registry = Registry::base(&catalog).unwrap();
|
||||
let mission = ®istry.missions[&"base:sector_1_wave_1".into()];
|
||||
let columns = mission.columns as usize;
|
||||
let rows = mission.layout.len() / columns;
|
||||
let selected = (0..rows - 2)
|
||||
.find(|row| {
|
||||
let labels: BTreeSet<_> = (0..3)
|
||||
.flat_map(|y| (0..3).map(move |x| mission.layout[(row + y) * columns + x + 3]))
|
||||
.collect();
|
||||
labels.len() > 2
|
||||
})
|
||||
.unwrap();
|
||||
let mut joined = Canvas::new(1);
|
||||
joined.image = Sprite {
|
||||
width: 96,
|
||||
height: 96,
|
||||
pixels: vec![0; 96 * 96],
|
||||
};
|
||||
let mut tiles = vec![];
|
||||
for row in 0..3 {
|
||||
for column in 0..3 {
|
||||
let index = mission.layout[(selected + row) * columns + column + 3];
|
||||
let sprite = catalog
|
||||
.sprite(mission.tiles[index].art.asset.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!((sprite.width, sprite.height), (32, 32));
|
||||
joined.blit(&sprite, (column * 32) as i32, (row * 32) as i32);
|
||||
tiles.push(sprite);
|
||||
}
|
||||
}
|
||||
let mut seam_counts = vec![];
|
||||
for factor in [2, 3, 4] {
|
||||
let filtered = upscale(&joined.image, factor).unwrap();
|
||||
assert_eq!(filtered, oracle(&joined.image, factor));
|
||||
let gutter = crop(&joined.image, 30, 30, 36, 36);
|
||||
assert_eq!(
|
||||
crop(
|
||||
&filtered,
|
||||
32 * factor,
|
||||
32 * factor,
|
||||
32 * factor,
|
||||
32 * factor
|
||||
),
|
||||
crop(
|
||||
&upscale(&gutter, factor).unwrap(),
|
||||
2 * factor,
|
||||
2 * factor,
|
||||
32 * factor,
|
||||
32 * factor
|
||||
)
|
||||
);
|
||||
let mut tiled = Canvas::new(1);
|
||||
tiled.image = Sprite::blank(96 * factor, 96 * factor);
|
||||
for (index, tile) in tiles.iter().enumerate() {
|
||||
tiled.blit(
|
||||
&upscale(tile, factor).unwrap(),
|
||||
((index % 3) * 32 * factor) as i32,
|
||||
((index / 3) * 32 * factor) as i32,
|
||||
);
|
||||
}
|
||||
let differences = filtered
|
||||
.pixels
|
||||
.iter()
|
||||
.zip(&tiled.image.pixels)
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
seam_counts
|
||||
.push(serde_json::json!({"factor":factor,"independent_tile_differences":differences}));
|
||||
png(
|
||||
&filtered,
|
||||
&palette,
|
||||
&output.join(format!("terrain-joined-{factor}x.png")),
|
||||
);
|
||||
png(
|
||||
&tiled.image,
|
||||
&palette,
|
||||
&output.join(format!("terrain-independent-{factor}x.png")),
|
||||
);
|
||||
}
|
||||
fs::write(output.join("report.json"),serde_json::to_vec_pretty(&serde_json::json!({"sequences":report,"terrain_source_row":selected,"terrain_differences":seam_counts})).unwrap()).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Local balance observations against original weapons in the real registry.
|
||||
//! Targets are fixtures; no original artwork or data is required by CI.
|
||||
use raptor_replica::{
|
||||
assets::Catalog,
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const COMBAT_MODS: [&str; 6] = [
|
||||
"pulse_cannon",
|
||||
"seeker_swarm",
|
||||
"ion_lance",
|
||||
"iron_coast_scout",
|
||||
"iron_coast_turret",
|
||||
"iron_coast_boss",
|
||||
];
|
||||
const TICKS: usize = 700;
|
||||
const HEALTH: i32 = 1_000_000;
|
||||
const WEAPONS: [&str; 15] = [
|
||||
"base:forward_guns",
|
||||
"base:plasma_guns",
|
||||
"base:micro_missile",
|
||||
"base:air_missile",
|
||||
"base:grd_missile",
|
||||
"base:dumb_missile",
|
||||
"base:mini_gun",
|
||||
"base:missile_pods",
|
||||
"base:turret",
|
||||
"base:pulse_cannon",
|
||||
"base:death_ray",
|
||||
"base:forward_laser",
|
||||
"pulse_cannon:equipment",
|
||||
"seeker_swarm:equipment",
|
||||
"ion_lance:equipment",
|
||||
];
|
||||
fn fixture(active: &mut ActiveMods) {
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"missions":[{"id":"balance:range","name":"Weapon range","duration":10000,"auto_complete":false}],
|
||||
"enemies":[
|
||||
{"id":"balance:air","name":"Air target","health":HEALTH,"speed":0},
|
||||
{"id":"balance:ground","name":"Ground target","health":HEALTH,"speed":0,"ground":true,"script":"ground.rhai"}
|
||||
]
|
||||
})).unwrap();
|
||||
active.registry.apply(pack, "balance").unwrap();
|
||||
active
|
||||
.scripts
|
||||
.add(
|
||||
"balance",
|
||||
"ground.rhai",
|
||||
r#"
|
||||
// Ground actors normally scroll one logical pixel each tick. Reset
|
||||
// this range target after collision processing; weapons are unmodified.
|
||||
fn on_tick(event,state,random) {
|
||||
#{commands:[#{op:"move",entity:event.entity,x:event.x,y:event.y-1}],state:state}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
fn observe(
|
||||
active: &mut ActiveMods,
|
||||
item: &str,
|
||||
scene: &str,
|
||||
targets: &[(&str, i32, i32)],
|
||||
with_forward_guns: bool,
|
||||
) -> Value {
|
||||
active
|
||||
.scripts
|
||||
.restore((Default::default(), Default::default()));
|
||||
let id = ContentId::from(item);
|
||||
let mut inventory = [(id.clone(), 1), ("base:energy".into(), 100)]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
if with_forward_guns {
|
||||
inventory.insert("base:forward_guns".into(), 1);
|
||||
}
|
||||
let pilot = Pilot {
|
||||
mission: "balance:range".into(),
|
||||
difficulty: 2,
|
||||
inventory,
|
||||
selected: active.registry.items[&id].special.then_some(id.clone()),
|
||||
..Pilot::default()
|
||||
};
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
let entities: Vec<_> = targets
|
||||
.iter()
|
||||
.map(|(kind, x, y)| {
|
||||
world
|
||||
.spawn(
|
||||
&format!("balance:{kind}").as_str().into(),
|
||||
*x,
|
||||
*y,
|
||||
&active.registry,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
for _ in 0..TICKS {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
assert_eq!(
|
||||
world.player.shield, 100,
|
||||
"range targets should not harm the pilot"
|
||||
);
|
||||
let damage: Vec<_> = entities
|
||||
.iter()
|
||||
.map(|entity| {
|
||||
let target = world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == *entity)
|
||||
.expect("range target retired");
|
||||
assert!(target.health > 0);
|
||||
assert_eq!(
|
||||
target.y,
|
||||
targets[entities.iter().position(|e| e == entity).unwrap()].2
|
||||
);
|
||||
HEALTH - target.health
|
||||
})
|
||||
.collect();
|
||||
json!({"item":item,"scene":scene,"price":active.registry.items[&id].price,
|
||||
"ticks":TICKS,"seconds":TICKS as f64 * 3.0/70.0,"with_forward_guns":with_forward_guns,
|
||||
"damage":damage,"damage_per_second":damage.iter().map(|d| *d as f64/(TICKS as f64*3.0/70.0)).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original untracked catalog; reports controlled stationary-target balance, not campaign difficulty"]
|
||||
fn original_and_mod_weapons_report_controlled_damage_locally() {
|
||||
let package = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let root = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| package.join("../data"));
|
||||
let catalog = Catalog::open(root).unwrap();
|
||||
let base = Registry::base(&catalog).unwrap();
|
||||
let discovery = modding::discover(&package.join("mods"));
|
||||
let enabled = COMBAT_MODS.map(String::from);
|
||||
let mut active = ActiveMods::build(&base, &discovery, &enabled, Some(&catalog)).unwrap();
|
||||
fixture(&mut active);
|
||||
let mut rows = Vec::new();
|
||||
for item in WEAPONS {
|
||||
for (scene, targets) in [
|
||||
("aligned_air", vec![("air", 144, 80)]),
|
||||
("aligned_ground", vec![("ground", 144, 80)]),
|
||||
("off_axis_air", vec![("air", 220, 80)]),
|
||||
("off_axis_ground", vec![("ground", 220, 80)]),
|
||||
("column", vec![("air", 144, 96), ("ground", 144, 40)]),
|
||||
(
|
||||
"cluster",
|
||||
vec![("air", 144, 80), ("ground", 144, 80), ("air", 154, 80)],
|
||||
),
|
||||
] {
|
||||
let result = observe(&mut active, item, scene, &targets, false);
|
||||
println!("{result}");
|
||||
rows.push(result);
|
||||
}
|
||||
}
|
||||
for item in [
|
||||
"base:plasma_guns",
|
||||
"base:micro_missile",
|
||||
"pulse_cannon:equipment",
|
||||
] {
|
||||
rows.push(observe(
|
||||
&mut active,
|
||||
item,
|
||||
"aligned_air",
|
||||
&[("air", 144, 80)],
|
||||
true,
|
||||
));
|
||||
rows.push(observe(
|
||||
&mut active,
|
||||
item,
|
||||
"aligned_ground",
|
||||
&[("ground", 144, 80)],
|
||||
true,
|
||||
));
|
||||
}
|
||||
let report = json!({"difficulty":2,"rate":"70/3 Hz","target_size":[32,32],
|
||||
"target_health":HEALTH,"fire_steps":TICKS,"rows":rows});
|
||||
if let Some(path) = std::env::var_os("RAPTOR_BALANCE_REPORT") {
|
||||
fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap();
|
||||
}
|
||||
let row = |item: &str, scene: &str| {
|
||||
report["rows"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r["item"] == item && r["scene"] == scene && r["with_forward_guns"] == false)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(
|
||||
row("seeker_swarm:equipment", "off_axis_air")["damage"][0]
|
||||
.as_i64()
|
||||
.unwrap()
|
||||
> 0
|
||||
);
|
||||
assert!(
|
||||
row("seeker_swarm:equipment", "off_axis_ground")["damage"][0]
|
||||
.as_i64()
|
||||
.unwrap()
|
||||
> 0
|
||||
);
|
||||
let column = row("ion_lance:equipment", "column")["damage"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert!(column.iter().all(|d| d.as_i64().unwrap() > 0));
|
||||
assert_eq!(
|
||||
column[0], column[1],
|
||||
"ion beam should damage both column targets equally"
|
||||
);
|
||||
assert_eq!(row("ion_lance:equipment", "off_axis_air")["damage"][0], 0);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! 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("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(),
|
||||
"firing into empty space must not create muzzle explosions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_firing_has_audio_and_projectiles_without_explosions_at_the_muzzle() {
|
||||
for id in PACKAGES {
|
||||
let (_root, mut active) = activate(&[id]);
|
||||
let mut world = world(id, &active.registry);
|
||||
let mut heard_fire = false;
|
||||
let mut saw_projectile = false;
|
||||
for _ in 0..80 {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
heard_fire |= world
|
||||
.mod_sounds
|
||||
.contains(&format!("{id}:fire").as_str().into());
|
||||
saw_projectile |= !world.projectiles.is_empty();
|
||||
assert!(
|
||||
world.effects.is_empty(),
|
||||
"{id} created an explosion while firing into empty space"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
heard_fire && saw_projectile,
|
||||
"{id} lost its firing sound or projectiles"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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