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
208 lines
6.3 KiB
TypeScript
208 lines
6.3 KiB
TypeScript
import { database } from "~/database/context";
|
||
import * as schema from "~/database/schema";
|
||
import { eq } from "drizzle-orm";
|
||
import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types";
|
||
|
||
// Re-export for convenience
|
||
export { DEFAULT_SCORING_RULES, type ScoringRules };
|
||
|
||
/**
|
||
* Get scoring rules for a season
|
||
*/
|
||
export async function getScoringRules(
|
||
seasonId: string,
|
||
providedDb?: ReturnType<typeof database>
|
||
): Promise<ScoringRules | null> {
|
||
const db = providedDb || database();
|
||
|
||
const season = await db.query.seasons.findFirst({
|
||
where: eq(schema.seasons.id, seasonId),
|
||
});
|
||
|
||
if (!season) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
pointsFor1st: season.pointsFor1st,
|
||
pointsFor2nd: season.pointsFor2nd,
|
||
pointsFor3rd: season.pointsFor3rd,
|
||
pointsFor4th: season.pointsFor4th,
|
||
pointsFor5th: season.pointsFor5th,
|
||
pointsFor6th: season.pointsFor6th,
|
||
pointsFor7th: season.pointsFor7th,
|
||
pointsFor8th: season.pointsFor8th,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Update scoring rules for a season
|
||
*/
|
||
export async function updateScoringRules(
|
||
seasonId: string,
|
||
rules: Partial<ScoringRules>,
|
||
providedDb?: ReturnType<typeof database>
|
||
): Promise<ScoringRules> {
|
||
const db = providedDb || database();
|
||
|
||
const [updated] = await db
|
||
.update(schema.seasons)
|
||
.set(rules)
|
||
.where(eq(schema.seasons.id, seasonId))
|
||
.returning({
|
||
pointsFor1st: schema.seasons.pointsFor1st,
|
||
pointsFor2nd: schema.seasons.pointsFor2nd,
|
||
pointsFor3rd: schema.seasons.pointsFor3rd,
|
||
pointsFor4th: schema.seasons.pointsFor4th,
|
||
pointsFor5th: schema.seasons.pointsFor5th,
|
||
pointsFor6th: schema.seasons.pointsFor6th,
|
||
pointsFor7th: schema.seasons.pointsFor7th,
|
||
pointsFor8th: schema.seasons.pointsFor8th,
|
||
});
|
||
|
||
return updated;
|
||
}
|
||
|
||
/**
|
||
* Calculate fantasy points for a given placement based on season scoring rules
|
||
*/
|
||
export function calculateFantasyPoints(
|
||
placement: number,
|
||
rules: ScoringRules
|
||
): number {
|
||
const pointsMap: Record<number, number> = {
|
||
1: rules.pointsFor1st,
|
||
2: rules.pointsFor2nd,
|
||
3: rules.pointsFor3rd,
|
||
4: rules.pointsFor4th,
|
||
5: rules.pointsFor5th,
|
||
6: rules.pointsFor6th,
|
||
7: rules.pointsFor7th,
|
||
8: rules.pointsFor8th,
|
||
};
|
||
|
||
return pointsMap[placement] || 0;
|
||
}
|
||
|
||
/**
|
||
* Calculate averaged points for shared placements (e.g., playoff ties)
|
||
* Used when multiple participants share positions
|
||
*
|
||
* Example: 4 teams lose in quarterfinals, they share positions 5-8
|
||
* Average = (25 + 25 + 15 + 15) / 4 = 20 points each
|
||
*/
|
||
export function calculateAveragedPoints(
|
||
placements: number[],
|
||
rules: ScoringRules
|
||
): number {
|
||
if (placements.length === 0) return 0;
|
||
|
||
const total = placements.reduce((sum, placement) => {
|
||
return sum + calculateFantasyPoints(placement, rules);
|
||
}, 0);
|
||
|
||
return total / placements.length;
|
||
}
|
||
|
||
/**
|
||
* Calculate points for participants sharing a standings placement.
|
||
*
|
||
* Example: two participants tied for 2nd share 2nd and 3rd place points.
|
||
* If a tie extends beyond the scoring range, the extra slots contribute 0.
|
||
*/
|
||
export function calculateSharedPlacementPoints(
|
||
startPlacement: number,
|
||
tiedParticipants: number,
|
||
rules: ScoringRules
|
||
): number {
|
||
if (startPlacement <= 0 || tiedParticipants <= 0) return 0;
|
||
|
||
const placements = Array.from(
|
||
{ length: tiedParticipants },
|
||
(_, index) => startPlacement + index
|
||
);
|
||
|
||
return calculateAveragedPoints(placements, rules);
|
||
}
|
||
|
||
/**
|
||
* Tier definitions for brackets where positions 5–8 split into two separate pairs.
|
||
*
|
||
* Most brackets (standard single-elimination) have ONE tier covering positions 5–8:
|
||
* four QF losers all tie and share the combined prize pool → avg([5,6,7,8]).
|
||
*
|
||
* AFL is different: it has TWO distinct tiers in the 5–8 zone:
|
||
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
|
||
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
|
||
*
|
||
* LLWS has the same shape from its two elimination brackets:
|
||
* - T5-T6: Elimination Final losers (one per side) → avg([5,6])
|
||
* - T7-T8: Elimination Round 4 losers (one per side) → avg([7,8])
|
||
*/
|
||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
|
||
|
||
/**
|
||
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
|
||
* (not averaged). Standard brackets average them because both SF losers tie.
|
||
*
|
||
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
|
||
* the two side runners-up.
|
||
*/
|
||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
|
||
|
||
/**
|
||
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||
*
|
||
* Standard single-elimination bracket tiers:
|
||
* 1st: solo winner
|
||
* 2nd: solo finalist
|
||
* 3rd-4th: two SF losers share these positions → averaged
|
||
* 5th-8th: four QF losers share these positions → averaged
|
||
*
|
||
* AFL bracket tiers (afl_10):
|
||
* 5th-6th: Semi-Finals losers → averaged separately from 7th-8th
|
||
* 7th-8th: Elimination Finals losers → averaged separately from 5th-6th
|
||
*
|
||
* Use this instead of calculateFantasyPoints for playoff_bracket scoring.
|
||
*/
|
||
export function calculateBracketPoints(
|
||
finalPosition: number,
|
||
rules: ScoringRules,
|
||
bracketTemplateId?: string | null
|
||
): number {
|
||
if (finalPosition <= 0) return 0;
|
||
if (finalPosition === 1) return rules.pointsFor1st;
|
||
if (finalPosition === 2) return rules.pointsFor2nd;
|
||
if (finalPosition === 3 || finalPosition === 4) {
|
||
if (bracketTemplateId && DISTINCT_34_TEMPLATE_IDS.has(bracketTemplateId))
|
||
return finalPosition === 3 ? rules.pointsFor3rd : rules.pointsFor4th;
|
||
return calculateAveragedPoints([3, 4], rules);
|
||
}
|
||
if (finalPosition >= 5 && finalPosition <= 8) {
|
||
if (bracketTemplateId && SPLIT_5678_TEMPLATE_IDS.has(bracketTemplateId)) {
|
||
// AFL-style: two separate 2-team tiers within 5–8
|
||
if (finalPosition <= 6) return calculateAveragedPoints([5, 6], rules);
|
||
return calculateAveragedPoints([7, 8], rules);
|
||
}
|
||
// Standard: all four QF losers share one tier
|
||
return calculateAveragedPoints([5, 6, 7, 8], rules);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
|
||
* Useful for display purposes
|
||
*/
|
||
export function getScoringRulesArray(rules: ScoringRules): number[] {
|
||
return [
|
||
rules.pointsFor1st,
|
||
rules.pointsFor2nd,
|
||
rules.pointsFor3rd,
|
||
rules.pointsFor4th,
|
||
rules.pointsFor5th,
|
||
rules.pointsFor6th,
|
||
rules.pointsFor7th,
|
||
rules.pointsFor8th,
|
||
];
|
||
}
|