Award AFL top-4 their guaranteed points when the bracket is set #143

Merged
chrisp merged 2 commits from claude/afl-top-4-guaranteed-points-lof6ze into main 2026-08-24 17:24:26 +00:00
5 changed files with 139 additions and 7 deletions
Showing only changes of commit 21422a7e92 - Show all commits

View file

@ -26,6 +26,10 @@ function makeDb(
matches: MatchRow[],
existingByParticipant: Record<string, { id: string; finalPosition: number; isPartialScore: boolean }> = {}
) {
const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({
participantId,
finalPosition: row.finalPosition,
}));
const insertedRows: Array<Record<string, unknown>> = [];
const updatedRows: Array<Record<string, unknown>> = [];
@ -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.

View file

@ -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,

View file

@ -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();
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);

View file

@ -266,5 +266,57 @@ describe("probability-updater", () => {
expect(callArgs.probabilities.probSeventh).toBe(0);
expect(callArgs.probabilities.probEighth).toBe(0);
});
it("does NOT treat a provisional floor as finished — the team is still playing", async () => {
// An AFL top-4 seed banks a provisional 5th-6th floor at seeding. Pinning them
// to 100% at 5th would erase their championship odds before they have played.
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
{
id: "result-1",
participantId: "participant-1",
sportsSeasonId: "season-1",
finalPosition: 5,
isPartialScore: true,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
participant: null,
},
] as never);
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
const result = await updateProbabilitiesAfterResult("season-1", false);
expect(result.finishedParticipants).toBe(0);
expect(upsertSpy).not.toHaveBeenCalled();
});
it("still finalizes a 0-position elimination — those rows are not partial", async () => {
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([
{
id: "result-1",
participantId: "participant-1",
sportsSeasonId: "season-1",
finalPosition: 0,
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
participant: null,
},
] as never);
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]);
const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
const result = await updateProbabilitiesAfterResult("season-1", false);
expect(result.finishedParticipants).toBe(1);
expect(upsertSpy.mock.calls[0][0].probabilities.probFirst).toBe(0);
});
});
});

View file

@ -123,10 +123,17 @@ export async function updateProbabilitiesAfterResult(
// Get all existing EVs
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
// Create map of participantId -> finalPosition
// Create map of participantId -> finalPosition.
//
// Provisional rows (isPartialScore) are NOT finished: they are the guaranteed
// minimum for someone still alive — a bracket entry floor, or the floor banked
// by winning a round. Treating them as finished pins the participant to 100% at
// that floor and drops them from the ICM recalculation below, which would zero
// the championship odds of every team still playing. They belong in the
// unfinished set until a real result lands.
const finishedMap = new Map(
results
.filter(r => r.finalPosition !== null)
.filter(r => r.finalPosition !== null && !r.isPartialScore)
.map(r => [r.participantId, r.finalPosition ?? 0])
);