Commit graph

76 commits

Author SHA1 Message Date
Claude
d83f6976bf
Stop the simulator re-run from trampling its caller's side effects
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
A review of this branch found four problems, all downstream of one decision:
calling runSportsSeasonSimulation from inside the result path. That function
does three jobs — recompute probabilities, recalculate standings, write the
daily EV snapshot — and the result path wants only the first.

1. The Discord standings post was silently suppressed on every scored match,
   for all 13 bracket-aware sports.

   recalculateAffectedLeagues detects change by snapshotting teamStandings,
   recalculating, then diffing; changedTeamIds gates the notification. But
   processMatchResult runs updateProbabilitiesAfterResult first, which now
   reached the runner's own recalculateStandings. The new totals were therefore
   already written when the "before" snapshot was taken, the diff came back
   empty, and the post never fired. previousRank went the same way:
   recalculateStandings rolls it forward on every call, so the extra one erased
   rank movement.

   runSportsSeasonSimulation now takes skipStandingsRecalc / skipSnapshots and
   the probability updater passes both. The snapshot is skipped because it is a
   per-day series keyed by snapshotDate — writing it per match result just
   overwrites the day's row with intra-day values.

2. finalizeQualifyingPoints marks the season completed immediately before
   calling the updater, and the runner rejects a completed season outright. With
   the ICM fallback gone that failed every time, stranding anyone still in the
   unfinished set on permanently stale probabilities — reachable for
   cs2_major_qualifying_points, the one bracket-aware qualifying-points sport.

   A completed season is not this branch's case rather than a failure: every
   placement is final and the floor it protects can no longer be contradicted.
   shouldRerunSimulator now excludes it and it falls through to ICM as before.
   The genuine failure modes still leave probabilities alone rather than falling
   back to the path being replaced.

3. match-sync calls processMatchResult in a per-match loop with no
   skipSideEffects, so each synced match ran a full Monte Carlo plus EV rewrite,
   snapshot and standings recalc. processMatchResult gains skipProbabilities —
   mirroring the option processPlayoffEvent already takes, and narrower than
   skipSideEffects — which match-sync passes in the loop before refreshing once
   at the end. Per-match standings and Discord posts are unchanged.

4. A partially-seeded afl_10 bracket now throws from inside the result path.
   The throw is correct and stays; the concern was that it was silent, which the
   error surfacing in 2 covers.

Two claims from the review did not hold up and were left alone: the batch
bracket route already passes skipSideEffects per match and refreshes once after
the loop, and autoCompleteRoundIfDone already passes skipProbabilities, so there
is no double run per round completion.

Tests: the runner honors both skip flags and still writes EVs; the updater asks
for probabilities only; a completed season takes the ICM path without erroring;
processMatchResult skips the refresh but still announces. The two behavioral
ones were confirmed to fail against the previous behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
Claude
eefe407e7f
Route every bracket-aware sport away from ICM, not just AFL
The previous commit gated the simulator re-run on a `bracketAware` flag set only
on afl_bracket and llws_bracket, on the claim that every other simulator was
bracket-blind and would resurrect eliminated teams if re-run. That claim was
wrong. Eleven more read playoff_matches and honor isComplete/winnerId already:
ucl, ncaam, ncaaw (both via ncaa-basketball), nba, nhl, snooker, world_cup,
darts, cs2_major, college_hockey and nll. All thirteen are now flagged, so any
sport with a bracket the simulator can read absorbs a result by re-running that
simulator rather than through ICM.

Two simulators are deliberately left off. playoff_bracket and
ncaa_football_bracket declare a "bracket" setup section but never read
playoff_matches, so re-running them really would re-draw the field. That
mismatch runs the other way too — world_cup, darts_bracket and
cs2_major_qualifying_points read the bracket without declaring the section — so
setupSections is not a usable signal here and the flag stays separate from it,
with both facts written down on the flag.

The EV-source condition is also gone. It only asked whether the existing EVs
came from a simulation, which protected nothing: the alternative to re-running
was never leaving them alone, it was the ICM branch overwriting them anyway.
Given two overwrites, the bracket-aware one wins regardless of what wrote them.

Tests: a bracket-blind simulator still goes through ICM, futures-odds EVs no
longer divert a bracket-aware season away from the re-run, and the bracket-aware
set is pinned in the manifest test so a new simulator is an explicit decision
rather than a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:49:17 +00:00
Claude
cf817cc9ff
Make the AFL simulator read the bracket that was actually drawn
An AFL club seeded into an Elimination Final is awarded 15 fantasy points the
moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish
worse than the 7th-8th tier. Its EV still read 13.

AFLSimulator was stateless with respect to the live bracket. It read only
participants, sourceElo and the regular-season standings, then re-projected the
whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations.
So a team with a locked Elimination Final berth was re-drawn into the Wildcard
Round, or out of the finals entirely, in a slice of them — and there it scores 0.
Even with the ladder complete the Math.random() tiebreaker reshuffled every club
tied on ladder points, which in the AFL is most of the middle of the table.
Games already played were re-played the same way, so a completed Wildcard win was
worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what
produced 13.

afl_10 is the only template that defines entryFloor at all, which is why this
surfaced here and not on LLWS, whose floors only exist once a team has won
something.

The simulator now mirrors llws-simulator's bracket-aware mode:

  - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket
    writes them into. The two Elimination Final participant2 slots are TBD by
    design and are never read as seeds, leaving exactly 10 named slots. No draw
    at all falls back to the ladder projection; a partially seeded, duplicated or
    unknown draw throws rather than silently discarding the draw and every
    recorded result with it.
  - makePlayGame replays a completed match from its recorded result whenever both
    recorded teams are the two the simulation routed into that game, so an
    already-played result sticks across all iterations.
  - simAFLFinals labels each game with the round and match number
    generateAFL10Bracket and advanceAFLWinner use, so a result is looked up
    against the game it was played in. Its routing was already correct.

EV >= the banked floor now holds by construction, with no clamping: a team seeded
into an Elimination Final is in that game in every iteration. Column sums stay
exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams
outside the bracket simply score nothing.

Fixing the simulator alone would not have held. processMatchResult calls
updateProbabilitiesAfterResult on every result, and its ICM branch re-derives
each still-alive participant's whole distribution from P(1st) alone, knowing
nothing about the bracket — so the next finals result would have put the EV
straight back under the floor. That branch was built for futures-odds seasons.
It now runs only when the season's EVs did not come from a bracket-aware
simulator; when they did, that simulator is re-run instead, since it already
knows the completed matches. Both conditions matter: re-running a bracket-blind
simulator would re-draw the field and hand equity back to knocked-out teams, so
a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run
leaves probabilities untouched rather than falling back to the ICM path that is
being replaced.

The finalized-participant pinning loop now runs after that recalculation rather
than before. A simulation run rewrites every participant in the season, the
finalized ones included; a finalized placement is a fact, not a projection, so it
is written last and wins.

Tests: seeds clear the entry floors their seeding banked, a Qualifying Final
entrant is structurally absent from the 7th-8th tier, the bracket's draw beats
Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and
Qualifying Finals are replayed with the winner banking its floor, teams outside
the bracket are zeroed, and the column sums and 340 total survive. The bracket
fixtures deliberately seed the ten weakest clubs, because seeding the strongest
ten lets the ladder projection reproduce much the same field by accident. Six of
the seven were confirmed to fail against the previous behavior. Plus the
probability-updater branch in each direction, its failure path, and the pin
ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
Claude
7ec17e417d
Fix EV reporting 20 pts for both LLWS 5-6 and 7-8 locked tiers
After an LLWS simulation, a team locked into the 5th-6th tier and one
locked into the 7th-8th tier both showed 20 points EV. They should show
25 and 15.

The simulator and calculateEV were both right. A team locked into the
5-6 tier comes out of llws-simulator at probFifth = probSixth = 0.5, and
against DEFAULT_SCORING_RULES (100/70/50/40/25/25/15/15) that is 25 —
matching calculateBracketPoints, which already knows llws_20 splits 5-8
into two tiers. The Admin -> Expected Values page just wasn't using that
table. It hardcoded its own stale copy:

  const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;

0.5*20 + 0.5*20 = 20 for either tier.

It is not LLWS-specific. Four places carried that same stale table, and
it stayed invisible because a standard single-elimination bracket puts
all four quarterfinal losers in one tier worth avg(25,25,15,15) = 20 —
the same number. It only diverges for the templates that split 5-8
(llws_20, afl_10) and those with a distinct 3rd/4th (llws_20, fifa_48,
where 45/45 should be 50/40). Two of the four *persist* EVs computed
that way, so the wrong values reached the database:

  - expected-values.tsx      displayed EV, the total, and the sort order
  - expected-values.server   manual EV entry, written to expected_value
  - golf-skills.tsx          simulation EVs + snapshots, written
  - surface-elo.tsx          simulation EVs + snapshots, written

All four now use the shared DEFAULT_SCORING_RULES. probability-updater
had a fourth inline copy with the right values; it is folded in too so
there is one table left. The page's 340 total-EV invariant is unchanged
— both tables sum to 340.

A second path collapses the same two tiers, this time in real fantasy
points. calculateBracketPoints falls back to the flat avg([5,6,7,8])
when bracketTemplateId is null, and four call sites resolved the
template by taking an arbitrary scoringEvents row for the sports season
— unordered, and not filtered to rows that actually carry a template. A
season can own several events (a bracket plus schedule events, or a
re-created bracket beside a stale one), so a null row wins at random and
llws_20 is lost. New getBracketTemplateIdsForSportsSeasons in
models/bracket-template.ts filters to events with a template and takes
the most recent, the same rule llws-simulator uses to pick its bracket
event; standings, calculateTeamScore, calculateTeamProjectedScore and
getDraftedParticipantsWithPoints all go through it.

Tests: evFromProbs pinned to 25 / 15 / 20-for-a-single-5-8-tier and the
340 invariant; the new lookup against a mixed set of events; and two
llws-simulator tests that play out a full U.S. side so a team really is
locked into each tier and must come out at exactly 50/50 across it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
2026-08-27 16:11:59 +00:00
Claude
3fae78c521
Address review findings in LLWS simulator
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m48s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m20s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Four fixes from code review of the previous commit.

1. Preserve the futures board's dispersion (llws-simulator.ts).
   convertFuturesToElo finishes by rescaling any field onto a fixed
   1250-1750 Elo span, discarding how spread out the board actually is: a
   board with a 22%-priced favorite and one with a 6%-priced favorite both
   came out 500 Elo wide. On a tight board that inflated the favorite from
   6% to 13% -- worse than the raw-futures model it replaced (RMSE 0.025 vs
   0.005), so the previous commit was a regression in that regime.

   buildLLWSElos now maps decompressed strengths by their log-ratio to the
   field's geometric mean, so Elo span tracks real dispersion. Re-swept the
   parity factor across three board shapes rather than one: 550 minimizes
   total error. Simulated vs priced favorite, with RMSE:
     wide       21.8% -> 20.7%  (0.0055), elo span 1355-1682
     top-heavy  28.2% -> 25.6%  (0.0085), elo span 1354-1733
     tight       6.0% ->  5.8%  (0.0026), elo span 1484-1518

2. Rate an unpriced team at the field's median, not 1500.
   DEFAULT_ELO is the midpoint of the Elo output range, not of the field;
   on a typical board it ranked an unpriced team ~6th of 20, so blanking a
   team's odds promoted it. buildLLWSElos now returns the priced field's
   median alongside the ratings (ranks 11th of 20 on the same board).

3. Pick the bracket event deterministically.
   scoringEvents.findFirst with no ordering returned an arbitrary row when a
   season had more than one llws_20 playoff event; landing on a stale one
   silently reverted to a randomized draw that ignored all recorded results.
   Now takes the most recent, matching world-cup-simulator.ts.

4. Fail on a partially seeded bracket instead of discarding it.
   readBracketSlots returned null on any single missing participant, throwing
   away the draw and every completed result with no warning. Since
   playoff_matches participant columns are ON DELETE SET NULL, removing and
   re-adding one participant mid-tournament was enough to put eliminated
   teams back in contention. It now distinguishes "generated but not seeded"
   (0 slots filled -> randomized draw) from "partially seeded" (throws).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
2026-08-21 22:08:04 +00:00
Claude
d4df0b65fb
Make LLWS simulator bracket-aware and calibrate its futures model
The LLWS simulator was overestimating favorites and ignoring games that had
already been played. Two separate causes:

1. Championship futures were used directly as single-game strength
   (p1 / (p1 + p2)). A future already compounds the ~6 wins needed to take
   the title, so this made every individual game as lopsided as the whole
   tournament and re-compounded that edge round after round. Against a
   representative 20-team board the favorite priced at 21.8% simulated at
   44.9%, and the longest shot fell to ~0%.

   Futures are now decompressed to single-game Elo via convertFuturesToElo,
   the same pipeline the other bracket simulators use, and games are played
   with eloWinProbabilityWithParity. The parity factor was calibrated by
   sweeping it until a randomized-draw simulation reproduces the board it
   was fed: at 1000 the favorite simulates at 21.8% and field-wide RMSE
   drops from 0.062 to 0.003. It is overridable per season via config.

2. The simulator never read playoff_matches, so it re-ran the tournament
   from an empty bracket every time and shuffled the draw at random each
   iteration. A recorded loss changed nothing.

   It now loads the seeded llws_20 bracket, places teams in their real
   slots, and replays completed games from their recorded result instead of
   re-simulating them, so an eliminated team correctly drops to zero. When
   no bracket exists (or it has no participants seeded) it falls back to the
   previous randomized-draw behavior, and a seeded bracket is authoritative
   about which side a team is on, so externalId is only required on the
   pre-bracket path.

Guards: a recorded result is only honored when its two participants are the
ones the simulation routed into that game, so a corrupt or out-of-order row
cannot desynchronize the rest of the bracket; brackets seeding an unknown or
duplicated participant now fail loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5vQZMPeokzfMqHQjq1RDZ
2026-08-21 21:09:47 +00:00
8edb4293c5 claude/indycar-ev-sims-probability-fpzfx6 (#139)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m28s
🚀 Deploy / 🚀 Deploy (push) Successful in 25s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #139
2026-08-17 22:50:35 +00:00
Claude
1bd23a4419
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs two independent 10-team
double-elimination brackets — United States and International — each
producing a side champion, then a World Championship game and a
Consolation Third Place game between the side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.

Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.

The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.

Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.

Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.

Also:

- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
  hardcoded a 5th-place floor for winners of non-scoring rounds feeding
  a scoring one, which is wrong inside a losers bracket where a win can
  guarantee only 7th. Opt-in, so no existing template changes behavior.

- Fixes TabbedBracketLayout's mobile path, which built its match map
  unfiltered and so would have merged U.S. and International games into
  one column. No-op for NCAA and NBA, whose groups already cover every
  match in their phases.

- Rewrites the LLWS Monte Carlo simulator, which still modelled the
  retired pool-play format (5 teams per pool, then a 4-team bracket per
  side) and no longer described the tournament being scored. It now runs
  the real 10-team double elimination and splits the 5–8 probabilities
  into the correct tiers instead of one even four-way split. Legacy
  "US:A"/"Intl:B" externalIds are still accepted, read as the side
  alone, so seasons configured for the old format keep loading.

Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00
3e50619629 claude/probability-config-review-1tdolw (#119)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #119
2026-06-30 23:24:48 +00:00
120056b0bd claude/fix-lint-errors-n48594 (#111)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m57s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #111
2026-06-26 07:26:55 +00:00
ee099c64cd claude/clever-archimedes-dvye42 (#110)
Some checks failed
🚀 Deploy / 🧪 Test (push) Successful in 3m7s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Failing after 50s
🚀 Deploy / 🐳 Build (push) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Has been skipped
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #110
2026-06-26 05:16:54 +00:00
Claude
88248e349c
Fix futures odds being ignored when stale Elo exists
All checks were successful
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
When an admin entered futures (preseason) odds for a season that already
had Elo ratings stored, the simulator kept using the old Elo and silently
ignored the new odds. This affected any Elo-based simulator (e.g. NHL).

Root cause: resolveSourceElos() ranks a direct sourceElo above the
sourceOdds -> convertFuturesToElo branch, but batchSaveFuturesOddsForSimulator()
only cleared the bracket-seeding `rating`/`ratingMethod` — never the stale
`sourceElo`/`sourceEloMethod`. A manually entered Elo (method "direct") is not
treated as generated, so it survived and short-circuited the resolver.

Fix:
- batchSaveFuturesOddsForSimulator now also nulls sourceElo and strips
  sourceEloMethod (both the pre-update and upsert-conflict paths), so the
  existing futures -> Elo conversion drives the run.
- resolveSourceElos' sourceOdds branch now guards for >= 2 participants
  (mirroring resolveRatings), so a lone-odds season falls through to the
  configured missing-Elo strategy instead of getting a flat ~1500.
- batchSaveSourceOdds clears the legacy EV sourceElo and marks source as
  futures_odds so the elo-ratings page won't resurrect a stale rating.

Adds unit coverage for odds-derived Elo, the single-participant guard, the
post-clear regression, generated-vs-direct sourceElo suppression, and the
new clearing behavior in batchSaveFuturesOddsForSimulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNfUEd9RzD3zm84oLHBHUH
2026-06-25 17:43:31 +00:00
Chris Parsons
6772079e86 Reflect group stage in World Cup sim; seed R32 from real 2026 bracket
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
The World Cup Monte Carlo simulator ignored the admin-populated knockout
draw and re-paired its own simulated group output sequentially, so bracket
paths from the Round of 32 onward did not match the real tournament. It
also silently invented 12 synthetic groups when none were configured,
discarding real group-stage results.

Rework the simulator around three regimes:
- draw set     → simulate the real R32 matchups, advancing through the
  bracket tree via the canonical ceil(matchNumber/2) rule (matching
  advanceWinnerTemplate) so sim paths match the admin tooling.
- groups only  → simulate remaining group matches, then seed the R32 with
  the official 2026 group-position template + FIFA's exact Annex C
  best-third-place allocation (495-row published table).
- neither      → futures-seeded bracket fallback, flagged via result source.

Completed group and knockout matches still lock in real results at every
round.

New app/lib/fifa-2026-bracket.ts encodes the R32 group-position template
(official FIFA matches 73-88 mapped onto DB matchNumbers 1-16 so the tree
reproduces the real halves) and assignThirdPlaceSlots(). The Annex C table
is generated deterministically by scripts/gen-fifa-third-place.mjs into
app/lib/fifa-2026-third-place-allocation.ts.

Tests assert exact Annex C values, cross-check the template's eligible
groups against all 495 combinations, and cover each regime including the
partial-draw dedup and fully-drawn fast path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:15:45 -07:00
Chris Parsons
9480501932 Unify majors: score once, fan out across windows + tennis bracket EV
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m27s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m25s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
Make a "major" (golf/tennis/CS2) scored once on its canonical tournament
and fan out to every linked sports_season window and league.

Fan-out & completion (app/services/sync-tournament-results.ts):
- syncTournamentResults now marks each synced window's event complete
  (gated by markComplete), recalculates affected leagues, and counts
  recalc failures so a stale league can't hide behind a "completed" badge
- syncMajorFromPrimaryEvent promotes a primary window's derived results to
  canonical tournament_results (deleting rows for dropped placements) and
  fans out to siblings; fanOutMajorIfPrimary guards on the primary
- placement removals now propagate (stale rows reset to filler)

Primary-event model (scoring_events.is_primary, migration 0122):
- getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent /
  setPrimaryEvent; event creation auto-seeds a primary for bracket majors;
  "Make primary" button on the tournament page
- per-window event/bracket/cs2 pages are read-only for non-primary linked
  events (not-participating stays editable)

Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG):
- bracket-scored qualifying major via the existing bracket pipeline
- simulator conditions in-progress EV on the real bracket (honoring
  completed matches, walkover for withdrawals), QP derived from config,
  round structure read from the template; CS2 + tennis share resolveStructureSource

Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile
of existing majors (link orphans, designate primary, promote canonical, sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
Chris Parsons
dfbcb7d953 Add complete CS2 event reset to clear orphaned results/QP
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m41s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
The CS2 Major EV simulation silently discarded the Champions Stage
bracket for IEM Cologne 2026: the bracket seeded "Team Spirit" in QF1
while the stage assignments listed the (different) "Spirit Academy", so
the realBracket gate found an out-of-field participant and rejected the
whole bracket. The phantom "Spirit Academy" also left a stray
event_results row (33 rows for 32 teams).

The existing "Reset all" button only cleared cs2_major_stage_results, so
re-entering couldn't remove the phantom result or its cached QP total.

Add resetCs2Event, which also deletes the event's event_results and
recomputes the affected participants' cached QP totals — all inside a
single transaction so a mid-operation failure can't leave the event in a
torn state. It deliberately preserves the Champions Stage bracket so
re-entry realigns the stage data to it. The reset button now confirms
and states that results/QP are cleared too.

Tests: resetCs2Event clear+recompute behavior, and a simulator
regression pinning that one out-of-field QF participant disables the
whole bracket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:22:11 -07:00
472875f151 claude/exciting-ride-q1jq8k (#101)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m55s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m42s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 15s
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #101
2026-06-20 03:56:28 +00:00
dc94403160 claude/trusting-heisenberg-r6xnfi (#90)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m23s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m24s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #90
2026-06-15 03:34:44 +00:00
d0a31f3883 Fix CS2 Stage 3 QP slot assignment (#89)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m48s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 5m5s
🚀 Deploy / 🐳 Build (push) Successful in 4m4s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
## Summary

- 0-3 teams in Stage 3 were being assigned slots 9–10 (high QP) when marked eliminated before other Stage 3 teams, because `computeStage3ExitQP` filled from slot 9 upward using only the teams passed in
- 1-3 teams had the same problem when marked before 2-3 teams
- Fix: gate all Stage 3 QP computation on `stage3Exits.length === STAGE3_TOTAL_EXITS` (8) — partial saves write 0 QP as a placeholder, and correct QP/placements are assigned once all 8 exits are known
- As belt-and-suspenders, `computeStage3ExitQP` now places 0-wins teams from the bottom of the slot range so the function itself is correct even if called with a partial set

## Test plan

- [ ] Run `npm run test:run -- app/models/__tests__/cs2-major-stage.test.ts` — all 17 tests pass
- [ ] Mark 2 Stage 3 teams as 0-3 eliminated and save — confirm they show 0 QP (not 2 QP)
- [ ] Mark all 8 Stage 3 exits and save — confirm 0-3 teams get slots 15–16 QP, 1-3 teams get slots 12–14 QP split correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #89
2026-06-14 05:49:06 +00:00
Chris Parsons
a4ef8c3aa1 Fix remaining world-cup simulator test timeouts (100 → 20 iterations)
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m33s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Column-sum and structural assertions are mathematically exact regardless of
iteration count; 20 iterations runs the suite in ~270ms vs the 5s CI limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:47:24 -07:00
Chris Parsons
73f0256ff5 Fix QP standings: filter to drafted/points-earning participants with correct global ranks
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Failing after 2m33s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m20s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- Filter the QP table to show only participants with QP > 0 or drafted by any team in the league (undrafted 0-QP participants hidden)
- Compute global ranks with tie handling across the full field before filtering so displayed rank numbers and the top-8 Points Line remain correct after rows are removed
- Fix flaky CI timeouts: world-cup simulator test (100 → 50 iterations), SportsSection userEvent test (explicit 15s timeout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:38:47 -07:00
a528aecc86 Fix MLB simulator: projected wins drive seeding, perf fix, + surface simulate errors (#65)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m34s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
## Summary

- **MLB simulator seeding now responds to projected wins**: replaced hardcoded FanGraphs `p_div`/`p_wc` probability draws with Binomial regular-season simulation. Admin-entered projected wins (via Elo) now drive both playoff _qualification_ odds and in-bracket game win probability, not just the latter.
- **Reads current standings mid-season**: `getRegularSeasonStandings` is called so synced `wins`/`gamesPlayed` feed into each simulation iteration's projected final standings.
- **27× seeding performance improvement**: `sampleBinomial` now uses a Box-Muller normal approximation for `n ≥ 30`, dropping the seeding phase from ~243M to ~3M `Math.random()` calls per 50K-sim run (pre-season, 162 remaining games × 30 teams).
- **Missing-standings warning**: when standings exist for some teams but not all recognized ones (partial sync), a `logger.warn` now surfaces which teams are falling back to 0 wins / 162 remaining instead of silently distorting seeding.
- **Cleanup**: `eloToRDif` calls `rawWinRateFromElo` instead of inlining the same formula.
- **Simulate errors surface inline**: moved simulation action from dedicated route to parent page `intent="simulate"` so errors display inline rather than crashing to an error page.

## Test plan

- [ ] `npm run test:run -- mlb-simulator` — 49 tests pass
- [ ] `npm run typecheck` — clean
- [ ] Trigger a simulation from the admin panel for an MLB season with projected wins entered; confirm teams with higher projected wins show meaningfully higher EVs
- [ ] Confirm that running simulate with no projected wins entered (pre-season defaults) still produces a valid bracket
- [ ] Check logs after simulating a season where some teams lack standings rows — confirm warning appears

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #65
2026-06-01 20:50:03 +00:00
add196902b Fix MLB standings 406 error and refactor ESPN adapter shared code (#62)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m21s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m18s
🚀 Deploy / 🔍 Lint (push) Successful in 49s
🚀 Deploy / 🐳 Build (push) Successful in 12m18s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
## Summary

- **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS.
- **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters.
- **Bug fixes** found during review and applied across all affected adapters:
  - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard
  - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA
  - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0)
  - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison)
  - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally
  - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`)

## Test plan

- [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`)
- [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406
- [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #62
2026-06-01 03:31:18 +00:00
03726f1a66 Fix simulator correctness bugs and reduce test iteration counts (#61)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m27s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m18s
🚀 Deploy / 🔍 Lint (push) Successful in 47s
🚀 Deploy / 🐳 Build (push) Successful in 15m5s
🚀 Deploy / 🚀 Deploy (push) Successful in 32s
## Summary

- **Bug fix**: EPL Elo/odds fallback was gated on \`dbEloMap.size === 0\` — if any team had a direct \`sourceElo\`, teams with only \`sourceOdds\` were never populated and the simulator threw "Missing Elo" instead of using their odds data
- **Iterations**: WorldCupSimulator default 50k→10k; manifest \`defaultConfig\` for \`world_cup\` and \`epl_standings\` now explicitly override \`BASE_CONFIG\` with 10k so the production fallback matches; test iterations reduced (WorldCup 500→100, EPL 10k→500) to fix timeout failures on slower CI
- **Cleanup**: Extracted \`runKnockoutRound\` out of the 10k-iteration simulation loop; replaced two hot-loop \`toSorted()\` calls on 2-element arrays with a conditional string comparison; removed local \`normalizeProbabilities\` in favour of the shared \`normalizeSimulationResultColumns\`

## Test plan

- [ ] \`npm run test:run\` — all simulator unit tests pass without timeout
- [ ] Typecheck passes (\`npm run typecheck\`)
- [ ] EPL simulate with mixed Elo+odds data no longer throws

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #61
2026-05-31 17:39:43 +00:00
10f23839cb Remove redundant flaky world-cup-simulator test (#60)
Some checks failed
🚀 Deploy / 🧪 Test (push) Failing after 2m31s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m17s
🚀 Deploy / 🔍 Lint (push) Successful in 48s
🚀 Deploy / 🐳 Build (push) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Has been skipped
## Summary

- Removes the "probabilities are all non-negative" test from \`world-cup-simulator.test.ts\`, which was timing out intermittently in CI (5s budget, 500 Monte Carlo iterations × 48 teams)
- The test is redundant: the "column sums ≈1.0" test immediately above it already catches negative probabilities (a negative value in one column forces a sibling above 1.0, which \`toBeCloseTo(1.0, 2)\` would fail)
- Non-negative assertions for meaningful edge cases are also covered by the "SF losers land in 3rd or 4th" test

## Test plan

- [ ] \`npm run test:run\` passes with no failures

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #60
2026-05-31 17:07:59 +00:00
Chris Parsons
43dbdf0040
Add not-participating exclusion support for event results (#446)
* Add notParticipating flag to allow excluding withdrawn participants from qualifying-points simulators

Adds a `not_participating` boolean column to `event_results` so admins can
mark a participant as not competing in a specific upcoming major (e.g. Alcaraz
withdrawing from Wimbledon due to injury). The golf, tennis, and CS2 major
simulators now query this flag for incomplete events and exclude those
participants from the event's draw/field, redistributing probability weight
to the remaining field. Admin UI for qualifying major_tournament events gains
a "Not Participating" card to mark/unmark withdrawals before the event runs.

https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9

* Address code review feedback on not-participating flag

Security: unmark-not-participating now validates the result exists, belongs
to this event, and is actually a DNP row before deleting. mark-not-participating
now returns a user-friendly error on duplicate-key constraint violations.

Code quality: extract shared getExcludedByEventMap() utility to event-result
model, eliminating the duplicated 20-line exclusion-loading block that was
copy-pasted into all three simulators. Fix hasParticipantResult() to exclude
notParticipating rows so it correctly reflects actual competition participation.
Remove optional chaining on the non-optional notParticipatingIds field in the
admin UI. Fix misleading empty-state message.

Tests: replace the misleading first tennis DNP test (which never used the
activeIds variable it created) with a test that explicitly validates the
fallback behaviour when too few players remain after exclusion. Add three CS2
DNP tests covering the excluded-team-gets-zero-QP path, the redistribution
of wins, and per-event pool independence.

https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9

* Fix lint errors: replace non-null assertions in CS2 DNP test

https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 11:13:32 -07:00
Chris Parsons
1f0fcf970b
Fix QP tie rounding and finalized scoring (#440) 2026-05-17 21:58:53 -07:00
Chris Parsons
766ba948e1
Add MLS standings sync and fix simulator conference resolution (#423)
* Add MLS standings sync and fix simulator conference resolution

- New MlsStandingsAdapter using ESPN's free soccer API (no key required),
  returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference
  rank, and home/away records; registered under mls_bracket
- Display route now shows soccer table columns and a 9-team playoff cutoff
  line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs)
- MLS simulator gains a third conference fallback: reads externalId="Eastern"
  or "Western" on the participant, mirroring the LLWS pool-assignment pattern
  so admins can bootstrap conference data before the first standings sync
- 9 unit tests covering stat mapping, conference normalization, rank ordering,
  and error paths

https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u

* Address code review feedback on MLS standings + simulator

1. Update mls-simulator.ts module comment to document the new step-3
   externalId fallback in the conference resolution order
2. Tighten normalizeConference to exact-match "Eastern"/"Western
   Conference" instead of broad substring, preventing false matches
   on names like "Northeast"
3. Pre-parse statsMap for each entry before the sort so it isn't
   rebuilt O(n log n) times during comparison
4. Document the externalId name-matching tradeoff on
   parseConferenceFromExternalId
5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties
   sum; export parseConferenceFromExternalId for direct testing
6. Add tests: normalizeConference passthrough, winPct=0 at preseason,
   and parseConferenceFromExternalId (case-insensitivity, null/undefined,
   numeric ESPN IDs, unrecognized strings)

https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u

* Fix lint: replace != null with !== null && !== undefined

oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged.

https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 15:39:40 -07:00
Chris Parsons
c097e4827c
Add MLS sport simulator (mls_bracket) (#422)
* Add MLS sport simulator (mls_bracket)

Adds Major League Soccer as a draftable sport with a full-season Monte Carlo
simulator. Models both preseason regular-season projection (34 games across
Eastern and Western conferences) and the MLS Cup Playoffs bracket, including
the Wild Card (single game + PKs), Round 1 best-of-3 series, Conference
Semis/Finals (single game), and MLS Cup.

P1–P8 mapping: MLS Cup winner, finalist, Conference Finals losers, Conference
Semifinals losers. Conference assignment reads from regularSeasonStandings.conference
or falls back to the region simulator input ("Eastern"/"Western").

Admin inputs: projectedTablePoints (primary, max 102 for 34×3), with derivation
chain to sourceElo via existing input-policy; sourceOdds as alternative.
No hardcoded team data — all inputs are admin-managed per season.

- database/schema.ts: add mls_bracket to simulatorTypeEnum
- drizzle/0104_chief_boom_boom.sql: migration for the new enum value
- mls-simulator.ts: MLSSimulator + exported pure helpers for testability
- registry.ts / manifest.ts / simulator-config.ts: register mls_bracket
- mls-simulator.test.ts: 38 unit tests covering all helpers and sync checks

https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa

* Fix MLS simulator: config loading, sourceOdds fallback, normalization

Three issues found in code review comparing against EPL and NFL simulators:

1. Load simulator config from DB via getSportsSeasonSimulatorConfig so
   admins can override iterations, seasonGames, parityFactor, drawRates
   per season without code changes. Previously all constants were hardcoded.

2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is
   present. EPL and NFL both do this; MLS was throwing immediately instead
   of attempting the odds conversion that the manifest declares as optional.

3. Call normalizeSimulationResultColumns before returning results, matching
   EPL's local normalization pattern for consistency (runner also normalizes
   globally, but EPL calls it locally too).

https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa

* Polish MLS simulator: configNumber zero, logger warning, Map lookup

Three small fixes from secondary code review:

1. configNumber: allow value >= 0 (not just > 0) so admins can
   legitimately set baseDrawRate or drawDecay to 0 without the value
   being silently discarded and replaced with the default.

2. Add logger.warn when a participant is excluded from simulation due
   to a missing Elo rating, matching the NLL bracket-aware pattern.
   Gives admins a visible signal instead of a silent exclusion.

3. getBySeeds: build a Map once instead of calling Array.find() per
   seed. Eliminates 4M linear scans across 50k iterations for a 9-
   element array — trivially fast either way, but Map is the right tool.

https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 12:45:53 -07:00
Chris Parsons
06339fd4b6
Fix EPL simulator parity config (#421) 2026-05-13 15:02:23 -07:00
Chris Parsons
af64a29cfa
Fix NCAAW futures odds simulation and admin import UX (#420)
- Revert ncaaw-simulator to Barthag win probability formula; set
  realistic rating bounds (ratingMin: 0.70, ratingMax: 0.97) so derived
  ratings stay in the range where the formula behaves well
- Add batchSaveFuturesOddsForSimulator which clears all ratings (manual
  and generated) before upserting sourceOdds, so futures odds always
  drive the simulation rather than being silently overridden by existing
  Barthag ratings from Simulator Setup
- Add clearSourceOddsForParticipants to zero out both tables for
  participants excluded from a bulk import
- Add "Clear existing odds" checkbox to the bulk import card; applies
  client-side on match and server-side on submit
- Fix missing sportsSeasonId filter in batchSaveFuturesOddsForSimulator
  pre-clear UPDATE (could have wiped ratings across other seasons)
- Fix race condition: run batchSaveSourceOdds then
  batchSaveFuturesOddsForSimulator sequentially so the simulator inputs
  table always ends in the correct cleared state
- Fix Math.round in convertFuturesToElo collapsing Barthag-scale ratings
  to 0 or 1; Elo callers already round after clamping
- Handle all-identical-odds edge case in convertFuturesToElo (assign
  midpoint instead of throwing)
- Add missingRatingStrategy: worstKnownMinus to ncaaw_bracket manifest
  so fallbackRatingDelta is live config, not dead
- Log warning in resolveRatings when only 1 participant has odds
- Reset clearExisting checkbox after applyMatches

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:06:16 -07:00
Chris Parsons
c88ae6b745
Upgrade NCAA preseason simulators (#419) 2026-05-12 22:26:25 -07:00
Chris Parsons
ce0ed4f485
Hide completed seasons in admin tools (#418) 2026-05-12 16:52:26 -07:00
Chris Parsons
5b261bf258
Add NLL box lacrosse simulator implementation plan and preseason-draftability guidance (#417)
* docs: plan NLL preseason simulator

* Add NLL Season + Playoffs Monte Carlo simulator

14-team regular season (18 games) projects top-8 via Elo + decaying
projectedWins prior (adjusts for games already played). Playoff bracket:
QF single-game (1v8, 2v7, 3v6, 4v5), SF and Finals best-of-3. Three
runtime modes: bracket-aware → known-seed → regular-season projection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:22:34 -07:00
Chris Parsons
686069aa0f
Fix circular dependency and client-bundle server-only code in simulator routes (#410)
NCAAM and NCAAW simulators imported getParticipantSimulatorInputs from
models/simulator, which imports manifest.ts, which imports registry.ts —
creating a cycle back through registry.ts → simulator files. This caused
a TDZ ReferenceError in the client bundle.

Fix: inline a direct seasonParticipantSimulatorInputs query in both
simulators (they already run direct DB queries) instead of going through
the model layer, breaking the cycle.

Also moves getSimulatorInputPolicy from the component body to the loader
on the simulator setup page. The component calling it pulled input-policy.ts
→ manifest.ts → registry.ts → all simulator implementations into the client
bundle, which contain Node.js-only code and caused "Error loading route
module" in production.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:24:29 -07:00
Chris Parsons
e5295812f6
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.

Key additions:
- manifest.ts: per-simulator display names, default configs, required/
  optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
  projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
  supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
  derived inputs, normalises result columns, zeroes omitted participants,
  snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
  summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
  falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
  only copied when explicitly requested

Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
  participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
  readiness failure, empty results, and error recovery with status reset

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
Chris Parsons
79092f0409
Fix college hockey bracket resolution (#396) 2026-05-08 12:29:39 -07:00
Chris Parsons
caaffda236
Deduplicate soccer simulator helpers (#391) 2026-05-07 11:48:57 -07:00
Chris Parsons
5b03b22267
Add Brackt as a league-private meta-sport (#200) (#377)
Commissioners can add Brackt to any league. Each team drafts one manager
from their own league; at season end, that manager's final overall fantasy
ranking earns placement points. Resolution fires automatically when all
other sports finalize.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:31:44 -07:00
Chris Parsons
ef7e098d68
Add sports season ↔ tournament linking with admin UI (#372)
Adds a sports_season_tournaments junction table and bidirectional
link/unlink UI on both the sports season and tournament admin pages.

- New junction table with unique index on (sports_season_id, tournament_id)
  and a separate index on tournament_id for reverse lookups
- New model with link/unlink/query functions and cross-sport validation
- Migration includes backfill from existing scoring_events tournament links
- Admin sports season page: Tournaments card with add/remove UI
- Admin tournament page: Linked Sports Seasons card with add/remove UI
- Inline success/error feedback, empty states, aria-labels on remove buttons
- cloneSportsSeason now copies tournament links
- Fixes darts simulator test timeout (15s for 128-player pre-bracket sim)
2026-05-02 12:45:53 -07:00
Chris Parsons
2848231235
Canonical tournament layer: schema + backfill (1/2) (#365)
* refactor(schema): rename per-window tables to season_* prefix

Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: rename participant.ts model file to season-participant.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(models): update model layer to use renamed schema exports

Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant

Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts

Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route layer to use renamed schema exports

- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file

Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts

Error count reduced from 499 to 453 (46 errors fixed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route files for schema rename

Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(services): update simulators and services for renamed schema

Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId

Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts

Typecheck errors reduced from 365 to 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: rename per-window tables to season_* prefix

* fix(tests): update mock query keys after participants table rename

Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.

Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): update remaining mock paths and keys after schema rename

* fix(tests): final two mock stragglers after schema rename

- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: add post-phase1a baseline capture (temp, for diff verification)

* chore: capture pre-migration baselines

* chore: remove post-phase1a capture helper after verification

* schema: add canonical tournament & participant tables

Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(models): add canonical tournament, participant, result, surface-elo models

Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).

Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
  createCanonicalParticipant, etc.) to avoid collision with existing
  season-participant.ts exports
- All four models include comprehensive unit tests following the
  audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints

Part of Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: create canonical tables, add nullable FKs

* scripts: add extractTournamentIdentity helper for backfill

Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.

* scripts: add backfill orchestrator for canonical layer

Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.

Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
  (recorded in report.errors) rather than overwriting

* scripts: add backfill CLI with dry-run default

Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).

* fix(backfill-cli): wrap runBackfill in DatabaseContext.run

The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backfill-cli): exit 0 on success so pg pool doesn't block

The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:13:18 -07:00
Chris Parsons
ef0ddeff39
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support

NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.

MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators

All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.

NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.

NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.

WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests

- NHLSimulator.simulateBracket: remove unused nameById variable
- NHLSimulator.simulateBracket: validate all R1 participants before entering
  Monte Carlo loop; throws with a clear message if any slot is missing
- wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter
  on resolveElo() (overrides SRS, overrides futures, overrides fallback,
  null/undefined fall through to normal priority chain)
- nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests)
  covering fallback to season-projection, structure validation errors, probability
  distributions, fully-decided champion, and source tag

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts

TeamEntry requires resolvedElo after the simulator refactor.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Fix lint: replace non-null assertions with null-safe alternatives in NHL test

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
Chris Parsons
7fa88fc0ef
Add projected-wins input mode to admin Elo Ratings page (#306)
* Add projected-wins input mode to admin Elo Ratings page

Admins can now enter projected season win totals instead of raw Elo
numbers on the Elo Ratings page. Wins are auto-converted to Elo using
the inverse formula and stored as sourceElo, keeping the rest of the
simulation pipeline unchanged.

- Add `projectedWinsToElo` / `eloToProjectedWins` to probability-engine
- Add `simulator-config.ts` centralising per-sport season length, parity
  factor, and average opponent Elo (AFL, NFL, NBA, NHL, MLB, WNBA)
- Admin Elo Ratings page: toggle between Elo and Projected Wins input
  modes; bulk import parses wins format; existing sourceElo back-fills
  the wins field on load
- AFL simulator now reads sourceElo from participantExpectedValues first,
  falling back to hardcoded TEAMS_DATA then 1400

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix lint errors in simulator-config tests and probability-engine

- Replace non-null assertions (`!`) with optional chaining (`?.`) in simulator-config tests
- Remove redundant type annotations on default parameters in probability-engine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 12:40:08 -07:00
Chris Parsons
66b0235678
fix: NBA play-in bracket advancement bugs (#292)
* fix: NBA play-in advancement writes E7/W7 to wrong participant slot

advanceNBAPlayInWinner was writing the PIR1 7v8 winner to participant1Id
of First Round M3/M7, but those slots already hold E2/W2 (seeded at
generation time). The "already filled" guard fired, silently swallowing
both the winner advancement and the loser's placement into Play-In
Round 2 — so the 7/8 loser never appeared in the second play-in game.

Fix: write the 7v8 winner to participant2Id (the correct null slot) and
update the guard, doc comment, and load-bearing comment accordingly.
Also swap the p2Override arguments in the simulator and test fixture to
match the corrected slot layout (FR M3: E2=p1, E7=p2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: hasGroupStage check for templates without groupStage property

undefined !== null is true, so templates that omit groupStage (like
nba_20) were incorrectly treated as group-stage templates, triggering
the generate-groups action and the "Invalid template or template has
no group stage" error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 22:25:32 -07:00
Chris Parsons
040c97137b
feat: NBA playoff bracket template and bracket-aware simulator (#291)
Adds the NBA_20 bracket template (20-team structure with play-in
tournament) and rewrites the NBA simulator to be bracket-aware like UCL.

- Add NBA_20 bracket template: PIR1 (4) → PIR2 (2) → First Round (8)
  → Conference Semifinals → Conference Finals → NBA Finals
- Add participantLabels (East/West 1–10) for admin bracket UI slot labels
- Add generateNBA20Bracket and advanceNBAPlayInWinner in playoff-match.ts
  with custom play-in advancement logic (PIR1 winners go to two different
  destinations)
- Rewrite NBASimulator with two auto-detected modes:
  - Bracket-aware (preferred): reads actual bracket state, simulates only
    remaining rounds forward using 50k Monte Carlo iterations
  - Season-projection (fallback): existing logic for pre-playoff use
- Guard resolveGame/resolveSeries against null participants (throws with
  match ID and slot info instead of silently using empty string)
- Document load-bearing First Round match ordering in generateNBA20Bracket
- Add 25 unit tests covering Elo math, team data, template structure,
  and all simulator integration paths

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:51:53 -07:00
Chris Parsons
68b2e070e6
fix: infer LLWS bracket side from name when externalId is null (#285)
Participants are created with externalId=null by default, causing the
LLWS simulator to crash at runtime. Fix in two parts:

1. Name-prefix inference: if externalId is null, participants whose
   name starts with "US " or equals "US" are assigned to the US side;
   all others are assigned to Intl. Pools are always randomized when
   inferred (no pool suffix).

2. Admin UI: add an inline-editable "Group" column to the Manage
   Participants page so admins can explicitly set externalId for any
   simulator that uses it (LLWS, and future cases like NHL conferences).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 09:51:16 -04:00
Chris Parsons
a6bb1330e6
Fix darts simulator: bracket bug, ELO calibration, fallback Elo (#284)
* fix: lower darts ELO_DIVISOR to 400 and fallback Elo to 1400

ELO_DIVISOR 500 → 400: elite darts is more skill-dominated than the
previous setting implied. A 400-point gap now gives ~78% per-set win
probability (vs ~73% before).

fallbackElo 1600 → 1400: unseeded PDC World Championship entrants
(regional qualifiers, Q-School players) are materially weaker than
seeded tour professionals. 1400 better reflects that gap.

Combined effect: a dominant player (e.g. 2000 Elo vs 1400-1600 field)
now produces EV ≈ 65 instead of ≈ 30, which better matches expectations
for a top-ranked player in a 128-player bracket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: lower darts ELO_DIVISOR to 200 for realistic EV distribution

With the actual PDC field (top players clustered 1800–1970 Elo, Littler at
~2080), ELO_DIVISOR=400 made the gap between world #1 and the top 8 too
soft — EV for Littler came out ~37 instead of the expected 65–70.

ELO_DIVISOR=200 calibrates correctly for this field: a 100-pt gap now
gives ~62% per-set win probability (vs ~56% at 400), sharply rewarding
the best players while still allowing upsets. Littler at 2084 produces
EV ≈ 65–70 against the real PDC tour roster.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: seed darts bracket by Elo instead of world ranking

World ranking (PDC Order of Merit) is prize-money-based and can diverge
from current skill — e.g. Kevin Doets (1818 Elo) was ranked 35th while
Joe Cullen (1667 Elo) held the 32nd seed. Seeding the weaker player and
leaving the stronger one unseeded contradicts the Elo-based match model.

Seeding by Elo descending ensures the 32 strongest players (by the same
metric used to compute match probabilities) get protected bracket positions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: restore world-ranking-based seeding for darts bracket

Seeding should follow the PDC Order of Merit (world ranking), not Elo.
Reverts the previous Elo-seeding commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: interleave seeded and unseeded R1 pairs in darts bracket

Previously, all 32 seeded-vs-unseeded matches were added to r1Pairs
first (indices 0–31) and all 32 unseeded-vs-unseeded matches last
(indices 32–63). Since R2 pairs adjacent R1 winners, this created two
completely separate sub-brackets that only converged at the Final:
  - Seeded sub-bracket: top players eliminating each other early
  - Unseeded sub-bracket: high-Elo unseeded players (e.g. Doets 1818,
    Zonneveld 1806) steamrolling weak opponents and making the Final
    ~20% of the time

Fix: interleave each seeded match with its adjacent unseeded-vs-unseeded
match. Seeded pair i is at r1Pairs[2i], unseeded pair i is at r1Pairs[2i+1].
From R2 onwards, winners from seeded and unseeded regions now merge
correctly as in a real single-elimination bracket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:47:47 -04:00
Chris Parsons
9e5d1cda5f
Fix NHL simulator seeding bugs and code quality issues (#281)
* Fix NHL simulator dynamic seeding and code quality issues

- Replace hardcoded seeding probabilities with standings-based Monte Carlo
  projection: simulates remaining regular season games per team using Elo
  win probability vs. a league-average opponent, so mathematically eliminated
  teams naturally fall out of playoff contention
- Fix double-simulation bug in wildcard pool: all 16 conference teams are now
  projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)),
  then filtered — wildcard pool no longer re-runs simulateProjectedPoints with
  fresh randomness independent of the division seeding
- Move ProjectedTeam interface, projectTeam(), and sortProjected() to module
  scope (defined once, not recreated on every buildConferenceBracket call)
- Replace conference-level participant count check (east < 8 || west < 8) with
  per-division checks (each division < 3) for a more actionable error message
- Add logger.warn when standings.length === 0 so admins know the sim is running
  without synced data
- Remove unused easternTeams/westernTeams variables
- Simplify test: replace Object.fromEntries pattern with a plain for..of loop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix oxlint errors in NHL simulator and tests

- Replace non-null assertions (data!) with optional chaining (data?.x ?? "")
- Move makeEntry helper to module scope to avoid recreating on every call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
Chris Parsons
0da80bd2c0
Add LLWS bracket Monte Carlo simulator (#280)
* Add LLWS bracket Monte Carlo simulator

Simulates the 20-team Little League World Series: pool play round-robin
(5 teams/pool, top 2 advance) → 4-team double-elimination per side
(US and International) → consolation game → World Series final.

Uses championship futures odds as the sole win-probability signal.
Pool assignments are auto-detected from externalId: bare "US"/"Intl"
randomizes pools each simulation (pre-draw mode); "US:A"/"Intl:B" etc.
uses fixed assignments (post-draw mode). Sides can differ.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix oxlint violations in LLWS simulator

- Replace non-null assertions with null-safe access (! → ?? / optional chaining)
- Replace .sort() with .toSorted() per linting rules
- Promote bump() to simulate() scope and pass it into simulateSideBracket,
  removing the need to pass counts into that function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:52:10 -04:00
Chris Parsons
4d5fea05ab
Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124

* Add NCAA Football CFP simulator (12-team bracket)

Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).

- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
  in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
  Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
  probability distributions, seeding, edge cases, futures blending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix lint errors in NCAA Football CFP simulator

Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix TS2345 in NCAA football simulator test

Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:48:32 -04:00
Chris Parsons
ad49d6246d
Fix CS Major simulator bugs and accuracy issues (#276)
* Fix CS Major simulator bugs and accuracy issues (fixes #275)

- Fix crash when pool teams are missing Elo ratings by including all
  participants with FALLBACK_ELO, ensuring Champions Stage always gets
  exactly 8 teams
- Add AdvancedTeam type tracking losses-at-advancement; seed Champions
  Stage by stage performance (fewer losses = higher seed) instead of
  world rank alone
- Promote decisive Stage 2 matches (either team at ≥2W or ≥2L) to Bo3,
  matching the real Challengers Stage format
- Tie-split QP for QF losers (slots 5–8) and SF losers (slots 3–4)
  instead of assigning arbitrary individual placements
- Change stage-complete threshold from === 8 to >= 8 for robustness
- Validate even team count in simulateSwiss to prevent silent infinite loops
- Short-circuit Monte Carlo loop when all events are complete, returning
  deterministic 0/1 probabilities
- Parallelize stage results DB fetches with Promise.all
- Export calcStage3ExitQP and simulateOneMajor; add test coverage for
  both, plus new simulateSwiss and simulateChampionsStage edge cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix oxlint violations in CS Major simulator

- Replace non-null assertion (!) with null-safe guard in simulateOneMajor
- Replace non-null assertions in test expectations with nullish coalescing
- Move makeStage3QPConfig out of describe block (no captured variables)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:28:24 -04:00