1pub mod commands;
13pub mod event;
14pub mod match_engine;
15pub mod observer;
16pub mod rng;
17pub mod schedule;
18pub mod session;
19pub mod state;
20pub mod worldgen;
21
22pub use commands::{Command, CommandError};
23pub use event::Event;
24pub use observer::{EventObserver, SeasonTelemetry};
25pub use session::{load_log, save_log, Session};
26pub use state::{league_table, GameState, TableRow};
27pub use worldgen::{generate, WorldGenConfig};
28
29pub fn new_game(seed: u64, cfg: &WorldGenConfig, player_club: fforge_domain::ClubId) -> Vec<Event> {
31 let (world, schedule, start_date) = generate(seed, cfg);
32 vec![Event::GameStarted {
33 seed,
34 start_date,
35 player_club,
36 world,
37 schedule,
38 }]
39}
40
41#[cfg(test)]
42mod tests {
43 use super::*;
44 use fforge_domain::ClubId;
45
46 fn run_full_season(seed: u64) -> Session {
47 let log = new_game(seed, &WorldGenConfig::default(), ClubId(0));
48 let mut session = Session::from_events(log, &mut []);
49 while !session.state.season_over() {
50 session
51 .execute(Command::AdvanceMatchday, &mut [])
52 .expect("advance");
53 }
54 session
55 }
56
57 #[test]
58 fn same_seed_same_season() {
59 let a = run_full_season(20260706);
60 let b = run_full_season(20260706);
61 assert_eq!(a.log, b.log, "identical seeds must yield identical logs");
62 assert_eq!(a.state, b.state);
63 }
64
65 #[test]
66 fn different_seed_different_season() {
67 let a = run_full_season(1);
68 let b = run_full_season(2);
69 assert_ne!(a.log, b.log);
70 }
71
72 #[test]
73 fn replay_reconstructs_state_exactly() {
74 let session = run_full_season(99);
75 let replayed = GameState::replay(&session.log);
76 assert_eq!(session.state, replayed);
77 }
78
79 #[test]
80 fn save_load_round_trip() {
81 let session = run_full_season(7);
82 let dir = std::env::temp_dir().join("fmsim-test");
83 std::fs::create_dir_all(&dir).unwrap();
84 let path = dir.join("roundtrip.fml");
85 save_log(&path, &session.log).unwrap();
86 let loaded = load_log(&path).unwrap();
87 assert_eq!(session.log, loaded);
88 assert_eq!(GameState::replay(&loaded), session.state);
89 }
90
91 #[test]
92 fn season_shape_is_sane() {
93 let session = run_full_season(123);
94 assert_eq!(session.state.results.len(), 380);
96 let table = league_table(
97 &session.state.world,
98 &session.state.schedule,
99 &session.state.results,
100 );
101 assert_eq!(table.len(), 20);
102 assert!(table.iter().all(|r| r.played == 38));
103 assert_eq!(session.state.champion, Some(table[0].club));
104 let total_pts: u32 = table.iter().map(|r| r.points()).sum();
106 let (w, d): (u32, u32) = table.iter().fold((0, 0), |(w, d), r| (w + r.won, d + r.drawn));
107 assert_eq!(total_pts, w * 3 + d);
108 }
109
110 #[test]
111 fn aggregates_are_in_a_believable_ballpark() {
112 let mut telemetry = SeasonTelemetry::default();
115 for seed in 0..10u64 {
116 let session = run_full_season(seed);
117 for e in &session.log {
118 telemetry.on_event(e);
119 }
120 }
121 let gpm = telemetry.goals_per_match();
122 assert!(
123 (1.8..=3.4).contains(&gpm),
124 "goals/match {gpm} outside sanity band"
125 );
126 assert!(
127 telemetry.home_win_rate() > telemetry.away_wins as f64 / telemetry.matches as f64,
128 "home advantage must be visible"
129 );
130 }
131}