Skip to main content

fforge_core/
match_engine.rs

1//! The Phase-2a event-based possession match engine (`MATCH_MODEL.md`),
2//! behind the same `play_match` call site the Phase-1 crude engine used to
3//! occupy. State space, resolution model, the wide route, and the knob
4//! table are a faithful Rust port of the calibrated Python prototype
5//! (`match_model_prototype.ipynb`, referenced from `MATCH_MODEL.md` §1) —
6//! nothing here is a re-guess of the shape-finding, only its translation.
7//!
8//! Deferred to Phase 2e (behind this same call site, no structural change):
9//! tactics as transition-matrix modifiers, cards & fouls, injuries, set
10//! pieces, substitutions, and the character/hidden attributes.
11
12mod calibrate;
13mod contest;
14mod knobs;
15mod resolve;
16mod stream;
17mod zone;
18
19pub use calibrate::{FormationStats, StreamTelemetry};
20pub use knobs::Knobs;
21pub use stream::{MatchEvent, MatchEventKind, ShotKind, ShotOutcome, ShotSource, Side};
22pub use zone::Zone;
23
24use crate::rng::Rng;
25use fforge_domain::{
26    ClubId, FORMATIONS, Lineup, PlayerId, ROLE_WEIGHTS, Role, World, XI, current_ability,
27};
28
29/// Mean CA-in-slot-role over the eleven — a squad-quality scalar independent
30/// of any particular match-resolution model. Used for display and by
31/// `ai_pick_lineup`'s formation comparison below.
32pub fn lineup_strength(world: &World, lineup: &Lineup) -> f64 {
33    let def = lineup.formation_def();
34    let mut sum = 0.0;
35    for (slot, &pid) in lineup.players.iter().enumerate() {
36        let player = world.player(pid);
37        sum += current_ability(&player.attributes, def.slots[slot], &ROLE_WEIGHTS) as f64;
38    }
39    sum / XI as f64
40}
41
42/// The result of a simulated match: the score that folds into `GameState`
43/// (via `Event::MatchPlayed`) plus the minute-by-minute trace. The trace
44/// rides alongside the fold, never inside it (`MATCH_MODEL.md` §7) — it is a
45/// Trace, not a fold input, and callers are free to discard it. Nothing here
46/// is persisted by `commands::advance_matchday`; only the score is.
47#[derive(Debug, Clone, PartialEq)]
48pub struct MatchOutcome {
49    pub home_goals: u8,
50    pub away_goals: u8,
51    pub stream: Vec<MatchEvent>,
52}
53
54/// Simulate one match: `(lineups, world, rng)` in, score + trace out. A pure
55/// function of its inputs — same seed stream, same outcome, by construction
56/// (`MATCH_MODEL.md` §7).
57pub fn play_match(world: &World, home: &Lineup, away: &Lineup, rng: &mut Rng) -> MatchOutcome {
58    resolve::play_match(world, home, away, rng)
59}
60
61/// Deterministic AI team selection: for each formation, greedily fill slots
62/// with the best remaining player by CA-in-slot-role (ties → lower player
63/// id); keep the formation with the best mean. This is the Phase-1 stub of
64/// the layer-3 club decision AI — same seam, richer policy later.
65pub fn ai_pick_lineup(world: &World, club: ClubId) -> Lineup {
66    let squad: Vec<PlayerId> = world.club(club).players.clone();
67    let mut best: Option<(f64, Lineup)> = None;
68
69    for (fi, formation) in FORMATIONS.iter().enumerate() {
70        let mut remaining = squad.clone();
71        let mut chosen = [PlayerId(0); XI];
72        let mut total = 0.0;
73        for (slot, &role) in formation.slots.iter().enumerate() {
74            let (idx, ca) = pick_best(world, &remaining, role);
75            chosen[slot] = remaining.remove(idx);
76            total += ca as f64;
77        }
78        let mean = total / XI as f64;
79        let candidate = Lineup {
80            formation: fi as u8,
81            players: chosen,
82        };
83        match &best {
84            Some((score, _)) if *score >= mean => {}
85            _ => best = Some((mean, candidate)),
86        }
87    }
88    best.expect("at least one formation").1
89}
90
91fn pick_best(world: &World, pool: &[PlayerId], role: Role) -> (usize, u8) {
92    let mut best_idx = 0;
93    let mut best_ca = 0u8;
94    let mut best_id = PlayerId(u32::MAX);
95    for (i, &pid) in pool.iter().enumerate() {
96        let ca = current_ability(&world.player(pid).attributes, role, &ROLE_WEIGHTS);
97        if ca > best_ca || (ca == best_ca && pid < best_id) {
98            best_idx = i;
99            best_ca = ca;
100            best_id = pid;
101        }
102    }
103    (best_idx, best_ca)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::rng::derive_stream;
110    use fforge_domain::World;
111
112    fn tiny_world_and_lineups() -> (World, Lineup, Lineup) {
113        let cfg = crate::worldgen::WorldGenConfig {
114            num_clubs: 2,
115            ..Default::default()
116        };
117        let (world, _schedule, _start) = crate::worldgen::generate(7, &cfg);
118        let clubs = world.competition.clubs.clone();
119        let home = ai_pick_lineup(&world, clubs[0]);
120        let away = ai_pick_lineup(&world, clubs[1]);
121        (world, home, away)
122    }
123
124    #[test]
125    fn same_seed_same_outcome() {
126        let (world, home, away) = tiny_world_and_lineups();
127        let mut r1 = derive_stream(99, 1);
128        let mut r2 = derive_stream(99, 1);
129        let a = play_match(&world, &home, &away, &mut r1);
130        let b = play_match(&world, &home, &away, &mut r2);
131        assert_eq!(
132            a, b,
133            "identical (lineups, world, rng stream) must yield an identical outcome"
134        );
135    }
136
137    #[test]
138    fn different_streams_can_diverge() {
139        let (world, home, away) = tiny_world_and_lineups();
140        let mut r1 = derive_stream(1, 1);
141        let mut r2 = derive_stream(2, 1);
142        let a = play_match(&world, &home, &away, &mut r1);
143        let b = play_match(&world, &home, &away, &mut r2);
144        assert_ne!(
145            a.stream, b.stream,
146            "different rng streams should not replay identically"
147        );
148    }
149
150    #[test]
151    fn stream_is_never_empty_and_ends_with_a_final_score_consistent_with_shot_events() {
152        let (world, home, away) = tiny_world_and_lineups();
153        let mut rng = derive_stream(42, 1);
154        let outcome = play_match(&world, &home, &away, &mut rng);
155        assert!(
156            !outcome.stream.is_empty(),
157            "a 90-minute match must produce events"
158        );
159        let goal_events = outcome
160            .stream
161            .iter()
162            .filter(|e| {
163                matches!(
164                    e.kind,
165                    MatchEventKind::Shot {
166                        outcome: ShotOutcome::Goal,
167                        ..
168                    }
169                )
170            })
171            .count();
172        assert_eq!(
173            goal_events,
174            outcome.home_goals as usize + outcome.away_goals as usize,
175            "every goal in the score must have exactly one corresponding Shot{{outcome: Goal}} event"
176        );
177    }
178
179    #[test]
180    fn identical_squads_show_a_structural_home_advantage() {
181        // Same club on both sides of the ball — the only asymmetry left is
182        // home_bias and each half's kickoff. Pooled over many seeds, home
183        // must win more often than away (mirrors the Phase-1 crude-engine
184        // home-advantage invariant, now against the real resolution model).
185        let cfg = crate::worldgen::WorldGenConfig {
186            num_clubs: 2,
187            ..Default::default()
188        };
189        let (world, _schedule, _start) = crate::worldgen::generate(7, &cfg);
190        let club = world.competition.clubs[0];
191        let lineup = ai_pick_lineup(&world, club);
192
193        let mut home_wins = 0u32;
194        let mut away_wins = 0u32;
195        for seed in 0..200u64 {
196            let mut rng = derive_stream(seed, 1);
197            let outcome = play_match(&world, &lineup, &lineup, &mut rng);
198            match outcome.home_goals.cmp(&outcome.away_goals) {
199                std::cmp::Ordering::Greater => home_wins += 1,
200                std::cmp::Ordering::Less => away_wins += 1,
201                std::cmp::Ordering::Equal => {}
202            }
203        }
204        assert!(
205            home_wins > away_wins,
206            "home_bias must be visible: {home_wins} home wins vs {away_wins} away wins"
207        );
208    }
209}