Skip to main content

fforge_core/
match_engine.rs

1//! The Phase-1 **crude** match engine (DESIGN.md §9, Phase 1).
2//!
3//! Deliberately shallow: XI strength → Poisson goal counts. It exists so the
4//! whole loop runs end-to-end; Phase 2 replaces it with the event-based
5//! possession model behind the same call site. What it *does* already
6//! exercise for real: the role-weighting table (a striker picked at CB rates
7//! as a CB and drags the team down) and derived per-fixture RNG streams.
8//!
9//! Baseline constants target believable aggregates (≈2.6 goals/game, visible
10//! home advantage) but are eyeballed, not calibrated — the calibration
11//! harness is a Phase 2 deliverable alongside the real engine.
12
13use crate::rng::Rng;
14use fforge_domain::{
15    current_ability, ClubId, Lineup, PlayerId, Role, World, FORMATIONS, ROLE_WEIGHTS, XI,
16};
17
18/// Mean CA-in-slot-role over the eleven. Playing out of position is legal and
19/// simply rates at that role's CA — the weighting table is the whole penalty
20/// model, no extra fudge factor.
21pub fn lineup_strength(world: &World, lineup: &Lineup) -> f64 {
22    let def = lineup.formation_def();
23    let mut sum = 0.0;
24    for (slot, &pid) in lineup.players.iter().enumerate() {
25        let player = world.player(pid);
26        sum += current_ability(&player.attributes, def.slots[slot], &ROLE_WEIGHTS) as f64;
27    }
28    sum / XI as f64
29}
30
31/// Home λ base > away λ base ⇒ home advantage; totals ≈ 2.6.
32const HOME_BASE: f64 = 1.42;
33const AWAY_BASE: f64 = 1.12;
34/// Sensitivity per rating point of strength difference.
35const DIFF_K: f64 = 0.030;
36
37pub fn simulate_match(home_strength: f64, away_strength: f64, rng: &mut Rng) -> (u8, u8) {
38    let diff = home_strength - away_strength;
39    let lambda_home = (HOME_BASE * (DIFF_K * diff).exp()).clamp(0.15, 6.0);
40    let lambda_away = (AWAY_BASE * (-DIFF_K * diff).exp()).clamp(0.10, 6.0);
41    let hg = rng.poisson(lambda_home).min(9) as u8;
42    let ag = rng.poisson(lambda_away).min(9) as u8;
43    (hg, ag)
44}
45
46/// Deterministic AI team selection: for each formation, greedily fill slots
47/// with the best remaining player by CA-in-slot-role (ties → lower player
48/// id); keep the formation with the best mean. This is the Phase-1 stub of
49/// the layer-3 club decision AI — same seam, richer policy later.
50pub fn ai_pick_lineup(world: &World, club: ClubId) -> Lineup {
51    let squad: Vec<PlayerId> = world.club(club).players.clone();
52    let mut best: Option<(f64, Lineup)> = None;
53
54    for (fi, formation) in FORMATIONS.iter().enumerate() {
55        let mut remaining = squad.clone();
56        let mut chosen = [PlayerId(0); XI];
57        let mut total = 0.0;
58        for (slot, &role) in formation.slots.iter().enumerate() {
59            let (idx, ca) = pick_best(world, &remaining, role);
60            chosen[slot] = remaining.remove(idx);
61            total += ca as f64;
62        }
63        let mean = total / XI as f64;
64        let candidate = Lineup {
65            formation: fi as u8,
66            players: chosen,
67        };
68        match &best {
69            Some((score, _)) if *score >= mean => {}
70            _ => best = Some((mean, candidate)),
71        }
72    }
73    best.expect("at least one formation").1
74}
75
76fn pick_best(world: &World, pool: &[PlayerId], role: Role) -> (usize, u8) {
77    let mut best_idx = 0;
78    let mut best_ca = 0u8;
79    let mut best_id = PlayerId(u32::MAX);
80    for (i, &pid) in pool.iter().enumerate() {
81        let ca = current_ability(&world.player(pid).attributes, role, &ROLE_WEIGHTS);
82        if ca > best_ca || (ca == best_ca && pid < best_id) {
83            best_idx = i;
84            best_ca = ca;
85            best_id = pid;
86        }
87    }
88    (best_idx, best_ca)
89}