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