Award AFL top-4 their guaranteed points when the bracket is set #143
5 changed files with 139 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue