# Brackt as a Draftable Sport — Design Reference ## What It Is "Brackt" is an optional meta-sport commissioners can add to any league. Each team drafts exactly one **manager** from their own league. At season end, that manager's final overall fantasy ranking (1st–8th across all other sports) determines how many points their drafter earns. Resolution is commissioner-triggered once all other sports finalize. Self-drafting is allowed and valid strategy. --- ## Schema ### `sportsSeasons` — new column ```typescript fantasySeasonId: uuid("fantasy_season_id").references(() => seasons.id) ``` When set, this sports season was auto-generated for a specific fantasy season. It is **league-private** — never shared across leagues, never shown in the sports picker or the league creation wizard. ### `simulatorTypeEnum` Add `"brackt"` to the existing enum. ### `auditActionEnum` Add `"brackt_resolved"` to the existing enum. **Migration**: `npm run db:generate && npm run db:migrate`. The new column defaults to `null` — backward-compatible. --- ## Admin One-Time Setup An admin creates a single **global Brackt sport** and a **template sports season** via the existing admin UI. These are created once and reused across all leagues. **Sport** (`/admin/sports/new`): - Name: `Brackt`, Slug: `brackt`, SimulatorType: `brackt` **Template sports season** (`/admin/sports-seasons/new`): - Sport: Brackt, Year: 2026, Status: `upcoming` - `draftOn`: `1970-01-01`, `draftOff`: `2099-12-31` — never expires - `fantasySeasonId`: null — this is the template, not a league copy - No participants — the template is just a selectable placeholder The template appears in the league creation wizard's sport picker. Per-league copies (see below) are excluded from it via `isNull(fantasySeasonId)`. --- ## Data Architecture ### Per-League Brackt Season When a league includes Brackt, `createBracktSportsSeasonForLeague()` runs during league creation: 1. Inserts a new `sportsSeasons` row with `fantasySeasonId = season.id` 2. Replaces the template's `seasonSports` link with this new per-league copy 3. Bulk-inserts `N` `seasonParticipants` rows — one per team: - `name`: team name - `externalId`: team ID ← **critical**: used for robust team→participant matching at resolution; immune to name changes - `expectedValue`: `"0"`, `vorpValue`: `"0"` (will be updated by Harville immediately after) 4. Calls `runBracktHarvilleForFantasySeason()` immediately so initial EVs are uniform (~27.5 for 8 teams) rather than 0 **Use `db.insert()` directly** — do not use `createParticipant()`, which auto-creates canonical participant rows. Brackt participants are manager-participants with no canonical counterpart. ### `seasonParticipants.vorpValue` is the source of truth for autopick `getTopAvailableParticipant()` sorts all undrafted participants across all eligible sports by `vorpValue` DESC. Brackt participants must have correct, up-to-date `vorpValue` so they rank appropriately relative to other sport participants. This is achieved by running the Harville simulation after every pick. --- ## EV Pipeline — Critical Architecture Rule > **All EV calculations must use a single `db` instance threaded through the entire call chain. Never call `database()` inside model functions that are reachable from the timer context.** ### Why `database()` uses `AsyncLocalStorage`. It is scoped to HTTP request handlers. The draft timer (`server/timer.ts`) fires outside any HTTP request — calling `database()` there throws `"DatabaseContext not set"`. This breaks the EV pipeline in the timer-triggered autopick path. ### The Rule Every function in the EV pipeline must accept a `db` parameter and pass it through: ``` runBracktHarvilleForFantasySeason(seasonId, db) → BracktSimulator.simulate(bracktSportsSeasonId, db) → batchUpsertParticipantEVs(inputs, db) → syncVorpForSeason(sportsSeasonId, db) ``` No function in this chain may call `database()` directly. They receive `db` from the caller. The timer passes `db` from `server/db.ts` (the global singleton). HTTP request handlers pass `db` from `database()`. Both are valid Drizzle instances connected to the same Postgres database. ### Where to Run the Harville Run `runBracktHarvilleForFantasySeason(seasonId, db)` in **three places**: 1. **`executeAutoPick`** — awaited, before participant selection. This ensures autopick sees current VORPs. 2. **`make-pick.ts` (manual pick handler)** — fire-and-forget after the pick commits. Updates EVs for all clients after a human picks. 3. **`createBracktSportsSeasonForLeague`** — awaited at league creation. Seeds uniform initial EVs. After running Harville, emit the updated EVs to all draft room clients via socket: ```typescript getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { evs: result.evs, // { participantId, expectedValue }[] }); ``` --- ## BracktSimulator Algorithm **File**: `app/services/simulations/brackt-simulator.ts` ``` simulate(bracktSportsSeasonId, db): 1. Look up bracktSportsSeason → get fantasySeasonId 2. Get all seasonSports for this fantasy season; separate brackt vs non-brackt 3. Get all bracktParticipants (externalId = teamId) 4. Get all non-brackt draftPicks for this fantasy season with their participant.expectedValue 5. For each non-brackt sport, compute averageAvailableEV: - mean expectedValue of undrafted participants - fallback 0 if no EVs set 6. For each team: projectedTotal = sum(EV of drafted non-brackt picks) + sum(averageAvailableEV for each non-brackt sport not yet picked) 7. weights = max(0, projectedTotal) for each team 8. placementMatrix = computeHarville(weights, 8) 9. Map back to bracktParticipants → SimulationResult[] ``` **Edge case**: All weights = 0 (pre-draft) → Harville produces uniform 1/N per placement. This is correct — uniform EVs → uniform VORPs → Brackt picks are not disproportionately valued early in the draft. **Registration**: Add `"brackt"` to `SIMULATOR_TYPES` in `app/services/simulations/registry.ts` and map it to `BracktSimulator`. --- ## `runBracktHarvilleForFantasySeason` **File**: `app/lib/brackt-ev-updater.server.ts` ```typescript export type BracktEvUpdate = { participantId: string; expectedValue: number; vorpValue: number; }; export async function runBracktHarvilleForFantasySeason( fantasySeasonId: string, db: DbInstance // required — never optional ): Promise<{ updated: false } | { updated: true; evs: BracktEvUpdate[] }> ``` Steps: 1. Find the Brackt sports season via `sportsSeasons WHERE fantasySeasonId = ? AND sport.slug = 'brackt'` 2. Call `simulator.simulate(bracktSportsSeasonId, db)` 3. If results empty, return `{ updated: false }` 4. Call `batchUpsertParticipantEVs(inputs, db)` — writes to `seasonParticipantExpectedValues` and triggers `syncVorpForSeason(db)` which writes to `seasonParticipants.vorpValue` 5. Re-read the updated `vorpValue` for all Brackt participants from DB (one query on `seasonParticipants` filtered by `sportsSeasonId`) 6. Compute `evs[]`: `expectedValue` from probabilities × scoring tiers; `vorpValue` from the DB read in step 5 7. Return `{ updated: true, evs }` Reading `vorpValue` from DB after `syncVorpForSeason` ensures the value is exactly what the server stored — no approximation, no client-side re-derivation. --- ## Client-Side Display (`$leagueId.draft.$seasonId.tsx`) The client never computes VORP. The server is the sole source of truth — `syncVorpForSeason` writes `vorpValue` to `seasonParticipants` after every Harville run, and the socket pushes those values to all clients immediately. ### Socket Payload `brackt-evs-updated` must include both `expectedValue` and `vorpValue` so clients can sort correctly in real time without waiting for a revalidation: ```typescript // Server emits (after runBracktHarvilleForFantasySeason): "brackt-evs-updated": (data: { evs: { participantId: string; expectedValue: number; vorpValue: number }[] }) => void; ``` `runBracktHarvilleForFantasySeason` should read the updated `vorpValue` back from the DB after `batchUpsertParticipantEVs` completes (since `syncVorpForSeason` writes it) and include it in the returned `evs`. ### Initial Load Seed `serverBracktVorps` from loader data: ```typescript const [serverBracktVorps, setServerBracktVorps] = useState>( () => new Map( availableParticipants .filter((p) => p.sportsSeasonId === bracktSportsSeasonId) .map((p) => [p.id, parseFloat(p.vorpValue ?? "0")]) ) ); ``` ### Socket Updates Listen for `brackt-evs-updated` and patch the VORP map: ```typescript useEffect(() => { const handler = (data: { evs: { participantId: string; vorpValue: number }[] }) => { setServerBracktVorps((prev) => { const next = new Map(prev); for (const { participantId, vorpValue } of data.evs) { next.set(participantId, vorpValue); } return next; }); }; on("brackt-evs-updated", handler); return () => off("brackt-evs-updated", handler); }, [on, off]); ``` ### Sorting All participants sort by `vorpValue` — server-computed for every sport including Brackt: ```typescript const sortedAvailableParticipants = useMemo(() => { const effectiveVorp = (p) => { if (p.sportsSeasonId === bracktSportsSeasonId) { return serverBracktVorps.get(p.id) ?? parseFloat(p.vorpValue ?? "0"); } return parseFloat(p.vorpValue ?? "0"); }; return availableParticipants.toSorted((a, b) => { const diff = effectiveVorp(b) - effectiveVorp(a); return diff !== 0 ? diff : a.name.localeCompare(b.name); }); }, [availableParticipants, bracktSportsSeasonId, serverBracktVorps]); ``` `serverBracktVorps` overrides stale loader values whenever the server pushes an update. When no socket update has arrived yet, the loader's `vorpValue` is used as the fallback — both reflect the same server-computed value. --- ## League Creation Flow (`app/routes/leagues/new.tsx`) ### Sports Selection Step Brackt appears in the picker like any other sport. When selected, show a callout: > "Each team drafts one manager from your league. At season end, their total fantasy points (across all other sports) earns you placement points — 1st through 8th. You can draft yourself!" ### Action After `createSeason()` + `createManyTeams()`: ```typescript const bracktTemplateId = await findBracktTemplateIdInSelection(selectedSportsSeasonIds, bracktSport.id); if (bracktTemplateId) { // Remove template from the regular sport linking; brackt-setup handles it const otherSportsSeasonIds = selectedSportsSeasonIds.filter(id => id !== bracktTemplateId); await linkMultipleSportsToSeason(season.id, otherSportsSeasonIds); await createBracktSportsSeasonForLeague(bracktTemplateId, season.id, leagueName, year, createdTeams); } else { await linkMultipleSportsToSeason(season.id, selectedSportsSeasonIds); } ``` ### `findDraftableSportsSeasons()` filter In `app/models/sports-season.ts`, add `isNull(ss.fantasySeasonId)` to exclude per-league Brackt copies from appearing in the picker. --- ## League Settings — Team Changes (`$leagueId.settings.tsx`) Whenever teams are added or removed, call: ```typescript await syncBracktParticipants(season.id); ``` `syncBracktParticipants` guards against deleting participants that have already been drafted. If a team is removed after the draft has started, only undrafted Brackt participants are deleted; drafted ones remain. --- ## Draft Room Considerations - **Self-drafting**: Explicitly allowed. No restriction needed. - **Eligibility**: `calculateDraftEligibility()` already enforces one pick per sport per team. Brackt is treated as any other sport — one required slot. No changes needed. - **Display**: Brackt participants show with the Brackt sport icon and manager name. The existing participant card handles this without changes. - **Draft queue**: Auto-pick respects the queue. If a team queues a Brackt participant, it will be picked from the queue like any other queued player. The VORP-based fallback applies only when the queue is empty. --- ## Autopick Ordering `getTopAvailableParticipant()` sorts all undrafted participants from all eligible sports by `vorpValue DESC`. For this to work correctly with Brackt: - `vorpValue` on Brackt `seasonParticipants` must be populated and current at pick time - `runBracktHarvilleForFantasySeason()` must be awaited inside `executeAutoPick()` **before** calling `autoPickForTeam()` - The `db` instance passed to `runBracktHarvilleForFantasySeason()` must be the same instance used by all downstream model functions (`batchUpsertParticipantEVs`, `syncVorpForSeason`) In the timer path, `db` is `server/db.ts`'s singleton. In the HTTP path, `db` is `database()`. Both are fine as long as they're threaded through — not bypassed with a fresh `database()` call inside model functions. --- ## Season Resolution ### Readiness Check — `isBracktResolvable(seasonId)` Returns `{ ready: boolean; pendingSports: string[] }`. A sport is "pending" if: - Its `sportsSeasons.status !== "completed"`, OR - Any of its participants has `seasonParticipantResults.isPartialScore = true` ### Commissioner UI — `BracktResolutionCard` Show on the standings page when: - `isBracktIncluded(seasonId)` is true - Brackt `sportsSeasons.status !== "completed"` Shows current projected rankings (from last Harville run). If ready → "Finalize Brackt" button. If not → "Waiting for: [NBA Playoffs, ...]". ### Resolution — `resolveBrackt(seasonId, actorUserId)` 1. Read `teamStandings.totalPoints` for all teams (this is purely non-Brackt points since Brackt isn't scored yet) 2. Sort by `totalPoints DESC`; tiebreak: 1st-place counts → 2nd → etc. 3. For each Brackt participant (matched via `externalId = teamId`): upsert `seasonParticipantResults` with `finalPosition = rank, isPartialScore = false` 4. Mark Brackt `sportsSeasons.status = "completed"` 5. Call `recalculateAffectedLeagues(bracktSportsSeasonId)` — applies Brackt placement points to team totals 6. Log to `commissionerAuditLog` with action `"brackt_resolved"` **No circular dependency**: resolution reads `totalPoints` before Brackt is scored, so Brackt points don't feed into their own resolution. --- ## Admin Sports-Seasons Listing Add a filter toggle to hide rows where `fantasySeasonId IS NOT NULL`. Per-league Brackt seasons are internal and should not clutter the admin listing by default. --- ## Rules & How to Play Both files are hardcoded React components. **`app/routes/rules.tsx`** — New "Brackt — Draft a Manager" section: - Draft exactly one manager from your league (self-drafting allowed) - Points based on final overall ranking across all other sports (1st = 100 pts … 8th = 15 pts) - Commissioner triggers Brackt resolution after all other sports conclude - One required Brackt round is added to every league that includes it **`app/routes/how-to-play.tsx`** — New "The Meta Game: Brackt" section: - Strategic angle: pick a manager who will perform well overall - Draft timing matters — strong early drafters show higher projected EV - Revealed at season end after all other sports finalize - You are a valid pick --- ## Gotchas | Risk | Mitigation | |---|---| | `database()` unavailable in timer context | Pass `db` through the entire EV pipeline; never call `database()` inside model functions | | `createParticipant()` auto-creates canonical participant rows | Use `db.insert(schema.seasonParticipants)` directly with `participantId: null` | | Per-league Brackt seasons leak into sports picker | `isNull(fantasySeasonId)` filter in `findDraftableSportsSeasons()` | | Admin listings cluttered with per-league Brackt seasons | Filter toggle in `/admin/sports-seasons` | | `BracktSimulator` reads stale EVs (all-zero pre-draft) | Harville produces uniform distribution from zero weights — valid behavior | | Brackt VORP starts at 0 before any Harville run | Call `runBracktHarvilleForFantasySeason()` immediately after `createBracktSportsSeasonForLeague()` | | `syncVorpForSeason` uses replacement level for 12-14 players; Brackt has 8 | `calculateReplacementLevel` clamps to `Math.min(idx, length - 1)`, so it uses position 8 (last) for all three. VORP = EV − minEV. This is correct. | | Resolution uses `totalPoints` which could include prior Brackt points (if re-running) | Guard: if Brackt is already resolved, require explicit override; otherwise this is correct since Brackt points are 0 until resolution | | Team removed after draft — Brackt participant already drafted | `syncBracktParticipants` checks `pickedIds` and only removes undrafted participants | | Participant-to-team matching at resolution breaks if team renamed | Store `team.id` in `externalId` at creation; match by `externalId` at resolution | | `recalculateAffectedLeagues` after resolution fires Discord notification | Acceptable — it announces Brackt final scoring | | `isSeasonComplete()` returns false until Brackt resolved | Intentional — commissioner finalizing Brackt is the explicit season-close action | --- ## File Map | File | Purpose | |---|---| | `database/schema.ts` | `fantasySeasonId` on `sportsSeasons`, enum additions | | `app/lib/brackt-constants.ts` | `BRACKT_SPORT_SLUG = "brackt"` | | `app/lib/brackt-setup.server.ts` | All Brackt lifecycle model functions | | `app/lib/brackt-ev-updater.server.ts` | `runBracktHarvilleForFantasySeason()` — EV pipeline entry point | | `app/services/simulations/brackt-simulator.ts` | `BracktSimulator` — Harville projection | | `app/services/simulations/registry.ts` | Register `"brackt"` simulator type | | `app/models/sports-season.ts` | `isNull(fantasySeasonId)` filter in `findDraftableSportsSeasons()` | | `app/models/draft-utils.ts` | `runBracktHarvilleForFantasySeason()` in `executeAutoPick()`; Harville + socket emit in autodraft chain | | `app/routes/api/draft.make-pick.ts` | Fire-and-forget Harville + `brackt-evs-updated` emit after manual pick | | `server/socket.ts` | `brackt-evs-updated` in `ServerToClientEvents` | | `app/routes/leagues/new.tsx` | Auto-create per-league Brackt season; callout in sport picker | | `app/routes/leagues/$leagueId.settings.tsx` | `syncBracktParticipants()` on team count changes | | `app/routes/leagues/$leagueId.standings.$seasonId.tsx` | `BracktResolutionCard` for commissioner | | `app/routes/rules.tsx` | Brackt rules section | | `app/routes/how-to-play.tsx` | Brackt how-to section | | `app/routes/admin/sports-seasons.tsx` | Filter toggle for per-league copies |