250 lines
10 KiB
Rust
250 lines
10 KiB
Rust
// 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<u8>) {
|
|
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<u8>,
|
|
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<u8> = 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<u32> {
|
|
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<u8> = 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|