Update gameplay acceptance tests for the Iron Coast retune
This commit is contained in:
1 parent
c1bbf2ea2e
commit
587235471e
6 files changed
+291
-156
No files matched your search
@@ -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
|
||||
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
|
||||
|
||||
Scouts and interceptors retain 24×24 logical bodies; turrets and siege units
|
||||
|
||||
+132
-78
@@ -120,7 +120,10 @@ fn all_enemy_packages_validate_independently_and_together() {
|
||||
.weapons
|
||||
.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!(
|
||||
a.registry.enemies[&ContentId::new(format!("{id}:unit")).unwrap()]
|
||||
.script
|
||||
@@ -135,42 +138,70 @@ fn all_enemy_packages_validate_independently_and_together() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scout_weaves_in_both_directions_and_aims_hostile_fire() {
|
||||
let (_temp, mut a) = active(&["iron_coast_scout"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_scout:unit"),
|
||||
120,
|
||||
0,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut right = false;
|
||||
let mut left = false;
|
||||
let mut sounds = 0;
|
||||
for _ in 0..95 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
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 mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(&"iron_coast_scout:unit".into(), x, -24, &a.registry)
|
||||
.unwrap();
|
||||
let e = &w.enemies[0];
|
||||
right |= e.vx > 0;
|
||||
left |= e.vx < 0;
|
||||
assert!(e.x >= 24 && e.x <= 272);
|
||||
assert_eq!(e.vy, 1);
|
||||
sounds += w
|
||||
.mod_sounds
|
||||
.iter()
|
||||
.filter(|id| id.0 == "iron_coast_scout:shot")
|
||||
.count();
|
||||
let mut speeds = std::collections::BTreeSet::new();
|
||||
let mut lateral = false;
|
||||
let mut aimed_left = false;
|
||||
for _ in 0..200 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
for p in w.projectiles.iter().filter(|p| p.age == 0 && p.hostile) {
|
||||
aimed_left |= p.vx < 0;
|
||||
}
|
||||
w.projectiles.clear(); // Isolate the approach, not player survival.
|
||||
let Some(e) = w.enemies.first() else { break };
|
||||
speeds.insert(e.vy);
|
||||
lateral |= e.vx.signum() == direction;
|
||||
}
|
||||
assert_eq!(speeds, [1, 2, 3].into_iter().collect());
|
||||
assert!(lateral && aimed_left);
|
||||
assert!(
|
||||
w.enemies.is_empty(),
|
||||
"the scout must finish its attack pass"
|
||||
);
|
||||
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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(right && left);
|
||||
assert_eq!(w.enemies[0].y, 94);
|
||||
assert!(sounds >= 2);
|
||||
assert!(
|
||||
w.projectiles
|
||||
.iter()
|
||||
.any(|p| p.hostile && p.weapon.0 == "iron_coast_scout:projectile" && p.vx < 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -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!(a.registry.enemies[&w.enemies[0].definition].ground);
|
||||
assert_eq!(sounds, 2);
|
||||
assert_eq!(sounds, 1);
|
||||
assert!(
|
||||
w.projectiles
|
||||
.iter()
|
||||
@@ -206,48 +237,73 @@ fn turret_scrolls_with_terrain_and_aims_at_player() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_enters_patrols_fires_fans_and_remains_on_screen() {
|
||||
let (_temp, mut a) = active(&["iron_coast_boss"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_boss:unit"),
|
||||
112,
|
||||
-64,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut left = false;
|
||||
let mut right = false;
|
||||
let mut fans = 0;
|
||||
for tick in 0..1500 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
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 mut w = world(&a);
|
||||
w.difficulty = difficulty;
|
||||
w.player.x = 0;
|
||||
w.spawn(&"iron_coast_boss:unit".into(), 112, -64, &a.registry)
|
||||
.unwrap();
|
||||
assert_eq!(w.outcome, Outcome::Playing);
|
||||
assert_eq!(w.enemies.len(), 1, "boss retired at{tick}");
|
||||
let e = &w.enemies[0];
|
||||
assert_eq!((e.width, e.height), (96, 64));
|
||||
if tick > 46 {
|
||||
assert_eq!(e.y, 24);
|
||||
assert!((80..=144).contains(&e.x));
|
||||
left |= e.vx < 0;
|
||||
right |= e.vx > 0;
|
||||
}
|
||||
if w.mod_sounds
|
||||
.contains(&ContentId::from("iron_coast_boss:shot"))
|
||||
{
|
||||
let mut directions = std::collections::BTreeSet::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut shots = Vec::new();
|
||||
for tick in 0..500 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(w.outcome, Outcome::Playing);
|
||||
assert_eq!(w.enemies.len(), 1, "boss retired at {tick}");
|
||||
let e = &w.enemies[0];
|
||||
assert_eq!((e.width, e.height), (84, 56));
|
||||
if tick > 46 {
|
||||
assert_eq!(e.y, 24);
|
||||
assert!((32..=204).contains(&e.x));
|
||||
directions.insert(e.vx.signum());
|
||||
}
|
||||
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());
|
||||
}
|
||||
let bullets: Vec<_> = w
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| p.age == 0 && p.hostile)
|
||||
.collect();
|
||||
assert_eq!(bullets.len(), 3);
|
||||
assert_eq!(bullets.iter().map(|p| p.vx).collect::<Vec<_>>(), [-2, 0, 2]);
|
||||
fans += 1;
|
||||
if w.mod_sounds.contains(&"iron_coast_boss:shot".into()) {
|
||||
assert_eq!(tick - warnings.last().unwrap(), 18 - difficulty * 2);
|
||||
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]
|
||||
@@ -407,14 +463,12 @@ fn mission_defeat_counts_reset_while_persistent_script_statistics_continue() {
|
||||
assert_eq!(next.objectives.get(&format!("{id}:defeated")), Some(&1));
|
||||
let state = a.scripts.checkpoints().0;
|
||||
assert_eq!(state[&format!("{id}/scripts/ai.rhai")]["destroyed"], 3);
|
||||
if id == "iron_coast_boss" {
|
||||
assert!(
|
||||
state[&format!("{id}/scripts/ai.rhai")]["directions"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
// The next tick must not resurrect or fire on behalf of a dead actor.
|
||||
next.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert!(next.enemies.is_empty());
|
||||
assert!(next.projectiles.is_empty());
|
||||
assert_eq!(next.objectives[&format!("{id}:defeated")], 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-2
@@ -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::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Manifest},
|
||||
@@ -33,6 +34,12 @@ fn base() -> Registry {
|
||||
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}));
|
||||
}
|
||||
// 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();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
@@ -336,7 +343,7 @@ fn restarted_mission_counts_new_turret_kills_independently_of_saved_statistics()
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert_eq!(
|
||||
world.outcome,
|
||||
|
||||
@@ -221,21 +221,26 @@ fn railburst_contact_penetrates_only_three_aligned_targets_with_falling_damage()
|
||||
world.effects.is_empty(),
|
||||
"no muzzle explosion before real contact"
|
||||
);
|
||||
let mut saw_contact_arc = false;
|
||||
for _ in 0..3 {
|
||||
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!(
|
||||
active.scripts.states["iron_coast_railburst/scripts/weapon.rhai"]["hits"],
|
||||
3
|
||||
);
|
||||
assert!(world.effects.iter().any(|e| e.kind == EffectKind::SmallAir));
|
||||
assert!(
|
||||
world
|
||||
.effects
|
||||
.iter()
|
||||
.any(|e| e.kind == EffectKind::SmallGround)
|
||||
);
|
||||
assert!(saw_contact_arc);
|
||||
for _ in 0..3 {
|
||||
step(&mut world, &mut active, false);
|
||||
}
|
||||
assert!(world.lightning_arcs.is_empty());
|
||||
assert_eq!(
|
||||
world.player.inventory[&"iron_coast_railburst:equipment".into()],
|
||||
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!(world.effects.len(), 1);
|
||||
assert_eq!(world.effects[0].kind, EffectKind::GroundBlast);
|
||||
assert!(world.effects[0].ground);
|
||||
assert_eq!(world.effects[0].kind, EffectKind::SmallAir);
|
||||
assert!(
|
||||
!world.effects[0].ground,
|
||||
"compact presentation does not change ground-only damage"
|
||||
);
|
||||
assert_eq!(
|
||||
active.scripts.states["iron_coast_depth_charge/scripts/weapon.rhai"]["hits"],
|
||||
4
|
||||
|
||||
+95
-61
@@ -140,65 +140,80 @@ fn stage2_packages_activate_independently_and_coexist_with_stage1() {
|
||||
assert!(a.registry.overrides.is_empty());
|
||||
}
|
||||
#[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() {
|
||||
let (_t, mut a) = active(&[*id]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 240;
|
||||
spawn(&mut w, &a, id, 100, 0);
|
||||
let mut shots = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for tick in 0..200 {
|
||||
let e = &mut w.enemies[0];
|
||||
e.x = 100;
|
||||
e.y = 0;
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
let new = w
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| p.age == 0)
|
||||
.collect::<Vec<_>>();
|
||||
if !new.is_empty() {
|
||||
assert!(new.iter().all(|p| p.hostile
|
||||
&& p.weapon.0 == format!("{id}:projectile")
|
||||
&& p.vy >= 1
|
||||
&& p.vx.abs() <= 3));
|
||||
shots.push((tick, new.len()));
|
||||
for difficulty in 0..4 {
|
||||
let (_t, mut a) = active(&[*id]);
|
||||
let mut w = world(&a);
|
||||
w.difficulty = difficulty as u32;
|
||||
w.player.x = 240;
|
||||
spawn(&mut w, &a, id, 100, 40);
|
||||
let mut groups: Vec<Vec<(usize, i32, i32)>> = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for tick in 0..320 {
|
||||
// A visible range fixture; movement/escape and offscreen suppression are tested separately.
|
||||
w.enemies[0].x = 100;
|
||||
w.enemies[0].y = 40;
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
let new: Vec<_> = w.projectiles.iter().filter(|p| p.age == 0).collect();
|
||||
assert!(
|
||||
new.len() <= 1,
|
||||
"{id} must space the burst, not stack shells"
|
||||
);
|
||||
for p in new {
|
||||
assert!(
|
||||
p.hostile
|
||||
&& p.weapon.0 == format!("{id}:projectile")
|
||||
&& p.vy >= 1
|
||||
&& p.vx.abs() <= 5
|
||||
);
|
||||
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")) {
|
||||
warnings.push(tick);
|
||||
}
|
||||
assert_eq!(w.enemies.len(), 1);
|
||||
w.projectiles.clear();
|
||||
}
|
||||
if w.mod_sounds.iter().any(|s| s.0 == format!("{id}:warning")) {
|
||||
warnings.push(tick);
|
||||
assert!(groups.len() >= 2, "{id} difficulty {difficulty}");
|
||||
// The final group can straddle the observation boundary.
|
||||
for g in &groups[..groups.len() - 1] {
|
||||
assert_eq!(g.len(), bursts[idx][difficulty]);
|
||||
assert!(
|
||||
g.iter().all(|s| (s.1, s.2) == (g[0].1, g[0].2)),
|
||||
"salvo aim must be locked"
|
||||
);
|
||||
}
|
||||
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());
|
||||
}
|
||||
assert_eq!(w.enemies.len(), 1, "AI must never spawn extra enemies");
|
||||
assert!(w.projectiles.len() < 32);
|
||||
}
|
||||
let expected = match idx {
|
||||
0 => vec![
|
||||
(1, 1),
|
||||
(5, 1),
|
||||
(9, 1),
|
||||
(71, 1),
|
||||
(75, 1),
|
||||
(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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_movement_has_distinct_bounded_patterns_and_units_escape_without_rewards() {
|
||||
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 right = false;
|
||||
let mut stopped = false;
|
||||
let mut vertical_speeds = std::collections::BTreeSet::new();
|
||||
for _ in 0..400 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
@@ -220,20 +236,30 @@ fn stage2_movement_has_distinct_bounded_patterns_and_units_escape_without_reward
|
||||
right |= e.vx > 0;
|
||||
stopped |= e.vx == 0;
|
||||
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_eq!(w.player.credits, 0);
|
||||
assert!(!w.objectives.contains_key(&format!("{id}:defeated")));
|
||||
if idx != 1 {
|
||||
assert!(left && right, "{id} must weave in both directions");
|
||||
} else {
|
||||
assert!(stopped && (left || right));
|
||||
assert!(
|
||||
stopped && !left && !right,
|
||||
"siege stays fixed in terrain coordinates"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[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 ground in [false, true] {
|
||||
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 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;
|
||||
for tick in 0..=10 {
|
||||
w.enemies[0].y = 0;
|
||||
let mut shots = Vec::new();
|
||||
for tick in 0..60 {
|
||||
w.enemies[0].y = 40;
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
if tick == 10 {
|
||||
assert_eq!(w.projectiles.iter().filter(|p| p.age == 0).count(), 1);
|
||||
for p in w.projectiles.iter().filter(|p| p.age == 0) {
|
||||
assert!(p.hostile);
|
||||
shots.push(tick);
|
||||
}
|
||||
w.projectiles.clear();
|
||||
}
|
||||
assert_eq!(shots.len(), 2);
|
||||
assert_eq!(shots[1] - shots[0], 8);
|
||||
}
|
||||
#[test]
|
||||
fn stage2_deaths_reward_count_once_and_replayed_entity_ids_reset_guards() {
|
||||
|
||||
+31
-6
@@ -128,7 +128,15 @@ 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);
|
||||
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.sounds.len(), 2);
|
||||
assert!(
|
||||
@@ -249,17 +257,27 @@ fn pulse_hits_deal_damage_and_run_effect_audio_and_hit_state_callbacks() {
|
||||
&mut active.scripts,
|
||||
)
|
||||
.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 {
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
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!(state(&active, "pulse_cannon")["hits"], 2);
|
||||
assert!(hit_sound);
|
||||
assert!(!world.effects.is_empty());
|
||||
assert!(hit_sound && saw_contact_arc);
|
||||
assert!(
|
||||
world.lightning_arcs.is_empty(),
|
||||
"short contact feedback must expire"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -319,6 +337,8 @@ fn ion_lance_pierces_air_and_ground_targets_in_its_column_only() {
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
let heard_impact = world.mod_sounds.contains(&"ion_lance:impact".into());
|
||||
let saw_arc = !world.lightning_arcs.is_empty();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
@@ -327,8 +347,13 @@ fn ion_lance_pierces_air_and_ground_targets_in_its_column_only() {
|
||||
[84, 84, 100]
|
||||
);
|
||||
assert_eq!(state(&active, "ion_lance")["hits"], 4);
|
||||
assert!(world.mod_sounds.contains(&"ion_lance:impact".into()));
|
||||
assert!(!world.effects.is_empty());
|
||||
assert!(heard_impact && saw_arc);
|
||||
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]
|
||||
|
||||
Reference in new issue
Block a user