Compare commits

..

No commits in common. "e2cf29920444a8a26b35fffedd1521fb5100ae8c" and "f40144e1e2656b9f6cbaa6d203304ae3c1849fa2" have entirely different histories.

4 changed files with 75 additions and 544 deletions

View file

@ -1,221 +0,0 @@
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,9 +13,8 @@
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema"; import { cs2MajorStageResults, seasonParticipants } 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;
@ -136,38 +135,25 @@ export async function clearCs2StageAssignments(
/** /**
* Mark teams as eliminated from a specific stage. * Mark teams as eliminated from a specific stage.
* Each elimination entry specifies which stage the team was eliminated at * Records stageEliminated and stageEliminatedWins for the specified seasonParticipants.
* this may differ from stageEntry for teams that advanced from an earlier stage * Teams NOT in eliminatedEntries that are in this stage are implicitly considered
* (e.g. a Stage 1 team eliminated in Stage 2 has stageEntry=1, stageEliminated=2). * to have advanced (stageEliminated remains null).
* 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; stageEliminated: number; winsAtElimination: number }> eliminations: Array<{ participantId: string; 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();
const existingResults = await getCs2StageResultsMapForEvent(scoringEventId); // Use individual upserts for each eliminated team to set their specific win count
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, winsAtElimination }) =>
db db
.update(cs2MajorStageResults) .update(cs2MajorStageResults)
.set({ .set({
stageEliminated, stageEliminated: sql`${cs2MajorStageResults.stageEntry}`, // eliminated at their current stage
stageEliminatedWins: winsAtElimination, stageEliminatedWins: winsAtElimination,
updatedAt: now, updatedAt: now,
}) })
@ -179,26 +165,6 @@ 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.
@ -225,173 +191,3 @@ 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,8 +9,6 @@ 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';
@ -87,49 +85,20 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
if (intent === 'mark-eliminations') { if (intent === 'mark-eliminations') {
// Parse elimination data: elim_{stageNum}_{participantId} = winsAtElimination (0, 1, or 2) // Parse elimination data: elim_{participantId} = winsAtElimination (0, 1, or 2)
// stageNum is the stage the team was eliminated AT (may differ from their stageEntry const eliminations: Array<{ participantId: string; winsAtElimination: number }> = [];
// when a Stage 1 team advances to Stage 2 before being eliminated). for (const [key, value] of formData.entries()) {
// if (key.startsWith('elim_')) {
// stage_displayed_{N} hidden fields indicate which stage sections were rendered. const participantId = key.slice('elim_'.length);
// Before applying submitted eliminations, we clear those stages so that unchecking const wins = parseInt(value as string, 10);
// a team actually removes them from the eliminated list (replacement semantics). if (!isNaN(wins) && wins >= 0 && wins <= 2) {
try { eliminations.push({ participantId, winsAtElimination: wins });
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);
}
} }
} }
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.' };
} }
await markCs2StageEliminations(eventId, eliminations);
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
} }
if (intent === 'sync-matches') { if (intent === 'sync-matches') {
@ -148,8 +117,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: 'Challengers Stage (8 direct + 8 from Stage 1, Bo1/Bo3 Swiss)', maxTeams: 16, badgeVariant: 'secondary' }, 2: { label: 'Stage 2', description: 'Elimination Stage (8 Challengers, Bo1/Bo3 Swiss)', maxTeams: 8, badgeVariant: 'secondary' },
3: { label: 'Stage 3', description: 'Legends Stage (8 direct + 8 from Stage 2, all Bo3 Swiss)', maxTeams: 16, badgeVariant: 'default' }, 3: { label: 'Stage 3', description: 'Decider Stage (8 Legends, all Bo3 Swiss)', maxTeams: 8, badgeVariant: 'default' },
}; };
const RECORD_OPTIONS = [ const RECORD_OPTIONS = [
@ -187,14 +156,14 @@ export default function AdminCs2Setup() {
return initial; return initial;
}); });
// Tracks which stage each team is currently eliminated at (null = still active) // Elimination checkbox state — tracks which teams are checked as eliminated
const [eliminatedAtStage, setEliminatedAtStage] = useState<Record<string, number | null>>(() => { const [eliminatedChecked, setEliminatedChecked] = useState<Record<string, boolean>>(() => {
const initial: Record<string, number | null> = {}; const initial: Record<string, boolean> = {};
stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated; }); stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated !== null; });
return initial; return initial;
}); });
// Group participants by starting stage for the Stage Assignments card // Group participants by stage for display
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);
@ -204,44 +173,6 @@ 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">
@ -338,10 +269,8 @@ 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.
Each stage shows its full 16-team field direct entries plus teams that advanced Teams not listed as eliminated are assumed to have advanced (or are still playing).
from the previous stage. Teams not checked as eliminated are assumed to have advanced Stage 3 W-L records determine QP sub-placements (916).
(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>
@ -349,35 +278,25 @@ 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 = byStageFullField[stage]; const teamsInStage = byStage[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 currentElimStage = eliminatedAtStage[r.participantId] ?? null; const isEliminated = eliminatedChecked[r.participantId] ?? false;
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}
{'advancedFrom' in r && r.advancedFrom !== undefined && ( {r.stageEliminated !== null && (
<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">
{r.stageEliminatedWins}-3 eliminated {r.stageEliminatedWins}-3
</Badge> </Badge>
)} )}
</span> </span>
@ -386,9 +305,9 @@ export default function AdminCs2Setup() {
type="checkbox" type="checkbox"
checked={isEliminated} checked={isEliminated}
onChange={e => onChange={e =>
setEliminatedAtStage(prev => ({ setEliminatedChecked(prev => ({
...prev, ...prev,
[r.participantId]: e.target.checked ? stage : null, [r.participantId]: e.target.checked,
})) }))
} }
className="rounded" className="rounded"
@ -397,8 +316,8 @@ export default function AdminCs2Setup() {
</label> </label>
{isEliminated && ( {isEliminated && (
<select <select
name={`elim_${stage}_${r.participantId}`} name={`elim_${r.participantId}`}
defaultValue={r.stageEliminated === stage ? (r.stageEliminatedWins?.toString() ?? '0') : '0'} defaultValue={r.stageEliminatedWins?.toString() ?? '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,8 +46,7 @@ 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, computeStage3ExitQP as calcStage3ExitQP } from "~/models/cs2-major-stage"; import { getCs2StageResultsMapForEvent } 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";
@ -389,6 +388,44 @@ 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 {