fforge-core 0.2.0

Core engines for the Football Forge game.
Documentation
//! fm-core — layer 2: the deterministic simulation core.
//!
//! Hard invariants enforced here (DESIGN.md):
//! - the core is a **pure fold over an append-only event log** (`state`);
//! - **no wall clock** (game-time derives from events — `GameDate` moves only
//!   via `MatchdayAdvanced`), **no unseeded randomness** (`rng` streams derive
//!   from the seed recorded in `GameStarted`), **no inline LLM calls** (none
//!   exist yet; when they do, their outputs arrive as recorded events);
//! - the evaluation/telemetry spine is a **passive event-stream consumer**
//!   (`observer`).
//!
//! `match_engine` runs the Phase 2a event-based possession engine
//! (`MATCH_MODEL.md`); only its score folds into `GameState` — the
//! minute-by-minute trace rides alongside, never inside, the fold.

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};

/// Convenience: assemble the opening event for a new game.
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);
        // 20 clubs → 38 matchdays, 380 results, every club played 38.
        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));
        // Points must be internally consistent.
        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() {
        // Phase-2a engine (MATCH_MODEL.md). The calibration harness
        // (`match_engine::calibrate::StreamTelemetry`, `bin/calibrate.rs`,
        // and `resolve.rs`'s `notebook_parity` test) diagnosed the original
        // ~1.7-2.0 goals/match this suite used to see: parity against
        // notebook-equivalent inputs held (loop is a faithful port, not a
        // port bug), coupling `p_wide` to each formation's actual
        // wide-presence share (`resolve::formation_p_wide`, `MATCH_MODEL.md`
        // §10 item 1) fixed the formation-shape mismatch but moved pooled
        // gpm by <0.01, and the dominant gap turned out to be conversion
        // (~7% vs the notebook's ~10%) — a `b_beat` re-tune against real
        // `worldgen`'s attribute distribution (`knobs.rs`'s doc comment)
        // closed it: this suite now reads ~2.5-2.6 goals/match, in line
        // with the notebook's own ~2.6 target. This stays a wide sanity
        // band that only needs to catch gross regressions/bugs going
        // forward; the harness is the place to re-chase the real number if
        // Phase 2e (tactics, cards, subs) moves it again.
        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"
        );
    }
}