brackt/app/services/match-sync/index.ts

703 lines
26 KiB
TypeScript
Raw Normal View History

import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
import {
findParticipantsBySportsSeasonId,
updateParticipant,
createParticipant,
createManyParticipants,
} from "~/models/season-participant";
import { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match";
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
import {
setMatchWinner,
updatePlayoffMatch,
advanceWinnerTemplate,
findPlayoffMatchesByEventId,
doesLoserAdvance,
populateBracketFromDraw,
} from "~/models/playoff-match";
import {
processMatchResult,
autoCompleteRoundIfDone,
processQualifyingBracketEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import {
recalculateParticipantQP,
diffChangedQualifyingPoints,
} from "~/models/qualifying-points";
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name";
import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { PandaScoreMatchSyncAdapter } from "./pandascore";
import { EspnScheduleAdapter } from "./espn-schedule";
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
import { WikipediaTennisAdapter } from "./wikipedia-tennis";
import { playerKey, canonicalPlayerName, buildResolvedMatches } from "./tennis-draw-mapping";
import type {
MatchSyncAdapter,
MatchSyncResult,
DrawSyncAdapter,
DrawSyncResult,
DrawSyncPreview,
FetchedDraw,
FetchedPlayer,
} from "./types";
import { logger } from "~/lib/logger";
function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
switch (simulatorType) {
case "cs2_major_qualifying_points":
return new PandaScoreMatchSyncAdapter();
case "mlb_bracket":
return new EspnScheduleAdapter("baseball/mlb", 3);
case "nba_bracket":
return new EspnScheduleAdapter("basketball/nba", 3);
case "mls_bracket":
return new EspnScheduleAdapter("soccer/mls", 3);
case "wnba_bracket":
return new EspnScheduleAdapter("basketball/wnba", 3);
case "nhl_bracket":
return new EspnScheduleAdapter("hockey/nhl", 3);
default:
throw new Error(
`No match sync adapter available for simulator type "${simulatorType}". ` +
"CS2, MLB, NBA, MLS, WNBA, and NHL are currently supported."
);
}
}
export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResult> {
const db = database();
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, sportsSeasonId),
with: { sport: true },
});
if (!sportsSeason) throw new Error(`Sports season ${sportsSeasonId} not found`);
const externalSeasonId = sportsSeason.externalSeasonId;
if (!externalSeasonId) {
throw new Error(`Sports season "${sportsSeason.name}" has no externalSeasonId configured`);
}
const simulatorType = sportsSeason.sport?.simulatorType;
if (!simulatorType) {
throw new Error(`Sport "${sportsSeason.sport?.name ?? "(none)"}" has no simulator type configured`);
}
const adapter = getMatchSyncAdapter(simulatorType);
const fetchedMatches = await adapter.fetchMatches(externalSeasonId);
// Load participants, build lookup maps
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const participantNames = participants.map((p) => p.name);
const participantByExternalId = new Map(
participants.filter((p) => p.externalId).map((p) => [p.externalId as string, p])
);
const participantByName = new Map(participants.map((p) => [p.name, p]));
async function resolveParticipant(externalId: string, name: string) {
let participant = participantByExternalId.get(externalId) ?? null;
if (!participant) {
const matchedName = findMatchingTeamName(name, participantNames);
if (matchedName) {
participant = participantByName.get(matchedName) ?? null;
if (participant && !participant.externalId) {
await updateParticipant(participant.id, { externalId });
participantByExternalId.set(externalId, participant);
}
}
}
return participant;
}
// Find the scoring event for this sports season (needed for CS2 stage lookup + playoff sync)
const scoringEvents = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
// Split matches: Swiss (matchStage set) vs playoff bracket (matchStage null)
const swissMatches = fetchedMatches.filter((m) => m.matchStage !== null && m.matchStage !== undefined);
const bracketMatches = fetchedMatches.filter((m) => m.matchStage === null || m.matchStage === undefined);
const unmatchedTeams: MatchSyncResult["unmatchedTeams"] = [];
const errors: MatchSyncResult["errors"] = [];
let swissCreated = 0;
let swissUpdated = 0;
let playoffUpdated = 0;
// --- Swiss matches → season_matches ---
if (swissMatches.length > 0) {
// For CS2, use the scoring event that has cs2_major_stage_results
const cs2Event = scoringEvents.find((e) => e.isQualifyingEvent) ?? scoringEvents[0] ?? null;
const toUpsert = [];
for (const m of swissMatches) {
const p1 = await resolveParticipant(m.team1ExternalId, m.team1Name);
const p2 = await resolveParticipant(m.team2ExternalId, m.team2Name);
if (!p1) unmatchedTeams.push({ externalId: m.team1ExternalId, name: m.team1Name });
if (!p2) unmatchedTeams.push({ externalId: m.team2ExternalId, name: m.team2Name });
const winnerParticipant = m.winnerExternalId
? (await resolveParticipant(m.winnerExternalId, ""))
: null;
toUpsert.push({
sportsSeasonId,
scoringEventId: cs2Event?.id ?? null,
participant1Id: p1?.id ?? null,
participant2Id: p2?.id ?? null,
winnerId: winnerParticipant?.id ?? null,
participant1Score: m.team1Score,
participant2Score: m.team2Score,
matchStage: m.matchStage ?? null,
matchRound: m.matchRound ?? null,
matchday: m.matchday ?? null,
isSeries: m.isSeries ?? false,
status: m.status,
scheduledAt: m.scheduledAt,
startedAt: m.startedAt,
completedAt: m.completedAt,
externalMatchId: m.externalMatchId,
});
}
const upserted = await upsertSeasonMatchBulk(toUpsert);
// Track created vs updated (rough heuristic: if completedAt is within last 2 min, it's new)
swissCreated = upserted.filter((r) => {
const diff = r.createdAt ? Date.now() - r.createdAt.getTime() : Infinity;
return diff < 120_000;
}).length;
swissUpdated = upserted.length - swissCreated;
// Upsert sub-games (maps) for matches with subGames
for (const m of swissMatches) {
if (!m.subGames?.length) continue;
const upsertedMatch = upserted.find((u) => u.externalMatchId === m.externalMatchId);
if (!upsertedMatch) continue;
for (const g of m.subGames) {
const winnerParticipant = g.winnerExternalId
? (await resolveParticipant(g.winnerExternalId, ""))
: null;
try {
await upsertMatchSubGame({
seasonMatchId: upsertedMatch.id,
gameNumber: g.gameNumber,
gameLabel: g.gameLabel ?? null,
participant1Score: g.team1Score,
participant2Score: g.team2Score,
winnerId: winnerParticipant?.id ?? null,
status: g.status,
externalGameId: g.externalGameId ?? null,
});
} catch (err) {
logger.error(`[match-sync] Error upserting sub-game ${g.gameNumber} for match ${m.externalMatchId}:`, err);
}
}
}
}
// --- Bracket matches → playoff_matches ---
if (bracketMatches.length > 0 && scoringEvents.length === 0) {
logger.warn(`[match-sync] ${bracketMatches.length} bracket match(es) found but no scoring events exist for season ${sportsSeasonId} — bracket sync skipped`);
}
if (bracketMatches.length > 0) {
for (const event of scoringEvents) {
const existingPlayoffMatches = await findPlayoffMatchesByEventId(event.id);
if (existingPlayoffMatches.length === 0) continue;
const bracketTemplate = event.bracketTemplateId
? getBracketTemplate(event.bracketTemplateId)
: null;
for (const m of bracketMatches) {
if (m.status !== "complete") continue;
const p1 = await resolveParticipant(m.team1ExternalId, m.team1Name);
const p2 = await resolveParticipant(m.team2ExternalId, m.team2Name);
if (!p1 || !p2) continue;
const winnerParticipant = m.winnerExternalId
? await resolveParticipant(m.winnerExternalId, "")
: null;
if (!winnerParticipant) continue;
const loserId = winnerParticipant.id === p1.id ? p2.id : p1.id;
// Find the matching playoff_matches row: prefer external_match_id match, fall back to participant ID match
let playoffMatch = existingPlayoffMatches.find(
(pm) => pm.externalMatchId === m.externalMatchId
);
if (!playoffMatch) {
playoffMatch = existingPlayoffMatches.find(
(pm) =>
!pm.isComplete &&
((pm.participant1Id === p1.id && pm.participant2Id === p2.id) ||
(pm.participant1Id === p2.id && pm.participant2Id === p1.id))
);
}
if (!playoffMatch) continue;
if (playoffMatch.isComplete && playoffMatch.winnerId) continue; // idempotent
try {
await setMatchWinner(
playoffMatch.id,
winnerParticipant.id,
loserId,
winnerParticipant.id === p1.id ? (m.team1Score ?? undefined) : (m.team2Score ?? undefined),
winnerParticipant.id === p1.id ? (m.team2Score ?? undefined) : (m.team1Score ?? undefined)
);
// Set external_match_id for future upserts
if (!playoffMatch.externalMatchId) {
await updatePlayoffMatch(playoffMatch.id, { externalMatchId: m.externalMatchId });
}
if (bracketTemplate) {
try {
await advanceWinnerTemplate(playoffMatch.id, winnerParticipant.id, bracketTemplate);
} catch (err) {
if (!(err instanceof Error && err.message.includes("already filled"))) throw err;
}
}
const roundIsScoring = bracketTemplate?.rounds.find(
(r) => r.name === playoffMatch.round
)?.isScoring;
await processMatchResult({
round: playoffMatch.round,
winnerId: winnerParticipant.id,
loserId,
isScoring: roundIsScoring !== undefined ? roundIsScoring : (playoffMatch.isScoring ?? true),
sportsSeasonId,
bracketTemplateId: event.bracketTemplateId ?? null,
eventId: event.id,
eventName: event.name ?? undefined,
matchId: playoffMatch.id,
loserAdvances: event.bracketTemplateId
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
: false,
});
await autoCompleteRoundIfDone(event.id, playoffMatch.round, sportsSeasonId, db);
playoffUpdated++;
} catch (err) {
logger.error(`[match-sync] Error processing playoff match ${m.externalMatchId}:`, err);
errors.push({
externalMatchId: m.externalMatchId,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
}
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
}
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
// ===========================================================================
// Tennis Grand Slam draw sync
// ===========================================================================
function getDrawAdapter(simulatorType: string): DrawSyncAdapter {
switch (simulatorType) {
case "tennis_qualifying_points":
return new WikipediaTennisAdapter();
default:
throw new Error(
`No draw sync adapter available for simulator type "${simulatorType}". ` +
"Tennis is currently supported.",
);
}
}
/** Validate the event, fetch + parse its draw. Read-only — used by sync & preview. */
async function loadTennisDraw(eventId: string) {
const db = database();
const event = await getScoringEventById(eventId);
if (!event) throw new Error(`Scoring event ${eventId} not found`);
if (isReadOnlySibling(event)) {
throw new Error(
"This event is a read-only window of a shared major. Sync the draw on the primary window; " +
"results fan out here automatically.",
);
}
if (!event.isQualifyingEvent) {
throw new Error(`Event "${event.name}" is not a qualifying event; cannot sync a tennis draw.`);
}
const sourceKey = event.externalSourceKey;
if (!sourceKey) {
throw new Error(
`Event "${event.name}" has no externalSourceKey (Wikipedia article title) configured.`,
);
}
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
with: { sport: true },
});
const simulatorType = sportsSeason?.sport?.simulatorType;
if (!simulatorType) {
throw new Error(`Sport for season ${event.sportsSeasonId} has no simulator type configured`);
}
const draw = await getDrawAdapter(simulatorType).fetchDraw(sourceKey);
const template = getBracketTemplate(draw.templateId);
if (!template) throw new Error(`Bracket template "${draw.templateId}" not found`);
return { event, sportsSeasonId: event.sportsSeasonId, draw, template };
}
/** Distinct players across the whole draw, keyed by stable identity. */
function collectUniquePlayers(draw: FetchedDraw): Map<string, FetchedPlayer> {
const players = new Map<string, FetchedPlayer>();
for (const round of draw.rounds) {
for (const match of round.matches) {
for (const player of [match.player1, match.player2]) {
if (player) players.set(playerKey(player), player);
}
}
}
return players;
}
type SeasonParticipant = Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>[number];
type PlayerLookups = {
byExternalId: Map<string, SeasonParticipant>;
byName: Map<string, SeasonParticipant>;
names: string[];
recon: { id: string; name: string; externalId: string | null }[];
};
function buildLookups(participants: SeasonParticipant[]): PlayerLookups {
return {
byExternalId: new Map(
participants.filter((p) => p.externalId).map((p) => [p.externalId as string, p]),
),
byName: new Map(participants.map((p) => [p.name, p])),
names: participants.map((p) => p.name),
recon: participants.map((p) => ({ id: p.id, name: p.name, externalId: p.externalId })),
};
}
type Classification =
| { kind: "matched"; participant: SeasonParticipant; backfill: boolean }
| {
kind: "create";
name: string;
suggestion: string | null;
suggestionId: string | null;
};
/** Read-only: decide how a drawn player resolves against existing participants. */
function classifyPlayer(player: FetchedPlayer, lk: PlayerLookups): Classification {
const name = canonicalPlayerName(player);
const byId = player.externalId ? lk.byExternalId.get(player.externalId) : undefined;
if (byId) return { kind: "matched", participant: byId, backfill: false };
const matchedName = findMatchingTeamName(name, lk.names);
if (matchedName) {
const p = lk.byName.get(matchedName);
if (p) return { kind: "matched", participant: p, backfill: !p.externalId && !!player.externalId };
}
// No confident match. Surface the closest existing participant (if any) as a
// possible duplicate to review.
const view = buildUnmatchedTeamResolutionView(name, lk.recon);
const top = view.confidence !== "none" ? view.topCandidates[0] ?? null : null;
return {
kind: "create",
name,
suggestion: top?.participantName ?? null,
suggestionId: top?.participantId ?? null,
};
}
/**
* Dry run: report what a draw sync would do (matches, would-be participant
* creations, likely duplicates, unfilled first-round slots) without any writes.
*/
export async function previewTennisDraw(eventId: string): Promise<DrawSyncPreview> {
const { event, sportsSeasonId, draw } = await loadTennisDraw(eventId);
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const lk = buildLookups(participants);
const uniquePlayers = collectUniquePlayers(draw);
let matched = 0;
const willCreate: DrawSyncPreview["willCreate"] = [];
const possibleDuplicates: DrawSyncPreview["possibleDuplicates"] = [];
for (const player of uniquePlayers.values()) {
const c = classifyPlayer(player, lk);
if (c.kind === "matched") {
matched++;
} else {
willCreate.push({ name: c.name, externalId: player.externalId });
if (c.suggestion && c.suggestionId) {
possibleDuplicates.push({
name: c.name,
externalId: player.externalId,
suggestion: c.suggestion,
suggestionId: c.suggestionId,
});
}
}
}
const firstRound = draw.rounds[0];
const tbdFirstRound = firstRound
? firstRound.matches.reduce((n, m) => n + (m.player1 ? 0 : 1) + (m.player2 ? 0 : 1), 0)
: 0;
const allMatches = draw.rounds.flatMap((r) => r.matches);
return {
article: event.externalSourceKey ?? "",
totalPlayers: uniquePlayers.size,
matched,
willCreate,
possibleDuplicates,
tbdFirstRound,
totalMatches: allMatches.length,
completedMatches: allMatches.filter((m) => m.winner !== null).length,
};
}
/**
* Populate and auto-score a tennis major's bracket from its external draw
* (Wikipedia). Resolves every drawn player to a participant matching by
* external id, then name, else auto-creating propagates new participants to
* every sports season linked to the same tournament (so result fan-out lands),
* writes the full bracket, and runs the qualifying-points scorer.
*
* Runs only on a major's primary window; read-only siblings receive results via
* fan-out, not direct scoring.
*/
export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
const db = database();
const { event, sportsSeasonId, draw, template } = await loadTennisDraw(eventId);
const roundIsScoring = new Map(template.rounds.map((r) => [r.name, r.isScoring]));
// ---- Resolve players → participants (primary season) ----------------------
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const lk = buildLookups(participants);
const uniquePlayers = collectUniquePlayers(draw);
const keyToParticipantId = new Map<string, string>();
const resolvedParticipants = new Map<
string,
{ canonicalId: string | null; name: string; externalId: string | null }
>();
const unmatched: DrawSyncResult["unmatched"] = [];
let participantsCreated = 0;
for (const [key, player] of uniquePlayers) {
const c = classifyPlayer(player, lk);
let participant: SeasonParticipant;
if (c.kind === "matched") {
participant = c.participant;
if (c.backfill && player.externalId) {
await updateParticipant(participant.id, { externalId: player.externalId });
}
} else {
// No confident match → auto-create so the bracket is complete; flag a
// likely duplicate for admin review when it resembles an existing player.
if (c.suggestion) unmatched.push({ name: c.name, externalId: player.externalId });
participant = await createParticipant({
sportsSeasonId,
name: c.name,
externalId: player.externalId ?? null,
});
lk.names.push(participant.name);
lk.byName.set(participant.name, participant);
lk.recon.push({ id: participant.id, name: participant.name, externalId: participant.externalId });
if (participant.externalId) lk.byExternalId.set(participant.externalId, participant);
participantsCreated++;
}
keyToParticipantId.set(key, participant.id);
resolvedParticipants.set(key, {
canonicalId: participant.participantId ?? null,
name: participant.name,
externalId: participant.externalId ?? null,
});
}
// ---- Propagate participants to linked sibling seasons ---------------------
if (event.tournamentId) {
const linkedRows = await db
.select({ sportsSeasonId: schema.scoringEvents.sportsSeasonId })
.from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
const linkedSeasonIds = [...new Set(linkedRows.map((r) => r.sportsSeasonId))].filter(
(id) => id !== sportsSeasonId,
);
// Dedupe by canonical participant: two drawn players can fuzzy-match the same
// existing participant, which would otherwise queue duplicate sibling inserts
// and trip the (sportsSeasonId, name) unique index.
const resolved = [
...new Map(
[...resolvedParticipants.values()]
.filter((r) => r.canonicalId)
.map((r) => [r.canonicalId, r]),
).values(),
];
for (const seasonId of linkedSeasonIds) {
const sib = await findParticipantsBySportsSeasonId(seasonId);
const sibCanon = new Set(sib.map((s) => s.participantId).filter(Boolean));
const sibNames = new Set(sib.map((s) => normalizeTeamName(s.name)));
const toCreate = resolved
.filter(
(r) =>
r.canonicalId &&
!sibCanon.has(r.canonicalId) &&
!sibNames.has(normalizeTeamName(r.name)),
)
.map((r) => ({
sportsSeasonId: seasonId,
name: r.name,
externalId: r.externalId,
participantId: r.canonicalId,
}));
if (toCreate.length > 0) {
await createManyParticipants(toCreate);
participantsCreated += toCreate.length;
}
}
}
// ---- Write the bracket ----------------------------------------------------
const resolvedMatches = buildResolvedMatches(draw, keyToParticipantId, roundIsScoring);
// Ensure the event is configured for QP bracket scoring before populating.
if (
event.bracketTemplateId !== draw.templateId ||
event.scoringStartsAtRound !== template.scoringStartsAtRound
) {
await updateScoringEvent(eventId, {
bracketTemplateId: draw.templateId,
scoringStartsAtRound: template.scoringStartsAtRound,
});
}
const { written, completed, newlyDecidedLoserIds } = await populateBracketFromDraw(
eventId,
resolvedMatches,
);
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
// ---- Score (qualifying points) + fan out to siblings ----------------------
// Re-derive QP from the bracket and learn which participants' QP changed so the
// Discord notification below only announces new/changed results on a re-sync.
const changedParticipantIds = await rescoreTennisBracketAndDetectChanges(
eventId,
sportsSeasonId,
db,
);
await recalculateAffectedLeagues(sportsSeasonId, db, {
eventId,
eventName: event.name ?? undefined,
});
await fanOutMajorIfPrimary(
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId },
{ markComplete: false },
);
// Announce QP changes for the primary window. Sibling windows are announced by
// the fan-out (via processQualifyingEvent); the primary is scored directly by
// processQualifyingBracketEvent, which does NOT notify — so do it here. Run
// outside the rescore transaction so the webhook HTTP call neither holds the
// transaction open nor rolls back the score if Discord fails.
//
// Also announce players knocked out this sync in a non-scoring round (rounds
// 13 of a Grand Slam), who earn no QP and so never appear via changedParticipantIds.
// Fire even when no QP changed, so an early-round elimination is still surfaced.
const newlyEliminatedIds = new Set(newlyDecidedLoserIds);
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(
sportsSeasonId,
eventId,
db,
changedParticipantIds,
newlyEliminatedIds,
);
} catch (error) {
logger.error(`[syncTennisDraw] QP Discord notification failed for event ${eventId}:`, error);
}
}
return { matchesWritten: written, completed, participantsCreated, unmatched };
}
/**
* Re-derive a tennis major's qualifying points from its bracket and report which
* season participants' awarded QP changed since the previous scoring.
*
* The synced draw is authoritative for tennis, so this clears ALL event_results
* for the event (including any manually-entered rows, e.g. a notParticipating
* withdrawal) and rebuilds them from the bracket. Change detection therefore
* snapshots each participant's awarded QP *before* the delete and diffs it against
* the freshly written rows: a value that differs, or a participant scored for the
* first time (null value), counts as changed. Wrapped in a transaction so a
* mid-rescore failure can't leave the event with results deleted-but-not-rebuilt.
*
* writeEventResultsQP (inside processQualifyingBracketEvent) upserts but never
* deletes, so QP totals for participants who no longer earn points (e.g. an
* early-round loser dropped from the rewritten results only Round of 16+ losers
* score) are recalculated by hand.
*
* Returns the set of season_participant ids whose QP changed, used to scope the
* QP Discord notification so a re-sync that changes nothing announces nothing.
*/
export async function rescoreTennisBracketAndDetectChanges(
eventId: string,
sportsSeasonId: string,
db: ReturnType<typeof database>,
): Promise<Set<string>> {
let changed = new Set<string>();
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
await db.transaction(async (tx) => {
const beforeRows = await tx
.select({
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId));
await processQualifyingBracketEvent(eventId, tx);
const afterRows = await tx
.select({
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
const afterIds = new Set(afterRows.map((r) => r.id));
for (const { id } of beforeRows) {
if (!afterIds.has(id)) await recalculateParticipantQP(id, sportsSeasonId, tx);
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
}
changed = diffChangedQualifyingPoints(beforeRows, afterRows);
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
});
return changed;
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
}