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 <noreply@anthropic.com>
This commit is contained in:
parent
f16b9de334
commit
e2cf299204
2 changed files with 88 additions and 34 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue