Make RustRaptor the repository root and track its playable bundle

This commit is contained in:
raptorreplica committed 2026-10-11 16:33:37 +11:00
1 parent 4b1edefeec
commit 8f459796b1
9860 files changed
+21084 -61019

No files matched your search

+487
View File
@@ -0,0 +1,487 @@
use raptor_replica::audio::{DigitalSample, Mixer, MusScore};
use std::{fs, path::Path, process::Command};
fn instruments(double: bool) -> Vec<u8> {
let mut bytes = b"#OPL_II#".to_vec();
for n in 0..175 {
let mut instrument = [0u8; 36];
instrument[0] = if n == 1 {
1
} else if double {
4
} else {
0
};
instrument[2] = 128;
instrument[3] = 48;
for part in 0..2 {
let start = 4 + part * 16;
instrument[start..start + 6].copy_from_slice(&[0x21, 0xf2, 0x52, 0, 0, 32]);
instrument[start + 6] = 0;
instrument[start + 7..start + 13].copy_from_slice(&[0x21, 0xf2, 0x52, 0, 0, 0]);
}
bytes.extend(instrument);
}
bytes
}
fn mus(body: &[u8], channels: u16) -> Vec<u8> {
let mut bytes = b"MUS\x1a".to_vec();
bytes.extend((body.len() as u16).to_le_bytes());
bytes.extend(16u16.to_le_bytes());
bytes.extend(channels.to_le_bytes());
bytes.extend([0; 6]);
bytes.extend(body);
bytes
}
fn score() -> Vec<u8> {
mus(
&[
0x40, 0, 0, 0x90, 188, 100, 10, 0xa0, 144, 5, 0xc0, 3, 90, 5, 0x80, 60, 10, 0x60,
],
1,
)
}
fn sample(data: Vec<u8>) -> DigitalSample {
DigitalSample { rate: 11025, data }
}
fn patch(data: &[u8]) -> Vec<u8> {
let mut bytes = vec![3, 0];
bytes.extend(11025u16.to_le_bytes());
bytes.extend(((data.len() + 32) as u32).to_le_bytes());
bytes.extend([0xff; 16]);
bytes.extend(data);
bytes.extend([0; 16]);
bytes
}
fn mixer() -> Mixer {
Mixer::new(44100, &instruments(false)).unwrap()
}
#[test]
fn digital_decoder_excludes_both_padding_regions() {
let decoded = DigitalSample::decode(&patch(&[0, 64, 128, 192, 255])).unwrap();
assert_eq!(decoded.rate, 11025);
assert_eq!(decoded.data, vec![0, 64, 128, 192, 255]);
assert!(DigitalSample::decode(&patch(&[])).is_err());
}
#[test]
fn music_events_start_on_the_first_seventy_hertz_service_tick() {
let mut mixer = mixer();
mixer.music_volume = 127;
mixer.play(MusScore::decode(&score()).unwrap(), false);
let mut prefix = vec![1; 630 * 2];
mixer.mix(&mut prefix);
assert!(prefix.iter().all(|n| *n == 0));
let mut following = vec![0; 3000 * 2];
mixer.mix(&mut following);
assert!(following.iter().any(|n| *n != 0));
}
#[test]
fn fm_and_digital_output_do_not_depend_on_audio_callback_size() {
let table = instruments(true);
let mut whole = Mixer::new(44117, &table).unwrap();
let mut chunked = Mixer::new(44117, &table).unwrap();
for m in [&mut whole, &mut chunked] {
m.play_with_fade(MusScore::decode(&score()).unwrap(), true, true);
m.sample(
sample((0..255).cycle().take(5000).collect()),
37,
192,
100,
1,
);
}
let mut expected = vec![0; 44117 * 2];
whole.mix(&mut expected);
let mut actual = vec![0; expected.len()];
let mut frame = 0;
for count in [1usize, 3, 17, 512, 127].into_iter().cycle() {
let count = count.min(actual.len() / 2 - frame);
chunked.mix(&mut actual[frame * 2..(frame + count) * 2]);
frame += count;
if frame == actual.len() / 2 {
break;
}
}
assert_eq!(actual, expected);
}
#[test]
fn digital_mixing_uses_the_original_11025_hertz_sample_hold() {
let mut mixer = mixer();
mixer.fx_volume = 127;
mixer.sample(sample(vec![255, 255]), 128, 128, 127, 1);
let mut out = [0; 24];
mixer.mix(&mut out);
assert_eq!(&out[..6], &[0; 6]);
assert!(out[6] > 0 && out[7] > 0);
assert_eq!(&out[6..8], &out[8..10]);
assert_eq!(&out[6..8], &out[10..12]);
assert_eq!(&out[6..8], &out[12..14]);
assert_eq!(&out[22..24], &[0, 0]);
}
#[test]
fn pitch_zero_is_a_valid_quarter_speed_pitch() {
let mut normal = mixer();
let mut slow = mixer();
normal.fx_volume = 127;
slow.fx_volume = 127;
normal.sample(sample(vec![255; 8]), 128, 128, 127, 1);
slow.sample(sample(vec![255; 8]), 128, 0, 127, 1);
let mut fast = [0; 100];
let mut quarter = [0; 100];
normal.mix(&mut fast);
slow.mix(&mut quarter);
assert!(fast[80..].iter().all(|v| *v == 0));
assert!(quarter[80..].iter().any(|v| *v > 0));
}
#[test]
fn lower_numeric_digital_priority_wins() {
let mut mixer = mixer();
mixer.fx_volume = 127;
mixer.set_fx_channels(1);
mixer.sample(sample(vec![255; 1000]), 128, 128, 127, 1);
mixer.sample(sample(vec![0; 1000]), 128, 128, 127, 8);
let mut protected = [0; 64];
mixer.mix(&mut protected);
assert!(protected[8..].iter().all(|v| *v > 0));
mixer.sample(sample(vec![0; 1000]), 128, 128, 127, 0);
let mut replaced = [0; 64];
mixer.mix(&mut replaced);
assert!(replaced[8..].iter().all(|v| *v < 0));
}
#[test]
fn digital_voices_clip_together_before_being_added_to_fm() {
let mut mixer = mixer();
mixer.fx_volume = 127;
mixer.set_fx_channels(8);
for _ in 0..8 {
mixer.sample(sample(vec![255; 100]), 128, 128, 127, 1);
}
let mut out = [0; 64];
mixer.mix(&mut out);
assert_eq!(out[20], 127 * 128);
assert_eq!(out[21], 127 * 128);
}
#[test]
fn changing_fx_volume_mutes_and_restores_already_playing_samples() {
let mut mixer = mixer();
mixer.fx_volume = 127;
mixer.sample(sample(vec![255; 4000]), 127, 128, 127, 0);
let mut before = [0; 128];
mixer.mix(&mut before);
assert!(before[16..].iter().all(|v| *v > 0));
mixer.fx_volume = 0;
let mut muted = [99; 256];
mixer.mix(&mut muted);
assert!(muted.iter().all(|v| *v == 0));
mixer.fx_volume = 127;
let mut restored = [0; 128];
mixer.mix(&mut restored);
assert!(restored[16..].iter().all(|v| *v > 0));
mixer.fx_volume = 32;
let mut reduced = [0; 128];
mixer.mix(&mut reduced);
assert!(reduced[16..].iter().all(|v| *v > 0));
assert!(reduced[16..].iter().max().unwrap() < &(restored[16..].iter().max().unwrap() / 2));
}
#[test]
fn music_mute_is_immediate_and_restores_an_active_score_without_muting_effects() {
let mut mixer = mixer();
mixer.music_volume = 127;
mixer.play(
MusScore::decode(&mus(&[0x90, 188, 100, 127, 0x60], 1)).unwrap(),
false,
);
let mut before = vec![0; 3000 * 2];
mixer.mix(&mut before);
assert!(before.iter().any(|v| *v != 0));
mixer.music_volume = 0;
let mut muted = [99; 512];
mixer.mix(&mut muted);
assert!(muted.iter().all(|v| *v == 0));
mixer.music_volume = 127;
let mut restored = vec![0; 3000 * 2];
mixer.mix(&mut restored);
assert!(restored.iter().any(|v| *v != 0));
mixer.music_volume = 0;
mixer.fx_volume = 127;
mixer.sample(sample(vec![255; 4000]), 127, 128, 127, 0);
mixer.mix(&mut muted);
assert!(muted[16..].iter().all(|v| *v > 0));
}
#[test]
fn bounded_capture_records_exact_callback_output_without_changing_it() {
let mut captured = mixer();
let mut normal = mixer();
captured.start_capture(1).unwrap();
assert!(normal.start_capture(0).is_err());
assert!(normal.start_capture(121).is_err());
for mixer in [&mut captured, &mut normal] {
mixer.fx_volume = 127;
mixer.sample(
sample((0..255).cycle().take(12000).collect()),
0,
128,
100,
0,
);
}
let mut actual = vec![0; 44100 * 4];
let mut expected = vec![0; actual.len()];
captured.mix(&mut actual);
normal.mix(&mut expected);
assert_eq!(actual, expected);
let capture = captured.take_capture().unwrap();
assert_eq!(capture.rate, 44100);
assert_eq!(capture.samples.len(), 44100 * 2);
assert_eq!(capture.samples, actual[..44100 * 2]);
assert!(capture.samples.chunks_exact(2).any(|s| s[0] != 0));
// Native DSP panning deliberately retains a small opposite-channel bleed.
let left: i64 = capture
.samples
.chunks_exact(2)
.map(|s| i64::from(s[0]).abs())
.sum();
let right: i64 = capture
.samples
.chunks_exact(2)
.map(|s| i64::from(s[1]).abs())
.sum();
assert!(left > right * 4);
let wav = raptor_replica::sound::encode_capture(&capture).unwrap();
assert_eq!(&wav[..4], b"RIFF");
assert_eq!(u16::from_le_bytes(wav[22..24].try_into().unwrap()), 2);
assert_eq!(
u32::from_le_bytes(wav[40..44].try_into().unwrap()),
44100 * 4
);
}
#[test]
fn malformed_scores_controller_values_and_missing_end_events_are_rejected() {
for body in [
vec![0x90, 188],
vec![0x90, 188, 200, 1, 0x60],
vec![0xc0, 3, 255, 1, 0x60],
vec![0x30, 9, 0x60],
vec![0x90, 188, 127, 1],
] {
assert!(MusScore::decode(&mus(&body, 1)).is_err());
}
assert!(MusScore::decode(&mus(&[0x60], 16)).is_err());
}
#[test]
fn a_zero_duration_loop_and_an_odd_output_slice_terminate_cleanly() {
let mut mixer = mixer();
mixer.play(MusScore::decode(&mus(&[0x60], 1)).unwrap(), true);
let mut out = [99; 2001];
mixer.mix(&mut out);
assert_eq!(out[2000], 0);
}
#[test]
#[ignore = "requires the original untracked catalog"]
fn original_music_and_sound_assets_decode_locally() {
use raptor_replica::assets::Catalog;
let root = std::env::var_os("RAPTOR_TEST_DATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../data"));
let catalog = Catalog::open(root).unwrap();
let mut music = 0;
let mut sounds = 0;
for entry in catalog.entries.values() {
let bytes = catalog.read(entry.id).unwrap();
if bytes.starts_with(b"MUS\x1a") {
MusScore::decode(&bytes).unwrap();
music += 1;
}
if bytes.get(..4) == Some(&[3, 0, 0x11, 0x2b]) {
DigitalSample::decode(&bytes).unwrap();
sounds += 1;
}
}
assert_eq!(music, 16);
assert_eq!(sounds, 26, "digital samples");
Mixer::new(44100, &catalog.named("GENMIDI_OP2").unwrap()).unwrap();
}
const NATIVE_REFERENCE: &str = r#"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
#include <fstream>
#include <iterator>
#include "SDL.h"
#include "musapi.h"
#include "i_oplmusic.h"
#include "dspapi.h"
#include "cards.h"
int fx_freq=44100, gsshack=0, gssrate=0, sys_midi=0;
musdevice_t mus_device_alsa={}, mus_device_tsf={};
void SND_Lock(){} void SND_Unlock(){} void GSS_Service(){} void GSS_Poll(){}
extern "C" Uint32 SDL_GetTicks(){return 0;}
std::vector<char> read(const char *path){std::ifstream in(path,std::ios::binary);return {std::istreambuf_iterator<char>(in),{}};}
int main(int argc,char **argv){
if(argc!=9)return 2;
auto gen=read(argv[1]),score=read(argv[2]),patch=read(argv[3]);
int frames=std::atoi(argv[4]),mode=std::atoi(argv[5]),fade=std::atoi(argv[6]),loop=std::atoi(argv[7]);
std::vector<int16_t> out(frames*2,0);
if(mode!=1){LoadInstrumentTable(gen.data());MUS_Init(M_ADLIB,0);MUS_SetVolume(127);MUS_PlaySong(score.data(),loop,fade);}
if(mode!=0){DSP_Init(8,11025);DSP_StartPatch((dsp_t*)patch.data(),37,192,85,1);}
if(mode!=1)MUS_Mix(out.data(),frames);
if(mode!=0)DSP_Mix(out.data(),frames);
FILE *file=fopen(argv[8],"wb");if(!file)return 3;
fwrite(out.data(),sizeof(int16_t),out.size(),file);fclose(file);return 0;
}
"#;
#[test]
#[ignore = "requires g++ and SDL2 development headers; synthetic assets only"]
fn rust_fm_mus_and_digital_pcm_match_the_retained_native_reference() {
let temp = tempfile::tempdir().unwrap();
let binary = compile_reference(temp.path());
let patch_data: Vec<_> = (0..255).cycle().take(2000).collect();
let patch_path = temp.path().join("patch.bin");
fs::write(&patch_path, patch(&patch_data)).unwrap();
for (double, mode, fade, looping) in [
(false, 0, false, false),
(true, 0, false, false),
(false, 1, false, false),
(true, 2, true, true),
] {
let bank = instruments(double);
let song = score();
let frames = 44100;
let gen_path = temp.path().join("genmidi.bin");
fs::write(&gen_path, &bank).unwrap();
let score_path = temp.path().join("score.bin");
fs::write(&score_path, &song).unwrap();
let output = temp.path().join("reference.pcm");
let status = Command::new(&binary)
.arg(&gen_path)
.arg(&score_path)
.arg(&patch_path)
.arg(frames.to_string())
.arg(mode.to_string())
.arg(i32::from(fade).to_string())
.arg(i32::from(looping).to_string())
.arg(&output)
.status()
.unwrap();
assert!(status.success());
let expected = fs::read(output).unwrap();
let mut mixer = Mixer::new(44100, &bank).unwrap();
mixer.music_volume = 127;
mixer.fx_volume = 127;
mixer.set_fx_channels(8);
if mode != 1 {
mixer.play_with_fade(MusScore::decode(&song).unwrap(), looping, fade);
}
if mode != 0 {
mixer.sample(sample(patch_data.clone()), 37, 192, 85, 1);
}
let mut out = vec![0; frames * 2];
mixer.mix(&mut out);
let actual: Vec<_> = out.iter().flat_map(|v| v.to_le_bytes()).collect();
if let Some(index) = actual
.chunks_exact(2)
.zip(expected.chunks_exact(2))
.position(|(a, b)| a != b)
{
let want = i16::from_le_bytes(expected[index * 2..index * 2 + 2].try_into().unwrap());
panic!(
"PCM mismatch double={double} mode={mode} fade={fade} loop={looping}: frame={} channel={} got={} expected={want}",
index / 2,
index % 2,
out[index]
);
}
assert_eq!(actual.len(), expected.len());
}
}
fn compile_reference(directory: &Path) -> std::path::PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let source = directory.join("reference.cpp");
fs::write(&source, NATIVE_REFERENCE).unwrap();
let binary = directory.join("reference");
let mut compile = Command::new("g++");
compile
.args(["-std=c++17", "-O2"])
.arg("-I")
.arg(root.join("../raptor/src"));
compile.arg("-I").arg("/usr/include/SDL2");
if let Some(home) = std::env::var_os("HOME") {
compile
.arg("-I")
.arg(Path::new(&home).join(".local/include/SDL2"));
}
compile.arg(&source);
for path in ["i_oplmusic.cpp", "opl3.cpp", "musapi.cpp", "dspapi.cpp"] {
compile.arg(root.join("../raptor/src").join(path));
}
let result = compile.arg("-o").arg(&binary).output().unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
binary
}
#[test]
#[ignore = "requires original untracked music assets, g++ and SDL2 headers"]
fn original_intro_pcm_matches_the_retained_native_reference() {
use raptor_replica::assets::Catalog;
let temp = tempfile::tempdir().unwrap();
let root = std::env::var_os("RAPTOR_TEST_DATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../data"));
let catalog = Catalog::open(root).unwrap();
let bank = catalog.named("GENMIDI_OP2").unwrap();
let song = catalog.named("RINTRO_MUS").unwrap();
let binary = compile_reference(temp.path());
fs::write(temp.path().join("genmidi.bin"), &bank).unwrap();
fs::write(temp.path().join("score.bin"), &song).unwrap();
fs::write(temp.path().join("patch.bin"), patch(&[128; 33])).unwrap();
let frames = 44100 * 4;
let output = temp.path().join("reference.pcm");
let status = Command::new(binary)
.arg(temp.path().join("genmidi.bin"))
.arg(temp.path().join("score.bin"))
.arg(temp.path().join("patch.bin"))
.arg(frames.to_string())
.args(["0", "1", "1"])
.arg(&output)
.status()
.unwrap();
assert!(status.success());
let expected = fs::read(output).unwrap();
let mut mixer = Mixer::new(44100, &bank).unwrap();
mixer.music_volume = 127;
mixer.play_with_fade(MusScore::decode(&song).unwrap(), true, true);
let mut actual = vec![0i16; frames * 2];
mixer.mix(&mut actual);
for (index, (got, bytes)) in actual.iter().zip(expected.chunks_exact(2)).enumerate() {
let want = i16::from_le_bytes(bytes.try_into().unwrap());
assert_eq!(
*got,
want,
"original intro frame {} channel {}",
index / 2,
index % 2
);
}
assert_eq!(actual.len() * 2, expected.len());
}
+201
View File
@@ -0,0 +1,201 @@
//! Original-data integration coverage. Run explicitly with --ignored; no game data is tracked.
//! The assisted campaign fixture is described in tools/campaign_check.rs.
#[path = "../tools/campaign_check.rs"]
mod campaign_check;
use raptor_replica::{
assets::{Catalog, decode_demo},
content::ContentId,
modding::ScriptRuntime,
persistence::Pilot,
simulation::{Input, World},
};
use std::{collections::BTreeSet, path::PathBuf};
fn catalog_registry() -> (Catalog, raptor_replica::content::Registry) {
let root = std::env::var_os("RAPTOR_TEST_DATA")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data"));
campaign_check::catalog_registry(&root).unwrap()
}
fn registry() -> raptor_replica::content::Registry {
catalog_registry().1
}
#[test]
#[ignore = "requires original untracked catalog; assisted inspection, not ordinary gameplay wins"]
fn every_original_wave_reaches_its_end_on_all_four_difficulties() {
let registry = registry();
assert_eq!(registry.missions.len(), 27);
let mut failures = vec![];
let mut reports = vec![];
for difficulty in 0..=3 {
for sector in 1..=3 {
for wave in 1..=9 {
let report =
campaign_check::run_wave(&registry, 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(&registry).unwrap();
pilot.validate(&registry, &[]).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, &registry).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()
},
&registry,
&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, &registry).unwrap();
pilot.mission = format!("base:sector_{}_wave_{}", sector + 1, wave + 1)
.as_str()
.into();
let mut world = World::new(&pilot.mission, &pilot, &registry).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()
},
&registry,
&mut scripts,
)
.unwrap();
assert_eq!(
world.reference_state(&registry),
expected,
"reference mismatch at simulation step {}",
world.tick
);
}
assert_eq!(world.tick, 1889);
assert_eq!(world.player.shield, 3);
assert_eq!(world.player.credits, 60825);
}
+303
View File
@@ -0,0 +1,303 @@
use raptor_replica::{
assets::{Catalog, Sprite, TRANSPARENT, contained, i16_at, i32_at},
audio::{DigitalSample, Mixer, MusScore},
content::{ContentPack, Registry},
persistence::{Config, Pilot},
render::upscale,
};
fn pilot_registry() -> Registry {
let missions: Vec<_> = (1..=3).flat_map(|s| (1..=9).map(move |w| serde_json::json!({
"id":format!("base:sector_{s}_wave_{w}"),"name":"Synthetic wave","duration":100,"legacy_map":100+s*9+w
}))).collect();
let pack: ContentPack = serde_json::from_value(serde_json::json!({
"items":[
{"id":"base:forward_guns","name":"Forward Guns","price":12000,"legacy":0},
{"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"legacy":16}
],"missions":missions
})).unwrap();
let mut registry = Registry::default();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
#[test]
fn shop_pack_sales_return_half_the_unit_price_without_creating_credits() {
let mut registry = pilot_registry();
let pack = serde_json::from_value(serde_json::json!({
"items":[{"id":"fixture:repair_pack", "name":"Three repairs", "price":900,
"initial_count":3, "max_count":8, "consumable":true}]
}))
.unwrap();
registry.apply(pack, "fixture").unwrap();
registry.validate().unwrap();
let id = "fixture:repair_pack".into();
let item = &registry.items[&id];
assert_eq!(item.resale_price(), 150);
let mut pilot = Pilot {
credits: 10_000,
..Pilot::default()
};
pilot.buy(&id, &registry).unwrap();
pilot.buy(&id, &registry).unwrap();
assert_eq!(pilot.inventory[&id], 6);
for remaining in (0..6).rev() {
pilot.sell(&id, &registry).unwrap();
assert_eq!(pilot.item_count(&id), remaining);
}
assert_eq!(pilot.credits, 9_100);
assert!(!pilot.inventory.contains_key(&id));
let credits = pilot.credits;
assert!(pilot.sell(&id, &registry).is_err());
assert_eq!(pilot.credits, credits);
assert_eq!(registry.items[&"base:energy".into()].resale_price(), 5_000);
}
#[test]
fn training_graduation_and_sector_cycles_preserve_original_progression() {
let registry = pilot_registry();
let mut pilot = Pilot {
difficulty: 0,
credits: 900000,
mission: "base:sector_2_wave_4".into(),
..Pilot::default()
};
pilot.complete_mission(&registry).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(&registry).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(&registry).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, &registry).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, &registry, &[]).unwrap().credits, 98765);
assert_eq!(std::fs::read(path).unwrap(), bytes);
}
fn header(kind: i32, width: i32, height: i32) -> Vec<u8> {
[kind, 0, 0, width, height]
.into_iter()
.flat_map(i32::to_le_bytes)
.collect()
}
#[test]
fn raw_pictures_keep_zero_opaque_and_spans_keep_empty_pixels_transparent() {
let mut raw = header(1, 2, 1);
raw.extend([0, 255]);
assert_eq!(Sprite::decode(&raw).unwrap().pixels, [0, 255]);
let mut span = header(0, 3, 2);
span.extend([1i32, 1, 4, 2].into_iter().flat_map(i32::to_le_bytes));
span.extend([0, 17]);
span.extend([0i32, 0, -1, 0].into_iter().flat_map(i32::to_le_bytes));
assert_eq!(
Sprite::decode(&span).unwrap().pixels,
[TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, 0, 17]
);
for n in 0..span.len() {
assert!(Sprite::decode(&span[..n]).is_err());
}
}
#[test]
fn malformed_fields_and_spans_fail_without_panicking() {
assert!(i16_at(&[], usize::MAX).is_err());
assert!(i32_at(&[], usize::MAX).is_err());
let mut span = header(0, 2, 2);
span.extend([i32::MAX, 0, 0, 2].into_iter().flat_map(i32::to_le_bytes));
span.extend([1, 2]);
assert!(Sprite::decode(&span).is_err());
assert!(Sprite::decode(&header(1, 4096, 4096)).is_err());
}
#[test]
fn scale_kernels_have_published_corner_results() {
let input = Sprite {
width: 3,
height: 3,
pixels: vec![9, 1, 8, 1, 2, 3, 7, 4, 6],
};
let two = upscale(&input, 2).unwrap();
let four = upscale(&input, 4).unwrap();
assert_eq!(
[
two.pixels[2 * 6 + 2],
two.pixels[2 * 6 + 3],
two.pixels[3 * 6 + 2],
two.pixels[3 * 6 + 3]
],
[1, 2, 2, 2]
);
let three = upscale(&input, 3).unwrap();
let center: Vec<_> = (3..6)
.flat_map(|y| (3..6).map(move |x| y * 9 + x))
.map(|n| three.pixels[n])
.collect();
assert_eq!(center, [1, 1, 2, 1, 2, 2, 2, 2, 2]);
assert_eq!(four, upscale(&two, 2).unwrap());
assert!(upscale(&input, 5).is_err());
}
#[test]
fn catalog_paths_cannot_escape_or_follow_an_external_symlink() {
let root = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
std::fs::write(other.path().join("outside"), b"outside").unwrap();
assert!(contained(root.path(), std::path::Path::new("../outside")).is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(other.path().join("outside"), root.path().join("link")).unwrap();
assert!(contained(root.path(), std::path::Path::new("link")).is_err());
}
}
#[test]
fn importing_settings_preserves_source_and_uses_sdl_key_names() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("SETUP.INI");
let original = b"[Keyboard]\nFire=29\nFireSp=56\nMegaFire=54\n[Video]\nupscaler=3\n";
std::fs::write(&path, original).unwrap();
let config = Config::import_ini(&path).unwrap();
assert_eq!(config.scale, 3);
assert_eq!(config.bindings["fire"], "Left Ctrl");
assert_eq!(config.bindings["special"], "Z");
assert_eq!(config.bindings["cycle"], "Left Alt");
assert_eq!(config.bindings["bomb"], "Space");
assert_eq!(std::fs::read(path).unwrap(), original);
#[cfg(feature = "desktop")]
for key in config.bindings.values() {
assert!(
sdl2::keyboard::Scancode::from_name(key).is_some(),
"unknown SDL key {key}"
);
}
}
// Original pixels never enter the test fixtures or version control. Set this
// variable only on a machine with the licensed extracted asset catalog.
#[test]
fn original_data_decoders_and_all_campaigns_when_available() {
let Some(data) = std::env::var_os("RAPTOR_TEST_DATA") else {
return;
};
let catalog = Catalog::open(data).unwrap();
let registry = Registry::base(&catalog).unwrap();
registry.validate().unwrap();
assert_eq!(registry.missions.len(), 27);
assert_eq!(registry.campaigns.len(), 3);
let mut sprites = 0;
for entry in catalog.entries.values() {
let bytes = catalog.read(entry.id).unwrap();
if bytes.len() >= 20
&& (i32_at(&bytes, 0).ok() == Some(0)
|| (i32_at(&bytes, 0).ok() == Some(1)
&& i32_at(&bytes, 4).ok() == Some(1)
&& i32_at(&bytes, 8).ok() == Some(0)))
&& matches!(i32_at(&bytes, 12).ok(), Some(1..=4096))
&& matches!(i32_at(&bytes, 16).ok(), Some(1..=4096))
{
Sprite::decode(&bytes).unwrap_or_else(|e| panic!("asset {:x}: {e}", entry.id));
sprites += 1;
}
if bytes.starts_with(b"MUS\x1a") {
MusScore::decode(&bytes).unwrap();
}
}
assert!(
sprites > 1000,
"format discovery must include unnamed graphics"
);
for sector in 0..3 {
let pilot = Pilot::demo(sector, &registry).unwrap();
pilot.validate(&registry, &[]).unwrap();
let selected = &registry.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));
}
+450
View File
@@ -0,0 +1,450 @@
//! Enemy package tests use synthetic PNG/WAV fixtures, never original/generated art.
use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{self, ActiveMods, Command},
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use serde_json::json;
use std::{fs, path::Path};
const ENEMIES: [&str; 3] = ["iron_coast_scout", "iron_coast_turret", "iron_coast_boss"];
fn base() -> Registry {
let mut registry = Registry::default();
let pack: ContentPack = serde_json::from_value(json!({
"weapons": [{"id":"base:projectile_forward_guns", "name":"Forward Guns",
"damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all",
"offsets":[[-8,0],[8,0]]}],
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,
"weapon":"base:projectile_forward_guns"},
{"id":"base:energy","name":"Shield","price":0,"max_count":100}],
"missions":[{"id":"base:sector_1_wave_1","name":"Synthetic Range","duration":5000}]
}))
.unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
fn wav() -> Vec<u8> {
let mut data = b"RIFF".to_vec();
data.extend(40_u32.to_le_bytes());
data.extend(b"WAVEfmt ");
data.extend(16_u32.to_le_bytes());
data.extend(1_u16.to_le_bytes());
data.extend(1_u16.to_le_bytes());
data.extend(22050_u32.to_le_bytes());
data.extend(44100_u32.to_le_bytes());
data.extend(2_u16.to_le_bytes());
data.extend(16_u16.to_le_bytes());
data.extend(b"data");
data.extend(4_u32.to_le_bytes());
data.extend([0, 0, 0, 0]);
data
}
fn install_fixture(root: &Path, id: &str) {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods").join(id);
let output = root.join(id);
fs::create_dir_all(output.join("scripts")).unwrap();
fs::create_dir_all(output.join("art")).unwrap();
fs::create_dir_all(output.join("audio")).unwrap();
for name in ["mod.toml", "content.json", "scripts/ai.rhai"] {
fs::copy(source.join(name), output.join(name)).unwrap();
}
let content: ContentPack =
serde_json::from_slice(&fs::read(source.join("content.json")).unwrap()).unwrap();
for art in content
.enemies
.iter()
.filter_map(|e| e.art.as_ref())
.chain(content.weapons.iter().filter_map(|w| w.art.as_ref()))
{
for (path, scale) in [
(art.png.as_ref().unwrap(), 1),
(art.hd_png.as_ref().unwrap(), 2),
] {
let mut image = image::RgbaImage::new(
art.logical_width * art.frames * scale,
art.logical_height * scale,
);
for frame in 0..art.frames {
for y in scale..(art.logical_height - 1) * scale {
for x in scale..(art.logical_width - 1) * scale {
image.put_pixel(
frame * art.logical_width * scale + x,
y,
image::Rgba([80, 90, 100, 255]),
);
}
}
}
image.save(output.join(path)).unwrap();
}
}
for sound in content.sounds {
fs::write(output.join(sound.file), wav()).unwrap();
}
}
fn active(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
let temp = tempfile::tempdir().unwrap();
for id in ids {
install_fixture(temp.path(), id);
}
let discovered = modding::discover(temp.path());
assert!(discovered.errors.is_empty(), "{:?}", discovered.errors);
let selected: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
let active = ActiveMods::build(&base(), &discovered, &selected, None).unwrap();
(temp, active)
}
fn world(active: &ActiveMods) -> World {
World::new(
&ContentId::from("base:sector_1_wave_1"),
&Pilot::default(),
&active.registry,
)
.unwrap()
}
#[test]
fn all_enemy_packages_validate_independently_and_together() {
for id in ENEMIES {
let (_temp, a) = active(&[id]);
assert_eq!(a.pins.len(), 1);
assert_eq!(a.registry.enemies.len(), 1);
assert!(
a.registry
.weapons
.contains_key(&ContentId::new(format!("{id}:projectile")).unwrap())
);
assert_eq!(a.registry.sounds.len(), 2);
assert_eq!(
a.registry.enemies[&ContentId::new(format!("{id}:unit")).unwrap()]
.script
.as_deref(),
Some("scripts/ai.rhai")
);
}
let (_temp, a) = active(&ENEMIES);
assert_eq!(a.pins.len(), 3);
assert_eq!(a.registry.enemies.len(), 3);
assert!(a.registry.overrides.is_empty());
}
#[test]
fn scout_weaves_in_both_directions_and_aims_hostile_fire() {
let (_temp, mut a) = active(&["iron_coast_scout"]);
let mut w = world(&a);
w.player.x = 0;
w.spawn(
&ContentId::from("iron_coast_scout:unit"),
120,
0,
&a.registry,
)
.unwrap();
let mut right = false;
let mut left = false;
let mut sounds = 0;
for _ in 0..95 {
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
let e = &w.enemies[0];
right |= e.vx > 0;
left |= e.vx < 0;
assert!(e.x >= 24 && e.x <= 272);
assert_eq!(e.vy, 1);
sounds += w
.mod_sounds
.iter()
.filter(|id| id.0 == "iron_coast_scout:shot")
.count();
}
assert!(right && left);
assert_eq!(w.enemies[0].y, 94);
assert!(sounds >= 2);
assert!(
w.projectiles
.iter()
.any(|p| p.hostile && p.weapon.0 == "iron_coast_scout:projectile" && p.vx < 0)
);
}
#[test]
fn turret_scrolls_with_terrain_and_aims_at_player() {
let (_temp, mut a) = active(&["iron_coast_turret"]);
let mut w = world(&a);
w.player.x = 0;
w.spawn(
&ContentId::from("iron_coast_turret:unit"),
220,
0,
&a.registry,
)
.unwrap();
let mut sounds = 0;
for _ in 0..80 {
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
sounds += w
.mod_sounds
.iter()
.filter(|id| id.0 == "iron_coast_turret:shot")
.count();
}
assert_eq!((w.enemies[0].x, w.enemies[0].y), (220, 80));
assert!(a.registry.enemies[&w.enemies[0].definition].ground);
assert_eq!(sounds, 2);
assert!(
w.projectiles
.iter()
.any(|p| p.hostile && p.vx < 0 && p.vy >= 0)
);
}
#[test]
fn boss_enters_patrols_fires_fans_and_remains_on_screen() {
let (_temp, mut a) = active(&["iron_coast_boss"]);
let mut w = world(&a);
w.player.x = 0;
w.spawn(
&ContentId::from("iron_coast_boss:unit"),
112,
-64,
&a.registry,
)
.unwrap();
let mut left = false;
let mut right = false;
let mut fans = 0;
for tick in 0..1500 {
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
assert_eq!(w.outcome, Outcome::Playing);
assert_eq!(w.enemies.len(), 1, "boss retired at{tick}");
let e = &w.enemies[0];
assert_eq!((e.width, e.height), (96, 64));
if tick > 46 {
assert_eq!(e.y, 24);
assert!((80..=144).contains(&e.x));
left |= e.vx < 0;
right |= e.vx > 0;
}
if w.mod_sounds
.contains(&ContentId::from("iron_coast_boss:shot"))
{
let bullets: Vec<_> = w
.projectiles
.iter()
.filter(|p| p.age == 0 && p.hostile)
.collect();
assert_eq!(bullets.len(), 3);
assert_eq!(bullets.iter().map(|p| p.vx).collect::<Vec<_>>(), [-2, 0, 2]);
fans += 1;
}
}
assert!(left && right);
assert!(fans >= 25);
}
#[test]
fn boss_can_be_defeated_with_starter_guns_without_healing() {
let (_temp, mut a) = active(&["iron_coast_boss"]);
let mut w = world(&a);
w.player.x = 128;
w.spawn(
&ContentId::from("iron_coast_boss:unit"),
112,
-64,
&a.registry,
)
.unwrap();
let mut defeated_at = None;
for tick in 0..600 {
w.step(
Input {
fire: true,
..Input::default()
},
&a.registry,
&mut a.scripts,
)
.unwrap();
if w.objectives.get("iron_coast_boss:defeated") == Some(&1) {
defeated_at = Some(tick);
break;
}
assert_eq!(w.outcome, Outcome::Playing, "starter pilot died at{tick}");
}
assert!(
defeated_at.is_some(),
"boss cannot be killed with starter guns"
);
assert!(w.enemies.is_empty());
assert!(w.player.shield > 0);
assert_eq!(w.player.credits, 1800);
assert!(
w.mod_sounds
.contains(&ContentId::from("iron_coast_boss:death"))
);
}
#[test]
fn death_rewards_objectives_and_sounds_are_emitted_exactly_once() {
let (_temp, mut a) = active(&ENEMIES);
let mut w = world(&a);
for (index, id) in ENEMIES.iter().enumerate() {
let unit = ContentId::new(format!("{id}:unit")).unwrap();
let entity = w
.spawn(&unit, 30 + index as i32 * 100, 0, &a.registry)
.unwrap();
w.apply_commands(
&[Command::Damage {
entity,
amount: 100000,
}],
&a.registry,
)
.unwrap();
}
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
assert!(w.enemies.is_empty());
assert_eq!(w.player.credits, 75 + 120 + 1800);
for id in ENEMIES {
assert_eq!(w.objectives.get(&format!("{id}:defeated")), Some(&1));
assert!(
w.mod_sounds
.contains(&ContentId::new(format!("{id}:death")).unwrap())
);
}
let previous = w.player.credits;
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
assert_eq!(w.player.credits, previous);
assert!(w.mod_sounds.is_empty());
}
#[test]
fn enemy_scripts_replay_identically_and_restore_serialized_state() {
let (_temp, mut first) = active(&ENEMIES);
let (_other, mut second) = active(&ENEMIES);
let mut a = world(&first);
for (index, id) in ENEMIES.iter().enumerate() {
a.spawn(
&ContentId::new(format!("{id}:unit")).unwrap(),
60 + index as i32 * 30,
-24,
&first.registry,
)
.unwrap();
}
a.player.x = 0;
let mut b = a.clone();
for _ in 0..75 {
a.step(Input::default(), &first.registry, &mut first.scripts)
.unwrap();
b.step(Input::default(), &second.registry, &mut second.scripts)
.unwrap();
assert_eq!(a.digest(), b.digest());
assert_eq!(first.scripts.checkpoints(), second.scripts.checkpoints());
}
let snapshot = serde_json::to_vec(&a).unwrap();
b = serde_json::from_slice(&snapshot).unwrap();
let checkpoint = first.scripts.checkpoints();
second.scripts.restore(checkpoint);
for _ in 0..25 {
a.step(Input::default(), &first.registry, &mut first.scripts)
.unwrap();
b.step(Input::default(), &second.registry, &mut second.scripts)
.unwrap();
assert_eq!(a.digest(), b.digest());
}
}
#[test]
fn mission_defeat_counts_reset_while_persistent_script_statistics_continue() {
let (_temp, mut a) = active(&ENEMIES);
for id in ENEMIES {
let unit = ContentId::new(format!("{id}:unit")).unwrap();
let mut first = world(&a);
for _ in 0..2 {
let entity = first.spawn(&unit, 20, 0, &a.registry).unwrap();
first
.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
first
.apply_commands(
&[Command::Damage {
entity,
amount: 100000,
}],
&a.registry,
)
.unwrap();
first
.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
}
assert_eq!(first.objectives.get(&format!("{id}:defeated")), Some(&2));
let mut next = world(&a);
let entity = next.spawn(&unit, 20, 0, &a.registry).unwrap();
next.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
next.apply_commands(
&[Command::Damage {
entity,
amount: 100000,
}],
&a.registry,
)
.unwrap();
next.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
assert_eq!(next.objectives.get(&format!("{id}:defeated")), Some(&1));
let state = a.scripts.checkpoints().0;
assert_eq!(state[&format!("{id}/scripts/ai.rhai")]["destroyed"], 3);
if id == "iron_coast_boss" {
assert!(
state[&format!("{id}/scripts/ai.rhai")]["directions"]
.as_object()
.unwrap()
.is_empty()
);
}
}
}
#[test]
fn shipped_enemy_assets_are_valid_and_aligned() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods");
let selected: Vec<_> = ENEMIES.iter().map(|id| id.to_string()).collect();
let discovery = modding::discover(&root);
ActiveMods::build(&base(), &discovery, &selected, None).unwrap();
for id in ENEMIES {
let content: ContentPack =
serde_json::from_slice(&fs::read(root.join(id).join("content.json")).unwrap()).unwrap();
let art = content.enemies[0].art.as_ref().unwrap();
for path in [art.png.as_ref().unwrap(), art.hd_png.as_ref().unwrap()] {
let image = image::open(root.join(id).join(path)).unwrap().into_rgba8();
assert_eq!(
image.width() / art.frames * art.logical_height,
image.height() * art.logical_width
);
for frame in 0..art.frames {
let width = image.width() / art.frames;
for x in frame * width..(frame + 1) * width {
assert!(image.get_pixel(x, 0)[3] < 128);
assert!(image.get_pixel(x, image.height() - 1)[3] < 128);
}
for y in 0..image.height() {
assert!(image.get_pixel(frame * width, y)[3] < 128);
assert!(image.get_pixel((frame + 1) * width - 1, y)[3] < 128);
}
}
}
}
}
+379
View File
@@ -0,0 +1,379 @@
//! Opt-in ordinary-input victory check against the installed original catalog.
use raptor_replica::{
assets::{Catalog, decode_demo},
content::Registry,
modding::ScriptRuntime,
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use serde_json::json;
use std::{
collections::BTreeMap,
fs,
io::{BufWriter, Write},
path::PathBuf,
};
const MISSION: &str = "base:sector_1_wave_1";
const ORIGINAL_FLYBY_SOUND: u32 = 129;
#[derive(Debug, serde::Serialize)]
struct DepartureObservation {
tick: u64,
timer: Option<u32>,
x: i32,
y: i32,
draw_player: bool,
control_paused: bool,
sounds: Vec<u32>,
}
fn target_x(world: &World, registry: &Registry) -> i32 {
let center = world.player.x + 16;
world
.enemies
.iter()
.filter(|e| e.health > 0 && e.y + e.height > 0 && e.y < world.player.y)
.min_by_key(|e| {
let def = &registry.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(&registry, &[]).unwrap();
let mut world = World::new(&MISSION.into(), &pilot, &registry).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, &registry);
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, &registry, &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(&registry));
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 = &registry.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, &registry).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()
},
&registry,
&mut replay_scripts,
)
.unwrap();
assert_eq!(
replay_world.reference_state(&registry),
*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, &registry).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()
},
&registry,
&mut keyboard_scripts,
)
.unwrap();
assert_eq!(
keyboard_world.reference_state(&registry),
expected,
"keyboard recording diverged at step {}",
keyboard_world.tick
);
}
assert_eq!(keyboard_world.outcome, Outcome::Complete);
}
}
+766
View File
@@ -0,0 +1,766 @@
use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{Command, ScriptRuntime},
persistence::Pilot,
simulation::{Input, LegacyProjectile, LineMove, Projectile, World},
};
use serde_json::json;
fn fixture() -> Registry {
let pack: ContentPack = serde_json::from_value(json!({
"weapons": [{"id":"base:projectile_forward_guns", "name":"Guns", "damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
"items":[
{"id":"base:forward_guns","name":"Guns","price":0,"weapon":"base:projectile_forward_guns","legacy":0},
{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16},
{"id":"base:super_shield","name":"Super Shield","price":0,"max_count":100,"legacy":15},
{"id":"base:medkit","name":"Medkit","price":0,"max_count":5,"special":true,"consumable":true,"shield":25,"script":"medkit.rhai"}
],
"enemies":[{"id":"base:drone","name":"Drone","speed":1,"health":20,"path":[[0,200]]}],
"missions":[{"id":"base:sector_1_wave_1","name":"Synthetic Mission","duration":1000}]
})).unwrap();
let mut reg = Registry::default();
reg.apply(pack, "base").unwrap();
reg.validate().unwrap();
reg
}
#[test]
fn demo_coordinates_bypass_normal_input_bounds() {
let reg = fixture();
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &reg).unwrap();
let mut scripts = ScriptRuntime::new();
world
.step(
Input {
absolute: Some([284, 162]),
demo_bank: Some(3),
..Input::default()
},
&reg,
&mut scripts,
)
.unwrap();
assert_eq!((world.player.x, world.player.y), (284, 162));
world
.step(
Input {
absolute: Some([284, 162]),
..Input::default()
},
&reg,
&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(), &reg).unwrap();
let mut scripts = ScriptRuntime::new();
world
.step(
Input {
fire: true,
demo_bank: Some(3),
..Input::default()
},
&reg,
&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()
},
&reg,
&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()
},
&reg,
&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, &reg).unwrap();
assert_eq!(world.player.super_shield, 31);
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 12,
}],
&reg,
)
.unwrap();
let saved = world.pilot(&pilot);
assert_eq!(saved.inventory[&ContentId::from("base:super_shield")], 19);
assert_eq!(
World::new(&pilot.mission, &saved, &reg)
.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(), &reg).unwrap();
let entity = world.spawn(&"base:drone".into(), 100, 0, &reg).unwrap();
world
.apply_commands(
&[Command::Move {
entity,
x: 20,
y: 20,
}],
&reg,
)
.unwrap();
let mut scripts = ScriptRuntime::new();
world.step(Input::default(), &reg, &mut scripts).unwrap();
assert_eq!((world.enemies[0].x, world.enemies[0].y), (20, 20));
world.step(Input::default(), &reg, &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, &reg).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()
},
&reg,
&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()
},
&reg,
&mut scripts,
)
.unwrap();
assert_eq!(world.player.credits, 7);
world.step(Input::default(), &reg, &mut scripts).unwrap();
world
.step(
Input {
special: true,
..Input::default()
},
&reg,
&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(&registry);
world.player.selected = Some("base:death_ray".into());
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 92,
}],
&registry,
)
.unwrap();
let seed = world.rng.seed;
world
.step(Input::default(), &registry, &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,
}],
&registry,
)
.unwrap();
world
.step(Input::default(), &registry, &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(&registry);
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 92,
}],
&registry,
)
.unwrap();
world
.step(Input::default(), &registry, &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(), &registry, &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(&registry);
world.player.selected = Some("base:death_ray".into());
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 89,
}],
&registry,
)
.unwrap();
world
.step(Input::default(), &registry, &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 }], &registry)
.unwrap();
world
.step(Input::default(), &registry, &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(), &registry).unwrap();
let entity = world
.spawn(&"base:drone".into(), 48, -131, &registry)
.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()
},
&registry,
&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, &registry).unwrap();
world
.spawn(&"base:drone".into(), 157, 40, &registry)
.unwrap();
let mut scripts = ScriptRuntime::new();
world
.step(
Input {
fire: true,
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
assert_eq!(world.enemies[0].health, 14);
world
.step(
Input {
absolute: Some([141, 160]),
..Input::default()
},
&registry,
&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 = &registry.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(), &registry).unwrap();
world
.spawn(&"base:ground".into(), 64, 64, &registry)
.unwrap();
let entity = world.spawn(&"base:air".into(), 64, 64, &registry).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,
&registry,
);
world
.step(Input::default(), &registry, &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(), &registry, &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(), &registry).unwrap();
world.spawn(&"base:air".into(), 64, 64, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
for _ in 0..5 {
inject_legacy_shot(
&mut world,
"base:projectile_energy_grab",
80,
80,
false,
&registry,
);
world
.step(Input::default(), &registry, &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(), &registry).unwrap();
world
.spawn(&"base:ground".into(), 48, 73, &registry)
.unwrap();
let shot = inject_legacy_shot(
&mut world,
"base:projectile_ground_bomb",
64,
89,
false,
&registry,
);
let mut scripts = ScriptRuntime::new();
world
.step(Input::default(), &registry, &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(), &registry).unwrap();
world.terrain_dead[7] = true;
world
.spawn(&"base:ground".into(), 64, 64, &registry)
.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,
&registry,
);
let mut scripts = ScriptRuntime::new();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.enemies[0].health, -20); // Original enemy damage50, separate from tiles.
assert!(world.projectiles.is_empty());
for position in 0..30 {
let expected = if position < 6 || position == 7 || position == 8 {
200
} else {
180
};
assert_eq!(
world.terrain_health[position], expected,
"terrain position{position}"
);
}
}
+1142
View File
File diff suppressed because it is too large. Load diff
+436
View File
@@ -0,0 +1,436 @@
//! Campaign acceptance uses synthetic PNG/WAV assets; original data is unnecessary.
use raptor_replica::{
content::{ContentPack, Registry},
modding::{self, ActiveMods, Manifest},
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use serde_json::{Value, json};
use std::{
fs,
path::{Path, PathBuf},
};
const PACKAGES: [&str; 8] = [
"iron_coast_music",
"iron_coast_scout",
"iron_coast_turret",
"iron_coast_boss",
"pulse_cannon",
"seeker_swarm",
"ion_lance",
"iron_coast_mission_1",
];
const MISSION: &str = "iron_coast_mission_1:mission_01";
fn base() -> Registry {
let mut registry = Registry::default();
let mut value = json!({
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,"max_count":1,"weapon":"base:projectile_forward_guns","legacy":0},{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16}],
"missions":[{"id":"base:sector_1_wave_1","name":"Base fixture","duration":1000}]
});
for n in 0..18 {
value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1}));
}
let pack: ContentPack = serde_json::from_value(value).unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
fn fixture_resources(value: &Value, root: &Path, original: &Path) {
match value {
Value::Object(object) => {
if let (Some(width), Some(height)) = (
object.get("logical_width").and_then(Value::as_u64),
object.get("logical_height").and_then(Value::as_u64),
) {
let frames = object.get("frames").and_then(Value::as_u64).unwrap_or(1);
for (key, scale) in [("png", 1), ("hd_png", 4)] {
if let Some(file) = object.get(key).and_then(Value::as_str) {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
let image = image::RgbaImage::from_pixel(
(width * frames * scale) as u32,
(height * scale) as u32,
image::Rgba([80, 96, 104, 255]),
);
image.save(path).unwrap();
}
}
}
if let Some(file) = object.get("file").and_then(Value::as_str) {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
if Path::new(file).extension().is_some_and(|ext| ext == "mus") {
fs::copy(original.join(file), &path).unwrap();
return;
}
// Small valid mono PCM16 WAV; integration checks use the actual cues.
let mut wav = Vec::new();
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&100_u32.to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16_u32.to_le_bytes());
wav.extend_from_slice(&1_u16.to_le_bytes());
wav.extend_from_slice(&1_u16.to_le_bytes());
wav.extend_from_slice(&22050_u32.to_le_bytes());
wav.extend_from_slice(&44100_u32.to_le_bytes());
wav.extend_from_slice(&2_u16.to_le_bytes());
wav.extend_from_slice(&16_u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&64_u32.to_le_bytes());
wav.extend_from_slice(&[0; 64]);
fs::write(path, wav).unwrap();
}
for child in object.values() {
fixture_resources(child, root, original);
}
}
Value::Array(values) => {
for child in values {
fixture_resources(child, root, original);
}
}
_ => {}
}
}
fn active() -> (tempfile::TempDir, ActiveMods) {
let started = std::time::Instant::now();
let temporary = tempfile::tempdir().unwrap();
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods");
for id in PACKAGES {
let original = source.join(id);
let root = temporary.path().join(id);
fs::create_dir_all(&root).unwrap();
let manifest_data = fs::read(original.join("mod.toml"))
.expect("all eight Breakwater packages must be installed");
let manifest: Manifest =
toml::from_str(std::str::from_utf8(&manifest_data).unwrap()).unwrap();
fs::write(root.join("mod.toml"), manifest_data).unwrap();
let content = fs::read(original.join(&manifest.content)).unwrap();
let value: Value = serde_json::from_slice(&content).unwrap();
fs::write(root.join(&manifest.content), content).unwrap();
for script in &manifest.scripts {
let path = root.join(script);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::copy(original.join(script), path).unwrap();
}
fixture_resources(&value, &root, &original);
}
let discovery = modding::discover(temporary.path());
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
let enabled = PACKAGES.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let active = ActiveMods::build(&base(), &discovery, &enabled, None).unwrap();
eprintln!(
"Iron Coast synthetic eight-package activation: {:.3}s",
started.elapsed().as_secs_f64()
);
(temporary, active)
}
#[test]
fn campaign_dependencies_and_generated_footprints_validate_together() {
let (_temporary, active) = active();
assert_eq!(active.pins.len(), 8);
let mission = &active.registry.missions[&MISSION.into()];
assert!(!mission.auto_complete);
assert_eq!(mission.columns, 9);
assert_eq!(mission.layout.len(), 72 * 9);
assert!(
mission
.spawns
.iter()
.any(|s| s.enemy.0 == "iron_coast_boss:unit" && s.tick == 720)
);
assert_eq!(mission.art.as_ref().unwrap().logical_width, 320);
assert!(
active.registry.campaigns[&"iron_coast_mission_1:campaign".into()]
.missions
.contains(&MISSION.into())
);
}
#[test]
fn boss_alone_cannot_complete_and_reward_is_paid_once() {
let (_temporary, mut active) = active();
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
world
.objectives
.insert("iron_coast_boss:defeated".into(), 1);
world
.objectives
.insert("iron_coast_turret:defeated".into(), 1);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Playing);
world
.objectives
.insert("iron_coast_turret:defeated".into(), 2);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Complete);
assert_eq!(world.objectives["iron_coast_mission_1:complete"], 1);
assert_eq!(world.player.credits, 5000);
assert_eq!(world.objectives["iron_coast_mission_1:debrief"], 1);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.player.credits, 5000);
}
#[test]
fn missed_breakwater_objective_turrets_stay_reachable() {
let (_temporary, mut active) = active();
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
let target = world
.spawn(&"iron_coast_turret:unit".into(), 40, 71, &active.registry)
.unwrap();
for _ in 0..60 {
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
let turret = world
.enemies
.iter()
.find(|unit| unit.entity == target)
.unwrap();
assert_eq!(turret.y, 72);
assert!(turret.health > 0);
assert_eq!(world.outcome, Outcome::Playing);
}
}
fn play(use_trial: bool) -> (World, Vec<(u64, String)>) {
let (_temporary, mut active) = active();
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
let mut seen = Vec::new();
for tick in 0..2000 {
let input = Input {
fire: true,
special: tick % 90 == 60,
cycle: tick == 1,
absolute: Some([144, 160]),
..Input::default()
};
world
.step(input, &active.registry, &mut active.scripts)
.unwrap();
if tick == 0 && !use_trial {
world
.player
.inventory
.remove(&"pulse_cannon:equipment".into());
}
for enemy in &world.enemies {
if !seen.iter().any(|(_, id)| id == &enemy.definition.0) {
seen.push((world.tick, enemy.definition.0.clone()));
}
}
if world.outcome != Outcome::Playing {
break;
}
}
if use_trial {
let weapon_state = &active.scripts.states["pulse_cannon/scripts/weapon.rhai"];
assert!(weapon_state["bursts"].as_i64().unwrap() > 0);
assert!(weapon_state["hits"].as_i64().unwrap() > 0);
let serialized = serde_json::to_string(&active.scripts.states).unwrap();
let recovered: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(recovered["pulse_cannon/scripts/weapon.rhai"], *weapon_state);
}
eprintln!(
"Iron Coast {} run: tick={} outcome={:?} shield={} credits={} repairs_remaining={}",
if use_trial {
"trial Pulse"
} else {
"starter guns"
},
world.tick,
world.outcome,
world.player.shield,
world.player.credits,
world
.player
.inventory
.get(&"iron_coast_mission_1:repair".into())
.copied()
.unwrap_or(0)
);
(world, seen)
}
#[test]
fn full_inventory_can_start_without_deleting_saved_equipment() {
let (_temporary, mut active) = active();
for reserves in [0, 4] {
let mut pilot = Pilot::default();
if reserves > 0 {
pilot.inventory.insert("base:super_shield".into(), 100);
pilot.super_shield_reserves = vec![100; reserves];
}
while pilot.inventory_slots() < 20 {
let n = pilot.inventory.len();
pilot
.inventory
.insert(format!("base:fixture_item_{n}").as_str().into(), 1);
}
assert_eq!(pilot.inventory_slots(), 20);
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.player.inventory, pilot.inventory);
assert_eq!(
world.player.super_shield_reserves,
pilot.super_shield_reserves
);
assert_eq!(world.objectives["iron_coast_mission_1:trial_issued"], 0);
assert_eq!(world.outcome, Outcome::Playing);
}
}
#[test]
fn restarted_mission_counts_new_turret_kills_independently_of_saved_statistics() {
let (_temporary, mut active) = active();
for _ in 0..2 {
let mut world = World::new(&MISSION.into(), &Pilot::default(), &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
let entity = world
.spawn(&"iron_coast_turret:unit".into(), 144, 0, &active.registry)
.unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
world
.apply_commands(
&[modding::Command::Damage {
entity,
amount: 1000,
}],
&active.registry,
)
.unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.objectives["iron_coast_turret:defeated"], 1);
assert_eq!(world.outcome, Outcome::Playing);
}
assert_eq!(
active.scripts.states["iron_coast_turret/scripts/ai.rhai"]["destroyed"],
2
);
}
#[test]
fn starter_guns_can_complete_real_waves_and_boss() {
let (world, seen) = play(false);
assert_eq!(
world.outcome,
Outcome::Complete,
"tick={} shield={} objectives={:?}",
world.tick,
world.player.shield,
world.objectives
);
assert!(
seen.iter()
.any(|(tick, id)| *tick >= 720 && id == "iron_coast_boss:unit")
);
assert!(world.objectives["iron_coast_turret:defeated"] >= 2);
assert!(world.objectives["iron_coast_boss:defeated"] >= 1);
assert_eq!(world.objectives["iron_coast_mission_1:phase"], 4);
assert!(world.player.shield > 0);
assert!(world.tick > 720 && world.tick < 2000);
}
#[test]
fn trial_pulse_cannon_completes_and_preserves_scripted_weapon_state() {
let (world, _seen) = play(true);
assert_eq!(
world.outcome,
Outcome::Complete,
"tick={} shield={} objectives={:?}",
world.tick,
world.player.shield,
world.objectives
);
assert!(
!world
.player
.inventory
.contains_key(&"pulse_cannon:equipment".into())
);
assert!(world.player.credits >= 5000);
}
#[test]
fn trial_is_a_loan_and_cannot_be_repeatedly_sold_after_victory() {
let (_temporary, mut active) = active();
let mut pilot = Pilot::default();
for _ in 0..2 {
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.player.inventory[&"pulse_cannon:equipment".into()], 1);
world
.objectives
.insert("iron_coast_turret:defeated".into(), 2);
world
.objectives
.insert("iron_coast_boss:defeated".into(), 1);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Complete);
pilot = world.pilot(&pilot);
assert!(
!pilot
.inventory
.contains_key(&"pulse_cannon:equipment".into())
);
let credits = pilot.credits;
assert!(
pilot
.sell(&"pulse_cannon:equipment".into(), &active.registry)
.is_err()
);
assert_eq!(pilot.credits, credits);
}
}
#[test]
fn owned_cannon_survives_trial_completion_without_replacement() {
let (_temporary, mut active) = active();
let mut pilot = Pilot::default();
pilot.inventory.insert("pulse_cannon:equipment".into(), 1);
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
world
.objectives
.insert("iron_coast_turret:defeated".into(), 2);
world
.objectives
.insert("iron_coast_boss:defeated".into(), 1);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Complete);
assert_eq!(world.player.inventory[&"pulse_cannon:equipment".into()], 1);
}
+704
View File
@@ -0,0 +1,704 @@
//! Portable assembly/progression checks use synthetic resources; the ignored
//! playable route requires the original untracked game catalog.
use raptor_replica::{
assets::Catalog,
content::{ContentPack, Registry},
mod_selection,
modding::{self, ActiveMods, Manifest},
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use serde_json::{Value, json};
use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
sync::OnceLock,
};
const CAMPAIGN: &str = "iron_coast_campaign";
const MISSIONS: [&str; 6] = [
"iron_coast_mission_1:mission_01",
"iron_coast_mission_2:mission_02",
"iron_coast_mission_3:mission_03",
"iron_coast_mission_4:mission_04",
"iron_coast_mission_5:mission_05",
"iron_coast_mission_6:mission_06",
];
fn fixture_resources(value: &Value, root: &Path, original: &Path) {
match value {
Value::Object(object) => {
if let (Some(width), Some(height)) = (
object.get("logical_width").and_then(Value::as_u64),
object.get("logical_height").and_then(Value::as_u64),
) {
let frames = object.get("frames").and_then(Value::as_u64).unwrap_or(1);
// Use the minimum valid HD resolution in portable tests. Actual
// generated 4x artwork is validated separately by the CLI.
for (key, scale) in [("png", 1), ("hd_png", 2)] {
if let Some(file) = object.get(key).and_then(Value::as_str) {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
image::RgbaImage::from_pixel(
(width * frames * scale) as u32,
(height * scale) as u32,
image::Rgba([80, 96, 104, 255]),
)
.save(path)
.unwrap();
}
}
}
if let Some(file) = object.get("file").and_then(Value::as_str) {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
if Path::new(file)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("mus"))
{
// The new soundtrack is original mod content, independent
// of the game catalog, so its bounded score can be tested.
fs::copy(original.join(file), path).unwrap();
} else {
let mut wav = Vec::new();
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&100_u32.to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16_u32.to_le_bytes());
wav.extend_from_slice(&1_u16.to_le_bytes());
wav.extend_from_slice(&1_u16.to_le_bytes());
wav.extend_from_slice(&22050_u32.to_le_bytes());
wav.extend_from_slice(&44100_u32.to_le_bytes());
wav.extend_from_slice(&2_u16.to_le_bytes());
wav.extend_from_slice(&16_u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&64_u32.to_le_bytes());
wav.extend_from_slice(&[0; 64]);
fs::write(path, wav).unwrap();
}
}
for child in object.values() {
fixture_resources(child, root, original);
}
}
Value::Array(values) => {
for child in values {
fixture_resources(child, root, original);
}
}
_ => {}
}
}
fn fixture_directory() -> &'static Path {
static FIXTURE: OnceLock<tempfile::TempDir> = OnceLock::new();
FIXTURE
.get_or_init(|| {
let temporary = tempfile::tempdir().unwrap();
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods");
let mut visited = BTreeSet::new();
let mut packages = vec![CAMPAIGN.to_string()];
while let Some(id) = packages.pop() {
if !visited.insert(id.clone()) {
continue;
}
let original = source.join(&id);
let root = temporary.path().join(&id);
fs::create_dir_all(&root).unwrap();
let manifest_data = fs::read(original.join("mod.toml")).unwrap();
let manifest: Manifest =
toml::from_str(std::str::from_utf8(&manifest_data).unwrap()).unwrap();
packages.extend(manifest.dependencies.keys().cloned());
fs::write(root.join("mod.toml"), manifest_data).unwrap();
let content = fs::read(original.join(&manifest.content)).unwrap();
let value: Value = serde_json::from_slice(&content).unwrap();
fs::write(root.join(&manifest.content), content).unwrap();
for script in manifest.scripts {
let path = root.join(&script);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::copy(original.join(script), path).unwrap();
}
fixture_resources(&value, &root, &original);
}
temporary
})
.path()
}
fn base() -> Registry {
let mut registry = Registry::default();
let mut value = json!({
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
"items":[
{"id":"base:forward_guns","name":"Forward Guns","price":0,"max_count":1,"weapon":"base:projectile_forward_guns","legacy":0},
{"id":"base:energy","name":"Shield","price":10000,"initial_count":25,"max_count":100,"legacy":16},
{"id":"base:super_shield","name":"Super Shield","price":78500,"initial_count":100,"max_count":100,"legacy":15}
],
"missions":[{"id":"base:sector_1_wave_1","name":"Base fixture","duration":1000}]
});
for n in 0..18 {
value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1}));
}
let pack: ContentPack = serde_json::from_value(value).unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
fn discovery() -> modding::Discovery {
let discovery = modding::discover(fixture_directory());
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
discovery
}
/// Native IDs are present only to validate the portable dependency graph and
/// first-step inventory grants. These placeholders supply no native behavior.
/// The playable campaign test below uses the original catalog, not this base.
fn structural_base(discovery: &modding::Discovery) -> Registry {
let mut registry = base();
let mut native = BTreeSet::new();
for package in discovery.mods.values() {
let pack: ContentPack = serde_json::from_slice(
&fs::read(package.root.join(&package.manifest.content)).unwrap(),
)
.unwrap();
for mission in pack.missions {
for spawn in mission.spawns {
if spawn.enemy.0.starts_with("base:enemy_") {
native.insert(spawn.enemy);
}
}
}
}
let enemies = native
.into_iter()
.map(|id| {
json!({
"id":id,
"name":"Structural native-ID placeholder; no gameplay acceptance",
"health":1,
"speed":0
})
})
.collect::<Vec<_>>();
registry
.apply(
serde_json::from_value(json!({"enemies":enemies})).unwrap(),
"base",
)
.unwrap();
registry.validate().unwrap();
registry
}
fn active() -> ActiveMods {
let discovery = discovery();
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
ActiveMods::build(&structural_base(&discovery), &discovery, &enabled, None).unwrap()
}
fn active_original() -> ActiveMods {
let data = std::env::var_os("RAPTOR_TEST_DATA")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data"));
let catalog = Catalog::open(data).unwrap();
let registry = Registry::base(&catalog).unwrap();
let discovery = modding::discover(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods"));
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
ActiveMods::build(&registry, &discovery, &enabled, Some(&catalog)).unwrap()
}
#[test]
fn explicit_mission_next_wins_over_campaign_order_and_standalone_stays_replayable() {
let mut registry = base();
registry
.apply(
serde_json::from_value(json!({
"missions": [
{"id":"base:test_a","name":"Explicit route","duration":20,"next":"base:test_c"},
{"id":"base:test_b","name":"Campaign route","duration":20},
{"id":"base:test_c","name":"Standalone destination","duration":20}
],
"campaigns":[{"id":"base:test_campaign","name":"Test route","missions":["base:test_a","base:test_b"]}]
}))
.unwrap(),
"base",
)
.unwrap();
registry.validate().unwrap();
let mut pilot = Pilot::default();
pilot.mission = "base:test_a".into();
pilot.complete_mission(&registry).unwrap();
assert_eq!(pilot.mission.0, "base:test_c");
pilot.complete_mission(&registry).unwrap();
assert_eq!(pilot.mission.0, "base:test_c");
assert_eq!(pilot.completed.len(), 2);
}
#[test]
fn campaign_order_fallback_uses_stable_campaign_id_order() {
let mut registry = base();
registry
.apply(
serde_json::from_value(json!({
"missions": [
{"id":"base:test_a","name":"Shared mission","duration":20},
{"id":"base:test_b","name":"First route","duration":20},
{"id":"base:test_c","name":"Second route","duration":20}
],
"campaigns":[
{"id":"base:z_route","name":"Later ID","missions":["base:test_a","base:test_c"]},
{"id":"base:a_route","name":"Earlier ID","missions":["base:test_a","base:test_b"]}
]
}))
.unwrap(),
"base",
)
.unwrap();
registry.validate().unwrap();
let mut pilot = Pilot::default();
pilot.mission = "base:test_a".into();
pilot.complete_mission(&registry).unwrap();
assert_eq!(pilot.mission.0, "base:test_b");
pilot.complete_mission(&registry).unwrap();
assert_eq!(pilot.mission.0, "base:test_b");
}
#[test]
fn selecting_campaign_selects_nineteen_packages_and_respects_shared_dependencies() {
let discovery = discovery();
let enabled = mod_selection::plan_enable(&discovery, &[], CAMPAIGN).unwrap();
assert_eq!(enabled.len(), 19);
assert_eq!(enabled.last().unwrap(), CAMPAIGN);
for number in 1..=6 {
let id = format!("iron_coast_mission_{number}");
assert!(enabled.contains(&id));
let standalone = mod_selection::plan_enable(&discovery, &[], &id).unwrap();
assert_eq!(standalone.len(), 8, "{id} must remain standalone");
assert!(!standalone.iter().any(|id| id == CAMPAIGN));
ActiveMods::build(&structural_base(&discovery), &discovery, &standalone, None).unwrap();
}
let disable = mod_selection::plan_disable(&discovery, &enabled, CAMPAIGN);
assert_eq!(disable.removed.len(), 19);
assert!(disable.enabled.is_empty());
// Two independent missions share content. Disabling one keeps the other's
// shared content while removing the disabled mission's unused stage boss.
let standalone = mod_selection::plan_enable(&discovery, &[], "iron_coast_mission_1").unwrap();
let standalone =
mod_selection::plan_enable(&discovery, &standalone, "iron_coast_mission_6").unwrap();
let disable = mod_selection::plan_disable(&discovery, &standalone, "iron_coast_mission_6");
assert_eq!(disable.removed.len(), 2);
assert!(
disable
.removed
.contains(&"iron_coast_mission_6".to_string())
);
assert!(disable.removed.contains(&"iron_coast_boss_6".to_string()));
assert!(
disable
.enabled
.contains(&"iron_coast_mission_1".to_string())
);
assert!(disable.enabled.contains(&"pulse_cannon".to_string()));
}
#[test]
fn campaign_order_is_complete_and_owns_only_its_campaign_definition() {
let active = active();
let campaign = &active.registry.campaigns[&"iron_coast_mission_1:campaign".into()];
assert_eq!(
campaign
.missions
.iter()
.map(|id| id.0.as_str())
.collect::<Vec<_>>(),
MISSIONS
);
assert_eq!(active.registry.overrides.len(), 1);
assert_eq!(
active.registry.owners[&"iron_coast_music:iron_coast".into()],
"iron_coast_music"
);
for (number, id) in MISSIONS.iter().enumerate() {
let owner = format!("iron_coast_mission_{}", number + 1);
assert_eq!(&active.registry.owners[&(*id).into()], &owner);
let mission = &active.registry.missions[&(*id).into()];
assert!(!mission.auto_complete);
assert_eq!(
serde_json::to_value(mission).unwrap()["music_track"],
"iron_coast_music:iron_coast"
);
let boss_id = if number == 0 {
"iron_coast_boss:unit".to_string()
} else {
format!("iron_coast_boss_{}:unit", number + 1)
};
let bosses = mission
.spawns
.iter()
.filter(|spawn| active.registry.enemies[&spawn.enemy].boss)
.collect::<Vec<_>>();
assert_eq!(bosses.len(), if number == 5 { 2 } else { 1 });
assert!(bosses.iter().all(|spawn| spawn.enemy.0 == boss_id));
if number > 0 {
assert!(
mission
.spawns
.iter()
.any(|spawn| spawn.enemy.0.starts_with("base:enemy_"))
);
}
}
}
#[test]
fn campaign_progression_round_trips_pilot_inventory_and_script_state() {
let active = active();
let temporary = tempfile::tempdir().unwrap();
let path = temporary.path().join("pilot.json");
let mut pilot = Pilot::default();
pilot.mission = MISSIONS[0].into();
pilot.mods = active.pins.clone();
pilot.inventory.insert("seeker_swarm:equipment".into(), 1);
pilot.script_state.insert(
"iron_coast_scout/scripts/ai.rhai".into(),
json!({"destroyed":4,"shots":17}),
);
pilot.script_rng.insert(
"iron_coast_scout/scripts/ai.rhai".into(),
raptor_replica::simulation::LegacyRng::new(12345),
);
for (number, id) in MISSIONS.iter().enumerate() {
assert_eq!(pilot.mission.0, *id);
pilot.complete_mission(&active.registry).unwrap();
assert_eq!(pilot.completed.len(), number + 1);
let expected = MISSIONS[(number + 1).min(5)];
assert_eq!(pilot.mission.0, expected);
pilot.save(&path).unwrap();
let recovered = Pilot::load(&path, &active.registry, &active.pins).unwrap();
assert_eq!(
serde_json::to_value(&recovered).unwrap(),
serde_json::to_value(&pilot).unwrap()
);
pilot = recovered;
}
// Revisiting the finale does not duplicate completed progression records.
pilot.complete_mission(&active.registry).unwrap();
assert_eq!(pilot.completed.len(), 6);
assert!(fs::metadata(path).unwrap().len() > 0);
}
#[test]
fn every_campaign_mission_accepts_a_full_twenty_slot_ship() {
let mut active = active();
for id in MISSIONS {
for reserves in [0, 4] {
let mut pilot = Pilot::default();
if reserves > 0 {
pilot.inventory.insert("base:super_shield".into(), 100);
pilot.super_shield_reserves = vec![100; reserves];
}
for index in 0..18 {
if pilot.inventory_slots() == 20 {
break;
}
pilot
.inventory
.insert(format!("base:fixture_item_{index}").as_str().into(), 1);
}
assert_eq!(pilot.inventory_slots(), 20);
pilot.validate(&active.registry, &[]).unwrap();
let mut world = World::new(&id.into(), &pilot, &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(
world.player.inventory, pilot.inventory,
"{id} changed a full inventory"
);
assert_eq!(
world.player.super_shield_reserves,
pilot.super_shield_reserves
);
assert_eq!(world.outcome, Outcome::Playing);
}
}
}
/// Controlled mouse input moves at most four horizontal pixels per simulation
/// step, fires continuously and cycles equipment using ordinary input edges.
/// It targets mission-local structures before shared turrets, then bosses and
/// scouts. Repairs are activated through their actual item callbacks.
fn campaign_input(world: &World, registry: &Registry) -> Input {
let namespace = world.mission.0.split_once(':').unwrap().0;
let target = world
.enemies
.iter()
.filter(|enemy| enemy.health > 0 && enemy.y >= 0 && enemy.y < 145)
.min_by_key(|enemy| {
let priority = if enemy.definition.0.starts_with(&format!("{namespace}:")) {
0
} else if enemy.definition.0 == "iron_coast_turret:unit" {
1
} else if registry.enemies[&enemy.definition].boss {
2
} else {
3
};
(priority, enemy.entity)
});
let goal = target
.map(|enemy| enemy.x + enemy.width / 2 - 16)
.unwrap_or(144)
.clamp(5, 282);
let x = world.player.x + (goal - world.player.x).clamp(-4, 4);
let special = world
.player
.inventory
.iter()
.filter_map(|(id, count)| {
let item = registry.items.get(id)?;
(*count > 0 && item.special).then_some((id, item))
})
.collect::<Vec<_>>();
let repair = special
.iter()
.find(|(_, item)| item.consumable && item.weapon.is_none())
.map(|(id, _)| *id);
let weapon = special
.iter()
.filter(|(_, item)| item.weapon.is_some())
.max_by_key(|(_, item)| item.price)
.map(|(id, _)| *id);
let wanted = if world.player.shield <= 70 {
repair.or(weapon)
} else {
weapon.or(repair)
};
let cycle =
wanted.is_some_and(|id| world.player.selected.as_ref() != Some(id)) && !world.cycle_held;
let consume = world.player.shield <= 70
&& repair.is_some_and(|id| world.player.selected.as_ref() == Some(id))
&& !world.special_held;
Input {
fire: true,
cycle,
special: consume,
absolute: Some([x, 160]),
..Input::default()
}
}
#[test]
#[ignore = "requires the original untracked catalog; native enemy paths and weapon behavior"]
fn registered_pilot_can_complete_six_real_missions_and_save_round_trips() {
let mut active = active_original();
assert_eq!(active.pins.len(), 19);
assert_eq!(active.registry.overrides.len(), 1);
let temporary = tempfile::tempdir().unwrap();
let path = temporary.path().join("campaign-pilot.json");
let mut pilot = Pilot::default();
pilot.name = "Campaign Acceptance".into();
pilot.callsign = "COAST".into();
pilot.credits = 10_000;
pilot.inventory.insert("base:energy".into(), 75);
pilot.mission = MISSIONS[0].into();
pilot.mods = active.pins.clone();
let mut reports = Vec::new();
let mut paid_refills = 0;
for (number, mission) in MISSIONS.iter().enumerate() {
assert_eq!(pilot.mission.0, *mission);
let refills_before = paid_refills;
// The hangar replenishes through the real purchase operation and its
// actual price; this test never inserts free shield or premium weapons.
while pilot.inventory[&"base:energy".into()] < 75 && pilot.credits >= 10_000 {
pilot.buy(&"base:energy".into(), &active.registry).unwrap();
paid_refills += 1;
}
let start_shield = pilot.inventory[&"base:energy".into()];
let start_credits = pilot.credits;
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
let mut native_seen = BTreeSet::new();
let mut native_motion = BTreeSet::new();
let limit = active.registry.missions[&pilot.mission].duration + 1600;
while world.tick < limit && world.outcome == Outcome::Playing {
let input = campaign_input(&world, &active.registry);
world
.step(input, &active.registry, &mut active.scripts)
.unwrap();
for enemy in &world.enemies {
if enemy.definition.0.starts_with("base:enemy_") {
assert!(active.registry.enemies[&enemy.definition].legacy.is_some());
native_seen.insert(enemy.definition.clone());
if enemy.x != enemy.sx || enemy.y != enemy.sy {
native_motion.insert(enemy.definition.clone());
}
}
}
}
assert_eq!(
world.outcome,
Outcome::Complete,
"{mission}: tick={}, shield={}, objectives={:?}",
world.tick,
world.player.shield,
world.objectives
);
assert!(world.player.shield > 0);
if number > 0 {
assert!(
!native_seen.is_empty(),
"{mission} never spawned a classic enemy"
);
assert!(
!native_motion.is_empty(),
"{mission} showed no classic enemy movement"
);
}
let namespace = mission.split_once(':').unwrap().0;
assert_eq!(world.objectives[&format!("{namespace}:complete")], 1);
assert_eq!(world.objectives[&format!("{namespace}:debrief")], 1);
let final_boss_tick = active.registry.missions[&pilot.mission]
.spawns
.iter()
.filter(|spawn| active.registry.enemies[&spawn.enemy].boss)
.map(|spawn| spawn.tick)
.max()
.unwrap();
assert!(world.tick > final_boss_tick);
let credits = world.player.credits;
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(
world.player.credits, credits,
"{mission} paid victory twice"
);
eprintln!(
"{mission}: complete at {}, shield {start_shield} -> {}, credits {}, classic types {}, moving types {}",
world.tick,
world.player.shield,
world.player.credits,
native_seen.len(),
native_motion.len()
);
let native_definitions = native_seen
.iter()
.map(|id| {
let definition = &active.registry.enemies[id];
let legacy = definition.legacy.as_ref().unwrap();
json!({
"id":id,
"name":definition.name,
"native_path_points":legacy.fields[19],
"native_move_type":legacy.fields[21],
"native_guns":legacy.fields[22]
})
})
.collect::<Vec<_>>();
reports.push(json!({
"mission":mission,
"completion_tick":world.tick,
"start_shield":start_shield,
"start_credits":start_credits,
"energy_refills":paid_refills - refills_before,
"final_shield":world.player.shield,
"credits":world.player.credits,
"objectives":world.objectives,
"native_enemies":native_definitions,
"native_motion_seen":native_motion,
"digest":world.digest()
}));
pilot = world.pilot(&pilot);
assert!(
!pilot
.inventory
.contains_key(&"pulse_cannon:equipment".into()),
"{mission} retained a mission-only Pulse loan"
);
assert!(
!pilot.inventory.contains_key(&"ion_lance:equipment".into()),
"{mission} retained a mission-only Ion loan"
);
pilot.merge_runtime_snapshot(&active.pins, &active.scripts.states, &active.scripts.rng);
pilot.complete_mission(&active.registry).unwrap();
assert_eq!(pilot.completed.len(), number + 1);
pilot.save(&path).unwrap();
let recovered = Pilot::load(&path, &active.registry, &active.pins).unwrap();
assert_eq!(
serde_json::to_value(&pilot).unwrap(),
serde_json::to_value(&recovered).unwrap()
);
active
.scripts
.restore((recovered.script_state.clone(), recovered.script_rng.clone()));
pilot = recovered;
}
assert_eq!(pilot.mission.0, MISSIONS[5]);
assert_eq!(pilot.completed.len(), 6);
// A completed finale must work again with its serialized mission and boss
// state, rather than inheriting completion gates or dead entity cursors.
let mut replay = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
let replay_limit = active.registry.missions[&pilot.mission].duration + 1600;
while replay.tick < replay_limit && replay.outcome == Outcome::Playing {
let input = campaign_input(&replay, &active.registry);
replay
.step(input, &active.registry, &mut active.scripts)
.unwrap();
}
assert_eq!(
replay.outcome,
Outcome::Complete,
"finale replay: {:?}",
replay.objectives
);
assert_eq!(replay.objectives["iron_coast_boss_6:defeated"], 2);
assert_eq!(replay.objectives["iron_coast_mission_6:complete"], 1);
assert_eq!(replay.objectives["iron_coast_mission_6:debrief"], 1);
assert!(replay.player.shield > 0);
let replay_report = json!({
"completion_tick":replay.tick,
"final_shield":replay.player.shield,
"credits":replay.player.credits,
"objectives":replay.objectives,
"digest":replay.digest()
});
pilot = replay.pilot(&pilot);
pilot.merge_runtime_snapshot(&active.pins, &active.scripts.states, &active.scripts.rng);
pilot.complete_mission(&active.registry).unwrap();
assert_eq!(pilot.completed.len(), 6);
assert_eq!(pilot.mission.0, MISSIONS[5]);
pilot.save(&path).unwrap();
let recovered = Pilot::load(&path, &active.registry, &active.pins).unwrap();
assert_eq!(
serde_json::to_value(&pilot).unwrap(),
serde_json::to_value(&recovered).unwrap()
);
eprintln!(
"Finale replay: {} ticks, {} shield",
replay.tick, replay.player.shield
);
let report = serde_json::to_string_pretty(&json!({
"original_catalog":true,
"package_count":active.pins.len(),
"package_pins":active.pins,
"paid_refills":paid_refills,
"reports":reports,
"finale_replay":replay_report
}))
.unwrap();
if let Ok(report_path) = std::env::var("RAPTOR_IRON_COAST_REPORT") {
let report_path = PathBuf::from(report_path);
if let Some(parent) = report_path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&report_path, format!("{report}\n")).unwrap();
eprintln!("Campaign acceptance report: {}", report_path.display());
}
eprintln!(
"Six-stage route and recovered finale replay passed; {paid_refills} paid Energy refills"
);
}
+347
View File
@@ -0,0 +1,347 @@
//! Starter forward guns must damage ground enemies and destructible terrain.
use raptor_replica::{
assets::Catalog,
content::{ContentId, ContentPack, Registry},
mod_selection,
modding::{self, ActiveMods, ScriptRuntime},
persistence::Pilot,
simulation::{Input, World},
};
use serde_json::json;
use std::path::PathBuf;
fn starter() -> Pilot {
let mut pilot = Pilot::default();
pilot.inventory.insert("base:energy".into(), 75);
pilot.credits = 10_000;
pilot
}
fn fixture(legacy_terrain: bool) -> Registry {
let mut layout = vec![0; 90];
layout[4 * 9 + 4] = 1;
let pack: ContentPack = serde_json::from_value(json!({
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
"items":[
{"id":"base:forward_guns","name":"Forward Guns","price":0,"weapon":"base:projectile_forward_guns","legacy":0},
{"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"legacy":16}
],
"enemies":[{"id":"base:ground","name":"Ground emplacement","health":12,"speed":0,"ground":true,"reward":120}],
"missions":[{"id":"base:sector_1_wave_1","name":"Ground range","duration":10000,"auto_complete":false,"columns":9,"layout":layout,"legacy_map":if legacy_terrain {Some(123)} else {None},
"tiles":[
{"art":{"asset":60,"logical_width":32,"logical_height":32}},
{"art":{"asset":61,"logical_width":32,"logical_height":32},"health":2,"reward":230,"destroyed":{"asset":62,"logical_width":32,"logical_height":32}}
]}
]
})).unwrap();
let mut registry = Registry::default();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
fn shoot(world: &mut World, registry: &Registry, scripts: &mut ScriptRuntime, ticks: usize) {
assert_eq!(world.player.selected, None);
assert_eq!(
world
.player
.inventory
.keys()
.filter(|id| registry.items[*id].weapon.is_some())
.count(),
1,
"the test must exercise only the default machine gun"
);
for _ in 0..ticks {
world
.step(
Input {
fire: true,
..Input::default()
},
registry,
scripts,
)
.unwrap();
}
}
#[test]
fn starter_machine_gun_destroys_ground_enemy_and_awards_its_reward() {
let mut registry = fixture(false);
registry
.missions
.get_mut(&Pilot::default().mission)
.unwrap()
.layout
.fill(0);
let pilot = starter();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let entity = world
.spawn(&"base:ground".into(), 144, 72, &registry)
.unwrap();
let mut scripts = ScriptRuntime::new();
shoot(&mut world, &registry, &mut scripts, 30);
assert!(!world.enemies.iter().any(|enemy| enemy.entity == entity));
assert_eq!(world.player.credits, pilot.credits + 120);
}
#[test]
fn starter_machine_gun_destroys_json_terrain_and_rewards_it_once() {
let registry = fixture(false);
let pilot = starter();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
let mut explosion_sounds = 0;
for _ in 0..60 {
shoot(&mut world, &registry, &mut scripts, 1);
explosion_sounds += world
.sound_events
.iter()
.filter(|sound| sound.asset == 139)
.count();
}
let position = 4 * 9 + 4;
assert!(
world.terrain_dead[position],
"forward gun damage never destroys mod terrain (health {})",
world.terrain_health[position]
);
assert_eq!(world.player.credits, pilot.credits + 230);
assert_eq!(
explosion_sounds, 1,
"terrain destruction should emit one original ground-explosion sound"
);
let health = world.terrain_health[position];
shoot(&mut world, &registry, &mut scripts, 30);
assert_eq!(world.terrain_health[position], health);
assert_eq!(world.player.credits, pilot.credits + 230);
}
#[test]
fn short_json_maps_use_their_actual_screen_position_for_forward_gun_hits() {
let mut registry = fixture(false);
let mission = registry
.missions
.get_mut(&Pilot::default().mission)
.unwrap();
mission.layout = vec![0; 36];
mission.layout[9 + 4] = 1;
let pilot = starter();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
shoot(&mut world, &registry, &mut ScriptRuntime::new(), 25);
assert!(world.terrain_dead[9 + 4]);
assert_eq!(world.player.credits, pilot.credits + 230);
}
#[test]
fn starter_machine_gun_keeps_native_delayed_terrain_destruction() {
let registry = fixture(true);
let pilot = starter();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
shoot(&mut world, &registry, &mut scripts, 60);
assert!(world.terrain_dead[4 * 9 + 4]);
assert!(world.terrain_settled[4 * 9 + 4]);
assert_eq!(world.player.credits, pilot.credits + 230);
}
#[test]
fn json_terrain_collision_matches_its_visible_rows_after_the_native_scroll_limit() {
let mut registry = fixture(false);
let mission = registry
.missions
.get_mut(&Pilot::default().mission)
.unwrap();
mission.layout.fill(0);
mission.layout[4] = 1;
let pilot = starter();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
for _ in 0..180 {
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
}
// The first row is now at screen y=60, rather than the repeating native
// terrain-window y=-4. It must be hittable at its displayed position.
assert_eq!(world.scroll, 180);
shoot(&mut world, &registry, &mut scripts, 25);
assert!(world.terrain_dead[4]);
assert_eq!(world.player.credits, pilot.credits + 230);
}
#[test]
fn json_terrain_that_scrolled_offscreen_cannot_absorb_forward_gun_shots() {
let mut registry = fixture(false);
let mission = registry
.missions
.get_mut(&Pilot::default().mission)
.unwrap();
mission.layout.fill(0);
mission.layout[9 + 4] = 1;
let pilot = starter();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let mut scripts = ScriptRuntime::new();
for _ in 0..400 {
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
}
shoot(&mut world, &registry, &mut scripts, 60);
assert_eq!(world.terrain_health[9 + 4], 2);
assert!(!world.terrain_dead[9 + 4]);
assert_eq!(world.player.credits, pilot.credits);
}
#[test]
fn health_based_mod_terrain_does_not_require_replacement_artwork_to_be_destructible() {
let mut registry = fixture(false);
registry
.missions
.get_mut(&Pilot::default().mission)
.unwrap()
.tiles[1]
.destroyed = None;
let pilot = starter();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
shoot(&mut world, &registry, &mut ScriptRuntime::new(), 60);
assert!(world.terrain_dead[4 * 9 + 4]);
assert_eq!(world.player.credits, pilot.credits + 230);
}
fn catalog() -> Catalog {
let path = std::env::var_os("RAPTOR_TEST_DATA")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data"));
Catalog::open(&path).unwrap()
}
#[test]
#[ignore = "requires locally installed original catalog and packaged campaign definitions"]
fn real_forward_guns_damage_classic_and_iron_coast_ground_units() {
let catalog = catalog();
let base = Registry::base(&catalog).unwrap();
let discovery = modding::discover(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods"));
let mut enabled = Vec::new();
for mission in [
"iron_coast_mission_1",
"iron_coast_mission_2",
"iron_coast_mission_3",
"iron_coast_mission_5",
] {
enabled = mod_selection::plan_enable(&discovery, &enabled, mission).unwrap();
}
// Asset decoding is covered by package validation. This combat check loads
// actual definitions and callbacks without requantizing every unrelated
// terrain PNG before it can exercise a single enemy collision.
let mut active = ActiveMods {
registry: base.clone(),
pins: Vec::new(),
scripts: ScriptRuntime::new(),
};
for id in &enabled {
let package = &discovery.mods[id];
let content: ContentPack = serde_json::from_slice(
&std::fs::read(package.root.join(&package.manifest.content)).unwrap(),
)
.unwrap();
active
.registry
.asset_roots
.insert(id.clone(), package.root.clone());
active.registry.apply(content, id).unwrap();
for script in &package.manifest.scripts {
active
.scripts
.add(
id,
script,
&std::fs::read_to_string(package.root.join(script)).unwrap(),
)
.unwrap();
}
}
active.registry.validate().unwrap();
// A quiet JSON mission leaves enemy definitions, original gun art, damage,
// movement and scripts intact while isolating their actual collisions.
let mission: ContentPack = serde_json::from_value(json!({"missions":[{"id":"test:ground_range","name":"Ground range","duration":10000,"auto_complete":false}]})).unwrap();
active.registry.apply(mission, "test").unwrap();
let first = &base.missions[&ContentId::from("base:sector_1_wave_1")];
let mut ground: Vec<_> = first
.spawns
.iter()
.map(|spawn| spawn.enemy.clone())
.filter(|id| base.enemies[id].ground && !base.enemies[id].boss)
.collect();
ground.sort();
ground.dedup();
assert!(!ground.is_empty());
ground.extend(
[
"iron_coast_turret:unit",
"iron_coast_mission_1:depot",
"iron_coast_mission_2:relay",
"iron_coast_mission_3:convoy",
"iron_coast_mission_5:foundry",
]
.map(ContentId::from),
);
let mut observations = Vec::new();
for id in ground {
let pilot = Pilot {
mission: "test:ground_range".into(),
..starter()
};
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
let entity = world.spawn(&id, 144, 24, &active.registry).unwrap();
let initial = world.enemies[0].health;
let mut lowest = initial;
for _ in 0..120 {
// Normal bounded absolute mouse movement aligns the ship with the
// observed ground target; health and projectiles are never injected.
let target = world.enemies.iter().find(|enemy| enemy.entity == entity);
let Some(enemy) = target else {
break;
};
let x = (enemy.x + enemy.width / 2 - 16).clamp(5, 282);
world
.step(
Input {
fire: true,
absolute: Some([x, 160]),
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
if let Some(enemy) = world.enemies.iter().find(|enemy| enemy.entity == entity) {
lowest = lowest.min(enemy.health);
}
}
assert!(lowest < initial, "starter machine gun did not damage {id}");
assert!(
!world.enemies.iter().any(|enemy| enemy.entity == entity),
"starter machine gun did not destroy {id}; lowest health {lowest}"
);
assert_eq!(
world.player.credits,
pilot.credits + active.registry.enemies[&id].reward,
"incorrect kill reward for {id}"
);
observations.push(json!({
"id": id,
"name": active.registry.enemies[&id].name,
"initial_health": initial,
"lowest_observed_health": lowest,
"destroyed": true,
"ticks": world.tick,
"reward": world.player.credits - pilot.credits,
"remaining_shield": world.player.shield
}));
}
if let Some(path) = std::env::var_os("RAPTOR_GROUND_REPORT") {
std::fs::write(path, serde_json::to_vec_pretty(&observations).unwrap()).unwrap();
}
}
+182
View File
@@ -0,0 +1,182 @@
use raptor_replica::{
mod_selection::{plan_disable, plan_enable},
modding::{self, Discovery, InstalledMod, Manifest},
};
use std::{collections::BTreeMap, path::PathBuf};
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|id| (*id).into()).collect()
}
fn package(id: &str, dependencies: &[(&str, &str)]) -> InstalledMod {
InstalledMod {
root: PathBuf::from(id),
hash: String::new(),
manifest: Manifest {
id: id.into(),
name: id.into(),
version: "1.0.0".into(),
api_version: 1,
description: String::new(),
content: "content.json".into(),
scripts: vec![],
conflicts: vec![],
dependencies: dependencies
.iter()
.map(|(id, req)| ((*id).into(), (*req).into()))
.collect(),
},
}
}
fn graph() -> Discovery {
Discovery {
mods: BTreeMap::from([
("shared".into(), package("shared", &[])),
("weapon".into(), package("weapon", &[("shared", "^1")])),
("enemy".into(), package("enemy", &[("shared", "^1")])),
(
"mission".into(),
package("mission", &[("weapon", "^1"), ("enemy", "^1")]),
),
("sequel".into(), package("sequel", &[("mission", "^1")])),
("other".into(), package("other", &[])),
]),
..Default::default()
}
}
#[test]
fn enabling_a_mission_selects_recursive_shared_dependencies_once() {
let selected = ids(&["other"]);
let planned = plan_enable(&graph(), &selected, "mission").unwrap();
assert_eq!(
planned,
ids(&["other", "shared", "enemy", "weapon", "mission"])
);
assert_eq!(selected, ids(&["other"]));
assert_eq!(plan_enable(&graph(), &planned, "mission").unwrap(), planned);
}
#[test]
fn unavailable_incompatible_api_cycles_and_conflicts_leave_selection_unchanged() {
let initial = ids(&["other"]);
let check = |graph: &Discovery, expected: &str| {
let error = plan_enable(graph, &initial, "mission")
.unwrap_err()
.to_string();
assert!(error.contains(expected), "{error}");
assert_eq!(initial, ids(&["other"]));
};
let mut g = graph();
g.mods.remove("shared");
check(&g, "install it");
let mut g = graph();
g.mods.get_mut("shared").unwrap().manifest.version = "2.0.0".into();
check(&g, "installed 2.0.0");
let mut g = graph();
g.mods.get_mut("shared").unwrap().manifest.api_version = 2;
check(&g, "requires API 2");
let mut g = graph();
g.mods
.get_mut("shared")
.unwrap()
.manifest
.dependencies
.insert("mission".into(), "^1".into());
check(&g, "cyclic");
let mut g = graph();
g.mods
.get_mut("other")
.unwrap()
.manifest
.conflicts
.push("weapon".into());
check(&g, "conflicts");
}
#[test]
fn disable_plan_removes_transitive_dependents_but_preserves_unrelated_mods() {
let initial = ids(&["other", "shared", "enemy", "weapon", "mission", "sequel"]);
let plan = plan_disable(&graph(), &initial, "weapon");
assert_eq!(
plan.removed,
ids(&["shared", "enemy", "weapon", "mission", "sequel"])
);
assert_eq!(plan.enabled, ids(&["other"]));
// Merely opening the confirmation must not change the pending selection.
assert_eq!(
initial,
ids(&["other", "shared", "enemy", "weapon", "mission", "sequel"])
);
assert_eq!(
modding::order(&graph(), &plan.enabled).unwrap(),
plan.enabled
);
}
#[test]
fn disabling_a_shared_dependency_cascades_across_both_branches() {
let initial = plan_enable(&graph(), &ids(&["other"]), "sequel").unwrap();
let plan = plan_disable(&graph(), &initial, "shared");
assert_eq!(plan.enabled, ids(&["other"]));
assert_eq!(plan.removed.len(), 5);
assert!(
plan_disable(&graph(), &initial, "absent")
.removed
.is_empty()
);
}
#[test]
fn disabling_a_missing_installed_package_can_still_remove_its_dependents() {
let mut g = graph();
g.mods.remove("shared");
let initial = ids(&["shared", "weapon", "other"]);
let plan = plan_disable(&g, &initial, "shared");
assert_eq!(plan.removed, ids(&["shared", "weapon"]));
assert_eq!(plan.enabled, ids(&["other"]));
}
#[test]
fn selecting_shipped_iron_coast_mission_selects_all_seven_real_dependencies() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods");
let discovery = modding::discover(&root);
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
let enabled = plan_enable(&discovery, &[], "iron_coast_mission_1").unwrap();
assert_eq!(enabled.len(), 8);
assert_eq!(enabled.last().unwrap(), "iron_coast_mission_1");
for id in [
"iron_coast_scout",
"iron_coast_turret",
"iron_coast_boss",
"pulse_cannon",
"seeker_swarm",
"ion_lance",
"iron_coast_music",
] {
assert!(enabled.contains(&id.to_string()));
let removal = plan_disable(&discovery, &enabled, id);
assert_eq!(removal.removed, enabled);
assert!(removal.enabled.is_empty());
}
assert_eq!(
plan_disable(&discovery, &enabled, "iron_coast_mission_1").removed,
enabled
);
}
#[test]
fn another_remaining_campaign_preserves_the_shared_dependencies_it_needs() {
let mut g = graph();
g.mods.insert(
"other_campaign".into(),
package("other_campaign", &[("enemy", "^1")]),
);
let mut enabled = plan_enable(&g, &[], "mission").unwrap();
enabled = plan_enable(&g, &enabled, "other_campaign").unwrap();
let plan = plan_disable(&g, &enabled, "mission");
assert_eq!(plan.removed, ids(&["weapon", "mission"]));
assert_eq!(plan.enabled, ids(&["shared", "enemy", "other_campaign"]));
assert_eq!(modding::order(&g, &plan.enabled).unwrap(), plan.enabled);
}
#[test]
fn a_standalone_mod_has_exactly_one_removal_for_the_instant_toggle() {
let enabled = plan_enable(&graph(), &ids(&["other"]), "mission").unwrap();
let plan = plan_disable(&graph(), &enabled, "other");
assert_eq!(plan.removed, ids(&["other"]));
assert_eq!(plan.enabled, ids(&["shared", "enemy", "weapon", "mission"]));
}
+1245
View File
File diff suppressed because it is too large. Load diff
+294
View File
@@ -0,0 +1,294 @@
use raptor_replica::{
audio::{DigitalSample, Mixer},
content::{ContentId, ContentPack, Registry},
modding::{self, ActiveMods},
sound::{self, SoundBank},
};
use serde_json::{Value, json};
use std::{fs, path::Path};
fn mus(body: &[u8], channels: u16) -> Vec<u8> {
let mut bytes = b"MUS\x1a".to_vec();
bytes.extend((body.len() as u16).to_le_bytes());
bytes.extend(16_u16.to_le_bytes());
bytes.extend(channels.to_le_bytes());
bytes.extend([0; 6]);
bytes.extend(body);
bytes
}
fn theme(key: u8) -> Vec<u8> {
mus(
&[0x40, 0, 0, 0x90, 128 | key, 100, 4, 0x80, key, 4, 0x60],
1,
)
}
fn pack(value: Value) -> ContentPack {
serde_json::from_value(value).unwrap()
}
fn install(root: &Path, id: &str, content: Value, score: &[u8]) {
let path = root.join(id);
fs::create_dir_all(&path).unwrap();
fs::write(
path.join("mod.toml"),
format!("id = \"{id}\"\nname = \"{id}\"\nversion = \"1.0.0\"\napi_version = 1\n"),
)
.unwrap();
fs::write(
path.join("content.json"),
serde_json::to_vec_pretty(&content).unwrap(),
)
.unwrap();
fs::write(path.join("theme.mus"), score).unwrap();
}
fn selected(ids: &[&str]) -> Vec<String> {
ids.iter().map(|id| (*id).to_owned()).collect()
}
fn error<T>(result: anyhow::Result<T>) -> String {
match result {
Ok(_) => panic!("expected failure"),
Err(error) => format!("{error:#}"),
}
}
fn music_pack() -> Value {
json!({
"music":[{"id":"demo:theme","name":"Demo theme","file":"theme.mus"}],
"missions":[{"id":"demo:mission","name":"Demo mission","duration":100,"music_track":"demo:theme"}]
})
}
fn build(root: &Path, enabled: &[&str]) -> anyhow::Result<ActiveMods> {
ActiveMods::build(
&Registry::default(),
&modding::discover(root),
&selected(enabled),
None,
)
}
fn instruments() -> Vec<u8> {
let mut bytes = b"#OPL_II#".to_vec();
for _ in 0..175 {
let mut instrument = [0; 36];
instrument[2] = 128;
for part in 0..2 {
let start = 4 + part * 16;
instrument[start..start + 6].copy_from_slice(&[0x21, 0xf2, 0x0f, 0, 0, 32]);
instrument[start + 7..start + 13].copy_from_slice(&[0x21, 0xf2, 0x0f, 0, 0, 0]);
}
bytes.extend(instrument);
}
bytes
}
#[test]
fn music_registry_references_defaults_and_selection_are_checked() {
let mut registry = Registry::default();
registry.apply(pack(music_pack()), "demo").unwrap();
registry.validate().unwrap();
assert!(registry.music[&ContentId::from("demo:theme")].looping);
let mission = registry.missions.get_mut(&"demo:mission".into()).unwrap();
mission.music = Some(86);
assert!(error(registry.validate()).contains("both music and music_track"));
let mission = registry.missions.get_mut(&"demo:mission".into()).unwrap();
mission.music = None;
mission.music_track = Some("missing:theme".into());
assert!(error(registry.validate()).contains("missing music track missing:theme"));
}
#[test]
fn music_additions_and_explicit_overrides_use_the_winning_package_files() {
let temp = tempfile::tempdir().unwrap();
install(temp.path(), "demo", music_pack(), &theme(60));
install(
temp.path(),
"override",
json!({
"overrides":["demo:theme"],
"music":[{"id":"demo:theme","name":"Replacement","file":"theme.mus","looping":false}]
}),
&theme(67),
);
let active = build(temp.path(), &["demo", "override"]).unwrap();
assert_eq!(active.registry.owners[&"demo:theme".into()], "override");
assert!(!active.registry.music[&"demo:theme".into()].looping);
let mut bank = SoundBank::default();
let score = bank
.mod_music(&active.registry, &"demo:theme".into())
.unwrap();
assert_eq!(
score.groups,
sound::decode_music(&theme(67)).unwrap().groups
);
let mut registry = Registry::default();
registry.apply(pack(music_pack()), "demo").unwrap();
assert!(
error(registry.apply(
pack(json!({"music":[{"id":"demo:theme","name":"Duplicate","file":"theme.mus"}]})),
"other"
))
.contains("explicit override")
);
assert!(error(registry.apply(pack(json!({"overrides":["demo:theme"],"sounds":[{"id":"demo:theme","name":"Wrong type","file":"theme.wav"}]})), "other")).contains("cannot change content type"));
}
#[test]
fn music_files_are_part_of_package_hashes_and_cache_clears_with_active_mods() {
let temp = tempfile::tempdir().unwrap();
install(temp.path(), "demo", music_pack(), &theme(60));
let discovery = modding::discover(temp.path());
let active =
ActiveMods::build(&Registry::default(), &discovery, &selected(&["demo"]), None).unwrap();
let mut bank = SoundBank::default();
let first = bank
.mod_music(&active.registry, &"demo:theme".into())
.unwrap();
fs::write(temp.path().join("demo/theme.mus"), theme(65)).unwrap();
assert_ne!(
discovery.mods["demo"].hash,
modding::discover(temp.path()).mods["demo"].hash
);
assert!(
error(ActiveMods::build(
&Registry::default(),
&discovery,
&selected(&["demo"]),
None
))
.contains("changed since discovery")
);
let cached = bank
.mod_music(&active.registry, &"demo:theme".into())
.unwrap();
assert_eq!(first.groups, cached.groups);
bank.clear_mods();
let replaced = bank
.mod_music(&active.registry, &"demo:theme".into())
.unwrap();
assert_ne!(first.groups, replaced.groups);
}
#[test]
fn missing_malformed_oversized_and_escaping_music_reject_activation() {
for (file, expected) in [
("missing.mus", "missing asset"),
("../outside.mus", "parent traversal"),
("theme.wav", ".mus file"),
] {
let temp = tempfile::tempdir().unwrap();
let mut content = music_pack();
content["music"][0]["file"] = json!(file);
install(temp.path(), "demo", content, &theme(60));
assert!(error(build(temp.path(), &["demo"])).contains(expected));
}
let temp = tempfile::tempdir().unwrap();
install(temp.path(), "demo", music_pack(), b"MThd");
assert!(error(build(temp.path(), &["demo"])).contains("invalid MUS signature"));
fs::write(
temp.path().join("demo/theme.mus"),
vec![0; sound::MAX_MUSIC_BYTES + 1],
)
.unwrap();
assert!(error(build(temp.path(), &["demo"])).contains("128 KiB"));
}
#[cfg(unix)]
#[test]
fn music_symlinks_cannot_escape_package_ownership() {
let temp = tempfile::tempdir().unwrap();
install(temp.path(), "demo", music_pack(), &theme(60));
let outside = temp.path().join("outside.mus");
fs::write(&outside, theme(67)).unwrap();
fs::remove_file(temp.path().join("demo/theme.mus")).unwrap();
std::os::unix::fs::symlink(&outside, temp.path().join("demo/theme.mus")).unwrap();
assert!(sound::load_music(&temp.path().join("demo"), "theme.mus").is_err());
assert!(!modding::discover(temp.path()).errors.is_empty());
}
#[test]
fn mod_mus_has_bounded_events_density_channels_and_duration() {
assert!(sound::decode_music(&mus(&[0x60], 1)).is_err());
assert!(sound::decode_music(&mus(&[0x90, 188, 100, 1, 0x60], 16)).is_err());
let mut simultaneous = vec![0x10, 60].repeat(256);
simultaneous.extend([0x90, 60, 1, 0x60]);
assert!(error(sound::decode_music(&mus(&simultaneous, 1))).contains("256 events"));
let mut events = vec![0x90, 60, 1].repeat(16385);
events.push(0x60);
assert!(error(sound::decode_music(&mus(&events, 1))).contains("16384 events"));
// 126001 encoded as a MUS base-128 time delta.
assert!(
error(sound::decode_music(&mus(
&[0x90, 188, 100, 0x87, 0xd8, 0x31, 0x60],
1
)))
.contains("30 minutes")
);
}
#[test]
fn mod_music_uses_original_fm_music_volume_and_its_declared_loop() {
let temp = tempfile::tempdir().unwrap();
let mut content = music_pack();
content["music"]
.as_array_mut()
.unwrap()
.push(json!({"id":"demo:once","name":"One play","file":"theme.mus","looping":false}));
install(temp.path(), "demo", content, &theme(60));
let active = build(temp.path(), &["demo"]).unwrap();
let mut bank = SoundBank::default();
let score = bank
.mod_music(&active.registry, &"demo:theme".into())
.unwrap();
let mut looping = Mixer::new(44100, &instruments()).unwrap();
looping.music_volume = 127;
looping.fx_volume = 0;
looping.play_with_fade(
score.clone(),
active.registry.music[&"demo:theme".into()].looping,
true,
);
let mut first = vec![0; 44100 * 2];
looping.mix(&mut first);
assert!(first.iter().any(|value| *value != 0));
looping.music_volume = 0;
let mut muted = vec![1; 4096];
looping.mix(&mut muted);
assert!(muted.iter().all(|value| *value == 0));
looping.fx_volume = 127;
looping.sample(
DigitalSample {
rate: 11025,
data: vec![220; 500],
},
127,
128,
100,
1,
);
looping.mix(&mut muted);
assert!(muted.iter().any(|value| *value != 0));
looping.music_volume = 127;
looping.fx_volume = 0;
let mut repeating_tail = vec![0; 44100 * 2];
looping.mix(&mut repeating_tail);
assert!(repeating_tail.iter().any(|value| *value != 0));
let mut once = Mixer::new(44100, &instruments()).unwrap();
once.music_volume = 127;
once.play_with_fade(
score,
active.registry.music[&"demo:once".into()].looping,
true,
);
once.mix(&mut first);
let mut ended_tail = vec![0; 44100 * 2];
once.mix(&mut ended_tail);
let repeating_energy: u64 = repeating_tail
.iter()
.map(|value| value.unsigned_abs() as u64)
.sum();
let ended_energy: u64 = ended_tail
.iter()
.map(|value| value.unsigned_abs() as u64)
.sum();
assert!(
ended_energy < repeating_energy / 20,
"ended energy {ended_energy}, loop energy {repeating_energy}"
);
}
+263
View File
@@ -0,0 +1,263 @@
//! A confirmed changed-capacity save retains equipment without increasing the
//! original combat shield limits or losing quantities on additive operations.
use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{Command, ScriptRuntime},
persistence::Pilot,
simulation::{Input, Pickup, World},
};
use serde_json::json;
fn registry() -> Registry {
let mut registry = Registry::default();
let pack: ContentPack = serde_json::from_value(json!({
"items": [
{"id":"base:forward_guns","name":"Guns","price":0},
{"id":"base:energy","name":"Energy","price":0,"max_count":100,"initial_count":25,"shield":25,"legacy":16},
{"id":"base:super_shield","name":"Super Shield","price":0,"max_count":100,"initial_count":100,"legacy":15},
{"id":"base:ammo","name":"Ammo","price":0,"max_count":3}
],
"missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":10000}]
})).unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
fn id(value: &str) -> ContentId {
value.into()
}
fn overflowing() -> Pilot {
let mut pilot = Pilot::default();
pilot.inventory.insert(id("base:energy"), 105);
pilot.inventory.insert(id("base:super_shield"), 125);
pilot.super_shield_reserves = vec![130];
pilot
}
fn command(world: &mut World, registry: &Registry, command: Command) {
world.apply_commands(&[command], registry).unwrap();
}
fn collect(world: &mut World, registry: &Registry, item: &str) {
world.pickups.push(Pickup {
entity: 123,
item: item.into(),
x: world.player.x,
y: world.player.y,
legacy: None,
});
world
.step(Input::default(), registry, &mut ScriptRuntime::new())
.unwrap();
assert!(world.pickups.is_empty());
}
#[test]
fn confirmed_load_steps_and_snapshots_keep_full_charge_with_bounded_usable_shields() {
let registry = registry();
let saved = overflowing();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pilot.json");
saved.save(&path).unwrap();
let loaded = Pilot::inspect_load(&path, &registry, &[]).unwrap();
assert!(!loaded.compatibility.is_compatible());
let pilot = loaded.confirm_load();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
assert_eq!((world.player.shield, world.player.super_shield), (100, 100));
for _ in 0..10 {
world
.step(Input::default(), &registry, &mut ScriptRuntime::new())
.unwrap();
}
let after = world.pilot(&pilot);
assert_eq!(after.inventory, saved.inventory);
assert_eq!(after.super_shield_reserves, saved.super_shield_reserves);
// A serialized in-progress world also keeps its retained charge.
let world: World = serde_json::from_value(serde_json::to_value(world).unwrap()).unwrap();
assert_eq!(world.pilot(&pilot).inventory, saved.inventory);
}
#[test]
fn original_world_serialization_omits_overflow_and_old_worlds_default_to_zero() {
let registry = registry();
let pilot = Pilot::default();
let world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let value = serde_json::to_value(world).unwrap();
assert!(value.get("energy_overflow").is_none());
assert!(value.get("super_shield_overflow").is_none());
let decoded: World = serde_json::from_value(value).unwrap();
assert_eq!(
(decoded.energy_overflow, decoded.super_shield_overflow),
(0, 0)
);
}
#[test]
fn damage_consumes_usable_charge_and_destroyed_modules_do_not_resurrect_overflow() {
let registry = registry();
let pilot = overflowing();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
command(
&mut world,
&registry,
Command::Damage {
entity: 0,
amount: 20,
},
);
assert_eq!(world.player.super_shield, 80);
assert_eq!(world.pilot(&pilot).inventory[&id("base:super_shield")], 105);
command(
&mut world,
&registry,
Command::Damage {
entity: 0,
amount: 81,
},
);
assert_eq!(world.player.super_shield, 100);
assert_eq!(world.super_shield_overflow, 30);
assert!(world.player.super_shield_reserves.is_empty());
assert_eq!(world.pilot(&pilot).inventory[&id("base:super_shield")], 130);
command(
&mut world,
&registry,
Command::Damage {
entity: 0,
amount: 101,
},
);
assert_eq!(world.super_shield_overflow, 0);
assert!(
!world
.pilot(&pilot)
.inventory
.contains_key(&id("base:super_shield"))
);
command(
&mut world,
&registry,
Command::Damage {
entity: 0,
amount: 10,
},
);
assert_eq!(world.player.shield, 90);
assert_eq!(world.pilot(&pilot).inventory[&id("base:energy")], 95);
collect(&mut world, &registry, "base:energy");
assert_eq!(world.player.shield, 100);
assert_eq!(world.pilot(&pilot).inventory[&id("base:energy")], 105);
}
#[test]
fn additive_pickup_and_script_grants_never_reduce_quantities_above_current_capacity() {
let registry = registry();
let mut pilot = overflowing();
pilot.inventory.insert(id("base:ammo"), 7);
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
for item in ["base:ammo", "base:energy", "base:super_shield"] {
command(
&mut world,
&registry,
Command::GiveItem {
item: item.into(),
count: 1,
},
);
}
assert_eq!(world.pilot(&pilot).inventory, pilot.inventory);
collect(&mut world, &registry, "base:ammo");
assert_eq!(world.pilot(&pilot).inventory[&id("base:ammo")], 7);
command(
&mut world,
&registry,
Command::ConsumeItem {
item: "base:ammo".into(),
count: 2,
},
);
command(
&mut world,
&registry,
Command::GiveItem {
item: "base:ammo".into(),
count: 1,
},
);
assert_eq!(world.pilot(&pilot).inventory[&id("base:ammo")], 5);
}
#[test]
fn scripted_consumption_reduces_retained_charge_without_restoring_spent_charge() {
let registry = registry();
let pilot = overflowing();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
command(
&mut world,
&registry,
Command::ConsumeItem {
item: "base:energy".into(),
count: 10,
},
);
command(
&mut world,
&registry,
Command::ConsumeItem {
item: "base:super_shield".into(),
count: 110,
},
);
assert_eq!(world.player.shield, 90);
assert_eq!(world.pilot(&pilot).inventory[&id("base:energy")], 95);
assert_eq!(world.player.super_shield, 0);
assert_eq!(world.pilot(&pilot).inventory[&id("base:super_shield")], 15);
command(
&mut world,
&registry,
Command::ConsumeItem {
item: "base:super_shield".into(),
count: 15,
},
);
command(
&mut world,
&registry,
Command::ConsumeItem {
item: "base:energy".into(),
count: 90,
},
);
assert_eq!(world.pilot(&pilot).inventory[&id("base:energy")], 5);
assert_eq!(world.super_shield_overflow, 0);
command(
&mut world,
&registry,
Command::Damage {
entity: 0,
amount: 1,
},
);
assert_eq!(world.pilot(&pilot).inventory[&id("base:super_shield")], 130);
}
#[test]
fn new_large_grants_are_split_once_without_duplicating_default_energy() {
let mut registry = registry();
for item in ["base:energy", "base:super_shield"] {
registry.items.get_mut(&id(item)).unwrap().max_count = 200;
}
let mut pilot = Pilot::default();
pilot.inventory.remove(&id("base:energy"));
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
for item in ["base:energy", "base:super_shield"] {
command(
&mut world,
&registry,
Command::GiveItem {
item: item.into(),
count: 125,
},
);
assert_eq!(world.pilot(&pilot).inventory[&id(item)], 125);
}
assert_eq!((world.player.shield, world.player.super_shield), (100, 100));
}
+195
View File
@@ -0,0 +1,195 @@
use raptor_replica::{
content::{ContentPack, Registry},
modding::{Command, ScriptRuntime},
persistence::Pilot,
simulation::{Effect, EffectKind, Input, VisualEffect, VisualEffectKind, World},
};
use serde_json::json;
fn fixture() -> (Registry, World) {
let pack: ContentPack = serde_json::from_value(json!({
"weapons":[{"id":"base:gun","name":"Gun","damage":1,"speed":8,"cooldown":8,"behavior":"straight","target":"all",
"legacy":0,"art":{"asset":0x101c3,"logical_width":4,"logical_height":8,"frames":4}}],
"items":[{"id":"base:forward_guns","name":"Guns","price":0,"legacy":0,"weapon":"base:gun"},
{"id":"base:energy","name":"Energy","price":0,"legacy":16,"max_count":100},
{"id":"base:super_shield","name":"Shield","price":0,"legacy":15,"max_count":100}],
"missions":[{"id":"base:sector_1_wave_1","name":"Effects fixture","duration":1000}]
})).unwrap();
let mut registry = Registry::default();
registry.apply(pack, "base").unwrap();
let pilot = Pilot::default();
let world = World::new(&pilot.mission, &pilot, &registry).unwrap();
(registry, world)
}
#[test]
fn original_forward_gun_casings_stay_in_world_space_and_do_not_create_explosions() {
let (registry, mut world) = fixture();
let mut scripts = ScriptRuntime::new();
world
.step(
Input {
fire: true,
demo_bank: Some(3),
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
assert!(world.effects.is_empty());
assert_eq!(world.visual_effects.len(), 2);
assert!(
world
.visual_effects
.iter()
.all(|e| e.kind == VisualEffectKind::Casing)
);
let positions: Vec<_> = world.visual_effects.iter().map(|e| (e.x, e.y)).collect();
assert_eq!(positions, [(166, 176), (153, 176)]);
world
.step(
Input {
absolute: Some([180, 150]),
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
assert_eq!(
world
.visual_effects
.iter()
.map(|e| (e.x, e.y))
.collect::<Vec<_>>(),
[(166, 177), (153, 177)]
);
for _ in 0..3 {
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
}
assert!(
world.visual_effects.is_empty(),
"native casing animation lasts four simulation steps"
);
}
#[test]
fn native_smoke_uses_two_pixel_drift_and_exact_frame_lifetimes() {
let (registry, mut world) = fixture();
world
.visual_effects
.push(VisualEffect::new(VisualEffectKind::SmokeDown, 100, 100));
world
.visual_effects
.push(VisualEffect::new(VisualEffectKind::SmokeTrail, 120, 100));
let mut scripts = ScriptRuntime::new();
for age in 1..=4 {
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(
(world.visual_effects[0].age, world.visual_effects[0].y),
(age, 100 + 2 * (age as i32 - 1))
);
assert_eq!(
(world.visual_effects[1].age, world.visual_effects[1].y),
(age, 100 - 2 * (age as i32 - 1))
);
}
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.visual_effects.len(), 1);
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert!(world.visual_effects.is_empty());
}
#[test]
fn shield_hit_follows_player_and_secondary_animations_do_not_change_gameplay_hashes() {
let (registry, mut world) = fixture();
world.player.super_shield = 20;
world
.player
.inventory
.insert("base:super_shield".into(), 20);
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 3,
}],
&registry,
)
.unwrap();
assert_eq!(world.visual_effects.len(), 1);
assert_eq!(world.visual_effects[0].kind, VisualEffectKind::Shield);
assert_eq!(world.visual_effects[0].anchor, Some(0));
let mut scripts = ScriptRuntime::new();
world
.step(
Input {
absolute: Some([100, 100]),
..Input::default()
},
&registry,
&mut scripts,
)
.unwrap();
assert_eq!(
(world.visual_effects[0].x, world.visual_effects[0].y),
(116, 116)
);
let digest = world.digest();
let snapshot = world.reference_state(&registry);
let seed = world.rng.seed;
world.visual_effects.clear();
assert_eq!(world.digest(), digest);
assert_eq!(world.reference_state(&registry), snapshot);
assert_eq!(world.rng.seed, seed);
}
#[test]
fn saturated_native_animation_pool_shares_one_limit_without_changing_fire_or_rng() {
let (registry, initial) = fixture();
let mut snapshots = Vec::new();
for existing in [98, 99, 100] {
let mut world = initial.clone();
world.effects = vec![
Effect {
x: 40,
y: 40,
px: 40,
py: 40,
age: 0,
kind: EffectKind::AirBlast,
ground: false
};
existing
];
world
.step(
Input {
fire: true,
demo_bank: Some(3),
..Input::default()
},
&registry,
&mut ScriptRuntime::new(),
)
.unwrap();
assert_eq!(world.effects.len() + world.visual_effects.len(), 100);
assert_eq!(world.visual_effects.len(), 100 - existing);
assert_eq!(
world.projectiles.len(),
2,
"visual allocation failure must not prevent real gun rounds"
);
snapshots.push(world.reference_state(&registry));
}
assert!(snapshots.windows(2).all(|pair| pair[0] == pair[1]));
}
+532
View File
@@ -0,0 +1,532 @@
//! Original shield-module inventory and recharge behavior, with synthetic data.
use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{Command, ScriptRuntime},
persistence::Pilot,
simulation::{Input, Pickup, World},
};
use serde_json::json;
fn fixture() -> Registry {
let mut items = vec![
json!({"id":"base:forward_guns","name":"Guns","price":12000,"weapon":"base:shot","legacy":0}),
json!({"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"shield":25,"legacy":16}),
json!({"id":"base:super_shield","name":"Super Shield","price":78500,"max_count":100,"initial_count":100,"legacy":15}),
];
for n in 0..20 {
items.push(json!({"id":format!("base:filler_{n}"),"name":"Inventory slot","price":1}));
}
let pack: ContentPack = serde_json::from_value(json!({
"items":items,
"weapons":[{"id":"base:shot","name":"Gun","damage":1,"speed":8,"cooldown":5,"behavior":"straight","target":"all"}],
"missions":[{"id":"base:sector_1_wave_1","name":"Shield fixture","duration":100000}]
})).unwrap();
let mut registry = Registry::default();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
fn super_id() -> ContentId {
"base:super_shield".into()
}
fn shielded(active: u32, reserves: &[u32]) -> Pilot {
let mut pilot = Pilot {
credits: 2_000_000,
difficulty: 2,
..Pilot::default()
};
pilot.inventory.insert(super_id(), active);
pilot.super_shield_reserves = reserves.to_vec();
pilot
}
fn mission_world(pilot: &Pilot, registry: &Registry) -> World {
World::new(&pilot.mission, pilot, registry).unwrap()
}
fn hit(world: &mut World, damage: i32, registry: &Registry) {
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: damage,
}],
registry,
)
.unwrap();
}
fn idle(world: &mut World, ticks: usize, registry: &Registry) {
let mut scripts = ScriptRuntime::new();
for _ in 0..ticks {
world
.step(Input::default(), registry, &mut scripts)
.unwrap();
}
}
fn collect(world: &mut World, item: &str, registry: &Registry) {
world.pickups.push(Pickup {
entity: 9000,
item: item.into(),
x: world.player.x,
y: world.player.y,
legacy: None,
});
idle(world, 1, registry);
assert!(world.pickups.is_empty());
}
#[test]
fn purchases_preserve_active_charge_and_enforce_five_modules_and_twenty_slots() {
let registry = fixture();
let mut pilot = shielded(17, &[]);
for _ in 0..4 {
pilot.buy(&super_id(), &registry).unwrap();
}
assert_eq!(pilot.inventory[&super_id()], 17);
assert_eq!(pilot.super_shield_reserves, vec![100; 4]);
assert_eq!(pilot.item_count(&super_id()), 5);
assert_eq!(pilot.inventory_slots(), 7);
let before = serde_json::to_value(&pilot).unwrap();
assert!(pilot.buy(&super_id(), &registry).is_err());
assert_eq!(serde_json::to_value(&pilot).unwrap(), before);
let mut full = shielded(100, &[100]);
for n in 0..16 {
full.inventory
.insert(format!("base:filler_{n}").as_str().into(), 1);
}
assert_eq!(full.inventory_slots(), 20);
assert!(full.buy(&super_id(), &registry).is_err());
full.inventory.insert("base:energy".into(), 75);
full.buy(&"base:energy".into(), &registry).unwrap();
assert_eq!(full.inventory[&ContentId::from("base:energy")], 100);
full.validate(&registry, &[]).unwrap();
}
#[test]
fn selling_removes_full_equipped_module_and_selects_first_reserve() {
let registry = fixture();
let mut pilot = shielded(100, &[37, 100]);
let credits = pilot.credits;
pilot.sell(&super_id(), &registry).unwrap();
assert_eq!(pilot.credits, credits + 39250);
assert_eq!(pilot.inventory[&super_id()], 37);
assert_eq!(pilot.super_shield_reserves, vec![100]);
assert_eq!(pilot.item_count(&super_id()), 2);
let before = serde_json::to_value(&pilot).unwrap();
assert!(
pilot.sell(&super_id(), &registry).is_err(),
"partial active module cannot sell a reserve instead"
);
assert_eq!(serde_json::to_value(&pilot).unwrap(), before);
pilot.inventory.insert(super_id(), 100);
pilot.sell(&super_id(), &registry).unwrap();
pilot.sell(&super_id(), &registry).unwrap();
assert_eq!(pilot.item_count(&super_id()), 0);
assert!(!pilot.inventory.contains_key(&super_id()));
}
#[test]
fn normal_energy_sales_remove_one_purchase_and_retain_positive_shield() {
let registry = fixture();
let mut pilot = Pilot::default();
pilot.sell(&"base:energy".into(), &registry).unwrap();
assert_eq!(pilot.inventory[&ContentId::from("base:energy")], 75);
assert_eq!(pilot.credits, 5000);
pilot.inventory.insert("base:energy".into(), 26);
pilot.sell(&"base:energy".into(), &registry).unwrap();
assert_eq!(pilot.inventory[&ContentId::from("base:energy")], 1);
assert!(pilot.sell(&"base:energy".into(), &registry).is_err());
}
#[test]
fn hits_do_not_spill_into_reserves_or_normal_shields_and_exact_zero_remains_equipped() {
let registry = fixture();
let pilot = shielded(5, &[37, 0, 100]);
let mut world = mission_world(&pilot, &registry);
hit(&mut world, 5, &registry);
assert_eq!(world.player.super_shield, 0);
assert!(world.player.inventory.contains_key(&super_id()));
assert_eq!(world.player.super_shield_reserves, vec![37, 0, 100]);
hit(&mut world, 9000, &registry);
assert_eq!(world.player.super_shield, 37);
assert_eq!(world.player.shield, 100);
hit(&mut world, 38, &registry);
assert_eq!(
world.player.super_shield, 0,
"a zero-charge reserve may become equipped"
);
assert!(world.player.inventory.contains_key(&super_id()));
hit(&mut world, 1, &registry);
assert_eq!(world.player.super_shield, 100);
hit(&mut world, 101, &registry);
assert!(!world.player.inventory.contains_key(&super_id()));
assert!(world.player.super_shield_reserves.is_empty());
assert_eq!(world.player.shield, 100);
hit(&mut world, 3, &registry);
assert_eq!(world.player.shield, 97);
let saved = world.pilot(&pilot);
assert!(saved.super_shield_reserves.is_empty());
assert_eq!(saved.inventory[&ContentId::from("base:energy")], 97);
}
#[test]
fn rookie_damage_is_halved_before_module_depletion_and_round_trips() {
let registry = fixture();
let mut pilot = shielded(5, &[71, 99]);
pilot.difficulty = 0;
let mut world = mission_world(&pilot, &registry);
hit(&mut world, 7, &registry);
assert_eq!(world.player.super_shield, 2);
hit(&mut world, 4, &registry);
assert_eq!(world.player.super_shield, 0);
hit(&mut world, 5, &registry);
assert_eq!(world.player.super_shield, 71);
let saved = world.pilot(&pilot);
saved.validate(&registry, &[]).unwrap();
let restored = mission_world(&saved, &registry);
assert_eq!(restored.player.super_shield, 71);
assert_eq!(restored.player.super_shield_reserves, vec![99]);
}
#[test]
fn pickups_add_modules_without_refilling_active_charge_and_energy_only_charges_active() {
let registry = fixture();
let pilot = shielded(20, &[100; 4]);
let mut world = mission_world(&pilot, &registry);
collect(&mut world, "base:super_shield", &registry);
assert_eq!(world.player.super_shield, 20);
assert_eq!(
world.player.super_shield_reserves,
vec![100; 5],
"native pickup capacity is slots; five is the shop limit"
);
collect(&mut world, "base:energy", &registry);
assert_eq!(world.player.super_shield, 26);
assert_eq!(world.player.super_shield_reserves, vec![100; 5]);
world.player.shield = 90;
collect(&mut world, "base:energy", &registry);
assert_eq!(world.player.shield, 100);
assert_eq!(
world.player.super_shield, 26,
"normal-shield overflow is discarded, not charged into a module"
);
world.player.super_shield = 0;
world.player.inventory.insert(super_id(), 0);
collect(&mut world, "base:energy", &registry);
assert_eq!(
world.player.super_shield, 0,
"normal energy cannot revive an empty equipped module"
);
let mut full = shielded(20, &[99]);
for n in 0..16 {
full.inventory
.insert(format!("base:filler_{n}").as_str().into(), 1);
}
let mut world = mission_world(&full, &registry);
collect(&mut world, "base:super_shield", &registry);
assert_eq!(world.player.super_shield, 20);
assert_eq!(world.player.super_shield_reserves, vec![99]);
}
#[test]
fn idle_recharge_uses_97_steps_pauses_on_weapon_attempts_and_preserves_rng() {
let registry = fixture();
let mut pilot = shielded(44, &[88]);
pilot.inventory.insert("base:energy".into(), 75);
let mut world = mission_world(&pilot, &registry);
let rng = world.rng;
idle(&mut world, 96, &registry);
assert_eq!(world.player.shield, 75);
assert_eq!(world.shield_recharge_ticks, 96);
let checkpoint = world.pilot(&pilot);
let mut world = mission_world(&checkpoint, &registry);
idle(&mut world, 1, &registry);
assert_eq!(world.player.shield, 76);
assert_eq!(world.player.super_shield, 44);
assert_eq!(world.player.super_shield_reserves, vec![88]);
assert_eq!(world.rng, rng);
idle(&mut world, 96, &registry);
world
.step(
Input {
fire: true,
..Input::default()
},
&registry,
&mut ScriptRuntime::new(),
)
.unwrap();
assert_eq!(world.shield_recharge_ticks, 0);
idle(&mut world, 96, &registry);
assert_eq!(world.player.shield, 76);
idle(&mut world, 1, &registry);
assert_eq!(world.player.shield, 77);
world.player.shield = 100;
idle(&mut world, 97, &registry);
assert_eq!(
world.player.super_shield, 44,
"one-unit idle recharge rounds to zero for super shields"
);
let mut ace = pilot.clone();
ace.difficulty = 3;
let mut ace = mission_world(&ace, &registry);
idle(&mut ace, 200, &registry);
assert_eq!(ace.player.shield, 75);
let mut ending = mission_world(&pilot, &registry);
ending.end_timer = Some(500);
idle(&mut ending, 97, &registry);
assert_eq!(ending.player.shield, 75);
}
#[test]
fn charge_unit_script_commands_preserve_reserves_and_rollback_all_module_state() {
let registry = fixture();
let pilot = shielded(12, &[44, 100]);
let mut world = mission_world(&pilot, &registry);
world
.apply_commands(
&[
Command::GiveItem {
item: super_id().0,
count: 16,
},
Command::ConsumeItem {
item: super_id().0,
count: 18,
},
],
&registry,
)
.unwrap();
assert_eq!(world.player.super_shield, 10);
assert_eq!(world.player.inventory[&super_id()], 10);
assert_eq!(world.player.super_shield_reserves, vec![44, 100]);
let before = serde_json::to_value(&world).unwrap();
assert!(
world
.apply_commands(
&[
Command::Damage {
entity: 0,
amount: 15
},
Command::Velocity {
entity: 999,
vx: 1,
vy: 1
}
],
&registry
)
.is_err()
);
assert_eq!(serde_json::to_value(&world).unwrap(), before);
world
.apply_commands(
&[
Command::ConsumeItem {
item: super_id().0,
count: 10,
},
Command::ConsumeItem {
item: "base:energy".into(),
count: 100,
},
],
&registry,
)
.unwrap();
assert_eq!(world.player.super_shield, 0);
assert_eq!(world.player.shield, 0);
assert_eq!(world.player.inventory[&super_id()], 0);
assert_eq!(world.player.inventory[&ContentId::from("base:energy")], 0);
assert_eq!(world.player.super_shield_reserves, vec![44, 100]);
}
#[test]
fn consumed_equipment_releases_slot_selects_next_special_and_rolls_back_atomically() {
let mut registry = fixture();
for n in 0..2 {
let item = registry
.items
.get_mut(&ContentId::from(format!("base:filler_{n}").as_str()))
.unwrap();
item.special = true;
item.weapon = Some("base:shot".into());
}
let first = ContentId::from("base:filler_0");
let second = ContentId::from("base:filler_1");
let mut pilot = shielded(17, &[100]);
for n in 0..16 {
pilot
.inventory
.insert(format!("base:filler_{n}").as_str().into(), 1);
}
pilot.selected = Some(first.clone());
assert_eq!(pilot.inventory_slots(), 20);
let mut world = mission_world(&pilot, &registry);
let before = serde_json::to_value(&world).unwrap();
let consume = Command::ConsumeItem {
item: first.0.clone(),
count: 1,
};
assert!(
world
.apply_commands(
&[
consume.clone(),
Command::Velocity {
entity: 999,
vx: 1,
vy: 1,
},
],
&registry,
)
.is_err()
);
assert_eq!(serde_json::to_value(&world).unwrap(), before);
world.apply_commands(&[consume], &registry).unwrap();
assert!(!world.player.inventory.contains_key(&first));
assert_eq!(world.player.selected, Some(second.clone()));
assert_eq!(world.pilot(&pilot).inventory_slots(), 19);
world
.apply_commands(
&[
Command::GiveItem {
item: "base:filler_16".into(),
count: 1,
},
Command::ConsumeItem {
item: second.0,
count: 1,
},
],
&registry,
)
.unwrap();
assert_eq!(world.player.selected, None);
assert_eq!(world.pilot(&pilot).inventory_slots(), 19);
world.pilot(&pilot).validate(&registry, &[]).unwrap();
}
#[test]
fn version_one_saves_without_extended_fields_remain_readable_without_source_changes() {
let registry = fixture();
let pilot = shielded(31, &[]);
let mut old = serde_json::to_value(&pilot).unwrap();
old.as_object_mut().unwrap().remove("super_shield_reserves");
old.as_object_mut().unwrap().remove("shield_recharge_ticks");
let bytes = serde_json::to_vec(&old).unwrap();
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("old-v1.json");
std::fs::write(&path, &bytes).unwrap();
let saved = Pilot::load(&path, &registry, &[]).unwrap();
assert_eq!(saved.item_count(&super_id()), 1);
assert!(saved.super_shield_reserves.is_empty());
assert_eq!(saved.shield_recharge_ticks, 0);
let mut world = mission_world(&saved, &registry);
hit(&mut world, 12, &registry);
assert_eq!(world.pilot(&saved).inventory[&super_id()], 19);
assert_eq!(std::fs::read(path).unwrap(), bytes);
let mut bad = shielded(100, &[101]);
assert!(bad.validate(&registry, &[]).is_err());
bad.super_shield_reserves = vec![100];
bad.inventory.remove(&super_id());
assert!(bad.validate(&registry, &[]).is_err());
}
#[test]
fn failed_callback_restores_module_order_charge_recharge_and_script_state() {
let mut registry = fixture();
registry
.missions
.get_mut(&ContentId::from("base:sector_1_wave_1"))
.unwrap()
.script = Some("failed.rhai".into());
let mut pilot = shielded(12, &[44, 100]);
pilot.shield_recharge_ticks = 95;
let mut world = mission_world(&pilot, &registry);
let mut scripts = ScriptRuntime::new();
scripts
.add(
"base",
"failed.rhai",
r#"
fn on_mission_start(e, s, r) {
#{commands:[#{op:"damage",entity:0,amount:15},
#{op:"velocity",entity:999,vx:1,vy:1}],state:#{fired:true}}
}
"#,
)
.unwrap();
let before = serde_json::to_value(&world).unwrap();
let states = scripts.states.clone();
let rng = scripts.rng.clone();
assert!(
world
.step(Input::default(), &registry, &mut scripts)
.is_err()
);
assert_eq!(serde_json::to_value(&world).unwrap(), before);
assert_eq!(scripts.states, states);
assert_eq!(scripts.rng, rng);
}
fn encrypt(bytes: &[u8]) -> Vec<u8> {
let key = b"CASTLE";
let mut index = 25 % key.len();
let mut previous = key[index];
bytes
.iter()
.map(|clear| {
let byte = clear.wrapping_add(key[index]).wrapping_add(previous);
previous = byte;
index = (index + 1) % key.len();
byte
})
.collect()
}
fn legacy_module_save(modules: &[(i32, i32)]) -> Vec<u8> {
let mut header = vec![0_u8; 88];
for (offset, value) in [
(40, -1_i32),
(60, modules.len() as i32 + 2),
(64, 2),
(68, 2),
(72, 2),
(76, 2),
] {
header[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
let mut records = vec![(1_i32, 0_i32, 1_i32), (75, 16, 1)];
records.extend(modules.iter().map(|&(charge, inuse)| (charge, 15, inuse)));
let mut body = vec![];
for (charge, kind, inuse) in records {
let mut record = vec![0_u8; 24];
for (offset, value) in [(8, charge), (12, kind), (20, inuse)] {
record[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
body.extend(record);
}
[encrypt(&header), encrypt(&body)].concat()
}
#[test]
fn legacy_import_preserves_independent_charges_equipped_flag_and_inventory_order() {
let registry = fixture();
let bytes = legacy_module_save(&[(11, 0), (22, 0), (100, 1)]);
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("CHAR0000.FIL");
std::fs::write(&path, &bytes).unwrap();
let pilot = Pilot::import_legacy(&path, &registry).unwrap();
assert_eq!(pilot.inventory[&super_id()], 100);
assert_eq!(pilot.super_shield_reserves, vec![11, 22]);
assert_eq!(pilot.item_count(&super_id()), 3);
let mut world = mission_world(&pilot, &registry);
hit(&mut world, 101, &registry);
assert_eq!(world.player.super_shield, 11);
assert_eq!(world.player.super_shield_reserves, vec![22]);
let output = temp.path().join("rust.json");
world.pilot(&pilot).save(&output).unwrap();
let restored = Pilot::load(&output, &registry, &[]).unwrap();
assert_eq!(restored.item_count(&super_id()), 2);
assert_eq!(std::fs::read(path).unwrap(), bytes);
}
+330
View File
@@ -0,0 +1,330 @@
use raptor_replica::{
content::{ContentPack, Registry},
modding::{self, ActiveMods, Command, ScriptRuntime},
persistence::Pilot,
simulation::{Input, Outcome, World},
sound,
};
use serde_json::json;
fn digital_patch() -> Vec<u8> {
let data: Vec<_> = (0..4000)
.map(|i| if i % 20 < 10 { 240 } else { 16 })
.collect();
let mut bytes = vec![3, 0];
bytes.extend(11025_u16.to_le_bytes());
bytes.extend(((data.len() + 32) as i32).to_le_bytes());
bytes.extend([0; 16]);
bytes.extend(data);
bytes.extend([0; 16]);
bytes
}
fn original_catalog() -> (tempfile::TempDir, raptor_replica::assets::Catalog) {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("marker"), []).unwrap();
let bytes = digital_patch();
std::fs::write(root.path().join("digital"), &bytes).unwrap();
std::fs::write(
root.path().join("INDEX"),
format!("00090 0 marker\n00094 {} digital\n", bytes.len()),
)
.unwrap();
let catalog = raptor_replica::assets::Catalog::open(root.path()).unwrap();
(root, catalog)
}
fn mixer() -> raptor_replica::audio::Mixer {
let mut genmidi = b"#OPL_II#".to_vec();
genmidi.resize(8 + 175 * 36, 0);
let mut mixer = raptor_replica::audio::Mixer::new(44100, &genmidi).unwrap();
mixer.fx_volume = 127;
mixer
}
#[test]
fn original_marker_resolves_digital_variant_and_keeps_decoded_cache() {
let (root, catalog) = original_catalog();
assert!(raptor_replica::audio::DigitalSample::decode(&catalog.read(144).unwrap()).is_err());
let mut bank = sound::SoundBank::default();
let resolved = bank.original(&catalog, 144).unwrap();
assert_eq!(resolved.data.len(), 4000);
assert_eq!(bank.original_count(), 1);
std::fs::remove_file(root.path().join("digital")).unwrap();
assert_eq!(bank.original(&catalog, 144).unwrap().data, resolved.data);
assert!(bank.original(&catalog, 999).is_err());
}
#[test]
fn live_world_firing_reaches_the_native_digital_mixer() {
let (_root, catalog) = original_catalog();
let mut registry = registry();
registry
.missions
.get_mut(&Pilot::default().mission)
.unwrap()
.script = None;
let content: ContentPack = serde_json::from_value(json!({
"weapons":[{"id":"base:test_guns","name":"Test guns","damage":2,"speed":8,"cooldown":2,"target":"all","behavior":"straight","legacy":0}]
})).unwrap();
registry.apply(content, "base").unwrap();
registry
.items
.get_mut(&"base:forward_guns".into())
.unwrap()
.weapon = Some("base:test_guns".into());
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).unwrap();
world
.step(
Input {
fire: true,
..Input::default()
},
&registry,
&mut ScriptRuntime::new(),
)
.unwrap();
let event = world
.sound_events
.iter()
.find(|s| s.asset == 144)
.expect("firing did not emit native gun sound");
let mut mixer = mixer();
let mut bank = sound::SoundBank::default();
bank.queue_original(&catalog, &mut mixer, event).unwrap();
let mut pcm = vec![0; 4096];
mixer.mix(&mut pcm);
assert!(pcm.iter().any(|s| *s != 0));
}
#[cfg(feature = "desktop")]
#[test]
fn production_sdl_callback_outputs_and_captures_resolved_original_effect() {
use sdl2::audio::AudioCallback;
let (_root, catalog) = original_catalog();
let mut mixer = mixer();
mixer.start_capture(1).unwrap();
let mut bank = sound::SoundBank::default();
bank.queue_original(&catalog, &mut mixer, &sound::ui_event("FX_GUN").unwrap())
.unwrap();
let mut output = [99; 4096];
AudioCallback::callback(&mut mixer, &mut output);
assert!(output.iter().any(|s| *s != 0));
assert!(output.chunks_exact(2).any(|s| s[0] != 0 && s[1] != 0));
assert_eq!(mixer.take_capture().unwrap().samples, output);
}
#[cfg(feature = "desktop")]
#[test]
#[ignore = "requires the original untracked catalog; uses only the Rust audio callback"]
fn all_original_effect_cues_reach_the_production_callback_locally() {
use raptor_replica::{assets::Catalog, audio::Mixer};
use sdl2::audio::AudioCallback;
let root = std::env::var_os("RAPTOR_TEST_DATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../data"));
let catalog = Catalog::open(root).unwrap();
let instruments = catalog.named("GENMIDI_OP2").unwrap();
let mut bank = sound::SoundBank::default();
bank.preload_originals(&catalog).unwrap();
assert_eq!(bank.original_count(), 26);
let names = [
"FX_MON1",
"FX_MON2",
"FX_MON3",
"FX_MON4",
"FX_MON5",
"FX_MON6",
"FX_DAVE",
"FX_THEME",
"FX_AIREXPLO",
"FX_AIREXPLO2",
"FX_BONUS",
"FX_CRASH",
"FX_DOOR",
"FX_FLYBY",
"FX_EGRAB",
"FX_GEXPLO",
"FX_GUN",
"FX_JETSND",
"FX_LASER",
"FX_MISSLE",
"FX_SWEP",
"FX_TURRET",
"FX_WARNING",
"FX_BOSS1",
"FX_IJETSND",
"FX_EJETSND",
"FX_INTROHIT",
"FX_INTROGUN",
"FX_ENEMYSHOT",
"FX_ENEMYLASER",
"FX_ENEMYMISSLE",
"FX_ENEMYPLASMA",
"FX_SHIT",
"FX_HIT",
"FX_NOSHOOT",
"FX_PULSE",
];
for name in names {
let mut mixer = Mixer::new(44100, &instruments).unwrap();
mixer.fx_volume = 127;
bank.queue_original(&catalog, &mut mixer, &sound::ui_event(name).unwrap())
.unwrap();
let mut output = vec![0; 44100 * 2];
AudioCallback::callback(&mut mixer, &mut output);
assert!(output.iter().any(|s| *s != 0), "silent original cue {name}");
}
println!(
"36 original cue variants produced PCM through the Rust SDL callback; 26 cached digital assets"
);
}
fn wav(channels: u16, bits: u16, data: &[u8]) -> Vec<u8> {
let align = channels * bits / 8;
let mut bytes = b"RIFF".to_vec();
bytes.extend((36u32 + data.len() as u32 + (data.len() as u32 % 2)).to_le_bytes());
bytes.extend(b"WAVEfmt ");
bytes.extend(16u32.to_le_bytes());
bytes.extend(1u16.to_le_bytes());
bytes.extend(channels.to_le_bytes());
bytes.extend(11025u32.to_le_bytes());
bytes.extend((11025u32 * align as u32).to_le_bytes());
bytes.extend(align.to_le_bytes());
bytes.extend(bits.to_le_bytes());
bytes.extend(b"data");
bytes.extend((data.len() as u32).to_le_bytes());
bytes.extend(data);
if data.len() % 2 == 1 {
bytes.push(0);
}
bytes
}
fn registry() -> Registry {
let mut registry = Registry::default();
let pack: ContentPack=serde_json::from_value(json!({
"items":[{"id":"base:energy","name":"Shield","price":0,"max_count":100},
{"id":"base:forward_guns","name":"Guns","price":0}],
"missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":1,"auto_complete":false,"script":"mission.rhai"}],
"sounds":[{"id":"base:test","name":"Test","file":"test.wav"}]
})).unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
#[test]
fn wav_handles_padding_unsigned_eight_bit_and_stereo_sixteen_bit() {
let decoded = sound::decode(&wav(1, 8, &[0, 128, 255])).unwrap();
assert_eq!(decoded.data, [0, 128, 255]);
assert_eq!(decoded.rate, 11025);
let samples: Vec<_> = [-32768i16, 32767, -32768, -32768, 32767, 32767]
.into_iter()
.flat_map(i16::to_le_bytes)
.collect();
assert_eq!(
sound::decode(&wav(2, 16, &samples)).unwrap().data,
[128, 0, 255]
);
}
#[test]
fn malformed_wave_ranges_formats_and_padding_fail() {
let bytes = wav(1, 8, &[128, 200, 0]);
for end in 0..bytes.len() {
assert!(sound::decode(&bytes[..end]).is_err());
}
assert!(sound::decode(&wav(3, 8, &[0, 0, 0])).is_err());
assert!(sound::decode(&wav(1, 32, &[0, 0, 0, 0])).is_err());
let mut huge = bytes.clone();
huge[40..44].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(sound::decode(&huge).is_err());
}
#[test]
fn scripted_completion_emits_end_hook_once_and_sound_ids_are_checked() {
let registry = registry();
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).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(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Complete);
assert_eq!(world.mod_sounds.len(), 1);
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.mod_sounds.len(), 1);
assert!(
world
.apply_commands(
&[Command::PlaySound {
sound: "base:missing".into()
}],
&registry
)
.is_err()
);
}
#[test]
fn scripted_mod_cue_outputs_pcm_and_cache_is_cleared_when_mods_change() {
let root = tempfile::tempdir().unwrap();
let mut registry = registry();
registry
.asset_roots
.insert("base".into(), root.path().to_path_buf());
let path = root.path().join("test.wav");
std::fs::write(&path, wav(1, 8, &vec![240; 2000])).unwrap();
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).unwrap();
let mut scripts = ScriptRuntime::new();
scripts.add("base", "mission.rhai", r#"
fn on_tick(event,state,random) { #{commands:[#{op:"complete"}],state:state} }
fn on_mission_end(event,state,random) { #{commands:[#{op:"play_sound",sound:"base:test"}],state:state} }
"#).unwrap();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.mod_sounds.len(), 1);
let id = &world.mod_sounds[0];
let mut bank = sound::SoundBank::default();
let mut audible = mixer();
bank.queue_mod(&registry, &mut audible, id).unwrap();
let mut pcm = [0; 1024];
audible.mix(&mut pcm);
assert!(pcm.iter().any(|s| *s != 0));
std::fs::write(&path, wav(1, 8, &vec![128; 2000])).unwrap();
assert!(
bank.mod_sample(&registry, id)
.unwrap()
.data
.iter()
.any(|v| *v != 128)
);
bank.clear_mods();
let mut replaced = mixer();
bank.queue_mod(&registry, &mut replaced, id).unwrap();
replaced.mix(&mut pcm);
assert!(pcm.iter().all(|s| *s == 0));
}
#[test]
fn package_local_sounds_validate_and_missing_files_reject_activation() {
let dir = tempfile::tempdir().unwrap();
let package = dir.path().join("tone");
std::fs::create_dir_all(&package).unwrap();
std::fs::write(
package.join("mod.toml"),
"id='tone'\nname='Tone'\nversion='1.0.0'\napi_version=1\ncontent='content.json'\n",
)
.unwrap();
std::fs::write(
package.join("content.json"),
serde_json::to_vec(&json!({"sounds":[{"id":"tone:shot","name":"Shot","file":"shot.wav"}]}))
.unwrap(),
)
.unwrap();
std::fs::write(package.join("shot.wav"), wav(1, 8, &[128, 200, 50])).unwrap();
let found = modding::discover(dir.path());
assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_ok());
std::fs::remove_file(package.join("shot.wav")).unwrap();
let found = modding::discover(dir.path());
assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_err());
}
+221
View File
@@ -0,0 +1,221 @@
use raptor_replica::{
content::{ContentPack, Registry},
modding::{Command, ScriptRuntime},
persistence::Pilot,
simulation::{EffectKind, Input, LegacyRng, Outcome, Projectile, World},
};
use serde_json::json;
fn fixture(legacy: bool, automatic: bool) -> (Registry, World, ScriptRuntime) {
let pack: ContentPack = serde_json::from_value(json!({
"weapons":[{"id":"base:shot","name":"Shot","damage":8,"speed":0,"cooldown":1,"behavior":"straight","target":"all"}],
"items":[{"id":"base:forward_guns","name":"Guns","price":0,"weapon":"base:shot","legacy":0},{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16}],
"enemies":[{"id":"base:target","name":"Final Target","health":1,"ground":true,"reward":10}],
"missions":[{"id":"base:sector_1_wave_1","name":"Transition Fixture","duration":1,"auto_complete":automatic,"legacy_map":if legacy {Some(123)} else {None}}]
})).unwrap();
let mut registry = Registry::default();
registry.apply(pack, "base").unwrap();
let world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).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(), &registry, &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()
},
&registry,
&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(), &registry, &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(), &registry, &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()
},
&registry,
&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, &registry)
.unwrap();
world
.apply_commands(&[Command::Damage { entity, amount: 1 }], &registry)
.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(), &registry, &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,
}],
&registry,
)
.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(), &registry, &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(), &registry, &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(), &registry, &mut scripts)
.unwrap();
assert!(world.draw_player);
}
let seed = world.rng;
world
.step(Input::default(), &registry, &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(), &registry, &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(), &registry, &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(), &registry, &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(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Failed);
assert_eq!(world.end_timer, None);
}
+852
View File
@@ -0,0 +1,852 @@
//! Indexed visual contracts and independent ScaleNx specification oracle.
//! Original-data contact sheets are opt-in and remain under ignored .reference/.
use raptor_replica::{
assets::{Catalog, Sprite, TRANSPARENT},
content::Registry,
render::{
BitmapFont, Canvas, SpriteCache, legacy_light_table, nearest, prefers_nearest,
shade_air_shadow, shade_ground_shadow, upscale,
},
};
use std::{collections::BTreeSet, fs, path::Path};
// Uses independent per-corner consensus rules rather than the renderer's shared
// optimized guard. Source specification: https://www.scale2x.it/algorithm
fn oracle(source: &Sprite, factor: usize) -> Sprite {
if factor == 1 {
return source.clone();
}
if factor == 4 {
return oracle(&oracle(source, 2), 2);
}
let mut result = Sprite::blank(source.width * factor, source.height * factor);
for y in 0..source.height {
for x in 0..source.width {
let read = |dx: i32, dy: i32| {
let column = (x as i32 + dx).max(0).min(source.width as i32 - 1) as usize;
let row = (y as i32 + dy).max(0).min(source.height as i32 - 1) as usize;
source.pixels[row * source.width + column]
};
let [a, b, c, d, e, f, g, h, i] = [
read(-1, -1),
read(0, -1),
read(1, -1),
read(-1, 0),
read(0, 0),
read(1, 0),
read(-1, 1),
read(0, 1),
read(1, 1),
];
let northwest = d == b && b != f && d != h;
let northeast = b == f && b != d && f != h;
let southwest = d == h && d != b && h != f;
let southeast = h == f && d != h && b != f;
let select = |condition, replacement| if condition { replacement } else { e };
let values = if factor == 2 {
vec![
select(northwest, d),
select(northeast, f),
select(southwest, d),
select(southeast, f),
]
} else {
vec![
select(northwest, d),
select((northwest && e != c) || (northeast && e != a), b),
select(northeast, f),
select((northwest && e != g) || (southwest && e != a), d),
e,
select((northeast && e != i) || (southeast && e != c), f),
select(southwest, d),
select((southwest && e != i) || (southeast && e != g), h),
select(southeast, f),
]
};
for yy in 0..factor {
for xx in 0..factor {
result.pixels[(y * factor + yy) * result.width + x * factor + xx] =
values[yy * factor + xx];
}
}
}
}
result
}
fn crop(source: &Sprite, x: usize, y: usize, width: usize, height: usize) -> Sprite {
let mut out = Sprite::blank(width, height);
for row in 0..height {
out.pixels[row * width..(row + 1) * width].copy_from_slice(
&source.pixels[(y + row) * source.width + x..(y + row) * source.width + x + width],
);
}
out
}
fn pattern(width: usize, height: usize, mut seed: u32) -> Sprite {
let pixels = (0..width * height)
.map(|_| {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
[0, 1, 245, 255, TRANSPARENT][(seed >> 16) as usize % 5]
})
.collect();
Sprite {
width,
height,
pixels,
}
}
#[test]
fn all_three_symbol_neighborhoods_match_unoptimized_published_oracle() {
// 3^9 arrangements cover every local equality class for two colors plus
// transparency; additional multi-color/border patterns are tested below.
for mut code in 0..3_usize.pow(9) {
let pixels = (0..9)
.map(|_| {
let value = [0, 245, TRANSPARENT][code % 3];
code /= 3;
value
})
.collect();
let source = Sprite {
width: 3,
height: 3,
pixels,
};
for factor in [2, 3, 4] {
assert_eq!(upscale(&source, factor).unwrap(), oracle(&source, factor));
}
}
}
#[test]
fn clamped_borders_and_single_pixel_dimensions_match_oracle() {
for (width, height) in [(1, 1), (1, 9), (9, 1), (2, 2), (7, 11)] {
for seed in 0..20 {
let source = pattern(width, height, seed);
for factor in [1, 2, 3, 4] {
assert_eq!(upscale(&source, factor).unwrap(), oracle(&source, factor));
}
}
}
}
#[test]
fn scale_copies_palette_labels_and_keeps_opaque_black_and_white() {
let source = Sprite {
width: 3,
height: 2,
pixels: vec![0, 255, TRANSPARENT, 245, 0, 255],
};
let colors: BTreeSet<_> = source.pixels.iter().copied().collect();
for factor in [1, 2, 3, 4] {
let scaled = upscale(&source, factor).unwrap();
assert!(scaled.pixels.iter().all(|value| colors.contains(value)));
assert!(scaled.pixels.contains(&0));
assert!(scaled.pixels.contains(&255));
assert!(scaled.pixels.contains(&TRANSPARENT));
assert_eq!(source.width, 3);
assert_eq!(source.height, 2);
}
let mut canvas = Canvas::new(1);
canvas.clear(37);
canvas.blit(&source, 4, 5);
let mut first = [[0; 3]; 256];
first[0] = [1, 2, 3];
first[255] = [250, 251, 252];
first[245] = [200, 90, 10];
first[37] = [8, 9, 10];
let pixels_before = canvas.image.pixels.clone();
let rgba_first = canvas.rgba(&first, false);
first[245] = [10, 90, 200];
let rgba_second = canvas.rgba(&first, false);
assert_eq!(canvas.image.pixels, pixels_before);
assert_ne!(rgba_first, rgba_second);
assert_eq!(
&rgba_second[((5 * 320 + 4) * 4)..((5 * 320 + 4) * 4 + 3)],
&[1, 2, 3]
);
assert_eq!(canvas.image.pixels[5 * 320 + 6], 37);
}
#[test]
fn scaled_transparent_sprites_clip_cleanly_at_every_viewport_edge() {
let source = pattern(7, 9, 19);
for factor in [1, 2, 3, 4] {
let sprite = upscale(&source, factor).unwrap();
let width = 320 * factor;
let height = 200 * factor;
for (x, y) in [
(-(sprite.width as i32) + 1, -(sprite.height as i32) + 1),
(-3, 20),
(30, -4),
(width as i32 - 1, height as i32 - 1),
(width as i32 - 4, 30),
(40, height as i32 - 4),
(-100, -100),
] {
let mut canvas = Canvas::new(factor);
canvas.clear(37);
canvas.blit(&sprite, x, y);
for yy in 0..height {
for xx in 0..width {
let sx = xx as i32 - x;
let sy = yy as i32 - y;
let expected = if sx >= 0
&& sy >= 0
&& sx < sprite.width as i32
&& sy < sprite.height as i32
{
let pixel = sprite.pixels[sy as usize * sprite.width + sx as usize];
if pixel == TRANSPARENT { 37 } else { pixel }
} else {
37
};
assert_eq!(canvas.image.pixels[yy * width + xx], expected);
}
}
}
}
}
#[test]
fn two_pixel_terrain_context_matches_full_joined_filter_at_all_scales() {
let source = pattern(18, 18, 28);
let with_gutter = crop(&source, 4, 4, 10, 10);
for factor in [2, 3, 4] {
let full = upscale(&source, factor).unwrap();
let gutter = upscale(&with_gutter, factor).unwrap();
assert_eq!(
crop(&full, 6 * factor, 6 * factor, 6 * factor, 6 * factor),
crop(&gutter, 2 * factor, 2 * factor, 6 * factor, 6 * factor)
);
assert_eq!(gutter, oracle(&with_gutter, factor));
}
}
#[test]
fn independent_tile_filtering_is_detected_by_joined_seam_regression() {
let source = pattern(8, 6, 1);
let left = crop(&source, 0, 0, 4, 6);
let right = crop(&source, 4, 0, 4, 6);
for factor in [2, 3, 4] {
let joined = upscale(&source, factor).unwrap();
let a = upscale(&left, factor).unwrap();
let b = upscale(&right, factor).unwrap();
let mut tiled = Sprite::blank(8 * factor, 6 * factor);
for row in 0..tiled.height {
tiled.pixels[row * tiled.width..row * tiled.width + a.width]
.copy_from_slice(&a.pixels[row * a.width..(row + 1) * a.width]);
tiled.pixels[row * tiled.width + a.width..(row + 1) * tiled.width]
.copy_from_slice(&b.pixels[row * b.width..(row + 1) * b.width]);
}
assert_ne!(joined, tiled, "fixture must expose a seam at scale{factor}");
}
}
#[test]
fn bitmap_text_keeps_exact_square_pixel_blocks_at_every_scale() {
let mut bytes = vec![0; 772];
bytes[..4].copy_from_slice(&2_i32.to_le_bytes());
bytes[516 + b'A' as usize] = 3;
bytes.extend([1, 0, 1, 0, 1, 0]);
let font = BitmapFont::decode(&bytes).unwrap();
assert_eq!(font.height(), 2);
for scale in [1, 2, 3, 4] {
let mut canvas = Canvas::new(scale);
canvas.clear(37);
font.text(&mut canvas, 10, 20, "A", 100);
for y in 0..2 * scale {
for x in 0..3 * scale {
let expected = if [1, 0, 1, 0, 1, 0][y / scale * 3 + x / scale] == 0 {
37
} else {
100
};
assert_eq!(
canvas.image.pixels[(20 * scale + y) * canvas.image.width + 10 * scale + x],
expected
);
}
}
assert_eq!(
canvas
.image
.pixels
.iter()
.filter(|pixel| **pixel == 100)
.count(),
3 * scale * scale
);
}
}
#[test]
fn indexed_shadow_masks_clip_without_shading_transparent_pixels() {
let mask = Sprite {
width: 3,
height: 3,
pixels: vec![
TRANSPARENT,
1,
TRANSPARENT,
1,
1,
1,
TRANSPARENT,
1,
TRANSPARENT,
],
};
let mut canvas = Canvas::new(2);
canvas.clear(10);
let mut lookup = std::array::from_fn(|index| index as u8);
lookup[10] = 11;
let y = canvas.image.height as i32 - 2;
canvas.shade(&mask, -1, y, &lookup);
for yy in 0..canvas.image.height {
for x in 0..canvas.image.width {
let source_x = x + 1;
let source_y = yy as i32 - y;
let affected = source_x < 3
&& (0..3).contains(&source_y)
&& mask.pixels[source_y as usize * 3 + source_x] != TRANSPARENT;
assert_eq!(
canvas.image.pixels[yy * canvas.image.width + x],
if affected { 11 } else { 10 }
);
}
}
}
#[test]
fn vga_light_tables_clamp_six_bit_channels_and_choose_last_equal_match() {
let palette = std::array::from_fn(|index| [(index % 64) as u8; 3]);
let identity = legacy_light_table(&palette, 0);
let dark = legacy_light_table(&palette, -9);
let light = legacy_light_table(&palette, 9);
for index in 0..256 {
assert_eq!(identity[index], 192 + (index % 64) as u8);
assert_eq!(dark[index], 192 + (index % 64).saturating_sub(9) as u8);
assert_eq!(light[index], 192 + ((index % 64) + 9).min(63) as u8);
}
}
#[test]
fn vga_remap_uses_manhattan_rgb_distance_instead_of_squared_distance() {
let mut palette = [[63; 3]; 256];
palette[0] = [20; 3];
palette[1] = [14, 10, 10]; // Manhattan 4, squared distance 16.
palette[2] = [12, 12, 11]; // Manhattan 5, squared distance 9.
assert_eq!(legacy_light_table(&palette, -10)[0], 1);
}
#[test]
fn ground_shadows_keep_native_mask_offsets_and_clip_at_every_scale() {
let mask = pattern(7, 9, 31);
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
for (x, y) in [(3, -4), (-2, 40), (320, 190), (100, 197), (-20, -30)] {
let mut native = Canvas::new(1);
native.clear(10);
// Reference inverse mask coordinates include SHADOW_GAdd's (-3,+4).
for yy in 0..200 {
for xx in 0..320 {
let sx = xx as i32 - x + 3;
let sy = yy as i32 - y - 4;
if (0..mask.width as i32).contains(&sx)
&& (0..mask.height as i32).contains(&sy)
&& mask.pixels[sy as usize * mask.width + sx as usize] != TRANSPARENT
{
native.image.pixels[yy * 320 + xx] = 11;
}
}
}
for scale in [1, 2, 3, 4] {
let mut actual = Canvas::new(scale);
actual.clear(10);
shade_ground_shadow(&mut actual, &mask, x as f32, y as f32, &lookup);
assert_eq!(actual.image, nearest(&native.image, scale));
}
}
}
// Reference SHADOW_Draw at native integer coordinates. Find mask runs from
// occupied coordinates, project their endpoints, then expand the final native
// raster. This keeps the oracle independent of high-resolution span drawing.
fn native_air_shadow(mask: &Sprite, x: i32, y: i32, lookup: &[u8; 256]) -> Sprite {
let project = |value: i32, center: i32| {
let fixed = ((value - center) as i64 * 409_600) / 280;
(fixed >> 11) as i32 + center
};
let mut result = Sprite {
width: 320,
height: 200,
pixels: vec![10; 320 * 200],
};
let mut seen_rows = BTreeSet::new();
for row in 0..mask.height {
let occupied: Vec<_> = (0..mask.width)
.filter(|&column| mask.pixels[row * mask.width + column] != TRANSPARENT)
.collect();
if occupied.is_empty() {
continue;
}
let yy = project(y + 20 + row as i32, 100);
if !seen_rows.insert(yy) || !(0..200).contains(&yy) {
continue;
}
let mut starts = vec![0];
for index in 1..occupied.len() {
if occupied[index] != occupied[index - 1] + 1 {
starts.push(index);
}
}
starts.push(occupied.len());
for endpoints in starts.windows(2) {
let start = project(x - 10 + occupied[endpoints[0]] as i32, 160);
let end = project(x - 10 + occupied[endpoints[1] - 1] as i32, 160);
for xx in start.max(0)..=end.min(319) {
let pixel = &mut result.pixels[yy as usize * 320 + xx as usize];
*pixel = lookup[*pixel as usize] as u16;
}
}
}
result
}
#[test]
fn air_shadows_project_signed_spans_and_suppress_collapsed_rows() {
let mask = Sprite {
width: 7,
height: 7,
pixels: vec![1; 49],
};
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
for scale in [1, 2, 3, 4] {
let mut actual = Canvas::new(scale);
actual.clear(10);
shade_air_shadow(&mut actual, &mask, 170.0, 80.0, &lookup);
assert_eq!(
actual.image,
nearest(&native_air_shadow(&mask, 170, 80, &lookup), scale)
);
assert_eq!(
actual.image.pixels.iter().filter(|&&p| p == 11).count(),
25 * scale * scale
);
assert!(
!actual.image.pixels.contains(&12),
"collapsed rows must not shade twice"
);
assert_eq!(
actual.image.pixels[100 * scale * actual.image.width + 160 * scale],
11
);
assert_eq!(
actual.image.pixels[105 * scale * actual.image.width + 160 * scale],
10
);
}
}
#[test]
fn air_shadows_preserve_sparse_mask_spans_and_clip_projected_edges() {
let mask = pattern(21, 17, 76);
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
for (x, y) in [
(12, -48),
(-55, 30),
(389, 198),
(165, 64),
(303, 223),
(-80, -70),
] {
let expected = native_air_shadow(&mask, x, y, &lookup);
for scale in [1, 2, 3, 4] {
let mut actual = Canvas::new(scale);
actual.clear(10);
shade_air_shadow(&mut actual, &mask, x as f32, y as f32, &lookup);
assert_eq!(
actual.image,
nearest(&expected, scale),
"air position ({x},{y}) at{scale}x"
);
}
}
}
#[test]
fn interpolated_shadows_translate_masks_without_changing_coverage_or_transparency() {
let mask = Sprite {
width: 7,
height: 7,
pixels: vec![1; 49],
};
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
let mut integer = Canvas::new(4);
integer.clear(10);
shade_air_shadow(&mut integer, &mask, 170.0, 80.0, &lookup);
let mut half = Canvas::new(4);
half.clear(10);
shade_air_shadow(&mut half, &mask, 170.5, 80.5, &lookup);
assert_eq!(half.image.pixels.iter().filter(|&&p| p == 11).count(), 400);
for y in 100 * 4..105 * 4 {
for x in 160 * 4..165 * 4 {
assert_eq!(
integer.image.pixels[y * integer.image.width + x],
half.image.pixels[(y + 1) * half.image.width + x + 1]
);
}
}
let mut empty = Canvas::new(4);
empty.clear(TRANSPARENT);
shade_air_shadow(&mut empty, &mask, 170.0, 80.0, &lookup);
assert!(empty.image.pixels.iter().all(|&p| p == TRANSPARENT));
shade_ground_shadow(&mut empty, &mask, 10.0, 10.0, &lookup);
assert!(empty.image.pixels.iter().all(|&p| p == TRANSPARENT));
}
fn png(sprite: &Sprite, palette: &[[u8; 3]; 256], path: &Path) {
let mut image = image::RgbaImage::new(sprite.width as u32, sprite.height as u32);
for (index, &value) in sprite.pixels.iter().enumerate() {
let color = if value == TRANSPARENT {
[0, 0, 0, 0]
} else {
let [r, g, b] = palette[value as usize];
[r, g, b, 255]
};
image.put_pixel(
(index % sprite.width) as u32,
(index / sprite.width) as u32,
image::Rgba(color),
);
}
image.save(path).unwrap();
}
fn sheet(sprites: &[Sprite], columns: usize, palette: &[[u8; 3]; 256], path: &Path) {
let width = sprites.iter().map(|s| s.width).max().unwrap() + 12;
let height = sprites.iter().map(|s| s.height).max().unwrap() + 12;
let rows = sprites.len().div_ceil(columns);
let mut canvas = Canvas::new(1);
canvas.image = Sprite {
width: columns * width,
height: rows * height,
pixels: vec![0; columns * width * rows * height],
};
for (index, sprite) in sprites.iter().enumerate() {
canvas.blit(
sprite,
((index % columns) * width + 6) as i32,
((index / columns) * height + 6) as i32,
);
}
png(&canvas.image, palette, path);
}
#[test]
#[ignore = "requires local original palette and sprites; emits private shadow contacts"]
fn original_shadow_tables_and_banking_masks_match_indexed_projection() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let catalog = Catalog::open(root.join("../data")).unwrap();
let palette = catalog.palette().unwrap();
let bytes = catalog.named("PALETTE_DAT").unwrap();
let vga: [[u8; 3]; 256] = std::array::from_fn(|i| bytes[i * 3..i * 3 + 3].try_into().unwrap());
assert_eq!(palette.map(|rgb| rgb.map(|component| component >> 2)), vga);
let output = root.join("../.reference/visuals");
fs::create_dir_all(&output).unwrap();
// Independent argmin over (distance, reverse index) preserves GFX_Remap's
// last-equal tie rule without the renderer's imperative running minimum.
for level in [-9, -6, 9] {
let expected: [u8; 256] = std::array::from_fn(|index| {
let target = vga[index].map(|component| (component as i32 + level).clamp(0, 63));
(0..256)
.min_by_key(|&candidate| {
let distance: i32 = vga[candidate]
.iter()
.zip(target)
.map(|(&a, b)| (a as i32 - b).abs())
.sum();
(distance, std::cmp::Reverse(candidate))
})
.unwrap() as u8
});
assert_eq!(legacy_light_table(&vga, level), expected);
}
let air = legacy_light_table(&vga, -6);
let ground = legacy_light_table(&vga, -9);
let mut banking = vec![];
let base = catalog.id("LPLAYER_PIC").unwrap();
let terrain_id = Registry::base(&catalog).unwrap().missions[&"base:sector_1_wave_1".into()]
.tiles[0]
.art
.asset
.unwrap();
let tile = catalog.sprite(terrain_id).unwrap();
for frame in 0..7 {
let mask = catalog.sprite(base + frame).unwrap();
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
for scale in [1, 2, 3, 4] {
let mut actual = Canvas::new(scale);
actual.clear(10);
shade_air_shadow(&mut actual, &mask, 144.0, 160.0, &lookup);
assert_eq!(
actual.image,
nearest(&native_air_shadow(&mask, 144, 160, &lookup), scale)
);
}
let mut preview = Canvas::new(4);
for y in (0..200).step_by(tile.height) {
for x in (0..320).step_by(tile.width) {
preview.blit(&upscale(&tile, 4).unwrap(), (x * 4) as i32, (y * 4) as i32);
}
}
shade_air_shadow(&mut preview, &mask, 144.0, 160.0, &air);
preview.blit(&upscale(&mask, 4).unwrap(), 144 * 4, 160 * 4);
banking.push(crop(&preview.image, 112 * 4, 132 * 4, 80 * 4, 68 * 4));
}
sheet(
&banking,
7,
&palette,
&output.join("player-shadow-banking-4x.png"),
);
let mask = catalog.sprite(catalog.id("SHIP37G1_PIC").unwrap()).unwrap();
let mut preview = Canvas::new(4);
for y in (0..200).step_by(tile.height) {
for x in (0..320).step_by(tile.width) {
preview.blit(&upscale(&tile, 4).unwrap(), (x * 4) as i32, (y * 4) as i32);
}
}
shade_ground_shadow(&mut preview, &mask, 144.0, 80.0, &ground);
preview.blit(&upscale(&mask, 4).unwrap(), 144 * 4, 80 * 4);
png(
&crop(&preview.image, 128 * 4, 64 * 4, 64 * 4, 64 * 4),
&palette,
&output.join("ground-shadow-4x.png"),
);
fs::write(
output.join("shadow-tables.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"ground_offset":[-3,4],"ground_level":-9,"air_offset":[-10,20],"air_level":-6,
"air_view":[160,100,1000],"air_depth":1280,"distance":200,
"ground_table":ground.to_vec(),"air_table":air.to_vec()
}))
.unwrap(),
)
.unwrap();
}
#[test]
#[ignore = "requires local original catalog; emits private .reference/visuals contact sheets"]
fn original_sequences_preserve_canvases_palette_origins_and_terrain_context() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let catalog = Catalog::open(root.join("../data")).unwrap();
let palette = catalog.palette().unwrap();
let output = root.join("../.reference/visuals");
fs::create_dir_all(&output).unwrap();
let sequences = [
("LPLAYER_PIC", 7),
("DPLAYER_PIC", 7),
("FPLAYER_PIC", 7),
("GEXPLO_BLK", 42),
("AIRBOOM_PIC", 16),
("EXPLO2_BLK", 13),
("NMSHOT_BLK", 4),
("GUNSTR_BLK", 4),
("SHIP22G1_PIC", 6),
("SHIP08G1_PIC", 4),
];
let mut report = vec![];
let mut runtime_cache = SpriteCache::new(None);
for (name, count) in sequences {
let base = catalog.id(name).unwrap();
let first = catalog.sprite(base).unwrap();
let mut original = vec![];
let mut filtered = vec![];
let mut runtime = vec![];
let mut metadata = vec![];
for frame in 0..count {
let sprite = catalog.sprite(base + frame).unwrap();
assert_eq!(
(sprite.width, sprite.height),
(first.width, first.height),
"{name} frame{frame} shifts animation canvas"
);
let labels: BTreeSet<_> = sprite.pixels.iter().copied().collect();
let mut bounds = vec![];
for factor in [1, 2, 3, 4] {
let scaled = upscale(&sprite, factor).unwrap();
assert_eq!(
scaled,
oracle(&sprite, factor),
"{name} frame{frame} scale{factor}"
);
assert!(scaled.pixels.iter().all(|value| labels.contains(value)));
assert_eq!(
(scaled.width, scaled.height),
(sprite.width * factor, sprite.height * factor)
);
let use_nearest = prefers_nearest(base + frame);
let presented = runtime_cache.get(&sprite, factor, use_nearest).unwrap();
if use_nearest {
assert_eq!(*presented, nearest(&sprite, factor));
assert_eq!(
presented
.pixels
.iter()
.filter(|pixel| **pixel != TRANSPARENT)
.count(),
sprite
.pixels
.iter()
.filter(|pixel| **pixel != TRANSPARENT)
.count()
* factor
* factor,
"{name} frame{frame} loses opacity under nearest fallback"
);
} else {
assert_eq!(*presented, scaled);
}
if factor == 4 {
runtime.push(presented.clone());
}
// C++ centers registered animations using the full frame canvas,
// never its changing opaque bounds. Keep that anchor at every scale.
let anchor = (sprite.width as i32 / 2, sprite.height as i32 / 2);
let mut canvas = Canvas::new(factor);
canvas.clear(37);
canvas.blit(
&scaled,
(160 - anchor.0) * factor as i32,
(100 - anchor.1) * factor as i32,
);
assert_eq!(
(160 - anchor.0) * factor as i32 + anchor.0 * factor as i32,
160 * factor as i32
);
let mut minx = scaled.width;
let mut miny = scaled.height;
let mut maxx = 0;
let mut maxy = 0;
for (index, &pixel) in scaled.pixels.iter().enumerate() {
if pixel != TRANSPARENT {
minx = minx.min(index % scaled.width);
miny = miny.min(index / scaled.width);
maxx = maxx.max(index % scaled.width + 1);
maxy = maxy.max(index / scaled.width + 1);
}
}
bounds.push(serde_json::json!({"factor":factor,"bounds":[minx,miny,maxx,maxy],
"opaque_pixels":scaled.pixels.iter().filter(|pixel|**pixel!=TRANSPARENT).count()}));
if factor == 4 {
filtered.push(scaled);
}
}
original.push(nearest(&sprite, 4));
metadata.push(serde_json::json!({"frame":frame,"asset":format!("{:05x}",base+frame),"logical_canvas":[sprite.width,sprite.height],"center_anchor":[sprite.width/2,sprite.height/2],"nearest_fallback":prefers_nearest(base+frame),"bounds":bounds}));
}
sheet(
&original,
7.min(count as usize),
&palette,
&output.join(format!("{name}-nearest4.png")),
);
sheet(
&filtered,
7.min(count as usize),
&palette,
&output.join(format!("{name}-scale4.png")),
);
sheet(
&runtime,
7.min(count as usize),
&palette,
&output.join(format!("{name}-runtime4.png")),
);
report.push(serde_json::json!({"name":name,"frames":metadata}));
}
let registry = Registry::base(&catalog).unwrap();
let mission = &registry.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();
}
+225
View File
@@ -0,0 +1,225 @@
//! Local balance observations against original weapons in the real registry.
//! Targets are fixtures; no original artwork or data is required by CI.
use raptor_replica::{
assets::Catalog,
content::{ContentId, ContentPack, Registry},
modding::{self, ActiveMods},
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use serde_json::{Value, json};
use std::{fs, path::Path};
const COMBAT_MODS: [&str; 6] = [
"pulse_cannon",
"seeker_swarm",
"ion_lance",
"iron_coast_scout",
"iron_coast_turret",
"iron_coast_boss",
];
const TICKS: usize = 700;
const HEALTH: i32 = 1_000_000;
const WEAPONS: [&str; 15] = [
"base:forward_guns",
"base:plasma_guns",
"base:micro_missile",
"base:air_missile",
"base:grd_missile",
"base:dumb_missile",
"base:mini_gun",
"base:missile_pods",
"base:turret",
"base:pulse_cannon",
"base:death_ray",
"base:forward_laser",
"pulse_cannon:equipment",
"seeker_swarm:equipment",
"ion_lance:equipment",
];
fn fixture(active: &mut ActiveMods) {
let pack: ContentPack = serde_json::from_value(json!({
"missions":[{"id":"balance:range","name":"Weapon range","duration":10000,"auto_complete":false}],
"enemies":[
{"id":"balance:air","name":"Air target","health":HEALTH,"speed":0},
{"id":"balance:ground","name":"Ground target","health":HEALTH,"speed":0,"ground":true,"script":"ground.rhai"}
]
})).unwrap();
active.registry.apply(pack, "balance").unwrap();
active
.scripts
.add(
"balance",
"ground.rhai",
r#"
// Ground actors normally scroll one logical pixel each tick. Reset
// this range target after collision processing; weapons are unmodified.
fn on_tick(event,state,random) {
#{commands:[#{op:"move",entity:event.entity,x:event.x,y:event.y-1}],state:state}
}
"#,
)
.unwrap();
}
fn observe(
active: &mut ActiveMods,
item: &str,
scene: &str,
targets: &[(&str, i32, i32)],
with_forward_guns: bool,
) -> Value {
active
.scripts
.restore((Default::default(), Default::default()));
let id = ContentId::from(item);
let mut inventory = [(id.clone(), 1), ("base:energy".into(), 100)]
.into_iter()
.collect::<std::collections::BTreeMap<_, _>>();
if with_forward_guns {
inventory.insert("base:forward_guns".into(), 1);
}
let pilot = Pilot {
mission: "balance:range".into(),
difficulty: 2,
inventory,
selected: active.registry.items[&id].special.then_some(id.clone()),
..Pilot::default()
};
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
let entities: Vec<_> = targets
.iter()
.map(|(kind, x, y)| {
world
.spawn(
&format!("balance:{kind}").as_str().into(),
*x,
*y,
&active.registry,
)
.unwrap()
})
.collect();
for _ in 0..TICKS {
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
}
assert_eq!(world.outcome, Outcome::Playing);
assert_eq!(
world.player.shield, 100,
"range targets should not harm the pilot"
);
let damage: Vec<_> = entities
.iter()
.map(|entity| {
let target = world
.enemies
.iter()
.find(|e| e.entity == *entity)
.expect("range target retired");
assert!(target.health > 0);
assert_eq!(
target.y,
targets[entities.iter().position(|e| e == entity).unwrap()].2
);
HEALTH - target.health
})
.collect();
json!({"item":item,"scene":scene,"price":active.registry.items[&id].price,
"ticks":TICKS,"seconds":TICKS as f64 * 3.0/70.0,"with_forward_guns":with_forward_guns,
"damage":damage,"damage_per_second":damage.iter().map(|d| *d as f64/(TICKS as f64*3.0/70.0)).collect::<Vec<_>>()})
}
#[test]
#[ignore = "requires original untracked catalog; reports controlled stationary-target balance, not campaign difficulty"]
fn original_and_mod_weapons_report_controlled_damage_locally() {
let package = Path::new(env!("CARGO_MANIFEST_DIR"));
let root = std::env::var_os("RAPTOR_TEST_DATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| package.join("../data"));
let catalog = Catalog::open(root).unwrap();
let base = Registry::base(&catalog).unwrap();
let discovery = modding::discover(&package.join("mods"));
let enabled = COMBAT_MODS.map(String::from);
let mut active = ActiveMods::build(&base, &discovery, &enabled, Some(&catalog)).unwrap();
fixture(&mut active);
let mut rows = Vec::new();
for item in WEAPONS {
for (scene, targets) in [
("aligned_air", vec![("air", 144, 80)]),
("aligned_ground", vec![("ground", 144, 80)]),
("off_axis_air", vec![("air", 220, 80)]),
("off_axis_ground", vec![("ground", 220, 80)]),
("column", vec![("air", 144, 96), ("ground", 144, 40)]),
(
"cluster",
vec![("air", 144, 80), ("ground", 144, 80), ("air", 154, 80)],
),
] {
let result = observe(&mut active, item, scene, &targets, false);
println!("{result}");
rows.push(result);
}
}
for item in [
"base:plasma_guns",
"base:micro_missile",
"pulse_cannon:equipment",
] {
rows.push(observe(
&mut active,
item,
"aligned_air",
&[("air", 144, 80)],
true,
));
rows.push(observe(
&mut active,
item,
"aligned_ground",
&[("ground", 144, 80)],
true,
));
}
let report = json!({"difficulty":2,"rate":"70/3 Hz","target_size":[32,32],
"target_health":HEALTH,"fire_steps":TICKS,"rows":rows});
if let Some(path) = std::env::var_os("RAPTOR_BALANCE_REPORT") {
fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap();
}
let row = |item: &str, scene: &str| {
report["rows"]
.as_array()
.unwrap()
.iter()
.find(|r| r["item"] == item && r["scene"] == scene && r["with_forward_guns"] == false)
.unwrap()
};
assert!(
row("seeker_swarm:equipment", "off_axis_air")["damage"][0]
.as_i64()
.unwrap()
> 0
);
assert!(
row("seeker_swarm:equipment", "off_axis_ground")["damage"][0]
.as_i64()
.unwrap()
> 0
);
let column = row("ion_lance:equipment", "column")["damage"]
.as_array()
.unwrap();
assert!(column.iter().all(|d| d.as_i64().unwrap() > 0));
assert_eq!(
column[0], column[1],
"ion beam should damage both column targets equally"
);
assert_eq!(row("ion_lance:equipment", "off_axis_air")["damage"][0], 0);
}
+408
View File
@@ -0,0 +1,408 @@
//! Validate the shipped weapon definitions/scripts using synthetic PNG and WAV inputs.
//! No original or locally generated game artwork is needed in CI.
use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{self, ActiveMods},
persistence::Pilot,
simulation::{Input, World},
};
use serde_json::{Value, json};
use std::{fs, path::Path};
const PACKAGES: [&str; 3] = ["pulse_cannon", "seeker_swarm", "ion_lance"];
fn content(value: Value) -> ContentPack {
serde_json::from_value(value).unwrap()
}
fn base() -> Registry {
let mut registry = Registry::default();
registry.apply(content(json!({
"items": [{"id":"base:energy","name":"Shield","price":100,"max_count":100}],
"missions": [{"id":"base:practice","name":"Practice","duration":2000}],
"enemies": [
{"id":"base:target","name":"Stationary target","health":100,"speed":0},
{"id":"base:ground_target","name":"Ground target","health":100,"speed":0,"ground":true}
]
})), "base").unwrap();
registry
}
fn wav() -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend(164u32.to_le_bytes());
bytes.extend(b"WAVEfmt ");
bytes.extend(16u32.to_le_bytes());
bytes.extend(1u16.to_le_bytes());
bytes.extend(1u16.to_le_bytes());
bytes.extend(11025u32.to_le_bytes());
bytes.extend(22050u32.to_le_bytes());
bytes.extend(2u16.to_le_bytes());
bytes.extend(16u16.to_le_bytes());
bytes.extend(b"data");
bytes.extend(128u32.to_le_bytes());
bytes.extend([0; 128]);
bytes
}
fn install_synthetic(root: &Path, id: &str) {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods").join(id);
let target = root.join(id);
fs::create_dir_all(target.join("scripts")).unwrap();
fs::create_dir_all(target.join("art")).unwrap();
fs::create_dir_all(target.join("audio")).unwrap();
for file in [
"mod.toml",
"content.json",
"scripts/weapon.rhai",
"scripts/equipment.rhai",
] {
fs::copy(source.join(file), target.join(file)).unwrap();
}
let definitions: Value =
serde_json::from_slice(&fs::read(target.join("content.json")).unwrap()).unwrap();
for entry in definitions["weapons"]
.as_array()
.unwrap()
.iter()
.chain(definitions["items"].as_array().unwrap())
{
let art = &entry["art"];
for (property, scale) in [("png", 1), ("hd_png", 4)] {
let image = image::RgbaImage::from_pixel(
art["logical_width"].as_u64().unwrap() as u32 * scale,
art["logical_height"].as_u64().unwrap() as u32 * scale,
image::Rgba([60, 90, 120, 255]),
);
image
.save(target.join(art[property].as_str().unwrap()))
.unwrap();
}
}
for sound in definitions["sounds"].as_array().unwrap() {
fs::write(target.join(sound["file"].as_str().unwrap()), wav()).unwrap();
}
}
fn activate(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
let root = tempfile::tempdir().unwrap();
for id in ids {
install_synthetic(root.path(), id);
}
let discovery = modding::discover(root.path());
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
let active = ActiveMods::build(
&base(),
&discovery,
&ids.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
None,
)
.unwrap();
(root, active)
}
fn pilot(id: &str, registry: &Registry) -> Pilot {
let equipment = ContentId::new(format!("{id}:equipment")).unwrap();
Pilot {
inventory: [(equipment.clone(), 1), ("base:energy".into(), 100)]
.into_iter()
.collect(),
selected: registry.items[&equipment].special.then_some(equipment),
mission: "base:practice".into(),
..Pilot::default()
}
}
fn world(id: &str, registry: &Registry) -> World {
World::new(&"base:practice".into(), &pilot(id, registry), registry).unwrap()
}
fn state<'a>(active: &'a ActiveMods, id: &str) -> &'a Value {
&active.scripts.states[&format!("{id}/scripts/weapon.rhai")]
}
#[test]
fn each_weapon_package_is_independent_and_the_three_can_coexist() {
for id in PACKAGES {
let (_root, active) = activate(&[id]);
assert_eq!(active.pins.len(), 1);
assert_eq!(active.pins[0].version, "1.0.0");
assert_eq!(active.registry.weapons.len(), 1);
assert_eq!(active.registry.sounds.len(), 2);
assert!(
active
.registry
.items
.contains_key(&format!("{id}:equipment").as_str().into())
);
assert!(
active
.registry
.weapons
.contains_key(&format!("{id}:projectile").as_str().into())
);
}
let (_root, all) = activate(&PACKAGES);
assert_eq!(all.pins.len(), 3);
assert_eq!(all.registry.weapons.len(), 3);
assert_eq!(all.registry.sounds.len(), 6);
assert!(all.registry.overrides.is_empty());
}
#[test]
fn shop_charges_each_price_and_rejects_duplicate_equipment() {
let (_root, active) = activate(&PACKAGES);
for id in PACKAGES {
let item: ContentId = format!("{id}:equipment").as_str().into();
let price = active.registry.items[&item].price;
let mut pilot = Pilot {
credits: price,
inventory: Default::default(),
..Pilot::default()
};
pilot.buy(&item, &active.registry).unwrap();
assert_eq!(pilot.credits, 0);
assert_eq!(pilot.inventory[&item], 1);
assert!(pilot.buy(&item, &active.registry).is_err());
pilot.sell(&item, &active.registry).unwrap();
assert!(pilot.credits > 0);
assert_eq!(pilot.inventory.get(&item).copied().unwrap_or(0), 0);
}
}
#[test]
fn pulse_cannon_emits_paired_rounds_and_every_third_burst_is_scripted() {
let (_root, mut active) = activate(&["pulse_cannon"]);
let mut world = world("pulse_cannon", &active.registry);
for _ in 0..19 {
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
}
assert_eq!(state(&active, "pulse_cannon")["bursts"], 3);
assert_eq!(world.projectiles.len(), 8);
assert_eq!(world.projectiles.iter().filter(|p| p.vx != 0).count(), 2);
assert_eq!(world.mod_sounds, vec![ContentId::from("pulse_cannon:fire")]);
assert!(
world.effects.is_empty(),
"firing into empty space must not create muzzle explosions"
);
}
#[test]
fn continuous_firing_has_audio_and_projectiles_without_explosions_at_the_muzzle() {
for id in PACKAGES {
let (_root, mut active) = activate(&[id]);
let mut world = world(id, &active.registry);
let mut heard_fire = false;
let mut saw_projectile = false;
for _ in 0..80 {
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
heard_fire |= world
.mod_sounds
.contains(&format!("{id}:fire").as_str().into());
saw_projectile |= !world.projectiles.is_empty();
assert!(
world.effects.is_empty(),
"{id} created an explosion while firing into empty space"
);
}
assert!(
heard_fire && saw_projectile,
"{id} lost its firing sound or projectiles"
);
}
}
#[test]
fn pulse_hits_deal_damage_and_run_effect_audio_and_hit_state_callbacks() {
let (_root, mut active) = activate(&["pulse_cannon"]);
let mut world = world("pulse_cannon", &active.registry);
world
.spawn(&"base:target".into(), 140, 100, &active.registry)
.unwrap();
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
let mut hit_sound = false;
for _ in 0..8 {
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
hit_sound |= world.mod_sounds.contains(&"pulse_cannon:impact".into());
}
assert_eq!(world.enemies[0].health, 92);
assert_eq!(state(&active, "pulse_cannon")["hits"], 2);
assert!(hit_sound);
assert!(!world.effects.is_empty());
}
#[test]
fn seeker_swarm_homes_and_fragmentation_damages_nearby_enemies() {
let (_root, mut active) = activate(&["seeker_swarm"]);
let mut world = world("seeker_swarm", &active.registry);
let primary = world
.spawn(&"base:target".into(), 180, 110, &active.registry)
.unwrap();
let neighbor = world
.spawn(&"base:ground_target".into(), 180, 110, &active.registry)
.unwrap();
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
assert_eq!(world.projectiles.len(), 3);
assert!(world.projectiles.iter().all(|p| p.vx > 0));
for _ in 0..12 {
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
}
let target = world.enemies.iter().find(|e| e.entity == primary).unwrap();
let nearby = world.enemies.iter().find(|e| e.entity == neighbor).unwrap();
assert_eq!(target.health, 82);
assert_eq!(nearby.health, 94);
assert_eq!(state(&active, "seeker_swarm")["hits"], 3);
}
#[test]
fn ion_lance_pierces_air_and_ground_targets_in_its_column_only() {
let (_root, mut active) = activate(&["ion_lance"]);
let mut world = world("ion_lance", &active.registry);
world
.spawn(&"base:target".into(), 144, 100, &active.registry)
.unwrap();
world
.spawn(&"base:ground_target".into(), 144, 60, &active.registry)
.unwrap();
world
.spawn(&"base:target".into(), 220, 60, &active.registry)
.unwrap();
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
[84, 84, 100]
);
assert_eq!(state(&active, "ion_lance")["hits"], 4);
assert!(world.mod_sounds.contains(&"ion_lance:impact".into()));
assert!(!world.effects.is_empty());
}
#[test]
fn equipped_specials_and_primary_upgrade_fire_without_consuming_equipment() {
let (_root, mut active) = activate(&PACKAGES);
let mut pilot = pilot("pulse_cannon", &active.registry);
pilot.inventory.insert("seeker_swarm:equipment".into(), 1);
pilot.inventory.insert("ion_lance:equipment".into(), 1);
pilot.selected = Some("ion_lance:equipment".into());
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
assert_eq!(state(&active, "pulse_cannon")["bursts"], 1);
assert_eq!(state(&active, "ion_lance")["bursts"], 1);
assert!(
!active
.scripts
.states
.contains_key("seeker_swarm/scripts/weapon.rhai")
);
for id in PACKAGES {
assert_eq!(
world.player.inventory[&format!("{id}:equipment").as_str().into()],
1
);
}
world.player.selected = Some("seeker_swarm:equipment".into());
world
.step(
Input {
special: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
assert_eq!(state(&active, "seeker_swarm")["bursts"], 1);
}
#[test]
fn scripted_bursts_and_state_resume_deterministically() {
let (_root, mut active) = activate(&["pulse_cannon"]);
let mut world = world("pulse_cannon", &active.registry);
for _ in 0..10 {
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
}
let mut resumed = world.clone();
let (_other_root, mut other) = activate(&["pulse_cannon"]);
other.scripts.states = active.scripts.states.clone();
other.scripts.rng = active.scripts.rng.clone();
for _ in 0..20 {
let input = Input {
fire: true,
..Input::default()
};
world
.step(input, &active.registry, &mut active.scripts)
.unwrap();
resumed
.step(input, &other.registry, &mut other.scripts)
.unwrap();
}
assert_eq!(world.digest(), resumed.digest());
assert_eq!(active.scripts.states, other.scripts.states);
assert_eq!(
serde_json::to_value(&active.scripts.rng).unwrap(),
serde_json::to_value(&other.scripts.rng).unwrap()
);
}