Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 138 additions & 36 deletions app/Actions/SeedStageFromGroups.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace App\Actions;

use App\Domain\Formats\BestPlacedAllocator;
use App\Domain\Formats\EntrantSlot;
use App\Domain\Standings\BestPlacedCalculator;
use App\Domain\Standings\StandingRow;
use App\Domain\Standings\StandingsRegistry;
use App\Enums\GameStatus;
use App\Models\Game;
Expand All @@ -17,10 +19,15 @@
* final standings, following the stage's entrant slot descriptors — the
* stage-to-stage analog of AdvanceBracketWinner.
*
* Group slots ("Winner Group A") resolve independently against their group
* table. The best-placed slots are resolved as a pool: the top-N qualifying
* thirds are assigned to those slots by BestPlacedAllocator so none is drawn
* against a side from its own group (see FIFA/UEFA third-placed allocation).
*
* preview() resolves every slot to a concrete team (or an explanation of
* why it can't) without touching the database, so the admin can review the
* seeding before confirming. execute() runs the same resolution and writes
* the teams inside a transaction.
* seeding — including any unavoidable rematch — before confirming.
* execute() runs the same resolution and writes the teams in a transaction.
*
* Idempotent: re-running after a result correction overwrites round-1
* teams with the (possibly different) qualifiers — but only while no
Expand All @@ -32,6 +39,7 @@ class SeedStageFromGroups
public function __construct(
private readonly StandingsRegistry $standings,
private readonly BestPlacedCalculator $bestPlaced,
private readonly BestPlacedAllocator $allocator,
) {
//
}
Expand All @@ -42,7 +50,7 @@ public function __construct(
* @return array{
* source: array{id: int, name: string},
* source_complete: bool,
* slots: array<int, array{label: string, team: array{id: int, name: string, acronym: string}|null, error: string|null}>,
* slots: array<int, array{label: string, team: array{id: int, name: string, acronym: string}|null, error: string|null, origin_group: string|null, rematch: bool}>,
* }
*/
public function preview(Stage $stage): array
Expand All @@ -61,27 +69,41 @@ public function preview(Stage $stage): array

$source->load(['groups.teams', 'games.result', 'season.teams']);

$resolver = $this->makeResolver($source);
$calculator = $this->standings->for($source->format);
$groupTables = [];

// First pass: resolve every group slot; note the best-placed slots to
// resolve together afterwards (their placement depends on each other).
$slots = [];
$bestPlacedIndices = [];

foreach ($entrants as $index => $entrant) {
if ($entrant->type === 'best_placed') {
$bestPlacedIndices[] = $index;
$slots[$index] = null;

continue;
}

$slots[$index] = $this->resolveGroupSlot($source, $calculator, $groupTables, $entrant);
}

if ($bestPlacedIndices !== []) {
$slots = $this->resolveBestPlacedPool($source, $entrants, $slots, $bestPlacedIndices);
}

return [
'source' => ['id' => $source->id, 'name' => $source->name],
'source_complete' => $source->games->isNotEmpty()
&& $source->games->every(fn (Game $game) => $game->status->isFinal()),
'slots' => array_map(function (EntrantSlot $slot) use ($resolver): array {
try {
$team = $resolver($slot);

return ['label' => $slot->label(), 'team' => $team, 'error' => null];
} catch (DomainException $e) {
return ['label' => $slot->label(), 'team' => null, 'error' => $e->getMessage()];
}
}, $entrants),
'slots' => array_values($slots),
];
}

/**
* Resolve and write the round-1 teams. Throws when any slot can't be
* resolved or the bracket is no longer safe to overwrite.
* resolved or the bracket is no longer safe to overwrite. An unavoidable
* rematch is surfaced in the preview but does not block seeding.
*/
public function execute(Stage $stage): void
{
Expand Down Expand Up @@ -124,40 +146,120 @@ public function execute(Stage $stage): void
}

/**
* Build a closure resolving one EntrantSlot to a team array, with the
* per-group standings and the best-placed ranking computed lazily and
* cached across slots.
* Resolve one "Winner Group A"-style slot against its group table.
*
* @return callable(EntrantSlot): array{id: int, name: string, acronym: string}
* @param array<int, Collection<int, StandingRow>> $groupTables
* @return array{label: string, team: array{id: int, name: string, acronym: string}|null, error: string|null, origin_group: string|null, rematch: bool}
*/
private function makeResolver(Stage $source): callable
private function resolveGroupSlot(Stage $source, $calculator, array &$groupTables, EntrantSlot $slot): array
{
$calculator = $this->standings->for($source->format);
$groupTables = [];
$bestPlacedRows = null;
$base = ['label' => $slot->label(), 'team' => null, 'error' => null, 'origin_group' => null, 'rematch' => false];

$group = $source->groups->firstWhere('name', $slot->group);

if ($group === null) {
return [...$base, 'error' => "no group named \"{$slot->group}\" exists in {$source->name}."];
}

$groupTables[$group->id] ??= $calculator->calculate($source, $group);
$table = $groupTables[$group->id];

$row = $table->get($slot->position - 1);

if ($row === null) {
return [...$base, 'error' => "{$slot->group} has only {$table->count()} teams."];
}

return function (EntrantSlot $slot) use ($source, $calculator, &$groupTables, &$bestPlacedRows): array {
if ($slot->type === 'best_placed') {
$bestPlacedRows ??= $this->bestPlaced->calculate($source, ($source->advances_count ?? 2) + 1);
return [...$base, 'team' => ['id' => $row->team_id, 'name' => $row->team_name, 'acronym' => $row->team_acronym]];
}

/**
* Resolve the best-placed slots together: take the top-N qualifying
* thirds and let the allocator assign them to the N slots avoiding
* same-group rematches.
*
* @param array<int, EntrantSlot> $entrants
* @param array<int, array<string, mixed>|null> $slots
* @param array<int, int> $bestPlacedIndices
* @return array<int, array<string, mixed>>
*/
private function resolveBestPlacedPool(Stage $source, array $entrants, array $slots, array $bestPlacedIndices): array
{
$position = ($source->advances_count ?? 2) + 1;
$label = 'Best '.$this->ordinal($position).'-placed';
$need = count($bestPlacedIndices);

$row = $bestPlacedRows->get($slot->rank - 1)
?? throw new DomainException("the best-placed ranking only has {$bestPlacedRows->count()} teams.");
$ranking = $this->bestPlaced->calculate($source, $position);

return ['id' => $row->row->team_id, 'name' => $row->row->team_name, 'acronym' => $row->row->team_acronym];
if ($ranking->count() < $need) {
foreach ($bestPlacedIndices as $index) {
$slots[$index] = [
'label' => $label,
'team' => null,
'error' => "only {$ranking->count()} best-placed teams are available for {$need} slots.",
'origin_group' => null,
'rematch' => false,
];
}

$group = $source->groups->firstWhere('name', $slot->group)
?? throw new DomainException("no group named \"{$slot->group}\" exists in {$source->name}.");
return $slots;
}

$teams = $ranking->take($need)
->map(fn ($row): array => [
'id' => $row->row->team_id,
'name' => $row->row->team_name,
'acronym' => $row->row->team_acronym,
'group' => $row->group_name,
])
->all();

$allocationSlots = array_map(fn (int $index): array => [
'index' => $index,
'opponent_group' => $this->opponentGroup($entrants, $index),
], $bestPlacedIndices);

$allocation = $this->allocator->allocate($teams, $allocationSlots);

$groupTables[$group->id] ??= $calculator->calculate($source, $group);
foreach ($bestPlacedIndices as $index) {
$team = $allocation[$index]['team'];
$slots[$index] = [
'label' => $label,
'team' => ['id' => $team['id'], 'name' => $team['name'], 'acronym' => $team['acronym']],
'error' => null,
'origin_group' => $team['group'],
'rematch' => $allocation[$index]['rematch'],
];
}

return $slots;
}

/** @var Collection $table */
$table = $groupTables[$group->id];
/**
* The group a best-placed slot's round-1 opponent comes from, for
* rematch avoidance. Round-1 partners are consecutive entrants, so the
* partner is the sibling index (i xor 1). Null when that partner is
* itself a best-placed slot (its group isn't known before allocation).
*
* @param array<int, EntrantSlot> $entrants
*/
private function opponentGroup(array $entrants, int $index): ?string
{
$opponent = $entrants[$index ^ 1] ?? null;

$row = $table->get($slot->position - 1)
?? throw new DomainException("{$slot->group} has only {$table->count()} teams.");
return $opponent !== null && $opponent->type === 'group' ? $opponent->group : null;
}

return ['id' => $row->team_id, 'name' => $row->team_name, 'acronym' => $row->team_acronym];
private function ordinal(int $n): string
{
$suffix = match (true) {
$n % 100 >= 11 && $n % 100 <= 13 => 'th',
$n % 10 === 1 => 'st',
$n % 10 === 2 => 'nd',
$n % 10 === 3 => 'rd',
default => 'th',
};

return $n.$suffix;
}
}
91 changes: 91 additions & 0 deletions app/Domain/Formats/BestPlacedAllocator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace App\Domain\Formats;

/**
* Assigns qualifying best-placed (e.g. third-placed) teams to the knockout
* slots reserved for them so that no team is drawn against a side from its
* own group — the job FIFA/UEFA hand-solve with a published allocation
* table per group combination.
*
* Rather than store those lookup tables, this finds a rematch-free
* assignment directly with a backtracking bipartite match: slots are filled
* in bracket order, teams tried in ranking order, so the result is
* deterministic and tends to keep the strongest thirds in the earliest
* slots. Any group combination that admits a rematch-free layout (all the
* ones FIFA's tables cover, and more) resolves here too.
*
* When no rematch-free assignment exists — a lopsided combination, or a
* third-vs-third slot whose opponent group can't be known ahead of the
* allocation — the leftover teams are placed in order and flagged as
* rematches, so the caller can surface them rather than fail.
*/
class BestPlacedAllocator
{
/**
* @param array<int, array{id: int, name: string, acronym: string, group: string}> $teams qualifying thirds, best rank first
* @param array<int, array{index: int, opponent_group: string|null}> $slots best-placed bracket slots, in bracket order
* @return array<int, array{team: array{id: int, name: string, acronym: string, group: string}, rematch: bool}> keyed by the slot's entrant index
*/
public function allocate(array $teams, array $slots): array
{
$teams = array_values($teams);
$slots = array_values($slots);

$order = $this->solve($teams, $slots, 0, []);

// No fully rematch-free layout: fall back to rank-order placement and
// flag every slot whose team ends up facing its own group.
if ($order === null) {
$order = array_keys($teams);
}

$allocation = [];

foreach ($slots as $position => $slot) {
$team = $teams[$order[$position]];
$allocation[$slot['index']] = [
'team' => $team,
'rematch' => $slot['opponent_group'] !== null && $slot['opponent_group'] === $team['group'],
];
}

return $allocation;
}

/**
* Backtracking search for a rematch-free team ordering: returns an array
* mapping slot position → team index, or null when none exists.
*
* @param array<int, array{id: int, name: string, acronym: string, group: string}> $teams
* @param array<int, array{index: int, opponent_group: string|null}> $slots
* @param array<int, bool> $used
* @return array<int, int>|null
*/
private function solve(array $teams, array $slots, int $position, array $used): ?array
{
if ($position === count($slots)) {
return [];
}

$opponentGroup = $slots[$position]['opponent_group'];

foreach ($teams as $teamIndex => $team) {
if (isset($used[$teamIndex])) {
continue;
}

if ($opponentGroup !== null && $team['group'] === $opponentGroup) {
continue;
}

$rest = $this->solve($teams, $slots, $position + 1, $used + [$teamIndex => true]);

if ($rest !== null) {
return [$position => $teamIndex] + $rest;
}
}

return null;
}
}
5 changes: 4 additions & 1 deletion app/Http/Controllers/StagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ public function show(League $league, Season $season, Stage $stage, StandingsRegi
* source_complete: bool,
* seeded: bool,
* can_apply: bool,
* has_rematch: bool,
* error: string|null,
* slots: array<int, array{label: string, team: array{id: int, name: string, acronym: string}|null, error: string|null}>,
* slots: array<int, array{label: string, team: array{id: int, name: string, acronym: string}|null, error: string|null, origin_group: string|null, rematch: bool}>,
* }
*/
private function buildSeeding(Stage $stage): ?array
Expand All @@ -116,6 +117,7 @@ private function buildSeeding(Stage $stage): ?array
'source_complete' => false,
'seeded' => false,
'can_apply' => false,
'has_rematch' => false,
'error' => $e->getMessage(),
'slots' => [],
];
Expand All @@ -128,6 +130,7 @@ private function buildSeeding(Stage $stage): ?array
...$preview,
'seeded' => $roundOne->contains(fn (Game $game) => $game->home_team_id !== null || $game->away_team_id !== null),
'can_apply' => $allResolved && $allScheduled,
'has_rematch' => collect($preview['slots'])->contains(fn (array $slot) => $slot['rematch']),
'error' => $allScheduled ? null : 'A round-1 game has already started; the bracket can no longer be re-seeded.',
];
}
Expand Down
Loading
Loading