From 04f6222759c4c46c5220fcf9caf8818b4a96ec96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 16:44:02 +0000 Subject: [PATCH 1/3] Fix CS2 stage advancement tracking and add progressive QP assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage Advancement UI now shows the full 16-team field for each stage (direct entries plus teams that advanced from the previous stage), so admins can see and mark eliminations for Stage 1 advancers competing in Stage 2/3 without confusion about where those teams went. Key changes: - markCs2StageEliminations now accepts an explicit stageEliminated value per team, fixing a bug where Stage 1 teams eliminated in Stage 2 would have stageEliminated set to 1 (their entry stage) instead of 2. - New assignCs2EliminationQP writes QP to event_results progressively as eliminations are saved: Stage 1/2 exits get 0 QP immediately, Stage 3 exits get sub-ranked QP (placements 9-16 by W-L record). - Form keys changed to elim_{stageNum}_{participantId} so the backend knows which stage each elimination occurred at. - Stage completion is inferred from saved data (≥8 eliminations at that stage) to automatically move advancers into the next section. - STAGE_LABELS updated to reflect the real 16-team field for stages 2/3. https://claude.ai/code/session_013u6vbGHdppe88wQ95BLANw --- app/models/__tests__/cs2-major-stage.test.ts | 205 ++++++++++++++++++ app/models/cs2-major-stage.ts | 123 ++++++++++- ...-seasons.$id.events.$eventId.cs2-setup.tsx | 102 +++++++-- 3 files changed, 400 insertions(+), 30 deletions(-) create mode 100644 app/models/__tests__/cs2-major-stage.test.ts diff --git a/app/models/__tests__/cs2-major-stage.test.ts b/app/models/__tests__/cs2-major-stage.test.ts new file mode 100644 index 0000000..316b660 --- /dev/null +++ b/app/models/__tests__/cs2-major-stage.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from "vitest"; +import { computeStage3ExitQP } from "../cs2-major-stage"; + +function makeStage3QPConfig(): Map { + const config = new Map(); + // Slots 9–16 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> { + const out: Record<1 | 2 | 3, Array> = { 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); + }); +}); diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts index bbaede8..da4078d 100644 --- a/app/models/cs2-major-stage.ts +++ b/app/models/cs2-major-stage.ts @@ -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 { 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 9–16 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 +): Map { + const byWins = new Map(); + 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(); + 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 17–32) + * - Stage 3 exits → sub-ranked QP (placements 9–16, 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 { + 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( + qpConfigArray.map((c) => [c.placement, parseFloat(c.points)]) + ); + + const qpByParticipant = new Map(); + + 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); + } +} diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx index db4e759..e213dce 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx @@ -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 = { 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>(() => { - const initial: Record = {}; - 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>(() => { + const initial: Record = {}; + 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 = { 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 = { 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 (
@@ -269,8 +316,10 @@ export default function AdminCs2Setup() { Stage Advancement Tracking 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 (9–16). + 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 (9–16), and + QP is written to the event as you save each stage. @@ -278,7 +327,7 @@ export default function AdminCs2Setup() { {[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() {

{STAGE_LABELS[stage].label} {STAGE_LABELS[stage].description} + + ({teamsInStage.length} teams) +

{teamsInStage.map(r => { - const isEliminated = eliminatedChecked[r.participantId] ?? false; + const currentElimStage = eliminatedAtStage[r.participantId] ?? null; + const isEliminated = currentElimStage === stage; return (
{r.participantName} - {r.stageEliminated !== null && ( + {'advancedFrom' in r && r.advancedFrom !== undefined && ( + + adv. from Stage {r.advancedFrom} + + )} + {r.stageEliminated !== null && r.stageEliminated === stage && ( - eliminated {r.stageEliminatedWins}-3 + {r.stageEliminatedWins}-3 )} @@ -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() { {isEliminated && (

{STAGE_LABELS[stage].label} {STAGE_LABELS[stage].description} diff --git a/app/services/simulations/cs-major-simulator.ts b/app/services/simulations/cs-major-simulator.ts index 7d2d79a..7e0c23d 100644 --- a/app/services/simulations/cs-major-simulator.ts +++ b/app/services/simulations/cs-major-simulator.ts @@ -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 -): Map { - // Group teams by wins count (0, 1, 2) - const byWins = new Map(); - 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(); - 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 { -- 2.45.3 From e2cf29920444a8a26b35fffedd1521fb5100ae8c Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 13 Jun 2026 22:12:01 -0700 Subject: [PATCH 3/3] Fix CS2 Stage 3 QP: 0-3 teams always slot to bottom, defer all QP until all 8 exits known Co-Authored-By: Claude Sonnet 4.6 --- app/models/__tests__/cs2-major-stage.test.ts | 36 +++++--- app/models/cs2-major-stage.ts | 86 ++++++++++++++------ 2 files changed, 88 insertions(+), 34 deletions(-) diff --git a/app/models/__tests__/cs2-major-stage.test.ts b/app/models/__tests__/cs2-major-stage.test.ts index 316b660..e83595c 100644 --- a/app/models/__tests__/cs2-major-stage.test.ts +++ b/app/models/__tests__/cs2-major-stage.test.ts @@ -50,16 +50,32 @@ describe("computeStage3ExitQP", () => { ...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")!); + expect(result.get("high") ?? 0).toBeGreaterThan(result.get("mid") ?? 0); + expect(result.get("mid") ?? 0).toBeGreaterThan(result.get("low") ?? 0); }); - it("handles a single team (gets slot 9 QP)", () => { + 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 15–16 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 }], @@ -71,12 +87,12 @@ describe("computeStage3ExitQP", () => { 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) + { id: "worst", wins: 0 }, // → slot 16 (lowest QP — 0-wins always fills from bottom) ]; const result = computeStage3ExitQP(elim, makeStage3QPConfig()); - // slot 9 = 80, slot 10 = 70 + // slot 9 = 80, slot 16 = 10 expect(result.get("best")).toBe(80); - expect(result.get("worst")).toBe(70); + expect(result.get("worst")).toBe(10); }); }); @@ -125,11 +141,11 @@ function computeFullField( 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 }; - } +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), diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts index f38f319..3a9e55c 100644 --- a/app/models/cs2-major-stage.ts +++ b/app/models/cs2-major-stage.ts @@ -226,15 +226,26 @@ export async function setCs2FinalPlacements( ); } +// Stage 3 always eliminates 8 teams (16 Swiss teams → 8 advance, 8 eliminated → slots 9–16). +const STAGE3_TOTAL_EXITS = 8; + /** * Compute QP for Stage 3 exits based on W-L record. * Teams are ranked within slots 9–16 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 15–16 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 + qpConfig: Map, + totalStage3Exits: number = STAGE3_TOTAL_EXITS ): Map { const byWins = new Map(); for (const t of elimTeams) { @@ -243,15 +254,27 @@ export function computeStage3ExitQP( } const result = new Map(); - const winsGroups = [...byWins.entries()].toSorted((a, b) => b[0] - a[0]); + 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 winsGroups) { + 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); - } + for (const id of ids) result.set(id, avgQP); slotStart += ids.length; } @@ -303,26 +326,41 @@ export async function assignCs2EliminationQP( } if (stage3Exits.length > 0) { - const stage3QP = computeStage3ExitQP( - stage3Exits.map((r) => ({ id: r.participantId, wins: r.stageEliminatedWins ?? 0 })), - qpConfig - ); + 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 + ); - // 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(); - 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 }); + // 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(); + 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 }); } - slotStart += ids.length; } } -- 2.45.3