726 lines
30 KiB
Rust
726 lines
30 KiB
Rust
// Rival dungeon masters: each is a real master (gold, imps, rooms, research - see add_rival)
|
|
// played by the brain in ai.rs; this keeps their record (side, heart, difficulty, alive) for the
|
|
// interface, the goals and the victory. A master whose heart falls is out; his ground becomes
|
|
// free. Units of a rival without a master (old savegames) still use rival_ai until he gets one.
|
|
|
|
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
|
|
#[serde(default)]
|
|
pub think_t: f32, // the brain (ai.rs): seconds until the next thought
|
|
#[serde(default)]
|
|
pub attacking: bool, // the attack flag is set on an enemy heart
|
|
#[serde(default)]
|
|
pub army0: f32, // army strength when the attack began
|
|
}
|
|
|
|
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 master around (cx, cy): a real master (ai.rs) with his heart, a little
|
|
// ground, a portal of his own, four imps and gold by the difficulty
|
|
pub fn add_rival(&mut self, side: u8, cx: i32, cy: i32, difficulty: u8) {
|
|
let core = (cx - 1, cy - 1);
|
|
self.carve_master_area(side, core);
|
|
self.add_master_portal(side, core);
|
|
self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: 5, grow_t: 20.0, spawn_t: 5.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY, think_t: 2.0, attacking: false, army0: 0.0 });
|
|
self.adopt_master(side, core, difficulty, true);
|
|
}
|
|
|
|
// the rival on `side` gets (or has) a master of his own. `fresh`: a new one with imps and gold
|
|
pub(crate) fn adopt_master(&mut self, side: u8, core: (i32, i32), difficulty: u8, fresh: bool) {
|
|
if self.is_master(side) {
|
|
return;
|
|
}
|
|
let me = self.me_side;
|
|
self.add_master(side, core);
|
|
self.switch_to(side);
|
|
self.gold = crate::ai::start_gold(difficulty);
|
|
self.mana = 150.0;
|
|
self.recount();
|
|
if fresh {
|
|
let (cx, cy) = core;
|
|
for j in 0..4 {
|
|
self.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + j as f32 * 0.7 + 0.3, 1);
|
|
}
|
|
}
|
|
// the creatures of that side are his
|
|
for e in self.ents.iter_mut().filter(|e| e.alive && !e.hero && e.side == side) {
|
|
e.home = None;
|
|
e.target = None;
|
|
}
|
|
self.switch_to(me);
|
|
}
|
|
|
|
|
|
pub fn update_rivals(&mut self, dt: f32) {
|
|
let back = self.me_side;
|
|
for k in 0..self.rivals.len() {
|
|
if !self.rivals[k].alive {
|
|
continue;
|
|
}
|
|
let side = self.rivals[k].side;
|
|
// a rival from an older savegame or a painted map: he gets his master now
|
|
if !self.is_master(side) {
|
|
let (core, diff) = (self.rivals[k].core, self.rivals[k].difficulty);
|
|
self.adopt_master(side, core, diff, false);
|
|
}
|
|
// his heart fell: he is out
|
|
if self.core_hp_of(side) <= 0.0 {
|
|
if !self.out_sides.contains(&side) {
|
|
self.out_sides.push(side);
|
|
}
|
|
self.eliminate_rival(k);
|
|
continue;
|
|
}
|
|
self.rivals[k].hp = self.core_hp_of(side) / CORE_HP * RIVAL_CORE_HP;
|
|
// 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);
|
|
}
|
|
}
|
|
self.switch_to(side);
|
|
self.ai_think(k, dt);
|
|
self.switch_to(back);
|
|
}
|
|
}
|
|
|
|
|
|
// 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 if side == 3 {
|
|
"msg.rival2_out"
|
|
} else {
|
|
"msg.rival3_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<usize> {
|
|
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 an_ai_master_builds_its_dungeon() {
|
|
let mut g = rival_game(2);
|
|
let side = g.rivals[0].side;
|
|
let owner = Owner::of_side(side);
|
|
assert!(g.is_master(side), "a real master");
|
|
let ground0 = g.map.t.iter().filter(|t| t.owner == owner).count();
|
|
for _ in 0..(8 * 60 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
let ground = g.map.t.iter().filter(|t| t.owner == owner).count();
|
|
assert!(ground > ground0 + 30, "its imps dig and claim: {} -> {}", ground0, ground);
|
|
g.switch_to(side);
|
|
let rooms: usize = g.counts.iter().sum();
|
|
let imps = g.worker_count();
|
|
g.switch_to(0);
|
|
assert!(rooms >= 9, "it builds rooms ({} tiles)", rooms);
|
|
assert!(imps >= 4, "and keeps its imps");
|
|
assert!(g.torches.iter().any(|t| g.map.t[t.wall].owner == owner || g.map.t[t.wall].solid()), "torches");
|
|
}
|
|
|
|
#[test]
|
|
fn the_fourth_master_works_too_and_is_no_dwarf() {
|
|
let mut g = (1..200u32).map(|s| Game::new_free(s, 96, 96, 1)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap();
|
|
let far = |g: &Game, (x, y): (i32, i32)| {
|
|
let d = |(a, b): (i32, i32)| (a - x).pow(2) + (b - y).pow(2);
|
|
d(g.map.core).min(d(g.rivals[0].core))
|
|
};
|
|
let spot = [(14, 14), (80, 14), (14, 80), (80, 80)].into_iter().max_by_key(|&p| far(&g, p)).unwrap();
|
|
g.add_rival(4, spot.0, spot.1, 1);
|
|
let owner = Owner::of_side(4);
|
|
let ground0 = g.map.t.iter().filter(|t| t.owner == owner).count();
|
|
for _ in 0..(6 * 60 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
assert!(g.ents.iter().any(|e| e.alive && e.side == 4 && e.rival() && !e.dwarf()), "its units belong to a master");
|
|
let ground = g.map.t.iter().filter(|t| t.owner == owner).count();
|
|
assert!(ground > ground0 + 20, "the yellow master digs and claims: {} -> {}", ground0, ground);
|
|
}
|
|
|
|
#[test]
|
|
fn ai_marks_stay_near_its_ground_and_imps_dig_only_their_masters_marks() {
|
|
let mut g = rival_game(2);
|
|
let side = g.rivals[0].side;
|
|
let owner = Owner::of_side(side);
|
|
for _ in 0..(10 * 60 * 20) {
|
|
g.update(0.05);
|
|
if g.time as i32 % 20 == 0 {
|
|
for (i, t) in g.map.t.iter().enumerate() {
|
|
if t.marked && t.mark_by == side {
|
|
let (x, y) = crate::map::xy(i);
|
|
let near = (-5..=5).any(|dy| (-5..=5).any(|dx| g.map.get(x + dx, y + dy).map_or(false, |o| o.owner == owner)));
|
|
assert!(near, "AI mark at {},{} far from its ground", x, y);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// a foreign mark next to the player's imps stays untouched
|
|
let (cx, cy) = g.map.core;
|
|
let spot = (0..g.map.t.len()).find(|&i| {
|
|
let (x, y) = crate::map::xy(i);
|
|
(x - cx).abs() < 10 && (y - cy).abs() < 10 && g.map.t[i].kind == Kind::Earth && !g.map.t[i].marked
|
|
&& DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |o| o.owner == Owner::Player && o.walkable()))
|
|
}).expect("earth beside the player's floor");
|
|
g.map.t[spot].reinforced = false;
|
|
g.map.t[spot].marked = true;
|
|
g.map.t[spot].mark_by = side;
|
|
for _ in 0..(60 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
assert_eq!(g.map.t[spot].kind, Kind::Earth, "the player's imps leave the AI's mark alone");
|
|
}
|
|
|
|
#[test]
|
|
fn the_ai_summons_new_imps_when_its_imps_die() {
|
|
let mut g = rival_game(1);
|
|
let side = g.rivals[0].side;
|
|
for _ in 0..(60 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
for e in g.ents.iter_mut().filter(|e| e.alive && e.side == side && e.kind == EK::Worker) {
|
|
e.hp = 0.0;
|
|
}
|
|
for _ in 0..20 {
|
|
g.update(0.05);
|
|
}
|
|
assert_eq!(g.ents.iter().filter(|e| e.alive && e.side == side && e.kind == EK::Worker).count(), 0, "all its imps are dead");
|
|
for _ in 0..(3 * 60 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
let imps = g.ents.iter().filter(|e| e.alive && e.side == side && e.kind == EK::Worker).count();
|
|
assert!(imps >= 2, "new imps (one per 80 mana, like the player): {}", imps);
|
|
}
|
|
|
|
#[test]
|
|
fn ai_dig_marks_carry_the_masters_side() {
|
|
let mut g = rival_game(1);
|
|
let side = g.rivals[0].side;
|
|
for _ in 0..(40 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
assert!(g.map.t.iter().any(|t| t.marked && t.mark_by == side), "the AI marks in its own colour");
|
|
let back = crate::save::from_bytes(crate::save::to_json(&g).as_bytes()).ok().expect("loads").game;
|
|
assert_eq!(
|
|
back.map.t.iter().filter(|t| t.marked && t.mark_by == side).count(),
|
|
g.map.t.iter().filter(|t| t.marked && t.mark_by == side).count(),
|
|
"kept in the save"
|
|
);
|
|
}
|
|
|
|
#[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 the_ai_partner_builds_only_what_is_researched() {
|
|
// the first missions of both co-op campaigns: no library, no forge yet
|
|
let doc = crate::editor::coop_campaign::coop_campaign();
|
|
let games = [
|
|
crate::campaign::start_editor_coop_mission(&doc.missions[0], 0, 77),
|
|
crate::campaign::start_coop_mission(crate::campaign::coop_mission_indexes()[0], None),
|
|
];
|
|
let lib = crate::ui::TOOLS.iter().position(|&t| t == crate::ui::Tool::Room(4)).unwrap();
|
|
let forge = crate::ui::TOOLS.iter().position(|&t| t == crate::ui::Tool::Room(8)).unwrap();
|
|
for mut g in games {
|
|
g.coop = true;
|
|
assert!(g.start_match(&[0, 2], 0));
|
|
g.sync_ai_sides(&[2], 1);
|
|
g.end_match();
|
|
let mine_knows = (g.tool_allowed(lib), g.tool_allowed(forge));
|
|
for _ in 0..(10 * 60 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
let owner = Owner::of_side(2);
|
|
let has = |k: Kind| g.map.t.iter().any(|t| t.kind == k && t.owner == owner);
|
|
if !mine_knows.0 {
|
|
assert!(!has(Kind::Library), "the partner has no library either");
|
|
}
|
|
if !mine_knows.1 {
|
|
assert!(!has(Kind::Forge), "the partner has no forge either");
|
|
}
|
|
assert!(has(Kind::Lair) || has(Kind::Treasury), "but builds what it knows");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn imps_take_ground_from_enemies_but_never_from_allies() {
|
|
let mut g = offline_league_game();
|
|
assert!(g.allied(0, 2));
|
|
// a partner floor tile right beside the player's floor
|
|
let spot = (0..g.map.t.len()).find(|&i| {
|
|
let (x, y) = crate::map::xy(i);
|
|
g.map.t[i].kind == Kind::Floor && g.map.t[i].owner == Owner::Player
|
|
&& DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |o| o.kind == Kind::Floor && o.owner == Owner::Player))
|
|
&& x > 2 && y > 2
|
|
}).expect("player floor");
|
|
let (x, y) = crate::map::xy(spot);
|
|
let n = DIR4.iter().map(|&(dx, dy)| crate::map::idx(x + dx, y + dy)).find(|&j| g.map.t[j].owner == Owner::Player).unwrap();
|
|
g.map.t[n].owner = Owner::of_side(2);
|
|
g.map.t[n].reserved = 0;
|
|
assert!(!g.claimable(n, 999), "an ally's floor stays his");
|
|
// the same tile of an enemy master may be taken
|
|
g.coop = false;
|
|
g.teams = vec![(0, 0), (2, 1)];
|
|
assert!(!g.allied(0, 2));
|
|
assert!(g.claimable(n, 999), "an enemy's floor can be claimed");
|
|
}
|
|
|
|
#[test]
|
|
fn an_offline_coop_partner_is_an_allied_ai_master() {
|
|
let mut g = offline_league_game();
|
|
assert!(g.netplay.is_none() && g.is_master(2), "the partner is a real master without the server");
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn ai_master_report() {
|
|
for d in 0..3u8 {
|
|
let mut g = (1..200u32).map(|s| Game::new_free(s, 96, 96, d)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap();
|
|
let side = g.rivals[0].side;
|
|
for m in 1..=15 {
|
|
for _ in 0..(60 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
g.switch_to(side);
|
|
let (rooms, imps, cr, gold) = (g.counts.iter().sum::<usize>(), g.worker_count(), g.creature_count(), g.gold);
|
|
let flag = g.flag.is_some();
|
|
let earned = g.stats.get(crate::stats::GOLD);
|
|
let me = g.me;
|
|
let veins = (0..g.map.t.len()).filter(|&i| g.map.t[i].marked && g.map.t[i].vein().is_some()).count();
|
|
let marked = g.map.t.iter().filter(|t| t.marked).count();
|
|
let near = (0..g.map.t.len()).filter(|&i| { let (x, y) = xy(i); g.map.t[i].marked && DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |n| n.owner == me && n.walkable())) }).count();
|
|
g.switch_to(0);
|
|
println!("diff {} min {:2}: rooms {:3} imps {:2} creatures {:2} gold {:5.0} attacking {} | marked {} veins {} reachable {} earned {:.0} | player core {:.0}", d, m, rooms, imps, cr, gold, flag, marked, veins, near, earned, g.core_hp);
|
|
}
|
|
}
|
|
}
|
|
}
|