1use 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 SubmitLineup(Lineup),
20 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
43pub const FIXTURE_STREAM_NS: u64 = 0x4D41_5443_0000_0000; pub 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
85fn 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
98pub 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 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}