diff --git a/app/models/__tests__/qualifying-bracket-scoring.test.ts b/app/models/__tests__/qualifying-bracket-scoring.test.ts new file mode 100644 index 0000000..a638123 --- /dev/null +++ b/app/models/__tests__/qualifying-bracket-scoring.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from "vitest"; +import { + deriveBracketQualifyingStates, + getRoundConfig, + getGuaranteedMinimumPosition, + type BracketMatchInput, +} from "../scoring-calculator"; +import { BRACKET_TEMPLATES } from "../../lib/bracket-templates"; +import { calculateSplitQualifyingPoints, DEFAULT_QP_VALUES } from "../qualifying-points"; + +// ── Test fixtures ───────────────────────────────────────────────────────────── + +const SIMPLE_8 = BRACKET_TEMPLATES["simple_8"]; +const getConfig = (round: string) => getRoundConfig(round, "simple_8"); +const states = (matches: BracketMatchInput[]) => + deriveBracketQualifyingStates(matches, SIMPLE_8.rounds, getConfig); + +const QP_MAP = new Map( + DEFAULT_QP_VALUES.map((v) => [v.placement, v.points]) +); +const qpFor = (placement: number, tieCount: number) => + calculateSplitQualifyingPoints(placement, tieCount, QP_MAP); + +/** Helper to build a played match. */ +const played = ( + round: string, + winnerId: string, + loserId: string +): BracketMatchInput => ({ + round, + winnerId, + loserId, + participant1Id: winnerId, + participant2Id: loserId, +}); + +/** Helper to build an as-yet-unplayed match (participants seeded, no result). */ +const pending = ( + round: string, + p1: string, + p2: string +): BracketMatchInput => ({ + round, + winnerId: null, + loserId: null, + participant1Id: p1, + participant2Id: p2, +}); + +// Full simple_8 quarterfinal field: winners t1,t2,t3,t4 / losers t5,t6,t7,t8. +const QF_ALL = [ + played("Quarterfinals", "t1", "t8"), + played("Quarterfinals", "t2", "t7"), + played("Quarterfinals", "t3", "t6"), + played("Quarterfinals", "t4", "t5"), +]; +// Semifinals: t1 & t3 advance. +const SF_ALL = [ + played("Semifinals", "t1", "t2"), + played("Semifinals", "t3", "t4"), +]; +// Final: t1 champion. +const FINAL = [played("Finals", "t1", "t3")]; + +// ── deriveBracketQualifyingStates ───────────────────────────────────────────── + +describe("deriveBracketQualifyingStates (simple_8)", () => { + it("returns an empty map for no matches", () => { + expect(states([]).size).toBe(0); + }); + + it("QF entered: losers lock T5–8, winners floor at T3–4", () => { + const s = states(QF_ALL); + // Losers — final placement 5, tie span 4 (T5–8). + for (const id of ["t5", "t6", "t7", "t8"]) { + expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 }); + } + // Winners — guaranteed at least T3–4: floor placement 3, tie span 2. + for (const id of ["t1", "t2", "t3", "t4"]) { + expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 }); + } + }); + + it("QF+SF entered: SF losers lock T3–4, SF winners floor at 2nd", () => { + const s = states([...QF_ALL, ...SF_ALL]); + // SF losers — final placement 3, tie span 2. + expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 }); + expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); + // SF winners (finalists-in-waiting) — floor placement 2, tie span 1. + expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 }); + expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); + // QF losers unchanged. + expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 }); + }); + + it("full bracket: champion 1st, finalist 2nd, SF losers T3–4, QF losers T5–8", () => { + const s = states([...QF_ALL, ...SF_ALL, ...FINAL]); + expect(s.get("t1")).toEqual({ placement: 1, tieCount: 1 }); // champion + expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); // finalist + expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 }); // SF loser + expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); // SF loser + expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 }); // QF loser + }); + + it("partial QF (2 of 4 played): unplayed teams sit at the T5–8 entry floor", () => { + const matches = [ + played("Quarterfinals", "t1", "t8"), + played("Quarterfinals", "t4", "t5"), + pending("Quarterfinals", "t3", "t6"), + pending("Quarterfinals", "t2", "t7"), + ]; + const s = states(matches); + // Played winners floor at T3–4. + expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 }); + expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); + // Played losers lock T5–8. + expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 }); + expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 }); + // Unplayed teams: entry floor T5–8 (no crash on incomplete round). + for (const id of ["t2", "t3", "t6", "t7"]) { + expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 }); + } + }); + + it("un-setting the Finals winner reverts both finalists to the 2nd-place floor", () => { + const matches = [ + ...QF_ALL, + ...SF_ALL, + pending("Finals", "t1", "t3"), // winner cleared + ]; + const s = states(matches); + expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 }); + expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); + }); + + it("a team eliminated in the QF never gains a later-round floor", () => { + const s = states([...QF_ALL, ...SF_ALL, ...FINAL]); + // t8 lost in the QF and stays T5–8 through the rest of the bracket. + expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 }); + }); + + it("is idempotent — same matches yield the same states", () => { + const a = states([...QF_ALL, ...SF_ALL]); + const b = states([...QF_ALL, ...SF_ALL]); + expect([...a.entries()].sort()).toEqual([...b.entries()].sort()); + }); +}); + +// ── QP values (the worked example) ──────────────────────────────────────────── + +describe("guaranteed-minimum QP per outcome (default config)", () => { + it("matches the worked example: QF win → 9, SF win → 14, Final win → 20", () => { + expect(qpFor(3, 2)).toBeCloseTo(9); // QF winner, guaranteed T3–4 + expect(qpFor(2, 1)).toBeCloseTo(14); // SF winner, guaranteed 2nd + expect(qpFor(1, 1)).toBeCloseTo(20); // champion + }); + + it("losers: QF loss → 4, SF loss → 9, Final loss → 14", () => { + expect(qpFor(5, 4)).toBeCloseTo(4); // (5+5+3+3)/4 + expect(qpFor(3, 2)).toBeCloseTo(9); // (10+8)/2 + expect(qpFor(2, 1)).toBeCloseTo(14); + }); + + it("end-to-end: a QF win immediately yields a 9 QP floor", () => { + const s = states(QF_ALL); + const winner = s.get("t1")!; + expect(qpFor(winner.placement, winner.tieCount)).toBeCloseTo(9); + const loser = s.get("t8")!; + expect(qpFor(loser.placement, loser.tieCount)).toBeCloseTo(4); + }); +}); + +// ── Regression guard: reused ROUND_CONFIG floors are intact ──────────────────── + +describe("ROUND_CONFIG floors (regression guard)", () => { + it("simple_8 Quarterfinals still floors winners at 3rd", () => { + expect(getGuaranteedMinimumPosition("Quarterfinals", "simple_8", true)).toBe(3); + expect(getRoundConfig("Quarterfinals", "simple_8")?.loserPosition).toBe(5); + expect(getRoundConfig("Semifinals", "simple_8")?.winnerFloor).toBe(2); + expect(getRoundConfig("Finals", "simple_8")?.winnerFloor).toBeNull(); + }); +}); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 283b34b..8852f49 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -10,7 +10,7 @@ import { import { getSeasonResults } from "./participant-season-result"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord"; -import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; +import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates"; import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match"; import { getUserDisplayName } from "~/models/user"; import { findDiscordIdsByUserIds } from "~/models/account"; @@ -143,7 +143,7 @@ function doesNonScoringRoundFeedIntoScoringRound( * overrides before falling back to the standard ROUND_CONFIG. * Returns null for unrecognized rounds. */ -function getRoundConfig( +export function getRoundConfig( round: string, bracketTemplateId?: string | null ): RoundScoringConfig | null { @@ -623,6 +623,215 @@ export function getGuaranteedMinimumPosition( return config.winnerFloor; // null for Finals; actual floor for all other rounds } +// ── Qualifying-points bracket scoring ──────────────────────────────────────── + +/** Minimal shape of a playoff match needed to derive qualifying-bracket states. */ +export interface BracketMatchInput { + round: string; + winnerId: string | null; + loserId: string | null; + participant1Id: string | null; + participant2Id: string | null; +} + +/** The guaranteed-minimum placement (and its structural tie span) for one team. */ +export interface BracketQualifyingState { + /** Placement tier the team has locked in (1, 2, 3, 5, …). */ + placement: number; + /** + * Number of bracket slots this placement tier spans (e.g. 4 for T5–8, 2 for + * T3–4, 1 for 1st/2nd). Used to tie-split QP across the tier. This is the + * *structural* span from the template — not the live count of teams currently + * sitting at this placement — so the floor value is stable and idempotent and + * agrees with processQualifyingEvent's finalization re-grouping. + */ + tieCount: number; +} + +/** + * Derive each bracket team's guaranteed-minimum (placement, tieCount) from the + * current set of playoff matches, reusing the standard ROUND_CONFIG floors. + * + * Rules per team (single-elimination ⇒ a team loses at most once): + * - Lost in round R → final placement = loserPosition(R), tier span = matchCount(R). + * (QF loss → 5/T5–8, SF loss → 3/T3–4, Finals loss → 2/finalist) + * - Still alive, highest round won = R: + * · R is the finalization round (winnerFloor null) → champion: winnerPosition ?? 1. + * · otherwise → floor = winnerFloor(R), tier span = matchCount(R.feedsInto). + * (QF win → 3/T3–4 = 9 QP, SF win → 2/finalist = 14 QP) + * - Alive but no match resolved yet → entry floor of the first scoring round + * (QF → 5/T5–8 = 4 QP), mirroring assignCs2EliminationQP's provisional floor. + * + * Pure and exported for unit testing. Teams eliminated in a non-scoring round + * (getConfig returns null) are omitted — they earn no QP from the bracket. + */ +export function deriveBracketQualifyingStates( + matches: BracketMatchInput[], + rounds: BracketRound[], + getConfig: (round: string) => RoundScoringConfig | null +): Map { + const roundIndex = new Map(rounds.map((r, i) => [r.name, i])); + const matchCountByRound = new Map(rounds.map((r) => [r.name, r.matchCount])); + const feedsIntoByRound = new Map(rounds.map((r) => [r.name, r.feedsInto])); + const firstScoringRound = rounds.find((r) => r.isScoring) ?? rounds[0]; + + const participants = new Set(); + const wonRounds = new Map>(); + const lostRound = new Map(); + + for (const m of matches) { + if (m.participant1Id) participants.add(m.participant1Id); + if (m.participant2Id) participants.add(m.participant2Id); + if (m.winnerId) { + participants.add(m.winnerId); + if (!wonRounds.has(m.winnerId)) wonRounds.set(m.winnerId, new Set()); + wonRounds.get(m.winnerId)?.add(m.round); + } + if (m.loserId) { + participants.add(m.loserId); + lostRound.set(m.loserId, m.round); + } + } + + const result = new Map(); + + for (const id of participants) { + const lost = lostRound.get(id); + if (lost) { + const cfg = getConfig(lost); + if (!cfg) continue; // non-scoring elimination: no bracket QP + result.set(id, { placement: cfg.loserPosition, tieCount: matchCountByRound.get(lost) ?? 1 }); + continue; + } + + // Still alive: floor from the highest round they have won. + const won = wonRounds.get(id); + let highest: string | null = null; + if (won) { + for (const r of won) { + if (highest === null || (roundIndex.get(r) ?? -1) > (roundIndex.get(highest) ?? -1)) { + highest = r; + } + } + } + + if (!highest) { + // In the bracket but no match resolved yet → entry floor. + const cfg = firstScoringRound ? getConfig(firstScoringRound.name) : null; + if (!cfg) continue; + result.set(id, { placement: cfg.loserPosition, tieCount: firstScoringRound.matchCount }); + continue; + } + + const cfg = getConfig(highest); + if (!cfg) continue; + if (cfg.winnerFloor === null) { + // Won the finalization round → champion (or 3rd-place-game winner). + result.set(id, { placement: cfg.winnerPosition ?? 1, tieCount: 1 }); + } else { + const next = feedsIntoByRound.get(highest) ?? null; + const tieCount = next ? (matchCountByRound.get(next) ?? 1) : 1; + result.set(id, { placement: cfg.winnerFloor, tieCount }); + } + } + + return result; +} + +/** + * Score a qualifying event whose Champions-Stage / knockout bracket lives in + * playoffMatches (e.g. a CS2 Major). Derives each bracket team's guaranteed + * minimum QP from the matches and upserts it into event_results, then refreshes + * each participant's QP total. + * + * Unlike processPlayoffEvent (which writes the fantasy-points table + * seasonParticipantResults), this writes ONLY the QP path. For a + * qualifying_points sport the per-major fantasy placement is meaningless — final + * fantasy placements come solely from finalizeQualifyingPoints across all majors. + * + * Invariant: progressive QP totals come from recalculateParticipantQP summing the + * directly-written qualifyingPointsAwarded — never from interim re-grouping. + * processQualifyingEvent's placement-grouping recompute only runs at finalization, + * by which point the bracket is complete and the placement groups have their + * structural sizes, so both paths produce identical QP. + * + * Idempotent: re-running on the same match state recomputes identical values. + */ +export async function processQualifyingBracketEvent( + eventId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const event = await db.query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.id, eventId), + }); + if (!event) throw new Error(`Event ${eventId} not found`); + if (!event.isQualifyingEvent) { + throw new Error(`Event ${eventId} is not a qualifying event`); + } + + const template = event.bracketTemplateId ? BRACKET_TEMPLATES[event.bracketTemplateId] : undefined; + if (!template) { + throw new Error(`Event ${eventId} has no bracket template; cannot derive qualifying bracket QP`); + } + + const matches = await db.query.playoffMatches.findMany({ + where: eq(schema.playoffMatches.scoringEventId, eventId), + }); + + const states = deriveBracketQualifyingStates( + matches.map((m) => ({ + round: m.round, + winnerId: m.winnerId, + loserId: m.loserId, + participant1Id: m.participant1Id, + participant2Id: m.participant2Id, + })), + template.rounds, + (round) => getRoundConfig(round, event.bracketTemplateId) + ); + + if (states.size === 0) return; + + const qpConfigArray = await getQPConfig(event.sportsSeasonId, db); + const qpMap = new Map( + qpConfigArray.map((c) => [c.placement, parseFloat(c.points)]) + ); + + const now = new Date(); + + await Promise.all( + [...states.entries()].map(([seasonParticipantId, { placement, tieCount }]) => { + const qp = calculateSplitQualifyingPoints(placement, tieCount, qpMap); + return db + .insert(schema.eventResults) + .values({ + scoringEventId: eventId, + seasonParticipantId, + qualifyingPointsAwarded: qp.toFixed(2), + placement, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [schema.eventResults.scoringEventId, schema.eventResults.seasonParticipantId], + set: { + qualifyingPointsAwarded: qp.toFixed(2), + placement, + updatedAt: now, + }, + }); + }) + ); + + await Promise.all( + [...states.keys()].map((seasonParticipantId) => + recalculateParticipantQP(seasonParticipantId, event.sportsSeasonId, db) + ) + ); +} + /** * Process a qualifying event completion and update QP totals. * Ties in QP are handled by sharing placements (averaged points). diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 1134eb4..c8ea068 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -25,6 +25,8 @@ import { import { processPlayoffEvent, processMatchResult, + processQualifyingBracketEvent, + processQualifyingEvent, recalculateAffectedLeagues, recalculateStandings, autoCompleteRoundIfDone, @@ -267,26 +269,38 @@ export async function action({ request, params }: Route.ActionArgs) { } } - // Immediately score this match: loser gets their final placement, - // winner gets provisional floor points (isPartialScore=true). - // Prefer the template-defined isScoring over the DB field: the DB column - // defaults to true, so legacy play-in rows may be incorrectly marked as scoring. - const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring; - await processMatchResult({ - round: match.round, - winnerId, - loserId, - isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true), - sportsSeasonId: event.sportsSeasonId, - bracketTemplateId: event.bracketTemplateId, - eventId: event.id, - eventName: event.name ?? undefined, - matchId, - loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), - }); - const db = database(); - await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db); + + if (event.isQualifyingEvent) { + // Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not + // fantasy points. Derive each team's guaranteed-minimum QP floor from the + // bracket and write it to event_results — do NOT touch seasonParticipantResults. + await processQualifyingBracketEvent(event.id, db); + await recalculateAffectedLeagues(event.sportsSeasonId, db, { + eventId: event.id, + eventName: event.name ?? undefined, + }); + } else { + // Immediately score this match: loser gets their final placement, + // winner gets provisional floor points (isPartialScore=true). + // Prefer the template-defined isScoring over the DB field: the DB column + // defaults to true, so legacy play-in rows may be incorrectly marked as scoring. + const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring; + await processMatchResult({ + round: match.round, + winnerId, + loserId, + isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true), + sportsSeasonId: event.sportsSeasonId, + bracketTemplateId: event.bracketTemplateId, + eventId: event.id, + eventName: event.name ?? undefined, + matchId, + loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), + }); + + await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db); + } return { success: "Winner set successfully" }; } catch (error) { @@ -385,19 +399,23 @@ export async function action({ request, params }: Route.ActionArgs) { // Score this match without triggering standings/probability recalc yet — // we batch those side effects into a single call after the loop. - await processMatchResult({ - round: match.round, - winnerId, - loserId, - isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true), - sportsSeasonId: event.sportsSeasonId, - bracketTemplateId: event.bracketTemplateId, - eventId: event.id, - eventName: event.name ?? undefined, - matchId, - skipSideEffects: true, - loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), - }); + // Qualifying majors derive QP from the whole bracket once after the loop + // (processQualifyingBracketEvent), so skip the per-match fantasy scoring here. + if (!event.isQualifyingEvent) { + await processMatchResult({ + round: match.round, + winnerId, + loserId, + isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true), + sportsSeasonId: event.sportsSeasonId, + bracketTemplateId: event.bracketTemplateId, + eventId: event.id, + eventName: event.name ?? undefined, + matchId, + skipSideEffects: true, + loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), + }); + } successCount++; processedMatchIds.push(matchId); @@ -414,6 +432,10 @@ export async function action({ request, params }: Route.ActionArgs) { // previously completed matches in the event. if (successCount > 0) { const db = database(); + // Qualifying majors: derive QP from the full bracket once for the batch. + if (event.isQualifyingEvent) { + await processQualifyingBracketEvent(event.id, db); + } // Update probabilities first so recalculateAffectedLeagues reads fresh EVs // when computing projected points. try { @@ -422,7 +444,11 @@ export async function action({ request, params }: Route.ActionArgs) { logger.error(`Error updating probabilities after batch round winners:`, error); } await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds }); - await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); + // autoCompleteRoundIfDone runs processPlayoffEvent (fantasy path); skip for + // qualifying majors, which are scored via processQualifyingBracketEvent above. + if (!event.isQualifyingEvent) { + await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); + } } if (errors.length > 0 && successCount === 0) { @@ -474,6 +500,19 @@ export async function action({ request, params }: Route.ActionArgs) { return { error: `Not all matches in ${round} are complete` }; } + // Qualifying majors (e.g. CS2): QP is derived directly from the bracket via + // processQualifyingBracketEvent — there are no seasonParticipantResults rows to + // validate against, and final fantasy placements come from finalizeQualifyingPoints. + if (event.isQualifyingEvent) { + const qpDb = database(); + await processQualifyingBracketEvent(params.eventId, qpDb); + await recalculateAffectedLeagues(event.sportsSeasonId, qpDb, { + eventId: params.eventId, + eventName: event.name ?? undefined, + }); + return { success: `${round} completed and qualifying points updated` }; + } + // Validate round order: ensure previous rounds are complete const existingResults = await findParticipantResultsBySportsSeasonId( params.id @@ -559,6 +598,24 @@ export async function action({ request, params }: Route.ActionArgs) { const matches = await findPlayoffMatchesByEventId(params.eventId); const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId); + // Qualifying majors: this is also the cleanup tool for majors that wrongly banked + // fantasy points under the old path. Delete the stale seasonParticipantResults rows + // (erasing those points), then rebuild correct QP from the bracket. Qualifying sports + // have no legitimate per-major fantasy placements — those come from + // finalizeQualifyingPoints across all majors. + if (event.isQualifyingEvent) { + const db = database(); + await db + .delete(schema.seasonParticipantResults) + .where(eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)); + await processQualifyingBracketEvent(params.eventId, db); + // skipDiscord: reprocess is a data-correction tool, not a result announcement. + await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true }); + return { + success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`, + }; + } + if (completed.length === 0) { return { error: "No completed matches to reprocess" }; } @@ -669,6 +726,25 @@ export async function action({ request, params }: Route.ActionArgs) { }; } + // Qualifying majors (e.g. CS2): lock in final bracket placements/QP, then run the + // standard qualifying finalizer for THIS event. Do not mark the whole sports season + // completed or recalc fantasy standings — a season spans multiple majors and final + // fantasy placements come from finalizeQualifyingPoints across all of them. + if (event.isQualifyingEvent) { + const db = database(); + await processQualifyingBracketEvent(params.eventId, db); + await processQualifyingEvent(params.eventId, db); + await db + .update(schema.scoringEvents) + .set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() }) + .where(eq(schema.scoringEvents.id, params.eventId)); + await recalculateAffectedLeagues(event.sportsSeasonId, db, { + eventId: params.eventId, + eventName: event.name ?? undefined, + }); + return { success: "Major bracket finalized — qualifying points awarded." }; + } + // Get all participants in this sports season const allParticipants = await findParticipantsBySportsSeasonId(params.id);