From e2cf29920444a8a26b35fffedd1521fb5100ae8c Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 13 Jun 2026 22:12:01 -0700 Subject: [PATCH] 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; } }