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
3 changed files with 400 additions and 30 deletions
Showing only changes of commit 04f6222759 - Show all commits

View file

@ -0,0 +1,205 @@
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")!).toBeGreaterThan(result.get("mid")!);
expect(result.get("mid")!).toBeGreaterThan(result.get("low")!);
});
it("handles a single team (gets slot 9 QP)", () => {
const config = makeStage3QPConfig();
const result = computeStage3ExitQP([{ id: "solo", wins: 2 }], config);
expect(result.get("solo")).toBe(config.get(9));
});
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 10 (next highest)
];
const result = computeStage3ExitQP(elim, makeStage3QPConfig());
// slot 9 = 80, slot 10 = 70
expect(result.get("best")).toBe(80);
expect(result.get("worst")).toBe(70);
});
});
// ─── 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;
}
describe("byStageFullField computation", () => {
function makeResult(id: string, entry: number, elim: number | null, wins: number | null = null): FakeResult {
return { participantId: id, stageEntry: entry, stageEliminated: elim, stageEliminatedWins: wins };
}
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 { cs2MajorStageResults, seasonParticipants } from "~/database/schema";
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
import { eq, and, sql } from "drizzle-orm";
import { getQPConfig, recalculateParticipantQP } from "~/models/qualifying-points";
export interface Cs2StageResult {
id: string;
@ -135,25 +136,25 @@ export async function clearCs2StageAssignments(
/**
* Mark teams as eliminated from a specific stage.
* Records stageEliminated and stageEliminatedWins for the specified seasonParticipants.
* Teams NOT in eliminatedEntries that are in this stage are implicitly considered
* to have advanced (stageEliminated remains null).
* Each elimination entry specifies which stage the team was eliminated at
* 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.
*/
export async function markCs2StageEliminations(
scoringEventId: string,
eliminations: Array<{ participantId: string; winsAtElimination: number }>
eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }>
): Promise<void> {
if (eliminations.length === 0) return;
const db = database();
const now = new Date();
// Use individual upserts for each eliminated team to set their specific win count
await Promise.all(
eliminations.map(({ participantId, winsAtElimination }) =>
eliminations.map(({ participantId, stageEliminated, winsAtElimination }) =>
db
.update(cs2MajorStageResults)
.set({
stageEliminated: sql`${cs2MajorStageResults.stageEntry}`, // eliminated at their current stage
stageEliminated,
stageEliminatedWins: winsAtElimination,
updatedAt: now,
})
@ -191,3 +192,109 @@ export async function setCs2FinalPlacements(
)
);
}
/**
* 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.
* Exported for unit testing.
*/
export function computeStage3ExitQP(
elimTeams: Array<{ id: string; wins: number }>,
qpConfig: Map<number, number>
): 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 winsGroups = [...byWins.entries()].toSorted((a, b) => b[0] - a[0]);
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;
}
/**
* 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.
*
* - Stage 1 or 2 exits 0 QP (placements 1732)
* - Stage 3 exits sub-ranked QP (placements 916, split by W-L record)
* - Champions Stage participants (stageEliminated = null) are not touched here;
* their QP is handled by the bracket admin when Champion Stage results are entered.
*
* 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 stage1or2Exits = allResults.filter(
(r) => r.stageEliminated === 1 || r.stageEliminated === 2
);
const stage3Exits = allResults.filter((r) => r.stageEliminated === 3);
if (stage1or2Exits.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>();
for (const r of stage1or2Exits) {
qpByParticipant.set(r.participantId, 0);
}
if (stage3Exits.length > 0) {
const stage3QP = computeStage3ExitQP(
stage3Exits.map((r) => ({ id: r.participantId, wins: r.stageEliminatedWins ?? 0 })),
qpConfig
);
for (const [id, qp] of stage3QP) {
qpByParticipant.set(id, qp);
}
}
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: {
qualifyingPointsAwarded: qp.toString(),
updatedAt: now,
},
});
}
for (const seasonParticipantId of qpByParticipant.keys()) {
await recalculateParticipantQP(seasonParticipantId, sportsSeasonId);
}
}

View file

@ -9,6 +9,7 @@ import {
upsertCs2StageAssignments,
markCs2StageEliminations,
clearCs2StageAssignments,
assignCs2EliminationQP,
} from '~/models/cs2-major-stage';
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
import { syncMatches } from '~/services/match-sync';
@ -85,19 +86,27 @@ export async function action({ request, params }: Route.ActionArgs) {
}
if (intent === 'mark-eliminations') {
// Parse elimination data: elim_{participantId} = winsAtElimination (0, 1, or 2)
const eliminations: Array<{ participantId: string; winsAtElimination: number }> = [];
// 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 participantId = key.slice('elim_'.length);
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(wins) && wins >= 0 && wins <= 2) {
eliminations.push({ participantId, winsAtElimination: wins });
if (!isNaN(stageEliminated) && stageEliminated >= 1 && stageEliminated <= 3
&& participantId.length > 0 && !isNaN(wins) && wins >= 0 && wins <= 2) {
eliminations.push({ participantId, stageEliminated, winsAtElimination: wins });
}
}
}
await markCs2StageEliminations(eventId, eliminations);
await assignCs2EliminationQP(eventId, params.id);
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
}
@ -117,8 +126,8 @@ export async function action({ request, params }: Route.ActionArgs) {
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' },
2: { label: 'Stage 2', description: 'Elimination Stage (8 Challengers, Bo1/Bo3 Swiss)', maxTeams: 8, badgeVariant: 'secondary' },
3: { label: 'Stage 3', description: 'Decider Stage (8 Legends, all Bo3 Swiss)', maxTeams: 8, badgeVariant: 'default' },
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: 'Legends Stage (8 direct + 8 from Stage 2, all Bo3 Swiss)', maxTeams: 16, badgeVariant: 'default' },
};
const RECORD_OPTIONS = [
@ -156,14 +165,14 @@ export default function AdminCs2Setup() {
return initial;
});
// Elimination checkbox state — tracks which teams are checked as eliminated
const [eliminatedChecked, setEliminatedChecked] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated !== null; });
// Tracks which stage each team is currently eliminated at (null = still active)
const [eliminatedAtStage, setEliminatedAtStage] = useState<Record<string, number | null>>(() => {
const initial: Record<string, number | null> = {};
stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated; });
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: [] };
for (const r of stageResults) {
byStage[r.stageEntry]?.push(r);
@ -173,6 +182,44 @@ export default function AdminCs2Setup() {
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 (
<div className="container mx-auto py-8">
<div className="mb-6">
@ -269,8 +316,10 @@ export default function AdminCs2Setup() {
<CardTitle>Stage Advancement Tracking</CardTitle>
<CardDescription>
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).
Stage 3 W-L records determine QP sub-placements (916).
Each stage shows its full 16-team field direct entries plus teams that advanced
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>
</CardHeader>
<CardContent>
@ -278,7 +327,7 @@ export default function AdminCs2Setup() {
<input type="hidden" name="intent" value="mark-eliminations" />
{[1, 2, 3].map(stage => {
const teamsInStage = byStage[stage];
const teamsInStage = byStageFullField[stage];
if (teamsInStage.length === 0) return null;
return (
@ -286,17 +335,26 @@ export default function AdminCs2Setup() {
<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}
<span className="text-xs font-normal text-muted-foreground">
({teamsInStage.length} teams)
</span>
</h3>
<div className="space-y-1">
{teamsInStage.map(r => {
const isEliminated = eliminatedChecked[r.participantId] ?? false;
const currentElimStage = eliminatedAtStage[r.participantId] ?? null;
const isEliminated = currentElimStage === stage;
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">
<span className="text-sm flex items-center gap-2">
{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">
eliminated {r.stageEliminatedWins}-3
{r.stageEliminatedWins}-3
</Badge>
)}
</span>
@ -305,9 +363,9 @@ export default function AdminCs2Setup() {
type="checkbox"
checked={isEliminated}
onChange={e =>
setEliminatedChecked(prev => ({
setEliminatedAtStage(prev => ({
...prev,
[r.participantId]: e.target.checked,
[r.participantId]: e.target.checked ? stage : null,
}))
}
className="rounded"
@ -316,8 +374,8 @@ export default function AdminCs2Setup() {
</label>
{isEliminated && (
<select
name={`elim_${r.participantId}`}
defaultValue={r.stageEliminatedWins?.toString() ?? '0'}
name={`elim_${stage}_${r.participantId}`}
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"
>
{RECORD_OPTIONS.map(opt => (