Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
33f9ad6ebe
commit
dbffddc9ff
12 changed files with 4320 additions and 187 deletions
|
|
@ -110,6 +110,54 @@ interface EliminatedEntry {
|
||||||
ownership: TeamOwnership | null;
|
ownership: TeamOwnership | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each complete match, determine which losers should be shown as eliminated
|
||||||
|
* in that round. In double-chance brackets (e.g. AFL), a participant who lost
|
||||||
|
* round X but then won a later round Y is NOT eliminated at round X — only their
|
||||||
|
* final loss counts.
|
||||||
|
*
|
||||||
|
* Returns a map of round name → array of eliminated participant IDs.
|
||||||
|
* Exported for unit testing.
|
||||||
|
*/
|
||||||
|
export function computeEliminatedByRound(
|
||||||
|
matches: Array<{
|
||||||
|
round: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
winnerId: string | null;
|
||||||
|
loser?: { id: string; name: string } | null;
|
||||||
|
}>,
|
||||||
|
rounds: string[]
|
||||||
|
): Map<string, string[]> {
|
||||||
|
const roundIndex = new Map(rounds.map((r, i) => [r, i]));
|
||||||
|
|
||||||
|
// Track the latest round (by index) each participant has APPEARED IN (as winner OR loser).
|
||||||
|
// Tracking appearances — not just wins — correctly handles AFL double-chance brackets,
|
||||||
|
// where a Qualifying Finals loser advances to Semi-Finals automatically (no intermediate
|
||||||
|
// win required). If a participant appears in a later round, they are not yet eliminated.
|
||||||
|
const participantLatestRound = new Map<string, number>();
|
||||||
|
const updateLatest = (id: string, ri: number) => {
|
||||||
|
const prev = participantLatestRound.get(id) ?? -1;
|
||||||
|
if (ri > prev) participantLatestRound.set(id, ri);
|
||||||
|
};
|
||||||
|
for (const match of matches) {
|
||||||
|
const ri = roundIndex.get(match.round) ?? -1;
|
||||||
|
if (match.winnerId) updateLatest(match.winnerId, ri);
|
||||||
|
if (match.loser?.id) updateLatest(match.loser.id, ri);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, string[]>();
|
||||||
|
for (const match of matches) {
|
||||||
|
if (!match.isComplete || !match.loser) continue;
|
||||||
|
const lossRoundIndex = roundIndex.get(match.round) ?? -1;
|
||||||
|
const loserLatestAppear = participantLatestRound.get(match.loser.id) ?? -1;
|
||||||
|
// Double-chance survivor: participant appeared in a LATER round — not yet eliminated here.
|
||||||
|
if (loserLatestAppear > lossRoundIndex) continue;
|
||||||
|
if (!result.has(match.round)) result.set(match.round, []);
|
||||||
|
result.get(match.round)!.push(match.loser.id);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function PlayoffBracket({
|
export function PlayoffBracket({
|
||||||
matches,
|
matches,
|
||||||
rounds,
|
rounds,
|
||||||
|
|
@ -150,17 +198,13 @@ export function PlayoffBracket({
|
||||||
: null;
|
: null;
|
||||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||||
|
|
||||||
// Build the set of all participants who won at least one completed match.
|
// Compute which participants are eliminated in which round, handling double-chance.
|
||||||
// If a participant won any match, their earlier loss was not their final elimination.
|
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||||
const participantWinIds = new Set<string>();
|
|
||||||
for (const match of matches) {
|
|
||||||
if (match.isComplete && match.winnerId) participantWinIds.add(match.winnerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (!match.isComplete || !match.loser) continue;
|
if (!match.isComplete || !match.loser) continue;
|
||||||
// Skip if this loser went on to win another match (double-chance bracket)
|
const eliminatedInRound = eliminatedByRound.get(match.round);
|
||||||
if (participantWinIds.has(match.loser.id)) continue;
|
if (!eliminatedInRound?.includes(match.loser.id)) continue;
|
||||||
const loserScore =
|
const loserScore =
|
||||||
match.loserId === match.participant1Id
|
match.loserId === match.participant1Id
|
||||||
? match.participant1Score
|
? match.participant1Score
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { buildFeederMap, groupMatchesByRound } from "../PlayoffBracket";
|
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -136,3 +136,101 @@ describe("buildFeederMap", () => {
|
||||||
expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
|
expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// computeEliminatedByRound — double-chance bracket logic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type TestMatch = Parameters<typeof computeEliminatedByRound>[0][number];
|
||||||
|
|
||||||
|
function makeCompleteMatch(
|
||||||
|
round: string,
|
||||||
|
winnerId: string,
|
||||||
|
loserId: string
|
||||||
|
): TestMatch {
|
||||||
|
return {
|
||||||
|
round,
|
||||||
|
isComplete: true,
|
||||||
|
winnerId,
|
||||||
|
loser: { id: loserId, name: loserId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeEliminatedByRound", () => {
|
||||||
|
describe("standard single-elimination (no double-chance)", () => {
|
||||||
|
it("a R16 loser who never wins again IS shown as eliminated at R16", () => {
|
||||||
|
const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"];
|
||||||
|
const matches: TestMatch[] = [
|
||||||
|
// teamA wins R16; teamB loses R16 and has no further matches
|
||||||
|
makeCompleteMatch("Round of 16", "teamA", "teamB"),
|
||||||
|
makeCompleteMatch("Quarterfinals", "teamC", "teamA"), // teamA loses QF
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = computeEliminatedByRound(matches, rounds);
|
||||||
|
|
||||||
|
// teamB is eliminated at R16 (never won a later round)
|
||||||
|
expect(result.get("Round of 16")).toContain("teamB");
|
||||||
|
// teamA is eliminated at QF (won R16, but lost QF)
|
||||||
|
expect(result.get("Quarterfinals")).toContain("teamA");
|
||||||
|
expect(result.get("Round of 16")).not.toContain("teamA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AFL double-chance bracket", () => {
|
||||||
|
// AFL afl_10 round order: Qualifying Finals → Elimination Finals →
|
||||||
|
// Semi-Finals → Preliminary Finals → Grand Final
|
||||||
|
const AFL_ROUNDS = [
|
||||||
|
"Qualifying Finals",
|
||||||
|
"Elimination Finals",
|
||||||
|
"Semi-Finals",
|
||||||
|
"Preliminary Finals",
|
||||||
|
"Grand Final",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("QF loser who wins a later Semi-Final is NOT shown as eliminated at QF", () => {
|
||||||
|
const matches: TestMatch[] = [
|
||||||
|
// teamA loses Qualifying Finals (double-chance — they come back via Semi-Finals)
|
||||||
|
makeCompleteMatch("Qualifying Finals", "teamB", "teamA"),
|
||||||
|
// teamA wins Semi-Finals comeback match
|
||||||
|
makeCompleteMatch("Semi-Finals", "teamA", "teamC"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = computeEliminatedByRound(matches, AFL_ROUNDS);
|
||||||
|
|
||||||
|
// teamA should NOT appear as eliminated at Qualifying Finals
|
||||||
|
expect(result.get("Qualifying Finals") ?? []).not.toContain("teamA");
|
||||||
|
// teamC (who lost the Semi-Final to teamA) IS eliminated
|
||||||
|
expect(result.get("Semi-Finals")).toContain("teamC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("QF loser who LATER loses their Semi-Final comeback IS shown as eliminated at Semi-Finals", () => {
|
||||||
|
const matches: TestMatch[] = [
|
||||||
|
makeCompleteMatch("Qualifying Finals", "teamB", "teamA"),
|
||||||
|
// teamA comes back via Semi-Finals but loses
|
||||||
|
makeCompleteMatch("Semi-Finals", "teamC", "teamA"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = computeEliminatedByRound(matches, AFL_ROUNDS);
|
||||||
|
|
||||||
|
// teamA finally eliminated at Semi-Finals, not at Qualifying Finals
|
||||||
|
expect(result.get("Qualifying Finals") ?? []).not.toContain("teamA");
|
||||||
|
expect(result.get("Semi-Finals")).toContain("teamA");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("winning an EARLIER round (normal advancement) does not protect a later loser", () => {
|
||||||
|
// Standard case: teamA wins R1, then loses R2 — they ARE eliminated at R2
|
||||||
|
const rounds = ["Round of 16", "Quarterfinals", "Semifinals"];
|
||||||
|
const matches: TestMatch[] = [
|
||||||
|
makeCompleteMatch("Round of 16", "teamA", "teamZ"),
|
||||||
|
makeCompleteMatch("Quarterfinals", "teamB", "teamA"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = computeEliminatedByRound(matches, rounds);
|
||||||
|
|
||||||
|
// teamA's R16 win is normal advancement, not double-chance
|
||||||
|
expect(result.get("Quarterfinals")).toContain("teamA");
|
||||||
|
// teamA should not appear in R16 eliminated list
|
||||||
|
expect(result.get("Round of 16") ?? []).not.toContain("teamA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
242
app/models/__tests__/process-match-result.test.ts
Normal file
242
app/models/__tests__/process-match-result.test.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock external DB-touching dependencies before importing the module under test
|
||||||
|
vi.mock("~/services/probability-updater", () => ({
|
||||||
|
updateProbabilitiesAfterResult: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Minimal db mock ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// processMatchResult calls, in order:
|
||||||
|
// 1. upsertParticipantResult (x2) → db.query.participantResults.findFirst, db.insert
|
||||||
|
// 2. recalculateAffectedLeagues → db.query.seasonSports.findMany (returns [] so loop exits)
|
||||||
|
// 3. updateProbabilitiesAfterResult (mocked above)
|
||||||
|
|
||||||
|
function makeDb(existingResult?: { id: string; isPartialScore: boolean }) {
|
||||||
|
// Track all inserted values in order
|
||||||
|
const insertedRows: object[] = [];
|
||||||
|
const updatedRows: object[] = [];
|
||||||
|
|
||||||
|
const insertValues = vi.fn().mockImplementation((values: object) => {
|
||||||
|
insertedRows.push(values);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const updateSet = vi.fn().mockImplementation((values: object) => {
|
||||||
|
updatedRows.push(values);
|
||||||
|
return { where: updateWhere };
|
||||||
|
});
|
||||||
|
|
||||||
|
// participantResults.findFirst: first call gets existingResult (if any),
|
||||||
|
// subsequent calls return undefined (winner/loser don't have existing rows)
|
||||||
|
let findFirstCallCount = 0;
|
||||||
|
const findFirst = vi.fn().mockImplementation(() => {
|
||||||
|
const result = findFirstCallCount === 0 ? existingResult : undefined;
|
||||||
|
findFirstCallCount++;
|
||||||
|
return Promise.resolve(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
insert: vi.fn().mockReturnValue({ values: insertValues }),
|
||||||
|
update: vi.fn().mockReturnValue({ set: updateSet }),
|
||||||
|
query: {
|
||||||
|
participantResults: { findFirst },
|
||||||
|
seasonSports: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]), // empty → standings loop exits immediately
|
||||||
|
},
|
||||||
|
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
insertedRows,
|
||||||
|
updatedRows,
|
||||||
|
findFirst,
|
||||||
|
insertValues,
|
||||||
|
updateSet,
|
||||||
|
updateWhere,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import { processMatchResult } from "../scoring-calculator";
|
||||||
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
|
|
||||||
|
const BASE = {
|
||||||
|
winnerId: "winner-1",
|
||||||
|
loserId: "loser-1",
|
||||||
|
sportsSeasonId: "ss-1",
|
||||||
|
bracketTemplateId: null as string | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("processMatchResult", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Quarterfinals (scoring)", () => {
|
||||||
|
it("loser gets position 5 (final), winner gets position 3 (provisional)", async () => {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult(
|
||||||
|
{ ...BASE, round: "Quarterfinals", isScoring: true },
|
||||||
|
db
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(insertedRows).toHaveLength(2);
|
||||||
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 5, isPartialScore: false });
|
||||||
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 3, isPartialScore: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also works for Elite Eight / Divisional / Conference Semifinals", async () => {
|
||||||
|
for (const round of ["Elite Eight", "Divisional", "Conference Semifinals"]) {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
await processMatchResult({ ...BASE, round, isScoring: true }, db);
|
||||||
|
expect(insertedRows[0]).toMatchObject({ finalPosition: 5, isPartialScore: false });
|
||||||
|
expect(insertedRows[1]).toMatchObject({ finalPosition: 3, isPartialScore: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recalculates standings (calls db.query.seasonSports.findMany)", async () => {
|
||||||
|
const { db } = makeDb();
|
||||||
|
await processMatchResult({ ...BASE, round: "Quarterfinals", isScoring: true }, db);
|
||||||
|
expect((db.query.seasonSports.findMany as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Semifinals (scoring)", () => {
|
||||||
|
it("loser gets position 3 (final), winner gets position 2 (provisional)", async () => {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult({ ...BASE, round: "Semifinals", isScoring: true }, db);
|
||||||
|
|
||||||
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 3, isPartialScore: false });
|
||||||
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 2, isPartialScore: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also works for Final Four / Conference Finals / Conference Championship", async () => {
|
||||||
|
for (const round of ["Final Four", "Conference Finals", "Conference Championship"]) {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
await processMatchResult({ ...BASE, round, isScoring: true }, db);
|
||||||
|
expect(insertedRows[0]).toMatchObject({ finalPosition: 3, isPartialScore: false });
|
||||||
|
expect(insertedRows[1]).toMatchObject({ finalPosition: 2, isPartialScore: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Finals / Championship (scoring)", () => {
|
||||||
|
it("loser=2nd (final), winner=1st (final), no third row", async () => {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult({ ...BASE, round: "Finals", isScoring: true }, db);
|
||||||
|
|
||||||
|
expect(insertedRows).toHaveLength(2);
|
||||||
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 2, isPartialScore: false });
|
||||||
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 1, isPartialScore: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles all Finals aliases the same way", async () => {
|
||||||
|
for (const round of ["Championship", "Super Bowl", "NBA Finals", "Grand Final"]) {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
await processMatchResult({ ...BASE, round, isScoring: true }, db);
|
||||||
|
expect(insertedRows).toHaveLength(2);
|
||||||
|
expect(insertedRows[0]).toMatchObject({ finalPosition: 2, isPartialScore: false });
|
||||||
|
expect(insertedRows[1]).toMatchObject({ finalPosition: 1, isPartialScore: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Non-scoring round (isScoring=false)", () => {
|
||||||
|
it("loser eliminated (pos 0 final), winner gets provisional T5 floor", async () => {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult({ ...BASE, round: "Round of 16", isScoring: false }, db);
|
||||||
|
|
||||||
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
||||||
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Unrecognized scoring round", () => {
|
||||||
|
it("gives loser 0 pts and still completes without throwing", async () => {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult({ ...BASE, round: "Mystery Round", isScoring: true }, db);
|
||||||
|
|
||||||
|
expect(insertedRows[0]).toMatchObject({ finalPosition: 0, isPartialScore: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AFL rounds", () => {
|
||||||
|
it("Elimination Finals: loser exits at position 7 (final)", async () => {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult(
|
||||||
|
{ ...BASE, round: "Elimination Finals", isScoring: true, bracketTemplateId: "afl_10" },
|
||||||
|
db
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 7, isPartialScore: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Qualifying Finals: loser is still alive (provisional floor 5)", async () => {
|
||||||
|
const { db, insertedRows } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult(
|
||||||
|
{ ...BASE, round: "Qualifying Finals", isScoring: true, bracketTemplateId: "afl_10" },
|
||||||
|
db
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loser row is provisional (isPartialScore=true)
|
||||||
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 5, isPartialScore: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Idempotency: already-final row is not un-finalized", () => {
|
||||||
|
it("does not overwrite a finalized row with a partial score", async () => {
|
||||||
|
// findFirst returns a finalized row for the first participant (loser)
|
||||||
|
const existing = { id: "result-1", isPartialScore: false };
|
||||||
|
const { db, insertedRows, updatedRows } = makeDb(existing);
|
||||||
|
|
||||||
|
await processMatchResult(
|
||||||
|
{ ...BASE, round: "Quarterfinals", isScoring: true },
|
||||||
|
db
|
||||||
|
);
|
||||||
|
|
||||||
|
// The guard in upsertParticipantResult should skip the update when
|
||||||
|
// existing.isPartialScore=false and we're trying to write isPartialScore=true.
|
||||||
|
// The loser row would try to write isPartialScore=false (final) — that IS
|
||||||
|
// allowed (both false). The winner row tries isPartialScore=true — since
|
||||||
|
// there's no existing row for the winner (findFirst returns undefined on
|
||||||
|
// second call), it inserts.
|
||||||
|
//
|
||||||
|
// Key assertion: no update/insert with isPartialScore=true was applied to
|
||||||
|
// the existing finalized row.
|
||||||
|
const finalizedOverwrite = updatedRows.find(
|
||||||
|
(r: any) => r.isPartialScore === true
|
||||||
|
);
|
||||||
|
expect(finalizedOverwrite).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Probability recalculation", () => {
|
||||||
|
it("always calls updateProbabilitiesAfterResult with the sports season id", async () => {
|
||||||
|
const { db } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult({ ...BASE, round: "Quarterfinals", isScoring: true }, db);
|
||||||
|
|
||||||
|
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw even if probability update fails", async () => {
|
||||||
|
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new Error("network error")
|
||||||
|
);
|
||||||
|
const { db } = makeDb();
|
||||||
|
|
||||||
|
// Should not throw — the error is caught and logged
|
||||||
|
await expect(
|
||||||
|
processMatchResult({ ...BASE, round: "Quarterfinals", isScoring: true }, db)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -77,6 +77,7 @@ export async function findLeaguesWithActiveSeasonsByUserId(
|
||||||
createdBy: schema.leagues.createdBy,
|
createdBy: schema.leagues.createdBy,
|
||||||
currentSeasonId: schema.leagues.currentSeasonId,
|
currentSeasonId: schema.leagues.currentSeasonId,
|
||||||
isPublicDraftBoard: schema.leagues.isPublicDraftBoard,
|
isPublicDraftBoard: schema.leagues.isPublicDraftBoard,
|
||||||
|
discordWebhookUrl: schema.leagues.discordWebhookUrl,
|
||||||
createdAt: schema.leagues.createdAt,
|
createdAt: schema.leagues.createdAt,
|
||||||
updatedAt: schema.leagues.updatedAt,
|
updatedAt: schema.leagues.updatedAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints } from "./scoring-rules";
|
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints } from "./scoring-rules";
|
||||||
import { getSeasonResults } from "./participant-season-result";
|
import { getSeasonResults } from "./participant-season-result";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
|
import { sendStandingsUpdateNotification } from "~/services/discord";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core scoring calculation engine
|
* Core scoring calculation engine
|
||||||
|
|
@ -15,6 +16,79 @@ export type ScoringPattern =
|
||||||
| "season_standings"
|
| "season_standings"
|
||||||
| "qualifying_points";
|
| "qualifying_points";
|
||||||
|
|
||||||
|
// ── Round scoring configuration ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RoundScoringConfig {
|
||||||
|
/** Final position assigned to the loser of this round. */
|
||||||
|
loserPosition: number;
|
||||||
|
/** true = loser is still competing (AFL double-chance); false = loser is eliminated. */
|
||||||
|
loserIsPartial: boolean;
|
||||||
|
/**
|
||||||
|
* Guaranteed minimum position for the winner.
|
||||||
|
* null signals a Finals round: winner is finalized as 1st place (handled inline).
|
||||||
|
*/
|
||||||
|
winnerFloor: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard round name → scoring config mapping.
|
||||||
|
* Covers all common bracket formats (NCAA, NBA, NFL, Darts, Snooker, AFL).
|
||||||
|
*/
|
||||||
|
const ROUND_CONFIG: Record<string, RoundScoringConfig> = {
|
||||||
|
// ── Finals equivalents ─────────────────────────────────────────────────────
|
||||||
|
Finals: { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||||
|
Championship: { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||||
|
"Super Bowl": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||||
|
"NBA Finals": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||||
|
"Grand Final": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||||
|
// ── Semi-final equivalents ─────────────────────────────────────────────────
|
||||||
|
Semifinals: { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
||||||
|
"Final Four": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
||||||
|
"Conference Finals": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
||||||
|
"Conference Championship": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
||||||
|
"Preliminary Finals": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
||||||
|
// ── Quarter-final equivalents ──────────────────────────────────────────────
|
||||||
|
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
||||||
|
"Elite Eight": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
||||||
|
Divisional: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
||||||
|
"Conference Semifinals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
||||||
|
// ── AFL-specific rounds ────────────────────────────────────────────────────
|
||||||
|
// Qualifying Finals losers get a second chance via Semi-Finals (double-chance).
|
||||||
|
"Qualifying Finals": { loserPosition: 5, loserIsPartial: true, winnerFloor: 3 },
|
||||||
|
// Elimination Finals losers are permanently out (7th–8th).
|
||||||
|
"Elimination Finals": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-template round config overrides.
|
||||||
|
* Some round names mean different things in different bracket formats.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Template overrides only apply when bracketTemplateId matches exactly.
|
||||||
|
* For example, "Semi-Finals" in afl_10 eliminates the loser (5th–6th), while a
|
||||||
|
* generic "Semi-Finals" round (without afl_10 template) is not in ROUND_CONFIG at
|
||||||
|
* all — use "Semifinals" (no hyphen) for standard brackets.
|
||||||
|
*/
|
||||||
|
const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>> = {
|
||||||
|
afl_10: {
|
||||||
|
"Semi-Finals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the scoring config for a given round name, applying any template-specific
|
||||||
|
* overrides before falling back to the standard ROUND_CONFIG.
|
||||||
|
* Returns null for unrecognized rounds.
|
||||||
|
*/
|
||||||
|
function getRoundConfig(
|
||||||
|
round: string,
|
||||||
|
bracketTemplateId?: string | null
|
||||||
|
): RoundScoringConfig | null {
|
||||||
|
if (bracketTemplateId && TEMPLATE_ROUND_CONFIG[bracketTemplateId]?.[round]) {
|
||||||
|
return TEMPLATE_ROUND_CONFIG[bracketTemplateId][round];
|
||||||
|
}
|
||||||
|
return ROUND_CONFIG[round] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a playoff event completion and assign final placements.
|
* Process a playoff event completion and assign final placements.
|
||||||
*
|
*
|
||||||
|
|
@ -126,143 +200,60 @@ export async function processPlayoffEvent(
|
||||||
throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`);
|
throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-scoring rounds: losers are pre-bracket eliminated (0 pts).
|
|
||||||
// Winners have cleared the qualifier and are guaranteed a top-8 finish,
|
|
||||||
// so we bank position 5 (T5–T8 floor) as their provisional score immediately.
|
|
||||||
// Using a fixed value of 5 is correct for standard brackets (R16 winners → QF → T5 worst case)
|
|
||||||
// and conservatively safe for non-standard cases (never over-promises points).
|
|
||||||
if (!isScoring) {
|
if (!isScoring) {
|
||||||
|
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
|
||||||
|
// Assumption: once eliminated in a non-scoring round a participant cannot
|
||||||
|
// re-enter the bracket. Winners bank a provisional T5–T8 floor immediately
|
||||||
|
// (guaranteed top-8 finish at worst for standard 8-team scoring brackets).
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (match.loserId) {
|
if (match.loserId) {
|
||||||
await upsertParticipantResult(
|
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||||
match.loserId,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
0, // 0 = pre-bracket elimination, earns no points
|
|
||||||
db
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (match.winnerId) {
|
if (match.winnerId) {
|
||||||
await upsertParticipantResult(
|
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
||||||
match.winnerId,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
5, // provisional T5–T8 floor: guaranteed top-8 finish
|
|
||||||
db,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// AFL-specific rounds (afl_10 bracket template)
|
|
||||||
else if (round === "Qualifying Finals") {
|
|
||||||
// AFL: Qualifying Finals losers get a second chance via Semi-Finals.
|
|
||||||
// They are not eliminated here, but earn a guaranteed minimum of 5th-6th.
|
|
||||||
// Winners are handled by the general guaranteed-minimum loop below (3rd-4th).
|
|
||||||
for (const match of matches) {
|
|
||||||
if (match.loserId) {
|
|
||||||
await upsertParticipantResult(
|
|
||||||
match.loserId,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
5, // Guaranteed minimum: at worst 5th-6th via Semi-Finals
|
|
||||||
db,
|
|
||||||
true // isPartialScore: still competing
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (round === "Elimination Finals") {
|
|
||||||
// AFL: Elimination Finals losers share 7th-8th
|
|
||||||
for (const match of matches) {
|
|
||||||
if (match.loserId) {
|
|
||||||
await upsertParticipantResult(
|
|
||||||
match.loserId,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
7, // Store as placement 7 (first shared placement for 7-8)
|
|
||||||
db
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (round === "Semi-Finals" && event.bracketTemplateId === "afl_10") {
|
|
||||||
// AFL: Semi-Finals losers share 5th-6th
|
|
||||||
for (const match of matches) {
|
|
||||||
if (match.loserId) {
|
|
||||||
await upsertParticipantResult(
|
|
||||||
match.loserId,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
5, // Store as placement 5 (first shared placement for 5-6)
|
|
||||||
db
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Standard playoff rounds
|
|
||||||
else if (round === "Finals" || round === "Championship" || round === "Super Bowl" || round === "NBA Finals" || round === "Grand Final") {
|
|
||||||
// Winner gets 1st, loser gets 2nd
|
|
||||||
const finalMatch = matches[0];
|
|
||||||
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
|
|
||||||
throw new Error("Finals match is not complete");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update or create participant results for winner (finalized, isPartialScore=false)
|
|
||||||
await upsertParticipantResult(
|
|
||||||
finalMatch.winnerId,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
1,
|
|
||||||
db
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update or create participant results for loser (finalized, isPartialScore=false)
|
|
||||||
await upsertParticipantResult(
|
|
||||||
finalMatch.loserId,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
2,
|
|
||||||
db
|
|
||||||
);
|
|
||||||
} else if (round === "Semifinals" || round === "Final Four" || round === "Conference Finals" || round === "Conference Championship" || round === "Preliminary Finals") {
|
|
||||||
// Losers share 3rd/4th
|
|
||||||
for (const match of matches) {
|
|
||||||
if (match.loserId) {
|
|
||||||
await upsertParticipantResult(
|
|
||||||
match.loserId,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
3, // Store as placement 3 (the first shared placement)
|
|
||||||
db
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (round === "Quarterfinals" || round === "Elite Eight" || round === "Divisional" || round === "Conference Semifinals") {
|
|
||||||
// Losers share 5th-8th
|
|
||||||
for (const match of matches) {
|
|
||||||
if (match.loserId) {
|
|
||||||
await upsertParticipantResult(
|
|
||||||
match.loserId,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
5, // Store as placement 5 (the first shared placement)
|
|
||||||
db
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unrecognized scoring round: losers are outside the top-8 and earn 0 pts.
|
const config = getRoundConfig(round, event.bracketTemplateId);
|
||||||
// This handles rounds like "Round of 16", "Round of 32" when isScoring=true,
|
|
||||||
// or any custom round name not explicitly mapped above.
|
if (config === null) {
|
||||||
console.warn(
|
// Unrecognized scoring round: losers earn 0 pts (outside top-8).
|
||||||
`[ScoringCalculator] Unrecognized scoring round "${round}" for event ${eventId}. Losers receive 0 pts.`
|
console.warn(
|
||||||
);
|
`[ScoringCalculator] Unrecognized scoring round "${round}" for event ${eventId}. Losers receive 0 pts.`
|
||||||
for (const match of matches) {
|
);
|
||||||
if (match.loserId) {
|
for (const match of matches) {
|
||||||
await upsertParticipantResult(
|
if (match.loserId) {
|
||||||
match.loserId,
|
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||||
event.sportsSeasonId,
|
}
|
||||||
0,
|
}
|
||||||
db
|
} else if (config.winnerFloor === null) {
|
||||||
);
|
// Finals: finalize both participants — winner gets 1st, loser gets 2nd.
|
||||||
|
const finalMatch = matches[0];
|
||||||
|
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
|
||||||
|
throw new Error("Finals match is not complete");
|
||||||
|
}
|
||||||
|
await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, 1, db);
|
||||||
|
await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
|
||||||
|
} else {
|
||||||
|
// All other scoring rounds: assign loser's final (or provisional) position.
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match.loserId) {
|
||||||
|
await upsertParticipantResult(
|
||||||
|
match.loserId,
|
||||||
|
event.sportsSeasonId,
|
||||||
|
config.loserPosition,
|
||||||
|
db,
|
||||||
|
config.loserIsPartial
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
||||||
// Winners of any scoring round (except Finals, which already finalize both
|
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
||||||
// participants above) earn provisional points reflecting the worst-case
|
// winner is already finalized as 1st above. For non-scoring rounds it also
|
||||||
// placement they can achieve from here. These update as they advance.
|
// returns null (winners were given floor 5 inline above).
|
||||||
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
||||||
round,
|
round,
|
||||||
event.bracketTemplateId,
|
event.bracketTemplateId,
|
||||||
|
|
@ -276,14 +267,14 @@ export async function processPlayoffEvent(
|
||||||
event.sportsSeasonId,
|
event.sportsSeasonId,
|
||||||
guaranteedMinimum,
|
guaranteedMinimum,
|
||||||
db,
|
db,
|
||||||
true // isPartialScore: still competing, score will increase as they advance
|
true // isPartialScore: still competing, floor will rise as they advance
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate standings for all affected leagues
|
// Recalculate standings for all affected leagues
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name });
|
||||||
|
|
||||||
// Auto-trigger probability recalculation after result
|
// Auto-trigger probability recalculation after result
|
||||||
try {
|
try {
|
||||||
|
|
@ -299,6 +290,74 @@ export async function processPlayoffEvent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single completed match and immediately award points.
|
||||||
|
*
|
||||||
|
* Called when a match winner is set, before the full round is complete.
|
||||||
|
* Loser placement is final (isPartialScore=false); winner placement is
|
||||||
|
* provisional (isPartialScore=true) reflecting their guaranteed minimum finish.
|
||||||
|
*
|
||||||
|
* @param skipSideEffects - When true, skips standings recalc and probability update.
|
||||||
|
* Use this when processing multiple matches in a batch (call side effects once after
|
||||||
|
* the loop instead of once per match).
|
||||||
|
*/
|
||||||
|
export async function processMatchResult(
|
||||||
|
params: {
|
||||||
|
round: string;
|
||||||
|
winnerId: string;
|
||||||
|
loserId: string;
|
||||||
|
/** isScoring ?? true: default to true for legacy rows that pre-date the isScoring column. */
|
||||||
|
isScoring: boolean;
|
||||||
|
sportsSeasonId: string;
|
||||||
|
bracketTemplateId?: string | null;
|
||||||
|
eventName?: string;
|
||||||
|
skipSideEffects?: boolean;
|
||||||
|
},
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
): Promise<void> {
|
||||||
|
const db = providedDb || database();
|
||||||
|
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventName, skipSideEffects } = params;
|
||||||
|
|
||||||
|
if (!isScoring) {
|
||||||
|
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||||
|
// winner banks provisional T5–T8 floor immediately.
|
||||||
|
// Assumption: non-scoring round losers cannot re-enter the bracket.
|
||||||
|
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||||
|
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||||
|
} else {
|
||||||
|
const config = getRoundConfig(round, bracketTemplateId);
|
||||||
|
|
||||||
|
if (config === null) {
|
||||||
|
// Unrecognized scoring round: loser earns 0 pts, winner gets fallback T5–T8 floor.
|
||||||
|
console.warn(
|
||||||
|
`[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
|
||||||
|
);
|
||||||
|
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||||
|
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||||
|
} else if (config.winnerFloor === null) {
|
||||||
|
// Finals: finalize both participants.
|
||||||
|
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
|
||||||
|
await upsertParticipantResult(winnerId, sportsSeasonId, 1, db);
|
||||||
|
} else {
|
||||||
|
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
||||||
|
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
|
||||||
|
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipSideEffects) {
|
||||||
|
await recalculateAffectedLeagues(sportsSeasonId, db, eventName ? { eventName } : undefined);
|
||||||
|
try {
|
||||||
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to upsert participant result
|
* Helper to upsert participant result
|
||||||
*
|
*
|
||||||
|
|
@ -350,62 +409,17 @@ async function upsertParticipantResult(
|
||||||
* This is the worst-case placement a participant can achieve if they lose
|
* This is the worst-case placement a participant can achieve if they lose
|
||||||
* every remaining match from this point forward. Returns null when:
|
* every remaining match from this point forward. Returns null when:
|
||||||
* - The round is non-scoring (winners haven't reached a points-paying zone)
|
* - The round is non-scoring (winners haven't reached a points-paying zone)
|
||||||
* - The round is the Finals (winners are already being finalized as 1st place)
|
* - The round is the Finals (winner already finalized as 1st place inline)
|
||||||
*/
|
*/
|
||||||
export function getGuaranteedMinimumPosition(
|
export function getGuaranteedMinimumPosition(
|
||||||
round: string,
|
round: string,
|
||||||
bracketTemplateId: string | null | undefined,
|
bracketTemplateId: string | null | undefined,
|
||||||
isScoring: boolean
|
isScoring: boolean
|
||||||
): number | null {
|
): number | null {
|
||||||
// Non-scoring rounds: winning doesn't guarantee any points yet
|
|
||||||
if (!isScoring) return null;
|
if (!isScoring) return null;
|
||||||
|
const config = getRoundConfig(round, bracketTemplateId);
|
||||||
// Finals: winner is finalized as 1st — handled inline, not via this helper
|
if (config === null) return 5; // Unknown scoring round: fallback to T5–T8 floor
|
||||||
if (
|
return config.winnerFloor; // null for Finals; actual floor for all other rounds
|
||||||
round === "Finals" ||
|
|
||||||
round === "Championship" ||
|
|
||||||
round === "Super Bowl" ||
|
|
||||||
round === "NBA Finals" ||
|
|
||||||
round === "Grand Final"
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFL-specific routing (afl_10 bracket template)
|
|
||||||
if (bracketTemplateId === "afl_10") {
|
|
||||||
// Qualifying Finals: winners → Prelim Finals (losers there get 3rd-4th)
|
|
||||||
if (round === "Qualifying Finals") return 3;
|
|
||||||
// Elimination Finals: winners → Semi-Finals (losers there get 5th-6th)
|
|
||||||
if (round === "Elimination Finals") return 5;
|
|
||||||
// Semi-Finals: winners → Prelim Finals (losers there get 3rd-4th)
|
|
||||||
if (round === "Semi-Finals") return 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard bracket rounds
|
|
||||||
// Semi-final winners advance to the Final — guaranteed at worst 2nd place
|
|
||||||
if (
|
|
||||||
round === "Semifinals" ||
|
|
||||||
round === "Final Four" ||
|
|
||||||
round === "Conference Finals" ||
|
|
||||||
round === "Conference Championship" ||
|
|
||||||
round === "Preliminary Finals"
|
|
||||||
) {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quarterfinal winners advance to Semis — guaranteed at worst 3rd-4th
|
|
||||||
if (
|
|
||||||
round === "Quarterfinals" ||
|
|
||||||
round === "Elite Eight" ||
|
|
||||||
round === "Divisional" ||
|
|
||||||
round === "Conference Semifinals"
|
|
||||||
) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All other scoring rounds (e.g. Round of 16, Round of 32, etc.) —
|
|
||||||
// winners are now guaranteed a top-8 finish at worst (position 5)
|
|
||||||
return 5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1093,7 +1107,8 @@ export async function recalculateStandings(
|
||||||
*/
|
*/
|
||||||
export async function recalculateAffectedLeagues(
|
export async function recalculateAffectedLeagues(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>,
|
||||||
|
options?: { eventName?: string }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
|
@ -1106,7 +1121,59 @@ export async function recalculateAffectedLeagues(
|
||||||
|
|
||||||
// Recalculate each affected season
|
// Recalculate each affected season
|
||||||
for (const seasonId of seasonIds) {
|
for (const seasonId of seasonIds) {
|
||||||
|
// Capture standings before recalculation for delta calculation
|
||||||
|
const beforeStandings = await db.query.teamStandings.findMany({
|
||||||
|
where: eq(schema.teamStandings.seasonId, seasonId),
|
||||||
|
with: { team: true },
|
||||||
|
});
|
||||||
|
const previousPointsById = new Map(
|
||||||
|
beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)])
|
||||||
|
);
|
||||||
|
|
||||||
await recalculateStandings(seasonId, db);
|
await recalculateStandings(seasonId, db);
|
||||||
|
|
||||||
|
// Check if any team's score changed — if so, send Discord notification
|
||||||
|
const afterStandings = await db.query.teamStandings.findMany({
|
||||||
|
where: eq(schema.teamStandings.seasonId, seasonId),
|
||||||
|
with: { team: true },
|
||||||
|
orderBy: (ts, { asc }) => [asc(ts.currentRank)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasChanges = afterStandings.some((s) => {
|
||||||
|
const prev = previousPointsById.get(s.teamId);
|
||||||
|
return prev === undefined || prev !== parseFloat(s.totalPoints);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasChanges) continue;
|
||||||
|
|
||||||
|
// Look up the league's Discord webhook URL via the season
|
||||||
|
const season = await db.query.seasons.findFirst({
|
||||||
|
where: eq(schema.seasons.id, seasonId),
|
||||||
|
with: { league: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookUrl = season?.league?.discordWebhookUrl;
|
||||||
|
if (!webhookUrl) continue;
|
||||||
|
|
||||||
|
const standings = afterStandings.map((s) => ({
|
||||||
|
teamId: s.teamId,
|
||||||
|
teamName: s.team.name,
|
||||||
|
totalPoints: parseFloat(s.totalPoints),
|
||||||
|
rank: s.currentRank,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendStandingsUpdateNotification({
|
||||||
|
webhookUrl,
|
||||||
|
seasonName: `${season.league.name} ${season.year}`,
|
||||||
|
standings,
|
||||||
|
previousStandings: previousPointsById,
|
||||||
|
eventName: options?.eventName,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Log but don't fail the scoring pipeline if Discord is unreachable
|
||||||
|
console.error(`[ScoringCalculator] Discord notification failed for season ${seasonId}:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,12 @@ import {
|
||||||
upsertMatchOdds,
|
upsertMatchOdds,
|
||||||
deleteOddsForParticipant,
|
deleteOddsForParticipant,
|
||||||
} from "~/models/playoff-match-odds";
|
} from "~/models/playoff-match-odds";
|
||||||
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
import {
|
||||||
|
processPlayoffEvent,
|
||||||
|
processMatchResult,
|
||||||
|
recalculateAffectedLeagues,
|
||||||
|
} from "~/models/scoring-calculator";
|
||||||
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||||
import {
|
import {
|
||||||
createGroupsForEvent,
|
createGroupsForEvent,
|
||||||
|
|
@ -242,6 +247,19 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Immediately score this match: loser gets their final placement,
|
||||||
|
// winner gets provisional floor points (isPartialScore=true).
|
||||||
|
// isScoring ?? true: default to true for legacy rows pre-dating the isScoring column.
|
||||||
|
await processMatchResult({
|
||||||
|
round: match.round,
|
||||||
|
winnerId,
|
||||||
|
loserId,
|
||||||
|
isScoring: match.isScoring ?? true,
|
||||||
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
|
eventName: event.name ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
return { success: "Winner set successfully" };
|
return { success: "Winner set successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error setting winner:", error);
|
console.error("Error setting winner:", error);
|
||||||
|
|
@ -294,6 +312,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard: ensure the match actually belongs to the submitted round.
|
||||||
|
if (match.round !== round) {
|
||||||
|
errors.push(`Match ${match.matchNumber} is in round "${match.round}", not "${round}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine loser
|
// Determine loser
|
||||||
const loserId =
|
const loserId =
|
||||||
match.participant1Id === winnerId
|
match.participant1Id === winnerId
|
||||||
|
|
@ -325,6 +349,20 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Score this match without triggering standings/probability recalc yet —
|
||||||
|
// we batch those side effects into a single call after the loop.
|
||||||
|
// isScoring ?? true: default to true for legacy rows pre-dating the isScoring column.
|
||||||
|
await processMatchResult({
|
||||||
|
round: match.round,
|
||||||
|
winnerId,
|
||||||
|
loserId,
|
||||||
|
isScoring: match.isScoring ?? true,
|
||||||
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
|
eventName: event.name ?? undefined,
|
||||||
|
skipSideEffects: true,
|
||||||
|
});
|
||||||
|
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error setting winner for match ${matchId}:`, error);
|
console.error(`Error setting winner for match ${matchId}:`, error);
|
||||||
|
|
@ -334,6 +372,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run side effects once for the whole batch (avoids N redundant recalcs).
|
||||||
|
if (successCount > 0) {
|
||||||
|
const db = database();
|
||||||
|
await recalculateAffectedLeagues(event.sportsSeasonId, db, event.name ? { eventName: event.name } : undefined);
|
||||||
|
try {
|
||||||
|
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating probabilities after batch round winners:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.length > 0 && successCount === 0) {
|
if (errors.length > 0 && successCount === 0) {
|
||||||
return { error: `Failed to set winners: ${errors.join(", ")}` };
|
return { error: `Failed to set winners: ${errors.join(", ")}` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const mockLeague = {
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
currentSeasonId: 'season-1',
|
currentSeasonId: 'season-1',
|
||||||
isPublicDraftBoard: false,
|
isPublicDraftBoard: false,
|
||||||
|
discordWebhookUrl: null,
|
||||||
createdAt: new Date('2025-01-01'),
|
createdAt: new Date('2025-01-01'),
|
||||||
updatedAt: new Date('2025-01-01'),
|
updatedAt: new Date('2025-01-01'),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
61
app/services/discord.ts
Normal file
61
app/services/discord.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
interface StandingsEntry {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
totalPoints: number;
|
||||||
|
rank: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendStandingsUpdateOptions {
|
||||||
|
webhookUrl: string;
|
||||||
|
seasonName: string;
|
||||||
|
standings: StandingsEntry[];
|
||||||
|
previousStandings: Map<string, number> | Record<string, number>;
|
||||||
|
eventName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendStandingsUpdateNotification({
|
||||||
|
webhookUrl,
|
||||||
|
seasonName,
|
||||||
|
standings,
|
||||||
|
previousStandings,
|
||||||
|
eventName,
|
||||||
|
}: SendStandingsUpdateOptions): Promise<void> {
|
||||||
|
const title = eventName
|
||||||
|
? `📊 ${seasonName} — Standings after ${eventName}`
|
||||||
|
: `📊 ${seasonName} — Standings Update`;
|
||||||
|
|
||||||
|
const getPrev = (teamId: string) =>
|
||||||
|
previousStandings instanceof Map
|
||||||
|
? previousStandings.get(teamId)
|
||||||
|
: previousStandings[teamId];
|
||||||
|
|
||||||
|
const lines = standings
|
||||||
|
.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999))
|
||||||
|
.map((s) => {
|
||||||
|
const prev = getPrev(s.teamId);
|
||||||
|
const diff =
|
||||||
|
prev !== undefined ? s.totalPoints - prev : null;
|
||||||
|
const diffStr =
|
||||||
|
diff !== null && diff !== 0
|
||||||
|
? diff > 0
|
||||||
|
? ` (+${diff.toFixed(1)})`
|
||||||
|
: ` (${diff.toFixed(1)})`
|
||||||
|
: "";
|
||||||
|
const rankStr = s.rank != null ? `${s.rank}.` : "-";
|
||||||
|
return `${rankStr} **${s.teamName}** — ${s.totalPoints.toFixed(1)} pts${diffStr}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = [`**${title}**`, "", ...lines].join("\n");
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Discord webhook returned ${response.status}: ${await response.text()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,7 @@ export const leagues = pgTable("leagues", {
|
||||||
createdBy: varchar("created_by", { length: 255 }).notNull(), // Clerk user ID
|
createdBy: varchar("created_by", { length: 255 }).notNull(), // Clerk user ID
|
||||||
currentSeasonId: uuid("current_season_id"), // References the active season
|
currentSeasonId: uuid("current_season_id"), // References the active season
|
||||||
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
|
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
|
||||||
|
discordWebhookUrl: varchar("discord_webhook_url", { length: 500 }),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
19
drizzle/0048_mean_enchantress.sql
Normal file
19
drizzle/0048_mean_enchantress.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'ncaam_bracket' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'simulator_type')) THEN
|
||||||
|
ALTER TYPE "public"."simulator_type" ADD VALUE 'ncaam_bracket';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'ncaaw_bracket' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'simulator_type')) THEN
|
||||||
|
ALTER TYPE "public"."simulator_type" ADD VALUE 'ncaaw_bracket';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'nba_bracket' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'simulator_type')) THEN
|
||||||
|
ALTER TYPE "public"."simulator_type" ADD VALUE 'nba_bracket';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "leagues" ADD COLUMN IF NOT EXISTS "discord_webhook_url" varchar(500);
|
||||||
3543
drizzle/meta/0048_snapshot.json
Normal file
3543
drizzle/meta/0048_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -337,6 +337,13 @@
|
||||||
"when": 1773730000000,
|
"when": 1773730000000,
|
||||||
"tag": "0047_fix_nba_bracket_simulator_type",
|
"tag": "0047_fix_nba_bracket_simulator_type",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773769397138,
|
||||||
|
"tag": "0048_mean_enchantress",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue