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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-06 15:07:02 +01:00
1 parent 24ba7fb5e3
commit 66eaedf016
8 files changed
+320 -21

No files matched your search

+91 -13
View File
@@ -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_RADIUS: [i32; 3] = [11, 15, 19]; // how far the dungeon grows around the heart
const MAX_IMPS: [usize; 3] = [5, 7, 9]; const MAX_IMPS: [usize; 3] = [5, 7, 9];
const FIRST_ATTACK: [f32; 3] = [940.0, 625.0, 430.0]; // no attack before (seconds) 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 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 MAX_TOWERS: [usize; 3] = [1, 2, 4];
const START_GOLD: [f32; 3] = [1500.0, 2500.0, 3500.0]; 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_imps(d, cx, cy);
self.ai_dig(k, d, cx, cy); self.ai_dig(k, d, cx, cy);
self.ai_gold(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_towers(d, cx, cy);
self.ai_fight(k, d, side, 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 // 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 // gold running low: a tunnel to the nearest vein that nobody works on yet
fn ai_gold(&mut self, cx: i32, cy: i32) { 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; return;
} }
let me = self.me; let me = self.me;
// a marked vein the imps can reach (next to own ground) is being worked already // marked veins the imps can reach (next to own ground) are being worked already; one vein
let busy = (0..self.map.t.len()).any(|i| { // for every three imps at a time
let t = &self.map.t[i]; let side = me.side().min(7);
let (x, y) = xy(i); let busy = (0..self.map.t.len())
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())) .filter(|&i| {
}); let t = &self.map.t[i];
if busy { let (x, y) = xy(i);
return; // a vein is being worked already 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| { self.ai_mark_tunnel(cx, cy, |g: &Game, i: usize| {
let t = &g.map.t[i]; 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 // 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() { for &(room, w, h, n) in BUILD.iter() {
// only what this master has researched (the same rules as the player) // only what this master has researched (the same rules as the player)
if !self.ai_knows(crate::ui::Tool::Room(room)) { if !self.ai_knows(crate::ui::Tool::Room(room)) {
@@ -239,6 +246,29 @@ impl Game {
} }
return; 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)? // has this master researched the tool (rooms, towers)?
@@ -295,6 +325,11 @@ impl Game {
self.place_mark(cx + 1, cy + 3, false); self.place_mark(cx + 1, cy + 3, false);
} }
let army = self.side_army(side); 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 // co-op: go where the allies go
if self.is_partner(side) { if self.is_partner(side) {
let lead = self.master_sides().into_iter().find(|&s| s != side && !self.is_out(s) && self.allied(side, s)); 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? // is an enemy heart near tile f?
fn ai_enemy_heart_near(&self, side: u8, f: usize) -> bool { fn ai_enemy_heart_near(&self, side: u8, f: usize) -> bool {
let (fx, fy) = xy(f); let (fx, fy) = xy(f);
+206
View File
@@ -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
);
}
}
}
+2
View File
@@ -161,6 +161,8 @@ pub struct St {
pub const RUNE_TOOL: usize = 20; // index of the rune forge tower in ui::TOOLS 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 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 { 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 }; let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty };
+1
View File
@@ -30,6 +30,7 @@ mod props;
mod research; mod research;
mod rival; mod rival;
mod rogues; mod rogues;
mod balance;
mod talents; mod talents;
mod weather; mod weather;
mod names; mod names;
+1 -1
View File
@@ -365,7 +365,7 @@ impl Game {
// the seat keeps its master (dungeon, gold, creatures): the brain (ai.rs) plays it // the seat keeps its master (dungeon, gold, creatures): the brain (ai.rs) plays it
let core = self.core_of_side(side); let core = self.core_of_side(side);
let difficulty = self.ai_difficulty; 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 });
} }
} }
+5 -1
View File
@@ -33,6 +33,10 @@ pub struct Rival {
pub attacking: bool, // the attack flag is set on an enemy heart pub attacking: bool, // the attack flag is set on an enemy heart
#[serde(default)] #[serde(default)]
pub army0: f32, // army strength when the attack began pub army0: f32, // army strength when the attack began
#[serde(default)]
pub focus: Option<u8>, // 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 { impl Rival {
@@ -98,7 +102,7 @@ impl Game {
let core = (cx - 1, cy - 1); let core = (cx - 1, cy - 1);
self.carve_master_area(side, core); self.carve_master_area(side, core);
self.add_master_portal(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); self.adopt_master(side, core, difficulty, true);
} }
+13 -5
View File
@@ -73,10 +73,10 @@ impl Talents {
} }
// Builder: imps dig and claim faster, rooms cost less // Builder: imps dig and claim faster, rooms cost less
pub fn dig_speed(&self) -> f32 { 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 { 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 // Warlord: creatures hit harder and take less damage
pub fn damage(&self) -> f32 { pub fn damage(&self) -> f32 {
@@ -136,8 +136,16 @@ impl Game {
self.sfx_ui(Sfx::LevelUp); self.sfx_ui(Sfx::LevelUp);
} }
// an AI master spends his points by himself: the branches in turn, the lowest first // an AI master spends his points by himself: the branches in turn, the lowest first (a balance
pub(crate) fn ai_talents(&mut self) { // run may hold him to one branch)
pub(crate) fn ai_talents(&mut self, focus: Option<u8>) {
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 { while self.talents.free() > 0 {
let order = [WARLORD, BUILDER, MERCHANT, WARLOCK]; 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 }; 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() { fn ai_masters_spend_their_points() {
let mut g = Game::new(9, 64, 64); let mut g = Game::new(9, 64, 64);
g.talents.xp = point_xp(6); g.talents.xp = point_xp(6);
g.ai_talents(); g.ai_talents(None);
assert_eq!(g.talents.free(), 0); assert_eq!(g.talents.free(), 0);
assert!(g.talents.lv.iter().all(|&l| l >= 1), "{:?}", g.talents.lv); assert!(g.talents.lv.iter().all(|&l| l >= 1), "{:?}", g.talents.lv);
} }
+1 -1
View File
@@ -559,7 +559,7 @@ fn branch_text(t: &crate::talents::Talents, b: usize) -> String {
let desc = |lv: u8| { let desc = |lv: u8| {
let l = lv as i32; let l = lv as i32;
let (a, c) = match b { let (a, c) = match b {
BUILDER => (10 * l, 6 * l), BUILDER => (15 * l, 10 * l),
WARLORD => (5 * l, 5 * l), WARLORD => (5 * l, 5 * l),
WARLOCK => (15 * l, 6 * l), WARLOCK => (15 * l, 6 * l),
_ => (6 * l, 6 * l), _ => (6 * l, 6 * l),