134 lines · 4.5 KB
Raw Download
1
<?php
2
/**
3
 * Tournament data loading and simulation.
4
 *
5
 * A "team" is ['seed' => int, 'name' => string] and flows through the whole
6
 * simulation unchanged, so every round knows the seed of whoever advanced.
7
 */
8
9
const DATA_DIR = __DIR__ . '/../data';
10
11
/** Order of seeds down one region, top to bottom. Fixed by the NCAA; never changes. */
12
const SEED_ORDER = [1, 16, 8, 9, 5, 12, 4, 13, 6, 11, 3, 14, 7, 10, 2, 15];
13
14
/**
15
 * [top, bottom] win weights, indexed by round then game-within-region.
16
 *
17
 * Counted from 1999-2018 results. These were previously duplicated per region
18
 * and had drifted apart; South, West and Midwest agreed with each other in
19
 * every game, so their values are the ones kept here.
20
 */
21
const WEIGHTS = [
22
    // First round: 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15
23
    1 => [[20, 1], [13, 7], [13, 7], [16, 4], [11, 9], [19, 1], [14, 6], [18, 2]],
24
    2 => [[15, 5], [9, 11], [9, 11], [8, 12]],   // round of 32
25
    3 => [[15, 5], [7, 13]],                     // sweet 16
26
    // TODO: 12:8 is an estimate (~60:40 toward the half holding the 1 seed),
27
    // not a recount of the source data. The old per-region values were East
28
    // 17:3 (too steep for an Elite Eight game) and 1:1 elsewhere (a coin flip
29
    // that ignores seeding entirely), so neither could be carried over.
30
    4 => [[12, 8]],                              // elite 8
31
    5 => [[1, 1]],                               // final 4
32
    6 => [[1, 1]],                               // national championship
33
];
34
35
/** Years with a data file, newest first. */
36
function bracket_years(): array
37
{
38
    $years = [];
39
    foreach (glob(DATA_DIR . '/*.json') as $path) {
40
        $years[] = (int) basename($path, '.json');
41
    }
42
    rsort($years);
43
    return $years;
44
}
45
46
/**
47
 * Load a year's data, falling back to the newest year. $requested comes
48
 * straight off the query string, so it may be any type and is matched against
49
 * the known years rather than interpolated into a path.
50
 */
51
function bracket_load(mixed $requested = null): array
52
{
53
    $years = bracket_years();
54
    if (!$years) {
55
        throw new RuntimeException('No tournament data found in ' . DATA_DIR);
56
    }
57
58
    $wanted = is_scalar($requested) ? (int) $requested : 0;
59
    $year   = in_array($wanted, $years, true) ? $wanted : $years[0];
60
    $data = json_decode(file_get_contents(DATA_DIR . "/$year.json"), true);
61
    if (!is_array($data)) {
62
        throw new RuntimeException("data/$year.json is not valid JSON");
63
    }
64
    return $data;
65
}
66
67
/** Pick a winner, weighted. Replaces the old array_fill/array_merge shuffle. */
68
function pick(array $top, array $bottom, int $wTop, int $wBottom): array
69
{
70
    return mt_rand(1, $wTop + $wBottom) <= $wTop ? $top : $bottom;
71
}
72
73
/** One played game. */
74
function game(array $top, array $bottom, int $wTop, int $wBottom): array
75
{
76
    return ['top' => $top, 'bottom' => $bottom, 'winner' => pick($top, $bottom, $wTop, $wBottom)];
77
}
78
79
/** The team that lost a game. */
80
function loser(array $game): array
81
{
82
    return $game['winner'] === $game['top'] ? $game['bottom'] : $game['top'];
83
}
84
85
/**
86
 * Play one region. Returns 0 => the 16 seeded teams in bracket order,
87
 * then 1..4 => the winners of each round.
88
 */
89
function simulate_region(array $teams): array
90
{
91
    $current = array_map(
92
        fn(int $seed) => ['seed' => $seed, 'name' => $teams[$seed]],
93
        SEED_ORDER
94
    );
95
    $rounds = [0 => $current];
96
97
    for ($r = 1; $r <= 4; $r++) {
98
        $next = [];
99
        foreach (array_chunk($current, 2) as $g => [$top, $bottom]) {
100
            $next[] = pick($top, $bottom, ...WEIGHTS[$r][$g]);
101
        }
102
        $rounds[$r] = $current = $next;
103
    }
104
    return $rounds;
105
}
106
107
/**
108
 * Play the whole tournament. Region keys are bracket positions:
109
 * TL/BL are the left half, TR/BR the right half.
110
 */
111
function simulate_tournament(array $data): array
112
{
113
    $regions = [];
114
    foreach ($data['regions'] as $key => $region) {
115
        $regions[$key] = [
116
            'label'  => $region['label'],
117
            'rounds' => simulate_region($region['teams']),
118
        ];
119
    }
120
121
    $semiLeft  = game($regions['TL']['rounds'][4][0], $regions['BL']['rounds'][4][0], ...WEIGHTS[5][0]);
122
    $semiRight = game($regions['TR']['rounds'][4][0], $regions['BR']['rounds'][4][0], ...WEIGHTS[5][0]);
123
    $final     = game($semiLeft['winner'], $semiRight['winner'], ...WEIGHTS[6][0]);
124
125
    return [
126
        'regions'  => $regions,
127
        'national' => [5 => [$semiLeft, $semiRight], 6 => [$final]],
128
        'left'     => $semiLeft['winner'],
129
        'right'    => $semiRight['winner'],
130
        'champion' => $final['winner'],
131
        'runnerUp' => loser($final),
132
    ];
133
}
134