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[],
|
matches: MatchRow[],
|
||||||
existingByParticipant: Record<string, { id: string; finalPosition: number; isPartialScore: boolean }> = {}
|
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 insertedRows: Array<Record<string, unknown>> = [];
|
||||||
const updatedRows: Array<Record<string, unknown>> = [];
|
const updatedRows: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
|
@ -47,6 +51,8 @@ function makeDb(
|
||||||
scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) },
|
scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) },
|
||||||
playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) },
|
playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) },
|
||||||
seasonParticipantResults: {
|
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 }) => {
|
findFirst: vi.fn().mockImplementation((args: { where?: unknown }) => {
|
||||||
// Resolve by scanning the seeded map — the mock has no real query engine,
|
// 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.
|
// so tests that need an existing row use a single-participant bracket.
|
||||||
|
|
@ -128,6 +134,34 @@ describe("applyBracketEntryFloors", () => {
|
||||||
expect(insertedRows).toHaveLength(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 () => {
|
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
|
// upsertParticipantResult's never-un-finalize guard: a finalized row must not be
|
||||||
// dragged back to a provisional floor when the bracket is regenerated.
|
// 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
|
* 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.
|
* 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(
|
export async function applyBracketEntryFloors(
|
||||||
eventId: string,
|
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;
|
let applied = 0;
|
||||||
for (const [participantId, floor] of floorByParticipant) {
|
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(
|
const oldFloor = await upsertParticipantResult(
|
||||||
participantId,
|
participantId,
|
||||||
event.sportsSeasonId,
|
event.sportsSeasonId,
|
||||||
|
|
|
||||||
|
|
@ -425,6 +425,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
.map((p) => p.id);
|
.map((p) => p.id);
|
||||||
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
|
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
|
||||||
logger.log(`[BracketGeneration] Marked ${eliminatedCount} participants as eliminated`);
|
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" };
|
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.
|
// Delete ALL results for this sports season and rebuild from scratch.
|
||||||
// Only deleting partial rows leaves stale finalized rows that block
|
// Only deleting partial rows leaves stale finalized rows that block
|
||||||
// the "never un-finalize" guard in upsertParticipantResult.
|
// 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();
|
const db = database();
|
||||||
|
if (completed.length > 0) {
|
||||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
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
|
// 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.
|
// them; a bracket with no completed matches still gets its guaranteed points.
|
||||||
const entryFloorCount = await applyBracketEntryFloors(params.eventId, db);
|
const entryFloorCount = await applyBracketEntryFloors(params.eventId, db);
|
||||||
|
|
|
||||||
|
|
@ -266,5 +266,57 @@ describe("probability-updater", () => {
|
||||||
expect(callArgs.probabilities.probSeventh).toBe(0);
|
expect(callArgs.probabilities.probSeventh).toBe(0);
|
||||||
expect(callArgs.probabilities.probEighth).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
|
// Get all existing EVs
|
||||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
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(
|
const finishedMap = new Map(
|
||||||
results
|
results
|
||||||
.filter(r => r.finalPosition !== null)
|
.filter(r => r.finalPosition !== null && !r.isPartialScore)
|
||||||
.map(r => [r.participantId, r.finalPosition ?? 0])
|
.map(r => [r.participantId, r.finalPosition ?? 0])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue