From 7fa88fc0efe0e46fe5a752735b24d98b1c1b26fe Mon Sep 17 00:00:00 2001
From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com>
Date: Fri, 17 Apr 2026 12:40:08 -0700
Subject: [PATCH] Add projected-wins input mode to admin Elo Ratings page
(#306)
* Add projected-wins input mode to admin Elo Ratings page
Admins can now enter projected season win totals instead of raw Elo
numbers on the Elo Ratings page. Wins are auto-converted to Elo using
the inverse formula and stored as sourceElo, keeping the rest of the
simulation pipeline unchanged.
- Add `projectedWinsToElo` / `eloToProjectedWins` to probability-engine
- Add `simulator-config.ts` centralising per-sport season length, parity
factor, and average opponent Elo (AFL, NFL, NBA, NHL, MLB, WNBA)
- Admin Elo Ratings page: toggle between Elo and Projected Wins input
modes; bulk import parses wins format; existing sourceElo back-fills
the wins field on load
- AFL simulator now reads sourceElo from participantExpectedValues first,
falling back to hardcoded TEAMS_DATA then 1400
Co-Authored-By: Claude Sonnet 4.6
* Fix lint errors in simulator-config tests and probability-engine
- Replace non-null assertions (`!`) with optional chaining (`?.`) in simulator-config tests
- Remove redundant type annotations on default parameters in probability-engine
Co-Authored-By: Claude Sonnet 4.6
---------
Co-authored-by: Claude Sonnet 4.6
---
.../admin.sports-seasons.$id.elo-ratings.tsx | 322 +++++++++++++-----
.../__tests__/probability-engine.test.ts | 76 +++++
app/services/probability-engine.ts | 75 ++++
.../__tests__/afl-simulator.test.ts | 86 ++++-
.../__tests__/simulator-config.test.ts | 70 ++++
app/services/simulations/afl-simulator.ts | 76 +++--
app/services/simulations/simulator-config.ts | 89 +++++
7 files changed, 672 insertions(+), 122 deletions(-)
create mode 100644 app/services/simulations/__tests__/simulator-config.test.ts
create mode 100644 app/services/simulations/simulator-config.ts
diff --git a/app/routes/admin.sports-seasons.$id.elo-ratings.tsx b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx
index 7c0416d..385e911 100644
--- a/app/routes/admin.sports-seasons.$id.elo-ratings.tsx
+++ b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx
@@ -31,6 +31,8 @@ import {
import { useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import { normalizeName } from '~/lib/fuzzy-match';
+import { getSimulatorConfig, supportsProjectedWins } from '~/services/simulations/simulator-config';
+import { eloToProjectedWins, projectedWinsToElo } from '~/services/probability-engine';
// Simulator types that use worldRanking in addition to sourceElo
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points']);
@@ -66,7 +68,14 @@ export async function loader({ params }: Route.LoaderArgs) {
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
- return { sportsSeason, participants, existingData, usesRanking };
+ const simulatorConfig = sportsSeason.sport?.simulatorType
+ ? getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType)
+ : null;
+ const canUseProjectedWins = sportsSeason.sport?.simulatorType
+ ? supportsProjectedWins(sportsSeason.sport.simulatorType as SimulatorType)
+ : false;
+
+ return { sportsSeason, participants, existingData, usesRanking, simulatorConfig, canUseProjectedWins };
}
interface ActionData {
@@ -85,32 +94,55 @@ export async function action({ request, params }: Route.ActionArgs) {
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
+ const rawMode = formData.get('inputMode');
+ const inputMode = rawMode === 'projectedWins' ? 'projectedWins' : 'elo';
const eloInputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }> = [];
- for (const participant of participants) {
- const eloVal = formData.get(`elo_${participant.id}`) as string;
- const rankVal = formData.get(`rank_${participant.id}`) as string;
- if (eloVal && eloVal.trim() !== '') {
- const elo = Number(eloVal);
- if (!isNaN(elo) && elo > 0) {
- if (usesRanking) {
- const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null;
- eloInputs.push({
- participantId: participant.id,
- sportsSeasonId,
- sourceElo: elo,
- worldRanking: ranking && !isNaN(ranking) && ranking > 0 ? ranking : null,
- });
- } else {
+ if (inputMode === 'projectedWins' && sportsSeason.sport?.simulatorType) {
+ const config = getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType);
+ if (!config) {
+ return { success: false, message: 'This sport does not support projected wins input.' };
+ }
+
+ for (const participant of participants) {
+ const winsVal = formData.get(`wins_${participant.id}`) as string;
+ if (winsVal && winsVal.trim() !== '') {
+ const projectedWins = parseFloat(winsVal);
+ if (!isNaN(projectedWins) && projectedWins >= 0 && projectedWins <= config.seasonGames) {
+ const elo = projectedWinsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo);
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
}
}
}
+ } else {
+ for (const participant of participants) {
+ const eloVal = formData.get(`elo_${participant.id}`) as string;
+ const rankVal = formData.get(`rank_${participant.id}`) as string;
+
+ if (eloVal && eloVal.trim() !== '') {
+ const elo = Number(eloVal);
+ if (!isNaN(elo) && elo > 0) {
+ if (usesRanking) {
+ const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null;
+ eloInputs.push({
+ participantId: participant.id,
+ sportsSeasonId,
+ sourceElo: elo,
+ worldRanking: ranking && !isNaN(ranking) && ranking > 0 ? ranking : null,
+ });
+ } else {
+ eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
+ }
+ }
+ }
+ }
}
if (eloInputs.length === 0) {
- return { success: false, message: 'Please enter an Elo rating for at least one participant' };
+ return { success: false, message: inputMode === 'projectedWins'
+ ? 'Please enter projected wins for at least one participant'
+ : 'Please enter an Elo rating for at least one participant' };
}
if (!sportsSeason.sport?.simulatorType) {
@@ -197,10 +229,12 @@ export async function action({ request, params }: Route.ActionArgs) {
}
export default function AdminSportsSeasonEloRatings() {
- const { sportsSeason, participants, existingData, usesRanking } = useLoaderData();
+ const { sportsSeason, participants, existingData, usesRanking, simulatorConfig, canUseProjectedWins } = useLoaderData();
const actionData = useActionData();
const navigation = useNavigation();
+ const [inputMode, setInputMode] = useState<'elo' | 'projectedWins'>('elo');
+
const [eloValues, setEloValues] = useState>(() => {
const initial: Record = {};
participants.forEach(p => {
@@ -219,6 +253,21 @@ export default function AdminSportsSeasonEloRatings() {
return initial;
});
+ const [winsValues, setWinsValues] = useState>(() => {
+ const initial: Record = {};
+ if (simulatorConfig) {
+ participants.forEach(p => {
+ const d = existingData[p.id];
+ if (d?.elo !== null && d?.elo !== undefined) {
+ initial[p.id] = eloToProjectedWins(
+ d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo
+ ).toFixed(1);
+ }
+ });
+ }
+ return initial;
+ });
+
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; elo: number; ranking: number | null; inputName: string }>;
@@ -255,24 +304,43 @@ export default function AdminSportsSeasonEloRatings() {
const trimmed = line.trim();
if (!trimmed) continue;
- // Match "Name, Elo" or "Name, Elo, Ranking" (comma/colon/tab separated)
- const match = usesRanking
- ? /^(.+?)[\s,:\t]+(\d{3,5})(?:[\s,:\t]+(\d{1,3}))?\s*$/.exec(trimmed)
- : /^(.+?)[\s,:\t]+(\d{3,5})\s*$/.exec(trimmed);
- if (!match) continue;
+ if (inputMode === 'projectedWins' && simulatorConfig) {
+ const match = /^(.+?)[\s,:\t]+(\d+(?:\.\d+)?)\s*$/.exec(trimmed);
+ if (!match) continue;
- const inputName = match[1].trim();
- const elo = parseInt(match[2], 10);
- const ranking = usesRanking && match[3] ? parseInt(match[3], 10) : null;
+ const inputName = match[1].trim();
+ const projectedWins = parseFloat(match[2]);
- if (isNaN(elo) || elo < 500 || elo > 5000) continue;
+ if (isNaN(projectedWins) || projectedWins < 0 || projectedWins > simulatorConfig.seasonGames) continue;
- const participant = findParticipantMatch(inputName);
- if (participant && !seen.has(participant.id)) {
- seen.add(participant.id);
- matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
- } else if (!participant) {
- unmatched.push({ inputName, elo, ranking });
+ const elo = projectedWinsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo);
+
+ const participant = findParticipantMatch(inputName);
+ if (participant && !seen.has(participant.id)) {
+ seen.add(participant.id);
+ matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName });
+ } else if (!participant) {
+ unmatched.push({ inputName, elo, ranking: null });
+ }
+ } else {
+ const match = usesRanking
+ ? /^(.+?)[\s,:\t]+(\d{3,5})(?:[\s,:\t]+(\d{1,3}))?\s*$/.exec(trimmed)
+ : /^(.+?)[\s,:\t]+(\d{3,5})\s*$/.exec(trimmed);
+ if (!match) continue;
+
+ const inputName = match[1].trim();
+ const elo = parseInt(match[2], 10);
+ const ranking = usesRanking && match[3] ? parseInt(match[3], 10) : null;
+
+ if (isNaN(elo) || elo < 500 || elo > 5000) continue;
+
+ const participant = findParticipantMatch(inputName);
+ if (participant && !seen.has(participant.id)) {
+ seen.add(participant.id);
+ matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
+ } else if (!participant) {
+ unmatched.push({ inputName, elo, ranking });
+ }
}
}
@@ -283,12 +351,19 @@ export default function AdminSportsSeasonEloRatings() {
if (!parseResults) return;
const newElos = { ...eloValues };
const newRanks = { ...rankValues };
+ const newWins = { ...winsValues };
for (const m of parseResults.matched) {
newElos[m.participantId] = m.elo.toString();
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
+ if (inputMode === 'projectedWins' && simulatorConfig) {
+ newWins[m.participantId] = eloToProjectedWins(
+ m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo
+ ).toFixed(1);
+ }
}
setEloValues(newElos);
setRankValues(newRanks);
+ setWinsValues(newWins);
setParseResults(null);
setBulkText('');
}
@@ -318,12 +393,38 @@ export default function AdminSportsSeasonEloRatings() {
+ {/* Input Mode Toggle */}
+ {canUseProjectedWins && !usesRanking && (
+
+ setInputMode('elo')}
+ >
+ Elo Ratings
+
+ setInputMode('projectedWins')}
+ >
+ Projected Wins
+
+
+ )}
+
{/* Bulk Import */}
Bulk Import
- {usesRanking ? (
+ {inputMode === 'projectedWins' && simulatorConfig ? (
+ <>
+ Paste projected season wins one per line. Format: Team Name, 15.6.
+ Wins are automatically converted to Elo ratings using {simulatorConfig.seasonGames} total games
+ and parity factor {simulatorConfig.parityFactor}. Names are fuzzy-matched to participants.
+ >
+ ) : usesRanking ? (
<>
Paste one entry per line. Format:{' '}
Name, Elo, {rankLabel} (ranking is optional — omit it and the simulator
@@ -340,11 +441,13 @@ export default function AdminSportsSeasonEloRatings() {
-
- Save Elo ratings{usesRanking ? ` and ${rankLabel}s` : ''} for all participants
- Compute per-game win probability from Elo difference
- Compute match win probability using the Bernoulli model for each round's format
- Run Monte Carlo simulations of the full event
- Distribute probabilities across 1st–8th place buckets
- Calculate expected fantasy value per player
-
- {usesRanking && (
+ {inputMode === 'projectedWins' ? (
+
+ Enter each team's projected total season wins
+ Wins are converted to Elo ratings using the inverse Elo formula
+ Elo ratings are saved and the simulation runs automatically
+ Results: probability distributions across 1st–8th place buckets
+ Expected fantasy value is calculated per team
+
+ ) : (
+
+ Save Elo ratings{usesRanking ? ` and ${rankLabel}s` : ''} for all participants
+ Compute per-game win probability from Elo difference
+ Compute match win probability using the Bernoulli model for each round's format
+ Run Monte Carlo simulations of the full event
+ Distribute probabilities across 1st–8th place buckets
+ Calculate expected fantasy value per player
+
+ )}
+ {usesRanking && inputMode === 'elo' && (
{rankLabel} is optional — if omitted, the simulator uses Elo order for seeding.
)}
+ {inputMode === 'projectedWins' && simulatorConfig && (
+
+ Conversion: Elo = {simulatorConfig.averageOpponentElo} − {simulatorConfig.parityFactor} × log₁₀((1 − winRate) / winRate),
+ where winRate = projectedWins / {simulatorConfig.seasonGames}.
+
+ )}
diff --git a/app/services/__tests__/probability-engine.test.ts b/app/services/__tests__/probability-engine.test.ts
index 2917801..0be8743 100644
--- a/app/services/__tests__/probability-engine.test.ts
+++ b/app/services/__tests__/probability-engine.test.ts
@@ -8,6 +8,8 @@ import {
eloWinProbability,
convertFuturesToElo,
calculatePredictionError,
+ projectedWinsToElo,
+ eloToProjectedWins,
} from '../probability-engine';
describe('probability-engine', () => {
@@ -319,4 +321,78 @@ describe('probability-engine', () => {
expect(error).toBeLessThan(0.25); // 25% tolerance before calibration
});
});
+
+ describe('projectedWinsToElo', () => {
+ it('converts average projected wins to 1500 Elo', () => {
+ // 11.5 wins out of 23 = 0.5 win rate = 1500 Elo
+ expect(projectedWinsToElo(11.5, 23, 450)).toBe(1500);
+ });
+
+ it('converts AFL 2026 Bulldogs (15.6 wins / 23) to ~1646 Elo', () => {
+ expect(projectedWinsToElo(15.6, 23, 450)).toBe(1646);
+ });
+
+ it('converts AFL 2026 Essendon (7.1 wins / 23) to ~1342 Elo', () => {
+ expect(projectedWinsToElo(7.1, 23, 450)).toBe(1342);
+ });
+
+ it('higher projected wins produce higher Elo', () => {
+ const elo10 = projectedWinsToElo(10, 23, 450);
+ const elo15 = projectedWinsToElo(15, 23, 450);
+ expect(elo15).toBeGreaterThan(elo10);
+ });
+
+ it('uses parity factor 400 as default', () => {
+ // NFL-like: 8.5/17 = 0.5 → 1500
+ expect(projectedWinsToElo(8.5, 17)).toBe(1500);
+ });
+
+ it('handles 0 projected wins (floor)', () => {
+ const elo = projectedWinsToElo(0, 23, 450);
+ expect(elo).toBeLessThan(1000);
+ });
+
+ it('handles max projected wins (cap)', () => {
+ const elo = projectedWinsToElo(23, 23, 450);
+ expect(elo).toBeGreaterThan(2500);
+ });
+
+ it('is inverse of eloToProjectedWins (round-trip)', () => {
+ const wins = 14.3;
+ const elo = projectedWinsToElo(wins, 23, 450);
+ const roundTrip = eloToProjectedWins(elo, 23, 450);
+ expect(roundTrip).toBeCloseTo(wins, 0);
+ });
+
+ it('throws for negative projected wins', () => {
+ expect(() => projectedWinsToElo(-1, 23, 450)).toThrow('between 0 and');
+ });
+
+ it('throws for projected wins exceeding total games', () => {
+ expect(() => projectedWinsToElo(25, 23, 450)).toThrow('between 0 and');
+ });
+
+ it('throws for zero total games', () => {
+ expect(() => projectedWinsToElo(5, 0, 450)).toThrow('must be positive');
+ });
+ });
+
+ describe('eloToProjectedWins', () => {
+ it('returns half the season for 1500 Elo (average)', () => {
+ expect(eloToProjectedWins(1500, 23, 450)).toBeCloseTo(11.5, 1);
+ });
+
+ it('returns more wins for higher Elo', () => {
+ const winsHigh = eloToProjectedWins(1700, 23, 450);
+ const winsLow = eloToProjectedWins(1300, 23, 450);
+ expect(winsHigh).toBeGreaterThan(winsLow);
+ });
+
+ it('round-trips with projectedWinsToElo', () => {
+ const elo = 1600;
+ const wins = eloToProjectedWins(elo, 23, 450);
+ const roundTrip = projectedWinsToElo(wins, 23, 450);
+ expect(roundTrip).toBe(elo);
+ });
+ });
});
diff --git a/app/services/probability-engine.ts b/app/services/probability-engine.ts
index 2f8549f..e7c7629 100644
--- a/app/services/probability-engine.ts
+++ b/app/services/probability-engine.ts
@@ -293,3 +293,78 @@ export function calculatePredictionError(
return { meanError, maxError };
}
+
+/**
+ * Convert projected season win totals to an Elo rating.
+ *
+ * Given a team's projected total wins (including games already played),
+ * derives the Elo rating that would produce that win rate against an
+ * average opponent over the full season.
+ *
+ * Inverse of the Elo win probability formula:
+ * elo = avgElo - parityFactor × log₁₀((1 − winRate) / winRate)
+ * where winRate = projectedWins / totalGames
+ *
+ * @param projectedWins Total projected wins for the season (e.g. 15.6)
+ * @param totalGames Total regular season games (e.g. 23 for AFL)
+ * @param parityFactor Elo parity factor for the sport (e.g. 450 for AFL)
+ * @param averageElo Elo of an average opponent (typically 1500)
+ * @returns Elo rating (rounded to integer)
+ *
+ * @example
+ * projectedWinsToElo(15.6, 23, 450) // 1646 (Western Bulldogs 2026)
+ * projectedWinsToElo(11.5, 23, 450) // 1500 (average team)
+ * projectedWinsToElo(7.1, 23, 450) // 1342 (Essendon 2026)
+ */
+export function projectedWinsToElo(
+ projectedWins: number,
+ totalGames: number,
+ parityFactor = 400,
+ averageElo = 1500
+): number {
+ if (totalGames <= 0) {
+ throw new Error('Total games must be positive');
+ }
+ if (parityFactor <= 0) {
+ throw new Error('Parity factor must be positive');
+ }
+ if (projectedWins < 0 || projectedWins > totalGames) {
+ throw new Error(
+ `Projected wins (${projectedWins}) must be between 0 and total games (${totalGames})`
+ );
+ }
+
+ const winRate = projectedWins / totalGames;
+
+ // Edge cases: clamp to avoid log(0) or division by zero
+ if (winRate >= 1.0) return averageElo + parityFactor * 3; // ~dominant cap
+ if (winRate <= 0.0) return averageElo - parityFactor * 3; // ~terrible floor
+
+ const elo = averageElo - parityFactor * Math.log10((1 - winRate) / winRate);
+ return Math.round(elo);
+}
+
+/**
+ * Convert an Elo rating back to projected season wins.
+ *
+ * This is the forward direction of projectedWinsToElo:
+ * winRate = 1 / (1 + 10^((averageElo - elo) / parityFactor))
+ * projectedWins = winRate × totalGames
+ *
+ * Useful for displaying the equivalent projected wins for a given Elo rating.
+ *
+ * @param elo Elo rating
+ * @param totalGames Total regular season games
+ * @param parityFactor Elo parity factor
+ * @param averageElo Elo of an average opponent
+ * @returns Projected wins (decimal, e.g. 15.6)
+ */
+export function eloToProjectedWins(
+ elo: number,
+ totalGames: number,
+ parityFactor = 400,
+ averageElo = 1500
+): number {
+ const winProb = 1 / (1 + Math.pow(10, (averageElo - elo) / parityFactor));
+ return winProb * totalGames;
+}
diff --git a/app/services/simulations/__tests__/afl-simulator.test.ts b/app/services/simulations/__tests__/afl-simulator.test.ts
index c7bbff4..56d7025 100644
--- a/app/services/simulations/__tests__/afl-simulator.test.ts
+++ b/app/services/simulations/__tests__/afl-simulator.test.ts
@@ -132,11 +132,25 @@ describe("AFLSimulator.simulate()", () => {
const { database } = await import("~/database/context");
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
+ const participantRows = PARTICIPANT_ROWS;
+
+ let selectCallCount = 0;
mockDb = {
- select: vi.fn().mockReturnValue({
- from: vi.fn().mockReturnValue({
- where: vi.fn().mockResolvedValue(PARTICIPANT_ROWS),
- }),
+ select: vi.fn().mockImplementation(() => {
+ selectCallCount++;
+ if (selectCallCount === 1) {
+ return {
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue(participantRows),
+ }),
+ };
+ }
+ // Second call: sourceElo query (no DB Elo by default)
+ return {
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([]),
+ }),
+ };
}),
};
@@ -146,10 +160,21 @@ describe("AFLSimulator.simulate()", () => {
});
it("throws if no participants found", async () => {
- mockDb.select.mockReturnValue({
- from: vi.fn().mockReturnValue({
- where: vi.fn().mockResolvedValue([]),
- }),
+ let selectCallCount = 0;
+ mockDb.select.mockImplementation(() => {
+ selectCallCount++;
+ if (selectCallCount === 1) {
+ return {
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([]),
+ }),
+ };
+ }
+ return {
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([]),
+ }),
+ };
});
const sim = new AFLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/);
@@ -285,4 +310,49 @@ describe("AFLSimulator.simulate()", () => {
expect(leader.probabilities.probFirst).toBeGreaterThan(bottom.probabilities.probFirst);
});
+
+ it("DB sourceElo overrides hardcoded TEAMS_DATA values", async () => {
+ // Set DB Elo for West Coast (team-18) to 1800 (higher than Bulldogs)
+ let selectCallCount = 0;
+ mockDb.select.mockImplementation(() => {
+ selectCallCount++;
+ if (selectCallCount === 1) {
+ return {
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue(PARTICIPANT_ROWS),
+ }),
+ };
+ }
+ return {
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([
+ { participantId: "team-18", sourceElo: 1800 },
+ ]),
+ }),
+ };
+ });
+
+ const sim = new AFLSimulator();
+ const results = await sim.simulate("season-1");
+
+ const westCoast = results.find((r) => r.participantId === "team-18");
+ const bulldogs = results.find((r) => r.participantId === "team-1");
+ if (!westCoast || !bulldogs) throw new Error("Expected results not found");
+
+ // With DB Elo 1800, West Coast should now be favored over Bulldogs (1646)
+ expect(westCoast.probabilities.probFirst).toBeGreaterThan(bulldogs.probabilities.probFirst);
+ });
+
+ it("falls back to hardcoded TEAMS_DATA when no DB sourceElo exists", async () => {
+ // Default mock already returns no sourceElo rows — should use TEAMS_DATA
+ const sim = new AFLSimulator();
+ const results = await sim.simulate("season-1");
+
+ const bulldogs = results.find((r) => r.participantId === "team-1");
+ const westCoast = results.find((r) => r.participantId === "team-18");
+ if (!bulldogs || !westCoast) throw new Error("Expected results not found");
+
+ // Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
+ expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
+ });
});
diff --git a/app/services/simulations/__tests__/simulator-config.test.ts b/app/services/simulations/__tests__/simulator-config.test.ts
new file mode 100644
index 0000000..fb7f0d8
--- /dev/null
+++ b/app/services/simulations/__tests__/simulator-config.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect } from "vitest";
+import { getSimulatorConfig, supportsProjectedWins } from "../simulator-config";
+
+describe("simulator-config", () => {
+ describe("getSimulatorConfig", () => {
+ it("returns config for afl_bracket", () => {
+ const config = getSimulatorConfig("afl_bracket");
+ expect(config).not.toBeNull();
+ expect(config?.seasonGames).toBe(23);
+ expect(config?.parityFactor).toBe(450);
+ expect(config?.averageOpponentElo).toBe(1500);
+ });
+
+ it("returns config for nfl_bracket", () => {
+ const config = getSimulatorConfig("nfl_bracket");
+ expect(config).not.toBeNull();
+ expect(config?.seasonGames).toBe(17);
+ expect(config?.parityFactor).toBe(400);
+ });
+
+ it("returns config for nba_bracket", () => {
+ const config = getSimulatorConfig("nba_bracket");
+ expect(config).not.toBeNull();
+ expect(config?.seasonGames).toBe(82);
+ });
+
+ it("returns config for nhl_bracket", () => {
+ const config = getSimulatorConfig("nhl_bracket");
+ expect(config).not.toBeNull();
+ expect(config?.seasonGames).toBe(82);
+ expect(config?.parityFactor).toBe(1000);
+ });
+
+ it("returns config for mlb_bracket", () => {
+ const config = getSimulatorConfig("mlb_bracket");
+ expect(config).not.toBeNull();
+ expect(config?.seasonGames).toBe(162);
+ });
+
+ it("returns null for snooker_bracket", () => {
+ expect(getSimulatorConfig("snooker_bracket")).toBeNull();
+ });
+
+ it("returns null for darts_bracket", () => {
+ expect(getSimulatorConfig("darts_bracket")).toBeNull();
+ });
+
+ it("returns null for playoff_bracket", () => {
+ expect(getSimulatorConfig("playoff_bracket")).toBeNull();
+ });
+ });
+
+ describe("supportsProjectedWins", () => {
+ it("returns true for afl_bracket", () => {
+ expect(supportsProjectedWins("afl_bracket")).toBe(true);
+ });
+
+ it("returns true for nfl_bracket", () => {
+ expect(supportsProjectedWins("nfl_bracket")).toBe(true);
+ });
+
+ it("returns false for snooker_bracket", () => {
+ expect(supportsProjectedWins("snooker_bracket")).toBe(false);
+ });
+
+ it("returns false for darts_bracket", () => {
+ expect(supportsProjectedWins("darts_bracket")).toBe(false);
+ });
+ });
+});
diff --git a/app/services/simulations/afl-simulator.ts b/app/services/simulations/afl-simulator.ts
index 36dc54f..5616844 100644
--- a/app/services/simulations/afl-simulator.ts
+++ b/app/services/simulations/afl-simulator.ts
@@ -5,8 +5,9 @@
*
* Algorithm:
* 1. Load all participants for the sports season from DB
- * 2. Load current regular season standings (wins, gamesPlayed) — if available
- * 3. Match participant names to hardcoded team data (Elo ratings)
+ * 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
+ * Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
+ * 3. Load current regular season standings (wins, gamesPlayed) — if available
* 4. For each simulation:
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
* using Elo win probability vs. an average opponent (Elo 1500)
@@ -36,12 +37,14 @@
* Per-game win probability = eloWinProbability(teamElo, 1500) where 1500 = average opponent.
* If no standings exist in DB, defaults to 0 wins / TOTAL_GAMES remaining (seeding by Elo only).
*
- * Elo ratings (see TEAMS_DATA below):
- * Backsolved from Squiggle's projected season win totals using the inverse formula:
+ * Elo ratings:
+ * Priority: sourceElo from participantExpectedValues (admin UI) → hardcoded TEAMS_DATA
+ * → fallback 1400.
+ * Admin can enter Elo directly or via "Projected Wins" mode on the Elo Ratings admin page,
+ * which auto-converts projected season wins to Elo using the inverse formula:
* elo = 1500 - 450 × log₁₀((1 − wins/23) / (wins/23))
- * Projected win counts were read from a screenshot of squiggle.com.au's season
- * simulation table (as of Round 2, 2026). Update each round as projections shift.
- * Source: https://squiggle.com.au
+ * The hardcoded TEAMS_DATA values are backsolved from Squiggle's projected season
+ * win totals (as of Round 2, 2026). Source: https://squiggle.com.au
*
* Placement tiers → SimulationProbabilities mapping:
* probFirst = Grand Final winner (1 per sim)
@@ -83,16 +86,13 @@ const AFL_REGULAR_SEASON_GAMES = 23;
/** Average opponent Elo used for regular season projections. */
const AVERAGE_OPPONENT_ELO = 1500;
-// ─── Team data (2026 AFL season, as of end of Round 2) ───────────────────────
+// ─── Hardcoded team data (FALLBACK — used only when no sourceElo in DB) ──────
//
// Elo ratings are backsolved from Squiggle's projected season win totals.
-// Process: we screenshotted squiggle.com.au's season simulation table (Round 2,
-// 2026), read each team's projected wins, then applied the inverse formula:
-// elo = 1500 - 450 × log₁₀((1 − wins/23) / (wins/23))
-// This calibrates each team so the simulator reproduces Squiggle's ladder
-// projection when every game is played against an average opponent (Elo 1500).
-// Update each round by re-reading the projected wins from Squiggle and recalculating.
-// Source: https://squiggle.com.au
+// These serve as fallback defaults when no sourceElo has been entered via the
+// admin Elo Ratings page. Prefer updating via Admin → Elo Ratings (projected
+// wins mode) rather than editing these values.
+// Source: https://squiggle.com.au (Round 2, 2026)
interface AflTeamData {
elo: number;
@@ -169,7 +169,8 @@ export function eloWinProbability(eloA: number, eloB: number): number {
interface TeamEntry {
id: string;
name: string;
- data: AflTeamData | undefined;
+ /** Resolved Elo: DB sourceElo > hardcoded TEAMS_DATA > fallback 1400. */
+ elo: number;
/** Actual wins from the standings table (0 if no standings loaded). */
currentWins: number;
/** Remaining regular season games = TOTAL_GAMES - gamesPlayed (0 if season is complete). */
@@ -178,12 +179,6 @@ interface TeamEntry {
winProb: number;
}
-/** Get Elo for a team entry.
- * Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */
-function elo(entry: TeamEntry): number {
- return entry.data?.elo ?? 1400;
-}
-
/** Simulate remaining regular season games for a team.
* Returns projected total wins for the season. */
function simulateProjectedWins(entry: TeamEntry): number {
@@ -200,12 +195,19 @@ export class AFLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise {
const db = database();
- // 1. Load participants and standings in parallel.
- const [participantRows, standings] = await Promise.all([
+ // 1. Load participants, DB Elo, and standings in parallel.
+ const [participantRows, evRows, standings] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
+ db
+ .select({
+ participantId: schema.participantExpectedValues.participantId,
+ sourceElo: schema.participantExpectedValues.sourceElo,
+ })
+ .from(schema.participantExpectedValues)
+ .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
]);
@@ -223,7 +225,16 @@ export class AFLSimulator implements Simulator {
);
}
- // 2. Build standings lookup and construct team entries.
+ // 2. Build Elo map from DB sourceElo values.
+ const dbEloMap = new Map();
+ for (const row of evRows) {
+ if (row.sourceElo !== null && row.sourceElo !== undefined) {
+ dbEloMap.set(row.participantId, row.sourceElo);
+ }
+ }
+
+ // 3. Build standings lookup and construct team entries.
+ // Elo priority: DB sourceElo → hardcoded TEAMS_DATA → fallback 1400.
// currentWins, remainingGames, and per-game winProb are all resolved once
// here so nothing is recomputed inside the hot simulation loop.
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
@@ -231,22 +242,25 @@ export class AFLSimulator implements Simulator {
const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsMap.get(r.id);
- const data = getTeamData(r.name);
- if (!data) {
+ const dbElo = dbEloMap.get(r.id);
+ const fallbackData = getTeamData(r.name);
+ const resolvedElo = dbElo ?? fallbackData?.elo ?? 1400;
+
+ if (dbElo === undefined && !fallbackData) {
logger.warn(
{ participantName: r.name, sportsSeasonId },
`AFL simulator: no Elo found for participant "${r.name}" — falling back to 1400. ` +
- `Add an entry to TEAMS_DATA or rename the participant to match an existing key.`
+ `Enter Elo via Admin → Elo Ratings or rename the participant to match a TEAMS_DATA key.`
);
}
const gamesPlayed = standing?.gamesPlayed ?? 0;
return {
id: r.id,
name: r.name,
- data,
+ elo: resolvedElo,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, AFL_REGULAR_SEASON_GAMES - gamesPlayed),
- winProb: eloWinProbability(data?.elo ?? 1400, AVERAGE_OPPONENT_ELO),
+ winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO),
};
});
@@ -254,7 +268,7 @@ export class AFLSimulator implements Simulator {
/** Simulate a single AFL game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
- Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b;
+ Math.random() < eloWinProbability(a.elo, b.elo) ? a : b;
/**
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
diff --git a/app/services/simulations/simulator-config.ts b/app/services/simulations/simulator-config.ts
new file mode 100644
index 0000000..ad921b3
--- /dev/null
+++ b/app/services/simulations/simulator-config.ts
@@ -0,0 +1,89 @@
+/**
+ * Simulator Configuration
+ *
+ * Per-sport parameters used by simulators and admin UI tools.
+ * Centralizes season length, parity factors, and other sport-specific
+ * constants so they can be shared between simulators and the Elo Ratings
+ * admin page (e.g., for projected wins → Elo conversion).
+ */
+
+import type { SimulatorType } from "./registry";
+
+export interface SimulatorConfig {
+ /** Total regular season games per team in this sport. */
+ seasonGames: number;
+ /** Elo parity factor for single-game win probability.
+ * P(A) = 1 / (1 + 10^((eloB - eloA) / parityFactor))
+ * Higher = more unpredictable per game. */
+ parityFactor: number;
+ /** Average opponent Elo — used for season projections against a generic opponent. */
+ averageOpponentElo: number;
+}
+
+const CONFIG: Record = {
+ afl_bracket: {
+ seasonGames: 23,
+ parityFactor: 450,
+ averageOpponentElo: 1500,
+ },
+ nfl_bracket: {
+ seasonGames: 17,
+ parityFactor: 400,
+ averageOpponentElo: 1500,
+ },
+ nba_bracket: {
+ seasonGames: 82,
+ parityFactor: 400,
+ averageOpponentElo: 1500,
+ },
+ nhl_bracket: {
+ seasonGames: 82,
+ // NHL is the highest-parity major North American league — roughly 1 in 3 games
+ // goes to overtime/shootout and favourites win far less reliably than in NBA/NFL.
+ // 1000 was calibrated so that a team projected at 55 wins (67%) maps to ~1570 Elo,
+ // which keeps the spread tight and reflects the empirical upset rate.
+ parityFactor: 1000,
+ averageOpponentElo: 1500,
+ },
+ mlb_bracket: {
+ seasonGames: 162,
+ parityFactor: 400,
+ averageOpponentElo: 1500,
+ },
+ wnba_bracket: {
+ seasonGames: 44,
+ parityFactor: 400,
+ averageOpponentElo: 1500,
+ },
+ // Simulators below don't use projected-wins → Elo conversion.
+ // They can be populated later if needed.
+ f1_standings: null,
+ indycar_standings: null,
+ golf_qualifying_points: null,
+ playoff_bracket: null,
+ ucl_bracket: null,
+ ncaam_bracket: null,
+ ncaaw_bracket: null,
+ snooker_bracket: null,
+ tennis_qualifying_points: null,
+ world_cup: null,
+ darts_bracket: null,
+ cs2_major_qualifying_points: null,
+ ncaa_football_bracket: null,
+ llws_bracket: null,
+};
+
+/**
+ * Get the simulator config for a given simulator type.
+ * Returns null for simulator types that don't have season-game / Elo config.
+ */
+export function getSimulatorConfig(simulatorType: SimulatorType): SimulatorConfig | null {
+ return CONFIG[simulatorType];
+}
+
+/**
+ * Check if a simulator type supports projected-wins → Elo conversion.
+ */
+export function supportsProjectedWins(simulatorType: SimulatorType): boolean {
+ return CONFIG[simulatorType] !== null;
+}