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:
parent
04f6222759
commit
f16b9de334
3 changed files with 134 additions and 89 deletions
|
|
@ -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 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<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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<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 9–16 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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue