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..7e479a1 --- /dev/null +++ b/app/models/__tests__/qualifying-bracket-scoring.test.ts @@ -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( + 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()].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 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); + 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 3–6 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(); + }); +}); diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts index 98fd409..5008079 100644 --- a/app/models/cs2-major-stage.ts +++ b/app/models/cs2-major-stage.ts @@ -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); } diff --git a/app/models/participant-result.ts b/app/models/participant-result.ts index 8c76e04..4e36257 100644 --- a/app/models/participant-result.ts +++ b/app/models/participant-result.ts @@ -95,9 +95,10 @@ export async function deleteParticipantResult(id: string): Promise { } export async function deleteParticipantResultsBySportsSeasonId( - sportsSeasonId: string + sportsSeasonId: string, + providedDb?: ReturnType ): Promise { - const db = database(); + const db = providedDb || database(); await db .delete(schema.seasonParticipantResults) .where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId)); diff --git a/app/models/qualifying-points.ts b/app/models/qualifying-points.ts index d069ac9..cde2d03 100644 --- a/app/models/qualifying-points.ts +++ b/app/models/qualifying-points.ts @@ -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, + providedDb?: ReturnType +): Promise { + 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 diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 283b34b..4b28bf6 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"; @@ -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 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. + * + * 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 +): 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 entries = new Map( + [...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(); - 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(); + 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 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..ee227ab 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,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, + recalcOptions?: Parameters[2] +): Promise { + 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);