From f16b9de3347b731f0518a59c730bce46acdff8ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 17:43:25 +0000 Subject: [PATCH] Fix 7 code-review issues in CS2 stage elimination and QP assignment - Write placement in assignCs2EliminationQP so processQualifyingEvent preserves progressive QP at event finalization instead of zeroing it. Stage 1 exits get placement=25, Stage 2 exits get placement=17, Stage 3 exits get their W-L group start slot (9-16) so tie-split logic re-derives the same averaged QP at finalization. - Add clearCs2EliminationsAtStage model function and stage_displayed_{N} hidden fields so unchecking a team in the admin form actually clears their DB record (replacement semantics instead of additive). - Add stageEliminated >= stageEntry validation in markCs2StageEliminations. - Wrap mark-eliminations action handler in try/catch to surface errors. - Switch serial recalculateParticipantQP loop to Promise.all. - Remove duplicate calcStage3ExitQP from cs-major-simulator; import computeStage3ExitQP from cs2-major-stage and re-export under the original name so simulator tests remain unchanged. https://claude.ai/code/session_013u6vbGHdppe88wQ95BLANw --- app/models/cs2-major-stage.ts | 127 +++++++++++++----- ...-seasons.$id.events.$eventId.cs2-setup.tsx | 55 +++++--- .../simulations/cs-major-simulator.ts | 41 +----- 3 files changed, 134 insertions(+), 89 deletions(-) diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts index da4078d..f38f319 100644 --- a/app/models/cs2-major-stage.ts +++ b/app/models/cs2-major-stage.ts @@ -140,6 +140,9 @@ export async function clearCs2StageAssignments( * this may differ from stageEntry for teams that advanced from an earlier stage * (e.g. a Stage 1 team eliminated in Stage 2 has stageEntry=1, stageEliminated=2). * Teams NOT in eliminatedEntries keep their existing stageEliminated value. + * + * Throws if stageEliminated < stageEntry for any participant (a team cannot be + * eliminated in a stage they haven't reached yet). */ export async function markCs2StageEliminations( scoringEventId: string, @@ -149,6 +152,16 @@ export async function markCs2StageEliminations( const db = database(); const now = new Date(); + const existingResults = await getCs2StageResultsMapForEvent(scoringEventId); + for (const { participantId, stageEliminated } of eliminations) { + const existing = existingResults.get(participantId); + if (existing && stageEliminated < existing.stageEntry) { + throw new Error( + `Team ${participantId} (stageEntry=${existing.stageEntry}) cannot be eliminated at stage ${stageEliminated}` + ); + } + } + await Promise.all( eliminations.map(({ participantId, stageEliminated, winsAtElimination }) => db @@ -166,6 +179,26 @@ export async function markCs2StageEliminations( ); } +/** + * Clear elimination records for all teams currently marked as eliminated at a + * specific stage. Used to implement replacement semantics in the admin form: + * before re-saving eliminations for a stage, wipe the existing records so that + * unchecking a team actually removes them from the eliminated list. + */ +export async function clearCs2EliminationsAtStage( + scoringEventId: string, + stageNum: number +): Promise { + const db = database(); + await db + .update(cs2MajorStageResults) + .set({ stageEliminated: null, stageEliminatedWins: null, updatedAt: new Date() }) + .where(and( + eq(cs2MajorStageResults.scoringEventId, scoringEventId), + eq(cs2MajorStageResults.stageEliminated, stageNum) + )); +} + /** * Set final placements for all seasonParticipants in a CS2 Major event. * Called after the Champions Stage is complete. @@ -226,15 +259,18 @@ export function computeStage3ExitQP( } /** - * Write qualifying points to event_results for teams whose elimination stage is - * now known. Called after marking stage eliminations so that QP is recorded - * progressively (like bracket points) rather than only at event completion. + * Write qualifying points and placements to event_results for teams whose + * elimination stage is now known. Called after marking stage eliminations so + * that QP is recorded progressively rather than only at event completion. * - * - Stage 1 or 2 exits → 0 QP (placements 17–32) - * - Stage 3 exits → sub-ranked QP (placements 9–16, split by W-L record) + * - Stage 1 exits → 0 QP, placement = 25 (group start for slots 25–32) + * - Stage 2 exits → 0 QP, placement = 17 (group start for slots 17–24) + * - Stage 3 exits → sub-ranked QP, placement = W-L group start slot (9–16) * - Champions Stage participants (stageEliminated = null) are not touched here; - * their QP is handled by the bracket admin when Champion Stage results are entered. + * their QP is handled by the bracket admin when Champions Stage results are entered. * + * Writing placement enables processQualifyingEvent to correctly re-derive QP at + * finalization via its own tie-split logic (grouping rows by placement value). * Idempotent: safe to call after each elimination-marking save. */ export async function assignCs2EliminationQP( @@ -245,22 +281,25 @@ export async function assignCs2EliminationQP( const allResults = await getCs2StageResultsForEvent(scoringEventId); - const stage1or2Exits = allResults.filter( - (r) => r.stageEliminated === 1 || r.stageEliminated === 2 - ); + const stage1Exits = allResults.filter((r) => r.stageEliminated === 1); + const stage2Exits = allResults.filter((r) => r.stageEliminated === 2); const stage3Exits = allResults.filter((r) => r.stageEliminated === 3); - if (stage1or2Exits.length === 0 && stage3Exits.length === 0) return; + if (stage1Exits.length === 0 && stage2Exits.length === 0 && stage3Exits.length === 0) return; const qpConfigArray = await getQPConfig(sportsSeasonId); const qpConfig = new Map( qpConfigArray.map((c) => [c.placement, parseFloat(c.points)]) ); - const qpByParticipant = new Map(); + // Map from participantId → { qp, placement } + const resultsByParticipant = new Map(); - for (const r of stage1or2Exits) { - qpByParticipant.set(r.participantId, 0); + for (const r of stage1Exits) { + resultsByParticipant.set(r.participantId, { qp: 0, placement: 25 }); + } + for (const r of stage2Exits) { + resultsByParticipant.set(r.participantId, { qp: 0, placement: 17 }); } if (stage3Exits.length > 0) { @@ -268,33 +307,53 @@ export async function assignCs2EliminationQP( stage3Exits.map((r) => ({ id: r.participantId, wins: r.stageEliminatedWins ?? 0 })), qpConfig ); - for (const [id, qp] of stage3QP) { - qpByParticipant.set(id, qp); + + // Compute group start slot per team (same grouping as computeStage3ExitQP). + // All teams in a W-L group share the same placement so processQualifyingEvent + // groups them and tie-splits QP identically to computeStage3ExitQP. + const byWins = new Map(); + for (const r of stage3Exits) { + const wins = r.stageEliminatedWins ?? 0; + if (!byWins.has(wins)) byWins.set(wins, []); + byWins.get(wins)!.push(r.participantId); + } + let slotStart = 9; + for (const [, ids] of [...byWins.entries()].toSorted((a, b) => b[0] - a[0])) { + for (const id of ids) { + resultsByParticipant.set(id, { qp: stage3QP.get(id) ?? 0, placement: slotStart }); + } + slotStart += ids.length; } } const now = new Date(); - for (const [seasonParticipantId, qp] of qpByParticipant) { - await db - .insert(eventResults) - .values({ - scoringEventId, - seasonParticipantId, - qualifyingPointsAwarded: qp.toString(), - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [eventResults.scoringEventId, eventResults.seasonParticipantId], - set: { + 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, + }, + }) + ) + ); - for (const seasonParticipantId of qpByParticipant.keys()) { - await recalculateParticipantQP(seasonParticipantId, sportsSeasonId); - } + await Promise.all( + [...resultsByParticipant.keys()].map((seasonParticipantId) => + recalculateParticipantQP(seasonParticipantId, sportsSeasonId) + ) + ); } 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 e213dce..8795161 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 @@ -9,6 +9,7 @@ import { upsertCs2StageAssignments, markCs2StageEliminations, clearCs2StageAssignments, + clearCs2EliminationsAtStage, assignCs2EliminationQP, } from '~/models/cs2-major-stage'; import { findSeasonMatchesByScoringEventId } from '~/models/season-match'; @@ -89,25 +90,46 @@ export async function action({ request, params }: Route.ActionArgs) { // Parse elimination data: elim_{stageNum}_{participantId} = winsAtElimination (0, 1, or 2) // stageNum is the stage the team was eliminated AT (may differ from their stageEntry // when a Stage 1 team advances to Stage 2 before being eliminated). - const eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }> = []; - for (const [key, value] of formData.entries()) { - if (key.startsWith('elim_')) { - const rest = key.slice('elim_'.length); - const underscoreIdx = rest.indexOf('_'); - if (underscoreIdx < 1) continue; - const stageEliminated = parseInt(rest.slice(0, underscoreIdx), 10); - const participantId = rest.slice(underscoreIdx + 1); - const wins = parseInt(value as string, 10); - if (!isNaN(stageEliminated) && stageEliminated >= 1 && stageEliminated <= 3 - && participantId.length > 0 && !isNaN(wins) && wins >= 0 && wins <= 2) { - eliminations.push({ participantId, stageEliminated, winsAtElimination: wins }); + // + // stage_displayed_{N} hidden fields indicate which stage sections were rendered. + // Before applying submitted eliminations, we clear those stages so that unchecking + // a team actually removes them from the eliminated list (replacement semantics). + try { + const displayedStages: number[] = []; + for (const [key] of formData.entries()) { + if (key.startsWith('stage_displayed_')) { + const stageNum = parseInt(key.slice('stage_displayed_'.length), 10); + if (!isNaN(stageNum) && stageNum >= 1 && stageNum <= 3) { + displayedStages.push(stageNum); + } } } - } - await markCs2StageEliminations(eventId, eliminations); - await assignCs2EliminationQP(eventId, params.id); - return { success: true, message: `Marked ${eliminations.length} eliminations.` }; + const eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }> = []; + for (const [key, value] of formData.entries()) { + if (key.startsWith('elim_')) { + const rest = key.slice('elim_'.length); + const underscoreIdx = rest.indexOf('_'); + if (underscoreIdx < 1) continue; + const stageEliminated = parseInt(rest.slice(0, underscoreIdx), 10); + const participantId = rest.slice(underscoreIdx + 1); + const wins = parseInt(value as string, 10); + if (!isNaN(stageEliminated) && stageEliminated >= 1 && stageEliminated <= 3 + && participantId.length > 0 && !isNaN(wins) && wins >= 0 && wins <= 2) { + eliminations.push({ participantId, stageEliminated, winsAtElimination: wins }); + } + } + } + + for (const stageNum of displayedStages) { + await clearCs2EliminationsAtStage(eventId, stageNum); + } + await markCs2StageEliminations(eventId, eliminations); + await assignCs2EliminationQP(eventId, params.id); + return { success: true, message: `Marked ${eliminations.length} eliminations.` }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' }; + } } if (intent === 'sync-matches') { @@ -332,6 +354,7 @@ export default function AdminCs2Setup() { return (
+

{STAGE_LABELS[stage].label} {STAGE_LABELS[stage].description} diff --git a/app/services/simulations/cs-major-simulator.ts b/app/services/simulations/cs-major-simulator.ts index 7d2d79a..7e0c23d 100644 --- a/app/services/simulations/cs-major-simulator.ts +++ b/app/services/simulations/cs-major-simulator.ts @@ -46,7 +46,8 @@ import { database } from "~/database/context"; import { eq, and, inArray } from "drizzle-orm"; import * as schema from "~/database/schema"; import { getQPConfig } from "~/models/qualifying-points"; -import { getCs2StageResultsMapForEvent } from "~/models/cs2-major-stage"; +import { getCs2StageResultsMapForEvent, computeStage3ExitQP as calcStage3ExitQP } from "~/models/cs2-major-stage"; +export { calcStage3ExitQP }; import { getExcludedByEventMap } from "~/models/event-result"; import type { Simulator, SimulationResult } from "./types"; @@ -388,44 +389,6 @@ export function simulateChampionsStage(teams: AdvancedTeam[]): ChampionsResult { // ─── QP helpers ─────────────────────────────────────────────────────────────── -/** - * Calculate QP for Stage 3 exits based on their W-L record. - * Teams are ranked within 9–16 by wins (2-3 > 1-3 > 0-3). - * Within the same wins count, QP is tie-split (averaged) across placement slots. - * - * qpConfig: map from placement (1-indexed) to QP value. - * Placements 9–16 correspond to stage 3 exit slots. - * - * Exported for unit testing. - */ -export function calcStage3ExitQP( - elimTeams: Array<{ id: string; wins: number }>, - qpConfig: Map -): Map { - // Group teams by wins count (0, 1, 2) - const byWins = new Map(); - for (const t of elimTeams) { - if (!byWins.has(t.wins)) byWins.set(t.wins, []); - byWins.get(t.wins)?.push(t.id); - } - - // Assign placement slots 9–16 from highest wins first - const result = new Map(); - const winsGroups = [...byWins.entries()].toSorted((a, b) => b[0] - a[0]); // descending wins - - let slotStart = 9; - for (const [, ids] of winsGroups) { - const slots = Array.from({ length: ids.length }, (_, i) => slotStart + i); - const avgQP = slots.reduce((sum, s) => sum + (qpConfig.get(s) ?? 0), 0) / slots.length; - for (const id of ids) { - result.set(id, avgQP); - } - slotStart += ids.length; - } - - return result; -} - // ─── Simulator ──────────────────────────────────────────────────────────────── export class CSMajorSimulator implements Simulator {