use std::collections::HashMap; use std::f32::consts::TAU; use std::fs; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use macroquad::prelude::*; use parry2d::math::Pose; use parry2d::math::Rot2; use parry2d::math::Vec2; use parry2d::query::contact; use parry2d::shape::Ball; use parry2d::shape::Segment; use parry2d::shape::Shape; use rand_gen::RngExt; use rand_gen::SeedableRng; use rand_gen::distr::Distribution; use rand_gen::distr::Uniform; use rand_gen::rngs::SmallRng; use rayon::iter::IntoParallelIterator; use rayon::iter::ParallelIterator; use clap::Parser; use serde::Deserialize; const BOT_RADIUS: f32 = 16.0; /// Broad-phase grid cell size. Derived from the sensing range (the dominant /// query length) and clamped so both sensing and food pick-up stay efficient /// across configurations. fn grid_cell(view_dist: f32) -> f32 { (view_dist * 0.8).clamp(64.0, 256.0) } /// Uniform spatial grid over a slice of 2D points, rebuilt every simulation /// step. Indices are stored in an intrusive singly-linked list (`head`/`next`) /// so building and querying is allocation-free and hash-free. struct Grid { cell: f32, inv_cell: f32, min_x: f32, min_y: f32, cols: i32, rows: i32, head: Vec, next: Vec, } impl Grid { fn new(cell: f32) -> Self { Self { cell, inv_cell: 1.0 / cell, min_x: 0.0, min_y: 0.0, cols: 0, rows: 0, head: Vec::new(), next: Vec::new(), } } /// Rebuild the grid for `pts` (index `i` in `pts` is what queries return). fn rebuild(&mut self, pts: &[Vec2]) { if pts.is_empty() { self.cols = 0; self.rows = 0; self.head.clear(); self.next.clear(); return; } let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); for p in pts { min_x = min_x.min(p.x); min_y = min_y.min(p.y); max_x = max_x.max(p.x); max_y = max_y.max(p.y); } // One cell of slack on every side keeps the arithmetic in bounds. self.min_x = min_x - self.cell; self.min_y = min_y - self.cell; self.cols = (((max_x - self.min_x) * self.inv_cell) as i32 + 2).max(1); self.rows = (((max_y - self.min_y) * self.inv_cell) as i32 + 2).max(1); let n = (self.cols as usize) * (self.rows as usize); self.head.clear(); self.head.resize(n, -1); self.next.clear(); self.next.resize(pts.len(), -1); for (i, p) in pts.iter().enumerate() { let cx = ((p.x - self.min_x) * self.inv_cell) as i32; let cy = ((p.y - self.min_y) * self.inv_cell) as i32; let cx = cx.clamp(0, self.cols - 1); let cy = cy.clamp(0, self.rows - 1); let c = (cy * self.cols + cx) as usize; self.next[i] = self.head[c]; self.head[c] = i as i32; } } /// Visit every index whose point lies inside the axis-aligned box /// `[lo, hi]`. Conservative: may visit points just outside. #[inline] fn for_each_in_aabb(&self, lo: Vec2, hi: Vec2, mut f: impl FnMut(usize)) { if self.cols == 0 { return; } let cx0 = (((lo.x - self.min_x) * self.inv_cell) as i32).clamp(0, self.cols - 1); let cx1 = (((hi.x - self.min_x) * self.inv_cell) as i32).clamp(0, self.cols - 1); let cy0 = (((lo.y - self.min_y) * self.inv_cell) as i32).clamp(0, self.rows - 1); let cy1 = (((hi.y - self.min_y) * self.inv_cell) as i32).clamp(0, self.rows - 1); for cy in cy0..=cy1 { let base = cy * self.cols; for cx in cx0..=cx1 { let mut i = self.head[(base + cx) as usize]; while i >= 0 { f(i as usize); i = self.next[i as usize]; } } } } } /// Exact distance from `a` to the point where the segment `a..b` first comes /// within `radius` of `center`, or `None` if it never does. /// /// This reproduces parry's `contact(Pose::IDENTITY, segment, center, ball, 0.0)` /// followed by `contact.point1.distance(a)`: parry reports the closest point on /// the segment as `point1`, and contacts exactly when the centre-to-segment /// distance is `<= radius`. #[inline] fn seg_ball_dist(a: Vec2, b: Vec2, center: Vec2, radius: f32) -> Option { let ab = b - a; let ab2 = ab.length_squared(); let t = if ab2 > 0.0 { ((center - a).dot(ab) / ab2).clamp(0.0, 1.0) } else { 0.0 }; let q = a + ab * t; if (center - q).length_squared() <= radius * radius { Some((q - a).length()) } else { None } } /// Exact distance from a ball's centre to the contact point on another ball, /// matching parry's ball-ball contact (`point1` sits on ball 1's surface along /// the centre-to-centre normal). Contacts exactly when the centres are closer /// than the sum of the radii. The add-then-subtract mirrors parry transforming /// the local contact point into world space and measuring back to the centre, /// so the result is bit-identical (including the cancellation error). #[inline] fn ball_ball_dist(center1: Vec2, center2: Vec2, radius: f32) -> Option { let delta = center2 - center1; let d2 = delta.length_squared(); let sum = radius + radius; if d2 < sum * sum { let n = if d2 != 0.0 { delta.normalize() } else { Vec2::X }; let world_point1 = center1 + n * radius; Some(world_point1.distance(center1)) } else { None } } trait Mortal { fn collider(&self) -> (&Pose, &dyn Shape); fn kill(&self); fn living(&self) -> bool; fn draw(&self); } struct Robot { pose: Pose, alive: AtomicBool, collider: Ball, team: Color, policy: Policy, weapon: Weapon, view_dist: f32, } impl Mortal for Robot { fn draw(&self) { let tl = self.pose.translation; let tl2 = self.view_end(); draw_circle(tl.x, tl.y, BOT_RADIUS, BLACK); draw_line(tl.x, tl.y, tl2.x, tl2.y, 2.0, BLUE); draw_circle(tl.x, tl.y, BOT_RADIUS-1.0, self.team); } fn collider(&self) -> (&Pose, &dyn Shape) { (&self.pose, &self.collider) } fn kill (&self) { self.alive.store(false, Ordering::Relaxed); } fn living(&self) -> bool { self.alive.load(Ordering::Relaxed) } } impl Robot { fn new(pose: Pose, team: Color, policy: Policy, weapon: Weapon, view_dist: f32) -> Self { Self { pose, alive: AtomicBool::new(true), collider: Ball::new(BOT_RADIUS), team, policy, weapon, view_dist} } fn move_step(&mut self, forward_right: [f32;2]) { let [forward, right] = forward_right; self.pose.translation += self.ahead() * forward; self.pose.rotation *= Rot2::from_angle(right); } pub fn ahead(&self) -> Vec2 { Vec2::new(self.pose.rotation.re, self.pose.rotation.im) } fn view_end(&self) -> Vec2 { self.pose.translation + self.ahead() * self.view_dist } /// Distance from the robot's translation to the closest point on its view /// segment that lies within `radius` of `center`, or `None` if the segment /// misses the target's ball. This is an exact, closed-form replacement for /// parry's GJK `contact` query in the ball-vs-segment case (all bodies here /// are circles), and is dramatically cheaper. #[inline] fn contact_dist(&self, translation: Vec2, end: Vec2, center: Vec2) -> Option { let reach = self.view_dist + BOT_RADIUS; let dx = center.x - translation.x; let dy = center.y - translation.y; if dx * dx + dy * dy > reach * reach { return None; } seg_ball_dist(translation, end, center, BOT_RADIUS) } fn crater(&self) -> (f32, f32, f32, Color) { self.weapon.model.crater(self) } fn weapon<'a>(&self, peers: impl Iterator) { let (pose, weapon) = self.weapon.model.collider(&self); for peer in peers { let (p_p, p_c) = peer.collider(); if let Some(_) = contact(&pose, &*weapon, p_p , p_c, 0.0).unwrap() { peer.kill(); } } if self.weapon.kills_user { self.kill(); // o7 } } pub fn policy(&mut self, sensor: &Sensor) -> bool{ match sensor { Sensor::Food => self.move_step(self.policy.food), Sensor::Kin => self.move_step(self.policy.kin), Sensor::NonKin => self.move_step(self.policy.nonkin), Sensor::None => self.move_step(self.policy.none), } return *sensor == Sensor::NonKin && self.policy.aggressive; } } struct Food { pose: Pose, collider: Ball, alive: AtomicBool, } #[derive(PartialEq, Eq, Clone, Copy)] enum Sensor { Food, Kin, NonKin, None } impl Mortal for Food { fn draw(&self) { let tl = self.pose.translation; draw_circle(tl.x, tl.y, BOT_RADIUS, BLACK); draw_circle(tl.x, tl.y, BOT_RADIUS-1.0, GREEN); } fn collider(&self) -> (&Pose, &dyn Shape) { (&self.pose, &self.collider) } fn kill (&self) { self.alive.store(false, Ordering::Relaxed); } fn living(&self) -> bool { self.alive.load(Ordering::Relaxed) } } impl Food { fn new(position: Vec2) -> Self { let pose:Pose = Pose::from_translation(position.to_array().into()); Self { pose, alive: AtomicBool::new(true), collider: Ball::new(BOT_RADIUS) } } /// Team of the nearest robot touching this food, via the robot broad-phase /// grid. Preserves the original tie-break (a later candidate wins ties). fn detect(&self, grid: &Grid, robots: &[Robot]) -> Option { let c = self.pose.translation; let r = 2.0 * BOT_RADIUS; let lo = Vec2::new(c.x - r, c.y - r); let hi = Vec2::new(c.x + r, c.y + r); let mut best: Option<(f32, Color)> = None; grid.for_each_in_aabb(lo, hi, |ri| { let robot = &robots[ri]; if let Some(d) = ball_ball_dist(c, robot.pose.translation, BOT_RADIUS) { match best { Some((bd, _)) if bd < d => {} _ => best = Some((d, robot.team)), } } }); best.map(|(_, team)| team) } } #[derive(Deserialize, Clone)] struct Policy { aggressive: bool, food: [f32;2], kin: [f32;2], nonkin: [f32;2], none: [f32;2], } #[derive(Deserialize, Clone)] #[serde(rename_all="lowercase", tag="type")] enum WeaponType { Ball { radius: f32 }, // radius Lazer { range: f32 }, // range /// Ball centered at end of detection range ForwardBall {radius: f32}, // radius } impl WeaponType { pub fn collider(&self, robot: &Robot) -> (Pose, Box) { match self { WeaponType::Ball{radius} => { (robot.pose, Box::new(Ball::new(*radius))) }, WeaponType::Lazer{range} => { let end = robot.pose.translation + robot.ahead() * *range; (Pose::identity(), Box::new(Segment::new(robot.pose.translation, end))) }, WeaponType::ForwardBall{radius} => { (Pose::from_translation(robot.view_end()), Box::new(Ball::new(*radius))) }, } } pub fn crater(&self, robot: &Robot) -> (f32, f32, f32, Color) { match self { WeaponType::Ball{radius} => { let tl = robot.pose.translation; (tl.x, tl.y, *radius, ORANGE) }, WeaponType::Lazer{range} => { let tl = robot.pose.translation; (tl.x, tl.y, *range, LIGHTGRAY) }, WeaponType::ForwardBall{radius} => { let tl = robot.view_end(); (tl.x, tl.y, *radius, ORANGE) }, } } } #[derive(Deserialize, Clone)] struct Weapon { kills_user: bool, #[serde(flatten)] model: WeaponType, } #[derive(Deserialize)] struct SimConfig { food_amount: usize, view_dist: f32, red_count: usize, red_policy: Policy, blu_count: usize, blu_policy: Policy, weapon: Weapon, } struct Simulation { robots: Vec, food: Vec, craters: Vec<(f32, f32, f32, Color)>, new_craters: Vec<(f32, f32, f32, Color)>, watchdog: i32, score: HashMap<[u32;4], i32>, robot_grid: Grid, food_grid: Grid, // Scratch buffers reused every step to avoid per-step allocations. robot_pos: Vec, food_pos: Vec, sensors: Vec, } impl Simulation { fn new(seed: u64, config: &SimConfig) -> Self { let mut rng = SmallRng::seed_from_u64(seed); let watchdog = 0; // iterations since something interesting let mut robots = Vec::new(); let craters: Vec<(f32, f32, f32, Color)> = Vec::new(); let new_craters: Vec<(f32, f32, f32, Color)> = Vec::new(); let score = HashMap::new(); let angle = Uniform::new(0.0, TAU).unwrap(); // blue robots for i in 0..config.blu_count { let rand_angle = angle.sample(&mut rng); let pose:Pose = Pose::from_parts(Vec2 { x: 100.0, y: i as f32*50.0 + 250.0}, Rot2::from_angle(rand_angle)); robots.push(Robot::new(pose, BLUE, config.blu_policy.clone(), config.weapon.clone(), config.view_dist)); } //red robots for i in 0..config.red_count { let rand_angle = angle.sample(&mut rng); let pose:Pose = Pose::from_parts(Vec2 { x: 1200.0, y: i as f32*50.0 + 250.0}, Rot2::from_angle(rand_angle)); robots.push(Robot::new(pose, RED, config.red_policy.clone(), config.weapon.clone(), config.view_dist)); } let mut food = Vec::new(); for _ in 0..config.food_amount { let x = rng.random_range(200.0 .. 1100.0); let y = rng.random_range(200.0 .. 900.0); food.push(Food::new(Vec2 { x, y })); } let cell = grid_cell(config.view_dist); Self { robots, food, craters, new_craters, watchdog, score, robot_grid: Grid::new(cell), food_grid: Grid::new(cell), robot_pos: Vec::new(), food_pos: Vec::new(), sensors: Vec::new() } } fn color_hash(color: Color) -> [u32; 4] { color.to_vec().to_array().map(|f| f.to_bits()) } fn sim_step(&mut self) -> Option<(i32, i32)>{ let Simulation { robots, food, craters: _, new_craters, watchdog, score, robot_grid, food_grid, robot_pos, food_pos, sensors } = self; // Broad-phase for food pickup: must cover the same robot set the // original iterated, i.e. *before* the retain below (dead robots get // one more chance to score). robot_pos.clear(); robot_pos.extend(robots.iter().map(|r| r.pose.translation)); robot_grid.rebuild(robot_pos); for food in &*food{ if let Some(team) = food.detect(robot_grid, robots) { if food.living() { score.entry(Self::color_hash(team)).and_modify(|s| *s +=1 ).or_insert(1); food.kill(); *watchdog = 0; } } } let robots_before = robots.len(); food.retain(Mortal::living); robots.retain(Mortal::living); // Rebuild the grids on the retained sets for the sensing pass. The // robot grid is still valid (positions unchanged) when no robot died. if robots.len() != robots_before { robot_pos.clear(); robot_pos.extend(robots.iter().map(|r| r.pose.translation)); robot_grid.rebuild(robot_pos); } food_pos.clear(); food_pos.extend(food.iter().map(|f| f.pose.translation)); food_grid.rebuild(food_pos); sensors.clear(); for (i, robot) in robots.iter().enumerate() { let translation = robot.pose.translation; let end = robot.view_end(); let lo = Vec2::new(translation.x.min(end.x) - BOT_RADIUS, translation.y.min(end.y) - BOT_RADIUS); let hi = Vec2::new(translation.x.max(end.x) + BOT_RADIUS, translation.y.max(end.y) + BOT_RADIUS); let mut detect_food: Option = None; food_grid.for_each_in_aabb(lo, hi, |fi| { if let Some(d) = robot.contact_dist(translation, end, food[fi].pose.translation) { detect_food = Some(detect_food.map_or(d, |b| b.min(d))); } }); let mut detect_kin: Option = None; let mut detect_non_kin: Option = None; robot_grid.for_each_in_aabb(lo, hi, |ri| { if ri == i { return; } let other = &robots[ri]; if let Some(d) = robot.contact_dist(translation, end, other.pose.translation) { if other.team == robot.team { detect_kin = Some(detect_kin.map_or(d, |b| b.min(d))); } else { detect_non_kin = Some(detect_non_kin.map_or(d, |b| b.min(d))); } } }); let mut sensor = Sensor::None; let mut closest: Option = None; for (dist, detection) in [(detect_food, Sensor::Food), (detect_kin, Sensor::Kin), (detect_non_kin, Sensor::NonKin)] { if let (Some(close), Some(dist)) = (closest, dist) { if close < dist {continue;} } if let Some(_) = dist { sensor = detection; closest = dist; } } sensors.push(sensor); } for idx in 0..robots.len() { let (before, after) = robots.split_at_mut(idx); let (robot, after) = after.split_first_mut().unwrap(); let robots = before.iter().chain(after.iter()); let robots = robots.map(|r| r as &dyn Mortal); let peers = robots.chain(food.iter().map(|f| f as &dyn Mortal)); let mut death = robot.policy(&sensors[idx]); if robot.team == BLUE { death = false; // pacifists } if death { new_craters.push(robot.crater()); robot.weapon(peers); } } *watchdog += 1; if food.is_empty() || *watchdog > 1000 { let red = score.get(&Self::color_hash(RED)).unwrap_or(&0); let blu = score.get(&Self::color_hash(BLUE)).unwrap_or(&0); return Some((*red, *blu)); } None } async fn render(&mut self) { let Simulation { robots, food, craters, new_craters, watchdog: _, score, .. } = self; clear_background(LIGHTGRAY); for crater in &*new_craters { draw_circle(crater.0, crater.1, crater.2, crater.3); } craters.append(&mut *new_craters); for crater in &*craters { draw_circle(crater.0, crater.1, crater.2, Color::new(0.75, 0.75, 0.75, 1.0)); } for food in &*food { food.draw(); } for robot in &*robots { robot.draw(); } draw_text(format!("RED: {}", score.get(&Self::color_hash(RED)).unwrap_or(&0)), 20.0, 20.0, 30.0, DARKGRAY); draw_text(format!("BLUE: {}", score.get(&Self::color_hash(BLUE)).unwrap_or(&0)), 20.0, 40.0, 30.0, DARKGRAY); next_frame().await // TODO: framerate cap } } async fn run_simulation(seed: u64, speedup: usize, config: &SimConfig) -> (i32, i32) { let mut sim = Simulation::new(seed, &config); let mut loo = 1; loop { let rendered = loo % speedup == 0; let result = sim.sim_step(); if rendered { sim.render().await } if let Some(result) = result { return result; } loo += 1; } } fn run_headless_simulation(seed: u64, config: &SimConfig) -> (i32, i32) { let mut sim = Simulation::new(seed, config); loop { if let Some(result) = sim.sim_step() { return result; } } } #[derive(Parser)] #[command(version, about, long_about = None)] struct Cli { /// run headless, allows multithreading #[arg(short, long)] no_gui: bool, /// number of samples #[arg(short, long)] run_count: Option, /// how many simulation steps to skip between frames (default 1) #[arg(short, long)] gui_speedup: Option, /// Sets a custom config file #[arg(short, long, value_name = "FILE")] config: PathBuf, /// Turn debugging information on #[arg(short, long, action = clap::ArgAction::Count)] debug: u8, } fn load_config(path: &std::path::Path) -> SimConfig { let text = fs::read_to_string(path).unwrap(); toml::from_str(&text).unwrap() } fn print_average(red: i32, blu: i32, iters: u64) { let iters = iters.max(1) as f32; let red = red as f32 / iters; let blu = blu as f32 / iters; println!("{} wins on average! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"}); } fn main() { let cli = Cli::parse(); // Headless runs never need a window. Going through macroquad's event loop // would otherwise cost seconds of X11/window setup on every invocation, // which dwarfs the actual simulation for parallel sweeps. if cli.no_gui { let config = load_config(&cli.config); let iters = cli.run_count.unwrap_or(1); let (red, blu) = (0..iters) .into_par_iter() .map(|s| run_headless_simulation(s, &config)) .reduce(|| (0, 0), |a, b| (a.0 + b.0, a.1 + b.1)); print_average(red, blu, iters); } else { macroquad::Window::new("Adversarial Foraging Simulator", gui_main(cli)); } } async fn gui_main(cli: Cli) { let iters = cli.run_count.unwrap_or(1); let speedup = cli.gui_speedup.unwrap_or(5); let config = load_config(&cli.config); let (mut rtot, mut btot) = (0, 0); for i in 0..iters { let (red, blu) = run_simulation(i, speedup, &config).await; rtot += red; btot += blu; println!("{} wins! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"}); } print_average(rtot, btot, iters); } #[cfg(test)] mod tests { use super::*; use parry2d::math::Pose2; fn config() -> SimConfig { toml::from_str(include_str!("../conf.toml")).unwrap() } /// The analytic replacements must agree with parry bit-for-bit, both on /// whether a contact exists and on the reported `point1.distance(origin)`. /// This is the load-bearing test behind the whole speedup: the hot path no /// longer calls into parry at all. #[test] fn analytic_matches_parry() { let mut rng = SmallRng::seed_from_u64(0xC0FFEE); let mut seg_contacts = 0u64; let mut bb_contacts = 0u64; for _ in 0..2_000_000 { // segment vs ball let a = Vec2::new(rng.random_range(-500.0f32..500.0), rng.random_range(-500.0..500.0)); let ang = rng.random_range(0.0f32..TAU); let len = rng.random_range(0.0f32..300.0); let b = a + Vec2::new(ang.cos(), ang.sin()) * len; let center = Vec2::new(rng.random_range(-600.0f32..600.0), rng.random_range(-600.0..600.0)); let pose = Pose::from_translation(center.to_array().into()); let parry = contact(&Pose2::IDENTITY, &Segment::new(a, b), &pose, &Ball::new(BOT_RADIUS), 0.0) .unwrap() .map(|c| c.point1.distance(a)); let mine = seg_ball_dist(a, b, center, BOT_RADIUS); assert_eq!(parry.is_some(), mine.is_some(), "segment-ball contact existence"); if let (Some(p), Some(m)) = (parry, mine) { assert_eq!(p.to_bits(), m.to_bits(), "segment-ball distance {p} != {m}"); seg_contacts += 1; } // ball vs ball let c1 = Vec2::new(rng.random_range(-500.0f32..500.0), rng.random_range(-500.0..500.0)); let c2 = Vec2::new(rng.random_range(-520.0f32..520.0), rng.random_range(-520.0..520.0)); let p1 = Pose::from_translation(c1.to_array().into()); let p2 = Pose::from_translation(c2.to_array().into()); let parry = contact(&p1, &Ball::new(BOT_RADIUS), &p2, &Ball::new(BOT_RADIUS), 0.0) .unwrap() .map(|c| c.point1.distance(c1)); let mine = ball_ball_dist(c1, c2, BOT_RADIUS); assert_eq!(parry.is_some(), mine.is_some(), "ball-ball contact existence"); if let (Some(p), Some(m)) = (parry, mine) { assert_eq!(p.to_bits(), m.to_bits(), "ball-ball distance {p} != {m}"); bb_contacts += 1; } } // Guard against the generator accidentally producing (almost) no contacts. assert!(seg_contacts > 1000, "too few segment-ball contacts sampled"); assert!(bb_contacts > 1000, "too few ball-ball contacts sampled"); } /// End-to-end behaviour lock: exact scores for the first 16 seeds. Any /// change to the simulation's physics, ordering or collision code that /// alters outcomes will fail here. #[test] fn regression_scores() { let config = config(); let expected: [(i32, i32); 16] = [ (53, 45), (37, 61), (32, 48), (48, 51), (43, 51), (30, 68), (59, 41), (49, 45), (54, 37), (70, 28), (43, 41), (41, 42), (44, 51), (49, 49), (30, 62), (48, 43), ]; for (seed, want) in expected.iter().enumerate() { let got = run_headless_simulation(seed as u64, &config); assert_eq!(got, *want, "seed {seed}"); } } /// The whole sweep must stay deterministic and reproduce the documented /// average when run from scratch. #[test] fn regression_sweep_average() { let config = config(); let (red, blu) = (0..200u64) .map(|seed| run_headless_simulation(seed, &config)) .fold((0i32, 0i32), |a, b| (a.0 + b.0, a.1 + b.1)); assert_eq!(red, 9615, "red total over 200 seeds"); assert_eq!(blu, 9088, "blu total over 200 seeds"); } /// Same seed twice must produce the same result (no hidden global state). #[test] fn deterministic() { let config = config(); for seed in 0..8u64 { assert_eq!( run_headless_simulation(seed, &config), run_headless_simulation(seed, &config), "seed {seed}" ); } } /// Coarse performance smoke test. Run explicitly with: /// cargo test --release -- --ignored --nocapture bench_full_sweep #[test] #[ignore] fn bench_full_sweep() { let config = config(); let t0 = std::time::Instant::now(); let (red, blu) = (0..200u64) .map(|seed| run_headless_simulation(seed, &config)) .fold((0i32, 0i32), |a, b| (a.0 + b.0, a.1 + b.1)); let dt = t0.elapsed(); println!("200 headless runs (1 core): {dt:?} ({:.3} ms/run) RED={red} BLU={blu}", dt.as_secs_f64() * 1000.0 / 200.0); } }