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//!
12//! `match_engine` runs the Phase 2a event-based possession engine
13//! (`MATCH_MODEL.md`); only its score folds into `GameState` — the
14//! minute-by-minute trace rides alongside, never inside, the fold.
15
16pub 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
33/// Convenience: assemble the opening event for a new game.
34pub 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        // 20 clubs → 38 matchdays, 380 results, every club played 38.
99        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        // Points must be internally consistent.
109        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        // Phase-2a engine (MATCH_MODEL.md). The calibration harness
119        // (`match_engine::calibrate::StreamTelemetry`, `bin/calibrate.rs`,
120        // and `resolve.rs`'s `notebook_parity` test) diagnosed the original
121        // ~1.7-2.0 goals/match this suite used to see: parity against
122        // notebook-equivalent inputs held (loop is a faithful port, not a
123        // port bug), coupling `p_wide` to each formation's actual
124        // wide-presence share (`resolve::formation_p_wide`, `MATCH_MODEL.md`
125        // §10 item 1) fixed the formation-shape mismatch but moved pooled
126        // gpm by <0.01, and the dominant gap turned out to be conversion
127        // (~7% vs the notebook's ~10%) — a `b_beat` re-tune against real
128        // `worldgen`'s attribute distribution (`knobs.rs`'s doc comment)
129        // closed it: this suite now reads ~2.5-2.6 goals/match, in line
130        // with the notebook's own ~2.6 target. This stays a wide sanity
131        // band that only needs to catch gross regressions/bugs going
132        // forward; the harness is the place to re-chase the real number if
133        // Phase 2e (tactics, cards, subs) moves it again.
134        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}