- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet Sixteen winners entering the first scoring round get a floor - AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8])) instead of one shared avg([5-8]) pool - Eliminated teams now always show their correct final rank (e.g. T33 for NCAAM R64 losers) even during partial round scoring, by deriving the rank label from the total match count per round rather than the dynamic still-alive count - Discord notifications now fire when a drafted participant is eliminated with 0 points (e.g. R64 losers whose team score doesn't change) - Discord notifications no longer include all prior matches — scoped to the current batch via matchIds - Discord section headers have spacing; rank medals replaced with numbers - Rounds auto-complete when all matches are marked done; manual Complete Round dropdown removed - Fixed double Discord notification when the last match in a round triggers auto-complete: processPlayoffEvent now accepts skipRecalculate so the caller controls when the notification fires - Fixed falsy check on finalPosition=0 in calculateTeamScore - Undrafted 0-point participants filtered from Eliminated Teams display Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
171 lines
5 KiB
TypeScript
171 lines
5 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"]);
|
||
|
||
/**
|
||
* 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)
|
||
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,
|
||
];
|
||
}
|