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
128 changes: 128 additions & 0 deletions app/Domain/Formats/EntrantSlot.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

namespace App\Domain\Formats;

use App\Models\Stage;
use DomainException;

/**
* One entry slot in a knockout bracket, describing where its team comes
* from rather than who the team is. Stored on the knockout stage as
* stage.config['entrants'] — an ordered list where consecutive pairs
* (0,1), (2,3)… are the round-1 matchups, mirroring the generator's
* bracket_position layout so slot 2p is game p's home side and 2p+1 its
* away side.
*
* Two shapes:
* ['type' => 'group', 'group' => 'Group A', 'position' => 1]
* — the team finishing at `position` in the named group of the
* previous stage (1 = winner, 2 = runner-up, …).
* ['type' => 'best_placed', 'rank' => 2]
* — the rank-th team in the cross-group best-placed ranking (the
* BestPlacedCalculator table). Which concrete slot each best-placed
* team fills is refined by the allocation step when seeding.
*/
final readonly class EntrantSlot
{
private function __construct(
public string $type,
public ?string $group,
public ?int $position,
public ?int $rank,
) {
//
}

/**
* @param array<string, mixed> $raw
*/
public static function fromArray(array $raw): self
{
$type = $raw['type'] ?? null;

if ($type === 'group') {
$group = $raw['group'] ?? null;
$position = $raw['position'] ?? null;

if (! is_string($group) || $group === '' || ! is_numeric($position) || (int) $position < 1) {
throw new DomainException('A group entrant slot needs a group name and a position of at least 1.');
}

return new self('group', $group, (int) $position, null);
}

if ($type === 'best_placed') {
$rank = $raw['rank'] ?? null;

if (! is_numeric($rank) || (int) $rank < 1) {
throw new DomainException('A best-placed entrant slot needs a rank of at least 1.');
}

return new self('best_placed', null, null, (int) $rank);
}

throw new DomainException("Unknown entrant slot type [{$type}]; expected 'group' or 'best_placed'.");
}

/**
* The stage's configured entrant slots, in bracket order. Empty array
* when the stage has none configured.
*
* @return array<int, self>
*/
public static function listForStage(Stage $stage): array
{
$entrants = $stage->config['entrants'] ?? null;

if (! is_array($entrants) || $entrants === []) {
return [];
}

return array_map(
fn (array $raw): self => self::fromArray($raw),
array_values($entrants),
);
}

/**
* Human label for an unfilled bracket slot, e.g. "Winner Group A",
* "Runner-up Group B", "3rd Group C", "Best-placed #4".
*/
public function label(): string
{
if ($this->type === 'best_placed') {
return "Best-placed #{$this->rank}";
}

return match ($this->position) {
1 => "Winner {$this->group}",
2 => "Runner-up {$this->group}",
default => self::ordinal((int) $this->position)." {$this->group}",
};
}

/**
* @return array{type: string, group?: string, position?: int, rank?: int}
*/
public function toArray(): array
{
if ($this->type === 'best_placed') {
return ['type' => 'best_placed', 'rank' => (int) $this->rank];
}

return ['type' => 'group', 'group' => (string) $this->group, 'position' => (int) $this->position];
}

private static 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;
}
}
48 changes: 48 additions & 0 deletions app/Domain/Formats/SingleEliminationGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@
* Seeding: standard bracket seeding so the top two seeds can only meet in the
* final. Lowest seed (first in input) is strongest; phantom seeds beyond n are
* the byes and fall to the strongest real seeds.
*
* Entrant mode: when the stage declares config['entrants'] (slot descriptors
* like "Winner Group A" — see EntrantSlot), the bracket's shape comes from
* that list instead of the season's teams: every game is emitted as a TBD
* placeholder, and the seeding action fills round 1 from the previous stage's
* final standings. Consecutive descriptor pairs are the round-1 matchups, so
* the descriptor list *is* the pairing template.
*/
class SingleEliminationGenerator implements FixtureGenerator
{
Expand All @@ -38,6 +45,12 @@ class SingleEliminationGenerator implements FixtureGenerator
*/
public function generate(Stage $stage): Collection
{
$entrants = EntrantSlot::listForStage($stage);

if ($entrants !== []) {
return $this->generatePlaceholders($stage, count($entrants));
}

$teams = $stage->season->teams->values();
$n = $teams->count();

Expand Down Expand Up @@ -105,6 +118,41 @@ public function generate(Stage $stage): Collection
return $games;
}

/**
* Emit an all-TBD bracket sized to the entrant list: every game in every
* round carries null teams. Round 1 gets filled by the stage-seeding
* action; later rounds fill as winners advance.
*
* @return Collection<int, array{home_team_id: null, away_team_id: null, group_id: null, round: int, bracket_position: int}>
*/
private function generatePlaceholders(Stage $stage, int $capacity): Collection
{
if ($capacity < 2 || $capacity !== self::nextPowerOfTwo($capacity)) {
throw new DomainException(
"Stage [{$stage->id}] has {$capacity} entrant slots; a knockout bracket needs a power of two (2, 4, 8, 16, …)."
);
}

$rounds = (int) log($capacity, 2);
$games = collect();

for ($round = 1; $round <= $rounds; $round++) {
$slots = intdiv($capacity, 2 ** $round);

for ($position = 0; $position < $slots; $position++) {
$games->push([
'home_team_id' => null,
'away_team_id' => null,
'group_id' => null,
'round' => $round,
'bracket_position' => $position,
]);
}
}

return $games;
}

/**
* Standard bracket seed ordering for a 2^rounds-team bracket. Returns
* 1-based seeds laid out so consecutive pairs (0,1), (2,3)… are the
Expand Down
78 changes: 78 additions & 0 deletions app/Http/Controllers/StagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Http\Controllers;

use App\Actions\GenerateFixtures;
use App\Domain\Formats\EntrantSlot;
use App\Domain\Standings\BestPlacedCalculator;
use App\Domain\Standings\StandingsRegistry;
use App\Enums\StageFormat;
Expand Down Expand Up @@ -163,6 +164,8 @@ private function buildBracket(Stage $stage): ?array
return null;
}

$entrants = $this->entrantSlots($stage);

return $byRound
->map(fn ($games, $round) => [
'round' => (int) $round,
Expand All @@ -172,6 +175,8 @@ private function buildBracket(Stage $stage): ?array
'bracket_position' => $game->bracket_position,
'home_team' => $game->homeTeam?->only(['id', 'name', 'acronym']),
'away_team' => $game->awayTeam?->only(['id', 'name', 'acronym']),
'home_placeholder' => $this->slotPlaceholder($entrants, (int) $round, $game, 'home'),
'away_placeholder' => $this->slotPlaceholder($entrants, (int) $round, $game, 'away'),
'home_team_score' => $game->result?->home_team_score,
'away_team_score' => $game->result?->away_team_score,
'status' => $game->status->value,
Expand All @@ -182,6 +187,43 @@ private function buildBracket(Stage $stage): ?array
->all();
}

/**
* The stage's entrant slot descriptors, or [] when unset/invalid — a
* malformed config should degrade to plain TBD slots, not a 500.
*
* @return array<int, EntrantSlot>
*/
private function entrantSlots(Stage $stage): array
{
try {
return EntrantSlot::listForStage($stage);
} catch (DomainException) {
return [];
}
}

/**
* Descriptor label for an unfilled round-1 slot ("Winner Group A"),
* null once a real team occupies it or for rounds the descriptors
* don't cover.
*
* @param array<int, EntrantSlot> $entrants
*/
private function slotPlaceholder(array $entrants, int $round, Game $game, string $side): ?string
{
if ($round !== 1 || $entrants === []) {
return null;
}

if (($side === 'home' ? $game->home_team_id : $game->away_team_id) !== null) {
return null;
}

$slot = $entrants[2 * $game->bracket_position + ($side === 'home' ? 0 : 1)] ?? null;

return $slot?->label();
}

/**
* 'home' / 'away' once a game is decided, otherwise null.
*/
Expand Down Expand Up @@ -219,9 +261,45 @@ public function edit(League $league, Season $season, Stage $stage): Response
'season' => $season->only(['id', 'name']),
'stage' => $stage,
'formats' => $this->formatOptions(),
'sourceStage' => $stage->format->isBracket() ? $this->buildSourceStage($season, $stage) : null,
]);
}

/**
* The grouped stage that feeds this knockout stage — the nearest earlier
* stage (by order, then id) whose format has groups. Used by the entrant
* builder to offer "Winner Group A"-style slot options. Null when the
* season has no earlier grouped stage.
*
* @return null|array{id: int, name: string, advances_count: int, best_placed_count: int, groups: array<int, array{id: int, name: string}>}
*/
private function buildSourceStage(Season $season, Stage $stage): ?array
{
$source = $season->stages()
->where(fn ($q) => $q
->where('order', '<', $stage->order)
->orWhere(fn ($q2) => $q2->where('order', $stage->order)->where('id', '<', $stage->id)))
->whereIn('format', [StageFormat::GroupStage->value, StageFormat::Conference->value])
->orderByDesc('order')
->orderByDesc('id')
->with('groups:id,stage_id,name,order')
->first();

if ($source === null) {
return null;
}

return [
'id' => $source->id,
'name' => $source->name,
'advances_count' => $source->advances_count ?? 2,
'best_placed_count' => (int) ($source->config['best_placed_count'] ?? 0),
'groups' => $source->groups
->map(fn ($group) => ['id' => $group->id, 'name' => $group->name])
->all(),
];
}

public function update(UpdateStageRequest $request, League $league, Season $season, Stage $stage): RedirectResponse
{
$this->ensureSeasonInLeague($league, $season);
Expand Down
Loading
Loading