Address code review feedback on not-participating flag
Security: unmark-not-participating now validates the result exists, belongs to this event, and is actually a DNP row before deleting. mark-not-participating now returns a user-friendly error on duplicate-key constraint violations. Code quality: extract shared getExcludedByEventMap() utility to event-result model, eliminating the duplicated 20-line exclusion-loading block that was copy-pasted into all three simulators. Fix hasParticipantResult() to exclude notParticipating rows so it correctly reflects actual competition participation. Remove optional chaining on the non-optional notParticipatingIds field in the admin UI. Fix misleading empty-state message. Tests: replace the misleading first tennis DNP test (which never used the activeIds variable it created) with a test that explicitly validates the fallback behaviour when too few players remain after exclusion. Add three CS2 DNP tests covering the excluded-team-gets-zero-QP path, the redistribution of wins, and per-event pool independence. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9
This commit is contained in:
parent
6fbeef2917
commit
ff116961f7
8 changed files with 133 additions and 83 deletions
|
|
@ -196,7 +196,8 @@ export async function deleteEventResults(
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if a participant has a result for a specific event
|
||||
* Check if a participant has a real result (placement/QP) for a specific event.
|
||||
* Returns false for not-participating rows, which are exclusion markers, not results.
|
||||
*/
|
||||
export async function hasParticipantResult(
|
||||
eventId: string,
|
||||
|
|
@ -208,13 +209,52 @@ export async function hasParticipantResult(
|
|||
const result = await db.query.eventResults.findFirst({
|
||||
where: and(
|
||||
eq(schema.eventResults.scoringEventId, eventId),
|
||||
eq(schema.eventResults.seasonParticipantId, participantId)
|
||||
eq(schema.eventResults.seasonParticipantId, participantId),
|
||||
eq(schema.eventResults.notParticipating, false)
|
||||
),
|
||||
});
|
||||
|
||||
return !!result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of eventId → Set<participantId> for all not-participating rows
|
||||
* across a list of scoring events. Used by qualifying-points simulators to
|
||||
* exclude withdrawn participants from each event's draw/pool in a single query.
|
||||
*/
|
||||
export async function getExcludedByEventMap(
|
||||
eventIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<Map<string, Set<string>>> {
|
||||
if (eventIds.length === 0) return new Map();
|
||||
|
||||
const db = providedDb || database();
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
scoringEventId: schema.eventResults.scoringEventId,
|
||||
seasonParticipantId: schema.eventResults.seasonParticipantId,
|
||||
})
|
||||
.from(schema.eventResults)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.eventResults.scoringEventId, eventIds),
|
||||
eq(schema.eventResults.notParticipating, true)
|
||||
)
|
||||
);
|
||||
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const r of rows) {
|
||||
let set = map.get(r.scoringEventId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(r.scoringEventId, set);
|
||||
}
|
||||
set.add(r.seasonParticipantId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all not-participating records for a scoring event.
|
||||
* Used by simulators to exclude withdrawn participants from future event draws.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
createEventResultsBulk,
|
||||
updateEventResult,
|
||||
deleteEventResult,
|
||||
getEventResultById,
|
||||
getNotParticipatingResults,
|
||||
type CreateEventResultData,
|
||||
type UpdateEventResultData,
|
||||
|
|
@ -455,6 +456,9 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
});
|
||||
return { success: "Participant marked as not participating" };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("event_results_event_participant_unique")) {
|
||||
return { error: "This participant is already marked for this event" };
|
||||
}
|
||||
logger.error("Error marking not-participating:", error);
|
||||
return { error: "Failed to mark participant as not participating" };
|
||||
}
|
||||
|
|
@ -467,6 +471,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { error: "Result ID is required" };
|
||||
}
|
||||
|
||||
const result = await getEventResultById(resultId);
|
||||
if (!result) return { error: "Result not found" };
|
||||
if (result.scoringEvent.id !== params.eventId) return { error: "Result does not belong to this event" };
|
||||
if (!result.notParticipating) return { error: "Cannot remove a result that is not a not-participating marker" };
|
||||
|
||||
try {
|
||||
await deleteEventResult(resultId);
|
||||
return { success: "Participant re-added to event" };
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export default function EventResults({
|
|||
|
||||
// Get participants without results (and not marked as DNP)
|
||||
const participantsWithoutResults = participants.filter(
|
||||
(p: { id: string }) => !participantResultsMap.has(p.id) && !notParticipatingIds?.has(p.id)
|
||||
(p: { id: string }) => !participantResultsMap.has(p.id) && !notParticipatingIds.has(p.id)
|
||||
);
|
||||
|
||||
// Sort results by placement, excluding DNP rows (those are shown in the DNP card)
|
||||
|
|
@ -434,7 +434,7 @@ export default function EventResults({
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Currently marked DNP */}
|
||||
{notParticipatingIds && notParticipatingIds.size > 0 && (
|
||||
{notParticipatingIds.size > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Marked as not participating:</p>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -482,8 +482,8 @@ export default function EventResults({
|
|||
</Form>
|
||||
)}
|
||||
|
||||
{notParticipatingIds?.size === 0 && participantsWithoutResults.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">All participants have results or are marked as not participating.</p>
|
||||
{notParticipatingIds.size === 0 && participantsWithoutResults.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">All participants already have results entered.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -479,3 +479,65 @@ describe("simulateOneMajor", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Not-participating exclusion ──────────────────────────────────────────────
|
||||
//
|
||||
// simulateOneMajor uses sampleField to draw a 32-team field from the pool.
|
||||
// sampleField returns all teams when pool.length <= 32, so pools must be > 32
|
||||
// for exclusion tests — otherwise the field shrinks below 32 and the Swiss
|
||||
// bracket gets an odd count. The actual simulator filters the pool before
|
||||
// calling simulateOneMajor, so the real-world scenario (pool of 20–30 teams,
|
||||
// 1 withdrawn) is safe: sampleField just uses all remaining teams up to 32.
|
||||
|
||||
describe("not-participating exclusion (CS2)", () => {
|
||||
const qpConfig = makeQPConfig();
|
||||
|
||||
it("a team not in the pool gets 0 QP from that major", () => {
|
||||
// Use 40 teams so sampleField still draws 32 after the exclusion.
|
||||
const allTeams = makeTeams(40);
|
||||
const excluded = allTeams[0];
|
||||
const reducedPool = allTeams.slice(1); // 39 teams — sampleField draws 32 from these
|
||||
|
||||
const result = simulateOneMajor(reducedPool, undefined, qpConfig);
|
||||
expect(result.has(excluded.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("excluding a dominant team from the pool means they never win", () => {
|
||||
const TRIALS = 200;
|
||||
// 40 teams: one dominant + 39 average. sampleField always draws 32.
|
||||
const dominantTeam = { id: "dominant", elo: 5000, rank: 1 };
|
||||
const otherTeams = makeTeams(39, 1500, 2);
|
||||
const allPool = [dominantTeam, ...otherTeams];
|
||||
const reducedPool = otherTeams; // dominant excluded
|
||||
|
||||
let dominantWinsWithFull = 0;
|
||||
let dominantWinsWithReduced = 0;
|
||||
|
||||
for (let i = 0; i < TRIALS; i++) {
|
||||
const fullResult = simulateOneMajor(allPool, undefined, qpConfig);
|
||||
if ((fullResult.get(dominantTeam.id) ?? 0) === qpConfig.get(1)!) dominantWinsWithFull++;
|
||||
|
||||
const reducedResult = simulateOneMajor(reducedPool, undefined, qpConfig);
|
||||
if ((reducedResult.get(dominantTeam.id) ?? 0) === qpConfig.get(1)!) dominantWinsWithReduced++;
|
||||
}
|
||||
|
||||
// Dominant team (Elo 5000 vs 1500) wins nearly every sampled major
|
||||
expect(dominantWinsWithFull).toBeGreaterThan(TRIALS * 0.8);
|
||||
// Excluded team never appears → 0 wins
|
||||
expect(dominantWinsWithReduced).toBe(0);
|
||||
});
|
||||
|
||||
it("per-event pool filtering is independent — exclusion in one event does not affect another", () => {
|
||||
// Verify that two separate filtered pools produce independent results.
|
||||
const allTeams = makeTeams(40);
|
||||
const excluded = allTeams[0];
|
||||
const fullPool = allTeams;
|
||||
const reducedPool = allTeams.slice(1); // exclude team[0] from event 2
|
||||
|
||||
const resultEvent1 = simulateOneMajor(fullPool, undefined, qpConfig);
|
||||
const resultEvent2 = simulateOneMajor(reducedPool, undefined, qpConfig);
|
||||
|
||||
expect(resultEvent1.has(excluded.id)).toBe(true); // present in event 1
|
||||
expect(resultEvent2.has(excluded.id)).toBe(false); // excluded from event 2
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -261,20 +261,19 @@ describe("simulateMajor", () => {
|
|||
// ─── Not-participating exclusion ──────────────────────────────────────────────
|
||||
|
||||
describe("not-participating exclusion (tennis)", () => {
|
||||
it("a player excluded from the active draw gets 0 QP from that major", () => {
|
||||
it("falls back to full participant list when too few remain after exclusions (< 128)", () => {
|
||||
// With exactly 128 season participants and 1 excluded, activeIds.length = 127 < 128.
|
||||
// The simulator uses the full list rather than building a broken bracket.
|
||||
const IDS = makeIds(128);
|
||||
const elos = IDS.map(() => 1500);
|
||||
const eloMap = makeEloMap(IDS, elos);
|
||||
|
||||
// Simulate the exclusion: build the draw from all 128 but the excluded player
|
||||
// wouldn't be in the draw (checked by running multiple trials)
|
||||
const excluded = "p001";
|
||||
const activeIds = IDS.filter((id) => id !== excluded); // 127 players
|
||||
|
||||
// With 127 < 128, the simulator falls back to full participantIds — verify no crash
|
||||
const fallbackDraw = buildDraw(IDS, eloMap);
|
||||
const results = simulateMajor(fallbackDraw, eloMap, "grass");
|
||||
expect(results).toHaveLength(128); // draw unchanged in fallback
|
||||
const activeIds = IDS.filter((id) => id !== "p001"); // 127 — triggers fallback
|
||||
expect(activeIds).toHaveLength(127);
|
||||
// Fallback: build with full IDS, no crash, draw is valid 128-slot bracket
|
||||
const drawIds = activeIds.length >= 128 ? activeIds : IDS;
|
||||
const draw = buildDraw(drawIds, eloMap);
|
||||
expect(draw).toHaveLength(128);
|
||||
expect(draw).toContain("p001"); // included via fallback
|
||||
});
|
||||
|
||||
it("when activeIds.length >= 128, excluded player's draw slot is freed", () => {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ 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 { getExcludedByEventMap } from "~/models/event-result";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
|
@ -536,28 +537,7 @@ export class CSMajorSimulator implements Simulator {
|
|||
const incompleteEvents = events.filter((e) => !e.isComplete);
|
||||
|
||||
// Load not-participating exclusions for each incomplete event.
|
||||
const excludedByEvent = new Map<string, Set<string>>();
|
||||
if (incompleteEvents.length > 0) {
|
||||
const incompleteEventIds = incompleteEvents.map((e) => e.id);
|
||||
const exclusions = await db
|
||||
.select({
|
||||
scoringEventId: schema.eventResults.scoringEventId,
|
||||
seasonParticipantId: schema.eventResults.seasonParticipantId,
|
||||
})
|
||||
.from(schema.eventResults)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.eventResults.scoringEventId, incompleteEventIds),
|
||||
eq(schema.eventResults.notParticipating, true)
|
||||
)
|
||||
);
|
||||
for (const r of exclusions) {
|
||||
if (!excludedByEvent.has(r.scoringEventId)) {
|
||||
excludedByEvent.set(r.scoringEventId, new Set());
|
||||
}
|
||||
excludedByEvent.get(r.scoringEventId)!.add(r.seasonParticipantId);
|
||||
}
|
||||
}
|
||||
const excludedByEvent = await getExcludedByEventMap(incompleteEvents.map((e) => e.id));
|
||||
|
||||
// 7. Short-circuit: if all events are complete, return deterministic probabilities
|
||||
// based on actual QP totals — no simulation needed.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { eq, and, inArray } from "drizzle-orm";
|
|||
import * as schema from "~/database/schema";
|
||||
import { getGolfSkillsMap, type GolfSkillsRecord } from "~/models/golf-skills";
|
||||
import { getQPConfig } from "~/models/qualifying-points";
|
||||
import { getExcludedByEventMap } from "~/models/event-result";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
|
@ -243,28 +244,7 @@ export class GolfSimulator implements Simulator {
|
|||
const incompleteMajors = events.filter((e) => !e.isComplete);
|
||||
|
||||
// Load not-participating exclusions for each incomplete major.
|
||||
const excludedByEvent = new Map<string, Set<string>>();
|
||||
if (incompleteMajors.length > 0) {
|
||||
const incompleteEventIds = incompleteMajors.map((e) => e.id);
|
||||
const exclusions = await db
|
||||
.select({
|
||||
scoringEventId: schema.eventResults.scoringEventId,
|
||||
seasonParticipantId: schema.eventResults.seasonParticipantId,
|
||||
})
|
||||
.from(schema.eventResults)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.eventResults.scoringEventId, incompleteEventIds),
|
||||
eq(schema.eventResults.notParticipating, true)
|
||||
)
|
||||
);
|
||||
for (const r of exclusions) {
|
||||
if (!excludedByEvent.has(r.scoringEventId)) {
|
||||
excludedByEvent.set(r.scoringEventId, new Set());
|
||||
}
|
||||
excludedByEvent.get(r.scoringEventId)!.add(r.seasonParticipantId);
|
||||
}
|
||||
}
|
||||
const excludedByEvent = await getExcludedByEventMap(incompleteMajors.map((e) => e.id));
|
||||
|
||||
// Pre-compute per-player strengths per incomplete major (outside the Monte Carlo loop).
|
||||
// strength = exp(PL_BETA × effectiveSkill); minimum clamped to 0.01 to avoid division issues.
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { database } from "~/database/context";
|
|||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import { getSurfaceEloMap } from "~/models/surface-elo";
|
||||
import { getExcludedByEventMap } from "~/models/event-result";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
|
@ -324,28 +325,7 @@ export class TennisSimulator implements Simulator {
|
|||
const incompleteMajors = events.filter((e) => !e.isComplete);
|
||||
|
||||
// Load not-participating exclusions for each incomplete major.
|
||||
const excludedByEvent = new Map<string, Set<string>>();
|
||||
if (incompleteMajors.length > 0) {
|
||||
const incompleteEventIds = incompleteMajors.map((e) => e.id);
|
||||
const exclusions = await db
|
||||
.select({
|
||||
scoringEventId: schema.eventResults.scoringEventId,
|
||||
seasonParticipantId: schema.eventResults.seasonParticipantId,
|
||||
})
|
||||
.from(schema.eventResults)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.eventResults.scoringEventId, incompleteEventIds),
|
||||
eq(schema.eventResults.notParticipating, true)
|
||||
)
|
||||
);
|
||||
for (const r of exclusions) {
|
||||
if (!excludedByEvent.has(r.scoringEventId)) {
|
||||
excludedByEvent.set(r.scoringEventId, new Set());
|
||||
}
|
||||
excludedByEvent.get(r.scoringEventId)!.add(r.seasonParticipantId);
|
||||
}
|
||||
}
|
||||
const excludedByEvent = await getExcludedByEventMap(incompleteMajors.map((e) => e.id));
|
||||
|
||||
// 5. Monte Carlo loop.
|
||||
// For each player, count how many times they finish 1st–8th by QP rank.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue