feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
import { eq, and } from "drizzle-orm";
|
|
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
|
import * as schema from "~/database/schema";
|
2026-03-15 21:52:47 -07:00
|
|
|
|
import type { BracketTemplate, BracketRegion } from "~/lib/bracket-templates";
|
|
|
|
|
|
import {
|
|
|
|
|
|
getBracketTemplate,
|
|
|
|
|
|
buildNCAA68SlotMap,
|
|
|
|
|
|
matchIndexForSeedSlot,
|
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.
Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.
The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.
Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.
Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.
Also:
- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
hardcoded a 5th-place floor for winners of non-scoring rounds feeding
a scoring one, which is wrong inside a losers bracket where a win can
guarantee only 7th. Opt-in, so no existing template changes behavior.
- Fixes TabbedBracketLayout's mobile path, which built its match map
unfiltered and so would have merged U.S. and International games into
one column. No-op for NCAA and NBA, whose groups already cover every
match in their phases.
- Rewrites the LLWS Monte Carlo simulator, which still modelled the
retired pool-play format (5 teams per pool, then a 4-team bracket per
side) and no longer described the tournament being scored. It now runs
the real 10-team double elimination and splits the 5–8 probabilities
into the correct tiers instead of one even four-way split. Legacy
"US:A"/"Intl:B" externalIds are still accepted, read as the side
alone, so seasons configured for the old format keep loading.
Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
|
|
|
|
llwsMatchNumber,
|
|
|
|
|
|
llwsSideAndLocal,
|
2026-03-15 21:52:47 -07:00
|
|
|
|
STANDARD_BRACKET_SEEDING,
|
|
|
|
|
|
} from "~/lib/bracket-templates";
|
Lay out brackets from the feeder graph
The LLWS bracket didn't read as a bracket: cards sat above games that
don't feed them, connectors joined the wrong pairs, and several games had
no line at all.
The stored data was correct — LLWS_ADVANCEMENT already matches the
official 2026 LLBWS bracket game for game. The renderer was the problem.
TreeColumns placed cards at `index * (height / roundSize)` and
ConnectorColumn assumed matches 2k and 2k+1 feed match k, which holds
only for an exact halving. The LLWS winners bracket is not one: two of
the four Opening Round games skip Winners Round 2 and go straight to the
semifinals, so those two got stranded in column one with nothing beside
them, and the halving branch drew confident, wrong connectors for the
rest.
Lay out from the graph instead. app/lib/bracket-layout.ts inverts a
template's advancement into "what fills each slot", then assigns columns
by depth from the group's final, orders each column by the parent's slot
order, and centres each card on its feeders. Counting back from the final
is what makes a printed bracket line up: a team entering late is drawn in
the column where it actually plays. This reproduces the official
International bracket exactly, and fixes Elimination Round 3, where the
official bracket prints the later game on top but match-number sort put
it below.
Because column is depth, every in-group edge spans exactly one gutter, so
connectors now draw for unplayed games too. Cards also take a fixed
height rather than stretching to fill their column, which is what made a
lone final tower over the rest.
Empty slots name their source — "Loser of Winners SF 1" rather than
"TBD". That is the only way to show the feeds crossing between the
winners and elimination brackets, which render as separate trees.
Also:
- Move the LLWS routing table to app/lib/llws-bracket.ts so the renderer
can import it without pulling the database context into the browser
bundle; models/playoff-match re-exports it.
- Page the mobile view one group at a time, matching desktop. A whole
double-elimination phase is a DAG, not a tree, so its columns would be
arbitrary.
- Add a clear-bracket admin action. Nothing else could rewrite a match's
participants, so a mis-seeded bracket had no repair path at all.
- Lift the PDF transcription into app/test/fixtures/llws-bracket.ts so the
routing and layout tests check against one copy of the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 17:50:59 +00:00
|
|
|
|
import {
|
|
|
|
|
|
LLWS_LOSER_ADVANCES_ROUNDS,
|
|
|
|
|
|
resolveLLWSAdvancement,
|
|
|
|
|
|
type LLWSResolvedDestination,
|
|
|
|
|
|
} from "~/lib/llws-bracket";
|
2026-09-04 15:13:13 +00:00
|
|
|
|
import {
|
|
|
|
|
|
resolveAflWildcardPlacements,
|
|
|
|
|
|
type AflWildcardResult,
|
|
|
|
|
|
} from "~/lib/afl-wildcard-reseed";
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
|
|
|
|
|
|
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
|
|
|
|
|
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
|
|
|
|
|
|
|
|
|
|
|
export async function createPlayoffMatch(
|
|
|
|
|
|
data: NewPlayoffMatch
|
|
|
|
|
|
): Promise<PlayoffMatch> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const [match] = await db
|
|
|
|
|
|
.insert(schema.playoffMatches)
|
|
|
|
|
|
.values(data)
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
return match;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function createManyPlayoffMatches(
|
|
|
|
|
|
data: NewPlayoffMatch[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
return await db
|
|
|
|
|
|
.insert(schema.playoffMatches)
|
|
|
|
|
|
.values(data)
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function findPlayoffMatchById(
|
|
|
|
|
|
id: string
|
|
|
|
|
|
): Promise<PlayoffMatch | undefined> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
return await db.query.playoffMatches.findFirst({
|
|
|
|
|
|
where: eq(schema.playoffMatches.id, id),
|
|
|
|
|
|
with: {
|
|
|
|
|
|
scoringEvent: true,
|
|
|
|
|
|
participant1: true,
|
|
|
|
|
|
participant2: true,
|
|
|
|
|
|
winner: true,
|
|
|
|
|
|
loser: true,
|
Add playoff match game scheduling and odds management (#135)
* Add playoff match games and odds storage
Introduces two new tables for bracket matchup detail storage:
- `playoff_match_games`: tracks individual game schedules within a
series matchup (game number, scheduledAt, status, per-game scores,
winner). Supports scheduled/complete/postponed status enum.
- `playoff_match_odds`: stores moneyline odds per participant per
matchup (single upsert record, no isLatest complexity).
Includes:
- Drizzle schema + relations with CASCADE deletes from playoff_matches
- Migration 0040_fat_puma.sql
- playoff-match-game.ts model with pure helpers: computeSeriesScore,
isSeriesComplete, getSeriesLeader — plus full CRUD
- playoff-match-odds.ts model with pure helpers: americanToImpliedProbability,
impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete
- findPlayoffMatchesByEventId and findPlayoffMatchById updated to
include games and odds in their query results
- Bracket server route: add-game, update-game, delete-game,
upsert-odds, delete-odds actions
- Bracket admin UI: expandable per-match panel for game schedule
management and moneyline odds entry
- 41 new unit tests (18 game + 23 odds), all 810 tests passing
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
* Code review fixes: type safety, abstraction, and React correctness
- Derive PlayoffMatchGameStatus from schema enum instead of hardcoding
the string union, eliminating the duplicate source of truth
- updateGame now returns PlayoffMatchGame | undefined to reflect reality
when no row matches the ID
- Remove TOCTOU check-then-act in update-game action: call updateGame
directly and check the return value instead of a pre-flight findGameById
- Add status enum validation before the cast in update-game action
- Move impliedProbability computation inside upsertMatchOdds so callers
only provide moneylineOdds; the model owns the derivation
- Remove unnecessary dynamic import of americanToImpliedProbability in
the upsert-odds action (was already imported from the same module)
- Fix React list reconciliation bug: replace bare <> fragment with
<Fragment key={match.id}> so React can correctly track rows
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 14:17:43 -07:00
|
|
|
|
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
|
|
|
|
|
|
odds: { with: { participant: true } },
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function findPlayoffMatchesByEventId(
|
|
|
|
|
|
eventId: string
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
return await db.query.playoffMatches.findMany({
|
|
|
|
|
|
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
|
|
|
|
|
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
|
|
|
|
|
|
with: {
|
|
|
|
|
|
participant1: true,
|
|
|
|
|
|
participant2: true,
|
|
|
|
|
|
winner: true,
|
|
|
|
|
|
loser: true,
|
Add playoff match game scheduling and odds management (#135)
* Add playoff match games and odds storage
Introduces two new tables for bracket matchup detail storage:
- `playoff_match_games`: tracks individual game schedules within a
series matchup (game number, scheduledAt, status, per-game scores,
winner). Supports scheduled/complete/postponed status enum.
- `playoff_match_odds`: stores moneyline odds per participant per
matchup (single upsert record, no isLatest complexity).
Includes:
- Drizzle schema + relations with CASCADE deletes from playoff_matches
- Migration 0040_fat_puma.sql
- playoff-match-game.ts model with pure helpers: computeSeriesScore,
isSeriesComplete, getSeriesLeader — plus full CRUD
- playoff-match-odds.ts model with pure helpers: americanToImpliedProbability,
impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete
- findPlayoffMatchesByEventId and findPlayoffMatchById updated to
include games and odds in their query results
- Bracket server route: add-game, update-game, delete-game,
upsert-odds, delete-odds actions
- Bracket admin UI: expandable per-match panel for game schedule
management and moneyline odds entry
- 41 new unit tests (18 game + 23 odds), all 810 tests passing
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
* Code review fixes: type safety, abstraction, and React correctness
- Derive PlayoffMatchGameStatus from schema enum instead of hardcoding
the string union, eliminating the duplicate source of truth
- updateGame now returns PlayoffMatchGame | undefined to reflect reality
when no row matches the ID
- Remove TOCTOU check-then-act in update-game action: call updateGame
directly and check the return value instead of a pre-flight findGameById
- Add status enum validation before the cast in update-game action
- Move impliedProbability computation inside upsertMatchOdds so callers
only provide moneylineOdds; the model owns the derivation
- Remove unnecessary dynamic import of americanToImpliedProbability in
the upsert-odds action (was already imported from the same module)
- Fix React list reconciliation bug: replace bare <> fragment with
<Fragment key={match.id}> so React can correctly track rows
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 14:17:43 -07:00
|
|
|
|
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
|
|
|
|
|
|
odds: { with: { participant: true } },
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function findPlayoffMatchesByEventIdAndRound(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
round: string
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
return await db.query.playoffMatches.findMany({
|
|
|
|
|
|
where: and(
|
|
|
|
|
|
eq(schema.playoffMatches.scoringEventId, eventId),
|
|
|
|
|
|
eq(schema.playoffMatches.round, round)
|
|
|
|
|
|
),
|
|
|
|
|
|
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
|
|
|
|
|
|
with: {
|
|
|
|
|
|
participant1: true,
|
|
|
|
|
|
participant2: true,
|
|
|
|
|
|
winner: true,
|
|
|
|
|
|
loser: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function updatePlayoffMatch(
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
data: Partial<NewPlayoffMatch>
|
|
|
|
|
|
): Promise<PlayoffMatch> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const [match] = await db
|
|
|
|
|
|
.update(schema.playoffMatches)
|
|
|
|
|
|
.set({ ...data, updatedAt: new Date() })
|
|
|
|
|
|
.where(eq(schema.playoffMatches.id, id))
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
return match;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function deletePlayoffMatch(id: string): Promise<void> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
await db.delete(schema.playoffMatches).where(eq(schema.playoffMatches.id, id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function deletePlayoffMatchesByEventId(
|
|
|
|
|
|
eventId: string
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
await db
|
|
|
|
|
|
.delete(schema.playoffMatches)
|
|
|
|
|
|
.where(eq(schema.playoffMatches.scoringEventId, eventId));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Set the winner of a playoff match
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function setMatchWinner(
|
|
|
|
|
|
matchId: string,
|
|
|
|
|
|
winnerId: string,
|
|
|
|
|
|
loserId: string,
|
|
|
|
|
|
participant1Score?: number,
|
|
|
|
|
|
participant2Score?: number
|
|
|
|
|
|
): Promise<PlayoffMatch> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const [match] = await db
|
|
|
|
|
|
.update(schema.playoffMatches)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
winnerId,
|
|
|
|
|
|
loserId,
|
|
|
|
|
|
isComplete: true,
|
|
|
|
|
|
participant1Score: participant1Score?.toString(),
|
|
|
|
|
|
participant2Score: participant2Score?.toString(),
|
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(eq(schema.playoffMatches.id, matchId))
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
return match;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.
Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass
Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
(gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
transaction.
Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
template-stripping so {{nowrap}}-wrapped players parse
Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
slots) with inline "rename existing" / "create as new" resolution via
fetcher (no full reload)
Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
|
|
|
|
/** A draw match with participants/winner already resolved to ids. */
|
|
|
|
|
|
export interface ResolvedDrawMatch {
|
|
|
|
|
|
externalMatchId: string;
|
|
|
|
|
|
round: string;
|
|
|
|
|
|
matchNumber: number;
|
|
|
|
|
|
participant1Id: string | null;
|
|
|
|
|
|
participant2Id: string | null;
|
|
|
|
|
|
winnerId: string | null;
|
|
|
|
|
|
loserId: string | null;
|
|
|
|
|
|
isScoring: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Populate (or advance) a bracket from a fully-resolved external draw, keyed on
|
|
|
|
|
|
* `externalMatchId` so repeated syncs update in place rather than duplicating.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Unlike `generateBracketFromTemplate` (which seeds only round 1 and leaves later
|
|
|
|
|
|
* rounds empty for `advanceWinnerTemplate`), this writes every round's actual
|
|
|
|
|
|
* matchups and known winners directly from the source — appropriate for a feed
|
|
|
|
|
|
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row
|
|
|
|
|
|
* is marked complete when both its winner and loser are known.
|
|
|
|
|
|
*
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
* @returns counts of rows written (inserted or updated), those carrying a result,
|
|
|
|
|
|
* and the participant ids of losers whose match *transitioned to complete on this
|
|
|
|
|
|
* run* (a row that was absent or not-yet-complete before and is complete now).
|
|
|
|
|
|
* That set is the newly-decided eliminations — used to announce knockouts once,
|
|
|
|
|
|
* idempotently across re-syncs, since playoff_matches persist between syncs.
|
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.
Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass
Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
(gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
transaction.
Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
template-stripping so {{nowrap}}-wrapped players parse
Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
slots) with inline "rename existing" / "create as new" resolution via
fetcher (no full reload)
Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
|
|
|
|
*/
|
|
|
|
|
|
export async function populateBracketFromDraw(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
matches: ResolvedDrawMatch[]
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
): Promise<{ written: number; completed: number; newlyDecidedLoserIds: string[] }> {
|
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.
Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass
Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
(gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
transaction.
Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
template-stripping so {{nowrap}}-wrapped players parse
Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
slots) with inline "rename existing" / "create as new" resolution via
fetcher (no full reload)
Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
|
|
|
|
const db = database();
|
|
|
|
|
|
|
|
|
|
|
|
const existing = await db.query.playoffMatches.findMany({
|
|
|
|
|
|
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
|
|
|
|
|
});
|
|
|
|
|
|
const byExternalId = new Map(
|
|
|
|
|
|
existing
|
|
|
|
|
|
.filter((m) => m.externalMatchId)
|
|
|
|
|
|
.map((m) => [m.externalMatchId as string, m])
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const toInsert: NewPlayoffMatch[] = [];
|
|
|
|
|
|
let written = 0;
|
|
|
|
|
|
let completed = 0;
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
const newlyDecidedLoserIds: string[] = [];
|
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.
Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass
Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
(gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
transaction.
Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
template-stripping so {{nowrap}}-wrapped players parse
Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
slots) with inline "rename existing" / "create as new" resolution via
fetcher (no full reload)
Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
|
|
|
|
|
|
|
|
|
|
for (const m of matches) {
|
|
|
|
|
|
const isComplete = m.winnerId !== null && m.loserId !== null;
|
|
|
|
|
|
if (isComplete) completed++;
|
|
|
|
|
|
|
|
|
|
|
|
const existingRow = byExternalId.get(m.externalMatchId);
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
// Loser is "newly decided" when this match reaches completion for the first
|
|
|
|
|
|
// time: either a brand-new complete row, or an existing row that was not
|
|
|
|
|
|
// complete before. Re-syncing an already-complete match yields nothing.
|
|
|
|
|
|
if (isComplete && m.loserId && !existingRow?.isComplete) {
|
|
|
|
|
|
newlyDecidedLoserIds.push(m.loserId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.
Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass
Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
(gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
transaction.
Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
template-stripping so {{nowrap}}-wrapped players parse
Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
slots) with inline "rename existing" / "create as new" resolution via
fetcher (no full reload)
Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
|
|
|
|
if (existingRow) {
|
|
|
|
|
|
await db
|
|
|
|
|
|
.update(schema.playoffMatches)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
round: m.round,
|
|
|
|
|
|
matchNumber: m.matchNumber,
|
|
|
|
|
|
participant1Id: m.participant1Id,
|
|
|
|
|
|
participant2Id: m.participant2Id,
|
|
|
|
|
|
winnerId: m.winnerId,
|
|
|
|
|
|
loserId: m.loserId,
|
|
|
|
|
|
isComplete,
|
|
|
|
|
|
isScoring: m.isScoring,
|
|
|
|
|
|
templateRound: m.round,
|
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(eq(schema.playoffMatches.id, existingRow.id));
|
|
|
|
|
|
written++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toInsert.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: m.round,
|
|
|
|
|
|
matchNumber: m.matchNumber,
|
|
|
|
|
|
participant1Id: m.participant1Id,
|
|
|
|
|
|
participant2Id: m.participant2Id,
|
|
|
|
|
|
winnerId: m.winnerId,
|
|
|
|
|
|
loserId: m.loserId,
|
|
|
|
|
|
isComplete,
|
|
|
|
|
|
isScoring: m.isScoring,
|
|
|
|
|
|
templateRound: m.round,
|
|
|
|
|
|
externalMatchId: m.externalMatchId,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (toInsert.length > 0) {
|
|
|
|
|
|
await createManyPlayoffMatches(toInsert);
|
|
|
|
|
|
written += toInsert.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
return { written, completed, newlyDecidedLoserIds };
|
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.
Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass
Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
(gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
transaction.
Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
template-stripping so {{nowrap}}-wrapped players parse
Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
slots) with inline "rename existing" / "create as new" resolution via
fetcher (no full reload)
Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Generate a standard single elimination bracket structure
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function generateSingleEliminationBracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
participantIds: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const numParticipants = participantIds.length;
|
|
|
|
|
|
|
|
|
|
|
|
// Validate that we have 4 or 8 participants for standard bracket
|
|
|
|
|
|
if (numParticipants !== 4 && numParticipants !== 8) {
|
|
|
|
|
|
throw new Error("Single elimination bracket requires 4 or 8 participants");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (numParticipants === 8) {
|
|
|
|
|
|
// Quarterfinals (4 matches)
|
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Quarterfinals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds[i * 2],
|
|
|
|
|
|
participant2Id: participantIds[i * 2 + 1],
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Semifinals (2 matches)
|
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Semifinals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (numParticipants === 4) {
|
|
|
|
|
|
// Semifinals (2 matches)
|
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Semifinals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds[i * 2],
|
|
|
|
|
|
participant2Id: participantIds[i * 2 + 1],
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Finals (1 match)
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Finals",
|
|
|
|
|
|
matchNumber: 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Advance the winner of a match to the next round
|
|
|
|
|
|
* Uses deterministic slot assignment to maintain bracket structure
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function advanceWinner(
|
|
|
|
|
|
matchId: string,
|
|
|
|
|
|
winnerId: string
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
// Get the match
|
|
|
|
|
|
const match = await findPlayoffMatchById(matchId);
|
|
|
|
|
|
if (!match) throw new Error("Match not found");
|
|
|
|
|
|
|
|
|
|
|
|
// Determine which round this is and where to advance
|
|
|
|
|
|
const eventId = match.scoringEventId;
|
|
|
|
|
|
let nextRound: string;
|
|
|
|
|
|
let nextMatchNumber: number;
|
|
|
|
|
|
let participantSlot: 'participant1Id' | 'participant2Id';
|
|
|
|
|
|
|
|
|
|
|
|
if (match.round === "Quarterfinals") {
|
|
|
|
|
|
nextRound = "Semifinals";
|
|
|
|
|
|
// Match 1,2 -> SF1, Match 3,4 -> SF2
|
|
|
|
|
|
nextMatchNumber = match.matchNumber <= 2 ? 1 : 2;
|
|
|
|
|
|
// Odd matches (1, 3) -> participant1, Even matches (2, 4) -> participant2
|
|
|
|
|
|
participantSlot = match.matchNumber % 2 === 1 ? 'participant1Id' : 'participant2Id';
|
|
|
|
|
|
} else if (match.round === "Semifinals") {
|
|
|
|
|
|
nextRound = "Finals";
|
|
|
|
|
|
nextMatchNumber = 1;
|
|
|
|
|
|
// SF Match 1 -> Finals participant1, SF Match 2 -> Finals participant2
|
|
|
|
|
|
participantSlot = match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Finals - no advancement needed
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find the next match
|
|
|
|
|
|
const nextMatches = await findPlayoffMatchesByEventIdAndRound(eventId, nextRound);
|
|
|
|
|
|
const nextMatch = nextMatches.find(m => m.matchNumber === nextMatchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!nextMatch) throw new Error("Next match not found");
|
|
|
|
|
|
|
|
|
|
|
|
// Check if the slot is already filled
|
|
|
|
|
|
if (nextMatch[participantSlot]) {
|
|
|
|
|
|
throw new Error(`Next match ${participantSlot} is already filled`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fill the determined slot
|
|
|
|
|
|
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
|
|
|
|
|
|
}
|
2025-11-03 13:16:37 -08:00
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Generate standard tournament seeding matchups
|
|
|
|
|
|
* Returns pairs of seed indices for proper bracket balance
|
|
|
|
|
|
*
|
|
|
|
|
|
* Examples:
|
|
|
|
|
|
* - 4 teams: [[0,3], [1,2]] = 1v4, 2v3
|
|
|
|
|
|
* - 8 teams: [[0,7], [3,4], [1,6], [2,5]] = 1v8, 4v5, 2v7, 3v6
|
|
|
|
|
|
* - 16 teams: [[0,15], [7,8], [4,11], [3,12], [5,10], [2,13], [6,9], [1,14]]
|
|
|
|
|
|
* = 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15
|
|
|
|
|
|
*/
|
|
|
|
|
|
function generateStandardSeeding(teamCount: number): [number, number][] {
|
|
|
|
|
|
if (![4, 8, 16, 32].includes(teamCount)) {
|
2025-11-08 21:18:09 -08:00
|
|
|
|
// Non-standard sizes need sequential seeding for now
|
2025-11-04 22:09:44 -08:00
|
|
|
|
const pairs: [number, number][] = [];
|
|
|
|
|
|
for (let i = 0; i < teamCount; i += 2) {
|
|
|
|
|
|
pairs.push([i, i + 1]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return pairs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Standard balanced bracket seeding
|
|
|
|
|
|
const rounds = Math.log2(teamCount);
|
|
|
|
|
|
let seeds = [0, 1]; // Start with 1 vs 2
|
|
|
|
|
|
|
|
|
|
|
|
// Build up seeding by adding rounds
|
|
|
|
|
|
for (let round = 1; round < rounds; round++) {
|
|
|
|
|
|
const newSeeds: number[] = [];
|
|
|
|
|
|
const maxSeed = Math.pow(2, round + 1) - 1;
|
|
|
|
|
|
|
|
|
|
|
|
for (const seed of seeds) {
|
|
|
|
|
|
newSeeds.push(seed);
|
|
|
|
|
|
newSeeds.push(maxSeed - seed);
|
|
|
|
|
|
}
|
|
|
|
|
|
seeds = newSeeds;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Convert to pairs (odd indices vs even indices)
|
|
|
|
|
|
const pairs: [number, number][] = [];
|
|
|
|
|
|
for (let i = 0; i < seeds.length; i += 2) {
|
|
|
|
|
|
pairs.push([seeds[i], seeds[i + 1]]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return pairs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Generate a bracket from a template
|
|
|
|
|
|
* Phase 2.6: Template-based bracket generation
|
2025-11-04 22:09:44 -08:00
|
|
|
|
* Phase 2.7: Added proper tournament seeding
|
2025-11-03 13:16:37 -08:00
|
|
|
|
*
|
|
|
|
|
|
* @param eventId - The scoring event ID
|
|
|
|
|
|
* @param templateId - The bracket template ID (e.g., "simple_16", "ncaa_68")
|
|
|
|
|
|
* @param participantIds - Array of participant IDs to assign to the bracket (optional, can be assigned later)
|
|
|
|
|
|
* @returns Array of created playoff matches
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function generateBracketFromTemplate(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
templateId: string,
|
2026-03-15 21:52:47 -07:00
|
|
|
|
participantIds?: string[],
|
|
|
|
|
|
regionOverride?: BracketRegion[]
|
2025-11-03 13:16:37 -08:00
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const template = getBracketTemplate(templateId);
|
|
|
|
|
|
if (!template) {
|
|
|
|
|
|
throw new Error(`Bracket template '${templateId}' not found`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Validate participant count if provided
|
|
|
|
|
|
if (participantIds && participantIds.length !== template.totalTeams) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Template '${templateId}' requires ${template.totalTeams} participants, but ${participantIds.length} were provided`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
|
// FIFA 48 generates an empty knockout bracket (participants assigned later via groups)
|
|
|
|
|
|
if (templateId === "fifa_48") {
|
|
|
|
|
|
return await generateFIFA48Bracket(eventId, template);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
// NCAA 68 requires special handling for First Four and Round of 64
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// Use regionOverride if supplied (per-event config), otherwise fall back to template.regions
|
2025-11-08 21:18:09 -08:00
|
|
|
|
if (templateId === "ncaa_68") {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
const effectiveTemplate = regionOverride
|
|
|
|
|
|
? { ...template, regions: regionOverride }
|
|
|
|
|
|
: template;
|
|
|
|
|
|
return await generateNCAA68Bracket(eventId, effectiveTemplate, participantIds);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:56:57 -08:00
|
|
|
|
// NFL 14 requires special handling for bye weeks
|
|
|
|
|
|
if (templateId === "nfl_14") {
|
|
|
|
|
|
return await generateNFL14Bracket(eventId, template, participantIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-12 23:21:22 -08:00
|
|
|
|
// AFL 10 requires special handling for double-chance finals system
|
|
|
|
|
|
if (templateId === "afl_10") {
|
|
|
|
|
|
return await generateAFL10Bracket(eventId, template, participantIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124
* Add NCAA Football CFP simulator (12-team bracket)
Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).
- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
probability distributions, seeding, edge cases, futures blending
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix lint errors in NCAA Football CFP simulator
Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TS2345 in NCAA football simulator test
Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:48:32 -04:00
|
|
|
|
// CFP 12 requires special handling for bye weeks (seeds 1–4 skip First Round)
|
|
|
|
|
|
if (templateId === "cfp_12") {
|
|
|
|
|
|
return await generateCFP12Bracket(eventId, template, participantIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 00:51:53 -04:00
|
|
|
|
// NBA 20 requires special handling for the play-in tournament
|
|
|
|
|
|
if (templateId === "nba_20") {
|
|
|
|
|
|
return await generateNBA20Bracket(eventId, template, participantIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.
Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.
The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.
Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.
Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.
Also:
- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
hardcoded a 5th-place floor for winners of non-scoring rounds feeding
a scoring one, which is wrong inside a losers bracket where a win can
guarantee only 7th. Opt-in, so no existing template changes behavior.
- Fixes TabbedBracketLayout's mobile path, which built its match map
unfiltered and so would have merged U.S. and International games into
one column. No-op for NCAA and NBA, whose groups already cover every
match in their phases.
- Rewrites the LLWS Monte Carlo simulator, which still modelled the
retired pool-play format (5 teams per pool, then a 4-team bracket per
side) and no longer described the tournament being scored. It now runs
the real 10-team double elimination and splits the 5–8 probabilities
into the correct tiers instead of one even four-way split. Legacy
"US:A"/"Intl:B" externalIds are still accepted, read as the side
alone, so seasons configured for the old format keep loading.
Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
|
|
|
|
// LLWS 20 requires special handling for its two double-elimination brackets
|
|
|
|
|
|
if (templateId === "llws_20") {
|
|
|
|
|
|
return await generateLLWS20Bracket(eventId, template, participantIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Generate matches for each round in the template
|
|
|
|
|
|
for (const round of template.rounds) {
|
2025-11-04 22:09:44 -08:00
|
|
|
|
// Only assign participants to the first round if participantIds provided
|
|
|
|
|
|
let seedingPairs: [number, number][] = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (participantIds && round === template.rounds[0]) {
|
|
|
|
|
|
const firstRoundTeams = round.matchCount * 2;
|
|
|
|
|
|
seedingPairs = generateStandardSeeding(firstRoundTeams);
|
|
|
|
|
|
}
|
2025-11-03 13:16:37 -08:00
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < round.matchCount; i++) {
|
|
|
|
|
|
let participant1Id: string | null = null;
|
|
|
|
|
|
let participant2Id: string | null = null;
|
2025-11-04 22:09:44 -08:00
|
|
|
|
let seedInfo: string | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
if (participantIds && round === template.rounds[0] && seedingPairs[i]) {
|
|
|
|
|
|
const [seed1Index, seed2Index] = seedingPairs[i];
|
|
|
|
|
|
participant1Id = participantIds[seed1Index] || null;
|
|
|
|
|
|
participant2Id = participantIds[seed2Index] || null;
|
2025-11-03 13:16:37 -08:00
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
|
// Store seed info for display (1-indexed for users)
|
|
|
|
|
|
seedInfo = `${seed1Index + 1} vs ${seed2Index + 1}`;
|
2025-11-03 13:16:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: round.name,
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id,
|
|
|
|
|
|
participant2Id,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: round.isScoring,
|
|
|
|
|
|
templateRound: round.name,
|
2025-11-04 22:09:44 -08:00
|
|
|
|
seedInfo,
|
2025-11-03 13:16:37 -08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
/**
|
2026-03-15 21:52:47 -07:00
|
|
|
|
* Generate NCAA 68 bracket using the template's regions config.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Participant array layout (driven by buildNCAA68SlotMap):
|
|
|
|
|
|
* [region 0 direct seeds] [region 1 direct seeds] ...
|
|
|
|
|
|
* [play-in teams in region/play-in order, 2 per game]
|
2025-11-08 21:18:09 -08:00
|
|
|
|
*/
|
|
|
|
|
|
async function generateNCAA68Bracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
template: BracketTemplate,
|
|
|
|
|
|
participantIds?: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
if (!template.regions || template.regions.length === 0) {
|
|
|
|
|
|
throw new Error("NCAA 68 template requires a regions config");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
2026-03-15 21:52:47 -07:00
|
|
|
|
const slotMap = buildNCAA68SlotMap(template.regions);
|
|
|
|
|
|
|
|
|
|
|
|
// Per-region map: region index → FF labels for that region's play-ins (e.g. "FF1")
|
|
|
|
|
|
const regionFFLabels = new Map<number, string[]>();
|
|
|
|
|
|
for (let i = 0; i < slotMap.playInOffsets.length; i++) {
|
|
|
|
|
|
const { regionIndex, playInIndex } = slotMap.playInOffsets[i];
|
|
|
|
|
|
if (!regionFFLabels.has(regionIndex)) regionFFLabels.set(regionIndex, []);
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
|
const labels = regionFFLabels.get(regionIndex);
|
|
|
|
|
|
if (labels) labels[playInIndex] = `FF${i + 1}`;
|
2026-03-15 21:52:47 -07:00
|
|
|
|
}
|
2025-11-08 21:18:09 -08:00
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// ── First Four ────────────────────────────────────────────────────────────
|
|
|
|
|
|
for (let i = 0; i < slotMap.playInOffsets.length; i++) {
|
|
|
|
|
|
const { startIndex, seedSlot, regionIndex } = slotMap.playInOffsets[i];
|
|
|
|
|
|
const regionName = template.regions[regionIndex].name;
|
2025-11-08 21:18:09 -08:00
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "First Four",
|
|
|
|
|
|
matchNumber: i + 1,
|
2026-03-15 21:52:47 -07:00
|
|
|
|
participant1Id: participantIds ? (participantIds[startIndex] ?? null) : null,
|
|
|
|
|
|
participant2Id: participantIds ? (participantIds[startIndex + 1] ?? null) : null,
|
2025-11-08 21:18:09 -08:00
|
|
|
|
isComplete: false,
|
2026-03-15 21:52:47 -07:00
|
|
|
|
isScoring: false,
|
2025-11-08 21:18:09 -08:00
|
|
|
|
templateRound: "First Four",
|
2026-03-15 21:52:47 -07:00
|
|
|
|
seedInfo: `${regionName} ${seedSlot}-seed play-in`,
|
2025-11-08 21:18:09 -08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// ── Round of 64: 8 matches per region ────────────────────────────────────
|
|
|
|
|
|
let r64MatchNumber = 1;
|
|
|
|
|
|
for (let r = 0; r < template.regions.length; r++) {
|
|
|
|
|
|
const region = template.regions[r];
|
|
|
|
|
|
const directOffset = slotMap.directOffsets[r];
|
|
|
|
|
|
const labels = regionFFLabels.get(r) ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
// Map seed rank → participant array index or FF label
|
|
|
|
|
|
const seedToSlot = new Map<number, number | string>();
|
|
|
|
|
|
for (let i = 0; i < region.directSeeds.length; i++) {
|
|
|
|
|
|
seedToSlot.set(region.directSeeds[i], directOffset + i);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (let p = 0; p < region.playIns.length; p++) {
|
|
|
|
|
|
seedToSlot.set(region.playIns[p].seedSlot, labels[p]);
|
|
|
|
|
|
}
|
2025-11-08 21:18:09 -08:00
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
for (const [hiSeed, loSeed] of STANDARD_BRACKET_SEEDING) {
|
|
|
|
|
|
const slot1 = seedToSlot.get(hiSeed);
|
|
|
|
|
|
const slot2 = seedToSlot.get(loSeed);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
if (slot1 === undefined || slot2 === undefined) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Region "${region.name}" missing seed ${slot1 === undefined ? hiSeed : loSeed}`
|
|
|
|
|
|
);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Round of 64",
|
|
|
|
|
|
matchNumber: r64MatchNumber++,
|
|
|
|
|
|
participant1Id:
|
|
|
|
|
|
participantIds && typeof slot1 === "number"
|
|
|
|
|
|
? (participantIds[slot1] ?? null)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
participant2Id:
|
|
|
|
|
|
participantIds && typeof slot2 === "number"
|
|
|
|
|
|
? (participantIds[slot2] ?? null)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
templateRound: "Round of 64",
|
|
|
|
|
|
seedInfo: `${region.name}: ${hiSeed} vs ${typeof slot2 === "string" ? slot2 : loSeed}`,
|
|
|
|
|
|
});
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// ── Round of 32 → Championship (empty match shells) ──────────────────────
|
2025-11-08 21:56:57 -08:00
|
|
|
|
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
|
|
|
|
|
|
const round = template.rounds[roundIndex];
|
|
|
|
|
|
for (let i = 0; i < round.matchCount; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: round.name,
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: round.isScoring,
|
|
|
|
|
|
templateRound: round.name,
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Generate NFL 14 bracket with bye weeks for top 2 seeds
|
|
|
|
|
|
* Phase 2.9: Special handling for Wild Card round (12 teams) and Divisional byes
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function generateNFL14Bracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
template: BracketTemplate,
|
|
|
|
|
|
participantIds?: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Actually for 14 teams (seeds 1-14), Wild Card should be:
|
|
|
|
|
|
// Match 1: #7 (index 6) vs #10 (index 9)
|
|
|
|
|
|
// Match 2: #6 (index 5) vs #11 (index 10)
|
|
|
|
|
|
// Match 3: #5 (index 4) vs #12 (index 11)
|
|
|
|
|
|
// Match 4: #8 (index 7) vs #9 (index 8)
|
|
|
|
|
|
// Match 5: #4 (index 3) vs #13 (index 12)
|
|
|
|
|
|
// Match 6: #3 (index 2) vs #14 (index 13)
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const wildCardSeeding = [
|
2025-11-08 21:56:57 -08:00
|
|
|
|
[6, 9], // #7 vs #10
|
|
|
|
|
|
[5, 10], // #6 vs #11
|
|
|
|
|
|
[4, 11], // #5 vs #12
|
|
|
|
|
|
[7, 8], // #8 vs #9
|
|
|
|
|
|
[3, 12], // #4 vs #13
|
|
|
|
|
|
[2, 13], // #3 vs #14
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-03-21 09:44:05 -07:00
|
|
|
|
for (let i = 0; i < wildCardSeeding.length; i++) {
|
|
|
|
|
|
const [seed1, seed2] = wildCardSeeding[i];
|
2025-11-08 21:56:57 -08:00
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Wild Card",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds ? participantIds[seed1] : null,
|
|
|
|
|
|
participant2Id: participantIds ? participantIds[seed2] : null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false, // Wild Card doesn't score fantasy points
|
|
|
|
|
|
templateRound: "Wild Card",
|
|
|
|
|
|
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Divisional: 4 games
|
|
|
|
|
|
// Top 2 seeds (#1 and #2) get byes and are placed in Divisional automatically
|
|
|
|
|
|
// The other 2 Divisional matches will be filled by Wild Card winners
|
|
|
|
|
|
const divisionalRound = template.rounds.find((r) => r.name === "Divisional");
|
|
|
|
|
|
if (divisionalRound) {
|
|
|
|
|
|
for (let i = 0; i < divisionalRound.matchCount; i++) {
|
|
|
|
|
|
let participant1Id: string | null = null;
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const participant2Id: string | null = null;
|
2025-11-08 21:56:57 -08:00
|
|
|
|
let seedInfo: string | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
// Assign bye teams to first two Divisional matches
|
|
|
|
|
|
if (participantIds) {
|
|
|
|
|
|
if (i === 0) {
|
|
|
|
|
|
// Match 1: #1 seed (index 0) vs TBD (Wild Card winner)
|
|
|
|
|
|
participant1Id = participantIds[0];
|
|
|
|
|
|
seedInfo = "1 vs TBD";
|
|
|
|
|
|
} else if (i === 1) {
|
|
|
|
|
|
// Match 2: #2 seed (index 1) vs TBD (Wild Card winner)
|
|
|
|
|
|
participant1Id = participantIds[1];
|
|
|
|
|
|
seedInfo = "2 vs TBD";
|
|
|
|
|
|
}
|
|
|
|
|
|
// Matches 3 and 4 are TBD vs TBD (Wild Card winners)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Divisional",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id,
|
|
|
|
|
|
participant2Id,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: divisionalRound.isScoring,
|
|
|
|
|
|
templateRound: "Divisional",
|
|
|
|
|
|
seedInfo,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Generate remaining rounds (Conference Championship, Super Bowl)
|
2025-11-08 21:18:09 -08:00
|
|
|
|
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
|
|
|
|
|
|
const round = template.rounds[roundIndex];
|
|
|
|
|
|
for (let i = 0; i < round.matchCount; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: round.name,
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: round.isScoring,
|
|
|
|
|
|
templateRound: round.name,
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-12 23:21:22 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Generate AFL 10 bracket with Wildcard Round (2026+ format)
|
|
|
|
|
|
* Phase 3.3: Special handling for AFL's double-chance finals system
|
|
|
|
|
|
*
|
|
|
|
|
|
* Structure:
|
|
|
|
|
|
* - Wildcard Round: 7v10, 8v9
|
|
|
|
|
|
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
2026-09-04 15:13:13 +00:00
|
|
|
|
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
|
|
|
|
|
|
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
2026-09-11 18:10:30 +00:00
|
|
|
|
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
|
|
|
|
|
|
* - Preliminary Finals: PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner
|
|
|
|
|
|
* (the crossover keeps a QF loser away from the side that just beat it)
|
2025-11-12 23:21:22 -08:00
|
|
|
|
* - Grand Final: PF winners
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function generateAFL10Bracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
template: BracketTemplate,
|
|
|
|
|
|
participantIds?: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Wildcard Round: 7th vs 10th, 8th vs 9th
|
|
|
|
|
|
const wildcardSeeding = [
|
|
|
|
|
|
[6, 9], // #7 (index 6) vs #10 (index 9) - 7th hosts
|
|
|
|
|
|
[7, 8], // #8 (index 7) vs #9 (index 8) - 8th hosts
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < wildcardSeeding.length; i++) {
|
|
|
|
|
|
const [seed1, seed2] = wildcardSeeding[i];
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Wildcard Round",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds ? participantIds[seed1] : null,
|
|
|
|
|
|
participant2Id: participantIds ? participantIds[seed2] : null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false, // Losers get 0 points (9th-10th place)
|
|
|
|
|
|
templateRound: "Wildcard Round",
|
|
|
|
|
|
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Qualifying Finals: 1st vs 4th, 2nd vs 3rd
|
|
|
|
|
|
const qualifyingSeeding = [
|
|
|
|
|
|
[0, 3], // #1 (index 0) vs #4 (index 3)
|
|
|
|
|
|
[1, 2], // #2 (index 1) vs #3 (index 2)
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < qualifyingSeeding.length; i++) {
|
|
|
|
|
|
const [seed1, seed2] = qualifyingSeeding[i];
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Qualifying Finals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds ? participantIds[seed1] : null,
|
|
|
|
|
|
participant2Id: participantIds ? participantIds[seed2] : null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false, // Winners get bye, losers get second chance
|
|
|
|
|
|
templateRound: "Qualifying Finals",
|
|
|
|
|
|
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 15:13:13 +00:00
|
|
|
|
// Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
|
|
|
|
|
|
// where is decided by ladder position once both games are played (see
|
|
|
|
|
|
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
|
2025-11-12 23:21:22 -08:00
|
|
|
|
const eliminationSeeding = [
|
2026-09-04 15:13:13 +00:00
|
|
|
|
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
|
|
|
|
|
|
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
|
2025-11-12 23:21:22 -08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < eliminationSeeding.length; i++) {
|
2026-09-04 15:13:13 +00:00
|
|
|
|
const { higher, opponent } = eliminationSeeding[i];
|
2025-11-12 23:21:22 -08:00
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Elimination Finals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds ? participantIds[higher] : null,
|
|
|
|
|
|
participant2Id: null, // Will be filled by wildcard winner
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: true, // Losers share 7th-8th
|
|
|
|
|
|
templateRound: "Elimination Finals",
|
2026-09-04 15:13:13 +00:00
|
|
|
|
seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
|
2025-11-12 23:21:22 -08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 18:10:30 +00:00
|
|
|
|
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
|
2025-11-12 23:21:22 -08:00
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Semi-Finals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null, // Will be filled by QF loser
|
|
|
|
|
|
participant2Id: null, // Will be filled by EF winner
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: true, // Losers share 5th-6th
|
|
|
|
|
|
templateRound: "Semi-Finals",
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Preliminary Finals: QF winners vs SF winners (TBD vs TBD)
|
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Preliminary Finals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null, // Will be filled by QF winner
|
|
|
|
|
|
participant2Id: null, // Will be filled by SF winner
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: true, // Losers share 3rd-4th
|
|
|
|
|
|
templateRound: "Preliminary Finals",
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Grand Final: PF winners (TBD vs TBD)
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Grand Final",
|
|
|
|
|
|
matchNumber: 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
templateRound: "Grand Final",
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 21:30:57 +00:00
|
|
|
|
/** What a re-seed changed, by Elimination Finals match number. */
|
|
|
|
|
|
export interface AflEliminationReseed {
|
|
|
|
|
|
vacated: number[];
|
|
|
|
|
|
filled: Array<{ matchNumber: number; participantId: string }>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Put the decided Wildcard winners in the Elimination Finals they belong in.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The two winners are re-seeded by ladder position — 5th meets the lower-ranked one and
|
|
|
|
|
|
* 6th the higher-ranked one — rather than crossing over from a fixed Wildcard match. That
|
|
|
|
|
|
* destination depends on both games, so this reconciles both slots against the results
|
|
|
|
|
|
* recorded so far every time it runs: it places a winner whose slot only became certain
|
|
|
|
|
|
* once the other game was decided, and moves one that an earlier (or corrected) result,
|
|
|
|
|
|
* or a bracket advanced before this rule existed, put in the other slot.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `pending` supplies a result that may not be in the database yet — the row read back
|
|
|
|
|
|
* while advancing a match can predate the winner being written to it.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Idempotent: pairings that are already right do no writes.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function reseedAflEliminationFinals(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
pending?: { matchId: string; winnerId: string }
|
|
|
|
|
|
): Promise<AflEliminationReseed> {
|
|
|
|
|
|
const [wcMatches, efMatches] = await Promise.all([
|
|
|
|
|
|
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
|
|
|
|
|
|
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
|
|
|
|
|
if (wcMatches.length === 0 || efMatches.length === 0) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Event ${eventId} has no AFL Wildcard Round / Elimination Finals matches to re-seed`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const winnerByMatchNumber = new Map<number, string>();
|
|
|
|
|
|
for (const wc of wcMatches) {
|
|
|
|
|
|
const decidedWinner =
|
|
|
|
|
|
pending && wc.id === pending.matchId ? pending.winnerId : wc.isComplete ? wc.winnerId : null;
|
|
|
|
|
|
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const results: AflWildcardResult[] = wcMatches.map((wc) => {
|
|
|
|
|
|
const decidedWinner = winnerByMatchNumber.get(wc.matchNumber) ?? null;
|
|
|
|
|
|
if (decidedWinner === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
|
|
|
|
|
|
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
|
|
|
|
|
|
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Wildcard Round match ${wc.matchNumber} winner is not one of its participants`
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const wanted = new Map<number, string>();
|
|
|
|
|
|
for (const placement of resolveAflWildcardPlacements(results)) {
|
|
|
|
|
|
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
|
|
|
|
|
|
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Only these teams can legitimately be moved between the two Elimination Finals;
|
|
|
|
|
|
// anyone else in a slot came from somewhere this function knows nothing about.
|
|
|
|
|
|
const wildcardParticipants = new Set<string>();
|
|
|
|
|
|
for (const wc of wcMatches) {
|
|
|
|
|
|
if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id);
|
|
|
|
|
|
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
|
|
|
|
|
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const efMatch of efMatches) {
|
|
|
|
|
|
const occupant = efMatch.participant2Id;
|
|
|
|
|
|
const belongsHere = wanted.get(efMatch.matchNumber) ?? null;
|
|
|
|
|
|
if (occupant === belongsHere) continue;
|
|
|
|
|
|
|
|
|
|
|
|
if (occupant !== null && !wildcardParticipants.has(occupant)) {
|
|
|
|
|
|
throw new Error(`EF ${efMatch.matchNumber} participant2 already filled`);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Re-seeding a game that has already been played would rewrite who contested a
|
|
|
|
|
|
// recorded result. Surface that (this message is not one callers swallow) rather
|
|
|
|
|
|
// than quietly corrupting the bracket.
|
|
|
|
|
|
if (occupant !== null && (efMatch.isComplete || efMatch.winnerId)) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Elimination Finals match ${efMatch.matchNumber} already has a recorded result, ` +
|
|
|
|
|
|
`so its Wildcard qualifier cannot be re-seeded — clear and regenerate the bracket`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (occupant !== null) slotsToClear.push({ id: efMatch.id, matchNumber: efMatch.matchNumber });
|
|
|
|
|
|
if (belongsHere !== null) {
|
|
|
|
|
|
slotsToFill.push({ id: efMatch.id, matchNumber: efMatch.matchNumber, participantId: belongsHere });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const reseed: AflEliminationReseed = {
|
|
|
|
|
|
vacated: slotsToClear.map((slot) => slot.matchNumber),
|
|
|
|
|
|
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
|
|
|
|
|
|
|
|
|
|
|
|
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
|
|
|
|
|
// same team in both Elimination Finals.
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
await db.transaction(async (tx) => {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
for (const slot of slotsToClear) {
|
|
|
|
|
|
await tx
|
|
|
|
|
|
.update(schema.playoffMatches)
|
|
|
|
|
|
.set({ participant2Id: null, updatedAt: now })
|
|
|
|
|
|
.where(eq(schema.playoffMatches.id, slot.id));
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const slot of slotsToFill) {
|
|
|
|
|
|
await tx
|
|
|
|
|
|
.update(schema.playoffMatches)
|
|
|
|
|
|
.set({ participant2Id: slot.participantId, updatedAt: now })
|
|
|
|
|
|
.where(eq(schema.playoffMatches.id, slot.id));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return reseed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 18:10:30 +00:00
|
|
|
|
/** What a Semi-Finals re-seed changed, by Semi-Finals match number. */
|
|
|
|
|
|
export interface AflSemiFinalReseed {
|
|
|
|
|
|
vacated: number[];
|
|
|
|
|
|
filled: Array<{ matchNumber: number; participantId: string }>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Put the decided Elimination Final winners in the Semi-Finals they belong in.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
|
|
|
|
|
* n, so SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2
|
|
|
|
|
|
* winner. The crossover in this system comes a round later, at Semi-Final → Preliminary
|
|
|
|
|
|
* Final, so that a Qualifying Final loser cannot meet the side that just beat it.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Brackets advanced before this was fixed crossed the two winners — the EF1 winner went
|
|
|
|
|
|
* to SF2 and the EF2 winner to SF1 — which is why this reconciles both slots against the
|
|
|
|
|
|
* results recorded so far rather than writing the one it was called for: a winner sitting
|
|
|
|
|
|
* in the wrong Semi-Final is vacated, and a corrected Elimination Final result pulls the
|
|
|
|
|
|
* beaten team back out instead of leaving it alive.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `pending` supplies a result that may not be in the database yet — the row read back
|
|
|
|
|
|
* while advancing a match can predate the winner being written to it.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Idempotent: pairings that are already right do no writes.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function reseedAflSemiFinals(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
pending?: { matchId: string; winnerId: string }
|
|
|
|
|
|
): Promise<AflSemiFinalReseed> {
|
|
|
|
|
|
const [efMatches, sfMatches] = await Promise.all([
|
|
|
|
|
|
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
|
|
|
|
|
findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
|
|
|
|
|
if (efMatches.length === 0 || sfMatches.length === 0) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Event ${eventId} has no AFL Elimination Finals / Semi-Finals matches to re-seed`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Elimination Final n feeds Semi-Final n, so a decided winner's destination never
|
|
|
|
|
|
// depends on the other game.
|
|
|
|
|
|
const wanted = new Map<number, string>();
|
|
|
|
|
|
for (const ef of efMatches) {
|
|
|
|
|
|
const decidedWinner =
|
|
|
|
|
|
pending && ef.id === pending.matchId ? pending.winnerId : ef.isComplete ? ef.winnerId : null;
|
|
|
|
|
|
if (!decidedWinner) continue;
|
|
|
|
|
|
if (decidedWinner !== ef.participant1Id && decidedWinner !== ef.participant2Id) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Elimination Finals match ${ef.matchNumber} winner is not one of its participants`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
wanted.set(ef.matchNumber, decidedWinner);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Only these teams can legitimately be moved between the two Semi-Finals; anyone else
|
|
|
|
|
|
// in a slot came from somewhere this function knows nothing about.
|
|
|
|
|
|
const eliminationParticipants = new Set<string>();
|
|
|
|
|
|
for (const ef of efMatches) {
|
|
|
|
|
|
if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id);
|
|
|
|
|
|
if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
|
|
|
|
|
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const sfMatch of sfMatches) {
|
|
|
|
|
|
const occupant = sfMatch.participant2Id;
|
|
|
|
|
|
const belongsHere = wanted.get(sfMatch.matchNumber) ?? null;
|
|
|
|
|
|
if (occupant === belongsHere) continue;
|
|
|
|
|
|
|
|
|
|
|
|
if (occupant !== null && !eliminationParticipants.has(occupant)) {
|
|
|
|
|
|
throw new Error(`SF ${sfMatch.matchNumber} participant2 already filled`);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Re-seeding a game that has already been played would rewrite who contested a
|
|
|
|
|
|
// recorded result. Surface that (this message is not one callers swallow) rather
|
|
|
|
|
|
// than quietly corrupting the bracket.
|
|
|
|
|
|
if (occupant !== null && (sfMatch.isComplete || sfMatch.winnerId)) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` +
|
|
|
|
|
|
`so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber });
|
|
|
|
|
|
if (belongsHere !== null) {
|
|
|
|
|
|
slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const reseed: AflSemiFinalReseed = {
|
|
|
|
|
|
vacated: slotsToClear.map((slot) => slot.matchNumber),
|
|
|
|
|
|
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
|
|
|
|
|
|
|
|
|
|
|
|
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
|
|
|
|
|
// same team in both Semi-Finals.
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
await db.transaction(async (tx) => {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
for (const slot of slotsToClear) {
|
|
|
|
|
|
await tx
|
|
|
|
|
|
.update(schema.playoffMatches)
|
|
|
|
|
|
.set({ participant2Id: null, updatedAt: now })
|
|
|
|
|
|
.where(eq(schema.playoffMatches.id, slot.id));
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const slot of slotsToFill) {
|
|
|
|
|
|
await tx
|
|
|
|
|
|
.update(schema.playoffMatches)
|
|
|
|
|
|
.set({ participant2Id: slot.participantId, updatedAt: now })
|
|
|
|
|
|
.where(eq(schema.playoffMatches.id, slot.id));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return reseed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-12 23:21:22 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* AFL-specific advancement logic for the complex double-chance system
|
|
|
|
|
|
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
|
|
|
|
|
*
|
|
|
|
|
|
* Advancement rules:
|
2026-09-04 15:13:13 +00:00
|
|
|
|
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
2026-09-11 18:10:30 +00:00
|
|
|
|
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
|
|
|
|
|
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
|
|
|
|
|
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
2025-11-12 23:21:22 -08:00
|
|
|
|
* - Preliminary Finals: Winner → Grand Final
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function advanceAFLWinner(
|
|
|
|
|
|
match: PlayoffMatch,
|
|
|
|
|
|
winnerId: string,
|
|
|
|
|
|
loserId: string
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const eventId = match.scoringEventId;
|
|
|
|
|
|
|
2026-09-04 21:30:57 +00:00
|
|
|
|
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
|
|
|
|
|
|
// position, so every result re-resolves both slots.
|
2025-11-12 23:21:22 -08:00
|
|
|
|
if (match.round === "Wildcard Round") {
|
2026-09-04 21:30:57 +00:00
|
|
|
|
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
|
2025-11-12 23:21:22 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
|
|
|
|
|
if (match.round === "Qualifying Finals") {
|
|
|
|
|
|
// QF Match 1: Winner → PF1 participant1, Loser → SF1 participant1
|
|
|
|
|
|
// QF Match 2: Winner → PF2 participant1, Loser → SF2 participant1
|
|
|
|
|
|
const pfMatchNumber = match.matchNumber;
|
|
|
|
|
|
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
|
|
|
|
|
|
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
|
|
|
|
|
|
if (pfMatch.participant1Id) throw new Error(`PF ${pfMatchNumber} participant1 already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(pfMatch.id, { participant1Id: winnerId });
|
|
|
|
|
|
|
|
|
|
|
|
// Also advance loser to Semi-Finals
|
|
|
|
|
|
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
|
|
|
|
|
const sfMatch = sfMatches.find((m) => m.matchNumber === match.matchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!sfMatch) throw new Error(`Semi-Finals match ${match.matchNumber} not found`);
|
|
|
|
|
|
if (sfMatch.participant1Id) throw new Error(`SF ${match.matchNumber} participant1 already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(sfMatch.id, { participant1Id: loserId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 18:10:30 +00:00
|
|
|
|
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
|
|
|
|
|
|
// system is a round later, at Semi-Finals → Preliminary Finals. Reconcile both slots so
|
|
|
|
|
|
// a corrected result moves the qualifier instead of leaving the beaten team alive.
|
2025-11-12 23:21:22 -08:00
|
|
|
|
if (match.round === "Elimination Finals") {
|
2026-09-11 18:10:30 +00:00
|
|
|
|
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
|
2025-11-12 23:21:22 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Semi-Finals: Winner → Preliminary Finals
|
|
|
|
|
|
if (match.round === "Semi-Finals") {
|
|
|
|
|
|
// SF Match 1 winner → PF2 participant2
|
|
|
|
|
|
// SF Match 2 winner → PF1 participant2
|
|
|
|
|
|
const pfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
|
|
|
|
|
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
|
|
|
|
|
|
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
|
|
|
|
|
|
if (pfMatch.participant2Id) throw new Error(`PF ${pfMatchNumber} participant2 already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(pfMatch.id, { participant2Id: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Preliminary Finals: Winner → Grand Final
|
|
|
|
|
|
if (match.round === "Preliminary Finals") {
|
|
|
|
|
|
const gfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Grand Final");
|
|
|
|
|
|
const gfMatch = gfMatches[0];
|
|
|
|
|
|
|
|
|
|
|
|
if (!gfMatch) throw new Error("Grand Final match not found");
|
|
|
|
|
|
|
|
|
|
|
|
const participantSlot: 'participant1Id' | 'participant2Id' =
|
|
|
|
|
|
match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
|
|
|
|
|
|
|
|
|
|
|
|
if (gfMatch[participantSlot]) throw new Error(`GF ${participantSlot} already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(gfMatch.id, { [participantSlot]: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Grand Final: No advancement
|
|
|
|
|
|
if (match.round === "Grand Final") {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Advance winner to next round using template-based logic
|
|
|
|
|
|
* Works with any bracket template
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function advanceWinnerTemplate(
|
|
|
|
|
|
matchId: string,
|
|
|
|
|
|
winnerId: string,
|
|
|
|
|
|
template: BracketTemplate
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
// Get the match
|
|
|
|
|
|
const match = await findPlayoffMatchById(matchId);
|
|
|
|
|
|
if (!match) throw new Error("Match not found");
|
|
|
|
|
|
|
2026-04-13 00:51:53 -04:00
|
|
|
|
// Special handling for NBA 20 play-in tournament
|
|
|
|
|
|
if (template.id === "nba_20" && (match.round === "Play-In Round 1" || match.round === "Play-In Round 2")) {
|
|
|
|
|
|
const loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
|
|
|
|
|
if (!loserId) throw new Error("Cannot determine loser for NBA play-in advancement");
|
|
|
|
|
|
return await advanceNBAPlayInWinner(match, winnerId, loserId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.
Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.
The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.
Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.
Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.
Also:
- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
hardcoded a 5th-place floor for winners of non-scoring rounds feeding
a scoring one, which is wrong inside a losers bracket where a win can
guarantee only 7th. Opt-in, so no existing template changes behavior.
- Fixes TabbedBracketLayout's mobile path, which built its match map
unfiltered and so would have merged U.S. and International games into
one column. No-op for NCAA and NBA, whose groups already cover every
match in their phases.
- Rewrites the LLWS Monte Carlo simulator, which still modelled the
retired pool-play format (5 teams per pool, then a 4-team bracket per
side) and no longer described the tournament being scored. It now runs
the real 10-team double elimination and splits the 5–8 probabilities
into the correct tiers instead of one even four-way split. Legacy
"US:A"/"Intl:B" externalIds are still accepted, read as the side
alone, so seasons configured for the old format keep loading.
Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
|
|
|
|
// Special handling for LLWS 20 double elimination: winners-bracket losers route
|
|
|
|
|
|
// into the elimination bracket instead of being knocked out.
|
|
|
|
|
|
if (template.id === "llws_20") {
|
|
|
|
|
|
const loserId =
|
|
|
|
|
|
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
|
|
|
|
|
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
|
|
|
|
|
|
return await advanceLLWSWinner(match, winnerId, loserId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-12 23:21:22 -08:00
|
|
|
|
// Special handling for AFL 10 double-chance system
|
|
|
|
|
|
// Phase 3.3: AFL has complex winner/loser advancement rules
|
|
|
|
|
|
if (template.id === "afl_10") {
|
|
|
|
|
|
const loserId = match.winnerId === match.participant1Id ? match.participant2Id : match.participant1Id;
|
|
|
|
|
|
if (!loserId) throw new Error("Cannot determine loser for AFL advancement");
|
|
|
|
|
|
return await advanceAFLWinner(match, winnerId, loserId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
// Special handling for NCAA 68 First Four
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// First Four winners advance to Round of 64 slots derived from the regions config
|
2025-11-08 21:18:09 -08:00
|
|
|
|
if (template.id === "ncaa_68" && match.round === "First Four") {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
return await advanceFirstFourWinner(match, winnerId, template);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
// Find current round in template
|
|
|
|
|
|
const currentRoundIndex = template.rounds.findIndex(
|
|
|
|
|
|
(r) => r.name === match.round
|
|
|
|
|
|
);
|
|
|
|
|
|
if (currentRoundIndex === -1) {
|
|
|
|
|
|
throw new Error(`Round '${match.round}' not found in template`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const currentRound = template.rounds[currentRoundIndex];
|
|
|
|
|
|
|
|
|
|
|
|
// Check if there's a next round
|
|
|
|
|
|
if (!currentRound.feedsInto) {
|
|
|
|
|
|
// Championship game - no advancement needed
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nextRound = template.rounds.find((r) => r.name === currentRound.feedsInto);
|
|
|
|
|
|
if (!nextRound) {
|
|
|
|
|
|
throw new Error(`Next round '${currentRound.feedsInto}' not found in template`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Determine next match number and slot
|
|
|
|
|
|
// This uses the same logic as before: matches advance in sequential pairs
|
|
|
|
|
|
const nextMatchNumber = Math.ceil(match.matchNumber / 2);
|
|
|
|
|
|
const participantSlot: 'participant1Id' | 'participant2Id' =
|
|
|
|
|
|
match.matchNumber % 2 === 1 ? 'participant1Id' : 'participant2Id';
|
|
|
|
|
|
|
|
|
|
|
|
// Find the next match
|
|
|
|
|
|
const nextMatches = await findPlayoffMatchesByEventIdAndRound(
|
|
|
|
|
|
match.scoringEventId,
|
|
|
|
|
|
nextRound.name
|
|
|
|
|
|
);
|
|
|
|
|
|
const nextMatch = nextMatches.find((m) => m.matchNumber === nextMatchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!nextMatch) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Next match not found: round=${nextRound.name}, matchNumber=${nextMatchNumber}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if the slot is already filled
|
|
|
|
|
|
if (nextMatch[participantSlot]) {
|
|
|
|
|
|
throw new Error(`Next match ${participantSlot} is already filled`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fill the determined slot
|
|
|
|
|
|
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
|
|
|
|
|
|
// If this round has a loserFeedsInto, also advance the loser to that round
|
|
|
|
|
|
if (currentRound.loserFeedsInto) {
|
|
|
|
|
|
const loserId =
|
|
|
|
|
|
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
|
|
|
|
|
if (loserId) {
|
|
|
|
|
|
const loserRound = template.rounds.find((r) => r.name === currentRound.loserFeedsInto);
|
|
|
|
|
|
if (loserRound) {
|
|
|
|
|
|
const loserMatches = await findPlayoffMatchesByEventIdAndRound(
|
|
|
|
|
|
match.scoringEventId,
|
|
|
|
|
|
loserRound.name
|
|
|
|
|
|
);
|
|
|
|
|
|
const loserMatch = loserMatches[0];
|
|
|
|
|
|
if (loserMatch) {
|
|
|
|
|
|
// SF match 1 loser → participant1Id, SF match 2 loser → participant2Id
|
|
|
|
|
|
const loserSlot: "participant1Id" | "participant2Id" =
|
|
|
|
|
|
match.matchNumber === 1 ? "participant1Id" : "participant2Id";
|
|
|
|
|
|
if (!loserMatch[loserSlot]) {
|
|
|
|
|
|
await updatePlayoffMatch(loserMatch.id, { [loserSlot]: loserId });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-03 13:16:37 -08:00
|
|
|
|
}
|
2025-11-08 21:18:09 -08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-15 21:52:47 -07:00
|
|
|
|
* Advance a First Four winner to their target Round of 64 slot.
|
2025-11-08 21:18:09 -08:00
|
|
|
|
*
|
2026-03-15 21:52:47 -07:00
|
|
|
|
* The target match number is computed dynamically from the template's regions config:
|
|
|
|
|
|
* r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1
|
|
|
|
|
|
*
|
|
|
|
|
|
* The winner always fills participant2Id (they are the lower seed in their matchup).
|
2025-11-08 21:18:09 -08:00
|
|
|
|
*/
|
|
|
|
|
|
async function advanceFirstFourWinner(
|
|
|
|
|
|
firstFourMatch: PlayoffMatch,
|
2026-03-15 21:52:47 -07:00
|
|
|
|
winnerId: string,
|
|
|
|
|
|
template: BracketTemplate
|
2025-11-08 21:18:09 -08:00
|
|
|
|
): Promise<void> {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// Prefer the per-event stored region config (set at bracket generation time);
|
|
|
|
|
|
// fall back to the template's built-in regions for backwards compatibility.
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const eventRow = await db.query.scoringEvents.findFirst({
|
|
|
|
|
|
where: eq(schema.scoringEvents.id, firstFourMatch.scoringEventId),
|
|
|
|
|
|
columns: { bracketRegionConfig: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
const regions =
|
|
|
|
|
|
(eventRow?.bracketRegionConfig as BracketRegion[] | null) ?? template.regions;
|
|
|
|
|
|
|
|
|
|
|
|
if (!regions) {
|
|
|
|
|
|
throw new Error("NCAA 68 template requires regions config for First Four advancement");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const slotMap = buildNCAA68SlotMap(regions);
|
|
|
|
|
|
const ffIndex = firstFourMatch.matchNumber - 1; // 0-indexed
|
|
|
|
|
|
|
|
|
|
|
|
if (ffIndex < 0 || ffIndex >= slotMap.playInOffsets.length) {
|
2025-11-08 21:18:09 -08:00
|
|
|
|
throw new Error(`Invalid First Four match number: ${firstFourMatch.matchNumber}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
const { regionIndex, seedSlot } = slotMap.playInOffsets[ffIndex];
|
|
|
|
|
|
const matchIndexInRegion = matchIndexForSeedSlot(seedSlot);
|
|
|
|
|
|
// Each region contributes exactly 8 matches to the Round of 64, in region order
|
|
|
|
|
|
const r64MatchNumber = regionIndex * 8 + matchIndexInRegion + 1;
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
const roundOf64Matches = await findPlayoffMatchesByEventIdAndRound(
|
|
|
|
|
|
firstFourMatch.scoringEventId,
|
|
|
|
|
|
"Round of 64"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === r64MatchNumber);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
if (!targetMatch) {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
throw new Error(`Round of 64 match ${r64MatchNumber} not found`);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetMatch.participant2Id) {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
throw new Error(`Round of 64 match ${r64MatchNumber} participant2Id is already filled`);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
|
|
|
|
|
|
}
|
2026-02-14 22:30:12 -08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Generate FIFA 48 knockout bracket (empty, no participants assigned)
|
|
|
|
|
|
* Creates 31 matches across 5 rounds identical to simple_32 structure.
|
|
|
|
|
|
* Participants are assigned later via assignParticipantsToKnockout after group stage.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function generateFIFA48Bracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
template: BracketTemplate
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const round of template.rounds) {
|
|
|
|
|
|
for (let i = 0; i < round.matchCount; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: round.name,
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: round.isScoring,
|
|
|
|
|
|
templateRound: round.name,
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Assign participants to Round of 32 knockout matches
|
|
|
|
|
|
* Used after group stage to populate the empty knockout bracket
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param eventId - The scoring event ID
|
|
|
|
|
|
* @param assignments - Array of { matchNumber, slot, participantId }
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function assignParticipantsToKnockout(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
assignments: Array<{
|
|
|
|
|
|
matchNumber: number;
|
|
|
|
|
|
slot: "participant1Id" | "participant2Id";
|
|
|
|
|
|
participantId: string;
|
|
|
|
|
|
}>
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
// Get Round of 32 matches
|
|
|
|
|
|
const r32Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Round of 32");
|
|
|
|
|
|
|
|
|
|
|
|
for (const assignment of assignments) {
|
|
|
|
|
|
const match = r32Matches.find((m) => m.matchNumber === assignment.matchNumber);
|
|
|
|
|
|
if (!match) {
|
|
|
|
|
|
throw new Error(`Round of 32 match ${assignment.matchNumber} not found`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (match[assignment.slot]) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Round of 32 match ${assignment.matchNumber} ${assignment.slot} is already filled`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(match.id, {
|
|
|
|
|
|
[assignment.slot]: assignment.participantId,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124
* Add NCAA Football CFP simulator (12-team bracket)
Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).
- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
probability distributions, seeding, edge cases, futures blending
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix lint errors in NCAA Football CFP simulator
Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TS2345 in NCAA football simulator test
Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:48:32 -04:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Generate College Football Playoff 12-team bracket.
|
|
|
|
|
|
*
|
|
|
|
|
|
* First Round (seeds 5–12, 4 games, not scoring):
|
|
|
|
|
|
* Match 1: 5 vs 12
|
|
|
|
|
|
* Match 2: 6 vs 11
|
|
|
|
|
|
* Match 3: 7 vs 10
|
|
|
|
|
|
* Match 4: 8 vs 9
|
|
|
|
|
|
*
|
|
|
|
|
|
* Quarterfinals (4 games, scoring starts):
|
|
|
|
|
|
* Match 1: 1 vs First Round match 4 winner (8/9)
|
|
|
|
|
|
* Match 2: 4 vs First Round match 1 winner (5/12)
|
|
|
|
|
|
* Match 3: 3 vs First Round match 2 winner (6/11)
|
|
|
|
|
|
* Match 4: 2 vs First Round match 3 winner (7/10)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Semifinals and National Championship: TBD vs TBD
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function generateCFP12Bracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
template: BracketTemplate,
|
|
|
|
|
|
participantIds?: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// First Round: seeds 5–12 (indices 4–11), top 4 (indices 0–3) have byes
|
|
|
|
|
|
const firstRoundSeeding: [number, number][] = [
|
|
|
|
|
|
[4, 11], // 5 vs 12
|
|
|
|
|
|
[5, 10], // 6 vs 11
|
|
|
|
|
|
[6, 9], // 7 vs 10
|
|
|
|
|
|
[7, 8], // 8 vs 9
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const firstRound = template.rounds[0]; // "First Round"
|
|
|
|
|
|
for (let i = 0; i < firstRoundSeeding.length; i++) {
|
|
|
|
|
|
const [s1, s2] = firstRoundSeeding[i];
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: firstRound.name,
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds ? participantIds[s1] : null,
|
|
|
|
|
|
participant2Id: participantIds ? participantIds[s2] : null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
templateRound: firstRound.name,
|
|
|
|
|
|
seedInfo: `${s1 + 1} vs ${s2 + 1}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Quarterfinals: seeds 1–4 (indices 0–3) receive byes, face First Round winners
|
|
|
|
|
|
// Matchup order mirrors CFP bracket: 1 vs 8/9 winner, 4 vs 5/12 winner, etc.
|
|
|
|
|
|
const quarterfinalsSeeding: [number, string][] = [
|
|
|
|
|
|
[0, "1 vs TBD"], // 1 seed vs First Round match 4 winner (8v9)
|
|
|
|
|
|
[3, "4 vs TBD"], // 4 seed vs First Round match 1 winner (5v12)
|
|
|
|
|
|
[2, "3 vs TBD"], // 3 seed vs First Round match 2 winner (6v11)
|
|
|
|
|
|
[1, "2 vs TBD"], // 2 seed vs First Round match 3 winner (7v10)
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const quarterfinalsRound = template.rounds[1]; // "Quarterfinals"
|
|
|
|
|
|
for (let i = 0; i < quarterfinalsSeeding.length; i++) {
|
|
|
|
|
|
const [byeSeedIndex, seedInfo] = quarterfinalsSeeding[i];
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: quarterfinalsRound.name,
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds ? participantIds[byeSeedIndex] : null,
|
|
|
|
|
|
participant2Id: null, // Filled when First Round winner is known
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: true,
|
|
|
|
|
|
templateRound: quarterfinalsRound.name,
|
|
|
|
|
|
seedInfo,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Semifinals and National Championship: all TBD
|
|
|
|
|
|
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
|
|
|
|
|
|
const round = template.rounds[roundIndex];
|
|
|
|
|
|
for (let i = 0; i < round.matchCount; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: round.name,
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: round.isScoring,
|
|
|
|
|
|
templateRound: round.name,
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
2026-04-13 00:51:53 -04:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Generate NBA 20-team bracket (Play-In + Playoffs).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Participant array layout (indices 0-19):
|
|
|
|
|
|
* [0–9] East seeds 1–10 (E1=0, E2=1, ..., E10=9)
|
|
|
|
|
|
* [10–19] West seeds 1–10 (W1=10, W2=11, ..., W10=19)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Play-In Round 1 (4 matches):
|
|
|
|
|
|
* M1: E7(6) vs E8(7) M2: E9(8) vs E10(9)
|
|
|
|
|
|
* M3: W7(16) vs W8(17) M4: W9(18) vs W10(19)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Play-In Round 2 (2 matches, TBD):
|
|
|
|
|
|
* M1: East loser(7v8) vs East winner(9v10) → E8 seed
|
|
|
|
|
|
* M2: West loser(7v8) vs West winner(9v10) → W8 seed
|
|
|
|
|
|
*
|
|
|
|
|
|
* First Round (8 series — bracket order for correct Conference Semis matchups via ceil logic):
|
|
|
|
|
|
* M1: E1 vs E8 (PIR2-M1 winner) M2: E4 vs E5
|
|
|
|
|
|
* M3: E2 vs E7 (PIR1-M1 winner) M4: E3 vs E6
|
|
|
|
|
|
* M5: W1 vs W8 (PIR2-M2 winner) M6: W4 vs W5
|
|
|
|
|
|
* M7: W2 vs W7 (PIR1-M3 winner) M8: W3 vs W6
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function generateNBA20Bracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
template: BracketTemplate,
|
|
|
|
|
|
participantIds?: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
const p = (idx: number): string | null =>
|
|
|
|
|
|
participantIds ? (participantIds[idx] ?? null) : null;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Play-In Round 1 (4 matches) ───────────────────────────────────────────────
|
|
|
|
|
|
const pir1Seeding: [number, number, string][] = [
|
|
|
|
|
|
[6, 7, "East: 7 vs 8"],
|
|
|
|
|
|
[8, 9, "East: 9 vs 10"],
|
|
|
|
|
|
[16, 17, "West: 7 vs 8"],
|
|
|
|
|
|
[18, 19, "West: 9 vs 10"],
|
|
|
|
|
|
];
|
|
|
|
|
|
for (let i = 0; i < pir1Seeding.length; i++) {
|
|
|
|
|
|
const [idx1, idx2, seedInfo] = pir1Seeding[i];
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Play-In Round 1",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: p(idx1),
|
|
|
|
|
|
participant2Id: p(idx2),
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
templateRound: "Play-In Round 1",
|
|
|
|
|
|
seedInfo,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Play-In Round 2 (2 matches, TBD) ─────────────────────────────────────────
|
|
|
|
|
|
const pir2SeedInfo = [
|
|
|
|
|
|
"East: 7/8 loser vs 9/10 winner",
|
|
|
|
|
|
"West: 7/8 loser vs 9/10 winner",
|
|
|
|
|
|
];
|
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Play-In Round 2",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
templateRound: "Play-In Round 2",
|
|
|
|
|
|
seedInfo: pir2SeedInfo[i],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── First Round (8 series) ────────────────────────────────────────────────────
|
|
|
|
|
|
// IMPORTANT: This match ordering is load-bearing.
|
|
|
|
|
|
// NBASimulator.simulateBracketAware() hard-codes match numbers to derive play-in
|
2026-04-13 01:25:32 -04:00
|
|
|
|
// seed slots: FR M1 p2 = E8, FR M3 p2 = E7, FR M5 p2 = W8, FR M7 p2 = W7.
|
2026-04-13 00:51:53 -04:00
|
|
|
|
// Do not change the match order without updating the simulator's resolveGame/
|
|
|
|
|
|
// resolveSeries calls and the corresponding test fixtures.
|
|
|
|
|
|
const firstRoundSeeding: [number | null, number | null, string][] = [
|
|
|
|
|
|
[0, null, "East: 1 vs 8"],
|
|
|
|
|
|
[3, 4, "East: 4 vs 5"],
|
|
|
|
|
|
[1, null, "East: 2 vs 7"],
|
|
|
|
|
|
[2, 5, "East: 3 vs 6"],
|
|
|
|
|
|
[10, null, "West: 1 vs 8"],
|
|
|
|
|
|
[13, 14, "West: 4 vs 5"],
|
|
|
|
|
|
[11, null, "West: 2 vs 7"],
|
|
|
|
|
|
[12, 15, "West: 3 vs 6"],
|
|
|
|
|
|
];
|
|
|
|
|
|
for (let i = 0; i < firstRoundSeeding.length; i++) {
|
|
|
|
|
|
const [idx1, idx2, seedInfo] = firstRoundSeeding[i];
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "First Round",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: idx1 !== null ? p(idx1) : null,
|
|
|
|
|
|
participant2Id: idx2 !== null ? p(idx2) : null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
templateRound: "First Round",
|
|
|
|
|
|
seedInfo,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Conference Semifinals, Conference Finals, NBA Finals (all TBD) ────────────
|
|
|
|
|
|
for (let roundIndex = 3; roundIndex < template.rounds.length; roundIndex++) {
|
|
|
|
|
|
const round = template.rounds[roundIndex];
|
|
|
|
|
|
for (let i = 0; i < round.matchCount; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: round.name,
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: round.isScoring,
|
|
|
|
|
|
templateRound: round.name,
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 22:16:57 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Returns true if the loser of the given match advances to another round rather
|
|
|
|
|
|
* than being eliminated. Use this to avoid recording a premature elimination result.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Currently only applies to NBA Play-In Round 1 matches 1 and 3 (the 7v8 games),
|
|
|
|
|
|
* whose losers go on to Play-In Round 2.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function doesLoserAdvance(
|
|
|
|
|
|
round: string,
|
|
|
|
|
|
matchNumber: number,
|
|
|
|
|
|
templateId: string
|
|
|
|
|
|
): boolean {
|
2026-04-15 11:27:00 -07:00
|
|
|
|
// NBA Play-In Round 1: 7v8 games (M1, M3) — loser gets a second chance in Round 2.
|
|
|
|
|
|
// 9v10 games (M2, M4) — loser is immediately eliminated.
|
2026-04-14 22:16:57 -07:00
|
|
|
|
if (templateId === "nba_20" && round === "Play-In Round 1") {
|
|
|
|
|
|
return matchNumber === 1 || matchNumber === 3;
|
|
|
|
|
|
}
|
2026-04-15 11:27:00 -07:00
|
|
|
|
// AFL Qualifying Finals: both losers (1v4 and 2v3) advance to Semi-Finals.
|
|
|
|
|
|
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.
Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.
The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.
Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.
Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.
Also:
- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
hardcoded a 5th-place floor for winners of non-scoring rounds feeding
a scoring one, which is wrong inside a losers bracket where a win can
guarantee only 7th. Opt-in, so no existing template changes behavior.
- Fixes TabbedBracketLayout's mobile path, which built its match map
unfiltered and so would have merged U.S. and International games into
one column. No-op for NCAA and NBA, whose groups already cover every
match in their phases.
- Rewrites the LLWS Monte Carlo simulator, which still modelled the
retired pool-play format (5 teams per pool, then a 4-team bracket per
side) and no longer described the tournament being scored. It now runs
the real 10-team double elimination and splits the 5–8 probabilities
into the correct tiers instead of one even four-way split. Legacy
"US:A"/"Intl:B" externalIds are still accepted, read as the side
alone, so seasons configured for the old format keep loading.
Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
|
|
|
|
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
|
|
|
|
|
|
// must not be recorded as an elimination. (Winners Final and Bracket Championship
|
|
|
|
|
|
// are scoring rounds and are handled via loserIsPartial instead.)
|
|
|
|
|
|
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-04-14 22:16:57 -07:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 00:51:53 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* NBA play-in advancement logic.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Play-In Round 1:
|
2026-04-13 01:25:32 -04:00
|
|
|
|
* M1 (E7v8): winner → First Round M3 p2 (E7 seed); loser → PIR2 M1 p1
|
2026-04-13 00:51:53 -04:00
|
|
|
|
* M2 (E9v10): winner → PIR2 M1 p2; loser eliminated
|
2026-04-13 01:25:32 -04:00
|
|
|
|
* M3 (W7v8): winner → First Round M7 p2 (W7 seed); loser → PIR2 M2 p1
|
2026-04-13 00:51:53 -04:00
|
|
|
|
* M4 (W9v10): winner → PIR2 M2 p2; loser eliminated
|
|
|
|
|
|
*
|
|
|
|
|
|
* Play-In Round 2:
|
|
|
|
|
|
* M1: winner → First Round M1 p2 (E8 seed); loser eliminated
|
|
|
|
|
|
* M2: winner → First Round M5 p2 (W8 seed); loser eliminated
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function advanceNBAPlayInWinner(
|
|
|
|
|
|
match: PlayoffMatch,
|
|
|
|
|
|
winnerId: string,
|
|
|
|
|
|
loserId: string
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const eventId = match.scoringEventId;
|
|
|
|
|
|
|
|
|
|
|
|
if (match.round === "Play-In Round 1") {
|
|
|
|
|
|
if (match.matchNumber === 1) {
|
2026-04-13 01:25:32 -04:00
|
|
|
|
// E7v8: winner → First Round M3 p2 (E7 seed); loser → PIR2 M1 p1
|
|
|
|
|
|
// FR M3 is "E2 vs E7": E2 is already at p1, E7 fills p2.
|
2026-04-13 00:51:53 -04:00
|
|
|
|
const [frMatches, pir2Matches] = await Promise.all([
|
|
|
|
|
|
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
|
|
|
|
|
|
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const frM3 = frMatches.find((m) => m.matchNumber === 3);
|
|
|
|
|
|
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
|
|
|
|
|
|
if (!frM3) throw new Error("First Round match 3 not found");
|
|
|
|
|
|
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
|
2026-04-13 01:25:32 -04:00
|
|
|
|
if (frM3.participant2Id) throw new Error("First Round M3 participant2 already filled");
|
2026-04-13 00:51:53 -04:00
|
|
|
|
if (pir2M1.participant1Id) throw new Error("Play-In Round 2 M1 participant1 already filled");
|
|
|
|
|
|
await Promise.all([
|
2026-04-13 01:25:32 -04:00
|
|
|
|
updatePlayoffMatch(frM3.id, { participant2Id: winnerId }),
|
2026-04-13 00:51:53 -04:00
|
|
|
|
updatePlayoffMatch(pir2M1.id, { participant1Id: loserId }),
|
|
|
|
|
|
]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (match.matchNumber === 2) {
|
|
|
|
|
|
// E9v10: winner → PIR2 M1 p2; loser eliminated
|
|
|
|
|
|
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
|
|
|
|
|
|
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
|
|
|
|
|
|
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
|
|
|
|
|
|
if (pir2M1.participant2Id) throw new Error("Play-In Round 2 M1 participant2 already filled");
|
|
|
|
|
|
await updatePlayoffMatch(pir2M1.id, { participant2Id: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (match.matchNumber === 3) {
|
2026-04-13 01:25:32 -04:00
|
|
|
|
// W7v8: winner → First Round M7 p2 (W7 seed); loser → PIR2 M2 p1
|
|
|
|
|
|
// FR M7 is "W2 vs W7": W2 is already at p1, W7 fills p2.
|
2026-04-13 00:51:53 -04:00
|
|
|
|
const [frMatches, pir2Matches] = await Promise.all([
|
|
|
|
|
|
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
|
|
|
|
|
|
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const frM7 = frMatches.find((m) => m.matchNumber === 7);
|
|
|
|
|
|
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
|
|
|
|
|
|
if (!frM7) throw new Error("First Round match 7 not found");
|
|
|
|
|
|
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
|
2026-04-13 01:25:32 -04:00
|
|
|
|
if (frM7.participant2Id) throw new Error("First Round M7 participant2 already filled");
|
2026-04-13 00:51:53 -04:00
|
|
|
|
if (pir2M2.participant1Id) throw new Error("Play-In Round 2 M2 participant1 already filled");
|
|
|
|
|
|
await Promise.all([
|
2026-04-13 01:25:32 -04:00
|
|
|
|
updatePlayoffMatch(frM7.id, { participant2Id: winnerId }),
|
2026-04-13 00:51:53 -04:00
|
|
|
|
updatePlayoffMatch(pir2M2.id, { participant1Id: loserId }),
|
|
|
|
|
|
]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (match.matchNumber === 4) {
|
|
|
|
|
|
// W9v10: winner → PIR2 M2 p2; loser eliminated
|
|
|
|
|
|
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
|
|
|
|
|
|
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
|
|
|
|
|
|
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
|
|
|
|
|
|
if (pir2M2.participant2Id) throw new Error("Play-In Round 2 M2 participant2 already filled");
|
|
|
|
|
|
await updatePlayoffMatch(pir2M2.id, { participant2Id: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
throw new Error(`Unknown Play-In Round 1 match number: ${match.matchNumber}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (match.round === "Play-In Round 2") {
|
|
|
|
|
|
if (match.matchNumber === 1) {
|
|
|
|
|
|
// E8 seed: winner → First Round M1 p2; loser eliminated
|
|
|
|
|
|
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
|
|
|
|
|
|
const frM1 = frMatches.find((m) => m.matchNumber === 1);
|
|
|
|
|
|
if (!frM1) throw new Error("First Round match 1 not found");
|
|
|
|
|
|
if (frM1.participant2Id) throw new Error("First Round M1 participant2 already filled");
|
|
|
|
|
|
await updatePlayoffMatch(frM1.id, { participant2Id: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (match.matchNumber === 2) {
|
|
|
|
|
|
// W8 seed: winner → First Round M5 p2; loser eliminated
|
|
|
|
|
|
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
|
|
|
|
|
|
const frM5 = frMatches.find((m) => m.matchNumber === 5);
|
|
|
|
|
|
if (!frM5) throw new Error("First Round match 5 not found");
|
|
|
|
|
|
if (frM5.participant2Id) throw new Error("First Round M5 participant2 already filled");
|
|
|
|
|
|
await updatePlayoffMatch(frM5.id, { participant2Id: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.
Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.
The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.
Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.
Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.
Also:
- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
hardcoded a 5th-place floor for winners of non-scoring rounds feeding
a scoring one, which is wrong inside a losers bracket where a win can
guarantee only 7th. Opt-in, so no existing template changes behavior.
- Fixes TabbedBracketLayout's mobile path, which built its match map
unfiltered and so would have merged U.S. and International games into
one column. No-op for NCAA and NBA, whose groups already cover every
match in their phases.
- Rewrites the LLWS Monte Carlo simulator, which still modelled the
retired pool-play format (5 teams per pool, then a 4-team bracket per
side) and no longer described the tournament being scored. It now runs
the real 10-team double elimination and splits the 5–8 probabilities
into the correct tiers instead of one even four-way split. Legacy
"US:A"/"Intl:B" externalIds are still accepted, read as the side
alone, so seasons configured for the old format keep loading.
Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
|
|
|
|
|
|
|
|
|
|
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
|
|
|
|
|
|
|
Lay out brackets from the feeder graph
The LLWS bracket didn't read as a bracket: cards sat above games that
don't feed them, connectors joined the wrong pairs, and several games had
no line at all.
The stored data was correct — LLWS_ADVANCEMENT already matches the
official 2026 LLBWS bracket game for game. The renderer was the problem.
TreeColumns placed cards at `index * (height / roundSize)` and
ConnectorColumn assumed matches 2k and 2k+1 feed match k, which holds
only for an exact halving. The LLWS winners bracket is not one: two of
the four Opening Round games skip Winners Round 2 and go straight to the
semifinals, so those two got stranded in column one with nothing beside
them, and the halving branch drew confident, wrong connectors for the
rest.
Lay out from the graph instead. app/lib/bracket-layout.ts inverts a
template's advancement into "what fills each slot", then assigns columns
by depth from the group's final, orders each column by the parent's slot
order, and centres each card on its feeders. Counting back from the final
is what makes a printed bracket line up: a team entering late is drawn in
the column where it actually plays. This reproduces the official
International bracket exactly, and fixes Elimination Round 3, where the
official bracket prints the later game on top but match-number sort put
it below.
Because column is depth, every in-group edge spans exactly one gutter, so
connectors now draw for unplayed games too. Cards also take a fixed
height rather than stretching to fill their column, which is what made a
lone final tower over the rest.
Empty slots name their source — "Loser of Winners SF 1" rather than
"TBD". That is the only way to show the feeds crossing between the
winners and elimination brackets, which render as separate trees.
Also:
- Move the LLWS routing table to app/lib/llws-bracket.ts so the renderer
can import it without pulling the database context into the browser
bundle; models/playoff-match re-exports it.
- Page the mobile view one group at a time, matching desktop. A whole
double-elimination phase is a DAG, not a tree, so its columns would be
arbitrary.
- Add a clear-bracket admin action. Nothing else could rewrite a match's
participants, so a mis-seeded bracket had no repair path at all.
- Lift the PDF transcription into app/test/fixtures/llws-bracket.ts so the
routing and layout tests check against one copy of the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 17:50:59 +00:00
|
|
|
|
// The routing table itself is pure and lives in lib/ so the renderer can import it
|
|
|
|
|
|
// without pulling the database context into the browser bundle. Re-exported here so
|
|
|
|
|
|
// existing server-side callers and tests keep their import path.
|
|
|
|
|
|
export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
|
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.
Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.
The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.
Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.
Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.
Also:
- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
hardcoded a 5th-place floor for winners of non-scoring rounds feeding
a scoring one, which is wrong inside a losers bracket where a win can
guarantee only 7th. Opt-in, so no existing template changes behavior.
- Fixes TabbedBracketLayout's mobile path, which built its match map
unfiltered and so would have merged U.S. and International games into
one column. No-op for NCAA and NBA, whose groups already cover every
match in their phases.
- Rewrites the LLWS Monte Carlo simulator, which still modelled the
retired pool-play format (5 teams per pool, then a 4-team bracket per
side) and no longer described the tournament being scored. It now runs
the real 10-team double elimination and splits the 5–8 probabilities
into the correct tiers instead of one even four-way split. Legacy
"US:A"/"Intl:B" externalIds are still accepted, read as the side
alone, so seasons configured for the old format keep loading.
Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Generate the 20-team LLWS double-elimination bracket (38 matches).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Only the Opening Round and the four bye slots receive participants up front;
|
|
|
|
|
|
* everything else is filled by advanceLLWSWinner as games complete.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Participant array layout (see LLWS_20 in lib/bracket-templates):
|
|
|
|
|
|
* [0–7] U.S. Opening Round teams, two per game
|
|
|
|
|
|
* [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1
|
|
|
|
|
|
* [10–17] International Opening Round teams, two per game
|
|
|
|
|
|
* [18,19] International bye teams → Winners Round 2 M3 / M4 participant1
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function generateLLWS20Bracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
template: BracketTemplate,
|
|
|
|
|
|
participantIds?: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
const p = (idx: number): string | null =>
|
|
|
|
|
|
participantIds ? (participantIds[idx] ?? null) : null;
|
|
|
|
|
|
|
|
|
|
|
|
const sides = [
|
|
|
|
|
|
{ side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 },
|
|
|
|
|
|
{ side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// ── Opening Round: 4 games per side, both slots seeded ──────────────────────
|
|
|
|
|
|
for (const { side, label, openingBase } of sides) {
|
|
|
|
|
|
for (let local = 1; local <= 4; local++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Opening Round",
|
|
|
|
|
|
matchNumber: llwsMatchNumber("Opening Round", side, local),
|
|
|
|
|
|
participant1Id: p(openingBase + (local - 1) * 2),
|
|
|
|
|
|
participant2Id: p(openingBase + (local - 1) * 2 + 1),
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
templateRound: "Opening Round",
|
|
|
|
|
|
seedInfo: `${label} Opening ${local}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─
|
|
|
|
|
|
for (const { side, label, byeBase } of sides) {
|
|
|
|
|
|
for (let local = 1; local <= 2; local++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Winners Round 2",
|
|
|
|
|
|
matchNumber: llwsMatchNumber("Winners Round 2", side, local),
|
|
|
|
|
|
participant1Id: p(byeBase + (local - 1)),
|
|
|
|
|
|
participant2Id: null, // Opening Round winner
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
templateRound: "Winners Round 2",
|
|
|
|
|
|
seedInfo: `${label} Bye ${local} vs Opening ${local} winner`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Every remaining round starts empty ──────────────────────────────────────
|
|
|
|
|
|
const remaining = template.rounds.filter(
|
|
|
|
|
|
(r) => r.name !== "Opening Round" && r.name !== "Winners Round 2"
|
|
|
|
|
|
);
|
|
|
|
|
|
for (const round of remaining) {
|
|
|
|
|
|
for (let i = 1; i <= round.matchCount; i++) {
|
|
|
|
|
|
// Championship/Consolation are single shared games; everything else is per-side.
|
|
|
|
|
|
const perSide = round.matchCount > 1;
|
|
|
|
|
|
const label = perSide
|
|
|
|
|
|
? llwsSideAndLocal(round.name, i).side === 0
|
|
|
|
|
|
? "U.S."
|
|
|
|
|
|
: "Intl"
|
|
|
|
|
|
: null;
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: round.name,
|
|
|
|
|
|
matchNumber: i,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: round.isScoring,
|
|
|
|
|
|
templateRound: round.name,
|
|
|
|
|
|
seedInfo: label ? `${label} ${round.name}` : null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* LLWS advancement: routes the winner forward and, in the winners bracket, routes the
|
|
|
|
|
|
* loser into the elimination bracket rather than eliminating them.
|
|
|
|
|
|
*
|
|
|
|
|
|
* All routing decisions live in resolveLLWSAdvancement; this function only writes.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function advanceLLWSWinner(
|
|
|
|
|
|
match: PlayoffMatch,
|
|
|
|
|
|
winnerId: string,
|
|
|
|
|
|
loserId: string
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const eventId = match.scoringEventId;
|
|
|
|
|
|
const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
// Winner and loser can land in different rounds, so resolve each independently.
|
|
|
|
|
|
const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = [];
|
|
|
|
|
|
if (winner) moves.push({ destination: winner, participantId: winnerId });
|
|
|
|
|
|
if (loser) moves.push({ destination: loser, participantId: loserId });
|
|
|
|
|
|
|
|
|
|
|
|
for (const { destination, participantId } of moves) {
|
|
|
|
|
|
const targetMatches = await findPlayoffMatchesByEventIdAndRound(
|
|
|
|
|
|
eventId,
|
|
|
|
|
|
destination.round
|
|
|
|
|
|
);
|
|
|
|
|
|
const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber);
|
|
|
|
|
|
if (!target) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (target[destination.slot]) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Next match ${destination.slot} is already filled ` +
|
|
|
|
|
|
`(round=${destination.round}, matchNumber=${destination.matchNumber})`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
await updatePlayoffMatch(target.id, { [destination.slot]: participantId });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|