Recently I have been playing a lot of W40k. In this game you roll a lot of dice. I wanted a tool to help me make better decisions in the game.
In the previous post, I showed how to make a simple and fast Monte Carlo simulation to compute the average damage and the worst-case scenario under a specified threshold.
Now, it is time to develop a proper web server for the web application.
The Framework: Actix-Web #
Well, being a web developer and a Rust enthusiast, I have been looking at a lot of web frameworks. The Rust ecosystem has many. And so far I have tried small projects with Axum, Actix-Web and Loco.
For this project, as I plan to host the application myself, I consider performance to be the most important criterion and I have chosen Actix-Web.
Refactoring #
Structure #
In the previous post of this series, I simply put all the code in src/main.rs. Let’s structure a bit the code.
Here we go:
src
├── handlers
│ ├── mod.rs
│ └── simulate.rs
├── logic
│ ├── dice.rs
│ ├── mod.rs
│ └── simulation.rs
├── main.rs
└── models
├── input.rs
├── mod.rs
└── result.rs
Logic #
In logic, we can put the code logic for the dice roll and the Monte Carlo simulation. There is noting to change for this part, and we simply copy and paste in separate files the two functions.
dice.rs #
use rand::Rng;
pub fn roll_d6() -> u8 {
rand::thread_rng().gen_range(1..=6)
}
simulation.rs #
use crate::logic::dice::roll_d6;
pub fn monte_carlo_simulation(
num_attacks: usize,
min_to_hit: u8,
min_to_wound: u8,
min_to_save: u8,
threshold_percent: f64,
num_iterations: usize,
) -> (f64, usize) {
let mut successful_attacks_count = vec![0; num_iterations];
for i in 0..num_iterations {
let mut successful_attacks = 0;
for _ in 0..num_attacks {
let hit_roll = roll_d6();
if hit_roll >= min_to_hit {
let wound_roll = roll_d6();
if wound_roll >= min_to_wound {
let save_roll = roll_d6();
if save_roll < min_to_save {
successful_attacks += 1;
}
}
}
}
successful_attacks_count[i] = successful_attacks;
}
let average =
successful_attacks_count.iter().copied().sum::<usize>() as f64 / num_iterations as f64;
successful_attacks_count.sort_unstable();
let threshold_index = ((num_iterations as f64 * (1.0 - threshold_percent / 100.0)).ceil()
as usize)
.saturating_sub(1);
let threshold = successful_attacks_count[threshold_index];
(average, threshold)
}
Server #
main.rs #
We need to instantiate a HttpServer with Actix-Web and configure a route for our application. We name the route simulate. The modifications are quite straightforward as our web server is super simple.
use actix_web::{web, App, HttpServer};
use handlers::simulate::simulate;
mod handlers;
mod logic;
mod models;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().route("/simulate", web::post().to(simulate)))
.bind("127.0.0.1:8000")?
.run()
.await
}
Models #
In models we include two structures: One to get the inputs from the future front and one to return the results. The Serde crate does all the serialisation and deserialisation for us.
inputs #
use serde::Deserialize;
#[derive(Deserialize)]
pub struct SimulationInput {
pub num_attacks: usize,
pub min_to_hit: u8,
pub min_to_wound: u8,
pub min_to_save: u8,
pub threshold_percent: f64,
}
results #
use serde::Serialize;
#[derive(Serialize)]
pub struct SimulationResult {
pub average_successful_attacks: f64,
pub threshold_successful_attacks: usize,
}
Handlers #
Here the code defines an asynchronous web handler function, simulate, which validates the input parameters for a Monte Carlo simulation and returns the results. Errors and successes are evaluated in the same function, and this may require further refactoring in the future. With only one handler, I did not find this necessary now.
simulate.rs #
use crate::logic::simulation::monte_carlo_simulation;
use crate::models::input::SimulationInput;
use crate::models::result::SimulationResult;
use actix_web::{web, HttpResponse, Responder};
pub async fn simulate(input: web::Json<SimulationInput>) -> impl Responder {
if !(2..=6).contains(&input.min_to_hit) {
return HttpResponse::BadRequest().body("min_to_hit must be between 2 and 6.");
}
if !(2..=6).contains(&input.min_to_wound) {
return HttpResponse::BadRequest().body("min_to_wound must be between 2 and 6.");
}
if !(2..=7).contains(&input.min_to_save) {
return HttpResponse::BadRequest().body("min_to_save must be between 2 and 7.");
}
if !(0.0..=100.0).contains(&input.threshold_percent) {
return HttpResponse::BadRequest().body("threshold_percent must be between 0 and 100.");
}
let num_iterations = 10_000;
let (average, threshold) = monte_carlo_simulation(
input.num_attacks,
input.min_to_hit,
input.min_to_wound,
input.min_to_save,
input.threshold_percent,
num_iterations,
);
HttpResponse::Ok().json(SimulationResult {
average_successful_attacks: average,
threshold_successful_attacks: threshold,
})
}
Testing #
Now we can do a POST request to the server with the appropriate inputs.
curl -X POST http://127.0.0.1:8000/simulate -H "Content-Type: application/json" -d '{"num_attacks": 8,"min_to_hit": 2, "min_to_wound": 2, "min_to_save": 7, "threshold_percent": 80.0}'
And we will see in the terminal
{"average_successful_attacks":5.5628,"threshold_successful_attacks":4}
Conclusion #
We now have a web server that can handle input requests, run Monte Carlo simulations and return estimates. The next step is to build a front-end.