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:
Chris Parsons 2026-03-17 10:50:30 -07:00 committed by GitHub
parent 33f9ad6ebe
commit dbffddc9ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 4320 additions and 187 deletions

View file

@ -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

View file

@ -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");
});
});
});

View 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();
});
});
});

View file

@ -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,
}; };

View file

@ -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 (7th8th).
"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 (5th6th), 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 (T5T8 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 T5T8 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 T5T8 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 T5T8 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 T5T8 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 T5T8 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(

View file

@ -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(", ")}` };
} }

View file

@ -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
View 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()}`
);
}
}

View file

@ -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(),
}); });

View 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);

File diff suppressed because it is too large Load diff

View file

@ -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
} }
] ]
} }