claude/bracket-wins-minimum-qp-el8z14 (#99)
Co-authored-by: Claude <noreply@anthropic.com> Reviewed-on: #99
This commit is contained in:
parent
d31c23d63b
commit
f078eab492
6 changed files with 636 additions and 114 deletions
200
app/models/__tests__/qualifying-bracket-scoring.test.ts
Normal file
200
app/models/__tests__/qualifying-bracket-scoring.test.ts
Normal 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 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue