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..3745ea6 --- /dev/null +++ b/app/models/__tests__/bracket-entry-floors.test.ts @@ -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 = {} +) { + const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({ + participantId, + finalPosition: row.finalPosition, + })); + 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: { + // 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(); + }); +}); 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..2f4fbd4 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -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 +): 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); + } + } + } + + // 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. 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..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 @@ -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 { 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]) );