# 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<Map<string, number>>(
  () => 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 |
