From cbaab920f92ff9601e38b41675fec931e8a30def Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:43:39 -0700 Subject: [PATCH] Add NBA playoff bracket Monte Carlo simulator (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `nba_bracket` simulator that projects both regular season seedings and the full NBA playoff bracket, unlike other simulators that operate on a pre-set bracket stored in the DB. Algorithm: - Per iteration: each team draws a conference seed (1–10) weighted by BBRef seed probabilities, then seeds 7–10 play the Play-In tournament (single elimination), then the resulting 8-team bracket is simulated as best-of-7 series using Elo playoff ratings - Elo source: playoff rating (last 110 games, no regression to mean, postseason games weighted 3×) — appropriate for playoff matchup quality - Seed probs source: Basketball-Reference (March 16, 2026), scaled from conditional to unconditional by multiplying by each team's Playoffs% Placement tiers map to SimulationProbabilities: probFirst/Second → NBA champion / Finals loser probThird/Fourth → Conference Finals losers (2 per sim) probFifth–Eighth → Conference Semis losers (4 per sim) Files: - app/services/simulations/nba-simulator.ts (new) - drizzle/0046_add_nba_bracket_simulator_type.sql (new) - database/schema.ts: adds `nba_bracket` to simulatorTypeEnum - app/services/simulations/registry.ts: registers NBASimulator Co-authored-by: Claude Sonnet 4.6 --- app/services/simulations/nba-simulator.ts | 444 ++++++++++++++++++ app/services/simulations/registry.ts | 6 + database/schema.ts | 1 + .../0046_add_nba_bracket_simulator_type.sql | 9 + 4 files changed, 460 insertions(+) create mode 100644 app/services/simulations/nba-simulator.ts create mode 100644 drizzle/0046_add_nba_bracket_simulator_type.sql diff --git a/app/services/simulations/nba-simulator.ts b/app/services/simulations/nba-simulator.ts new file mode 100644 index 0000000..9ea1e8f --- /dev/null +++ b/app/services/simulations/nba-simulator.ts @@ -0,0 +1,444 @@ +/** + * NBA Playoff Simulator + * + * Monte Carlo simulation of the NBA playoffs including seeding projection + * for the current season (2025-26). + * + * Algorithm: + * 1. Load all participants for the sports season from DB + * 2. Match participant names to hardcoded team data (Elo + seed probabilities) + * 3. For each simulation: + * a. Assign each team a seed based on its weighted probability distribution (p_1..p_10) + * Teams with no seed probabilities always miss the playoffs (seed = 11) + * b. Sort each conference by drawn seed + random tiebreaker + * → Top 6 lock in directly; seeds 7–10 enter the Play-In tournament + * c. Simulate Play-In (single game each): + * - Game 1: seed 7 vs seed 8 → winner becomes 7th playoff seed + * - Game 2: seed 9 vs seed 10 → winner advances + * - Game 3: Game 1 loser vs Game 2 winner → winner becomes 8th playoff seed + * d. Simulate NBA playoff bracket (best-of-7 series each round): + * Round 1: 1v8, 4v5, 2v7, 3v6 (per conference) + * Round 2: Conference Semis (winners of 1v8/4v5, winners of 2v7/3v6) + * Round 3: Conference Finals + * NBA Finals: East champion vs West champion + * 4. Track placement counts per scoring tier + * 5. Convert counts to probability distributions + * + * Win probability (Elo, PARITY_FACTOR = 400): + * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 400)) + * + * Placement tiers → SimulationProbabilities mapping: + * probFirst = NBA champion (1 per sim) + * probSecond = NBA Finals loser (1 per sim) + * probThird/Fourth = Conference Finals losers (2 per sim — East + West) + * probFifth–Eighth = Conference Semis losers (4 per sim) + * Round 1 losers → all 0 (score 0 points) + * Missed playoffs → all 0 + * + * Elo ratings and seed probabilities are hardcoded below (March 2026 data). + * Source: Basketball-Reference Playoff Probabilities + Neil Paine Substack estimates. + * Update at the start of each season. + */ + +import { database } from "~/database/context"; +import { eq } from "drizzle-orm"; +import * as schema from "~/database/schema"; +import type { Simulator, SimulationResult } from "./types"; + +// ─── Simulation parameters ──────────────────────────────────────────────────── + +const NUM_SIMULATIONS = 50_000; + +/** + * Elo parity factor. NBA uses 400 (standard formula). + * A 400-point Elo difference → ~90.9% win probability per game. + */ +const PARITY_FACTOR = 400; + +/** Seed probability keys — ordered p_1..p_10 for the drawSeed() inner loop. */ +const SEED_KEYS = ["p_1", "p_2", "p_3", "p_4", "p_5", "p_6", "p_7", "p_8", "p_9", "p_10"] as const; + +// ─── Team data (2025-26 season, as of March 6, 2026) ───────────────────────── +// +// elo: Estimated Elo rating (higher = stronger). +// p_1 through p_10: Probability of finishing at each conference seed. +// Sum of all p_X for a team = probability of making the playoffs. +// Teams with no p_X entries always miss the playoffs in simulation. +// +// Sources: +// - Elo: Playoff rating (last 110 games, no regression to mean, postseason games 3× weight). +// This is the appropriate signal for simulating playoff matchups. +// - Seed probs: Basketball-Reference Playoff Probabilities Report (March 16, 2026). +// +// Seed probability methodology: +// BBRef reports conditional seed probabilities (summing to ~100% per team). +// We scale each team's values by their Playoffs% to get unconditional probabilities. +// The drawSeed() function then treats the remainder (1 - sum) as "miss playoffs". +// Formula: p_X = (BBRef_conditional_X / 100) × (Playoffs% / 100) + +interface NbaTeamData { + conference: "Eastern" | "Western"; + elo: number; + p_1?: number; + p_2?: number; + p_3?: number; + p_4?: number; + p_5?: number; + p_6?: number; + p_7?: number; + p_8?: number; + p_9?: number; + p_10?: number; +} + +const TEAMS_DATA: Record = { + // ── Eastern Conference ────────────────────────────────────────────────────── + // Elo = playoff rating (last 110 games, no regression, postseason games 3× weight) + // Seed probs = BBRef conditional × (Playoffs% / 100) + + // Detroit (PO%=100%): locked as 1-seed + "Detroit Pistons": { conference: "Eastern", elo: 1558, + p_1: 0.982, p_2: 0.016, p_3: 0.001 }, + + // Boston (PO%=100%): clear 2/3 seed + "Boston Celtics": { conference: "Eastern", elo: 1699, + p_1: 0.014, p_2: 0.540, p_3: 0.389, p_4: 0.052, p_5: 0.004 }, + + // New York (PO%=100%) + "New York Knicks": { conference: "Eastern", elo: 1626, + p_1: 0.003, p_2: 0.423, p_3: 0.479, p_4: 0.086, p_5: 0.009, p_6: 0.001 }, + + // Cleveland (PO%=99.9%): mostly 4-seed, slight play-in risk + "Cleveland Cavaliers": { conference: "Eastern", elo: 1628, + p_2: 0.020, p_3: 0.117, p_4: 0.684, p_5: 0.137, p_6: 0.032, p_7: 0.009, p_8: 0.001 }, + + // Orlando (PO%=88.8%): 5/6-seed range, play-in risk + "Orlando Magic": { conference: "Eastern", elo: 1508, + p_3: 0.007, p_4: 0.075, p_5: 0.333, p_6: 0.182, p_7: 0.139, p_8: 0.091, p_9: 0.044, p_10: 0.018 }, + + // Miami (PO%=78.7%) + "Miami Heat": { conference: "Eastern", elo: 1530, + p_3: 0.004, p_4: 0.041, p_5: 0.212, p_6: 0.224, p_7: 0.255, p_8: 0.113, p_9: 0.037, p_10: 0.009 }, + + // Toronto (PO%=86.4%) + "Toronto Raptors": { conference: "Eastern", elo: 1467, + p_3: 0.001, p_4: 0.038, p_5: 0.165, p_6: 0.324, p_7: 0.192, p_8: 0.103, p_9: 0.041, p_10: 0.011 }, + + // Atlanta (PO%=46.7%): mostly play-in range + "Atlanta Hawks": { conference: "Eastern", elo: 1496, + p_4: 0.001, p_5: 0.011, p_6: 0.022, p_7: 0.058, p_8: 0.124, p_9: 0.134, p_10: 0.123 }, + + // Philadelphia (PO%=52.9%) + "Philadelphia 76ers": { conference: "Eastern", elo: 1471, + p_5: 0.011, p_6: 0.039, p_7: 0.079, p_8: 0.116, p_9: 0.134, p_10: 0.091 }, + + // Charlotte (PO%=47.1%): deep play-in territory + "Charlotte Hornets": { conference: "Eastern", elo: 1496, + p_5: 0.002, p_6: 0.004, p_7: 0.017, p_8: 0.058, p_9: 0.118, p_10: 0.201 }, + + // Milwaukee (PO%=0%) + "Milwaukee Bucks": { conference: "Eastern", elo: 1442 }, + + // Chicago (PO%=0%) + "Chicago Bulls": { conference: "Eastern", elo: 1381 }, + + // Brooklyn (PO%=0%) + "Brooklyn Nets": { conference: "Eastern", elo: 1334 }, + + // Indiana (PO%=0%) + "Indiana Pacers": { conference: "Eastern", elo: 1433 }, + + // Washington (PO%=0%) + "Washington Wizards": { conference: "Eastern", elo: 1255 }, + + // ── Western Conference ────────────────────────────────────────────────────── + + // OKC (PO%=100%): dominant 1-seed + "Oklahoma City Thunder": { conference: "Western", elo: 1731, + p_1: 0.920, p_2: 0.081 }, + + // San Antonio (PO%=100%): locked as 2-seed + "San Antonio Spurs": { conference: "Western", elo: 1599, + p_1: 0.081, p_2: 0.919 }, + + // Houston (PO%=99.8%): 3/4 seed range + "Houston Rockets": { conference: "Western", elo: 1564, + p_3: 0.467, p_4: 0.258, p_5: 0.169, p_6: 0.092, p_7: 0.014, p_8: 0.001 }, + + // Denver (PO%=99.8%) + "Denver Nuggets": { conference: "Western", elo: 1618, + p_3: 0.222, p_4: 0.270, p_5: 0.277, p_6: 0.181, p_7: 0.043, p_8: 0.001 }, + + // LA Lakers (PO%=99.7%) + "Los Angeles Lakers": { conference: "Western", elo: 1569, + p_3: 0.219, p_4: 0.267, p_5: 0.242, p_6: 0.172, p_7: 0.079, p_8: 0.003 }, + + // Minnesota (PO%=96.2%) + "Minnesota Timberwolves": { conference: "Western", elo: 1603, + p_3: 0.072, p_4: 0.154, p_5: 0.222, p_6: 0.340, p_7: 0.173, p_8: 0.007 }, + + // Phoenix (PO%=83.4%): mostly 7-seed play-in entry + "Phoenix Suns": { conference: "Western", elo: 1500, + p_3: 0.011, p_4: 0.032, p_5: 0.065, p_6: 0.165, p_7: 0.517, p_8: 0.051, p_9: 0.005 }, + + // LA Clippers (PO%=71.1%): heavy play-in range + "LA Clippers": { conference: "Western", elo: 1573, + p_6: 0.042, p_7: 0.468, p_8: 0.112, p_9: 0.027 }, + + // Golden State (PO%=30.3%): longshot play-in + "Golden State Warriors": { conference: "Western", elo: 1530, + p_6: 0.003, p_7: 0.053, p_8: 0.160, p_9: 0.147 }, + + // Portland (PO%=19.5%): deep longshot + "Portland Trail Blazers": { conference: "Western", elo: 1426, + p_7: 0.013, p_8: 0.077, p_9: 0.111 }, + + // Dallas (PO%=0%) + "Dallas Mavericks": { conference: "Western", elo: 1473 }, + + // Memphis (PO%=0%) + "Memphis Grizzlies": { conference: "Western", elo: 1417 }, + + // New Orleans (PO%=0%) + "New Orleans Pelicans": { conference: "Western", elo: 1380 }, + + // Sacramento (PO%=0%) + "Sacramento Kings": { conference: "Western", elo: 1352 }, + + // Utah (PO%=0%) + "Utah Jazz": { conference: "Western", elo: 1334 }, +}; + +// ─── Public helpers (exported for unit testing) ─────────────────────────────── + +/** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */ +export function normalizeTeamName(name: string): string { + return name.toLowerCase().trim().replace(/\s+/g, " "); +} + +/** Look up team data by participant name (case-insensitive). */ +export function getTeamData(name: string): NbaTeamData | undefined { + const normalized = normalizeTeamName(name); + for (const [teamName, data] of Object.entries(TEAMS_DATA)) { + if (normalizeTeamName(teamName) === normalized) return data; + } + return undefined; +} + +/** + * Elo win probability for team A over team B. + * P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR)) + * Exported for unit testing. + */ +export function eloWinProbability(eloA: number, eloB: number): number { + return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR)); +} + +// ─── Internal types ─────────────────────────────────────────────────────────── + +interface TeamEntry { + id: string; + name: string; + data: NbaTeamData | undefined; +} + +// ─── Simulator ──────────────────────────────────────────────────────────────── + +export class NBASimulator implements Simulator { + async simulate(sportsSeasonId: string): Promise { + const db = database(); + + // 1. Load all participants for this sports season. + const participantRows = await db + .select({ id: schema.participants.id, name: schema.participants.name }) + .from(schema.participants) + .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); + + if (participantRows.length === 0) { + throw new Error( + `No participants found for sports season ${sportsSeasonId}. ` + + `Add NBA teams as participants before running simulation.` + ); + } + + // 2. Match participant names to hardcoded team data. + // Teams with no match or no seed probabilities will always miss the playoffs. + const participantIds = participantRows.map((r) => r.id); + + const teams: TeamEntry[] = participantRows.map((r) => ({ + id: r.id, + name: r.name, + data: getTeamData(r.name), + })); + + // Separate by conference for simulation (fall back to Eastern if no data found). + const easternTeams = teams.filter((t) => (t.data?.conference ?? "Eastern") === "Eastern"); + const westernTeams = teams.filter((t) => t.data?.conference === "Western"); + + // Validate: each conference needs at least 10 teams to fill the bracket + play-in. + if (easternTeams.length < 10 || westernTeams.length < 10) { + throw new Error( + `Each conference needs at least 10 participants (got East: ${easternTeams.length}, ` + + `West: ${westernTeams.length}). Add all 30 NBA teams before running simulation.` + ); + } + + // ─── Helpers (defined once, outside the hot loop) ───────────────────────── + + /** Draw a conference seed (1–10) based on a team's probability distribution. + * Returns 11 if the draw falls outside all p_X values (team misses playoffs). */ + const drawSeed = (entry: TeamEntry): number => { + const data = entry.data; + if (!data) return 11; // Unknown team — always misses playoffs + let r = Math.random(); + for (let i = 0; i < SEED_KEYS.length; i++) { + const prob = data[SEED_KEYS[i]] ?? 0; + r -= prob; + if (r <= 0) return i + 1; + } + return 11; // Missed playoffs + }; + + /** Get Elo for a team entry. + * Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */ + const elo = (entry: TeamEntry): number => entry.data?.elo ?? 1400; + + /** Simulate a single playoff game. Returns the winner. */ + const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => + Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b; + + /** Simulate a best-of-7 series. Returns winner and loser. */ + const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { + const winProb = eloWinProbability(elo(a), elo(b)); + let winsA = 0; + let winsB = 0; + while (winsA < 4 && winsB < 4) { + if (Math.random() < winProb) winsA++; else winsB++; + } + return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a }; + }; + + /** Simulate the Play-In tournament. + * @param candidates 4 teams sorted by seeding position [7th, 8th, 9th, 10th] + * @returns [7th playoff seed, 8th playoff seed] */ + const simPlayIn = ([s7, s8, s9, s10]: [TeamEntry, TeamEntry, TeamEntry, TeamEntry]): [TeamEntry, TeamEntry] => { + // Game 1: 7 vs 8 — winner locks up the 7th seed + const game1Winner = simGame(s7, s8); + const game1Loser = game1Winner === s7 ? s8 : s7; + // Game 2: 9 vs 10 — winner advances to the final play-in game + const game2Winner = simGame(s9, s10); + // Game 3: loser of Game 1 vs winner of Game 2 — winner gets the 8th seed + return [game1Winner, simGame(game1Loser, game2Winner)]; + }; + + /** Build an 8-team conference bracket [s1..s8] for one simulation iteration. + * Seeds are drawn probabilistically; positions 7–10 go through the Play-In. */ + const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => { + const seeded = confTeams.map((t) => ({ + team: t, + seed: drawSeed(t), + tiebreaker: Math.random(), + })); + seeded.sort((a, b) => a.seed - b.seed || a.tiebreaker - b.tiebreaker); + + const top6 = seeded.slice(0, 6).map((x) => x.team); + const playIn = seeded.slice(6, 10).map((x) => x.team) as + [TeamEntry, TeamEntry, TeamEntry, TeamEntry]; + const [seed7, seed8] = simPlayIn(playIn); + return [...top6, seed7, seed8]; + }; + + /** Round 1: 1v8, 4v5, 2v7, 3v6. Returns 4 winners. */ + const simR1 = ([s1, s2, s3, s4, s5, s6, s7, s8]: TeamEntry[]): TeamEntry[] => [ + simSeries(s1, s8).winner, + simSeries(s4, s5).winner, + simSeries(s2, s7).winner, + simSeries(s3, s6).winner, + ]; + + /** Conference Semis: winner(1v8) vs winner(4v5), winner(2v7) vs winner(3v6). */ + const simR2 = ([w0, w1, w2, w3]: TeamEntry[]): { winners: TeamEntry[]; losers: TeamEntry[] } => { + const m1 = simSeries(w0, w1); + const m2 = simSeries(w2, w3); + return { winners: [m1.winner, m2.winner], losers: [m1.loser, m2.loser] }; + }; + + // 3. Integer placement count maps — initialized to 0 for all participants. + const championCounts = new Map(participantIds.map((id) => [id, 0])); + const finalistCounts = new Map(participantIds.map((id) => [id, 0])); + const confFinalLoserCounts = new Map(participantIds.map((id) => [id, 0])); + const confSemiLoserCounts = new Map(participantIds.map((id) => [id, 0])); + + // 4. Monte Carlo simulation loop. + for (let s = 0; s < NUM_SIMULATIONS; s++) { + // ── Build brackets ─────────────────────────────────────────────────────── + const eastBracket = buildConferenceBracket(easternTeams); + const westBracket = buildConferenceBracket(westernTeams); + + // ── Simulate rounds ────────────────────────────────────────────────────── + const { winners: eastR2Winners, losers: eastR2Losers } = simR2(simR1(eastBracket)); + const { winner: eastChamp, loser: eastCFLoser } = simSeries(eastR2Winners[0], eastR2Winners[1]); + + const { winners: westR2Winners, losers: westR2Losers } = simR2(simR1(westBracket)); + const { winner: westChamp, loser: westCFLoser } = simSeries(westR2Winners[0], westR2Winners[1]); + + const { winner: champion, loser: finalist } = simSeries(eastChamp, westChamp); + + // ── Record counts (maps are pre-populated so .get() is always defined) ─── + championCounts.set(champion.id, championCounts.get(champion.id)! + 1); + finalistCounts.set(finalist.id, finalistCounts.get(finalist.id)! + 1); + confFinalLoserCounts.set(eastCFLoser.id, confFinalLoserCounts.get(eastCFLoser.id)! + 1); + confFinalLoserCounts.set(westCFLoser.id, confFinalLoserCounts.get(westCFLoser.id)! + 1); + for (const loser of [...eastR2Losers, ...westR2Losers]) { + confSemiLoserCounts.set(loser.id, confSemiLoserCounts.get(loser.id)! + 1); + } + // Round 1 losers are not counted (0 points per scoring rules). + } + + // 5. Convert integer counts to probability distributions. + // Exact denominators guarantee column sums of 1.0 by construction: + // probFirst/Second → N total (1 per sim) + // probThird/Fourth → confFinalLoserCounts / (2*N) — 2 conf final losers per sim + // probFifth–Eighth → confSemiLoserCounts / (4*N) — 4 conf semi losers per sim + const N = NUM_SIMULATIONS; + const results: SimulationResult[] = participantIds.map((participantId) => { + const c = championCounts.get(participantId)!; + const f = finalistCounts.get(participantId)!; + const cf = confFinalLoserCounts.get(participantId)!; + const cs = confSemiLoserCounts.get(participantId)!; + return { + participantId, + probabilities: { + probFirst: c / N, + probSecond: f / N, + probThird: cf / (2 * N), + probFourth: cf / (2 * N), + probFifth: cs / (4 * N), + probSixth: cs / (4 * N), + probSeventh: cs / (4 * N), + probEighth: cs / (4 * N), + }, + source: "nba_bracket_monte_carlo", + }; + }); + + // 6. Per-position normalization — belt-and-suspenders guard against floating-point + // division residuals. Columns are already near-exactly 1.0 after step 5. + const positionKeys: Array = [ + "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; + } +} diff --git a/app/services/simulations/registry.ts b/app/services/simulations/registry.ts index 3c74abf..7b82f04 100644 --- a/app/services/simulations/registry.ts +++ b/app/services/simulations/registry.ts @@ -13,6 +13,7 @@ import { GolfSimulator } from "./golf-simulator"; import { UCLSimulator } from "./ucl-simulator"; import { NCAAMSimulator } from "./ncaam-simulator"; import { NCAAWSimulator } from "./ncaaw-simulator"; +import { NBASimulator } from "./nba-simulator"; export const SIMULATOR_TYPES = [ "f1_standings", @@ -22,6 +23,7 @@ export const SIMULATOR_TYPES = [ "ucl_bracket", "ncaam_bracket", "ncaaw_bracket", + "nba_bracket", ] as const; export type SimulatorType = typeof SIMULATOR_TYPES[number]; @@ -74,6 +76,10 @@ const REGISTRY: Record Simul info: { name: "NCAAW Bracket Monte Carlo", description: "Simulates the NCAA Women's Basketball Tournament 64-team bracket using Barttorvik Barthag ratings" }, create: () => new NCAAWSimulator(), }, + nba_bracket: { + info: { name: "NBA Playoff Monte Carlo", description: "Simulates NBA playoff seedings (via seed probabilities) and full bracket (best-of-7 series) using Elo ratings" }, + create: () => new NBASimulator(), + }, }; export function getSimulator(simulatorType: SimulatorType): Simulator { diff --git a/database/schema.ts b/database/schema.ts index 728f9ea..c40779a 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -84,6 +84,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [ "ucl_bracket", "ncaam_bracket", "ncaaw_bracket", + "nba_bracket", ]); export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [ diff --git a/drizzle/0046_add_nba_bracket_simulator_type.sql b/drizzle/0046_add_nba_bracket_simulator_type.sql new file mode 100644 index 0000000..046d8a4 --- /dev/null +++ b/drizzle/0046_add_nba_bracket_simulator_type.sql @@ -0,0 +1,9 @@ +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'nba_bracket' + AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'simulator_type') + ) THEN + ALTER TYPE "public"."simulator_type" ADD VALUE 'nba_bracket'; + END IF; +END $$;