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

18 KiB
Raw Blame History

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

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:

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

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:

// 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:

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:

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:

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():

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:

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