Files

222 lines
8.2 KiB
Rust

//! Package-local callback acceptance probe; compile against raptor_replica and serde_json.
use raptor_replica::{
content::{ContentPack, Registry},
modding::{self, ActiveMods, Command, ScriptRuntime},
persistence::Pilot,
simulation::{Input, World},
};
use serde_json::{Value, json};
use std::path::Path;
const OWNER: &str = "iron_coast_boss_3";
const SCRIPT: &str = "scripts/ai.rhai";
fn runtime(path: &Path) -> ScriptRuntime {
let mut result = ScriptRuntime::new();
result
.add(
OWNER,
SCRIPT,
&std::fs::read_to_string(path.join(SCRIPT)).unwrap(),
)
.unwrap();
result
}
fn event(entity: u64, x: i32, y: i32, health: i32) -> Value {
json!({"entity":entity, "x":x, "y":y, "health":health, "tick":999,
"entities":[{"entity":entity,"health":health}],"objectives":{}})
}
fn call(runtime: &mut ScriptRuntime, hook: &str, event: Value) -> Vec<Command> {
runtime.invoke(OWNER, SCRIPT, hook, &event, 303).unwrap()
}
fn queued_spawn_replay(package: &Path) {
let mut base = Registry::default();
let content: 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"}],
"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":"Spawn lifecycle collision regression","duration":500,"auto_complete":false,
"spawns":[{"tick":0,"enemy":"iron_coast_boss_3:unit","x":112,"y":120}]}]
})).unwrap();
base.apply(content, "base").unwrap();
let discovery = modding::discover(package.parent().unwrap());
let mut active = ActiveMods::build(&base, &discovery, &[OWNER.to_string()], None).unwrap();
// A real collision during advance kills this fragile boss before dispatch.
// Its on_spawn callback is queued before on_death, and both see no live boss.
active
.registry
.enemies
.get_mut(&"iron_coast_boss_3:unit".into())
.unwrap()
.health = 1;
let mut pilot = Pilot::default();
pilot.difficulty = 2;
pilot.credits = 0;
let mut reused_id = None;
for replay in 0..3 {
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
world
.step(
Input {
absolute: Some([144, 160]),
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
assert!(
world.enemies.is_empty(),
"collision must remove the boss before dispatch"
);
assert_eq!(
world.objectives.get("iron_coast_boss_3:defeated"),
Some(&1),
"replay {replay} must count its new lifecycle"
);
assert_eq!(
world.player.credits, 1800,
"exactly one engine reward per lifecycle"
);
let checkpoints = active.scripts.checkpoints();
let state = &checkpoints.0["iron_coast_boss_3/scripts/ai.rhai"];
let ids = state["dead"]
.as_object()
.unwrap()
.keys()
.collect::<Vec<_>>();
assert_eq!(ids.len(), 1);
if let Some(ref previous) = reused_id {
assert_eq!(previous, ids[0]);
} else {
reused_id = Some(ids[0].clone());
}
assert!(state["units"].as_object().unwrap().is_empty());
assert!(world.projectiles.is_empty(), "dead spawn must not shoot");
assert_eq!(world.mod_sounds, vec!["iron_coast_boss_3:death".into()]);
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(world.player.credits, 1800);
assert_eq!(world.objectives.get("iron_coast_boss_3:defeated"), Some(&1));
// Reconstruct the runtime from saved state exactly as a pilot load does.
let mut restored = runtime(package);
restored.restore(checkpoints);
active.scripts = restored;
}
}
fn main() {
let path = std::env::args().nth(1).expect("pass package directory");
let path = Path::new(&path);
let mut ai = runtime(path);
call(&mut ai, "on_spawn", event(1, 112, -64, 160));
for _ in 0..20 {
let commands = call(&mut ai, "on_tick", event(1, 112, -64, 160));
assert!(
!commands
.iter()
.any(|c| matches!(c, Command::Projectile { .. } | Command::PlaySound { .. }))
);
}
let mut warnings = Vec::new();
let mut volleys = Vec::new();
for tick in 1..=108 {
let commands = call(&mut ai, "on_tick", event(1, 112, 24, 160));
if commands
.iter()
.any(|c| matches!(c,Command::PlaySound{sound} if sound=="iron_coast_boss_3:warning"))
{
warnings.push(tick);
}
let shells = commands
.iter()
.filter_map(|c| match c {
Command::Projectile {
weapon,
x,
y,
vx,
vy,
hostile,
} => {
assert_eq!(weapon, "iron_coast_boss_3:projectile");
assert_eq!((*y, *vy, *hostile), (78, 4, true));
Some((*x, *vx))
}
_ => None,
})
.collect::<Vec<_>>();
if !shells.is_empty() {
volleys.push((tick, shells));
}
}
assert_eq!(warnings, vec![45, 99]);
assert_eq!(
volleys,
vec![
(54, vec![(147, 0), (147, 1)]),
(108, vec![(173, 0), (173, -1)])
]
);
assert!(
call(&mut ai, "on_tick", event(1, 144, 24, 160))
.iter()
.any(|c| matches!(c, Command::Velocity { vx: -1, vy: 0, .. }))
);
assert!(
call(&mut ai, "on_tick", event(1, 80, 24, 160))
.iter()
.any(|c| matches!(c, Command::Velocity { vx: 1, vy: 0, .. }))
);
// A second boss starts a separate clock and cannot inherit the first gun phase.
call(&mut ai, "on_spawn", event(2, 112, 24, 160));
assert!(
!call(&mut ai, "on_tick", event(2, 112, 24, 160))
.iter()
.any(|c| matches!(c, Command::Projectile { .. }))
);
let mut restored = runtime(path);
restored.restore(ai.checkpoints());
for _ in 0..111 {
assert_eq!(
serde_json::to_value(call(&mut ai, "on_tick", event(1, 112, 24, 160))).unwrap(),
serde_json::to_value(call(&mut restored, "on_tick", event(1, 112, 24, 160))).unwrap()
);
}
// A dead or absent entity cannot move or fire. Death reports once and never wins a mission itself.
assert!(call(&mut ai, "on_tick", event(1, 112, 24, 0)).is_empty());
let mut missing = event(1, 112, 24, 160);
missing["entities"] = json!([]);
assert!(call(&mut ai, "on_tick", missing).is_empty());
let death = call(&mut ai, "on_death", event(1, 112, 24, 0));
assert!(
death.iter().any(
|c| matches!(c,Command::Objective{key,value:1} if key=="iron_coast_boss_3:defeated")
)
);
assert!(
!death
.iter()
.any(|c| matches!(c, Command::Complete | Command::Credits { .. }))
);
assert!(call(&mut ai, "on_death", event(1, 112, 24, 0)).is_empty());
let mut second = event(2, 112, 24, 0);
second["objectives"] = json!({"iron_coast_boss_3:defeated":1});
assert!(
call(&mut ai, "on_death", second).iter().any(
|c| matches!(c,Command::Objective{key,value:2} if key=="iron_coast_boss_3:defeated")
)
);
queued_spawn_replay(path);
println!(
"Saltbreaker: arrival/patrol, two-shell alternating volleys, nine-step warnings, independent entities, checkpoint replay, dead-unit safety and exact death objectives passed"
);
println!(
"Real World lethal collision before queued spawn: three restored-state replays reuse the same entity ID and each count and reward exactly once"
);
}