brackt/docs/agents/brackt-sport.md
2026-05-04 09:33:13 -07:00

398 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |