Fix four issues found reviewing the entry-floor change
- Provisional rows were being treated as finished by updateProbabilitiesAfterResult, whose finishedMap filtered on finalPosition alone. Entry floors made that fire for the whole seeded field: on the first match result, AFL seeds 1-6 would each be pinned to 100% at their floor position and dropped from the ICM recalc, zeroing the championship odds of six teams that had not played. Filter partial rows out of finishedMap so they stay in the unfinished set. Finalized 0-position eliminations still finalize as before. - generate-bracket recalculated standings only inside markEliminatedAndAnnounce, which no-ops when nothing was eliminated. A season whose participants exactly equal the bracket field would never surface the floors in teamStandings.totalPoints. Recalculate explicitly in that case (skipDiscord: seeding is not a result). - applyBracketEntryFloors upserted unconditionally, so regenerating a bracket mid-tournament could downgrade a team already sitting on a better placement. Read existing placements first and only write when the floor improves on what a participant already has; position 0 is eliminated, not a placement, so it never blocks a floor. - Relaxing the reprocess guard to matches.length made the season-wide deleteParticipantResultsBySportsSeasonId reachable with zero completed matches, wiping other events' placements with no replay able to rebuild them. Skip the wipe when there is nothing to replay; entry floors and elimination marking are additive and need no wipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
This commit is contained in:
parent
ea58db9595
commit
21422a7e92
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