Initial Rust CLI/TUI Rebuild (Seeded Sim + CSV Export)

This commit is contained in:
markmental 2026-02-10 16:44:07 -05:00
commit dae34cce41
16 changed files with 2739 additions and 0 deletions

251
src/app.rs Normal file
View file

@ -0,0 +1,251 @@
use std::fs::File;
use std::io::{self, Write};
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use crate::data::TEAMS;
use crate::instance::{SimStatus, SimulationInstance};
use crate::sim::SimulationType;
use crate::ui;
use crate::utils::{derive_seed, Rng};
const MAX_INSTANCES: usize = 100;
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum Speed {
X1,
X2,
X4,
Instant,
}
impl Speed {
pub fn frames_per_tick(self) -> usize {
match self {
Speed::X1 => 1,
Speed::X2 => 2,
Speed::X4 => 4,
Speed::Instant => 200,
}
}
pub fn label(self) -> &'static str {
match self {
Speed::X1 => "1x",
Speed::X2 => "2x",
Speed::X4 => "4x",
Speed::Instant => "Instant",
}
}
}
pub struct App {
pub base_seed: u64,
pub speed: Speed,
pub instances: Vec<SimulationInstance>,
pub selected: usize,
pub show_detail: bool,
pub status_line: String,
next_id: usize,
}
impl App {
pub fn new(base_seed: u64, speed: Speed) -> Self {
Self {
base_seed,
speed,
instances: Vec::with_capacity(MAX_INSTANCES),
selected: 0,
show_detail: false,
status_line: format!("Ready. Seed={base_seed}, Speed={}", speed.label()),
next_id: 0,
}
}
pub fn create_instance(&mut self, sim_type: SimulationType) {
if self.instances.len() >= MAX_INSTANCES {
self.status_line = format!("Instance limit reached ({MAX_INSTANCES})");
return;
}
let id = self.next_id;
let seed = derive_seed(self.base_seed, id as u64 + 1);
let teams = self.random_teams_for_mode(sim_type, seed);
let instance = SimulationInstance::new(id, sim_type, teams, seed);
self.instances.push(instance);
self.selected = self.instances.len().saturating_sub(1);
self.next_id += 1;
self.status_line = format!("Created sim-{id}");
}
pub fn start_selected(&mut self) {
if let Some(inst) = self.instances.get_mut(self.selected) {
inst.start();
self.status_line = format!("Started sim-{}", inst.id);
}
}
pub fn clone_selected(&mut self) {
if self.instances.len() >= MAX_INSTANCES {
self.status_line = format!("Instance limit reached ({MAX_INSTANCES})");
return;
}
let Some(existing) = self.instances.get(self.selected).cloned() else {
return;
};
let new_id = self.next_id;
let new_seed = derive_seed(self.base_seed, new_id as u64 + 1);
let cloned = existing.clone_as(new_id, new_seed);
self.instances.push(cloned);
self.selected = self.instances.len().saturating_sub(1);
self.next_id += 1;
self.status_line = format!("Cloned sim-{} -> sim-{}", existing.id, new_id);
}
pub fn delete_selected(&mut self) {
if self.instances.is_empty() {
return;
}
let removed = self.instances.remove(self.selected);
if self.selected >= self.instances.len() {
self.selected = self.instances.len().saturating_sub(1);
}
self.status_line = format!("Deleted sim-{}", removed.id);
}
pub fn export_selected(&mut self) {
let Some(inst) = self.instances.get(self.selected) else {
return;
};
match inst.export_csv() {
Ok(bytes) => {
let file_name = format!("sim-{}-{}.csv", inst.id, inst.sim_type.as_str());
match File::create(&file_name).and_then(|mut f| f.write_all(&bytes)) {
Ok(_) => {
self.status_line = format!("Exported {}", file_name);
}
Err(e) => {
self.status_line = format!("Export failed: {e}");
}
}
}
Err(e) => self.status_line = e,
}
}
pub fn cycle_speed(&mut self, speed: Speed) {
self.speed = speed;
self.status_line = format!("Speed set to {}", self.speed.label());
}
fn random_teams_for_mode(&self, sim_type: SimulationType, seed: u64) -> Vec<String> {
let count = match sim_type {
SimulationType::Single => 2,
SimulationType::League4 | SimulationType::Knockout4 => 4,
};
unique_random_teams(count, seed)
}
pub fn tick(&mut self) {
let frames = self.speed.frames_per_tick();
for inst in &mut self.instances {
if matches!(inst.status, SimStatus::Running { .. }) {
inst.tick(frames);
}
}
}
pub fn selected_instance(&self) -> Option<&SimulationInstance> {
self.instances.get(self.selected)
}
pub fn select_prev(&mut self) {
if self.instances.is_empty() {
return;
}
if self.selected == 0 {
self.selected = self.instances.len() - 1;
} else {
self.selected -= 1;
}
}
pub fn select_next(&mut self) {
if self.instances.is_empty() {
return;
}
self.selected = (self.selected + 1) % self.instances.len();
}
}
pub fn run_tui(mut app: App) -> io::Result<()> {
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let mut running = true;
let tick_rate = Duration::from_millis(16);
let mut last_tick = Instant::now();
while running {
terminal.draw(|f| ui::draw(f, &app))?;
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => running = false,
KeyCode::Up | KeyCode::Char('k') => app.select_prev(),
KeyCode::Down | KeyCode::Char('j') => app.select_next(),
KeyCode::Char('n') => app.create_instance(SimulationType::Single),
KeyCode::Char('l') => app.create_instance(SimulationType::League4),
KeyCode::Char('o') => app.create_instance(SimulationType::Knockout4),
KeyCode::Char('s') => app.start_selected(),
KeyCode::Char('c') => app.clone_selected(),
KeyCode::Char('d') => app.delete_selected(),
KeyCode::Enter | KeyCode::Char('v') => app.show_detail = !app.show_detail,
KeyCode::Char('e') => app.export_selected(),
KeyCode::Char('1') => app.cycle_speed(Speed::X1),
KeyCode::Char('2') => app.cycle_speed(Speed::X2),
KeyCode::Char('4') => app.cycle_speed(Speed::X4),
KeyCode::Char('0') => app.cycle_speed(Speed::Instant),
_ => {}
}
}
}
}
if last_tick.elapsed() >= tick_rate {
app.tick();
last_tick = Instant::now();
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn unique_random_teams(count: usize, seed: u64) -> Vec<String> {
let mut rng = Rng::new(seed);
let mut pool: Vec<&str> = TEAMS.to_vec();
let mut picked = Vec::with_capacity(count);
while picked.len() < count && !pool.is_empty() {
let i = rng.range_usize(pool.len());
picked.push(pool.remove(i).to_string());
}
picked
}

255
src/data.rs Normal file
View file

@ -0,0 +1,255 @@
#[derive(Debug, Clone, Copy)]
pub struct Tactic {
pub key: &'static str,
pub label: &'static str,
pub attack_bias: f64,
pub goal_mult: f64,
pub fast_break: f64,
pub foul_mult: f64,
pub block_mult: f64,
pub press_mult: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct TeamProfile {
pub formation: &'static str,
pub tactic: &'static str,
}
pub const TACTICS: [Tactic; 4] = [
Tactic {
key: "counter",
label: "Counter",
attack_bias: 1.10,
goal_mult: 1.08,
fast_break: 0.25,
foul_mult: 1.00,
block_mult: 1.00,
press_mult: 0.95,
},
Tactic {
key: "possession",
label: "Possession",
attack_bias: 1.00,
goal_mult: 0.95,
fast_break: 0.10,
foul_mult: 0.90,
block_mult: 1.00,
press_mult: 0.90,
},
Tactic {
key: "high_press",
label: "High Press",
attack_bias: 1.15,
goal_mult: 1.00,
fast_break: 0.20,
foul_mult: 1.20,
block_mult: 0.95,
press_mult: 1.20,
},
Tactic {
key: "low_block",
label: "Low Block",
attack_bias: 0.92,
goal_mult: 0.92,
fast_break: 0.12,
foul_mult: 0.95,
block_mult: 1.15,
press_mult: 0.85,
},
];
pub const TEAMS: [&str; 29] = [
"Kashima Antlers",
"Urawa Red Diamonds",
"Gamba Osaka",
"Cerezo Osaka",
"Kawasaki Frontale",
"Yokohama F. Marinos",
"Nagoya Grampus",
"Shimizu S-Pulse",
"Sanfrecce Hiroshima",
"Consadole Sapporo",
"Ventforet Kofu",
"Tokyo Verdy",
"JEF United Chiba",
"Arsenal",
"FC Barcelona",
"Real Madrid",
"Manchester City",
"Manchester United",
"Liverpool",
"Bayern Munich",
"Borussia Dortmund",
"Paris Saint-Germain",
"Juventus",
"Inter",
"AC Milan",
"Ajax",
"Benfica",
"Porto",
"Celtic",
];
pub fn team_flag(team: &str) -> &'static str {
match team {
"Kashima Antlers"
| "Urawa Red Diamonds"
| "Gamba Osaka"
| "Cerezo Osaka"
| "Kawasaki Frontale"
| "Yokohama F. Marinos"
| "Nagoya Grampus"
| "Shimizu S-Pulse"
| "Sanfrecce Hiroshima"
| "Consadole Sapporo"
| "Ventforet Kofu"
| "Tokyo Verdy"
| "JEF United Chiba" => "🇯🇵",
"Arsenal" | "Manchester City" | "Manchester United" | "Liverpool" | "Celtic" => "🇬🇧",
"FC Barcelona" | "Real Madrid" => "🇪🇸",
"Bayern Munich" | "Borussia Dortmund" => "🇩🇪",
"Paris Saint-Germain" => "🇫🇷",
"Juventus" | "Inter" | "AC Milan" => "🇮🇹",
"Ajax" => "🇳🇱",
"Benfica" | "Porto" => "🇵🇹",
_ => "🏳️",
}
}
pub fn display_name(team: &str) -> String {
format!("{} {}", team_flag(team), team)
}
pub fn tactic_by_key(key: &str) -> Tactic {
TACTICS
.iter()
.copied()
.find(|t| t.key == key)
.unwrap_or(TACTICS[0])
}
pub fn profile_for(team: &str) -> TeamProfile {
match team {
"Arsenal" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"FC Barcelona" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Real Madrid" => TeamProfile {
formation: "4-3-3",
tactic: "counter",
},
"Manchester City" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Manchester United" => TeamProfile {
formation: "4-2-3-1",
tactic: "high_press",
},
"Liverpool" => TeamProfile {
formation: "4-3-3",
tactic: "high_press",
},
"Bayern Munich" => TeamProfile {
formation: "4-2-3-1",
tactic: "high_press",
},
"Borussia Dortmund" => TeamProfile {
formation: "4-2-3-1",
tactic: "high_press",
},
"Paris Saint-Germain" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Juventus" => TeamProfile {
formation: "3-5-2",
tactic: "low_block",
},
"Inter" => TeamProfile {
formation: "3-5-2",
tactic: "low_block",
},
"AC Milan" => TeamProfile {
formation: "4-2-3-1",
tactic: "possession",
},
"Ajax" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Benfica" => TeamProfile {
formation: "4-2-3-1",
tactic: "possession",
},
"Porto" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Celtic" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Kawasaki Frontale" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Yokohama F. Marinos" => TeamProfile {
formation: "4-3-3",
tactic: "high_press",
},
"Kashima Antlers" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Urawa Red Diamonds" => TeamProfile {
formation: "4-2-3-1",
tactic: "possession",
},
"Gamba Osaka" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Cerezo Osaka" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Nagoya Grampus" => TeamProfile {
formation: "4-2-3-1",
tactic: "low_block",
},
"Sanfrecce Hiroshima" => TeamProfile {
formation: "3-5-2",
tactic: "possession",
},
"Consadole Sapporo" => TeamProfile {
formation: "3-5-2",
tactic: "high_press",
},
"Shimizu S-Pulse" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Ventforet Kofu" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Tokyo Verdy" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"JEF United Chiba" => TeamProfile {
formation: "4-3-3",
tactic: "counter",
},
_ => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
}
}

153
src/export.rs Normal file
View file

@ -0,0 +1,153 @@
use std::io::{self, Write};
use crate::sim::{PreparedSimulation, SimOutcome};
use crate::utils::csv_escape;
fn write_row<W: Write>(mut w: W, cols: &[String]) -> io::Result<()> {
let mut first = true;
for col in cols {
if !first {
w.write_all(b",")?;
}
first = false;
w.write_all(csv_escape(col).as_bytes())?;
}
w.write_all(b"\n")
}
pub fn simulation_to_csv_bytes(sim: &PreparedSimulation) -> io::Result<Vec<u8>> {
let mut out: Vec<u8> = Vec::new();
match &sim.outcome {
SimOutcome::Single(m) => {
write_row(
&mut out,
&[
"Category".to_string(),
"Home Team".to_string(),
"Away Team".to_string(),
],
)?;
write_row(
&mut out,
&["Team".to_string(), m.home.clone(), m.away.clone()],
)?;
write_row(
&mut out,
&[
"Goals".to_string(),
m.home_goals.to_string(),
m.away_goals.to_string(),
],
)?;
write_row(
&mut out,
&[
"Shots".to_string(),
m.stats.home.shots.to_string(),
m.stats.away.shots.to_string(),
],
)?;
write_row(
&mut out,
&[
"Shots on Target".to_string(),
m.stats.home.sot.to_string(),
m.stats.away.sot.to_string(),
],
)?;
write_row(
&mut out,
&[
"xG".to_string(),
format!("{:.2}", m.stats.home.xg),
format!("{:.2}", m.stats.away.xg),
],
)?;
write_row(
&mut out,
&[
"Corners".to_string(),
m.stats.home.corners.to_string(),
m.stats.away.corners.to_string(),
],
)?;
write_row(
&mut out,
&[
"Fouls".to_string(),
m.stats.home.fouls.to_string(),
m.stats.away.fouls.to_string(),
],
)?;
write_row(
&mut out,
&[
"Yellow Cards".to_string(),
m.stats.home.yellows.to_string(),
m.stats.away.yellows.to_string(),
],
)?;
write_row(
&mut out,
&[
"Saves".to_string(),
m.stats.home.saves.to_string(),
m.stats.away.saves.to_string(),
],
)?;
}
SimOutcome::League { final_table, .. } => {
write_row(
&mut out,
&[
"Team".to_string(),
"P".to_string(),
"W".to_string(),
"D".to_string(),
"L".to_string(),
"GF".to_string(),
"GA".to_string(),
"GD".to_string(),
"Pts".to_string(),
],
)?;
for row in final_table {
write_row(
&mut out,
&[
row.team.clone(),
row.p.to_string(),
row.w.to_string(),
row.d.to_string(),
row.l.to_string(),
row.gf.to_string(),
row.ga.to_string(),
row.gd.to_string(),
row.pts.to_string(),
],
)?;
}
}
SimOutcome::Knockout { .. } => {
write_row(&mut out, &["Stage".to_string(), "Match Result".to_string()])?;
for line in &sim.history_lines {
let stage = if line.starts_with("Semi 1") {
"Semi 1"
} else if line.starts_with("Semi 2") {
"Semi 2"
} else if line.starts_with("Final") {
"Final"
} else if line.starts_with("Champion") {
"Champion"
} else {
"Info"
};
write_row(&mut out, &[stage.to_string(), line.clone()])?;
}
}
}
Ok(out)
}

160
src/instance.rs Normal file
View file

@ -0,0 +1,160 @@
use std::collections::VecDeque;
use crate::export::simulation_to_csv_bytes;
use crate::sim::{run_simulation, PreparedSimulation, SimOutcome, SimulationType};
use crate::utils::Rng;
pub const MAX_LOG_LINES: usize = 1000;
#[derive(Debug, Clone)]
pub enum SimStatus {
Pending,
Running {
frame_index: usize,
total_frames: usize,
},
Completed,
}
#[derive(Debug, Clone)]
pub struct SimulationInstance {
pub id: usize,
pub sim_type: SimulationType,
pub teams: Vec<String>,
pub seed: u64,
pub status: SimStatus,
pub scoreboard: String,
pub logs: VecDeque<String>,
pub stats_lines: Vec<String>,
pub competition_lines: Vec<String>,
pub history_lines: Vec<String>,
prepared: Option<PreparedSimulation>,
}
impl SimulationInstance {
pub fn new(id: usize, sim_type: SimulationType, teams: Vec<String>, seed: u64) -> Self {
Self {
id,
sim_type,
teams,
seed,
status: SimStatus::Pending,
scoreboard: "Waiting for kickoff...".to_string(),
logs: VecDeque::with_capacity(MAX_LOG_LINES),
stats_lines: Vec::new(),
competition_lines: Vec::new(),
history_lines: Vec::new(),
prepared: None,
}
}
pub fn start(&mut self) {
if !matches!(self.status, SimStatus::Pending) {
return;
}
let mut rng = Rng::new(self.seed);
let prepared = run_simulation(self.sim_type, &self.teams, &mut rng);
let total_frames = prepared.frames.len();
self.stats_lines = prepared.stats_lines.clone();
self.competition_lines = prepared.competition_lines.clone();
self.history_lines = prepared.history_lines.clone();
self.status = SimStatus::Running {
frame_index: 0,
total_frames,
};
self.prepared = Some(prepared);
self.push_log(format!(
"Instance sim-{} started (seed={})",
self.id, self.seed
));
}
pub fn tick(&mut self, frames_to_advance: usize) {
if self.prepared.is_none() {
return;
}
let total_available = self.prepared.as_ref().map(|p| p.frames.len()).unwrap_or(0);
let SimStatus::Running {
mut frame_index,
total_frames,
} = self.status.clone()
else {
return;
};
for _ in 0..frames_to_advance {
if frame_index >= total_available {
self.status = SimStatus::Completed;
self.push_log("Simulation completed.".to_string());
return;
}
let frame = self
.prepared
.as_ref()
.and_then(|p| p.frames.get(frame_index).cloned())
.expect("frame exists while running");
self.scoreboard = frame.scoreboard;
for line in frame.logs {
self.push_log(line);
}
frame_index += 1;
}
if frame_index >= total_frames {
self.status = SimStatus::Completed;
self.push_log("Simulation completed.".to_string());
} else {
self.status = SimStatus::Running {
frame_index,
total_frames,
};
}
}
pub fn clone_as(&self, new_id: usize, new_seed: u64) -> Self {
Self::new(new_id, self.sim_type, self.teams.clone(), new_seed)
}
pub fn progress_text(&self) -> String {
match &self.status {
SimStatus::Pending => "Ready to start".to_string(),
SimStatus::Running {
frame_index,
total_frames,
} => format!("Running {}/{}", frame_index, total_frames),
SimStatus::Completed => "Completed".to_string(),
}
}
pub fn export_csv(&self) -> Result<Vec<u8>, String> {
let Some(prepared) = &self.prepared else {
return Err("Simulation has not run yet".to_string());
};
simulation_to_csv_bytes(prepared).map_err(|e| format!("CSV export failed: {e}"))
}
pub fn outcome_summary(&self) -> String {
let Some(prepared) = &self.prepared else {
return "No result yet".to_string();
};
match &prepared.outcome {
SimOutcome::Single(m) => {
format!("{} {}-{} {}", m.home, m.home_goals, m.away_goals, m.away)
}
SimOutcome::League { champion, .. } => format!("Champion: {}", champion),
SimOutcome::Knockout { champion } => format!("Champion: {}", champion),
}
}
fn push_log(&mut self, line: String) {
if self.logs.len() == MAX_LOG_LINES {
self.logs.pop_front();
}
self.logs.push_back(line);
}
}

135
src/main.rs Normal file
View file

@ -0,0 +1,135 @@
mod app;
mod data;
mod export;
mod instance;
mod sim;
mod ui;
mod utils;
use std::fs::File;
use std::io::{self, Write};
use clap::{Parser, Subcommand, ValueEnum};
use app::{run_tui, App, Speed};
use data::{display_name, TEAMS};
use export::simulation_to_csv_bytes;
use sim::{run_simulation, SimulationType};
use utils::{derive_seed, Rng};
#[derive(Debug, Parser)]
#[command(name = "soccercloud")]
#[command(about = "MentalNet SoccerCloud - Rust CLI/TUI simulator")]
struct Cli {
#[arg(long, global = true)]
seed: Option<u64>,
#[arg(long, global = true, value_enum, default_value_t = Speed::X1)]
speed: Speed,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Debug, Subcommand)]
enum Commands {
Quick {
#[arg(long)]
home: String,
#[arg(long)]
away: String,
},
List,
Export {
#[arg(long, value_enum)]
mode: ModeArg,
#[arg(long)]
out: String,
#[arg(long = "team", required = true)]
teams: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ModeArg {
Single,
League4,
Knockout4,
}
impl From<ModeArg> for SimulationType {
fn from(value: ModeArg) -> Self {
match value {
ModeArg::Single => SimulationType::Single,
ModeArg::League4 => SimulationType::League4,
ModeArg::Knockout4 => SimulationType::Knockout4,
}
}
}
fn main() -> io::Result<()> {
let cli = Cli::parse();
let base_seed = cli.seed.unwrap_or_else(|| Rng::from_time().next_u64());
match cli.command {
None => {
let app = App::new(base_seed, cli.speed);
run_tui(app)
}
Some(Commands::Quick { home, away }) => {
quick_mode(&home, &away, base_seed);
Ok(())
}
Some(Commands::List) => {
for team in TEAMS {
println!("{}", display_name(team));
}
Ok(())
}
Some(Commands::Export { mode, out, teams }) => export_mode(mode, out, teams, base_seed),
}
}
fn quick_mode(home: &str, away: &str, base_seed: u64) {
let teams = vec![home.to_string(), away.to_string()];
let seed = derive_seed(base_seed, 1);
let mut rng = Rng::new(seed);
let prepared = run_simulation(SimulationType::Single, &teams, &mut rng);
println!("seed={seed}");
if let sim::SimOutcome::Single(m) = prepared.outcome {
println!("{} {}-{} {}", m.home, m.home_goals, m.away_goals, m.away);
println!("xG {:.2} - {:.2}", m.stats.home.xg, m.stats.away.xg);
}
println!("-- log --");
for frame in prepared.frames {
for line in frame.logs {
println!("{}", line);
}
}
}
fn export_mode(mode: ModeArg, out: String, teams: Vec<String>, base_seed: u64) -> io::Result<()> {
let required = match mode {
ModeArg::Single => 2,
ModeArg::League4 | ModeArg::Knockout4 => 4,
};
if teams.len() != required {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"mode {:?} requires exactly {} --team values",
mode, required
),
));
}
let mut rng = Rng::new(derive_seed(base_seed, 1));
let prepared = run_simulation(mode.into(), &teams, &mut rng);
let bytes = simulation_to_csv_bytes(&prepared)?;
let mut f = File::create(&out)?;
f.write_all(&bytes)?;
println!("Wrote {}", out);
Ok(())
}

688
src/sim.rs Normal file
View file

@ -0,0 +1,688 @@
use std::cmp::Ordering;
use std::collections::BTreeMap;
use crate::data::{display_name, profile_for, tactic_by_key, TeamProfile};
use crate::utils::{pad2, Rng};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SimulationType {
Single,
League4,
Knockout4,
}
impl SimulationType {
pub fn as_str(self) -> &'static str {
match self {
SimulationType::Single => "single",
SimulationType::League4 => "league4",
SimulationType::Knockout4 => "knockout4",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TeamStats {
pub shots: u16,
pub sot: u16,
pub xg: f64,
pub corners: u16,
pub fouls: u16,
pub yellows: u16,
pub offsides: u16,
pub saves: u16,
pub attacks: u16,
}
#[derive(Debug, Clone)]
pub struct MatchStats {
pub home: TeamStats,
pub away: TeamStats,
}
#[derive(Debug, Clone)]
pub struct MatchResult {
pub home: String,
pub away: String,
pub home_goals: u8,
pub away_goals: u8,
pub home_profile: TeamProfile,
pub away_profile: TeamProfile,
pub stats: MatchStats,
}
#[derive(Debug, Clone)]
pub struct StandingsRow {
pub team: String,
pub p: u8,
pub w: u8,
pub d: u8,
pub l: u8,
pub gf: u16,
pub ga: u16,
pub gd: i16,
pub pts: u8,
}
#[derive(Debug, Clone)]
pub struct SimFrame {
pub scoreboard: String,
pub logs: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum SimOutcome {
Single(MatchResult),
League {
champion: String,
final_table: Vec<StandingsRow>,
},
Knockout {
champion: String,
},
}
#[derive(Debug, Clone)]
pub struct PreparedSimulation {
pub frames: Vec<SimFrame>,
pub outcome: SimOutcome,
pub stats_lines: Vec<String>,
pub competition_lines: Vec<String>,
pub history_lines: Vec<String>,
}
fn chance(rng: &mut Rng, p: f64) -> bool {
rng.chance(p)
}
pub fn penalties(rng: &mut Rng) -> (u8, u8, bool) {
let mut h = 0u8;
let mut a = 0u8;
for _ in 0..5 {
if chance(rng, 0.76) {
h += 1;
}
if chance(rng, 0.76) {
a += 1;
}
}
let mut rounds = 0;
while h == a && rounds < 20 {
if chance(rng, 0.76) {
h += 1;
}
if chance(rng, 0.76) {
a += 1;
}
rounds += 1;
}
(h, a, h > a)
}
pub fn simulate_match(home: &str, away: &str, rng: &mut Rng) -> (MatchResult, Vec<SimFrame>) {
let home_profile = profile_for(home);
let away_profile = profile_for(away);
let home_tactic = tactic_by_key(home_profile.tactic);
let away_tactic = tactic_by_key(away_profile.tactic);
let mut minute: u8 = 0;
let mut home_goals: u8 = 0;
let mut away_goals: u8 = 0;
let mut stats = MatchStats {
home: TeamStats::default(),
away: TeamStats::default(),
};
let kickoff = format!(
"Kickoff! {} ({}, {}) vs {} ({}, {})",
display_name(home),
home_profile.formation,
home_tactic.label,
display_name(away),
away_profile.formation,
away_tactic.label
);
let mut frames = vec![SimFrame {
scoreboard: format!(
"{} ({}) {} - {} {} ({}) | {}'",
display_name(home),
home_profile.formation,
home_goals,
away_goals,
display_name(away),
away_profile.formation,
pad2(minute)
),
logs: vec![kickoff],
}];
while minute < 90 {
minute += 1;
let pressure_boost = if minute < 15 || minute > 75 { 1.2 } else { 1.0 };
let mut logs: Vec<String> = Vec::new();
let home_bias = home_tactic.attack_bias;
let away_bias = away_tactic.attack_bias;
let home_attacks = rng.next_f64() * (home_bias + away_bias) < home_bias;
let (atk_team, def_team, atk_prof, def_prof, atk_stats, def_stats) = if home_attacks {
(
home,
away,
home_profile,
away_profile,
&mut stats.home,
&mut stats.away,
)
} else {
(
away,
home,
away_profile,
home_profile,
&mut stats.away,
&mut stats.home,
)
};
let atk_tactic = tactic_by_key(atk_prof.tactic);
let def_tactic = tactic_by_key(def_prof.tactic);
if chance(rng, 0.24 * pressure_boost) {
atk_stats.attacks += 1;
let fast_break = chance(rng, atk_tactic.fast_break);
if chance(rng, (if fast_break { 0.75 } else { 0.55 }) * pressure_boost) {
atk_stats.shots += 1;
let mut xg = if fast_break {
0.20 + rng.next_f64() * 0.25
} else {
0.05 + rng.next_f64() * 0.22
};
xg *= atk_tactic.goal_mult;
xg /= def_tactic.block_mult;
let on_target = chance(rng, 0.52);
if on_target {
atk_stats.sot += 1;
}
let is_goal = on_target && chance(rng, xg);
if is_goal {
if home_attacks {
home_goals += 1;
} else {
away_goals += 1;
}
let finish = if fast_break {
"cut-back finish"
} else {
"drilled low"
};
logs.push(format!(
"{}' GOOOOAL - {} ({}, xG {:.2})",
pad2(minute),
display_name(atk_team),
finish,
xg
));
} else if on_target {
def_stats.saves += 1;
logs.push(format!(
"{}' Big save by {}'s keeper!",
pad2(minute),
display_name(def_team)
));
} else if chance(rng, 0.25) {
logs.push(format!(
"{}' {} fire it just wide.",
pad2(minute),
display_name(atk_team)
));
}
atk_stats.xg += xg;
}
if chance(rng, 0.05 * atk_tactic.attack_bias) {
atk_stats.corners += 1;
logs.push(format!(
"{}' Corner to {}.",
pad2(minute),
display_name(atk_team)
));
}
if chance(rng, 0.035 + 0.02 * atk_tactic.fast_break) {
atk_stats.offsides += 1;
logs.push(format!(
"{}' Flag up - {} caught offside.",
pad2(minute),
display_name(atk_team)
));
}
}
if chance(rng, 0.07 * atk_tactic.press_mult * atk_tactic.foul_mult) {
def_stats.fouls += 1;
if chance(rng, 0.22 * atk_tactic.press_mult) {
def_stats.yellows += 1;
logs.push(format!(
"{}' Yellow card to {}.",
pad2(minute),
display_name(def_team)
));
}
}
if minute == 45 {
logs.push(format!(
"Halftime - {} {}-{} {}",
display_name(home),
home_goals,
away_goals,
display_name(away)
));
}
if minute == 90 {
logs.push(format!(
"Full time - {} {}-{} {}",
display_name(home),
home_goals,
away_goals,
display_name(away)
));
}
frames.push(SimFrame {
scoreboard: format!(
"{} ({}) {} - {} {} ({}) | {}'",
display_name(home),
home_profile.formation,
home_goals,
away_goals,
display_name(away),
away_profile.formation,
pad2(minute)
),
logs,
});
}
(
MatchResult {
home: home.to_string(),
away: away.to_string(),
home_goals,
away_goals,
home_profile,
away_profile,
stats,
},
frames,
)
}
pub fn match_stats_lines(result: &MatchResult) -> Vec<String> {
let home_tactic = tactic_by_key(result.home_profile.tactic);
let away_tactic = tactic_by_key(result.away_profile.tactic);
let home_poss_base = (result.stats.home.attacks as f64)
* if result.home_profile.tactic == "possession" {
1.15
} else {
1.0
};
let away_poss_base = (result.stats.away.attacks as f64)
* if result.away_profile.tactic == "possession" {
1.15
} else {
1.0
};
let home_poss = if (home_poss_base + away_poss_base) > 0.0 {
((home_poss_base / (home_poss_base + away_poss_base)) * 100.0).round() as u8
} else {
50
};
let away_poss = 100 - home_poss;
vec![
format!(
"Tactics: {} {} | {} {}",
display_name(&result.home),
home_tactic.label,
display_name(&result.away),
away_tactic.label
),
format!(
"Shots (On Target): {} ({}) vs {} ({})",
result.stats.home.shots,
result.stats.home.sot,
result.stats.away.shots,
result.stats.away.sot
),
format!(
"xG: {:.2} vs {:.2}",
result.stats.home.xg, result.stats.away.xg
),
format!(
"Corners: {} vs {}",
result.stats.home.corners, result.stats.away.corners
),
format!(
"Fouls (Yellows): {} ({}) vs {} ({})",
result.stats.home.fouls,
result.stats.home.yellows,
result.stats.away.fouls,
result.stats.away.yellows
),
format!(
"Offsides: {} vs {}",
result.stats.home.offsides, result.stats.away.offsides
),
format!(
"Saves: {} vs {}",
result.stats.home.saves, result.stats.away.saves
),
format!("Possession: {}% vs {}%", home_poss, away_poss),
]
}
fn standings_cmp(a: &StandingsRow, b: &StandingsRow) -> Ordering {
b.pts
.cmp(&a.pts)
.then(b.gd.cmp(&a.gd))
.then(b.gf.cmp(&a.gf))
.then(a.team.cmp(&b.team))
}
fn init_table(teams: &[String]) -> BTreeMap<String, StandingsRow> {
let mut map = BTreeMap::new();
for team in teams {
map.insert(
team.clone(),
StandingsRow {
team: team.clone(),
p: 0,
w: 0,
d: 0,
l: 0,
gf: 0,
ga: 0,
gd: 0,
pts: 0,
},
);
}
map
}
pub fn run_single(teams: &[String], rng: &mut Rng) -> PreparedSimulation {
let home = teams[0].clone();
let away = teams[1].clone();
let (result, frames) = simulate_match(&home, &away, rng);
let stats_lines = match_stats_lines(&result);
PreparedSimulation {
frames,
outcome: SimOutcome::Single(result),
stats_lines,
competition_lines: vec![],
history_lines: vec![],
}
}
pub fn run_league4(teams: &[String], rng: &mut Rng) -> PreparedSimulation {
let fixtures = vec![
(teams[0].clone(), teams[1].clone()),
(teams[2].clone(), teams[3].clone()),
(teams[0].clone(), teams[2].clone()),
(teams[1].clone(), teams[3].clone()),
(teams[0].clone(), teams[3].clone()),
(teams[1].clone(), teams[2].clone()),
];
let mut table = init_table(teams);
let mut frames = Vec::new();
let mut history = Vec::new();
let mut last_stats = Vec::new();
for (idx, (home, away)) in fixtures.iter().enumerate() {
frames.push(SimFrame {
scoreboard: format!("Running League Match {}/{}", idx + 1, fixtures.len()),
logs: vec![format!(
"League fixture {}/{}: {} vs {}",
idx + 1,
fixtures.len(),
display_name(home),
display_name(away)
)],
});
let (res, mut match_frames) = simulate_match(home, away, rng);
frames.append(&mut match_frames);
last_stats = match_stats_lines(&res);
{
let home_row = table.get_mut(home).expect("home in table");
home_row.p += 1;
home_row.gf += res.home_goals as u16;
home_row.ga += res.away_goals as u16;
home_row.gd = home_row.gf as i16 - home_row.ga as i16;
if res.home_goals > res.away_goals {
home_row.w += 1;
home_row.pts += 3;
} else if res.home_goals == res.away_goals {
home_row.d += 1;
home_row.pts += 1;
} else {
home_row.l += 1;
}
}
{
let away_row = table.get_mut(away).expect("away in table");
away_row.p += 1;
away_row.gf += res.away_goals as u16;
away_row.ga += res.home_goals as u16;
away_row.gd = away_row.gf as i16 - away_row.ga as i16;
if res.away_goals > res.home_goals {
away_row.w += 1;
away_row.pts += 3;
} else if res.away_goals == res.home_goals {
away_row.d += 1;
away_row.pts += 1;
} else {
away_row.l += 1;
}
}
history.push(format!(
"{} {}-{} {}",
display_name(home),
res.home_goals,
res.away_goals,
display_name(away)
));
}
let mut final_table: Vec<StandingsRow> = table.into_values().collect();
final_table.sort_by(standings_cmp);
let champion = final_table[0].team.clone();
history.push(format!(
"Champion: {} with {} pts",
display_name(&champion),
final_table[0].pts
));
let competition = final_table
.iter()
.map(|r| {
format!(
"{} | P:{} W:{} D:{} L:{} GF:{} GA:{} GD:{} Pts:{}",
display_name(&r.team),
r.p,
r.w,
r.d,
r.l,
r.gf,
r.ga,
r.gd,
r.pts
)
})
.collect();
PreparedSimulation {
frames,
outcome: SimOutcome::League {
champion,
final_table,
},
stats_lines: last_stats,
competition_lines: competition,
history_lines: history,
}
}
pub fn run_knockout4(teams: &[String], rng: &mut Rng) -> PreparedSimulation {
let semis = vec![
(teams[0].clone(), teams[3].clone()),
(teams[1].clone(), teams[2].clone()),
];
let mut winners = Vec::new();
let mut history = Vec::new();
let mut frames = Vec::new();
for (idx, (home, away)) in semis.iter().enumerate() {
frames.push(SimFrame {
scoreboard: format!("Running Semi-final {}/2", idx + 1),
logs: vec![format!(
"Semi {}: {} vs {}",
idx + 1,
display_name(home),
display_name(away)
)],
});
let (res, mut semi_frames) = simulate_match(home, away, rng);
frames.append(&mut semi_frames);
let winner = if res.home_goals == res.away_goals {
let (ph, pa, home_wins) = penalties(rng);
history.push(format!(
"Semi {}: {} {}-{} {} (pens {}-{})",
idx + 1,
display_name(home),
res.home_goals,
res.away_goals,
display_name(away),
ph,
pa
));
if home_wins {
home.clone()
} else {
away.clone()
}
} else if res.home_goals > res.away_goals {
history.push(format!(
"Semi {}: {} {}-{} {}",
idx + 1,
display_name(home),
res.home_goals,
res.away_goals,
display_name(away)
));
home.clone()
} else {
history.push(format!(
"Semi {}: {} {}-{} {}",
idx + 1,
display_name(home),
res.home_goals,
res.away_goals,
display_name(away)
));
away.clone()
};
winners.push(winner);
}
frames.push(SimFrame {
scoreboard: "Running Final".to_string(),
logs: vec![format!(
"Final: {} vs {}",
display_name(&winners[0]),
display_name(&winners[1])
)],
});
let (final_res, mut final_frames) = simulate_match(&winners[0], &winners[1], rng);
frames.append(&mut final_frames);
let last_stats = match_stats_lines(&final_res);
let champion = if final_res.home_goals == final_res.away_goals {
let (ph, pa, home_wins) = penalties(rng);
history.push(format!(
"Final: {} {}-{} {} (pens {}-{})",
display_name(&winners[0]),
final_res.home_goals,
final_res.away_goals,
display_name(&winners[1]),
ph,
pa
));
if home_wins {
winners[0].clone()
} else {
winners[1].clone()
}
} else if final_res.home_goals > final_res.away_goals {
history.push(format!(
"Final: {} {}-{} {}",
display_name(&winners[0]),
final_res.home_goals,
final_res.away_goals,
display_name(&winners[1])
));
winners[0].clone()
} else {
history.push(format!(
"Final: {} {}-{} {}",
display_name(&winners[0]),
final_res.home_goals,
final_res.away_goals,
display_name(&winners[1])
));
winners[1].clone()
};
history.push(format!("Champion: {} 🏆", display_name(&champion)));
PreparedSimulation {
frames,
outcome: SimOutcome::Knockout {
champion: champion.clone(),
},
stats_lines: last_stats,
competition_lines: vec![
"Bracket: Semi 1 = Team1 vs Team4".to_string(),
"Bracket: Semi 2 = Team2 vs Team3".to_string(),
format!("Champion: {}", display_name(&champion)),
],
history_lines: history,
}
}
pub fn run_simulation(
sim_type: SimulationType,
teams: &[String],
rng: &mut Rng,
) -> PreparedSimulation {
match sim_type {
SimulationType::Single => run_single(teams, rng),
SimulationType::League4 => run_league4(teams, rng),
SimulationType::Knockout4 => run_knockout4(teams, rng),
}
}

83
src/ui/dashboard.rs Normal file
View file

@ -0,0 +1,83 @@
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use crate::app::App;
use crate::instance::SimStatus;
use crate::ui::widgets::status_badge;
pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
let mut items: Vec<ListItem> = Vec::new();
if app.instances.is_empty() {
items.push(ListItem::new(
"No instances yet. Press n, l, or o to create one.",
));
} else {
for (idx, inst) in app.instances.iter().enumerate() {
let marker = if idx == app.selected { ">" } else { " " };
let line = format!(
"{} sim-{} [{}] {} | {}",
marker,
inst.id,
inst.sim_type.as_str(),
status_badge(&inst.status),
inst.progress_text()
);
let mut item = ListItem::new(line);
if idx == app.selected {
item = item.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
}
items.push(item);
}
}
let list = List::new(items).block(Block::default().title("Instances").borders(Borders::ALL));
f.render_widget(list, chunks[0]);
let detail_text = if let Some(inst) = app.selected_instance() {
let status = match &inst.status {
SimStatus::Pending => "pending",
SimStatus::Running { .. } => "running",
SimStatus::Completed => "completed",
};
format!(
"ID: sim-{}\nType: {}\nStatus: {}\nSeed: {}\nTeams:\n- {}\n- {}{}\n\nOutcome:\n{}\n\nTip: Press Enter or v to open live detail view.",
inst.id,
inst.sim_type.as_str(),
status,
inst.seed,
inst.teams.first().cloned().unwrap_or_default(),
inst.teams.get(1).cloned().unwrap_or_default(),
if inst.teams.len() > 2 {
format!(
"\n- {}\n- {}",
inst.teams.get(2).cloned().unwrap_or_default(),
inst.teams.get(3).cloned().unwrap_or_default()
)
} else {
String::new()
},
inst.outcome_summary()
)
} else {
"No selection".to_string()
};
let details = Paragraph::new(detail_text)
.block(
Block::default()
.title("Selected Instance")
.borders(Borders::ALL),
)
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(details, chunks[1]);
}

109
src/ui/detail.rs Normal file
View file

@ -0,0 +1,109 @@
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs};
use crate::app::App;
use crate::instance::SimStatus;
pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
let Some(inst) = app.selected_instance() else {
let empty = Paragraph::new("No selected instance")
.block(Block::default().title("Detail").borders(Borders::ALL));
f.render_widget(empty, area);
return;
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(8),
])
.split(area);
let score = Paragraph::new(inst.scoreboard.clone())
.block(
Block::default()
.title(format!("sim-{} Scoreboard", inst.id))
.borders(Borders::ALL),
)
.style(Style::default().fg(Color::White).bg(Color::Black));
f.render_widget(score, chunks[0]);
let middle = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(chunks[1]);
let logs: Vec<ListItem> = inst
.logs
.iter()
.rev()
.take((middle[0].height as usize).saturating_sub(2))
.rev()
.map(|line| ListItem::new(line.clone()))
.collect();
let log_widget =
List::new(logs).block(Block::default().title("Live Log").borders(Borders::ALL));
f.render_widget(log_widget, middle[0]);
let mut right_lines: Vec<ListItem> = Vec::new();
let status = match &inst.status {
SimStatus::Pending => "pending",
SimStatus::Running { .. } => "running",
SimStatus::Completed => "completed",
};
right_lines.push(ListItem::new(format!("Status: {}", status)));
right_lines.push(ListItem::new(format!("Seed: {}", inst.seed)));
right_lines.push(ListItem::new(format!("Mode: {}", inst.sim_type.as_str())));
right_lines.push(ListItem::new(""));
right_lines.push(ListItem::new("Teams:"));
for t in &inst.teams {
right_lines.push(ListItem::new(format!("- {}", t)));
}
let side = List::new(right_lines).block(
Block::default()
.title("Instance Info")
.borders(Borders::ALL),
);
f.render_widget(side, middle[1]);
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(34),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(chunks[2]);
let stats = List::new(
inst.stats_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect::<Vec<_>>(),
)
.block(Block::default().title("Stats").borders(Borders::ALL));
let comp = List::new(
inst.competition_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect::<Vec<_>>(),
)
.block(Block::default().title("Competition").borders(Borders::ALL));
let hist = List::new(
inst.history_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect::<Vec<_>>(),
)
.block(Block::default().title("History").borders(Borders::ALL));
f.render_widget(stats, bottom[0]);
f.render_widget(comp, bottom[1]);
f.render_widget(hist, bottom[2]);
let tabs = Tabs::new(vec!["Dashboard", "Detail"]).select(1);
let _ = tabs;
}

41
src/ui/mod.rs Normal file
View file

@ -0,0 +1,41 @@
pub mod dashboard;
pub mod detail;
pub mod modal;
pub mod widgets;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::App;
pub fn draw(f: &mut Frame<'_>, app: &App) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(2),
])
.split(f.area());
let header = Paragraph::new("MentalNet SoccerCloud | n=single l=league4 o=knockout4 s=start c=clone d=delete e=export v=detail q=quit")
.block(Block::default().title("Dashboard").borders(Borders::ALL))
.style(Style::default().fg(Color::Cyan));
f.render_widget(header, areas[0]);
if app.show_detail {
detail::render(f, areas[1], app);
} else {
dashboard::render(f, areas[1], app);
}
let footer = Paragraph::new(format!(
"{} | speed={} (1/2/4/0)",
app.status_line,
app.speed.label()
))
.block(Block::default().borders(Borders::ALL).title("Status"))
.style(Style::default().fg(Color::Green));
f.render_widget(footer, areas[2]);
}

2
src/ui/modal.rs Normal file
View file

@ -0,0 +1,2 @@
// Placeholder module for future interactive creation modal.
// Current MVP uses keyboard shortcuts for fast instance creation.

9
src/ui/widgets.rs Normal file
View file

@ -0,0 +1,9 @@
use crate::instance::SimStatus;
pub fn status_badge(status: &SimStatus) -> &'static str {
match status {
SimStatus::Pending => "PENDING",
SimStatus::Running { .. } => "RUNNING",
SimStatus::Completed => "COMPLETED",
}
}

90
src/utils.rs Normal file
View file

@ -0,0 +1,90 @@
use std::time::{SystemTime, UNIX_EPOCH};
pub const CSV_INJECTION_PREFIX: char = '\'';
#[derive(Debug, Clone)]
pub struct Rng {
state: u64,
}
impl Rng {
pub fn new(seed: u64) -> Self {
let seeded = if seed == 0 { 0x9E3779B97F4A7C15 } else { seed };
Self {
state: splitmix64(seeded),
}
}
pub fn from_time() -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0xA5A5_A5A5_A5A5_A5A5);
Self::new(now)
}
pub fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.state = x;
x.wrapping_mul(0x2545F4914F6CDD1D)
}
pub fn next_f64(&mut self) -> f64 {
let v = self.next_u64() >> 11;
(v as f64) * (1.0 / ((1u64 << 53) as f64))
}
pub fn chance(&mut self, p: f64) -> bool {
self.next_f64() < p.clamp(0.0, 1.0)
}
pub fn range_usize(&mut self, upper_exclusive: usize) -> usize {
if upper_exclusive <= 1 {
return 0;
}
(self.next_u64() % (upper_exclusive as u64)) as usize
}
}
pub fn derive_seed(base_seed: u64, salt: u64) -> u64 {
splitmix64(base_seed ^ salt.wrapping_mul(0x9E3779B97F4A7C15))
}
pub fn splitmix64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E3779B97F4A7C15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
pub fn pad2(minute: u8) -> String {
format!("{:02}", minute)
}
pub fn sanitize_csv_cell(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.starts_with('=')
|| trimmed.starts_with('+')
|| trimmed.starts_with('-')
|| trimmed.starts_with('@')
{
format!("{}{}", CSV_INJECTION_PREFIX, raw)
} else {
raw.to_string()
}
}
pub fn csv_escape(field: &str) -> String {
let sanitized = sanitize_csv_cell(field);
let needs_quotes =
sanitized.contains(',') || sanitized.contains('\n') || sanitized.contains('"');
if needs_quotes {
format!("\"{}\"", sanitized.replace('"', "\"\""))
} else {
sanitized
}
}