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 { computeStage3ExitQP } from "../cs2-major-stage";
|
||||
import { calculateSplitQualifyingPoints } from "../qualifying-points";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// 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> {
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
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 { cs2MajorStageResults, seasonParticipants } from "~/database/schema";
|
||||
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
|
||||
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 {
|
||||
id: string;
|
||||
|
|
@ -126,14 +127,57 @@ export async function upsertCs2StageAssignments(
|
|||
* Called when the admin resets the event setup.
|
||||
*/
|
||||
export async function clearCs2StageAssignments(
|
||||
scoringEventId: string
|
||||
scoringEventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
const db = providedDb || database();
|
||||
await db
|
||||
.delete(cs2MajorStageResults)
|
||||
.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.
|
||||
* Each elimination entry specifies which stage the team was eliminated at —
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
getCs2StageResultsForEvent,
|
||||
upsertCs2StageAssignments,
|
||||
markCs2StageEliminations,
|
||||
clearCs2StageAssignments,
|
||||
resetCs2Event,
|
||||
clearCs2EliminationsAtStage,
|
||||
assignCs2EliminationQP,
|
||||
} from '~/models/cs2-major-stage';
|
||||
|
|
@ -62,8 +62,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const intent = formData.get('intent') as string;
|
||||
|
||||
if (intent === 'reset') {
|
||||
await clearCs2StageAssignments(eventId);
|
||||
return { success: true, message: 'Stage assignments cleared.' };
|
||||
await resetCs2Event(eventId, params.id);
|
||||
return { success: true, message: 'Stage assignments and recorded results cleared.' };
|
||||
}
|
||||
|
||||
if (intent === 'assign') {
|
||||
|
|
@ -268,7 +268,14 @@ export default function AdminCs2Setup() {
|
|||
<CardTitle className="flex items-center justify-between">
|
||||
Stage Assignments
|
||||
{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" />
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
|
||||
<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).
|
||||
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) ──────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue