From 587235471ef6512190d4d90e13e1798ee6b3fada Mon Sep 17 00:00:00 2001 From: raptorreplica Date: Sat, 17 Oct 2026 18:33:46 +1100 Subject: [PATCH] Update gameplay acceptance tests for the Iron Coast retune --- docs/iron-coast-retune.md | 7 + tests/enemies_mods.rs | 210 ++++++++++++++++++----------- tests/iron_coast.rs | 11 +- tests/iron_coast_stage2_weapons.rs | 26 ++-- tests/stage2_enemies.rs | 156 ++++++++++++--------- tests/weapons_mods.rs | 37 ++++- 6 files changed, 291 insertions(+), 156 deletions(-) diff --git a/docs/iron-coast-retune.md b/docs/iron-coast-retune.md index 34384efc..060c6832 100644 --- a/docs/iron-coast-retune.md +++ b/docs/iron-coast-retune.md @@ -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 diff --git a/tests/enemies_mods.rs b/tests/enemies_mods.rs index edf0ff73..8103c574 100644 --- a/tests/enemies_mods.rs +++ b/tests/enemies_mods.rs @@ -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::>(), [-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::>(), [-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); } } diff --git a/tests/iron_coast.rs b/tests/iron_coast.rs index 0cbe6ba8..a08e0e0d 100644 --- a/tests/iron_coast.rs +++ b/tests/iron_coast.rs @@ -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, diff --git a/tests/iron_coast_stage2_weapons.rs b/tests/iron_coast_stage2_weapons.rs index 47a26d90..2d06601b 100644 --- a/tests/iron_coast_stage2_weapons.rs +++ b/tests/iron_coast_stage2_weapons.rs @@ -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 diff --git a/tests/stage2_enemies.rs b/tests/stage2_enemies.rs index fe964d08..3a741a3f 100644 --- a/tests/stage2_enemies.rs +++ b/tests/stage2_enemies.rs @@ -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::>(); - 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::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() { diff --git a/tests/weapons_mods.rs b/tests/weapons_mods.rs index 04a84aaf..eb704d18 100644 --- a/tests/weapons_mods.rs +++ b/tests/weapons_mods.rs @@ -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]