Compare commits

..

3 commits

Author SHA1 Message Date
3d89db3d0a Merge pull request 'Award AFL top-4 their guaranteed points when the bracket is set' (#143) from claude/afl-top-4-guaranteed-points-lof6ze into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m11s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Reviewed-on: #143
2026-08-24 17:24:25 +00:00
Claude
21422a7e92
Fix four issues found reviewing the entry-floor change
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m16s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m17s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- 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
2026-08-24 17:13:45 +00:00
Claude
ea58db9595
Award AFL top-4 their guaranteed points when the bracket is set
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
An AFL top-4 seed has the double chance from the moment the bracket is
drawn: lose the Qualifying Final, lose the Semi-Final, and you still
finish in the 5th-6th tier. Nothing was awarding that. Seeds 1-4 sat on
0 fantasy points until their first game resolved, which understated
every roster holding them.

Add an `entryFloor` field to BracketRound for floors a seeding locks in
before anyone plays, plus `applyBracketEntryFloors` to bank them, wired
into both bracket generation and reprocess-bracket. For afl_10 that is 5
for the Qualifying Finals (seeds 1-4) and 7 for the Elimination Finals
(seeds 5-6). Every write is provisional, so a real result supersedes it,
and upsertParticipantResult's never-un-finalize guard leaves finalized
rows alone.

Two related floors were also wrong, both from the generic
"winning into a scoring round means top-8" default in
nonScoringWinnerFloorFor:

  - Qualifying Finals winners banked 5 when the bye to a Preliminary
    Final guarantees the 3rd-4th tier. progressive-floor-scoring.test.ts
    already asserted 3 here, but via an isScoring=true call the runtime
    never makes.
  - Wildcard winners banked 5 when winning only buys an Elimination
    Final, whose losers are the 7th-8th tier — an over-award of a full
    tier until that game was played.

Both are now explicit nonScoringWinnerFloor values on the template.

reprocess-bracket now applies entry floors after wiping results and
before replaying matches, and no longer refuses a bracket with no
completed matches, so setting a bracket and reprocessing awards the
guaranteed points. It stays silent on Discord as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
2026-08-24 16:56:33 +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();
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
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])
);