// 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"); // BALANCE_PAIR=a,b runs one pairing only let pairs: Vec<(u8, u8)> = match std::env::var("BALANCE_PAIR").ok().and_then(|p| p.split_once(',').map(|(a, b)| (a.parse().ok(), b.parse().ok()))) { Some((Some(a), Some(b))) => vec![(a, b)], _ => vec![(0, 1), (1, 2), (0, 2), (1, 1)], }; for (a, b) in pairs { 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 ); } } } // where the heroes of a wave really break in, against the marked entry points #[test] #[ignore] fn balance_entry_points() { use crate::map::*; println!("\n== planned entry vs. where the heroes step onto the master's ground =="); for seed in seeds() { let mut g = Game::new(seed, 96, 96); pilot(&mut g, 1, None); let mut planned: Vec<(i32, i32)> = Vec::new(); let mut first_in: Option<(i32, i32)> = None; let mut wave = 0; for _ in 0..((20.0 * 60.0 / STEP) as usize) { g.update(STEP); if g.state != 0 { break; } if !g.planned_entries.is_empty() { planned = g.planned_entries.clone(); } if g.wave != wave { wave = g.wave; first_in = None; } if first_in.is_none() { if let Some(e) = g.ents.iter().find(|e| e.alive && e.active_hero() && g.map.get(e.x as i32, e.y as i32).map_or(false, |t| t.owner == Owner::Player)) { first_in = Some((e.x as i32, e.y as i32)); let d = planned.iter().map(|&(px, py)| (((px - e.x as i32).pow(2) + (py - e.y as i32).pow(2)) as f32).sqrt()).fold(f32::MAX, f32::min); let wp = g.ents.iter().filter(|e| e.alive && e.active_hero()).filter(|e| e.waypoint.is_some()).count(); let heroes = g.ents.iter().filter(|e| e.alive && e.active_hero()).count(); let job = format!("{:?}", e.job); println!("seed {} wave {}: planned {:?} first in at {:?} -> {:.1} tiles off; heroes with waypoint {}/{}; its job {}", seed, wave, planned, first_in.unwrap(), d, wp, heroes, job); } } } } }