brackt/docs/agents/brackt-sport.md

399 lines
18 KiB
Markdown
Raw Normal View History

2026-05-04 09:33:13 -07:00
# 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 (1st8th 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 |