brackt/app/models/scoring-rules.ts

172 lines
5 KiB
TypeScript
Raw Normal View History

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"]);
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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
*
* 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
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
*
* Use this instead of calculateFantasyPoints for playoff_bracket scoring.
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
*/
export function calculateBracketPoints(
finalPosition: number,
rules: ScoringRules,
bracketTemplateId?: string | null
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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;
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 58
if (finalPosition <= 6) return calculateAveragedPoints([5, 6], rules);
return calculateAveragedPoints([7, 8], rules);
}
// Standard: all four QF losers share one tier
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
return calculateAveragedPoints([5, 6, 7, 8], rules);
}
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
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,
];
}