* 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>
180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
import { database } from "~/database/context";
|
||
import * as schema from "~/database/schema";
|
||
import { eq } from "drizzle-orm";
|
||
import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types";
|
||
|
||
// Re-export for convenience
|
||
export { DEFAULT_SCORING_RULES, type ScoringRules };
|
||
|
||
/**
|
||
* Get scoring rules for a season
|
||
*/
|
||
export async function getScoringRules(
|
||
seasonId: string,
|
||
providedDb?: ReturnType<typeof database>
|
||
): Promise<ScoringRules | null> {
|
||
const db = providedDb || database();
|
||
|
||
const season = await db.query.seasons.findFirst({
|
||
where: eq(schema.seasons.id, seasonId),
|
||
});
|
||
|
||
if (!season) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
pointsFor1st: season.pointsFor1st,
|
||
pointsFor2nd: season.pointsFor2nd,
|
||
pointsFor3rd: season.pointsFor3rd,
|
||
pointsFor4th: season.pointsFor4th,
|
||
pointsFor5th: season.pointsFor5th,
|
||
pointsFor6th: season.pointsFor6th,
|
||
pointsFor7th: season.pointsFor7th,
|
||
pointsFor8th: season.pointsFor8th,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Update scoring rules for a season
|
||
*/
|
||
export async function updateScoringRules(
|
||
seasonId: string,
|
||
rules: Partial<ScoringRules>,
|
||
providedDb?: ReturnType<typeof database>
|
||
): Promise<ScoringRules> {
|
||
const db = providedDb || database();
|
||
|
||
const [updated] = await db
|
||
.update(schema.seasons)
|
||
.set(rules)
|
||
.where(eq(schema.seasons.id, seasonId))
|
||
.returning({
|
||
pointsFor1st: schema.seasons.pointsFor1st,
|
||
pointsFor2nd: schema.seasons.pointsFor2nd,
|
||
pointsFor3rd: schema.seasons.pointsFor3rd,
|
||
pointsFor4th: schema.seasons.pointsFor4th,
|
||
pointsFor5th: schema.seasons.pointsFor5th,
|
||
pointsFor6th: schema.seasons.pointsFor6th,
|
||
pointsFor7th: schema.seasons.pointsFor7th,
|
||
pointsFor8th: schema.seasons.pointsFor8th,
|
||
});
|
||
|
||
return updated;
|
||
}
|
||
|
||
/**
|
||
* Calculate fantasy points for a given placement based on season scoring rules
|
||
*/
|
||
export function calculateFantasyPoints(
|
||
placement: number,
|
||
rules: ScoringRules
|
||
): number {
|
||
const pointsMap: Record<number, number> = {
|
||
1: rules.pointsFor1st,
|
||
2: rules.pointsFor2nd,
|
||
3: rules.pointsFor3rd,
|
||
4: rules.pointsFor4th,
|
||
5: rules.pointsFor5th,
|
||
6: rules.pointsFor6th,
|
||
7: rules.pointsFor7th,
|
||
8: rules.pointsFor8th,
|
||
};
|
||
|
||
return pointsMap[placement] || 0;
|
||
}
|
||
|
||
/**
|
||
* Calculate averaged points for shared placements (e.g., playoff ties)
|
||
* Used when multiple participants share positions
|
||
*
|
||
* Example: 4 teams lose in quarterfinals, they share positions 5-8
|
||
* Average = (25 + 25 + 15 + 15) / 4 = 20 points each
|
||
*/
|
||
export function calculateAveragedPoints(
|
||
placements: number[],
|
||
rules: ScoringRules
|
||
): number {
|
||
if (placements.length === 0) return 0;
|
||
|
||
const total = placements.reduce((sum, placement) => {
|
||
return sum + calculateFantasyPoints(placement, rules);
|
||
}, 0);
|
||
|
||
return total / placements.length;
|
||
}
|
||
|
||
/**
|
||
* 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])
|
||
*/
|
||
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 5–8
|
||
if (finalPosition <= 6) return calculateAveragedPoints([5, 6], rules);
|
||
return calculateAveragedPoints([7, 8], rules);
|
||
}
|
||
// Standard: all four QF losers share one tier
|
||
return calculateAveragedPoints([5, 6, 7, 8], rules);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* Get points array as a simple ordered list [1st, 2nd, 3rd, ..., 8th]
|
||
* Useful for display purposes
|
||
*/
|
||
export function getScoringRulesArray(rules: ScoringRules): number[] {
|
||
return [
|
||
rules.pointsFor1st,
|
||
rules.pointsFor2nd,
|
||
rules.pointsFor3rd,
|
||
rules.pointsFor4th,
|
||
rules.pointsFor5th,
|
||
rules.pointsFor6th,
|
||
rules.pointsFor7th,
|
||
rules.pointsFor8th,
|
||
];
|
||
}
|