Skip to main content

fforge_core/
state.rs

1//! `GameState` — a pure fold over the event log.
2//!
3//! `apply` is the fold step: no RNG, no clock, no I/O, no engine calls. All
4//! of those live in `commands::step`, which *produces* events; this module
5//! only consumes them. `replay(events)` is therefore save-loading, bug
6//! reproduction, and (later) counterfactual branch points, all in one.
7
8use crate::event::Event;
9use fforge_domain::{ClubId, Fixture, FixtureId, GameDate, Lineup, World};
10use std::collections::BTreeMap;
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct GameState {
14    pub seed: u64,
15    pub world: World,
16    pub player_club: ClubId,
17    pub schedule: Vec<Fixture>,
18    pub date: GameDate,
19    /// 1-based matchday about to be played next. `last_matchday + 1` once over.
20    pub current_matchday: u8,
21    pub last_matchday: u8,
22    pub results: BTreeMap<FixtureId, (u8, u8)>,
23    /// The human's submitted lineup for the upcoming matchday, if any.
24    pub pending_lineup: Option<Lineup>,
25    /// The lineup most recently used, reused as the default next time.
26    pub last_lineup: Option<Lineup>,
27    pub champion: Option<ClubId>,
28}
29
30impl GameState {
31    /// Rebuild state from the log. Panics on a malformed log (an empty log or
32    /// one not starting with `GameStarted`) — that is a corrupted save, not a
33    /// recoverable game situation.
34    pub fn replay(events: &[Event]) -> GameState {
35        let mut iter = events.iter();
36        let first = iter.next().expect("event log is empty");
37        let mut state = match first {
38            Event::GameStarted {
39                seed,
40                start_date,
41                player_club,
42                world,
43                schedule,
44            } => {
45                let last_matchday = schedule.iter().map(|f| f.matchday).max().unwrap_or(0);
46                GameState {
47                    seed: *seed,
48                    world: world.clone(),
49                    player_club: *player_club,
50                    schedule: schedule.clone(),
51                    date: *start_date,
52                    current_matchday: 1,
53                    last_matchday,
54                    results: BTreeMap::new(),
55                    pending_lineup: None,
56                    last_lineup: None,
57                    champion: None,
58                }
59            }
60            other => panic!("event log must start with GameStarted, found {other:?}"),
61        };
62        for event in iter {
63            state.apply(event);
64        }
65        state
66    }
67
68    /// The fold step. Total over post-`GameStarted` events.
69    pub fn apply(&mut self, event: &Event) {
70        match event {
71            Event::GameStarted { .. } => {
72                panic!("GameStarted may only appear as the first event")
73            }
74            Event::LineupSubmitted { lineup, .. } => {
75                self.pending_lineup = Some(lineup.clone());
76            }
77            Event::MatchPlayed {
78                fixture,
79                home_goals,
80                away_goals,
81                ..
82            } => {
83                self.results.insert(*fixture, (*home_goals, *away_goals));
84            }
85            Event::MatchdayAdvanced { new_date, .. } => {
86                if let Some(lineup) = self.pending_lineup.take() {
87                    self.last_lineup = Some(lineup);
88                }
89                self.date = *new_date;
90                self.current_matchday += 1;
91            }
92            Event::SeasonEnded { champion } => {
93                self.champion = Some(*champion);
94            }
95        }
96    }
97
98    pub fn season_over(&self) -> bool {
99        self.champion.is_some()
100    }
101
102    pub fn fixtures_of_matchday(&self, matchday: u8) -> impl Iterator<Item = &Fixture> {
103        self.schedule.iter().filter(move |f| f.matchday == matchday)
104    }
105}
106
107/// One league-table row. The table is **derived, never stored** — same
108/// philosophy as CA: results are the single source of truth, the table is a
109/// view.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct TableRow {
112    pub club: ClubId,
113    pub played: u32,
114    pub won: u32,
115    pub drawn: u32,
116    pub lost: u32,
117    pub goals_for: u32,
118    pub goals_against: u32,
119}
120
121impl TableRow {
122    pub fn points(&self) -> u32 {
123        self.won * 3 + self.drawn
124    }
125    pub fn goal_diff(&self) -> i64 {
126        self.goals_for as i64 - self.goals_against as i64
127    }
128}
129
130/// Standings from an arbitrary results map (callers may merge not-yet-applied
131/// events in). Sort: points, goal difference, goals for, then club name —
132/// fully deterministic.
133pub fn league_table(
134    world: &World,
135    schedule: &[Fixture],
136    results: &BTreeMap<FixtureId, (u8, u8)>,
137) -> Vec<TableRow> {
138    let mut rows: BTreeMap<ClubId, TableRow> = world
139        .competition
140        .clubs
141        .iter()
142        .map(|&c| {
143            (
144                c,
145                TableRow {
146                    club: c,
147                    played: 0,
148                    won: 0,
149                    drawn: 0,
150                    lost: 0,
151                    goals_for: 0,
152                    goals_against: 0,
153                },
154            )
155        })
156        .collect();
157
158    for fixture in schedule {
159        let Some(&(hg, ag)) = results.get(&fixture.id) else {
160            continue;
161        };
162        {
163            let home = rows.get_mut(&fixture.home).expect("home club in table");
164            home.played += 1;
165            home.goals_for += hg as u32;
166            home.goals_against += ag as u32;
167            match hg.cmp(&ag) {
168                std::cmp::Ordering::Greater => home.won += 1,
169                std::cmp::Ordering::Equal => home.drawn += 1,
170                std::cmp::Ordering::Less => home.lost += 1,
171            }
172        }
173        {
174            let away = rows.get_mut(&fixture.away).expect("away club in table");
175            away.played += 1;
176            away.goals_for += ag as u32;
177            away.goals_against += hg as u32;
178            match ag.cmp(&hg) {
179                std::cmp::Ordering::Greater => away.won += 1,
180                std::cmp::Ordering::Equal => away.drawn += 1,
181                std::cmp::Ordering::Less => away.lost += 1,
182            }
183        }
184    }
185
186    let mut table: Vec<TableRow> = rows.into_values().collect();
187    table.sort_by(|a, b| {
188        b.points()
189            .cmp(&a.points())
190            .then(b.goal_diff().cmp(&a.goal_diff()))
191            .then(b.goals_for.cmp(&a.goals_for))
192            .then(world.club(a.club).name.cmp(&world.club(b.club).name))
193    });
194    table
195}