Fix CS2 Stage 3 QP slot assignment (#89)
## Summary - 0-3 teams in Stage 3 were being assigned slots 9–10 (high QP) when marked eliminated before other Stage 3 teams, because `computeStage3ExitQP` filled from slot 9 upward using only the teams passed in - 1-3 teams had the same problem when marked before 2-3 teams - Fix: gate all Stage 3 QP computation on `stage3Exits.length === STAGE3_TOTAL_EXITS` (8) — partial saves write 0 QP as a placeholder, and correct QP/placements are assigned once all 8 exits are known - As belt-and-suspenders, `computeStage3ExitQP` now places 0-wins teams from the bottom of the slot range so the function itself is correct even if called with a partial set ## Test plan - [ ] Run `npm run test:run -- app/models/__tests__/cs2-major-stage.test.ts` — all 17 tests pass - [ ] Mark 2 Stage 3 teams as 0-3 eliminated and save — confirm they show 0 QP (not 2 QP) - [ ] Mark all 8 Stage 3 exits and save — confirm 0-3 teams get slots 15–16 QP, 1-3 teams get slots 12–14 QP split correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #89
This commit is contained in:
parent
f40144e1e2
commit
d0a31f3883
4 changed files with 544 additions and 75 deletions
221
app/models/__tests__/cs2-major-stage.test.ts
Normal file
221
app/models/__tests__/cs2-major-stage.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { computeStage3ExitQP } from "../cs2-major-stage";
|
||||
|
||||
function makeStage3QPConfig(): Map<number, number> {
|
||||
const config = new Map<number, number>();
|
||||
// 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") ?? 0).toBeGreaterThan(result.get("mid") ?? 0);
|
||||
expect(result.get("mid") ?? 0).toBeGreaterThan(result.get("low") ?? 0);
|
||||
});
|
||||
|
||||
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 }],
|
||||
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 16 (lowest QP — 0-wins always fills from bottom)
|
||||
];
|
||||
const result = computeStage3ExitQP(elim, makeStage3QPConfig());
|
||||
// slot 9 = 80, slot 16 = 10
|
||||
expect(result.get("best")).toBe(80);
|
||||
expect(result.get("worst")).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
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),
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,38 @@ 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.
|
||||
*
|
||||
* Throws if stageEliminated < stageEntry for any participant (a team cannot be
|
||||
* eliminated in a stage they haven't reached yet).
|
||||
*/
|
||||
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
|
||||
const existingResults = await getCs2StageResultsMapForEvent(scoringEventId);
|
||||
for (const { participantId, stageEliminated } of eliminations) {
|
||||
const existing = existingResults.get(participantId);
|
||||
if (existing && stageEliminated < existing.stageEntry) {
|
||||
throw new Error(
|
||||
`Team ${participantId} (stageEntry=${existing.stageEntry}) cannot be eliminated at stage ${stageEliminated}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
|
@ -165,6 +179,26 @@ export async function markCs2StageEliminations(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear elimination records for all teams currently marked as eliminated at a
|
||||
* specific stage. Used to implement replacement semantics in the admin form:
|
||||
* before re-saving eliminations for a stage, wipe the existing records so that
|
||||
* unchecking a team actually removes them from the eliminated list.
|
||||
*/
|
||||
export async function clearCs2EliminationsAtStage(
|
||||
scoringEventId: string,
|
||||
stageNum: number
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
await db
|
||||
.update(cs2MajorStageResults)
|
||||
.set({ stageEliminated: null, stageEliminatedWins: null, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(cs2MajorStageResults.scoringEventId, scoringEventId),
|
||||
eq(cs2MajorStageResults.stageEliminated, stageNum)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set final placements for all seasonParticipants in a CS2 Major event.
|
||||
* Called after the Champions Stage is complete.
|
||||
|
|
@ -191,3 +225,173 @@ 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>,
|
||||
totalStage3Exits: number = STAGE3_TOTAL_EXITS
|
||||
): 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 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 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);
|
||||
slotStart += ids.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write qualifying points and placements to event_results for teams whose
|
||||
* elimination stage is now known. Called after marking stage eliminations so
|
||||
* that QP is recorded progressively rather than only at event completion.
|
||||
*
|
||||
* - Stage 1 exits → 0 QP, placement = 25 (group start for slots 25–32)
|
||||
* - Stage 2 exits → 0 QP, placement = 17 (group start for slots 17–24)
|
||||
* - Stage 3 exits → sub-ranked QP, placement = W-L group start slot (9–16)
|
||||
* - Champions Stage participants (stageEliminated = null) are not touched here;
|
||||
* their QP is handled by the bracket admin when Champions Stage results are entered.
|
||||
*
|
||||
* Writing placement enables processQualifyingEvent to correctly re-derive QP at
|
||||
* finalization via its own tie-split logic (grouping rows by placement value).
|
||||
* 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 stage1Exits = allResults.filter((r) => r.stageEliminated === 1);
|
||||
const stage2Exits = allResults.filter((r) => r.stageEliminated === 2);
|
||||
const stage3Exits = allResults.filter((r) => r.stageEliminated === 3);
|
||||
|
||||
if (stage1Exits.length === 0 && stage2Exits.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)])
|
||||
);
|
||||
|
||||
// Map from participantId → { qp, placement }
|
||||
const resultsByParticipant = new Map<string, { qp: number; placement: number }>();
|
||||
|
||||
for (const r of stage1Exits) {
|
||||
resultsByParticipant.set(r.participantId, { qp: 0, placement: 25 });
|
||||
}
|
||||
for (const r of stage2Exits) {
|
||||
resultsByParticipant.set(r.participantId, { qp: 0, placement: 17 });
|
||||
}
|
||||
|
||||
if (stage3Exits.length > 0) {
|
||||
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
|
||||
);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all(
|
||||
[...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) =>
|
||||
db
|
||||
.insert(eventResults)
|
||||
.values({
|
||||
scoringEventId,
|
||||
seasonParticipantId,
|
||||
qualifyingPointsAwarded: qp.toString(),
|
||||
placement,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
|
||||
set: {
|
||||
qualifyingPointsAwarded: qp.toString(),
|
||||
placement,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[...resultsByParticipant.keys()].map((seasonParticipantId) =>
|
||||
recalculateParticipantQP(seasonParticipantId, sportsSeasonId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
upsertCs2StageAssignments,
|
||||
markCs2StageEliminations,
|
||||
clearCs2StageAssignments,
|
||||
clearCs2EliminationsAtStage,
|
||||
assignCs2EliminationQP,
|
||||
} from '~/models/cs2-major-stage';
|
||||
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||||
import { syncMatches } from '~/services/match-sync';
|
||||
|
|
@ -85,20 +87,49 @@ 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 }> = [];
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key.startsWith('elim_')) {
|
||||
const participantId = key.slice('elim_'.length);
|
||||
const wins = parseInt(value as string, 10);
|
||||
if (!isNaN(wins) && wins >= 0 && wins <= 2) {
|
||||
eliminations.push({ participantId, winsAtElimination: wins });
|
||||
// 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).
|
||||
//
|
||||
// stage_displayed_{N} hidden fields indicate which stage sections were rendered.
|
||||
// Before applying submitted eliminations, we clear those stages so that unchecking
|
||||
// a team actually removes them from the eliminated list (replacement semantics).
|
||||
try {
|
||||
const displayedStages: number[] = [];
|
||||
for (const [key] of formData.entries()) {
|
||||
if (key.startsWith('stage_displayed_')) {
|
||||
const stageNum = parseInt(key.slice('stage_displayed_'.length), 10);
|
||||
if (!isNaN(stageNum) && stageNum >= 1 && stageNum <= 3) {
|
||||
displayedStages.push(stageNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }> = [];
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key.startsWith('elim_')) {
|
||||
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(stageEliminated) && stageEliminated >= 1 && stageEliminated <= 3
|
||||
&& participantId.length > 0 && !isNaN(wins) && wins >= 0 && wins <= 2) {
|
||||
eliminations.push({ participantId, stageEliminated, winsAtElimination: wins });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const stageNum of displayedStages) {
|
||||
await clearCs2EliminationsAtStage(eventId, stageNum);
|
||||
}
|
||||
await markCs2StageEliminations(eventId, eliminations);
|
||||
await assignCs2EliminationQP(eventId, params.id);
|
||||
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === 'sync-matches') {
|
||||
|
|
@ -117,8 +148,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 +187,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 +204,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 +338,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 (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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -278,25 +349,35 @@ 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 (
|
||||
<div key={stage}>
|
||||
<input type="hidden" name={`stage_displayed_${stage}`} value="1" />
|
||||
<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 +386,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 +397,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 => (
|
||||
|
|
|
|||
|
|
@ -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<number, number>
|
||||
): Map<string, number> {
|
||||
// Group teams by wins count (0, 1, 2)
|
||||
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);
|
||||
}
|
||||
|
||||
// Assign placement slots 9–16 from highest wins first
|
||||
const result = new Map<string, number>();
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue