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, play_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). Public
44/// so the calibration harness (`fforge-core/src/bin/calibrate.rs`) can derive
45/// the exact same per-fixture stream `advance_matchday` uses without
46/// duplicating the constant.
47pub const FIXTURE_STREAM_NS: u64 = 0x4D41_5443_0000_0000; // "MATC"
48
49pub fn step(state: &GameState, command: Command) -> Result<Vec<Event>, CommandError> {
50    if state.season_over() {
51        return Err(CommandError::SeasonOver);
52    }
53    match command {
54        Command::SubmitLineup(lineup) => {
55            validate_lineup(state, &lineup)?;
56            Ok(vec![Event::LineupSubmitted {
57                matchday: state.current_matchday,
58                lineup,
59            }])
60        }
61        Command::AdvanceMatchday => Ok(advance_matchday(state)),
62    }
63}
64
65fn validate_lineup(state: &GameState, lineup: &Lineup) -> Result<(), CommandError> {
66    if lineup.formation as usize >= FORMATIONS.len() {
67        return Err(CommandError::UnknownFormation(lineup.formation));
68    }
69    let mut seen = BTreeSet::new();
70    for &pid in &lineup.players {
71        if !seen.insert(pid) {
72            return Err(CommandError::DuplicatePlayers);
73        }
74    }
75    let squad = &state.world.club(state.player_club).players;
76    for &pid in &lineup.players {
77        if !squad.contains(&pid) {
78            return Err(CommandError::NotInSquad(pid));
79        }
80    }
81    debug_assert_eq!(lineup.players.len(), XI);
82    Ok(())
83}
84
85/// The human club's effective lineup for this matchday: the submitted one,
86/// else the last one used, else a deterministic auto-pick. Never fails —
87/// forgetting to set a team costs quality, not a crash.
88fn effective_player_lineup(state: &GameState) -> Lineup {
89    if let Some(lineup) = &state.pending_lineup {
90        return lineup.clone();
91    }
92    if let Some(lineup) = &state.last_lineup {
93        return lineup.clone();
94    }
95    ai_pick_lineup(&state.world, state.player_club)
96}
97
98/// The player's own fixture for the upcoming matchday, simulated exactly as
99/// `advance_matchday` is about to simulate it (same lineup selection, same
100/// seed-derived RNG stream) — a pure query, computed from `state` and
101/// discarded by the caller, that never mutates anything or produces an
102/// `Event`. Because it re-derives from the same inputs `advance_matchday`
103/// consumes, its score can never disagree with what `Command::AdvanceMatchday`
104/// actually records. Live-viewing consumers (fforge-game's main game loop)
105/// call this *before* executing `AdvanceMatchday` to render the humble text
106/// match view (`DESIGN.md` §9) for the human's own match. `None` if the
107/// player's club has a bye this matchday.
108pub fn player_match_preview(
109    state: &GameState,
110) -> Option<crate::match_engine::MatchOutcome> {
111    let md = state.current_matchday;
112    let fixture = state
113        .fixtures_of_matchday(md)
114        .find(|f| f.home == state.player_club || f.away == state.player_club)?;
115    let home_lineup = if fixture.home == state.player_club {
116        effective_player_lineup(state)
117    } else {
118        ai_pick_lineup(&state.world, fixture.home)
119    };
120    let away_lineup = if fixture.away == state.player_club {
121        effective_player_lineup(state)
122    } else {
123        ai_pick_lineup(&state.world, fixture.away)
124    };
125    let mut rng = derive_stream(state.seed, FIXTURE_STREAM_NS | fixture.id.0 as u64);
126    Some(play_match(&state.world, &home_lineup, &away_lineup, &mut rng))
127}
128
129fn advance_matchday(state: &GameState) -> Vec<Event> {
130    let md = state.current_matchday;
131    let mut events = Vec::new();
132    let mut new_results = state.results.clone();
133
134    for fixture in state.fixtures_of_matchday(md) {
135        let home_lineup = if fixture.home == state.player_club {
136            effective_player_lineup(state)
137        } else {
138            ai_pick_lineup(&state.world, fixture.home)
139        };
140        let away_lineup = if fixture.away == state.player_club {
141            effective_player_lineup(state)
142        } else {
143            ai_pick_lineup(&state.world, fixture.away)
144        };
145        let mut rng = derive_stream(state.seed, FIXTURE_STREAM_NS | fixture.id.0 as u64);
146        // The minute-by-minute stream is a Trace, not a fold input
147        // (MATCH_MODEL.md §7) — only the score is recorded; it rides
148        // alongside for live-viewing consumers (fforge-game's friendly
149        // viewer) but is never persisted through the event log.
150        let outcome = play_match(&state.world, &home_lineup, &away_lineup, &mut rng);
151        let (hg, ag) = (outcome.home_goals, outcome.away_goals);
152        new_results.insert(fixture.id, (hg, ag));
153        events.push(Event::MatchPlayed {
154            fixture: fixture.id,
155            matchday: md,
156            home_goals: hg,
157            away_goals: ag,
158        });
159    }
160
161    events.push(Event::MatchdayAdvanced {
162        matchday: md,
163        new_date: state.date.add_days(7),
164    });
165
166    if md == state.last_matchday {
167        let table = league_table(&state.world, &state.schedule, &new_results);
168        let champion = table.first().expect("non-empty league").club;
169        events.push(Event::SeasonEnded { champion });
170    }
171
172    events
173}