Add scripted lightning weapon mods with interpolated anchored effects
This commit is contained in:
1 parent
98465133aa
commit
c0a6fd3431
50 files changed
+3500
-65
No files matched your search
@@ -0,0 +1,949 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
render::{Canvas, draw_lightning_arcs, draw_world_lightning_arcs},
|
||||
simulation::{Input, LightningArc, MAX_LIGHTNING_ARCS, Outcome, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn registry() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let mut pack: ContentPack = serde_json::from_value(json!({
|
||||
"weapons":[
|
||||
{"id":"test:selected","name":"Selected","damage":20,"speed":0,"cooldown":4,
|
||||
"behavior":"scripted","target":"all","script":"selected.rhai"},
|
||||
{"id":"test:equipped","name":"Equipped","damage":2,"speed":0,"cooldown":2,
|
||||
"behavior":"scripted","target":"air","script":"equipped.rhai"}
|
||||
],
|
||||
"items":[
|
||||
{"id":"test:gun","name":"Gun","price":100,"weapon":"test:selected","special":true},
|
||||
{"id":"test:shield","name":"Shield","price":100,"weapon":"test:equipped"}
|
||||
],
|
||||
"enemies":[
|
||||
{"id":"test:air","name":"Air","health":100,"speed":0,"cooldown":2,"weapon":"test:selected"},
|
||||
{"id":"test:ground","name":"Ground","health":100,"speed":0,"ground":true}
|
||||
],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":1000}]
|
||||
})).unwrap();
|
||||
let missions = std::mem::take(&mut pack.missions);
|
||||
registry
|
||||
.apply(
|
||||
ContentPack {
|
||||
missions,
|
||||
..ContentPack::default()
|
||||
},
|
||||
"base",
|
||||
)
|
||||
.unwrap();
|
||||
registry.apply(pack, "test").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn world(registry: &Registry) -> World {
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.inventory.clear();
|
||||
pilot.inventory.insert("base:energy".into(), 100);
|
||||
pilot.inventory.insert("test:gun".into(), 1);
|
||||
pilot.inventory.insert("test:shield".into(), 1);
|
||||
pilot.selected = Some("test:gun".into());
|
||||
World::new(&pilot.mission, &pilot, registry).unwrap()
|
||||
}
|
||||
|
||||
fn arc(ticks: u32) -> Command {
|
||||
Command::Arc {
|
||||
x: 160,
|
||||
y: 160,
|
||||
to_x: 160,
|
||||
to_y: 30,
|
||||
ticks,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_selected_and_equipped_weapons_follow_input_and_cooldowns_without_projectiles() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
for path in ["selected.rhai", "equipped.rhai"] {
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
path,
|
||||
r#"fn on_fire(e,s,r) {
|
||||
let count=if s.contains("count") {s.count+1} else {1};
|
||||
#{commands:[],state:#{count:count,hostile:e.hostile,entity:e.entity}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert!(runtime.states.is_empty());
|
||||
for _ in 0..9 {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut runtime,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(runtime.states["test/selected.rhai"]["count"], 3);
|
||||
assert_eq!(runtime.states["test/equipped.rhai"]["count"], 5);
|
||||
assert_eq!(runtime.states["test/selected.rhai"]["entity"], 0);
|
||||
assert_eq!(runtime.states["test/selected.rhai"]["hostile"], false);
|
||||
assert!(world.projectiles.is_empty());
|
||||
// Special input fires the selected weapon while ordinary equipment remains idle.
|
||||
for _ in 0..4 {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
special: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut runtime,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(runtime.states["test/selected.rhai"]["count"], 4);
|
||||
assert_eq!(runtime.states["test/equipped.rhai"]["count"], 5);
|
||||
assert!(world.projectiles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_enemy_weapons_emit_fire_hooks_without_native_projectiles() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let entity = world
|
||||
.spawn(&ContentId::from("test:air"), 30, 30, ®istry)
|
||||
.unwrap();
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"selected.rhai",
|
||||
r#"fn on_fire(e,s,r) {
|
||||
#{commands:[],state:#{hostile:e.hostile,entity:e.entity}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert_eq!(runtime.states["test/selected.rhai"]["entity"], entity);
|
||||
assert_eq!(runtime.states["test/selected.rhai"]["hostile"], true);
|
||||
assert!(world.projectiles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enemy_snapshots_expose_registry_ground_flags() {
|
||||
let mut registry = registry();
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&ContentId::from("base:sector_1_wave_1"))
|
||||
.unwrap()
|
||||
.script = Some("queries.rhai".into());
|
||||
let mut world = world(®istry);
|
||||
world
|
||||
.spawn(&ContentId::from("test:air"), 30, 30, ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.spawn(&ContentId::from("test:ground"), 90, 30, ®istry)
|
||||
.unwrap();
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime.add("test", "selected.rhai", "").unwrap();
|
||||
runtime
|
||||
.add(
|
||||
"base",
|
||||
"queries.rhai",
|
||||
r#"fn on_mission_start(e,s,r) {
|
||||
#{commands:[],state:#{air:e.entities[0].ground,ground:e.entities[1].ground}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
runtime.states["base/queries.rhai"],
|
||||
json!({"air":false,"ground":true})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_behavior_requires_a_script_and_arc_defaults_to_three_ticks() {
|
||||
let mut registry = registry();
|
||||
registry
|
||||
.weapons
|
||||
.get_mut(&ContentId::from("test:selected"))
|
||||
.unwrap()
|
||||
.script = None;
|
||||
assert!(
|
||||
registry
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("requires a script")
|
||||
);
|
||||
let command: Command = serde_json::from_value(json!({
|
||||
"op":"arc","x":0,"y":0,"to_x":20,"to_y":20
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
command,
|
||||
Command::Arc {
|
||||
ticks: 3,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arcs_validate_coordinates_lifetime_and_capacity_transactionally() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let before = world.digest();
|
||||
for bad in [
|
||||
arc(0),
|
||||
arc(13),
|
||||
Command::Arc {
|
||||
x: i32::MIN,
|
||||
y: 0,
|
||||
to_x: 0,
|
||||
to_y: 0,
|
||||
ticks: 3,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
},
|
||||
Command::Arc {
|
||||
x: 0,
|
||||
y: 32769,
|
||||
to_x: 0,
|
||||
to_y: 0,
|
||||
ticks: 3,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
},
|
||||
Command::Arc {
|
||||
x: 0,
|
||||
y: 0,
|
||||
to_x: -32769,
|
||||
to_y: 0,
|
||||
ticks: 3,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
},
|
||||
Command::Arc {
|
||||
x: 0,
|
||||
y: 0,
|
||||
to_x: 0,
|
||||
to_y: 32769,
|
||||
ticks: 3,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(&[arc(3), Command::Credits { amount: 5 }, bad], ®istry)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(world.digest(), before);
|
||||
assert!(world.lightning_arcs.is_empty());
|
||||
}
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Arc {
|
||||
x: -32768,
|
||||
y: 32768,
|
||||
to_x: 32768,
|
||||
to_y: -32768,
|
||||
ticks: 12,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.apply_commands(&vec![arc(3); MAX_LIGHTNING_ARCS - 1], ®istry)
|
||||
.unwrap();
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(&[Command::Credits { amount: 5 }, arc(3)], ®istry)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(world.lightning_arcs.len(), MAX_LIGHTNING_ARCS);
|
||||
assert_eq!(world.digest(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_arc_hook_rolls_back_world_script_state_and_rng() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let before = world.digest();
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime.add("test", "equipped.rhai", "").unwrap();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"selected.rhai",
|
||||
r#"fn on_fire(e,s,r) {
|
||||
#{commands:[#{op:"arc",x:0,y:0,to_x:10,to_y:10},
|
||||
#{op:"credits",amount:5},
|
||||
#{op:"arc",x:0,y:0,to_x:10,to_y:10,ticks:13}],
|
||||
state:#{used:r[0]}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let error = world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut runtime,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("arc lifetime"), "{error:#}");
|
||||
assert!(error.to_string().contains("test/selected.rhai::on_fire"));
|
||||
assert_eq!(world.digest(), before);
|
||||
assert!(world.lightning_arcs.is_empty());
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_lifetime_uses_simulation_ticks_and_never_changes_gameplay_state() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let mut plain = world.clone();
|
||||
world.apply_commands(&[arc(1), arc(3)], ®istry).unwrap();
|
||||
assert_eq!(world.digest(), plain.digest());
|
||||
assert!(
|
||||
serde_json::to_value(&world)
|
||||
.unwrap()
|
||||
.get("lightning_arcs")
|
||||
.is_none()
|
||||
);
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
let mut plain_runtime = ScriptRuntime::new();
|
||||
for expected in [1, 1, 0] {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
plain
|
||||
.step(Input::default(), ®istry, &mut plain_runtime)
|
||||
.unwrap();
|
||||
assert_eq!(world.lightning_arcs.len(), expected);
|
||||
assert_eq!(world.digest(), plain.digest());
|
||||
assert_eq!(world.rng, plain.rng);
|
||||
}
|
||||
world.apply_commands(&[arc(3)], ®istry).unwrap();
|
||||
world.outcome = Outcome::Complete;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert_eq!(world.lightning_arcs[0].age, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successive_discharge_seeds_vary_without_using_gameplay_randomness() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let rng = world.rng;
|
||||
let mut replay = world.clone();
|
||||
for _ in 0..3 {
|
||||
let previous = world.lightning_arcs.last().map(|arc| arc.seed);
|
||||
world.apply_commands(&[arc(3)], ®istry).unwrap();
|
||||
replay.apply_commands(&[arc(3)], ®istry).unwrap();
|
||||
assert_ne!(previous, Some(world.lightning_arcs.last().unwrap().seed));
|
||||
assert_eq!(world.lightning_arcs, replay.lightning_arcs);
|
||||
assert_eq!(world.rng, rng);
|
||||
}
|
||||
let first = world.lightning_arcs[0].seed;
|
||||
world.lightning_arcs.clear();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut ScriptRuntime::new())
|
||||
.unwrap();
|
||||
world.apply_commands(&[arc(3)], ®istry).unwrap();
|
||||
assert_ne!(first, world.lightning_arcs[0].seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_renderer_draws_visible_jagged_cyan_white_lines_at_all_scales_without_mutation() {
|
||||
let mut palette = [[0; 3]; 256];
|
||||
palette[1] = [24, 198, 255];
|
||||
palette[2] = [230, 255, 255];
|
||||
let arcs = vec![LightningArc {
|
||||
x: 20,
|
||||
y: 160,
|
||||
to_x: 200,
|
||||
to_y: 30,
|
||||
ticks: 3,
|
||||
age: 0,
|
||||
seed: 0,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
}];
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut canvas = Canvas::new(scale);
|
||||
draw_lightning_arcs(&mut canvas, &arcs, &palette);
|
||||
assert!(canvas.image.pixels.contains(&1));
|
||||
assert!(canvas.image.pixels.contains(&2));
|
||||
assert_eq!(
|
||||
canvas.image.pixels[160 * scale * canvas.image.width + 20 * scale],
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
canvas.image.pixels[30 * scale * canvas.image.width + 200 * scale],
|
||||
2
|
||||
);
|
||||
let first = canvas.image.clone();
|
||||
canvas.clear(0);
|
||||
draw_lightning_arcs(&mut canvas, &arcs, &palette);
|
||||
assert_eq!(canvas.image, first);
|
||||
// Reusing a renderer cache across palettes must not reuse old indices.
|
||||
let mut alternate = [[0; 3]; 256];
|
||||
alternate[1] = palette[2];
|
||||
canvas.clear(0);
|
||||
draw_lightning_arcs(&mut canvas, &arcs, &alternate);
|
||||
assert_eq!(
|
||||
canvas.image.pixels[160 * scale * canvas.image.width + 20 * scale],
|
||||
1
|
||||
);
|
||||
canvas.clear(0);
|
||||
draw_lightning_arcs(&mut canvas, &arcs, &palette);
|
||||
assert_eq!(canvas.image, first);
|
||||
let mut later = arcs.clone();
|
||||
later[0].age = 1;
|
||||
canvas.clear(0);
|
||||
draw_lightning_arcs(&mut canvas, &later, &palette);
|
||||
assert_ne!(canvas.image, first);
|
||||
later[0].age = 0;
|
||||
later[0].seed = 12345;
|
||||
canvas.clear(0);
|
||||
draw_lightning_arcs(&mut canvas, &later, &palette);
|
||||
assert_ne!(canvas.image, first);
|
||||
}
|
||||
assert_eq!(arcs[0].age, 0);
|
||||
let mut canvas = Canvas::new(4);
|
||||
let offscreen = [LightningArc {
|
||||
x: -32768,
|
||||
y: -32768,
|
||||
to_x: 32768,
|
||||
to_y: 32768,
|
||||
ticks: 3,
|
||||
age: 0,
|
||||
seed: 0,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
}];
|
||||
draw_lightning_arcs(&mut canvas, &offscreen, &palette);
|
||||
assert!(canvas.image.pixels.contains(&2));
|
||||
canvas.clear(0);
|
||||
let expired = [LightningArc {
|
||||
age: 3,
|
||||
..arcs[0].clone()
|
||||
}];
|
||||
draw_lightning_arcs(&mut canvas, &expired, &palette);
|
||||
assert!(canvas.image.pixels.iter().all(|p| *p == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_glow_has_falloff_lights_background_and_avoids_animated_colors() {
|
||||
let mut palette = [[0; 3]; 256];
|
||||
for (i, rgb) in palette[..240].iter_mut().enumerate() {
|
||||
*rgb = [i as u8 / 2, i as u8, i as u8];
|
||||
}
|
||||
palette[239] = [255; 3];
|
||||
palette[240..].fill([230, 255, 255]); // Deliberately perfect, but reserved.
|
||||
let arcs = [LightningArc {
|
||||
x: 80,
|
||||
y: 160,
|
||||
to_x: 160,
|
||||
to_y: 20,
|
||||
ticks: 3,
|
||||
age: 0,
|
||||
seed: 4,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
}];
|
||||
let brightness =
|
||||
|rgb: [u8; 3]| 3 * u32::from(rgb[0]) + 6 * u32::from(rgb[1]) + u32::from(rgb[2]);
|
||||
for scale in [1, 4] {
|
||||
let mut canvas = Canvas::new(scale);
|
||||
canvas.clear(40);
|
||||
draw_lightning_arcs(&mut canvas, &arcs, &palette);
|
||||
assert_eq!(canvas.image.pixels[0], 40);
|
||||
let colors: std::collections::BTreeSet<_> = canvas.image.pixels.iter().copied().collect();
|
||||
assert!(
|
||||
colors.len() > 6,
|
||||
"glow must have a gradient, not a flat outline"
|
||||
);
|
||||
assert!(colors.iter().all(|index| *index < 240));
|
||||
assert!(
|
||||
colors
|
||||
.iter()
|
||||
.all(|index| brightness(palette[*index as usize]) >= brightness(palette[40]))
|
||||
);
|
||||
canvas.clear(239);
|
||||
draw_lightning_arcs(&mut canvas, &arcs, &palette);
|
||||
assert!(canvas.image.pixels.iter().all(|index| *index == 239));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_handles_point_discharges_and_fully_offscreen_segments() {
|
||||
let mut palette = [[0; 3]; 256];
|
||||
palette[1] = [24, 198, 255];
|
||||
palette[2] = [230, 255, 255];
|
||||
let mut canvas = Canvas::new(4);
|
||||
let point = LightningArc {
|
||||
x: 0,
|
||||
y: 100,
|
||||
to_x: 0,
|
||||
to_y: 100,
|
||||
ticks: 3,
|
||||
age: 0,
|
||||
seed: 1,
|
||||
anchor: None,
|
||||
to_anchor: None,
|
||||
};
|
||||
draw_lightning_arcs(&mut canvas, &[point.clone()], &palette);
|
||||
assert_eq!(canvas.image.pixels[400 * canvas.image.width], 2);
|
||||
assert_eq!(canvas.image.pixels[0], 0);
|
||||
canvas.clear(0);
|
||||
let offscreen = LightningArc {
|
||||
x: -32768,
|
||||
y: -32768,
|
||||
to_x: -32000,
|
||||
to_y: -32000,
|
||||
..point
|
||||
};
|
||||
draw_lightning_arcs(&mut canvas, &[offscreen], &palette);
|
||||
assert!(canvas.image.pixels.iter().all(|pixel| *pixel == 0));
|
||||
}
|
||||
|
||||
fn anchored_arc(anchor: Option<u64>, to_anchor: Option<u64>) -> Command {
|
||||
Command::Arc {
|
||||
x: 160,
|
||||
y: 179,
|
||||
to_x: 86,
|
||||
to_y: 56,
|
||||
ticks: 8,
|
||||
anchor,
|
||||
to_anchor,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchored_arcs_preserve_absolute_creation_positions_and_follow_both_ships() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let entity = world
|
||||
.spawn(&ContentId::from("test:ground"), 70, 40, ®istry)
|
||||
.unwrap();
|
||||
let fixed = arc(8);
|
||||
world
|
||||
.apply_commands(
|
||||
&[
|
||||
anchored_arc(Some(0), Some(entity)),
|
||||
anchored_arc(Some(0), Some(0)),
|
||||
fixed,
|
||||
Command::Velocity {
|
||||
entity,
|
||||
vx: 3,
|
||||
vy: -2,
|
||||
},
|
||||
],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[0].x, world.lightning_arcs[0].y),
|
||||
(160, 179)
|
||||
);
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
|
||||
(86, 56)
|
||||
);
|
||||
assert_eq!(world.lightning_arcs[0].anchor.unwrap().offset, [16, 19]);
|
||||
assert_eq!(world.lightning_arcs[0].to_anchor.unwrap().offset, [16, 16]);
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([110, 130]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut runtime,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[0].x, world.lightning_arcs[0].y),
|
||||
(126, 149)
|
||||
);
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
|
||||
(89, 54)
|
||||
);
|
||||
// Both ends of a ring segment retain their offset from the player origin.
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[1].x, world.lightning_arcs[1].y),
|
||||
(126, 149)
|
||||
);
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[1].to_x, world.lightning_arcs[1].to_y),
|
||||
(52, 26)
|
||||
);
|
||||
// Existing unanchored commands remain fixed in world space.
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[2].x, world.lightning_arcs[2].y),
|
||||
(160, 160)
|
||||
);
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[2].to_x, world.lightning_arcs[2].to_y),
|
||||
(160, 30)
|
||||
);
|
||||
assert!(world.lightning_arcs.iter().all(|arc| arc.age == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_lightning_renders_at_interpolated_ship_positions_without_mutating_world() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let entity = world
|
||||
.spawn(&ContentId::from("test:ground"), 70, 40, ®istry)
|
||||
.unwrap();
|
||||
let point = |x, y, anchor| Command::Arc {
|
||||
x,
|
||||
y,
|
||||
to_x: x,
|
||||
to_y: y,
|
||||
ticks: 8,
|
||||
anchor,
|
||||
to_anchor: anchor,
|
||||
};
|
||||
world
|
||||
.apply_commands(
|
||||
&[
|
||||
point(160, 179, Some(0)),
|
||||
point(86, 56, Some(entity)),
|
||||
point(240, 80, None),
|
||||
Command::Velocity {
|
||||
entity,
|
||||
vx: 4,
|
||||
vy: 2,
|
||||
},
|
||||
],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([154, 156]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut ScriptRuntime::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let before = world.digest();
|
||||
let arcs_before = world.lightning_arcs.clone();
|
||||
let mut palette = [[0; 3]; 256];
|
||||
palette[1] = [24, 198, 255];
|
||||
palette[2] = [230, 255, 255];
|
||||
for scale in [1, 4] {
|
||||
for (alpha, player_point, enemy_point) in [
|
||||
(0.0, [160, 179], [86, 56]),
|
||||
(0.5, [165, 177], [88, 57]),
|
||||
(1.0, [170, 175], [90, 58]),
|
||||
] {
|
||||
let mut canvas = Canvas::new(scale);
|
||||
draw_world_lightning_arcs(&mut canvas, &world, &palette, alpha);
|
||||
// Point discharges have no directional noise: compare the complete
|
||||
// image with fixed discharges at the independently expected sprite
|
||||
// positions, including the endpoint that is not attached to a ship.
|
||||
let mut expected_arcs = arcs_before.clone();
|
||||
for (arc, [x, y]) in
|
||||
expected_arcs
|
||||
.iter_mut()
|
||||
.zip([player_point, enemy_point, [240, 80]])
|
||||
{
|
||||
arc.x = x;
|
||||
arc.y = y;
|
||||
arc.to_x = x;
|
||||
arc.to_y = y;
|
||||
arc.anchor = None;
|
||||
arc.to_anchor = None;
|
||||
}
|
||||
let mut expected = Canvas::new(scale);
|
||||
draw_lightning_arcs(&mut expected, &expected_arcs, &palette);
|
||||
assert_eq!(
|
||||
canvas.image.pixels, expected.image.pixels,
|
||||
"attached arc drift at scale {scale}, alpha {alpha}"
|
||||
);
|
||||
assert_eq!(world.digest(), before);
|
||||
assert_eq!(world.lightning_arcs, arcs_before);
|
||||
}
|
||||
}
|
||||
world
|
||||
.apply_commands(&[Command::Despawn { entity }], ®istry)
|
||||
.unwrap();
|
||||
let mut canvas = Canvas::new(4);
|
||||
draw_world_lightning_arcs(&mut canvas, &world, &palette, 0.0);
|
||||
let frozen = &world.lightning_arcs[1];
|
||||
assert!(frozen.anchor.is_none() && frozen.to_anchor.is_none());
|
||||
assert_ne!(
|
||||
canvas.image.pixels[frozen.y as usize * 4 * canvas.image.width + frozen.x as usize * 4],
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_then_despawned_anchors_freeze_at_the_last_command_position() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let entity = world
|
||||
.spawn(&ContentId::from("test:ground"), 70, 40, ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.apply_commands(
|
||||
&[
|
||||
anchored_arc(Some(0), Some(entity)),
|
||||
Command::Move {
|
||||
entity,
|
||||
x: 90,
|
||||
y: 70,
|
||||
},
|
||||
Command::Despawn { entity },
|
||||
],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
|
||||
(106, 86)
|
||||
);
|
||||
assert!(world.lightning_arcs[0].to_anchor.is_none());
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([120, 140]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut runtime,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[0].x, world.lightning_arcs[0].y),
|
||||
(136, 159)
|
||||
);
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
|
||||
(106, 86)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatically_retired_enemy_anchors_freeze_at_their_final_simulation_position() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let entity = world
|
||||
.spawn(&ContentId::from("test:ground"), 70, 240, ®istry)
|
||||
.unwrap();
|
||||
let command = serde_json::from_value(json!({
|
||||
"op":"arc","x":160,"y":160,"to_x":86,"to_y":256,
|
||||
"to_anchor":entity,"ticks":8
|
||||
}))
|
||||
.unwrap();
|
||||
world
|
||||
.apply_commands(
|
||||
&[
|
||||
command,
|
||||
Command::Velocity {
|
||||
entity,
|
||||
vx: 0,
|
||||
vy: 4,
|
||||
},
|
||||
],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert!(world.enemies.is_empty());
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
|
||||
(86, 260)
|
||||
);
|
||||
assert!(world.lightning_arcs[0].to_anchor.is_none());
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut runtime)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
|
||||
(86, 260)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_anchors_reject_the_entire_batch_and_do_not_accept_projectile_ids() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let before = world.digest();
|
||||
for command in [
|
||||
anchored_arc(Some(u64::MAX), None),
|
||||
anchored_arc(Some(0), Some(999)),
|
||||
] {
|
||||
let error = world
|
||||
.apply_commands(
|
||||
&[
|
||||
anchored_arc(Some(0), Some(0)),
|
||||
Command::Credits { amount: 50 },
|
||||
command,
|
||||
],
|
||||
®istry,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("arc anchor does not exist"));
|
||||
assert_eq!(world.digest(), before);
|
||||
assert!(world.lightning_arcs.is_empty());
|
||||
}
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Projectile {
|
||||
weapon: "test:selected".into(),
|
||||
x: 10,
|
||||
y: 10,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
hostile: false,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
let projectile = world.projectiles[0].entity;
|
||||
assert!(
|
||||
world
|
||||
.apply_commands(&[anchored_arc(None, Some(projectile))], ®istry)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<Command>(json!({
|
||||
"op":"arc","x":0,"y":0,"to_x":1,"to_y":1,"anchor":-1
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_anchor_callbacks_restore_already_moved_arcs_and_script_rng() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
world
|
||||
.apply_commands(&[anchored_arc(Some(0), Some(0))], ®istry)
|
||||
.unwrap();
|
||||
let before = world.digest();
|
||||
let arcs_before = world.lightning_arcs.clone();
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
runtime.add("test", "equipped.rhai", "").unwrap();
|
||||
runtime
|
||||
.add(
|
||||
"test",
|
||||
"selected.rhai",
|
||||
r#"fn on_fire(e,s,r) {
|
||||
#{commands:[#{op:"arc",x:e.x,y:e.y,to_x:10,to_y:10,anchor:0,to_anchor:999}],
|
||||
state:#{used:r[0]}}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let error = world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
absolute: Some([110, 130]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut runtime,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("arc anchor does not exist"));
|
||||
assert!(error.to_string().contains("test/selected.rhai::on_fire"));
|
||||
assert_eq!(world.digest(), before);
|
||||
assert_eq!(world.lightning_arcs, arcs_before);
|
||||
assert!(runtime.states.is_empty());
|
||||
assert!(runtime.rng.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn following_arcs_remain_cosmetic_and_clamp_extreme_endpoints_to_world_bounds() {
|
||||
let registry = registry();
|
||||
let mut world = world(®istry);
|
||||
let mut plain = world.clone();
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Arc {
|
||||
x: 32768,
|
||||
y: -32768,
|
||||
to_x: -32768,
|
||||
to_y: 32768,
|
||||
ticks: 8,
|
||||
anchor: Some(0),
|
||||
to_anchor: Some(0),
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.digest(), plain.digest());
|
||||
assert!(
|
||||
serde_json::to_value(&world)
|
||||
.unwrap()
|
||||
.get("lightning_arcs")
|
||||
.is_none()
|
||||
);
|
||||
let mut runtime = ScriptRuntime::new();
|
||||
let mut plain_runtime = ScriptRuntime::new();
|
||||
for input in [
|
||||
Input {
|
||||
absolute: Some([282, 160]),
|
||||
..Input::default()
|
||||
},
|
||||
Input {
|
||||
absolute: Some([5, 0]),
|
||||
..Input::default()
|
||||
},
|
||||
] {
|
||||
world.step(input, ®istry, &mut runtime).unwrap();
|
||||
plain.step(input, ®istry, &mut plain_runtime).unwrap();
|
||||
assert_eq!(world.digest(), plain.digest());
|
||||
assert_eq!(world.rng, plain.rng);
|
||||
assert!(world.lightning_arcs.iter().all(|arc| {
|
||||
[arc.x, arc.y, arc.to_x, arc.to_y]
|
||||
.iter()
|
||||
.all(|n| n.unsigned_abs() <= 32768)
|
||||
}));
|
||||
}
|
||||
assert_eq!(world.lightning_arcs[0].y, -32768);
|
||||
assert_eq!(world.lightning_arcs[0].to_x, -32768);
|
||||
}
|
||||
+68
-2
@@ -10,17 +10,19 @@ use raptor_replica::{
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const COMBAT_MODS: [&str; 6] = [
|
||||
const COMBAT_MODS: [&str; 8] = [
|
||||
"pulse_cannon",
|
||||
"seeker_swarm",
|
||||
"ion_lance",
|
||||
"lightning_cannon",
|
||||
"lightning_shield",
|
||||
"iron_coast_scout",
|
||||
"iron_coast_turret",
|
||||
"iron_coast_boss",
|
||||
];
|
||||
const TICKS: usize = 700;
|
||||
const HEALTH: i32 = 1_000_000;
|
||||
const WEAPONS: [&str; 15] = [
|
||||
const WEAPONS: [&str; 17] = [
|
||||
"base:forward_guns",
|
||||
"base:plasma_guns",
|
||||
"base:micro_missile",
|
||||
@@ -36,6 +38,8 @@ const WEAPONS: [&str; 15] = [
|
||||
"pulse_cannon:equipment",
|
||||
"seeker_swarm:equipment",
|
||||
"ion_lance:equipment",
|
||||
"lightning_cannon:equipment",
|
||||
"lightning_shield:equipment",
|
||||
];
|
||||
fn fixture(active: &mut ActiveMods) {
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
@@ -188,6 +192,40 @@ fn original_and_mod_weapons_report_controlled_damage_locally() {
|
||||
true,
|
||||
));
|
||||
}
|
||||
for item in ["lightning_cannon:equipment", "lightning_shield:equipment"] {
|
||||
for (scene, targets) in [
|
||||
("close_air", vec![("air", 188, 160)]),
|
||||
("close_ground", vec![("ground", 188, 160)]),
|
||||
(
|
||||
"chain",
|
||||
vec![
|
||||
("air", 144, 80),
|
||||
("ground", 184, 80),
|
||||
("air", 224, 80),
|
||||
("air", 264, 80),
|
||||
("air", 264, 120),
|
||||
],
|
||||
),
|
||||
(
|
||||
"close_cluster",
|
||||
vec![
|
||||
("air", 104, 160),
|
||||
("air", 184, 160),
|
||||
("air", 144, 120),
|
||||
("air", 144, 196),
|
||||
("air", 188, 160),
|
||||
],
|
||||
),
|
||||
] {
|
||||
rows.push(observe(&mut active, item, scene, &targets, false));
|
||||
}
|
||||
for (scene, targets) in [
|
||||
("aligned_air", vec![("air", 144, 80)]),
|
||||
("close_air", vec![("air", 188, 160)]),
|
||||
] {
|
||||
rows.push(observe(&mut active, item, scene, &targets, 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") {
|
||||
@@ -222,4 +260,32 @@ fn original_and_mod_weapons_report_controlled_damage_locally() {
|
||||
"ion beam should damage both column targets equally"
|
||||
);
|
||||
assert_eq!(row("ion_lance:equipment", "off_axis_air")["damage"][0], 0);
|
||||
assert_eq!(
|
||||
row("lightning_cannon:equipment", "off_axis_air")["damage"][0],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
row("lightning_cannon:equipment", "chain")["damage"],
|
||||
json!([1248, 780, 468, 312, 0])
|
||||
);
|
||||
assert_eq!(
|
||||
row("lightning_shield:equipment", "aligned_air")["damage"][0],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
row("lightning_shield:equipment", "close_air")["damage"][0],
|
||||
176
|
||||
);
|
||||
assert_eq!(
|
||||
row("lightning_shield:equipment", "close_ground")["damage"][0],
|
||||
0
|
||||
);
|
||||
let close = row("lightning_shield:equipment", "close_cluster")["damage"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert_eq!(close.iter().map(|v| v.as_i64().unwrap()).sum::<i64>(), 704);
|
||||
assert_eq!(
|
||||
close[4], 0,
|
||||
"a fifth ship stays outside the four-target pulse cap"
|
||||
);
|
||||
}
|
||||
+514
-7
@@ -9,7 +9,14 @@ use raptor_replica::{
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const PACKAGES: [&str; 3] = ["pulse_cannon", "seeker_swarm", "ion_lance"];
|
||||
const PROJECTILE_PACKAGES: [&str; 3] = ["pulse_cannon", "seeker_swarm", "ion_lance"];
|
||||
const PACKAGES: [&str; 5] = [
|
||||
"pulse_cannon",
|
||||
"seeker_swarm",
|
||||
"ion_lance",
|
||||
"lightning_cannon",
|
||||
"lightning_shield",
|
||||
];
|
||||
fn content(value: Value) -> ContentPack {
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
@@ -64,6 +71,9 @@ fn install_synthetic(root: &Path, id: &str) {
|
||||
.chain(definitions["items"].as_array().unwrap())
|
||||
{
|
||||
let art = &entry["art"];
|
||||
if art.is_null() {
|
||||
continue;
|
||||
}
|
||||
for (property, scale) in [("png", 1), ("hd_png", 4)] {
|
||||
let image = image::RgbaImage::from_pixel(
|
||||
art["logical_width"].as_u64().unwrap() as u32 * scale,
|
||||
@@ -114,7 +124,7 @@ fn state<'a>(active: &'a ActiveMods, id: &str) -> &'a Value {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_weapon_package_is_independent_and_the_three_can_coexist() {
|
||||
fn each_weapon_package_is_independent_and_all_five_can_coexist() {
|
||||
for id in PACKAGES {
|
||||
let (_root, active) = activate(&[id]);
|
||||
assert_eq!(active.pins.len(), 1);
|
||||
@@ -135,9 +145,9 @@ fn each_weapon_package_is_independent_and_the_three_can_coexist() {
|
||||
);
|
||||
}
|
||||
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_eq!(all.pins.len(), 5);
|
||||
assert_eq!(all.registry.weapons.len(), 5);
|
||||
assert_eq!(all.registry.sounds.len(), 10);
|
||||
assert!(all.registry.overrides.is_empty());
|
||||
}
|
||||
|
||||
@@ -190,7 +200,7 @@ fn pulse_cannon_emits_paired_rounds_and_every_third_burst_is_scripted() {
|
||||
|
||||
#[test]
|
||||
fn continuous_firing_has_audio_and_projectiles_without_explosions_at_the_muzzle() {
|
||||
for id in PACKAGES {
|
||||
for id in PROJECTILE_PACKAGES {
|
||||
let (_root, mut active) = activate(&[id]);
|
||||
let mut world = world(id, &active.registry);
|
||||
let mut heard_fire = false;
|
||||
@@ -347,7 +357,7 @@ fn equipped_specials_and_primary_upgrade_fire_without_consuming_equipment() {
|
||||
.states
|
||||
.contains_key("seeker_swarm/scripts/weapon.rhai")
|
||||
);
|
||||
for id in PACKAGES {
|
||||
for id in PROJECTILE_PACKAGES {
|
||||
assert_eq!(
|
||||
world.player.inventory[&format!("{id}:equipment").as_str().into()],
|
||||
1
|
||||
@@ -406,3 +416,500 @@ fn scripted_bursts_and_state_resume_deterministically() {
|
||||
serde_json::to_value(&other.scripts.rng).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
fn fire_once(world: &mut World, active: &mut ActiveMods) {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_cannon_chains_from_each_hit_with_falling_damage_and_a_four_target_cap() {
|
||||
let (_root, mut active) = activate(&["lightning_cannon"]);
|
||||
let mut world = world("lightning_cannon", &active.registry);
|
||||
for (enemy, x, y) in [
|
||||
("base:target", 144, 80),
|
||||
("base:ground_target", 184, 80),
|
||||
("base:target", 224, 80),
|
||||
("base:target", 264, 80),
|
||||
("base:target", 264, 120),
|
||||
("base:target", 32, 80),
|
||||
] {
|
||||
world.spawn(&enemy.into(), x, y, &active.registry).unwrap();
|
||||
}
|
||||
fire_once(&mut world, &mut active);
|
||||
assert_eq!(
|
||||
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
|
||||
[68, 80, 88, 92, 100, 100]
|
||||
);
|
||||
assert_eq!(state(&active, "lightning_cannon")["hits"], 4);
|
||||
assert_eq!(state(&active, "lightning_cannon")["bursts"], 1);
|
||||
assert!(world.projectiles.is_empty(), "no damaging carrier shot");
|
||||
assert!(
|
||||
world.effects.is_empty(),
|
||||
"electrical arcs are not explosions"
|
||||
);
|
||||
assert!(world.mod_sounds.contains(&"lightning_cannon:fire".into()));
|
||||
assert!(world.mod_sounds.contains(&"lightning_cannon:impact".into()));
|
||||
assert_eq!(world.player.shield, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_cannon_cannot_rehit_a_target_or_jump_across_a_gap() {
|
||||
let (_root, mut active) = activate(&["lightning_cannon"]);
|
||||
let mut world = world("lightning_cannon", &active.registry);
|
||||
for x in [144, 184, 234] {
|
||||
world
|
||||
.spawn(&"base:target".into(), x, 80, &active.registry)
|
||||
.unwrap();
|
||||
}
|
||||
// The initial victim dies, but its position still launches the next arc.
|
||||
world.enemies[0].health = 10;
|
||||
fire_once(&mut world, &mut active);
|
||||
assert_eq!(
|
||||
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
|
||||
[-22, 80, 100]
|
||||
);
|
||||
assert_eq!(state(&active, "lightning_cannon")["hits"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_cannon_requires_forward_aim_and_respects_its_cooldown() {
|
||||
let (_root, mut active) = activate(&["lightning_cannon"]);
|
||||
let mut world = world("lightning_cannon", &active.registry);
|
||||
for (x, y) in [(190, 80), (144, 196)] {
|
||||
world
|
||||
.spawn(&"base:target".into(), x, y, &active.registry)
|
||||
.unwrap();
|
||||
}
|
||||
fire_once(&mut world, &mut active);
|
||||
assert!(world.enemies.iter().all(|e| e.health == 100));
|
||||
assert_eq!(state(&active, "lightning_cannon")["hits"], 0);
|
||||
assert!(world.mod_sounds.contains(&"lightning_cannon:fire".into()));
|
||||
assert!(!world.mod_sounds.contains(&"lightning_cannon:impact".into()));
|
||||
let aligned = world
|
||||
.spawn(&"base:target".into(), 144, 80, &active.registry)
|
||||
.unwrap();
|
||||
for _ in 0..17 {
|
||||
fire_once(&mut world, &mut active);
|
||||
}
|
||||
assert_eq!(
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == aligned)
|
||||
.unwrap()
|
||||
.health,
|
||||
100
|
||||
);
|
||||
assert_eq!(state(&active, "lightning_cannon")["bursts"], 1);
|
||||
fire_once(&mut world, &mut active);
|
||||
assert_eq!(
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == aligned)
|
||||
.unwrap()
|
||||
.health,
|
||||
68
|
||||
);
|
||||
assert_eq!(state(&active, "lightning_cannon")["bursts"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_shield_zaps_close_aircraft_in_all_directions_only_while_firing() {
|
||||
let (_root, mut active) = activate(&["lightning_shield"]);
|
||||
let mut world = world("lightning_shield", &active.registry);
|
||||
for (enemy, x, y) in [
|
||||
("base:target", 104, 160),
|
||||
("base:target", 184, 160),
|
||||
("base:target", 144, 120),
|
||||
("base:target", 144, 196),
|
||||
("base:target", 192, 160),
|
||||
("base:ground_target", 184, 160),
|
||||
] {
|
||||
world.spawn(&enemy.into(), x, y, &active.registry).unwrap();
|
||||
}
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert!(world.enemies.iter().all(|e| e.health == 100));
|
||||
assert!(
|
||||
!active
|
||||
.scripts
|
||||
.states
|
||||
.contains_key("lightning_shield/scripts/weapon.rhai")
|
||||
);
|
||||
assert!(world.player.selected.is_none());
|
||||
assert!(world.lightning_arcs.is_empty());
|
||||
fire_once(&mut world, &mut active);
|
||||
assert_eq!(
|
||||
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
|
||||
[98, 98, 98, 98, 100, 100]
|
||||
);
|
||||
assert_eq!(state(&active, "lightning_shield")["hits"], 4);
|
||||
assert!(world.mod_sounds.contains(&"lightning_shield:fire".into()));
|
||||
assert_eq!(
|
||||
world.player.inventory[&"lightning_shield:equipment".into()],
|
||||
1
|
||||
);
|
||||
assert_eq!(world.player.shield, 100);
|
||||
assert!(world.projectiles.is_empty());
|
||||
assert!(world.effects.is_empty());
|
||||
let center = (world.player.x + 16, world.player.y + 16);
|
||||
let zaps: Vec<_> = world
|
||||
.lightning_arcs
|
||||
.iter()
|
||||
.filter(|arc| arc.ticks == 3)
|
||||
.collect();
|
||||
assert_eq!(zaps.len(), 4);
|
||||
for arc in zaps {
|
||||
let radius_squared = (arc.x - center.0).pow(2) + (arc.y - center.1).pow(2);
|
||||
assert!((18 * 18..=20 * 20).contains(&radius_squared));
|
||||
assert_eq!(arc.anchor.as_ref().unwrap().entity, 0);
|
||||
assert_ne!(arc.to_anchor.as_ref().unwrap().entity, 0);
|
||||
}
|
||||
for _ in 0..7 {
|
||||
fire_once(&mut world, &mut active);
|
||||
}
|
||||
assert_eq!(state(&active, "lightning_shield")["bursts"], 1);
|
||||
fire_once(&mut world, &mut active);
|
||||
assert_eq!(state(&active, "lightning_shield")["bursts"], 2);
|
||||
assert_eq!(state(&active, "lightning_shield")["hits"], 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_shield_has_a_strict_range_boundary_and_a_bounded_pulse() {
|
||||
let (_root, mut active) = activate(&["lightning_shield"]);
|
||||
let mut world = world("lightning_shield", &active.registry);
|
||||
// Centers are far away, but the hull is exactly 28px from the pilot center.
|
||||
world
|
||||
.spawn(&"base:target".into(), 188, 160, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.spawn(&"base:target".into(), 189, 160, &active.registry)
|
||||
.unwrap();
|
||||
fire_once(&mut world, &mut active);
|
||||
assert_eq!(
|
||||
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
|
||||
[98, 100]
|
||||
);
|
||||
for _ in 0..4 {
|
||||
world
|
||||
.spawn(&"base:target".into(), 184, 160, &active.registry)
|
||||
.unwrap();
|
||||
}
|
||||
for _ in 0..8 {
|
||||
fire_once(&mut world, &mut active);
|
||||
}
|
||||
assert_eq!(state(&active, "lightning_shield")["hits"], 5);
|
||||
assert_eq!(world.enemies[0].health, 98);
|
||||
assert_eq!(world.enemies[1].health, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_shield_is_quiet_when_no_aircraft_are_close_and_when_sold() {
|
||||
let (_root, mut active) = activate(&["lightning_shield"]);
|
||||
let mut world = world("lightning_shield", &active.registry);
|
||||
world
|
||||
.spawn(&"base:ground_target".into(), 184, 160, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.spawn(&"base:target".into(), 144, 80, &active.registry)
|
||||
.unwrap();
|
||||
fire_once(&mut world, &mut active);
|
||||
assert_eq!(state(&active, "lightning_shield")["hits"], 0);
|
||||
assert!(world.mod_sounds.is_empty());
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.remove(&"lightning_shield:equipment".into());
|
||||
world
|
||||
.spawn(&"base:target".into(), 184, 160, &active.registry)
|
||||
.unwrap();
|
||||
for _ in 0..10 {
|
||||
fire_once(&mut world, &mut active);
|
||||
}
|
||||
assert!(world.enemies.iter().all(|e| e.health == 100));
|
||||
assert_eq!(state(&active, "lightning_shield")["bursts"], 1);
|
||||
assert!(world.lightning_arcs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_shield_halo_closes_around_hull_follows_movement_and_expires_after_firing() {
|
||||
let (_root, mut active) = activate(&["lightning_shield"]);
|
||||
let mut world = world("lightning_shield", &active.registry);
|
||||
fire_once(&mut world, &mut active);
|
||||
let ring = world.lightning_arcs.clone();
|
||||
assert_eq!(ring.len(), 16);
|
||||
assert!(world.mod_sounds.is_empty());
|
||||
assert_eq!(state(&active, "lightning_shield")["hits"], 0);
|
||||
let center = (world.player.x + 16, world.player.y + 16);
|
||||
for (i, arc) in ring.iter().enumerate() {
|
||||
assert_eq!(
|
||||
(arc.to_x, arc.to_y),
|
||||
(ring[(i + 1) % ring.len()].x, ring[(i + 1) % ring.len()].y)
|
||||
);
|
||||
let radius_squared = (arc.x - center.0).pow(2) + (arc.y - center.1).pow(2);
|
||||
assert!((18 * 18..=20 * 20).contains(&radius_squared));
|
||||
assert_eq!(arc.anchor.as_ref().unwrap().entity, 0);
|
||||
assert_eq!(arc.to_anchor.as_ref().unwrap().entity, 0);
|
||||
}
|
||||
let old_position = (world.player.x, world.player.y);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
dx: 1,
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
let movement = (
|
||||
world.player.x - old_position.0,
|
||||
world.player.y - old_position.1,
|
||||
);
|
||||
assert_ne!(movement, (0, 0));
|
||||
for (old, arc) in ring.iter().zip(&world.lightning_arcs) {
|
||||
assert_eq!((arc.x - old.x, arc.y - old.y), movement);
|
||||
assert_eq!((arc.to_x - old.to_x, arc.to_y - old.to_y), movement);
|
||||
}
|
||||
for _ in 0..8 {
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(world.lightning_arcs.is_empty());
|
||||
assert_eq!(state(&active, "lightning_shield")["bursts"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_shield_fires_with_other_primaries_and_the_selected_cannon() {
|
||||
let (_root, mut active) = activate(&PACKAGES);
|
||||
let mut pilot = pilot("lightning_cannon", &active.registry);
|
||||
pilot
|
||||
.inventory
|
||||
.insert("lightning_shield:equipment".into(), 1);
|
||||
pilot.inventory.insert("pulse_cannon:equipment".into(), 1);
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.spawn(&"base:target".into(), 144, 80, &active.registry)
|
||||
.unwrap();
|
||||
world
|
||||
.spawn(&"base:target".into(), 184, 160, &active.registry)
|
||||
.unwrap();
|
||||
fire_once(&mut world, &mut active);
|
||||
for id in ["lightning_cannon", "lightning_shield", "pulse_cannon"] {
|
||||
assert_eq!(state(&active, id)["bursts"], 1);
|
||||
assert_eq!(
|
||||
world.player.inventory[&format!("{id}:equipment").as_str().into()],
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(world.enemies[0].health, 68);
|
||||
assert_eq!(world.enemies[1].health, 98);
|
||||
assert_eq!(world.projectiles.len(), 2);
|
||||
world.player.selected = Some("ion_lance:equipment".into());
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.insert("ion_lance:equipment".into(), 1);
|
||||
for _ in 0..8 {
|
||||
fire_once(&mut world, &mut active);
|
||||
}
|
||||
assert_eq!(state(&active, "lightning_shield")["bursts"], 2);
|
||||
assert_eq!(state(&active, "lightning_cannon")["bursts"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_loadout_state_and_damage_resume_deterministically() {
|
||||
let ids = ["lightning_cannon", "lightning_shield"];
|
||||
let (_root, mut active) = activate(&ids);
|
||||
let mut pilot = pilot("lightning_cannon", &active.registry);
|
||||
pilot
|
||||
.inventory
|
||||
.insert("lightning_shield:equipment".into(), 1);
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
for (x, y) in [(144, 80), (184, 80), (184, 160)] {
|
||||
world
|
||||
.spawn(&"base:target".into(), x, y, &active.registry)
|
||||
.unwrap();
|
||||
}
|
||||
for _ in 0..10 {
|
||||
fire_once(&mut world, &mut active);
|
||||
}
|
||||
let mut resumed = world.clone();
|
||||
let (_other_root, mut other) = activate(&ids);
|
||||
other.scripts.states = active.scripts.states.clone();
|
||||
other.scripts.rng = active.scripts.rng.clone();
|
||||
for tick in 0..24 {
|
||||
let input = Input {
|
||||
fire: tick % 3 != 0,
|
||||
..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()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original local palette/player artwork; writes ignored SD/HD visual proofs"]
|
||||
fn lightning_mods_render_real_icons_and_discharge_arcs_in_sd_and_hd() {
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, Sprite},
|
||||
render::{BitmapFont, Canvas, draw_lightning_arcs, nearest},
|
||||
};
|
||||
let package = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let data = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| package.join("../data"));
|
||||
let catalog = Catalog::open(data).unwrap();
|
||||
let palette = catalog.palette().unwrap();
|
||||
let font = BitmapFont::decode(&catalog.named("SYSTEM_FNT").unwrap()).unwrap();
|
||||
let player = catalog
|
||||
.sprite(catalog.id("LPLAYER_PIC").unwrap() + 3)
|
||||
.unwrap();
|
||||
let discovery = modding::discover(&package.join("mods"));
|
||||
let output = package.join("../.reference/lightning-mods");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
for id in ["lightning_cannon", "lightning_shield"] {
|
||||
let mut active =
|
||||
ActiveMods::build(&base(), &discovery, &[id.to_string()], Some(&catalog)).unwrap();
|
||||
let mut world = world(id, &active.registry);
|
||||
let positions = if id == "lightning_cannon" {
|
||||
vec![(144, 80), (184, 80), (224, 80), (264, 80)]
|
||||
} else {
|
||||
vec![(104, 160), (184, 160), (144, 120), (144, 196)]
|
||||
};
|
||||
for (x, y) in positions {
|
||||
world
|
||||
.spawn(&"base:target".into(), x, y, &active.registry)
|
||||
.unwrap();
|
||||
}
|
||||
fire_once(&mut world, &mut active);
|
||||
assert_eq!(
|
||||
world.lightning_arcs.len(),
|
||||
if id == "lightning_shield" { 20 } else { 4 }
|
||||
);
|
||||
let digest = world.digest();
|
||||
let script_rng = serde_json::to_value(&active.scripts.rng).unwrap();
|
||||
for (mode, scale) in [("sd", 1), ("hd", 4)] {
|
||||
let mut canvas = Canvas::new(scale);
|
||||
canvas.clear(0);
|
||||
font.text(
|
||||
&mut canvas,
|
||||
8,
|
||||
8,
|
||||
&format!(
|
||||
"{} / {mode}",
|
||||
active.registry.items[&format!("{id}:equipment").as_str().into()].name
|
||||
),
|
||||
8,
|
||||
);
|
||||
let equipment = Sprite::png(
|
||||
&package
|
||||
.join("mods")
|
||||
.join(id)
|
||||
.join(format!("art/equipment-{mode}.png")),
|
||||
&palette,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
equipment
|
||||
.pixels
|
||||
.iter()
|
||||
.all(|pixel| *pixel < 240 || *pixel == raptor_replica::assets::TRANSPARENT)
|
||||
);
|
||||
canvas.blit(&equipment, 8 * scale as i32, 28 * scale as i32);
|
||||
let price = active.registry.items[&format!("{id}:equipment").as_str().into()].price;
|
||||
font.text(&mut canvas, 8, 48, &format!("${price}"), 8);
|
||||
let unit = Sprite::png(
|
||||
&package.join(format!("mods/iron_coast_scout/art/unit-{mode}.png")),
|
||||
&palette,
|
||||
)
|
||||
.unwrap();
|
||||
// The scout's HD master is 8x; fit to the same 24px logical canvas
|
||||
// as the game renderer rather than drawing the raw master size.
|
||||
let mut fitted_unit = Sprite::blank(24 * scale, 24 * scale);
|
||||
for y in 0..fitted_unit.height {
|
||||
for x in 0..fitted_unit.width {
|
||||
fitted_unit.pixels[y * fitted_unit.width + x] =
|
||||
unit.pixels[(y * unit.height / fitted_unit.height) * unit.width
|
||||
+ x * unit.width / fitted_unit.width];
|
||||
}
|
||||
}
|
||||
for enemy in &world.enemies {
|
||||
canvas.blit(
|
||||
&fitted_unit,
|
||||
(enemy.x + 4) * scale as i32,
|
||||
(enemy.y + 4) * scale as i32,
|
||||
);
|
||||
}
|
||||
canvas.blit(
|
||||
&nearest(&player, scale),
|
||||
world.player.x * scale as i32,
|
||||
world.player.y * scale as i32,
|
||||
);
|
||||
let scene = canvas.image.clone();
|
||||
// Show successive discharges at their actual firing cadence, not
|
||||
// just a single still: the cosmetic seed changes between bursts.
|
||||
let weapon = active.registry.items[&format!("{id}:equipment").as_str().into()]
|
||||
.weapon
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let cooldown = active.registry.weapons[weapon].cooldown;
|
||||
for frame in 0..cooldown * 3 {
|
||||
canvas.image = scene.clone();
|
||||
let mut arcs = world.lightning_arcs.clone();
|
||||
for arc in &mut arcs {
|
||||
arc.age = frame % cooldown;
|
||||
arc.seed = arc
|
||||
.seed
|
||||
.wrapping_add((frame / cooldown * cooldown).wrapping_mul(0x1656_67b1));
|
||||
}
|
||||
draw_lightning_arcs(&mut canvas, &arcs, &palette);
|
||||
image::save_buffer(
|
||||
output.join(format!("{id}-{mode}-{frame:02}.png")),
|
||||
&canvas.rgba(&palette, false),
|
||||
canvas.image.width as u32,
|
||||
canvas.image.height as u32,
|
||||
image::ColorType::Rgba8,
|
||||
)
|
||||
.unwrap();
|
||||
if frame == 0 {
|
||||
image::save_buffer(
|
||||
output.join(format!("{id}-{mode}.png")),
|
||||
&canvas.rgba(&palette, false),
|
||||
canvas.image.width as u32,
|
||||
canvas.image.height as u32,
|
||||
image::ColorType::Rgba8,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(world.digest(), digest);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&active.scripts.rng).unwrap(),
|
||||
script_rng
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user