brackt/app/models/bracket-template.ts

67 lines
2.8 KiB
TypeScript
Raw Normal View History

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
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { and, desc, inArray, isNotNull } from "drizzle-orm";
/**
* Resolve which bracket template a sports season's placements should be scored against.
*
* A sports season can own several scoring events a bracket plus schedule events, or a
* re-created bracket alongside a stale one and only some of them carry a
* bracketTemplateId. Picking an arbitrary row is not harmless: calculateBracketPoints
* falls back to the standard single 5th8th tier when the template id is null, which
* silently collapses the two-tier templates (llws_20, afl_10) so a team locked into
* 5th6th and one locked into 7th8th both score the flat 58 average. The 3rd/4th
* distinction that llws_20 and fifa_48 have goes the same way.
*
* So: only events that actually carry a template are considered, most recent first
* matching the "a re-created event wins over a stale one" rule the LLWS simulator uses
* when it picks its bracket event.
*
* Every requested season gets an entry, null when it has no bracket event, so callers
* can cache the negative result too.
*/
export async function getBracketTemplateIdsForSportsSeasons(
sportsSeasonIds: string[],
providedDb?: ReturnType<typeof database>
): Promise<Map<string, string | null>> {
const resolved = new Map<string, string | null>(
sportsSeasonIds.map((id) => [id, null])
);
if (sportsSeasonIds.length === 0) return resolved;
const db = providedDb || database();
const events = await db.query.scoringEvents.findMany({
where: and(
inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds),
isNotNull(schema.scoringEvents.bracketTemplateId)
),
columns: { sportsSeasonId: true, bracketTemplateId: true },
// createdAt can tie when a bracket is generated in the same transaction as a
// sibling event, so id breaks the tie and keeps the choice deterministic.
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
});
for (const event of events) {
// Ordered newest-first, so the first row seen for a season is the one to keep.
// The isNotNull filter means bracketTemplateId is set, but a mocked or partial row
// could still carry null — skip those rather than caching a null as a real answer.
if (resolved.get(event.sportsSeasonId) === null && event.bracketTemplateId) {
resolved.set(event.sportsSeasonId, event.bracketTemplateId);
}
}
return resolved;
}
/**
* Single-season form of getBracketTemplateIdsForSportsSeasons.
*/
export async function getBracketTemplateIdForSportsSeason(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<string | null> {
const resolved = await getBracketTemplateIdsForSportsSeasons([sportsSeasonId], providedDb);
return resolved.get(sportsSeasonId) ?? null;
}