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
This commit is contained in:
Claude 2026-06-13 17:43:25 +00:00
parent 04f6222759
commit f16b9de334
No known key found for this signature in database
3 changed files with 134 additions and 89 deletions

View file

@ -140,6 +140,9 @@ export async function clearCs2StageAssignments(
* this may differ from stageEntry for teams that advanced from an earlier stage * 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). * (e.g. a Stage 1 team eliminated in Stage 2 has stageEntry=1, stageEliminated=2).
* Teams NOT in eliminatedEntries keep their existing stageEliminated value. * 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( export async function markCs2StageEliminations(
scoringEventId: string, scoringEventId: string,
@ -149,6 +152,16 @@ export async function markCs2StageEliminations(
const db = database(); const db = database();
const now = new Date(); 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( await Promise.all(
eliminations.map(({ participantId, stageEliminated, winsAtElimination }) => eliminations.map(({ participantId, stageEliminated, winsAtElimination }) =>
db 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<void> {
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. * Set final placements for all seasonParticipants in a CS2 Major event.
* Called after the Champions Stage is complete. * 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 * Write qualifying points and placements to event_results for teams whose
* now known. Called after marking stage eliminations so that QP is recorded * elimination stage is now known. Called after marking stage eliminations so
* progressively (like bracket points) rather than only at event completion. * that QP is recorded progressively rather than only at event completion.
* *
* - Stage 1 or 2 exits 0 QP (placements 1732) * - Stage 1 exits 0 QP, placement = 25 (group start for slots 2532)
* - Stage 3 exits sub-ranked QP (placements 916, split by W-L record) * - Stage 2 exits 0 QP, placement = 17 (group start for slots 1724)
* - Stage 3 exits sub-ranked QP, placement = W-L group start slot (916)
* - Champions Stage participants (stageEliminated = null) are not touched here; * - 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. * Idempotent: safe to call after each elimination-marking save.
*/ */
export async function assignCs2EliminationQP( export async function assignCs2EliminationQP(
@ -245,22 +281,25 @@ export async function assignCs2EliminationQP(
const allResults = await getCs2StageResultsForEvent(scoringEventId); const allResults = await getCs2StageResultsForEvent(scoringEventId);
const stage1or2Exits = allResults.filter( const stage1Exits = allResults.filter((r) => r.stageEliminated === 1);
(r) => r.stageEliminated === 1 || r.stageEliminated === 2 const stage2Exits = allResults.filter((r) => r.stageEliminated === 2);
);
const stage3Exits = allResults.filter((r) => r.stageEliminated === 3); 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 qpConfigArray = await getQPConfig(sportsSeasonId);
const qpConfig = new Map<number, number>( const qpConfig = new Map<number, number>(
qpConfigArray.map((c) => [c.placement, parseFloat(c.points)]) qpConfigArray.map((c) => [c.placement, parseFloat(c.points)])
); );
const qpByParticipant = new Map<string, number>(); // Map from participantId → { qp, placement }
const resultsByParticipant = new Map<string, { qp: number; placement: number }>();
for (const r of stage1or2Exits) { for (const r of stage1Exits) {
qpByParticipant.set(r.participantId, 0); 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) { if (stage3Exits.length > 0) {
@ -268,33 +307,53 @@ export async function assignCs2EliminationQP(
stage3Exits.map((r) => ({ id: r.participantId, wins: r.stageEliminatedWins ?? 0 })), stage3Exits.map((r) => ({ id: r.participantId, wins: r.stageEliminatedWins ?? 0 })),
qpConfig 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<number, string[]>();
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(); const now = new Date();
for (const [seasonParticipantId, qp] of qpByParticipant) { await Promise.all(
await db [...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) =>
.insert(eventResults) db
.values({ .insert(eventResults)
scoringEventId, .values({
seasonParticipantId, scoringEventId,
qualifyingPointsAwarded: qp.toString(), seasonParticipantId,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
set: {
qualifyingPointsAwarded: qp.toString(), qualifyingPointsAwarded: qp.toString(),
placement,
createdAt: now,
updatedAt: now, updatedAt: now,
}, })
}); .onConflictDoUpdate({
} target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
set: {
qualifyingPointsAwarded: qp.toString(),
placement,
updatedAt: now,
},
})
)
);
for (const seasonParticipantId of qpByParticipant.keys()) { await Promise.all(
await recalculateParticipantQP(seasonParticipantId, sportsSeasonId); [...resultsByParticipant.keys()].map((seasonParticipantId) =>
} recalculateParticipantQP(seasonParticipantId, sportsSeasonId)
)
);
} }

View file

@ -9,6 +9,7 @@ import {
upsertCs2StageAssignments, upsertCs2StageAssignments,
markCs2StageEliminations, markCs2StageEliminations,
clearCs2StageAssignments, clearCs2StageAssignments,
clearCs2EliminationsAtStage,
assignCs2EliminationQP, assignCs2EliminationQP,
} from '~/models/cs2-major-stage'; } from '~/models/cs2-major-stage';
import { findSeasonMatchesByScoringEventId } from '~/models/season-match'; 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) // 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 // 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). // 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()) { // stage_displayed_{N} hidden fields indicate which stage sections were rendered.
if (key.startsWith('elim_')) { // Before applying submitted eliminations, we clear those stages so that unchecking
const rest = key.slice('elim_'.length); // a team actually removes them from the eliminated list (replacement semantics).
const underscoreIdx = rest.indexOf('_'); try {
if (underscoreIdx < 1) continue; const displayedStages: number[] = [];
const stageEliminated = parseInt(rest.slice(0, underscoreIdx), 10); for (const [key] of formData.entries()) {
const participantId = rest.slice(underscoreIdx + 1); if (key.startsWith('stage_displayed_')) {
const wins = parseInt(value as string, 10); const stageNum = parseInt(key.slice('stage_displayed_'.length), 10);
if (!isNaN(stageEliminated) && stageEliminated >= 1 && stageEliminated <= 3 if (!isNaN(stageNum) && stageNum >= 1 && stageNum <= 3) {
&& participantId.length > 0 && !isNaN(wins) && wins >= 0 && wins <= 2) { displayedStages.push(stageNum);
eliminations.push({ participantId, stageEliminated, winsAtElimination: wins }); }
} }
} }
}
await markCs2StageEliminations(eventId, eliminations); const eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }> = [];
await assignCs2EliminationQP(eventId, params.id); for (const [key, value] of formData.entries()) {
return { success: true, message: `Marked ${eliminations.length} eliminations.` }; 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') { if (intent === 'sync-matches') {
@ -332,6 +354,7 @@ export default function AdminCs2Setup() {
return ( return (
<div key={stage}> <div key={stage}>
<input type="hidden" name={`stage_displayed_${stage}`} value="1" />
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2"> <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Badge variant={STAGE_LABELS[stage].badgeVariant}>{STAGE_LABELS[stage].label}</Badge> <Badge variant={STAGE_LABELS[stage].badgeVariant}>{STAGE_LABELS[stage].label}</Badge>
{STAGE_LABELS[stage].description} {STAGE_LABELS[stage].description}

View file

@ -46,7 +46,8 @@ import { database } from "~/database/context";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { getQPConfig } from "~/models/qualifying-points"; 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 { getExcludedByEventMap } from "~/models/event-result";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
@ -388,44 +389,6 @@ export function simulateChampionsStage(teams: AdvancedTeam[]): ChampionsResult {
// ─── QP helpers ─────────────────────────────────────────────────────────────── // ─── QP helpers ───────────────────────────────────────────────────────────────
/**
* Calculate QP for Stage 3 exits based on their W-L record.
* Teams are ranked within 916 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 916 correspond to stage 3 exit slots.
*
* Exported for unit testing.
*/
export function calcStage3ExitQP(
elimTeams: Array<{ id: string; wins: number }>,
qpConfig: Map<number, number>
): Map<string, number> {
// Group teams by wins count (0, 1, 2)
const byWins = new Map<number, string[]>();
for (const t of elimTeams) {
if (!byWins.has(t.wins)) byWins.set(t.wins, []);
byWins.get(t.wins)?.push(t.id);
}
// Assign placement slots 916 from highest wins first
const result = new Map<string, number>();
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 ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class CSMajorSimulator implements Simulator { export class CSMajorSimulator implements Simulator {