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            return events;
433        }
434
435        // Post-delay
436        if self.post_pending {
437            self.post_timer -= dt;
438            if self.post_timer <= 0.0 {
439                self.post_pending = false;
440                self.advance_wave();
441            }
442            return events;
443        }
444
445        // Active wave
446        let wave = match self.waves.get(self.current_wave).cloned() {
447            Some(w) => w,
448            None    => return events,
449        };
450
451        let mut all_done = true;
452
453        for (gi, group) in wave.groups.iter().enumerate() {
454            let state = &mut self.group_states[gi];
455            if state.complete { continue; }
456
457            // Per-group delay
458            if !state.delay_done {
459                state.delay_timer += dt;
460                if state.delay_timer < group.delay { all_done = false; continue; }
461                state.delay_done = true;
462            }
463
464            // Spawn rate
465            let remaining = group.count - state.spawned;
466            if remaining > 0 {
467                all_done = false;
468                if group.rate <= 0.0 {
469                    // Spawn all at once
470                    let positions = group.pattern.positions(
471                        remaining as usize,
472                        group.zone.sample(&mut self.rng, self.player_pos),
473                        &mut self.rng,
474                    );
475                    for pos in positions {
476                        events.push(SpawnEvent {
477                            blueprint: group.blueprint.clone(),
478                            position:  pos,
479                            tag:       group.tag.clone(),
480                            wave_name: wave.name.clone(),
481                        });
482                        state.spawned += 1;
483                    }
484                } else {
485                    state.timer += dt;
486                    while state.timer >= 1.0 / group.rate && state.spawned < group.count {
487                        state.timer -= 1.0 / group.rate;
488                        let pos = group.zone.sample(&mut self.rng, self.player_pos);
489                        events.push(SpawnEvent {
490                            blueprint: group.blueprint.clone(),
491                            position:  pos,
492                            tag:       group.tag.clone(),
493                            wave_name: wave.name.clone(),
494                        });
495                        state.spawned += 1;
496                    }
497                }
498            } else if group.blocking {
499                // Wait for kills
500                let needed = group.count;
501                if state.killed < needed {
502                    all_done = false;
503                } else {
504                    state.complete = true;
505                }
506            } else {
507                state.complete = true;
508            }
509        }
510
511        if all_done && self.active {
512            self.on_wave_cleared(&wave.clone());
513        }
514
515        events
516    }
517
518    fn activate_current_wave(&mut self) {
519        let wave = match self.waves.get(self.current_wave) {
520            Some(w) => w,
521            None    => { self.finished = true; return; }
522        };
523        let n = wave.groups.len();
524        self.group_states = vec![GroupState {
525            spawned: 0, killed: 0, timer: 0.0,
526            delay_done: false, delay_timer: 0.0, complete: false,
527        }; n];
528        self.active = true;
529    }
530
531    fn on_wave_cleared(&mut self, wave: &SpawnWave) {
532        self.active = false;
533        self.wave_count += 1;
534
535        if let Some(flag) = &wave.on_clear {
536            self.flags.insert(flag.clone(), true);
537        }
538
539        if wave.repeat {
540            self.wave_timer  = wave.pre_delay;
541            self.post_pending = true;
542            self.post_timer  = wave.post_delay;
543        } else {
544            self.post_pending = true;
545            self.post_timer  = wave.post_delay;
546        }
547    }
548
549    fn advance_wave(&mut self) {
550        if self.waves.get(self.current_wave).map(|w| w.repeat).unwrap_or(false) {
551            // Stay on same wave
552            self.wave_timer = self.waves[self.current_wave].pre_delay;
553        } else {
554            self.current_wave += 1;
555            if self.current_wave >= self.waves.len() {
556                self.finished = true;
557                return;
558            }
559            self.wave_timer = self.waves[self.current_wave].pre_delay;
560        }
561    }
562
563    pub fn current_wave_name(&self) -> &str {
564        self.waves.get(self.current_wave).map(|w| w.name.as_str()).unwrap_or("none")
565    }
566
567    pub fn total_waves(&self) -> usize { self.waves.len() }
568    pub fn is_active(&self) -> bool { self.active }
569    pub fn get_flag(&self, k: &str) -> bool { self.flags.get(k).copied().unwrap_or(false) }
570}
571
572// ── BlueprintLibrary ──────────────────────────────────────────────────────────
573
574/// Registry of named entity blueprints.
575#[derive(Default)]
576pub struct BlueprintLibrary {
577    pub blueprints: HashMap<String, EntityBlueprint>,
578}
579
580impl BlueprintLibrary {
581    pub fn new() -> Self { Self::default() }
582
583    pub fn register(&mut self, blueprint: EntityBlueprint) {
584        self.blueprints.insert(blueprint.name.clone(), blueprint);
585    }
586
587    pub fn get(&self, name: &str) -> Option<&EntityBlueprint> {
588        self.blueprints.get(name)
589    }
590
591    /// Register standard enemy blueprints.
592    pub fn with_defaults(mut self) -> Self {
593        self.register(EntityBlueprint::new("grunt")
594            .with_hp(60.0).with_speed(2.5).with_damage(8.0)
595            .with_color([0.8, 0.2, 0.2, 1.0]).with_glyph('g').tagged("enemy"));
596        self.register(EntityBlueprint::new("archer")
597            .with_hp(40.0).with_speed(2.0).with_damage(15.0)
598            .with_color([0.8, 0.5, 0.2, 1.0]).with_glyph('a').tagged("enemy"));
599        self.register(EntityBlueprint::new("tank")
600            .with_hp(200.0).with_speed(1.5).with_damage(25.0)
601            .with_color([0.5, 0.2, 0.8, 1.0]).with_glyph('T').tagged("enemy"));
602        self.register(EntityBlueprint::new("healer")
603            .with_hp(50.0).with_speed(2.0).with_damage(5.0)
604            .with_color([0.2, 0.9, 0.4, 1.0]).with_glyph('h').tagged("enemy"));
605        self.register(EntityBlueprint::new("boss")
606            .with_hp(1000.0).with_speed(3.5).with_damage(50.0)
607            .with_color([1.0, 0.1, 0.1, 1.0]).with_glyph('B').tagged("enemy").tagged("boss")
608            .with_attr("enrage_threshold", 0.3));
609        self
610    }
611}
612
613// ── Tests ─────────────────────────────────────────────────────────────────────
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn spawn_zone_point() {
621        let z   = SpawnZone::Point(Vec3::new(1.0, 2.0, 3.0));
622        let mut rng = 12345u64;
623        let p   = z.sample(&mut rng, Vec3::ZERO);
624        assert_eq!(p, Vec3::new(1.0, 2.0, 3.0));
625    }
626
627    #[test]
628    fn spawn_zone_sphere_bounded() {
629        let z   = SpawnZone::Sphere { center: Vec3::ZERO, radius: 5.0 };
630        let mut rng = 42u64;
631        for _ in 0..100 {
632            let p = z.sample(&mut rng, Vec3::ZERO);
633            assert!(p.length() <= 5.05, "Point outside sphere: {:?}", p);
634        }
635    }
636
637    #[test]
638    fn spawn_pattern_ring_count() {
639        let p = SpawnPattern::Ring { radius: 3.0, phase_offset: 0.0 };
640        let positions = p.positions(8, Vec3::ZERO, &mut 0u64);
641        assert_eq!(positions.len(), 8);
642    }
643
644    #[test]
645    fn spawn_pattern_grid() {
646        let p = SpawnPattern::Grid { cols: 3, spacing: Vec3::ONE };
647        let positions = p.positions(9, Vec3::ZERO, &mut 0u64);
648        assert_eq!(positions.len(), 9);
649    }
650
651    #[test]
652    fn blueprint_library_default() {
653        let lib = BlueprintLibrary::new().with_defaults();
654        assert!(lib.get("grunt").is_some());
655        assert!(lib.get("boss").is_some());
656        assert!(lib.get("nobody").is_none());
657    }
658
659    #[test]
660    fn wave_manager_starts_and_spawns() {
661        let lib = BlueprintLibrary::new().with_defaults();
662        let wave = SpawnWave::new("w1", vec![
663            SpawnGroup::new("grunt", 3, SpawnZone::Point(Vec3::ZERO))
664                .with_rate(0.0)   // all at once
665                .non_blocking(),
666        ]).with_pre_delay(0.0).with_post_delay(0.0);
667
668        let mut mgr = WaveManager::new(vec![wave], lib);
669        mgr.start();
670
671        // Tick past activation
672        let events = mgr.tick(0.016);
673        assert!(!events.is_empty(), "Expected spawn events");
674    }
675
676    #[test]
677    fn wave_manager_rate_spawn() {
678        let lib = BlueprintLibrary::new().with_defaults();
679        let wave = SpawnWave::new("w1", vec![
680            SpawnGroup::new("grunt", 10, SpawnZone::Point(Vec3::ZERO))
681                .with_rate(5.0)  // 5 per second
682                .non_blocking(),
683        ]).with_pre_delay(0.0).with_post_delay(0.0);
684
685        let mut mgr = WaveManager::new(vec![wave], lib);
686        mgr.start();
687
688        let mut total = 0;
689        for _ in 0..60 {
690            total += mgr.tick(1.0 / 60.0).len();
691        }
692        // In 1 second at 5/s, should spawn ~5
693        assert!(total >= 4 && total <= 6, "Expected ~5 spawns, got {}", total);
694    }
695
696    #[test]
697    fn wave_advances() {
698        let lib = BlueprintLibrary::new().with_defaults();
699        let w1 = SpawnWave::new("w1", vec![
700            SpawnGroup::new("grunt", 1, SpawnZone::Point(Vec3::ZERO))
701                .non_blocking(),
702        ]).with_pre_delay(0.0).with_post_delay(0.0);
703        let w2 = SpawnWave::new("w2", vec![
704            SpawnGroup::new("tank", 1, SpawnZone::Point(Vec3::ONE))
705                .non_blocking(),
706        ]).with_pre_delay(0.0).with_post_delay(0.0);
707
708        let mut mgr = WaveManager::new(vec![w1, w2], lib);
709        mgr.start();
710
711        // Drain wave 1
712        for _ in 0..30 { mgr.tick(0.1); }
713        assert_eq!(mgr.current_wave_name(), "w2");
714    }
715}