diff --git a/src/ai.rs b/src/ai.rs index a355b02..d0179fa 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -14,8 +14,9 @@ const THINK: [f32; 3] = [5.2, 3.25, 1.95]; // seconds between two thoughts (a ca const MAX_RADIUS: [i32; 3] = [11, 15, 19]; // how far the dungeon grows around the heart const MAX_IMPS: [usize; 3] = [5, 7, 9]; const FIRST_ATTACK: [f32; 3] = [940.0, 625.0, 430.0]; // no attack before (seconds) -const BOLDNESS: [f32; 3] = [1.5, 1.15, 0.9]; // own army / enemy army needed to attack +const BOLDNESS: [f32; 3] = [1.0, 1.2, 1.35]; // own army / enemy army needed to attack (easy is reckless, hard waits) const MARK_REACH: i32 = 5; // an AI master marks only this close to its own ground +const CREATURE_TARGET: [usize; 3] = [30, 50, 80]; // beds an AI master builds up to const MAX_TOWERS: [usize; 3] = [1, 2, 4]; const START_GOLD: [f32; 3] = [1500.0, 2500.0, 3500.0]; @@ -51,10 +52,11 @@ impl Game { self.ai_imps(d, cx, cy); self.ai_dig(k, d, cx, cy); self.ai_gold(cx, cy); - self.ai_rooms(cx, cy); + self.ai_rooms(d, cx, cy); self.ai_towers(d, cx, cy); self.ai_fight(k, d, side, cx, cy); - self.ai_talents(); + let focus = self.rivals[k].focus; + self.ai_talents(focus); } // the tiles an AI master may mark: within MARK_REACH tiles of its own ground @@ -161,18 +163,23 @@ impl Game { // gold running low: a tunnel to the nearest vein that nobody works on yet fn ai_gold(&mut self, cx: i32, cy: i32) { - if self.gold > 1500.0 { + // gold for one and a half paydays and a little more: enough + if self.gold > self.next_payday_cost() * 1.5 + 1000.0 { return; } let me = self.me; - // a marked vein the imps can reach (next to own ground) is being worked already - let busy = (0..self.map.t.len()).any(|i| { - let t = &self.map.t[i]; - let (x, y) = xy(i); - t.marked && t.vein().is_some() && DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == me && n.walkable())) - }); - if busy { - return; // a vein is being worked already + // marked veins the imps can reach (next to own ground) are being worked already; one vein + // for every three imps at a time + let side = me.side().min(7); + let busy = (0..self.map.t.len()) + .filter(|&i| { + let t = &self.map.t[i]; + let (x, y) = xy(i); + t.marked && t.mark_by == side && t.vein().is_some() && DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == me && n.walkable())) + }) + .count(); + if busy >= (self.worker_count() / 3).max(1) { + return; } self.ai_mark_tunnel(cx, cy, |g: &Game, i: usize| { let t = &g.map.t[i]; @@ -220,7 +227,7 @@ impl Game { } // the next room of the build order, where there is free own floor for it - fn ai_rooms(&mut self, cx: i32, cy: i32) { + fn ai_rooms(&mut self, d: usize, cx: i32, cy: i32) { for &(room, w, h, n) in BUILD.iter() { // only what this master has researched (the same rules as the player) if !self.ai_knows(crate::ui::Tool::Room(room)) { @@ -239,6 +246,29 @@ impl Game { } return; } + // the base is done: a bigger treasury when it is nearly full + if self.gold > self.gold_cap() * 0.85 { + let cost = ROOM_COST[0] as f32 * 9.0; + if self.gold >= cost + 400.0 { + if let Some((x0, y0)) = self.ai_free_rect(cx, cy, 3, 3) { + self.build_rect(0, x0, y0, x0 + 2, y0 + 2); + return; + } + } + } + // more beds and food while the army may still grow (by difficulty) + if self.counts[1] >= CREATURE_TARGET[d] || self.free_beds() > 2 { + return; + } + let room = if self.counts[2] * 10 < self.counts[1] * 6 { 2 } else { 1 }; + let (w, h) = (4, 3); + let cost = ROOM_COST[room] as f32 * (w * h) as f32; + if self.gold < cost + 400.0 { + return; + } + if let Some((x0, y0)) = self.ai_free_rect(cx, cy, w, h) { + self.build_rect(room, x0, y0, x0 + w - 1, y0 + h - 1); + } } // has this master researched the tool (rooms, towers)? @@ -295,6 +325,11 @@ impl Game { self.place_mark(cx + 1, cy + 3, false); } let army = self.side_army(side); + // heroes or enemy units in the dungeon: everybody goes for them (the rally flag) and the + // master casts lightning on them and heals his hurt creatures + if !self.is_partner(side) && self.ai_defend(k) { + return; + } // co-op: go where the allies go if self.is_partner(side) { let lead = self.master_sides().into_iter().find(|&s| s != side && !self.is_out(s) && self.allied(side, s)); @@ -363,6 +398,49 @@ impl Game { } } + // the defence of the dungeon; true while there are intruders + fn ai_defend(&mut self, k: usize) -> bool { + let me = self.me; + let intruders: Vec<(f32, f32)> = self + .ents + .iter() + .filter(|e| e.alive && !e.held && e.captive == 0 && self.is_hostile(e) && self.map.get(e.x as i32, e.y as i32).map_or(false, |t| t.owner == me)) + .map(|e| (e.x, e.y)) + .collect(); + if intruders.is_empty() { + if self.rivals[k].defending { + self.rivals[k].defending = false; + self.flag = None; + } + return false; + } + // the middle of the intruders, on walkable ground + let n = intruders.len() as f32; + let (mx, my) = intruders.iter().fold((0.0, 0.0), |a, p| (a.0 + p.0 / n, a.1 + p.1 / n)); + let near = intruders.iter().min_by(|a, b| ((a.0 - mx).powi(2) + (a.1 - my).powi(2)).partial_cmp(&((b.0 - mx).powi(2) + (b.1 - my).powi(2))).unwrap()).copied().unwrap(); + let tile = idx(near.0 as i32, near.1 as i32); + if self.rivals[k].attacking { + self.rivals[k].attacking = false; // home first + } + self.rivals[k].defending = true; + if self.map.t[tile].walkable() && self.flag != Some(tile) { + self.flag = Some(tile); + } + // lightning on the thickest of them, healing for the hurt + let bolt = 50.0 * self.talents.spell_cost(); + if self.mana >= bolt + 40.0 && self.known(crate::game::LIGHTNING_TOOL) { + self.lightning(near.0, near.1); + } + let heal = 60.0 * self.talents.spell_cost(); + let hurt = self.ents.iter().filter(|e| e.alive && e.creature() && e.hp < e.max_hp * 0.5 && (e.x - near.0).powi(2) + (e.y - near.1).powi(2) < 36.0).map(|e| (e.x, e.y)).next(); + if let Some((hx, hy)) = hurt { + if self.mana >= heal + 40.0 && self.known(crate::game::HEAL_TOOL) { + self.heal(hx, hy); + } + } + true + } + // is an enemy heart near tile f? fn ai_enemy_heart_near(&self, side: u8, f: usize) -> bool { let (fx, fy) = xy(f); diff --git a/src/balance.rs b/src/balance.rs new file mode 100644 index 0000000..ebee1a8 --- /dev/null +++ b/src/balance.rs @@ -0,0 +1,206 @@ +// Automated balancing: whole games played by the AI brain on every side, headless and +// deterministic, many seeds each. Run with +// cargo test --release balance_ -- --ignored --nocapture --test-threads=4 +// and read the tables (docs/BALANCE.md keeps the findings). + +#![cfg(test)] + +use crate::game::*; +use crate::rival::{Rival, PARTNER_GIFT_EVERY, RIVAL_CORE_HP}; + +const STEP: f32 = 0.05; + +// the master on side 0 gets the same brain as the rivals +fn pilot(g: &mut Game, difficulty: u8, focus: Option) { + let core = g.map.core; + // like every AI master: the 9x9 starting area and a portal of its own beside it + g.carve_master_area(0, core); + g.add_master_portal(0, core); + g.rivals.push(Rival { side: 0, core, hp: RIVAL_CORE_HP, alive: true, radius: 5, grow_t: 0.0, spawn_t: 0.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY, think_t: 1.0, attacking: false, army0: 0.0, focus, defending: false }); +} + +// the other corners of a square map for up to three more masters (centre of the core) +fn corners(w: i32, h: i32) -> [(i32, i32); 3] { + [(w - 29, 13), (w - 29, h - 15), (15, h - 15)] +} + +#[derive(Clone, Debug, Default)] +struct Outcome { + out_at: Vec<(u8, f32)>, // side, minute it fell + score: Vec<(u8, f32)>, // side, strength at the end (alive ones) + winner: Option, + minutes: f32, +} + +fn alive(g: &Game, side: u8) -> bool { + g.core_hp_of(side) > 0.0 && g.rivals.iter().any(|r| r.side == side && r.alive) +} + +fn score(g: &Game, side: u8) -> f32 { + g.core_hp_of(side) * 2.0 + g.side_army(side) + g.gold_of(side) * 0.2 +} + +// plays until one side is left or the time is up; side 0 falling does not stop it +fn play(g: &mut Game, sides: &[u8], max_min: f32) -> Outcome { + let mut o = Outcome::default(); + let steps = (max_min * 60.0 / STEP) as usize; + for k in 0..steps { + g.update(STEP); + if g.state != 0 { + g.state = 0; // the harness decides the end + } + if k % 20 == 0 { + for &s in sides { + if !o.out_at.iter().any(|x| x.0 == s) && !alive(g, s) { + o.out_at.push((s, g.time / 60.0)); + } + } + let left: Vec = sides.iter().copied().filter(|&s| !o.out_at.iter().any(|x| x.0 == s)).collect(); + if left.len() <= 1 { + o.winner = left.first().copied(); + break; + } + } + } + o.minutes = g.time / 60.0; + o.score = sides.iter().copied().filter(|&s| !o.out_at.iter().any(|x| x.0 == s)).map(|s| (s, score(g, s))).collect(); + if o.winner.is_none() { + o.winner = o.score.iter().max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|x| x.0); + } + o +} + +fn arena(seed: u32, size: usize) -> Game { + let mut g = Game::new(seed, size, size); + g.gate_on = false; // no hero waves: masters against each other + g.ai_difficulty = 1; + g +} + +fn seeds() -> Vec { + let n: u32 = std::env::var("BALANCE_SEEDS").ok().and_then(|s| s.parse().ok()).unwrap_or(6); + (1..=n).map(|k| 1000 + k * 37).collect() +} + +// ---------------------------------------------------------------- difficulty duels + +#[test] +#[ignore] +fn balance_difficulty() { + println!("\n== difficulty duels (2 masters, 112x112, no waves, 45 min) =="); + println!("{:<14} {:>5} {:>5} {:>5} {:>8}", "pair", "A", "B", "draw", "min"); + for (a, b) in [(0u8, 1u8), (1, 2), (0, 2), (1, 1)] { + let (mut wa, mut wb, mut dr, mut mins) = (0, 0, 0, 0.0); + for (k, seed) in seeds().into_iter().enumerate() { + let mut g = arena(seed, 112); + // swap who plays which side every other seed (side 0 has the map's own start) + let (d0, d2) = if k % 2 == 0 { (a, b) } else { (b, a) }; + pilot(&mut g, d0, None); + let (x, y) = corners(112, 112)[k % 3]; + g.add_rival(2, x, y, d2); + let o = play(&mut g, &[0, 2], 45.0); + mins += o.minutes; + let a_side = if k % 2 == 0 { 0 } else { 2 }; + let decisive = o.out_at.len() == 1; + match o.winner { + Some(w) if decisive && w == a_side => wa += 1, + Some(_) if decisive => wb += 1, + _ => dr += 1, + } + } + let n = seeds().len() as f32; + println!("{:<14} {:>5} {:>5} {:>5} {:>8.1}", format!("{} vs {}", a, b), wa, wb, dr, mins / n); + } +} + +// ---------------------------------------------------------------- talent branches + +#[test] +#[ignore] +fn balance_talents() { + use crate::talents::*; + println!("\n== talent tournament (4 masters, medium, 128x128, no waves, 50 min) =="); + let names = ["builder", "warlord", "warlock", "merchant"]; + let mut wins = [0usize; 4]; + let mut place_sum = [0.0f32; 4]; + let mut lv_sum = [0.0f32; 4]; + let sides = [0u8, 2, 3, 4]; + for (k, seed) in seeds().into_iter().enumerate() { + let mut g = arena(seed, 128); + // the branches move round the four starts from seed to seed + let branch_of = |s: usize| ((s + k) % 4) as u8; + pilot(&mut g, 1, Some(branch_of(0))); + for (n, &(x, y)) in corners(128, 128).iter().enumerate() { + g.add_rival(sides[n + 1], x, y, 1); + g.rivals.last_mut().unwrap().focus = Some(branch_of(n + 1)); + } + let o = play(&mut g, &sides, 50.0); + // placement: the winner 1st, the survivors by score, the fallen by how long they lasted + let mut order: Vec = Vec::new(); + let mut alive_sorted = o.score.clone(); + alive_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + order.extend(alive_sorted.iter().map(|x| x.0)); + let mut fallen = o.out_at.clone(); + fallen.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + order.extend(fallen.iter().map(|x| x.0)); + for (place, &s) in order.iter().enumerate() { + let n = sides.iter().position(|&x| x == s).unwrap(); + let b = branch_of(n) as usize; + place_sum[b] += place as f32 + 1.0; + if place == 0 { + wins[b] += 1; + } + lv_sum[b] += g.talents_of(s).lv[b] as f32; + } + let _ = BRANCHES; + } + let n = seeds().len() as f32; + println!("{:<10} {:>5} {:>10} {:>10}", "branch", "wins", "avg place", "avg level"); + for b in 0..4 { + println!("{:<10} {:>5} {:>10.2} {:>10.1}", names[b], wins[b], place_sum[b] / n, lv_sum[b] / n); + } +} + +// ---------------------------------------------------------------- one master against the waves + +#[test] +#[ignore] +fn balance_waves() { + println!("\n== one AI master against the hero waves (96x96, 40 min) =="); + println!("{:<6} {:>8} {:>6} {:>7} {:>7} {:>7} {:>6}", "diff", "fell@min", "wave", "heroes", "lost", "gold", "army"); + for d in 0..3u8 { + for seed in seeds() { + let mut g = Game::new(seed, 96, 96); + g.ai_difficulty = d; + pilot(&mut g, d, None); + let mut fell = None; + for k in 0..((40.0 * 60.0 / STEP) as usize) { + g.update(STEP); + if g.state != 0 { + if g.core_hp <= 0.0 { + fell = Some(g.time / 60.0); + break; + } + g.state = 0; + } + if std::env::var("BALANCE_TRACE").is_ok() && k % (120.0 / STEP) as usize == 0 { + let rooms: usize = g.counts.iter().sum(); + let portal = (0..g.map.portals.len()).any(|p| g.map.portal_owner(p) == crate::map::Owner::Player); + println!(" seed {} d{} min {:>4.1}: creatures {:>3} imps {:>2} gold {:>6} rooms {:>3} lair {:>3} farm {:>3} wave {:>2} core {:>5} portal {} heroes near {}", + seed, d, g.time / 60.0, g.creature_count(), g.worker_count(), g.gold as i32, rooms, g.counts[1], g.counts[2], g.wave, g.core_hp as i32, portal, + g.ents.iter().filter(|e| e.alive && e.active_hero()).count()); + } + } + println!( + "{:<6} {:>8} {:>6} {:>7} {:>7} {:>7} {:>6}", + d, + fell.map_or("-".to_string(), |m| format!("{:.1}", m)), + g.wave, + g.stats.get(crate::stats::HEROES) as i32, + g.stats.get(crate::stats::LOST) as i32, + g.gold as i32, + g.side_army(0) as i32 + ); + } + } +} diff --git a/src/game.rs b/src/game.rs index a8df9ea..a43336b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -161,6 +161,8 @@ pub struct St { pub const RUNE_TOOL: usize = 20; // index of the rune forge tower in ui::TOOLS pub const SUMMON_TOOL: usize = 11; // index of summoning in ui::TOOLS +pub const LIGHTNING_TOOL: usize = 9; // lightning in ui::TOOLS +pub const HEAL_TOOL: usize = 10; // healing in ui::TOOLS pub fn st(k: EK) -> St { let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty }; diff --git a/src/main.rs b/src/main.rs index 5a05f65..432b092 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod props; mod research; mod rival; mod rogues; +mod balance; mod talents; mod weather; mod names; diff --git a/src/netplay.rs b/src/netplay.rs index 3bfc656..404954d 100644 --- a/src/netplay.rs +++ b/src/netplay.rs @@ -365,7 +365,7 @@ impl Game { // the seat keeps its master (dungeon, gold, creatures): the brain (ai.rs) plays it let core = self.core_of_side(side); let difficulty = self.ai_difficulty; - self.rivals.push(crate::rival::Rival { side, core, hp: crate::rival::RIVAL_CORE_HP, alive: true, radius: 6, grow_t: 0.0, spawn_t: 0.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: crate::rival::PARTNER_GIFT_EVERY, think_t: 1.0, attacking: false, army0: 0.0 }); + self.rivals.push(crate::rival::Rival { side, core, hp: crate::rival::RIVAL_CORE_HP, alive: true, radius: 6, grow_t: 0.0, spawn_t: 0.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: crate::rival::PARTNER_GIFT_EVERY, think_t: 1.0, attacking: false, army0: 0.0, focus: None, defending: false }); } } diff --git a/src/rival.rs b/src/rival.rs index 93afb44..0c83a6a 100644 --- a/src/rival.rs +++ b/src/rival.rs @@ -33,6 +33,10 @@ pub struct Rival { pub attacking: bool, // the attack flag is set on an enemy heart #[serde(default)] pub army0: f32, // army strength when the attack began + #[serde(default)] + pub focus: Option, // balance runs: this master learns only one talent branch + #[serde(default)] + pub defending: bool, // the rally flag is on intruders in the dungeon (ai.rs) } impl Rival { @@ -98,7 +102,7 @@ impl Game { 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.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, focus: None, defending: false }); self.adopt_master(side, core, difficulty, true); } diff --git a/src/talents.rs b/src/talents.rs index 91ae27a..05fa7ce 100644 --- a/src/talents.rs +++ b/src/talents.rs @@ -73,10 +73,10 @@ impl Talents { } // Builder: imps dig and claim faster, rooms cost less pub fn dig_speed(&self) -> f32 { - 1.0 + 0.1 * self.level(BUILDER) + 1.0 + 0.15 * self.level(BUILDER) } pub fn room_cost(&self) -> f32 { - 1.0 - 0.06 * self.level(BUILDER) + 1.0 - 0.1 * self.level(BUILDER) } // Warlord: creatures hit harder and take less damage pub fn damage(&self) -> f32 { @@ -136,8 +136,16 @@ impl Game { self.sfx_ui(Sfx::LevelUp); } - // an AI master spends his points by himself: the branches in turn, the lowest first - pub(crate) fn ai_talents(&mut self) { + // an AI master spends his points by himself: the branches in turn, the lowest first (a balance + // run may hold him to one branch) + pub(crate) fn ai_talents(&mut self, focus: Option) { + if let Some(b) = focus { + let b = b as usize; + while self.talents.free() > 0 && self.talents.lv[b] < MAX_LEVEL { + self.talents.lv[b] += 1; + } + return; + } while self.talents.free() > 0 { let order = [WARLORD, BUILDER, MERCHANT, WARLOCK]; let Some(&b) = order.iter().filter(|&&b| self.talents.lv[b] < MAX_LEVEL).min_by_key(|&&b| self.talents.lv[b]) else { break }; @@ -213,7 +221,7 @@ mod tests { fn ai_masters_spend_their_points() { let mut g = Game::new(9, 64, 64); g.talents.xp = point_xp(6); - g.ai_talents(); + g.ai_talents(None); assert_eq!(g.talents.free(), 0); assert!(g.talents.lv.iter().all(|&l| l >= 1), "{:?}", g.talents.lv); } diff --git a/src/ui.rs b/src/ui.rs index 6cf34e6..07de82e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -559,7 +559,7 @@ fn branch_text(t: &crate::talents::Talents, b: usize) -> String { let desc = |lv: u8| { let l = lv as i32; let (a, c) = match b { - BUILDER => (10 * l, 6 * l), + BUILDER => (15 * l, 10 * l), WARLORD => (5 * l, 5 * l), WARLOCK => (15 * l, 6 * l), _ => (6 * l, 6 * l),