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

150 lines
4.1 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;
}
/**
* 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
*
* Use this instead of calculateFantasyPoints for playoff_bracket scoring.
*
* TODO: This hardcodes 8-team bracket tiers. Future support for larger brackets
* (e.g. 16-team with 9th-16th scoring) will require parameterised tier config.
*/
export function calculateBracketPoints(
finalPosition: number,
rules: ScoringRules
): 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)
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,
];
}