Skip to main content

fforge_core/
schedule.rs

1//! Deterministic double round-robin via the circle method. Pure function of
2//! the club ordering — shuffle the input (seeded) if variety is wanted.
3
4use fforge_domain::{ClubId, Fixture, FixtureId};
5
6/// Requires an even number of clubs (≥ 2). Produces 2·(n−1) matchdays; the
7/// second half mirrors the first with venues swapped. Fixture ids are
8/// sequential in emission order.
9pub fn double_round_robin(clubs: &[ClubId]) -> Vec<Fixture> {
10    let n = clubs.len();
11    assert!(n >= 2 && n.is_multiple_of(2), "need an even number of clubs, got {n}");
12    let rounds = (n - 1) as u8;
13    let mut fixtures = Vec::with_capacity(n * (n - 1));
14    let mut next_id = 0u32;
15
16    // Circle method: club[0] fixed, the rest rotate.
17    let mut ring: Vec<ClubId> = clubs[1..].to_vec();
18
19    let mut first_half: Vec<Vec<(ClubId, ClubId)>> = Vec::with_capacity(rounds as usize);
20    for round in 0..rounds {
21        let mut pairs = Vec::with_capacity(n / 2);
22        // Alternate the fixed club's venue so nobody plays 19 straight at home.
23        let (a, b) = (clubs[0], ring[0]);
24        if round % 2 == 0 {
25            pairs.push((a, b));
26        } else {
27            pairs.push((b, a));
28        }
29        for i in 1..n / 2 {
30            let x = ring[i];
31            let y = ring[n - 1 - i];
32            if round % 2 == 0 {
33                pairs.push((y, x));
34            } else {
35                pairs.push((x, y));
36            }
37        }
38        first_half.push(pairs);
39        ring.rotate_right(1);
40    }
41
42    for (round, pairs) in first_half.iter().enumerate() {
43        for &(home, away) in pairs {
44            fixtures.push(Fixture {
45                id: FixtureId(next_id),
46                matchday: round as u8 + 1,
47                home,
48                away,
49            });
50            next_id += 1;
51        }
52    }
53    // Second half: mirror with venues swapped.
54    for (round, pairs) in first_half.iter().enumerate() {
55        for &(home, away) in pairs {
56            fixtures.push(Fixture {
57                id: FixtureId(next_id),
58                matchday: rounds + round as u8 + 1,
59                home: away,
60                away: home,
61            });
62            next_id += 1;
63        }
64    }
65    fixtures
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use std::collections::BTreeMap;
72
73    #[test]
74    fn round_robin_properties() {
75        let clubs: Vec<ClubId> = (0..20).map(ClubId).collect();
76        let fixtures = double_round_robin(&clubs);
77        assert_eq!(fixtures.len(), 20 * 19);
78
79        // Every ordered pair appears exactly once (each pair home & away).
80        let mut seen: BTreeMap<(ClubId, ClubId), u32> = BTreeMap::new();
81        for f in &fixtures {
82            assert_ne!(f.home, f.away);
83            *seen.entry((f.home, f.away)).or_default() += 1;
84        }
85        assert!(seen.values().all(|&c| c == 1));
86        assert_eq!(seen.len(), 20 * 19);
87
88        // Each club plays exactly once per matchday.
89        for md in 1..=38u8 {
90            let mut playing = Vec::new();
91            for f in fixtures.iter().filter(|f| f.matchday == md) {
92                playing.push(f.home);
93                playing.push(f.away);
94            }
95            playing.sort();
96            playing.dedup();
97            assert_eq!(playing.len(), 20, "matchday {md}");
98        }
99    }
100}