Compare commits
3 commits
3c7272392e
...
3d89db3d0a
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d89db3d0a | |||
|
|
21422a7e92 | ||
|
|
ea58db9595 |
8 changed files with 572 additions and 16 deletions
|
|
@ -31,6 +31,20 @@ export interface BracketRound {
|
|||
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
|
||||
*/
|
||||
nonScoringWinnerFloor?: number | null;
|
||||
/**
|
||||
* Floor position every team is guaranteed simply by being *seeded into* this
|
||||
* round when the bracket is generated — before a single match is played.
|
||||
*
|
||||
* Omit (the default) for rounds where entering guarantees nothing: a team that
|
||||
* loses its first match earns 0. Set a number when the bracket structure locks
|
||||
* in a scoring tier on entry — e.g. afl_10's Qualifying Finals, where the loser
|
||||
* still gets a Semi-Final and so cannot finish worse than the 5th-6th tier.
|
||||
*
|
||||
* Only teams actually assigned to a match slot at generation receive this floor;
|
||||
* TBD slots filled later by advancing winners get their floor from the round they
|
||||
* won (nonScoringWinnerFloor / RoundScoringConfig.winnerFloor) instead.
|
||||
*/
|
||||
entryFloor?: number | null;
|
||||
}
|
||||
|
||||
export interface GroupStageConfig {
|
||||
|
|
@ -707,18 +721,32 @@ export const AFL_10: BracketTemplate = {
|
|||
matchCount: 2,
|
||||
feedsInto: "Elimination Finals",
|
||||
isScoring: false, // Losers get 0 points (9th-10th)
|
||||
// A Wildcard win only buys an Elimination Final; losing that is the 7th-8th
|
||||
// tier, so the winner banks 7 — not the generic "entering a scoring round
|
||||
// means top-8" default of 5, which would over-award them a 5th-6th floor.
|
||||
nonScoringWinnerFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Qualifying Finals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Preliminary Finals", // Winners get bye
|
||||
isScoring: false, // Losers get second chance (go to Semi-Finals)
|
||||
// Seeds 1-4 have the double chance from the moment the bracket is drawn:
|
||||
// lose the QF, lose the Semi-Final, and you still finish in the 5th-6th tier.
|
||||
entryFloor: 5,
|
||||
// Winning the QF is a bye straight to a Preliminary Final; losing that is the
|
||||
// 3rd-4th tier, so the winner's floor is 3 rather than the generic default of 5.
|
||||
nonScoringWinnerFloor: 3,
|
||||
},
|
||||
{
|
||||
name: "Elimination Finals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Semi-Finals",
|
||||
isScoring: true, // Losers share 7th-8th
|
||||
// Seeds 5-6 are seeded straight into this round, so the 7th-8th tier is
|
||||
// locked in for them at generation. (The other slot is a TBD Wildcard winner,
|
||||
// who banks the same floor by winning the Wildcard Round.)
|
||||
entryFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Semi-Finals",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints, type ScoringRules } from "../scoring-rules";
|
||||
import { getBracketEntryFloor } from "../scoring-calculator";
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
|
|
@ -206,3 +207,69 @@ describe("AFL Finals System - Phase 3.3", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AFL guaranteed floors from seeding (afl_10)", () => {
|
||||
const byName = (name: string) => AFL_10.rounds.find((r) => r.name === name);
|
||||
|
||||
describe("getBracketEntryFloor — banked the moment the bracket is set", () => {
|
||||
it("gives seeds 1-4 the 5th-6th tier: the double chance is locked in at seeding", () => {
|
||||
// Worst case for a top-4 seed is lose the Qualifying Final, then lose the
|
||||
// Semi-Final — which is the 5th-6th tier. They can never finish below it.
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "afl_10")).toBe(25);
|
||||
});
|
||||
|
||||
it("gives seeds 5-6 the 7th-8th tier: they are seeded straight into a scoring round", () => {
|
||||
expect(getBracketEntryFloor("Elimination Finals", "afl_10")).toBe(7);
|
||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "afl_10")).toBe(15);
|
||||
});
|
||||
|
||||
it("gives seeds 7-10 nothing: a Wildcard loss is worth 0", () => {
|
||||
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the template is unknown or missing", () => {
|
||||
expect(getBracketEntryFloor("Qualifying Finals", null)).toBeNull();
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "not_a_template")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not hand out floors for TBD rounds nobody is seeded into yet", () => {
|
||||
// These rounds do carry a loser tier, but every slot is empty at generation,
|
||||
// so applyBracketEntryFloors has no participant to write against.
|
||||
expect(byName("Semi-Finals")?.entryFloor).toBeUndefined();
|
||||
expect(byName("Preliminary Finals")?.entryFloor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nonScoringWinnerFloor — the generic top-8 default is wrong for both AFL non-scoring rounds", () => {
|
||||
it("Qualifying Finals winners bank 3, not 5 — the bye means a Prelim loss is 3rd-4th", () => {
|
||||
expect(byName("Qualifying Finals")?.nonScoringWinnerFloor).toBe(3);
|
||||
});
|
||||
|
||||
it("Wildcard winners bank 7, not 5 — winning only buys an Elimination Final", () => {
|
||||
expect(byName("Wildcard Round")?.nonScoringWinnerFloor).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("floors only ever improve along every AFL path", () => {
|
||||
const pts = (position: number) => calculateBracketPoints(position, DEFAULT_SCORING, "afl_10");
|
||||
|
||||
it("top-4 seed: entry 5 → QF win 3 → PF win 2 → GF win 1", () => {
|
||||
expect(pts(5)).toBeLessThan(pts(3));
|
||||
expect(pts(3)).toBeLessThan(pts(2));
|
||||
expect(pts(2)).toBeLessThan(pts(1));
|
||||
});
|
||||
|
||||
it("top-4 seed losing the QF holds the entry floor, then finalizes at 5th-6th", () => {
|
||||
// QF losers advance to the Semi-Final, so nothing is written at the QF —
|
||||
// the entry floor of 5 carries them until the Semi-Final resolves.
|
||||
const entryFloor = getBracketEntryFloor("Qualifying Finals", "afl_10");
|
||||
expect(entryFloor).toBe(5);
|
||||
expect(pts(entryFloor ?? 0)).toBe(25); // unchanged by the loss
|
||||
});
|
||||
|
||||
it("seeds 5-6 and Wildcard winners share a 7th-8th floor, below the top-4's", () => {
|
||||
expect(pts(7)).toBeLessThan(pts(5));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
243
app/models/__tests__/bracket-entry-floors.test.ts
Normal file
243
app/models/__tests__/bracket-entry-floors.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -190,12 +190,26 @@ describe("processMatchResult", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => {
|
||||
it("AFL Wildcard Round: loser=0, winner gets T7 floor (a Wildcard win only buys an Elimination Final)", async () => {
|
||||
// The generic "entering a scoring round ⇒ top-8" default would bank 5 here,
|
||||
// over-awarding the 5th-6th tier to a team whose next loss is the 7th-8th tier.
|
||||
const { db, insertedRows } = makeDb();
|
||||
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
|
||||
expect(insertedRows).toHaveLength(2);
|
||||
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
|
||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 7, isPartialScore: true });
|
||||
});
|
||||
|
||||
it("AFL Qualifying Finals: winner gets T3 floor (bye to a Preliminary Final), loser holds their entry floor", async () => {
|
||||
const { db, insertedRows } = makeDb();
|
||||
await processMatchResult(
|
||||
{ ...BASE, bracketTemplateId: "afl_10", round: "Qualifying Finals", isScoring: false, loserAdvances: true },
|
||||
db
|
||||
);
|
||||
// Only the winner is written: the loser still has a Semi-Final, so their
|
||||
// seeding-derived floor of 5 stands untouched.
|
||||
expect(insertedRows).toHaveLength(1);
|
||||
expect(insertedRows[0]).toMatchObject({ participantId: "winner-1", finalPosition: 3, isPartialScore: true });
|
||||
});
|
||||
|
||||
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
|
||||
|
|
|
|||
|
|
@ -174,6 +174,110 @@ function nonScoringWinnerFloorFor(
|
|||
return nextRound?.isScoring === true ? 5 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the floor position a participant banks purely by being *seeded into* the
|
||||
* given round when the bracket is generated, or null when entry guarantees nothing.
|
||||
*
|
||||
* Two sources, in order:
|
||||
* 1. The template round's explicit `entryFloor` (e.g. afl_10 "Qualifying Finals" → 5:
|
||||
* seeds 1-4 have the double chance, so the 5th-6th tier is locked in on day one).
|
||||
* 2. Otherwise a scoring round's own loser position — being drawn into a round whose
|
||||
* losers score means the worst case is that round's loser tier.
|
||||
*
|
||||
* Non-scoring rounds with no explicit `entryFloor` return null: losing your first game
|
||||
* there is worth 0, so there is nothing to bank yet.
|
||||
*/
|
||||
export function getBracketEntryFloor(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined
|
||||
): number | null {
|
||||
const template = bracketTemplateId ? BRACKET_TEMPLATES[bracketTemplateId] : undefined;
|
||||
const templateRound = template?.rounds.find((r) => r.name === round);
|
||||
if (templateRound?.entryFloor !== undefined) return templateRound.entryFloor;
|
||||
if (!templateRound?.isScoring) return null;
|
||||
return getRoundConfig(round, bracketTemplateId)?.loserPosition ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the provisional entry floors for a freshly generated (or reprocessed) bracket.
|
||||
*
|
||||
* A seeded bracket can guarantee points before anyone plays: an AFL top-4 seed cannot
|
||||
* finish below the 5th-6th tier because a Qualifying Final loss still leaves them a
|
||||
* Semi-Final. Without this, those teams sit on 0 fantasy points until their first game
|
||||
* resolves, which understates every roster holding them.
|
||||
*
|
||||
* Only participants already assigned to a match slot are touched, and every write is
|
||||
* provisional (isPartialScore=true) so it is superseded the moment a real result lands.
|
||||
*
|
||||
* Floors never go backwards. A participant already sitting on an equal or better
|
||||
* placement is skipped, so regenerating a bracket mid-tournament (clear-bracket →
|
||||
* generate-bracket) cannot knock a finalist back down to their seeding floor. Combined
|
||||
* with upsertParticipantResult's never-un-finalize guard, re-running over the same
|
||||
* bracket is a no-op.
|
||||
*
|
||||
* Returns the number of participants whose floor this call actually raised.
|
||||
*/
|
||||
export async function applyBracketEntryFloors(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
if (!event?.bracketTemplateId) return 0;
|
||||
|
||||
const matches = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
});
|
||||
|
||||
// Highest (best) floor wins when a participant somehow appears in more than one
|
||||
// round's slots — a lower position number is a better guarantee.
|
||||
const floorByParticipant = new Map<string, number>();
|
||||
for (const match of matches) {
|
||||
const floor = getBracketEntryFloor(match.round, event.bracketTemplateId);
|
||||
if (floor === null) continue;
|
||||
for (const participantId of [match.participant1Id, match.participant2Id]) {
|
||||
if (!participantId) continue;
|
||||
const existing = floorByParticipant.get(participantId);
|
||||
if (existing === undefined || floor < existing) {
|
||||
floorByParticipant.set(participantId, floor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Existing placements, so a floor is only ever written when it improves on what
|
||||
// the participant already has. Position 0 means eliminated / missed the bracket —
|
||||
// not a better placement — so it never blocks a floor.
|
||||
const existingRows = await db.query.seasonParticipantResults.findMany({
|
||||
where: eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId),
|
||||
columns: { participantId: true, finalPosition: true },
|
||||
});
|
||||
const existingPosition = new Map(
|
||||
existingRows
|
||||
.filter((r) => r.finalPosition !== null && r.finalPosition > 0)
|
||||
.map((r) => [r.participantId, r.finalPosition as number])
|
||||
);
|
||||
|
||||
let applied = 0;
|
||||
for (const [participantId, floor] of floorByParticipant) {
|
||||
const current = existingPosition.get(participantId);
|
||||
if (current !== undefined && current <= floor) continue; // already as good or better
|
||||
|
||||
const oldFloor = await upsertParticipantResult(
|
||||
participantId,
|
||||
event.sportsSeasonId,
|
||||
floor,
|
||||
db,
|
||||
true // provisional: replaced as soon as the participant wins or is eliminated
|
||||
);
|
||||
if (oldFloor !== null) applied++;
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the scoring config for a given round name, applying any template-specific
|
||||
* overrides before falling back to the standard ROUND_CONFIG.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
recalculateAffectedLeagues,
|
||||
recalculateStandings,
|
||||
autoCompleteRoundIfDone,
|
||||
applyBracketEntryFloors,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||
|
|
@ -396,6 +397,24 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
try {
|
||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
||||
|
||||
// The template ID has to land on the event before entry floors can be derived
|
||||
// (getBracketEntryFloor reads it), so persist it here rather than after the
|
||||
// elimination pass below.
|
||||
await updateScoringEvent(params.eventId, {
|
||||
bracketTemplateId: templateId,
|
||||
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||
bracketRegionConfig: regionOverride,
|
||||
});
|
||||
|
||||
// Some seedings guarantee points before a ball is bounced — an AFL top-4 seed
|
||||
// has the double chance, so the 5th-6th tier is locked in at generation. Bank
|
||||
// those provisional floors now, ahead of the elimination announcement below so
|
||||
// the standings it posts already reflect them.
|
||||
const entryFloorCount = await applyBracketEntryFloors(params.eventId);
|
||||
if (entryFloorCount > 0) {
|
||||
logger.log(`[BracketGeneration] Applied entry floors to ${entryFloorCount} participant(s)`);
|
||||
}
|
||||
|
||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce).
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (event) {
|
||||
|
|
@ -406,14 +425,18 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
.map((p) => p.id);
|
||||
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
|
||||
logger.log(`[BracketGeneration] Marked ${eliminatedCount} participants as eliminated`);
|
||||
}
|
||||
|
||||
// Update the event to store the template ID, scoring start round, and region config
|
||||
await updateScoringEvent(params.eventId, {
|
||||
bracketTemplateId: templateId,
|
||||
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||
bracketRegionConfig: regionOverride,
|
||||
// markEliminatedAndAnnounce recalculates standings only when it actually
|
||||
// eliminated somebody. When the bracket field is the whole season (nothing to
|
||||
// eliminate) the entry floors above would never reach teamStandings.totalPoints,
|
||||
// so recalculate here. skipDiscord: seeding floors are not a result to announce.
|
||||
if (toEliminate.length === 0 && entryFloorCount > 0) {
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
||||
eventName: event.name ?? undefined,
|
||||
skipDiscord: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: "Bracket generated successfully" };
|
||||
} catch (error) {
|
||||
|
|
@ -883,15 +906,28 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { success: `${baseMessage} No mirror windows to sync.` };
|
||||
}
|
||||
|
||||
if (completed.length === 0) {
|
||||
return { error: "No completed matches to reprocess" };
|
||||
if (matches.length === 0) {
|
||||
return { error: "No bracket to reprocess" };
|
||||
}
|
||||
|
||||
// Delete ALL results for this sports season and rebuild from scratch.
|
||||
// Only deleting partial rows leaves stale finalized rows that block
|
||||
// the "never un-finalize" guard in upsertParticipantResult.
|
||||
//
|
||||
// seasonParticipantResults is keyed by sports season, not by event, so this
|
||||
// wipes every event's placements in the season and only the replay below can
|
||||
// rebuild them (the same hazard clear-bracket documents). With nothing to
|
||||
// replay there is nothing to rebuild from, so skip it entirely: applying entry
|
||||
// floors and re-marking eliminations below is additive and needs no wipe.
|
||||
const db = database();
|
||||
if (completed.length > 0) {
|
||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// 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
|
||||
// them; a bracket with no completed matches still gets its guaranteed points.
|
||||
const entryFloorCount = await applyBracketEntryFloors(params.eventId, db);
|
||||
|
||||
// Replay each completed match in bracket order (earlier rounds first).
|
||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||
|
|
@ -952,7 +988,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
||||
|
||||
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
|
||||
return {
|
||||
success:
|
||||
`Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ` +
|
||||
`${entryFloorCount} seeded participant(s) given their guaranteed entry floor, ` +
|
||||
`${eliminatedCount} non-bracket participant(s) eliminated`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error reprocessing bracket:", error);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -266,5 +266,57 @@ describe("probability-updater", () => {
|
|||
expect(callArgs.probabilities.probSeventh).toBe(0);
|
||||
expect(callArgs.probabilities.probEighth).toBe(0);
|
||||
});
|
||||
|
||||
it("does NOT treat a provisional floor as finished — the team is still playing", async () => {
|
||||
// An AFL top-4 seed banks a provisional 5th-6th floor at seeding. Pinning them
|
||||
// to 100% at 5th would erase their championship odds before they have played.
|
||||
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
||||
{
|
||||
id: "result-1",
|
||||
participantId: "participant-1",
|
||||
sportsSeasonId: "season-1",
|
||||
finalPosition: 5,
|
||||
isPartialScore: true,
|
||||
qualifyingPoints: null,
|
||||
notes: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
participant: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
||||
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", false);
|
||||
|
||||
expect(result.finishedParticipants).toBe(0);
|
||||
expect(upsertSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still finalizes a 0-position elimination — those rows are not partial", async () => {
|
||||
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
|
||||
{
|
||||
id: "result-1",
|
||||
participantId: "participant-1",
|
||||
sportsSeasonId: "season-1",
|
||||
finalPosition: 0,
|
||||
isPartialScore: false,
|
||||
qualifyingPoints: null,
|
||||
notes: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
participant: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
|
||||
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", false);
|
||||
|
||||
expect(result.finishedParticipants).toBe(1);
|
||||
expect(upsertSpy.mock.calls[0][0].probabilities.probFirst).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -123,10 +123,17 @@ export async function updateProbabilitiesAfterResult(
|
|||
// Get all existing EVs
|
||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
|
||||
// Create map of participantId -> finalPosition
|
||||
// Create map of participantId -> finalPosition.
|
||||
//
|
||||
// Provisional rows (isPartialScore) are NOT finished: they are the guaranteed
|
||||
// minimum for someone still alive — a bracket entry floor, or the floor banked
|
||||
// by winning a round. Treating them as finished pins the participant to 100% at
|
||||
// that floor and drops them from the ICM recalculation below, which would zero
|
||||
// the championship odds of every team still playing. They belong in the
|
||||
// unfinished set until a real result lands.
|
||||
const finishedMap = new Map(
|
||||
results
|
||||
.filter(r => r.finalPosition !== null)
|
||||
.filter(r => r.finalPosition !== null && !r.isPartialScore)
|
||||
.map(r => [r.participantId, r.finalPosition ?? 0])
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue