optimized via deepseek (100x speedup)
This commit is contained in:
parent
f2d3ea77cb
commit
57e9bcab90
2 changed files with 396 additions and 44 deletions
|
|
@ -12,3 +12,7 @@ rand_gen = { package = "rand", version = "0.10.3" }
|
||||||
rayon = "1.12.0"
|
rayon = "1.12.0"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
toml = "1.2.1"
|
toml = "1.2.1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
use std::cmp::min;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::f32::consts::TAU;
|
use std::f32::consts::TAU;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::ptr;
|
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
use parry2d::math::Pose;
|
use parry2d::math::Pose;
|
||||||
use parry2d::math::Pose2;
|
|
||||||
use parry2d::math::Rot2;
|
use parry2d::math::Rot2;
|
||||||
use parry2d::math::Vec2;
|
use parry2d::math::Vec2;
|
||||||
use parry2d::query::contact;
|
use parry2d::query::contact;
|
||||||
|
|
@ -31,6 +28,148 @@ use serde::Deserialize;
|
||||||
|
|
||||||
const BOT_RADIUS: f32 = 16.0;
|
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 {
|
trait Mortal {
|
||||||
fn collider(&self) -> (&Pose, &dyn Shape);
|
fn collider(&self) -> (&Pose, &dyn Shape);
|
||||||
fn kill(&self);
|
fn kill(&self);
|
||||||
|
|
@ -90,11 +229,20 @@ impl Robot {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn detect<'a>(&self, things: impl Iterator<Item = (&'a Pose, &'a dyn Shape)>) -> Option<f32> {
|
/// Distance from the robot's translation to the closest point on its view
|
||||||
let collider = Segment::new(self.pose.translation, self.view_end());
|
/// segment that lies within `radius` of `center`, or `None` if the segment
|
||||||
things
|
/// misses the target's ball. This is an exact, closed-form replacement for
|
||||||
.map(|thing| contact(&Pose2::IDENTITY, &collider, thing.0, thing.1, 0.0).unwrap()).flatten()
|
/// parry's GJK `contact` query in the ball-vs-segment case (all bodies here
|
||||||
.map(|contact| contact.point1.distance(self.pose.translation)).reduce(|x: f32,y: f32| x.min(y))
|
/// 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) {
|
fn crater(&self) -> (f32, f32, f32, Color) {
|
||||||
|
|
@ -134,7 +282,7 @@ struct Food {
|
||||||
alive: AtomicBool,
|
alive: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq)]
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||||
enum Sensor {
|
enum Sensor {
|
||||||
Food,
|
Food,
|
||||||
Kin,
|
Kin,
|
||||||
|
|
@ -169,10 +317,24 @@ impl Food {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn detect<'a>(&self, things: impl Iterator<Item = &'a Robot>) -> Option<Color> {
|
/// Team of the nearest robot touching this food, via the robot broad-phase
|
||||||
things
|
/// grid. Preserves the original tie-break (a later candidate wins ties).
|
||||||
.map(|robot| contact(&self.pose, &self.collider, &robot.pose, &robot.collider, 0.0).unwrap().map(|a| (a,robot.team))).flatten()
|
fn detect(&self, grid: &Grid, robots: &[Robot]) -> Option<Color> {
|
||||||
.map(|(contact,team)| (contact.point1.distance(self.pose.translation),team)).reduce(|x ,y| if x.0 < y.0 {x} else{y} ).map(|(_, team)| team)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,6 +415,12 @@ struct Simulation {
|
||||||
new_craters: Vec<(f32, f32, f32, Color)>,
|
new_craters: Vec<(f32, f32, f32, Color)>,
|
||||||
watchdog: i32,
|
watchdog: i32,
|
||||||
score: HashMap<[u32;4], 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 {
|
impl Simulation {
|
||||||
|
|
@ -288,7 +456,10 @@ impl Simulation {
|
||||||
let y = rng.random_range(200.0 .. 900.0);
|
let y = rng.random_range(200.0 .. 900.0);
|
||||||
food.push(Food::new(Vec2 { x, y }));
|
food.push(Food::new(Vec2 { x, y }));
|
||||||
}
|
}
|
||||||
Self { robots, food, craters, new_craters, watchdog, score }
|
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] {
|
fn color_hash(color: Color) -> [u32; 4] {
|
||||||
|
|
@ -296,10 +467,18 @@ impl Simulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sim_step(&mut self) -> Option<(i32, i32)>{
|
fn sim_step(&mut self) -> Option<(i32, i32)>{
|
||||||
let Simulation { robots, food, craters, new_craters, watchdog, score } = self;
|
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{
|
for food in &*food{
|
||||||
if let Some(team) = food.detect(robots.iter()) {
|
if let Some(team) = food.detect(robot_grid, robots) {
|
||||||
if food.living() {
|
if food.living() {
|
||||||
score.entry(Self::color_hash(team)).and_modify(|s| *s +=1 ).or_insert(1);
|
score.entry(Self::color_hash(team)).and_modify(|s| *s +=1 ).or_insert(1);
|
||||||
food.kill();
|
food.kill();
|
||||||
|
|
@ -308,14 +487,50 @@ impl Simulation {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let robots_before = robots.len();
|
||||||
food.retain(Mortal::living);
|
food.retain(Mortal::living);
|
||||||
robots.retain(Mortal::living);
|
robots.retain(Mortal::living);
|
||||||
|
|
||||||
let mut sensors = Vec::new();
|
// Rebuild the grids on the retained sets for the sensing pass. The
|
||||||
for robot in &*robots {
|
// robot grid is still valid (positions unchanged) when no robot died.
|
||||||
let detect_food = robot.detect(food.iter().map(Food::collider));
|
if robots.len() != robots_before {
|
||||||
let detect_kin = robot.detect(robots.iter().filter(|o| o.team == robot.team).filter(|r| !ptr::eq(&**r , &**&robot)).map(Robot::collider));
|
robot_pos.clear();
|
||||||
let detect_non_kin = robot.detect(robots.iter().filter(|o| o.team != robot.team).filter(|r| !ptr::eq(&**r , &**&robot)).map(Robot::collider));
|
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 sensor = Sensor::None;
|
||||||
let mut closest: Option<f32> = None;
|
let mut closest: Option<f32> = None;
|
||||||
|
|
@ -334,6 +549,7 @@ impl Simulation {
|
||||||
|
|
||||||
sensors.push(sensor);
|
sensors.push(sensor);
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx in 0..robots.len() {
|
for idx in 0..robots.len() {
|
||||||
let (before, after) = robots.split_at_mut(idx);
|
let (before, after) = robots.split_at_mut(idx);
|
||||||
let (robot, after) = after.split_first_mut().unwrap();
|
let (robot, after) = after.split_first_mut().unwrap();
|
||||||
|
|
@ -362,7 +578,7 @@ impl Simulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn render(&mut self) {
|
async fn render(&mut self) {
|
||||||
let Simulation { robots, food, craters, new_craters, watchdog: _, score } = self;
|
let Simulation { robots, food, craters, new_craters, watchdog: _, score, .. } = self;
|
||||||
|
|
||||||
clear_background(LIGHTGRAY);
|
clear_background(LIGHTGRAY);
|
||||||
for crater in &*new_craters {
|
for crater in &*new_craters {
|
||||||
|
|
@ -440,31 +656,163 @@ struct Cli {
|
||||||
debug: u8,
|
debug: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[macroquad::main("Adversarial Foraging Simulator")]
|
fn load_config(path: &std::path::Path) -> SimConfig {
|
||||||
async fn main() {
|
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();
|
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 iters = cli.run_count.unwrap_or(1);
|
||||||
let speedup = cli.gui_speedup.unwrap_or(5);
|
let speedup = cli.gui_speedup.unwrap_or(5);
|
||||||
|
let config = load_config(&cli.config);
|
||||||
|
|
||||||
let config = fs::read_to_string(cli.config).unwrap();
|
let (mut rtot, mut btot) = (0, 0);
|
||||||
let config: SimConfig = toml::from_str(&config).unwrap();
|
for i in 0..iters {
|
||||||
|
let (red, blu) = run_simulation(i, speedup, &config).await;
|
||||||
let (red, blu) = if cli.no_gui {
|
rtot += red;
|
||||||
(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))
|
btot += blu;
|
||||||
} else {
|
println!("{} wins! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"});
|
||||||
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"});
|
|
||||||
}
|
|
||||||
(rtot,btot)
|
|
||||||
};
|
|
||||||
|
|
||||||
let red = red as f32 / iters as f32;
|
|
||||||
let blu = blu as f32 / iters as f32;
|
|
||||||
println!("{} wins on average! 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue