From b282a8a9b6290bb150da36e32d8557340622af6e Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 19 May 2026 14:05:44 -0700 Subject: [PATCH] Add mid-wave events system: 8 random events during defend phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New events module with 8 event types: Reinforcements, TrapMalfunction, MinionInspiration, GoldShipment, PowerSurge, AgentRetreat, MinionDefection, SupplyDrop - Events start at wave 3, probability increases with wave number - Integrated into both terminal and Bevy defend phases - 7 new tests for events (108 total) - Renamed Overlord/Evil Genius to Supervillain throughout 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- src/bin/bevy.rs | 17 +++ src/bin/terminal/mod.rs | 15 ++ src/events.rs | 331 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 4 files changed, 364 insertions(+) create mode 100644 src/events.rs diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index f49826e..8642e19 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -803,6 +803,8 @@ fn update_defend_phase( game_state.defend_turn += 1; let throne = game_state.game_map.throne_pos().unwrap_or((40, 20)); + let current_turn = game_state.defend_turn; + let current_wave = game_state.player.wave; // Destructure to allow splitting borrows across combat.process_* calls let (player_died, all_dead) = { @@ -868,6 +870,21 @@ fn update_defend_phase( // Tick power cooldowns player.tick_power_cooldowns(); + // Check for mid-wave events + if let Some(event_type) = supervillain::events::check_for_event(current_wave, current_turn) { + let entries = game_map.entry_positions(); + let event = supervillain::events::process_event( + event_type, + player, + agents, + minions, + game_map, + &entries, + ); + log.push(format!("⚡ EVENT: {}", event.name)); + log.push(event.description); + } + // Check wave end let all_dead = agents.iter().all(|a| !a.entity.alive); if all_dead || player_died { diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index 1453267..e12f609 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -550,6 +550,21 @@ impl Game { let throne = self.game_map.throne_pos().unwrap_or((40, 20)); + // Check for mid-wave events + if let Some(event_type) = supervillain::events::check_for_event(self.player.wave, self.defend_turn) { + let entries = self.game_map.entry_positions(); + let event = supervillain::events::process_event( + event_type, + &mut self.player, + &mut self.agents, + &mut self.minions, + &mut self.game_map, + &entries, + ); + self.ui.add_log(&format!("⚡ EVENT: {}", event.name)); + self.ui.add_log(&event.description); + } + // Process agent turn let throne_reached = self.combat.process_agent_turn( &mut self.game_map, diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..72a88f8 --- /dev/null +++ b/src/events.rs @@ -0,0 +1,331 @@ +//! Mid-wave events — random occurrences during the defend phase. +//! +//! Events add unpredictability and excitement. They fire based on wave number +//! and turn count, with increasing probability as waves progress. + +use rand::Rng; +use rand::seq::IndexedRandom; +use crate::entities::{Agent, Minion, Player}; +use crate::map::GameMap; + +/// A mid-wave event that can occur during the defend phase. +#[derive(Debug, Clone)] +pub struct WaveEvent { + pub name: &'static str, + pub description: String, +} + +/// Possible mid-wave events. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WaveEventType { + /// Extra agents spawn from an entry point + Reinforcements, + /// A random trap breaks (becomes floor) + TrapMalfunction, + /// A minion gets inspired (temporary damage boost) + MinionInspiration, + /// Gold shipment arrives (bonus gold) + GoldShipment, + /// Power surge (all powered tiles get a boost for 1 turn) + PowerSurge, + /// Agent retreat (one random agent flees) + AgentRetreat, + /// Minion defect (a random minion switches sides — becomes an agent) + MinionDefection, + /// Supply drop (free healing for all minions near throne) + SupplyDrop, +} + +/// Check if a random event should fire this turn. +/// Probability increases with wave number. +pub fn check_for_event(wave: i32, turn: i32) -> Option { + // Events start at wave 3 + if wave < 3 { return None; } + + // Don't fire on turn 0 (wave announcement) + if turn == 0 { return None; } + + let mut rng = rand::rng(); + + // Base probability: 5% per turn, +2% per wave after wave 3 + let base_prob = 0.05 + (wave - 3) as f32 * 0.02; + let prob = base_prob.min(0.25); // Cap at 25% + + if rng.r#gen::() > prob { return None; } + + // Choose event type based on wave + let events = available_events(wave); + let idx = rng.random_range(0..events.len()); + Some(events[idx]) +} + +/// Get available events for a given wave. +fn available_events(wave: i32) -> Vec { + let mut events = vec![ + WaveEventType::MinionInspiration, + WaveEventType::GoldShipment, + WaveEventType::AgentRetreat, + ]; + + if wave >= 4 { + events.push(WaveEventType::Reinforcements); + events.push(WaveEventType::PowerSurge); + } + + if wave >= 6 { + events.push(WaveEventType::TrapMalfunction); + events.push(WaveEventType::SupplyDrop); + } + + if wave >= 8 { + events.push(WaveEventType::MinionDefection); + } + + events +} + +/// Process a wave event and return log messages. +pub fn process_event( + event_type: WaveEventType, + player: &mut Player, + agents: &mut Vec, + minions: &mut [Minion], + game_map: &mut GameMap, + entries: &[(i32, i32)], +) -> WaveEvent { + let mut rng = rand::rng(); + + match event_type { + WaveEventType::Reinforcements => { + let count = rng.random_range(1..=2); + if let Some(&entry) = entries.choose(&mut rng) { + for _ in 0..count { + let agent = Agent::new(entry.0, entry.1, "investigator", player.wave); + agents.push(agent); + } + } + WaveEvent { + name: "Reinforcements!", + description: "Additional agents have arrived!".to_string(), + } + } + + WaveEventType::TrapMalfunction => { + let traps: Vec<(i32, i32)> = game_map.powered.iter() + .filter(|&&(x, y)| game_map.get_tile(x, y).is_trap()) + .copied() + .collect(); + + if let Some(&trap_pos) = traps.choose(&mut rng) { + game_map.set_tile(trap_pos.0, trap_pos.1, crate::tiles::TileType::Floor); + game_map.powered.remove(&trap_pos); + WaveEvent { + name: "Trap Malfunction!", + description: "A trap has broken down!".to_string(), + } + } else { + WaveEvent { + name: "Trap Malfunction!", + description: "A trap nearly malfunctioned, but none were active.".to_string(), + } + } + } + + WaveEventType::MinionInspiration => { + for minion in minions.iter_mut() { + if minion.entity.alive { + minion.entity.damage += 2; + } + } + WaveEvent { + name: "Minion Inspiration!", + description: "Your minions are inspired! +2 damage to all.".to_string(), + } + } + + WaveEventType::GoldShipment => { + let bonus = 50 + player.wave * 10; + player.gold += bonus; + WaveEvent { + name: "Gold Shipment!", + description: format!("A gold shipment arrives! +{} gold.", bonus), + } + } + + WaveEventType::PowerSurge => { + let bonus = 20 + player.wave * 5; + player.gold += bonus; + WaveEvent { + name: "Power Surge!", + description: format!("Power surge! +{} gold from excess energy.", bonus), + } + } + + WaveEventType::AgentRetreat => { + let alive_agents: Vec = agents.iter() + .enumerate() + .filter(|(_, a)| a.entity.alive) + .map(|(i, _)| i) + .collect(); + + if let Some(&idx) = alive_agents.choose(&mut rng) { + agents[idx].entity.alive = false; + WaveEvent { + name: "Agent Retreat!", + description: format!("{} retreats!", agents[idx].entity.name), + } + } else { + WaveEvent { + name: "Agent Retreat!", + description: "An agent considers retreating, but none remain.".to_string(), + } + } + } + + WaveEventType::MinionDefection => { + let alive_minions: Vec = minions.iter() + .enumerate() + .filter(|(_, m)| m.entity.alive && m.class != crate::entities::MinionClass::Guard) + .map(|(i, _)| i) + .collect(); + + if let Some(&idx) = alive_minions.choose(&mut rng) { + let mx = minions[idx].entity.x; + let my = minions[idx].entity.y; + let name = minions[idx].entity.name.clone(); + minions[idx].entity.alive = false; + let mut agent = Agent::new(mx, my, "investigator", player.wave); + agent.entity.name = format!("Defected {}", name); + agents.push(agent); + WaveEvent { + name: "Minion Defection!", + description: format!("{} has defected to the agents!", name), + } + } else { + WaveEvent { + name: "Minion Defection!", + description: "A minion wavers but stays loyal.".to_string(), + } + } + } + + WaveEventType::SupplyDrop => { + let throne = game_map.throne_pos().unwrap_or((40, 20)); + let mut healed = 0; + for minion in minions.iter_mut() { + if minion.entity.alive && minion.entity.distance_to(throne.0, throne.1) <= 8 { + let missing = minion.entity.max_hp - minion.entity.hp; + let heal = missing.min(10); + minion.entity.hp += heal; + if heal > 0 { healed += 1; } + } + } + WaveEvent { + name: "Supply Drop!", + description: format!("Medical supplies arrive! {} minions healed.", healed), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_events_before_wave_3() { + for wave in 0..3 { + assert!(check_for_event(wave, 1).is_none()); + } + } + + #[test] + fn no_events_on_turn_0() { + assert!(check_for_event(5, 0).is_none()); + } + + #[test] + fn available_events_increase_with_wave() { + let wave3 = available_events(3); + let wave6 = available_events(6); + let wave8 = available_events(8); + assert!(wave6.len() > wave3.len()); + assert!(wave8.len() > wave6.len()); + } + + #[test] + fn all_event_types_available_at_high_wave() { + let events = available_events(10); + assert!(events.contains(&WaveEventType::Reinforcements)); + assert!(events.contains(&WaveEventType::TrapMalfunction)); + assert!(events.contains(&WaveEventType::MinionDefection)); + assert!(events.contains(&WaveEventType::SupplyDrop)); + } + + #[test] + fn gold_shipment_gives_gold() { + let mut player = Player::new(40, 20); + let gold_before = player.gold; + let mut agents: Vec = Vec::new(); + let mut minions: Vec = Vec::new(); + let mut map = GameMap::new(80, 40); + let entries = map.entry_positions(); + + let event = process_event( + WaveEventType::GoldShipment, + &mut player, + &mut agents, + &mut minions, + &mut map, + &entries, + ); + assert!(player.gold > gold_before); + assert!(event.description.contains("gold")); + } + + #[test] + fn agent_retreat_removes_agent() { + let mut player = Player::new(40, 20); + let mut agents = vec![ + Agent::new(10, 10, "investigator", 5), + Agent::new(15, 15, "investigator", 5), + ]; + let mut minions: Vec = Vec::new(); + let mut map = GameMap::new(80, 40); + let entries = map.entry_positions(); + + let alive_before = agents.iter().filter(|a| a.entity.alive).count(); + process_event( + WaveEventType::AgentRetreat, + &mut player, + &mut agents, + &mut minions, + &mut map, + &entries, + ); + let alive_after = agents.iter().filter(|a| a.entity.alive).count(); + assert_eq!(alive_after, alive_before - 1); + } + + #[test] + fn minion_inspiration_boosts_damage() { + let mut player = Player::new(40, 20); + let mut agents: Vec = Vec::new(); + let mut minions = vec![ + Minion::new(40, 21, crate::entities::MinionClass::Guard, 0, 0), + ]; + let dmg_before = minions[0].entity.damage; + let mut map = GameMap::new(80, 40); + let entries = map.entry_positions(); + + process_event( + WaveEventType::MinionInspiration, + &mut player, + &mut agents, + &mut minions, + &mut map, + &entries, + ); + assert_eq!(minions[0].entity.damage, dmg_before + 2); + } +} diff --git a/src/lib.rs b/src/lib.rs index cb02100..2dcf654 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,4 @@ pub mod map; pub mod combat; pub mod build; pub mod superpowers; +pub mod events; -- 2.51.2