Skip to main content

fforge_core/
commands.rs

1//! `step(state, command) -> events` — the deterministic transition producer.
2//!
3//! This is the propose-then-validate gate in miniature: a `Command` is a
4//! *proposal* (from the human today; from LLM agents in Phase 5), validation
5//! happens here, and only resolved, validated values become events. `step`
6//! never mutates state — callers apply the returned events through the fold.
7
8use crate::event::Event;
9use crate::match_engine::{ai_pick_lineup, lineup_strength, simulate_match};
10use crate::rng::derive_stream;
11use crate::state::{league_table, GameState};
12use fforge_domain::{Lineup, PlayerId, FORMATIONS, XI};
13use std::collections::BTreeSet;
14use std::fmt;
15
16#[derive(Debug, Clone, PartialEq)]
17pub enum Command {
18    /// Submit the human club's team sheet for the upcoming matchday.
19    SubmitLineup(Lineup),
20    /// Simulate every fixture of the current matchday and advance the calendar.
21    AdvanceMatchday,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum CommandError {
26    SeasonOver,
27    UnknownFormation(u8),
28    DuplicatePlayers,
29    NotInSquad(PlayerId),
30}
31
32impl fmt::Display for CommandError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            CommandError::SeasonOver => write!(f, "the season is over"),
36            CommandError::UnknownFormation(i) => write!(f, "unknown formation index {i}"),
37            CommandError::DuplicatePlayers => write!(f, "a player appears twice in the lineup"),
38            CommandError::NotInSquad(p) => write!(f, "player {p} is not in your squad"),
39        }
40    }
41}
42
43/// Tag namespace for per-fixture RNG streams (see rng::derive_stream).
44const FIXTURE_STREAM_NS: u64 = 0x4D41_5443_0000_0000; // "MATC"
45
46pub fn step(state: &GameState, command: Command) -> Result<Vec<Event>, CommandError> {
47    if state.season_over() {
48        return Err(CommandError::SeasonOver);
49    }
50    match command {
51        Command::SubmitLineup(lineup) => {
52            validate_lineup(state, &lineup)?;
53            Ok(vec![Event::LineupSubmitted {
54                matchday: state.current_matchday,
55                lineup,
56            }])
57        }
58        Command::AdvanceMatchday => Ok(advance_matchday(state)),
59    }
60}
61
62fn validate_lineup(state: &GameState, lineup: &Lineup) -> Result<(), CommandError> {
63    if lineup.formation as usize >= FORMATIONS.len() {
64        return Err(CommandError::UnknownFormation(lineup.formation));
65    }
66    let mut seen = BTreeSet::new();
67    for &pid in &lineup.players {
68        if !seen.insert(pid) {
69            return Err(CommandError::DuplicatePlayers);
70        }
71    }
72    let squad = &state.world.club(state.player_club).players;
73    for &pid in &lineup.players {
74        if !squad.contains(&pid) {
75            return Err(CommandError::NotInSquad(pid));
76        }
77    }
78    debug_assert_eq!(lineup.players.len(), XI);
79    Ok(())
80}
81
82/// The human club's effective lineup for this matchday: the submitted one,
83/// else the last one used, else a deterministic auto-pick. Never fails —
84/// forgetting to set a team costs quality, not a crash.
85fn effective_player_lineup(state: &GameState) -> Lineup {
86    if let Some(lineup) = &state.pending_lineup {
87        return lineup.clone();
88    }
89    if let Some(lineup) = &state.last_lineup {
90        return lineup.clone();
91    }
92    ai_pick_lineup(&state.world, state.player_club)
93}
94
95fn advance_matchday(state: &GameState) -> Vec<Event> {
96    let md = state.current_matchday;
97    let mut events = Vec::new();
98    let mut new_results = state.results.clone();
99
100    for fixture in state.fixtures_of_matchday(md) {
101        let home_lineup = if fixture.home == state.player_club {
102            effective_player_lineup(state)
103        } else {
104            ai_pick_lineup(&state.world, fixture.home)
105        };
106        let away_lineup = if fixture.away == state.player_club {
107            effective_player_lineup(state)
108        } else {
109            ai_pick_lineup(&state.world, fixture.away)
110        };
111        let hs = lineup_strength(&state.world, &home_lineup);
112        let as_ = lineup_strength(&state.world, &away_lineup);
113        let mut rng = derive_stream(state.seed, FIXTURE_STREAM_NS | fixture.id.0 as u64);
114        let (hg, ag) = simulate_match(hs, as_, &mut rng);
115        new_results.insert(fixture.id, (hg, ag));
116        events.push(Event::MatchPlayed {
117            fixture: fixture.id,
118            matchday: md,
119            home_goals: hg,
120            away_goals: ag,
121        });
122    }
123
124    events.push(Event::MatchdayAdvanced {
125        matchday: md,
126        new_date: state.date.add_days(7),
127    });
128
129    if md == state.last_matchday {
130        let table = league_table(&state.world, &state.schedule, &new_results);
131        let champion = table.first().expect("non-empty league").club;
132        events.push(Event::SeasonEnded { champion });
133    }
134
135    events
136}