Skip to main content

proof_engine/procedural/
spawn.rs

1//! Spawn tables — weighted creature/item spawn selection.
2//!
3//! Provides tiered, depth-scaled spawn tables used by dungeon floors.
4//! Each spawn table entry has:
5//! - A weight (higher = more common)
6//! - A minimum depth (won't appear before this floor)
7//! - An optional maximum depth (won't appear after this floor)
8//! - An optional group tag for filtering by category
9
10use super::Rng;
11
12// ── SpawnTier ─────────────────────────────────────────────────────────────────
13
14/// Rarity tier of a spawn entry.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum SpawnTier {
17    Common,
18    Uncommon,
19    Rare,
20    Epic,
21    Legendary,
22    Boss,
23}
24
25impl SpawnTier {
26    /// Base weight multiplier for this tier.
27    pub fn base_weight(self) -> f32 {
28        match self {
29            SpawnTier::Common    => 100.0,
30            SpawnTier::Uncommon  => 40.0,
31            SpawnTier::Rare      => 15.0,
32            SpawnTier::Epic      => 5.0,
33            SpawnTier::Legendary => 1.5,
34            SpawnTier::Boss      => 1.0,
35        }
36    }
37
38    /// Name string.
39    pub fn name(self) -> &'static str {
40        match self {
41            SpawnTier::Common    => "Common",
42            SpawnTier::Uncommon  => "Uncommon",
43            SpawnTier::Rare      => "Rare",
44            SpawnTier::Epic      => "Epic",
45            SpawnTier::Legendary => "Legendary",
46            SpawnTier::Boss      => "Boss",
47        }
48    }
49
50    /// Display colour for this tier.
51    pub fn color(self) -> glam::Vec4 {
52        match self {
53            SpawnTier::Common    => glam::Vec4::new(0.8, 0.8, 0.8, 1.0),
54            SpawnTier::Uncommon  => glam::Vec4::new(0.0, 1.0, 0.2, 1.0),
55            SpawnTier::Rare      => glam::Vec4::new(0.2, 0.5, 1.0, 1.0),
56            SpawnTier::Epic      => glam::Vec4::new(0.7, 0.0, 1.0, 1.0),
57            SpawnTier::Legendary => glam::Vec4::new(1.0, 0.6, 0.0, 1.0),
58            SpawnTier::Boss      => glam::Vec4::new(1.0, 0.0, 0.0, 1.0),
59        }
60    }
61}
62
63// ── SpawnEntry ────────────────────────────────────────────────────────────────
64
65/// One entry in a spawn table.
66#[derive(Debug, Clone)]
67pub struct SpawnEntry {
68    /// Unique identifier (e.g., "skeleton_archer").
69    pub id:        String,
70    /// Display name.
71    pub name:      String,
72    /// Selection weight (positive).
73    pub weight:    f32,
74    /// Rarity tier.
75    pub tier:      SpawnTier,
76    /// Minimum dungeon depth to appear.
77    pub min_depth: u32,
78    /// Maximum dungeon depth (u32::MAX = no limit).
79    pub max_depth: u32,
80    /// Category tags (e.g., "undead", "melee", "ranged").
81    pub tags:      Vec<String>,
82    /// Group count: how many of this type spawn at once (min, max).
83    pub group:     (u32, u32),
84    /// Scaled properties: (base_hp, base_damage, base_xp).
85    pub stats:     (f32, f32, f32),
86}
87
88impl SpawnEntry {
89    pub fn new(id: impl Into<String>, name: impl Into<String>, tier: SpawnTier) -> Self {
90        let id = id.into();
91        let name = name.into();
92        let weight = tier.base_weight();
93        Self {
94            id, name, weight, tier,
95            min_depth: 1,
96            max_depth: u32::MAX,
97            tags:  Vec::new(),
98            group: (1, 1),
99            stats: (10.0, 2.0, 5.0),
100        }
101    }
102
103    pub fn with_depth(mut self, min: u32, max: u32) -> Self {
104        self.min_depth = min; self.max_depth = max; self
105    }
106
107    pub fn with_weight(mut self, w: f32) -> Self { self.weight = w; self }
108
109    pub fn with_group(mut self, min: u32, max: u32) -> Self {
110        self.group = (min, max); self
111    }
112
113    pub fn with_tags(mut self, tags: &[&str]) -> Self {
114        self.tags = tags.iter().map(|&s| s.to_string()).collect(); self
115    }
116
117    pub fn with_stats(mut self, hp: f32, dmg: f32, xp: f32) -> Self {
118        self.stats = (hp, dmg, xp); self
119    }
120
121    /// Is this entry valid for the given depth?
122    pub fn valid_for_depth(&self, depth: u32) -> bool {
123        depth >= self.min_depth && depth <= self.max_depth
124    }
125
126    /// Does this entry have the given tag?
127    pub fn has_tag(&self, tag: &str) -> bool {
128        self.tags.iter().any(|t| t == tag)
129    }
130
131    /// Compute depth-scaled stats: hp and xp scale with depth.
132    pub fn scaled_stats(&self, depth: u32) -> (f32, f32, f32) {
133        let scale = 1.0 + (depth as f32 - 1.0) * 0.15;
134        let (hp, dmg, xp) = self.stats;
135        (hp * scale, dmg * (1.0 + (depth as f32 - 1.0) * 0.08), xp * scale)
136    }
137}
138
139// ── SpawnResult ───────────────────────────────────────────────────────────────
140
141/// Result of a spawn roll.
142#[derive(Debug, Clone)]
143pub struct SpawnResult {
144    pub entry:   SpawnEntry,
145    pub count:   u32,
146    pub position: Option<(i32, i32)>,
147}
148
149// ── SpawnTable ────────────────────────────────────────────────────────────────
150
151/// A weighted spawn table.
152///
153/// Usage:
154/// ```text
155/// let mut table = SpawnTable::new();
156/// table.add(SpawnEntry::new("goblin", "Goblin", SpawnTier::Common));
157/// table.add(SpawnEntry::new("orc", "Orc", SpawnTier::Uncommon).with_depth(3, u32::MAX));
158/// let result = table.roll(&mut rng, 5);
159/// ```
160#[derive(Debug, Clone, Default)]
161pub struct SpawnTable {
162    entries: Vec<SpawnEntry>,
163}
164
165impl SpawnTable {
166    pub fn new() -> Self { Self { entries: Vec::new() } }
167
168    pub fn add(&mut self, entry: SpawnEntry) -> &mut Self {
169        self.entries.push(entry);
170        self
171    }
172
173    /// Add multiple entries from a slice.
174    pub fn add_many(&mut self, entries: Vec<SpawnEntry>) -> &mut Self {
175        self.entries.extend(entries);
176        self
177    }
178
179    /// Roll for a single spawn at `depth`, optionally filtered by `tag`.
180    pub fn roll_one(&self, rng: &mut Rng, depth: u32, tag: Option<&str>) -> Option<&SpawnEntry> {
181        let valid: Vec<(&SpawnEntry, f32)> = self.entries.iter()
182            .filter(|e| e.valid_for_depth(depth))
183            .filter(|e| tag.map_or(true, |t| e.has_tag(t)))
184            .map(|e| (e, e.weight))
185            .collect();
186        rng.pick_weighted(&valid).copied()
187    }
188
189    /// Roll for `n` spawns at `depth`. May return fewer if table is small.
190    pub fn roll(&self, rng: &mut Rng, n: usize, depth: u32) -> Vec<SpawnResult> {
191        (0..n).filter_map(|_| {
192            self.roll_one(rng, depth, None).map(|entry| {
193                let count = rng.range_i32(entry.group.0 as i32, entry.group.1 as i32) as u32;
194                SpawnResult { entry: entry.clone(), count, position: None }
195            })
196        }).collect()
197    }
198
199    /// Roll guaranteeing at least one entry from each tier in `tiers`.
200    pub fn roll_guaranteed(&self, rng: &mut Rng, depth: u32, tiers: &[SpawnTier]) -> Vec<SpawnResult> {
201        let mut results = Vec::new();
202        for &tier in tiers {
203            let valid: Vec<(&SpawnEntry, f32)> = self.entries.iter()
204                .filter(|e| e.tier == tier && e.valid_for_depth(depth))
205                .map(|e| (e, e.weight))
206                .collect();
207            if let Some(entry) = rng.pick_weighted(&valid).copied() {
208                let count = rng.range_i32(entry.group.0 as i32, entry.group.1 as i32) as u32;
209                results.push(SpawnResult { entry: entry.clone(), count, position: None });
210            }
211        }
212        results
213    }
214
215    /// Get all entries with a given tag.
216    pub fn by_tag(&self, tag: &str) -> Vec<&SpawnEntry> {
217        self.entries.iter().filter(|e| e.has_tag(tag)).collect()
218    }
219
220    /// Get entries valid for depth, sorted by weight descending.
221    pub fn available_at_depth(&self, depth: u32) -> Vec<&SpawnEntry> {
222        let mut entries: Vec<&SpawnEntry> = self.entries.iter()
223            .filter(|e| e.valid_for_depth(depth))
224            .collect();
225        entries.sort_by(|a, b| b.weight.partial_cmp(&a.weight).unwrap());
226        entries
227    }
228
229    pub fn len(&self) -> usize { self.entries.len() }
230    pub fn is_empty(&self) -> bool { self.entries.is_empty() }
231}
232
233// ── Default spawn tables ───────────────────────────────────────────────────────
234
235/// Build the default creature spawn table for chaos-rpg.
236pub fn chaos_rpg_creatures() -> SpawnTable {
237    let mut t = SpawnTable::new();
238
239    // Floor 1-3: weak undead
240    t.add(SpawnEntry::new("skeleton",       "Skeleton",        SpawnTier::Common)
241           .with_depth(1, 8).with_group(1, 3).with_tags(&["undead", "melee"])
242           .with_stats(8.0, 2.0, 4.0));
243    t.add(SpawnEntry::new("zombie",         "Zombie",          SpawnTier::Common)
244           .with_depth(1, 6).with_group(1, 2).with_tags(&["undead", "melee"])
245           .with_stats(15.0, 1.5, 3.0));
246    t.add(SpawnEntry::new("skeleton_archer","Skeleton Archer",  SpawnTier::Uncommon)
247           .with_depth(2, 10).with_group(1, 2).with_tags(&["undead", "ranged"])
248           .with_stats(6.0, 3.0, 6.0));
249
250    // Floor 3-7: mid-tier
251    t.add(SpawnEntry::new("cave_troll",     "Cave Troll",      SpawnTier::Uncommon)
252           .with_depth(3, 12).with_group(1, 1).with_tags(&["beast", "melee"])
253           .with_stats(30.0, 5.0, 15.0));
254    t.add(SpawnEntry::new("shadow_wraith",  "Shadow Wraith",   SpawnTier::Rare)
255           .with_depth(4, 15).with_group(1, 1).with_tags(&["undead", "shadow", "melee"])
256           .with_stats(20.0, 6.0, 25.0));
257    t.add(SpawnEntry::new("chaos_imp",      "Chaos Imp",       SpawnTier::Uncommon)
258           .with_depth(3, 20).with_group(2, 4).with_tags(&["demon", "chaos"])
259           .with_stats(5.0, 4.0, 8.0));
260
261    // Floor 5+: strong
262    t.add(SpawnEntry::new("stone_golem",    "Stone Golem",     SpawnTier::Rare)
263           .with_depth(5, u32::MAX).with_group(1, 1).with_tags(&["construct", "melee"])
264           .with_stats(50.0, 8.0, 40.0));
265    t.add(SpawnEntry::new("lich",           "Lich",            SpawnTier::Epic)
266           .with_depth(6, u32::MAX).with_group(1, 1).with_tags(&["undead", "caster"])
267           .with_stats(40.0, 12.0, 80.0));
268    t.add(SpawnEntry::new("void_stalker",   "Void Stalker",    SpawnTier::Rare)
269           .with_depth(7, u32::MAX).with_group(1, 2).with_tags(&["void", "ranged"])
270           .with_stats(25.0, 10.0, 55.0));
271
272    // Rare elites
273    t.add(SpawnEntry::new("chaos_champion", "Chaos Champion",  SpawnTier::Epic)
274           .with_depth(8, u32::MAX).with_group(1, 1).with_tags(&["demon", "melee", "elite"])
275           .with_stats(80.0, 15.0, 120.0));
276    t.add(SpawnEntry::new("elder_dragon",   "Elder Dragon",    SpawnTier::Legendary)
277           .with_depth(10, u32::MAX).with_group(1, 1).with_tags(&["dragon", "elite"])
278           .with_stats(200.0, 25.0, 500.0));
279
280    // Boss-only
281    t.add(SpawnEntry::new("bone_king",      "Bone King",       SpawnTier::Boss)
282           .with_depth(3, 3).with_group(1, 1).with_tags(&["undead", "boss"])
283           .with_stats(120.0, 18.0, 300.0));
284    t.add(SpawnEntry::new("chaos_archon",   "Chaos Archon",    SpawnTier::Boss)
285           .with_depth(6, 6).with_group(1, 1).with_tags(&["demon", "chaos", "boss"])
286           .with_stats(250.0, 30.0, 800.0));
287    t.add(SpawnEntry::new("void_sovereign", "Void Sovereign",  SpawnTier::Boss)
288           .with_depth(10, 10).with_group(1, 1).with_tags(&["void", "boss"])
289           .with_stats(500.0, 50.0, 2000.0));
290
291    t
292}
293
294/// Build the default item spawn table.
295pub fn chaos_rpg_items() -> SpawnTable {
296    let mut t = SpawnTable::new();
297
298    t.add(SpawnEntry::new("health_potion",  "Health Potion",   SpawnTier::Common)
299           .with_stats(0.0, 0.0, 0.0));
300    t.add(SpawnEntry::new("mana_potion",    "Mana Potion",     SpawnTier::Common));
301    t.add(SpawnEntry::new("iron_sword",     "Iron Sword",      SpawnTier::Common)
302           .with_depth(1, 4));
303    t.add(SpawnEntry::new("steel_sword",    "Steel Sword",     SpawnTier::Uncommon)
304           .with_depth(3, 8));
305    t.add(SpawnEntry::new("chaos_shard",    "Chaos Shard",     SpawnTier::Rare)
306           .with_depth(4, u32::MAX));
307    t.add(SpawnEntry::new("void_crystal",   "Void Crystal",    SpawnTier::Epic)
308           .with_depth(7, u32::MAX));
309    t.add(SpawnEntry::new("amulet_of_chaos","Amulet of Chaos", SpawnTier::Legendary)
310           .with_depth(9, u32::MAX));
311
312    t
313}