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
* (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<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.
* 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 1732)
* - Stage 3 exits sub-ranked QP (placements 916, split by W-L record)
* - Stage 1 exits 0 QP, placement = 25 (group start for slots 2532)
* - 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;
* 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<number, number>(
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) {
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<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();
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)
)
);
}

View file

@ -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 (
<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">
<Badge variant={STAGE_LABELS[stage].badgeVariant}>{STAGE_LABELS[stage].label}</Badge>
{STAGE_LABELS[stage].description}

View file

@ -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 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 ────────────────────────────────────────────────────────────────
export class CSMajorSimulator implements Simulator {