1mod calibrate;
13mod contest;
14mod knobs;
15mod resolve;
16mod stream;
17mod zone;
18
19pub use calibrate::{FormationStats, StreamTelemetry};
20pub use knobs::Knobs;
21pub use stream::{MatchEvent, MatchEventKind, ShotKind, ShotOutcome, ShotSource, Side};
22pub use zone::Zone;
23
24use crate::rng::Rng;
25use fforge_domain::{
26 ClubId, FORMATIONS, Lineup, PlayerId, ROLE_WEIGHTS, Role, World, XI, current_ability,
27};
28
29pub fn lineup_strength(world: &World, lineup: &Lineup) -> f64 {
33 let def = lineup.formation_def();
34 let mut sum = 0.0;
35 for (slot, &pid) in lineup.players.iter().enumerate() {
36 let player = world.player(pid);
37 sum += current_ability(&player.attributes, def.slots[slot], &ROLE_WEIGHTS) as f64;
38 }
39 sum / XI as f64
40}
41
42#[derive(Debug, Clone, PartialEq)]
48pub struct MatchOutcome {
49 pub home_goals: u8,
50 pub away_goals: u8,
51 pub stream: Vec<MatchEvent>,
52}
53
54pub fn play_match(world: &World, home: &Lineup, away: &Lineup, rng: &mut Rng) -> MatchOutcome {
58 resolve::play_match(world, home, away, rng)
59}
60
61pub fn ai_pick_lineup(world: &World, club: ClubId) -> Lineup {
66 let squad: Vec<PlayerId> = world.club(club).players.clone();
67 let mut best: Option<(f64, Lineup)> = None;
68
69 for (fi, formation) in FORMATIONS.iter().enumerate() {
70 let mut remaining = squad.clone();
71 let mut chosen = [PlayerId(0); XI];
72 let mut total = 0.0;
73 for (slot, &role) in formation.slots.iter().enumerate() {
74 let (idx, ca) = pick_best(world, &remaining, role);
75 chosen[slot] = remaining.remove(idx);
76 total += ca as f64;
77 }
78 let mean = total / XI as f64;
79 let candidate = Lineup {
80 formation: fi as u8,
81 players: chosen,
82 };
83 match &best {
84 Some((score, _)) if *score >= mean => {}
85 _ => best = Some((mean, candidate)),
86 }
87 }
88 best.expect("at least one formation").1
89}
90
91fn pick_best(world: &World, pool: &[PlayerId], role: Role) -> (usize, u8) {
92 let mut best_idx = 0;
93 let mut best_ca = 0u8;
94 let mut best_id = PlayerId(u32::MAX);
95 for (i, &pid) in pool.iter().enumerate() {
96 let ca = current_ability(&world.player(pid).attributes, role, &ROLE_WEIGHTS);
97 if ca > best_ca || (ca == best_ca && pid < best_id) {
98 best_idx = i;
99 best_ca = ca;
100 best_id = pid;
101 }
102 }
103 (best_idx, best_ca)
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::rng::derive_stream;
110 use fforge_domain::World;
111
112 fn tiny_world_and_lineups() -> (World, Lineup, Lineup) {
113 let cfg = crate::worldgen::WorldGenConfig {
114 num_clubs: 2,
115 ..Default::default()
116 };
117 let (world, _schedule, _start) = crate::worldgen::generate(7, &cfg);
118 let clubs = world.competition.clubs.clone();
119 let home = ai_pick_lineup(&world, clubs[0]);
120 let away = ai_pick_lineup(&world, clubs[1]);
121 (world, home, away)
122 }
123
124 #[test]
125 fn same_seed_same_outcome() {
126 let (world, home, away) = tiny_world_and_lineups();
127 let mut r1 = derive_stream(99, 1);
128 let mut r2 = derive_stream(99, 1);
129 let a = play_match(&world, &home, &away, &mut r1);
130 let b = play_match(&world, &home, &away, &mut r2);
131 assert_eq!(
132 a, b,
133 "identical (lineups, world, rng stream) must yield an identical outcome"
134 );
135 }
136
137 #[test]
138 fn different_streams_can_diverge() {
139 let (world, home, away) = tiny_world_and_lineups();
140 let mut r1 = derive_stream(1, 1);
141 let mut r2 = derive_stream(2, 1);
142 let a = play_match(&world, &home, &away, &mut r1);
143 let b = play_match(&world, &home, &away, &mut r2);
144 assert_ne!(
145 a.stream, b.stream,
146 "different rng streams should not replay identically"
147 );
148 }
149
150 #[test]
151 fn stream_is_never_empty_and_ends_with_a_final_score_consistent_with_shot_events() {
152 let (world, home, away) = tiny_world_and_lineups();
153 let mut rng = derive_stream(42, 1);
154 let outcome = play_match(&world, &home, &away, &mut rng);
155 assert!(
156 !outcome.stream.is_empty(),
157 "a 90-minute match must produce events"
158 );
159 let goal_events = outcome
160 .stream
161 .iter()
162 .filter(|e| {
163 matches!(
164 e.kind,
165 MatchEventKind::Shot {
166 outcome: ShotOutcome::Goal,
167 ..
168 }
169 )
170 })
171 .count();
172 assert_eq!(
173 goal_events,
174 outcome.home_goals as usize + outcome.away_goals as usize,
175 "every goal in the score must have exactly one corresponding Shot{{outcome: Goal}} event"
176 );
177 }
178
179 #[test]
180 fn identical_squads_show_a_structural_home_advantage() {
181 let cfg = crate::worldgen::WorldGenConfig {
186 num_clubs: 2,
187 ..Default::default()
188 };
189 let (world, _schedule, _start) = crate::worldgen::generate(7, &cfg);
190 let club = world.competition.clubs[0];
191 let lineup = ai_pick_lineup(&world, club);
192
193 let mut home_wins = 0u32;
194 let mut away_wins = 0u32;
195 for seed in 0..200u64 {
196 let mut rng = derive_stream(seed, 1);
197 let outcome = play_match(&world, &lineup, &lineup, &mut rng);
198 match outcome.home_goals.cmp(&outcome.away_goals) {
199 std::cmp::Ordering::Greater => home_wins += 1,
200 std::cmp::Ordering::Less => away_wins += 1,
201 std::cmp::Ordering::Equal => {}
202 }
203 }
204 assert!(
205 home_wins > away_wins,
206 "home_bias must be visible: {home_wins} home wins vs {away_wins} away wins"
207 );
208 }
209}