From ea58db9595edec3720f8165b1c1ad1f51842d0dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:56:33 +0000 Subject: [PATCH 1/2] 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 { -- 2.45.3 From 21422a7e92823fc75cf7c28f776557a0bcf6ea83 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:13:45 +0000 Subject: [PATCH 2/2] Fix four issues found reviewing the entry-floor change - 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 Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd --- .../__tests__/bracket-entry-floors.test.ts | 34 ++++++++++++ app/models/scoring-calculator.ts | 26 ++++++++-- ...sons.$id.events.$eventId.bracket.server.ts | 23 +++++++- .../__tests__/probability-updater.test.ts | 52 +++++++++++++++++++ app/services/probability-updater.ts | 11 +++- 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/app/models/__tests__/bracket-entry-floors.test.ts b/app/models/__tests__/bracket-entry-floors.test.ts index 94ea850..3745ea6 100644 --- a/app/models/__tests__/bracket-entry-floors.test.ts +++ b/app/models/__tests__/bracket-entry-floors.test.ts @@ -26,6 +26,10 @@ function makeDb( matches: MatchRow[], existingByParticipant: Record = {} ) { + const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({ + participantId, + finalPosition: row.finalPosition, + })); const insertedRows: Array> = []; const updatedRows: Array> = []; @@ -47,6 +51,8 @@ function makeDb( 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. @@ -128,6 +134,34 @@ describe("applyBracketEntryFloors", () => { 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. diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 258eab8..2f4fbd4 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -208,10 +208,14 @@ export function getBracketEntryFloor( * * 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. + * 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, @@ -243,8 +247,24 @@ export async function applyBracketEntryFloors( } } + // 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, 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 feff81c..fcae69b 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 @@ -425,6 +425,17 @@ 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`); + + // 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" }; @@ -902,10 +913,18 @@ export async function action({ request, params }: Route.ActionArgs) { // 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 just wiped (e.g. the AFL + // 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); diff --git a/app/services/__tests__/probability-updater.test.ts b/app/services/__tests__/probability-updater.test.ts index 4187802..21faa64 100644 --- a/app/services/__tests__/probability-updater.test.ts +++ b/app/services/__tests__/probability-updater.test.ts @@ -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); + }); }); }); diff --git a/app/services/probability-updater.ts b/app/services/probability-updater.ts index a3a7511..d7e4081 100644 --- a/app/services/probability-updater.ts +++ b/app/services/probability-updater.ts @@ -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]) ); -- 2.45.3