Award AFL top-4 their guaranteed points when the bracket is set
An AFL top-4 seed has the double chance from the moment the bracket is
drawn: lose the Qualifying Final, lose the Semi-Final, and you still
finish in the 5th-6th tier. Nothing was awarding that. Seeds 1-4 sat on
0 fantasy points until their first game resolved, which understated
every roster holding them.
Add an `entryFloor` field to BracketRound for floors a seeding locks in
before anyone plays, plus `applyBracketEntryFloors` to bank them, wired
into both bracket generation and reprocess-bracket. For afl_10 that is 5
for the Qualifying Finals (seeds 1-4) and 7 for the Elimination Finals
(seeds 5-6). Every write is provisional, so a real result supersedes it,
and upsertParticipantResult's never-un-finalize guard leaves finalized
rows alone.
Two related floors were also wrong, both from the generic
"winning into a scoring round means top-8" default in
nonScoringWinnerFloorFor:
- Qualifying Finals winners banked 5 when the bye to a Preliminary
Final guarantees the 3rd-4th tier. progressive-floor-scoring.test.ts
already asserted 3 here, but via an isScoring=true call the runtime
never makes.
- Wildcard winners banked 5 when winning only buys an Elimination
Final, whose losers are the 7th-8th tier — an over-award of a full
tier until that game was played.
Both are now explicit nonScoringWinnerFloor values on the template.
reprocess-bracket now applies entry floors after wiping results and
before replaying matches, and no longer refuses a bracket with no
completed matches, so setting a bracket and reprocessing awards the
guaranteed points. It stays silent on Discord as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
2026-08-24 16:56:33 +00:00
|
|
|
/**
|
|
|
|
|
* Entry-floor scoring: points a bracket guarantees at seeding time.
|
|
|
|
|
*
|
|
|
|
|
* Some seedings lock in a scoring tier before a single match is played. The AFL
|
|
|
|
|
* finals are the clearest case: a top-4 seed has the double chance, so losing the
|
|
|
|
|
* Qualifying Final still leaves them a Semi-Final, and losing that is the 5th-6th
|
|
|
|
|
* tier. Those teams must not sit on 0 fantasy points until their first game.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
|
|
|
|
|
|
interface MatchRow {
|
|
|
|
|
round: string;
|
|
|
|
|
participant1Id: string | null;
|
|
|
|
|
participant2Id: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Minimal db mock. applyBracketEntryFloors calls, in order:
|
|
|
|
|
* 1. db.query.scoringEvents.findFirst → the event (for template + sportsSeasonId)
|
|
|
|
|
* 2. db.query.playoffMatches.findMany → the bracket's match slots
|
|
|
|
|
* 3. upsertParticipantResult per floored participant → findFirst + insert/update
|
|
|
|
|
*/
|
|
|
|
|
function makeDb(
|
|
|
|
|
event: { bracketTemplateId: string | null; sportsSeasonId: string } | null,
|
|
|
|
|
matches: MatchRow[],
|
|
|
|
|
existingByParticipant: Record<string, { id: string; finalPosition: number; isPartialScore: boolean }> = {}
|
|
|
|
|
) {
|
2026-08-24 17:13:45 +00:00
|
|
|
const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({
|
|
|
|
|
participantId,
|
|
|
|
|
finalPosition: row.finalPosition,
|
|
|
|
|
}));
|
Award AFL top-4 their guaranteed points when the bracket is set
An AFL top-4 seed has the double chance from the moment the bracket is
drawn: lose the Qualifying Final, lose the Semi-Final, and you still
finish in the 5th-6th tier. Nothing was awarding that. Seeds 1-4 sat on
0 fantasy points until their first game resolved, which understated
every roster holding them.
Add an `entryFloor` field to BracketRound for floors a seeding locks in
before anyone plays, plus `applyBracketEntryFloors` to bank them, wired
into both bracket generation and reprocess-bracket. For afl_10 that is 5
for the Qualifying Finals (seeds 1-4) and 7 for the Elimination Finals
(seeds 5-6). Every write is provisional, so a real result supersedes it,
and upsertParticipantResult's never-un-finalize guard leaves finalized
rows alone.
Two related floors were also wrong, both from the generic
"winning into a scoring round means top-8" default in
nonScoringWinnerFloorFor:
- Qualifying Finals winners banked 5 when the bye to a Preliminary
Final guarantees the 3rd-4th tier. progressive-floor-scoring.test.ts
already asserted 3 here, but via an isScoring=true call the runtime
never makes.
- Wildcard winners banked 5 when winning only buys an Elimination
Final, whose losers are the 7th-8th tier — an over-award of a full
tier until that game was played.
Both are now explicit nonScoringWinnerFloor values on the template.
reprocess-bracket now applies entry floors after wiping results and
before replaying matches, and no longer refuses a bracket with no
completed matches, so setting a bracket and reprocessing awards the
guaranteed points. It stays silent on Discord as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
2026-08-24 16:56:33 +00:00
|
|
|
const insertedRows: Array<Record<string, unknown>> = [];
|
|
|
|
|
const updatedRows: Array<Record<string, unknown>> = [];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
db: {
|
|
|
|
|
insert: vi.fn().mockReturnValue({
|
|
|
|
|
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
|
|
|
|
insertedRows.push(values);
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
update: vi.fn().mockReturnValue({
|
|
|
|
|
set: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
|
|
|
|
updatedRows.push(values);
|
|
|
|
|
return { where: vi.fn().mockResolvedValue(undefined) };
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
query: {
|
|
|
|
|
scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) },
|
|
|
|
|
playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) },
|
|
|
|
|
seasonParticipantResults: {
|
2026-08-24 17:13:45 +00:00
|
|
|
// The pre-pass that stops a floor from downgrading an existing placement.
|
|
|
|
|
findMany: vi.fn().mockResolvedValue(existingRows),
|
Award AFL top-4 their guaranteed points when the bracket is set
An AFL top-4 seed has the double chance from the moment the bracket is
drawn: lose the Qualifying Final, lose the Semi-Final, and you still
finish in the 5th-6th tier. Nothing was awarding that. Seeds 1-4 sat on
0 fantasy points until their first game resolved, which understated
every roster holding them.
Add an `entryFloor` field to BracketRound for floors a seeding locks in
before anyone plays, plus `applyBracketEntryFloors` to bank them, wired
into both bracket generation and reprocess-bracket. For afl_10 that is 5
for the Qualifying Finals (seeds 1-4) and 7 for the Elimination Finals
(seeds 5-6). Every write is provisional, so a real result supersedes it,
and upsertParticipantResult's never-un-finalize guard leaves finalized
rows alone.
Two related floors were also wrong, both from the generic
"winning into a scoring round means top-8" default in
nonScoringWinnerFloorFor:
- Qualifying Finals winners banked 5 when the bye to a Preliminary
Final guarantees the 3rd-4th tier. progressive-floor-scoring.test.ts
already asserted 3 here, but via an isScoring=true call the runtime
never makes.
- Wildcard winners banked 5 when winning only buys an Elimination
Final, whose losers are the 7th-8th tier — an over-award of a full
tier until that game was played.
Both are now explicit nonScoringWinnerFloor values on the template.
reprocess-bracket now applies entry floors after wiping results and
before replaying matches, and no longer refuses a bracket with no
completed matches, so setting a bracket and reprocessing awards the
guaranteed points. It stays silent on Discord as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
2026-08-24 16:56:33 +00:00
|
|
|
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.
|
|
|
|
|
void args;
|
|
|
|
|
const only = Object.values(existingByParticipant)[0];
|
|
|
|
|
return Promise.resolve(only);
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
} as never,
|
|
|
|
|
insertedRows,
|
|
|
|
|
updatedRows,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
import { applyBracketEntryFloors, getBracketEntryFloor } from "../scoring-calculator";
|
|
|
|
|
|
|
|
|
|
/** The AFL bracket exactly as generateAFL10Bracket writes it: later rounds are TBD. */
|
|
|
|
|
const AFL_BRACKET: MatchRow[] = [
|
|
|
|
|
{ round: "Wildcard Round", participant1Id: "seed7", participant2Id: "seed10" },
|
|
|
|
|
{ round: "Wildcard Round", participant1Id: "seed8", participant2Id: "seed9" },
|
|
|
|
|
{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: "seed4" },
|
|
|
|
|
{ round: "Qualifying Finals", participant1Id: "seed2", participant2Id: "seed3" },
|
|
|
|
|
{ round: "Elimination Finals", participant1Id: "seed5", participant2Id: null },
|
|
|
|
|
{ round: "Elimination Finals", participant1Id: "seed6", participant2Id: null },
|
|
|
|
|
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
|
|
|
|
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
|
|
|
|
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
|
|
|
|
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
|
|
|
|
{ round: "Grand Final", participant1Id: null, participant2Id: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
describe("applyBracketEntryFloors", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("afl_10", () => {
|
|
|
|
|
it("banks 5 for the top 4 and 7 for seeds 5-6, and nothing for the wildcard teams", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb(
|
|
|
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
|
|
|
AFL_BRACKET
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const applied = await applyBracketEntryFloors("event-1", db);
|
|
|
|
|
|
|
|
|
|
expect(applied).toBe(6);
|
|
|
|
|
const floors = Object.fromEntries(
|
|
|
|
|
insertedRows.map((r) => [r.participantId as string, r.finalPosition as number])
|
|
|
|
|
);
|
|
|
|
|
expect(floors).toEqual({
|
|
|
|
|
seed1: 5, seed2: 5, seed3: 5, seed4: 5, // double chance → 5th-6th tier
|
|
|
|
|
seed5: 7, seed6: 7, // seeded into the Elimination Finals
|
|
|
|
|
});
|
|
|
|
|
// Seeds 7-10 lose the Wildcard Round for 0, so nothing is guaranteed yet.
|
|
|
|
|
expect(floors).not.toHaveProperty("seed7");
|
|
|
|
|
expect(floors).not.toHaveProperty("seed10");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("writes every floor as provisional so real results supersede it", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb(
|
|
|
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
|
|
|
AFL_BRACKET
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await applyBracketEntryFloors("event-1", db);
|
|
|
|
|
|
|
|
|
|
expect(insertedRows.every((r) => r.isPartialScore === true)).toBe(true);
|
|
|
|
|
expect(insertedRows.every((r) => r.sportsSeasonId === "ss-1")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("leaves TBD slots alone — a Semi-Final nobody has reached grants nothing", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb(
|
|
|
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
|
|
|
[{ round: "Semi-Finals", participant1Id: null, participant2Id: null }]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
|
|
|
expect(insertedRows).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-24 17:13:45 +00:00
|
|
|
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 });
|
|
|
|
|
});
|
|
|
|
|
|
Award AFL top-4 their guaranteed points when the bracket is set
An AFL top-4 seed has the double chance from the moment the bracket is
drawn: lose the Qualifying Final, lose the Semi-Final, and you still
finish in the 5th-6th tier. Nothing was awarding that. Seeds 1-4 sat on
0 fantasy points until their first game resolved, which understated
every roster holding them.
Add an `entryFloor` field to BracketRound for floors a seeding locks in
before anyone plays, plus `applyBracketEntryFloors` to bank them, wired
into both bracket generation and reprocess-bracket. For afl_10 that is 5
for the Qualifying Finals (seeds 1-4) and 7 for the Elimination Finals
(seeds 5-6). Every write is provisional, so a real result supersedes it,
and upsertParticipantResult's never-un-finalize guard leaves finalized
rows alone.
Two related floors were also wrong, both from the generic
"winning into a scoring round means top-8" default in
nonScoringWinnerFloorFor:
- Qualifying Finals winners banked 5 when the bye to a Preliminary
Final guarantees the 3rd-4th tier. progressive-floor-scoring.test.ts
already asserted 3 here, but via an isScoring=true call the runtime
never makes.
- Wildcard winners banked 5 when winning only buys an Elimination
Final, whose losers are the 7th-8th tier — an over-award of a full
tier until that game was played.
Both are now explicit nonScoringWinnerFloor values on the template.
reprocess-bracket now applies entry floors after wiping results and
before replaying matches, and no longer refuses a bracket with no
completed matches, so setting a bracket and reprocessing awards the
guaranteed points. It stays silent on Discord as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
2026-08-24 16:56:33 +00:00
|
|
|
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.
|
|
|
|
|
const { db, insertedRows, updatedRows } = makeDb(
|
|
|
|
|
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
|
|
|
|
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
|
|
|
|
{ seed1: { id: "row-1", finalPosition: 1, isPartialScore: false } }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
|
|
|
expect(insertedRows).toHaveLength(0);
|
|
|
|
|
expect(updatedRows).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("other brackets", () => {
|
|
|
|
|
it("is a no-op for an event with no bracket template", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb(
|
|
|
|
|
{ bracketTemplateId: null, sportsSeasonId: "ss-1" },
|
|
|
|
|
AFL_BRACKET
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
|
|
|
expect(insertedRows).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("is a no-op when the event does not exist", async () => {
|
|
|
|
|
const { db } = makeDb(null, AFL_BRACKET);
|
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("grants nothing to an NBA bracket: every seeded round is non-scoring", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb(
|
|
|
|
|
{ bracketTemplateId: "nba_20", sportsSeasonId: "ss-1" },
|
|
|
|
|
[
|
|
|
|
|
{ round: "Play-In Round 1", participant1Id: "e7", participant2Id: "e8" },
|
|
|
|
|
{ round: "First Round", participant1Id: "e1", participant2Id: null },
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
|
|
|
|
expect(insertedRows).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("grants the T5-8 tier to a simple_8 field: every entrant is already in a scoring round", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb(
|
|
|
|
|
{ bracketTemplateId: "simple_8", sportsSeasonId: "ss-1" },
|
|
|
|
|
[{ round: "Quarterfinals", participant1Id: "a", participant2Id: "b" }]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await applyBracketEntryFloors("event-1", db)).toBe(2);
|
|
|
|
|
expect(insertedRows.map((r) => r.finalPosition)).toEqual([5, 5]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("getBracketEntryFloor", () => {
|
|
|
|
|
it("prefers a round's explicit entryFloor over its loser position", () => {
|
|
|
|
|
// Qualifying Finals is non-scoring, so only the explicit entryFloor makes it pay.
|
|
|
|
|
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back to a scoring round's own loser position", () => {
|
|
|
|
|
expect(getBracketEntryFloor("Quarterfinals", "simple_8")).toBe(5);
|
|
|
|
|
expect(getBracketEntryFloor("Semifinals", "simple_8")).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns null for non-scoring rounds with no explicit floor", () => {
|
|
|
|
|
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
|
|
|
|
expect(getBracketEntryFloor("First Round", "nba_20")).toBeNull();
|
|
|
|
|
expect(getBracketEntryFloor("Round of 64", "ncaa_68")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns null for unknown rounds and templates", () => {
|
|
|
|
|
expect(getBracketEntryFloor("Not A Round", "afl_10")).toBeNull();
|
|
|
|
|
expect(getBracketEntryFloor("Quarterfinals", "not_a_template")).toBeNull();
|
|
|
|
|
expect(getBracketEntryFloor("Quarterfinals", null)).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|