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
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
|
*
|
|
|
|
|
|
* The result is rounded to the nearest whole point, matching the published rule
|
|
|
|
|
|
* on the /rules page: "the points for all tied positions are combined and split
|
|
|
|
|
|
* equally among them, rounded to the nearest whole point". That page's own
|
|
|
|
|
|
* example rounds down — a three-way tie for 6th–8th is (25 + 15 + 15) / 3 =
|
|
|
|
|
|
* 18.33 → 18 — so this is nearest, not ceiling. Season point values are integer
|
|
|
|
|
|
* columns, so this averaging is the only place fractional points can arise.
|
2025-10-28 23:50:50 -07:00
|
|
|
|
*/
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
|
return Math.round(total / placements.length);
|
2025-10-28 23:50:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Scoring patterns whose participants can share a final placement, so that a tied
|
|
|
|
|
|
* group splits the combined points of the positions it spans.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Both patterns record ties by writing the SAME finalPosition to every tied
|
|
|
|
|
|
* participant, leaving the split to scoring time:
|
|
|
|
|
|
* - qualifying_points — finalizeQualifyingPoints groups participants by QP total
|
|
|
|
|
|
* - season_standings — processSeasonStandings gives a tied group the first
|
|
|
|
|
|
* placement in its range ("if 4 people tie for 5th they all get placement 5")
|
|
|
|
|
|
*
|
|
|
|
|
|
* playoff_bracket is deliberately absent: its ties are structural (both SF losers
|
|
|
|
|
|
* tie for 3rd) and calculateBracketPoints derives the span from the bracket shape
|
|
|
|
|
|
* rather than from a count of results.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const TIE_SPLIT_PATTERNS = new Set(["qualifying_points", "season_standings"]);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether a scoring pattern needs a tie count to score a placement correctly.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Callers must gate their tie-count lookups on this rather than testing a pattern
|
|
|
|
|
|
* name directly. A caller that hardcodes one pattern silently passes
|
|
|
|
|
|
* tiedParticipants: 1 for the other, which reads as "no tie" and awards the full
|
|
|
|
|
|
* placement value — the failure mode that left F1 ties unsplit.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function usesSharedPlacementSplit(
|
|
|
|
|
|
scoringPattern: string | null | undefined
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
return TIE_SPLIT_PATTERNS.has(scoringPattern ?? "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Fantasy points earned by a single drafted participant, for any scoring pattern.
|
|
|
|
|
|
*
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
|
* This is the ONE place the bracket / tie-split / default cascade lives. It
|
|
|
|
|
|
* previously existed as a hand-rolled if/else at six call sites, two of which
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
|
* were missing the qualifying_points arm entirely — that divergence is what made
|
|
|
|
|
|
* a tied golfer worth 15 points on the team page and draft board but 7.5 in the
|
|
|
|
|
|
* standings. Every caller must route through here.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param finalPosition - Placement in the sports season (1-8 scores, 0 = none).
|
|
|
|
|
|
* @param scoringPattern - The sports season's scoringPattern column.
|
|
|
|
|
|
* @param rules - The fantasy season's point values.
|
|
|
|
|
|
* @param opts.bracketTemplateId - Required for playoff_bracket to pick the right
|
|
|
|
|
|
* tier structure (e.g. AFL/LLWS split 5–8 into two pairs).
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
|
* @param opts.tiedParticipants - Required for any pattern where
|
|
|
|
|
|
* usesSharedPlacementSplit is true: how many participants share this
|
|
|
|
|
|
* finalPosition across the WHOLE sports season, not just the ones that were
|
|
|
|
|
|
* drafted. Defaults to 1 (no tie).
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
|
*/
|
|
|
|
|
|
export function calculatePickPoints(
|
|
|
|
|
|
finalPosition: number,
|
|
|
|
|
|
scoringPattern: string | null | undefined,
|
|
|
|
|
|
rules: ScoringRules,
|
|
|
|
|
|
opts?: { bracketTemplateId?: string | null; tiedParticipants?: number }
|
|
|
|
|
|
): number {
|
|
|
|
|
|
if (scoringPattern === "playoff_bracket") {
|
|
|
|
|
|
return calculateBracketPoints(finalPosition, rules, opts?.bracketTemplateId ?? null);
|
|
|
|
|
|
}
|
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.
season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.
The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.
A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.
Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.
The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.
Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.
Every fix is covered by a test confirmed to fail when that fix alone is
reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00
|
|
|
|
if (usesSharedPlacementSplit(scoringPattern)) {
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
|
return calculateSharedPlacementPoints(
|
|
|
|
|
|
finalPosition,
|
|
|
|
|
|
opts?.tiedParticipants ?? 1,
|
|
|
|
|
|
rules
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return calculateFantasyPoints(finalPosition, rules);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|