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
This commit is contained in:
parent
3c757c1f73
commit
6fbeef2917
12 changed files with 6512 additions and 11 deletions
|
|
@ -9,6 +9,7 @@ export interface CreateEventResultData {
|
|||
qualifyingPointsAwarded?: number;
|
||||
eliminated?: boolean;
|
||||
rawScore?: number;
|
||||
notParticipating?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateEventResultData {
|
||||
|
|
@ -16,6 +17,7 @@ export interface UpdateEventResultData {
|
|||
qualifyingPointsAwarded?: number;
|
||||
eliminated?: boolean;
|
||||
rawScore?: number;
|
||||
notParticipating?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -36,6 +38,7 @@ export async function createEventResult(
|
|||
qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(),
|
||||
eliminated: data.eliminated,
|
||||
rawScore: data.rawScore?.toString(),
|
||||
notParticipating: data.notParticipating,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -62,6 +65,7 @@ export async function createEventResultsBulk(
|
|||
qualifyingPointsAwarded: r.qualifyingPointsAwarded?.toString(),
|
||||
eliminated: r.eliminated,
|
||||
rawScore: r.rawScore?.toString(),
|
||||
notParticipating: r.notParticipating,
|
||||
}))
|
||||
)
|
||||
.returning();
|
||||
|
|
@ -156,6 +160,7 @@ export async function updateEventResult(
|
|||
qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(),
|
||||
eliminated: data.eliminated,
|
||||
rawScore: data.rawScore?.toString(),
|
||||
notParticipating: data.notParticipating,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, resultId))
|
||||
|
|
@ -210,6 +215,27 @@ export async function hasParticipantResult(
|
|||
return !!result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Useful for checking drafted participants' performance
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
createEventResultsBulk,
|
||||
updateEventResult,
|
||||
deleteEventResult,
|
||||
getNotParticipatingResults,
|
||||
type CreateEventResultData,
|
||||
type UpdateEventResultData,
|
||||
} from "~/models/event-result";
|
||||
|
|
@ -65,6 +66,16 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
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 {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
||||
|
|
@ -76,6 +87,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
seasonResults,
|
||||
qpConfig,
|
||||
qpStandings,
|
||||
notParticipatingIds,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -424,6 +436,46 @@ 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) {
|
||||
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" };
|
||||
}
|
||||
|
||||
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") {
|
||||
const name = formData.get("name");
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export default function EventResults({
|
|||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { sportsSeason, event, participants, results, participantResults, seasonResults } = loaderData;
|
||||
const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds } = loaderData;
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// 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])
|
||||
);
|
||||
|
||||
// Get participants without results
|
||||
// Get participants without results (and not marked as DNP)
|
||||
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
|
||||
const sortedResults = [...results].toSorted((a, b) => (a.placement || 999) - (b.placement || 999));
|
||||
// Sort results by placement, excluding DNP rows (those are shown in the DNP card)
|
||||
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
|
||||
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 && 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 have results or are marked as not participating.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Batch Paste Results — primary entry for qualifying events */}
|
||||
{event.isQualifyingEvent && event.eventType !== "final_standings" && event.eventType !== "playoff_game" && !event.isComplete && (
|
||||
<BatchResultEntry
|
||||
|
|
|
|||
|
|
@ -259,3 +259,48 @@ describe("simulateMajor Monte Carlo properties", () => {
|
|||
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,64 @@ describe("simulateMajor", () => {
|
|||
// - Dividing by NUM_SIMULATIONS gives column sums of exactly 1.0.
|
||||
// This is verified indirectly by the simulateMajor + buildDraw tests above.
|
||||
|
||||
// ─── Not-participating exclusion ──────────────────────────────────────────────
|
||||
|
||||
describe("not-participating exclusion (tennis)", () => {
|
||||
it("a player excluded from the active draw gets 0 QP from that major", () => {
|
||||
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
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
it("seed 1 wins more often than a random player across many trials", () => {
|
||||
const IDS = makeIds(128);
|
||||
|
|
|
|||
|
|
@ -535,6 +535,30 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Short-circuit: if all events are complete, return deterministic probabilities
|
||||
// based on actual QP totals — no simulation needed.
|
||||
if (incompleteEvents.length === 0) {
|
||||
|
|
@ -566,8 +590,10 @@ export class CSMajorSimulator implements Simulator {
|
|||
const simQP = new Map<string, number>(actualQPMap);
|
||||
|
||||
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 eventQP = simulateOneMajor(pool, stageResultsForEvent, qpConfig);
|
||||
const eventQP = simulateOneMajor(eventPool, stageResultsForEvent, qpConfig);
|
||||
for (const [pid, qp] of eventQP) {
|
||||
simQP.set(pid, (simQP.get(pid) ?? 0) + qp);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,14 +242,42 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Participants marked notParticipating for a specific major are excluded from that event's field.
|
||||
const majorConfigs = incompleteMajors.map((event) => {
|
||||
const excluded = excludedByEvent.get(event.id) ?? new Set<string>();
|
||||
const oddsKey = getMajorOddsKey(event.name);
|
||||
const players = participantIds.map((id) => ({
|
||||
id,
|
||||
strength: Math.max(Math.exp(PL_BETA * resolveSkill(skillsMap.get(id), oddsKey)), 0.01),
|
||||
}));
|
||||
const players = participantIds
|
||||
.filter((id) => !excluded.has(id))
|
||||
.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 restStrength = 1.0; // exp(PL_BETA * 0) = 1, representing SG = 0
|
||||
return { players, restCount, restStrength };
|
||||
|
|
|
|||
|
|
@ -323,6 +323,30 @@ export class TennisSimulator implements Simulator {
|
|||
// Incomplete majors that need to be simulated.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Monte Carlo loop.
|
||||
// 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));
|
||||
|
|
@ -335,7 +359,12 @@ export class TennisSimulator implements Simulator {
|
|||
// Simulate each incomplete major and accumulate QP.
|
||||
for (const event of incompleteMajors) {
|
||||
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);
|
||||
for (const { participantId, qp } of results) {
|
||||
simQP.set(participantId, (simQP.get(participantId) ?? 0) + qp);
|
||||
|
|
|
|||
|
|
@ -647,6 +647,9 @@ export const eventResults = pgTable("event_results", {
|
|||
eliminated: boolean("eliminated"),
|
||||
// Raw sport-specific data (optional)
|
||||
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(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
}, (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,
|
||||
"tag": "0108_faulty_purple_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 109,
|
||||
"version": "7",
|
||||
"when": 1779211722613,
|
||||
"tag": "0109_fair_peter_quill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue