Award progressive guaranteed-minimum QP for qualifying-event brackets

Winning a round in a major bracket (CS2 Champions Stage) now immediately
awards the team the guaranteed-minimum qualifying points for the position
they've locked in — e.g. a QF win guarantees at least a T3 finish (9 QP),
an SF win guarantees 2nd (14 QP), and a Final win earns 1st (20 QP).

This also fixes a bug where CS2 majors (a qualifying_points sport) wrongly
banked actual fantasy points: the generic bracket admin routed results
through processMatchResult/processPlayoffEvent, which write the fantasy
placement table (seasonParticipantResults). For qualifying sports, fantasy
points must come only from finalizeQualifyingPoints across all majors.

- scoring-calculator.ts: add deriveBracketQualifyingStates (pure) and
  processQualifyingBracketEvent, deriving each team's (placement, tieCount)
  floor from playoffMatches via the existing ROUND_CONFIG and writing QP to
  event_results. Export getRoundConfig for testing.
- bracket.server.ts: branch all five write handlers on isQualifyingEvent so
  qualifying brackets are scored via the QP path and never write fantasy
  placements. reprocess-bracket additionally clears stale
  seasonParticipantResults rows, making it the cleanup tool for majors that
  already banked points under the old path.
- Add qualifying-bracket-scoring.test.ts covering the floor derivation,
  partial/un-set brackets, idempotency, and the worked QP example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVLqaYGXCeSYYqTHZmbRZi
This commit is contained in:
Claude 2026-06-18 20:23:37 +00:00
parent d31c23d63b
commit 50f2f93b0b
No known key found for this signature in database
3 changed files with 502 additions and 35 deletions

View file

@ -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<number, number>(
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 T58, winners floor at T34", () => {
const s = states(QF_ALL);
// Losers — final placement 5, tie span 4 (T58).
for (const id of ["t5", "t6", "t7", "t8"]) {
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
}
// Winners — guaranteed at least T34: 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 T34, 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 T34, QF losers T58", () => {
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 T58 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 T34.
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
// Played losers lock T58.
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 });
// Unplayed teams: entry floor T58 (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 T58 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 T34
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();
});
});

View file

@ -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 T58, 2 for
* T34, 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/T58, SF loss 3/T34, 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/T34 = 9 QP, SF win 2/finalist = 14 QP)
* - Alive but no match resolved yet entry floor of the first scoring round
* (QF 5/T58 = 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<string, BracketQualifyingState> {
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<string>();
const wonRounds = new Map<string, Set<string>>();
const lostRound = new Map<string, string>();
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<string, BracketQualifyingState>();
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<typeof database>
): Promise<void> {
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<number, number>(
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).

View file

@ -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);