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:
parent
d31c23d63b
commit
50f2f93b0b
3 changed files with 502 additions and 35 deletions
182
app/models/__tests__/qualifying-bracket-scoring.test.ts
Normal file
182
app/models/__tests__/qualifying-bracket-scoring.test.ts
Normal 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 T5–8, winners floor at T3–4", () => {
|
||||
const s = states(QF_ALL);
|
||||
// Losers — final placement 5, tie span 4 (T5–8).
|
||||
for (const id of ["t5", "t6", "t7", "t8"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
|
||||
}
|
||||
// Winners — guaranteed at least T3–4: floor placement 3, tie span 2.
|
||||
for (const id of ["t1", "t2", "t3", "t4"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
it("QF+SF entered: SF losers lock T3–4, SF winners floor at 2nd", () => {
|
||||
const s = states([...QF_ALL, ...SF_ALL]);
|
||||
// SF losers — final placement 3, tie span 2.
|
||||
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 });
|
||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
|
||||
// SF winners (finalists-in-waiting) — floor placement 2, tie span 1.
|
||||
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
|
||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
|
||||
// QF losers unchanged.
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
});
|
||||
|
||||
it("full bracket: champion 1st, finalist 2nd, SF losers T3–4, QF losers T5–8", () => {
|
||||
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
|
||||
expect(s.get("t1")).toEqual({ placement: 1, tieCount: 1 }); // champion
|
||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); // finalist
|
||||
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
|
||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
|
||||
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 }); // QF loser
|
||||
});
|
||||
|
||||
it("partial QF (2 of 4 played): unplayed teams sit at the T5–8 entry floor", () => {
|
||||
const matches = [
|
||||
played("Quarterfinals", "t1", "t8"),
|
||||
played("Quarterfinals", "t4", "t5"),
|
||||
pending("Quarterfinals", "t3", "t6"),
|
||||
pending("Quarterfinals", "t2", "t7"),
|
||||
];
|
||||
const s = states(matches);
|
||||
// Played winners floor at T3–4.
|
||||
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
|
||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
|
||||
// Played losers lock T5–8.
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 });
|
||||
// Unplayed teams: entry floor T5–8 (no crash on incomplete round).
|
||||
for (const id of ["t2", "t3", "t6", "t7"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
|
||||
}
|
||||
});
|
||||
|
||||
it("un-setting the Finals winner reverts both finalists to the 2nd-place floor", () => {
|
||||
const matches = [
|
||||
...QF_ALL,
|
||||
...SF_ALL,
|
||||
pending("Finals", "t1", "t3"), // winner cleared
|
||||
];
|
||||
const s = states(matches);
|
||||
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
|
||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
|
||||
});
|
||||
|
||||
it("a team eliminated in the QF never gains a later-round floor", () => {
|
||||
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
|
||||
// t8 lost in the QF and stays T5–8 through the rest of the bracket.
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
});
|
||||
|
||||
it("is idempotent — same matches yield the same states", () => {
|
||||
const a = states([...QF_ALL, ...SF_ALL]);
|
||||
const b = states([...QF_ALL, ...SF_ALL]);
|
||||
expect([...a.entries()].sort()).toEqual([...b.entries()].sort());
|
||||
});
|
||||
});
|
||||
|
||||
// ── QP values (the worked example) ────────────────────────────────────────────
|
||||
|
||||
describe("guaranteed-minimum QP per outcome (default config)", () => {
|
||||
it("matches the worked example: QF win → 9, SF win → 14, Final win → 20", () => {
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9); // QF winner, guaranteed T3–4
|
||||
expect(qpFor(2, 1)).toBeCloseTo(14); // SF winner, guaranteed 2nd
|
||||
expect(qpFor(1, 1)).toBeCloseTo(20); // champion
|
||||
});
|
||||
|
||||
it("losers: QF loss → 4, SF loss → 9, Final loss → 14", () => {
|
||||
expect(qpFor(5, 4)).toBeCloseTo(4); // (5+5+3+3)/4
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9); // (10+8)/2
|
||||
expect(qpFor(2, 1)).toBeCloseTo(14);
|
||||
});
|
||||
|
||||
it("end-to-end: a QF win immediately yields a 9 QP floor", () => {
|
||||
const s = states(QF_ALL);
|
||||
const winner = s.get("t1")!;
|
||||
expect(qpFor(winner.placement, winner.tieCount)).toBeCloseTo(9);
|
||||
const loser = s.get("t8")!;
|
||||
expect(qpFor(loser.placement, loser.tieCount)).toBeCloseTo(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regression guard: reused ROUND_CONFIG floors are intact ────────────────────
|
||||
|
||||
describe("ROUND_CONFIG floors (regression guard)", () => {
|
||||
it("simple_8 Quarterfinals still floors winners at 3rd", () => {
|
||||
expect(getGuaranteedMinimumPosition("Quarterfinals", "simple_8", true)).toBe(3);
|
||||
expect(getRoundConfig("Quarterfinals", "simple_8")?.loserPosition).toBe(5);
|
||||
expect(getRoundConfig("Semifinals", "simple_8")?.winnerFloor).toBe(2);
|
||||
expect(getRoundConfig("Finals", "simple_8")?.winnerFloor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
import { getSeasonResults } from "./participant-season-result";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
|
||||
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||
import { getUserDisplayName } from "~/models/user";
|
||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||
|
|
@ -143,7 +143,7 @@ function doesNonScoringRoundFeedIntoScoringRound(
|
|||
* overrides before falling back to the standard ROUND_CONFIG.
|
||||
* Returns null for unrecognized rounds.
|
||||
*/
|
||||
function getRoundConfig(
|
||||
export function getRoundConfig(
|
||||
round: string,
|
||||
bracketTemplateId?: string | null
|
||||
): RoundScoringConfig | null {
|
||||
|
|
@ -623,6 +623,215 @@ export function getGuaranteedMinimumPosition(
|
|||
return config.winnerFloor; // null for Finals; actual floor for all other rounds
|
||||
}
|
||||
|
||||
// ── Qualifying-points bracket scoring ────────────────────────────────────────
|
||||
|
||||
/** Minimal shape of a playoff match needed to derive qualifying-bracket states. */
|
||||
export interface BracketMatchInput {
|
||||
round: string;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
}
|
||||
|
||||
/** The guaranteed-minimum placement (and its structural tie span) for one team. */
|
||||
export interface BracketQualifyingState {
|
||||
/** Placement tier the team has locked in (1, 2, 3, 5, …). */
|
||||
placement: number;
|
||||
/**
|
||||
* Number of bracket slots this placement tier spans (e.g. 4 for T5–8, 2 for
|
||||
* T3–4, 1 for 1st/2nd). Used to tie-split QP across the tier. This is the
|
||||
* *structural* span from the template — not the live count of teams currently
|
||||
* sitting at this placement — so the floor value is stable and idempotent and
|
||||
* agrees with processQualifyingEvent's finalization re-grouping.
|
||||
*/
|
||||
tieCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive each bracket team's guaranteed-minimum (placement, tieCount) from the
|
||||
* current set of playoff matches, reusing the standard ROUND_CONFIG floors.
|
||||
*
|
||||
* Rules per team (single-elimination ⇒ a team loses at most once):
|
||||
* - Lost in round R → final placement = loserPosition(R), tier span = matchCount(R).
|
||||
* (QF loss → 5/T5–8, SF loss → 3/T3–4, Finals loss → 2/finalist)
|
||||
* - Still alive, highest round won = R:
|
||||
* · R is the finalization round (winnerFloor null) → champion: winnerPosition ?? 1.
|
||||
* · otherwise → floor = winnerFloor(R), tier span = matchCount(R.feedsInto).
|
||||
* (QF win → 3/T3–4 = 9 QP, SF win → 2/finalist = 14 QP)
|
||||
* - Alive but no match resolved yet → entry floor of the first scoring round
|
||||
* (QF → 5/T5–8 = 4 QP), mirroring assignCs2EliminationQP's provisional floor.
|
||||
*
|
||||
* Pure and exported for unit testing. Teams eliminated in a non-scoring round
|
||||
* (getConfig returns null) are omitted — they earn no QP from the bracket.
|
||||
*/
|
||||
export function deriveBracketQualifyingStates(
|
||||
matches: BracketMatchInput[],
|
||||
rounds: BracketRound[],
|
||||
getConfig: (round: string) => RoundScoringConfig | null
|
||||
): Map<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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue