- Provisional rows were being treated as finished by updateProbabilitiesAfterResult, whose finishedMap filtered on finalPosition alone. Entry floors made that fire for the whole seeded field: on the first match result, AFL seeds 1-6 would each be pinned to 100% at their floor position and dropped from the ICM recalc, zeroing the championship odds of six teams that had not played. Filter partial rows out of finishedMap so they stay in the unfinished set. Finalized 0-position eliminations still finalize as before. - generate-bracket recalculated standings only inside markEliminatedAndAnnounce, which no-ops when nothing was eliminated. A season whose participants exactly equal the bracket field would never surface the floors in teamStandings.totalPoints. Recalculate explicitly in that case (skipDiscord: seeding is not a result). - applyBracketEntryFloors upserted unconditionally, so regenerating a bracket mid-tournament could downgrade a team already sitting on a better placement. Read existing placements first and only write when the floor improves on what a participant already has; position 0 is eliminated, not a placement, so it never blocks a floor. - Relaxing the reprocess guard to matches.length made the season-wide deleteParticipantResultsBySportsSeasonId reachable with zero completed matches, wiping other events' placements with no replay able to rebuild them. Skip the wipe when there is nothing to replay; entry floors and elimination marking are additive and need no wipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
/**
|
|
* Entry-floor scoring: points a bracket guarantees at seeding time.
|
|
*
|
|
* Some seedings lock in a scoring tier before a single match is played. The AFL
|
|
* finals are the clearest case: a top-4 seed has the double chance, so losing the
|
|
* Qualifying Final still leaves them a Semi-Final, and losing that is the 5th-6th
|
|
* tier. Those teams must not sit on 0 fantasy points until their first game.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
interface MatchRow {
|
|
round: string;
|
|
participant1Id: string | null;
|
|
participant2Id: string | null;
|
|
}
|
|
|
|
/**
|
|
* Minimal db mock. applyBracketEntryFloors calls, in order:
|
|
* 1. db.query.scoringEvents.findFirst → the event (for template + sportsSeasonId)
|
|
* 2. db.query.playoffMatches.findMany → the bracket's match slots
|
|
* 3. upsertParticipantResult per floored participant → findFirst + insert/update
|
|
*/
|
|
function makeDb(
|
|
event: { bracketTemplateId: string | null; sportsSeasonId: string } | null,
|
|
matches: MatchRow[],
|
|
existingByParticipant: Record<string, { id: string; finalPosition: number; isPartialScore: boolean }> = {}
|
|
) {
|
|
const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({
|
|
participantId,
|
|
finalPosition: row.finalPosition,
|
|
}));
|
|
const insertedRows: Array<Record<string, unknown>> = [];
|
|
const updatedRows: Array<Record<string, unknown>> = [];
|
|
|
|
return {
|
|
db: {
|
|
insert: vi.fn().mockReturnValue({
|
|
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
|
insertedRows.push(values);
|
|
return Promise.resolve();
|
|
}),
|
|
}),
|
|
update: vi.fn().mockReturnValue({
|
|
set: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
|
updatedRows.push(values);
|
|
return { where: vi.fn().mockResolvedValue(undefined) };
|
|
}),
|
|
}),
|
|
query: {
|
|
scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) },
|
|
playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) },
|
|
seasonParticipantResults: {
|
|
// The pre-pass that stops a floor from downgrading an existing placement.
|
|
findMany: vi.fn().mockResolvedValue(existingRows),
|
|
findFirst: vi.fn().mockImplementation((args: { where?: unknown }) => {
|
|
// Resolve by scanning the seeded map — the mock has no real query engine,
|
|
// so tests that need an existing row use a single-participant bracket.
|
|
void args;
|
|
const only = Object.values(existingByParticipant)[0];
|
|
return Promise.resolve(only);
|
|
}),
|
|
},
|
|
},
|
|
} as never,
|
|
insertedRows,
|
|
updatedRows,
|
|
};
|
|
}
|
|
|
|
import { applyBracketEntryFloors, getBracketEntryFloor } from "../scoring-calculator";
|
|
|
|
/** The AFL bracket exactly as generateAFL10Bracket writes it: later rounds are TBD. */
|
|
const AFL_BRACKET: MatchRow[] = [
|
|
{ round: "Wildcard Round", participant1Id: "seed7", participant2Id: "seed10" },
|
|
{ round: "Wildcard Round", participant1Id: "seed8", participant2Id: "seed9" },
|
|
{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: "seed4" },
|
|
{ round: "Qualifying Finals", participant1Id: "seed2", participant2Id: "seed3" },
|
|
{ round: "Elimination Finals", participant1Id: "seed5", participant2Id: null },
|
|
{ round: "Elimination Finals", participant1Id: "seed6", participant2Id: null },
|
|
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
|
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
|
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
|
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
|
{ round: "Grand Final", participant1Id: null, participant2Id: null },
|
|
];
|
|
|
|
describe("applyBracketEntryFloors", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("afl_10", () => {
|
|
it("banks 5 for the top 4 and 7 for seeds 5-6, and nothing for the wildcard teams", async () => {
|
|
const { db, insertedRows } = makeDb(
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
AFL_BRACKET
|
|
);
|
|
|
|
const applied = await applyBracketEntryFloors("event-1", db);
|
|
|
|
expect(applied).toBe(6);
|
|
const floors = Object.fromEntries(
|
|
insertedRows.map((r) => [r.participantId as string, r.finalPosition as number])
|
|
);
|
|
expect(floors).toEqual({
|
|
seed1: 5, seed2: 5, seed3: 5, seed4: 5, // double chance → 5th-6th tier
|
|
seed5: 7, seed6: 7, // seeded into the Elimination Finals
|
|
});
|
|
// Seeds 7-10 lose the Wildcard Round for 0, so nothing is guaranteed yet.
|
|
expect(floors).not.toHaveProperty("seed7");
|
|
expect(floors).not.toHaveProperty("seed10");
|
|
});
|
|
|
|
it("writes every floor as provisional so real results supersede it", async () => {
|
|
const { db, insertedRows } = makeDb(
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
AFL_BRACKET
|
|
);
|
|
|
|
await applyBracketEntryFloors("event-1", db);
|
|
|
|
expect(insertedRows.every((r) => r.isPartialScore === true)).toBe(true);
|
|
expect(insertedRows.every((r) => r.sportsSeasonId === "ss-1")).toBe(true);
|
|
});
|
|
|
|
it("leaves TBD slots alone — a Semi-Final nobody has reached grants nothing", async () => {
|
|
const { db, insertedRows } = makeDb(
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
[{ round: "Semi-Finals", participant1Id: null, participant2Id: null }]
|
|
);
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
expect(insertedRows).toHaveLength(0);
|
|
});
|
|
|
|
it("never downgrades a better placement — a finalist regenerating stays a finalist", async () => {
|
|
// clear-bracket → generate-bracket mid-tournament must not knock a team sitting
|
|
// on a 2nd-place floor back down to their 5th-6th seeding floor.
|
|
const { db, insertedRows, updatedRows } = makeDb(
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
|
{ seed1: { id: "row-1", finalPosition: 2, isPartialScore: true } }
|
|
);
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
expect(insertedRows).toHaveLength(0);
|
|
expect(updatedRows).toHaveLength(0);
|
|
});
|
|
|
|
it("treats position 0 as eliminated, not as a better placement", async () => {
|
|
// A 0 means "missed the bracket". Re-seeding a team into the bracket must still
|
|
// give them their floor rather than reading 0 as an unbeatable placement.
|
|
const { db, updatedRows } = makeDb(
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
|
{ seed1: { id: "row-1", finalPosition: 0, isPartialScore: true } }
|
|
);
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(1);
|
|
expect(updatedRows).toHaveLength(1);
|
|
expect(updatedRows[0]).toMatchObject({ finalPosition: 5, isPartialScore: true });
|
|
});
|
|
|
|
it("does not un-finalize a participant who already has a real result", async () => {
|
|
// upsertParticipantResult's never-un-finalize guard: a finalized row must not be
|
|
// dragged back to a provisional floor when the bracket is regenerated.
|
|
const { db, insertedRows, updatedRows } = makeDb(
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
|
{ seed1: { id: "row-1", finalPosition: 1, isPartialScore: false } }
|
|
);
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
expect(insertedRows).toHaveLength(0);
|
|
expect(updatedRows).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("other brackets", () => {
|
|
it("is a no-op for an event with no bracket template", async () => {
|
|
const { db, insertedRows } = makeDb(
|
|
{ bracketTemplateId: null, sportsSeasonId: "ss-1" },
|
|
AFL_BRACKET
|
|
);
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
expect(insertedRows).toHaveLength(0);
|
|
});
|
|
|
|
it("is a no-op when the event does not exist", async () => {
|
|
const { db } = makeDb(null, AFL_BRACKET);
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
});
|
|
|
|
it("grants nothing to an NBA bracket: every seeded round is non-scoring", async () => {
|
|
const { db, insertedRows } = makeDb(
|
|
{ bracketTemplateId: "nba_20", sportsSeasonId: "ss-1" },
|
|
[
|
|
{ round: "Play-In Round 1", participant1Id: "e7", participant2Id: "e8" },
|
|
{ round: "First Round", participant1Id: "e1", participant2Id: null },
|
|
]
|
|
);
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
expect(insertedRows).toHaveLength(0);
|
|
});
|
|
|
|
it("grants the T5-8 tier to a simple_8 field: every entrant is already in a scoring round", async () => {
|
|
const { db, insertedRows } = makeDb(
|
|
{ bracketTemplateId: "simple_8", sportsSeasonId: "ss-1" },
|
|
[{ round: "Quarterfinals", participant1Id: "a", participant2Id: "b" }]
|
|
);
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(2);
|
|
expect(insertedRows.map((r) => r.finalPosition)).toEqual([5, 5]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getBracketEntryFloor", () => {
|
|
it("prefers a round's explicit entryFloor over its loser position", () => {
|
|
// Qualifying Finals is non-scoring, so only the explicit entryFloor makes it pay.
|
|
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
|
});
|
|
|
|
it("falls back to a scoring round's own loser position", () => {
|
|
expect(getBracketEntryFloor("Quarterfinals", "simple_8")).toBe(5);
|
|
expect(getBracketEntryFloor("Semifinals", "simple_8")).toBe(3);
|
|
});
|
|
|
|
it("returns null for non-scoring rounds with no explicit floor", () => {
|
|
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
|
expect(getBracketEntryFloor("First Round", "nba_20")).toBeNull();
|
|
expect(getBracketEntryFloor("Round of 64", "ncaa_68")).toBeNull();
|
|
});
|
|
|
|
it("returns null for unknown rounds and templates", () => {
|
|
expect(getBracketEntryFloor("Not A Round", "afl_10")).toBeNull();
|
|
expect(getBracketEntryFloor("Quarterfinals", "not_a_template")).toBeNull();
|
|
expect(getBracketEntryFloor("Quarterfinals", null)).toBeNull();
|
|
});
|
|
});
|