Merge pull request 'llws ev' (#144) from claude/llws-ev-calculation-bug-30xpxg into main
This commit is contained in:
commit
1acef25027
19 changed files with 951 additions and 118 deletions
|
|
@ -108,6 +108,8 @@ export function NbaBracketLayout({
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
116
app/components/scoring/__tests__/NbaBracketLayout.test.tsx
Normal file
116
app/components/scoring/__tests__/NbaBracketLayout.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, within } from "@testing-library/react";
|
||||||
|
import { NbaBracketLayout } from "../NbaBracketLayout";
|
||||||
|
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||||
|
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||||
|
import type { BracketMatch } from "../BracketTreeView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NbaBracketLayout renders a desktop view and a mobile pager side by side, hidden from
|
||||||
|
* each other by Tailwind breakpoints. Both need `feeders` and `template` — without them
|
||||||
|
* bracketGeometry falls back to index-derived positions and an unplayed slot reads "TBD"
|
||||||
|
* where the feeder graph would name the game it is waiting on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEMPLATE: BracketTemplate = {
|
||||||
|
id: "test_conf_4",
|
||||||
|
name: "Two-conference test bracket",
|
||||||
|
totalTeams: 4,
|
||||||
|
scoringStartsAtRound: "Final",
|
||||||
|
rounds: [
|
||||||
|
{ name: "Semis", matchCount: 2, feedsInto: "Final", isScoring: false },
|
||||||
|
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true },
|
||||||
|
],
|
||||||
|
conferenceGroups: [
|
||||||
|
{ name: "East", roundMatchNumbers: { Semis: [1] } },
|
||||||
|
{ name: "West", roundMatchNumbers: { Semis: [2] } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROUNDS = ["Semis", "Final"];
|
||||||
|
|
||||||
|
function match(
|
||||||
|
round: string,
|
||||||
|
matchNumber: number,
|
||||||
|
overrides: Partial<BracketMatch> = {}
|
||||||
|
): BracketMatch {
|
||||||
|
return {
|
||||||
|
id: `${round}-${matchNumber}`,
|
||||||
|
round,
|
||||||
|
matchNumber,
|
||||||
|
participant1Id: null,
|
||||||
|
participant2Id: null,
|
||||||
|
winnerId: null,
|
||||||
|
loserId: null,
|
||||||
|
isComplete: false,
|
||||||
|
participant1Score: null,
|
||||||
|
participant2Score: null,
|
||||||
|
...overrides,
|
||||||
|
} as BracketMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Semis are played; the Final's two slots are still empty. */
|
||||||
|
const MATCHES_BY_ROUND = new Map<string, BracketMatch[]>([
|
||||||
|
[
|
||||||
|
"Semis",
|
||||||
|
[
|
||||||
|
match("Semis", 1, { participant1Id: "p1", participant2Id: "p2" }),
|
||||||
|
match("Semis", 2, { participant1Id: "p3", participant2Id: "p4" }),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
["Final", [match("Final", 1)]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function renderLayout(withGraph: boolean) {
|
||||||
|
const { container } = render(
|
||||||
|
<NbaBracketLayout
|
||||||
|
matches={[...MATCHES_BY_ROUND.values()].flat()}
|
||||||
|
rounds={ROUNDS}
|
||||||
|
matchesByRound={MATCHES_BY_ROUND}
|
||||||
|
ownershipMap={new Map()}
|
||||||
|
userParticipantIds={new Set()}
|
||||||
|
conferenceGroups={TEMPLATE.conferenceGroups ?? []}
|
||||||
|
scoringRoundIdx={1}
|
||||||
|
feeders={withGraph ? buildFeederMap(TEMPLATE) : undefined}
|
||||||
|
template={withGraph ? TEMPLATE : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both panes render in jsdom — media queries are class-based, not applied — so scope
|
||||||
|
// each assertion to the pane it is about.
|
||||||
|
const mobile = container.querySelector<HTMLElement>(".md\\:hidden");
|
||||||
|
const desktop = container.querySelector<HTMLElement>(".md\\:flex");
|
||||||
|
if (!mobile || !desktop) throw new Error("Expected both a mobile and a desktop pane");
|
||||||
|
return { mobile, desktop };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NbaBracketLayout", () => {
|
||||||
|
it("names the feeding game in the mobile pager", () => {
|
||||||
|
// Only slots filled by advancement get a label; a directly seeded slot with no
|
||||||
|
// participant still reads "TBD", which is why this asserts on the Final's slots.
|
||||||
|
const { mobile } = renderLayout(true);
|
||||||
|
|
||||||
|
expect(within(mobile).getAllByText(/Winner of/).length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the mobile pager the same slot labels as the desktop view", () => {
|
||||||
|
const { mobile, desktop } = renderLayout(true);
|
||||||
|
|
||||||
|
const labels = (pane: HTMLElement) =>
|
||||||
|
within(pane)
|
||||||
|
.getAllByText(/Winner of/)
|
||||||
|
.map((el) => el.textContent)
|
||||||
|
.toSorted();
|
||||||
|
|
||||||
|
expect(labels(mobile)).toEqual(labels(desktop));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to TBD when the feeder graph is unavailable", () => {
|
||||||
|
// Guards the assertions above: without feeders/template there is nothing to name a
|
||||||
|
// slot with, which is exactly the state the mobile pane was stuck in.
|
||||||
|
const { mobile } = renderLayout(false);
|
||||||
|
|
||||||
|
expect(within(mobile).queryByText(/Winner of/)).toBeNull();
|
||||||
|
expect(within(mobile).getAllByText("TBD").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
app/models/__tests__/bracket-template-lookup.test.ts
Normal file
92
app/models/__tests__/bracket-template-lookup.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBracketTemplateIdForSportsSeason,
|
||||||
|
getBracketTemplateIdsForSportsSeasons,
|
||||||
|
} from "../bracket-template";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sports season can own several scoring events — a bracket plus schedule events, or a
|
||||||
|
* re-created bracket alongside a stale one. Resolving the template from an arbitrary row
|
||||||
|
* is not harmless: calculateBracketPoints falls back to the flat 5th–8th average when the
|
||||||
|
* template id is null, so losing "llws_20" makes a team locked into 5th–6th and one
|
||||||
|
* locked into 7th–8th both score 20.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal db stub. Applies the same filter and ordering the real query does, so the
|
||||||
|
* assertions exercise the helper's row-picking rather than re-stating the query.
|
||||||
|
*/
|
||||||
|
function makeDb(
|
||||||
|
rows: Array<{ sportsSeasonId: string; bracketTemplateId: string | null; createdAt: Date }>
|
||||||
|
) {
|
||||||
|
const findMany = vi.fn(async () =>
|
||||||
|
rows
|
||||||
|
.filter((row) => row.bracketTemplateId !== null)
|
||||||
|
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||||
|
);
|
||||||
|
return { db: { query: { scoringEvents: { findMany } } } as any, findMany };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getBracketTemplateIdForSportsSeason", () => {
|
||||||
|
it("ignores a non-bracket event and returns the bracket event's template", async () => {
|
||||||
|
const { db } = makeDb([
|
||||||
|
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
||||||
|
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-07-01") },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the most recent bracket event when a stale one is still around", async () => {
|
||||||
|
const { db } = makeDb([
|
||||||
|
{ sportsSeasonId: "ss1", bracketTemplateId: "simple_16", createdAt: new Date("2026-06-01") },
|
||||||
|
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the season has no bracket event", async () => {
|
||||||
|
const { db } = makeDb([
|
||||||
|
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBracketTemplateIdsForSportsSeasons", () => {
|
||||||
|
it("resolves each season independently in one query", async () => {
|
||||||
|
const { db, findMany } = makeDb([
|
||||||
|
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
||||||
|
{ sportsSeasonId: "ss2", bracketTemplateId: "afl_10", createdAt: new Date("2026-08-02") },
|
||||||
|
{ sportsSeasonId: "ss3", bracketTemplateId: null, createdAt: new Date("2026-08-03") },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2", "ss3"], db);
|
||||||
|
|
||||||
|
expect(resolved.get("ss1")).toBe("llws_20");
|
||||||
|
expect(resolved.get("ss2")).toBe("afl_10");
|
||||||
|
expect(resolved.get("ss3")).toBeNull();
|
||||||
|
expect(findMany).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every requested season an entry so callers can cache the miss", async () => {
|
||||||
|
const { db } = makeDb([]);
|
||||||
|
|
||||||
|
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2"], db);
|
||||||
|
|
||||||
|
expect([...resolved.entries()]).toEqual([
|
||||||
|
["ss1", null],
|
||||||
|
["ss2", null],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not query at all for an empty season list", async () => {
|
||||||
|
const { db, findMany } = makeDb([]);
|
||||||
|
|
||||||
|
await expect(getBracketTemplateIdsForSportsSeasons([], db)).resolves.toEqual(new Map());
|
||||||
|
expect(findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -80,6 +80,9 @@ function makeDb(
|
||||||
},
|
},
|
||||||
scoringEvents: {
|
scoringEvents: {
|
||||||
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
||||||
|
// getBracketTemplateIdsForSportsSeasons filters to events that carry a
|
||||||
|
// template, so "no bracket template" is an empty result, not a null row.
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
seasonParticipantResults: {
|
seasonParticipantResults: {
|
||||||
findMany: vi.fn().mockResolvedValue(seasonResults),
|
findMany: vi.fn().mockResolvedValue(seasonResults),
|
||||||
|
|
|
||||||
66
app/models/bracket-template.ts
Normal file
66
app/models/bracket-template.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
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 5th–8th tier when the template id is null, which
|
||||||
|
* silently collapses the two-tier templates (llws_20, afl_10) so a team locked into
|
||||||
|
* 5th–6th and one locked into 7th–8th both score the flat 5–8 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;
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
calculateBracketPoints,
|
calculateBracketPoints,
|
||||||
calculateSharedPlacementPoints,
|
calculateSharedPlacementPoints,
|
||||||
} from "./scoring-rules";
|
} from "./scoring-rules";
|
||||||
|
import { getBracketTemplateIdsForSportsSeasons } from "./bracket-template";
|
||||||
|
|
||||||
export async function createDraftPick(data: {
|
export async function createDraftPick(data: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
|
|
@ -175,18 +176,10 @@ export async function getDraftedParticipantsWithPoints(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch-fetch bracket template IDs (one per sports season)
|
// Batch-fetch bracket template IDs (one per sports season)
|
||||||
const bracketTemplateMap = new Map<string, string | null>();
|
const bracketTemplateMap =
|
||||||
if (bracketSeasonIds.size > 0) {
|
bracketSeasonIds.size > 0
|
||||||
const events = await db.query.scoringEvents.findMany({
|
? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
|
||||||
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
|
: new Map<string, string | null>();
|
||||||
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
|
||||||
});
|
|
||||||
for (const ev of events) {
|
|
||||||
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
|
|
||||||
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch-fetch QP totals for qualifying_points participants
|
// Batch-fetch QP totals for qualifying_points participants
|
||||||
const qpMap = new Map<string, number>(); // participantId → totalQP
|
const qpMap = new Map<string, number>(); // participantId → totalQP
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
|
@ -104,6 +104,33 @@ export async function deleteParticipantResultsBySportsSeasonId(
|
||||||
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the results of specific participants within one sports season.
|
||||||
|
*
|
||||||
|
* The season-wide delete above is too blunt for a single bracket: results are keyed by
|
||||||
|
* sports season, not by event, so wiping the season takes every other event's placements
|
||||||
|
* with it. Scoping to the participants a bracket actually holds lets reprocess-bracket
|
||||||
|
* rebuild that bracket from scratch while leaving the rest of the season alone.
|
||||||
|
*
|
||||||
|
* No-ops on an empty id list — `inArray` with no values is not a valid SQL predicate.
|
||||||
|
*/
|
||||||
|
export async function deleteParticipantResultsForParticipants(
|
||||||
|
sportsSeasonId: string,
|
||||||
|
participantIds: string[],
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
): Promise<void> {
|
||||||
|
if (participantIds.length === 0) return;
|
||||||
|
const db = providedDb || database();
|
||||||
|
await db
|
||||||
|
.delete(schema.seasonParticipantResults)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
||||||
|
inArray(schema.seasonParticipantResults.participantId, participantIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set result for a participant in a sports season
|
* Set result for a participant in a sports season
|
||||||
* Points are calculated on-demand based on each fantasy league's scoring rules
|
* Points are calculated on-demand based on each fantasy league's scoring rules
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-
|
||||||
import { getUserDisplayName } from "~/models/user";
|
import { getUserDisplayName } from "~/models/user";
|
||||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
|
import { getBracketTemplateIdForSportsSeason } from "~/models/bracket-template";
|
||||||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getEventResults } from "./event-result";
|
import { getEventResults } from "./event-result";
|
||||||
|
|
@ -1465,11 +1466,7 @@ export async function calculateTeamScore(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
}
|
}
|
||||||
const event = await db.query.scoringEvents.findFirst({
|
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
||||||
columns: { bracketTemplateId: true },
|
|
||||||
});
|
|
||||||
const templateId = event?.bracketTemplateId ?? null;
|
|
||||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
@ -1578,11 +1575,7 @@ export async function calculateTeamProjectedScore(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
}
|
}
|
||||||
const event = await db.query.scoringEvents.findFirst({
|
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
||||||
columns: { bracketTemplateId: true },
|
|
||||||
});
|
|
||||||
const templateId = event?.bracketTemplateId ?? null;
|
|
||||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from
|
||||||
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getParticipantEV } from "./participant-expected-value";
|
import { getParticipantEV } from "./participant-expected-value";
|
||||||
|
import { getBracketTemplateIdForSportsSeason } from "./bracket-template";
|
||||||
import { calculateEV } from "~/services/ev-calculator";
|
import { calculateEV } from "~/services/ev-calculator";
|
||||||
|
|
||||||
// Re-export types from shared types file
|
// Re-export types from shared types file
|
||||||
|
|
@ -163,11 +164,7 @@ export async function getTeamScoreBreakdown(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
}
|
}
|
||||||
const event = await db.query.scoringEvents.findFirst({
|
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
||||||
columns: { bracketTemplateId: true },
|
|
||||||
});
|
|
||||||
const templateId = event?.bracketTemplateId ?? null;
|
|
||||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Expected Values admin page renders EV from the stored probability columns.
|
||||||
|
*
|
||||||
|
* It used to carry its own hardcoded scoring table (100/70/45/45/20/20/20/20), which
|
||||||
|
* flattened positions 5–8 to 20 points each. For a standard single-elimination bracket
|
||||||
|
* that was invisible — all four quarterfinal losers share one tier worth
|
||||||
|
* avg(25,25,15,15) = 20 anyway — but for the templates that split 5–8 into two tiers
|
||||||
|
* (llws_20, afl_10) it reported a team locked into 5th–6th and a team locked into
|
||||||
|
* 7th–8th as the same 20 points. These pin it to the shared DEFAULT_SCORING_RULES.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock("../admin.sports-seasons.$id.expected-values.server", () => ({
|
||||||
|
loader: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { evFromProbs } from "../admin.sports-seasons.$id.expected-values";
|
||||||
|
|
||||||
|
const ZERO = {
|
||||||
|
probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0",
|
||||||
|
probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("evFromProbs", () => {
|
||||||
|
it("gives a team locked into the 5th–6th tier 25 points, not 20", () => {
|
||||||
|
expect(evFromProbs({ ...ZERO, probFifth: "0.5", probSixth: "0.5" })).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a team locked into the 7th–8th tier 15 points, not 20", () => {
|
||||||
|
expect(evFromProbs({ ...ZERO, probSeventh: "0.5", probEighth: "0.5" })).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still gives a single 5th–8th tier (4 QF losers) 20 points", () => {
|
||||||
|
const ev = evFromProbs({
|
||||||
|
...ZERO,
|
||||||
|
probFifth: "0.25", probSixth: "0.25", probSeventh: "0.25", probEighth: "0.25",
|
||||||
|
});
|
||||||
|
expect(ev).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 3rd and 4th distinct rather than a flat 45 each", () => {
|
||||||
|
expect(evFromProbs({ ...ZERO, probThird: "1" })).toBe(50);
|
||||||
|
expect(evFromProbs({ ...ZERO, probFourth: "1" })).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the 340 total-EV invariant across a full set of unit columns", () => {
|
||||||
|
const perPosition = [
|
||||||
|
evFromProbs({ ...ZERO, probFirst: "1" }),
|
||||||
|
evFromProbs({ ...ZERO, probSecond: "1" }),
|
||||||
|
evFromProbs({ ...ZERO, probThird: "1" }),
|
||||||
|
evFromProbs({ ...ZERO, probFourth: "1" }),
|
||||||
|
evFromProbs({ ...ZERO, probFifth: "1" }),
|
||||||
|
evFromProbs({ ...ZERO, probSixth: "1" }),
|
||||||
|
evFromProbs({ ...ZERO, probSeventh: "1" }),
|
||||||
|
evFromProbs({ ...ZERO, probEighth: "1" }),
|
||||||
|
];
|
||||||
|
expect(perPosition.reduce((sum, ev) => sum + ev, 0)).toBe(340);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for a participant with no probability mass", () => {
|
||||||
|
expect(evFromProbs(ZERO)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
/**
|
||||||
|
* generate-bracket banks the floors a seeding guarantees before anyone plays (an AFL
|
||||||
|
* top-4 seed cannot finish below the 5th-6th tier). Those floors only reach
|
||||||
|
* teamStandings.totalPoints through a standings recalculation, so the action has to be
|
||||||
|
* sure one ran — markEliminatedAndAnnounce runs one for its Discord announcement in some
|
||||||
|
* cases but not others.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { generateBracketFromTemplate } from "~/models/playoff-match";
|
||||||
|
import {
|
||||||
|
findParticipantResultsBySportsSeasonId,
|
||||||
|
setParticipantResult,
|
||||||
|
} from "~/models/participant-result";
|
||||||
|
import {
|
||||||
|
applyBracketEntryFloors,
|
||||||
|
recalculateAffectedLeagues,
|
||||||
|
} from "~/models/scoring-calculator";
|
||||||
|
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
||||||
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||||
|
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||||
|
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
getScoringEventById: vi.fn(),
|
||||||
|
updateScoringEvent: vi.fn(),
|
||||||
|
isReadOnlySibling: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
generateBracketFromTemplate: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
findParticipantResultsBySportsSeasonId: vi.fn(),
|
||||||
|
setParticipantResult: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
applyBracketEntryFloors: vi.fn(),
|
||||||
|
recalculateAffectedLeagues: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
findParticipantsBySportsSeasonId: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const params = { id: "season-1", eventId: "event-1" };
|
||||||
|
|
||||||
|
const EVENT = {
|
||||||
|
id: "event-1",
|
||||||
|
name: "AFL Finals",
|
||||||
|
sportsSeasonId: "season-1",
|
||||||
|
isQualifyingEvent: false,
|
||||||
|
bracketTemplateId: "afl_10",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** afl_10 takes exactly 10 seeded participants. */
|
||||||
|
const SEEDED = Array.from({ length: 10 }, (_, i) => `seed-${i + 1}`);
|
||||||
|
|
||||||
|
function generateRequest(): Request {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set("intent", "generate-bracket");
|
||||||
|
body.set("templateId", "afl_10");
|
||||||
|
SEEDED.forEach((id, i) => body.set(`participant${i}`, id));
|
||||||
|
return new Request("http://localhost/generate", { method: "POST", body });
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (request: Request) =>
|
||||||
|
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}>)({ request, params });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param extras participants in the season beyond the 10 seeded into the bracket —
|
||||||
|
* these are the ones generate-bracket marks eliminated.
|
||||||
|
* @param withExistingResults ids that already carry a result row, so
|
||||||
|
* markEliminatedAndAnnounce treats them as not newly eliminated.
|
||||||
|
*/
|
||||||
|
function setSeason(extras: string[], withExistingResults: string[] = []) {
|
||||||
|
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
||||||
|
[...SEEDED, ...extras].map((id) => ({ id })) as unknown as Awaited<
|
||||||
|
ReturnType<typeof findParticipantsBySportsSeasonId>
|
||||||
|
>
|
||||||
|
);
|
||||||
|
vi.mocked(findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
||||||
|
withExistingResults.map((participantId) => ({ participantId })) as unknown as Awaited<
|
||||||
|
ReturnType<typeof findParticipantResultsBySportsSeasonId>
|
||||||
|
>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generate-bracket entry-floor standings recalculation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||||
|
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||||||
|
);
|
||||||
|
vi.mocked(generateBracketFromTemplate).mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof generateBracketFromTemplate>>
|
||||||
|
);
|
||||||
|
vi.mocked(updateScoringEvent).mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof updateScoringEvent>>
|
||||||
|
);
|
||||||
|
vi.mocked(setParticipantResult).mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
||||||
|
);
|
||||||
|
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||||
|
);
|
||||||
|
// afl_10 seeds 1-4 into the Qualifying Finals, whose entry floor is the 5th-6th tier.
|
||||||
|
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recalculates when every eliminated team already had a result row", async () => {
|
||||||
|
// The second run of a generation: the first wrote position 0 for the non-bracket
|
||||||
|
// participants, so nobody is *newly* eliminated and the announcement is skipped.
|
||||||
|
// The floors banked moments ago would never reach the standings.
|
||||||
|
setSeason(["extra-1"], ["extra-1"]);
|
||||||
|
|
||||||
|
const result = await run(generateRequest());
|
||||||
|
|
||||||
|
expect(result.success).toBeDefined();
|
||||||
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||||||
|
"season-1",
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ skipDiscord: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recalculates for a qualifying event, which never announces eliminations", async () => {
|
||||||
|
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||||
|
{ ...EVENT, isQualifyingEvent: true } as unknown as Awaited<
|
||||||
|
ReturnType<typeof getScoringEventById>
|
||||||
|
>
|
||||||
|
);
|
||||||
|
setSeason(["extra-1"]);
|
||||||
|
|
||||||
|
await run(generateRequest());
|
||||||
|
|
||||||
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||||||
|
"season-1",
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ skipDiscord: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recalculates when the bracket field is the whole season", async () => {
|
||||||
|
setSeason([]);
|
||||||
|
|
||||||
|
await run(generateRequest());
|
||||||
|
|
||||||
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recalculates when the elimination announcement threw", async () => {
|
||||||
|
// The announcement is best-effort and its failure is swallowed — but a failed recalc
|
||||||
|
// is exactly when the floors still need one.
|
||||||
|
setSeason(["extra-1"]);
|
||||||
|
vi.mocked(recalculateAffectedLeagues)
|
||||||
|
.mockRejectedValueOnce(new Error("discord down"))
|
||||||
|
.mockResolvedValue(undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>);
|
||||||
|
|
||||||
|
const result = await run(generateRequest());
|
||||||
|
|
||||||
|
expect(result.success).toBeDefined();
|
||||||
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
|
||||||
|
expect(recalculateAffectedLeagues).toHaveBeenLastCalledWith(
|
||||||
|
"season-1",
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ skipDiscord: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not recalculate twice when the announcement already did", async () => {
|
||||||
|
setSeason(["extra-1"]);
|
||||||
|
|
||||||
|
await run(generateRequest());
|
||||||
|
|
||||||
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||||
|
// The announcing call, not the floor fallback.
|
||||||
|
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||||||
|
"season-1",
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ eliminatedParticipantIds: ["extra-1"] })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not recalculate at all when no floors were banked", async () => {
|
||||||
|
// A template that guarantees nothing at seeding: no floors, nobody to eliminate,
|
||||||
|
// so there is nothing for a recalculation to pick up.
|
||||||
|
vi.mocked(applyBracketEntryFloors).mockResolvedValue(0);
|
||||||
|
setSeason([]);
|
||||||
|
|
||||||
|
await run(generateRequest());
|
||||||
|
|
||||||
|
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
/**
|
||||||
|
* reprocess-bracket rebuilds a bracket's placements from scratch. What it wipes first
|
||||||
|
* decides whether the clear-bracket → regenerate → reprocess repair path actually works,
|
||||||
|
* and whether it takes the rest of the season's placements down with it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||||
|
import {
|
||||||
|
deleteParticipantResultsBySportsSeasonId,
|
||||||
|
deleteParticipantResultsForParticipants,
|
||||||
|
setParticipantResult,
|
||||||
|
} from "~/models/participant-result";
|
||||||
|
import {
|
||||||
|
applyBracketEntryFloors,
|
||||||
|
processMatchResult,
|
||||||
|
processQualifyingBracketEvent,
|
||||||
|
recalculateAffectedLeagues,
|
||||||
|
} from "~/models/scoring-calculator";
|
||||||
|
import { getScoringEventById } from "~/models/scoring-event";
|
||||||
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||||
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
|
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||||
|
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
getScoringEventById: vi.fn(),
|
||||||
|
updateScoringEvent: vi.fn(),
|
||||||
|
isReadOnlySibling: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
findPlayoffMatchesByEventId: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
||||||
|
deleteParticipantResultsForParticipants: vi.fn(),
|
||||||
|
setParticipantResult: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
applyBracketEntryFloors: vi.fn(),
|
||||||
|
processMatchResult: vi.fn(),
|
||||||
|
recalculateAffectedLeagues: vi.fn(),
|
||||||
|
processQualifyingBracketEvent: vi.fn(),
|
||||||
|
finalizeQualifyingPoints: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
findParticipantsBySportsSeasonId: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/sports-season", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
findSportsSeasonById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const params = { id: "season-1", eventId: "event-1" };
|
||||||
|
|
||||||
|
const EVENT = {
|
||||||
|
id: "event-1",
|
||||||
|
name: "AFL Finals",
|
||||||
|
sportsSeasonId: "season-1",
|
||||||
|
isQualifyingEvent: false,
|
||||||
|
isPrimary: false,
|
||||||
|
tournamentId: null,
|
||||||
|
bracketTemplateId: "afl_10",
|
||||||
|
};
|
||||||
|
|
||||||
|
function reprocessRequest(): Request {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set("intent", "reprocess-bracket");
|
||||||
|
return new Request("http://localhost/reprocess", { method: "POST", body });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A seeded, unplayed bracket slot. */
|
||||||
|
function slot(matchNumber: number, participant1Id: string, participant2Id: string) {
|
||||||
|
return {
|
||||||
|
id: `m-${matchNumber}`,
|
||||||
|
round: "Qualifying Finals",
|
||||||
|
matchNumber,
|
||||||
|
participant1Id,
|
||||||
|
participant2Id,
|
||||||
|
winnerId: null,
|
||||||
|
loserId: null,
|
||||||
|
isComplete: false,
|
||||||
|
isScoring: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (request: Request) =>
|
||||||
|
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}>)({ request, params });
|
||||||
|
|
||||||
|
function setEvent(overrides: Partial<typeof EVENT> = {}) {
|
||||||
|
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||||
|
{ ...EVENT, ...overrides } as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMatches(matches: ReturnType<typeof slot>[]) {
|
||||||
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
||||||
|
matches as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reprocess-bracket", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setEvent();
|
||||||
|
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
||||||
|
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
||||||
|
[] as unknown as Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>
|
||||||
|
);
|
||||||
|
vi.mocked(setParticipantResult).mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
||||||
|
);
|
||||||
|
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||||
|
);
|
||||||
|
vi.mocked(processMatchResult).mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof processMatchResult>>
|
||||||
|
);
|
||||||
|
vi.mocked(processQualifyingBracketEvent).mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof processQualifyingBracketEvent>>
|
||||||
|
);
|
||||||
|
vi.mocked(findSportsSeasonById).mockResolvedValue(
|
||||||
|
{ qualifyingPointsFinalized: false } as unknown as Awaited<
|
||||||
|
ReturnType<typeof findSportsSeasonById>
|
||||||
|
>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears placements even when no match has been played", async () => {
|
||||||
|
// The clear-bracket → regenerate → reprocess repair path lands here: the freshly
|
||||||
|
// re-seeded bracket has nothing completed, yet the discarded bracket's finalized
|
||||||
|
// placements are exactly what has to go. Skipping the wipe leaves them permanently,
|
||||||
|
// because upsertParticipantResult refuses to un-finalize a result.
|
||||||
|
setMatches([slot(1, "p1", "p2"), slot(2, "p3", "p4")]);
|
||||||
|
|
||||||
|
const result = await run(reprocessRequest());
|
||||||
|
|
||||||
|
expect(result.success).toBeDefined();
|
||||||
|
expect(deleteParticipantResultsForParticipants).toHaveBeenCalledTimes(1);
|
||||||
|
const [sportsSeasonId, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||||
|
expect(sportsSeasonId).toBe("season-1");
|
||||||
|
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3", "p4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes the wipe to this bracket, never the whole season", async () => {
|
||||||
|
// A season-wide delete would take every other event's placements with it, with only
|
||||||
|
// this bracket's replay able to rebuild them.
|
||||||
|
setMatches([slot(1, "p1", "p2")]);
|
||||||
|
|
||||||
|
await run(reprocessRequest());
|
||||||
|
|
||||||
|
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||||
|
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||||
|
expect(ids).not.toContain("p3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes each participant once when a team appears in more than one slot", async () => {
|
||||||
|
setMatches([slot(1, "p1", "p2"), slot(2, "p1", "p3")]);
|
||||||
|
|
||||||
|
await run(reprocessRequest());
|
||||||
|
|
||||||
|
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||||
|
expect(ids).toHaveLength(3);
|
||||||
|
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips empty slots rather than passing nulls through", async () => {
|
||||||
|
setMatches([
|
||||||
|
{ ...slot(1, "p1", "p2"), participant2Id: null as unknown as string },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await run(reprocessRequest());
|
||||||
|
|
||||||
|
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||||
|
expect(ids).toEqual(["p1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still takes the season-wide delete for a qualifying event", async () => {
|
||||||
|
// Qualifying seasons have no legitimate per-major fantasy placements — those come
|
||||||
|
// from finalizeQualifyingPoints across all majors — so that path wipes the season
|
||||||
|
// on purpose and rebuilds QP from the bracket.
|
||||||
|
setEvent({ isQualifyingEvent: true });
|
||||||
|
setMatches([slot(1, "p1", "p2")]);
|
||||||
|
|
||||||
|
await run(reprocessRequest());
|
||||||
|
|
||||||
|
expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1", {});
|
||||||
|
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an event with no bracket rather than wiping anything", async () => {
|
||||||
|
setMatches([]);
|
||||||
|
|
||||||
|
const result = await run(reprocessRequest());
|
||||||
|
|
||||||
|
expect(result.error).toContain("No bracket to reprocess");
|
||||||
|
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||||||
|
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -44,6 +44,7 @@ import {
|
||||||
setParticipantResult,
|
setParticipantResult,
|
||||||
findParticipantResultsBySportsSeasonId,
|
findParticipantResultsBySportsSeasonId,
|
||||||
deleteParticipantResultsBySportsSeasonId,
|
deleteParticipantResultsBySportsSeasonId,
|
||||||
|
deleteParticipantResultsForParticipants,
|
||||||
} from "~/models/participant-result";
|
} from "~/models/participant-result";
|
||||||
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
|
|
@ -171,7 +172,7 @@ async function scoreQualifyingBracket(
|
||||||
/**
|
/**
|
||||||
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
|
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
|
||||||
* (non-qualifying) events, announce the teams newly eliminated by this run to the
|
* (non-qualifying) events, announce the teams newly eliminated by this run to the
|
||||||
* affected leagues' Discord channels. Returns the number of participants marked.
|
* affected leagues' Discord channels.
|
||||||
*
|
*
|
||||||
* "Newly eliminated" = participants with no prior result row, so re-running a
|
* "Newly eliminated" = participants with no prior result row, so re-running a
|
||||||
* generation step never re-announces the same teams. The announcement is a
|
* generation step never re-announces the same teams. The announcement is a
|
||||||
|
|
@ -179,11 +180,18 @@ async function scoreQualifyingBracket(
|
||||||
* the eliminations themselves are already committed. eventId is deliberately
|
* the eliminations themselves are already committed. eventId is deliberately
|
||||||
* omitted from the recalc call so the announcement doesn't pull in unrelated
|
* omitted from the recalc call so the announcement doesn't pull in unrelated
|
||||||
* completed matches as "Scored Matches".
|
* completed matches as "Scored Matches".
|
||||||
|
*
|
||||||
|
* Returns the number of participants marked alongside whether a standings recalculation
|
||||||
|
* actually ran. The caller banks entry floors before calling this and needs them to
|
||||||
|
* reach teamStandings.totalPoints; it cannot infer that from the participant count,
|
||||||
|
* because the recalc is skipped for qualifying events, when every eliminated team
|
||||||
|
* already had a result row (the second run of a generation), and when the announcement
|
||||||
|
* threw. `recalculated` reports the fact rather than making the caller re-derive it.
|
||||||
*/
|
*/
|
||||||
async function markEliminatedAndAnnounce(
|
async function markEliminatedAndAnnounce(
|
||||||
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
||||||
participantIds: string[]
|
participantIds: string[]
|
||||||
): Promise<number> {
|
): Promise<{ markedCount: number; recalculated: boolean }> {
|
||||||
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
|
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
|
||||||
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
|
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
|
||||||
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
|
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
|
||||||
|
|
@ -192,6 +200,8 @@ async function markEliminatedAndAnnounce(
|
||||||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let recalculated = false;
|
||||||
|
|
||||||
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
|
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
|
||||||
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
|
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -199,12 +209,13 @@ async function markEliminatedAndAnnounce(
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
eliminatedParticipantIds: newlyEliminatedIds,
|
eliminatedParticipantIds: newlyEliminatedIds,
|
||||||
});
|
});
|
||||||
|
recalculated = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("[Eliminations] Discord announcement failed:", err);
|
logger.error("[Eliminations] Discord announcement failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return participantIds.length;
|
return { markedCount: participantIds.length, recalculated };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request, params }: Route.ActionArgs) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
|
@ -423,14 +434,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const toEliminate = allParticipants
|
const toEliminate = allParticipants
|
||||||
.filter((p) => !participantsInBracket.has(p.id))
|
.filter((p) => !participantsInBracket.has(p.id))
|
||||||
.map((p) => p.id);
|
.map((p) => p.id);
|
||||||
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
|
const { markedCount, recalculated } = await markEliminatedAndAnnounce(event, toEliminate);
|
||||||
logger.log(`[BracketGeneration] Marked ${eliminatedCount} participants as eliminated`);
|
logger.log(`[BracketGeneration] Marked ${markedCount} participants as eliminated`);
|
||||||
|
|
||||||
// markEliminatedAndAnnounce recalculates standings only when it actually
|
// The floors banked above only reach teamStandings.totalPoints via a recalc, and
|
||||||
// eliminated somebody. When the bracket field is the whole season (nothing to
|
// markEliminatedAndAnnounce runs one for its announcement in some cases but not
|
||||||
// eliminate) the entry floors above would never reach teamStandings.totalPoints,
|
// others: not for a qualifying event, not when every eliminated team already had
|
||||||
// so recalculate here. skipDiscord: seeding floors are not a result to announce.
|
// a result row (the second run of a generation, since the first wrote 0 for all
|
||||||
if (toEliminate.length === 0 && entryFloorCount > 0) {
|
// of them), not when there was nobody to eliminate, and not when the announcement
|
||||||
|
// threw. Drive off what it reports rather than re-deriving it from toEliminate.
|
||||||
|
// skipDiscord: seeding floors are not a result to announce.
|
||||||
|
if (entryFloorCount > 0 && !recalculated) {
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
skipDiscord: true,
|
skipDiscord: true,
|
||||||
|
|
@ -910,19 +924,31 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { error: "No bracket to reprocess" };
|
return { error: "No bracket to reprocess" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete ALL results for this sports season and rebuild from scratch.
|
// Wipe this bracket's participants' results and rebuild from scratch. Deleting
|
||||||
// Only deleting partial rows leaves stale finalized rows that block
|
// only the partial rows would leave stale finalized ones, which the "never
|
||||||
// the "never un-finalize" guard in upsertParticipantResult.
|
// un-finalize" guard in upsertParticipantResult then refuses to correct.
|
||||||
//
|
//
|
||||||
// seasonParticipantResults is keyed by sports season, not by event, so this
|
// Scoped to the participants this bracket actually holds, not the whole season:
|
||||||
// wipes every event's placements in the season and only the replay below can
|
// seasonParticipantResults is keyed by sports season, not by event, so a
|
||||||
// rebuild them (the same hazard clear-bracket documents). With nothing to
|
// season-wide delete takes every other event's placements with it and only this
|
||||||
// replay there is nothing to rebuild from, so skip it entirely: applying entry
|
// bracket's replay could rebuild them (the hazard clear-bracket documents).
|
||||||
// floors and re-marking eliminations below is additive and needs no wipe.
|
//
|
||||||
|
// Unconditional, because zero completed matches is precisely the clear-bracket →
|
||||||
|
// regenerate → reprocess repair path: the discarded bracket's finalized
|
||||||
|
// placements are exactly what needs clearing, and there is always something to
|
||||||
|
// rebuild from — the entry floors below, then the replay.
|
||||||
const db = database();
|
const db = database();
|
||||||
if (completed.length > 0) {
|
// Reused further down to decide who is *not* in the bracket and so eliminated.
|
||||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
const bracketParticipantIds = new Set<string>();
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
||||||
|
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
||||||
}
|
}
|
||||||
|
await deleteParticipantResultsForParticipants(
|
||||||
|
event.sportsSeasonId,
|
||||||
|
[...bracketParticipantIds],
|
||||||
|
db
|
||||||
|
);
|
||||||
|
|
||||||
// Re-bank the seeding-derived floors the delete above wipes (e.g. the AFL
|
// Re-bank the seeding-derived floors the delete above wipes (e.g. the AFL
|
||||||
// top-4's 5th-6th tier). Done before the replay so real match results overwrite
|
// top-4's 5th-6th tier). Done before the replay so real match results overwrite
|
||||||
|
|
@ -968,11 +994,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
|
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
|
||||||
// This covers teams that didn't make the playoffs/play-in tournament.
|
// This covers teams that didn't make the playoffs/play-in tournament.
|
||||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
const bracketParticipantIds = new Set<string>();
|
|
||||||
for (const match of matches) {
|
|
||||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
|
||||||
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
|
||||||
}
|
|
||||||
let eliminatedCount = 0;
|
let eliminatedCount = 0;
|
||||||
for (const participant of allParticipants) {
|
for (const participant of allParticipants) {
|
||||||
if (!bracketParticipantIds.has(participant.id)) {
|
if (!bracketParticipantIds.has(participant.id)) {
|
||||||
|
|
@ -1178,7 +1199,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const toEliminate = allParticipants
|
const toEliminate = allParticipants
|
||||||
.filter((p) => !uniqueParticipants.has(p.id))
|
.filter((p) => !uniqueParticipants.has(p.id))
|
||||||
.map((p) => p.id);
|
.map((p) => p.id);
|
||||||
const eliminatedCount = await markEliminatedAndAnnounce(groupsEvent, toEliminate);
|
const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
|
||||||
|
groupsEvent,
|
||||||
|
toEliminate
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
batchUpsertParticipantEVs,
|
batchUpsertParticipantEVs,
|
||||||
getAllParticipantEVsForSeason
|
getAllParticipantEVsForSeason
|
||||||
} from "~/models/participant-expected-value";
|
} from "~/models/participant-expected-value";
|
||||||
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const sportsSeason = await findSportsSeasonById(params.id);
|
const sportsSeason = await findSportsSeasonById(params.id);
|
||||||
|
|
@ -27,17 +28,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoringRules = {
|
|
||||||
pointsFor1st: 100,
|
|
||||||
pointsFor2nd: 70,
|
|
||||||
pointsFor3rd: 45,
|
|
||||||
pointsFor4th: 45,
|
|
||||||
pointsFor5th: 20,
|
|
||||||
pointsFor6th: 20,
|
|
||||||
pointsFor7th: 20,
|
|
||||||
pointsFor8th: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function action({ request, params }: Route.ActionArgs) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
|
@ -58,7 +48,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
probSeventh: parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100,
|
probSeventh: parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100,
|
||||||
probEighth: parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100,
|
probEighth: parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100,
|
||||||
},
|
},
|
||||||
scoringRules,
|
scoringRules: DEFAULT_SCORING_RULES,
|
||||||
source: "manual" as const,
|
source: "manual" as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { ArrowLeft, Calculator } from "lucide-react";
|
import { ArrowLeft, Calculator } from "lucide-react";
|
||||||
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||||
|
import { calculateEV } from "~/services/ev-calculator";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||||
|
|
@ -26,9 +28,18 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
|
||||||
export { loader };
|
export { loader };
|
||||||
|
|
||||||
// DEFAULT scoring values — must match DEFAULT_SCORING_RULES in the simulate route.
|
// EV is shown on the same reference scale the runner persists it with: a sports season
|
||||||
// Scoring: 1st=100, 2nd=70, 3rd/4th (FF losers)=45 each, 5th–8th (E8 losers)=20 each.
|
// is shared across leagues with different scoring, so DEFAULT_SCORING_RULES is the
|
||||||
// Sum = 100+70+45+45+20+20+20+20 = 340.
|
// common scale and each league re-derives its own EV from the stored probabilities
|
||||||
|
// (see getPersistenceContext in services/simulations/runner.ts).
|
||||||
|
//
|
||||||
|
// Scoring: 1st=100, 2nd=70, 3rd=50, 4th=40, 5th/6th=25 each, 7th/8th=15 each.
|
||||||
|
// Sum = 100+70+50+40+25+25+15+15 = 340.
|
||||||
|
//
|
||||||
|
// The 5th–8th values must stay distinct rather than collapsing to a flat 20: templates
|
||||||
|
// that split that zone into two tiers (llws_20, afl_10) put a team locked into 5th–6th
|
||||||
|
// at probFifth=probSixth=0.5 (EV 25) and one locked into 7th–8th at
|
||||||
|
// probSeventh=probEighth=0.5 (EV 15). A flat table reports both as 20.
|
||||||
//
|
//
|
||||||
// Total EV invariant: Σ EV across all participants = Σ scoring values = 340,
|
// Total EV invariant: Σ EV across all participants = Σ scoring values = 340,
|
||||||
// because each probability column sums to 1.0 across all participants.
|
// because each probability column sums to 1.0 across all participants.
|
||||||
|
|
@ -36,20 +47,23 @@ export { loader };
|
||||||
// 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now
|
// 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now
|
||||||
// zeros non-bracket participants automatically)
|
// zeros non-bracket participants automatically)
|
||||||
// 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams)
|
// 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams)
|
||||||
const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;
|
export function evFromProbs(ev: {
|
||||||
|
|
||||||
function evFromProbs(ev: {
|
|
||||||
probFirst: string; probSecond: string; probThird: string; probFourth: string;
|
probFirst: string; probSecond: string; probThird: string; probFourth: string;
|
||||||
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
|
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
|
||||||
}): number {
|
}): number {
|
||||||
return parseFloat(ev.probFirst) * SCORING[0]
|
return calculateEV(
|
||||||
+ parseFloat(ev.probSecond) * SCORING[1]
|
{
|
||||||
+ parseFloat(ev.probThird) * SCORING[2]
|
probFirst: parseFloat(ev.probFirst),
|
||||||
+ parseFloat(ev.probFourth) * SCORING[3]
|
probSecond: parseFloat(ev.probSecond),
|
||||||
+ parseFloat(ev.probFifth) * SCORING[4]
|
probThird: parseFloat(ev.probThird),
|
||||||
+ parseFloat(ev.probSixth) * SCORING[5]
|
probFourth: parseFloat(ev.probFourth),
|
||||||
+ parseFloat(ev.probSeventh) * SCORING[6]
|
probFifth: parseFloat(ev.probFifth),
|
||||||
+ parseFloat(ev.probEighth) * SCORING[7];
|
probSixth: parseFloat(ev.probSixth),
|
||||||
|
probSeventh: parseFloat(ev.probSeventh),
|
||||||
|
probEighth: parseFloat(ev.probEighth),
|
||||||
|
},
|
||||||
|
DEFAULT_SCORING_RULES
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(val: string | number) {
|
function fmt(val: string | number) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
|
||||||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||||
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
|
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
|
||||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||||
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
|
import { calculateEV } from '~/services/ev-calculator';
|
||||||
|
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
||||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||||
import { database } from '~/database/context';
|
import { database } from '~/database/context';
|
||||||
import * as schema from '~/database/schema';
|
import * as schema from '~/database/schema';
|
||||||
|
|
@ -28,17 +29,6 @@ import { useEffect, useRef, useState } from 'react';
|
||||||
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
||||||
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
||||||
|
|
||||||
const DEFAULT_SCORING_RULES: ScoringRules = {
|
|
||||||
pointsFor1st: 100,
|
|
||||||
pointsFor2nd: 70,
|
|
||||||
pointsFor3rd: 45,
|
|
||||||
pointsFor4th: 45,
|
|
||||||
pointsFor5th: 20,
|
|
||||||
pointsFor6th: 20,
|
|
||||||
pointsFor7th: 20,
|
|
||||||
pointsFor8th: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Golf Skills — ${data?.sportsSeason?.name ?? 'Sports Season'} - Brackt Admin` }];
|
return [{ title: `Golf Skills — ${data?.sportsSeason?.name ?? 'Sports Season'} - Brackt Admin` }];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import {
|
||||||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||||
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
|
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
|
||||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||||
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
|
import { calculateEV } from '~/services/ev-calculator';
|
||||||
|
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
||||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||||
import { database } from '~/database/context';
|
import { database } from '~/database/context';
|
||||||
import * as schema from '~/database/schema';
|
import * as schema from '~/database/schema';
|
||||||
|
|
@ -30,17 +31,6 @@ import { useEffect, useRef, useState } from 'react';
|
||||||
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
|
||||||
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
|
||||||
|
|
||||||
const DEFAULT_SCORING_RULES: ScoringRules = {
|
|
||||||
pointsFor1st: 100,
|
|
||||||
pointsFor2nd: 70,
|
|
||||||
pointsFor3rd: 45,
|
|
||||||
pointsFor4th: 45,
|
|
||||||
pointsFor5th: 20,
|
|
||||||
pointsFor6th: 20,
|
|
||||||
pointsFor7th: 20,
|
|
||||||
pointsFor8th: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import type { ProbabilityDistribution } from "./ev-calculator";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of probability update operation
|
* Result of probability update operation
|
||||||
|
|
@ -137,18 +138,9 @@ export async function updateProbabilitiesAfterResult(
|
||||||
.map(r => [r.participantId, r.finalPosition ?? 0])
|
.map(r => [r.participantId, r.finalPosition ?? 0])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update finished participants
|
// Update finished participants. The shared default table is used because we only
|
||||||
// Use default scoring rules (we only care about setting probabilities, not EV for finished)
|
// care about setting probabilities here, not the EV — each league re-derives its own
|
||||||
const defaultScoringRules = {
|
// EV from the stored probabilities in calculateTeamProjectedScore.
|
||||||
pointsFor1st: 100,
|
|
||||||
pointsFor2nd: 70,
|
|
||||||
pointsFor3rd: 50,
|
|
||||||
pointsFor4th: 40,
|
|
||||||
pointsFor5th: 25,
|
|
||||||
pointsFor6th: 25,
|
|
||||||
pointsFor7th: 15,
|
|
||||||
pointsFor8th: 15,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
||||||
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
||||||
|
|
@ -162,7 +154,7 @@ export async function updateProbabilitiesAfterResult(
|
||||||
participantId,
|
participantId,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
probabilities,
|
probabilities,
|
||||||
scoringRules: defaultScoringRules,
|
scoringRules: DEFAULT_SCORING_RULES,
|
||||||
source: 'manual', // Result is from actual outcome
|
source: 'manual', // Result is from actual outcome
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -216,7 +208,7 @@ export async function updateProbabilitiesAfterResult(
|
||||||
participantId,
|
participantId,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
probabilities,
|
probabilities,
|
||||||
scoringRules: defaultScoringRules,
|
scoringRules: DEFAULT_SCORING_RULES,
|
||||||
source: 'futures_odds', // Recalculated from remaining odds
|
source: 'futures_odds', // Recalculated from remaining odds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -767,6 +767,75 @@ describe("LLWSSimulator", () => {
|
||||||
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
expect(results.reduce((s, r) => s + r.probabilities.probFirst, 0)).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play out the entire U.S. side, so two of its teams are locked into a scoring tier:
|
||||||
|
* us-7 loses Elimination Round 4 (the 7th-8th tier) and us-9 loses the Elimination
|
||||||
|
* Final (the 5th-6th tier). Every game feeding those two is recorded, which is what
|
||||||
|
* makes the results honorable — makePlayGame only replays a result when the teams
|
||||||
|
* the simulation routed into the game are the pair the result was recorded between.
|
||||||
|
*
|
||||||
|
* Slot order per side is ids[0..7] into the four Opening Round games and ids[8..9]
|
||||||
|
* as the byes, so the U.S. draw is us-1 v us-2, us-3 v us-4, us-5 v us-6,
|
||||||
|
* us-7 v us-8, with us-9 and us-10 entering at Winners Round 2.
|
||||||
|
*/
|
||||||
|
function usSidePlayedOut(): PlayoffMatchRow[] {
|
||||||
|
let matches = seededBracket();
|
||||||
|
const play = (round: string, matchNumber: number, winnerId: string, loserId: string) => {
|
||||||
|
matches = completeMatch(matches, round, matchNumber, winnerId, loserId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Winners bracket
|
||||||
|
play("Opening Round", 1, "us-1", "us-2");
|
||||||
|
play("Opening Round", 2, "us-3", "us-4");
|
||||||
|
play("Opening Round", 3, "us-5", "us-6");
|
||||||
|
play("Opening Round", 4, "us-7", "us-8");
|
||||||
|
play("Winners Round 2", 1, "us-9", "us-1"); // bye us-9 v OP1 winner
|
||||||
|
play("Winners Round 2", 2, "us-10", "us-3"); // bye us-10 v OP2 winner
|
||||||
|
play("Winners Semifinals", 1, "us-5", "us-9");
|
||||||
|
play("Winners Semifinals", 2, "us-10", "us-7");
|
||||||
|
play("Winners Final", 1, "us-5", "us-10");
|
||||||
|
|
||||||
|
// Elimination bracket, including the deliberate cross-overs
|
||||||
|
play("Elimination Round 1", 1, "us-4", "us-6"); // OP2 loser v OP3 loser
|
||||||
|
play("Elimination Round 1", 2, "us-2", "us-8"); // OP1 loser v OP4 loser
|
||||||
|
play("Elimination Round 2", 1, "us-1", "us-4");
|
||||||
|
play("Elimination Round 2", 2, "us-3", "us-2");
|
||||||
|
play("Elimination Round 3", 1, "us-9", "us-3");
|
||||||
|
play("Elimination Round 3", 2, "us-7", "us-1");
|
||||||
|
play("Elimination Round 4", 1, "us-9", "us-7"); // us-7 out: 7th-8th tier
|
||||||
|
play("Elimination Final", 1, "us-10", "us-9"); // us-9 out: 5th-6th tier
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("puts a team locked into the 5th-6th tier at exactly 50/50 across those two spots", async () => {
|
||||||
|
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
|
||||||
|
const results = await new LLWSSimulator(2_000).simulate("season-1");
|
||||||
|
const locked = probsFor(results, "us-9");
|
||||||
|
|
||||||
|
// The tier is two tied positions, so its probability splits evenly across them.
|
||||||
|
// Under DEFAULT_SCORING_RULES that is 0.5 x 25 + 0.5 x 25 = 25 points of EV —
|
||||||
|
// the 5th-6th tier value, not the flat 5th-8th average of 20.
|
||||||
|
expect(locked.probFifth).toBe(0.5);
|
||||||
|
expect(locked.probSixth).toBe(0.5);
|
||||||
|
expect(locked.probSeventh).toBe(0);
|
||||||
|
expect(locked.probEighth).toBe(0);
|
||||||
|
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a team locked into the 7th-8th tier at exactly 50/50 across those two spots", async () => {
|
||||||
|
setupMockDb(defaultParticipants(), favouredEvRows, usSidePlayedOut());
|
||||||
|
const results = await new LLWSSimulator(2_000).simulate("season-1");
|
||||||
|
const locked = probsFor(results, "us-7");
|
||||||
|
|
||||||
|
// 0.5 x 15 + 0.5 x 15 = 15 points of EV, again distinct from the flat 20.
|
||||||
|
expect(locked.probSeventh).toBe(0.5);
|
||||||
|
expect(locked.probEighth).toBe(0.5);
|
||||||
|
expect(locked.probFifth).toBe(0);
|
||||||
|
expect(locked.probSixth).toBe(0);
|
||||||
|
expect(locked.probFirst + locked.probSecond + locked.probThird + locked.probFourth).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Result-honoring rules ─────────────────────────────────────────────────
|
// ── Result-honoring rules ─────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue