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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
matches,
|
||||
rounds,
|
||||
|
|
@ -150,17 +198,13 @@ export function PlayoffBracket({
|
|||
: null;
|
||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||
|
||||
// Build the set of all participants who won at least one completed match.
|
||||
// If a participant won any match, their earlier loss was not their final elimination.
|
||||
const participantWinIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.isComplete && match.winnerId) participantWinIds.add(match.winnerId);
|
||||
}
|
||||
// Compute which participants are eliminated in which round, handling double-chance.
|
||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||
|
||||
for (const match of matches) {
|
||||
if (!match.isComplete || !match.loser) continue;
|
||||
// Skip if this loser went on to win another match (double-chance bracket)
|
||||
if (participantWinIds.has(match.loser.id)) continue;
|
||||
const eliminatedInRound = eliminatedByRound.get(match.round);
|
||||
if (!eliminatedInRound?.includes(match.loser.id)) continue;
|
||||
const loserScore =
|
||||
match.loserId === match.participant1Id
|
||||
? match.participant1Score
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildFeederMap, groupMatchesByRound } from "../PlayoffBracket";
|
||||
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -136,3 +136,101 @@ describe("buildFeederMap", () => {
|
|||
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,
|
||||
currentSeasonId: schema.leagues.currentSeasonId,
|
||||
isPublicDraftBoard: schema.leagues.isPublicDraftBoard,
|
||||
discordWebhookUrl: schema.leagues.discordWebhookUrl,
|
||||
createdAt: schema.leagues.createdAt,
|
||||
updatedAt: schema.leagues.updatedAt,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { eq, and, inArray } from "drizzle-orm";
|
|||
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints } from "./scoring-rules";
|
||||
import { getSeasonResults } from "./participant-season-result";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { sendStandingsUpdateNotification } from "~/services/discord";
|
||||
|
||||
/**
|
||||
* Core scoring calculation engine
|
||||
|
|
@ -15,6 +16,79 @@ export type ScoringPattern =
|
|||
| "season_standings"
|
||||
| "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.
|
||||
*
|
||||
|
|
@ -126,143 +200,60 @@ export async function processPlayoffEvent(
|
|||
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) {
|
||||
// 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) {
|
||||
if (match.loserId) {
|
||||
await upsertParticipantResult(
|
||||
match.loserId,
|
||||
event.sportsSeasonId,
|
||||
0, // 0 = pre-bracket elimination, earns no points
|
||||
db
|
||||
);
|
||||
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||
}
|
||||
if (match.winnerId) {
|
||||
await upsertParticipantResult(
|
||||
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
|
||||
);
|
||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unrecognized scoring round: losers are outside the top-8 and earn 0 pts.
|
||||
// This handles rounds like "Round of 16", "Round of 32" when isScoring=true,
|
||||
// or any custom round name not explicitly mapped above.
|
||||
const config = getRoundConfig(round, event.bracketTemplateId);
|
||||
|
||||
if (config === null) {
|
||||
// Unrecognized scoring round: losers earn 0 pts (outside top-8).
|
||||
console.warn(
|
||||
`[ScoringCalculator] Unrecognized scoring round "${round}" for event ${eventId}. Losers receive 0 pts.`
|
||||
);
|
||||
for (const match of matches) {
|
||||
if (match.loserId) {
|
||||
await upsertParticipantResult(match.loserId, 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,
|
||||
0,
|
||||
db
|
||||
config.loserPosition,
|
||||
db,
|
||||
config.loserIsPartial
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
||||
// Winners of any scoring round (except Finals, which already finalize both
|
||||
// participants above) earn provisional points reflecting the worst-case
|
||||
// placement they can achieve from here. These update as they advance.
|
||||
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
||||
// winner is already finalized as 1st above. For non-scoring rounds it also
|
||||
// returns null (winners were given floor 5 inline above).
|
||||
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
||||
round,
|
||||
event.bracketTemplateId,
|
||||
|
|
@ -276,14 +267,14 @@ export async function processPlayoffEvent(
|
|||
event.sportsSeasonId,
|
||||
guaranteedMinimum,
|
||||
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
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name });
|
||||
|
||||
// Auto-trigger probability recalculation after result
|
||||
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
|
||||
*
|
||||
|
|
@ -350,62 +409,17 @@ async function upsertParticipantResult(
|
|||
* This is the worst-case placement a participant can achieve if they lose
|
||||
* 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 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(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined,
|
||||
isScoring: boolean
|
||||
): number | null {
|
||||
// Non-scoring rounds: winning doesn't guarantee any points yet
|
||||
if (!isScoring) return null;
|
||||
|
||||
// Finals: winner is finalized as 1st — handled inline, not via this helper
|
||||
if (
|
||||
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;
|
||||
const config = getRoundConfig(round, bracketTemplateId);
|
||||
if (config === null) return 5; // Unknown scoring round: fallback to T5–T8 floor
|
||||
return config.winnerFloor; // null for Finals; actual floor for all other rounds
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1093,7 +1107,8 @@ export async function recalculateStandings(
|
|||
*/
|
||||
export async function recalculateAffectedLeagues(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
options?: { eventName?: string }
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
|
|
@ -1106,7 +1121,59 @@ export async function recalculateAffectedLeagues(
|
|||
|
||||
// Recalculate each affected season
|
||||
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);
|
||||
|
||||
// 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(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ import {
|
|||
upsertMatchOdds,
|
||||
deleteOddsForParticipant,
|
||||
} 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 {
|
||||
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" };
|
||||
} catch (error) {
|
||||
console.error("Error setting winner:", error);
|
||||
|
|
@ -294,6 +312,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
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
|
||||
const loserId =
|
||||
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++;
|
||||
} catch (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) {
|
||||
return { error: `Failed to set winners: ${errors.join(", ")}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const mockLeague = {
|
|||
createdBy: 'user-1',
|
||||
currentSeasonId: 'season-1',
|
||||
isPublicDraftBoard: false,
|
||||
discordWebhookUrl: null,
|
||||
createdAt: 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
|
||||
currentSeasonId: uuid("current_season_id"), // References the active season
|
||||
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
|
||||
discordWebhookUrl: varchar("discord_webhook_url", { length: 500 }),
|
||||
createdAt: timestamp("created_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,
|
||||
"tag": "0047_fix_nba_bracket_simulator_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "7",
|
||||
"when": 1773769397138,
|
||||
"tag": "0048_mean_enchantress",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue