claude/bracket-wins-minimum-qp-el8z14 #99

Merged
chrisp merged 3 commits from claude/bracket-wins-minimum-qp-el8z14 into main 2026-06-19 01:25:18 +00:00
6 changed files with 636 additions and 114 deletions

View file

@ -0,0 +1,200 @@
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()].toSorted()).toEqual([...b.entries()].toSorted());
});
});
// ── 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);
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
expect(qpFor(3, 2)).toBeCloseTo(9);
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
expect(qpFor(5, 4)).toBeCloseTo(4);
});
});
// ── Regression guard: structural tieCount, not live count (the #1 fix) ─────────
describe("structural tie span vs. live-count regrouping", () => {
it("4 QF winners share placement 3 but each keeps the 2-slot (9 QP) floor", () => {
const s = states(QF_ALL);
// All four QF winners sit at placement 3, but with the STRUCTURAL tie span of 2.
for (const id of ["t1", "t2", "t3", "t4"]) {
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
}
expect(qpFor(3, 2)).toBeCloseTo(9);
// A naive regroup by LIVE count (4 rows at placement 3) would average over
// slots 36 and wrongly yield 7 QP — the bug processQualifyingEvent must avoid
// by delegating bracket events to the structural-tieCount path.
expect(qpFor(3, 4)).toBeCloseTo(7);
expect(qpFor(3, 4)).not.toBeCloseTo(9);
});
});
// ── 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

@ -13,9 +13,9 @@
*/
import { database } from "~/database/context";
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
import { cs2MajorStageResults, seasonParticipants } from "~/database/schema";
import { eq, and, sql } from "drizzle-orm";
import { getQPConfig, recalculateParticipantQP, calculateSplitQualifyingPoints } from "~/models/qualifying-points";
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP } from "~/models/qualifying-points";
export interface Cs2StageResult {
id: string;
@ -394,34 +394,5 @@ export async function assignCs2EliminationQP(
}
}
const now = new Date();
await Promise.all(
[...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) =>
db
.insert(eventResults)
.values({
scoringEventId,
seasonParticipantId,
qualifyingPointsAwarded: qp.toString(),
placement,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
set: {
qualifyingPointsAwarded: qp.toString(),
placement,
updatedAt: now,
},
})
)
);
await Promise.all(
[...resultsByParticipant.keys()].map((seasonParticipantId) =>
recalculateParticipantQP(seasonParticipantId, sportsSeasonId)
)
);
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
}

View file

@ -95,9 +95,10 @@ export async function deleteParticipantResult(id: string): Promise<void> {
}
export async function deleteParticipantResultsBySportsSeasonId(
sportsSeasonId: string
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = database();
const db = providedDb || database();
await db
.delete(schema.seasonParticipantResults)
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));

View file

@ -283,6 +283,57 @@ export async function recalculateParticipantQP(
return { totalQP, eventsScored };
}
/**
* Upsert qualifying points + placement into event_results for a set of participants,
* then recalculate each participant's QP total.
*
* Shared by the QP writers (assignCs2EliminationQP for Swiss-stage exits,
* processQualifyingBracketEvent for Champions-Stage / knockout brackets) so the
* upsert + recalc loop lives in one place. Upserts on
* (scoringEventId, seasonParticipantId), so repeated calls overwrite cleanly.
*
* @param entries Map from seasonParticipantId { qp, placement } to write.
*/
export async function writeEventResultsQP(
scoringEventId: string,
sportsSeasonId: string,
entries: Map<string, { qp: number; placement: number }>,
providedDb?: ReturnType<typeof database>
): Promise<void> {
if (entries.size === 0) return;
const db = providedDb || database();
const now = new Date();
await Promise.all(
[...entries.entries()].map(([seasonParticipantId, { qp, placement }]) =>
db
.insert(schema.eventResults)
.values({
scoringEventId,
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(
[...entries.keys()].map((seasonParticipantId) =>
recalculateParticipantQP(seasonParticipantId, sportsSeasonId, db)
)
);
}
/**
* Recalculate eventsScored for a participant by counting unique events with QP awarded
* @deprecated Use recalculateParticipantQP instead for more accurate recalculation

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";
@ -23,6 +23,7 @@ import {
getQPConfig,
hasProcessedQualifyingPlacement,
recalculateParticipantQP,
writeEventResultsQP,
getQPStandings,
updateFinalRankings,
} from "./qualifying-points";
@ -143,7 +144,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 +624,194 @@ 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.
*
* QP is written directly with the placement tier's STRUCTURAL tie span (QF tier
* spans 4 slots, SF tier 2, finalist/champion 1) not the live count of teams
* currently at a placement. This is what makes a QF-winner floor 9 QP (avg of
* 3rd+4th) even while all four QF winners transiently share placement 3.
* processQualifyingEvent delegates to this function for bracket events precisely
* so it does NOT re-group those rows by live count (which would average four
* placement-3 rows down to 7).
*
* 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 entries = new Map<string, { qp: number; placement: number }>(
[...states.entries()].map(([id, { placement, tieCount }]) => [
id,
{ qp: calculateSplitQualifyingPoints(placement, tieCount, qpMap), placement },
])
);
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
}
/**
* Process a qualifying event completion and update QP totals.
* Ties in QP are handled by sharing placements (averaged points).
@ -655,52 +844,62 @@ export async function processQualifyingEvent(
// Check if this was already processed (for majorsCompleted counter)
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
for (const result of results) {
await db
.update(schema.eventResults)
.set({
qualifyingPointsAwarded: "0",
updatedAt: new Date(),
})
.where(eq(schema.eventResults.id, result.id));
}
const qpConfig = await getQPConfig(event.sportsSeasonId, db);
const pointsByPlacement = new Map(
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
);
// Group results by placement to handle ties
const placementGroups = new Map<number, typeof results>();
for (const result of results) {
if (result.placement) {
const group = placementGroups.get(result.placement) || [];
group.push(result);
placementGroups.set(result.placement, group);
}
}
// Process each placement group and update event_results with QP awarded
for (const [placement, group] of placementGroups) {
const tieCount = group.length;
const qpPerParticipant = calculateSplitQualifyingPoints(
placement,
tieCount,
pointsByPlacement
);
// Update the event result with the QP awarded
for (const result of group) {
if (event.bracketTemplateId) {
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
// (written by assignCs2EliminationQP) untouched. This deliberately skips the
// placement-regroup below — re-grouping provisional floors by their LIVE count
// would average four placement-3 QF winners down to 7 QP instead of their 9 floor.
await processQualifyingBracketEvent(eventId, db);
} else {
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
for (const result of results) {
await db
.update(schema.eventResults)
.set({
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
qualifyingPointsAwarded: "0",
updatedAt: new Date(),
})
.where(eq(schema.eventResults.id, result.id));
}
const qpConfig = await getQPConfig(event.sportsSeasonId, db);
const pointsByPlacement = new Map(
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
);
// Group results by placement to handle ties
const placementGroups = new Map<number, typeof results>();
for (const result of results) {
if (result.placement) {
const group = placementGroups.get(result.placement) || [];
group.push(result);
placementGroups.set(result.placement, group);
}
}
// Process each placement group and update event_results with QP awarded
for (const [placement, group] of placementGroups) {
const tieCount = group.length;
const qpPerParticipant = calculateSplitQualifyingPoints(
placement,
tieCount,
pointsByPlacement
);
// Update the event result with the QP awarded
for (const result of group) {
await db
.update(schema.eventResults)
.set({
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
updatedAt: new Date(),
})
.where(eq(schema.eventResults.id, result.id));
}
}
}
// Recalculate totals for all participants from scratch

View file

@ -25,13 +25,20 @@ import {
import {
processPlayoffEvent,
processMatchResult,
processQualifyingBracketEvent,
processQualifyingEvent,
finalizeQualifyingPoints,
recalculateAffectedLeagues,
recalculateStandings,
autoCompleteRoundIfDone,
} from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
import {
setParticipantResult,
findParticipantResultsBySportsSeasonId,
deleteParticipantResultsBySportsSeasonId,
} from "~/models/participant-result";
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
import { createDailySnapshot } from "~/models/standings";
import {
@ -103,6 +110,28 @@ export async function loader({ params }: Route.LoaderArgs) {
};
}
/**
* Score a qualifying-event bracket (e.g. CS2 Champions Stage): derive each team's
* guaranteed-minimum QP from the bracket and refresh affected league standings.
*
* Qualifying brackets award QUALIFYING POINTS, not fantasy points, so this never
* writes seasonParticipantResults and never records team_score_events match
* results surface via the QP Standings and the Discord standings update. Final
* fantasy placements come from finalizeQualifyingPoints across all majors.
*/
async function scoreQualifyingBracket(
event: { id: string; sportsSeasonId: string; name: string | null },
db: ReturnType<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2]
): Promise<void> {
await processQualifyingBracketEvent(event.id, db);
await recalculateAffectedLeagues(
event.sportsSeasonId,
db,
recalcOptions ?? { eventId: event.id, eventName: event.name ?? undefined }
);
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
@ -267,26 +296,37 @@ 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. matchIds scopes the Discord notification to just this match.
await scoreQualifyingBracket(event, db, {
eventId: event.id,
eventName: event.name ?? undefined,
matchIds: [matchId],
});
} 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 +425,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 +458,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 +470,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 +526,14 @@ 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) {
await scoreQualifyingBracket(event, database());
return { success: `${round} completed and qualifying points updated` };
}
// Validate round order: ensure previous rounds are complete
const existingResults = await findParticipantResultsBySportsSeasonId(
params.id
@ -559,6 +619,29 @@ 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 deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
await processQualifyingBracketEvent(params.eventId, db);
// If the season's QP was already finalized, the delete above wiped the final
// placements — recompute them from QP totals so standings aren't left blank.
const sportsSeason = await findSportsSeasonById(params.id);
if (sportsSeason?.qualifyingPointsFinalized) {
await finalizeQualifyingPoints(event.sportsSeasonId, db); // recalcs leagues itself
} else {
// 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" };
}
@ -567,11 +650,7 @@ export async function action({ request, params }: Route.ActionArgs) {
// Only deleting partial rows leaves stale finalized rows that block
// the "never un-finalize" guard in upsertParticipantResult.
const db = database();
await db
.delete(schema.seasonParticipantResults)
.where(
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)
);
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
// Replay each completed match in bracket order (earlier rounds first).
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
@ -669,6 +748,27 @@ 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();
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
// increments majorsCompleted, and recalcs participant QP totals. Season-wide
// fantasy finalization stays with finalizeQualifyingPoints across all majors.
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);