Add complete CS2 event reset to clear orphaned results/QP #102
4 changed files with 152 additions and 11 deletions
|
|
@ -1,6 +1,17 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { computeStage3ExitQP } from "../cs2-major-stage";
|
|
||||||
import { calculateSplitQualifyingPoints } from "../qualifying-points";
|
// Stub only the cached-total recompute so resetCs2Event can be exercised without
|
||||||
|
// a real DB; the rest of qualifying-points stays real for the pure-function tests.
|
||||||
|
vi.mock("../qualifying-points", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return {
|
||||||
|
...(actual as Record<string, unknown>),
|
||||||
|
recalculateParticipantQP: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { computeStage3ExitQP, resetCs2Event } from "../cs2-major-stage";
|
||||||
|
import { calculateSplitQualifyingPoints, recalculateParticipantQP } from "../qualifying-points";
|
||||||
|
|
||||||
function makeStage3QPConfig(): Map<number, number> {
|
function makeStage3QPConfig(): Map<number, number> {
|
||||||
const config = new Map<number, number>();
|
const config = new Map<number, number>();
|
||||||
|
|
@ -257,3 +268,57 @@ describe("Champions Stage floor QP calculation", () => {
|
||||||
expect(floorQP).toBeCloseTo((10 + 8 + 6 + 4) / 4); // = 7
|
expect(floorQP).toBeCloseTo((10 + 8 + 6 + 4) / 4); // = 7
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resetCs2Event", () => {
|
||||||
|
/**
|
||||||
|
* Build a mock db whose `transaction` runs its callback with the same handle
|
||||||
|
* (so the deletes + recalcs run inline), capturing which tables were deleted.
|
||||||
|
* The eventResults SELECT returns `resultRows`.
|
||||||
|
*/
|
||||||
|
function makeMockDb(resultRows: Array<{ participantId: string }>) {
|
||||||
|
const deletedTables: unknown[] = [];
|
||||||
|
const db: Record<string, unknown> = {
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({ where: vi.fn().mockResolvedValue(resultRows) })),
|
||||||
|
})),
|
||||||
|
delete: vi.fn((table: unknown) => {
|
||||||
|
deletedTables.push(table);
|
||||||
|
return { where: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
db.transaction = vi.fn(async (cb: (tx: unknown) => unknown) => cb(db));
|
||||||
|
return { db, deletedTables };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("clears assignments + results and recomputes each distinct participant's QP total", async () => {
|
||||||
|
vi.mocked(recalculateParticipantQP).mockClear();
|
||||||
|
// Two participants, one duplicated across rows → recompute exactly twice.
|
||||||
|
const { db, deletedTables } = makeMockDb([
|
||||||
|
{ participantId: "p1" },
|
||||||
|
{ participantId: "p1" },
|
||||||
|
{ participantId: "p2" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await resetCs2Event("event-1", "season-1", db as never);
|
||||||
|
|
||||||
|
// Both the stage-results table and the event-results table were deleted,
|
||||||
|
// inside the transaction.
|
||||||
|
expect((db as Record<string, ReturnType<typeof vi.fn>>).transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deletedTables).toHaveLength(2);
|
||||||
|
|
||||||
|
const recalcCalls = vi.mocked(recalculateParticipantQP).mock.calls;
|
||||||
|
expect(recalcCalls).toHaveLength(2);
|
||||||
|
expect(recalcCalls.map((c) => c[0]).sort()).toEqual(["p1", "p2"]);
|
||||||
|
for (const call of recalcCalls) expect(call[1]).toBe("season-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still clears the event when there are no recorded results", async () => {
|
||||||
|
vi.mocked(recalculateParticipantQP).mockClear();
|
||||||
|
const { db, deletedTables } = makeMockDb([]);
|
||||||
|
|
||||||
|
await resetCs2Event("event-1", "season-1", db as never);
|
||||||
|
|
||||||
|
expect(deletedTables).toHaveLength(2);
|
||||||
|
expect(recalculateParticipantQP).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { cs2MajorStageResults, seasonParticipants } from "~/database/schema";
|
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP } from "~/models/qualifying-points";
|
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
|
||||||
|
import { deleteEventResults } from "~/models/event-result";
|
||||||
|
|
||||||
export interface Cs2StageResult {
|
export interface Cs2StageResult {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -126,14 +127,57 @@ export async function upsertCs2StageAssignments(
|
||||||
* Called when the admin resets the event setup.
|
* Called when the admin resets the event setup.
|
||||||
*/
|
*/
|
||||||
export async function clearCs2StageAssignments(
|
export async function clearCs2StageAssignments(
|
||||||
scoringEventId: string
|
scoringEventId: string,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = database();
|
const db = providedDb || database();
|
||||||
await db
|
await db
|
||||||
.delete(cs2MajorStageResults)
|
.delete(cs2MajorStageResults)
|
||||||
.where(eq(cs2MajorStageResults.scoringEventId, scoringEventId));
|
.where(eq(cs2MajorStageResults.scoringEventId, scoringEventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully reset a CS2 Major event so it can be set up again from scratch.
|
||||||
|
*
|
||||||
|
* Clears more than stage assignments alone: it also removes every recorded
|
||||||
|
* result for the event and recomputes the cached QP totals of the affected
|
||||||
|
* participants. This matters because a stale stage assignment can leave a
|
||||||
|
* phantom participant (e.g. an academy team entered in place of the main
|
||||||
|
* roster) with provisional QP in event_results and a cached total that would
|
||||||
|
* otherwise survive a stage-only reset and keep polluting the standings and
|
||||||
|
* the EV simulation field.
|
||||||
|
*
|
||||||
|
* Deliberately does NOT touch the Champions-Stage bracket (playoffMatches) —
|
||||||
|
* re-entry realigns the stage data to the existing bracket.
|
||||||
|
*/
|
||||||
|
export async function resetCs2Event(
|
||||||
|
scoringEventId: string,
|
||||||
|
sportsSeasonId: string,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
): Promise<void> {
|
||||||
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
// Capture participants with results first so we can recompute their cached
|
||||||
|
// QP totals after the rows are deleted (mirrors deleteScoringEvent).
|
||||||
|
const existing = await db
|
||||||
|
.select({ participantId: eventResults.seasonParticipantId })
|
||||||
|
.from(eventResults)
|
||||||
|
.where(eq(eventResults.scoringEventId, scoringEventId));
|
||||||
|
const affectedParticipantIds = [...new Set(existing.map((r) => r.participantId))];
|
||||||
|
|
||||||
|
// Delete + recompute atomically so a mid-operation failure can't leave the
|
||||||
|
// event torn (e.g. assignments gone but stale QP totals surviving in the
|
||||||
|
// standings) — the exact phantom-QP state this reset exists to clear.
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await clearCs2StageAssignments(scoringEventId, tx);
|
||||||
|
await deleteEventResults(scoringEventId, tx);
|
||||||
|
|
||||||
|
for (const participantId of affectedParticipantIds) {
|
||||||
|
await recalculateParticipantQP(participantId, sportsSeasonId, tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark teams as eliminated from a specific stage.
|
* Mark teams as eliminated from a specific stage.
|
||||||
* Each elimination entry specifies which stage the team was eliminated at —
|
* Each elimination entry specifies which stage the team was eliminated at —
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
getCs2StageResultsForEvent,
|
getCs2StageResultsForEvent,
|
||||||
upsertCs2StageAssignments,
|
upsertCs2StageAssignments,
|
||||||
markCs2StageEliminations,
|
markCs2StageEliminations,
|
||||||
clearCs2StageAssignments,
|
resetCs2Event,
|
||||||
clearCs2EliminationsAtStage,
|
clearCs2EliminationsAtStage,
|
||||||
assignCs2EliminationQP,
|
assignCs2EliminationQP,
|
||||||
} from '~/models/cs2-major-stage';
|
} from '~/models/cs2-major-stage';
|
||||||
|
|
@ -62,8 +62,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const intent = formData.get('intent') as string;
|
const intent = formData.get('intent') as string;
|
||||||
|
|
||||||
if (intent === 'reset') {
|
if (intent === 'reset') {
|
||||||
await clearCs2StageAssignments(eventId);
|
await resetCs2Event(eventId, params.id);
|
||||||
return { success: true, message: 'Stage assignments cleared.' };
|
return { success: true, message: 'Stage assignments and recorded results cleared.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === 'assign') {
|
if (intent === 'assign') {
|
||||||
|
|
@ -268,7 +268,14 @@ export default function AdminCs2Setup() {
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
Stage Assignments
|
Stage Assignments
|
||||||
{stageResults.length > 0 && (
|
{stageResults.length > 0 && (
|
||||||
<Form method="post">
|
<Form
|
||||||
|
method="post"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
if (!confirm('Reset this event? This clears all stage assignments AND recorded results/QP for the event so you can set it up again. The Champions Stage bracket is kept.')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input type="hidden" name="intent" value="reset" />
|
<input type="hidden" name="intent" value="reset" />
|
||||||
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
|
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
|
|
||||||
|
|
@ -728,6 +728,31 @@ describe("simulateChampionsStage honoring a real bracket", () => {
|
||||||
// foreign bracket (which would have forced its own — absent — winner).
|
// foreign bracket (which would have forced its own — absent — winner).
|
||||||
expect(aWins / 500).toBeGreaterThan(0.25);
|
expect(aWins / 500).toBeGreaterThan(0.25);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discards the whole bracket when a single QF participant is not in the field", () => {
|
||||||
|
// Regression for IEM Cologne 2026: the Champions field held "Spirit Academy"
|
||||||
|
// while the bracket seeded the (different) "Team Spirit" participant in QF1.
|
||||||
|
// The realBracket gate is all-or-nothing, so one out-of-field participant
|
||||||
|
// makes the entire bracket — including the 3 valid completed QFs — be ignored
|
||||||
|
// and the stage falls back to Elo seeding. (When this changes to per-match
|
||||||
|
// honoring, update this test deliberately.)
|
||||||
|
const teams = makeChampTeams();
|
||||||
|
const bracket = makeFullBracket().map((mm) =>
|
||||||
|
mm.round === "Quarterfinals" && mm.matchNumber === 1
|
||||||
|
? { ...mm, participant1Id: "ghost-not-in-field" }
|
||||||
|
: mm
|
||||||
|
);
|
||||||
|
|
||||||
|
let hWins = 0; // h wins everything if (and only if) the bracket is honored.
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
const result = simulateChampionsStage(teams, bracket);
|
||||||
|
expect(result.placements.size).toBe(8);
|
||||||
|
if (result.placements.get("h") === 1) hWins++;
|
||||||
|
}
|
||||||
|
// Weakest team h would be champion every run if the bracket applied; under
|
||||||
|
// Elo fallback it wins far less than the ~1.0 a honored bracket would force.
|
||||||
|
expect(hWins / 500).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── simulateOneMajor full conditioning (stages + bracket + recorded QP) ──────
|
// ─── simulateOneMajor full conditioning (stages + bracket + recorded QP) ──────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue