Compare commits
4 commits
58e656f15e
...
8416b54052
| Author | SHA1 | Date | |
|---|---|---|---|
| 8416b54052 | |||
|
|
698c6a6509 | ||
|
|
12c165a481 | ||
|
|
0baef5a948 |
4 changed files with 283 additions and 26 deletions
|
|
@ -286,7 +286,7 @@ export function PlayoffBracket({
|
|||
.map((id) => participantMap.get(id))
|
||||
.filter((p): p is Participant => p !== undefined)
|
||||
// Owned-by-a-manager players first, then alphabetical by name.
|
||||
.sort((a, b) => {
|
||||
.toSorted((a, b) => {
|
||||
const aOwned = ownershipMap.has(a.id);
|
||||
const bOwned = ownershipMap.has(b.id);
|
||||
if (aOwned !== bOwned) return aOwned ? -1 : 1;
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ import { database } from "~/database/context";
|
|||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
|
||||
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
|
||||
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
|
||||
|
||||
|
|
@ -763,11 +763,32 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
|
||||
}
|
||||
// Re-propagate corrected QP to sibling windows (data-correction; not final).
|
||||
await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||
return {
|
||||
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
|
||||
};
|
||||
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
|
||||
// final). Call syncMajorFromPrimaryEvent directly rather than the
|
||||
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
|
||||
// mirrors were re-scored — a silent failure here is exactly how mirrors got
|
||||
// left showing stale QP behind a green "success".
|
||||
const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`;
|
||||
if (event.isPrimary && event.tournamentId) {
|
||||
try {
|
||||
const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false });
|
||||
// Surface a partial fan-out as an error so it renders as a warning
|
||||
// banner, not a green success the admin might skim past while some
|
||||
// mirror windows are left stale.
|
||||
if (report.windowsFailed > 0) {
|
||||
const reasons = report.failures.map((f) => f.error).join("; ");
|
||||
return {
|
||||
error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`,
|
||||
};
|
||||
}
|
||||
return { success: `${baseMessage} Synced ${report.windowsSynced} mirror window(s).` };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { success: `${baseMessage} No mirror windows to sync.` };
|
||||
}
|
||||
|
||||
if (completed.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import type * as DrizzleOrm from "drizzle-orm";
|
||||
import type * as ScoringCalculatorModule from "~/models/scoring-calculator";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
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-calculator", async () => {
|
||||
// Keep the real implementations of the PURE helpers so the tie-count map handed
|
||||
// to processQualifyingEvent reflects the mock canonical results AND the bracket's
|
||||
// structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the
|
||||
// DB-touching orchestrators are stubbed.
|
||||
const actual = await vi.importActual<typeof ScoringCalculatorModule>(
|
||||
"~/models/scoring-calculator"
|
||||
);
|
||||
return {
|
||||
processQualifyingEvent: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
buildTieCountByPlacement: actual.buildTieCountByPlacement,
|
||||
deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
|
||||
getRoundConfig: actual.getRoundConfig,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("~/models/scoring-event", () => ({
|
||||
completeScoringEvent: vi.fn(),
|
||||
|
|
@ -99,11 +103,21 @@ interface FakeEventResult {
|
|||
// - event_results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakePlayoffMatch {
|
||||
scoringEventId: string;
|
||||
round: string;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
}
|
||||
|
||||
interface FakeDbState {
|
||||
tournamentResults: FakeTournamentResult[];
|
||||
scoringEvents: FakeScoringEvent[];
|
||||
seasonParticipants: FakeSeasonParticipant[];
|
||||
eventResults: FakeEventResult[];
|
||||
playoffMatches: FakePlayoffMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -151,6 +165,8 @@ function makeFakeDb(state: FakeDbState) {
|
|||
return state.seasonParticipants;
|
||||
case "event_results":
|
||||
return state.eventResults;
|
||||
case "playoff_matches":
|
||||
return state.playoffMatches;
|
||||
default:
|
||||
throw new Error(`Unknown table in fake db: ${name}`);
|
||||
}
|
||||
|
|
@ -291,6 +307,7 @@ function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
|
|||
scoringEvents: [],
|
||||
seasonParticipants: [],
|
||||
eventResults: [],
|
||||
playoffMatches: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -836,6 +853,140 @@ describe("syncMajorFromPrimaryEvent", () => {
|
|||
expect(completeScoringEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("splits mirror QP by the bracket's structural tie span, not the canonical row count (R16 in progress)", async () => {
|
||||
// Tennis R16 in progress: two R16 matches are decided (losers cp-L1/cp-L2 land
|
||||
// final at 9th) and two players (cp-F1/cp-F2) won their R32 match and are still
|
||||
// "floored" at the R16 tier (also placement 9). So only FOUR players sit at
|
||||
// placement 9 right now — the canonical row-count is 4, which would wrongly
|
||||
// split 9th–16th four ways (→ 2 QP). The R16 tier structurally spans 8 slots,
|
||||
// so every window must split (2+2+2+2+1+1+1+1)/8 = 1.5. This asserts the map
|
||||
// handed to each mirror's processQualifyingEvent carries the structural span (8),
|
||||
// not the live count (4).
|
||||
const state = seedBasicState({
|
||||
scoringEvents: [
|
||||
{
|
||||
id: "ev-PRIMARY",
|
||||
sportsSeasonId: "ss-P",
|
||||
tournamentId: "t-1",
|
||||
name: "Wimbledon",
|
||||
},
|
||||
{
|
||||
id: "ev-MIRROR",
|
||||
sportsSeasonId: "ss-M",
|
||||
tournamentId: "t-1",
|
||||
name: "Wimbledon",
|
||||
},
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-ML1", sportsSeasonId: "ss-M", participantId: "cp-L1", name: "L1" },
|
||||
{ id: "sp-MF1", sportsSeasonId: "ss-M", participantId: "cp-F1", name: "F1" },
|
||||
],
|
||||
// Primary bracket state. R32 wins establish the "floored at R16" players;
|
||||
// decided R16 matches establish the final 9th-place losers and the QF-floored
|
||||
// winners (placement 5).
|
||||
playoffMatches: [
|
||||
{
|
||||
scoringEventId: "ev-PRIMARY",
|
||||
round: "Round of 32",
|
||||
winnerId: "cp-F1",
|
||||
loserId: "cp-out1",
|
||||
participant1Id: "cp-F1",
|
||||
participant2Id: "cp-out1",
|
||||
},
|
||||
{
|
||||
scoringEventId: "ev-PRIMARY",
|
||||
round: "Round of 32",
|
||||
winnerId: "cp-F2",
|
||||
loserId: "cp-out2",
|
||||
participant1Id: "cp-F2",
|
||||
participant2Id: "cp-out2",
|
||||
},
|
||||
{
|
||||
scoringEventId: "ev-PRIMARY",
|
||||
round: "Round of 16",
|
||||
winnerId: "cp-W1",
|
||||
loserId: "cp-L1",
|
||||
participant1Id: "cp-W1",
|
||||
participant2Id: "cp-L1",
|
||||
},
|
||||
{
|
||||
scoringEventId: "ev-PRIMARY",
|
||||
round: "Round of 16",
|
||||
winnerId: "cp-W2",
|
||||
loserId: "cp-L2",
|
||||
participant1Id: "cp-W2",
|
||||
participant2Id: "cp-L2",
|
||||
},
|
||||
],
|
||||
});
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
||||
id: "ev-PRIMARY",
|
||||
sportsSeasonId: "ss-P",
|
||||
tournamentId: "t-1",
|
||||
name: "Wimbledon",
|
||||
bracketTemplateId: "tennis_128",
|
||||
} as never);
|
||||
|
||||
// Primary window's derived results (what processQualifyingBracketEvent wrote):
|
||||
// four players at placement 9 (2 final losers + 2 floored), two at placement 5.
|
||||
vi.mocked(getEventResults).mockResolvedValue(
|
||||
[
|
||||
["sp-PL1", "cp-L1", 9],
|
||||
["sp-PL2", "cp-L2", 9],
|
||||
["sp-PF1", "cp-F1", 9],
|
||||
["sp-PF2", "cp-F2", 9],
|
||||
["sp-PW1", "cp-W1", 5],
|
||||
["sp-PW2", "cp-W2", 5],
|
||||
].map(([seasonParticipantId, participantId, placement]) => ({
|
||||
placement,
|
||||
rawScore: null,
|
||||
notParticipating: false,
|
||||
seasonParticipantId,
|
||||
seasonParticipant: { participantId },
|
||||
})) as never
|
||||
);
|
||||
|
||||
vi.mocked(upsertTournamentResult).mockImplementation(
|
||||
async (data: {
|
||||
tournamentId: string;
|
||||
participantId: string;
|
||||
placement?: number | null;
|
||||
rawScore?: string | null;
|
||||
}) => {
|
||||
state.tournamentResults.push({
|
||||
tournamentId: data.tournamentId,
|
||||
participantId: data.participantId,
|
||||
placement: data.placement ?? null,
|
||||
rawScore: data.rawScore ?? null,
|
||||
});
|
||||
return data as never;
|
||||
}
|
||||
);
|
||||
|
||||
await syncMajorFromPrimaryEvent("ev-PRIMARY", { markComplete: false });
|
||||
|
||||
// Sanity: only four canonical rows sit at placement 9 (the live count is 4).
|
||||
expect(
|
||||
state.tournamentResults.filter((r) => r.placement === 9)
|
||||
).toHaveLength(4);
|
||||
|
||||
// The mirror window was scored with the STRUCTURAL span, not the row count.
|
||||
const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find(
|
||||
(c) => c[0] === "ev-MIRROR"
|
||||
);
|
||||
expect(mirrorCall).toBeDefined();
|
||||
if (!mirrorCall) return;
|
||||
const tieMap = mirrorCall[2].canonicalTieCountByPlacement as Map<number, number>;
|
||||
expect(tieMap.get(9)).toBe(8); // R16 tier spans 8, not the 4 rows currently there
|
||||
expect(tieMap.get(5)).toBe(4); // QF tier spans 4
|
||||
});
|
||||
|
||||
it("throws when the primary event is not linked to a tournament", async () => {
|
||||
const db = makeFakeDb(seedBasicState());
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ import {
|
|||
processQualifyingEvent,
|
||||
recalculateAffectedLeagues,
|
||||
buildTieCountByPlacement,
|
||||
deriveBracketQualifyingStates,
|
||||
getRoundConfig,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
|
||||
import { getEventResults } from "~/models/event-result";
|
||||
import { upsertTournamentResult } from "~/models/tournament-result";
|
||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
export interface SyncReport {
|
||||
|
|
@ -43,6 +46,20 @@ export interface SyncOptions {
|
|||
* (e.g. a mis-split 2 → correct 1.5), which would otherwise re-ping every league.
|
||||
*/
|
||||
skipNotifications?: boolean;
|
||||
/**
|
||||
* Pre-computed tie span (placement → tieCount) to split QP across each tied
|
||||
* group, overriding the row-count derived from canonical tournament_results.
|
||||
*
|
||||
* For bracket majors (tennis, CS2) the tie span is a STRUCTURAL property of the
|
||||
* round — R16 spans 8 slots (9th–16th), QF 4, SF 2, Final 1 — not the live count
|
||||
* of players currently sitting at a placement. Mid-tournament, players "floored"
|
||||
* at a tier make the canonical row-count diverge from the structural span, so a
|
||||
* mirror window would split differently than the primary (e.g. R16 losers at
|
||||
* 2 QP instead of 1.5). syncMajorFromPrimaryEvent derives this map from the
|
||||
* primary bracket so every window splits identically to the primary at every
|
||||
* stage. Omitted for golf-style majors (no bracket) → canonical row-count is used.
|
||||
*/
|
||||
tieCountByPlacement?: Map<number, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -65,7 +82,12 @@ export async function syncTournamentResults(
|
|||
options: SyncOptions = {}
|
||||
): Promise<SyncReport> {
|
||||
const db = database();
|
||||
const { markComplete = true, skipEventId, skipNotifications = false } = options;
|
||||
const {
|
||||
markComplete = true,
|
||||
skipEventId,
|
||||
skipNotifications = false,
|
||||
tieCountByPlacement,
|
||||
} = options;
|
||||
|
||||
const report: SyncReport = {
|
||||
tournamentId,
|
||||
|
|
@ -80,10 +102,16 @@ 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.
|
||||
// The tie span (placement → count) used to split QP across a tied group. Default
|
||||
// is the count of canonical rows at each placement (a whole-tournament property,
|
||||
// computed once here). For bracket majors the caller also passes the STRUCTURAL
|
||||
// span from the primary bracket (R16 = 8, QF = 4, …); merge it OVER the counts so
|
||||
// bracket placements split like the primary even mid-tournament while any
|
||||
// non-bracket placements (e.g. CS2 Swiss exits, golf) keep their canonical count.
|
||||
const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults);
|
||||
const effectiveTieCountByPlacement = tieCountByPlacement
|
||||
? new Map([...canonicalTieCountByPlacement, ...tieCountByPlacement])
|
||||
: canonicalTieCountByPlacement;
|
||||
|
||||
// 2. Load every scoring_event that points at this tournament.
|
||||
const allLinkedEvents = await db
|
||||
|
|
@ -191,7 +219,7 @@ export async function syncTournamentResults(
|
|||
// 3d. Delegate to scoring engine inside the same transaction.
|
||||
await processQualifyingEvent(ev.id, tx, {
|
||||
skipNotifications,
|
||||
canonicalTieCountByPlacement,
|
||||
canonicalTieCountByPlacement: effectiveTieCountByPlacement,
|
||||
});
|
||||
|
||||
// 3e. Mark the window event complete (final-results sync only). This is
|
||||
|
|
@ -333,13 +361,70 @@ export async function syncMajorFromPrimaryEvent(
|
|||
`[syncMajorFromPrimaryEvent] promoted ${promotedCanonicalIds.size} result(s) from primary ${primaryEventId} to tournament ${tournamentId}; removed ${staleIds.length} stale`
|
||||
);
|
||||
|
||||
// For a bracket major, split each placement's QP by the round's STRUCTURAL tie
|
||||
// span (the same span the primary used via processQualifyingBracketEvent), not by
|
||||
// the live count of canonical rows at that placement. Mid-tournament, players
|
||||
// floored at a tier inflate the row-count and would make mirror windows split
|
||||
// differently than the primary (e.g. R16 losers → 2 QP instead of 1.5). Derived
|
||||
// here so every fan-out caller (reprocess, live rounds, finalize) stays consistent.
|
||||
const tieCountByPlacement = await deriveStructuralTieSpanForBracket(
|
||||
primaryEvent,
|
||||
db
|
||||
);
|
||||
|
||||
return syncTournamentResults(tournamentId, {
|
||||
markComplete,
|
||||
skipEventId: primaryEventId,
|
||||
skipNotifications,
|
||||
tieCountByPlacement,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a placement → structural tie-span map for a bracket major's primary
|
||||
* window, mirroring the spans processQualifyingBracketEvent assigns (R16 losers
|
||||
* span 8 slots, QF 4, SF 2, Final 1). Returns undefined for a primary with no
|
||||
* bracket template or no matches (e.g. golf), so the fan-out falls back to counting
|
||||
* canonical rows exactly as before.
|
||||
*
|
||||
* The span for a given placement is consistent across the bracket (every R16-tier
|
||||
* state carries tieCount 8, whether the player is a final loser or still floored),
|
||||
* so collapsing the per-participant states into a placement → tieCount map is safe.
|
||||
*/
|
||||
async function deriveStructuralTieSpanForBracket(
|
||||
primaryEvent: { id: string; bracketTemplateId: string | null },
|
||||
db: ReturnType<typeof database>
|
||||
): Promise<Map<number, number> | undefined> {
|
||||
if (!primaryEvent.bracketTemplateId) return undefined;
|
||||
const template = BRACKET_TEMPLATES[primaryEvent.bracketTemplateId];
|
||||
if (!template) return undefined;
|
||||
|
||||
const matches = await db
|
||||
.select()
|
||||
.from(schema.playoffMatches)
|
||||
.where(eq(schema.playoffMatches.scoringEventId, primaryEvent.id));
|
||||
if (matches.length === 0) return undefined;
|
||||
|
||||
const states = deriveBracketQualifyingStates(
|
||||
matches.map((m) => ({
|
||||
round: m.round,
|
||||
winnerId: m.winnerId,
|
||||
loserId: m.loserId,
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
})),
|
||||
template.rounds,
|
||||
(round) => getRoundConfig(round, primaryEvent.bracketTemplateId)
|
||||
);
|
||||
if (states.size === 0) return undefined;
|
||||
|
||||
const map = new Map<number, number>();
|
||||
for (const { placement, tieCount } of states.values()) {
|
||||
map.set(placement, tieCount);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience guard for route handlers: fan out a just-scored bracket/stage event
|
||||
* to its sibling windows ONLY when it's the designated primary of a shared
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue