Update gameplay acceptance tests for the Iron Coast retune

This commit is contained in:
raptorreplica committed 2026-10-17 18:33:46 +11:00
1 parent c1bbf2ea2e
commit 587235471e
6 files changed
+263 -128

No files matched your search

+7
View File
@@ -72,6 +72,13 @@ save-pin roundtrips at every difficulty. Gameplay measurements use the frozen
runtime definitions/scripts; the final version-number, README and blank-line runtime definitions/scripts; the final version-number, README and blank-line
whitespace corrections do not change gameplay tokens. whitespace corrections do not change gameplay tokens.
Seven maintained campaign, enemy and weapon test suites pass against the same
runtime: 65 tests passed, none failed and four existing integration tests remain
ignored. Updated fixtures check the revised attack clocks, offscreen firing
suppression, reward-free aircraft exits and short contact effects. Synthetic
native-enemy placeholders in portable mission tests are explicitly labelled;
the original-catalog playthrough checks above cover actual native enemies.
## Enemy silhouettes and attacks ## Enemy silhouettes and attacks
Scouts and interceptors retain 24×24 logical bodies; turrets and siege units Scouts and interceptors retain 24×24 logical bodies; turrets and siege units
+115 -61
View File
@@ -120,7 +120,10 @@ fn all_enemy_packages_validate_independently_and_together() {
.weapons .weapons
.contains_key(&ContentId::new(format!("{id}:projectile")).unwrap()) .contains_key(&ContentId::new(format!("{id}:projectile")).unwrap())
); );
assert_eq!(a.registry.sounds.len(), 2); assert_eq!(
a.registry.sounds.len(),
if id == "iron_coast_boss" { 3 } else { 2 }
);
assert_eq!( assert_eq!(
a.registry.enemies[&ContentId::new(format!("{id}:unit")).unwrap()] a.registry.enemies[&ContentId::new(format!("{id}:unit")).unwrap()]
.script .script
@@ -135,42 +138,70 @@ fn all_enemy_packages_validate_independently_and_together() {
} }
#[test] #[test]
fn scout_weaves_in_both_directions_and_aims_hostile_fire() { fn scout_enters_attacks_once_and_exits_without_kill_rewards() {
// Each approach has one lateral leg, rather than the old endless weave.
for (x, direction) in [(120, 1), (200, -1)] {
let (_temp, mut a) = active(&["iron_coast_scout"]); let (_temp, mut a) = active(&["iron_coast_scout"]);
let mut w = world(&a); let mut w = world(&a);
w.player.x = 0; w.player.x = 0;
w.spawn( w.spawn(&"iron_coast_scout:unit".into(), x, -24, &a.registry)
&ContentId::from("iron_coast_scout:unit"),
120,
0,
&a.registry,
)
.unwrap(); .unwrap();
let mut right = false; let mut speeds = std::collections::BTreeSet::new();
let mut left = false; let mut lateral = false;
let mut sounds = 0; let mut aimed_left = false;
for _ in 0..95 { for _ in 0..200 {
w.step(Input::default(), &a.registry, &mut a.scripts) w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap(); .unwrap();
let e = &w.enemies[0]; for p in w.projectiles.iter().filter(|p| p.age == 0 && p.hostile) {
right |= e.vx > 0; aimed_left |= p.vx < 0;
left |= e.vx < 0;
assert!(e.x >= 24 && e.x <= 272);
assert_eq!(e.vy, 1);
sounds += w
.mod_sounds
.iter()
.filter(|id| id.0 == "iron_coast_scout:shot")
.count();
} }
assert!(right && left); w.projectiles.clear(); // Isolate the approach, not player survival.
assert_eq!(w.enemies[0].y, 94); let Some(e) = w.enemies.first() else { break };
assert!(sounds >= 2); speeds.insert(e.vy);
lateral |= e.vx.signum() == direction;
}
assert_eq!(speeds, [1, 2, 3].into_iter().collect());
assert!(lateral && aimed_left);
assert!( assert!(
w.projectiles w.enemies.is_empty(),
.iter() "the scout must finish its attack pass"
.any(|p| p.hostile && p.weapon.0 == "iron_coast_scout:projectile" && p.vx < 0)
); );
assert_eq!(w.player.credits, 0);
assert!(!w.objectives.contains_key("iron_coast_scout:defeated"));
}
}
#[test]
fn light_enemies_do_not_fire_when_the_actor_is_not_fully_visible() {
for id in ["iron_coast_scout", "iron_coast_turret"] {
// Partially offscreen, above the viewport, and below the attack window.
// Keep x inside the natural-exit boundary so the same actor is observed.
for (x, y) in [(-1, 40), (40, -40), (40, 180)] {
let (_temp, mut a) = active(&[id]);
let mut w = world(&a);
w.spawn(&format!("{id}:unit").as_str().into(), x, y, &a.registry)
.unwrap();
for _ in 0..80 {
w.enemies[0].x = x;
w.enemies[0].y = y;
// Movement precedes the refreshed actor snapshot. Hold the
// fixture outside so its prior velocity cannot carry it into view.
w.enemies[0].vx = 0;
w.enemies[0].vy = 0;
w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap();
assert!(
w.projectiles.is_empty(),
"{id} fired outside its attack window"
);
assert!(
!w.mod_sounds
.iter()
.any(|sound| sound.0 == format!("{id}:shot"))
);
}
}
}
} }
#[test] #[test]
@@ -197,7 +228,7 @@ fn turret_scrolls_with_terrain_and_aims_at_player() {
} }
assert_eq!((w.enemies[0].x, w.enemies[0].y), (220, 80)); assert_eq!((w.enemies[0].x, w.enemies[0].y), (220, 80));
assert!(a.registry.enemies[&w.enemies[0].definition].ground); assert!(a.registry.enemies[&w.enemies[0].definition].ground);
assert_eq!(sounds, 2); assert_eq!(sounds, 1);
assert!( assert!(
w.projectiles w.projectiles
.iter() .iter()
@@ -206,48 +237,73 @@ fn turret_scrolls_with_terrain_and_aims_at_player() {
} }
#[test] #[test]
fn boss_enters_patrols_fires_fans_and_remains_on_screen() { fn boss_telegraphs_three_patterns_and_recovers_on_screen_at_every_difficulty() {
for difficulty in 0..4 {
let (_temp, mut a) = active(&["iron_coast_boss"]); let (_temp, mut a) = active(&["iron_coast_boss"]);
let mut w = world(&a); let mut w = world(&a);
w.difficulty = difficulty;
w.player.x = 0; w.player.x = 0;
w.spawn( w.spawn(&"iron_coast_boss:unit".into(), 112, -64, &a.registry)
&ContentId::from("iron_coast_boss:unit"),
112,
-64,
&a.registry,
)
.unwrap(); .unwrap();
let mut left = false; let mut directions = std::collections::BTreeSet::new();
let mut right = false; let mut warnings = Vec::new();
let mut fans = 0; let mut shots = Vec::new();
for tick in 0..1500 { for tick in 0..500 {
w.step(Input::default(), &a.registry, &mut a.scripts) w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap(); .unwrap();
assert_eq!(w.outcome, Outcome::Playing); assert_eq!(w.outcome, Outcome::Playing);
assert_eq!(w.enemies.len(), 1, "boss retired at{tick}"); assert_eq!(w.enemies.len(), 1, "boss retired at {tick}");
let e = &w.enemies[0]; let e = &w.enemies[0];
assert_eq!((e.width, e.height), (96, 64)); assert_eq!((e.width, e.height), (84, 56));
if tick > 46 { if tick > 46 {
assert_eq!(e.y, 24); assert_eq!(e.y, 24);
assert!((80..=144).contains(&e.x)); assert!((32..=204).contains(&e.x));
left |= e.vx < 0; directions.insert(e.vx.signum());
right |= e.vx > 0; }
if w.mod_sounds.contains(&"iron_coast_boss:warning".into()) {
warnings.push(tick);
assert_eq!(e.vx, 0, "warning is a stationary hit window");
assert!(!w.lightning_arcs.is_empty());
} }
if w.mod_sounds
.contains(&ContentId::from("iron_coast_boss:shot"))
{
let bullets: Vec<_> = w let bullets: Vec<_> = w
.projectiles .projectiles
.iter() .iter()
.filter(|p| p.age == 0 && p.hostile) .filter(|p| p.age == 0 && p.hostile)
.collect(); .collect();
assert_eq!(bullets.len(), 3); if w.mod_sounds.contains(&"iron_coast_boss:shot".into()) {
assert_eq!(bullets.iter().map(|p| p.vx).collect::<Vec<_>>(), [-2, 0, 2]); assert_eq!(tick - warnings.last().unwrap(), 18 - difficulty * 2);
fans += 1; assert_eq!(e.vx, 0);
let pattern = shots.len() % 3;
assert_eq!(bullets.len(), [3, 2, 1][pattern]);
if pattern == 0 {
assert_eq!(bullets.iter().map(|p| p.vx).collect::<Vec<_>>(), [-1, 0, 1]);
} else if pattern == 1 {
assert!(bullets.iter().all(|p| p.vx == 0));
} else {
assert!(
bullets[0].vx < 0,
"aimed pattern follows the locked left lane"
);
} }
assert!(
bullets
.iter()
.all(|p| p.vy == if difficulty >= 2 { 4 } else { 3 })
);
shots.push(tick);
} else {
assert!(bullets.is_empty(), "no untelegraphed salvo");
}
w.projectiles.clear(); // Separate clock/pattern coverage from the starter-gun survival test.
}
assert_eq!(directions, [-1, 0, 1].into_iter().collect());
assert!(shots.len() >= 4);
assert!(
warnings
.windows(2)
.all(|pair| pair[1] - pair[0] == 96 - difficulty * 6)
);
} }
assert!(left && right);
assert!(fans >= 25);
} }
#[test] #[test]
@@ -407,14 +463,12 @@ fn mission_defeat_counts_reset_while_persistent_script_statistics_continue() {
assert_eq!(next.objectives.get(&format!("{id}:defeated")), Some(&1)); assert_eq!(next.objectives.get(&format!("{id}:defeated")), Some(&1));
let state = a.scripts.checkpoints().0; let state = a.scripts.checkpoints().0;
assert_eq!(state[&format!("{id}/scripts/ai.rhai")]["destroyed"], 3); assert_eq!(state[&format!("{id}/scripts/ai.rhai")]["destroyed"], 3);
if id == "iron_coast_boss" { // The next tick must not resurrect or fire on behalf of a dead actor.
assert!( next.step(Input::default(), &a.registry, &mut a.scripts)
state[&format!("{id}/scripts/ai.rhai")]["directions"] .unwrap();
.as_object() assert!(next.enemies.is_empty());
.unwrap() assert!(next.projectiles.is_empty());
.is_empty() assert_eq!(next.objectives[&format!("{id}:defeated")], 1);
);
}
} }
} }
+9 -2
View File
@@ -1,4 +1,5 @@
//! Campaign acceptance uses synthetic PNG/WAV assets; original data is unnecessary. //! Scripted mission acceptance uses synthetic PNG/WAV and native-ID placeholders.
//! This does not validate original-catalog enemy paths, guns, or native-wave balance.
use raptor_replica::{ use raptor_replica::{
content::{ContentPack, Registry}, content::{ContentPack, Registry},
modding::{self, ActiveMods, Manifest}, modding::{self, ActiveMods, Manifest},
@@ -33,6 +34,12 @@ fn base() -> Registry {
for n in 0..18 { for n in 0..18 {
value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1})); value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1}));
} }
// Native wave references were added by the retune. Keep these portable
// fixtures explicitly synthetic; real native behavior belongs to catalog tests.
value["enemies"] = json!([21, 22, 73, 74].map(|id| json!({
"id":format!("base:enemy_0_{id}"), "name":"Synthetic native-ID placeholder",
"health":1, "speed":1
})));
let pack: ContentPack = serde_json::from_value(value).unwrap(); let pack: ContentPack = serde_json::from_value(value).unwrap();
registry.apply(pack, "base").unwrap(); registry.apply(pack, "base").unwrap();
registry.validate().unwrap(); registry.validate().unwrap();
@@ -336,7 +343,7 @@ fn restarted_mission_counts_new_turret_kills_independently_of_saved_statistics()
} }
#[test] #[test]
fn starter_guns_can_complete_real_waves_and_boss() { fn starter_guns_complete_scripted_waves_and_boss_with_synthetic_native_fillers() {
let (world, seen) = play(false); let (world, seen) = play(false);
assert_eq!( assert_eq!(
world.outcome, world.outcome,
+17 -9
View File
@@ -221,21 +221,26 @@ fn railburst_contact_penetrates_only_three_aligned_targets_with_falling_damage()
world.effects.is_empty(), world.effects.is_empty(),
"no muzzle explosion before real contact" "no muzzle explosion before real contact"
); );
let mut saw_contact_arc = false;
for _ in 0..3 { for _ in 0..3 {
step(&mut world, &mut active, false); step(&mut world, &mut active, false);
saw_contact_arc |= !world.lightning_arcs.is_empty();
assert!(world.lightning_arcs.iter().all(|arc| arc.ticks == 2));
assert!(
world.effects.is_empty(),
"rail contacts use short arcs, not explosions"
);
} }
assert_eq!(damage(&world, &ids), [42, 32, 22, 0, 0]); assert_eq!(damage(&world, &ids), [42, 32, 22, 0, 0]);
assert_eq!( assert_eq!(
active.scripts.states["iron_coast_railburst/scripts/weapon.rhai"]["hits"], active.scripts.states["iron_coast_railburst/scripts/weapon.rhai"]["hits"],
3 3
); );
assert!(world.effects.iter().any(|e| e.kind == EffectKind::SmallAir)); assert!(saw_contact_arc);
assert!( for _ in 0..3 {
world step(&mut world, &mut active, false);
.effects }
.iter() assert!(world.lightning_arcs.is_empty());
.any(|e| e.kind == EffectKind::SmallGround)
);
assert_eq!( assert_eq!(
world.player.inventory[&"iron_coast_railburst:equipment".into()], world.player.inventory[&"iron_coast_railburst:equipment".into()],
1 1
@@ -266,8 +271,11 @@ fn depth_charge_has_contact_only_ground_splash_falloff_radius_and_neighbor_cap()
} }
assert_eq!(damage(&world, &ids), [72, 36, 20, 20, 0, 0, 0]); assert_eq!(damage(&world, &ids), [72, 36, 20, 20, 0, 0, 0]);
assert_eq!(world.effects.len(), 1); assert_eq!(world.effects.len(), 1);
assert_eq!(world.effects[0].kind, EffectKind::GroundBlast); assert_eq!(world.effects[0].kind, EffectKind::SmallAir);
assert!(world.effects[0].ground); assert!(
!world.effects[0].ground,
"compact presentation does not change ground-only damage"
);
assert_eq!( assert_eq!(
active.scripts.states["iron_coast_depth_charge/scripts/weapon.rhai"]["hits"], active.scripts.states["iron_coast_depth_charge/scripts/weapon.rhai"]["hits"],
4 4
+84 -50
View File
@@ -140,65 +140,80 @@ fn stage2_packages_activate_independently_and_coexist_with_stage1() {
assert!(a.registry.overrides.is_empty()); assert!(a.registry.overrides.is_empty());
} }
#[test] #[test]
fn stage2_engine_cooldowns_produce_finite_bursts_and_warnings() { fn stage2_engine_difficulty_clocks_produce_finite_locked_bursts_and_warnings() {
let periods = [[66, 56, 48, 42], [126, 108, 94, 82], [112, 96, 82, 72]];
let bursts = [[1, 2, 2, 3], [1, 1, 2, 2], [1, 1, 2, 2]];
let windups = [[8, 7, 7, 6], [18, 17, 16, 14], [13, 12, 11, 10]];
let spacing = [4, 8, 6];
for (idx, id) in IDS.iter().enumerate() { for (idx, id) in IDS.iter().enumerate() {
for difficulty in 0..4 {
let (_t, mut a) = active(&[*id]); let (_t, mut a) = active(&[*id]);
let mut w = world(&a); let mut w = world(&a);
w.difficulty = difficulty as u32;
w.player.x = 240; w.player.x = 240;
spawn(&mut w, &a, id, 100, 0); spawn(&mut w, &a, id, 100, 40);
let mut shots = Vec::new(); let mut groups: Vec<Vec<(usize, i32, i32)>> = Vec::new();
let mut warnings = Vec::new(); let mut warnings = Vec::new();
for tick in 0..200 { for tick in 0..320 {
let e = &mut w.enemies[0]; // A visible range fixture; movement/escape and offscreen suppression are tested separately.
e.x = 100; w.enemies[0].x = 100;
e.y = 0; w.enemies[0].y = 40;
w.step(Input::default(), &a.registry, &mut a.scripts) w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap(); .unwrap();
let new = w let new: Vec<_> = w.projectiles.iter().filter(|p| p.age == 0).collect();
.projectiles assert!(
.iter() new.len() <= 1,
.filter(|p| p.age == 0) "{id} must space the burst, not stack shells"
.collect::<Vec<_>>(); );
if !new.is_empty() { for p in new {
assert!(new.iter().all(|p| p.hostile assert!(
p.hostile
&& p.weapon.0 == format!("{id}:projectile") && p.weapon.0 == format!("{id}:projectile")
&& p.vy >= 1 && p.vy >= 1
&& p.vx.abs() <= 3)); && p.vx.abs() <= 5
shots.push((tick, new.len())); );
if groups
.last()
.is_none_or(|g| tick - g.last().unwrap().0 != spacing[idx])
{
groups.push(Vec::new());
}
groups.last_mut().unwrap().push((tick, p.vx, p.vy));
// Subsequent shots in this salvo must retain its original aim.
w.player.x = if w.player.x == 240 { 0 } else { 240 };
} }
if w.mod_sounds.iter().any(|s| s.0 == format!("{id}:warning")) { if w.mod_sounds.iter().any(|s| s.0 == format!("{id}:warning")) {
warnings.push(tick); warnings.push(tick);
} }
assert_eq!(w.enemies.len(), 1, "AI must never spawn extra enemies"); assert_eq!(w.enemies.len(), 1);
assert!(w.projectiles.len() < 32); w.projectiles.clear();
} }
let expected = match idx { assert!(groups.len() >= 2, "{id} difficulty {difficulty}");
0 => vec![ // The final group can straddle the observation boundary.
(1, 1), for g in &groups[..groups.len() - 1] {
(5, 1), assert_eq!(g.len(), bursts[idx][difficulty]);
(9, 1), assert!(
(71, 1), g.iter().all(|s| (s.1, s.2) == (g[0].1, g[0].2)),
(75, 1), "salvo aim must be locked"
(79, 1),
(141, 1),
(145, 1),
(149, 1),
],
1 => vec![(10, 2), (97, 2), (184, 2)],
_ => vec![(6, 2), (88, 2), (170, 2)],
};
assert_eq!(shots, expected, "{id}");
assert_eq!(
warnings,
match idx {
0 => vec![],
1 => vec![0, 87, 174],
_ => vec![0, 82, 164],
},
"{id}"
); );
} }
assert!(
groups
.windows(2)
.all(|g| g[1][0].0 - g[0][0].0 == periods[idx][difficulty])
);
if idx == 0 {
assert!(warnings.is_empty());
} else {
for (g, warning) in groups.iter().zip(&warnings) {
assert_eq!(g[0].0 - warning, windups[idx][difficulty]);
}
assert!(warnings.len() >= groups.len());
}
}
}
} }
#[test] #[test]
fn stage2_movement_has_distinct_bounded_patterns_and_units_escape_without_rewards() { fn stage2_movement_has_distinct_bounded_patterns_and_units_escape_without_rewards() {
for (idx, id) in IDS.iter().enumerate() { for (idx, id) in IDS.iter().enumerate() {
@@ -209,6 +224,7 @@ fn stage2_movement_has_distinct_bounded_patterns_and_units_escape_without_reward
let mut left = false; let mut left = false;
let mut right = false; let mut right = false;
let mut stopped = false; let mut stopped = false;
let mut vertical_speeds = std::collections::BTreeSet::new();
for _ in 0..400 { for _ in 0..400 {
w.step(Input::default(), &a.registry, &mut a.scripts) w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap(); .unwrap();
@@ -220,20 +236,30 @@ fn stage2_movement_has_distinct_bounded_patterns_and_units_escape_without_reward
right |= e.vx > 0; right |= e.vx > 0;
stopped |= e.vx == 0; stopped |= e.vx == 0;
assert!(e.x >= 6 && e.x + e.width <= 314); assert!(e.x >= 6 && e.x + e.width <= 314);
assert!((1..=2).contains(&e.vy)); vertical_speeds.insert(e.vy);
} }
assert_eq!(
vertical_speeds,
if idx == 0 { vec![1, 2, 3, 4] } else { vec![1] }
.into_iter()
.collect(),
"{id} approach/pass/exit versus terrain scroll"
);
assert!(w.enemies.is_empty(), "{id} should leave naturally"); assert!(w.enemies.is_empty(), "{id} should leave naturally");
assert_eq!(w.player.credits, 0); assert_eq!(w.player.credits, 0);
assert!(!w.objectives.contains_key(&format!("{id}:defeated"))); assert!(!w.objectives.contains_key(&format!("{id}:defeated")));
if idx != 1 { if idx != 1 {
assert!(left && right, "{id} must weave in both directions"); assert!(left && right, "{id} must weave in both directions");
} else { } else {
assert!(stopped && (left || right)); assert!(
stopped && !left && !right,
"siege stays fixed in terrain coordinates"
);
} }
} }
} }
#[test] #[test]
fn stage2_air_ground_filtering_and_siege_damage_disable_second_shell() { fn stage2_air_ground_filtering_and_damaged_siege_keeps_difficulty_burst() {
for (idx, id) in IDS.iter().enumerate() { for (idx, id) in IDS.iter().enumerate() {
for ground in [false, true] { for ground in [false, true] {
let (_t, mut a) = active(&[*id]); let (_t, mut a) = active(&[*id]);
@@ -266,18 +292,26 @@ fn stage2_air_ground_filtering_and_siege_damage_disable_second_shell() {
); );
} }
} }
// Low health no longer selects the old single-shell variant. The current
// actor remains live and difficulty 2 still emits two separated shells.
let (_t, mut a) = active(&[IDS[1]]); let (_t, mut a) = active(&[IDS[1]]);
let mut w = world(&a); let mut w = world(&a);
spawn(&mut w, &a, IDS[1], 120, 0); w.difficulty = 2;
spawn(&mut w, &a, IDS[1], 120, 40);
w.enemies[0].health = 24; w.enemies[0].health = 24;
for tick in 0..=10 { let mut shots = Vec::new();
w.enemies[0].y = 0; for tick in 0..60 {
w.enemies[0].y = 40;
w.step(Input::default(), &a.registry, &mut a.scripts) w.step(Input::default(), &a.registry, &mut a.scripts)
.unwrap(); .unwrap();
if tick == 10 { for p in w.projectiles.iter().filter(|p| p.age == 0) {
assert_eq!(w.projectiles.iter().filter(|p| p.age == 0).count(), 1); assert!(p.hostile);
shots.push(tick);
} }
w.projectiles.clear();
} }
assert_eq!(shots.len(), 2);
assert_eq!(shots[1] - shots[0], 8);
} }
#[test] #[test]
fn stage2_deaths_reward_count_once_and_replayed_entity_ids_reset_guards() { fn stage2_deaths_reward_count_once_and_replayed_entity_ids_reset_guards() {
+31 -6
View File
@@ -128,7 +128,15 @@ fn each_weapon_package_is_independent_and_all_five_can_coexist() {
for id in PACKAGES { for id in PACKAGES {
let (_root, active) = activate(&[id]); let (_root, active) = activate(&[id]);
assert_eq!(active.pins.len(), 1); assert_eq!(active.pins.len(), 1);
assert_eq!(active.pins[0].version, "1.0.0"); assert_eq!(
active.pins[0].version,
if PROJECTILE_PACKAGES.contains(&id) {
"1.1.0"
} else {
"1.0.0"
},
"{id}"
);
assert_eq!(active.registry.weapons.len(), 1); assert_eq!(active.registry.weapons.len(), 1);
assert_eq!(active.registry.sounds.len(), 2); assert_eq!(active.registry.sounds.len(), 2);
assert!( assert!(
@@ -249,17 +257,27 @@ fn pulse_hits_deal_damage_and_run_effect_audio_and_hit_state_callbacks() {
&mut active.scripts, &mut active.scripts,
) )
.unwrap(); .unwrap();
let mut hit_sound = false; let mut hit_sound = world.mod_sounds.contains(&"pulse_cannon:impact".into());
let mut saw_contact_arc = false;
for _ in 0..8 { for _ in 0..8 {
world world
.step(Input::default(), &active.registry, &mut active.scripts) .step(Input::default(), &active.registry, &mut active.scripts)
.unwrap(); .unwrap();
hit_sound |= world.mod_sounds.contains(&"pulse_cannon:impact".into()); hit_sound |= world.mod_sounds.contains(&"pulse_cannon:impact".into());
saw_contact_arc |= !world.lightning_arcs.is_empty();
assert!(world.lightning_arcs.iter().all(|arc| arc.ticks == 2));
assert!(
world.effects.is_empty(),
"compact hits must not spawn explosions"
);
} }
assert_eq!(world.enemies[0].health, 92); assert_eq!(world.enemies[0].health, 92);
assert_eq!(state(&active, "pulse_cannon")["hits"], 2); assert_eq!(state(&active, "pulse_cannon")["hits"], 2);
assert!(hit_sound); assert!(hit_sound && saw_contact_arc);
assert!(!world.effects.is_empty()); assert!(
world.lightning_arcs.is_empty(),
"short contact feedback must expire"
);
} }
#[test] #[test]
@@ -319,6 +337,8 @@ fn ion_lance_pierces_air_and_ground_targets_in_its_column_only() {
&mut active.scripts, &mut active.scripts,
) )
.unwrap(); .unwrap();
let heard_impact = world.mod_sounds.contains(&"ion_lance:impact".into());
let saw_arc = !world.lightning_arcs.is_empty();
world world
.step(Input::default(), &active.registry, &mut active.scripts) .step(Input::default(), &active.registry, &mut active.scripts)
.unwrap(); .unwrap();
@@ -327,8 +347,13 @@ fn ion_lance_pierces_air_and_ground_targets_in_its_column_only() {
[84, 84, 100] [84, 84, 100]
); );
assert_eq!(state(&active, "ion_lance")["hits"], 4); assert_eq!(state(&active, "ion_lance")["hits"], 4);
assert!(world.mod_sounds.contains(&"ion_lance:impact".into())); assert!(heard_impact && saw_arc);
assert!(!world.effects.is_empty()); assert!(
!world.mod_sounds.contains(&"ion_lance:impact".into()),
"the audio gate prevents repeated contact spam"
);
assert!(world.effects.is_empty());
assert!(world.lightning_arcs.iter().all(|arc| arc.ticks == 2));
} }
#[test] #[test]