2025-10-28 23:50:50 -07:00
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
|
import { eq } from "drizzle-orm";
|
2025-10-29 00:04:27 -07:00
|
|
|
|
import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types";
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
2025-10-29 00:04:27 -07:00
|
|
|
|
// Re-export for convenience
|
|
|
|
|
|
export { DEFAULT_SCORING_RULES, type ScoringRules };
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 21:58:53 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 12:34:10 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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])
|
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 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])
|
2026-03-17 12:34:10 -07:00
|
|
|
|
*/
|
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
|
|
|
|
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
|
2026-03-17 12:34:10 -07:00
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
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's Consolation Third Place game decides 3rd and 4th head-to-head between
|
|
|
|
|
|
* the two side runners-up.
|
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
|
|
|
|
*/
|
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
|
|
|
|
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
|
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
|
|
|
|
|
2026-03-10 10:27:58 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
|
|
|
|
|
*
|
2026-03-17 12:34:10 -07:00
|
|
|
|
* 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
|
2026-03-10 10:27:58 -07:00
|
|
|
|
*
|
2026-03-17 12:34:10 -07:00
|
|
|
|
* Use this instead of calculateFantasyPoints for playoff_bracket scoring.
|
2026-03-10 10:27:58 -07:00
|
|
|
|
*/
|
|
|
|
|
|
export function calculateBracketPoints(
|
|
|
|
|
|
finalPosition: number,
|
2026-03-17 12:34:10 -07:00
|
|
|
|
rules: ScoringRules,
|
|
|
|
|
|
bracketTemplateId?: string | null
|
2026-03-10 10:27:58 -07:00
|
|
|
|
): number {
|
|
|
|
|
|
if (finalPosition <= 0) return 0;
|
|
|
|
|
|
if (finalPosition === 1) return rules.pointsFor1st;
|
|
|
|
|
|
if (finalPosition === 2) return rules.pointsFor2nd;
|
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 (finalPosition === 3 || finalPosition === 4) {
|
|
|
|
|
|
if (bracketTemplateId && DISTINCT_34_TEMPLATE_IDS.has(bracketTemplateId))
|
|
|
|
|
|
return finalPosition === 3 ? rules.pointsFor3rd : rules.pointsFor4th;
|
2026-03-10 10:27:58 -07:00
|
|
|
|
return calculateAveragedPoints([3, 4], rules);
|
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
|
|
|
|
}
|
2026-03-17 12:34:10 -07:00
|
|
|
|
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
|
2026-03-10 10:27:58 -07:00
|
|
|
|
return calculateAveragedPoints([5, 6, 7, 8], rules);
|
2026-03-17 12:34:10 -07:00
|
|
|
|
}
|
2026-03-10 10:27:58 -07:00
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|