Add not-participating exclusion support for event results (#446)
* Add notParticipating flag to allow excluding withdrawn participants from qualifying-points simulators Adds a `not_participating` boolean column to `event_results` so admins can mark a participant as not competing in a specific upcoming major (e.g. Alcaraz withdrawing from Wimbledon due to injury). The golf, tennis, and CS2 major simulators now query this flag for incomplete events and exclude those participants from the event's draw/field, redistributing probability weight to the remaining field. Admin UI for qualifying major_tournament events gains a "Not Participating" card to mark/unmark withdrawals before the event runs. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 * 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 * Fix lint errors: replace non-null assertions in CS2 DNP test https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3c757c1f73
commit
43dbdf0040
13 changed files with 6565 additions and 13 deletions
|
|
@ -9,6 +9,7 @@ export interface CreateEventResultData {
|
||||||
qualifyingPointsAwarded?: number;
|
qualifyingPointsAwarded?: number;
|
||||||
eliminated?: boolean;
|
eliminated?: boolean;
|
||||||
rawScore?: number;
|
rawScore?: number;
|
||||||
|
notParticipating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateEventResultData {
|
export interface UpdateEventResultData {
|
||||||
|
|
@ -16,6 +17,7 @@ export interface UpdateEventResultData {
|
||||||
qualifyingPointsAwarded?: number;
|
qualifyingPointsAwarded?: number;
|
||||||
eliminated?: boolean;
|
eliminated?: boolean;
|
||||||
rawScore?: number;
|
rawScore?: number;
|
||||||
|
notParticipating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -36,6 +38,7 @@ export async function createEventResult(
|
||||||
qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(),
|
qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(),
|
||||||
eliminated: data.eliminated,
|
eliminated: data.eliminated,
|
||||||
rawScore: data.rawScore?.toString(),
|
rawScore: data.rawScore?.toString(),
|
||||||
|
notParticipating: data.notParticipating,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
@ -62,6 +65,7 @@ export async function createEventResultsBulk(
|
||||||
qualifyingPointsAwarded: r.qualifyingPointsAwarded?.toString(),
|
qualifyingPointsAwarded: r.qualifyingPointsAwarded?.toString(),
|
||||||
eliminated: r.eliminated,
|
eliminated: r.eliminated,
|
||||||
rawScore: r.rawScore?.toString(),
|
rawScore: r.rawScore?.toString(),
|
||||||
|
notParticipating: r.notParticipating,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
@ -156,6 +160,7 @@ export async function updateEventResult(
|
||||||
qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(),
|
qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(),
|
||||||
eliminated: data.eliminated,
|
eliminated: data.eliminated,
|
||||||
rawScore: data.rawScore?.toString(),
|
rawScore: data.rawScore?.toString(),
|
||||||
|
notParticipating: data.notParticipating,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.eventResults.id, resultId))
|
.where(eq(schema.eventResults.id, resultId))
|
||||||
|
|
@ -191,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(
|
export async function hasParticipantResult(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
|
|
@ -203,13 +209,73 @@ export async function hasParticipantResult(
|
||||||
const result = await db.query.eventResults.findFirst({
|
const result = await db.query.eventResults.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.eventResults.scoringEventId, eventId),
|
eq(schema.eventResults.scoringEventId, eventId),
|
||||||
eq(schema.eventResults.seasonParticipantId, participantId)
|
eq(schema.eventResults.seasonParticipantId, participantId),
|
||||||
|
eq(schema.eventResults.notParticipating, false)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return !!result;
|
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.
|
||||||
|
*/
|
||||||
|
export async function getNotParticipatingResults(
|
||||||
|
eventId: string,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
): Promise<{ seasonParticipantId: string }[]> {
|
||||||
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
return await db
|
||||||
|
.select({ seasonParticipantId: schema.eventResults.seasonParticipantId })
|
||||||
|
.from(schema.eventResults)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.eventResults.scoringEventId, eventId),
|
||||||
|
eq(schema.eventResults.notParticipating, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get results for multiple participants in a specific event
|
* Get results for multiple participants in a specific event
|
||||||
* Useful for checking drafted participants' performance
|
* Useful for checking drafted participants' performance
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import {
|
||||||
createEventResultsBulk,
|
createEventResultsBulk,
|
||||||
updateEventResult,
|
updateEventResult,
|
||||||
deleteEventResult,
|
deleteEventResult,
|
||||||
|
getEventResultById,
|
||||||
|
getNotParticipatingResults,
|
||||||
type CreateEventResultData,
|
type CreateEventResultData,
|
||||||
type UpdateEventResultData,
|
type UpdateEventResultData,
|
||||||
} from "~/models/event-result";
|
} from "~/models/event-result";
|
||||||
|
|
@ -65,6 +67,16 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
qpStandings = await getQPStandings(params.id);
|
qpStandings = await getQPStandings(params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For qualifying major_tournament events, get not-participating participant IDs
|
||||||
|
const notParticipatingIds: Set<string> =
|
||||||
|
event.isQualifyingEvent && event.eventType === "major_tournament"
|
||||||
|
? new Set(
|
||||||
|
(await getNotParticipatingResults(params.eventId)).map(
|
||||||
|
(r) => r.seasonParticipantId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: new Set();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||||
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
||||||
|
|
@ -76,6 +88,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
seasonResults,
|
seasonResults,
|
||||||
qpConfig,
|
qpConfig,
|
||||||
qpStandings,
|
qpStandings,
|
||||||
|
notParticipatingIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,6 +437,54 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "mark-not-participating") {
|
||||||
|
const participantId = formData.get("participantId");
|
||||||
|
|
||||||
|
if (typeof participantId !== "string" || !participantId) {
|
||||||
|
return { error: "Participant ID is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
if (!event) return { error: "Event not found" };
|
||||||
|
if (event.isComplete) return { error: "Cannot mark not-participating on a completed event" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createEventResult({
|
||||||
|
scoringEventId: params.eventId,
|
||||||
|
participantId,
|
||||||
|
notParticipating: true,
|
||||||
|
});
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "unmark-not-participating") {
|
||||||
|
const resultId = formData.get("resultId");
|
||||||
|
|
||||||
|
if (typeof resultId !== "string" || !resultId) {
|
||||||
|
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" };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error unmarking not-participating:", error);
|
||||||
|
return { error: "Failed to re-add participant to event" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === "create-participant") {
|
if (intent === "create-participant") {
|
||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export default function EventResults({
|
||||||
loaderData,
|
loaderData,
|
||||||
actionData,
|
actionData,
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
const { sportsSeason, event, participants, results, participantResults, seasonResults } = loaderData;
|
const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds } = loaderData;
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
// Create a map of participants with results for easy lookup
|
// Create a map of participants with results for easy lookup
|
||||||
|
|
@ -102,13 +102,22 @@ export default function EventResults({
|
||||||
results.map((r: { seasonParticipant: { id: string } }) => [r.seasonParticipant.id, r])
|
results.map((r: { seasonParticipant: { id: string } }) => [r.seasonParticipant.id, r])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get participants without results
|
// Get participants without results (and not marked as DNP)
|
||||||
const participantsWithoutResults = participants.filter(
|
const participantsWithoutResults = participants.filter(
|
||||||
(p: { id: string }) => !participantResultsMap.has(p.id)
|
(p: { id: string }) => !participantResultsMap.has(p.id) && !notParticipatingIds.has(p.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort results by placement
|
// Sort results by placement, excluding DNP rows (those are shown in the DNP card)
|
||||||
const sortedResults = [...results].toSorted((a, b) => (a.placement || 999) - (b.placement || 999));
|
const sortedResults = [...results]
|
||||||
|
.filter((r: { notParticipating?: boolean | null }) => !r.notParticipating)
|
||||||
|
.toSorted((a, b) => (a.placement || 999) - (b.placement || 999));
|
||||||
|
|
||||||
|
// Map of participantId → result ID for DNP rows (needed for unmark action)
|
||||||
|
const dnpResultIdMap = new Map<string, string>(
|
||||||
|
results
|
||||||
|
.filter((r: { notParticipating?: boolean | null; seasonParticipant: { id: string } }) => r.notParticipating)
|
||||||
|
.map((r: { id: string; seasonParticipant: { id: string } }) => [r.seasonParticipant.id, r.id])
|
||||||
|
);
|
||||||
|
|
||||||
// Create a map of season results for easy lookup
|
// Create a map of season results for easy lookup
|
||||||
const seasonResultsMap = seasonResults
|
const seasonResultsMap = seasonResults
|
||||||
|
|
@ -413,6 +422,73 @@ export default function EventResults({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Not Participating — mark known withdrawals before the event runs */}
|
||||||
|
{event.isQualifyingEvent && event.eventType === "major_tournament" && !event.isComplete && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Not Participating</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Mark participants who will not compete in this event (e.g. injury withdrawal).
|
||||||
|
The simulator will exclude them from this event's draw when calculating EV.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Currently marked DNP */}
|
||||||
|
{notParticipatingIds.size > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Marked as not participating:</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{participants
|
||||||
|
.filter((p: { id: string }) => notParticipatingIds.has(p.id))
|
||||||
|
.map((p: { id: string; name: string }) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between py-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs border-red-400 text-red-500">DNP</Badge>
|
||||||
|
<span className="text-sm">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<Form method="post" className="inline">
|
||||||
|
<input type="hidden" name="intent" value="unmark-not-participating" />
|
||||||
|
<input type="hidden" name="resultId" value={dnpResultIdMap.get(p.id) ?? ""} />
|
||||||
|
<Button type="submit" size="sm" variant="outline">
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mark a participant as DNP */}
|
||||||
|
{participantsWithoutResults.length > 0 && (
|
||||||
|
<Form method="post" className="flex items-end gap-2">
|
||||||
|
<input type="hidden" name="intent" value="mark-not-participating" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Label htmlFor="dnp-participant">Participant</Label>
|
||||||
|
<Select name="participantId" required>
|
||||||
|
<SelectTrigger id="dnp-participant">
|
||||||
|
<SelectValue placeholder="Select participant" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{participantsWithoutResults.map((p: { id: string; name: string }) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Mark Not Participating
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notParticipatingIds.size === 0 && participantsWithoutResults.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">All participants already have results entered.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Batch Paste Results — primary entry for qualifying events */}
|
{/* Batch Paste Results — primary entry for qualifying events */}
|
||||||
{event.isQualifyingEvent && event.eventType !== "final_standings" && event.eventType !== "playoff_game" && !event.isComplete && (
|
{event.isQualifyingEvent && event.eventType !== "final_standings" && event.eventType !== "playoff_game" && !event.isComplete && (
|
||||||
<BatchResultEntry
|
<BatchResultEntry
|
||||||
|
|
|
||||||
|
|
@ -479,3 +479,66 @@ 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;
|
||||||
|
const firstPlaceQP = qpConfig.get(1) ?? 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < TRIALS; i++) {
|
||||||
|
const fullResult = simulateOneMajor(allPool, undefined, qpConfig);
|
||||||
|
if ((fullResult.get(dominantTeam.id) ?? 0) === firstPlaceQP) dominantWinsWithFull++;
|
||||||
|
|
||||||
|
const reducedResult = simulateOneMajor(reducedPool, undefined, qpConfig);
|
||||||
|
if ((reducedResult.get(dominantTeam.id) ?? 0) === firstPlaceQP) 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -259,3 +259,48 @@ describe("simulateMajor Monte Carlo properties", () => {
|
||||||
expect(winRate).toBeLessThan(0.09);
|
expect(winRate).toBeLessThan(0.09);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Not-participating exclusion ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("not-participating exclusion (golf)", () => {
|
||||||
|
const qpConfig = makeQPConfig();
|
||||||
|
|
||||||
|
it("a player omitted from the players list gets 0 QP from that major", () => {
|
||||||
|
// Simulates the effect of notParticipating=true: the excluded player is simply
|
||||||
|
// not passed to simulateMajor, so they are absent from the result map.
|
||||||
|
const included = [
|
||||||
|
{ id: "p1", strength: 5.0 },
|
||||||
|
{ id: "p2", strength: 1.0 },
|
||||||
|
];
|
||||||
|
const result = simulateMajor(included, 154, 1.0, qpConfig);
|
||||||
|
expect(result.has("excluded")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excluding an elite player from one major redistributes probability to others", () => {
|
||||||
|
// With the elite player in both majors, p2 and p3 win rarely.
|
||||||
|
// After exclusion from major2, p2/p3 should win major2 QP more often.
|
||||||
|
const TRIALS = 2_000;
|
||||||
|
const singleQP = new Map([[1, 20]]);
|
||||||
|
|
||||||
|
let p2WinsWithElite = 0;
|
||||||
|
let p2WinsWithoutElite = 0;
|
||||||
|
const allPlayers = [
|
||||||
|
{ id: "elite", strength: 50.0 },
|
||||||
|
{ id: "p2", strength: 1.0 },
|
||||||
|
{ id: "p3", strength: 1.0 },
|
||||||
|
];
|
||||||
|
const reducedPlayers = allPlayers.filter((p) => p.id !== "elite");
|
||||||
|
|
||||||
|
for (let i = 0; i < TRIALS; i++) {
|
||||||
|
const withElite = simulateMajor(allPlayers, 0, 1.0, singleQP);
|
||||||
|
if (withElite.get("p2") === 20) p2WinsWithElite++;
|
||||||
|
|
||||||
|
const withoutElite = simulateMajor(reducedPlayers, 0, 1.0, singleQP);
|
||||||
|
if (withoutElite.get("p2") === 20) p2WinsWithoutElite++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the elite player, p2 should win ~50% (2 equal players); with elite ~2%
|
||||||
|
expect(p2WinsWithoutElite / TRIALS).toBeGreaterThan(0.35);
|
||||||
|
expect(p2WinsWithElite / TRIALS).toBeLessThan(0.15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,63 @@ describe("simulateMajor", () => {
|
||||||
// - Dividing by NUM_SIMULATIONS gives column sums of exactly 1.0.
|
// - Dividing by NUM_SIMULATIONS gives column sums of exactly 1.0.
|
||||||
// This is verified indirectly by the simulateMajor + buildDraw tests above.
|
// This is verified indirectly by the simulateMajor + buildDraw tests above.
|
||||||
|
|
||||||
|
// ─── Not-participating exclusion ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("not-participating exclusion (tennis)", () => {
|
||||||
|
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);
|
||||||
|
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", () => {
|
||||||
|
// 130 participants; exclude 2 → 128 remain → draw contains only active players
|
||||||
|
const LARGE_IDS = makeIds(130);
|
||||||
|
const elos = LARGE_IDS.map(() => 1500);
|
||||||
|
const eloMap = makeEloMap(LARGE_IDS, elos);
|
||||||
|
|
||||||
|
const excluded = new Set(["p001", "p002"]);
|
||||||
|
const activeIds = LARGE_IDS.filter((id) => !excluded.has(id)); // 128 players
|
||||||
|
|
||||||
|
expect(activeIds).toHaveLength(128);
|
||||||
|
const draw = buildDraw(activeIds, eloMap);
|
||||||
|
expect(draw).toHaveLength(128);
|
||||||
|
expect(draw).not.toContain("p001");
|
||||||
|
expect(draw).not.toContain("p002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excluding the dominant player redistributes wins to the remaining field", () => {
|
||||||
|
const LARGE_IDS = makeIds(130);
|
||||||
|
const dominantEloMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
|
||||||
|
dominantEloMap.set("p001", { worldRanking: 1, eloHard: 5000, eloClay: 5000, eloGrass: 5000 });
|
||||||
|
for (let i = 1; i < LARGE_IDS.length; i++) {
|
||||||
|
dominantEloMap.set(LARGE_IDS[i], { worldRanking: i + 1, eloHard: 1500, eloClay: 1500, eloGrass: 1500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIds = LARGE_IDS.filter((id) => id !== "p001"); // exclude dominant player
|
||||||
|
expect(activeIds.length).toBeGreaterThanOrEqual(128);
|
||||||
|
|
||||||
|
const TRIALS = 100;
|
||||||
|
let p001Wins = 0;
|
||||||
|
for (let i = 0; i < TRIALS; i++) {
|
||||||
|
const draw = buildDraw(activeIds, dominantEloMap);
|
||||||
|
const results = simulateMajor(draw, dominantEloMap, "grass");
|
||||||
|
if (results.find((r) => r.participantId === "p001")?.qp === 20) p001Wins++;
|
||||||
|
}
|
||||||
|
// Excluded player should not appear in any draw → 0 wins
|
||||||
|
expect(p001Wins).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("QP accumulation ranking (unit)", () => {
|
describe("QP accumulation ranking (unit)", () => {
|
||||||
it("seed 1 wins more often than a random player across many trials", () => {
|
it("seed 1 wins more often than a random player across many trials", () => {
|
||||||
const IDS = makeIds(128);
|
const IDS = makeIds(128);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import { eq, and, inArray } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { getQPConfig } from "~/models/qualifying-points";
|
import { getQPConfig } from "~/models/qualifying-points";
|
||||||
import { getCs2StageResultsMapForEvent } from "~/models/cs2-major-stage";
|
import { getCs2StageResultsMapForEvent } from "~/models/cs2-major-stage";
|
||||||
|
import { getExcludedByEventMap } from "~/models/event-result";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
@ -535,6 +536,9 @@ export class CSMajorSimulator implements Simulator {
|
||||||
|
|
||||||
const incompleteEvents = events.filter((e) => !e.isComplete);
|
const incompleteEvents = events.filter((e) => !e.isComplete);
|
||||||
|
|
||||||
|
// Load not-participating exclusions for each incomplete event.
|
||||||
|
const excludedByEvent = await getExcludedByEventMap(incompleteEvents.map((e) => e.id));
|
||||||
|
|
||||||
// 7. Short-circuit: if all events are complete, return deterministic probabilities
|
// 7. Short-circuit: if all events are complete, return deterministic probabilities
|
||||||
// based on actual QP totals — no simulation needed.
|
// based on actual QP totals — no simulation needed.
|
||||||
if (incompleteEvents.length === 0) {
|
if (incompleteEvents.length === 0) {
|
||||||
|
|
@ -566,8 +570,10 @@ export class CSMajorSimulator implements Simulator {
|
||||||
const simQP = new Map<string, number>(actualQPMap);
|
const simQP = new Map<string, number>(actualQPMap);
|
||||||
|
|
||||||
for (const event of incompleteEvents) {
|
for (const event of incompleteEvents) {
|
||||||
|
const excluded = excludedByEvent.get(event.id) ?? new Set<string>();
|
||||||
|
const eventPool = excluded.size > 0 ? pool.filter((t) => !excluded.has(t.id)) : pool;
|
||||||
const stageResultsForEvent = eventStageResults.get(event.id);
|
const stageResultsForEvent = eventStageResults.get(event.id);
|
||||||
const eventQP = simulateOneMajor(pool, stageResultsForEvent, qpConfig);
|
const eventQP = simulateOneMajor(eventPool, stageResultsForEvent, qpConfig);
|
||||||
for (const [pid, qp] of eventQP) {
|
for (const [pid, qp] of eventQP) {
|
||||||
simQP.set(pid, (simQP.get(pid) ?? 0) + qp);
|
simQP.set(pid, (simQP.get(pid) ?? 0) + qp);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { eq, and, inArray } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { getGolfSkillsMap, type GolfSkillsRecord } from "~/models/golf-skills";
|
import { getGolfSkillsMap, type GolfSkillsRecord } from "~/models/golf-skills";
|
||||||
import { getQPConfig } from "~/models/qualifying-points";
|
import { getQPConfig } from "~/models/qualifying-points";
|
||||||
|
import { getExcludedByEventMap } from "~/models/event-result";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
@ -242,14 +243,21 @@ export class GolfSimulator implements Simulator {
|
||||||
|
|
||||||
const incompleteMajors = events.filter((e) => !e.isComplete);
|
const incompleteMajors = events.filter((e) => !e.isComplete);
|
||||||
|
|
||||||
|
// Load not-participating exclusions for each incomplete major.
|
||||||
|
const excludedByEvent = await getExcludedByEventMap(incompleteMajors.map((e) => e.id));
|
||||||
|
|
||||||
// Pre-compute per-player strengths per incomplete major (outside the Monte Carlo loop).
|
// 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.
|
// strength = exp(PL_BETA × effectiveSkill); minimum clamped to 0.01 to avoid division issues.
|
||||||
|
// Participants marked notParticipating for a specific major are excluded from that event's field.
|
||||||
const majorConfigs = incompleteMajors.map((event) => {
|
const majorConfigs = incompleteMajors.map((event) => {
|
||||||
|
const excluded = excludedByEvent.get(event.id) ?? new Set<string>();
|
||||||
const oddsKey = getMajorOddsKey(event.name);
|
const oddsKey = getMajorOddsKey(event.name);
|
||||||
const players = participantIds.map((id) => ({
|
const players = participantIds
|
||||||
id,
|
.filter((id) => !excluded.has(id))
|
||||||
strength: Math.max(Math.exp(PL_BETA * resolveSkill(skillsMap.get(id), oddsKey)), 0.01),
|
.map((id) => ({
|
||||||
}));
|
id,
|
||||||
|
strength: Math.max(Math.exp(PL_BETA * resolveSkill(skillsMap.get(id), oddsKey)), 0.01),
|
||||||
|
}));
|
||||||
const restCount = Math.max(0, FIELD_SIZE - players.length);
|
const restCount = Math.max(0, FIELD_SIZE - players.length);
|
||||||
const restStrength = 1.0; // exp(PL_BETA * 0) = 1, representing SG = 0
|
const restStrength = 1.0; // exp(PL_BETA * 0) = 1, representing SG = 0
|
||||||
return { players, restCount, restStrength };
|
return { players, restCount, restStrength };
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import { database } from "~/database/context";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { getSurfaceEloMap } from "~/models/surface-elo";
|
import { getSurfaceEloMap } from "~/models/surface-elo";
|
||||||
|
import { getExcludedByEventMap } from "~/models/event-result";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
@ -323,6 +324,9 @@ export class TennisSimulator implements Simulator {
|
||||||
// Incomplete majors that need to be simulated.
|
// Incomplete majors that need to be simulated.
|
||||||
const incompleteMajors = events.filter((e) => !e.isComplete);
|
const incompleteMajors = events.filter((e) => !e.isComplete);
|
||||||
|
|
||||||
|
// Load not-participating exclusions for each incomplete major.
|
||||||
|
const excludedByEvent = await getExcludedByEventMap(incompleteMajors.map((e) => e.id));
|
||||||
|
|
||||||
// 5. Monte Carlo loop.
|
// 5. Monte Carlo loop.
|
||||||
// For each player, count how many times they finish 1st–8th by QP rank.
|
// For each player, count how many times they finish 1st–8th by QP rank.
|
||||||
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0));
|
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0));
|
||||||
|
|
@ -335,7 +339,12 @@ export class TennisSimulator implements Simulator {
|
||||||
// Simulate each incomplete major and accumulate QP.
|
// Simulate each incomplete major and accumulate QP.
|
||||||
for (const event of incompleteMajors) {
|
for (const event of incompleteMajors) {
|
||||||
const surface = getSurfaceForEvent(event.name);
|
const surface = getSurfaceForEvent(event.name);
|
||||||
const draw = buildDraw(participantIds, surfaceEloMap);
|
const excluded = excludedByEvent.get(event.id) ?? new Set<string>();
|
||||||
|
const activeIds = participantIds.filter((id) => !excluded.has(id));
|
||||||
|
// Fall back to full participant list if too few remain after exclusions
|
||||||
|
// (buildDraw requires >= 128 players to fill all bracket slots).
|
||||||
|
const drawIds = activeIds.length >= 128 ? activeIds : participantIds;
|
||||||
|
const draw = buildDraw(drawIds, surfaceEloMap);
|
||||||
const results = simulateMajor(draw, surfaceEloMap, surface);
|
const results = simulateMajor(draw, surfaceEloMap, surface);
|
||||||
for (const { participantId, qp } of results) {
|
for (const { participantId, qp } of results) {
|
||||||
simQP.set(participantId, (simQP.get(participantId) ?? 0) + qp);
|
simQP.set(participantId, (simQP.get(participantId) ?? 0) + qp);
|
||||||
|
|
|
||||||
|
|
@ -647,6 +647,9 @@ export const eventResults = pgTable("event_results", {
|
||||||
eliminated: boolean("eliminated"),
|
eliminated: boolean("eliminated"),
|
||||||
// Raw sport-specific data (optional)
|
// Raw sport-specific data (optional)
|
||||||
rawScore: decimal("raw_score", { precision: 10, scale: 2 }), // strokes, points, time, etc.
|
rawScore: decimal("raw_score", { precision: 10, scale: 2 }), // strokes, points, time, etc.
|
||||||
|
// Marks a participant as known non-participant before the event runs (injury withdrawal, etc.)
|
||||||
|
// Used by qualifying-points simulators to exclude them from future event draws/pools.
|
||||||
|
notParticipating: boolean("not_participating").default(false),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
}, (t) => ({
|
}, (t) => ({
|
||||||
|
|
|
||||||
1
drizzle/0109_fair_peter_quill.sql
Normal file
1
drizzle/0109_fair_peter_quill.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "event_results" ADD COLUMN "not_participating" boolean DEFAULT false;
|
||||||
6150
drizzle/meta/0109_snapshot.json
Normal file
6150
drizzle/meta/0109_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -764,6 +764,13 @@
|
||||||
"when": 1778997704083,
|
"when": 1778997704083,
|
||||||
"tag": "0108_faulty_purple_man",
|
"tag": "0108_faulty_purple_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 109,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779211722613,
|
||||||
|
"tag": "0109_fair_peter_quill",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue