use super::MatchOutcome;
use super::contest::{self, blend, contest_p, fatigue_mult};
use super::knobs::Knobs;
use super::stream::{MatchEvent, MatchEventKind, ShotKind, ShotOutcome, ShotSource, Side};
use super::zone::{self, Zone};
use crate::rng::Rng;
use fforge_domain::{Attribute, Attributes, Lineup, Role, World};
struct XiPlayer {
role: Role,
attrs: Attributes,
}
fn build_xi(world: &World, lineup: &Lineup) -> Vec<XiPlayer> {
let def = lineup.formation_def();
lineup
.players
.iter()
.enumerate()
.map(|(slot, &pid)| XiPlayer {
role: def.slots[slot],
attrs: world.player(pid).attributes.clone(),
})
.collect()
}
struct TeamMeans {
pass_atk: f64,
takeon_atk: f64,
cross_atk: f64,
finish_atk: f64,
header_atk: f64,
p_wide: f64,
}
fn team_means(xi: &[XiPlayer], k: &Knobs) -> TeamMeans {
let n = xi.len() as f64;
let mean =
|w: &[(Attribute, f64)]| xi.iter().map(|p| contest::score(&p.attrs, w)).sum::<f64>() / n;
let roles: Vec<Role> = xi.iter().map(|p| p.role).collect();
TeamMeans {
pass_atk: mean(contest::PASS_ATK),
takeon_atk: mean(contest::TAKEON_ATK),
cross_atk: mean(contest::CROSS_ATK),
finish_atk: mean(contest::FINISH_ATK),
header_atk: mean(contest::HEADER_ATK),
p_wide: formation_p_wide(&roles, k),
}
}
const REFERENCE_XI_ROLES: [Role; 11] = [
Role::Gk,
Role::Cb,
Role::Cb,
Role::Fb,
Role::Fb,
Role::Dm,
Role::Cm,
Role::Am,
Role::W,
Role::W,
Role::St,
];
fn wide_presence_share(roles: &[Role]) -> f64 {
let (mut attc, mut attw) = (0u32, 0u32);
for &role in roles {
attc += zone::attacking_presence(role, Zone::AttC);
attw += zone::attacking_presence(role, Zone::AttW);
}
let total = attc + attw;
if total == 0 {
0.5
} else {
attw as f64 / total as f64
}
}
fn formation_p_wide(roles: &[Role], k: &Knobs) -> f64 {
let reference = wide_presence_share(&REFERENCE_XI_ROLES);
let team = wide_presence_share(roles);
(k.p_wide * team / reference).clamp(0.0, 1.0)
}
fn side_index(s: Side) -> usize {
match s {
Side::Home => 0,
Side::Away => 1,
}
}
fn other_side(s: Side) -> Side {
match s {
Side::Home => Side::Away,
Side::Away => Side::Home,
}
}
fn turnover(poss: Side, zone: Zone) -> (Side, Zone) {
let next_zone = match zone {
Zone::Def => Zone::AttC,
Zone::Mid => Zone::Mid,
Zone::AttC => Zone::Def,
Zone::AttW => Zone::Def,
Zone::Box => Zone::Def,
};
(other_side(poss), next_zone)
}
fn sample_by_presence(
xi: &[XiPlayer],
zone: Zone,
presence: fn(Role, Zone) -> u32,
rng: &mut Rng,
) -> usize {
let weights: Vec<u32> = xi.iter().map(|p| presence(p.role, zone)).collect();
let total: u32 = weights.iter().sum();
debug_assert!(
total > 0,
"zone must have nonzero presence for some slot role in the lineup"
);
let mut draw = rng.below(total);
for (i, &w) in weights.iter().enumerate() {
if draw < w {
return i;
}
draw -= w;
}
unreachable!("draw < total by construction")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Action {
Pass,
TakeOn,
Cross,
LongShot,
}
fn select_action(zone: Zone, actor: &XiPlayer, rng: &mut Rng, k: &Knobs) -> Action {
match zone {
Zone::Def => Action::Pass,
Zone::Mid => weighted_choice(
&[
(Action::Pass, k.w_pass_mid),
(
Action::TakeOn,
k.w_takeon_mid * (actor.attrs.get(Attribute::Dribbling) as f64 / 50.0),
),
],
rng,
),
Zone::AttC => weighted_choice(
&[
(Action::Pass, k.w_pass_attc),
(
Action::TakeOn,
k.w_takeon_attc * (actor.attrs.get(Attribute::Dribbling) as f64 / 50.0),
),
(
Action::LongShot,
k.w_longshot_attc * (actor.attrs.get(Attribute::Finishing) as f64 / 50.0),
),
],
rng,
),
Zone::AttW => weighted_choice(
&[
(
Action::Cross,
k.w_cross_attw * (actor.attrs.get(Attribute::Crossing) as f64 / 50.0),
),
(
Action::TakeOn,
k.w_takeon_attw * (actor.attrs.get(Attribute::Dribbling) as f64 / 50.0),
),
(Action::Pass, k.w_pass_attw),
],
rng,
),
Zone::Box => unreachable!("Box is never a dwelling zone — it resolves inline"),
}
}
fn weighted_choice<T: Copy>(options: &[(T, f64)], rng: &mut Rng) -> T {
let total: f64 = options.iter().map(|&(_, w)| w.max(0.0)).sum();
if total <= 0.0 {
return options[0].0;
}
let mut draw = rng.f64() * total;
for &(item, w) in options {
let w = w.max(0.0);
if draw < w {
return item;
}
draw -= w;
}
options[options.len() - 1].0
}
#[allow(clippy::too_many_arguments)]
fn take_shot(
poss: Side,
kind: ShotKind,
source: ShotSource,
base_q: f64,
att: &[XiPlayer],
def_side: &[XiPlayer],
tm_att: &TeamMeans,
minute: f64,
rng: &mut Rng,
k: &Knobs,
home_attacking: bool,
goals: &mut [u32; 2],
stream: &mut Vec<MatchEvent>,
) -> (Side, Zone) {
let shooter = &att[sample_by_presence(att, Zone::Box, zone::attacking_presence, rng)];
let defender =
&def_side[sample_by_presence(def_side, Zone::Box, zone::defending_presence, rng)];
let gk = &def_side[0];
let mut kind = kind;
let mut base_q = base_q;
let minute_u8 = minute as u8;
for _ in 0..3 {
let (atk, d_block, d_gk) = match kind {
ShotKind::Header => (
blend(
contest::score(&shooter.attrs, contest::HEADER_ATK),
tm_att.header_atk,
k,
) * fatigue_mult(&shooter.attrs, minute, k),
contest::score(&defender.attrs, contest::AERIAL_DEF),
contest::score(&gk.attrs, contest::GK_AERIAL),
),
ShotKind::Finish | ShotKind::LongShot => (
blend(
contest::score(&shooter.attrs, contest::FINISH_ATK),
tm_att.finish_atk,
k,
) * fatigue_mult(&shooter.attrs, minute, k),
contest::score(&defender.attrs, contest::BLOCK_DEF),
contest::score(&gk.attrs, contest::GK_SHOT),
),
};
let hb = if home_attacking { k.home_bias } else { 0.0 };
let p_on =
contest::sigmoid(k.k_ontarget * (atk - d_block) / k.s + k.b_ontarget + base_q + hb);
let p_beat = contest::sigmoid(k.k_gk * (atk - d_gk) / k.s + k.b_beat + base_q);
if rng.f64() < p_on {
if rng.f64() < p_beat {
goals[side_index(poss)] += 1;
stream.push(MatchEvent {
minute: minute_u8,
side: poss,
zone: Zone::Box,
kind: MatchEventKind::Shot {
kind,
source,
outcome: ShotOutcome::Goal,
},
});
return (other_side(poss), Zone::Mid); }
stream.push(MatchEvent {
minute: minute_u8,
side: poss,
zone: Zone::Box,
kind: MatchEventKind::Shot {
kind,
source,
outcome: ShotOutcome::Saved,
},
});
let rebound = rng.f64() < k.p_rebound;
stream.push(MatchEvent {
minute: minute_u8,
side: poss,
zone: Zone::Box,
kind: MatchEventKind::Save { parried: rebound },
});
if rebound {
kind = ShotKind::Finish;
base_q = k.q_rebound;
continue;
}
return (other_side(poss), Zone::Def); }
let outcome = if rng.f64() < k.p_off_frac {
ShotOutcome::Off
} else {
ShotOutcome::Blocked
};
stream.push(MatchEvent {
minute: minute_u8,
side: poss,
zone: Zone::Box,
kind: MatchEventKind::Shot {
kind,
source,
outcome,
},
});
return (other_side(poss), Zone::Def); }
(other_side(poss), Zone::Def)
}
#[allow(clippy::too_many_arguments)]
fn step(
poss: Side,
zone: Zone,
home: &[XiPlayer],
away: &[XiPlayer],
tm: &[TeamMeans; 2],
minute: f64,
rng: &mut Rng,
k: &Knobs,
goals: &mut [u32; 2],
stream: &mut Vec<MatchEvent>,
) -> (Side, Zone) {
let (att, def_side) = match poss {
Side::Home => (home, away),
Side::Away => (away, home),
};
let tm_att = &tm[side_index(poss)];
let home_attacking = poss == Side::Home;
let minute_u8 = minute as u8;
let actor = &att[sample_by_presence(att, zone, zone::attacking_presence, rng)];
let defender = &def_side[sample_by_presence(def_side, zone, zone::defending_presence, rng)];
let action = select_action(zone, actor, rng, k);
match action {
Action::Pass => {
let atk = blend(
contest::score(&actor.attrs, contest::PASS_ATK),
tm_att.pass_atk,
k,
) * fatigue_mult(&actor.attrs, minute, k);
let dfe = contest::score(&defender.attrs, contest::PASS_DEF)
* fatigue_mult(&defender.attrs, minute, k);
let success = rng.f64() < contest_p(atk, dfe, k.b_pass, k, home_attacking);
stream.push(MatchEvent {
minute: minute_u8,
side: poss,
zone,
kind: MatchEventKind::Pass { success },
});
if !success {
return turnover(poss, zone);
}
match zone {
Zone::Def => (
poss,
if rng.f64() < k.p_def_advance {
Zone::Mid
} else {
Zone::Def
},
),
Zone::Mid => {
if rng.f64() < k.p_mid_advance {
(
poss,
if rng.f64() < tm_att.p_wide {
Zone::AttW
} else {
Zone::AttC
},
)
} else {
(poss, Zone::Mid)
}
}
Zone::AttC => {
if rng.f64() < k.p_attc_penetrate {
take_shot(
poss,
ShotKind::Finish,
ShotSource::Through,
k.q_through,
att,
def_side,
tm_att,
minute,
rng,
k,
home_attacking,
goals,
stream,
)
} else if rng.f64() < 0.5 {
(poss, Zone::Mid)
} else {
(poss, Zone::AttC)
}
}
Zone::AttW => {
if rng.f64() < 0.5 {
(poss, Zone::AttC)
} else {
(poss, Zone::Mid)
}
}
Zone::Box => unreachable!("Box is never a dwelling zone"),
}
}
Action::TakeOn => {
let atk = blend(
contest::score(&actor.attrs, contest::TAKEON_ATK),
tm_att.takeon_atk,
k,
) * fatigue_mult(&actor.attrs, minute, k);
let dfe = contest::score(&defender.attrs, contest::TAKEON_DEF)
* fatigue_mult(&defender.attrs, minute, k);
let success = rng.f64() < contest_p(atk, dfe, k.b_takeon, k, home_attacking);
stream.push(MatchEvent {
minute: minute_u8,
side: poss,
zone,
kind: MatchEventKind::TakeOn { success },
});
if !success {
return turnover(poss, zone);
}
match zone {
Zone::Mid => {
if rng.f64() < k.p_mid_advance {
(
poss,
if rng.f64() < tm_att.p_wide {
Zone::AttW
} else {
Zone::AttC
},
)
} else {
(poss, Zone::Mid)
}
}
Zone::AttC => {
if rng.f64() < k.p_attc_dribble_box {
take_shot(
poss,
ShotKind::Finish,
ShotSource::Dribble,
k.q_dribble,
att,
def_side,
tm_att,
minute,
rng,
k,
home_attacking,
goals,
stream,
)
} else {
(poss, Zone::AttC)
}
}
Zone::AttW => {
if rng.f64() < k.p_attw_cutback {
take_shot(
poss,
ShotKind::Finish,
ShotSource::Cutback,
k.q_cutback,
att,
def_side,
tm_att,
minute,
rng,
k,
home_attacking,
goals,
stream,
)
} else if rng.f64() < k.p_attw_cut_inside {
(poss, Zone::AttC)
} else {
(poss, Zone::AttW)
}
}
Zone::Def | Zone::Box => {
unreachable!("take-on never selected in Def; Box is never dwelt in")
}
}
}
Action::Cross => {
let atk = blend(
contest::score(&actor.attrs, contest::CROSS_ATK),
tm_att.cross_atk,
k,
) * fatigue_mult(&actor.attrs, minute, k);
let dfe = contest::score(&defender.attrs, contest::CROSS_DEF)
* fatigue_mult(&defender.attrs, minute, k);
let success = rng.f64() < contest_p(atk, dfe, k.b_cross_delivery, k, home_attacking);
stream.push(MatchEvent {
minute: minute_u8,
side: poss,
zone,
kind: MatchEventKind::Cross { success },
});
if success {
take_shot(
poss,
ShotKind::Header,
ShotSource::Cross,
k.q_header,
att,
def_side,
tm_att,
minute,
rng,
k,
home_attacking,
goals,
stream,
)
} else {
stream.push(MatchEvent {
minute: minute_u8,
side: poss,
zone,
kind: MatchEventKind::Clearance,
});
turnover(poss, zone)
}
}
Action::LongShot => take_shot(
poss,
ShotKind::LongShot,
ShotSource::Long,
k.q_long,
att,
def_side,
tm_att,
minute,
rng,
k,
home_attacking,
goals,
stream,
),
}
}
pub fn play_match(
world: &World,
home_lineup: &Lineup,
away_lineup: &Lineup,
rng: &mut Rng,
) -> MatchOutcome {
let home = build_xi(world, home_lineup);
let away = build_xi(world, away_lineup);
simulate(&home, &away, rng, &Knobs::default())
}
fn simulate(home: &[XiPlayer], away: &[XiPlayer], rng: &mut Rng, k: &Knobs) -> MatchOutcome {
let tm = [team_means(home, k), team_means(away, k)];
let mut goals = [0u32, 0u32];
let mut stream = Vec::new();
for half in 0..2u8 {
let start = 45.0 * half as f64;
let end = 45.0 * (half as f64 + 1.0);
let mut poss = if half == 0 { Side::Home } else { Side::Away }; let mut zone = Zone::Mid;
let mut minute = start;
while minute < end {
let (next_poss, next_zone) = step(
poss,
zone,
home,
away,
&tm,
minute,
rng,
k,
&mut goals,
&mut stream,
);
poss = next_poss;
zone = next_zone;
minute += k.delta;
}
}
MatchOutcome {
home_goals: goals[0].min(u8::MAX as u32) as u8,
away_goals: goals[1].min(u8::MAX as u32) as u8,
stream,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn turnover_mirrors_zones_per_match_model_table() {
assert_eq!(turnover(Side::Home, Zone::Def), (Side::Away, Zone::AttC));
assert_eq!(turnover(Side::Home, Zone::Mid), (Side::Away, Zone::Mid));
assert_eq!(turnover(Side::Home, Zone::AttC), (Side::Away, Zone::Def));
assert_eq!(turnover(Side::Home, Zone::AttW), (Side::Away, Zone::Def));
assert_eq!(turnover(Side::Away, Zone::Def), (Side::Home, Zone::AttC));
}
#[test]
fn weighted_choice_always_picks_the_only_positive_option() {
let mut rng = Rng::seed_from(1);
for _ in 0..50 {
let picked = weighted_choice(
&[
(Action::Pass, 0.0),
(Action::TakeOn, 5.0),
(Action::Cross, 0.0),
],
&mut rng,
);
assert_eq!(picked, Action::TakeOn);
}
}
#[test]
fn formation_p_wide_is_unchanged_for_the_reference_shape() {
let k = Knobs::default();
let p = formation_p_wide(&REFERENCE_XI_ROLES, &k);
assert!(
(p - k.p_wide).abs() < 1e-9,
"the reference shape must reproduce the fitted knob exactly: got {p}, knob {}",
k.p_wide
);
}
#[test]
fn formation_p_wide_drops_for_a_winger_less_back_three() {
let k = Knobs::default();
let three_five_two = fforge_domain::FORMATIONS[3].slots;
assert_eq!(fforge_domain::FORMATIONS[3].name, "3-5-2");
let p = formation_p_wide(&three_five_two, &k);
assert!(
p < k.p_wide,
"a winger-less shape must route less often into AttW than the fitted knob: got {p}, knob {}",
k.p_wide
);
}
#[test]
fn formation_p_wide_stays_a_probability_for_every_real_formation() {
let k = Knobs::default();
for formation in fforge_domain::FORMATIONS {
let p = formation_p_wide(&formation.slots, &k);
assert!(
(0.0..=1.0).contains(&p),
"{}: formation_p_wide {p} out of [0,1]",
formation.name
);
}
}
}
#[cfg(test)]
mod notebook_parity {
use super::*;
use crate::rng::derive_stream;
use crate::schedule::double_round_robin;
use fforge_domain::{ClubId, NUM_ATTRIBUTES, ROLE_WEIGHTS, XI};
fn notebook_gen_player(rng: &mut Rng, role: Role, club_q: f64) -> Attributes {
let base = rng.normal(club_q, 6.0).clamp(25.0, 92.0);
let mut values = [0u8; NUM_ATTRIBUTES];
for attr in Attribute::ALL {
let w = ROLE_WEIGHTS.weight(role, attr);
let v = if w == 0 {
rng.range_i32(8, 22) as f64
} else {
rng.normal(base + (w as f64 - 3.0) * 4.0, 4.5)
};
values[attr.index()] = v.clamp(15.0, 96.0) as u8;
}
Attributes::new(values)
}
const FIXED_XI: [Role; XI] = [
Role::Gk,
Role::Cb,
Role::Cb,
Role::Fb,
Role::Fb,
Role::Dm,
Role::Cm,
Role::Am,
Role::W,
Role::W,
Role::St,
];
fn build_fixed_xi(rng: &mut Rng, club_q: f64) -> Vec<XiPlayer> {
FIXED_XI
.iter()
.map(|&role| XiPlayer {
role,
attrs: notebook_gen_player(rng, role, club_q),
})
.collect()
}
const PARITY_NS: u64 = 0x4E42_5052_0000_0000;
#[test]
fn port_reproduces_notebook_gpm_on_notebook_equivalent_inputs() {
const NUM_LEAGUES: u64 = 8;
const NUM_CLUBS: usize = 20;
let notebook_knobs = Knobs {
b_beat: -1.7,
..Knobs::default()
};
let mut total_goals = 0u32;
let mut total_matches = 0u32;
for league in 0..NUM_LEAGUES {
let qualities: Vec<f64> = (0..NUM_CLUBS)
.map(|i| 48.0 + 26.0 * i as f64 / (NUM_CLUBS - 1) as f64)
.collect();
let mut gen_rng = derive_stream(league, PARITY_NS);
let teams: Vec<Vec<XiPlayer>> = qualities
.iter()
.map(|&q| build_fixed_xi(&mut gen_rng, q))
.collect();
let club_ids: Vec<ClubId> = (0..NUM_CLUBS as u16).map(ClubId).collect();
let fixtures = double_round_robin(&club_ids);
for fixture in &fixtures {
let home = &teams[fixture.home.0 as usize];
let away = &teams[fixture.away.0 as usize];
let mut match_rng = derive_stream(league, PARITY_NS | (fixture.id.0 as u64 + 1));
let outcome = simulate(home, away, &mut match_rng, ¬ebook_knobs);
total_goals += outcome.home_goals as u32 + outcome.away_goals as u32;
total_matches += 1;
}
}
let gpm = total_goals as f64 / total_matches as f64;
assert!(
(2.3..=3.1).contains(&gpm),
"pooled gpm {gpm} over {total_matches} notebook-equivalent-input matches falls \
outside the ~2.5-2.9 band the notebook itself reads (~2.6-2.7 target/fitted). That \
means the gap versus real-worldgen gpm (~1.7-2.0) is NOT purely an input-distribution \
effect — diff this loop against the notebook cell-by-cell (kickoff alternation, the \
minute += delta step count, the take_shot rebound loop, turnover mirroring, the \
action-selection weights) before touching any knob or presence table."
);
}
}