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:
Claude 2026-05-19 17:53:08 +00:00
parent 6fbeef2917
commit ff116961f7
No known key found for this signature in database
8 changed files with 133 additions and 83 deletions

View file

@ -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.

View file

@ -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" };

View file

@ -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>

View file

@ -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 2030 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
});
});

View file

@ -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", () => {

View file

@ -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.

View file

@ -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.

View file

@ -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 1st8th by QP rank.