From c24a30ac3105664306d6791b24472f504f798d7e Mon Sep 17 00:00:00 2001 From: Andy Killorin <37423245+Speedy6451@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:34:19 -0500 Subject: [PATCH] plotter sim support via deepseek --- simulator/src/main.rs | 189 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/simulator/src/main.rs b/simulator/src/main.rs index 614e616..e08ded1 100644 --- a/simulator/src/main.rs +++ b/simulator/src/main.rs @@ -372,6 +372,16 @@ impl WeaponType { }, } } + /// The radius of the weapon's ball, if it has one. Used to report the + /// swept parameter in sequential-sweep JSON output. + pub fn radius(&self) -> Option { + match self { + WeaponType::Ball { radius } => Some(*radius), + WeaponType::ForwardBall { radius } => Some(*radius), + WeaponType::Lazer { .. } => None, + } + } + pub fn crater(&self, robot: &Robot) -> (f32, f32, f32, Color) { match self { WeaponType::Ball{radius} => { @@ -631,6 +641,149 @@ fn run_headless_simulation(seed: u64, config: &SimConfig) -> (i32, i32) { } } +/// Stopping parameters for [`run_sequential`]. +#[derive(Debug, Clone, Copy)] +struct SeqOptions { + /// Stop once `|mean| / standard_error >= sigma`. + sigma: f64, + /// Never check the stopping rule before this many samples. + min_samples: u64, + /// Hard cap on samples, whether or not the rule fired. + max_samples: u64, + /// Seeds evaluated per parallel batch between stopping-rule checks. + batch: u64, +} + +/// Outcome of a sequential sweep for a single configuration. `winner` is the +/// side the food-share difference favours; `stopped` records whether that +/// decision came from the sigma rule or from hitting `max_samples` (in which +/// case the match is effectively undecided). +#[derive(Debug, Clone, Copy)] +struct SeqResult { + n: u64, + mean_red: f64, + mean_blu: f64, + mean_diff: f64, + sd_diff: f64, + se_diff: f64, + sigma: f64, + winner: &'static str, + stopped: &'static str, + red_wins: u64, + blu_wins: u64, + ties: u64, +} + +/// Sequentially evaluate a single configuration, stopping as soon as the +/// RED-BLU food-share difference is `sigma` standard errors away from zero (or +/// `max_samples` is reached). Seeds run in parallel batches; every batch +/// reuses the same seed range starting at 0, so all configurations share +/// common random numbers and remain directly comparable. +fn run_sequential(config: &SimConfig, opts: SeqOptions) -> SeqResult { + let denom = config.food_amount.max(1) as f64; + let mut red = Variance::default(); + let mut blu = Variance::default(); + let mut diff = Variance::default(); + let (mut red_wins, mut blu_wins, mut ties) = (0u64, 0u64, 0u64); + let mut n = 0u64; + let mut stopped = "max_samples"; + + while n < opts.max_samples { + let take = opts.batch.max(1).min(opts.max_samples - n); + let batch: Vec<(i32, i32)> = (n..n + take) + .into_par_iter() + .map(|seed| run_headless_simulation(seed, config)) + .collect(); + + for &(r, b) in &batch { + let r_share = r as f64 / denom; + let b_share = b as f64 / denom; + red.push(r_share); + blu.push(b_share); + diff.push(r_share - b_share); + match r.cmp(&b) { + std::cmp::Ordering::Greater => red_wins += 1, + std::cmp::Ordering::Less => blu_wins += 1, + std::cmp::Ordering::Equal => ties += 1, + } + } + n += take; + + if n >= opts.min_samples { + if let Some(se) = diff.std_err() { + if se > 0.0 && (diff.mean.abs() / se) >= opts.sigma { + stopped = "sigma"; + break; + } + } + } + } + + let sd = diff.std_dev().unwrap_or(0.0); + let se = diff.std_err().unwrap_or(0.0); + let sigma = if se > 0.0 { diff.mean.abs() / se } else { 0.0 }; + + SeqResult { + n, + mean_red: red.mean, + mean_blu: blu.mean, + mean_diff: diff.mean, + sd_diff: sd, + se_diff: se, + sigma, + winner: if diff.mean >= 0.0 { "red" } else { "blu" }, + stopped, + red_wins, + blu_wins, + ties, + } +} + +/// Format a float as a JSON number, falling back to `null` for non-finite +/// values so downstream `json` parsers never see `NaN`/`inf`. +fn json_float(x: f64) -> String { + if x.is_finite() { + format!("{x}") + } else { + "null".to_string() + } +} + +/// Emit one line of JSON describing the configuration and its sequential +/// result. Flat object, hand-rolled to avoid pulling in `serde_json`. +fn print_seq_json(config: &SimConfig, res: &SeqResult) { + let radius = match config.weapon.model.radius() { + Some(r) => json_float(r as f64), + None => "null".to_string(), + }; + let fields: Vec<(&str, String)> = vec![ + ("view_dist", json_float(config.view_dist as f64)), + ("weapon_radius", radius), + ("weapon_kills_user", config.weapon.kills_user.to_string()), + ("red_count", config.red_count.to_string()), + ("blu_count", config.blu_count.to_string()), + ("food_amount", config.food_amount.to_string()), + ("n", res.n.to_string()), + ("mean_red", json_float(res.mean_red)), + ("mean_blu", json_float(res.mean_blu)), + ("mean_diff", json_float(res.mean_diff)), + ("sd_diff", json_float(res.sd_diff)), + ("se_diff", json_float(res.se_diff)), + ("sigma", json_float(res.sigma)), + ("winner", format!("\"{}\"", res.winner)), + ("stopped", format!("\"{}\"", res.stopped)), + ("red_wins", res.red_wins.to_string()), + ("blu_wins", res.blu_wins.to_string()), + ("ties", res.ties.to_string()), + ]; + let body = fields + .iter() + .map(|(k, v)| format!("\"{k}\":{v}")) + .collect::>() + .join(","); + println!("{{{body}}}"); +} + #[derive(Parser)] #[command(version, about, long_about = None)] @@ -651,6 +804,27 @@ struct Cli { #[arg(short, long, value_name = "FILE")] config: PathBuf, + /// Sequential mode: run seeds in batches, stop once the RED-BLU food-share + /// difference is `--sigma` standard errors from zero, print JSON, exit. + #[arg(long)] + sequential: bool, + + /// Sigma threshold for `--sequential` stopping (default 3). + #[arg(long, default_value_t = 3.0)] + sigma: f64, + + /// Minimum seeds before the `--sequential` stopping rule may fire (default 32). + #[arg(long, default_value_t = 32)] + min_samples: u64, + + /// Hard cap on seeds for `--sequential` (default 2000). + #[arg(long, default_value_t = 2000)] + max_samples: u64, + + /// Seeds per parallel batch for `--sequential` (default 64). + #[arg(long, default_value_t = 64)] + batch: u64, + /// Turn debugging information on #[arg(short, long, action = clap::ArgAction::Count)] debug: u8, @@ -750,6 +924,21 @@ fn print_average(red: i32, blu: i32, iters: u64) { fn main() { let cli = Cli::parse(); + // Sequential mode is inherently headless: it never touches the GUI and + // prints a single JSON line per invocation. + if cli.sequential { + let config = load_config(&cli.config); + let opts = SeqOptions { + sigma: cli.sigma, + min_samples: cli.min_samples, + max_samples: cli.max_samples.max(1), + batch: cli.batch, + }; + let result = run_sequential(&config, opts); + print_seq_json(&config, &result); + return; + } + // 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.