From dfbcb7d9536c8f7f2004aaad728e64dc5b4d199f Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 19 Jun 2026 22:22:11 -0700 Subject: [PATCH] Add complete CS2 event reset to clear orphaned results/QP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/models/__tests__/cs2-major-stage.test.ts | 71 ++++++++++++++++++- app/models/cs2-major-stage.ts | 52 ++++++++++++-- ...-seasons.$id.events.$eventId.cs2-setup.tsx | 15 ++-- .../__tests__/cs-major-simulator.test.ts | 25 +++++++ 4 files changed, 152 insertions(+), 11 deletions(-) diff --git a/app/models/__tests__/cs2-major-stage.test.ts b/app/models/__tests__/cs2-major-stage.test.ts index 90a2d04..2fd6e84 100644 --- a/app/models/__tests__/cs2-major-stage.test.ts +++ b/app/models/__tests__/cs2-major-stage.test.ts @@ -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), + recalculateParticipantQP: vi.fn(), + }; +}); + +import { computeStage3ExitQP, resetCs2Event } from "../cs2-major-stage"; +import { calculateSplitQualifyingPoints, recalculateParticipantQP } from "../qualifying-points"; function makeStage3QPConfig(): Map { const config = new Map(); @@ -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 = { + 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>).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(); + }); +}); diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts index 5008079..df7258c 100644 --- a/app/models/cs2-major-stage.ts +++ b/app/models/cs2-major-stage.ts @@ -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 ): Promise { - 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 +): Promise { + 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 — diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx index 8795161..76dff71 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx @@ -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() { Stage Assignments {stageResults.length > 0 && ( -
+ { + 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(); + } + }} + >