Risk Datamining
Building on a previous blog post on how to embed wasm in astro, let’s do some risk battle data mining with rust + wasm.
Here’s the result, continue reading to find out how it works :)
Contents
Why?
When I played risk for the first time, I found it hard to intuit who would win a battle. The rules aren’t complicated but have subtle differences for attacker and defender.
How?
Risk Battle Rules Refresher
Attacking another territory in Risk involves dice rolls from both the attacker and defender. Comparing the ordered dice from highest to lowest defines how a battle is won or lost, there are no draws.
Number of dice:
- Attacker can roll a maximum of 3 dice and must have at least one more army piece in the attacking territory than number of dice rolled.
- Defender can roll a maximum of 2 dice, or if they have one army piece in the territory then only 1 dice.
Deciding a battle:
- Compare the highest roll for attacker and defender
- If attacker rolls higher then defender loses one army piece
- If defender draws or roles higher than defender loses one army piece
- Then compare the next highest rolls
The imbalance comes from attacker rolling more dice, and defender winning draws. Who has an advantage here?
Can’t we use maths and probabilities to figure this out?
Yes… but we can also simulate 10s of 1000s of battles and converge to the same thing!
Implementation
High Level
I usually like to start with some sort of high level API for the components I might build. Something like this would be good to achieve:
- create an attacking country and a defending country
- loop n times and:
- one country attacks the other until conclusion
- collect total results
We can accomplish this with a map and a fold:
fn simulate(
attacker: Country,
defender: Country,
n: usize
) -> SimulationResult {
let mut rng = rand::thread_rng();
(0..n)
.map(|_| attacker.attack(defender, &mut rng))
.fold(
SimulationResult::default(),
|simulation_result, battle_result|
simulation_result.with_battle_result(battle_result),
)
}
Country/Territory
expand
enum BattleResult {
Win(usize),
Loss(usize),
}
struct Country {
units: usize,
}
impl Country {
fn new(units: usize) -> Country {
Country { units }
}
fn attack(
mut self,
mut defender: Country,
rng: &mut impl rand::Rng
) -> BattleResult {
while self.units > 1 && defender.units > 0 {
let result = FightResult::from_rolls(
roll_attacker(self.num_attackers(), rng),
roll_defender(defender.num_defenders(), rng),
);
self.units -= result.attacker_loss;
defender.units -= result.defender_loss;
}
if defender.units == 0 {
BattleResult::Win(self.units)
} else {
BattleResult::Loss(defender.units)
}
}
fn num_attackers(&self) -> usize {
std::cmp::min(self.units - 1, 3)
}
fn num_defenders(&self) -> usize {
std::cmp::min(self.units, 2)
}
}
struct FightResult {
// ...
}
impl FightResult {
fn from_rolls(
attacker: impl Iterator<Item = DiceRoll>,
defender: impl Iterator<Item = DiceRoll>,
) -> FightResult {
attacker
.zip(defender)
.fold(FightResult::default(), |mut r, (attack, defend)| {
if attack <= defend {
r.attacker_loss += 1
} else {
r.defender_loss += 1;
}
r
})
}
}
Results Collection
expand
#[derive(Serialize)]
struct SimulationResult {
wins: usize,
losses: usize,
total: usize,
}
impl Default for SimulationResult {
fn default() -> Self {
SimulationResult {
wins: 0,
losses: 0,
total: 0,
}
}
}
impl SimulationResult {
fn with_battle_result(self, battle_result: BattleResult) -> SimulationResult {
match battle_result {
BattleResult::Win(units) => self.with_win(units),
BattleResult::Loss(units) => self.with_loss(units),
}
}
fn with_win(mut self, remaining_units: usize) -> SimulationResult {
SimulationResult {
wins: self.wins + 1,
losses: self.losses,
total: self.total + 1,
remaining_units_after_win: self.remaining_units_after_win,
remaining_units_after_loss: self.remaining_units_after_loss,
}
}
fn with_loss(mut self, remaining_units: usize) -> SimulationResult {
SimulationResult {
wins: self.wins,
losses: self.losses + 1,
total: self.total + 1,
remaining_units_after_win: self.remaining_units_after_win,
remaining_units_after_loss: self.remaining_units_after_loss,
}
}
}
Conclusion
We built a risk battle simulator in rust, compiled to wasm and got it ro run in the browser.
It’s interesting experimenting with the number of attackers and defenders. The rules favour defenders in small armies, and favour attackers when there are large armies. It seems like 11vs11 or 12vs12 is the sweet spot of roughly 50/50 odds.
This encourages building up armies at the beginning of the game and large battles towards the middle and end game.