Automated balance runs; AI masters keep growing, mine more, defend with the rally flag and spells; Builder talent stronger
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
24ba7fb5e3
commit
66eaedf016
8 files changed
+320
-21
No files matched your search
+206
@@ -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<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");
|
||||
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<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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user