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