From ea58db9595edec3720f8165b1c1ad1f51842d0dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:56:33 +0000 Subject: [PATCH] Award AFL top-4 their guaranteed points when the bracket is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd --- app/lib/bracket-templates.ts | 28 +++ app/models/__tests__/afl-finals.test.ts | 69 +++++- .../__tests__/bracket-entry-floors.test.ts | 209 ++++++++++++++++++ .../__tests__/process-match-result.test.ts | 18 +- app/models/scoring-calculator.ts | 84 +++++++ ...sons.$id.events.$eventId.bracket.server.ts | 42 +++- 6 files changed, 437 insertions(+), 13 deletions(-) create mode 100644 app/models/__tests__/bracket-entry-floors.test.ts diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index a687d32..288cc67 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -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", diff --git a/app/models/__tests__/afl-finals.test.ts b/app/models/__tests__/afl-finals.test.ts index 1fb5201..3efe01b 100644 --- a/app/models/__tests__/afl-finals.test.ts +++ b/app/models/__tests__/afl-finals.test.ts @@ -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)); + }); + }); +}); diff --git a/app/models/__tests__/bracket-entry-floors.test.ts b/app/models/__tests__/bracket-entry-floors.test.ts new file mode 100644 index 0000000..94ea850 --- /dev/null +++ b/app/models/__tests__/bracket-entry-floors.test.ts @@ -0,0 +1,209 @@ +/** + * 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 = {} +) { + const insertedRows: Array> = []; + const updatedRows: Array> = []; + + return { + db: { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockImplementation((values: Record) => { + insertedRows.push(values); + return Promise.resolve(); + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockImplementation((values: Record) => { + updatedRows.push(values); + return { where: vi.fn().mockResolvedValue(undefined) }; + }), + }), + query: { + scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) }, + playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) }, + seasonParticipantResults: { + 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("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(); + }); +}); diff --git a/app/models/__tests__/process-match-result.test.ts b/app/models/__tests__/process-match-result.test.ts index df825ee..8967180 100644 --- a/app/models/__tests__/process-match-result.test.ts +++ b/app/models/__tests__/process-match-result.test.ts @@ -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)", () => { diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index cf6ef02..258eab8 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -174,6 +174,90 @@ 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. + * Idempotent: re-running over the same bracket recomputes identical values, and + * upsertParticipantResult's never-un-finalize guard leaves finalized rows alone. + * + * Returns the number of participants given a floor. + */ +export async function applyBracketEntryFloors( + eventId: string, + providedDb?: ReturnType +): Promise { + 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(); + 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); + } + } + } + + let applied = 0; + for (const [participantId, floor] of floorByParticipant) { + 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. diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 0ffcc73..feff81c 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -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) { @@ -408,13 +427,6 @@ export async function action({ request, params }: Route.ActionArgs) { 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, - }); - return { success: "Bracket generated successfully" }; } catch (error) { logger.error("Error generating bracket:", error); @@ -883,8 +895,8 @@ 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. @@ -893,6 +905,11 @@ export async function action({ request, params }: Route.ActionArgs) { const db = database(); await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db); + // Re-bank the seeding-derived floors the delete above just wiped (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; const roundOrder = template ? template.rounds.map((r) => r.name) : []; @@ -952,7 +969,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 {