Initial Rust CLI/TUI Rebuild (Seeded Sim + CSV Export)
This commit is contained in:
parent
faf0929bb6
commit
dae34cce41
16 changed files with 2739 additions and 0 deletions
251
src/app.rs
Normal file
251
src/app.rs
Normal 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
255
src/data.rs
Normal 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
153
src/export.rs
Normal 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
160
src/instance.rs
Normal 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
135
src/main.rs
Normal 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
688
src/sim.rs
Normal 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
83
src/ui/dashboard.rs
Normal 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
109
src/ui/detail.rs
Normal 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
41
src/ui/mod.rs
Normal 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
2
src/ui/modal.rs
Normal 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
9
src/ui/widgets.rs
Normal 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
90
src/utils.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue