Fix CS2 Stage 3 QP: 0-3 teams always slot to bottom, defer all QP until all 8 exits known
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m0s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-06-13 22:12:01 -07:00
parent f16b9de334
commit e2cf299204
2 changed files with 88 additions and 34 deletions

View file

@ -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 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 }],
@ -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),

View file

@ -226,15 +226,26 @@ 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>
qpConfig: Map<number, number>,
totalStage3Exits: number = STAGE3_TOTAL_EXITS
): Map<string, number> {
const byWins = new Map<number, string[]>();
for (const t of elimTeams) {
@ -243,15 +254,27 @@ export function computeStage3ExitQP(
}
const result = new Map<string, number>();
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<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);
}
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<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 });
}
slotStart += ids.length;
}
}