claude/wimbledon-qp-scoring-bug-xevhlf (#127)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m20s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s

Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #127
This commit is contained in:
chrisp 2026-07-03 21:40:11 +00:00
parent 8a948e1388
commit 58e656f15e
15 changed files with 624 additions and 155 deletions

View file

@ -3,7 +3,6 @@ import {
calculateSplitQualifyingPoints,
DEFAULT_QP_VALUES,
diffChangedQualifyingPoints,
hasProcessedQualifyingPlacement,
} from "../qualifying-points";
describe("diffChangedQualifyingPoints", () => {
@ -310,52 +309,6 @@ describe("Qualifying Points Configuration", () => {
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
});
it("should not increment majorsCompleted when reprocessing", () => {
// First processing
let majorsCompleted = 0;
const wasAlreadyProcessed = false;
if (!wasAlreadyProcessed) {
majorsCompleted += 1;
}
expect(majorsCompleted).toBe(1);
// Reprocessing (wasAlreadyProcessed = true)
const reprocessing = true;
if (!reprocessing) {
majorsCompleted += 1;
}
expect(majorsCompleted).toBe(1); // Should still be 1, not 2
});
});
describe("Processed event detection", () => {
it("treats a placed zero-QP result as already processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: 20, qualifyingPointsAwarded: "0.00" },
])
).toBe(true);
});
it("does not treat filler zero-QP rows without placements as processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: null, qualifyingPointsAwarded: "0" },
])
).toBe(false);
});
it("does not treat unprocessed placed rows as processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: 15, qualifyingPointsAwarded: null },
])
).toBe(false);
});
});
describe("Scoring Workflow", () => {

View file

@ -200,7 +200,21 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
});
describe("processQualifyingEvent", () => {
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
function makeProcessQPMockDb(
eventResults: Array<Record<string, unknown>>,
options: {
tournamentId?: string | null;
canonicalPlacements?: number[];
bracketTemplateId?: string | null;
playoffMatchIds?: string[];
} = {}
) {
const {
tournamentId = null,
canonicalPlacements = [],
bracketTemplateId = null,
playoffMatchIds = [],
} = options;
const setCalls: unknown[] = [];
const updateChain = {
set: (values: unknown) => {
@ -217,6 +231,8 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
id: "event-1",
sportsSeasonId: "sports-season-1",
isQualifyingEvent: true,
tournamentId,
bracketTemplateId,
sportsSeason: { majorsCompleted: 1 },
}),
},
@ -238,6 +254,23 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
}),
},
},
// Two select shapes flow through the non-bracket path:
// • playoff-match existence check (has an `id` projection, uses .limit(1))
// • canonical tournament_results tie span (has a `placement` projection).
// Discriminate by the projection keys and return a thenable that also
// exposes .limit so both call shapes resolve.
select: (fields: Record<string, unknown>) => {
const isPlayoff = fields && "id" in fields;
const rows = isPlayoff
? playoffMatchIds.map((id) => ({ id }))
: canonicalPlacements.map((placement) => ({ placement }));
return {
from: () => ({
where: () =>
Object.assign(Promise.resolve(rows), { limit: async () => rows }),
}),
};
},
update: () => updateChain,
} as any;
@ -273,6 +306,123 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
);
});
it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => {
// Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all
// land at placement 9 with a structural tie span of 8 (positions 916 →
// (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted
// subset — here just 2 of the 8 tied players — but the split must still use 8,
// not the 2 present locally (which would wrongly give (2+2)/2 = 2.00).
const windowRows = [
{
id: "result-1",
seasonParticipantId: "participant-1",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
{
id: "result-2",
seasonParticipantId: "participant-2",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
];
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
tournamentId: "tournament-1",
canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th
});
await processQualifyingEvent("event-1", db, { skipNotifications: true });
// Both present players earn the correct split of 1.50, not 2.00.
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
])
);
expect(setCalls).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
])
);
});
it("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => {
// cloneSportsSeason copies bracketTemplateId to league windows but not the
// playoff matches. Such a window must NOT take the bracket branch (which would
// derive zero states and write no QP) — it has to fall through to the
// placement/canonical path and still split R16 losers to 1.5.
const windowRows = [
{
id: "result-1",
seasonParticipantId: "participant-1",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
{
id: "result-2",
seasonParticipantId: "participant-2",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
];
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
tournamentId: "tournament-1",
bracketTemplateId: "tennis_128", // copied by clone…
playoffMatchIds: [], // …but no matches exist on this window
canonicalPlacements: Array.from({ length: 8 }, () => 9),
});
await processQualifyingEvent("event-1", db, { skipNotifications: true });
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
])
);
expect(setCalls).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
])
);
});
it("falls back to the live roster count for standalone events with no tournament", async () => {
// No canonical field exists for a manual/standalone qualifying event, so the
// tie span is the players actually present: 2 players tied at 9th →
// (2+2)/2 = 2.00. This preserves existing behavior where there is no full field.
const windowRows = [
{
id: "result-1",
seasonParticipantId: "participant-1",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
{
id: "result-2",
seasonParticipantId: "participant-2",
placement: 9,
qualifyingPointsAwarded: null,
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
},
];
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
tournamentId: null,
});
await processQualifyingEvent("event-1", db, { skipNotifications: true });
expect(setCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
])
);
});
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
const { db, setCalls } = makeProcessQPMockDb([
{

View file

@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => {
import { deleteScoringEvent } from "../scoring-event";
describe("deleteScoringEvent", () => {
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
// majorsCompleted is no longer a stored counter — it is computed via
// getMajorsCompleted from completed qualifying events. Deleting a qualifying
// event must therefore never issue a sportsSeasons.majorsCompleted update.
const setCalls: unknown[] = [];
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
const updateChain = {
@ -43,12 +46,6 @@ describe("deleteScoringEvent", () => {
},
]),
},
sportsSeasons: {
findFirst: vi.fn().mockResolvedValue({
id: "sports-season-1",
majorsCompleted: 1,
}),
},
},
transaction: vi.fn(async (callback) => callback(db)),
delete: vi.fn(() => deleteChain),
@ -57,9 +54,9 @@ describe("deleteScoringEvent", () => {
await deleteScoringEvent("event-1", db);
expect(setCalls).toEqual(
expect(setCalls).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ majorsCompleted: 0 }),
expect.objectContaining({ majorsCompleted: expect.anything() }),
])
);
});

View file

@ -75,21 +75,6 @@ export function calculateSplitQualifyingPoints(
return roundQualifyingPoints(totalQP / tieCount);
}
export function hasProcessedQualifyingPlacement(
results: Array<{
placement?: number | null;
qualifyingPointsAwarded?: string | null;
}>
): boolean {
return results.some(
(result) =>
result.placement !== null &&
result.placement !== undefined &&
result.qualifyingPointsAwarded !== null &&
result.qualifyingPointsAwarded !== undefined
);
}
/**
* Initialize default qualifying point configuration for a sports season
*/

View file

@ -23,7 +23,6 @@ import {
calculateSplitQualifyingPoints,
diffChangedQualifyingPoints,
getQPConfig,
hasProcessedQualifyingPlacement,
recalculateParticipantQP,
writeEventResultsQP,
getQPStandings,
@ -848,13 +847,40 @@ export async function processQualifyingBracketEvent(
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
}
/**
* Count how many results share each placement the structural tie span used to
* split QP across a tied group. Callers pass the FULL canonical field
* (tournament_results) so the span reflects the whole tournament, not one window's
* roster subset. Null placements (filler / not-participating) are ignored.
*/
export function buildTieCountByPlacement(
results: Array<{ placement: number | null }>
): Map<number, number> {
const map = new Map<number, number>();
for (const r of results) {
if (r.placement === null) continue;
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
}
return map;
}
/**
* Process a qualifying event completion and update QP totals.
* Ties in QP are handled by sharing placements (averaged points).
*/
export async function processQualifyingEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
providedDb?: ReturnType<typeof database>,
options: {
skipNotifications?: boolean;
/**
* Pre-computed full-field tie span (placement count) from the canonical
* tournament_results. When the fan-out already loaded the canonical results it
* passes this in so we don't re-query per window. Omitted for direct callers,
* which fall back to querying it here.
*/
canonicalTieCountByPlacement?: Map<number, number>;
} = {}
): Promise<void> {
const db = providedDb || database();
@ -877,9 +903,6 @@ export async function processQualifyingEvent(
// Get all event results for this qualifying event
const results = await getEventResults(eventId, db);
// Check if this was already processed (for majorsCompleted counter)
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
// Snapshot awarded QP before reprocessing so the Discord notification below can
// announce only the participants whose QP actually changed. Without this, a
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
@ -889,7 +912,23 @@ export async function processQualifyingEvent(
qp: r.qualifyingPointsAwarded,
}));
// Route to the bracket writer only when this window actually OWNS a bracket (has
// playoff matches). A window can carry a bracketTemplateId with no matches — e.g. a
// league window cloned from a bracket season copies the template id but not the
// matches (cloneSportsSeason) — and processQualifyingBracketEvent would derive zero
// states and write NO QP. Those windows must be scored via the placement/canonical
// path below, exactly like a no-template sibling.
let hasBracketMatches = false;
if (event.bracketTemplateId) {
const existing = await db
.select({ id: schema.playoffMatches.id })
.from(schema.playoffMatches)
.where(eq(schema.playoffMatches.scoringEventId, eventId))
.limit(1);
hasBracketMatches = existing.length > 0;
}
if (hasBracketMatches) {
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
@ -914,6 +953,28 @@ export async function processQualifyingEvent(
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
);
// Full-field tie span. The number of players tied at a placement is a property
// of the whole tournament field (canonical tournament_results), NOT of who
// happens to be on THIS window's roster. Sibling/mirror windows only hold the
// draftable subset of the field, so counting the placements present locally
// (group.length) under-counts a tied group and over-awards it: tennis R16 losers
// all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5
// QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving
// the span from canonical results keeps every window/league identical. The fan-out
// passes this map in (already loaded once per tournament); direct callers with a
// tournament link query it here. Standalone events (no tournamentId, no map) have
// no canonical field, so fall back to the live count.
const canonicalTieCountByPlacement: Map<number, number> | null =
options.canonicalTieCountByPlacement ??
(event.tournamentId
? buildTieCountByPlacement(
await db
.select({ placement: schema.tournamentResults.placement })
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, event.tournamentId))
)
: null);
// Group results by placement to handle ties
const placementGroups = new Map<number, typeof results>();
for (const result of results) {
@ -926,7 +987,7 @@ export async function processQualifyingEvent(
// Process each placement group and update event_results with QP awarded
for (const [placement, group] of placementGroups) {
const tieCount = group.length;
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
const qpPerParticipant = calculateSplitQualifyingPoints(
placement,
@ -954,17 +1015,10 @@ export async function processQualifyingEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// Increment majorsCompleted counter (only if this is the first time processing)
if (!wasAlreadyProcessed) {
const sportsSeason = event.sportsSeason;
await db
.update(schema.sportsSeasons)
.set({
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
updatedAt: new Date(),
})
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
logger.log(
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
@ -981,7 +1035,7 @@ export async function processQualifyingEvent(
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
);
if (changedParticipantIds.size > 0) {
if (changedParticipantIds.size > 0 && !options.skipNotifications) {
try {
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds);
} catch (error) {

View file

@ -3,7 +3,7 @@ import * as schema from "~/database/schema";
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
import type { BracketRegion } from "~/lib/bracket-templates";
import { recalculateAffectedLeagues } from "./scoring-calculator";
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
import { recalculateParticipantQP } from "./qualifying-points";
import { findParticipantNamesByIds } from "./season-participant";
import { deleteTournament } from "./tournament";
import type { EventType } from "./scoring-event-types";
@ -144,6 +144,33 @@ export async function getQualifyingEvents(
});
}
/**
* Number of "majors completed" for a sports season, derived on read as the count of
* qualifying events that have been marked complete. Replaces the old stored
* sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and
* over-counted past totalMajors ("11 of 4"). Computing it makes the value
* self-correcting and immune to double-counting.
*/
export async function getMajorsCompleted(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<number> {
const db = providedDb || database();
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.scoringEvents)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isQualifyingEvent, true),
eq(schema.scoringEvents.isComplete, true)
)
);
return rows[0]?.count ?? 0;
}
/**
* Update a scoring event
*/
@ -246,13 +273,11 @@ export async function deleteScoringEvent(
// For qualifying events: capture affected participant IDs before the cascade deletes
// eventResults (which is how we know who had QP awarded from this event).
let affectedParticipantIds: string[] = [];
let wasQPProcessed = false;
if (event.isQualifyingEvent) {
const results = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId),
});
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
wasQPProcessed = hasProcessedQualifyingPlacement(results);
}
await db.transaction(async (tx) => {
@ -275,18 +300,9 @@ export async function deleteScoringEvent(
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// Decrement majorsCompleted if this event had already been processed
if (wasQPProcessed) {
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
});
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
await db
.update(schema.sportsSeasons)
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
}
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
// — which removes it from the completed-qualifying-event count — self-corrects the
// number. No stored counter to decrement.
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}

View file

@ -882,9 +882,11 @@ export async function action({ request, params }: Route.ActionArgs) {
// fantasy placements come from finalizeQualifyingPoints across all of them.
if (event.isQualifyingEvent) {
const db = database();
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
// increments majorsCompleted, and recalcs participant QP totals. Season-wide
// fantasy finalization stays with finalizeQualifyingPoints across all majors.
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
// and recalcs participant QP totals. majorsCompleted is derived on read from
// completed qualifying events (see getMajorsCompleted) — marking this event
// complete below is what advances it. Season-wide fantasy finalization stays with
// finalizeQualifyingPoints across all majors.
await processQualifyingEvent(params.eventId, db);
await db
.update(schema.scoringEvents)

View file

@ -9,6 +9,7 @@ import {
bulkCreateScoringEvents,
ensurePrimaryEvent,
countWindowsByTournament,
getMajorsCompleted,
type CreateScoringEventData,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
@ -28,10 +29,14 @@ export async function loader({ params }: Route.LoaderArgs) {
const events = await getScoringEventsForSportsSeason(params.id);
// For qualifying sports seasons, get QP standings with global ranks attached
// For qualifying sports seasons, get QP standings with global ranks attached.
// majorsCompleted is derived on read (count of completed qualifying events), not a
// stored counter — see getMajorsCompleted.
let qpStandings = null;
const scoringRules = null;
let majorsCompleted = 0;
if (sportsSeason.scoringPattern === "qualifying_points") {
majorsCompleted = await getMajorsCompleted(params.id);
const standings = await getQPStandings(params.id);
let prevQP = -1;
let prevRankStart = 1;
@ -88,7 +93,7 @@ export async function loader({ params }: Route.LoaderArgs) {
});
return {
sportsSeason: sportsSeason as typeof sportsSeason & {
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string };
},
events: eventsWithSharing,

View file

@ -14,6 +14,7 @@ import { getQPStandings } from "~/models/qualifying-points";
import {
getUpcomingScoringEvents,
getRecentCompletedEvents,
getMajorsCompleted,
} from "~/models/scoring-event";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
@ -157,6 +158,7 @@ export async function loader(args: Route.LoaderArgs) {
let seasonStandings: SeasonStanding[] = [];
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
let qpStandings: QPStanding[] = [];
let majorsCompleted = 0;
// Group standings for group-stage events (e.g. FIFA World Cup)
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
@ -293,6 +295,9 @@ export async function loader(args: Route.LoaderArgs) {
},
}));
} else if (scoringPattern === "qualifying_points") {
// majorsCompleted is derived on read (count of completed qualifying events), not a
// stored counter — see getMajorsCompleted.
majorsCompleted = await getMajorsCompleted(sportsSeasonId);
const standings = await getQPStandings(sportsSeasonId);
// Compute global ranks with tie handling across the full field before filtering,
// so the displayed rank numbers remain correct after undrafted/zero-QP rows are removed.
@ -341,7 +346,7 @@ export async function loader(args: Route.LoaderArgs) {
return {
league,
season,
sportsSeason,
sportsSeason: { ...sportsSeason, majorsCompleted },
scoringPattern,
playoffMatches,
playoffRounds,

View file

@ -675,16 +675,40 @@ describe("sendDraftOrderNotification", () => {
});
});
// The caller now supplies each entry's rank in the FULL season field. This helper
// mirrors the production ranking (qualifying-points-discord.server.ts): sort by
// qpTotal desc, competition ranking with ties sharing the lower rank, so existing
// tests keep asserting ranks derived from qpTotal.
function withRanks<T extends { qpTotal: number }>(entries: T[]) {
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
const rankByTotal = new Map<number, number>();
let prevTotal = Number.NaN;
let prevRank = 0;
sorted.forEach((e, i) => {
const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1;
rankByTotal.set(e.qpTotal, rank);
prevTotal = e.qpTotal;
prevRank = rank;
});
const countByTotal = new Map<number, number>();
for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1);
return entries.map((e) => ({
...e,
globalRank: rankByTotal.get(e.qpTotal) ?? 0,
globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1,
}));
}
describe("sendQualifyingPointsUpdateNotification", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
});
const BASE_ENTRIES = [
const BASE_ENTRIES = withRanks([
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
];
]);
it("sends an embed with gold color and QP title", async () => {
await sendQualifyingPointsUpdateNotification({
@ -759,11 +783,11 @@ describe("sendQualifyingPointsUpdateNotification", () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
entries: withRanks([
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
],
]),
});
const desc = getDescription();
@ -772,11 +796,74 @@ describe("sendQualifyingPointsUpdateNotification", () => {
expect(desc).toContain("3\\. Player C");
});
it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => {
// Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP
// (positions 916 split) but Discord rounded it to 2 via Math.round.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
sportName: "Tennis - Men",
eventName: "Wimbledon",
entries: [
{
participantName: "Novak Djokovic",
qpEarned: 1.5,
qpTotal: 1.5,
globalRank: 9,
globalRankTied: true,
ownerUsername: "snarkymcgee",
},
],
});
const desc = getDescription();
// Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"),
// never rounded to an integer.
expect(desc).toContain("+1.50 QP");
expect(desc).not.toContain("+2 QP");
expect(desc).toContain("1.50 QP");
expect(desc).not.toContain("— 2 QP");
});
it("ranks the standings by caller-provided full-field globalRank, not position among entries", async () => {
// Screenshot scenario: only two players scored this event, but they sit at T9 in
// the full season field (8 players ahead). They must show T9, not T1.
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Rumble League 2026",
sportName: "Tennis - Men",
eventName: "Wimbledon",
entries: [
{
participantName: "Novak Djokovic",
qpEarned: 1.5,
qpTotal: 1.5,
globalRank: 9,
globalRankTied: true,
ownerUsername: "snarkymcgee",
},
{
participantName: "Jannik Sinner",
qpEarned: 1.5,
qpTotal: 1.5,
globalRank: 9,
globalRankTied: true,
ownerUsername: "tanay002",
},
],
});
const desc = getDescription();
expect(desc).toContain("T9\\. Novak Djokovic (snarkymcgee) — 1.50 QP");
expect(desc).toContain("T9\\. Jannik Sinner (tanay002) — 1.50 QP");
expect(desc).not.toContain("T1\\.");
});
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
entries: withRanks([
{
participantName: "Novak Djokovic",
qpEarned: 20,
@ -784,7 +871,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
ownerUsername: "alex",
ownerDiscordUserId: "111222333",
},
],
]),
});
const desc = getDescription();
@ -796,10 +883,10 @@ describe("sendQualifyingPointsUpdateNotification", () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
entries: withRanks([
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
],
]),
});
const payload = getPayload();
@ -864,9 +951,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
entries: withRanks([
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
],
]),
});
const desc = getDescription();
@ -875,12 +962,14 @@ describe("sendQualifyingPointsUpdateNotification", () => {
});
it("truncates description at 4096 characters", async () => {
const longEntries = Array.from({ length: 200 }, (_, i) => ({
participantName: `Very Long Participant Name Number ${i}`,
qpEarned: i % 2 === 0 ? 5 : 0,
qpTotal: 200 - i,
ownerUsername: `owner_with_long_username_${i}`,
}));
const longEntries = withRanks(
Array.from({ length: 200 }, (_, i) => ({
participantName: `Very Long Participant Name Number ${i}`,
qpEarned: i % 2 === 0 ? 5 : 0,
qpTotal: 200 - i,
ownerUsername: `owner_with_long_username_${i}`,
}))
);
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,

View file

@ -8,6 +8,16 @@ vi.mock("~/database/context", () => ({
vi.mock("~/models/scoring-calculator", () => ({
processQualifyingEvent: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
// Pure helper — keep the real implementation so the tie-count map handed to
// processQualifyingEvent reflects the mock canonical results.
buildTieCountByPlacement: (results: Array<{ placement: number | null }>) => {
const map = new Map<number, number>();
for (const r of results) {
if (r.placement === null) continue;
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
}
return map;
},
}));
vi.mock("~/models/scoring-event", () => ({
@ -360,7 +370,10 @@ describe("syncTournamentResults", () => {
expect(c?.qualifyingPointsAwarded).toBe("0");
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
});
// -------------------------------------------------------------------------
@ -412,8 +425,14 @@ describe("syncTournamentResults", () => {
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
});
// -------------------------------------------------------------------------
@ -723,7 +742,10 @@ describe("syncTournamentResults", () => {
expect(report.windowsSynced).toBe(1);
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, {
skipNotifications: false,
canonicalTieCountByPlacement: expect.any(Map),
});
// No event_results written for the skipped primary window.
expect(
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")

View file

@ -63,6 +63,16 @@ function escapeMarkdown(text: string): string {
return text.replace(/[_*~`|\\]/g, "\\$&");
}
/**
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
* earns 1.5 QP from the 916 split), so we must NOT round: show whole numbers plainly
* and fractional values to 2 decimals. Mirrors the web UI's formatQP
* (app/components/scoring/QualifyingPointsStandings.tsx) so Discord and the site agree.
*/
function formatQPValue(n: number): string {
return n % 1 === 0 ? n.toString() : n.toFixed(2);
}
export interface StandingEntry {
teamId: string;
teamName: string;
@ -251,6 +261,15 @@ export interface QPEventEntry {
participantName: string;
qpEarned: number;
qpTotal: number;
/**
* The participant's rank in the FULL season QP standings (all participants), not
* their position among this event's scorers. Computed by the caller so the "QP
* Standings" block reflects the whole sport season e.g. two R16 losers on 1.5 QP
* show as T9 (8 players ahead) rather than T1 among just the two of them.
*/
globalRank: number;
/** True when another participant in the full field shares this globalRank. */
globalRankTied: boolean;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
@ -301,7 +320,7 @@ export async function sendQualifyingPointsUpdateNotification({
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`• **${label}** — +${Math.round(e.qpEarned)} QP`);
sections.push(`• **${label}** — +${formatQPValue(e.qpEarned)} QP`);
}
}
@ -338,33 +357,23 @@ export async function sendQualifyingPointsUpdateNotification({
}
}
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
// Compute ranks with tie detection
const ranks: number[] = [];
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i].qpTotal === sorted[i - 1].qpTotal) {
ranks.push(ranks[ranks.length - 1]);
} else {
ranks.push(i + 1);
}
}
const isTied = buildTiedRankChecker(ranks);
// Order by the participant's rank in the FULL season standings (globalRank),
// supplied by the caller, so the block reads as a season leaderboard slice.
const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank);
// The standings block reflects QP earners; skip it entirely when this sync only
// reported knockouts (no QP change) so we don't emit an empty header.
if (sorted.length > 0) {
sections.push("\n**QP Standings**");
for (let i = 0; i < sorted.length; i++) {
const e = sorted[i];
const r = ranks[i];
const rankPrefix = isTied(r) ? `T${r}` : `${r}`;
for (const e of sorted) {
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
const ownerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${Math.round(e.qpTotal)} QP`);
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${formatQPValue(e.qpTotal)} QP`);
}
}

View file

@ -95,6 +95,29 @@ export async function notifyQualifyingPointsUpdate(
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
);
// Rank every participant across the FULL season field (not just this event's
// scorers) so the notification's "QP Standings" block reads as a season-leaderboard
// slice and matches the website's globalRank. Standard competition ranking: ties
// share the lower rank (…, 9, 9, 11, …). Two R16 losers on 1.5 QP with 8 players
// ahead therefore render as T9, not T1 among just the two of them.
const rankedField = [...qpTotalById.entries()]
.map(([id, total]) => ({ id, total }))
.toSorted((a, b) => b.total - a.total);
const globalRankById = new Map<string, number>();
let prevTotal = Number.NaN;
let prevRank = 0;
rankedField.forEach((row, index) => {
const rank =
index > 0 && Math.abs(row.total - prevTotal) < 0.001 ? prevRank : index + 1;
globalRankById.set(row.id, rank);
prevTotal = row.total;
prevRank = rank;
});
const countByRank = new Map<number, number>();
for (const rank of globalRankById.values()) {
countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1);
}
// Batch-fetch all season + league metadata in one query
const seasons = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
@ -158,10 +181,13 @@ export async function notifyQualifyingPointsUpdate(
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
const globalRank = globalRankById.get(participantId) ?? 0;
return {
participantName: participantNameById.get(participantId) ?? participantId,
qpEarned: qpEarnedById.get(participantId) ?? 0,
qpTotal: qpTotalById.get(participantId) ?? 0,
globalRank,
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
};

View file

@ -4,6 +4,7 @@ import * as schema from "~/database/schema";
import {
processQualifyingEvent,
recalculateAffectedLeagues,
buildTieCountByPlacement,
} from "~/models/scoring-calculator";
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
import { getEventResults } from "~/models/event-result";
@ -35,6 +36,13 @@ export interface SyncOptions {
* primary is already scored in place, so re-running it would be wasted work.
*/
skipEventId?: string;
/**
* Suppress ALL Discord notifications for this fan-out (both the per-window QP
* update from processQualifyingEvent and the league standings recalc). Used by
* one-off backfills that re-score historical events the QP values change
* (e.g. a mis-split 2 correct 1.5), which would otherwise re-ping every league.
*/
skipNotifications?: boolean;
}
/**
@ -57,7 +65,7 @@ export async function syncTournamentResults(
options: SyncOptions = {}
): Promise<SyncReport> {
const db = database();
const { markComplete = true, skipEventId } = options;
const { markComplete = true, skipEventId, skipNotifications = false } = options;
const report: SyncReport = {
tournamentId,
@ -72,6 +80,11 @@ export async function syncTournamentResults(
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
// The full-field tie span (placement → count) is a property of the whole
// tournament, so compute it once here and hand it to every window's
// processQualifyingEvent instead of re-querying canonical results per window.
const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults);
// 2. Load every scoring_event that points at this tournament.
const allLinkedEvents = await db
.select()
@ -176,12 +189,17 @@ export async function syncTournamentResults(
}
// 3d. Delegate to scoring engine inside the same transaction.
await processQualifyingEvent(ev.id, tx);
await processQualifyingEvent(ev.id, tx, {
skipNotifications,
canonicalTieCountByPlacement,
});
// 3e. Mark the window event complete (final-results sync only). This is
// what makes "score once" actually complete every window — without it,
// each sibling stayed "In Progress" and had to be completed by hand.
if (markComplete) {
// Skip windows already complete so a re-run (e.g. a backfill) doesn't
// needlessly re-stamp completedAt.
if (markComplete && !ev.isComplete) {
await completeScoringEvent(ev.id, tx);
}
});
@ -213,8 +231,9 @@ export async function syncTournamentResults(
eventName: w.eventName ?? undefined,
// Mid-tournament fan-out (markComplete=false) updates standings silently;
// the primary window already announced the round. Only the final sync
// (completion) announces to each window's leagues.
skipDiscord: !markComplete,
// (completion) announces to each window's leagues. A backfill
// (skipNotifications) is always silent.
skipDiscord: skipNotifications || !markComplete,
});
} catch (e: unknown) {
const msg =
@ -250,9 +269,9 @@ export async function syncTournamentResults(
*/
export async function syncMajorFromPrimaryEvent(
primaryEventId: string,
options: { markComplete?: boolean } = {}
options: { markComplete?: boolean; skipNotifications?: boolean } = {}
): Promise<SyncReport> {
const { markComplete = false } = options;
const { markComplete = false, skipNotifications = false } = options;
const primaryEvent = await getScoringEventById(primaryEventId);
if (!primaryEvent) {
@ -317,6 +336,7 @@ export async function syncMajorFromPrimaryEvent(
return syncTournamentResults(tournamentId, {
markComplete,
skipEventId: primaryEventId,
skipNotifications,
});
}

View file

@ -0,0 +1,136 @@
/**
* Backfill: re-score qualifying majors so mis-split QP is corrected.
*
* Sibling/mirror windows used to compute a placement's tie span from the players
* present on THAT window's roster (a subset of the field), so a tied group split
* fewer ways and over-awarded tennis Round-of-16 losers landed at 2 QP instead of
* the correct 1.5 (positions 916: (2+2+2+2+1+1+1+1)/8). processQualifyingEvent now
* derives the tie span from the canonical full field, so re-running the fan-out
* rewrites the stored event_results QP, participant totals, and league standings.
*
* This is idempotent and SILENT: notifications are suppressed so re-scoring history
* (2 1.5 for many participants) does not re-ping every league's Discord. It does
* NOT re-link events or designate primaries run backfill-major-linking.ts first if
* the data predates the shared-major model. majorsCompleted needs no fix (it is now
* derived on read from completed qualifying events).
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/backfill-qp-resplit.ts # apply
* npx tsx scripts/backfill-qp-resplit.ts --dry # report only
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { eq } from "drizzle-orm";
import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js";
import { isBracketMajor } from "../app/lib/event-utils.js";
import {
syncTournamentResults,
syncMajorFromPrimaryEvent,
} from "../app/services/sync-tournament-results.js";
const DRY = process.argv.includes("--dry");
const log = (...a: unknown[]) => console.log(...a);
async function run() {
const db = database();
// Every tournament-linked qualifying event, grouped by canonical tournament.
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.isQualifyingEvent, true),
with: { sportsSeason: { with: { sport: true } } },
});
const byTournament = new Map<string, typeof events>();
for (const ev of events) {
if (!ev.tournamentId) continue; // standalone/manual events: nothing to fan out
const arr = byTournament.get(ev.tournamentId) ?? [];
arr.push(ev);
byTournament.set(ev.tournamentId, arr);
}
log(`Tournaments with linked qualifying events: ${byTournament.size}`);
let ok = 0;
let failed = 0;
for (const [tournamentId, evs] of byTournament) {
const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null;
const bracketMajor = isBracketMajor(simulatorType);
const name = evs[0]?.name ?? tournamentId;
// Only re-score fully-scored majors so we don't mark in-progress ones complete.
const anyComplete = evs.some((e) => e.isComplete);
const markComplete = evs.every((e) => e.isComplete);
if (DRY) {
log(
` (dry) ${name}: ${evs.length} window(s), bracket=${bracketMajor}, ` +
`complete=${markComplete} — would re-score silently`
);
continue;
}
try {
if (bracketMajor) {
// Re-derive canonical from the primary, then fan out to siblings (which now
// split ties by the full field). Primary is scored in place and skipped.
const primary = evs.find((e) => e.isPrimary);
if (!primary) {
log(` ! ${name}: no primary window — skipping (run backfill-major-linking.ts)`);
failed += 1;
continue;
}
const report = await syncMajorFromPrimaryEvent(primary.id, {
markComplete,
skipNotifications: true,
});
ok += report.windowsSynced;
failed += report.windowsFailed;
log(
` ${name}: re-scored via primary — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
);
} else {
// Golf/placement: canonical tournament_results already exist; just fan out.
if (!anyComplete) {
log(` ${name}: no completed window — skipping`);
continue;
}
const report = await syncTournamentResults(tournamentId, {
markComplete,
skipNotifications: true,
});
ok += report.windowsSynced;
failed += report.windowsFailed;
log(
` ${name}: re-scored — ${report.windowsSynced} ok, ${report.windowsFailed} failed`
);
}
} catch (e) {
failed += 1;
log(` ! ${name}: ${(e as Error).message}`);
}
}
log(`\nDone${DRY ? " (dry run — no writes)" : ""}. windows ok=${ok}, failed=${failed}.`);
}
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("ERROR: DATABASE_URL is required");
process.exit(1);
}
const client = postgres(dbUrl, { max: 1 });
const db = drizzle(client, { schema });
try {
await DatabaseContext.run(db, run);
} finally {
await client.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});