1
Fork 0
adversarial_foraging/simulator/src/main.rs

818 lines
27 KiB
Rust

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<i32>,
next: Vec<i32>,
}
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<f32> {
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<f32> {
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<f32> {
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<Item = &'a dyn Mortal>) {
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<Color> {
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<dyn Shape>) {
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<Robot>,
food: Vec<Food>,
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<Vec2>,
food_pos: Vec<Vec2>,
sensors: Vec<Sensor>,
}
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<f32> = 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<f32> = None;
let mut detect_non_kin: Option<f32> = 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<f32> = 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<u64>,
/// how many simulation steps to skip between frames (default 1)
#[arg(short, long)]
gui_speedup: Option<usize>,
/// 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);
}
}