int, 'name' => string] and flows through the whole * simulation unchanged, so every round knows the seed of whoever advanced. */ const DATA_DIR = __DIR__ . '/../data'; /** Order of seeds down one region, top to bottom. Fixed by the NCAA; never changes. */ const SEED_ORDER = [1, 16, 8, 9, 5, 12, 4, 13, 6, 11, 3, 14, 7, 10, 2, 15]; /** * [top, bottom] win weights, indexed by round then game-within-region. * * Counted from 1999-2018 results. These were previously duplicated per region * and had drifted apart; South, West and Midwest agreed with each other in * every game, so their values are the ones kept here. */ const WEIGHTS = [ // First round: 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15 1 => [[20, 1], [13, 7], [13, 7], [16, 4], [11, 9], [19, 1], [14, 6], [18, 2]], 2 => [[15, 5], [9, 11], [9, 11], [8, 12]], // round of 32 3 => [[15, 5], [7, 13]], // sweet 16 // TODO: 12:8 is an estimate (~60:40 toward the half holding the 1 seed), // not a recount of the source data. The old per-region values were East // 17:3 (too steep for an Elite Eight game) and 1:1 elsewhere (a coin flip // that ignores seeding entirely), so neither could be carried over. 4 => [[12, 8]], // elite 8 5 => [[1, 1]], // final 4 6 => [[1, 1]], // national championship ]; /** Years with a data file, newest first. */ function bracket_years(): array { $years = []; foreach (glob(DATA_DIR . '/*.json') as $path) { $years[] = (int) basename($path, '.json'); } rsort($years); return $years; } /** * Load a year's data, falling back to the newest year. $requested comes * straight off the query string, so it may be any type and is matched against * the known years rather than interpolated into a path. */ function bracket_load(mixed $requested = null): array { $years = bracket_years(); if (!$years) { throw new RuntimeException('No tournament data found in ' . DATA_DIR); } $wanted = is_scalar($requested) ? (int) $requested : 0; $year = in_array($wanted, $years, true) ? $wanted : $years[0]; $data = json_decode(file_get_contents(DATA_DIR . "/$year.json"), true); if (!is_array($data)) { throw new RuntimeException("data/$year.json is not valid JSON"); } return $data; } /** Pick a winner, weighted. Replaces the old array_fill/array_merge shuffle. */ function pick(array $top, array $bottom, int $wTop, int $wBottom): array { return mt_rand(1, $wTop + $wBottom) <= $wTop ? $top : $bottom; } /** One played game. */ function game(array $top, array $bottom, int $wTop, int $wBottom): array { return ['top' => $top, 'bottom' => $bottom, 'winner' => pick($top, $bottom, $wTop, $wBottom)]; } /** The team that lost a game. */ function loser(array $game): array { return $game['winner'] === $game['top'] ? $game['bottom'] : $game['top']; } /** * Play one region. Returns 0 => the 16 seeded teams in bracket order, * then 1..4 => the winners of each round. */ function simulate_region(array $teams): array { $current = array_map( fn(int $seed) => ['seed' => $seed, 'name' => $teams[$seed]], SEED_ORDER ); $rounds = [0 => $current]; for ($r = 1; $r <= 4; $r++) { $next = []; foreach (array_chunk($current, 2) as $g => [$top, $bottom]) { $next[] = pick($top, $bottom, ...WEIGHTS[$r][$g]); } $rounds[$r] = $current = $next; } return $rounds; } /** * Play the whole tournament. Region keys are bracket positions: * TL/BL are the left half, TR/BR the right half. */ function simulate_tournament(array $data): array { $regions = []; foreach ($data['regions'] as $key => $region) { $regions[$key] = [ 'label' => $region['label'], 'rounds' => simulate_region($region['teams']), ]; } $semiLeft = game($regions['TL']['rounds'][4][0], $regions['BL']['rounds'][4][0], ...WEIGHTS[5][0]); $semiRight = game($regions['TR']['rounds'][4][0], $regions['BR']['rounds'][4][0], ...WEIGHTS[5][0]); $final = game($semiLeft['winner'], $semiRight['winner'], ...WEIGHTS[6][0]); return [ 'regions' => $regions, 'national' => [5 => [$semiLeft, $semiRight], 6 => [$final]], 'left' => $semiLeft['winner'], 'right' => $semiRight['winner'], 'champion' => $final['winner'], 'runnerUp' => loser($final), ]; }