pub mod commands;
pub mod event;
pub mod match_engine;
pub mod observer;
pub mod rng;
pub mod schedule;
pub mod session;
pub mod state;
pub mod worldgen;
pub use commands::{Command, CommandError, FIXTURE_STREAM_NS, player_match_preview};
pub use event::Event;
pub use observer::{EventObserver, SeasonTelemetry};
pub use session::{Session, load_log, save_log};
pub use state::{GameState, TableRow, league_table};
pub use worldgen::{WorldGenConfig, generate};
pub fn new_game(seed: u64, cfg: &WorldGenConfig, player_club: fforge_domain::ClubId) -> Vec<Event> {
let (world, schedule, start_date) = generate(seed, cfg);
vec![Event::GameStarted {
seed,
start_date,
player_club,
world,
schedule,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use fforge_domain::ClubId;
fn run_full_season(seed: u64) -> Session {
let log = new_game(seed, &WorldGenConfig::default(), ClubId(0));
let mut session = Session::from_events(log, &mut []);
while !session.state.season_over() {
session
.execute(Command::AdvanceMatchday, &mut [])
.expect("advance");
}
session
}
#[test]
fn same_seed_same_season() {
let a = run_full_season(20260706);
let b = run_full_season(20260706);
assert_eq!(a.log, b.log, "identical seeds must yield identical logs");
assert_eq!(a.state, b.state);
}
#[test]
fn different_seed_different_season() {
let a = run_full_season(1);
let b = run_full_season(2);
assert_ne!(a.log, b.log);
}
#[test]
fn replay_reconstructs_state_exactly() {
let session = run_full_season(99);
let replayed = GameState::replay(&session.log);
assert_eq!(session.state, replayed);
}
#[test]
fn save_load_round_trip() {
let session = run_full_season(7);
let dir = std::env::temp_dir().join("fmsim-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("roundtrip.fml");
save_log(&path, &session.log).unwrap();
let loaded = load_log(&path).unwrap();
assert_eq!(session.log, loaded);
assert_eq!(GameState::replay(&loaded), session.state);
}
#[test]
fn season_shape_is_sane() {
let session = run_full_season(123);
assert_eq!(session.state.results.len(), 380);
let table = league_table(
&session.state.world,
&session.state.schedule,
&session.state.results,
);
assert_eq!(table.len(), 20);
assert!(table.iter().all(|r| r.played == 38));
assert_eq!(session.state.champion, Some(table[0].club));
let total_pts: u32 = table.iter().map(|r| r.points()).sum();
let (w, d): (u32, u32) = table
.iter()
.fold((0, 0), |(w, d), r| (w + r.won, d + r.drawn));
assert_eq!(total_pts, w * 3 + d);
}
#[test]
fn aggregates_are_in_a_believable_ballpark() {
let mut telemetry = SeasonTelemetry::default();
for seed in 0..10u64 {
let session = run_full_season(seed);
for e in &session.log {
telemetry.on_event(e);
}
}
let gpm = telemetry.goals_per_match();
assert!(
(1.2..=4.0).contains(&gpm),
"goals/match {gpm} outside sanity band"
);
assert!(
telemetry.home_win_rate() > telemetry.away_wins as f64 / telemetry.matches as f64,
"home advantage must be visible"
);
}
}