Package RustRaptor and refine controls, menus, saves, sound and mod selection
This commit is contained in:
1 parent
0844f6dffa
commit
e17bc1003e
649 files changed
+8652
-2677
No files matched your search
-390
@@ -1,390 +0,0 @@
|
||||
use raptor_replica::audio::{DigitalSample, Mixer, MusScore};
|
||||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
fn instruments(double: bool) -> Vec<u8> {
|
||||
let mut bytes = b"#OPL_II#".to_vec();
|
||||
for n in 0..175 {
|
||||
let mut instrument = [0u8; 36];
|
||||
instrument[0] = if n == 1 {
|
||||
1
|
||||
} else if double {
|
||||
4
|
||||
} else {
|
||||
0
|
||||
};
|
||||
instrument[2] = 128;
|
||||
instrument[3] = 48;
|
||||
for part in 0..2 {
|
||||
let start = 4 + part * 16;
|
||||
instrument[start..start + 6].copy_from_slice(&[0x21, 0xf2, 0x52, 0, 0, 32]);
|
||||
instrument[start + 6] = 0;
|
||||
instrument[start + 7..start + 13].copy_from_slice(&[0x21, 0xf2, 0x52, 0, 0, 0]);
|
||||
}
|
||||
bytes.extend(instrument);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
fn mus(body: &[u8], channels: u16) -> Vec<u8> {
|
||||
let mut bytes = b"MUS\x1a".to_vec();
|
||||
bytes.extend((body.len() as u16).to_le_bytes());
|
||||
bytes.extend(16u16.to_le_bytes());
|
||||
bytes.extend(channels.to_le_bytes());
|
||||
bytes.extend([0; 6]);
|
||||
bytes.extend(body);
|
||||
bytes
|
||||
}
|
||||
fn score() -> Vec<u8> {
|
||||
mus(
|
||||
&[
|
||||
0x40, 0, 0, 0x90, 188, 100, 10, 0xa0, 144, 5, 0xc0, 3, 90, 5, 0x80, 60, 10, 0x60,
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
fn sample(data: Vec<u8>) -> DigitalSample {
|
||||
DigitalSample { rate: 11025, data }
|
||||
}
|
||||
fn patch(data: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = vec![3, 0];
|
||||
bytes.extend(11025u16.to_le_bytes());
|
||||
bytes.extend(((data.len() + 32) as u32).to_le_bytes());
|
||||
bytes.extend([0xff; 16]);
|
||||
bytes.extend(data);
|
||||
bytes.extend([0; 16]);
|
||||
bytes
|
||||
}
|
||||
fn mixer() -> Mixer {
|
||||
Mixer::new(44100, &instruments(false)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_decoder_excludes_both_padding_regions() {
|
||||
let decoded = DigitalSample::decode(&patch(&[0, 64, 128, 192, 255])).unwrap();
|
||||
assert_eq!(decoded.rate, 11025);
|
||||
assert_eq!(decoded.data, vec![0, 64, 128, 192, 255]);
|
||||
assert!(DigitalSample::decode(&patch(&[])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_events_start_on_the_first_seventy_hertz_service_tick() {
|
||||
let mut mixer = mixer();
|
||||
mixer.music_volume = 127;
|
||||
mixer.play(MusScore::decode(&score()).unwrap(), false);
|
||||
let mut prefix = vec![1; 630 * 2];
|
||||
mixer.mix(&mut prefix);
|
||||
assert!(prefix.iter().all(|n| *n == 0));
|
||||
let mut following = vec![0; 3000 * 2];
|
||||
mixer.mix(&mut following);
|
||||
assert!(following.iter().any(|n| *n != 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fm_and_digital_output_do_not_depend_on_audio_callback_size() {
|
||||
let table = instruments(true);
|
||||
let mut whole = Mixer::new(44117, &table).unwrap();
|
||||
let mut chunked = Mixer::new(44117, &table).unwrap();
|
||||
for m in [&mut whole, &mut chunked] {
|
||||
m.play_with_fade(MusScore::decode(&score()).unwrap(), true, true);
|
||||
m.sample(
|
||||
sample((0..255).cycle().take(5000).collect()),
|
||||
37,
|
||||
192,
|
||||
100,
|
||||
1,
|
||||
);
|
||||
}
|
||||
let mut expected = vec![0; 44117 * 2];
|
||||
whole.mix(&mut expected);
|
||||
let mut actual = vec![0; expected.len()];
|
||||
let mut frame = 0;
|
||||
for count in [1usize, 3, 17, 512, 127].into_iter().cycle() {
|
||||
let count = count.min(actual.len() / 2 - frame);
|
||||
chunked.mix(&mut actual[frame * 2..(frame + count) * 2]);
|
||||
frame += count;
|
||||
if frame == actual.len() / 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_mixing_uses_the_original_11025_hertz_sample_hold() {
|
||||
let mut mixer = mixer();
|
||||
mixer.fx_volume = 127;
|
||||
mixer.sample(sample(vec![255, 255]), 128, 128, 127, 1);
|
||||
let mut out = [0; 24];
|
||||
mixer.mix(&mut out);
|
||||
assert_eq!(&out[..6], &[0; 6]);
|
||||
assert!(out[6] > 0 && out[7] > 0);
|
||||
assert_eq!(&out[6..8], &out[8..10]);
|
||||
assert_eq!(&out[6..8], &out[10..12]);
|
||||
assert_eq!(&out[6..8], &out[12..14]);
|
||||
assert_eq!(&out[22..24], &[0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pitch_zero_is_a_valid_quarter_speed_pitch() {
|
||||
let mut normal = mixer();
|
||||
let mut slow = mixer();
|
||||
normal.fx_volume = 127;
|
||||
slow.fx_volume = 127;
|
||||
normal.sample(sample(vec![255; 8]), 128, 128, 127, 1);
|
||||
slow.sample(sample(vec![255; 8]), 128, 0, 127, 1);
|
||||
let mut fast = [0; 100];
|
||||
let mut quarter = [0; 100];
|
||||
normal.mix(&mut fast);
|
||||
slow.mix(&mut quarter);
|
||||
assert!(fast[80..].iter().all(|v| *v == 0));
|
||||
assert!(quarter[80..].iter().any(|v| *v > 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_numeric_digital_priority_wins() {
|
||||
let mut mixer = mixer();
|
||||
mixer.fx_volume = 127;
|
||||
mixer.set_fx_channels(1);
|
||||
mixer.sample(sample(vec![255; 1000]), 128, 128, 127, 1);
|
||||
mixer.sample(sample(vec![0; 1000]), 128, 128, 127, 8);
|
||||
let mut protected = [0; 64];
|
||||
mixer.mix(&mut protected);
|
||||
assert!(protected[8..].iter().all(|v| *v > 0));
|
||||
mixer.sample(sample(vec![0; 1000]), 128, 128, 127, 0);
|
||||
let mut replaced = [0; 64];
|
||||
mixer.mix(&mut replaced);
|
||||
assert!(replaced[8..].iter().all(|v| *v < 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_voices_clip_together_before_being_added_to_fm() {
|
||||
let mut mixer = mixer();
|
||||
mixer.fx_volume = 127;
|
||||
mixer.set_fx_channels(8);
|
||||
for _ in 0..8 {
|
||||
mixer.sample(sample(vec![255; 100]), 128, 128, 127, 1);
|
||||
}
|
||||
let mut out = [0; 64];
|
||||
mixer.mix(&mut out);
|
||||
assert_eq!(out[20], 127 * 128);
|
||||
assert_eq!(out[21], 127 * 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_scores_controller_values_and_missing_end_events_are_rejected() {
|
||||
for body in [
|
||||
vec![0x90, 188],
|
||||
vec![0x90, 188, 200, 1, 0x60],
|
||||
vec![0xc0, 3, 255, 1, 0x60],
|
||||
vec![0x30, 9, 0x60],
|
||||
vec![0x90, 188, 127, 1],
|
||||
] {
|
||||
assert!(MusScore::decode(&mus(&body, 1)).is_err());
|
||||
}
|
||||
assert!(MusScore::decode(&mus(&[0x60], 16)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_duration_loop_and_an_odd_output_slice_terminate_cleanly() {
|
||||
let mut mixer = mixer();
|
||||
mixer.play(MusScore::decode(&mus(&[0x60], 1)).unwrap(), true);
|
||||
let mut out = [99; 2001];
|
||||
mixer.mix(&mut out);
|
||||
assert_eq!(out[2000], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the original untracked catalog"]
|
||||
fn original_music_and_sound_assets_decode_locally() {
|
||||
use raptor_replica::assets::Catalog;
|
||||
let root = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("data"));
|
||||
let catalog = Catalog::open(root).unwrap();
|
||||
let mut music = 0;
|
||||
let mut sounds = 0;
|
||||
for entry in catalog.entries.values() {
|
||||
let bytes = catalog.read(entry.id).unwrap();
|
||||
if bytes.starts_with(b"MUS\x1a") {
|
||||
MusScore::decode(&bytes).unwrap();
|
||||
music += 1;
|
||||
}
|
||||
if bytes.get(..4) == Some(&[3, 0, 0x11, 0x2b]) {
|
||||
DigitalSample::decode(&bytes).unwrap();
|
||||
sounds += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(music, 16);
|
||||
assert_eq!(sounds, 26, "digital samples");
|
||||
Mixer::new(44100, &catalog.named("GENMIDI_OP2").unwrap()).unwrap();
|
||||
}
|
||||
|
||||
const NATIVE_REFERENCE: &str = r#"
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include "SDL.h"
|
||||
#include "musapi.h"
|
||||
#include "i_oplmusic.h"
|
||||
#include "dspapi.h"
|
||||
#include "cards.h"
|
||||
int fx_freq=44100, gsshack=0, gssrate=0, sys_midi=0;
|
||||
musdevice_t mus_device_alsa={}, mus_device_tsf={};
|
||||
void SND_Lock(){} void SND_Unlock(){} void GSS_Service(){} void GSS_Poll(){}
|
||||
extern "C" Uint32 SDL_GetTicks(){return 0;}
|
||||
std::vector<char> read(const char *path){std::ifstream in(path,std::ios::binary);return {std::istreambuf_iterator<char>(in),{}};}
|
||||
int main(int argc,char **argv){
|
||||
if(argc!=9)return 2;
|
||||
auto gen=read(argv[1]),score=read(argv[2]),patch=read(argv[3]);
|
||||
int frames=std::atoi(argv[4]),mode=std::atoi(argv[5]),fade=std::atoi(argv[6]),loop=std::atoi(argv[7]);
|
||||
std::vector<int16_t> out(frames*2,0);
|
||||
if(mode!=1){LoadInstrumentTable(gen.data());MUS_Init(M_ADLIB,0);MUS_SetVolume(127);MUS_PlaySong(score.data(),loop,fade);}
|
||||
if(mode!=0){DSP_Init(8,11025);DSP_StartPatch((dsp_t*)patch.data(),37,192,85,1);}
|
||||
if(mode!=1)MUS_Mix(out.data(),frames);
|
||||
if(mode!=0)DSP_Mix(out.data(),frames);
|
||||
FILE *file=fopen(argv[8],"wb");if(!file)return 3;
|
||||
fwrite(out.data(),sizeof(int16_t),out.size(),file);fclose(file);return 0;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires g++ and SDL2 development headers; synthetic assets only"]
|
||||
fn rust_fm_mus_and_digital_pcm_match_the_retained_native_reference() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let binary = compile_reference(temp.path());
|
||||
let patch_data: Vec<_> = (0..255).cycle().take(2000).collect();
|
||||
let patch_path = temp.path().join("patch.bin");
|
||||
fs::write(&patch_path, patch(&patch_data)).unwrap();
|
||||
for (double, mode, fade, looping) in [
|
||||
(false, 0, false, false),
|
||||
(true, 0, false, false),
|
||||
(false, 1, false, false),
|
||||
(true, 2, true, true),
|
||||
] {
|
||||
let bank = instruments(double);
|
||||
let song = score();
|
||||
let frames = 44100;
|
||||
let gen_path = temp.path().join("genmidi.bin");
|
||||
fs::write(&gen_path, &bank).unwrap();
|
||||
let score_path = temp.path().join("score.bin");
|
||||
fs::write(&score_path, &song).unwrap();
|
||||
let output = temp.path().join("reference.pcm");
|
||||
let status = Command::new(&binary)
|
||||
.arg(&gen_path)
|
||||
.arg(&score_path)
|
||||
.arg(&patch_path)
|
||||
.arg(frames.to_string())
|
||||
.arg(mode.to_string())
|
||||
.arg(i32::from(fade).to_string())
|
||||
.arg(i32::from(looping).to_string())
|
||||
.arg(&output)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let expected = fs::read(output).unwrap();
|
||||
let mut mixer = Mixer::new(44100, &bank).unwrap();
|
||||
mixer.music_volume = 127;
|
||||
mixer.fx_volume = 127;
|
||||
mixer.set_fx_channels(8);
|
||||
if mode != 1 {
|
||||
mixer.play_with_fade(MusScore::decode(&song).unwrap(), looping, fade);
|
||||
}
|
||||
if mode != 0 {
|
||||
mixer.sample(sample(patch_data.clone()), 37, 192, 85, 1);
|
||||
}
|
||||
let mut out = vec![0; frames * 2];
|
||||
mixer.mix(&mut out);
|
||||
let actual: Vec<_> = out.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
if let Some(index) = actual
|
||||
.chunks_exact(2)
|
||||
.zip(expected.chunks_exact(2))
|
||||
.position(|(a, b)| a != b)
|
||||
{
|
||||
let want = i16::from_le_bytes(expected[index * 2..index * 2 + 2].try_into().unwrap());
|
||||
panic!(
|
||||
"PCM mismatch double={double} mode={mode} fade={fade} loop={looping}: frame={} channel={} got={} expected={want}",
|
||||
index / 2,
|
||||
index % 2,
|
||||
out[index]
|
||||
);
|
||||
}
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_reference(directory: &Path) -> std::path::PathBuf {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let source = directory.join("reference.cpp");
|
||||
fs::write(&source, NATIVE_REFERENCE).unwrap();
|
||||
let binary = directory.join("reference");
|
||||
let mut compile = Command::new("g++");
|
||||
compile
|
||||
.args(["-std=c++17", "-O2"])
|
||||
.arg("-I")
|
||||
.arg(root.join("raptor/src"));
|
||||
compile.arg("-I").arg("/usr/include/SDL2");
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
compile
|
||||
.arg("-I")
|
||||
.arg(Path::new(&home).join(".local/include/SDL2"));
|
||||
}
|
||||
compile.arg(&source);
|
||||
for path in ["i_oplmusic.cpp", "opl3.cpp", "musapi.cpp", "dspapi.cpp"] {
|
||||
compile.arg(root.join("raptor/src").join(path));
|
||||
}
|
||||
let result = compile.arg("-o").arg(&binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
binary
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original untracked music assets, g++ and SDL2 headers"]
|
||||
fn original_intro_pcm_matches_the_retained_native_reference() {
|
||||
use raptor_replica::assets::Catalog;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("data"));
|
||||
let catalog = Catalog::open(root).unwrap();
|
||||
let bank = catalog.named("GENMIDI_OP2").unwrap();
|
||||
let song = catalog.named("RINTRO_MUS").unwrap();
|
||||
let binary = compile_reference(temp.path());
|
||||
fs::write(temp.path().join("genmidi.bin"), &bank).unwrap();
|
||||
fs::write(temp.path().join("score.bin"), &song).unwrap();
|
||||
fs::write(temp.path().join("patch.bin"), patch(&[128; 33])).unwrap();
|
||||
let frames = 44100 * 4;
|
||||
let output = temp.path().join("reference.pcm");
|
||||
let status = Command::new(binary)
|
||||
.arg(temp.path().join("genmidi.bin"))
|
||||
.arg(temp.path().join("score.bin"))
|
||||
.arg(temp.path().join("patch.bin"))
|
||||
.arg(frames.to_string())
|
||||
.args(["0", "1", "1"])
|
||||
.arg(&output)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let expected = fs::read(output).unwrap();
|
||||
let mut mixer = Mixer::new(44100, &bank).unwrap();
|
||||
mixer.music_volume = 127;
|
||||
mixer.play_with_fade(MusScore::decode(&song).unwrap(), true, true);
|
||||
let mut actual = vec![0i16; frames * 2];
|
||||
mixer.mix(&mut actual);
|
||||
for (index, (got, bytes)) in actual.iter().zip(expected.chunks_exact(2)).enumerate() {
|
||||
let want = i16::from_le_bytes(bytes.try_into().unwrap());
|
||||
assert_eq!(
|
||||
*got,
|
||||
want,
|
||||
"original intro frame {} channel {}",
|
||||
index / 2,
|
||||
index % 2
|
||||
);
|
||||
}
|
||||
assert_eq!(actual.len() * 2, expected.len());
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
-269
@@ -1,269 +0,0 @@
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, Sprite, TRANSPARENT, contained, i16_at, i32_at},
|
||||
audio::{DigitalSample, Mixer, MusScore},
|
||||
content::{ContentPack, Registry},
|
||||
persistence::{Config, Pilot},
|
||||
render::upscale,
|
||||
};
|
||||
|
||||
fn pilot_registry() -> Registry {
|
||||
let missions: Vec<_> = (1..=3).flat_map(|s| (1..=9).map(move |w| serde_json::json!({
|
||||
"id":format!("base:sector_{s}_wave_{w}"),"name":"Synthetic wave","duration":100,"legacy_map":100+s*9+w
|
||||
}))).collect();
|
||||
let pack: ContentPack = serde_json::from_value(serde_json::json!({
|
||||
"items":[
|
||||
{"id":"base:forward_guns","name":"Forward Guns","price":12000,"legacy":0},
|
||||
{"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"legacy":16}
|
||||
],"missions":missions
|
||||
})).unwrap();
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_graduation_and_sector_cycles_preserve_original_progression() {
|
||||
let registry = pilot_registry();
|
||||
let mut pilot = Pilot {
|
||||
difficulty: 0,
|
||||
credits: 900000,
|
||||
mission: "base:sector_2_wave_4".into(),
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:sector_1_wave_1");
|
||||
assert_eq!(pilot.difficulty, 1);
|
||||
assert_eq!(pilot.credits, 10000);
|
||||
assert_eq!(pilot.inventory[&"base:energy".into()], 75);
|
||||
assert!(pilot.legacy_progress.as_ref().unwrap().training_completed);
|
||||
pilot.mission = "base:sector_3_wave_9".into();
|
||||
pilot.difficulty = 2;
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:sector_1_wave_1");
|
||||
assert_eq!(pilot.legacy_progress.as_ref().unwrap().difficulties[2], 3);
|
||||
assert_eq!(pilot.difficulty, 1);
|
||||
pilot.mission = "base:sector_1_wave_2".into();
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
assert_eq!(pilot.mission.0, "base:sector_1_wave_3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_save_import_retains_metadata_without_modifying_source() {
|
||||
fn encrypt(bytes: &[u8]) -> Vec<u8> {
|
||||
let key = b"CASTLE";
|
||||
let mut index = 25 % key.len();
|
||||
let mut previous = key[index];
|
||||
bytes
|
||||
.iter()
|
||||
.map(|clear| {
|
||||
let byte = clear.wrapping_add(key[index]).wrapping_add(previous);
|
||||
previous = byte;
|
||||
index = (index + 1) % key.len();
|
||||
byte
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
let mut header = vec![0u8; 88];
|
||||
header[..9].copy_from_slice(b"Synthetic");
|
||||
header[20..24].copy_from_slice(b"TEST");
|
||||
for (offset, value) in [
|
||||
(32, 65814i32),
|
||||
(36, 98765),
|
||||
(40, -1),
|
||||
(44, 1),
|
||||
(48, 3),
|
||||
(52, 8),
|
||||
(56, 1),
|
||||
(60, 2),
|
||||
(64, 0),
|
||||
(68, 2),
|
||||
(72, 1),
|
||||
(76, 3),
|
||||
(80, 0),
|
||||
(84, 1),
|
||||
] {
|
||||
header[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
let mut body = vec![0u8; 48];
|
||||
for (offset, value) in [(8, 1i32), (12, 0), (32, 71), (36, 16)] {
|
||||
body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
let bytes = [encrypt(&header), encrypt(&body)].concat();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("CHAR0000.FIL");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let registry = pilot_registry();
|
||||
let pilot = Pilot::import_legacy(&path, ®istry).unwrap();
|
||||
assert_eq!(pilot.callsign, "TEST");
|
||||
assert_eq!(pilot.credits, 98765);
|
||||
assert_eq!(pilot.mission.0, "base:sector_2_wave_9");
|
||||
assert_eq!(pilot.inventory[&"base:energy".into()], 71);
|
||||
assert_eq!(pilot.completed.len(), 12);
|
||||
let progress = pilot.legacy_progress.as_ref().unwrap();
|
||||
assert_eq!(progress.portrait, 65814);
|
||||
assert_eq!(progress.difficulties, [0, 2, 1, 3]);
|
||||
assert!(progress.training_completed);
|
||||
let output = dir.path().join("pilot.json");
|
||||
pilot.save(&output).unwrap();
|
||||
assert_eq!(Pilot::load(&output, ®istry, &[]).unwrap().credits, 98765);
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
}
|
||||
|
||||
fn header(kind: i32, width: i32, height: i32) -> Vec<u8> {
|
||||
[kind, 0, 0, width, height]
|
||||
.into_iter()
|
||||
.flat_map(i32::to_le_bytes)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_pictures_keep_zero_opaque_and_spans_keep_empty_pixels_transparent() {
|
||||
let mut raw = header(1, 2, 1);
|
||||
raw.extend([0, 255]);
|
||||
assert_eq!(Sprite::decode(&raw).unwrap().pixels, [0, 255]);
|
||||
let mut span = header(0, 3, 2);
|
||||
span.extend([1i32, 1, 4, 2].into_iter().flat_map(i32::to_le_bytes));
|
||||
span.extend([0, 17]);
|
||||
span.extend([0i32, 0, -1, 0].into_iter().flat_map(i32::to_le_bytes));
|
||||
assert_eq!(
|
||||
Sprite::decode(&span).unwrap().pixels,
|
||||
[TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, 0, 17]
|
||||
);
|
||||
for n in 0..span.len() {
|
||||
assert!(Sprite::decode(&span[..n]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_fields_and_spans_fail_without_panicking() {
|
||||
assert!(i16_at(&[], usize::MAX).is_err());
|
||||
assert!(i32_at(&[], usize::MAX).is_err());
|
||||
let mut span = header(0, 2, 2);
|
||||
span.extend([i32::MAX, 0, 0, 2].into_iter().flat_map(i32::to_le_bytes));
|
||||
span.extend([1, 2]);
|
||||
assert!(Sprite::decode(&span).is_err());
|
||||
assert!(Sprite::decode(&header(1, 4096, 4096)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_kernels_have_published_corner_results() {
|
||||
let input = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels: vec![9, 1, 8, 1, 2, 3, 7, 4, 6],
|
||||
};
|
||||
let two = upscale(&input, 2).unwrap();
|
||||
let four = upscale(&input, 4).unwrap();
|
||||
assert_eq!(
|
||||
[
|
||||
two.pixels[2 * 6 + 2],
|
||||
two.pixels[2 * 6 + 3],
|
||||
two.pixels[3 * 6 + 2],
|
||||
two.pixels[3 * 6 + 3]
|
||||
],
|
||||
[1, 2, 2, 2]
|
||||
);
|
||||
let three = upscale(&input, 3).unwrap();
|
||||
let center: Vec<_> = (3..6)
|
||||
.flat_map(|y| (3..6).map(move |x| y * 9 + x))
|
||||
.map(|n| three.pixels[n])
|
||||
.collect();
|
||||
assert_eq!(center, [1, 1, 2, 1, 2, 2, 2, 2, 2]);
|
||||
assert_eq!(four, upscale(&two, 2).unwrap());
|
||||
assert!(upscale(&input, 5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_paths_cannot_escape_or_follow_an_external_symlink() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
std::fs::write(other.path().join("outside"), b"outside").unwrap();
|
||||
assert!(contained(root.path(), std::path::Path::new("../outside")).is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(other.path().join("outside"), root.path().join("link")).unwrap();
|
||||
assert!(contained(root.path(), std::path::Path::new("link")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_settings_preserves_source_and_uses_sdl_key_names() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("SETUP.INI");
|
||||
let original = b"[Keyboard]\nFire=29\nFireSp=56\nMegaFire=54\n[Video]\nupscaler=3\n";
|
||||
std::fs::write(&path, original).unwrap();
|
||||
let config = Config::import_ini(&path).unwrap();
|
||||
assert_eq!(config.scale, 3);
|
||||
assert_eq!(config.bindings["fire"], "Left Ctrl");
|
||||
assert_eq!(config.bindings["special"], "Left Alt");
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
#[cfg(feature = "desktop")]
|
||||
for key in config.bindings.values() {
|
||||
assert!(
|
||||
sdl2::keyboard::Scancode::from_name(key).is_some(),
|
||||
"unknown SDL key {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Original pixels never enter the test fixtures or version control. Set this
|
||||
// variable only on a machine with the licensed extracted asset catalog.
|
||||
#[test]
|
||||
fn original_data_decoders_and_all_campaigns_when_available() {
|
||||
let Some(data) = std::env::var_os("RAPTOR_TEST_DATA") else {
|
||||
return;
|
||||
};
|
||||
let catalog = Catalog::open(data).unwrap();
|
||||
let registry = Registry::base(&catalog).unwrap();
|
||||
registry.validate().unwrap();
|
||||
assert_eq!(registry.missions.len(), 27);
|
||||
assert_eq!(registry.campaigns.len(), 3);
|
||||
let mut sprites = 0;
|
||||
for entry in catalog.entries.values() {
|
||||
let bytes = catalog.read(entry.id).unwrap();
|
||||
if bytes.len() >= 20
|
||||
&& (i32_at(&bytes, 0).ok() == Some(0)
|
||||
|| (i32_at(&bytes, 0).ok() == Some(1)
|
||||
&& i32_at(&bytes, 4).ok() == Some(1)
|
||||
&& i32_at(&bytes, 8).ok() == Some(0)))
|
||||
&& matches!(i32_at(&bytes, 12).ok(), Some(1..=4096))
|
||||
&& matches!(i32_at(&bytes, 16).ok(), Some(1..=4096))
|
||||
{
|
||||
Sprite::decode(&bytes).unwrap_or_else(|e| panic!("asset {:x}: {e}", entry.id));
|
||||
sprites += 1;
|
||||
}
|
||||
if bytes.starts_with(b"MUS\x1a") {
|
||||
MusScore::decode(&bytes).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
sprites > 1000,
|
||||
"format discovery must include unnamed graphics"
|
||||
);
|
||||
for sector in 0..3 {
|
||||
let pilot = Pilot::demo(sector, ®istry).unwrap();
|
||||
pilot.validate(®istry, &[]).unwrap();
|
||||
let selected = ®istry.items[pilot.selected.as_ref().unwrap()];
|
||||
assert_eq!(selected.legacy, Some([5, 8, 8][sector]));
|
||||
}
|
||||
let mut mixer = Mixer::new(44100, &catalog.named("GENMIDI_OP2").unwrap()).unwrap();
|
||||
mixer.play(
|
||||
MusScore::decode(&catalog.named("RAP1_MUS").unwrap()).unwrap(),
|
||||
false,
|
||||
);
|
||||
let mut output = vec![0i16; 44100 * 2];
|
||||
mixer.mix(&mut output);
|
||||
assert!(
|
||||
output.iter().any(|v| *v != 0),
|
||||
"FM playback must produce sound"
|
||||
);
|
||||
let sample = catalog
|
||||
.entries
|
||||
.values()
|
||||
.find_map(|e| DigitalSample::decode(&catalog.read(e.id).ok()?).ok())
|
||||
.unwrap();
|
||||
mixer.sample(sample, 128, 128, 127, 1);
|
||||
mixer.mix(&mut output);
|
||||
assert!(output.iter().any(|v| *v != 0));
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
//! Enemy package tests use synthetic PNG/WAV fixtures, never original/generated art.
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const ENEMIES: [&str; 3] = ["iron_coast_scout", "iron_coast_turret", "iron_coast_boss"];
|
||||
|
||||
fn base() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"weapons": [{"id":"base:projectile_forward_guns", "name":"Forward Guns",
|
||||
"damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all",
|
||||
"offsets":[[-8,0],[8,0]]}],
|
||||
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,
|
||||
"weapon":"base:projectile_forward_guns"},
|
||||
{"id":"base:energy","name":"Shield","price":0,"max_count":100}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Synthetic Range","duration":5000}]
|
||||
}))
|
||||
.unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn wav() -> Vec<u8> {
|
||||
let mut data = b"RIFF".to_vec();
|
||||
data.extend(40_u32.to_le_bytes());
|
||||
data.extend(b"WAVEfmt ");
|
||||
data.extend(16_u32.to_le_bytes());
|
||||
data.extend(1_u16.to_le_bytes());
|
||||
data.extend(1_u16.to_le_bytes());
|
||||
data.extend(22050_u32.to_le_bytes());
|
||||
data.extend(44100_u32.to_le_bytes());
|
||||
data.extend(2_u16.to_le_bytes());
|
||||
data.extend(16_u16.to_le_bytes());
|
||||
data.extend(b"data");
|
||||
data.extend(4_u32.to_le_bytes());
|
||||
data.extend([0, 0, 0, 0]);
|
||||
data
|
||||
}
|
||||
|
||||
fn install_fixture(root: &Path, id: &str) {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("examples/mods")
|
||||
.join(id);
|
||||
let output = root.join(id);
|
||||
fs::create_dir_all(output.join("scripts")).unwrap();
|
||||
fs::create_dir_all(output.join("art")).unwrap();
|
||||
fs::create_dir_all(output.join("audio")).unwrap();
|
||||
for name in ["mod.toml", "content.json", "scripts/ai.rhai"] {
|
||||
fs::copy(source.join(name), output.join(name)).unwrap();
|
||||
}
|
||||
let content: ContentPack =
|
||||
serde_json::from_slice(&fs::read(source.join("content.json")).unwrap()).unwrap();
|
||||
for art in content
|
||||
.enemies
|
||||
.iter()
|
||||
.filter_map(|e| e.art.as_ref())
|
||||
.chain(content.weapons.iter().filter_map(|w| w.art.as_ref()))
|
||||
{
|
||||
for (path, scale) in [
|
||||
(art.png.as_ref().unwrap(), 1),
|
||||
(art.hd_png.as_ref().unwrap(), 2),
|
||||
] {
|
||||
let mut image = image::RgbaImage::new(
|
||||
art.logical_width * art.frames * scale,
|
||||
art.logical_height * scale,
|
||||
);
|
||||
for frame in 0..art.frames {
|
||||
for y in scale..(art.logical_height - 1) * scale {
|
||||
for x in scale..(art.logical_width - 1) * scale {
|
||||
image.put_pixel(
|
||||
frame * art.logical_width * scale + x,
|
||||
y,
|
||||
image::Rgba([80, 90, 100, 255]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
image.save(output.join(path)).unwrap();
|
||||
}
|
||||
}
|
||||
for sound in content.sounds {
|
||||
fs::write(output.join(sound.file), wav()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn active(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
for id in ids {
|
||||
install_fixture(temp.path(), id);
|
||||
}
|
||||
let discovered = modding::discover(temp.path());
|
||||
assert!(discovered.errors.is_empty(), "{:?}", discovered.errors);
|
||||
let selected: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
|
||||
let active = ActiveMods::build(&base(), &discovered, &selected, None).unwrap();
|
||||
(temp, active)
|
||||
}
|
||||
|
||||
fn world(active: &ActiveMods) -> World {
|
||||
World::new(
|
||||
&ContentId::from("base:sector_1_wave_1"),
|
||||
&Pilot::default(),
|
||||
&active.registry,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_enemy_packages_validate_independently_and_together() {
|
||||
for id in ENEMIES {
|
||||
let (_temp, a) = active(&[id]);
|
||||
assert_eq!(a.pins.len(), 1);
|
||||
assert_eq!(a.registry.enemies.len(), 1);
|
||||
assert!(
|
||||
a.registry
|
||||
.weapons
|
||||
.contains_key(&ContentId::new(format!("{id}:projectile")).unwrap())
|
||||
);
|
||||
assert_eq!(a.registry.sounds.len(), 2);
|
||||
assert_eq!(
|
||||
a.registry.enemies[&ContentId::new(format!("{id}:unit")).unwrap()]
|
||||
.script
|
||||
.as_deref(),
|
||||
Some("scripts/ai.rhai")
|
||||
);
|
||||
}
|
||||
let (_temp, a) = active(&ENEMIES);
|
||||
assert_eq!(a.pins.len(), 3);
|
||||
assert_eq!(a.registry.enemies.len(), 3);
|
||||
assert!(a.registry.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scout_weaves_in_both_directions_and_aims_hostile_fire() {
|
||||
let (_temp, mut a) = active(&["iron_coast_scout"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_scout:unit"),
|
||||
120,
|
||||
0,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut right = false;
|
||||
let mut left = false;
|
||||
let mut sounds = 0;
|
||||
for _ in 0..95 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
let e = &w.enemies[0];
|
||||
right |= e.vx > 0;
|
||||
left |= e.vx < 0;
|
||||
assert!(e.x >= 24 && e.x <= 272);
|
||||
assert_eq!(e.vy, 1);
|
||||
sounds += w
|
||||
.mod_sounds
|
||||
.iter()
|
||||
.filter(|id| id.0 == "iron_coast_scout:shot")
|
||||
.count();
|
||||
}
|
||||
assert!(right && left);
|
||||
assert_eq!(w.enemies[0].y, 94);
|
||||
assert!(sounds >= 2);
|
||||
assert!(
|
||||
w.projectiles
|
||||
.iter()
|
||||
.any(|p| p.hostile && p.weapon.0 == "iron_coast_scout:projectile" && p.vx < 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turret_scrolls_with_terrain_and_aims_at_player() {
|
||||
let (_temp, mut a) = active(&["iron_coast_turret"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_turret:unit"),
|
||||
220,
|
||||
0,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut sounds = 0;
|
||||
for _ in 0..80 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
sounds += w
|
||||
.mod_sounds
|
||||
.iter()
|
||||
.filter(|id| id.0 == "iron_coast_turret:shot")
|
||||
.count();
|
||||
}
|
||||
assert_eq!((w.enemies[0].x, w.enemies[0].y), (220, 80));
|
||||
assert!(a.registry.enemies[&w.enemies[0].definition].ground);
|
||||
assert_eq!(sounds, 2);
|
||||
assert!(
|
||||
w.projectiles
|
||||
.iter()
|
||||
.any(|p| p.hostile && p.vx < 0 && p.vy >= 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_enters_patrols_fires_fans_and_remains_on_screen() {
|
||||
let (_temp, mut a) = active(&["iron_coast_boss"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_boss:unit"),
|
||||
112,
|
||||
-64,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut left = false;
|
||||
let mut right = false;
|
||||
let mut fans = 0;
|
||||
for tick in 0..1500 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(w.outcome, Outcome::Playing);
|
||||
assert_eq!(w.enemies.len(), 1, "boss retired at{tick}");
|
||||
let e = &w.enemies[0];
|
||||
assert_eq!((e.width, e.height), (96, 64));
|
||||
if tick > 46 {
|
||||
assert_eq!(e.y, 24);
|
||||
assert!((80..=144).contains(&e.x));
|
||||
left |= e.vx < 0;
|
||||
right |= e.vx > 0;
|
||||
}
|
||||
if w.mod_sounds
|
||||
.contains(&ContentId::from("iron_coast_boss:shot"))
|
||||
{
|
||||
let bullets: Vec<_> = w
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| p.age == 0 && p.hostile)
|
||||
.collect();
|
||||
assert_eq!(bullets.len(), 3);
|
||||
assert_eq!(bullets.iter().map(|p| p.vx).collect::<Vec<_>>(), [-2, 0, 2]);
|
||||
fans += 1;
|
||||
}
|
||||
}
|
||||
assert!(left && right);
|
||||
assert!(fans >= 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_can_be_defeated_with_starter_guns_without_healing() {
|
||||
let (_temp, mut a) = active(&["iron_coast_boss"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 128;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_boss:unit"),
|
||||
112,
|
||||
-64,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut defeated_at = None;
|
||||
for tick in 0..600 {
|
||||
w.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&a.registry,
|
||||
&mut a.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
if w.objectives.get("iron_coast_boss:defeated") == Some(&1) {
|
||||
defeated_at = Some(tick);
|
||||
break;
|
||||
}
|
||||
assert_eq!(w.outcome, Outcome::Playing, "starter pilot died at{tick}");
|
||||
}
|
||||
assert!(
|
||||
defeated_at.is_some(),
|
||||
"boss cannot be killed with starter guns"
|
||||
);
|
||||
assert!(w.enemies.is_empty());
|
||||
assert!(w.player.shield > 0);
|
||||
assert_eq!(w.player.credits, 1800);
|
||||
assert!(
|
||||
w.mod_sounds
|
||||
.contains(&ContentId::from("iron_coast_boss:death"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn death_rewards_objectives_and_sounds_are_emitted_exactly_once() {
|
||||
let (_temp, mut a) = active(&ENEMIES);
|
||||
let mut w = world(&a);
|
||||
for (index, id) in ENEMIES.iter().enumerate() {
|
||||
let unit = ContentId::new(format!("{id}:unit")).unwrap();
|
||||
let entity = w
|
||||
.spawn(&unit, 30 + index as i32 * 100, 0, &a.registry)
|
||||
.unwrap();
|
||||
w.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert!(w.enemies.is_empty());
|
||||
assert_eq!(w.player.credits, 75 + 120 + 1800);
|
||||
for id in ENEMIES {
|
||||
assert_eq!(w.objectives.get(&format!("{id}:defeated")), Some(&1));
|
||||
assert!(
|
||||
w.mod_sounds
|
||||
.contains(&ContentId::new(format!("{id}:death")).unwrap())
|
||||
);
|
||||
}
|
||||
let previous = w.player.credits;
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(w.player.credits, previous);
|
||||
assert!(w.mod_sounds.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enemy_scripts_replay_identically_and_restore_serialized_state() {
|
||||
let (_temp, mut first) = active(&ENEMIES);
|
||||
let (_other, mut second) = active(&ENEMIES);
|
||||
let mut a = world(&first);
|
||||
for (index, id) in ENEMIES.iter().enumerate() {
|
||||
a.spawn(
|
||||
&ContentId::new(format!("{id}:unit")).unwrap(),
|
||||
60 + index as i32 * 30,
|
||||
-24,
|
||||
&first.registry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
a.player.x = 0;
|
||||
let mut b = a.clone();
|
||||
for _ in 0..75 {
|
||||
a.step(Input::default(), &first.registry, &mut first.scripts)
|
||||
.unwrap();
|
||||
b.step(Input::default(), &second.registry, &mut second.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(a.digest(), b.digest());
|
||||
assert_eq!(first.scripts.checkpoints(), second.scripts.checkpoints());
|
||||
}
|
||||
let snapshot = serde_json::to_vec(&a).unwrap();
|
||||
b = serde_json::from_slice(&snapshot).unwrap();
|
||||
let checkpoint = first.scripts.checkpoints();
|
||||
second.scripts.restore(checkpoint);
|
||||
for _ in 0..25 {
|
||||
a.step(Input::default(), &first.registry, &mut first.scripts)
|
||||
.unwrap();
|
||||
b.step(Input::default(), &second.registry, &mut second.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(a.digest(), b.digest());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mission_defeat_counts_reset_while_persistent_script_statistics_continue() {
|
||||
let (_temp, mut a) = active(&ENEMIES);
|
||||
for id in ENEMIES {
|
||||
let unit = ContentId::new(format!("{id}:unit")).unwrap();
|
||||
let mut first = world(&a);
|
||||
for _ in 0..2 {
|
||||
let entity = first.spawn(&unit, 20, 0, &a.registry).unwrap();
|
||||
first
|
||||
.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
first
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
first
|
||||
.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(first.objectives.get(&format!("{id}:defeated")), Some(&2));
|
||||
let mut next = world(&a);
|
||||
let entity = next.spawn(&unit, 20, 0, &a.registry).unwrap();
|
||||
next.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
next.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
next.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(next.objectives.get(&format!("{id}:defeated")), Some(&1));
|
||||
let state = a.scripts.checkpoints().0;
|
||||
assert_eq!(state[&format!("{id}/scripts/ai.rhai")]["destroyed"], 3);
|
||||
if id == "iron_coast_boss" {
|
||||
assert!(
|
||||
state[&format!("{id}/scripts/ai.rhai")]["directions"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_enemy_assets_are_valid_and_aligned() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mods");
|
||||
let selected: Vec<_> = ENEMIES.iter().map(|id| id.to_string()).collect();
|
||||
let discovery = modding::discover(&root);
|
||||
ActiveMods::build(&base(), &discovery, &selected, None).unwrap();
|
||||
for id in ENEMIES {
|
||||
let content: ContentPack =
|
||||
serde_json::from_slice(&fs::read(root.join(id).join("content.json")).unwrap()).unwrap();
|
||||
let art = content.enemies[0].art.as_ref().unwrap();
|
||||
for path in [art.png.as_ref().unwrap(), art.hd_png.as_ref().unwrap()] {
|
||||
let image = image::open(root.join(id).join(path)).unwrap().into_rgba8();
|
||||
assert_eq!(
|
||||
image.width() / art.frames * art.logical_height,
|
||||
image.height() * art.logical_width
|
||||
);
|
||||
for frame in 0..art.frames {
|
||||
let width = image.width() / art.frames;
|
||||
for x in frame * width..(frame + 1) * width {
|
||||
assert!(image.get_pixel(x, 0)[3] < 128);
|
||||
assert!(image.get_pixel(x, image.height() - 1)[3] < 128);
|
||||
}
|
||||
for y in 0..image.height() {
|
||||
assert!(image.get_pixel(frame * width, y)[3] < 128);
|
||||
assert!(image.get_pixel((frame + 1) * width - 1, y)[3] < 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,766 +0,0 @@
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
//! Campaign acceptance uses synthetic PNG/WAV assets; original data is unnecessary.
|
||||
use raptor_replica::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Manifest},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
const PACKAGES: [&str; 7] = [
|
||||
"iron_coast_scout",
|
||||
"iron_coast_turret",
|
||||
"iron_coast_boss",
|
||||
"pulse_cannon",
|
||||
"seeker_swarm",
|
||||
"ion_lance",
|
||||
"iron_coast_mission_1",
|
||||
];
|
||||
const MISSION: &str = "iron_coast_mission_1:mission_01";
|
||||
|
||||
fn base() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let 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) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let (Some(width), Some(height)) = (
|
||||
object.get("logical_width").and_then(Value::as_u64),
|
||||
object.get("logical_height").and_then(Value::as_u64),
|
||||
) {
|
||||
let frames = object.get("frames").and_then(Value::as_u64).unwrap_or(1);
|
||||
for (key, scale) in [("png", 1), ("hd_png", 4)] {
|
||||
if let Some(file) = object.get(key).and_then(Value::as_str) {
|
||||
let path = root.join(file);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let image = image::RgbaImage::from_pixel(
|
||||
(width * frames * scale) as u32,
|
||||
(height * scale) as u32,
|
||||
image::Rgba([80, 96, 104, 255]),
|
||||
);
|
||||
image.save(path).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(file) = object.get("file").and_then(Value::as_str) {
|
||||
let path = root.join(file);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
// Small valid mono PCM16 WAV; integration checks use the actual cues.
|
||||
let mut wav = Vec::new();
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&100_u32.to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1_u16.to_le_bytes());
|
||||
wav.extend_from_slice(&1_u16.to_le_bytes());
|
||||
wav.extend_from_slice(&22050_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&44100_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&2_u16.to_le_bytes());
|
||||
wav.extend_from_slice(&16_u16.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&64_u32.to_le_bytes());
|
||||
wav.extend_from_slice(&[0; 64]);
|
||||
fs::write(path, wav).unwrap();
|
||||
}
|
||||
for child in object.values() {
|
||||
fixture_resources(child, root);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
fixture_resources(child, root);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn active() -> (tempfile::TempDir, ActiveMods) {
|
||||
let started = std::time::Instant::now();
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/mods");
|
||||
for id in PACKAGES {
|
||||
let original = source.join(id);
|
||||
let root = temporary.path().join(id);
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let manifest_data = fs::read(original.join("mod.toml"))
|
||||
.expect("all seven Iron Coast packages must be installed");
|
||||
let manifest: Manifest =
|
||||
toml::from_str(std::str::from_utf8(&manifest_data).unwrap()).unwrap();
|
||||
fs::write(root.join("mod.toml"), manifest_data).unwrap();
|
||||
let content = fs::read(original.join(&manifest.content)).unwrap();
|
||||
let value: Value = serde_json::from_slice(&content).unwrap();
|
||||
fs::write(root.join(&manifest.content), content).unwrap();
|
||||
for script in &manifest.scripts {
|
||||
let path = root.join(script);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::copy(original.join(script), path).unwrap();
|
||||
}
|
||||
fixture_resources(&value, &root);
|
||||
}
|
||||
let discovery = modding::discover(temporary.path());
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let enabled = PACKAGES.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let active = ActiveMods::build(&base(), &discovery, &enabled, None).unwrap();
|
||||
eprintln!(
|
||||
"Iron Coast synthetic seven-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(), 7);
|
||||
let mission = &active.registry.missions[&MISSION.into()];
|
||||
assert!(!mission.auto_complete);
|
||||
assert_eq!(mission.columns, 9);
|
||||
assert_eq!(mission.layout.len(), 72 * 9);
|
||||
assert!(
|
||||
mission
|
||||
.spawns
|
||||
.iter()
|
||||
.any(|s| s.enemy.0 == "iron_coast_boss:unit" && s.tick == 720)
|
||||
);
|
||||
assert_eq!(mission.art.as_ref().unwrap().logical_width, 320);
|
||||
assert!(
|
||||
active.registry.campaigns[&"iron_coast_mission_1:campaign".into()]
|
||||
.missions
|
||||
.contains(&MISSION.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_alone_cannot_complete_and_reward_is_paid_once() {
|
||||
let (_temporary, mut active) = active();
|
||||
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
world
|
||||
.objectives
|
||||
.insert("iron_coast_boss:defeated".into(), 1);
|
||||
world
|
||||
.objectives
|
||||
.insert("iron_coast_turret:defeated".into(), 1);
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
world
|
||||
.objectives
|
||||
.insert("iron_coast_turret:defeated".into(), 2);
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Complete);
|
||||
assert_eq!(world.objectives["iron_coast_mission_1:complete"], 1);
|
||||
assert_eq!(world.player.credits, 5000);
|
||||
assert_eq!(world.objectives["iron_coast_mission_1:debrief"], 1);
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.credits, 5000);
|
||||
}
|
||||
|
||||
fn play(use_trial: bool) -> (World, Vec<(u64, String)>) {
|
||||
let (_temporary, mut active) = active();
|
||||
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
|
||||
let mut seen = Vec::new();
|
||||
for tick in 0..2000 {
|
||||
let input = Input {
|
||||
fire: true,
|
||||
special: tick % 90 == 60,
|
||||
cycle: tick == 1,
|
||||
absolute: Some([144, 160]),
|
||||
..Input::default()
|
||||
};
|
||||
world
|
||||
.step(input, &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
if tick == 0 && !use_trial {
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.remove(&"pulse_cannon:equipment".into());
|
||||
}
|
||||
for enemy in &world.enemies {
|
||||
if !seen.iter().any(|(_, id)| id == &enemy.definition.0) {
|
||||
seen.push((world.tick, enemy.definition.0.clone()));
|
||||
}
|
||||
}
|
||||
if world.outcome != Outcome::Playing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if use_trial {
|
||||
let weapon_state = &active.scripts.states["pulse_cannon/scripts/weapon.rhai"];
|
||||
assert!(weapon_state["bursts"].as_i64().unwrap() > 0);
|
||||
assert!(weapon_state["hits"].as_i64().unwrap() > 0);
|
||||
let serialized = serde_json::to_string(&active.scripts.states).unwrap();
|
||||
let recovered: serde_json::Value = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(recovered["pulse_cannon/scripts/weapon.rhai"], *weapon_state);
|
||||
}
|
||||
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();
|
||||
let mut pilot = Pilot::default();
|
||||
for n in 0..18 {
|
||||
pilot
|
||||
.inventory
|
||||
.insert(format!("base:fixture_item_{n}").as_str().into(), 1);
|
||||
}
|
||||
assert_eq!(pilot.inventory.len(), 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.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);
|
||||
}
|
||||
@@ -1,980 +0,0 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command, Manifest, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
fn pack(value: Value) -> ContentPack {
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
fn item(id: &str, price: u32) -> Value {
|
||||
json!({"id": id, "name": id, "price": price, "max_count": 100})
|
||||
}
|
||||
fn base() -> Registry {
|
||||
let mut reg = Registry::default();
|
||||
reg.apply(pack(json!({
|
||||
"weapons": [{"id":"base:projectile_forward_guns", "name":"Forward Guns", "damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}],
|
||||
"items": [
|
||||
{"id":"base:forward_guns","name":"Forward Guns","price":0,"weapon":"base:projectile_forward_guns"},
|
||||
{"id":"base:energy","name":"Shield","price":0,"max_count":100}
|
||||
],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Test Mission","duration":1000}]
|
||||
})), "base").unwrap();
|
||||
reg.validate().unwrap();
|
||||
reg
|
||||
}
|
||||
fn install(root: &Path, folder: &str, id: &str, content: Value) -> Manifest {
|
||||
let dir = root.join(folder);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let manifest = Manifest {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
version: "1.0.0".into(),
|
||||
api_version: 1,
|
||||
description: String::new(),
|
||||
dependencies: BTreeMap::new(),
|
||||
conflicts: vec![],
|
||||
content: "content.json".into(),
|
||||
scripts: vec![],
|
||||
};
|
||||
write_manifest(&dir, &manifest);
|
||||
fs::write(
|
||||
dir.join("content.json"),
|
||||
serde_json::to_vec_pretty(&content).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
manifest
|
||||
}
|
||||
fn write_manifest(dir: &Path, manifest: &Manifest) {
|
||||
fs::write(dir.join("mod.toml"), toml::to_string(manifest).unwrap()).unwrap();
|
||||
}
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|v| (*v).into()).collect()
|
||||
}
|
||||
fn error<T>(result: anyhow::Result<T>) -> String {
|
||||
match result {
|
||||
Ok(_) => panic!("expected failure"),
|
||||
Err(e) => format!("{e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependencies_load_before_dependents_and_keep_independent_priority() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "alpha", "alpha", json!({}));
|
||||
let mut beta = install(temp.path(), "beta", "beta", json!({}));
|
||||
beta.dependencies.insert("alpha".into(), "^1.0".into());
|
||||
write_manifest(&temp.path().join("beta"), &beta);
|
||||
install(temp.path(), "gamma", "gamma", json!({}));
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(d.errors.is_empty());
|
||||
assert_eq!(
|
||||
modding::order(&d, &ids(&["gamma", "beta", "alpha"])).unwrap(),
|
||||
ids(&["gamma", "alpha", "beta"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_disabled_wrong_version_and_cyclic_dependencies_are_actionable() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut alpha = install(temp.path(), "alpha", "alpha", json!({}));
|
||||
let mut beta = install(temp.path(), "beta", "beta", json!({}));
|
||||
beta.dependencies.insert("alpha".into(), "^2".into());
|
||||
write_manifest(&temp.path().join("beta"), &beta);
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(error(modding::order(&d, &ids(&["beta"]))).contains("requires enabled mod alpha"));
|
||||
assert!(error(modding::order(&d, &ids(&["alpha", "beta"]))).contains("installed 1.0.0"));
|
||||
beta.dependencies.insert("alpha".into(), "^1".into());
|
||||
alpha.dependencies.insert("beta".into(), "^1".into());
|
||||
write_manifest(&temp.path().join("beta"), &beta);
|
||||
write_manifest(&temp.path().join("alpha"), &alpha);
|
||||
assert!(
|
||||
error(modding::order(
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha", "beta"])
|
||||
))
|
||||
.contains("cyclic")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_conflicts_and_duplicate_selected_ids_reject_activation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut alpha = install(temp.path(), "alpha", "alpha", json!({}));
|
||||
install(temp.path(), "beta", "beta", json!({}));
|
||||
alpha.api_version = 99;
|
||||
write_manifest(&temp.path().join("alpha"), &alpha);
|
||||
assert!(
|
||||
error(modding::order(
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"])
|
||||
))
|
||||
.contains("requires API 99")
|
||||
);
|
||||
alpha.api_version = 1;
|
||||
alpha.conflicts.push("beta".into());
|
||||
write_manifest(&temp.path().join("alpha"), &alpha);
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(error(modding::order(&d, &ids(&["alpha", "beta"]))).contains("conflicts with beta"));
|
||||
assert!(error(modding::order(&d, &ids(&["beta", "beta"]))).contains("duplicate mod"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_duplicate_installed_namespace_is_unusable() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
for folder in ["first", "second", "third"] {
|
||||
install(temp.path(), folder, "collision", json!({}));
|
||||
}
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(!d.mods.contains_key("collision"));
|
||||
assert_eq!(d.errors.len(), 2);
|
||||
assert!(
|
||||
error(ActiveMods::build(&base(), &d, &ids(&["collision"]), None)).contains("not installed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_duplicate_after_discovery_requires_rescanning() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "first", "alpha", json!({}));
|
||||
let original = modding::discover(temp.path());
|
||||
install(temp.path(), "second", "alpha", json!({}));
|
||||
let message = error(ActiveMods::build(
|
||||
&base(),
|
||||
&original,
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
));
|
||||
assert!(message.contains("duplicated since discovery") && message.contains("rescan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_files_require_a_new_discovery_snapshot() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[item("alpha:cell", 10)]}),
|
||||
);
|
||||
let original = modding::discover(temp.path());
|
||||
let before = ActiveMods::build(&base(), &original, &ids(&["alpha"]), None).unwrap();
|
||||
fs::write(
|
||||
temp.path().join("alpha/content.json"),
|
||||
serde_json::to_vec(&json!({"items":[item("alpha:cell", 12)]})).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
error(ActiveMods::build(
|
||||
&base(),
|
||||
&original,
|
||||
&ids(&["alpha"]),
|
||||
None
|
||||
))
|
||||
.contains("rescan")
|
||||
);
|
||||
let after = ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(before.pins[0].hash, after.pins[0].hash);
|
||||
assert_eq!(
|
||||
after.registry.items[&ContentId::from("alpha:cell")].price,
|
||||
12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_hash_covers_added_assets_and_manifest_changes() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut manifest = install(temp.path(), "alpha", "alpha", json!({}));
|
||||
let d = modding::discover(temp.path());
|
||||
fs::write(temp.path().join("alpha/readme.txt"), "Extra package file").unwrap();
|
||||
assert!(error(ActiveMods::build(&base(), &d, &ids(&["alpha"]), None)).contains("rescan"));
|
||||
let d = modding::discover(temp.path());
|
||||
manifest.version = "1.0.1".into();
|
||||
write_manifest(&temp.path().join("alpha"), &manifest);
|
||||
assert!(error(ActiveMods::build(&base(), &d, &ids(&["alpha"]), None)).contains("rescan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_paths_depth_and_size_are_bounded() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut m = install(temp.path(), "traversal", "traversal", json!({}));
|
||||
m.content = "../outside.json".into();
|
||||
write_manifest(&temp.path().join("traversal"), &m);
|
||||
install(temp.path(), "deep", "deep", json!({}));
|
||||
let mut nested = temp.path().join("deep");
|
||||
for _ in 0..17 {
|
||||
nested = nested.join("nested");
|
||||
}
|
||||
fs::create_dir_all(nested).unwrap();
|
||||
install(temp.path(), "large", "large", json!({}));
|
||||
fs::File::create(temp.path().join("large/huge.bin"))
|
||||
.unwrap()
|
||||
.set_len(64 * 1024 * 1024 + 1)
|
||||
.unwrap();
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(d.mods.is_empty());
|
||||
assert!(d.errors.iter().any(|e| e.contains("parent traversal")));
|
||||
assert!(d.errors.iter().any(|e| e.contains("depth exceeds")));
|
||||
assert!(d.errors.iter().any(|e| e.contains("64 MiB")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_folders_and_files_are_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(temp.path(), "alpha", "alpha", json!({}));
|
||||
symlink("/etc/passwd", temp.path().join("alpha/link")).unwrap();
|
||||
symlink(temp.path().join("alpha"), temp.path().join("alias")).unwrap();
|
||||
let d = modding::discover(temp.path());
|
||||
assert!(d.mods.is_empty());
|
||||
assert_eq!(d.errors.len(), 2);
|
||||
assert!(d.errors.iter().all(|e| e.contains("symlink")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_manifest_versions_and_duplicate_sources_fail_discovery() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut m = install(temp.path(), "alpha", "alpha", json!({}));
|
||||
m.dependencies
|
||||
.insert("beta".into(), "obviously invalid".into());
|
||||
write_manifest(&temp.path().join("alpha"), &m);
|
||||
assert!(modding::discover(temp.path()).mods.is_empty());
|
||||
m.dependencies.clear();
|
||||
m.scripts = ids(&["script.rhai", "script.rhai"]);
|
||||
fs::write(temp.path().join("alpha/script.rhai"), "").unwrap();
|
||||
write_manifest(&temp.path().join("alpha"), &m);
|
||||
assert!(modding::discover(temp.path()).errors[0].contains("duplicate script"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_explicit_overrides_win_and_report_the_previous_owner() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[item("base:energy", 10)], "overrides":["base:energy"]}),
|
||||
);
|
||||
install(
|
||||
temp.path(),
|
||||
"beta",
|
||||
"beta",
|
||||
json!({"items":[item("base:energy", 20)], "overrides":["base:energy"]}),
|
||||
);
|
||||
let d = modding::discover(temp.path());
|
||||
let active = ActiveMods::build(&base(), &d, &ids(&["alpha", "beta"]), None).unwrap();
|
||||
assert_eq!(
|
||||
active.registry.items[&ContentId::from("base:energy")].price,
|
||||
20
|
||||
);
|
||||
assert_eq!(
|
||||
active.registry.owners[&ContentId::from("base:energy")],
|
||||
"beta"
|
||||
);
|
||||
assert_eq!(
|
||||
active.registry.overrides,
|
||||
ids(&["base:energy: base -> alpha", "base:energy: alpha -> beta"])
|
||||
);
|
||||
let reversed = ActiveMods::build(&base(), &d, &ids(&["beta", "alpha"]), None).unwrap();
|
||||
assert_eq!(
|
||||
reversed.registry.items[&ContentId::from("base:energy")].price,
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accidental_duplicates_type_changes_and_failed_packs_leave_registry_intact() {
|
||||
let mut registry = base();
|
||||
let previous = registry.items.len();
|
||||
assert!(
|
||||
registry
|
||||
.apply(
|
||||
pack(json!({"items":[item("alpha:valid", 1), item("base:energy", 2)]})),
|
||||
"alpha"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(registry.items.len(), previous);
|
||||
assert!(
|
||||
!registry
|
||||
.owners
|
||||
.contains_key(&ContentId::from("alpha:valid"))
|
||||
);
|
||||
assert!(
|
||||
registry
|
||||
.apply(
|
||||
pack(json!({"items":[item("alpha:twice", 1), item("alpha:twice", 2)]})),
|
||||
"alpha"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(registry.apply(pack(json!({"enemies":[{"id":"base:energy","name":"Wrong type"}],"overrides":["base:energy"]})), "alpha").is_err());
|
||||
assert!(
|
||||
registry
|
||||
.apply(pack(json!({"items":[item("foreign:cell", 1)]})), "alpha")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
registry
|
||||
.apply(
|
||||
pack(json!({"items":[item("alpha:new", 1)],"overrides":["alpha:new"]})),
|
||||
"alpha"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_references_and_undeclared_scripts_reject_the_whole_mod() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"weapon":"alpha:missing"}]}),
|
||||
);
|
||||
assert!(
|
||||
error(ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None
|
||||
))
|
||||
.contains("missing weapon")
|
||||
);
|
||||
fs::write(temp.path().join("alpha/content.json"), serde_json::to_vec(&json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"script":"undeclared.rhai"}]})).unwrap()).unwrap();
|
||||
assert!(
|
||||
error(ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None
|
||||
))
|
||||
.contains("manifest.scripts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_failure_rolls_back_world_state_and_deterministic_rng() {
|
||||
let mut registry = base();
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&ContentId::from("base:sector_1_wave_1"))
|
||||
.unwrap()
|
||||
.script = Some("mission.rhai".into());
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime
|
||||
.add(
|
||||
"base",
|
||||
"mission.rhai",
|
||||
r#"fn on_mission_start(e,s,r) {
|
||||
#{commands:[#{op:"credits",amount:7}, #{op:"move",entity:999,x:0,y:0}],state:#{random:r[0]}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let before = world.digest();
|
||||
assert!(
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(world.digest(), before);
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
let source = r#"fn on_mission_start(e,s,r) { #{commands:[#{op:"credits",amount:r[0]}],state:#{random:r[0]}} }"#;
|
||||
runtime.add("base", "mission.rhai", source).unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
let mut fresh_runtime = ScriptRuntime::new();
|
||||
fresh_runtime.add("base", "mission.rhai", source).unwrap();
|
||||
let mut fresh_world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
fresh_world
|
||||
.step(Input::default(), ®istry, &mut fresh_runtime)
|
||||
.unwrap();
|
||||
assert_eq!(world.digest(), fresh_world.digest());
|
||||
assert_eq!(runtime.states, fresh_runtime.states);
|
||||
assert_eq!(runtime.rng, fresh_runtime.rng);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_batches_are_atomic_even_after_an_earlier_valid_command() {
|
||||
let registry = base();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let before = world.digest();
|
||||
let commands = [
|
||||
Command::Credits { amount: 100 },
|
||||
Command::Spawn {
|
||||
enemy: "missing:enemy".into(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
];
|
||||
assert!(world.apply_commands(&commands, ®istry).is_err());
|
||||
assert_eq!(world.digest(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_result_excessive_commands_and_oversized_state_are_rejected() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
for (source, expected) in [
|
||||
(
|
||||
r#"fn on_tick(e,s,r) { #{commands:[#{op:"unknown"}],state:#{}} }"#,
|
||||
"invalid result",
|
||||
),
|
||||
(
|
||||
r#"fn on_tick(e,s,r) { let a=[]; for i in 0..257 { a.push(#{op:"complete"}); } #{commands:a,state:#{}} }"#,
|
||||
"too many commands",
|
||||
),
|
||||
(
|
||||
r#"fn on_tick(e,s,r) { let a=[]; for i in 0..4000 { a.push(999999999999999999); } #{commands:[],state:a} }"#,
|
||||
"state exceeds",
|
||||
),
|
||||
] {
|
||||
runtime.begin_tick();
|
||||
runtime.add("test", "limits.rhai", source).unwrap();
|
||||
let message = error(runtime.invoke("test", "limits.rhai", "on_tick", &json!({}), 17));
|
||||
assert!(
|
||||
message.contains(expected),
|
||||
"expected {expected}, got {message}"
|
||||
);
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_budget_is_shared_across_callbacks_and_resets_per_tick() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime.add("test", "budget.rhai", r#"fn on_tick(e,s,r) { let x=0; for n in 0..2500 { x+=1; } #{commands:[],state:#{iterations:x}} }"#).unwrap();
|
||||
runtime.begin_tick();
|
||||
let mut success = 0;
|
||||
for _ in 0..100 {
|
||||
if runtime
|
||||
.invoke("test", "budget.rhai", "on_tick", &json!({}), 1)
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
success += 1;
|
||||
}
|
||||
assert!(
|
||||
success > 1 && success < 100,
|
||||
"successful callbacks: {success}"
|
||||
);
|
||||
runtime.begin_tick();
|
||||
runtime
|
||||
.invoke("test", "budget.rhai", "on_tick", &json!({}), 1)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_hook_does_not_advance_rng_or_state() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"empty.rhai",
|
||||
"fn other(e,s,r) { #{commands:[],state:s} }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
runtime
|
||||
.invoke("test", "empty.rhai", "on_tick", &json!({}), 1)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_allow_added_mods_while_preserving_required_versions_and_order() {
|
||||
let registry = base();
|
||||
let pin = |id: &str| modding::ModPin {
|
||||
id: id.into(),
|
||||
version: "1.0.0".into(),
|
||||
hash: format!("{id}-content"),
|
||||
};
|
||||
let a = pin("alpha");
|
||||
let b = pin("beta");
|
||||
let c = pin("extra");
|
||||
let mut pilot = Pilot::default();
|
||||
assert!(pilot.validate(®istry, &[a.clone(), b.clone()]).is_ok());
|
||||
pilot.mods = vec![a.clone(), b.clone()];
|
||||
pilot
|
||||
.script_state
|
||||
.insert("alpha/ai.rhai".into(), json!({"kills": 17}));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("pilot.json");
|
||||
pilot.save(&path).unwrap();
|
||||
let before = fs::read(&path).unwrap();
|
||||
let loaded = Pilot::load(&path, ®istry, &[a.clone(), c, b.clone()]).unwrap();
|
||||
assert_eq!(loaded.script_state, pilot.script_state);
|
||||
assert!(Pilot::load(&path, ®istry, &[b.clone(), a.clone()]).is_err());
|
||||
assert!(Pilot::load(&path, ®istry, &[a.clone()]).is_err());
|
||||
let mut changed = b;
|
||||
changed.version = "2.0.0".into();
|
||||
assert!(Pilot::load(&path, ®istry, &[a, changed]).is_err());
|
||||
assert_eq!(fs::read(path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_save_pins_and_disabled_equipment_do_not_modify_the_save() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[item("alpha:cell", 1)]}),
|
||||
);
|
||||
let active = ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.mods = active.pins.clone();
|
||||
pilot.inventory.insert("alpha:cell".into(), 1);
|
||||
let path = temp.path().join("pilot.json");
|
||||
pilot.save(&path).unwrap();
|
||||
let before = fs::read(&path).unwrap();
|
||||
Pilot::load(&path, &active.registry, &active.pins).unwrap();
|
||||
assert!(error(Pilot::load(&path, &base(), &[])).contains("enable the matching mods"));
|
||||
let mut changed = active.pins.clone();
|
||||
changed[0].hash = "different-content-hash".into();
|
||||
assert!(Pilot::load(&path, &active.registry, &changed).is_err());
|
||||
assert!(
|
||||
error(Pilot::load(&path, &base(), &active.pins))
|
||||
.contains("saved item alpha:cell is unavailable")
|
||||
);
|
||||
assert_eq!(fs::read(path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_script_state_and_rng_resume_the_same_callback_sequence() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
let source = r#"fn on_tick(e,s,r) { let n=if s.contains("n") {s.n+1} else {1}; #{commands:[#{op:"credits",amount:r[0]}],state:#{n:n,last:r[0]}} }"#;
|
||||
runtime.add("test", "state.rhai", source).unwrap();
|
||||
runtime
|
||||
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
|
||||
.unwrap();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.script_state = runtime.states.clone();
|
||||
pilot.script_rng = runtime.rng.clone();
|
||||
let restored_pilot: Pilot =
|
||||
serde_json::from_slice(&serde_json::to_vec(&pilot).unwrap()).unwrap();
|
||||
let mut restored = ScriptRuntime::new();
|
||||
restored.add("test", "state.rhai", source).unwrap();
|
||||
restored.states = restored_pilot.script_state;
|
||||
restored.rng = restored_pilot.script_rng;
|
||||
let a = runtime
|
||||
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
|
||||
.unwrap();
|
||||
let b = restored
|
||||
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(a).unwrap(),
|
||||
serde_json::to_value(b).unwrap()
|
||||
);
|
||||
assert_eq!(runtime.states, restored.states);
|
||||
assert_eq!(runtime.rng, restored.rng);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_mod_compiles_and_its_weapon_enemy_item_and_mission_hooks_execute() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mods");
|
||||
let mut active =
|
||||
ActiveMods::build(&base(), &modding::discover(&dir), &ids(&["aurora"]), None).unwrap();
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.weapons
|
||||
.contains_key(&ContentId::from("aurora:pulse_projectile"))
|
||||
);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.items
|
||||
.contains_key(&ContentId::from("aurora:pulse_cannon"))
|
||||
);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.enemies
|
||||
.contains_key(&ContentId::from("aurora:weaver"))
|
||||
);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.campaigns
|
||||
.contains_key(&ContentId::from("aurora:trials"))
|
||||
);
|
||||
let mission = ContentId::from("aurora:field_trials");
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&mission, &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.get(&ContentId::from("aurora:pulse_cannon")),
|
||||
Some(&1)
|
||||
);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
active.scripts.states["aurora/scripts/weapon.rhai"]["shots"],
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| p.weapon.0 == "aurora:pulse_projectile")
|
||||
.count()
|
||||
>= 3
|
||||
);
|
||||
let entity = world
|
||||
.spawn(&ContentId::from("aurora:weaver"), 60, 30, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == entity)
|
||||
.unwrap()
|
||||
.vx,
|
||||
1
|
||||
);
|
||||
let commands = active
|
||||
.scripts
|
||||
.invoke(
|
||||
"aurora",
|
||||
"scripts/cell.rhai",
|
||||
"on_pickup",
|
||||
&json!({"entity":1}),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
world.apply_commands(&commands, &active.registry).unwrap();
|
||||
assert_eq!(world.player.credits, 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_strips_are_validated_without_the_original_catalog() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let art = json!({"png":"cell.png","logical_width":1,"logical_height":1,"frames":2});
|
||||
install(
|
||||
temp.path(),
|
||||
"alpha",
|
||||
"alpha",
|
||||
json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"art":art}]}),
|
||||
);
|
||||
image::RgbaImage::from_pixel(3, 1, image::Rgba([20, 40, 60, 255]))
|
||||
.save(temp.path().join("alpha/cell.png"))
|
||||
.unwrap();
|
||||
let message = error(ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
));
|
||||
assert!(message.contains("strip width"));
|
||||
image::RgbaImage::from_pixel(4, 2, image::Rgba([20, 40, 60, 255]))
|
||||
.save(temp.path().join("alpha/cell.png"))
|
||||
.unwrap();
|
||||
ActiveMods::build(
|
||||
&base(),
|
||||
&modding::discover(temp.path()),
|
||||
&ids(&["alpha"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_arity_and_source_size_are_checked_before_activation() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
assert!(
|
||||
error(runtime.add("test", "bad.rhai", "fn on_fire(event,state) { state }"))
|
||||
.contains("callbacks require")
|
||||
);
|
||||
assert!(
|
||||
error(runtime.add("test", "huge.rhai", &" ".repeat(1024 * 1024 + 1)))
|
||||
.contains("source exceeds 1 MiB")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callbacks_can_query_player_entities_projectiles_pickups_and_objectives() {
|
||||
let mut registry = base();
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&ContentId::from("base:sector_1_wave_1"))
|
||||
.unwrap()
|
||||
.script = Some("queries.rhai".into());
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime.add("base", "queries.rhai", r#"fn on_mission_start(e,s,r) {
|
||||
#{commands:[],state:#{shield:e.player.shield,inventory:e.player.inventory,
|
||||
enemies:e.entities.len,shots:e.projectiles.len,pickups:e.pickups.len,objectives:e.objectives}}
|
||||
}"#).unwrap();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
let state = &runtime.states["base/queries.rhai"];
|
||||
assert_eq!(state["shield"], 100);
|
||||
assert_eq!(state["inventory"]["base:forward_guns"], 1);
|
||||
assert_eq!(state["enemies"], 0);
|
||||
assert_eq!(state["shots"], 0);
|
||||
assert_eq!(state["pickups"], 0);
|
||||
assert_eq!(state["objectives"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_special_items_run_on_use_once_per_button_press() {
|
||||
let mut registry = base();
|
||||
registry
|
||||
.apply(
|
||||
pack(json!({"items":[{
|
||||
"id":"test:repair","name":"Repair","price":5,"max_count":10,"shield":7,
|
||||
"special":true,"consumable":true,"script":"repair.rhai"
|
||||
}]})),
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"repair.rhai",
|
||||
r#"fn on_use(e,s,r) {
|
||||
let n=if s.contains("n") {s.n+1} else {1};
|
||||
#{commands:[#{op:"credits",amount:5}],state:#{n:n}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.inventory.insert("base:energy".into(), 50);
|
||||
pilot.inventory.insert("test:repair".into(), 2);
|
||||
pilot.selected = Some("test:repair".into());
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let input = Input {
|
||||
special: true,
|
||||
..Input::default()
|
||||
};
|
||||
world.step(input, ®istry, &mut runtime).unwrap();
|
||||
world.step(input, ®istry, &mut runtime).unwrap();
|
||||
assert_eq!(world.player.shield, 57);
|
||||
assert_eq!(world.player.inventory[&ContentId::from("test:repair")], 1);
|
||||
assert_eq!(runtime.states["test/repair.rhai"]["n"], 1);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
world.step(input, ®istry, &mut runtime).unwrap();
|
||||
assert_eq!(world.player.shield, 64);
|
||||
assert_eq!(world.player.credits, 10);
|
||||
assert_eq!(runtime.states["test/repair.rhai"]["n"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_sleep_and_indirect_native_calls_are_not_exposed() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
for source in [
|
||||
"fn on_tick(e,s,r) { timestamp(); #{commands:[],state:s} }",
|
||||
"fn on_tick(e,s,r) { sleep(0); #{commands:[],state:s} }",
|
||||
"fn on_tick(e,s,r) { let f=Fn(\"sleep\"); f.call(0); #{commands:[],state:s} }",
|
||||
"fn on_tick(e,s,r) { let f=Fn(\"timestamp\"); f.call(); #{commands:[],state:s} }",
|
||||
] {
|
||||
runtime.begin_tick();
|
||||
runtime.add("test", "unsafe.rhai", source).unwrap();
|
||||
let message = error(runtime.invoke("test", "unsafe.rhai", "on_tick", &json!({}), 1));
|
||||
assert!(message.contains("Function not found"), "{message}");
|
||||
}
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_eval_and_imports_cannot_be_reintroduced_by_function_pointers() {
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
assert!(
|
||||
runtime
|
||||
.add("test", "eval.rhai", "fn on_tick(e,s,r) { eval(\"42\"); }")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.add("test", "imports.rhai", "import \"external\" as x;")
|
||||
.is_err()
|
||||
);
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"indirect.rhai",
|
||||
"fn on_tick(e,s,r) { let f=Fn(\"eval\"); f.call(\"42\"); #{commands:[],state:s} }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
runtime
|
||||
.invoke("test", "indirect.rhai", "on_tick", &json!({}), 1)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_inventory_commands_preserve_the_twenty_item_capacity() {
|
||||
let mut registry = base();
|
||||
let extra: Vec<_> = (0..19)
|
||||
.map(|n| item(&format!("test:item_{n}"), 1))
|
||||
.collect();
|
||||
registry
|
||||
.apply(pack(json!({"items":extra})), "test")
|
||||
.unwrap();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
for n in 0..18 {
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::GiveItem {
|
||||
item: format!("test:item_{n}"),
|
||||
count: 1,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(world.player.inventory.len(), 20);
|
||||
let before = world.digest();
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::GiveItem {
|
||||
item: "test:item_18".into(),
|
||||
count: 1
|
||||
}],
|
||||
®istry
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(before, world.digest());
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::GiveItem {
|
||||
item: "test:item_0".into(),
|
||||
count: 1,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.inventory[&ContentId::from("test:item_0")], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_objectives_remain_updatable_at_the_capacity_limit() {
|
||||
let registry = base();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
for n in 0..1024 {
|
||||
world.objectives.insert(format!("test:{n}"), 0);
|
||||
}
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Objective {
|
||||
key: "test:0".into(),
|
||||
value: 10,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.objectives["test:0"], 10);
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Objective {
|
||||
key: "test:new".into(),
|
||||
value: 1
|
||||
}],
|
||||
®istry
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(world.objectives.len(), 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enemy_weapons_and_enemies_receive_fire_and_damage_hooks() {
|
||||
let mut registry = base();
|
||||
registry.apply(pack(json!({
|
||||
"weapons":[{"id":"test:bolt","name":"Bolt","damage":1,"speed":1,"cooldown":2,"behavior":"straight","target":"all","script":"weapon.rhai"}],
|
||||
"enemies":[{"id":"test:fighter","name":"Fighter","health":100,"speed":1,"cooldown":2,"weapon":"test:bolt","script":"enemy.rhai"}]
|
||||
})), "test").unwrap();
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"weapon.rhai",
|
||||
r#"fn on_fire(e,s,r) {
|
||||
#{commands:[],state:#{hostile:e.hostile,entity:e.entity}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"enemy.rhai",
|
||||
r#"fn on_fire(e,s,r) { #{commands:[],state:#{fired:true}} }
|
||||
fn on_hit(e,s,r) { s.hit=e.damage; s.scripted=e.scripted; #{commands:[],state:s} }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let entity = world
|
||||
.spawn(&ContentId::from("test:fighter"), 30, 30, ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert_eq!(runtime.states["test/weapon.rhai"]["hostile"], true);
|
||||
assert_eq!(runtime.states["test/weapon.rhai"]["entity"], entity);
|
||||
assert_eq!(runtime.states["test/enemy.rhai"]["fired"], true);
|
||||
world
|
||||
.apply_commands(&[Command::Damage { entity, amount: 7 }], ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert_eq!(runtime.states["test/enemy.rhai"]["hit"], 7);
|
||||
assert_eq!(runtime.states["test/enemy.rhai"]["scripted"], true);
|
||||
}
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
sound,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn wav(channels: u16, bits: u16, data: &[u8]) -> Vec<u8> {
|
||||
let align = channels * bits / 8;
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend((36u32 + data.len() as u32 + (data.len() as u32 % 2)).to_le_bytes());
|
||||
bytes.extend(b"WAVEfmt ");
|
||||
bytes.extend(16u32.to_le_bytes());
|
||||
bytes.extend(1u16.to_le_bytes());
|
||||
bytes.extend(channels.to_le_bytes());
|
||||
bytes.extend(11025u32.to_le_bytes());
|
||||
bytes.extend((11025u32 * align as u32).to_le_bytes());
|
||||
bytes.extend(align.to_le_bytes());
|
||||
bytes.extend(bits.to_le_bytes());
|
||||
bytes.extend(b"data");
|
||||
bytes.extend((data.len() as u32).to_le_bytes());
|
||||
bytes.extend(data);
|
||||
if data.len() % 2 == 1 {
|
||||
bytes.push(0);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
fn registry() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let pack: ContentPack=serde_json::from_value(json!({
|
||||
"items":[{"id":"base:energy","name":"Shield","price":0,"max_count":100},
|
||||
{"id":"base:forward_guns","name":"Guns","price":0}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":1,"auto_complete":false,"script":"mission.rhai"}],
|
||||
"sounds":[{"id":"base:test","name":"Test","file":"test.wav"}]
|
||||
})).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
#[test]
|
||||
fn wav_handles_padding_unsigned_eight_bit_and_stereo_sixteen_bit() {
|
||||
let decoded = sound::decode(&wav(1, 8, &[0, 128, 255])).unwrap();
|
||||
assert_eq!(decoded.data, [0, 128, 255]);
|
||||
assert_eq!(decoded.rate, 11025);
|
||||
let samples: Vec<_> = [-32768i16, 32767, -32768, -32768, 32767, 32767]
|
||||
.into_iter()
|
||||
.flat_map(i16::to_le_bytes)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sound::decode(&wav(2, 16, &samples)).unwrap().data,
|
||||
[128, 0, 255]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_wave_ranges_formats_and_padding_fail() {
|
||||
let bytes = wav(1, 8, &[128, 200, 0]);
|
||||
for end in 0..bytes.len() {
|
||||
assert!(sound::decode(&bytes[..end]).is_err());
|
||||
}
|
||||
assert!(sound::decode(&wav(3, 8, &[0, 0, 0])).is_err());
|
||||
assert!(sound::decode(&wav(1, 32, &[0, 0, 0, 0])).is_err());
|
||||
let mut huge = bytes.clone();
|
||||
huge[40..44].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(sound::decode(&huge).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn scripted_completion_emits_end_hook_once_and_sound_ids_are_checked() {
|
||||
let registry = registry();
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
scripts.add("base","mission.rhai",r#"
|
||||
fn on_tick(event,state,random) { #{commands:[#{op:"complete"}],state:state} }
|
||||
fn on_mission_end(event,state,random) { #{commands:[#{op:"play_sound",sound:"base:test"}],state:#{ended:1}} }
|
||||
"#).unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Complete);
|
||||
assert_eq!(world.mod_sounds.len(), 1);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.mod_sounds.len(), 1);
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::PlaySound {
|
||||
sound: "base:missing".into()
|
||||
}],
|
||||
®istry
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn package_local_sounds_validate_and_missing_files_reject_activation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let package = dir.path().join("tone");
|
||||
std::fs::create_dir_all(&package).unwrap();
|
||||
std::fs::write(
|
||||
package.join("mod.toml"),
|
||||
"id='tone'\nname='Tone'\nversion='1.0.0'\napi_version=1\ncontent='content.json'\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
package.join("content.json"),
|
||||
serde_json::to_vec(&json!({"sounds":[{"id":"tone:shot","name":"Shot","file":"shot.wav"}]}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(package.join("shot.wav"), wav(1, 8, &[128, 200, 50])).unwrap();
|
||||
let found = modding::discover(dir.path());
|
||||
assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_ok());
|
||||
std::fs::remove_file(package.join("shot.wav")).unwrap();
|
||||
let found = modding::discover(dir.path());
|
||||
assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_err());
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,852 +0,0 @@
|
||||
//! 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();
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
//! Validate the shipped weapon definitions/scripts using synthetic PNG and WAV inputs.
|
||||
//! No original or locally generated game artwork is needed in CI.
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const PACKAGES: [&str; 3] = ["pulse_cannon", "seeker_swarm", "ion_lance"];
|
||||
fn content(value: Value) -> ContentPack {
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
fn base() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(content(json!({
|
||||
"items": [{"id":"base:energy","name":"Shield","price":100,"max_count":100}],
|
||||
"missions": [{"id":"base:practice","name":"Practice","duration":2000}],
|
||||
"enemies": [
|
||||
{"id":"base:target","name":"Stationary target","health":100,"speed":0},
|
||||
{"id":"base:ground_target","name":"Ground target","health":100,"speed":0,"ground":true}
|
||||
]
|
||||
})), "base").unwrap();
|
||||
registry
|
||||
}
|
||||
fn wav() -> Vec<u8> {
|
||||
let mut bytes = b"RIFF".to_vec();
|
||||
bytes.extend(164u32.to_le_bytes());
|
||||
bytes.extend(b"WAVEfmt ");
|
||||
bytes.extend(16u32.to_le_bytes());
|
||||
bytes.extend(1u16.to_le_bytes());
|
||||
bytes.extend(1u16.to_le_bytes());
|
||||
bytes.extend(11025u32.to_le_bytes());
|
||||
bytes.extend(22050u32.to_le_bytes());
|
||||
bytes.extend(2u16.to_le_bytes());
|
||||
bytes.extend(16u16.to_le_bytes());
|
||||
bytes.extend(b"data");
|
||||
bytes.extend(128u32.to_le_bytes());
|
||||
bytes.extend([0; 128]);
|
||||
bytes
|
||||
}
|
||||
fn install_synthetic(root: &Path, id: &str) {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("examples/mods")
|
||||
.join(id);
|
||||
let target = root.join(id);
|
||||
fs::create_dir_all(target.join("scripts")).unwrap();
|
||||
fs::create_dir_all(target.join("art")).unwrap();
|
||||
fs::create_dir_all(target.join("audio")).unwrap();
|
||||
for file in [
|
||||
"mod.toml",
|
||||
"content.json",
|
||||
"scripts/weapon.rhai",
|
||||
"scripts/equipment.rhai",
|
||||
] {
|
||||
fs::copy(source.join(file), target.join(file)).unwrap();
|
||||
}
|
||||
let definitions: Value =
|
||||
serde_json::from_slice(&fs::read(target.join("content.json")).unwrap()).unwrap();
|
||||
for entry in definitions["weapons"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.chain(definitions["items"].as_array().unwrap())
|
||||
{
|
||||
let art = &entry["art"];
|
||||
for (property, scale) in [("png", 1), ("hd_png", 4)] {
|
||||
let image = image::RgbaImage::from_pixel(
|
||||
art["logical_width"].as_u64().unwrap() as u32 * scale,
|
||||
art["logical_height"].as_u64().unwrap() as u32 * scale,
|
||||
image::Rgba([60, 90, 120, 255]),
|
||||
);
|
||||
image
|
||||
.save(target.join(art[property].as_str().unwrap()))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
for sound in definitions["sounds"].as_array().unwrap() {
|
||||
fs::write(target.join(sound["file"].as_str().unwrap()), wav()).unwrap();
|
||||
}
|
||||
}
|
||||
fn activate(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for id in ids {
|
||||
install_synthetic(root.path(), id);
|
||||
}
|
||||
let discovery = modding::discover(root.path());
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let active = ActiveMods::build(
|
||||
&base(),
|
||||
&discovery,
|
||||
&ids.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
(root, active)
|
||||
}
|
||||
fn pilot(id: &str, registry: &Registry) -> Pilot {
|
||||
let equipment = ContentId::new(format!("{id}:equipment")).unwrap();
|
||||
Pilot {
|
||||
inventory: [(equipment.clone(), 1), ("base:energy".into(), 100)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
selected: registry.items[&equipment].special.then_some(equipment),
|
||||
mission: "base:practice".into(),
|
||||
..Pilot::default()
|
||||
}
|
||||
}
|
||||
fn world(id: &str, registry: &Registry) -> World {
|
||||
World::new(&"base:practice".into(), &pilot(id, registry), registry).unwrap()
|
||||
}
|
||||
fn state<'a>(active: &'a ActiveMods, id: &str) -> &'a Value {
|
||||
&active.scripts.states[&format!("{id}/scripts/weapon.rhai")]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_weapon_package_is_independent_and_the_three_can_coexist() {
|
||||
for id in PACKAGES {
|
||||
let (_root, active) = activate(&[id]);
|
||||
assert_eq!(active.pins.len(), 1);
|
||||
assert_eq!(active.pins[0].version, "1.0.0");
|
||||
assert_eq!(active.registry.weapons.len(), 1);
|
||||
assert_eq!(active.registry.sounds.len(), 2);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.items
|
||||
.contains_key(&format!("{id}:equipment").as_str().into())
|
||||
);
|
||||
assert!(
|
||||
active
|
||||
.registry
|
||||
.weapons
|
||||
.contains_key(&format!("{id}:projectile").as_str().into())
|
||||
);
|
||||
}
|
||||
let (_root, all) = activate(&PACKAGES);
|
||||
assert_eq!(all.pins.len(), 3);
|
||||
assert_eq!(all.registry.weapons.len(), 3);
|
||||
assert_eq!(all.registry.sounds.len(), 6);
|
||||
assert!(all.registry.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shop_charges_each_price_and_rejects_duplicate_equipment() {
|
||||
let (_root, active) = activate(&PACKAGES);
|
||||
for id in PACKAGES {
|
||||
let item: ContentId = format!("{id}:equipment").as_str().into();
|
||||
let price = active.registry.items[&item].price;
|
||||
let mut pilot = Pilot {
|
||||
credits: price,
|
||||
inventory: Default::default(),
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.buy(&item, &active.registry).unwrap();
|
||||
assert_eq!(pilot.credits, 0);
|
||||
assert_eq!(pilot.inventory[&item], 1);
|
||||
assert!(pilot.buy(&item, &active.registry).is_err());
|
||||
pilot.sell(&item, &active.registry).unwrap();
|
||||
assert!(pilot.credits > 0);
|
||||
assert_eq!(pilot.inventory.get(&item).copied().unwrap_or(0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_cannon_emits_paired_rounds_and_every_third_burst_is_scripted() {
|
||||
let (_root, mut active) = activate(&["pulse_cannon"]);
|
||||
let mut world = world("pulse_cannon", &active.registry);
|
||||
for _ in 0..19 {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(state(&active, "pulse_cannon")["bursts"], 3);
|
||||
assert_eq!(world.projectiles.len(), 8);
|
||||
assert_eq!(world.projectiles.iter().filter(|p| p.vx != 0).count(), 2);
|
||||
assert_eq!(world.mod_sounds, vec![ContentId::from("pulse_cannon:fire")]);
|
||||
assert!(!world.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_hits_deal_damage_and_run_effect_audio_and_hit_state_callbacks() {
|
||||
let (_root, mut active) = activate(&["pulse_cannon"]);
|
||||
let mut world = world("pulse_cannon", &active.registry);
|
||||
world
|
||||
.spawn(&"base:target".into(), 140, 100, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
let mut hit_sound = false;
|
||||
for _ in 0..8 {
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
hit_sound |= world.mod_sounds.contains(&"pulse_cannon:impact".into());
|
||||
}
|
||||
assert_eq!(world.enemies[0].health, 92);
|
||||
assert_eq!(state(&active, "pulse_cannon")["hits"], 2);
|
||||
assert!(hit_sound);
|
||||
assert!(!world.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeker_swarm_homes_and_fragmentation_damages_nearby_enemies() {
|
||||
let (_root, mut active) = activate(&["seeker_swarm"]);
|
||||
let mut world = world("seeker_swarm", &active.registry);
|
||||
let primary = world
|
||||
.spawn(&"base:target".into(), 180, 110, &active.registry)
|
||||
.unwrap();
|
||||
let neighbor = world
|
||||
.spawn(&"base:ground_target".into(), 180, 110, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.projectiles.len(), 3);
|
||||
assert!(world.projectiles.iter().all(|p| p.vx > 0));
|
||||
for _ in 0..12 {
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
let target = world.enemies.iter().find(|e| e.entity == primary).unwrap();
|
||||
let nearby = world.enemies.iter().find(|e| e.entity == neighbor).unwrap();
|
||||
assert_eq!(target.health, 82);
|
||||
assert_eq!(nearby.health, 94);
|
||||
assert_eq!(state(&active, "seeker_swarm")["hits"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ion_lance_pierces_air_and_ground_targets_in_its_column_only() {
|
||||
let (_root, mut active) = activate(&["ion_lance"]);
|
||||
let mut world = world("ion_lance", &active.registry);
|
||||
world
|
||||
.spawn(&"base:target".into(), 144, 100, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.spawn(&"base:ground_target".into(), 144, 60, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.spawn(&"base:target".into(), 220, 60, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
|
||||
[84, 84, 100]
|
||||
);
|
||||
assert_eq!(state(&active, "ion_lance")["hits"], 4);
|
||||
assert!(world.mod_sounds.contains(&"ion_lance:impact".into()));
|
||||
assert!(!world.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipped_specials_and_primary_upgrade_fire_without_consuming_equipment() {
|
||||
let (_root, mut active) = activate(&PACKAGES);
|
||||
let mut pilot = pilot("pulse_cannon", &active.registry);
|
||||
pilot.inventory.insert("seeker_swarm:equipment".into(), 1);
|
||||
pilot.inventory.insert("ion_lance:equipment".into(), 1);
|
||||
pilot.selected = Some("ion_lance:equipment".into());
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state(&active, "pulse_cannon")["bursts"], 1);
|
||||
assert_eq!(state(&active, "ion_lance")["bursts"], 1);
|
||||
assert!(
|
||||
!active
|
||||
.scripts
|
||||
.states
|
||||
.contains_key("seeker_swarm/scripts/weapon.rhai")
|
||||
);
|
||||
for id in PACKAGES {
|
||||
assert_eq!(
|
||||
world.player.inventory[&format!("{id}:equipment").as_str().into()],
|
||||
1
|
||||
);
|
||||
}
|
||||
world.player.selected = Some("seeker_swarm:equipment".into());
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
special: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state(&active, "seeker_swarm")["bursts"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_bursts_and_state_resume_deterministically() {
|
||||
let (_root, mut active) = activate(&["pulse_cannon"]);
|
||||
let mut world = world("pulse_cannon", &active.registry);
|
||||
for _ in 0..10 {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut resumed = world.clone();
|
||||
let (_other_root, mut other) = activate(&["pulse_cannon"]);
|
||||
other.scripts.states = active.scripts.states.clone();
|
||||
other.scripts.rng = active.scripts.rng.clone();
|
||||
for _ in 0..20 {
|
||||
let input = Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
};
|
||||
world
|
||||
.step(input, &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
resumed
|
||||
.step(input, &other.registry, &mut other.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(world.digest(), resumed.digest());
|
||||
assert_eq!(active.scripts.states, other.scripts.states);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&active.scripts.rng).unwrap(),
|
||||
serde_json::to_value(&other.scripts.rng).unwrap()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user