April 3, 2023

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:

Deciding a battle:

The imbalance comes from attacker rolling more dice, and defender winning draws. Who has an advantage here?

cool owl says:

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:

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.