Skip to main content

proof_engine/scene/
spawn_system.rs

1//! Spawn system — wave-based enemy spawning with zones, patterns, and blueprints.
2//!
3//! `WaveManager` drives timed waves of entity spawns defined by `SpawnWave`.
4//! Each wave contains one or more `SpawnGroup`s that describe how many entities
5//! to spawn, in what pattern, from what zone, and at what rate.
6
7use glam::Vec3;
8use std::collections::HashMap;
9
10// ── SpawnZone ─────────────────────────────────────────────────────────────────
11
12/// Defines where entities can spawn.
13#[derive(Clone, Debug)]
14pub enum SpawnZone {
15    /// A single fixed point.
16    Point(Vec3),
17    /// Uniform random within an AABB.
18    Box { min: Vec3, max: Vec3 },
19    /// Uniform random within a sphere.
20    Sphere { center: Vec3, radius: f32 },
21    /// Random on the surface of a sphere.
22    SphereSurface { center: Vec3, radius: f32 },
23    /// Uniform random within a disc (flat in XZ).
24    Disc { center: Vec3, inner_radius: f32, outer_radius: f32 },
25    /// Along a line segment.
26    Line { start: Vec3, end: Vec3 },
27    /// On a ring of equally-spaced points (deterministic).
28    Ring { center: Vec3, radius: f32, count: usize, phase: f32 },
29    /// Relative to the player position.
30    AroundPlayer { offset_min: f32, offset_max: f32 },
31}
32
33impl SpawnZone {
34    /// Generate a spawn position using a pseudo-random seed.
35    pub fn sample(&self, rng: &mut u64, player_pos: Vec3) -> Vec3 {
36        match self {
37            SpawnZone::Point(p) => *p,
38
39            SpawnZone::Box { min, max } => {
40                Vec3::new(
41                    min.x + rng_f32(rng) * (max.x - min.x),
42                    min.y + rng_f32(rng) * (max.y - min.y),
43                    min.z + rng_f32(rng) * (max.z - min.z),
44                )
45            }
46
47            SpawnZone::Sphere { center, radius } => {
48                let (center, radius) = (*center, *radius);
49                
50                // Rejection sampling for uniform interior
51                loop {
52                    let x = rng_f32_signed(rng);
53                    let y = rng_f32_signed(rng);
54                    let z = rng_f32_signed(rng);
55                    if x*x + y*y + z*z <= 1.0 {
56                        return center + Vec3::new(x, y, z) * radius;
57                    }
58                }
59            }
60
61            SpawnZone::SphereSurface { center, radius } => {
62                let (center, radius) = (*center, *radius);
63                
64                let theta = rng_f32(rng) * std::f32::consts::TAU;
65                let phi   = (rng_f32_signed(rng)).acos();
66                center + Vec3::new(
67                    phi.sin() * theta.cos(),
68                    phi.sin() * theta.sin(),
69                    phi.cos(),
70                ) * radius
71            }
72
73            SpawnZone::Disc { center, inner_radius, outer_radius } => {
74                let (center, inner_radius, outer_radius) = (*center, *inner_radius, *outer_radius);
75                let angle  = rng_f32(rng) * std::f32::consts::TAU;
76                let r      = (inner_radius + rng_f32(rng) * (outer_radius - inner_radius)).sqrt();
77                center + Vec3::new(r * angle.cos(), 0.0, r * angle.sin())
78            }
79
80            SpawnZone::Line { start, end } => {
81                let (start, end) = (*start, *end);
82                start.lerp(end, rng_f32(rng))
83            }
84
85            SpawnZone::Ring { center, radius, count, phase } => {
86                let (center, radius, count, phase) = (*center, *radius, *count, *phase);
87                let idx   = (rng_f32(rng) * count as f32) as usize % count;
88                let angle = phase + std::f32::consts::TAU * idx as f32 / count as f32;
89                center + Vec3::new(angle.cos() * radius, 0.0, angle.sin() * radius)
90            }
91
92            SpawnZone::AroundPlayer { offset_min, offset_max } => {
93                let angle  = rng_f32(rng) * std::f32::consts::TAU;
94                let radius = offset_min + rng_f32(rng) * (offset_max - offset_min);
95                player_pos + Vec3::new(angle.cos() * radius, 0.0, angle.sin() * radius)
96            }
97        }
98    }
99}
100
101fn rng_f32(rng: &mut u64) -> f32 {
102    *rng ^= *rng << 13; *rng ^= *rng >> 7; *rng ^= *rng << 17;
103    (*rng & 0xFFFF) as f32 / 65535.0
104}
105
106fn rng_f32_signed(rng: &mut u64) -> f32 {
107    rng_f32(rng) * 2.0 - 1.0
108}
109
110// ── SpawnPattern ─────────────────────────────────────────────────────────────
111
112/// How multiple entities in a group are arranged.
113#[derive(Clone, Debug)]
114pub enum SpawnPattern {
115    /// All at random positions within the zone.
116    Random,
117    /// Equally spaced on a ring.
118    Ring { radius: f32, phase_offset: f32 },
119    /// Grid pattern in XZ.
120    Grid { cols: u32, spacing: Vec3 },
121    /// V-formation (like birds).
122    VFormation { spread: f32, depth: f32 },
123    /// Single-file line.
124    Line { direction: Vec3, spacing: f32 },
125    /// Random within a burst radius from zone center.
126    Burst { radius: f32 },
127    /// Each entity in a formation around a leader.
128    Escort { leader_offset: Vec3, follower_offsets: Vec<Vec3> },
129}
130
131impl SpawnPattern {
132    /// Generate positions for `count` entities using this pattern.
133    pub fn positions(&self, count: usize, zone_center: Vec3, rng: &mut u64) -> Vec<Vec3> {
134        match self {
135            SpawnPattern::Random => {
136                (0..count).map(|_| {
137                    zone_center + Vec3::new(
138                        rng_f32_signed(rng),
139                        0.0,
140                        rng_f32_signed(rng),
141                    )
142                }).collect()
143            }
144
145            SpawnPattern::Ring { radius, phase_offset } => {
146                (0..count).map(|i| {
147                    let angle = phase_offset + std::f32::consts::TAU * i as f32 / count as f32;
148                    zone_center + Vec3::new(angle.cos() * radius, 0.0, angle.sin() * radius)
149                }).collect()
150            }
151
152            SpawnPattern::Grid { cols, spacing } => {
153                let cols = (*cols).max(1) as usize;
154                (0..count).map(|i| {
155                    let col = i % cols;
156                    let row = i / cols;
157                    zone_center + Vec3::new(col as f32, 0.0, row as f32) * *spacing
158                }).collect()
159            }
160
161            SpawnPattern::VFormation { spread, depth } => {
162                (0..count).map(|i| {
163                    let offset_x = (i as f32 - count as f32 * 0.5) * spread;
164                    let offset_z = (i as f32 * 0.5).abs() * depth;
165                    zone_center + Vec3::new(offset_x, 0.0, offset_z)
166                }).collect()
167            }
168
169            SpawnPattern::Line { direction, spacing } => {
170                let dir = direction.normalize_or_zero();
171                (0..count).map(|i| {
172                    zone_center + dir * (i as f32 * spacing)
173                }).collect()
174            }
175
176            SpawnPattern::Burst { radius } => {
177                (0..count).map(|_| {
178                    let angle  = rng_f32(rng) * std::f32::consts::TAU;
179                    let r      = rng_f32(rng).sqrt() * radius;
180                    zone_center + Vec3::new(angle.cos() * r, 0.0, angle.sin() * r)
181                }).collect()
182            }
183
184            SpawnPattern::Escort { leader_offset, follower_offsets } => {
185                let mut positions = vec![zone_center + *leader_offset];
186                for (i, off) in follower_offsets.iter().enumerate() {
187                    if i + 1 >= count { break; }
188                    positions.push(zone_center + *off);
189                }
190                while positions.len() < count {
191                    positions.push(zone_center);
192                }
193                positions
194            }
195        }
196    }
197}
198
199// ── EntityBlueprint ───────────────────────────────────────────────────────────
200
201/// Defines a type of entity to spawn.
202#[derive(Clone, Debug)]
203pub struct EntityBlueprint {
204    pub name:     String,
205    pub tags:     Vec<String>,
206    pub hp:       f32,
207    pub speed:    f32,
208    pub damage:   f32,
209    pub scale:    Vec3,
210    pub color:    [f32; 4],
211    /// AI behavior tree name.
212    pub ai:       Option<String>,
213    /// Custom attributes.
214    pub attrs:    HashMap<String, f32>,
215    /// Character glyphs that make up this entity.
216    pub glyphs:   Vec<char>,
217}
218
219impl EntityBlueprint {
220    pub fn new(name: &str) -> Self {
221        Self {
222            name:   name.into(),
223            tags:   Vec::new(),
224            hp:     100.0,
225            speed:  3.0,
226            damage: 10.0,
227            scale:  Vec3::ONE,
228            color:  [1.0, 1.0, 1.0, 1.0],
229            ai:     None,
230            attrs:  HashMap::new(),
231            glyphs: vec!['@'],
232        }
233    }
234
235    pub fn with_hp(mut self, hp: f32) -> Self { self.hp = hp; self }
236    pub fn with_speed(mut self, s: f32) -> Self { self.speed = s; self }
237    pub fn with_damage(mut self, d: f32) -> Self { self.damage = d; self }
238    pub fn with_color(mut self, c: [f32; 4]) -> Self { self.color = c; self }
239    pub fn with_ai(mut self, ai: &str) -> Self { self.ai = Some(ai.into()); self }
240    pub fn with_glyph(mut self, g: char) -> Self { self.glyphs = vec![g]; self }
241    pub fn with_glyphs(mut self, g: Vec<char>) -> Self { self.glyphs = g; self }
242    pub fn tagged(mut self, tag: &str) -> Self { self.tags.push(tag.into()); self }
243    pub fn with_attr(mut self, k: &str, v: f32) -> Self { self.attrs.insert(k.into(), v); self }
244}
245
246// ── SpawnGroup ────────────────────────────────────────────────────────────────
247
248/// One group within a wave: blueprint × count at a zone.
249#[derive(Clone, Debug)]
250pub struct SpawnGroup {
251    pub blueprint:      String,         // name in BlueprintLibrary
252    pub count:          u32,
253    pub zone:           SpawnZone,
254    pub pattern:        SpawnPattern,
255    /// Spawns per second (0 = all at once).
256    pub rate:           f32,
257    /// Delay from wave start before this group activates.
258    pub delay:          f32,
259    /// Tag applied to all spawned entities.
260    pub tag:            Option<String>,
261    /// If true, the wave doesn't complete until all these entities die.
262    pub blocking:       bool,
263}
264
265impl SpawnGroup {
266    pub fn new(blueprint: &str, count: u32, zone: SpawnZone) -> Self {
267        Self {
268            blueprint: blueprint.into(),
269            count,
270            zone,
271            pattern:   SpawnPattern::Random,
272            rate:       0.0,
273            delay:      0.0,
274            tag:        None,
275            blocking:   true,
276        }
277    }
278
279    pub fn with_pattern(mut self, p: SpawnPattern) -> Self { self.pattern = p; self }
280    pub fn with_rate(mut self, r: f32) -> Self { self.rate = r; self }
281    pub fn with_delay(mut self, d: f32) -> Self { self.delay = d; self }
282    pub fn tagged(mut self, t: &str) -> Self { self.tag = Some(t.into()); self }
283    pub fn non_blocking(mut self) -> Self { self.blocking = false; self }
284}
285
286// ── SpawnWave ─────────────────────────────────────────────────────────────────
287
288/// A complete wave: one or more groups, activation conditions, completion rewards.
289#[derive(Clone, Debug)]
290pub struct SpawnWave {
291    pub name:       String,
292    pub groups:     Vec<SpawnGroup>,
293    /// Delay after the previous wave before this one starts.
294    pub pre_delay:  f32,
295    /// Delay after this wave completes before the next.
296    pub post_delay: f32,
297    /// Music vibe to set when this wave starts.
298    pub music_vibe: Option<String>,
299    /// Flag to set when the wave is cleared.
300    pub on_clear:   Option<String>,
301    /// Repeat this wave indefinitely.
302    pub repeat:     bool,
303}
304
305impl SpawnWave {
306    pub fn new(name: &str, groups: Vec<SpawnGroup>) -> Self {
307        Self {
308            name:       name.into(),
309            groups,
310            pre_delay:  0.0,
311            post_delay: 2.0,
312            music_vibe: None,
313            on_clear:   None,
314            repeat:     false,
315        }
316    }
317
318    pub fn with_pre_delay(mut self, d: f32) -> Self { self.pre_delay = d; self }
319    pub fn with_post_delay(mut self, d: f32) -> Self { self.post_delay = d; self }
320    pub fn with_music(mut self, v: &str) -> Self { self.music_vibe = Some(v.into()); self }
321    pub fn on_clear(mut self, flag: &str) -> Self { self.on_clear = Some(flag.into()); self }
322    pub fn repeating(mut self) -> Self { self.repeat = true; self }
323}
324
325// ── Group runtime state ────────────────────────────────────────────────────────
326
327#[derive(Clone, Debug)]
328struct GroupState {
329    pub spawned:    u32,
330    pub killed:     u32,
331    pub timer:      f32,    // rate timer
332    pub delay_done: bool,
333    pub delay_timer: f32,
334    pub complete:   bool,
335}
336
337// ── SpawnEvent ────────────────────────────────────────────────────────────────
338
339/// Emitted by the spawn system for the game to process.
340#[derive(Clone, Debug)]
341pub struct SpawnEvent {
342    pub blueprint: String,
343    pub position:  Vec3,
344    pub tag:       Option<String>,
345    pub wave_name: String,
346}
347
348// ── WaveManager ───────────────────────────────────────────────────────────────
349
350/// Manages a sequence of spawn waves.
351pub struct WaveManager {
352    waves:        Vec<SpawnWave>,
353    current_wave: usize,
354    group_states: Vec<GroupState>,
355    /// Seconds until wave starts (pre-delay).
356    wave_timer:   f32,
357    /// Whether the current wave is active.
358    active:       bool,
359    /// Post-delay timer after wave clear.
360    post_timer:   f32,
361    post_pending: bool,
362    rng:          u64,
363    pub flags:    HashMap<String, bool>,
364    pub player_pos: Vec3,
365    /// Library of blueprints.
366    pub blueprints: BlueprintLibrary,
367    pub finished:   bool,
368    pub wave_count:  u32,
369}
370
371impl WaveManager {
372    pub fn new(waves: Vec<SpawnWave>, blueprints: BlueprintLibrary) -> Self {
373        let n = waves.first().map(|w| w.groups.len()).unwrap_or(0);
374        let pre = waves.first().map(|w| w.pre_delay).unwrap_or(0.0);
375        let group_states = vec![GroupState {
376            spawned: 0, killed: 0, timer: 0.0,
377            delay_done: false, delay_timer: 0.0, complete: false,
378        }; n];
379
380        Self {
381            waves,
382            current_wave: 0,
383            group_states,
384            wave_timer:   pre,
385            active:       false,
386            post_timer:   0.0,
387            post_pending: false,
388            rng:          0xDEADBEEF_CAFEBABE,
389            flags:        HashMap::new(),
390            player_pos:   Vec3::ZERO,
391            blueprints,
392            finished:     false,
393            wave_count:   0,
394        }
395    }
396
397    pub fn start(&mut self) {
398        if self.waves.is_empty() {
399            self.finished = true;
400            return;
401        }
402        self.active = false;
403        self.wave_timer = self.waves[0].pre_delay;
404    }
405
406    /// Notify the manager that an entity with `tag` was killed.
407    pub fn on_entity_killed(&mut self, tag: &str) {
408        let wave = match self.waves.get(self.current_wave) {
409            Some(w) => w,
410            None    => return,
411        };
412        for (i, group) in wave.groups.iter().enumerate() {
413            if group.tag.as_deref() == Some(tag) || group.blocking {
414                if let Some(s) = self.group_states.get_mut(i) {
415                    s.killed += 1;
416                }
417            }
418        }
419    }
420
421    /// Advance the spawn system by dt. Returns spawned entities this tick.
422    pub fn tick(&mut self, dt: f32) -> Vec<SpawnEvent> {
423        if self.finished { return Vec::new(); }
424        let mut events = Vec::new();
425
426        // Pre-delay
427        if !self.active && !self.post_pending {
428            self.wave_timer -= dt;
429            if self.wave_timer <= 0.0 {
430                self.activate_current_wave();
431            }
432            // Spawn in the same tick the wave activates. Returning here made
433            // every wave start one tick late, so a wave with no pre-delay
434            // spawned nothing on its first tick.
435            if !self.active || self.finished {
436                return events;
437            }
438        }
439
440        // Post-delay
441        if self.post_pending {
442            self.post_timer -= dt;
443            if self.post_timer <= 0.0 {
444                self.post_pending = false;
445                self.advance_wave();
446            }
447            return events;
448        }
449
450        // Active wave
451        let wave = match self.waves.get(self.current_wave).cloned() {
452            Some(w) => w,
453            None    => return events,
454        };
455
456        let mut all_done = true;
457
458        for (gi, group) in wave.groups.iter().enumerate() {
459            let state = &mut self.group_states[gi];
460            if state.complete { continue; }
461
462            // Per-group delay
463            if !state.delay_done {
464                state.delay_timer += dt;
465                if state.delay_timer < group.delay { all_done = false; continue; }
466                state.delay_done = true;
467            }
468
469            // Spawn rate
470            let remaining = group.count - state.spawned;
471            if remaining > 0 {
472                all_done = false;
473                if group.rate <= 0.0 {
474                    // Spawn all at once
475                    let positions = group.pattern.positions(
476                        remaining as usize,
477                        group.zone.sample(&mut self.rng, self.player_pos),
478                        &mut self.rng,
479                    );
480                    for pos in positions {
481                        events.push(SpawnEvent {
482                            blueprint: group.blueprint.clone(),
483                            position:  pos,
484                            tag:       group.tag.clone(),
485                            wave_name: wave.name.clone(),
486                        });
487                        state.spawned += 1;
488                    }
489                } else {
490                    state.timer += dt;
491                    while state.timer >= 1.0 / group.rate && state.spawned < group.count {
492                        state.timer -= 1.0 / group.rate;
493                        let pos = group.zone.sample(&mut self.rng, self.player_pos);
494                        events.push(SpawnEvent {
495                            blueprint: group.blueprint.clone(),
496                            position:  pos,
497                            tag:       group.tag.clone(),
498                            wave_name: wave.name.clone(),
499                        });
500                        state.spawned += 1;
501                    }
502                }
503            } else if group.blocking {
504                // Wait for kills
505                let needed = group.count;
506                if state.killed < needed {
507                    all_done = false;
508                } else {
509                    state.complete = true;
510                }
511            } else {
512                state.complete = true;
513            }
514        }
515
516        if all_done && self.active {
517            self.on_wave_cleared(&wave.clone());
518        }
519
520        events
521    }
522
523    fn activate_current_wave(&mut self) {
524        let wave = match self.waves.get(self.current_wave) {
525            Some(w) => w,
526            None    => { self.finished = true; return; }
527        };
528        let n = wave.groups.len();
529        self.group_states = vec![GroupState {
530            spawned: 0, killed: 0, timer: 0.0,
531            delay_done: false, delay_timer: 0.0, complete: false,
532        }; n];
533        self.active = true;
534    }
535
536    fn on_wave_cleared(&mut self, wave: &SpawnWave) {
537        self.active = false;
538        self.wave_count += 1;
539
540        if let Some(flag) = &wave.on_clear {
541            self.flags.insert(flag.clone(), true);
542        }
543
544        if wave.repeat {
545            self.wave_timer  = wave.pre_delay;
546            self.post_pending = true;
547            self.post_timer  = wave.post_delay;
548        } else {
549            self.post_pending = true;
550            self.post_timer  = wave.post_delay;
551        }
552    }
553
554    fn advance_wave(&mut self) {
555        if self.waves.get(self.current_wave).map(|w| w.repeat).unwrap_or(false) {
556            // Stay on same wave
557            self.wave_timer = self.waves[self.current_wave].pre_delay;
558        } else {
559            self.current_wave += 1;
560            if self.current_wave >= self.waves.len() {
561                self.finished = true;
562                return;
563            }
564            self.wave_timer = self.waves[self.current_wave].pre_delay;
565        }
566    }
567
568    pub fn current_wave_name(&self) -> &str {
569        self.waves.get(self.current_wave).map(|w| w.name.as_str()).unwrap_or("none")
570    }
571
572    pub fn total_waves(&self) -> usize { self.waves.len() }
573    pub fn is_active(&self) -> bool { self.active }
574    pub fn get_flag(&self, k: &str) -> bool { self.flags.get(k).copied().unwrap_or(false) }
575}
576
577// ── BlueprintLibrary ──────────────────────────────────────────────────────────
578
579/// Registry of named entity blueprints.
580#[derive(Default)]
581pub struct BlueprintLibrary {
582    pub blueprints: HashMap<String, EntityBlueprint>,
583}
584
585impl BlueprintLibrary {
586    pub fn new() -> Self { Self::default() }
587
588    pub fn register(&mut self, blueprint: EntityBlueprint) {
589        self.blueprints.insert(blueprint.name.clone(), blueprint);
590    }
591
592    pub fn get(&self, name: &str) -> Option<&EntityBlueprint> {
593        self.blueprints.get(name)
594    }
595
596    /// Register standard enemy blueprints.
597    pub fn with_defaults(mut self) -> Self {
598        self.register(EntityBlueprint::new("grunt")
599            .with_hp(60.0).with_speed(2.5).with_damage(8.0)
600            .with_color([0.8, 0.2, 0.2, 1.0]).with_glyph('g').tagged("enemy"));
601        self.register(EntityBlueprint::new("archer")
602            .with_hp(40.0).with_speed(2.0).with_damage(15.0)
603            .with_color([0.8, 0.5, 0.2, 1.0]).with_glyph('a').tagged("enemy"));
604        self.register(EntityBlueprint::new("tank")
605            .with_hp(200.0).with_speed(1.5).with_damage(25.0)
606            .with_color([0.5, 0.2, 0.8, 1.0]).with_glyph('T').tagged("enemy"));
607        self.register(EntityBlueprint::new("healer")
608            .with_hp(50.0).with_speed(2.0).with_damage(5.0)
609            .with_color([0.2, 0.9, 0.4, 1.0]).with_glyph('h').tagged("enemy"));
610        self.register(EntityBlueprint::new("boss")
611            .with_hp(1000.0).with_speed(3.5).with_damage(50.0)
612            .with_color([1.0, 0.1, 0.1, 1.0]).with_glyph('B').tagged("enemy").tagged("boss")
613            .with_attr("enrage_threshold", 0.3));
614        self
615    }
616}
617
618// ── Tests ─────────────────────────────────────────────────────────────────────
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn spawn_zone_point() {
626        let z   = SpawnZone::Point(Vec3::new(1.0, 2.0, 3.0));
627        let mut rng = 12345u64;
628        let p   = z.sample(&mut rng, Vec3::ZERO);
629        assert_eq!(p, Vec3::new(1.0, 2.0, 3.0));
630    }
631
632    #[test]
633    fn spawn_zone_sphere_bounded() {
634        let z   = SpawnZone::Sphere { center: Vec3::ZERO, radius: 5.0 };
635        let mut rng = 42u64;
636        for _ in 0..100 {
637            let p = z.sample(&mut rng, Vec3::ZERO);
638            assert!(p.length() <= 5.05, "Point outside sphere: {:?}", p);
639        }
640    }
641
642    #[test]
643    fn spawn_pattern_ring_count() {
644        let p = SpawnPattern::Ring { radius: 3.0, phase_offset: 0.0 };
645        let positions = p.positions(8, Vec3::ZERO, &mut 0u64);
646        assert_eq!(positions.len(), 8);
647    }
648
649    #[test]
650    fn spawn_pattern_grid() {
651        let p = SpawnPattern::Grid { cols: 3, spacing: Vec3::ONE };
652        let positions = p.positions(9, Vec3::ZERO, &mut 0u64);
653        assert_eq!(positions.len(), 9);
654    }
655
656    #[test]
657    fn blueprint_library_default() {
658        let lib = BlueprintLibrary::new().with_defaults();
659        assert!(lib.get("grunt").is_some());
660        assert!(lib.get("boss").is_some());
661        assert!(lib.get("nobody").is_none());
662    }
663
664    #[test]
665    fn wave_manager_starts_and_spawns() {
666        let lib = BlueprintLibrary::new().with_defaults();
667        let wave = SpawnWave::new("w1", vec![
668            SpawnGroup::new("grunt", 3, SpawnZone::Point(Vec3::ZERO))
669                .with_rate(0.0)   // all at once
670                .non_blocking(),
671        ]).with_pre_delay(0.0).with_post_delay(0.0);
672
673        let mut mgr = WaveManager::new(vec![wave], lib);
674        mgr.start();
675
676        // Tick past activation
677        let events = mgr.tick(0.016);
678        assert!(!events.is_empty(), "Expected spawn events");
679    }
680
681    #[test]
682    fn wave_manager_rate_spawn() {
683        let lib = BlueprintLibrary::new().with_defaults();
684        let wave = SpawnWave::new("w1", vec![
685            SpawnGroup::new("grunt", 10, SpawnZone::Point(Vec3::ZERO))
686                .with_rate(5.0)  // 5 per second
687                .non_blocking(),
688        ]).with_pre_delay(0.0).with_post_delay(0.0);
689
690        let mut mgr = WaveManager::new(vec![wave], lib);
691        mgr.start();
692
693        let mut total = 0;
694        for _ in 0..60 {
695            total += mgr.tick(1.0 / 60.0).len();
696        }
697        // In 1 second at 5/s, should spawn ~5
698        assert!(total >= 4 && total <= 6, "Expected ~5 spawns, got {}", total);
699    }
700
701    #[test]
702    fn wave_advances() {
703        let lib = BlueprintLibrary::new().with_defaults();
704        let w1 = SpawnWave::new("w1", vec![
705            SpawnGroup::new("grunt", 1, SpawnZone::Point(Vec3::ZERO))
706                .non_blocking(),
707        ]).with_pre_delay(0.0).with_post_delay(0.0);
708        let w2 = SpawnWave::new("w2", vec![
709            SpawnGroup::new("tank", 1, SpawnZone::Point(Vec3::ONE))
710                .non_blocking(),
711        ]).with_pre_delay(0.0).with_post_delay(0.0);
712
713        let mut mgr = WaveManager::new(vec![w1, w2], lib);
714        mgr.start();
715
716        // Tick until wave 1 is done. The old test ran 30 ticks, long enough
717        // to finish wave 2 as well (both are one non-blocking spawn), after
718        // which there is no current wave; it then expected "w2".
719        let mut ticks = 0;
720        while mgr.current_wave_name() == "w1" && ticks < 30 {
721            mgr.tick(0.1);
722            ticks += 1;
723        }
724        assert_eq!(mgr.current_wave_name(), "w2");
725        for _ in 0..30 { mgr.tick(0.1); }
726        assert_eq!(mgr.current_wave_name(), "none", "both waves done");
727    }
728}