// The rogues of the later waves: the thief sneaks into a treasury and runs off with gold, the // monk shrugs off spells, the ranger lays snares in the dungeon's corridors and the inquisitor // now and then turns one of the weakest creatures (kobolds, spiders) to the heroes' side. use crate::game::*; use crate::map::*; // (kind, first wave, first campaign mission (index)) - the wave pool takes them from then on pub const ROGUES: [(EK, u32, usize); 4] = [(EK::Thief, 3, 4), (EK::Monk, 5, 8), (EK::Ranger, 7, 11), (EK::Inquisitor, 9, 15)]; // gold a thief can carry, and how fast he scoops it pub const THIEF_BAG: i32 = 250; const THIEF_GRAB: i32 = 25; // a ranger's snares: one every SNARE_EVERY seconds, at most MAX_SNARES on the map const SNARE_EVERY: f32 = 18.0; const MAX_SNARES: usize = 30; pub const SNARE_DAMAGE: f32 = 25.0; pub const SNARE_STUN: f32 = 3.0; // the inquisitor turns a weak creature every CONVERT_EVERY seconds, within CONVERT_RANGE tiles pub const CONVERT_EVERY: f32 = 20.0; const CONVERT_RANGE: f32 = 5.0; // spells do not touch a monk of the order pub fn spell_proof(e: &Ent) -> bool { e.hero && e.kind == EK::Monk } // the creatures an inquisitor can turn: only the weakest pub fn convertible(k: EK) -> bool { matches!(k, EK::Kobold | EK::Spider) } fn master_ground(t: &Tile) -> bool { Owner::MASTER_SIDES.contains(&t.owner.side()) } impl Game { // true: the rogue did all he wanted this step (the usual hero AI is skipped) pub(crate) fn rogue_ai(&mut self, e: &mut Ent, dt: f32) -> bool { if !e.active_hero() { return false; } match e.kind { EK::Thief => self.thief_ai(e, dt), EK::Ranger => { self.lay_snare(e); false } EK::Inquisitor => { self.convert_creature(e); false } _ => false, } } // ------------------------------------------------------------ thief fn thief_ai(&mut self, e: &mut Ent, dt: f32) -> bool { let cur = e.tile(); let full = e.carry >= THIEF_BAG; // a full bag (or nothing left to steal): back to the gate and away if full || (e.carry > 0 && !self.gold_to_steal(cur)) { if !self.has_gate() { return false; } let (gx, gy) = self.gate_center(); if (e.x - gx).powi(2) + (e.y - gy).powi(2) < 2.0 * 2.0 { e.alive = false; self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070); self.tell_local("msg.thief_escaped", vec![e.carry.to_string()], Some(Sfx::Error)); return true; } if e.path.is_empty() && e.repath <= 0.0 { e.repath = 1.5; if let Some(p) = self.map.path_to(cur, idx(gx as i32, gy as i32)) { e.path = p; } } self.step_path(e, dt); return true; } // in a treasury with gold: scoop it up let t = self.map.t[cur]; if t.kind == Kind::Treasury && t.gold > 0 && master_ground(&t) { e.path.clear(); e.working = true; if e.cd <= 0.0 { e.cd = 0.5; let want = THIEF_GRAB.min(THIEF_BAG - e.carry).min(t.gold); let got = self.steal_gold(t.owner.side(), cur, want); if got > 0 && e.carry == 0 && t.owner.side() == self.local_side { self.tell_local("msg.thief_in_treasury", vec![], Some(Sfx::WaveAlarm)); } e.carry += got; self.fx(5, e.x, e.y, 0.0, 0.0, 0.5, 0xff30d0ff); } return true; } // on the way to the nearest treasury with gold if e.path.is_empty() && e.repath <= 0.0 { e.repath = 2.0; let cost = self.map.hero_cost(); let found = self.map.search(cur, cost, |i| { let t = &self.map.t[i]; t.kind == Kind::Treasury && t.gold > 0 && master_ground(t) }, true); match found { Some(p) => e.path = p, None if e.carry > 0 => {} None => return false, // nothing to steal: he fights like the others } } if e.path.is_empty() { return e.carry > 0; } self.step_path(e, dt); true } // is there a treasury with gold anywhere a thief could reach from tile i? fn gold_to_steal(&self, i: usize) -> bool { let cost = self.map.hero_cost(); self.map.search(i, cost, |j| { let t = &self.map.t[j]; t.kind == Kind::Treasury && t.gold > 0 && master_ground(t) }, true).is_some() } // takes gold from the treasury tile i of the master on `side`; returns what was taken fn steal_gold(&mut self, side: u8, i: usize, want: i32) -> i32 { let me = self.me_side; if side != me { if !self.is_master(side) { return 0; } self.switch_to(side); } let take = (want as f32).min(self.gold).floor().max(0.0) as i32; self.gold -= take as f32; let t = &mut self.map.t[i]; t.gold = (t.gold - take).max(0); if side != me { self.switch_to(me); } take } // a slain thief drops his loot: it goes to whoever slew him (with the bounty) pub(crate) fn thief_loot(&self, id: u32) -> f32 { self.ents.iter().find(|e| e.id == id).map_or(0.0, |e| e.carry.max(0) as f32) } // ------------------------------------------------------------ ranger // a snare on the dungeon's ground under the ranger's feet, now and then fn lay_snare(&mut self, e: &mut Ent) { if e.skill_cd > 0.0 || self.snares.len() >= MAX_SNARES { return; } let i = e.tile(); let t = &self.map.t[i]; if !t.walkable() || !master_ground(t) || t.bridge() || self.snares.contains(&i) || self.traps.iter().any(|tr| tr.tile == i) { e.skill_cd = 2.0; return; } self.snares.push(i); e.skill_cd = SNARE_EVERY; self.fx(6, e.x, e.y, 0.0, 0.0, 0.4, 0xff3a5a7a); } // the shared step: a creature that steps into a snare is caught (hurt and stuck for a while) pub(crate) fn update_snares(&mut self) { if self.snares.is_empty() { return; } let mut k = 0; while k < self.snares.len() { let i = self.snares[k]; if !self.map.t[i].walkable() { self.snares.swap_remove(k); continue; } let caught = self.ents.iter().position(|e| { e.alive && !e.held && !e.hero && !e.chained && e.captive == 0 && !flies(e.kind) && Owner::MASTER_SIDES.contains(&e.side) && e.tile() == i }); let Some(j) = caught else { k += 1; continue; }; self.snares.swap_remove(k); let e = &mut self.ents[j]; e.stun = e.stun.max(SNARE_STUN); e.hp -= SNARE_DAMAGE; e.flash = 0.2; let (x, y, side, kind, dead) = (e.x, e.y, e.side, e.kind, e.hp <= 0.0); self.fx(2, x, y, 0.0, 0.0, 0.4, 0xff4040ff); self.sfx(Sfx::MeleeHit, x, y); if side == self.local_side { self.tell_local("msg.snare_caught", vec![format!("@{}", st(kind).name)], None); } if dead { self.kill(j); } } } // ------------------------------------------------------------ inquisitor // turns the nearest weak creature in sight to the heroes' side fn convert_creature(&mut self, e: &mut Ent) { if e.skill_cd > 0.0 { return; } e.skill_cd = 1.0; let target = self .ents .iter() .enumerate() .filter(|(_, o)| o.alive && !o.held && !o.hero && !o.chained && o.captive == 0 && convertible(o.kind) && Owner::MASTER_SIDES.contains(&o.side)) .map(|(j, o)| (j, (o.x - e.x).powi(2) + (o.y - e.y).powi(2))) .filter(|&(j, d2)| d2 < CONVERT_RANGE * CONVERT_RANGE && self.los(e.x, e.y, self.ents[j].x, self.ents[j].y)) .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) .map(|(j, _)| j); let Some(j) = target else { return }; e.skill_cd = CONVERT_EVERY; let goal = e.target; let (side, kind, id, x, y) = { let o = &self.ents[j]; (o.side, o.kind, o.id, o.x, o.y) }; self.release_all(id); let o = &mut self.ents[j]; o.hero = true; o.side = 1; o.job = Job::None; o.path.clear(); o.home = None; o.bed = None; o.guard = false; o.target = goal; o.waypoint = None; o.flash = 0.4; self.shot(e.x, e.y, 0.6, x, y, 0.45, 0.35, 0xff40d0ff); self.style_last_fx(crate::particles::ST_HOLY); self.fx(4, x, y, 0.0, 0.0, 0.9, 0xffffeecc); self.sfx(Sfx::Capture, x, y); if side == self.local_side { self.tell_local("msg.converted", vec![format!("@{}", st(kind).name)], Some(Sfx::WaveAlarm)); } } } #[cfg(test)] mod tests { use super::*; // a free game with an open dungeon around the core and no waves fn open_game() -> Game { let mut g = Game::new(5, 96, 96); g.gate_on = false; let (cx, cy) = g.map.core; for y in cy - 8..cy + 10 { for x in cx - 8..cx + 10 { let t = &mut g.map.t[idx(x, y)]; if t.kind != Kind::Core { t.kind = Kind::Floor; t.owner = Owner::Player; } } } g.ents.clear(); g.recount(); g } fn hero(g: &mut Game, k: EK, x: f32, y: f32) -> u32 { let id = g.spawn(k, true, x, y, 1); if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) { e.job = Job::None; } id } #[test] fn a_thief_steals_from_the_treasury() { let mut g = open_game(); let (cx, cy) = g.map.core; g.build_rect(0, cx + 4, cy + 4, cx + 6, cy + 6); g.gold = 3000.0; g.sync_treasury(); let gold0 = g.gold; let id = hero(&mut g, EK::Thief, cx as f32 - 5.5, cy as f32 + 5.5); let mut carried = 0; for _ in 0..(60 * 20) { g.state = 0; // no creatures in the test: it would count as over g.update(0.05); carried = g.ents.iter().find(|e| e.id == id).map_or(carried, |e| e.carry); if carried >= THIEF_BAG { break; } } assert!(carried > 0, "the thief took gold"); assert!(g.gold < gold0, "the master lost it: {} -> {}", gold0, g.gold); } #[test] fn monks_shrug_off_spells() { let mut g = open_game(); let (cx, cy) = g.map.core; let monk = hero(&mut g, EK::Monk, cx as f32 + 6.5, cy as f32 + 6.5); let knight = hero(&mut g, EK::Knight, cx as f32 + 6.5, cy as f32 + 7.0); g.mana = 400.0; g.lightning(cx as f32 + 6.5, cy as f32 + 6.7); let hp = |g: &Game, id: u32| g.ents.iter().find(|e| e.id == id).map_or(0.0, |e| e.hp); assert_eq!(hp(&g, monk), st(EK::Monk).hp, "the monk is untouched"); assert!(hp(&g, knight) < st(EK::Knight).hp, "the knight is not"); } #[test] fn a_rangers_snare_catches_a_creature() { let mut g = open_game(); let (cx, cy) = g.map.core; let r = hero(&mut g, EK::Ranger, cx as f32 + 6.5, cy as f32 + 6.5); let mut e = g.ents.iter().find(|e| e.id == r).unwrap().clone(); e.skill_cd = 0.0; g.lay_snare(&mut e); let i = idx(cx + 6, cy + 6); assert!(g.snares.contains(&i)); g.ents.retain(|e| e.id != r); let k = g.spawn(EK::Ogre, false, cx as f32 + 6.5, cy as f32 + 6.5, 1); g.update_snares(); let o = g.ents.iter().find(|e| e.id == k).unwrap(); assert!(o.stun > 0.0 && o.hp < st(EK::Ogre).hp, "caught"); assert!(g.snares.is_empty(), "a snare works once"); } #[test] fn the_inquisitor_turns_only_weak_creatures() { let mut g = open_game(); let (cx, cy) = g.map.core; let ogre = g.spawn(EK::Ogre, false, cx as f32 + 5.5, cy as f32 + 6.0, 1); let kobold = g.spawn(EK::Kobold, false, cx as f32 + 6.5, cy as f32 + 7.5, 1); let q = hero(&mut g, EK::Inquisitor, cx as f32 + 6.5, cy as f32 + 5.5); let mut e = g.ents.iter().find(|e| e.id == q).unwrap().clone(); e.skill_cd = 0.0; g.convert_creature(&mut e); let get = |g: &Game, id: u32| g.ents.iter().find(|e| e.id == id).unwrap().clone(); assert!(get(&g, kobold).hero, "the kobold now fights for the heroes"); assert!(!get(&g, ogre).hero, "an ogre is too strong to be turned"); assert_eq!(e.skill_cd, CONVERT_EVERY); // nothing else to turn: the ogre stays e.skill_cd = 0.0; g.convert_creature(&mut e); assert!(!get(&g, ogre).hero); } #[test] fn rogues_join_the_later_waves() { for (k, wave, mission) in ROGUES { assert!(wave >= 3 && mission >= 4, "{:?} comes late", k); } assert!(ROGUES.iter().any(|r| r.0 == EK::Inquisitor && r.1 == 9)); } }