Award AFL top-4 their guaranteed points when the bracket is set #143

Merged
chrisp merged 2 commits from claude/afl-top-4-guaranteed-points-lof6ze into main 2026-08-24 17:24:26 +00:00
8 changed files with 572 additions and 16 deletions

View file

@ -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",

View file

@ -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));
});
});
});

View 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();
});
});

View file

@ -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)", () => {

View file

@ -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.

View file

@ -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 {

View file

@ -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);
});
});
});

View file

@ -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])
);