Skip to main content

fforge_core/
lib.rs

1//! fm-core — layer 2: the deterministic simulation core.
2//!
3//! Hard invariants enforced here (DESIGN.md):
4//! - the core is a **pure fold over an append-only event log** (`state`);
5//! - **no wall clock** (game-time derives from events — `GameDate` moves only
6//!   via `MatchdayAdvanced`), **no unseeded randomness** (`rng` streams derive
7//!   from the seed recorded in `GameStarted`), **no inline LLM calls** (none
8//!   exist yet; when they do, their outputs arrive as recorded events);
9//! - the evaluation/telemetry spine is a **passive event-stream consumer**
10//!   (`observer`).
11
12pub 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
29/// Convenience: assemble the opening event for a new game.
30pub 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        // 20 clubs → 38 matchdays, 380 results, every club played 38.
95        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        // Points must be internally consistent.
105        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        // Crude engine, eyeballed constants — assert only a wide sanity band;
113        // real calibration is the Phase 2 harness's job.
114        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}