Compare commits
4 commits
main
...
claude/wim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929e30c58c | ||
|
|
83e41e9cf1 | ||
|
|
ad238e6bfb | ||
|
|
efec504c08 |
15 changed files with 658 additions and 155 deletions
|
|
@ -3,7 +3,6 @@ import {
|
||||||
calculateSplitQualifyingPoints,
|
calculateSplitQualifyingPoints,
|
||||||
DEFAULT_QP_VALUES,
|
DEFAULT_QP_VALUES,
|
||||||
diffChangedQualifyingPoints,
|
diffChangedQualifyingPoints,
|
||||||
hasProcessedQualifyingPlacement,
|
|
||||||
} from "../qualifying-points";
|
} from "../qualifying-points";
|
||||||
|
|
||||||
describe("diffChangedQualifyingPoints", () => {
|
describe("diffChangedQualifyingPoints", () => {
|
||||||
|
|
@ -310,52 +309,6 @@ describe("Qualifying Points Configuration", () => {
|
||||||
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
|
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", () => {
|
describe("Scoring Workflow", () => {
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,21 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("processQualifyingEvent", () => {
|
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 setCalls: unknown[] = [];
|
||||||
const updateChain = {
|
const updateChain = {
|
||||||
set: (values: unknown) => {
|
set: (values: unknown) => {
|
||||||
|
|
@ -217,6 +231,8 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
id: "event-1",
|
id: "event-1",
|
||||||
sportsSeasonId: "sports-season-1",
|
sportsSeasonId: "sports-season-1",
|
||||||
isQualifyingEvent: true,
|
isQualifyingEvent: true,
|
||||||
|
tournamentId,
|
||||||
|
bracketTemplateId,
|
||||||
sportsSeason: { majorsCompleted: 1 },
|
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,
|
update: () => updateChain,
|
||||||
} as any;
|
} 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 9–16 →
|
||||||
|
// (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 () => {
|
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
||||||
const { db, setCalls } = makeProcessQPMockDb([
|
const { db, setCalls } = makeProcessQPMockDb([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => {
|
||||||
import { deleteScoringEvent } from "../scoring-event";
|
import { deleteScoringEvent } from "../scoring-event";
|
||||||
|
|
||||||
describe("deleteScoringEvent", () => {
|
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 setCalls: unknown[] = [];
|
||||||
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
||||||
const updateChain = {
|
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)),
|
transaction: vi.fn(async (callback) => callback(db)),
|
||||||
delete: vi.fn(() => deleteChain),
|
delete: vi.fn(() => deleteChain),
|
||||||
|
|
@ -57,9 +54,9 @@ describe("deleteScoringEvent", () => {
|
||||||
|
|
||||||
await deleteScoringEvent("event-1", db);
|
await deleteScoringEvent("event-1", db);
|
||||||
|
|
||||||
expect(setCalls).toEqual(
|
expect(setCalls).not.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ majorsCompleted: 0 }),
|
expect.objectContaining({ majorsCompleted: expect.anything() }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -75,21 +75,6 @@ export function calculateSplitQualifyingPoints(
|
||||||
return roundQualifyingPoints(totalQP / tieCount);
|
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
|
* Initialize default qualifying point configuration for a sports season
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import {
|
||||||
calculateSplitQualifyingPoints,
|
calculateSplitQualifyingPoints,
|
||||||
diffChangedQualifyingPoints,
|
diffChangedQualifyingPoints,
|
||||||
getQPConfig,
|
getQPConfig,
|
||||||
hasProcessedQualifyingPlacement,
|
|
||||||
recalculateParticipantQP,
|
recalculateParticipantQP,
|
||||||
writeEventResultsQP,
|
writeEventResultsQP,
|
||||||
getQPStandings,
|
getQPStandings,
|
||||||
|
|
@ -848,13 +847,40 @@ export async function processQualifyingBracketEvent(
|
||||||
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
|
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.
|
* Process a qualifying event completion and update QP totals.
|
||||||
* Ties in QP are handled by sharing placements (averaged points).
|
* Ties in QP are handled by sharing placements (averaged points).
|
||||||
*/
|
*/
|
||||||
export async function processQualifyingEvent(
|
export async function processQualifyingEvent(
|
||||||
eventId: string,
|
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> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
|
@ -877,9 +903,6 @@ export async function processQualifyingEvent(
|
||||||
// Get all event results for this qualifying event
|
// Get all event results for this qualifying event
|
||||||
const results = await getEventResults(eventId, db);
|
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
|
// Snapshot awarded QP before reprocessing so the Discord notification below can
|
||||||
// announce only the participants whose QP actually changed. Without this, a
|
// announce only the participants whose QP actually changed. Without this, a
|
||||||
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
|
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
|
||||||
|
|
@ -889,7 +912,23 @@ export async function processQualifyingEvent(
|
||||||
qp: r.qualifyingPointsAwarded,
|
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) {
|
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-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
|
||||||
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
|
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
|
||||||
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
|
// 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)])
|
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
|
// Group results by placement to handle ties
|
||||||
const placementGroups = new Map<number, typeof results>();
|
const placementGroups = new Map<number, typeof results>();
|
||||||
for (const result of 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
|
// Process each placement group and update event_results with QP awarded
|
||||||
for (const [placement, group] of placementGroups) {
|
for (const [placement, group] of placementGroups) {
|
||||||
const tieCount = group.length;
|
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
|
||||||
|
|
||||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||||
placement,
|
placement,
|
||||||
|
|
@ -954,17 +1015,10 @@ export async function processQualifyingEvent(
|
||||||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment majorsCompleted counter (only if this is the first time processing)
|
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
|
||||||
if (!wasAlreadyProcessed) {
|
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
|
||||||
const sportsSeason = event.sportsSeason;
|
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
|
||||||
await db
|
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
|
||||||
.update(schema.sportsSeasons)
|
|
||||||
.set({
|
|
||||||
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log(
|
logger.log(
|
||||||
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
`[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 }))
|
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (changedParticipantIds.size > 0) {
|
if (changedParticipantIds.size > 0 && !options.skipNotifications) {
|
||||||
try {
|
try {
|
||||||
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds);
|
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -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 { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
||||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
import { recalculateParticipantQP } from "./qualifying-points";
|
||||||
import { findParticipantNamesByIds } from "./season-participant";
|
import { findParticipantNamesByIds } from "./season-participant";
|
||||||
import { deleteTournament } from "./tournament";
|
import { deleteTournament } from "./tournament";
|
||||||
import type { EventType } from "./scoring-event-types";
|
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
|
* Update a scoring event
|
||||||
*/
|
*/
|
||||||
|
|
@ -246,13 +273,11 @@ export async function deleteScoringEvent(
|
||||||
// For qualifying events: capture affected participant IDs before the cascade deletes
|
// For qualifying events: capture affected participant IDs before the cascade deletes
|
||||||
// eventResults (which is how we know who had QP awarded from this event).
|
// eventResults (which is how we know who had QP awarded from this event).
|
||||||
let affectedParticipantIds: string[] = [];
|
let affectedParticipantIds: string[] = [];
|
||||||
let wasQPProcessed = false;
|
|
||||||
if (event.isQualifyingEvent) {
|
if (event.isQualifyingEvent) {
|
||||||
const results = await db.query.eventResults.findMany({
|
const results = await db.query.eventResults.findMany({
|
||||||
where: eq(schema.eventResults.scoringEventId, eventId),
|
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||||
});
|
});
|
||||||
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
||||||
wasQPProcessed = hasProcessedQualifyingPlacement(results);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
|
@ -275,18 +300,9 @@ export async function deleteScoringEvent(
|
||||||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement majorsCompleted if this event had already been processed
|
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
|
||||||
if (wasQPProcessed) {
|
// — which removes it from the completed-qualifying-event count — self-corrects the
|
||||||
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
// number. No stored counter to decrement.
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -882,9 +882,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// fantasy placements come from finalizeQualifyingPoints across all of them.
|
// fantasy placements come from finalizeQualifyingPoints across all of them.
|
||||||
if (event.isQualifyingEvent) {
|
if (event.isQualifyingEvent) {
|
||||||
const db = database();
|
const db = database();
|
||||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
|
||||||
// increments majorsCompleted, and recalcs participant QP totals. Season-wide
|
// and recalcs participant QP totals. majorsCompleted is derived on read from
|
||||||
// fantasy finalization stays with finalizeQualifyingPoints across all majors.
|
// 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 processQualifyingEvent(params.eventId, db);
|
||||||
await db
|
await db
|
||||||
.update(schema.scoringEvents)
|
.update(schema.scoringEvents)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
bulkCreateScoringEvents,
|
bulkCreateScoringEvents,
|
||||||
ensurePrimaryEvent,
|
ensurePrimaryEvent,
|
||||||
countWindowsByTournament,
|
countWindowsByTournament,
|
||||||
|
getMajorsCompleted,
|
||||||
type CreateScoringEventData,
|
type CreateScoringEventData,
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import { isBracketMajor } from "~/lib/event-utils";
|
import { isBracketMajor } from "~/lib/event-utils";
|
||||||
|
|
@ -28,10 +29,14 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
||||||
const events = await getScoringEventsForSportsSeason(params.id);
|
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;
|
let qpStandings = null;
|
||||||
const scoringRules = null;
|
const scoringRules = null;
|
||||||
|
let majorsCompleted = 0;
|
||||||
if (sportsSeason.scoringPattern === "qualifying_points") {
|
if (sportsSeason.scoringPattern === "qualifying_points") {
|
||||||
|
majorsCompleted = await getMajorsCompleted(params.id);
|
||||||
const standings = await getQPStandings(params.id);
|
const standings = await getQPStandings(params.id);
|
||||||
let prevQP = -1;
|
let prevQP = -1;
|
||||||
let prevRankStart = 1;
|
let prevRankStart = 1;
|
||||||
|
|
@ -88,7 +93,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & {
|
||||||
sport: { id: string; name: string; type: string; slug: string };
|
sport: { id: string; name: string; type: string; slug: string };
|
||||||
},
|
},
|
||||||
events: eventsWithSharing,
|
events: eventsWithSharing,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { getQPStandings } from "~/models/qualifying-points";
|
||||||
import {
|
import {
|
||||||
getUpcomingScoringEvents,
|
getUpcomingScoringEvents,
|
||||||
getRecentCompletedEvents,
|
getRecentCompletedEvents,
|
||||||
|
getMajorsCompleted,
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
||||||
|
|
@ -157,6 +158,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
let seasonStandings: SeasonStanding[] = [];
|
let seasonStandings: SeasonStanding[] = [];
|
||||||
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
|
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number] & { globalRank: number };
|
||||||
let qpStandings: QPStanding[] = [];
|
let qpStandings: QPStanding[] = [];
|
||||||
|
let majorsCompleted = 0;
|
||||||
|
|
||||||
// Group standings for group-stage events (e.g. FIFA World Cup)
|
// 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;
|
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") {
|
} 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);
|
const standings = await getQPStandings(sportsSeasonId);
|
||||||
// Compute global ranks with tie handling across the full field before filtering,
|
// 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.
|
// 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 {
|
return {
|
||||||
league,
|
league,
|
||||||
season,
|
season,
|
||||||
sportsSeason,
|
sportsSeason: { ...sportsSeason, majorsCompleted },
|
||||||
scoringPattern,
|
scoringPattern,
|
||||||
playoffMatches,
|
playoffMatches,
|
||||||
playoffRounds,
|
playoffRounds,
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal("fetch", mockFetch(204));
|
vi.stubGlobal("fetch", mockFetch(204));
|
||||||
});
|
});
|
||||||
|
|
||||||
const BASE_ENTRIES = [
|
const BASE_ENTRIES = withRanks([
|
||||||
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
|
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
|
||||||
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
|
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
|
||||||
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
|
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
|
||||||
];
|
]);
|
||||||
|
|
||||||
it("sends an embed with gold color and QP title", async () => {
|
it("sends an embed with gold color and QP title", async () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
|
|
@ -759,11 +783,11 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: [
|
entries: withRanks([
|
||||||
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
||||||
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
||||||
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
||||||
],
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
|
|
@ -772,11 +796,74 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
expect(desc).toContain("3\\. Player C");
|
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 9–16 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 () => {
|
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: [
|
entries: withRanks([
|
||||||
{
|
{
|
||||||
participantName: "Novak Djokovic",
|
participantName: "Novak Djokovic",
|
||||||
qpEarned: 20,
|
qpEarned: 20,
|
||||||
|
|
@ -784,7 +871,7 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
ownerUsername: "alex",
|
ownerUsername: "alex",
|
||||||
ownerDiscordUserId: "111222333",
|
ownerDiscordUserId: "111222333",
|
||||||
},
|
},
|
||||||
],
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
|
|
@ -796,10 +883,10 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: [
|
entries: withRanks([
|
||||||
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
|
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
|
||||||
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
|
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
|
||||||
],
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = getPayload();
|
const payload = getPayload();
|
||||||
|
|
@ -864,9 +951,9 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
seasonName: "Slam League 2025",
|
seasonName: "Slam League 2025",
|
||||||
entries: [
|
entries: withRanks([
|
||||||
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
|
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
|
||||||
],
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
|
|
@ -875,12 +962,14 @@ describe("sendQualifyingPointsUpdateNotification", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("truncates description at 4096 characters", async () => {
|
it("truncates description at 4096 characters", async () => {
|
||||||
const longEntries = Array.from({ length: 200 }, (_, i) => ({
|
const longEntries = withRanks(
|
||||||
participantName: `Very Long Participant Name Number ${i}`,
|
Array.from({ length: 200 }, (_, i) => ({
|
||||||
qpEarned: i % 2 === 0 ? 5 : 0,
|
participantName: `Very Long Participant Name Number ${i}`,
|
||||||
qpTotal: 200 - i,
|
qpEarned: i % 2 === 0 ? 5 : 0,
|
||||||
ownerUsername: `owner_with_long_username_${i}`,
|
qpTotal: 200 - i,
|
||||||
}));
|
ownerUsername: `owner_with_long_username_${i}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
await sendQualifyingPointsUpdateNotification({
|
await sendQualifyingPointsUpdateNotification({
|
||||||
webhookUrl: WEBHOOK_URL,
|
webhookUrl: WEBHOOK_URL,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ vi.mock("~/database/context", () => ({
|
||||||
vi.mock("~/models/scoring-calculator", () => ({
|
vi.mock("~/models/scoring-calculator", () => ({
|
||||||
processQualifyingEvent: vi.fn(),
|
processQualifyingEvent: vi.fn(),
|
||||||
recalculateAffectedLeagues: 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", () => ({
|
vi.mock("~/models/scoring-event", () => ({
|
||||||
|
|
@ -360,7 +370,10 @@ describe("syncTournamentResults", () => {
|
||||||
expect(c?.qualifyingPointsAwarded).toBe("0");
|
expect(c?.qualifyingPointsAwarded).toBe("0");
|
||||||
|
|
||||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
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(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
|
||||||
|
|
||||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
|
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
|
||||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
|
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, {
|
||||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", 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(report.windowsSynced).toBe(1);
|
||||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(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.
|
// No event_results written for the skipped primary window.
|
||||||
expect(
|
expect(
|
||||||
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
|
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,16 @@ function escapeMarkdown(text: string): string {
|
||||||
return text.replace(/[_*~`|\\]/g, "\\$&");
|
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 9–16 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 {
|
export interface StandingEntry {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -251,6 +261,15 @@ export interface QPEventEntry {
|
||||||
participantName: string;
|
participantName: string;
|
||||||
qpEarned: number;
|
qpEarned: number;
|
||||||
qpTotal: 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;
|
ownerUsername?: string;
|
||||||
ownerDiscordUserId?: string;
|
ownerDiscordUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -301,7 +320,7 @@ export async function sendQualifyingPointsUpdateNotification({
|
||||||
const label = managerLabel
|
const label = managerLabel
|
||||||
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
|
||||||
: escapeMarkdown(e.participantName);
|
: 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);
|
// Order by the participant's rank in the FULL season standings (globalRank),
|
||||||
// Compute ranks with tie detection
|
// supplied by the caller, so the block reads as a season leaderboard slice.
|
||||||
const ranks: number[] = [];
|
const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank);
|
||||||
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);
|
|
||||||
|
|
||||||
// The standings block reflects QP earners; skip it entirely when this sync only
|
// 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.
|
// reported knockouts (no QP change) so we don't emit an empty header.
|
||||||
if (sorted.length > 0) {
|
if (sorted.length > 0) {
|
||||||
sections.push("\n**QP Standings**");
|
sections.push("\n**QP Standings**");
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
for (const e of sorted) {
|
||||||
const e = sorted[i];
|
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
|
||||||
const r = ranks[i];
|
|
||||||
const rankPrefix = isTied(r) ? `T${r}` : `${r}`;
|
|
||||||
const ownerLabel = e.ownerDiscordUserId
|
const ownerLabel = e.ownerDiscordUserId
|
||||||
? `<@${e.ownerDiscordUserId}>`
|
? `<@${e.ownerDiscordUserId}>`
|
||||||
: e.ownerUsername
|
: e.ownerUsername
|
||||||
? escapeMarkdown(e.ownerUsername)
|
? escapeMarkdown(e.ownerUsername)
|
||||||
: undefined;
|
: undefined;
|
||||||
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
|
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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,29 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
|
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
|
// Batch-fetch all season + league metadata in one query
|
||||||
const seasons = await db.query.seasons.findMany({
|
const seasons = await db.query.seasons.findMany({
|
||||||
where: inArray(schema.seasons.id, seasonIds),
|
where: inArray(schema.seasons.id, seasonIds),
|
||||||
|
|
@ -158,10 +181,13 @@ export async function notifyQualifyingPointsUpdate(
|
||||||
|
|
||||||
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
|
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
|
||||||
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
|
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
|
||||||
|
const globalRank = globalRankById.get(participantId) ?? 0;
|
||||||
return {
|
return {
|
||||||
participantName: participantNameById.get(participantId) ?? participantId,
|
participantName: participantNameById.get(participantId) ?? participantId,
|
||||||
qpEarned: qpEarnedById.get(participantId) ?? 0,
|
qpEarned: qpEarnedById.get(participantId) ?? 0,
|
||||||
qpTotal: qpTotalById.get(participantId) ?? 0,
|
qpTotal: qpTotalById.get(participantId) ?? 0,
|
||||||
|
globalRank,
|
||||||
|
globalRankTied: (countByRank.get(globalRank) ?? 0) > 1,
|
||||||
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
|
||||||
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import * as schema from "~/database/schema";
|
||||||
import {
|
import {
|
||||||
processQualifyingEvent,
|
processQualifyingEvent,
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
|
buildTieCountByPlacement,
|
||||||
} from "~/models/scoring-calculator";
|
} from "~/models/scoring-calculator";
|
||||||
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
|
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
|
||||||
import { getEventResults } from "~/models/event-result";
|
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.
|
* primary is already scored in place, so re-running it would be wasted work.
|
||||||
*/
|
*/
|
||||||
skipEventId?: string;
|
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 = {}
|
options: SyncOptions = {}
|
||||||
): Promise<SyncReport> {
|
): Promise<SyncReport> {
|
||||||
const db = database();
|
const db = database();
|
||||||
const { markComplete = true, skipEventId } = options;
|
const { markComplete = true, skipEventId, skipNotifications = false } = options;
|
||||||
|
|
||||||
const report: SyncReport = {
|
const report: SyncReport = {
|
||||||
tournamentId,
|
tournamentId,
|
||||||
|
|
@ -72,6 +80,11 @@ export async function syncTournamentResults(
|
||||||
.from(schema.tournamentResults)
|
.from(schema.tournamentResults)
|
||||||
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
|
.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.
|
// 2. Load every scoring_event that points at this tournament.
|
||||||
const allLinkedEvents = await db
|
const allLinkedEvents = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -176,12 +189,17 @@ export async function syncTournamentResults(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3d. Delegate to scoring engine inside the same transaction.
|
// 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
|
// 3e. Mark the window event complete (final-results sync only). This is
|
||||||
// what makes "score once" actually complete every window — without it,
|
// what makes "score once" actually complete every window — without it,
|
||||||
// each sibling stayed "In Progress" and had to be completed by hand.
|
// 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);
|
await completeScoringEvent(ev.id, tx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -213,8 +231,9 @@ export async function syncTournamentResults(
|
||||||
eventName: w.eventName ?? undefined,
|
eventName: w.eventName ?? undefined,
|
||||||
// Mid-tournament fan-out (markComplete=false) updates standings silently;
|
// Mid-tournament fan-out (markComplete=false) updates standings silently;
|
||||||
// the primary window already announced the round. Only the final sync
|
// the primary window already announced the round. Only the final sync
|
||||||
// (completion) announces to each window's leagues.
|
// (completion) announces to each window's leagues. A backfill
|
||||||
skipDiscord: !markComplete,
|
// (skipNotifications) is always silent.
|
||||||
|
skipDiscord: skipNotifications || !markComplete,
|
||||||
});
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
|
|
@ -250,9 +269,9 @@ export async function syncTournamentResults(
|
||||||
*/
|
*/
|
||||||
export async function syncMajorFromPrimaryEvent(
|
export async function syncMajorFromPrimaryEvent(
|
||||||
primaryEventId: string,
|
primaryEventId: string,
|
||||||
options: { markComplete?: boolean } = {}
|
options: { markComplete?: boolean; skipNotifications?: boolean } = {}
|
||||||
): Promise<SyncReport> {
|
): Promise<SyncReport> {
|
||||||
const { markComplete = false } = options;
|
const { markComplete = false, skipNotifications = false } = options;
|
||||||
|
|
||||||
const primaryEvent = await getScoringEventById(primaryEventId);
|
const primaryEvent = await getScoringEventById(primaryEventId);
|
||||||
if (!primaryEvent) {
|
if (!primaryEvent) {
|
||||||
|
|
@ -317,6 +336,7 @@ export async function syncMajorFromPrimaryEvent(
|
||||||
return syncTournamentResults(tournamentId, {
|
return syncTournamentResults(tournamentId, {
|
||||||
markComplete,
|
markComplete,
|
||||||
skipEventId: primaryEventId,
|
skipEventId: primaryEventId,
|
||||||
|
skipNotifications,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
170
scripts/backfill-qp-resplit.ts
Normal file
170
scripts/backfill-qp-resplit.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
/**
|
||||||
|
* 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 9–16: (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, all tournaments
|
||||||
|
* npx tsx scripts/backfill-qp-resplit.ts --dry # report only
|
||||||
|
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon # scope to matching event names
|
||||||
|
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon --sport Men # + scope to a sport (e.g. gender)
|
||||||
|
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon --sport Men --dry
|
||||||
|
*
|
||||||
|
* --name matches case-insensitively against each linked scoring event's name;
|
||||||
|
* matches ALL tournaments with a matching event (e.g. both years of "Wimbledon"),
|
||||||
|
* since the reported bug spans seasons. Tournament names don't encode gender (both
|
||||||
|
* men's and women's majors are just "Wimbledon") — use --sport to scope further,
|
||||||
|
* matched case-insensitively against the sport's name (e.g. "Tennis - Men").
|
||||||
|
* Both filters apply to a tournament if ANY of its linked events match.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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");
|
||||||
|
function argValue(flag: string): string | null {
|
||||||
|
const idx = process.argv.indexOf(flag);
|
||||||
|
return idx !== -1 && process.argv[idx + 1] ? process.argv[idx + 1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
const NAME_FILTER = argValue("--name");
|
||||||
|
const SPORT_FILTER = argValue("--sport");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NAME_FILTER) {
|
||||||
|
for (const [tournamentId, evs] of byTournament) {
|
||||||
|
const matches = evs.some((e) => e.name?.toLowerCase().includes(NAME_FILTER));
|
||||||
|
if (!matches) byTournament.delete(tournamentId);
|
||||||
|
}
|
||||||
|
log(`Filtering to tournaments matching --name "${NAME_FILTER}"`);
|
||||||
|
}
|
||||||
|
if (SPORT_FILTER) {
|
||||||
|
for (const [tournamentId, evs] of byTournament) {
|
||||||
|
const matches = evs.some((e) =>
|
||||||
|
e.sportsSeason?.sport?.name?.toLowerCase().includes(SPORT_FILTER)
|
||||||
|
);
|
||||||
|
if (!matches) byTournament.delete(tournamentId);
|
||||||
|
}
|
||||||
|
log(`Filtering to tournaments matching --sport "${SPORT_FILTER}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sportName = evs[0]?.sportsSeason?.sport?.name ?? "unknown sport";
|
||||||
|
const name = `${evs[0]?.name ?? tournamentId} [${sportName}]`;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue