Add complete CS2 event reset to clear orphaned results/QP
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m41s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

The CS2 Major EV simulation silently discarded the Champions Stage
bracket for IEM Cologne 2026: the bracket seeded "Team Spirit" in QF1
while the stage assignments listed the (different) "Spirit Academy", so
the realBracket gate found an out-of-field participant and rejected the
whole bracket. The phantom "Spirit Academy" also left a stray
event_results row (33 rows for 32 teams).

The existing "Reset all" button only cleared cs2_major_stage_results, so
re-entering couldn't remove the phantom result or its cached QP total.

Add resetCs2Event, which also deletes the event's event_results and
recomputes the affected participants' cached QP totals — all inside a
single transaction so a mid-operation failure can't leave the event in a
torn state. It deliberately preserves the Champions Stage bracket so
re-entry realigns the stage data to it. The reset button now confirms
and states that results/QP are cleared too.

Tests: resetCs2Event clear+recompute behavior, and a simulator
regression pinning that one out-of-field QF participant disables the
whole bracket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-06-19 22:22:11 -07:00
parent 472875f151
commit dfbcb7d953
4 changed files with 152 additions and 11 deletions

View file

@ -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();
});
});

View file

@ -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

View file

@ -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" />

View file

@ -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) ──────