1301 lines
44 KiB
Rust
1301 lines
44 KiB
Rust
use raptor_replica::{
|
|
content::{ContentId, ContentPack, Registry},
|
|
modding::{self, ActiveMods, Command, Manifest, ScriptRuntime},
|
|
persistence::Pilot,
|
|
simulation::{Input, World},
|
|
};
|
|
use serde_json::{Value, json};
|
|
use std::{collections::BTreeMap, fs, path::Path};
|
|
|
|
fn pack(value: Value) -> ContentPack {
|
|
serde_json::from_value(value).unwrap()
|
|
}
|
|
fn item(id: &str, price: u32) -> Value {
|
|
json!({"id": id, "name": id, "price": price, "max_count": 100})
|
|
}
|
|
fn base() -> Registry {
|
|
let mut reg = Registry::default();
|
|
reg.apply(pack(json!({
|
|
"weapons": [{"id":"base:projectile_forward_guns", "name":"Forward Guns", "damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}],
|
|
"items": [
|
|
{"id":"base:forward_guns","name":"Forward Guns","price":0,"weapon":"base:projectile_forward_guns"},
|
|
{"id":"base:energy","name":"Shield","price":0,"max_count":100}
|
|
],
|
|
"missions":[{"id":"base:sector_1_wave_1","name":"Test Mission","duration":1000}]
|
|
})), "base").unwrap();
|
|
reg.validate().unwrap();
|
|
reg
|
|
}
|
|
fn install(root: &Path, folder: &str, id: &str, content: Value) -> Manifest {
|
|
let dir = root.join(folder);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
let manifest = Manifest {
|
|
id: id.into(),
|
|
name: id.into(),
|
|
version: "1.0.0".into(),
|
|
api_version: 1,
|
|
description: String::new(),
|
|
dependencies: BTreeMap::new(),
|
|
conflicts: vec![],
|
|
content: "content.json".into(),
|
|
scripts: vec![],
|
|
};
|
|
write_manifest(&dir, &manifest);
|
|
fs::write(
|
|
dir.join("content.json"),
|
|
serde_json::to_vec_pretty(&content).unwrap(),
|
|
)
|
|
.unwrap();
|
|
manifest
|
|
}
|
|
fn write_manifest(dir: &Path, manifest: &Manifest) {
|
|
fs::write(dir.join("mod.toml"), toml::to_string(manifest).unwrap()).unwrap();
|
|
}
|
|
fn ids(values: &[&str]) -> Vec<String> {
|
|
values.iter().map(|v| (*v).into()).collect()
|
|
}
|
|
fn error<T>(result: anyhow::Result<T>) -> String {
|
|
match result {
|
|
Ok(_) => panic!("expected failure"),
|
|
Err(e) => format!("{e:#}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dependencies_load_before_dependents_and_keep_independent_priority() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(temp.path(), "alpha", "alpha", json!({}));
|
|
let mut beta = install(temp.path(), "beta", "beta", json!({}));
|
|
beta.dependencies.insert("alpha".into(), "^1.0".into());
|
|
write_manifest(&temp.path().join("beta"), &beta);
|
|
install(temp.path(), "gamma", "gamma", json!({}));
|
|
let d = modding::discover(temp.path());
|
|
assert!(d.errors.is_empty());
|
|
assert_eq!(
|
|
modding::order(&d, &ids(&["gamma", "beta", "alpha"])).unwrap(),
|
|
ids(&["gamma", "alpha", "beta"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_disabled_wrong_version_and_cyclic_dependencies_are_actionable() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mut alpha = install(temp.path(), "alpha", "alpha", json!({}));
|
|
let mut beta = install(temp.path(), "beta", "beta", json!({}));
|
|
beta.dependencies.insert("alpha".into(), "^2".into());
|
|
write_manifest(&temp.path().join("beta"), &beta);
|
|
let d = modding::discover(temp.path());
|
|
assert!(error(modding::order(&d, &ids(&["beta"]))).contains("requires enabled mod alpha"));
|
|
assert!(error(modding::order(&d, &ids(&["alpha", "beta"]))).contains("installed 1.0.0"));
|
|
beta.dependencies.insert("alpha".into(), "^1".into());
|
|
alpha.dependencies.insert("beta".into(), "^1".into());
|
|
write_manifest(&temp.path().join("beta"), &beta);
|
|
write_manifest(&temp.path().join("alpha"), &alpha);
|
|
assert!(
|
|
error(modding::order(
|
|
&modding::discover(temp.path()),
|
|
&ids(&["alpha", "beta"])
|
|
))
|
|
.contains("cyclic")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_conflicts_and_duplicate_selected_ids_reject_activation() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mut alpha = install(temp.path(), "alpha", "alpha", json!({}));
|
|
install(temp.path(), "beta", "beta", json!({}));
|
|
alpha.api_version = 99;
|
|
write_manifest(&temp.path().join("alpha"), &alpha);
|
|
assert!(
|
|
error(modding::order(
|
|
&modding::discover(temp.path()),
|
|
&ids(&["alpha"])
|
|
))
|
|
.contains("requires API 99")
|
|
);
|
|
alpha.api_version = 1;
|
|
alpha.conflicts.push("beta".into());
|
|
write_manifest(&temp.path().join("alpha"), &alpha);
|
|
let d = modding::discover(temp.path());
|
|
assert!(error(modding::order(&d, &ids(&["alpha", "beta"]))).contains("conflicts with beta"));
|
|
assert!(error(modding::order(&d, &ids(&["beta", "beta"]))).contains("duplicate mod"));
|
|
}
|
|
|
|
#[test]
|
|
fn every_duplicate_installed_namespace_is_unusable() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
for folder in ["first", "second", "third"] {
|
|
install(temp.path(), folder, "collision", json!({}));
|
|
}
|
|
let d = modding::discover(temp.path());
|
|
assert!(!d.mods.contains_key("collision"));
|
|
assert_eq!(d.errors.len(), 2);
|
|
assert!(
|
|
error(ActiveMods::build(&base(), &d, &ids(&["collision"]), None)).contains("not installed")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn adding_a_duplicate_after_discovery_requires_rescanning() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(temp.path(), "first", "alpha", json!({}));
|
|
let original = modding::discover(temp.path());
|
|
install(temp.path(), "second", "alpha", json!({}));
|
|
let message = error(ActiveMods::build(
|
|
&base(),
|
|
&original,
|
|
&ids(&["alpha"]),
|
|
None,
|
|
));
|
|
assert!(message.contains("duplicated since discovery") && message.contains("rescan"));
|
|
}
|
|
|
|
#[test]
|
|
fn changed_files_require_a_new_discovery_snapshot() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(
|
|
temp.path(),
|
|
"alpha",
|
|
"alpha",
|
|
json!({"items":[item("alpha:cell", 10)]}),
|
|
);
|
|
let original = modding::discover(temp.path());
|
|
let before = ActiveMods::build(&base(), &original, &ids(&["alpha"]), None).unwrap();
|
|
fs::write(
|
|
temp.path().join("alpha/content.json"),
|
|
serde_json::to_vec(&json!({"items":[item("alpha:cell", 12)]})).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
error(ActiveMods::build(
|
|
&base(),
|
|
&original,
|
|
&ids(&["alpha"]),
|
|
None
|
|
))
|
|
.contains("rescan")
|
|
);
|
|
let after = ActiveMods::build(
|
|
&base(),
|
|
&modding::discover(temp.path()),
|
|
&ids(&["alpha"]),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert_ne!(before.pins[0].hash, after.pins[0].hash);
|
|
assert_eq!(
|
|
after.registry.items[&ContentId::from("alpha:cell")].price,
|
|
12
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn package_hash_covers_added_assets_and_manifest_changes() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mut manifest = install(temp.path(), "alpha", "alpha", json!({}));
|
|
let d = modding::discover(temp.path());
|
|
fs::write(temp.path().join("alpha/readme.txt"), "Extra package file").unwrap();
|
|
assert!(error(ActiveMods::build(&base(), &d, &ids(&["alpha"]), None)).contains("rescan"));
|
|
let d = modding::discover(temp.path());
|
|
manifest.version = "1.0.1".into();
|
|
write_manifest(&temp.path().join("alpha"), &manifest);
|
|
assert!(error(ActiveMods::build(&base(), &d, &ids(&["alpha"]), None)).contains("rescan"));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_package_hash_is_portable_and_preserves_existing_linux_order() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let root = temp.path().join("fixture");
|
|
// Intentionally independent of the TOML serializer and native path spelling.
|
|
// The fixed digest uses UTF-8 component ordering (art/... before art-qa.json),
|
|
// forward slash names, little-endian u64 byte lengths and these exact bytes.
|
|
// The BMP/supplementary names also distinguish UTF-8 from UTF-16 ordering.
|
|
let files: &[(&str, &[u8])] = &[
|
|
("art/deep/hull.bin", b"\x00\x7f\xff"),
|
|
("art/hull-é.png", b"synthetic PNG bytes\r\n"),
|
|
("art/\u{e000}.bin", b"bmp filename"),
|
|
("art/\u{10000}.bin", b"supplementary filename"),
|
|
("art-qa.json", b"{\"ok\":true}\n"),
|
|
("content.json", b"{}"),
|
|
("mod.toml", b"id = \"fixture\"\nname = \"Portable fixture\"\nversion = \"1.0.0\"\napi_version = 1\ncontent = \"content.json\"\nscripts = [\"scripts/weapon.rhai\"]\n"),
|
|
("scripts/weapon.rhai", b"fn on_tick(event, state, random) { #{ commands: [], state: state } }\n"),
|
|
];
|
|
// Reverse creation order to ensure directory enumeration cannot set the hash.
|
|
for (name, data) in files.iter().rev() {
|
|
let path = root.join(name);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(path, data).unwrap();
|
|
}
|
|
let discovered = modding::discover(temp.path());
|
|
assert!(discovered.errors.is_empty(), "{:?}", discovered.errors);
|
|
assert_eq!(
|
|
discovered.mods["fixture"].hash,
|
|
"1c851b694b601dc9658d0bbbc6a6edd2d5359571c5b558cf0c3f076afa08cf3d"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn ambiguous_windows_filename_components_and_non_utf8_names_are_rejected() {
|
|
use std::os::unix::ffi::OsStringExt;
|
|
for filename in ["art\\hull.bin", "hull.bin:extra"] {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(temp.path(), "alpha", "alpha", json!({}));
|
|
fs::write(temp.path().join("alpha").join(filename), b"ambiguous asset").unwrap();
|
|
let discovered = modding::discover(temp.path());
|
|
assert!(discovered.mods.is_empty());
|
|
assert_eq!(discovered.errors.len(), 1);
|
|
assert!(discovered.errors[0].contains("ambiguous Windows"));
|
|
}
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(temp.path(), "alpha", "alpha", json!({}));
|
|
let filename = std::ffi::OsString::from_vec(b"asset-\xff.bin".to_vec());
|
|
fs::write(temp.path().join("alpha").join(filename), b"invalid name").unwrap();
|
|
let discovered = modding::discover(temp.path());
|
|
assert!(discovered.mods.is_empty());
|
|
assert_eq!(discovered.errors.len(), 1);
|
|
assert!(discovered.errors[0].contains("filenames must be UTF-8"));
|
|
}
|
|
|
|
#[test]
|
|
fn package_paths_depth_and_size_are_bounded() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mut m = install(temp.path(), "traversal", "traversal", json!({}));
|
|
m.content = "../outside.json".into();
|
|
write_manifest(&temp.path().join("traversal"), &m);
|
|
install(temp.path(), "deep", "deep", json!({}));
|
|
let mut nested = temp.path().join("deep");
|
|
for _ in 0..17 {
|
|
nested = nested.join("nested");
|
|
}
|
|
fs::create_dir_all(nested).unwrap();
|
|
install(temp.path(), "large", "large", json!({}));
|
|
fs::File::create(temp.path().join("large/huge.bin"))
|
|
.unwrap()
|
|
.set_len(64 * 1024 * 1024 + 1)
|
|
.unwrap();
|
|
let d = modding::discover(temp.path());
|
|
assert!(d.mods.is_empty());
|
|
assert!(d.errors.iter().any(|e| e.contains("parent traversal")));
|
|
assert!(d.errors.iter().any(|e| e.contains("depth exceeds")));
|
|
assert!(d.errors.iter().any(|e| e.contains("64 MiB")));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn symlink_folders_and_files_are_rejected() {
|
|
use std::os::unix::fs::symlink;
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(temp.path(), "alpha", "alpha", json!({}));
|
|
symlink("/etc/passwd", temp.path().join("alpha/link")).unwrap();
|
|
symlink(temp.path().join("alpha"), temp.path().join("alias")).unwrap();
|
|
let d = modding::discover(temp.path());
|
|
assert!(d.mods.is_empty());
|
|
assert_eq!(d.errors.len(), 2);
|
|
assert!(d.errors.iter().all(|e| e.contains("symlink")));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_manifest_versions_and_duplicate_sources_fail_discovery() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mut m = install(temp.path(), "alpha", "alpha", json!({}));
|
|
m.dependencies
|
|
.insert("beta".into(), "obviously invalid".into());
|
|
write_manifest(&temp.path().join("alpha"), &m);
|
|
assert!(modding::discover(temp.path()).mods.is_empty());
|
|
m.dependencies.clear();
|
|
m.scripts = ids(&["script.rhai", "script.rhai"]);
|
|
fs::write(temp.path().join("alpha/script.rhai"), "").unwrap();
|
|
write_manifest(&temp.path().join("alpha"), &m);
|
|
assert!(modding::discover(temp.path()).errors[0].contains("duplicate script"));
|
|
}
|
|
|
|
#[test]
|
|
fn later_explicit_overrides_win_and_report_the_previous_owner() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(
|
|
temp.path(),
|
|
"alpha",
|
|
"alpha",
|
|
json!({"items":[item("base:energy", 10)], "overrides":["base:energy"]}),
|
|
);
|
|
install(
|
|
temp.path(),
|
|
"beta",
|
|
"beta",
|
|
json!({"items":[item("base:energy", 20)], "overrides":["base:energy"]}),
|
|
);
|
|
let d = modding::discover(temp.path());
|
|
let active = ActiveMods::build(&base(), &d, &ids(&["alpha", "beta"]), None).unwrap();
|
|
assert_eq!(
|
|
active.registry.items[&ContentId::from("base:energy")].price,
|
|
20
|
|
);
|
|
assert_eq!(
|
|
active.registry.owners[&ContentId::from("base:energy")],
|
|
"beta"
|
|
);
|
|
assert_eq!(
|
|
active.registry.overrides,
|
|
ids(&["base:energy: base -> alpha", "base:energy: alpha -> beta"])
|
|
);
|
|
let reversed = ActiveMods::build(&base(), &d, &ids(&["beta", "alpha"]), None).unwrap();
|
|
assert_eq!(
|
|
reversed.registry.items[&ContentId::from("base:energy")].price,
|
|
10
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accidental_duplicates_type_changes_and_failed_packs_leave_registry_intact() {
|
|
let mut registry = base();
|
|
let previous = registry.items.len();
|
|
assert!(
|
|
registry
|
|
.apply(
|
|
pack(json!({"items":[item("alpha:valid", 1), item("base:energy", 2)]})),
|
|
"alpha"
|
|
)
|
|
.is_err()
|
|
);
|
|
assert_eq!(registry.items.len(), previous);
|
|
assert!(
|
|
!registry
|
|
.owners
|
|
.contains_key(&ContentId::from("alpha:valid"))
|
|
);
|
|
assert!(
|
|
registry
|
|
.apply(
|
|
pack(json!({"items":[item("alpha:twice", 1), item("alpha:twice", 2)]})),
|
|
"alpha"
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(registry.apply(pack(json!({"enemies":[{"id":"base:energy","name":"Wrong type"}],"overrides":["base:energy"]})), "alpha").is_err());
|
|
assert!(
|
|
registry
|
|
.apply(pack(json!({"items":[item("foreign:cell", 1)]})), "alpha")
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
registry
|
|
.apply(
|
|
pack(json!({"items":[item("alpha:new", 1)],"overrides":["alpha:new"]})),
|
|
"alpha"
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn content_references_and_undeclared_scripts_reject_the_whole_mod() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(
|
|
temp.path(),
|
|
"alpha",
|
|
"alpha",
|
|
json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"weapon":"alpha:missing"}]}),
|
|
);
|
|
assert!(
|
|
error(ActiveMods::build(
|
|
&base(),
|
|
&modding::discover(temp.path()),
|
|
&ids(&["alpha"]),
|
|
None
|
|
))
|
|
.contains("missing weapon")
|
|
);
|
|
fs::write(temp.path().join("alpha/content.json"), serde_json::to_vec(&json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"script":"undeclared.rhai"}]})).unwrap()).unwrap();
|
|
assert!(
|
|
error(ActiveMods::build(
|
|
&base(),
|
|
&modding::discover(temp.path()),
|
|
&ids(&["alpha"]),
|
|
None
|
|
))
|
|
.contains("manifest.scripts")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn script_failure_rolls_back_world_state_and_deterministic_rng() {
|
|
let mut registry = base();
|
|
registry
|
|
.missions
|
|
.get_mut(&ContentId::from("base:sector_1_wave_1"))
|
|
.unwrap()
|
|
.script = Some("mission.rhai".into());
|
|
let mut runtime = ScriptRuntime::new();
|
|
runtime
|
|
.add(
|
|
"base",
|
|
"mission.rhai",
|
|
r#"fn on_mission_start(e,s,r) {
|
|
#{commands:[#{op:"credits",amount:7}, #{op:"move",entity:999,x:0,y:0}],state:#{random:r[0]}}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
let pilot = Pilot::default();
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
let before = world.digest();
|
|
assert!(
|
|
world
|
|
.step(Input::default(), ®istry, &mut runtime)
|
|
.is_err()
|
|
);
|
|
assert_eq!(world.digest(), before);
|
|
assert!(runtime.states.is_empty());
|
|
assert!(runtime.rng.is_empty());
|
|
let source = r#"fn on_mission_start(e,s,r) { #{commands:[#{op:"credits",amount:r[0]}],state:#{random:r[0]}} }"#;
|
|
runtime.add("base", "mission.rhai", source).unwrap();
|
|
world
|
|
.step(Input::default(), ®istry, &mut runtime)
|
|
.unwrap();
|
|
let mut fresh_runtime = ScriptRuntime::new();
|
|
fresh_runtime.add("base", "mission.rhai", source).unwrap();
|
|
let mut fresh_world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
fresh_world
|
|
.step(Input::default(), ®istry, &mut fresh_runtime)
|
|
.unwrap();
|
|
assert_eq!(world.digest(), fresh_world.digest());
|
|
assert_eq!(runtime.states, fresh_runtime.states);
|
|
assert_eq!(runtime.rng, fresh_runtime.rng);
|
|
}
|
|
|
|
#[test]
|
|
fn command_batches_are_atomic_even_after_an_earlier_valid_command() {
|
|
let registry = base();
|
|
let pilot = Pilot::default();
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
let before = world.digest();
|
|
let commands = [
|
|
Command::Credits { amount: 100 },
|
|
Command::Spawn {
|
|
enemy: "missing:enemy".into(),
|
|
x: 0,
|
|
y: 0,
|
|
},
|
|
];
|
|
assert!(world.apply_commands(&commands, ®istry).is_err());
|
|
assert_eq!(world.digest(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_result_excessive_commands_and_oversized_state_are_rejected() {
|
|
let mut runtime = ScriptRuntime::new();
|
|
for (source, expected) in [
|
|
(
|
|
r#"fn on_tick(e,s,r) { #{commands:[#{op:"unknown"}],state:#{}} }"#,
|
|
"invalid result",
|
|
),
|
|
(
|
|
r#"fn on_tick(e,s,r) { let a=[]; for i in 0..257 { a.push(#{op:"complete"}); } #{commands:a,state:#{}} }"#,
|
|
"too many commands",
|
|
),
|
|
(
|
|
r#"fn on_tick(e,s,r) { let a=[]; for i in 0..4000 { a.push(999999999999999999); } #{commands:[],state:a} }"#,
|
|
"state exceeds",
|
|
),
|
|
] {
|
|
runtime.begin_tick();
|
|
runtime.add("test", "limits.rhai", source).unwrap();
|
|
let message = error(runtime.invoke("test", "limits.rhai", "on_tick", &json!({}), 17));
|
|
assert!(
|
|
message.contains(expected),
|
|
"expected {expected}, got {message}"
|
|
);
|
|
assert!(runtime.states.is_empty());
|
|
assert!(runtime.rng.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn execution_budget_is_shared_across_callbacks_and_resets_per_tick() {
|
|
let mut runtime = ScriptRuntime::new();
|
|
runtime.add("test", "budget.rhai", r#"fn on_tick(e,s,r) { let x=0; for n in 0..2500 { x+=1; } #{commands:[],state:#{iterations:x}} }"#).unwrap();
|
|
runtime.begin_tick();
|
|
let mut success = 0;
|
|
for _ in 0..100 {
|
|
if runtime
|
|
.invoke("test", "budget.rhai", "on_tick", &json!({}), 1)
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
success += 1;
|
|
}
|
|
assert!(
|
|
success > 1 && success < 100,
|
|
"successful callbacks: {success}"
|
|
);
|
|
runtime.begin_tick();
|
|
runtime
|
|
.invoke("test", "budget.rhai", "on_tick", &json!({}), 1)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn missing_hook_does_not_advance_rng_or_state() {
|
|
let mut runtime = ScriptRuntime::new();
|
|
runtime
|
|
.add(
|
|
"test",
|
|
"empty.rhai",
|
|
"fn other(e,s,r) { #{commands:[],state:s} }",
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
runtime
|
|
.invoke("test", "empty.rhai", "on_tick", &json!({}), 1)
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
assert!(runtime.states.is_empty());
|
|
assert!(runtime.rng.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn saves_allow_added_mods_while_preserving_required_versions_and_order() {
|
|
let registry = base();
|
|
let pin = |id: &str| modding::ModPin {
|
|
id: id.into(),
|
|
version: "1.0.0".into(),
|
|
hash: format!("{id}-content"),
|
|
};
|
|
let a = pin("alpha");
|
|
let b = pin("beta");
|
|
let c = pin("extra");
|
|
let mut pilot = Pilot::default();
|
|
assert!(pilot.validate(®istry, &[a.clone(), b.clone()]).is_ok());
|
|
pilot.mods = vec![a.clone(), b.clone()];
|
|
pilot
|
|
.script_state
|
|
.insert("alpha/ai.rhai".into(), json!({"kills": 17}));
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("pilot.json");
|
|
pilot.save(&path).unwrap();
|
|
let before = fs::read(&path).unwrap();
|
|
let loaded = Pilot::load(&path, ®istry, &[a.clone(), c, b.clone()]).unwrap();
|
|
assert_eq!(loaded.script_state, pilot.script_state);
|
|
assert!(Pilot::load(&path, ®istry, &[b.clone(), a.clone()]).is_err());
|
|
assert!(Pilot::load(&path, ®istry, &[a.clone()]).is_err());
|
|
let mut changed = b;
|
|
changed.version = "2.0.0".into();
|
|
assert!(Pilot::load(&path, ®istry, &[a, changed]).is_err());
|
|
assert_eq!(fs::read(path).unwrap(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn incompatible_save_pins_and_disabled_equipment_do_not_modify_the_save() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(
|
|
temp.path(),
|
|
"alpha",
|
|
"alpha",
|
|
json!({"items":[item("alpha:cell", 1)]}),
|
|
);
|
|
let active = ActiveMods::build(
|
|
&base(),
|
|
&modding::discover(temp.path()),
|
|
&ids(&["alpha"]),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut pilot = Pilot::default();
|
|
pilot.mods = active.pins.clone();
|
|
pilot.inventory.insert("alpha:cell".into(), 1);
|
|
let path = temp.path().join("pilot.json");
|
|
pilot.save(&path).unwrap();
|
|
let before = fs::read(&path).unwrap();
|
|
Pilot::load(&path, &active.registry, &active.pins).unwrap();
|
|
assert!(error(Pilot::load(&path, &base(), &[])).contains("enable the matching mods"));
|
|
let mut changed = active.pins.clone();
|
|
changed[0].hash = "different-content-hash".into();
|
|
assert!(Pilot::load(&path, &active.registry, &changed).is_err());
|
|
assert!(
|
|
error(Pilot::load(&path, &base(), &active.pins))
|
|
.contains("saved item alpha:cell is unavailable")
|
|
);
|
|
assert_eq!(fs::read(path).unwrap(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn inspected_saves_report_version_hash_and_order_changes_before_confirmation() {
|
|
use raptor_replica::persistence::SaveMismatch;
|
|
let registry = base();
|
|
let pin = |id: &str| modding::ModPin {
|
|
id: id.into(),
|
|
version: "1.0.0".into(),
|
|
hash: format!("{id}-old"),
|
|
};
|
|
let mut pilot = Pilot::default();
|
|
pilot.mods = vec![pin("alpha"), pin("beta")];
|
|
let mut changed_alpha = pin("alpha");
|
|
changed_alpha.version = "2.0.0".into();
|
|
let mut changed_beta = pin("beta");
|
|
changed_beta.hash = "new-content".into();
|
|
let pins = [changed_beta, changed_alpha];
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let path = temp.path().join("pilot.json");
|
|
pilot.save(&path).unwrap();
|
|
let bytes = fs::read(&path).unwrap();
|
|
let inspected = Pilot::inspect_load(&path, ®istry, &pins).unwrap();
|
|
assert!(!inspected.compatibility.is_compatible());
|
|
assert_eq!(inspected.compatibility.issues.len(), 3);
|
|
assert!(
|
|
matches!(&inspected.compatibility.issues[0], SaveMismatch::ChangedMod { saved, active }
|
|
if saved.id == "alpha" && active.version == "2.0.0")
|
|
);
|
|
assert!(
|
|
matches!(&inspected.compatibility.issues[1], SaveMismatch::ChangedMod { saved, active }
|
|
if saved.id == "beta" && active.hash == "new-content")
|
|
);
|
|
assert!(
|
|
matches!(&inspected.compatibility.issues[2], SaveMismatch::ChangedLoadOrder { saved, active }
|
|
if saved == &ids(&["alpha", "beta"]) && active == &ids(&["beta", "alpha"]))
|
|
);
|
|
assert!(
|
|
inspected
|
|
.compatibility
|
|
.messages()
|
|
.join("\n")
|
|
.contains("content changed")
|
|
);
|
|
assert!(Pilot::load(&path, ®istry, &pins).is_err());
|
|
let confirmed = inspected.confirm_load();
|
|
assert_eq!(
|
|
serde_json::to_value(confirmed).unwrap(),
|
|
serde_json::to_value(pilot).unwrap()
|
|
);
|
|
assert_eq!(fs::read(path).unwrap(), bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn confirmed_missing_content_preserves_equipment_progression_and_script_state() {
|
|
use raptor_replica::persistence::SaveMismatch;
|
|
let registry = base();
|
|
let mut pilot = Pilot::default();
|
|
pilot.mods = vec![modding::ModPin {
|
|
id: "missing".into(),
|
|
version: "1.2.3".into(),
|
|
hash: "original-art-and-code".into(),
|
|
}];
|
|
let item: ContentId = "missing:weapon".into();
|
|
pilot.inventory.insert(item.clone(), 7);
|
|
pilot.selected = Some(item.clone());
|
|
pilot.mission = "missing:mission_2".into();
|
|
pilot.completed.push("missing:mission_1".into());
|
|
pilot
|
|
.script_state
|
|
.insert("missing/weapon.rhai".into(), json!({"charges": 27}));
|
|
pilot.script_rng.insert(
|
|
"missing/weapon.rhai".into(),
|
|
raptor_replica::simulation::LegacyRng::new(972),
|
|
);
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let path = temp.path().join("pilot.json");
|
|
pilot.save(&path).unwrap();
|
|
let bytes = fs::read(&path).unwrap();
|
|
let loaded = Pilot::inspect_load(&path, ®istry, &[]).unwrap();
|
|
assert!(
|
|
loaded
|
|
.compatibility
|
|
.issues
|
|
.iter()
|
|
.any(|issue| matches!(issue, SaveMismatch::MissingMod(_)))
|
|
);
|
|
assert!(
|
|
loaded
|
|
.compatibility
|
|
.issues
|
|
.iter()
|
|
.any(|issue| matches!(issue, SaveMismatch::MissingItem(id) if id == &item))
|
|
);
|
|
assert!(
|
|
loaded
|
|
.compatibility
|
|
.issues
|
|
.iter()
|
|
.any(|issue| matches!(issue, SaveMismatch::MissingMission(_)))
|
|
);
|
|
assert!(
|
|
loaded
|
|
.compatibility
|
|
.issues
|
|
.iter()
|
|
.any(|issue| matches!(issue, SaveMismatch::MissingCompletedMission(_)))
|
|
);
|
|
let confirmed = loaded.confirm_load();
|
|
assert_eq!(
|
|
serde_json::to_value(&confirmed).unwrap(),
|
|
serde_json::to_value(&pilot).unwrap()
|
|
);
|
|
confirmed.validate_tolerant(®istry).unwrap();
|
|
let mut world = World::new(&"base:sector_1_wave_1".into(), &confirmed, ®istry).unwrap();
|
|
world
|
|
.step(
|
|
Input {
|
|
fire: true,
|
|
special: true,
|
|
..Input::default()
|
|
},
|
|
®istry,
|
|
&mut ScriptRuntime::new(),
|
|
)
|
|
.unwrap();
|
|
let after = world.pilot(&confirmed);
|
|
assert_eq!(after.inventory[&item], 7);
|
|
assert_eq!(after.selected, Some(item));
|
|
assert_eq!(after.completed, confirmed.completed);
|
|
assert_eq!(after.script_state, confirmed.script_state);
|
|
assert_eq!(after.script_rng, confirmed.script_rng);
|
|
// Parked equipment cannot be destroyed by gameplay while its definition
|
|
// is unavailable. The old low-shield loss rule used selected blindly.
|
|
world.player.shield = 9;
|
|
world
|
|
.step(Input::default(), ®istry, &mut ScriptRuntime::new())
|
|
.unwrap();
|
|
assert_eq!(
|
|
world.pilot(&confirmed).inventory[&ContentId::from("missing:weapon")],
|
|
7
|
|
);
|
|
assert_eq!(fs::read(path).unwrap(), bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn confirmed_snapshot_preserves_inactive_pins_and_script_data() {
|
|
let pin = |id: &str, version: &str| modding::ModPin {
|
|
id: id.into(),
|
|
version: version.into(),
|
|
hash: format!("{id}-{version}"),
|
|
};
|
|
let mut pilot = Pilot::default();
|
|
pilot.mods = vec![pin("alpha", "1.0.0"), pin("inactive", "1.5.0")];
|
|
pilot
|
|
.script_state
|
|
.insert("alpha/old.rhai".into(), json!({"old": 1}));
|
|
pilot
|
|
.script_state
|
|
.insert("inactive/ai.rhai".into(), json!({"kills": 17}));
|
|
pilot.script_rng.insert(
|
|
"inactive/ai.rhai".into(),
|
|
raptor_replica::simulation::LegacyRng::new(33),
|
|
);
|
|
let inactive_rng = pilot.script_rng.clone();
|
|
let current = [pin("new", "1.0.0"), pin("alpha", "2.0.0")];
|
|
let states = [("alpha/new.rhai".into(), json!({"ticks": 4}))]
|
|
.into_iter()
|
|
.collect();
|
|
let rng = [(
|
|
"alpha/new.rhai".into(),
|
|
raptor_replica::simulation::LegacyRng::new(91),
|
|
)]
|
|
.into_iter()
|
|
.collect();
|
|
pilot.merge_runtime_snapshot(¤t, &states, &rng);
|
|
assert_eq!(
|
|
pilot.mods,
|
|
vec![
|
|
current[0].clone(),
|
|
current[1].clone(),
|
|
pin("inactive", "1.5.0")
|
|
]
|
|
);
|
|
assert_eq!(pilot.script_state["inactive/ai.rhai"], json!({"kills": 17}));
|
|
assert_eq!(pilot.script_state["alpha/old.rhai"], json!({"old": 1}));
|
|
assert_eq!(pilot.script_state["alpha/new.rhai"], json!({"ticks": 4}));
|
|
assert_eq!(
|
|
pilot.script_rng["inactive/ai.rhai"],
|
|
inactive_rng["inactive/ai.rhai"]
|
|
);
|
|
assert_eq!(pilot.script_rng["alpha/new.rhai"], rng["alpha/new.rhai"]);
|
|
}
|
|
|
|
#[test]
|
|
fn capacity_changes_are_reported_without_clamping_and_corrupt_saves_still_fail() {
|
|
use raptor_replica::persistence::SaveMismatch;
|
|
let registry = base();
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let path = temp.path().join("pilot.json");
|
|
let mut pilot = Pilot::default();
|
|
pilot.inventory.insert("base:energy".into(), 105);
|
|
pilot.save(&path).unwrap();
|
|
let loaded = Pilot::inspect_load(&path, ®istry, &[]).unwrap();
|
|
assert!(loaded.compatibility.issues.iter().any(|issue| matches!(issue,
|
|
SaveMismatch::ChangedItemCapacity { id, saved: 105, available: 100 } if id.0 == "base:energy")));
|
|
assert_eq!(
|
|
loaded.confirm_load().inventory[&ContentId::from("base:energy")],
|
|
105
|
|
);
|
|
let mut overflow = Pilot::default();
|
|
overflow
|
|
.inventory
|
|
.insert("missing:overflow".into(), u32::MAX);
|
|
for invalid in [
|
|
overflow,
|
|
Pilot {
|
|
version: 99,
|
|
..Pilot::default()
|
|
},
|
|
Pilot {
|
|
difficulty: 4,
|
|
..Pilot::default()
|
|
},
|
|
Pilot {
|
|
selected: Some("missing:not_owned".into()),
|
|
..Pilot::default()
|
|
},
|
|
Pilot {
|
|
mission: "broken id".into(),
|
|
..Pilot::default()
|
|
},
|
|
] {
|
|
invalid.save(&path).unwrap();
|
|
let before = fs::read(&path).unwrap();
|
|
assert!(Pilot::inspect_load(&path, ®istry, &[]).is_err());
|
|
assert_eq!(fs::read(&path).unwrap(), before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tolerant_campaign_completion_retains_disabled_mod_progress_and_equipment() {
|
|
let mut registry = base();
|
|
registry
|
|
.missions
|
|
.get_mut(&ContentId::from("base:sector_1_wave_1"))
|
|
.unwrap()
|
|
.next = Some("base:sector_1_wave_1".into());
|
|
let mut pilot = Pilot::default();
|
|
pilot.inventory.insert("inactive:weapon".into(), 1);
|
|
pilot.selected = Some("inactive:weapon".into());
|
|
pilot.completed.push("inactive:mission_1".into());
|
|
pilot.complete_mission_tolerant(®istry).unwrap();
|
|
assert_eq!(pilot.inventory[&ContentId::from("inactive:weapon")], 1);
|
|
assert_eq!(pilot.selected, Some("inactive:weapon".into()));
|
|
assert!(
|
|
pilot
|
|
.completed
|
|
.contains(&ContentId::from("inactive:mission_1"))
|
|
);
|
|
assert!(
|
|
pilot
|
|
.completed
|
|
.contains(&ContentId::from("base:sector_1_wave_1"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn saved_script_state_and_rng_resume_the_same_callback_sequence() {
|
|
let mut runtime = ScriptRuntime::new();
|
|
let source = r#"fn on_tick(e,s,r) { let n=if s.contains("n") {s.n+1} else {1}; #{commands:[#{op:"credits",amount:r[0]}],state:#{n:n,last:r[0]}} }"#;
|
|
runtime.add("test", "state.rhai", source).unwrap();
|
|
runtime
|
|
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
|
|
.unwrap();
|
|
let mut pilot = Pilot::default();
|
|
pilot.script_state = runtime.states.clone();
|
|
pilot.script_rng = runtime.rng.clone();
|
|
let restored_pilot: Pilot =
|
|
serde_json::from_slice(&serde_json::to_vec(&pilot).unwrap()).unwrap();
|
|
let mut restored = ScriptRuntime::new();
|
|
restored.add("test", "state.rhai", source).unwrap();
|
|
restored.states = restored_pilot.script_state;
|
|
restored.rng = restored_pilot.script_rng;
|
|
let a = runtime
|
|
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
|
|
.unwrap();
|
|
let b = restored
|
|
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
|
|
.unwrap();
|
|
assert_eq!(
|
|
serde_json::to_value(a).unwrap(),
|
|
serde_json::to_value(b).unwrap()
|
|
);
|
|
assert_eq!(runtime.states, restored.states);
|
|
assert_eq!(runtime.rng, restored.rng);
|
|
}
|
|
|
|
#[test]
|
|
fn example_mod_compiles_and_its_weapon_enemy_item_and_mission_hooks_execute() {
|
|
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods");
|
|
let mut active =
|
|
ActiveMods::build(&base(), &modding::discover(&dir), &ids(&["aurora"]), None).unwrap();
|
|
assert!(
|
|
active
|
|
.registry
|
|
.weapons
|
|
.contains_key(&ContentId::from("aurora:pulse_projectile"))
|
|
);
|
|
assert!(
|
|
active
|
|
.registry
|
|
.items
|
|
.contains_key(&ContentId::from("aurora:pulse_cannon"))
|
|
);
|
|
assert!(
|
|
active
|
|
.registry
|
|
.enemies
|
|
.contains_key(&ContentId::from("aurora:weaver"))
|
|
);
|
|
assert!(
|
|
active
|
|
.registry
|
|
.campaigns
|
|
.contains_key(&ContentId::from("aurora:trials"))
|
|
);
|
|
let mission = ContentId::from("aurora:field_trials");
|
|
let pilot = Pilot::default();
|
|
let mut world = World::new(&mission, &pilot, &active.registry).unwrap();
|
|
world
|
|
.step(Input::default(), &active.registry, &mut active.scripts)
|
|
.unwrap();
|
|
assert_eq!(
|
|
world
|
|
.player
|
|
.inventory
|
|
.get(&ContentId::from("aurora:pulse_cannon")),
|
|
Some(&1)
|
|
);
|
|
world
|
|
.step(
|
|
Input {
|
|
fire: true,
|
|
..Input::default()
|
|
},
|
|
&active.registry,
|
|
&mut active.scripts,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
active.scripts.states["aurora/scripts/weapon.rhai"]["shots"],
|
|
1
|
|
);
|
|
assert!(
|
|
world
|
|
.projectiles
|
|
.iter()
|
|
.filter(|p| p.weapon.0 == "aurora:pulse_projectile")
|
|
.count()
|
|
>= 3
|
|
);
|
|
let entity = world
|
|
.spawn(&ContentId::from("aurora:weaver"), 60, 30, &active.registry)
|
|
.unwrap();
|
|
world
|
|
.step(Input::default(), &active.registry, &mut active.scripts)
|
|
.unwrap();
|
|
assert_eq!(
|
|
world
|
|
.enemies
|
|
.iter()
|
|
.find(|e| e.entity == entity)
|
|
.unwrap()
|
|
.vx,
|
|
1
|
|
);
|
|
let commands = active
|
|
.scripts
|
|
.invoke(
|
|
"aurora",
|
|
"scripts/cell.rhai",
|
|
"on_pickup",
|
|
&json!({"entity":1}),
|
|
0,
|
|
)
|
|
.unwrap();
|
|
world.apply_commands(&commands, &active.registry).unwrap();
|
|
assert_eq!(world.player.credits, 25);
|
|
}
|
|
|
|
#[test]
|
|
fn png_strips_are_validated_without_the_original_catalog() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let art = json!({"png":"cell.png","logical_width":1,"logical_height":1,"frames":2});
|
|
install(
|
|
temp.path(),
|
|
"alpha",
|
|
"alpha",
|
|
json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"art":art}]}),
|
|
);
|
|
image::RgbaImage::from_pixel(3, 1, image::Rgba([20, 40, 60, 255]))
|
|
.save(temp.path().join("alpha/cell.png"))
|
|
.unwrap();
|
|
let message = error(ActiveMods::build(
|
|
&base(),
|
|
&modding::discover(temp.path()),
|
|
&ids(&["alpha"]),
|
|
None,
|
|
));
|
|
assert!(message.contains("strip width"));
|
|
image::RgbaImage::from_pixel(4, 2, image::Rgba([20, 40, 60, 255]))
|
|
.save(temp.path().join("alpha/cell.png"))
|
|
.unwrap();
|
|
ActiveMods::build(
|
|
&base(),
|
|
&modding::discover(temp.path()),
|
|
&ids(&["alpha"]),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn callback_arity_and_source_size_are_checked_before_activation() {
|
|
let mut runtime = ScriptRuntime::new();
|
|
assert!(
|
|
error(runtime.add("test", "bad.rhai", "fn on_fire(event,state) { state }"))
|
|
.contains("callbacks require")
|
|
);
|
|
assert!(
|
|
error(runtime.add("test", "huge.rhai", &" ".repeat(1024 * 1024 + 1)))
|
|
.contains("source exceeds 1 MiB")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn callbacks_can_query_player_entities_projectiles_pickups_and_objectives() {
|
|
let mut registry = base();
|
|
registry
|
|
.missions
|
|
.get_mut(&ContentId::from("base:sector_1_wave_1"))
|
|
.unwrap()
|
|
.script = Some("queries.rhai".into());
|
|
let mut runtime = ScriptRuntime::new();
|
|
runtime.add("base", "queries.rhai", r#"fn on_mission_start(e,s,r) {
|
|
#{commands:[],state:#{shield:e.player.shield,inventory:e.player.inventory,
|
|
enemies:e.entities.len,shots:e.projectiles.len,pickups:e.pickups.len,objectives:e.objectives}}
|
|
}"#).unwrap();
|
|
let pilot = Pilot::default();
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
world
|
|
.step(Input::default(), ®istry, &mut runtime)
|
|
.unwrap();
|
|
let state = &runtime.states["base/queries.rhai"];
|
|
assert_eq!(state["shield"], 100);
|
|
assert_eq!(state["inventory"]["base:forward_guns"], 1);
|
|
assert_eq!(state["enemies"], 0);
|
|
assert_eq!(state["shots"], 0);
|
|
assert_eq!(state["pickups"], 0);
|
|
assert_eq!(state["objectives"], json!({}));
|
|
}
|
|
|
|
#[test]
|
|
fn consumable_special_items_run_on_use_once_per_button_press() {
|
|
let mut registry = base();
|
|
registry
|
|
.apply(
|
|
pack(json!({"items":[{
|
|
"id":"test:repair","name":"Repair","price":5,"max_count":10,"shield":7,
|
|
"special":true,"consumable":true,"script":"repair.rhai"
|
|
}]})),
|
|
"test",
|
|
)
|
|
.unwrap();
|
|
let mut runtime = ScriptRuntime::new();
|
|
runtime
|
|
.add(
|
|
"test",
|
|
"repair.rhai",
|
|
r#"fn on_use(e,s,r) {
|
|
let n=if s.contains("n") {s.n+1} else {1};
|
|
#{commands:[#{op:"credits",amount:5}],state:#{n:n}}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
let mut pilot = Pilot::default();
|
|
pilot.inventory.insert("base:energy".into(), 50);
|
|
pilot.inventory.insert("test:repair".into(), 2);
|
|
pilot.selected = Some("test:repair".into());
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
let input = Input {
|
|
special: true,
|
|
..Input::default()
|
|
};
|
|
world.step(input, ®istry, &mut runtime).unwrap();
|
|
world.step(input, ®istry, &mut runtime).unwrap();
|
|
assert_eq!(world.player.shield, 57);
|
|
assert_eq!(world.player.inventory[&ContentId::from("test:repair")], 1);
|
|
assert_eq!(runtime.states["test/repair.rhai"]["n"], 1);
|
|
world
|
|
.step(Input::default(), ®istry, &mut runtime)
|
|
.unwrap();
|
|
world.step(input, ®istry, &mut runtime).unwrap();
|
|
assert_eq!(world.player.shield, 64);
|
|
assert_eq!(world.player.credits, 10);
|
|
assert_eq!(runtime.states["test/repair.rhai"]["n"], 2);
|
|
}
|
|
|
|
#[test]
|
|
fn wall_clock_sleep_and_indirect_native_calls_are_not_exposed() {
|
|
let mut runtime = ScriptRuntime::new();
|
|
for source in [
|
|
"fn on_tick(e,s,r) { timestamp(); #{commands:[],state:s} }",
|
|
"fn on_tick(e,s,r) { sleep(0); #{commands:[],state:s} }",
|
|
"fn on_tick(e,s,r) { let f=Fn(\"sleep\"); f.call(0); #{commands:[],state:s} }",
|
|
"fn on_tick(e,s,r) { let f=Fn(\"timestamp\"); f.call(); #{commands:[],state:s} }",
|
|
] {
|
|
runtime.begin_tick();
|
|
runtime.add("test", "unsafe.rhai", source).unwrap();
|
|
let message = error(runtime.invoke("test", "unsafe.rhai", "on_tick", &json!({}), 1));
|
|
assert!(message.contains("Function not found"), "{message}");
|
|
}
|
|
assert!(runtime.states.is_empty());
|
|
assert!(runtime.rng.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_eval_and_imports_cannot_be_reintroduced_by_function_pointers() {
|
|
let mut runtime = ScriptRuntime::new();
|
|
assert!(
|
|
runtime
|
|
.add("test", "eval.rhai", "fn on_tick(e,s,r) { eval(\"42\"); }")
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
runtime
|
|
.add("test", "imports.rhai", "import \"external\" as x;")
|
|
.is_err()
|
|
);
|
|
runtime
|
|
.add(
|
|
"test",
|
|
"indirect.rhai",
|
|
"fn on_tick(e,s,r) { let f=Fn(\"eval\"); f.call(\"42\"); #{commands:[],state:s} }",
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
runtime
|
|
.invoke("test", "indirect.rhai", "on_tick", &json!({}), 1)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn script_inventory_commands_preserve_the_twenty_item_capacity() {
|
|
let mut registry = base();
|
|
let extra: Vec<_> = (0..19)
|
|
.map(|n| item(&format!("test:item_{n}"), 1))
|
|
.collect();
|
|
registry
|
|
.apply(pack(json!({"items":extra})), "test")
|
|
.unwrap();
|
|
let pilot = Pilot::default();
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
for n in 0..18 {
|
|
world
|
|
.apply_commands(
|
|
&[Command::GiveItem {
|
|
item: format!("test:item_{n}"),
|
|
count: 1,
|
|
}],
|
|
®istry,
|
|
)
|
|
.unwrap();
|
|
}
|
|
assert_eq!(world.player.inventory.len(), 20);
|
|
let before = world.digest();
|
|
assert!(
|
|
world
|
|
.apply_commands(
|
|
&[Command::GiveItem {
|
|
item: "test:item_18".into(),
|
|
count: 1
|
|
}],
|
|
®istry
|
|
)
|
|
.is_err()
|
|
);
|
|
assert_eq!(before, world.digest());
|
|
world
|
|
.apply_commands(
|
|
&[Command::GiveItem {
|
|
item: "test:item_0".into(),
|
|
count: 1,
|
|
}],
|
|
®istry,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(world.player.inventory[&ContentId::from("test:item_0")], 2);
|
|
}
|
|
|
|
#[test]
|
|
fn existing_objectives_remain_updatable_at_the_capacity_limit() {
|
|
let registry = base();
|
|
let pilot = Pilot::default();
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
for n in 0..1024 {
|
|
world.objectives.insert(format!("test:{n}"), 0);
|
|
}
|
|
world
|
|
.apply_commands(
|
|
&[Command::Objective {
|
|
key: "test:0".into(),
|
|
value: 10,
|
|
}],
|
|
®istry,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(world.objectives["test:0"], 10);
|
|
assert!(
|
|
world
|
|
.apply_commands(
|
|
&[Command::Objective {
|
|
key: "test:new".into(),
|
|
value: 1
|
|
}],
|
|
®istry
|
|
)
|
|
.is_err()
|
|
);
|
|
assert_eq!(world.objectives.len(), 1024);
|
|
}
|
|
|
|
#[test]
|
|
fn enemy_weapons_and_enemies_receive_fire_and_damage_hooks() {
|
|
let mut registry = base();
|
|
registry.apply(pack(json!({
|
|
"weapons":[{"id":"test:bolt","name":"Bolt","damage":1,"speed":1,"cooldown":2,"behavior":"straight","target":"all","script":"weapon.rhai"}],
|
|
"enemies":[{"id":"test:fighter","name":"Fighter","health":100,"speed":1,"cooldown":2,"weapon":"test:bolt","script":"enemy.rhai"}]
|
|
})), "test").unwrap();
|
|
let mut runtime = ScriptRuntime::new();
|
|
runtime
|
|
.add(
|
|
"test",
|
|
"weapon.rhai",
|
|
r#"fn on_fire(e,s,r) {
|
|
#{commands:[],state:#{hostile:e.hostile,entity:e.entity}}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
runtime
|
|
.add(
|
|
"test",
|
|
"enemy.rhai",
|
|
r#"fn on_fire(e,s,r) { #{commands:[],state:#{fired:true}} }
|
|
fn on_hit(e,s,r) { s.hit=e.damage; s.scripted=e.scripted; #{commands:[],state:s} }"#,
|
|
)
|
|
.unwrap();
|
|
let pilot = Pilot::default();
|
|
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
|
let entity = world
|
|
.spawn(&ContentId::from("test:fighter"), 30, 30, ®istry)
|
|
.unwrap();
|
|
world
|
|
.step(Input::default(), ®istry, &mut runtime)
|
|
.unwrap();
|
|
assert_eq!(runtime.states["test/weapon.rhai"]["hostile"], true);
|
|
assert_eq!(runtime.states["test/weapon.rhai"]["entity"], entity);
|
|
assert_eq!(runtime.states["test/enemy.rhai"]["fired"], true);
|
|
world
|
|
.apply_commands(&[Command::Damage { entity, amount: 7 }], ®istry)
|
|
.unwrap();
|
|
world
|
|
.step(Input::default(), ®istry, &mut runtime)
|
|
.unwrap();
|
|
assert_eq!(runtime.states["test/enemy.rhai"]["hit"], 7);
|
|
assert_eq!(runtime.states["test/enemy.rhai"]["scripted"], true);
|
|
}
|