// Rival dungeon masters (simplified): a dungeon around their own soul core that grows over // time, creatures that appear there, guard it and attack in groups - the player, the other // master or the humans. A master whose core falls is out; its ground becomes free. use crate::game::*; use crate::map::*; use serde::{Deserialize, Serialize}; pub const RIVAL_CORE_HP: f32 = 2000.0; // a co-op AI partner helps masters short of gold: every minute it may send some pub const PARTNER_GIFT_EVERY: f32 = 60.0; pub const PARTNER_GIFT_BELOW: f32 = 300.0; pub const PARTNER_GIFT: f32 = 400.0; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Rival { pub side: u8, // 2 (green) or 3 (blue) pub core: (i32, i32), // top-left corner of the 3x3 core pub hp: f32, pub alive: bool, pub radius: i32, // size of the dungeon around the core pub grow_t: f32, pub spawn_t: f32, pub attack_t: f32, pub difficulty: u8, // 0 easy, 1 medium, 2 hard pub power: f32, // grows with the time: level of the new creatures #[serde(default)] pub gift_t: f32, // co-op AI partner: seconds until it looks whether its allies need gold } struct Pace { spawn: f32, // seconds between new creatures grow: f32, // seconds between growth steps attack: f32, // seconds between attacks cap: usize, // most creatures at a time max_radius: i32, // largest dungeon power: f32, // levels per minute } fn pace(d: u8) -> Pace { match d { 0 => Pace { spawn: 55.0, grow: 45.0, attack: 330.0, cap: 6, max_radius: 8, power: 0.25 }, 2 => Pace { spawn: 26.0, grow: 22.0, attack: 170.0, cap: 13, max_radius: 13, power: 0.6 }, _ => Pace { spawn: 38.0, grow: 30.0, attack: 240.0, cap: 9, max_radius: 11, power: 0.4 }, } } impl Rival { pub fn centre(&self) -> (f32, f32) { (self.core.0 as f32 + 1.5, self.core.1 as f32 + 1.5) } } impl Game { // Free game: one opponent per map up to 128 x 128 tiles, two on bigger maps; with a chance of // one in two one of them is the humans' gate instead of a rival master pub fn new_free(seed: u32, w: usize, h: usize, difficulty: u8) -> Game { let opponents = if w * h > 128 * 128 { 2 } else { 1 }; let mut rng = Rng((seed ^ 0x9e37_79b9) | 1); for _ in 0..5 { rng.next(); } let humans = rng.f() < 0.5; let rivals = if humans { opponents - 1 } else { opponents }; let opts = GenOpts { gate: humans, ..GenOpts::default() }; let map = Map::generate_with(seed, w, h, &opts); let mut g = Game::from_map(map, seed); g.gate_on = humans; if humans { let (cx, cy) = g.map.core; let (gx, gy) = g.map.gate; let dist = ((gx - cx).abs() + (gy - cy).abs()) as f32; g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0); } else { g.gate_hp = 0.0; } g.guard_treasures(); // the rival dungeons on the far side, away from the gate let (wi, hi) = (g.map.w as i32, g.map.h as i32); let x = wi - 14; let spots: Vec<(i32, i32)> = match (rivals, humans) { (1, false) => vec![(x, hi / 2)], (1, true) => vec![(x, if g.map.gate.1 < hi / 2 { hi * 3 / 4 } else { hi / 4 })], (2, _) => vec![(x, hi / 4), (x, hi * 3 / 4)], _ => Vec::new(), }; for (k, &(rx, ry)) in spots.iter().enumerate() { g.add_rival(2 + k as u8, rx, ry.clamp(8, hi - 9), difficulty); } g.place_all_torches(); g.msg("msg.welcome1", vec![]); g.msg("msg.welcome2", vec![]); g.msg(if rivals == 0 { "msg.welcome_humans" } else if humans { "msg.welcome_both" } else { "msg.welcome_rivals" }, vec![rivals.to_string()]); g.add_wilds(seed, true); g.init_research(None); g.init_fog(); g.place_dwarf_caves(seed); g } pub fn rival_alive(&self, side: u8) -> bool { self.rivals.iter().any(|r| r.side == side && r.alive) } // sets up a rival dungeon around (cx, cy) pub fn add_rival(&mut self, side: u8, cx: i32, cy: i32, difficulty: u8) { let owner = Owner::of_side(side); let core = (cx - 1, cy - 1); let r = 4; for y in cy - r..=cy + r { for x in cx - r..=cx + r { if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 { continue; } let t = &mut self.map.t[idx(x, y)]; if matches!(t.kind, Kind::Portal | Kind::Gate | Kind::Core) { continue; // portals and other cores stay } t.kind = Kind::Floor; t.owner = owner; t.gold = 0; t.marked = false; t.reinforced = false; } } for y in 0..3 { for x in 0..3 { let t = &mut self.map.t[idx(core.0 + x, core.1 + y)]; t.kind = Kind::Core; t.owner = owner; } } // a lair and a treasury (the player can take it) for y in cy - 3..=cy + 3 { let t = &mut self.map.t[idx(cx - 3, y)]; t.kind = Kind::Lair; } for y in cy - 1..=cy + 1 { let t = &mut self.map.t[idx(cx + 3, y)]; t.kind = Kind::Treasury; t.gold = 300; t.res = 0; } self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: r, grow_t: 20.0, spawn_t: 5.0, attack_t: pace(difficulty).attack, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY }); for k in 0..3 { self.spawn_rival_unit(side, EK::Kobold, 1, k as f32); } } fn spawn_rival_unit(&mut self, side: u8, kind: EK, level: u32, k: f32) { let Some(rv) = self.rivals.iter().find(|r| r.side == side) else { return }; let (cx, cy) = rv.centre(); let a = k * 1.7 + self.rng.f() * 2.0; let (x, y) = (cx + a.cos() * 2.4, cy + a.sin() * 2.4); let id = self.spawn(kind, false, x, y, level); if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { e.side = side; e.home = Some((cx, cy)); } } pub fn update_rivals(&mut self, dt: f32) { for k in 0..self.rivals.len() { if !self.rivals[k].alive { continue; } let p = pace(self.rivals[k].difficulty); let side = self.rivals[k].side; { let r = &mut self.rivals[k]; r.power += dt / 60.0 * p.power; r.grow_t -= dt; r.spawn_t -= dt; r.attack_t -= dt; } // a co-op partner sends gold to allied masters who run short if self.is_partner(side) { self.rivals[k].gift_t -= dt; if self.rivals[k].gift_t <= 0.0 { self.rivals[k].gift_t = PARTNER_GIFT_EVERY; self.partner_gift(side); } } // growth: one more ring of ground (no imps needed) if self.rivals[k].grow_t <= 0.0 { self.rivals[k].grow_t = p.grow * (0.8 + self.rng.f() * 0.4); if self.rivals[k].radius < p.max_radius { self.rivals[k].radius += 1; self.rival_grow(k); } } // new creatures let units = self.ents.iter().filter(|e| e.alive && e.side == side && !e.hero).count(); if self.rivals[k].spawn_t <= 0.0 { self.rivals[k].spawn_t = p.spawn * (0.8 + self.rng.f() * 0.4); if units < p.cap { let power = self.rivals[k].power; let mut pool = vec![EK::Kobold, EK::Spider]; if power >= 1.5 { pool.extend([EK::Mage, EK::Ogre]); } if power >= 3.0 { pool.push(EK::Gargoyle); } let kind = pool[(self.rng.next() as usize) % pool.len()]; let level = (1.0 + power) as u32; self.spawn_rival_unit(side, kind, level.clamp(1, 10), units as f32); } } // attack: about two thirds of the creatures march on an enemy if self.rivals[k].attack_t <= 0.0 { self.rivals[k].attack_t = p.attack * (0.8 + self.rng.f() * 0.4); let mut targets: Vec<(u8, u32)> = Vec::new(); if self.coop { if self.gate_on || self.ents.iter().any(|e| e.alive && e.hero) { targets.push((1, 3)); } } else { // the masters (the player, or the human masters of a match) that are not allies for s in self.master_sides() { if !self.is_out(s) && !self.allied(side, s) { targets.push((s, 3)); } } for o in &self.rivals { if o.alive && o.side != side && !self.allied(side, o.side) { targets.push((o.side, 1)); } } if self.has_gate() && self.gate_hp > 0.0 { targets.push((1, 1)); } } let total: u32 = targets.iter().map(|t| t.1).sum(); if total == 0 { continue; } let mut r = self.rng.next() % total; let mut target = 0; for (s, w) in targets { if r < w { target = s; break; } r -= w; } let mut n = 0; for e in self.ents.iter_mut() { if e.alive && !e.hero && e.side == side && e.target.is_none() && n * 3 < units * 2 { e.target = Some(target); e.path.clear(); n += 1; } } if n > 0 && target == 0 { self.msg(if side == 2 { "msg.rival1_attack" } else { "msg.rival2_attack" }, vec![n.to_string()]); self.sfx_ui(Sfx::WaveAlarm); } } } } // a ring of earth around the rival dungeon becomes its ground fn rival_grow(&mut self, k: usize) { let rv = self.rivals[k].clone(); let owner = Owner::of_side(rv.side); let (cx, cy) = (rv.core.0 + 1, rv.core.1 + 1); let r = rv.radius; for y in cy - r..=cy + r { for x in cx - r..=cx + r { if (x - cx).abs() != r && (y - cy).abs() != r { continue; } if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 { continue; } let t = self.map.t[idx(x, y)]; let free = matches!(t.kind, Kind::Earth | Kind::Floor) && !t.reinforced && t.owner == Owner::None; let touches = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == owner && n.walkable())); if free && touches { let tl = &mut self.map.t[idx(x, y)]; tl.kind = Kind::Floor; tl.owner = owner; tl.gold = 0; tl.marked = false; } } } } // damage to the core of a rival master pub fn rival_core_hit(&mut self, side: u8, dmg: f32) { let Some(k) = self.rivals.iter().position(|r| r.side == side && r.alive) else { return }; self.rivals[k].hp -= dmg; let (x, y) = self.rivals[k].centre(); self.fx(2, x, y, 0.0, 0.0, 0.3, 0xff4040ff); self.sfx(Sfx::CoreHit, x, y); if self.rivals[k].hp <= 0.0 { self.eliminate_rival(k); } } pub(crate) fn eliminate_rival(&mut self, k: usize) { let side = self.rivals[k].side; let owner = Owner::of_side(side); self.rivals[k].alive = false; self.rivals[k].hp = 0.0; let (x, y) = self.rivals[k].centre(); self.fx(1, x, y, 0.0, 0.0, 1.0, 0xffffffff); self.sfx(Sfx::Victory, x, y); let key = if self.is_partner(side) { "msg.partner_out" } else if side == 2 { "msg.rival1_out" } else { "msg.rival2_out" }; self.msg(key, vec![]); // its creatures scatter and vanish, its ground is free for e in self.ents.iter_mut() { if e.alive && !e.hero && e.side == side { e.alive = false; } } for t in self.map.t.iter_mut() { if t.owner == owner { if t.kind == Kind::Core { t.kind = Kind::Floor; } t.owner = Owner::None; } } } // where an attacker of `side`'s enemy is heading: the core (or gate) of the target side fn target_core_adjacent(&self, i: usize, target: u8) -> bool { match target { 1 => { let (x, y) = xy(i); DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |t| t.kind == Kind::Gate)) } s => self.map.adj_core_of(i, Owner::of_side(s)), } } fn target_alive(&self, target: u8) -> bool { match target { 1 => self.has_gate() && self.gate_hp > 0.0, s if self.is_master(s) => !self.is_out(s) && self.core_hp_of(s) > 0.0, s => self.rival_alive(s), } } // hit the core (or gate) of the target side pub fn hit_target_core(&mut self, e: &mut Ent, target: u8) { let dmg = e.atk(); e.cd = st(e.kind).interval; match target { s if self.is_master(s) => { self.damage_master_core(s, dmg); let (cx, cy) = self.core_of_side(s); self.fx(2, (e.x + cx as f32 + 1.5) * 0.5, (e.y + cy as f32 + 1.5) * 0.5, 0.0, 0.0, 0.3, 0xff4040ff); self.sfx(Sfx::CoreHit, e.x, e.y); self.hint(6, "hint.core_attacked"); } 1 => { self.gate_hp -= dmg; self.gate_hit_t = self.time; let (gx, gy) = self.gate_center(); self.sfx(Sfx::GateHit, gx, gy); } s => self.rival_core_hit(s, dmg), } } // the march of an attacker on a core; false = no target (any more) pub fn march_on(&mut self, e: &mut Ent, dt: f32) -> bool { let Some(target) = e.target else { return false }; if !self.target_alive(target) || target == e.side() { e.target = None; return false; } let cur = e.tile(); if e.path.is_empty() && self.target_core_adjacent(cur, target) { e.working = true; if e.cd <= 0.0 { self.hit_target_core(e, target); } return true; } if e.path.is_empty() && e.repath <= 0.0 { e.repath = 1.5; // attackers dig their way like heroes; the player's creatures only walk let goal = |i: usize| self.target_core_adjacent(i, target); let found = if !e.hero && self.is_master(e.side()) { self.map.search(cur, self.map.creature_cost(flies(e.kind)), goal, true) } else { self.map.search(cur, self.map.hero_cost_as(flies(e.kind)), goal, true) }; match found { Some(p) => e.path = p, None => { e.target = None; return false; } } } self.step_path(e, dt); true } // units of a rival master: fight enemies nearby, march when sent, otherwise guard home // an AI seat of a co-op game: an allied master who builds his own dungeon pub fn is_partner(&self, side: u8) -> bool { self.coop && self.ai_sides.contains(&side) } // the partner gives gold to every allied master below the limit fn partner_gift(&mut self, side: u8) { let me = self.me_side; for s in self.master_sides() { if self.is_out(s) || self.gold_of(s) >= PARTNER_GIFT_BELOW { continue; } self.switch_to(s); let got = self.add_gold(PARTNER_GIFT).floor(); if got >= 1.0 { self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{side}")]); self.sfx_ui(Sfx::Payday); } } self.switch_to(me); } // a co-op partner's creatures follow the attack flag of the first master still standing fn partner_flag(&self, side: u8) -> Option { if !self.is_partner(side) { return None; } let lead = self.master_sides().into_iter().find(|s| !self.is_out(*s))?; if lead == self.me_side { self.flag } else { self.masters.iter().find(|m| m.side == lead)?.flag } } pub fn rival_ai(&mut self, e: &mut Ent, dt: f32) { if e.think <= 0.0 { e.think = 0.5; if !matches!(e.job, Job::Attack(_)) { let radius = if e.target.is_some() { 6.0 } else { 8.0 }; if let Some(id) = self.nearest_enemy(e, radius) { e.job = Job::Attack(id); e.path.clear(); e.repath = 0.0; } } } // two of three healthy creatures of a co-op partner join the attack at the allies' flag let assist = if e.id % 3 != 0 && e.hp > e.max_hp * 0.4 { self.partner_flag(e.side) } else { None }; if let Job::Attack(id) = e.job { let (hx, hy) = e.home.unwrap_or((e.x, e.y)); let far = e.target.is_none() && assist.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0; if !far && self.attack_job(e, id, dt) { return; } e.job = Job::None; e.path.clear(); } if let Some(f) = assist { // the flag at the humans' gate: attack the gate let (fx, fy) = xy(f); let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5); let (gx, gy) = self.gate_center(); if e.target.is_none() && self.has_gate() && self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0 { e.target = Some(1); } } if self.march_on(e, dt) { return; } if let Some(f) = assist { let (fx, fy) = xy(f); let d2 = (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2); if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 { e.repath = 1.5; if let Some(p) = self.map.creature_path(flies(e.kind), e.tile(), f) { e.path = p; } } self.step_path(e, dt); return; } // guard the own dungeon let (hx, hy) = e.home.unwrap_or((e.x, e.y)); let d2 = (e.x - hx).powi(2) + (e.y - hy).powi(2); if e.path.is_empty() && e.repath <= 0.0 { e.repath = 3.0 + self.rng.f() * 3.0; let (tx, ty) = if d2 > 36.0 { (hx, hy) } else { (hx + self.rng.f() * 8.0 - 4.0, hy + self.rng.f() * 8.0 - 4.0) }; if self.map.walk(tx as i32, ty as i32) { if let Some(p) = self.map.creature_path(flies(e.kind), e.tile(), idx(tx as i32, ty as i32)) { e.path = p; } } } self.step_path(e, dt); } } #[cfg(test)] mod tests { use super::*; #[test] fn free_games_have_one_or_two_opponents() { for seed in 1..=12u32 { for &(w, h) in &[(64usize, 64usize), (160, 160)] { let g = Game::new_free(seed, w, h, 1); let opponents = g.rivals.len() + g.gate_on as usize; assert_eq!(opponents, if w * h > 128 * 128 { 2 } else { 1 }, "seed {} {}x{}", seed, w, h); for r in &g.rivals { let (cx, cy) = (r.core.0 + 1, r.core.1 + 1); assert_eq!(g.map.t[idx(cx, cy)].kind, Kind::Core); assert_eq!(g.map.t[idx(cx, cy)].owner, Owner::of_side(r.side)); } // the player's core is untouched let (px, py) = g.map.core; assert_eq!(g.map.t[idx(px + 1, py + 1)].owner, Owner::Player); } } } // a free game with exactly one rival master and no gate fn rival_game(difficulty: u8) -> Game { (1..200u32).map(|s| Game::new_free(s, 64, 64, difficulty)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap() } #[test] fn rival_grows_spawns_and_attacks() { let mut g = rival_game(2); let r0 = g.rivals[0].radius; let core0 = g.core_hp; let mut attacked = false; for _ in 0..(12 * 60 * 20) { g.update(0.05); attacked |= g.ents.iter().any(|e| e.rival() && e.target == Some(0)); if g.core_hp < core0 - 50.0 { break; } } assert!(g.rivals[0].radius > r0, "the rival dungeon grows"); assert!(attacked, "the rival sends creatures"); assert!(g.core_hp < core0, "they reach the player's core"); } #[test] fn beating_the_rival_wins_the_free_game() { let mut g = rival_game(1); let side = g.rivals[0].side; g.update(0.05); assert!(g.ents.iter().any(|e| e.side == side && e.alive)); g.rival_core_hit(side, RIVAL_CORE_HP + 1.0); assert!(!g.rivals[0].alive); assert!(!g.ents.iter().any(|e| e.side == side && e.alive)); assert!(!g.map.t.iter().any(|t| t.owner == Owner::of_side(side))); g.update(0.05); assert_eq!(g.state, 1, "all opponents down: victory"); } #[test] fn humans_attack_rival_masters_too() { let mut g = (1..200u32).map(|s| Game::new_free(s, 160, 160, 1)).find(|g| g.rivals.len() == 1 && g.gate_on).unwrap(); let side = g.rivals[0].side; let (rx, ry) = g.rivals[0].centre(); for k in 0..4 { let id = g.spawn(EK::Knight, true, rx + 6.0 + k as f32 * 0.3, ry, 4); if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) { e.target = Some(side); } } let hp0 = g.rivals[0].hp; let mut fought = false; for _ in 0..(120 * 20) { g.update(0.05); fought |= g.rivals[0].hp < hp0 || g.ents.iter().any(|e| e.rival() && e.hp < e.max_hp); if fought { break; } } assert!(fought, "heroes fight the rival master"); } #[test] fn hero_waves_march_on_the_strongest_master() { let mut g = (1..200u32).map(|s| Game::new_free(s, 160, 160, 1)).find(|g| g.rivals.len() == 1 && g.gate_on).unwrap(); let side = g.rivals[0].side; let (rx, ry) = g.rivals[0].centre(); let army = |g: &mut Game, side: u8, x: f32, y: f32, n: usize| { for k in 0..n { let id = g.spawn(EK::Ogre, false, x + k as f32 * 0.2, y, 6); if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) { e.side = side; } } }; // the rival master is far stronger: the wave marches on him and is measured against him g.gold = 0.0; army(&mut g, side, rx, ry + 2.0, 8); assert_eq!(g.wave_goal(), side); let before = g.ents.len(); g.spawn_wave(); let heroes: Vec<&Ent> = g.ents[before..].iter().filter(|e| e.hero).collect(); assert!(!heroes.is_empty()); assert!(heroes.iter().all(|e| e.target == Some(side)), "the wave marches on the rival"); // now the player is the strongest: the next wave comes for him let (cx, cy) = g.map.core; army(&mut g, 0, cx as f32 + 1.5, cy as f32 + 4.0, 20); assert_eq!(g.wave_goal(), 0); let before = g.ents.len(); g.spawn_wave(); assert!(g.ents[before..].iter().filter(|e| e.hero).all(|e| e.target.is_none()), "the wave comes for the player"); } // the first mission of the co-op league, set up like an offline co-op game: player on side 0, // an AI partner on side 2, no server fn offline_league_game() -> Game { let doc = crate::editor::coop_campaign::coop_campaign(); let mut g = crate::campaign::start_editor_coop_mission(&doc.missions[0], 0, 77); g.coop = true; assert!(g.start_match(&[0, 2], 0)); g.sync_ai_sides(&[2], 1); g.end_match(); g } #[test] fn an_offline_coop_partner_is_an_allied_ai_master() { let mut g = offline_league_game(); assert!(g.netplay.is_none() && g.masters.is_empty()); assert!(g.rival_alive(2) && g.is_partner(2)); assert!(g.allied(0, 2) && g.allied(2, 0)); // short of gold: the partner helps out within a minute g.gold = 0.0; for _ in 0..(65 * 20) { g.update(0.05); } assert!(g.msgs.iter().any(|m| m.key == "msg.gold_received"), "the partner sent gold"); assert_eq!(g.state, 0); // the partner's creatures never fight the player's assert!(!g.ents.iter().any(|e| e.alive && !e.hero && e.side == 0 && e.hp < e.max_hp && matches!(e.job, Job::Attack(_)))); } #[test] fn the_partner_follows_the_attack_flag() { let mut g = offline_league_game(); for _ in 0..(90 * 20) { g.update(0.05); } let (rx, ry) = g.rivals.iter().find(|r| r.side == 2).unwrap().centre(); // a flag on the partner's own ground a little away from its core let f = (0..g.map.t.len()) .find(|&i| { let (x, y) = xy(i); let d2 = (x as f32 - rx).powi(2) + (y as f32 - ry).powi(2); g.map.t[i].owner == Owner::of_side(2) && g.map.walk(x, y) && d2 > 16.0 && d2 < 36.0 }) .expect("partner ground"); g.flag = Some(f); let (fx, fy) = xy(f); let mut near = false; for _ in 0..(30 * 20) { g.update(0.05); near |= g.ents.iter().any(|e| e.alive && e.side == 2 && !e.hero && e.id % 3 != 0 && (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2) < 3.0 * 3.0); if near { break; } } assert!(near, "partner creatures gather at the flag"); } }