brackt/app/services/simulations/auto-racing-simulator.ts
Chris Parsons 4bffa40606
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations

Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.

no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).

consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix no-non-null-assertion lint violations and promote to error

Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.

Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers

Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.

- prefer-add-event-listener: converted onchange/onclick/onload
  assignments to addEventListener in useDraftNotifications.ts and
  admin.data-sync.tsx; stored changeHandler ref for proper cleanup
  with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
  side-effect imports (*.css, @testing-library/jest-dom,
  @testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
  cypress/support/e2e.ts (file already has an import)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix TypeScript errors from no-non-null-assertion fixes

Two fixes introduced by the non-null assertion cleanup produced type
errors:

- scoring-event.ts: `?? ""` was wrong type for a participant object map;
  restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
  truthy guarantee, causing TS18047 on the write-back block; added
  `participant &&` guard before accessing its properties

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add npm run typecheck as Stop hook in Claude settings

Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00

270 lines
11 KiB
TypeScript

/**
* Auto Racing Season Standings Simulator
*
* Generic simulator for points-based auto racing championships (F1, IndyCar, etc.).
* The race points table is injected at construction time so different series can
* use their own scoring systems.
*
* Algorithm:
* 1. Load participants + current championship points from DB
* 2. Count remaining races (incomplete non-schedule scoring events)
* 3. Convert sourceOdds → vig-removed probability weights
* 4. Two simulation paths:
* a. remainingRaces === 0 (pre-season): pure weighted draws from odds
* b. remainingRaces > 0 (in-season): simulate each remaining race,
* starting from real standings, awarding series-specific points per finish
* 5. Convert finish counts → probability distributions + normalize columns
*
* Notes:
* - Drivers without odds fall back to uniform probability (1/N)
* - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import { getSeasonResults } from "~/models/participant-season-result";
import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters (mirrors Python constants) ────────────────────────
const NUM_SIMULATIONS = 10000;
/** Per-race performance variance. 0 = no noise, 1 = fully random each race. */
const RACE_NOISE = 0.50;
/**
* Season-long multiplier range per driver.
* Each driver gets uniform(1 - V, 1 + V) applied to their base probability
* for the entire season, capturing "cars that over/underperform expectations".
*/
const PARTICIPANT_VOLATILITY = 1.5;
/**
* Optional smoothing toward the mean after vig removal.
* 0.0 = use vig-removed market odds exactly (recommended).
* Increase slightly (e.g. 0.1) to soften extreme probabilities.
*/
const UNCERTAINTY_FACTOR = 0.0;
/** Lookup points for a finishing position; returns 0 for unscored positions. */
function getRacePoints(racePoints: Record<number, number>, position: number): number {
return racePoints[position] ?? 0;
}
// ─── Odds helpers ─────────────────────────────────────────────────────────────
/** Convert American odds to implied probability (no vig removal). */
function americanToImpliedProb(americanOdds: number): number {
if (americanOdds > 0) {
return 100 / (americanOdds + 100);
}
return Math.abs(americanOdds) / (Math.abs(americanOdds) + 100);
}
// ─── Core simulation helper ───────────────────────────────────────────────────
/**
* Weighted sequential draw without replacement.
* Returns all items in a simulated finishing order.
* Each draw is proportional to remaining weights.
*/
function weightedDrawWithoutReplacement(ids: string[], weights: number[]): string[] {
const pool = ids.slice();
const w = weights.slice();
const result: string[] = [];
while (pool.length > 0) {
const total = w.reduce((s, v) => s + v, 0);
let r = Math.random() * total;
let idx = 0;
while (idx < w.length - 1 && r > w[idx]) {
r -= w[idx];
idx++;
}
result.push(pool[idx]);
pool.splice(idx, 1);
w.splice(idx, 1);
}
return result;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class AutoRacingSimulator implements Simulator {
constructor(
private readonly racePoints: Record<number, number>,
private readonly source: string,
) {}
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this sports season
const participants = await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, sportsSeasonId),
});
if (participants.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// 2. Load current championship standings (existing points earned this season)
const seasonResults = await getSeasonResults(sportsSeasonId);
const currentPointsMap = new Map<string, number>(
seasonResults.map((r) => [r.participant.id, parseFloat(r.currentPoints ?? "0")])
);
// 3. Count remaining races: incomplete scoring events, excluding schedule_event entries
const allEvents = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
const remainingRaces = allEvents.filter(
(e) => !e.isComplete && e.eventType !== "schedule_event"
).length;
// 4. Load EV data for championship win probabilities
const evs = await getAllParticipantEVsForSeason(sportsSeasonId);
const evMap = new Map(evs.map((ev) => [ev.participantId, ev]));
const ids = participants.map((p) => p.id);
// 5. Build raw implied championship win probabilities from odds.
// americanToImpliedProb includes vig (sum > 1.0), so we normalize to sum = 1.0
// before using as weights. This is standard "vig removal" and ensures a driver
// with -200 odds (~66.7% implied) gets ~55% weight when the total vig is ~1.2.
const fallbackProb = 1 / participants.length;
const rawProbs = new Map<string, number>();
for (const p of participants) {
const ev = evMap.get(p.id);
rawProbs.set(p.id, ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb);
}
// Normalize to remove vig
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawProbs) {
rawProbs.set(id, prob / rawSum);
}
// 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
const baseProbs = new Map<string, number>();
if (UNCERTAINTY_FACTOR === 0) {
for (const [id, prob] of rawProbs) baseProbs.set(id, prob);
} else {
const avgProb = [...rawProbs.values()].reduce((a, b) => a + b, 0) / participants.length;
for (const [id, prob] of rawProbs) {
baseProbs.set(id, prob * (1 - UNCERTAINTY_FACTOR) + avgProb * UNCERTAINTY_FACTOR);
}
}
// 7. Accumulate finish counts across simulations
// rankCounts[id][0..7] = number of times driver finished 1st..8th
const rankCounts = new Map<string, number[]>();
for (const id of ids) {
rankCounts.set(id, Array.from({ length: 8 }, () => 0));
}
if (remainingRaces === 0) {
// Pre-season: no races to simulate, derive placement probabilities
// from sourceOdds via pure weighted draws.
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) {
const counts = rankCounts.get(finishOrder[rank]);
if (counts) counts[rank]++;
}
}
} else {
// In-season: simulate remaining races from current standings.
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
// 7a. Season-long performance multiplier per driver
const seasonWeights = new Map<string, number>();
for (const id of ids) {
const base = baseProbs.get(id) ?? fallbackProb;
const mult = Math.max(
0.05,
1 - PARTICIPANT_VOLATILITY + Math.random() * PARTICIPANT_VOLATILITY * 2
);
seasonWeights.set(id, base * mult);
}
// 7b. Start from current championship points
const simPoints = new Map<string, number>(
ids.map((id) => [id, currentPointsMap.get(id) ?? 0])
);
// 7c. Simulate each remaining race
for (let race = 0; race < remainingRaces; race++) {
const raceWeights = ids.map((id) => {
const sw = seasonWeights.get(id) ?? fallbackProb;
const noise = Math.max(0.01, 1 - RACE_NOISE + Math.random() * RACE_NOISE * 2);
return sw * noise;
});
const finishOrder = weightedDrawWithoutReplacement(ids, raceWeights);
for (let pos = 0; pos < finishOrder.length; pos++) {
const pts = getRacePoints(this.racePoints, pos + 1);
if (pts === 0) break; // unscored positions earn no points
simPoints.set(finishOrder[pos], (simPoints.get(finishOrder[pos]) ?? 0) + pts);
}
}
// 7d. Sort by final championship points, record top-8 finishes
const finalOrder = [...simPoints.entries()]
.toSorted((a, b) => b[1] - a[1])
.map(([id]) => id);
for (let rank = 0; rank < Math.min(8, finalOrder.length); rank++) {
const counts = rankCounts.get(finalOrder[rank]);
if (counts) counts[rank]++;
}
}
}
// 8. Convert counts → probability distributions
const results: SimulationResult[] = participants.map((p) => {
const counts = rankCounts.get(p.id) ?? [0,0,0,0,0,0,0,0];
return {
participantId: p.id,
probabilities: {
probFirst: counts[0] / NUM_SIMULATIONS,
probSecond: counts[1] / NUM_SIMULATIONS,
probThird: counts[2] / NUM_SIMULATIONS,
probFourth: counts[3] / NUM_SIMULATIONS,
probFifth: counts[4] / NUM_SIMULATIONS,
probSixth: counts[5] / NUM_SIMULATIONS,
probSeventh: counts[6] / NUM_SIMULATIONS,
probEighth: counts[7] / NUM_SIMULATIONS,
},
source: this.source,
};
});
// 9. Per-position normalization: each column should sum to exactly 1.0 but
// floating-point division (count / 10000) accumulates small errors across
// ~20 drivers, causing the total EV to drift (e.g. 340.02 instead of 340).
// Fix: add the residual (1.0 - colSum) to the largest probability in each
// column so the sum is exactly 1.0 in IEEE 754 arithmetic.
const positionKeys: Array<keyof typeof results[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}
}