diff --git a/app/Actions/SeedStageFromGroups.php b/app/Actions/SeedStageFromGroups.php index 63b7fd8..35293a0 100644 --- a/app/Actions/SeedStageFromGroups.php +++ b/app/Actions/SeedStageFromGroups.php @@ -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; @@ -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 @@ -32,6 +39,7 @@ class SeedStageFromGroups public function __construct( private readonly StandingsRegistry $standings, private readonly BestPlacedCalculator $bestPlaced, + private readonly BestPlacedAllocator $allocator, ) { // } @@ -42,7 +50,7 @@ public function __construct( * @return array{ * source: array{id: int, name: string}, * source_complete: bool, - * slots: array, + * slots: array, * } */ public function preview(Stage $stage): array @@ -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 { @@ -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> $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 $entrants + * @param array|null> $slots + * @param array $bestPlacedIndices + * @return array> + */ + 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 $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; } } diff --git a/app/Domain/Formats/BestPlacedAllocator.php b/app/Domain/Formats/BestPlacedAllocator.php new file mode 100644 index 0000000..2ddbb2c --- /dev/null +++ b/app/Domain/Formats/BestPlacedAllocator.php @@ -0,0 +1,91 @@ + $teams qualifying thirds, best rank first + * @param array $slots best-placed bracket slots, in bracket order + * @return array 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 $teams + * @param array $slots + * @param array $used + * @return array|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; + } +} diff --git a/app/Http/Controllers/StagesController.php b/app/Http/Controllers/StagesController.php index d76175b..c7c2049 100644 --- a/app/Http/Controllers/StagesController.php +++ b/app/Http/Controllers/StagesController.php @@ -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, + * slots: array, * } */ private function buildSeeding(Stage $stage): ?array @@ -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' => [], ]; @@ -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.', ]; } diff --git a/resources/js/pages/Stages/Show.vue b/resources/js/pages/Stages/Show.vue index 3d4da79..3059fa9 100644 --- a/resources/js/pages/Stages/Show.vue +++ b/resources/js/pages/Stages/Show.vue @@ -116,6 +116,8 @@ interface SeedingSlot { label: string; team: { id: number; name: string; acronym: string } | null; error: string | null; + origin_group: string | null; + rematch: boolean; } interface Seeding { @@ -123,6 +125,7 @@ interface Seeding { source_complete: boolean; seeded: boolean; can_apply: boolean; + has_rematch: boolean; error: string | null; slots: SeedingSlot[]; } @@ -243,6 +246,15 @@ const seedingMatches = computed<{ home: SeedingSlot; away: SeedingSlot }[]>(() = return matches; }); +/** + * Descriptor hint under a seeded team: the slot label plus, for a pooled + * best-placed team, the group it actually came from (which the label no + * longer names once allocation has moved thirds around to avoid rematches). + */ +function slotHint(slot: SeedingSlot): string { + return slot.origin_group ? `${slot.label} · ${slot.origin_group}` : slot.label; +} + const seedForm = useForm({}); function applySeeding() { @@ -365,6 +377,10 @@ function deleteGroup(group: Group) { Not every game in {{ seeding.source.name }} has finished — these qualifiers can still change. You can seed now and re-apply later, as long as no knockout game has kicked off. +
+ A best-placed team could not avoid a side from its own group (marked below). This combination has no rematch-free layout — seed anyway or adjust the entrant slots. +
+

{{ seeding.error }}

{{ seedForm.errors.seeding }}

@@ -378,11 +394,13 @@ function deleteGroup(group: Group) { {{ match.home.team?.name ?? match.home.error ?? '—' }} - ({{ match.home.label }}) + ({{ slotHint(match.home) }}) + ⚠ rematch {{ match.away.team?.name ?? match.away.error ?? '—' }} - ({{ match.away.label }}) + ({{ slotHint(match.away) }}) + ⚠ rematch diff --git a/tests/Feature/Actions/SeedStageFromGroupsTest.php b/tests/Feature/Actions/SeedStageFromGroupsTest.php index dfe9986..73df567 100644 --- a/tests/Feature/Actions/SeedStageFromGroupsTest.php +++ b/tests/Feature/Actions/SeedStageFromGroupsTest.php @@ -117,10 +117,13 @@ function roundOneGames(Stage $knockout) $games = roundOneGames($knockout); + // Allocation keeps thirds out of their own group: Alpha One (Group A + // winner) faces Beta Two, not the Group-A third Alpha Two — which the + // naive rank-based placement would have produced as a rematch. expect($games[0]->home_team_id)->toBe($teams['Alpha One']->id); - expect($games[0]->away_team_id)->toBe($teams['Alpha Two']->id); + expect($games[0]->away_team_id)->toBe($teams['Beta Two']->id); expect($games[1]->home_team_id)->toBe($teams['Beta One']->id); - expect($games[1]->away_team_id)->toBe($teams['Beta Two']->id); + expect($games[1]->away_team_id)->toBe($teams['Alpha Two']->id); }); it('previews the resolution without writing anything', function () { @@ -132,8 +135,14 @@ function roundOneGames(Stage $knockout) expect($preview['source_complete'])->toBeTrue(); expect($preview['slots'][0]['label'])->toBe('Winner Group A'); expect($preview['slots'][0]['team']['name'])->toBe('Alpha One'); - expect($preview['slots'][3]['label'])->toBe('Best-placed #1'); - expect($preview['slots'][3]['team']['name'])->toBe('Beta Two'); + // Best-placed slots are a pooled, allocation-filled set: labelled by + // the placed position (advances_count 1 → 2nd) and tagged with the + // group each allocated team actually came from. + expect($preview['slots'][1]['label'])->toBe('Best 2nd-placed'); + expect($preview['slots'][1]['team']['name'])->toBe('Beta Two'); + expect($preview['slots'][1]['origin_group'])->toBe('Group B'); + expect($preview['slots'][1]['rematch'])->toBeFalse(); + expect($preview['slots'][3]['team']['name'])->toBe('Alpha Two'); expect(roundOneGames($knockout)->every(fn (Game $game) => $game->home_team_id === null))->toBeTrue(); }); @@ -156,8 +165,11 @@ function roundOneGames(Stage $knockout) $action->execute($knockout->fresh()); + // Alpha Two now wins Group A; Alpha One drops to the third-placed pool + // and is allocated away from the Group-A slot, so match 1 is + // Alpha Two v Beta Two. expect(roundOneGames($knockout)[0]->home_team_id)->toBe($teams['Alpha Two']->id); - expect(roundOneGames($knockout)[0]->away_team_id)->toBe($teams['Alpha One']->id); + expect(roundOneGames($knockout)[0]->away_team_id)->toBe($teams['Beta Two']->id); }); it('refuses to re-seed once a round-1 game has started', function () { diff --git a/tests/Unit/Formats/BestPlacedAllocatorTest.php b/tests/Unit/Formats/BestPlacedAllocatorTest.php new file mode 100644 index 0000000..fae3cfc --- /dev/null +++ b/tests/Unit/Formats/BestPlacedAllocatorTest.php @@ -0,0 +1,121 @@ + $id, 'name' => $name, 'acronym' => strtoupper(substr($name, 0, 3)), 'group' => $group]; +} + +/** + * @return array{index: int, opponent_group: string|null} + */ +function slot(int $index, ?string $opponentGroup): array +{ + return ['index' => $index, 'opponent_group' => $opponentGroup]; +} + +describe('BestPlacedAllocator', function () { + it('assigns teams to slots without drawing a team against its own group', function () { + $teams = [ + team(1, 'Alpha', 'Group A'), + team(2, 'Bravo', 'Group B'), + ]; + + // Slot 1 faces Group A, slot 3 faces Group B — so Alpha must go to + // slot 3 and Bravo to slot 1, the opposite of rank order. + $slots = [slot(1, 'Group A'), slot(3, 'Group B')]; + + $allocation = (new BestPlacedAllocator)->allocate($teams, $slots); + + expect($allocation[1]['team']['name'])->toBe('Bravo'); + expect($allocation[1]['rematch'])->toBeFalse(); + expect($allocation[3]['team']['name'])->toBe('Alpha'); + expect($allocation[3]['rematch'])->toBeFalse(); + }); + + it('keeps best rank in the earliest slot when no constraint forces otherwise', function () { + $teams = [ + team(1, 'Alpha', 'Group A'), + team(2, 'Bravo', 'Group B'), + ]; + + // Neither slot faces A or B, so rank order stands: Alpha first. + $slots = [slot(0, 'Group C'), slot(2, 'Group D')]; + + $allocation = (new BestPlacedAllocator)->allocate($teams, $slots); + + expect($allocation[0]['team']['name'])->toBe('Alpha'); + expect($allocation[2]['team']['name'])->toBe('Bravo'); + }); + + it('resolves a four-team combination avoiding every same-group clash', function () { + $teams = [ + team(1, 'Alpha', 'Group A'), + team(2, 'Bravo', 'Group B'), + team(3, 'Charlie', 'Group C'), + team(4, 'Delta', 'Group D'), + ]; + + $slots = [slot(0, 'Group A'), slot(1, 'Group B'), slot(2, 'Group C'), slot(3, 'Group D')]; + + $allocation = (new BestPlacedAllocator)->allocate($teams, $slots); + + foreach ($slots as $s) { + expect($allocation[$s['index']]['team']['group'])->not->toBe($s['opponent_group']); + expect($allocation[$s['index']]['rematch'])->toBeFalse(); + } + + // Every team placed exactly once. + $names = collect($allocation)->pluck('team.name'); + expect($names->unique())->toHaveCount(4); + }); + + it('ignores slots with no known opponent group', function () { + $teams = [team(1, 'Alpha', 'Group A'), team(2, 'Bravo', 'Group B')]; + $slots = [slot(0, null), slot(1, null)]; + + $allocation = (new BestPlacedAllocator)->allocate($teams, $slots); + + expect($allocation[0]['team']['name'])->toBe('Alpha'); + expect($allocation[0]['rematch'])->toBeFalse(); + expect($allocation[1]['team']['name'])->toBe('Bravo'); + }); + + it('flags an unavoidable rematch instead of failing', function () { + // Two teams from Group A, and both slots face Group A — no layout + // avoids a rematch, so one is placed and flagged. + $teams = [team(1, 'Alpha', 'Group A'), team(2, 'Alpha II', 'Group A')]; + $slots = [slot(0, 'Group A'), slot(1, 'Group B')]; + + $allocation = (new BestPlacedAllocator)->allocate($teams, $slots); + + expect($allocation[0]['rematch'])->toBeTrue(); + expect($allocation[1]['rematch'])->toBeFalse(); + // Both teams still placed. + expect(collect($allocation)->pluck('team.id')->unique())->toHaveCount(2); + }); + + it('is deterministic across runs', function () { + $teams = [ + team(1, 'Alpha', 'Group A'), + team(2, 'Bravo', 'Group B'), + team(3, 'Charlie', 'Group C'), + team(4, 'Delta', 'Group D'), + ]; + $slots = [slot(0, 'Group D'), slot(1, 'Group A'), slot(2, 'Group B'), slot(3, 'Group C')]; + + $allocator = new BestPlacedAllocator; + $first = $allocator->allocate($teams, $slots); + $second = $allocator->allocate($teams, $slots); + + expect(collect($first)->map(fn ($a) => $a['team']['id'])->all()) + ->toBe(collect($second)->map(fn ($a) => $a['team']['id'])->all()); + }); +});