simu

Ice hockey final standings simulator
git clone https://git.inz.fi/simu
Log | Files | Refs

commit 6868a1fe65e1c2e2490d020cb18bc0871bc2cd5e
parent 0a61712c1fcab678dc05743ec256912efda93abe
Author: Santtu Lakkala <inz@inz.fi>
Date:   Tue,  3 Feb 2026 09:56:47 +0200

Initial rust version

Diffstat:
Arust/Makefile | 15+++++++++++++++
Arust/elo.rs | 37+++++++++++++++++++++++++++++++++++++
Arust/rand.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arust/simu.rs | 756+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 877 insertions(+), 0 deletions(-)

diff --git a/rust/Makefile b/rust/Makefile @@ -0,0 +1,15 @@ +RUSTC = rustc +RUSTFMT = rustfmt +RUSTFLAGS = --edition 2024 +RUSTCFLAGS = --cfg 'feature="use_unsafe"' +CLIPPY = clippy-driver +SOURCES := simu.rs rand.rs elo.rs + +simu: simu.rs $(SOURCES) Makefile + $(RUSTC) $(RUSTFLAGS) $(RUSTCFLAGS) -O -o $@ $< + +fmt: + $(RUSTFMT) $(RUSTFLAGS) $(SOURCES) + +clippy: + $(CLIPPY) $(RUSTFLAGS) $(RUSTCFLAGS) -W clippy::pedantic simu.rs diff --git a/rust/elo.rs b/rust/elo.rs @@ -0,0 +1,37 @@ +#[derive(Clone, Copy, Debug)] +pub struct Elo(pub f64); + +impl Default for Elo { + fn default() -> Self { + Self(2000.) + } +} + +impl std::fmt::Display for Elo { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + write!(fmt, "{:.0}", self.0) + } +} + +impl Elo { + pub fn expected(self, other: Self, adv: f64) -> f64 { + 1. / (1. + (10_f64).powf((other.0 - self.0 - adv) / 400.0)) + } + + pub fn update(&mut self, other: &mut Self, k: f64, actual: f64, adv: f64) { + let expected = self.expected(*other, adv); + self.0 += k * (actual - expected); + other.0 -= k * (actual - expected); + } + + fn regress(&mut self, k: f64, factor: f64) { + self.0 += (Self::default().0 - self.0) * k * factor; + } + + pub fn simulated(&mut self, other: &mut Self, adv: f64, k: f64, regress: f64) -> f64 { + let expected = self.expected(*other, adv); + self.regress(k, regress); + other.regress(k, regress); + expected + } +} diff --git a/rust/rand.rs b/rust/rand.rs @@ -0,0 +1,69 @@ +pub trait RandOutput: + Copy + Ord + std::ops::Sub<Output = Self> + std::fmt::Debug + Default +{ +} +impl<T: Copy + Ord + std::ops::Sub<Output = Self> + std::fmt::Debug + Default> RandOutput for T {} + +pub trait Rand: Copy { + type Output: RandOutput; + const MAX: Self::Output; + + fn rand(&mut self) -> Self::Output; +} + +pub trait YesNo<T: RandOutput>: Rand<Output = T> { + fn yesno(&mut self, limit: T) -> (bool, bool) { + let r = self.rand(); + (r < limit, Self::MAX - r < limit) + } +} + +impl<T, R> YesNo<T> for R +where + T: RandOutput, + R: Rand<Output = T>, +{ +} + +#[derive(Clone, Copy)] +pub struct Lehmer { + state: std::num::Wrapping<u128>, +} + +impl Default for Lehmer { + fn default() -> Self { + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("Failed to initialize random"); + Self::new(u128::from(now.subsec_nanos()) << 64 | u128::from(now.as_secs()) | 1) + } +} + +impl Lehmer { + pub fn new(seed: u128) -> Self { + Self { + state: std::num::Wrapping(seed), + } + } + + pub fn new_with<T: Into<u128>>(other: &mut impl Rand<Output = T>) -> Self { + Self::new( + other.rand().into() << 96 + ^ other.rand().into() << 64 + ^ other.rand().into() << 32 + ^ other.rand().into() + | 1, + ) + } +} + +impl Rand for Lehmer { + type Output = u32; + const MAX: Self::Output = Self::Output::MAX; + + #[allow(clippy::cast_possible_truncation)] + fn rand(&mut self) -> Self::Output { + self.state *= 0xda94_2042_e4dd_58b5; + (self.state >> 64).0 as Self::Output + } +} diff --git a/rust/simu.rs b/rust/simu.rs @@ -0,0 +1,756 @@ +mod elo; +mod rand; + +use elo::Elo; +use rand::{Lehmer, Rand, RandOutput, YesNo}; + +pub type Error = Box<dyn std::error::Error>; +pub type Result<T> = std::result::Result<T, Error>; + +trait Getter<T> { + fn idxmut(&mut self, idx: usize) -> &mut T; +} + +#[cfg(not(feature = "use_unsafe"))] +impl<T> Getter<T> for Vec<T> { + fn idxmut(&mut self, idx: usize) -> &mut T { + &mut self[idx] + } +} + +#[cfg(feature = "use_unsafe")] +impl<T> Getter<T> for Vec<T> { + fn idxmut(&mut self, idx: usize) -> &mut T { + unsafe { &mut *self.as_mut_ptr().add(idx) } + } +} + +trait CloseEnough<T> { + fn lossy_from(other: T) -> Self; +} + +impl CloseEnough<f64> for u32 { + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + fn lossy_from(other: f64) -> Self { + other as Self + } +} + +trait FirstLast { + type Item; + + fn first_last<F: FnMut(Self::Item) -> bool>(self, f: F) -> Option<(usize, usize)>; +} + +impl<T, I: std::iter::IntoIterator<Item = T>> FirstLast for I { + type Item = T; + + fn first_last<F: FnMut(Self::Item) -> bool>(self, mut f: F) -> Option<(usize, usize)> { + self.into_iter().enumerate().fold(None, |acc, (i, e)| { + (f(e)).then_some((acc.map_or(i, |(a, _)| a), i)).or(acc) + }) + } +} + +#[derive(Debug)] +struct Options { + pub threads: usize, + pub iterations: usize, + pub home_advantage: f64, + pub overtime_prob: f64, + pub elo_k: f64, + pub regression: f64, +} + +impl Default for Options { + fn default() -> Self { + Self { + threads: 1, + iterations: 100_000, + home_advantage: 58., + overtime_prob: 0.23, + elo_k: 32., + regression: 0.005, + } + } +} + +#[derive(Debug)] +struct PlayedGame { + pub home_id: usize, + pub away_id: usize, + pub home_score: usize, + pub away_score: usize, + pub overtime: bool, +} + +impl std::str::FromStr for PlayedGame { + type Err = Error; + + fn from_str(s: &str) -> Result<Self> { + let mut parts = s.split_ascii_whitespace(); + let home_id = parts.next().ok_or("Missing field")?.parse()?; + let away_id = parts.next().ok_or("Missing fields")?.parse()?; + let home_score = parts.next().ok_or("Missing fields")?.parse()?; + let away_score = parts.next().ok_or("Missing fields")?.parse()?; + let overtime = parts.next().is_some_and(|s| s == "OT" || s == "SO"); + + Ok(Self { + home_id, + away_id, + home_score, + away_score, + overtime, + }) + } +} + +#[derive(Clone, Copy, Debug)] +struct Game { + pub home_id: usize, + pub away_id: usize, +} + +#[derive(Clone, Copy, Debug)] +struct UpcomingGame<R: Rand> { + pub home_id: usize, + pub away_id: usize, + pub expected: R::Output, + _phantom: std::marker::PhantomData<R>, +} + +impl Game { + pub fn into_upcoming<R>( + self, + map: &[usize], + teams: &mut [Team], + opts: &Options, + ) -> Result<UpcomingGame<R>> + where + R: Rand, + R::Output: Into<f64> + CloseEnough<f64>, + { + if let Some(&home_id) = map.get(self.home_id) + && let Some(&away_id) = map.get(self.away_id) + { + let [home, away] = teams.get_disjoint_mut([home_id, away_id])?; + let ug = UpcomingGame { + home_id, + away_id, + expected: R::Output::lossy_from( + home.simulated(away, opts.home_advantage, opts.elo_k, opts.regression) + * R::MAX.into(), + ), + _phantom: std::marker::PhantomData, + }; + Ok(ug) + } else { + Err(Error::from("Invalid team id")) + } + } +} + +impl std::str::FromStr for Game { + type Err = Error; + + fn from_str(s: &str) -> Result<Self> { + let mut parts = s.split_ascii_whitespace(); + let home_id = parts.next().ok_or("Missing fields")?.parse()?; + let away_id = parts.next().ok_or("Missing fields")?.parse()?; + + Ok(Self { home_id, away_id }) + } +} + +#[derive(Default, Clone, Debug)] +struct Team { + pub name: String, + pub id: usize, + pub points: u64, + pub games: u64, + pub wins: u64, + pub pointssum: u64, + pub gamessum: u64, + pub winssum: u64, + pub elo: Elo, + pub elo0: Elo, + pub poscounts: Vec<(usize, bool)>, +} + +#[derive(Clone, Copy, Debug)] +enum NormalizedProb { + Zero, + Epsilon, + Percentage(f64), + Always, +} + +impl std::fmt::Display for NormalizedProb { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use NormalizedProb as N; + match self { + N::Zero => write!(f, "-"), + N::Epsilon => write!(f, "0"), + N::Percentage(p) => write!(f, "{p:e}"), + N::Always => write!(f, "+"), + } + } +} + +#[allow(dead_code)] +struct NormalizedTeam { + pub name: String, + pub points: u64, + pub games: u64, + pub wins: u64, + pub final_points: f64, + pub final_games: f64, + pub final_wins: f64, + pub elo: Elo, + pub poscounts: Vec<NormalizedProb>, +} + +impl std::cmp::PartialEq for Team { + fn eq(&self, other: &Self) -> bool { + self.points * other.games == self.games * other.points + } +} +impl std::cmp::Eq for Team {} + +impl std::cmp::Ord for Team { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (other.points * self.games) + .cmp(&(self.points * other.games)) + .then_with(|| (other.wins * self.games).cmp(&(self.wins * other.games))) + } +} + +impl std::cmp::PartialOrd for Team { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + +impl Team { + pub fn new(name: String, id: usize, n_teams: usize) -> Self { + Self { + name, + id, + poscounts: vec![(0, false); n_teams], + ..Default::default() + } + } + + pub fn simulated(&mut self, other: &mut Self, adv: f64, elo_k: f64, regress: f64) -> f64 { + self.elo.simulated(&mut other.elo, adv, elo_k, regress) + } + + pub fn game(&mut self, gf: usize, ga: usize, ot: bool) -> u64 { + self.games += 1; + let points = match (gf > ga, ot) { + (true, false) => { + self.wins += 1; + 3 + } + (true, true) => 2, + (false, true) => 1, + (false, false) => 0, + }; + self.points += points; + points + } + + #[allow(clippy::cast_precision_loss)] + pub fn normalize(self, iterations: usize) -> NormalizedTeam { + let Team { + name, + points, + games, + wins, + pointssum, + gamessum, + winssum, + elo0: elo, + poscounts, + .. + } = self; + NormalizedTeam { + name, + points, + games, + wins, + elo, + final_points: pointssum as f64 / iterations as f64, + final_games: gamessum as f64 / iterations as f64, + final_wins: winssum as f64 / iterations as f64, + poscounts: poscounts + .into_iter() + .map(|count| match count { + (0, false) => NormalizedProb::Zero, + (0, true) => NormalizedProb::Epsilon, + (count, _) if count == iterations => NormalizedProb::Always, + (count, _) => { + NormalizedProb::Percentage(100. * (count - 1) as f64 / iterations as f64) + } + }) + .collect(), + } + } + + fn add_result(&mut self, rhs: &SimulationResult) { + for ((w, _), &r) in self.poscounts.iter_mut().zip(&rhs.position_counts) { + *w += r; + } + self.gamessum += rhs.games; + self.pointssum += rhs.points; + } + + fn add_winlose_result(&mut self, rhs: &WinLoseSimulationResult) { + for ((_, p), &r) in self.poscounts.iter_mut().zip(&rhs.positions) { + *p |= r; + } + } +} + +impl std::fmt::Display for NormalizedTeam { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let NormalizedTeam { + name, + games, + points, + final_games, + final_points, + elo, + poscounts, + .. + } = self; + write!( + f, + "{name} {games} {points} {final_games:.0} {final_points:.1} {elo:.0}" + )?; + for count in poscounts { + write!(f, " {count}")?; + } + Ok(()) + } +} + +trait IteratorArgExt { + fn get_arg<E, T>(&mut self) -> Result<T> + where + E: std::error::Error + 'static, + T: std::str::FromStr<Err = E>; +} + +impl<S: AsRef<str>, I: Iterator<Item = S>> IteratorArgExt for I { + fn get_arg<E, T>(&mut self) -> Result<T> + where + E: std::error::Error + 'static, + T: std::str::FromStr<Err = E>, + { + Ok(self.next().ok_or("Missing argument")?.as_ref().parse()?) + } +} + +impl Options { + fn from_args<T: IntoIterator<Item = String>>(iter: T) -> Result<Self> { + let mut iter = iter.into_iter(); + let mut rv = Self::default(); + while let Some(flags) = iter.next() { + let mut chars = flags.chars(); + chars + .next() + .filter(|c| *c == '-') + .ok_or("Invalid argument")?; + for c in chars { + match c { + 'i' => rv.iterations = iter.get_arg()?, + 't' => rv.threads = iter.get_arg()?, + 'a' => rv.home_advantage = iter.get_arg()?, + 'o' => rv.overtime_prob = iter.get_arg()?, + 'k' => rv.elo_k = iter.get_arg()?, + 'r' => rv.regression = iter.get_arg()?, + _ => Err(format!("Invalid option {c}"))?, + } + } + } + Ok(rv) + } +} + +#[derive(Clone, Debug, Copy)] +struct SimulationTeam<R> +where + R: Rand, + R::Output: Ord + Copy + std::fmt::Debug, +{ + id: usize, + points: u64, + games: u64, + wins: u64, + random: R::Output, +} + +impl<R: Rand> std::cmp::PartialEq for SimulationTeam<R> { + fn eq(&self, other: &Self) -> bool { + self.points * other.games == other.points * self.games + && self.wins * other.games == other.wins * self.games + } +} + +impl<R: Rand> std::cmp::Eq for SimulationTeam<R> {} + +impl<R: Rand> std::cmp::Ord for SimulationTeam<R> { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (other.points * self.games) + .cmp(&(self.points * other.games)) + .then_with(|| { + (other.wins * self.games) + .cmp(&(self.wins * other.games)) + .then_with(|| self.random.cmp(&other.random)) + }) + } +} + +impl<R: Rand> std::cmp::PartialOrd for SimulationTeam<R> { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + +impl<R: Rand> SimulationTeam<R> { + pub fn copy_with(&self, rand: &mut R) -> (Self, Self) { + let r = rand.rand(); + ( + Self { random: r, ..*self }, + Self { + random: R::MAX - r, + ..*self + }, + ) + } + + pub fn game(&mut self, win: bool, ot: bool) { + self.games += 1; + self.points += (3 * u64::from(win)) ^ u64::from(ot); + } +} + +impl<R: Rand> From<(usize, &Team)> for SimulationTeam<R> { + fn from((id, team): (usize, &Team)) -> Self { + Self { + id, + points: team.points, + games: team.games, + wins: team.wins, + random: Default::default(), + } + } +} + +#[derive(Clone)] +struct WinLoseSimulationResult { + positions: Vec<bool>, +} + +#[derive(Clone)] +struct SimulationResult { + position_counts: Vec<usize>, + games: u64, + points: u64, + wins: u64, +} + +impl SimulationResult { + fn new(n_teams: usize) -> Self { + Self { + position_counts: vec![0; n_teams], + games: 0, + points: 0, + wins: 0, + } + } +} + +fn simulate<T, R>( + teams: &[Team], + upcoming_games: &[UpcomingGame<R>], + rand: &mut R, + opts: &Options, +) -> (usize, Vec<SimulationResult>) +where + T: RandOutput + CloseEnough<f64> + Into<f64>, + R: YesNo<T> + Rand<Output = T>, +{ + let simulationteams0: Vec<SimulationTeam<R>> = + teams.iter().enumerate().map(From::from).collect(); + let mut result: Vec<_> = (0..teams.len()) + .map(|_| SimulationResult::new(teams.len())) + .collect(); + let otprob = R::Output::lossy_from(opts.overtime_prob * R::MAX.into()); + let mut simulationteams = simulationteams0.clone(); + let mut antisimulationteams = simulationteams0.clone(); + for _ in 0..opts.iterations / 2 { + for ((s, antis), s0) in simulationteams + .iter_mut() + .zip(antisimulationteams.iter_mut()) + .zip(simulationteams0.iter()) + { + (*s, *antis) = s0.copy_with(rand); + } + + for game in upcoming_games { + let (res, antires) = rand.yesno(game.expected); + let (ot, antiot) = rand.yesno(otprob); + + simulationteams.idxmut(game.home_id).game(res, ot); + simulationteams.idxmut(game.away_id).game(!res, ot); + antisimulationteams + .idxmut(game.home_id) + .game(antires, antiot); + antisimulationteams + .idxmut(game.away_id) + .game(!antires, antiot); + } + + simulationteams.sort_unstable(); + antisimulationteams.sort_unstable(); + + for (i, t) in simulationteams + .iter() + .enumerate() + .chain(antisimulationteams.iter().enumerate()) + { + let res = result.idxmut(t.id); + res.position_counts[i] += 1; + res.games += t.games; + res.points += t.points; + res.wins += t.wins; + } + } + + (opts.iterations & (usize::MAX - 1), result) +} + +#[derive(Debug, Clone, Copy)] +enum WinLose { + WinAll(usize), + LoseAll(usize), +} + +enum Winner { + Undefined, + Home, + Away, +} + +impl WinLose { + pub fn check<T>(&self, game: &UpcomingGame<impl Rand<Output = T>>) -> Winner { + use WinLose as WL; + use Winner as W; + match self { + WL::WinAll(id) if &game.home_id == id => W::Home, + WL::WinAll(id) if &game.away_id == id => W::Away, + WL::LoseAll(id) if &game.home_id == id => W::Away, + WL::LoseAll(id) if &game.away_id == id => W::Home, + _ => W::Undefined, + } + } +} + +fn simulate_winall<T, R>( + teams: &[Team], + upcoming_games: &[UpcomingGame<R>], + rand: &mut R, + opts: &Options, + winlose: WinLose, +) -> Vec<SimulationResult> +where + T: RandOutput + CloseEnough<f64> + Into<f64>, + R: YesNo<T> + Rand<Output = T>, +{ + let mut teams = Vec::from(teams); + let upcoming: Vec<_> = upcoming_games + .iter() + .filter(|game| { + !match winlose.check(game) { + Winner::Home => Some((game.home_id, game.away_id)), + Winner::Away => Some((game.away_id, game.home_id)), + Winner::Undefined => None, + } + .is_some_and(|(winner, loser)| { + teams[winner].game(1, 0, false); + teams[loser].game(0, 1, false); + true + }) + }) + .copied() + .collect(); + simulate(&teams, &upcoming, rand, opts).1 +} + +fn simulate_winloseall<T, R>( + teams: &[Team], + upcoming_games: &[UpcomingGame<R>], + rand: &mut R, + opts: &Options, +) -> Vec<WinLoseSimulationResult> +where + T: RandOutput + CloseEnough<f64> + Into<f64>, + R: YesNo<T> + Rand<Output = T>, +{ + let opts = Options { + iterations: opts.iterations / teams.len() / 2, + ..*opts + }; + (0..teams.len()) + .map(|idx| { + let (whigh, wlow) = + simulate_winall(teams, upcoming_games, rand, &opts, WinLose::WinAll(idx))[idx] + .position_counts + .iter() + .first_last(|&p| p != 0) + .unwrap(); + let (lhigh, llow) = + simulate_winall(teams, upcoming_games, rand, &opts, WinLose::LoseAll(idx))[idx] + .position_counts + .iter() + .first_last(|&p| p != 0) + .unwrap(); + WinLoseSimulationResult { + positions: (0..teams.len()) + .map(|p| p >= whigh.min(lhigh) && p <= wlow.max(llow)) + .collect(), + } + }) + .collect() +} + +fn simulate_parallel( + teams: &mut [Team], + games: &[UpcomingGame<Lehmer>], + rnd: &mut Lehmer, + opts: &Options, +) -> usize { + let opts = Options { + iterations: opts.iterations / opts.threads, + ..*opts + }; + let results = std::thread::scope(|s| { + (0..opts.threads) + .map(|_| { + let teams = &teams; + let games = &games; + let opts = &opts; + let mut rnd = Lehmer::new_with(rnd); + + s.spawn(move || { + ( + simulate(teams, games, &mut rnd, opts), + simulate_winloseall(teams, games, &mut rnd, opts), + ) + }) + }) + .collect::<Vec<_>>() + .into_iter() + .map(|t| t.join().expect("Thread panicked")) + .collect::<Vec<_>>() + }); + let mut iterations = 0; + for ((iters, simulated), simulatedwl) in results { + iterations += iters; + for (team, simu) in teams.iter_mut().zip(&simulated) { + team.add_result(simu); + } + for (team, simu) in teams.iter_mut().zip(&simulatedwl) { + team.add_winlose_result(simu); + } + } + iterations +} + +fn main() -> Result<()> { + let opts = Options::from_args(std::env::args().skip(1))?; + let mut lines = std::io::stdin() + .lines() + .filter(|r| !r.as_ref().is_ok_and(|s| s.trim().is_empty())); + + let n_teams: usize = lines.next().ok_or("Missing team count")??.trim().parse()?; + let mut teams = (0..n_teams) + .map(|id| { + Ok(Team::new( + lines.next().ok_or("Missing teams")??.trim().to_owned(), + id, + n_teams, + )) + }) + .collect::<Result<Vec<_>>>()?; + let n_games: usize = lines.next().ok_or("Missing game count")??.trim().parse()?; + for game in (0..n_games).map(|_| lines.next().ok_or("Missing game")??.parse::<PlayedGame>()) { + let PlayedGame { + home_id, + away_id, + home_score, + away_score, + overtime, + } = game?; + let [home, away] = teams.get_disjoint_mut([home_id, away_id])?; + + away.game(away_score, home_score, overtime); + #[allow(clippy::cast_precision_loss)] + let actual = home.game(home_score, away_score, overtime) as f64 / 3.; + + home.elo + .update(&mut away.elo, opts.elo_k, actual, opts.home_advantage); + } + + teams.sort(); + let mut team_map = vec![0; n_teams]; + for (i, team) in teams.iter_mut().enumerate() { + team_map[team.id] = i; + team.elo0 = team.elo; + } + + let mut rnd = Lehmer::default(); + + let n_games: usize = lines.next().ok_or("Missing game count")??.trim().parse()?; + let games: Vec<_> = (0..n_games) + .map(|_| { + lines + .next() + .ok_or("Missing games")?? + .parse::<Game>()? + .into_upcoming::<Lehmer>(&team_map, &mut teams, &opts) + }) + .collect::<Result<_>>()?; + + let iterations = if opts.threads == 1 { + let (iterations, simulated) = simulate(&teams, &games, &mut rnd, &opts); + let simulatedwl = simulate_winloseall(&teams, &games, &mut rnd, &opts); + for (team, simu) in teams.iter_mut().zip(&simulated) { + team.add_result(simu); + } + for (team, simu) in teams.iter_mut().zip(&simulatedwl) { + team.add_winlose_result(simu); + } + iterations + } else { + simulate_parallel(&mut teams, &games, &mut rnd, &opts) + }; + + teams.sort_by(|a, b| { + (b.pointssum * a.gamessum) + .cmp(&(a.pointssum * b.gamessum)) + .then_with(|| (b.winssum * a.gamessum).cmp(&(a.winssum * a.gamessum))) + }); + + for team in teams.into_iter().map(|team| team.normalize(iterations)) { + println!("{team}"); + } + + Ok(()) +}