diff --git a/app/models/event-result.ts b/app/models/event-result.ts index c013660..9ffb8fc 100644 --- a/app/models/event-result.ts +++ b/app/models/event-result.ts @@ -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 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 +): Promise>> { + 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>(); + 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. diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts index 95587fe..34cb0d0 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts @@ -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" }; diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx index 275cdb9..5fbb0c0 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx @@ -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({ {/* Currently marked DNP */} - {notParticipatingIds && notParticipatingIds.size > 0 && ( + {notParticipatingIds.size > 0 && (

Marked as not participating:

@@ -482,8 +482,8 @@ export default function EventResults({ )} - {notParticipatingIds?.size === 0 && participantsWithoutResults.length === 0 && ( -

All participants have results or are marked as not participating.

+ {notParticipatingIds.size === 0 && participantsWithoutResults.length === 0 && ( +

All participants already have results entered.

)} diff --git a/app/services/simulations/__tests__/cs-major-simulator.test.ts b/app/services/simulations/__tests__/cs-major-simulator.test.ts index 46d3eca..1e6e6d5 100644 --- a/app/services/simulations/__tests__/cs-major-simulator.test.ts +++ b/app/services/simulations/__tests__/cs-major-simulator.test.ts @@ -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 + }); +}); diff --git a/app/services/simulations/__tests__/tennis-simulator.test.ts b/app/services/simulations/__tests__/tennis-simulator.test.ts index 53118ba..3e16ba2 100644 --- a/app/services/simulations/__tests__/tennis-simulator.test.ts +++ b/app/services/simulations/__tests__/tennis-simulator.test.ts @@ -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", () => { diff --git a/app/services/simulations/cs-major-simulator.ts b/app/services/simulations/cs-major-simulator.ts index fe30d07..7d2d79a 100644 --- a/app/services/simulations/cs-major-simulator.ts +++ b/app/services/simulations/cs-major-simulator.ts @@ -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>(); - 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. diff --git a/app/services/simulations/golf-simulator.ts b/app/services/simulations/golf-simulator.ts index f1319df..673fa58 100644 --- a/app/services/simulations/golf-simulator.ts +++ b/app/services/simulations/golf-simulator.ts @@ -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>(); - 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. diff --git a/app/services/simulations/tennis-simulator.ts b/app/services/simulations/tennis-simulator.ts index c595bd4..23e7f5f 100644 --- a/app/services/simulations/tennis-simulator.ts +++ b/app/services/simulations/tennis-simulator.ts @@ -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>(); - 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.