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
Mirror tournament windows (leagues drafting the same real-world major)
announced Qualifying Points awards but silently omitted the "Knocked Out"
section. Knockouts are derived only on the primary window's bracket
(playoff_matches → newlyDecidedLoserIds); mirrors receive placement/rawScore
only, so a player eliminated in a non-scoring round (0 QP) becomes a
null-placement filler indistinguishable from "not yet played" — the mirror
has no local signal to detect the knockout, and its notification was also
gated on QP having changed.
Thread the primary's newly-eliminated participants down the fan-out
(syncTennisDraw → fanOutMajorIfPrimary → syncMajorFromPrimaryEvent →
syncTournamentResults → processQualifyingEvent), translating identity across
window boundaries (primary season_participant → canonical participant → each
mirror's season_participant), and relax the mirror notification guard to fire
on eliminations even when no QP changed — matching the primary path.
Reuses the same notifyQualifyingPointsUpdate the primary already calls, so
mirrors now produce the same combined "Points Awarded" + "Knocked Out" embed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.
Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass
Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
(gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
transaction.
Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
template-stripping so {{nowrap}}-wrapped players parse
Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
slots) with inline "rename existing" / "create as new" resolution via
fetcher (no full reload)
Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>