brackt/app/models/scoring-rules.ts
Chris Parsons 338979e0a8
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

180 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types";
// Re-export for convenience
export { DEFAULT_SCORING_RULES, type ScoringRules };
/**
* Get scoring rules for a season
*/
export async function getScoringRules(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<ScoringRules | null> {
const db = providedDb || database();
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) {
return null;
}
return {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
pointsFor8th: season.pointsFor8th,
};
}
/**
* Update scoring rules for a season
*/
export async function updateScoringRules(
seasonId: string,
rules: Partial<ScoringRules>,
providedDb?: ReturnType<typeof database>
): Promise<ScoringRules> {
const db = providedDb || database();
const [updated] = await db
.update(schema.seasons)
.set(rules)
.where(eq(schema.seasons.id, seasonId))
.returning({
pointsFor1st: schema.seasons.pointsFor1st,
pointsFor2nd: schema.seasons.pointsFor2nd,
pointsFor3rd: schema.seasons.pointsFor3rd,
pointsFor4th: schema.seasons.pointsFor4th,
pointsFor5th: schema.seasons.pointsFor5th,
pointsFor6th: schema.seasons.pointsFor6th,
pointsFor7th: schema.seasons.pointsFor7th,
pointsFor8th: schema.seasons.pointsFor8th,
});
return updated;
}
/**
* Calculate fantasy points for a given placement based on season scoring rules
*/
export function calculateFantasyPoints(
placement: number,
rules: ScoringRules
): number {
const pointsMap: Record<number, number> = {
1: rules.pointsFor1st,
2: rules.pointsFor2nd,
3: rules.pointsFor3rd,
4: rules.pointsFor4th,
5: rules.pointsFor5th,
6: rules.pointsFor6th,
7: rules.pointsFor7th,
8: rules.pointsFor8th,
};
return pointsMap[placement] || 0;
}
/**
* Calculate averaged points for shared placements (e.g., playoff ties)
* Used when multiple participants share positions
*
* Example: 4 teams lose in quarterfinals, they share positions 5-8
* Average = (25 + 25 + 15 + 15) / 4 = 20 points each
*/
export function calculateAveragedPoints(
placements: number[],
rules: ScoringRules
): number {
if (placements.length === 0) return 0;
const total = placements.reduce((sum, placement) => {
return sum + calculateFantasyPoints(placement, rules);
}, 0);
return total / placements.length;
}
/**
* Tier definitions for brackets where positions 58 split into two separate pairs.
*
* Most brackets (standard single-elimination) have ONE tier covering positions 58:
* 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 58 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])
*/
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]);
/**
* 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.
*/
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]);
/**
* Calculate fantasy points for a bracket placement, averaging tied positions.
*
* Standard single-elimination bracket tiers:
* 1st: solo winner
* 2nd: solo finalist
* 3rd-4th: two SF losers share these positions → averaged
* 5th-8th: four QF losers share these positions → averaged
*
* AFL bracket tiers (afl_10):
* 5th-6th: Semi-Finals losers → averaged separately from 7th-8th
* 7th-8th: Elimination Finals losers → averaged separately from 5th-6th
*
* Use this instead of calculateFantasyPoints for playoff_bracket scoring.
*/
export function calculateBracketPoints(
finalPosition: number,
rules: ScoringRules,
bracketTemplateId?: string | null
): number {
if (finalPosition <= 0) return 0;
if (finalPosition === 1) return rules.pointsFor1st;
if (finalPosition === 2) return rules.pointsFor2nd;
if (finalPosition === 3 || finalPosition === 4) {
if (bracketTemplateId && DISTINCT_34_TEMPLATE_IDS.has(bracketTemplateId))
return finalPosition === 3 ? rules.pointsFor3rd : rules.pointsFor4th;
return calculateAveragedPoints([3, 4], rules);
}
if (finalPosition >= 5 && finalPosition <= 8) {
if (bracketTemplateId && SPLIT_5678_TEMPLATE_IDS.has(bracketTemplateId)) {
// AFL-style: two separate 2-team tiers within 58
if (finalPosition <= 6) return calculateAveragedPoints([5, 6], rules);
return calculateAveragedPoints([7, 8], rules);
}
// Standard: all four QF losers share one tier
return calculateAveragedPoints([5, 6, 7, 8], rules);
}
return 0;
}
/**
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
* Useful for display purposes
*/
export function getScoringRulesArray(rules: ScoringRules): number[] {
return [
rules.pointsFor1st,
rules.pointsFor2nd,
rules.pointsFor3rd,
rules.pointsFor4th,
rules.pointsFor5th,
rules.pointsFor6th,
rules.pointsFor7th,
rules.pointsFor8th,
];
}