diff --git a/app/Domain/Formats/EntrantSlot.php b/app/Domain/Formats/EntrantSlot.php new file mode 100644 index 0000000..5c65a34 --- /dev/null +++ b/app/Domain/Formats/EntrantSlot.php @@ -0,0 +1,128 @@ + '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 $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 + */ + 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; + } +} diff --git a/app/Domain/Formats/SingleEliminationGenerator.php b/app/Domain/Formats/SingleEliminationGenerator.php index a0bbc76..ad2933e 100644 --- a/app/Domain/Formats/SingleEliminationGenerator.php +++ b/app/Domain/Formats/SingleEliminationGenerator.php @@ -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 { @@ -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(); @@ -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 + */ + 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 diff --git a/app/Http/Controllers/StagesController.php b/app/Http/Controllers/StagesController.php index 3263b2c..856fd7e 100644 --- a/app/Http/Controllers/StagesController.php +++ b/app/Http/Controllers/StagesController.php @@ -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; @@ -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, @@ -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, @@ -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 + */ + 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 $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. */ @@ -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} + */ + 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); diff --git a/app/Http/Requests/StoreStageRequest.php b/app/Http/Requests/StoreStageRequest.php index c37fae8..873852f 100644 --- a/app/Http/Requests/StoreStageRequest.php +++ b/app/Http/Requests/StoreStageRequest.php @@ -2,8 +2,11 @@ namespace App\Http\Requests; +use App\Domain\Formats\EntrantSlot; use App\Enums\StageFormat; use App\Models\Season; +use Closure; +use DomainException; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -42,9 +45,64 @@ public function rules(): array 'config' => ['nullable', 'array'], 'config.legs_per_group' => ['sometimes', 'integer', 'in:1,2'], 'config.best_placed_count' => ['sometimes', 'integer', 'min:1', 'max:16'], + 'config.entrants' => ['sometimes', 'array', self::entrantsRule()], ]; } + /** + * Shared shape check for config.entrants: a power-of-two-sized list of + * valid slot descriptors (see EntrantSlot). + */ + public static function entrantsRule(): Closure + { + return function (string $attribute, mixed $value, Closure $fail): void { + if (! is_array($value)) { + return; + } + + $count = count($value); + + if ($count < 2 || ($count & ($count - 1)) !== 0) { + $fail("Entrant slots must total a power of two (2, 4, 8, 16, …) to form a bracket; got {$count}."); + + return; + } + + foreach (array_values($value) as $index => $slot) { + try { + EntrantSlot::fromArray(is_array($slot) ? $slot : []); + } catch (DomainException $e) { + $fail('Slot '.($index + 1).': '.$e->getMessage()); + + return; + } + } + }; + } + + /** + * Canonicalize an incoming entrants list (string ints → ints, dropped + * extraneous keys) while leaving invalid slots untouched for the + * validator to reject with a useful message. + * + * @param array $entrants + * @return array + */ + public static function normalizeEntrants(array $entrants): array + { + return array_map(function (mixed $slot): mixed { + if (! is_array($slot)) { + return $slot; + } + + try { + return EntrantSlot::fromArray($slot)->toArray(); + } catch (DomainException) { + return $slot; + } + }, array_values($entrants)); + } + protected function prepareForValidation(): void { $this->merge([ @@ -73,6 +131,12 @@ protected function prepareForValidation(): void unset($config['best_placed_count']); } + if (! empty($config['entrants']) && is_array($config['entrants']) && in_array($format, ['single_elimination', 'double_elimination'], true)) { + $config['entrants'] = self::normalizeEntrants($config['entrants']); + } else { + unset($config['entrants']); + } + $this->merge([ 'config' => empty($config) ? null : $config, ]); diff --git a/app/Http/Requests/UpdateStageRequest.php b/app/Http/Requests/UpdateStageRequest.php index 2da19f5..8dbae1a 100644 --- a/app/Http/Requests/UpdateStageRequest.php +++ b/app/Http/Requests/UpdateStageRequest.php @@ -44,6 +44,7 @@ public function rules(): array 'config' => ['nullable', 'array'], 'config.legs_per_group' => ['sometimes', 'integer', 'in:1,2'], 'config.best_placed_count' => ['sometimes', 'integer', 'min:1', 'max:16'], + 'config.entrants' => ['sometimes', 'array', StoreStageRequest::entrantsRule()], ]; } @@ -69,6 +70,12 @@ protected function prepareForValidation(): void unset($config['best_placed_count']); } + if (! empty($config['entrants']) && is_array($config['entrants']) && in_array($format, ['single_elimination', 'double_elimination'], true)) { + $config['entrants'] = StoreStageRequest::normalizeEntrants($config['entrants']); + } else { + unset($config['entrants']); + } + $this->merge([ 'config' => empty($config) ? null : $config, ]); diff --git a/resources/js/components/Bracket.vue b/resources/js/components/Bracket.vue index 4a71207..afc7246 100644 --- a/resources/js/components/Bracket.vue +++ b/resources/js/components/Bracket.vue @@ -13,6 +13,8 @@ interface BracketGame { bracket_position: number; home_team: TeamRef | null; away_team: TeamRef | null; + home_placeholder: string | null; + away_placeholder: string | null; home_team_score: number | null; away_team_score: number | null; status: string; @@ -50,14 +52,18 @@ defineProps<{ class="flex items-center justify-between gap-2 border-b px-3 py-1.5 text-sm" :class="game.winner === 'home' ? 'font-semibold' : 'text-muted-foreground'" > - {{ game.home_team?.name ?? 'TBD' }} + + {{ game.home_team?.name ?? game.home_placeholder ?? 'TBD' }} + {{ game.home_team_score ?? '–' }}
- {{ game.away_team?.name ?? 'TBD' }} + + {{ game.away_team?.name ?? game.away_placeholder ?? 'TBD' }} + {{ game.away_team_score ?? '–' }}
diff --git a/resources/js/pages/Stages/Edit.vue b/resources/js/pages/Stages/Edit.vue index a297034..01f101e 100644 --- a/resources/js/pages/Stages/Edit.vue +++ b/resources/js/pages/Stages/Edit.vue @@ -1,6 +1,6 @@