Fix CS2 Stage 3 QP slot assignment #89

Merged
chrisp merged 3 commits from claude/cs-results-qp-points-x7hums into main 2026-06-14 05:49:07 +00:00
4 changed files with 544 additions and 75 deletions

View file

@ -0,0 +1,221 @@
import { describe, it, expect } from "vitest";
import { computeStage3ExitQP } from "../cs2-major-stage";
function makeStage3QPConfig(): Map<number, number> {
const config = new Map<number, number>();
// Slots 916 with descending QP: 9→80, 10→70, ..., 16→10
for (let i = 9; i <= 16; i++) {
config.set(i, (17 - i) * 10);
}
return config;
}
describe("computeStage3ExitQP", () => {
it("returns empty map for empty input", () => {
const result = computeStage3ExitQP([], new Map());
expect(result.size).toBe(0);
});
it("tie-splits QP within each wins group", () => {
const elim = [
{ id: "a", wins: 2 }, { id: "b", wins: 2 }, // slots 9-10: avg (80+70)/2 = 75
{ id: "c", wins: 1 }, { id: "d", wins: 1 }, // slots 11-12: avg (60+50)/2 = 55
{ id: "e", wins: 0 }, { id: "f", wins: 0 },
{ id: "g", wins: 0 }, { id: "h", wins: 0 }, // slots 13-16: avg (40+30+20+10)/4 = 25
];
const result = computeStage3ExitQP(elim, makeStage3QPConfig());
expect(result.get("a")).toBeCloseTo(75);
expect(result.get("b")).toBeCloseTo(75);
expect(result.get("c")).toBeCloseTo(55);
expect(result.get("d")).toBeCloseTo(55);
expect(result.get("e")).toBeCloseTo(25);
expect(result.get("h")).toBeCloseTo(25);
});
it("all teams with the same wins get identical averaged QP", () => {
const elim = Array.from({ length: 8 }, (_, i) => ({ id: `t-${i}`, wins: 1 }));
const result = computeStage3ExitQP(elim, makeStage3QPConfig());
const expected = (80 + 70 + 60 + 50 + 40 + 30 + 20 + 10) / 8;
for (const [, qp] of result) {
expect(qp).toBeCloseTo(expected);
}
});
it("higher wins groups get higher QP than lower wins groups", () => {
const elim = [
{ id: "high", wins: 2 },
{ id: "mid", wins: 1 },
{ id: "low", wins: 0 },
...Array.from({ length: 5 }, (_, i) => ({ id: `pad-${i}`, wins: 0 })),
];
const result = computeStage3ExitQP(elim, makeStage3QPConfig());
expect(result.get("high") ?? 0).toBeGreaterThan(result.get("mid") ?? 0);
expect(result.get("mid") ?? 0).toBeGreaterThan(result.get("low") ?? 0);
});
it("handles a single 2-wins team (gets slot 9 QP, best position)", () => {
const config = makeStage3QPConfig();
const result = computeStage3ExitQP([{ id: "solo", wins: 2 }], config);
expect(result.get("solo")).toBe(config.get(9));
});
it("handles a single 0-wins team (gets slot 16 QP, worst position)", () => {
const config = makeStage3QPConfig();
const result = computeStage3ExitQP([{ id: "solo", wins: 0 }], config);
expect(result.get("solo")).toBe(config.get(16));
});
it("two 0-wins teams get slots 1516 even when no other exits are known yet", () => {
const config = makeStage3QPConfig();
// This is the progressive case: only 0-3 teams marked so far in Stage 3.
// They should get avg of slots 15+16, not slots 9+10.
const result = computeStage3ExitQP([{ id: "a", wins: 0 }, { id: "b", wins: 0 }], config);
const expected = ((config.get(15) ?? 0) + (config.get(16) ?? 0)) / 2; // (20+10)/2 = 15
expect(result.get("a")).toBeCloseTo(expected);
expect(result.get("b")).toBeCloseTo(expected);
});
it("returns 0 for teams when qpConfig has no entries for those slots", () => {
const result = computeStage3ExitQP(
[{ id: "x", wins: 0 }],
new Map() // empty config → all 0
);
expect(result.get("x")).toBe(0);
});
it("assigns slots in order: highest wins group gets lowest slot numbers (highest QP)", () => {
const elim = [
{ id: "best", wins: 2 }, // → slot 9 (highest QP)
{ id: "worst", wins: 0 }, // → slot 16 (lowest QP — 0-wins always fills from bottom)
];
const result = computeStage3ExitQP(elim, makeStage3QPConfig());
// slot 9 = 80, slot 16 = 10
expect(result.get("best")).toBe(80);
expect(result.get("worst")).toBe(10);
});
});
// ─── byStageFullField logic (pure computation, no DB) ────────────────────────
//
// The cs2-setup route computes which elimination section each team belongs to
// based on stageEntry, stageEliminated, and stage completion thresholds.
// We test the same logic here as a pure function to keep it verifiable.
interface FakeResult {
participantId: string;
stageEntry: number;
stageEliminated: number | null;
stageEliminatedWins: number | null;
}
function computeFullField(
results: FakeResult[],
stage1Complete: boolean,
stage2Complete: boolean
): Record<1 | 2 | 3, Array<FakeResult & { advancedFrom?: number }>> {
const out: Record<1 | 2 | 3, Array<FakeResult & { advancedFrom?: number }>> = { 1: [], 2: [], 3: [] };
for (const r of results) {
if (r.stageEliminated !== null) {
(out[r.stageEliminated as 1 | 2 | 3] ?? out[3]).push(r);
} else if (r.stageEntry === 1) {
if (stage1Complete) {
if (stage2Complete) {
out[3].push({ ...r, advancedFrom: 1 });
} else {
out[2].push({ ...r, advancedFrom: 1 });
}
} else {
out[1].push(r);
}
} else if (r.stageEntry === 2) {
if (stage2Complete) {
out[3].push({ ...r, advancedFrom: 2 });
} else {
out[2].push(r);
}
} else {
out[3].push(r);
}
}
return out;
}
function makeResult(id: string, entry: number, elim: number | null, wins: number | null = null): FakeResult {
return { participantId: id, stageEntry: entry, stageEliminated: elim, stageEliminatedWins: wins };
}
describe("byStageFullField computation", () => {
it("before any stage completes, each team appears in their starting stage", () => {
const results = [
makeResult("s1a", 1, null), makeResult("s1b", 1, null),
makeResult("s2a", 2, null),
makeResult("s3a", 3, null),
];
const full = computeFullField(results, false, false);
expect(full[1].map(r => r.participantId)).toContain("s1a");
expect(full[2].map(r => r.participantId)).toContain("s2a");
expect(full[3].map(r => r.participantId)).toContain("s3a");
expect(full[2].map(r => r.participantId)).not.toContain("s1a");
});
it("stage 1 eliminated teams stay in section 1 regardless of stage completion", () => {
const results = [makeResult("t", 1, 1, 0)];
const full = computeFullField(results, true, true);
expect(full[1].map(r => r.participantId)).toContain("t");
expect(full[2].map(r => r.participantId)).not.toContain("t");
});
it("stage 1 advancers move to section 2 when stage 1 is complete", () => {
const results = [makeResult("adv", 1, null)];
const full = computeFullField(results, true, false);
expect(full[2].map(r => r.participantId)).toContain("adv");
expect(full[1].map(r => r.participantId)).not.toContain("adv");
expect((full[2].find(r => r.participantId === "adv") as { advancedFrom?: number })?.advancedFrom).toBe(1);
});
it("stage 1 advancers move to section 3 when both stages 1 and 2 are complete", () => {
const results = [makeResult("adv", 1, null)];
const full = computeFullField(results, true, true);
expect(full[3].map(r => r.participantId)).toContain("adv");
expect(full[2].map(r => r.participantId)).not.toContain("adv");
});
it("stage 2 advancers move to section 3 when stage 2 is complete", () => {
const results = [makeResult("s2adv", 2, null)];
const full = computeFullField(results, true, true);
expect(full[3].map(r => r.participantId)).toContain("s2adv");
expect(full[2].map(r => r.participantId)).not.toContain("s2adv");
});
it("stage 2 eliminated teams always appear in section 2", () => {
const results = [makeResult("e2", 1, 2, 1)]; // Stage 1 entrant eliminated in Stage 2
const full = computeFullField(results, true, false);
expect(full[2].map(r => r.participantId)).toContain("e2");
expect(full[1].map(r => r.participantId)).not.toContain("e2");
});
it("stage 3 eliminated teams always appear in section 3", () => {
const results = [makeResult("e3", 1, 3, 2)]; // Stage 1 entrant eliminated in Stage 3
const full = computeFullField(results, true, true);
expect(full[3].map(r => r.participantId)).toContain("e3");
expect(full[1].map(r => r.participantId)).not.toContain("e3");
expect(full[2].map(r => r.participantId)).not.toContain("e3");
});
it("each team appears in exactly one section", () => {
const results = [
makeResult("a", 1, 1, 0), // eliminated Stage 1
makeResult("b", 1, null), // Stage 1 advancer (Stage 1 complete)
makeResult("c", 2, 2, 1), // eliminated Stage 2
makeResult("d", 2, null), // Stage 2 advancer (Stage 2 complete)
makeResult("e", 3, 3, 2), // eliminated Stage 3
makeResult("f", 3, null), // Stage 3 survivor (Champions)
];
const full = computeFullField(results, true, true);
const allIds = [...full[1], ...full[2], ...full[3]].map(r => r.participantId);
const uniqueIds = new Set(allIds);
expect(uniqueIds.size).toBe(results.length);
expect(allIds.length).toBe(results.length);
});
});

View file

@ -13,8 +13,9 @@
*/ */
import { database } from "~/database/context"; 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 { eq, and, sql } from "drizzle-orm";
import { getQPConfig, recalculateParticipantQP } from "~/models/qualifying-points";
export interface Cs2StageResult { export interface Cs2StageResult {
id: string; id: string;
@ -135,25 +136,38 @@ export async function clearCs2StageAssignments(
/** /**
* Mark teams as eliminated from a specific stage. * Mark teams as eliminated from a specific stage.
* Records stageEliminated and stageEliminatedWins for the specified seasonParticipants. * Each elimination entry specifies which stage the team was eliminated at
* Teams NOT in eliminatedEntries that are in this stage are implicitly considered * this may differ from stageEntry for teams that advanced from an earlier stage
* to have advanced (stageEliminated remains null). * (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( export async function markCs2StageEliminations(
scoringEventId: string, scoringEventId: string,
eliminations: Array<{ participantId: string; winsAtElimination: number }> eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }>
): Promise<void> { ): Promise<void> {
if (eliminations.length === 0) return; if (eliminations.length === 0) return;
const db = database(); const db = database();
const now = new Date(); const now = new Date();
// Use individual upserts for each eliminated team to set their specific win count 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, winsAtElimination }) => eliminations.map(({ participantId, stageEliminated, winsAtElimination }) =>
db db
.update(cs2MajorStageResults) .update(cs2MajorStageResults)
.set({ .set({
stageEliminated: sql`${cs2MajorStageResults.stageEntry}`, // eliminated at their current stage stageEliminated,
stageEliminatedWins: winsAtElimination, stageEliminatedWins: winsAtElimination,
updatedAt: now, updatedAt: now,
}) })
@ -165,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.
@ -191,3 +225,173 @@ export async function setCs2FinalPlacements(
) )
); );
} }
// Stage 3 always eliminates 8 teams (16 Swiss teams → 8 advance, 8 eliminated → slots 916).
const STAGE3_TOTAL_EXITS = 8;
/**
* Compute QP for Stage 3 exits based on W-L record.
* Teams are ranked within slots 916 by wins descending (2-3 > 1-3 > 0-3).
* Within the same wins count QP is tie-split (averaged) across placement slots.
*
* 0-wins teams always occupy the BOTTOM of the slot range (slots 1516 for 2
* teams) because they can never rank above any team with at least 1 win.
* Higher-wins groups fill from slot 9 upward as before. This means the
* progressive intermediate QP values for 0-3 teams are correct even before
* the other Stage 3 exits have been marked.
*
* Exported for unit testing.
*/
export function computeStage3ExitQP(
elimTeams: Array<{ id: string; wins: number }>,
qpConfig: Map<number, number>,
totalStage3Exits: number = STAGE3_TOTAL_EXITS
): Map<string, number> {
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);
}
const result = new Map<string, number>();
const slotRangeEnd = 9 + totalStage3Exits - 1; // = 16 for 8 exits
// 0-wins teams always fill the bottom of the range.
const zeroWinsIds = byWins.get(0) ?? [];
const zeroWinsSlotStart = slotRangeEnd - zeroWinsIds.length + 1;
if (zeroWinsIds.length > 0) {
const slots = Array.from({ length: zeroWinsIds.length }, (_, i) => zeroWinsSlotStart + i);
const avgQP = slots.reduce((sum, s) => sum + (qpConfig.get(s) ?? 0), 0) / slots.length;
for (const id of zeroWinsIds) result.set(id, avgQP);
}
// Higher-wins groups fill from slot 9 upward.
const higherWinsGroups = [...byWins.entries()]
.filter(([wins]) => wins > 0)
.toSorted((a, b) => b[0] - a[0]);
let slotStart = 9;
for (const [, ids] of higherWinsGroups) {
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;
}
/**
* 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 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 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(
scoringEventId: string,
sportsSeasonId: string
): Promise<void> {
const db = database();
const allResults = await getCs2StageResultsForEvent(scoringEventId);
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 (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)])
);
// Map from participantId → { qp, placement }
const resultsByParticipant = new Map<string, { qp: number; placement: number }>();
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) {
if (stage3Exits.length === STAGE3_TOTAL_EXITS) {
// All exits known — compute correct QP and placements.
const stage3QP = computeStage3ExitQP(
stage3Exits.map((r) => ({ id: r.participantId, wins: r.stageEliminatedWins ?? 0 })),
qpConfig
);
// All teams in a W-L group share the same placement so processQualifyingEvent
// groups them and tie-splits QP identically to computeStage3ExitQP.
// 0-wins teams fill the bottom of the range; higher-wins groups fill from slot 9 up.
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);
}
const slotRangeEnd = 9 + STAGE3_TOTAL_EXITS - 1; // = 16
const zeroWinIds = byWins.get(0) ?? [];
const zeroWinSlotStart = slotRangeEnd - zeroWinIds.length + 1;
for (const id of zeroWinIds) {
resultsByParticipant.set(id, { qp: stage3QP.get(id) ?? 0, placement: zeroWinSlotStart });
}
let slotStart = 9;
for (const [wins, ids] of [...byWins.entries()].toSorted((a, b) => b[0] - a[0])) {
if (wins === 0) continue;
for (const id of ids) {
resultsByParticipant.set(id, { qp: stage3QP.get(id) ?? 0, placement: slotStart });
}
slotStart += ids.length;
}
} else {
// Partial — write 0 QP so rows exist but defer correct values until all exits are known.
for (const r of stage3Exits) {
resultsByParticipant.set(r.participantId, { qp: 0, placement: 9 });
}
}
}
const now = new Date();
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,
},
})
)
);
await Promise.all(
[...resultsByParticipant.keys()].map((seasonParticipantId) =>
recalculateParticipantQP(seasonParticipantId, sportsSeasonId)
)
);
}

View file

@ -9,6 +9,8 @@ import {
upsertCs2StageAssignments, upsertCs2StageAssignments,
markCs2StageEliminations, markCs2StageEliminations,
clearCs2StageAssignments, clearCs2StageAssignments,
clearCs2EliminationsAtStage,
assignCs2EliminationQP,
} from '~/models/cs2-major-stage'; } from '~/models/cs2-major-stage';
import { findSeasonMatchesByScoringEventId } from '~/models/season-match'; import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
import { syncMatches } from '~/services/match-sync'; import { syncMatches } from '~/services/match-sync';
@ -85,20 +87,49 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
if (intent === 'mark-eliminations') { if (intent === 'mark-eliminations') {
// Parse elimination data: elim_{participantId} = winsAtElimination (0, 1, or 2) // Parse elimination data: elim_{stageNum}_{participantId} = winsAtElimination (0, 1, or 2)
const eliminations: Array<{ participantId: string; winsAtElimination: number }> = []; // stageNum is the stage the team was eliminated AT (may differ from their stageEntry
for (const [key, value] of formData.entries()) { // when a Stage 1 team advances to Stage 2 before being eliminated).
if (key.startsWith('elim_')) { //
const participantId = key.slice('elim_'.length); // stage_displayed_{N} hidden fields indicate which stage sections were rendered.
const wins = parseInt(value as string, 10); // Before applying submitted eliminations, we clear those stages so that unchecking
if (!isNaN(wins) && wins >= 0 && wins <= 2) { // a team actually removes them from the eliminated list (replacement semantics).
eliminations.push({ participantId, winsAtElimination: wins }); 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); const eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }> = [];
return { success: true, message: `Marked ${eliminations.length} eliminations.` }; 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') { if (intent === 'sync-matches') {
@ -117,8 +148,8 @@ export async function action({ request, params }: Route.ActionArgs) {
const STAGE_LABELS: Record<number, { label: string; description: string; maxTeams: number; badgeVariant: 'default' | 'secondary' | 'outline' }> = { const STAGE_LABELS: Record<number, { label: string; description: string; maxTeams: number; badgeVariant: 'default' | 'secondary' | 'outline' }> = {
1: { label: 'Stage 1', description: 'Opening Stage (16 teams, Bo1 Swiss)', maxTeams: 16, badgeVariant: 'outline' }, 1: { label: 'Stage 1', description: 'Opening Stage (16 teams, Bo1 Swiss)', maxTeams: 16, badgeVariant: 'outline' },
2: { label: 'Stage 2', description: 'Elimination Stage (8 Challengers, Bo1/Bo3 Swiss)', maxTeams: 8, badgeVariant: 'secondary' }, 2: { label: 'Stage 2', description: 'Challengers Stage (8 direct + 8 from Stage 1, Bo1/Bo3 Swiss)', maxTeams: 16, badgeVariant: 'secondary' },
3: { label: 'Stage 3', description: 'Decider Stage (8 Legends, all Bo3 Swiss)', maxTeams: 8, badgeVariant: 'default' }, 3: { label: 'Stage 3', description: 'Legends Stage (8 direct + 8 from Stage 2, all Bo3 Swiss)', maxTeams: 16, badgeVariant: 'default' },
}; };
const RECORD_OPTIONS = [ const RECORD_OPTIONS = [
@ -156,14 +187,14 @@ export default function AdminCs2Setup() {
return initial; return initial;
}); });
// Elimination checkbox state — tracks which teams are checked as eliminated // Tracks which stage each team is currently eliminated at (null = still active)
const [eliminatedChecked, setEliminatedChecked] = useState<Record<string, boolean>>(() => { const [eliminatedAtStage, setEliminatedAtStage] = useState<Record<string, number | null>>(() => {
const initial: Record<string, boolean> = {}; const initial: Record<string, number | null> = {};
stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated !== null; }); stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated; });
return initial; return initial;
}); });
// Group participants by stage for display // Group participants by starting stage for the Stage Assignments card
const byStage: Record<number, typeof stageResults> = { 1: [], 2: [], 3: [] }; const byStage: Record<number, typeof stageResults> = { 1: [], 2: [], 3: [] };
for (const r of stageResults) { for (const r of stageResults) {
byStage[r.stageEntry]?.push(r); byStage[r.stageEntry]?.push(r);
@ -173,6 +204,44 @@ export default function AdminCs2Setup() {
Object.entries(byStage).map(([k, v]) => [k, v.length]) Object.entries(byStage).map(([k, v]) => [k, v.length])
); );
// Determine stage completion from saved data (used to move advancers into the next stage section)
const stage1EliminatedCount = stageResults.filter(r => r.stageEliminated === 1).length;
const stage1Complete = stage1EliminatedCount >= 8;
const stage2EliminatedCount = stageResults.filter(r => r.stageEliminated === 2).length;
const stage2Complete = stage2EliminatedCount >= 8;
// Full field per stage for the Elimination Tracking section.
// Each team appears in exactly one section — the stage they are currently playing in
// (or were last eliminated from). Once stage N is complete, its advancers move to stage N+1.
type StageResultWithAdvance = typeof stageResults[0] & { advancedFrom?: number };
const byStageFullField: Record<number, StageResultWithAdvance[]> = { 1: [], 2: [], 3: [] };
for (const r of stageResults) {
if (r.stageEliminated !== null) {
// Already eliminated — show in the section for their elimination stage
byStageFullField[r.stageEliminated]?.push(r);
} else if (r.stageEntry === 1) {
if (stage1Complete) {
// Survived Stage 1 — now in Stage 2 (or beyond)
if (stage2Complete) {
byStageFullField[3].push({ ...r, advancedFrom: 1 });
} else {
byStageFullField[2].push({ ...r, advancedFrom: 1 });
}
} else {
byStageFullField[1].push(r);
}
} else if (r.stageEntry === 2) {
if (stage2Complete) {
byStageFullField[3].push({ ...r, advancedFrom: 2 });
} else {
byStageFullField[2].push(r);
}
} else {
// stageEntry === 3, always shown in Stage 3
byStageFullField[3].push(r);
}
}
return ( return (
<div className="container mx-auto py-8"> <div className="container mx-auto py-8">
<div className="mb-6"> <div className="mb-6">
@ -269,8 +338,10 @@ export default function AdminCs2Setup() {
<CardTitle>Stage Advancement Tracking</CardTitle> <CardTitle>Stage Advancement Tracking</CardTitle>
<CardDescription> <CardDescription>
Record which teams were eliminated and their W-L record at elimination. Record which teams were eliminated and their W-L record at elimination.
Teams not listed as eliminated are assumed to have advanced (or are still playing). Each stage shows its full 16-team field direct entries plus teams that advanced
Stage 3 W-L records determine QP sub-placements (916). from the previous stage. Teams not checked as eliminated are assumed to have advanced
(or are still playing). Stage 3 W-L records determine QP sub-placements (916), and
QP is written to the event as you save each stage.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -278,25 +349,35 @@ export default function AdminCs2Setup() {
<input type="hidden" name="intent" value="mark-eliminations" /> <input type="hidden" name="intent" value="mark-eliminations" />
{[1, 2, 3].map(stage => { {[1, 2, 3].map(stage => {
const teamsInStage = byStage[stage]; const teamsInStage = byStageFullField[stage];
if (teamsInStage.length === 0) return null; if (teamsInStage.length === 0) return null;
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}
<span className="text-xs font-normal text-muted-foreground">
({teamsInStage.length} teams)
</span>
</h3> </h3>
<div className="space-y-1"> <div className="space-y-1">
{teamsInStage.map(r => { {teamsInStage.map(r => {
const isEliminated = eliminatedChecked[r.participantId] ?? false; const currentElimStage = eliminatedAtStage[r.participantId] ?? null;
const isEliminated = currentElimStage === stage;
return ( return (
<div key={r.participantId} className="grid grid-cols-[1fr_auto_200px] gap-x-3 items-center py-1 border-b border-border/40 last:border-0"> <div key={r.participantId} className="grid grid-cols-[1fr_auto_200px] gap-x-3 items-center py-1 border-b border-border/40 last:border-0">
<span className="text-sm flex items-center gap-2"> <span className="text-sm flex items-center gap-2">
{r.participantName} {r.participantName}
{r.stageEliminated !== null && ( {'advancedFrom' in r && r.advancedFrom !== undefined && (
<Badge variant="outline" className="text-xs text-muted-foreground">
adv. from Stage {r.advancedFrom}
</Badge>
)}
{r.stageEliminated !== null && r.stageEliminated === stage && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
eliminated {r.stageEliminatedWins}-3 {r.stageEliminatedWins}-3
</Badge> </Badge>
)} )}
</span> </span>
@ -305,9 +386,9 @@ export default function AdminCs2Setup() {
type="checkbox" type="checkbox"
checked={isEliminated} checked={isEliminated}
onChange={e => onChange={e =>
setEliminatedChecked(prev => ({ setEliminatedAtStage(prev => ({
...prev, ...prev,
[r.participantId]: e.target.checked, [r.participantId]: e.target.checked ? stage : null,
})) }))
} }
className="rounded" className="rounded"
@ -316,8 +397,8 @@ export default function AdminCs2Setup() {
</label> </label>
{isEliminated && ( {isEliminated && (
<select <select
name={`elim_${r.participantId}`} name={`elim_${stage}_${r.participantId}`}
defaultValue={r.stageEliminatedWins?.toString() ?? '0'} defaultValue={r.stageEliminated === stage ? (r.stageEliminatedWins?.toString() ?? '0') : '0'}
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2" className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
> >
{RECORD_OPTIONS.map(opt => ( {RECORD_OPTIONS.map(opt => (

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 {