* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127 - New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule) - `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering - `GroupStageStandings` component showing all 12 groups with standings table and manager column - Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action - `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss - Partial group completion: completed matches replayed with real scores, remaining matches simulated - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings - `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals - Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally - Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game - `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug) - Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings - Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader - League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only - Elo ratings admin page supports World Cup (same bulk-import flow as snooker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Increase Node heap to 4GB for unit tests in CI to prevent OOM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests Tests now pass numSimulations=500 instead of the production default of 50,000. Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
361 lines
10 KiB
TypeScript
361 lines
10 KiB
TypeScript
import { and, asc, eq, gte, inArray, lte, or } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { logger } from "~/lib/logger";
|
|
|
|
export type GroupStageMatch = typeof schema.groupStageMatches.$inferSelect;
|
|
|
|
export interface GroupStandingsRow {
|
|
participantId: string;
|
|
participantName: string;
|
|
played: number;
|
|
wins: number;
|
|
draws: number;
|
|
losses: number;
|
|
gf: number; // goals for
|
|
ga: number; // goals against
|
|
gd: number; // goal difference
|
|
points: number;
|
|
}
|
|
|
|
export interface UpcomingGroupMatch {
|
|
matchId: string;
|
|
groupId: string;
|
|
groupName: string;
|
|
sportsSeasonId: string;
|
|
matchday: number;
|
|
scheduledAt: string; // ISO string
|
|
isComplete: boolean;
|
|
participant1: { id: string; name: string };
|
|
participant2: { id: string; name: string };
|
|
participant1Score: number | null;
|
|
participant2Score: number | null;
|
|
}
|
|
|
|
interface MatchInput {
|
|
participant1Id: string;
|
|
participant2Id: string;
|
|
matchday: number;
|
|
scheduledAt?: Date | null;
|
|
}
|
|
|
|
/**
|
|
* Create the 6 round-robin matches for a tournament group.
|
|
* Matches are created without scores (to be filled in as the tournament progresses).
|
|
*/
|
|
export async function createGroupStageMatches(
|
|
groupId: string,
|
|
matches: MatchInput[]
|
|
): Promise<GroupStageMatch[]> {
|
|
const db = database();
|
|
return await db
|
|
.insert(schema.groupStageMatches)
|
|
.values(
|
|
matches.map((m) => ({
|
|
tournamentGroupId: groupId,
|
|
participant1Id: m.participant1Id,
|
|
participant2Id: m.participant2Id,
|
|
matchday: m.matchday,
|
|
scheduledAt: m.scheduledAt ?? null,
|
|
}))
|
|
)
|
|
.returning();
|
|
}
|
|
|
|
/**
|
|
* Generate all 6 round-robin pairings for 4 participants in a group.
|
|
* Uses a fixed round-robin schedule (matchdays 1, 2, 3).
|
|
*
|
|
* Standard 4-team round-robin schedule:
|
|
* Matchday 1: 1v4, 2v3
|
|
* Matchday 2: 1v3, 4v2
|
|
* Matchday 3: 1v2, 3v4
|
|
*/
|
|
export function generateRoundRobinPairings(
|
|
participantIds: string[]
|
|
): MatchInput[] {
|
|
if (participantIds.length !== 4) {
|
|
throw new Error("Round-robin generation requires exactly 4 participants");
|
|
}
|
|
const [p1, p2, p3, p4] = participantIds;
|
|
return [
|
|
{ participant1Id: p1, participant2Id: p4, matchday: 1 },
|
|
{ participant1Id: p2, participant2Id: p3, matchday: 1 },
|
|
{ participant1Id: p1, participant2Id: p3, matchday: 2 },
|
|
{ participant1Id: p4, participant2Id: p2, matchday: 2 },
|
|
{ participant1Id: p1, participant2Id: p2, matchday: 3 },
|
|
{ participant1Id: p3, participant2Id: p4, matchday: 3 },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Record the result of a group stage match and mark it complete.
|
|
*/
|
|
export async function updateGroupStageMatchResult(
|
|
matchId: string,
|
|
participant1Score: number,
|
|
participant2Score: number
|
|
): Promise<GroupStageMatch> {
|
|
const db = database();
|
|
const [updated] = await db
|
|
.update(schema.groupStageMatches)
|
|
.set({
|
|
participant1Score,
|
|
participant2Score,
|
|
isComplete: true,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.groupStageMatches.id, matchId))
|
|
.returning();
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Update the scheduled time of a group stage match.
|
|
*/
|
|
export async function updateGroupStageMatchSchedule(
|
|
matchId: string,
|
|
scheduledAt: Date | null
|
|
): Promise<GroupStageMatch> {
|
|
const db = database();
|
|
const [updated] = await db
|
|
.update(schema.groupStageMatches)
|
|
.set({ scheduledAt, updatedAt: new Date() })
|
|
.where(eq(schema.groupStageMatches.id, matchId))
|
|
.returning();
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Fetch all matches for a group, with participant info.
|
|
*/
|
|
export async function findMatchesByGroupId(groupId: string) {
|
|
const db = database();
|
|
return await db.query.groupStageMatches.findMany({
|
|
where: eq(schema.groupStageMatches.tournamentGroupId, groupId),
|
|
orderBy: [
|
|
asc(schema.groupStageMatches.matchday),
|
|
asc(schema.groupStageMatches.createdAt),
|
|
],
|
|
with: {
|
|
participant1: true,
|
|
participant2: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch all matches for multiple groups in a single query.
|
|
* Returns a Map from groupId → matches (ordered by matchday, createdAt).
|
|
* Use this instead of calling findMatchesByGroupId N times.
|
|
*/
|
|
export async function findMatchesByGroupIds(
|
|
groupIds: string[]
|
|
): Promise<Map<string, Awaited<ReturnType<typeof findMatchesByGroupId>>>> {
|
|
if (groupIds.length === 0) return new Map();
|
|
const db = database();
|
|
const rows = await db.query.groupStageMatches.findMany({
|
|
where: inArray(schema.groupStageMatches.tournamentGroupId, groupIds),
|
|
orderBy: [
|
|
asc(schema.groupStageMatches.matchday),
|
|
asc(schema.groupStageMatches.createdAt),
|
|
],
|
|
with: {
|
|
participant1: true,
|
|
participant2: true,
|
|
},
|
|
});
|
|
const result = new Map<string, typeof rows>();
|
|
for (const groupId of groupIds) result.set(groupId, []);
|
|
for (const row of rows) {
|
|
result.get(row.tournamentGroupId)?.push(row);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Fetch all group stage matches for a scoring event (all groups combined).
|
|
*/
|
|
export async function findMatchesByEventId(eventId: string) {
|
|
const db = database();
|
|
const groups = await db.query.tournamentGroups.findMany({
|
|
where: eq(schema.tournamentGroups.scoringEventId, eventId),
|
|
with: {
|
|
matches: {
|
|
orderBy: [
|
|
asc(schema.groupStageMatches.matchday),
|
|
asc(schema.groupStageMatches.createdAt),
|
|
],
|
|
with: {
|
|
participant1: true,
|
|
participant2: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* Compute standings for a group from match results.
|
|
* Sorted by: points desc → goal difference desc → goals for desc → name asc.
|
|
*/
|
|
export function computeGroupStandings(
|
|
members: Array<{ participantId: string; participantName: string }>,
|
|
matches: Array<{
|
|
participant1Id: string;
|
|
participant2Id: string;
|
|
participant1Score: number | null;
|
|
participant2Score: number | null;
|
|
isComplete: boolean;
|
|
}>
|
|
): GroupStandingsRow[] {
|
|
const stats = new Map<string, GroupStandingsRow>();
|
|
|
|
for (const m of members) {
|
|
stats.set(m.participantId, {
|
|
participantId: m.participantId,
|
|
participantName: m.participantName,
|
|
played: 0,
|
|
wins: 0,
|
|
draws: 0,
|
|
losses: 0,
|
|
gf: 0,
|
|
ga: 0,
|
|
gd: 0,
|
|
points: 0,
|
|
});
|
|
}
|
|
|
|
for (const match of matches) {
|
|
if (!match.isComplete) continue;
|
|
if (match.participant1Score === null || match.participant2Score === null) {
|
|
logger.warn(
|
|
`[computeGroupStandings] Match between ${match.participant1Id} and ${match.participant2Id} is marked complete but has null scores — skipping`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const p1 = stats.get(match.participant1Id);
|
|
const p2 = stats.get(match.participant2Id);
|
|
if (!p1 || !p2) continue;
|
|
|
|
const s1 = match.participant1Score;
|
|
const s2 = match.participant2Score;
|
|
|
|
p1.played++;
|
|
p2.played++;
|
|
p1.gf += s1;
|
|
p1.ga += s2;
|
|
p2.gf += s2;
|
|
p2.ga += s1;
|
|
p1.gd = p1.gf - p1.ga;
|
|
p2.gd = p2.gf - p2.ga;
|
|
|
|
if (s1 > s2) {
|
|
p1.wins++;
|
|
p1.points += 3;
|
|
p2.losses++;
|
|
} else if (s2 > s1) {
|
|
p2.wins++;
|
|
p2.points += 3;
|
|
p1.losses++;
|
|
} else {
|
|
p1.draws++;
|
|
p2.draws++;
|
|
p1.points++;
|
|
p2.points++;
|
|
}
|
|
}
|
|
|
|
return [...stats.values()].toSorted((a, b) => {
|
|
if (b.points !== a.points) return b.points - a.points;
|
|
if (b.gd !== a.gd) return b.gd - a.gd;
|
|
if (b.gf !== a.gf) return b.gf - a.gf;
|
|
return a.participantName.localeCompare(b.participantName);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get upcoming group stage matches (within a date window) for specific participants.
|
|
* Used for calendar/schedule display on league home and team pages.
|
|
*/
|
|
export async function getUpcomingGroupStageMatchesForParticipants(
|
|
sportsSeasonId: string,
|
|
participantIds: string[],
|
|
dateFrom: Date,
|
|
dateTo: Date
|
|
): Promise<UpcomingGroupMatch[]> {
|
|
if (participantIds.length === 0) return [];
|
|
|
|
const db = database();
|
|
|
|
const rows = await db
|
|
.select({
|
|
matchId: schema.groupStageMatches.id,
|
|
groupId: schema.tournamentGroups.id,
|
|
groupName: schema.tournamentGroups.groupName,
|
|
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
|
|
matchday: schema.groupStageMatches.matchday,
|
|
scheduledAt: schema.groupStageMatches.scheduledAt,
|
|
isComplete: schema.groupStageMatches.isComplete,
|
|
participant1Id: schema.groupStageMatches.participant1Id,
|
|
participant2Id: schema.groupStageMatches.participant2Id,
|
|
participant1Score: schema.groupStageMatches.participant1Score,
|
|
participant2Score: schema.groupStageMatches.participant2Score,
|
|
})
|
|
.from(schema.groupStageMatches)
|
|
.innerJoin(
|
|
schema.tournamentGroups,
|
|
eq(schema.groupStageMatches.tournamentGroupId, schema.tournamentGroups.id)
|
|
)
|
|
.innerJoin(
|
|
schema.scoringEvents,
|
|
eq(schema.tournamentGroups.scoringEventId, schema.scoringEvents.id)
|
|
)
|
|
.where(
|
|
and(
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
or(
|
|
inArray(schema.groupStageMatches.participant1Id, participantIds),
|
|
inArray(schema.groupStageMatches.participant2Id, participantIds)
|
|
),
|
|
and(
|
|
gte(schema.groupStageMatches.scheduledAt, dateFrom),
|
|
lte(schema.groupStageMatches.scheduledAt, dateTo)
|
|
)
|
|
)
|
|
)
|
|
.orderBy(asc(schema.groupStageMatches.scheduledAt));
|
|
|
|
if (rows.length === 0) return [];
|
|
|
|
// Load participant names for all involved participants
|
|
const allParticipantIds = [
|
|
...new Set(rows.flatMap((r) => [r.participant1Id, r.participant2Id])),
|
|
];
|
|
const participantRows = await db.query.participants.findMany({
|
|
where: inArray(schema.participants.id, allParticipantIds),
|
|
});
|
|
const participantMap = new Map(participantRows.map((p) => [p.id, p]));
|
|
|
|
return rows
|
|
.filter((r) => r.scheduledAt !== null)
|
|
.map((r) => {
|
|
const p1 = participantMap.get(r.participant1Id);
|
|
const p2 = participantMap.get(r.participant2Id);
|
|
return {
|
|
matchId: r.matchId,
|
|
groupId: r.groupId,
|
|
groupName: r.groupName,
|
|
sportsSeasonId: r.sportsSeasonId,
|
|
matchday: r.matchday,
|
|
scheduledAt: r.scheduledAt?.toISOString() ?? "",
|
|
isComplete: r.isComplete,
|
|
participant1: { id: r.participant1Id, name: p1?.name ?? "Unknown" },
|
|
participant2: { id: r.participant2Id, name: p2?.name ?? "Unknown" },
|
|
participant1Score: r.participant1Score,
|
|
participant2Score: r.participant2Score,
|
|
};
|
|
});
|
|
}
|