Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* 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>
This commit is contained in:
parent
611e1ccf0a
commit
338979e0a8
26 changed files with 10858 additions and 83 deletions
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
|
|
@ -49,6 +49,7 @@ jobs:
|
|||
run: npm run test:run
|
||||
env:
|
||||
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
typecheck:
|
||||
name: ʦ TypeScript
|
||||
|
|
|
|||
185
app/components/sport-season/GroupStageStandings.tsx
Normal file
185
app/components/sport-season/GroupStageStandings.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import type { GroupStandingsRow } from "~/models/group-stage-match";
|
||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||
|
||||
interface GroupMatch {
|
||||
id: string;
|
||||
matchday: number;
|
||||
scheduledAt: string | null;
|
||||
isComplete: boolean;
|
||||
participant1Id: string;
|
||||
participant2Id: string;
|
||||
participant1Score: number | null;
|
||||
participant2Score: number | null;
|
||||
participant1: { id: string; name: string } | null;
|
||||
participant2: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface GroupData {
|
||||
groupName: string;
|
||||
standings: GroupStandingsRow[];
|
||||
matches: GroupMatch[];
|
||||
}
|
||||
|
||||
interface OwnerInfo {
|
||||
teamName: string;
|
||||
ownerName: string;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
interface GroupStageStandingsProps {
|
||||
groups: GroupData[];
|
||||
/** participantId → owner info, used to show which manager drafted each team */
|
||||
ownershipMap?: Record<string, OwnerInfo>;
|
||||
/** If true, shows all groups. If false, shows only groups with at least one completed match. */
|
||||
showEmpty?: boolean;
|
||||
}
|
||||
|
||||
function MatchResult({ match }: { match: GroupMatch }) {
|
||||
const p1 = match.participant1?.name ?? "?";
|
||||
const p2 = match.participant2?.name ?? "?";
|
||||
|
||||
if (match.isComplete && match.participant1Score !== null && match.participant2Score !== null) {
|
||||
const s1 = match.participant1Score;
|
||||
const s2 = match.participant2Score;
|
||||
const result = s1 > s2 ? "w" : s1 < s2 ? "l" : "d";
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span
|
||||
className={`truncate max-w-[80px] ${result === "w" ? "font-semibold text-foreground" : ""}`}
|
||||
>
|
||||
{p1}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono font-semibold text-foreground tabular-nums">
|
||||
{s1}–{s2}
|
||||
</span>
|
||||
<span
|
||||
className={`truncate max-w-[80px] text-right ${result === "l" ? "font-semibold text-foreground" : ""}`}
|
||||
>
|
||||
{p2}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const kickoff = match.scheduledAt ? new Date(match.scheduledAt) : null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[80px]">{p1}</span>
|
||||
<span className="shrink-0 text-muted-foreground/60">
|
||||
{kickoff
|
||||
? kickoff.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
: "vs"}
|
||||
</span>
|
||||
<span className="truncate max-w-[80px] text-right">{p2}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupStageStandings({
|
||||
groups,
|
||||
ownershipMap,
|
||||
showEmpty = false,
|
||||
}: GroupStageStandingsProps) {
|
||||
const visibleGroups = showEmpty
|
||||
? groups
|
||||
: groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0);
|
||||
|
||||
if (visibleGroups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Group Stage</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.groupName}
|
||||
className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-2 bg-muted/50 border-b">
|
||||
<h4 className="text-sm font-semibold">Group {group.groupName}</h4>
|
||||
</div>
|
||||
|
||||
{/* Standings table */}
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b">
|
||||
<th className="text-left pb-1 font-medium">Team</th>
|
||||
<th className="text-left pb-1 font-medium">Manager</th>
|
||||
<th className="text-center pb-1 font-medium w-6" title="Played">P</th>
|
||||
<th className="text-center pb-1 font-medium w-6" title="Wins">W</th>
|
||||
<th className="text-center pb-1 font-medium w-6" title="Draws">D</th>
|
||||
<th className="text-center pb-1 font-medium w-6" title="Losses">L</th>
|
||||
<th className="text-center pb-1 font-medium w-8" title="Goal Difference">GD</th>
|
||||
<th className="text-center pb-1 font-medium w-7" title="Points">Pts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.standings.map((row, idx) => {
|
||||
const owner = ownershipMap?.[row.participantId];
|
||||
return (
|
||||
<tr
|
||||
key={row.participantId}
|
||||
className={`border-b last:border-0 ${
|
||||
idx < 2
|
||||
? "text-green-700 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<td className="py-1 font-medium text-foreground">
|
||||
<span className="truncate block">
|
||||
{row.participantName}
|
||||
{idx < 2 && (
|
||||
<span className="ml-1 text-green-600 dark:text-green-400 text-[10px]">↑</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1">
|
||||
{owner
|
||||
? <TeamOwnerBadge teamName={owner.teamName} ownerName={owner.ownerName} />
|
||||
: <span className="text-xs text-muted-foreground/50">—</span>
|
||||
}
|
||||
</td>
|
||||
<td className="text-center tabular-nums">{row.played}</td>
|
||||
<td className="text-center tabular-nums">{row.wins}</td>
|
||||
<td className="text-center tabular-nums">{row.draws}</td>
|
||||
<td className="text-center tabular-nums">{row.losses}</td>
|
||||
<td className="text-center tabular-nums">
|
||||
{row.gd > 0 ? `+${row.gd}` : row.gd}
|
||||
</td>
|
||||
<td className="text-center tabular-nums font-semibold text-foreground">
|
||||
{row.points}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Match results */}
|
||||
{group.matches.length > 0 && (
|
||||
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
|
||||
{[1, 2, 3].map((day) => {
|
||||
const dayMatches = group.matches.filter((m) => m.matchday === day);
|
||||
if (dayMatches.length === 0) return null;
|
||||
return (
|
||||
<div key={day} className="space-y-0.5">
|
||||
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">
|
||||
MD {day}
|
||||
</p>
|
||||
{dayMatches.map((m) => (
|
||||
<MatchResult key={m.id} match={m} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@ export interface BracketRound {
|
|||
feedsInto: string | null;
|
||||
/** Whether this round affects fantasy points (false = 0 points per Q20) */
|
||||
isScoring: boolean;
|
||||
/**
|
||||
* Name of the round that *losers* advance to (e.g., "Third Place Game" for SF losers).
|
||||
* When set, the loser of each match in this round is placed into the target round.
|
||||
*/
|
||||
loserFeedsInto?: string | null;
|
||||
}
|
||||
|
||||
export interface GroupStageConfig {
|
||||
|
|
@ -544,6 +549,13 @@ export const FIFA_48: BracketTemplate = {
|
|||
matchCount: 2,
|
||||
feedsInto: "Finals",
|
||||
isScoring: true,
|
||||
loserFeedsInto: "Third Place Game",
|
||||
},
|
||||
{
|
||||
name: "Third Place Game",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true,
|
||||
},
|
||||
{
|
||||
name: "Finals",
|
||||
|
|
|
|||
190
app/models/__tests__/group-stage-match.test.ts
Normal file
190
app/models/__tests__/group-stage-match.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeGroupStandings,
|
||||
generateRoundRobinPairings,
|
||||
} from "../group-stage-match";
|
||||
|
||||
const TEAMS = [
|
||||
{ participantId: "t1", participantName: "Brazil" },
|
||||
{ participantId: "t2", participantName: "France" },
|
||||
{ participantId: "t3", participantName: "Germany" },
|
||||
{ participantId: "t4", participantName: "Argentina" },
|
||||
];
|
||||
|
||||
function match(
|
||||
p1: string,
|
||||
p2: string,
|
||||
s1: number,
|
||||
s2: number,
|
||||
isComplete = true
|
||||
) {
|
||||
return {
|
||||
participant1Id: p1,
|
||||
participant2Id: p2,
|
||||
participant1Score: s1,
|
||||
participant2Score: s2,
|
||||
isComplete,
|
||||
};
|
||||
}
|
||||
|
||||
function findTeam(
|
||||
standings: ReturnType<typeof computeGroupStandings>,
|
||||
id: string
|
||||
) {
|
||||
const row = standings.find((s) => s.participantId === id);
|
||||
if (!row) throw new Error(`Team ${id} not found in standings`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("computeGroupStandings", () => {
|
||||
it("returns all four teams with zeroes when no matches are complete", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [
|
||||
{
|
||||
participant1Id: "t1",
|
||||
participant2Id: "t2",
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
isComplete: false,
|
||||
},
|
||||
]);
|
||||
expect(standings).toHaveLength(4);
|
||||
for (const row of standings) {
|
||||
expect(row.played).toBe(0);
|
||||
expect(row.points).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("awards 3 points for a win and 0 for a loss", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [match("t1", "t2", 2, 0)]);
|
||||
const brazil = findTeam(standings, "t1");
|
||||
const france = findTeam(standings, "t2");
|
||||
expect(brazil.points).toBe(3);
|
||||
expect(brazil.wins).toBe(1);
|
||||
expect(france.points).toBe(0);
|
||||
expect(france.losses).toBe(1);
|
||||
});
|
||||
|
||||
it("awards 1 point each for a draw", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [match("t1", "t2", 1, 1)]);
|
||||
const brazil = findTeam(standings, "t1");
|
||||
const france = findTeam(standings, "t2");
|
||||
expect(brazil.points).toBe(1);
|
||||
expect(brazil.draws).toBe(1);
|
||||
expect(france.points).toBe(1);
|
||||
expect(france.draws).toBe(1);
|
||||
});
|
||||
|
||||
it("tracks goals for, against, and difference correctly", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [match("t1", "t2", 3, 1)]);
|
||||
const brazil = findTeam(standings, "t1");
|
||||
const france = findTeam(standings, "t2");
|
||||
expect(brazil.gf).toBe(3);
|
||||
expect(brazil.ga).toBe(1);
|
||||
expect(brazil.gd).toBe(2);
|
||||
expect(france.gf).toBe(1);
|
||||
expect(france.ga).toBe(3);
|
||||
expect(france.gd).toBe(-2);
|
||||
});
|
||||
|
||||
it("sorts by points descending", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [
|
||||
match("t1", "t2", 2, 0), // Brazil 3pts
|
||||
match("t3", "t4", 1, 0), // Germany 3pts
|
||||
match("t1", "t3", 0, 0), // Brazil 1pt, Germany 1pt
|
||||
match("t2", "t4", 2, 0), // France 3pts
|
||||
]);
|
||||
const ids = standings.map((s) => s.participantId);
|
||||
// Brazil: 4pts gd+2, Germany: 4pts gd-1, France: 3pts, Argentina: 0pts
|
||||
expect(ids[0]).toBe("t1"); // Brazil
|
||||
expect(ids[1]).toBe("t3"); // Germany
|
||||
expect(ids[2]).toBe("t2"); // France
|
||||
expect(ids[3]).toBe("t4"); // Argentina
|
||||
});
|
||||
|
||||
it("uses goal difference as tiebreaker after points", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [
|
||||
match("t1", "t3", 3, 0), // Brazil big win
|
||||
match("t2", "t4", 1, 0), // France narrow win
|
||||
]);
|
||||
expect(standings[0].participantId).toBe("t1"); // Brazil: 3pts, gd+3
|
||||
expect(standings[1].participantId).toBe("t2"); // France: 3pts, gd+1
|
||||
});
|
||||
|
||||
it("uses goals for as tiebreaker when points and gd are equal", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [
|
||||
match("t1", "t3", 2, 1), // Brazil: 3pts, gd+1, gf2
|
||||
match("t2", "t4", 3, 2), // France: 3pts, gd+1, gf3
|
||||
]);
|
||||
expect(standings[0].participantId).toBe("t2"); // France higher gf
|
||||
expect(standings[1].participantId).toBe("t1");
|
||||
});
|
||||
|
||||
it("falls back to alphabetical name when fully tied", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [
|
||||
match("t1", "t3", 1, 0),
|
||||
match("t2", "t4", 1, 0),
|
||||
]);
|
||||
// Both t1 (Brazil) and t2 (France) have 3pts, gd+1, gf1
|
||||
expect(standings[0].participantId).toBe("t1"); // Brazil < France alphabetically
|
||||
expect(standings[1].participantId).toBe("t2");
|
||||
});
|
||||
|
||||
it("handles a full 6-match group correctly", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [
|
||||
match("t1", "t4", 3, 0), // Brazil beat Argentina
|
||||
match("t2", "t3", 2, 1), // France beat Germany
|
||||
match("t1", "t3", 2, 0), // Brazil beat Germany
|
||||
match("t4", "t2", 0, 1), // France beat Argentina
|
||||
match("t1", "t2", 1, 0), // Brazil beat France
|
||||
match("t3", "t4", 2, 1), // Germany beat Argentina
|
||||
]);
|
||||
expect(standings[0].participantId).toBe("t1"); // Brazil 9pts
|
||||
expect(standings[0].points).toBe(9);
|
||||
expect(standings[1].participantId).toBe("t2"); // France 6pts
|
||||
expect(standings[2].participantId).toBe("t3"); // Germany 3pts
|
||||
expect(standings[3].participantId).toBe("t4"); // Argentina 0pts
|
||||
});
|
||||
|
||||
it("skips incomplete matches", () => {
|
||||
const standings = computeGroupStandings(TEAMS, [
|
||||
match("t1", "t2", 2, 0, true),
|
||||
match("t3", "t4", 1, 0, false),
|
||||
]);
|
||||
expect(findTeam(standings, "t1").played).toBe(1);
|
||||
expect(findTeam(standings, "t3").played).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateRoundRobinPairings", () => {
|
||||
it("generates exactly 6 pairings for 4 teams", () => {
|
||||
const pairings = generateRoundRobinPairings(["t1", "t2", "t3", "t4"]);
|
||||
expect(pairings).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("covers all 6 unique matchups", () => {
|
||||
const pairings = generateRoundRobinPairings(["t1", "t2", "t3", "t4"]);
|
||||
const keys = new Set(
|
||||
pairings.map((p) =>
|
||||
[p.participant1Id, p.participant2Id].toSorted().join("-")
|
||||
)
|
||||
);
|
||||
expect(keys.size).toBe(6);
|
||||
});
|
||||
|
||||
it("uses matchdays 1, 2, and 3", () => {
|
||||
const pairings = generateRoundRobinPairings(["t1", "t2", "t3", "t4"]);
|
||||
const matchdays = [...new Set(pairings.map((p) => p.matchday))].toSorted();
|
||||
expect(matchdays).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("has exactly 2 matches per matchday", () => {
|
||||
const pairings = generateRoundRobinPairings(["t1", "t2", "t3", "t4"]);
|
||||
for (const day of [1, 2, 3]) {
|
||||
expect(pairings.filter((p) => p.matchday === day)).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws if not exactly 4 participants", () => {
|
||||
expect(() => generateRoundRobinPairings(["t1", "t2", "t3"])).toThrow();
|
||||
});
|
||||
});
|
||||
361
app/models/group-stage-match.ts
Normal file
361
app/models/group-stage-match.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -922,6 +922,30 @@ export async function advanceWinnerTemplate(
|
|||
|
||||
// Fill the determined slot
|
||||
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
|
||||
|
||||
// If this round has a loserFeedsInto, also advance the loser to that round
|
||||
if (currentRound.loserFeedsInto) {
|
||||
const loserId =
|
||||
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
if (loserId) {
|
||||
const loserRound = template.rounds.find((r) => r.name === currentRound.loserFeedsInto);
|
||||
if (loserRound) {
|
||||
const loserMatches = await findPlayoffMatchesByEventIdAndRound(
|
||||
match.scoringEventId,
|
||||
loserRound.name
|
||||
);
|
||||
const loserMatch = loserMatches[0];
|
||||
if (loserMatch) {
|
||||
// SF match 1 loser → participant1Id, SF match 2 loser → participant2Id
|
||||
const loserSlot: "participant1Id" | "participant2Id" =
|
||||
match.matchNumber === 1 ? "participant1Id" : "participant2Id";
|
||||
if (!loserMatch[loserSlot]) {
|
||||
await updatePlayoffMatch(loserMatch.id, { [loserSlot]: loserId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -29,9 +29,14 @@ interface RoundScoringConfig {
|
|||
loserIsPartial: boolean;
|
||||
/**
|
||||
* Guaranteed minimum position for the winner.
|
||||
* null signals a Finals round: winner is finalized as 1st place (handled inline).
|
||||
* null signals a finalization round: both winner and loser receive their exact positions.
|
||||
*/
|
||||
winnerFloor: number | null;
|
||||
/**
|
||||
* When winnerFloor is null, the winner is finalized at this position.
|
||||
* Defaults to 1 (champion) when omitted — set to 3 for a 3rd place game.
|
||||
*/
|
||||
winnerPosition?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -76,6 +81,14 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
|||
afl_10: {
|
||||
"Semi-Finals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
||||
},
|
||||
fifa_48: {
|
||||
// QF winner is guaranteed 4th at worst (lose SF → lose 3PG).
|
||||
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
|
||||
// SF loser still has the 3rd place game — provisional 4th until it's played.
|
||||
Semifinals: { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
|
||||
// 3rd place game finalizes both positions distinctly.
|
||||
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -259,12 +272,13 @@ export async function processPlayoffEvent(
|
|||
}
|
||||
}
|
||||
} else if (config.winnerFloor === null) {
|
||||
// Finals: finalize both participants — winner gets 1st, loser gets 2nd.
|
||||
// Finalization round: both participants receive their exact positions.
|
||||
// winnerPosition defaults to 1 (champion); set to 3 for a 3rd place game.
|
||||
const finalMatch = matches[0];
|
||||
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
|
||||
throw new Error("Finals match is not complete");
|
||||
}
|
||||
await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, 1, db);
|
||||
await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, config.winnerPosition ?? 1, db);
|
||||
await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
|
||||
} else {
|
||||
// All other scoring rounds: assign loser's final (or provisional) position.
|
||||
|
|
@ -376,9 +390,9 @@ export async function processMatchResult(
|
|||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||
} else if (config.winnerFloor === null) {
|
||||
// Finals: finalize both participants.
|
||||
// Finalization round: winner and loser both get their exact positions.
|
||||
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, 1, db);
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerPosition ?? 1, db);
|
||||
} else {
|
||||
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
||||
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ export function calculateAveragedPoints(
|
|||
*/
|
||||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]);
|
||||
|
||||
/**
|
||||
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
|
||||
* (not averaged). Standard brackets average them because both SF losers tie.
|
||||
*/
|
||||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]);
|
||||
|
||||
/**
|
||||
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||
*
|
||||
|
|
@ -139,8 +145,11 @@ export function calculateBracketPoints(
|
|||
if (finalPosition <= 0) return 0;
|
||||
if (finalPosition === 1) return rules.pointsFor1st;
|
||||
if (finalPosition === 2) return rules.pointsFor2nd;
|
||||
if (finalPosition === 3 || finalPosition === 4)
|
||||
if (finalPosition === 3 || finalPosition === 4) {
|
||||
if (bracketTemplateId && DISTINCT_34_TEMPLATE_IDS.has(bracketTemplateId))
|
||||
return finalPosition === 3 ? rules.pointsFor3rd : rules.pointsFor4th;
|
||||
return calculateAveragedPoints([3, 4], rules);
|
||||
}
|
||||
if (finalPosition >= 5 && finalPosition <= 8) {
|
||||
if (bracketTemplateId && SPLIT_5678_TEMPLATE_IDS.has(bracketTemplateId)) {
|
||||
// AFL-style: two separate 2-team tiers within 5–8
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import {
|
|||
} from '~/models/participant-expected-value';
|
||||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
|
||||
import { calculateEV } from '~/services/ev-calculator';
|
||||
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||
import { database } from '~/database/context';
|
||||
import * as schema from '~/database/schema';
|
||||
|
|
@ -30,16 +31,6 @@ import {
|
|||
import { useState } from 'react';
|
||||
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
|
||||
const DEFAULT_SCORING_RULES: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 45,
|
||||
pointsFor4th: 45,
|
||||
pointsFor5th: 20,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 20,
|
||||
pointsFor8th: 20,
|
||||
};
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Elo Ratings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@ import {
|
|||
getAdvancingParticipantIds,
|
||||
getEliminatedParticipantIds,
|
||||
} from "~/models/tournament-group";
|
||||
import {
|
||||
createGroupStageMatches,
|
||||
generateRoundRobinPairings,
|
||||
updateGroupStageMatchResult,
|
||||
updateGroupStageMatchSchedule,
|
||||
findMatchesByGroupId,
|
||||
} from "~/models/group-stage-match";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
|
@ -65,8 +72,14 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
||||
|
||||
// Fetch tournament groups if this event has a group-stage template
|
||||
const tournamentGroups = await findGroupsByEventId(params.eventId);
|
||||
// Fetch tournament groups with their round-robin matches
|
||||
const tournamentGroupsRaw = await findGroupsByEventId(params.eventId);
|
||||
const tournamentGroups = await Promise.all(
|
||||
tournamentGroupsRaw.map(async (group) => ({
|
||||
...group,
|
||||
groupMatches: await findMatchesByGroupId(group.id),
|
||||
}))
|
||||
);
|
||||
|
||||
// The matches already include participant relations from the model query
|
||||
return {
|
||||
|
|
@ -554,6 +567,63 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "recalculate-floors") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
|
||||
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
||||
const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId);
|
||||
|
||||
if (completed.length === 0) {
|
||||
return { error: "No completed matches to reprocess" };
|
||||
}
|
||||
|
||||
// Delete ALL results for this sports season and rebuild from scratch.
|
||||
// Only deleting partial rows leaves stale finalized rows that block
|
||||
// the "never un-finalize" guard in upsertParticipantResult.
|
||||
const db = database();
|
||||
await db
|
||||
.delete(schema.participantResults)
|
||||
.where(
|
||||
eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId)
|
||||
);
|
||||
|
||||
// Replay each completed match in bracket order (earlier rounds first).
|
||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
|
||||
|
||||
const sortedMatches = completed.toSorted((a, b) => {
|
||||
const ai = roundOrder.indexOf(a.round);
|
||||
const bi = roundOrder.indexOf(b.round);
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
||||
});
|
||||
|
||||
for (const match of sortedMatches) {
|
||||
await processMatchResult(
|
||||
{
|
||||
round: match.round,
|
||||
winnerId: match.winnerId ?? "",
|
||||
loserId: match.loserId ?? "",
|
||||
isScoring: match.isScoring ?? true,
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
skipSideEffects: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId);
|
||||
|
||||
return { success: `Recalculated floors from ${sortedMatches.length} completed match(es)` };
|
||||
} catch (error) {
|
||||
logger.error("Error recalculating floors:", error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Failed to recalculate floors",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "reprocess-eliminations") {
|
||||
try {
|
||||
// Get the event
|
||||
|
|
@ -735,7 +805,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// Create tournament groups
|
||||
const groups = await createGroupsForEvent(params.eventId, groupStage.groupLabels);
|
||||
|
||||
// Distribute participants into groups (in selection order)
|
||||
// Distribute participants into groups (in selection order) and create round-robin matches
|
||||
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||
const startIdx = groupIndex * groupStage.teamsPerGroup;
|
||||
const groupParticipantIds = participantIds.slice(
|
||||
|
|
@ -743,6 +813,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
startIdx + groupStage.teamsPerGroup
|
||||
);
|
||||
await addMembersToGroup(groups[groupIndex].id, groupParticipantIds);
|
||||
|
||||
// Create the 6 round-robin match pairings for this group
|
||||
if (groupParticipantIds.length === 4) {
|
||||
const pairings = generateRoundRobinPairings(groupParticipantIds);
|
||||
await createGroupStageMatches(groups[groupIndex].id, pairings);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the empty knockout bracket structure
|
||||
|
|
@ -779,6 +855,49 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "update-group-match") {
|
||||
const matchId = formData.get("matchId");
|
||||
const p1Score = formData.get("participant1Score");
|
||||
const p2Score = formData.get("participant2Score");
|
||||
|
||||
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
||||
if (typeof p1Score !== "string" || typeof p2Score !== "string") {
|
||||
return { error: "Both scores are required" };
|
||||
}
|
||||
|
||||
const s1 = parseInt(p1Score, 10);
|
||||
const s2 = parseInt(p2Score, 10);
|
||||
if (isNaN(s1) || isNaN(s2) || s1 < 0 || s2 < 0) {
|
||||
return { error: "Scores must be non-negative integers" };
|
||||
}
|
||||
|
||||
try {
|
||||
await updateGroupStageMatchResult(matchId, s1, s2);
|
||||
return { success: "Match result saved" };
|
||||
} catch (error) {
|
||||
logger.error("Error updating group match:", error);
|
||||
return { error: error instanceof Error ? error.message : "Failed to update match" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "update-group-match-schedule") {
|
||||
const matchId = formData.get("matchId");
|
||||
const scheduledAt = formData.get("scheduledAt");
|
||||
|
||||
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
||||
|
||||
try {
|
||||
const dateValue = typeof scheduledAt === "string" && scheduledAt
|
||||
? new Date(scheduledAt)
|
||||
: null;
|
||||
await updateGroupStageMatchSchedule(matchId, dateValue);
|
||||
return { success: "Match time saved" };
|
||||
} catch (error) {
|
||||
logger.error("Error updating group match schedule:", error);
|
||||
return { error: error instanceof Error ? error.message : "Failed to update schedule" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "populate-knockout") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from "~/components/ui/select";
|
||||
import { ArrowLeft, Plus, Trophy, X, Check, ChevronDown, ChevronRight, Calendar, TrendingUp, Trash2 } from "lucide-react";
|
||||
import { getAllBracketTemplates, getBracketTemplate, getOrderedRoundsFromMatches, buildNCAA68SlotMap, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||
import { computeGroupStandings } from "~/models/group-stage-match";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -322,6 +323,29 @@ export default function EventBracket({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Recalculate Floors - Fix guaranteed minimum points for still-alive participants.
|
||||
Hidden once every match is complete — at that point all placements are final
|
||||
and "finalize bracket" should be used instead. */}
|
||||
{matches.length > 0 && !matches.every((m) => m.isComplete) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recalculate Floors</CardTitle>
|
||||
<CardDescription>
|
||||
Recompute guaranteed-minimum placements for participants still in the bracket.
|
||||
Use this after scoring rule changes or when floor points look wrong.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="recalculate-floors" />
|
||||
<Button type="submit" variant="outline">
|
||||
Recalculate Floors
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reprocess Eliminations - For existing brackets (non-group-stage) */}
|
||||
{matches.length > 0 && !isGroupStageEvent && (
|
||||
<Card>
|
||||
|
|
@ -685,13 +709,79 @@ export default function EventBracket({
|
|||
|
||||
{/* Group Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tournamentGroups.map((group) => (
|
||||
{tournamentGroups.map((group) => {
|
||||
const members = group.members || [];
|
||||
const groupMatches = group.groupMatches || [];
|
||||
const standings = computeGroupStandings(
|
||||
members
|
||||
.filter((m) => m.participant !== null && m.participant !== undefined)
|
||||
.map((m) => ({
|
||||
participantId: m.participant?.id ?? "",
|
||||
participantName: m.participant?.name ?? "",
|
||||
})),
|
||||
groupMatches.map((m) => ({
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
participant1Score: m.participant1Score,
|
||||
participant2Score: m.participant2Score,
|
||||
isComplete: m.isComplete,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={group.id}>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-base">Group {group.groupName}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{(group.members || []).map((member) => (
|
||||
<CardContent className="px-4 pb-4 space-y-3">
|
||||
{/* Standings table */}
|
||||
{standings.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<th className="text-left pb-1 font-medium">Team</th>
|
||||
<th className="text-center pb-1 font-medium w-6">P</th>
|
||||
<th className="text-center pb-1 font-medium w-6">W</th>
|
||||
<th className="text-center pb-1 font-medium w-6">D</th>
|
||||
<th className="text-center pb-1 font-medium w-6">L</th>
|
||||
<th className="text-center pb-1 font-medium w-8">GD</th>
|
||||
<th className="text-center pb-1 font-medium w-7">Pts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{standings.map((row, idx) => {
|
||||
const member = members.find((m) => m.participant?.id === row.participantId);
|
||||
const isTop2 = idx < 2;
|
||||
return (
|
||||
<tr
|
||||
key={row.participantId}
|
||||
className={`border-b last:border-0 ${
|
||||
member?.eliminated
|
||||
? "text-muted-foreground line-through"
|
||||
: isTop2
|
||||
? "text-green-600 dark:text-green-400 font-medium"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<td className="py-0.5 truncate max-w-[80px]">{row.participantName}</td>
|
||||
<td className="text-center">{row.played}</td>
|
||||
<td className="text-center">{row.wins}</td>
|
||||
<td className="text-center">{row.draws}</td>
|
||||
<td className="text-center">{row.losses}</td>
|
||||
<td className="text-center">{row.gd > 0 ? `+${row.gd}` : row.gd}</td>
|
||||
<td className="text-center font-semibold">{row.points}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Elimination toggles */}
|
||||
<div className="space-y-1.5">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
|
||||
|
|
@ -731,9 +821,77 @@ export default function EventBracket({
|
|||
</Form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Match score entry */}
|
||||
{groupMatches.length > 0 && (
|
||||
<div className="space-y-1.5 pt-1 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground">Matches</p>
|
||||
{groupMatches.map((match) => (
|
||||
<div key={match.id} className="text-xs space-y-1">
|
||||
{/* Schedule row */}
|
||||
<Form method="post" className="flex items-center gap-1">
|
||||
<input type="hidden" name="intent" value="update-group-match-schedule" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
type="datetime-local"
|
||||
name="scheduledAt"
|
||||
defaultValue={
|
||||
match.scheduledAt
|
||||
? new Date(match.scheduledAt).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
className="h-6 text-xs px-1 py-0"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
{/* Score row */}
|
||||
<Form method="post" className="flex items-center gap-1">
|
||||
<input type="hidden" name="intent" value="update-group-match" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<span className="truncate max-w-[70px]">
|
||||
{match.participant1?.name ?? "?"}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
name="participant1Score"
|
||||
min={0}
|
||||
defaultValue={match.participant1Score ?? ""}
|
||||
className={`h-6 w-10 text-center text-xs px-1 py-0 ${match.isComplete ? "bg-muted" : ""}`}
|
||||
disabled={match.isComplete}
|
||||
/>
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<Input
|
||||
type="number"
|
||||
name="participant2Score"
|
||||
min={0}
|
||||
defaultValue={match.participant2Score ?? ""}
|
||||
className={`h-6 w-10 text-center text-xs px-1 py-0 ${match.isComplete ? "bg-muted" : ""}`}
|
||||
disabled={match.isComplete}
|
||||
/>
|
||||
<span className="truncate max-w-[70px] text-right">
|
||||
{match.participant2?.name ?? "?"}
|
||||
</span>
|
||||
{!match.isComplete && (
|
||||
<Button type="submit" size="sm" variant="default" className="h-6 px-1.5 text-xs shrink-0">
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
{match.isComplete && (
|
||||
<Badge variant="secondary" className="h-5 text-xs px-1 shrink-0">FT</Badge>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Knockout Assignment Section */}
|
||||
|
|
|
|||
|
|
@ -17,25 +17,13 @@ import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
|||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||
import { getSimulator, type SimulatorType } from "~/services/simulations/registry";
|
||||
import { calculateEV, type ScoringRules } from "~/services/ev-calculator";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import { recalculateStandings } from "~/models/scoring-calculator";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Default scoring rules — matches the season table defaults.
|
||||
// Per-league EV is recalculated from probabilities using league-specific rules in standings.
|
||||
const DEFAULT_SCORING_RULES: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 45,
|
||||
pointsFor4th: 45,
|
||||
pointsFor5th: 20,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 20,
|
||||
pointsFor8th: 20,
|
||||
};
|
||||
|
||||
export async function action({ params }: Route.ActionArgs) {
|
||||
const sportsSeasonId = params.id;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { findCurrentSeasonWithSports } from "~/models/season";
|
||||
import { getSeasonStandings } from "~/models/standings";
|
||||
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
||||
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
|
||||
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
|
||||
import type { Route } from "./+types/$leagueId";
|
||||
|
||||
|
|
@ -114,6 +115,9 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
|
||||
: new Map<string, Array<{ id: string; name: string }>>();
|
||||
|
||||
const dateFrom = new Date(dateFromStr + "T00:00:00.000Z");
|
||||
const dateTo = new Date(dateToStr + "T23:59:59.999Z");
|
||||
|
||||
const sportsSeasons = await Promise.all(
|
||||
rawSportsSeasons.map(async (ss) => {
|
||||
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
|
||||
|
|
@ -127,6 +131,33 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
dateToStr
|
||||
)
|
||||
: [];
|
||||
|
||||
// For group-stage bracket sports, also include scheduled group matches
|
||||
if (ss.scoringPattern === "playoff_bracket" && draftedParticipants.length > 0) {
|
||||
const draftedIds = draftedParticipants.map((p) => p.id);
|
||||
const groupMatches = await getUpcomingGroupStageMatchesForParticipants(
|
||||
ss.id,
|
||||
draftedIds,
|
||||
dateFrom,
|
||||
dateTo
|
||||
);
|
||||
for (const gm of groupMatches) {
|
||||
const relevant = draftedParticipants.filter(
|
||||
(p) => p.id === gm.participant1.id || p.id === gm.participant2.id
|
||||
);
|
||||
upcomingParticipantEvents.push({
|
||||
id: gm.matchId,
|
||||
name: `${gm.participant1.name} vs ${gm.participant2.name}`,
|
||||
eventDate: gm.scheduledAt ? gm.scheduledAt.split("T")[0] : null,
|
||||
earliestGameTime: gm.scheduledAt || null,
|
||||
matchLabel: `Group ${gm.groupName} · MD${gm.matchday}`,
|
||||
eventType: "group_match",
|
||||
sportsSeasonId: ss.id,
|
||||
relevantParticipants: relevant,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: ss.id,
|
||||
name: ss.name,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ import {
|
|||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
||||
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
|
||||
import {
|
||||
findMatchesByGroupIds,
|
||||
computeGroupStandings,
|
||||
} from "~/models/group-stage-match";
|
||||
import { findGroupsByEventId } from "~/models/tournament-group";
|
||||
import type { PlayoffMatch } from "~/models/playoff-match";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
|
@ -152,6 +157,15 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number];
|
||||
let qpStandings: QPStanding[] = [];
|
||||
|
||||
// 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 GroupStandingData = {
|
||||
groupName: string;
|
||||
standings: ReturnType<typeof computeGroupStandings>;
|
||||
matches: Array<Omit<RawGroupMatch, "scheduledAt"> & { scheduledAt: string | null }>;
|
||||
};
|
||||
let groupStandings: GroupStandingData[] = [];
|
||||
|
||||
if (scoringPattern === "playoff_bracket") {
|
||||
// Fetch playoff matches via scoring events
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
|
|
@ -176,6 +190,42 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
||||
|
||||
// Load group stage standings if this event uses a group-stage template
|
||||
if (template?.groupStage) {
|
||||
const groupStageEventId = events.find((e) => e.bracketTemplateId === templateId)?.id;
|
||||
if (groupStageEventId) {
|
||||
const groups = await findGroupsByEventId(groupStageEventId);
|
||||
// Single batched query for all groups instead of N round-trips
|
||||
const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id));
|
||||
groupStandings = groups.map((group) => {
|
||||
const groupMatches = matchesByGroupId.get(group.id) ?? [];
|
||||
const members = (group.members ?? [])
|
||||
.filter((m) => m.participant !== null && m.participant !== undefined)
|
||||
.map((m) => ({
|
||||
participantId: m.participant?.id ?? "",
|
||||
participantName: m.participant?.name ?? "",
|
||||
}));
|
||||
return {
|
||||
groupName: group.groupName,
|
||||
standings: computeGroupStandings(
|
||||
members,
|
||||
groupMatches.map((m) => ({
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
participant1Score: m.participant1Score,
|
||||
participant2Score: m.participant2Score,
|
||||
isComplete: m.isComplete,
|
||||
}))
|
||||
),
|
||||
matches: groupMatches.map((m) => ({
|
||||
...m,
|
||||
scheduledAt: m.scheduledAt ? m.scheduledAt.toISOString() : null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch group-stage losers and all participant results for bracket scoring
|
||||
|
|
@ -288,5 +338,6 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
seasonIsFinalized,
|
||||
regularSeasonStandings,
|
||||
participantEvs,
|
||||
groupStandings,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
|||
import { SportSeasonDisplay } from "~/components/scoring/SportSeasonDisplay";
|
||||
import { EventSchedule } from "~/components/sport-season/EventSchedule";
|
||||
import { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
|
||||
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
|
||||
|
|
@ -65,6 +66,7 @@ export default function SportSeasonDetail({
|
|||
seasonIsFinalized,
|
||||
regularSeasonStandings,
|
||||
participantEvs,
|
||||
groupStandings,
|
||||
} = loaderData;
|
||||
|
||||
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
||||
|
|
@ -140,6 +142,13 @@ export default function SportSeasonDetail({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Group stage standings — shown for FIFA-style tournaments before/during group phase */}
|
||||
{groupStandings.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hide bracket display when standings exist but no bracket matches have been set yet */}
|
||||
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
|
||||
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
|||
/>
|
||||
</div>
|
||||
<div className="text-sm font-medium tabular-nums">
|
||||
{standing ? standing.totalPoints : 0} pts
|
||||
{standing ? (standing.actualPoints ?? standing.totalPoints) : 0} pts
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
268
app/services/simulations/__tests__/world-cup-simulator.test.ts
Normal file
268
app/services/simulations/__tests__/world-cup-simulator.test.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { simGroupMatch, WorldCupSimulator } from "../world-cup-simulator";
|
||||
|
||||
// ─── Pure math: simGroupMatch ─────────────────────────────────────────────────
|
||||
|
||||
describe("simGroupMatch", () => {
|
||||
it("returns win, draw, or loss", () => {
|
||||
const results = new Set<string>();
|
||||
for (let i = 0; i < 300; i++) {
|
||||
results.add(simGroupMatch(1800, 1600));
|
||||
}
|
||||
// All three outcomes should appear in 300 trials
|
||||
expect(results.has("win")).toBe(true);
|
||||
expect(results.has("draw")).toBe(true);
|
||||
expect(results.has("loss")).toBe(true);
|
||||
});
|
||||
|
||||
it("equal teams draw ≈28% of the time", () => {
|
||||
let draws = 0;
|
||||
const N = 5_000;
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (simGroupMatch(1500, 1500) === "draw") draws++;
|
||||
}
|
||||
const drawRate = draws / N;
|
||||
// BASE_DRAW_RATE = 0.28 at eloDiff=0; accept ±5% from sampling noise at N=5k
|
||||
expect(drawRate).toBeGreaterThan(0.23);
|
||||
expect(drawRate).toBeLessThan(0.33);
|
||||
});
|
||||
|
||||
it("draw rate decays with large Elo gap", () => {
|
||||
let draws = 0;
|
||||
const N = 5_000;
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (simGroupMatch(2000, 1400) === "draw") draws++;
|
||||
}
|
||||
const drawRate = draws / N;
|
||||
// eloDiff = 600 → pDraw ≈ 0.28 * exp(-1.2) ≈ 0.084; accept ±5%
|
||||
expect(drawRate).toBeLessThan(0.15);
|
||||
});
|
||||
|
||||
it("strong favorite wins more often than underdog", () => {
|
||||
let wins = 0;
|
||||
let losses = 0;
|
||||
const N = 5_000;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const r = simGroupMatch(1900, 1600);
|
||||
if (r === "win") wins++;
|
||||
if (r === "loss") losses++;
|
||||
}
|
||||
expect(wins).toBeGreaterThan(losses);
|
||||
});
|
||||
|
||||
it("results sum to 1.0 (no probability mass lost)", () => {
|
||||
let wins = 0, draws = 0, losses = 0;
|
||||
const N = 10_000;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const r = simGroupMatch(1700, 1700);
|
||||
if (r === "win") wins++;
|
||||
else if (r === "draw") draws++;
|
||||
else losses++;
|
||||
}
|
||||
expect(wins + draws + losses).toBe(N);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WorldCupSimulator (with mocked DB) ───────────────────────────────────────
|
||||
|
||||
// Build 48 mock participants (ids p0..p47)
|
||||
function makeParticipants(count = 48) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
name: `Team${i}`,
|
||||
sportsSeasonId: "season-1",
|
||||
}));
|
||||
}
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
import { database } from "~/database/context";
|
||||
|
||||
const mockDb = {
|
||||
query: {
|
||||
participants: { findMany: vi.fn() },
|
||||
scoringEvents: { findFirst: vi.fn() },
|
||||
tournamentGroups: { findMany: vi.fn() },
|
||||
playoffMatches: { findMany: vi.fn() },
|
||||
},
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(database).mockReturnValue(mockDb as never);
|
||||
mockDb.query.participants.findMany.mockReset();
|
||||
mockDb.query.scoringEvents.findFirst.mockReset();
|
||||
mockDb.query.tournamentGroups.findMany.mockReset();
|
||||
mockDb.query.playoffMatches.findMany.mockReset();
|
||||
mockDb.select.mockReturnValue(mockDb);
|
||||
mockDb.from.mockReturnValue(mockDb);
|
||||
mockDb.where.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("WorldCupSimulator", () => {
|
||||
it("throws when no participants are found", async () => {
|
||||
mockDb.query.participants.findMany.mockResolvedValue([]);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
const sim = new WorldCupSimulator(500);
|
||||
await expect(sim.simulate("season-1")).rejects.toThrow("No participants found");
|
||||
});
|
||||
|
||||
it("returns one result per participant", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.participants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
const sim = new WorldCupSimulator(500);
|
||||
const results = await sim.simulate("season-1");
|
||||
expect(results).toHaveLength(48);
|
||||
const ids = new Set(results.map((r) => r.participantId));
|
||||
for (const p of participants) {
|
||||
expect(ids.has(p.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("column sums for champion, runner-up, 3rd, 4th are each ≈1.0", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.participants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
const sim = new WorldCupSimulator(500);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
const sumSecond = results.reduce((s, r) => s + r.probabilities.probSecond, 0);
|
||||
const sumThird = results.reduce((s, r) => s + r.probabilities.probThird, 0);
|
||||
const sumFourth = results.reduce((s, r) => s + r.probabilities.probFourth, 0);
|
||||
|
||||
expect(sumFirst).toBeCloseTo(1.0, 2);
|
||||
expect(sumSecond).toBeCloseTo(1.0, 2);
|
||||
expect(sumThird).toBeCloseTo(1.0, 2);
|
||||
expect(sumFourth).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
|
||||
it("probabilities are all non-negative", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.participants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
const sim = new WorldCupSimulator(500);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
for (const r of results) {
|
||||
const { probFirst, probSecond, probThird, probFourth, probFifth } = r.probabilities;
|
||||
expect(probFirst).toBeGreaterThanOrEqual(0);
|
||||
expect(probSecond).toBeGreaterThanOrEqual(0);
|
||||
expect(probThird).toBeGreaterThanOrEqual(0);
|
||||
expect(probFourth).toBeGreaterThanOrEqual(0);
|
||||
expect(probFifth).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("SF losers land in 3rd or 4th, never 1st or 2nd", async () => {
|
||||
// Set up 8 participants (small bracket, 2 groups of 4)
|
||||
const participants = makeParticipants(8);
|
||||
mockDb.query.participants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
const sim = new WorldCupSimulator(500);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
// probFirst + probSecond + probThird + probFourth should cover all probability mass
|
||||
// No team should have probThird or probFourth < 0
|
||||
for (const r of results) {
|
||||
expect(r.probabilities.probFirst).toBeGreaterThanOrEqual(0);
|
||||
expect(r.probabilities.probThird).toBeGreaterThanOrEqual(0);
|
||||
expect(r.probabilities.probFourth).toBeGreaterThanOrEqual(0);
|
||||
// A champion should have 0 chance at 3rd/4th AND vice versa
|
||||
// (not guaranteed in aggregate but probFirst + probThird can't both be 1)
|
||||
expect(r.probabilities.probFirst + r.probabilities.probThird).toBeLessThanOrEqual(1.01);
|
||||
}
|
||||
});
|
||||
|
||||
it("a team with pre-completed group stage result is fixed in simulation", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.participants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
|
||||
|
||||
// One group fully complete: p0 wins everything, p3 loses everything
|
||||
const group = {
|
||||
id: "group-a",
|
||||
groupName: "A",
|
||||
scoringEventId: "event-1",
|
||||
members: [
|
||||
{ participantId: "p0" }, { participantId: "p1" },
|
||||
{ participantId: "p2" }, { participantId: "p3" },
|
||||
],
|
||||
matches: [
|
||||
{ participant1Id: "p0", participant2Id: "p1", participant1Score: 3, participant2Score: 0, isComplete: true, matchday: 1 },
|
||||
{ participant1Id: "p2", participant2Id: "p3", participant1Score: 2, participant2Score: 0, isComplete: true, matchday: 1 },
|
||||
{ participant1Id: "p0", participant2Id: "p2", participant1Score: 2, participant2Score: 0, isComplete: true, matchday: 2 },
|
||||
{ participant1Id: "p1", participant2Id: "p3", participant1Score: 1, participant2Score: 0, isComplete: true, matchday: 2 },
|
||||
{ participant1Id: "p0", participant2Id: "p3", participant1Score: 1, participant2Score: 0, isComplete: true, matchday: 3 },
|
||||
{ participant1Id: "p1", participant2Id: "p2", participant1Score: 1, participant2Score: 1, isComplete: true, matchday: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
// Remaining 44 participants in 11 synthetic groups
|
||||
const remainingGroups = Array.from({ length: 11 }, (_, gi) => ({
|
||||
id: `group-${gi + 2}`,
|
||||
groupName: String.fromCharCode(66 + gi),
|
||||
scoringEventId: "event-1",
|
||||
members: [
|
||||
{ participantId: `p${(gi + 1) * 4 + 0}` },
|
||||
{ participantId: `p${(gi + 1) * 4 + 1}` },
|
||||
{ participantId: `p${(gi + 1) * 4 + 2}` },
|
||||
{ participantId: `p${(gi + 1) * 4 + 3}` },
|
||||
],
|
||||
matches: [],
|
||||
}));
|
||||
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([group, ...remainingGroups]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
const sim = new WorldCupSimulator(500);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
const p3Result = results.find((r) => r.participantId === "p3");
|
||||
expect(p3Result).toBeDefined();
|
||||
// p3 lost all 3 group games (0 pts) — always last in group, never advances
|
||||
// → probFirst = probSecond = probThird = probFourth = 0
|
||||
expect(p3Result?.probabilities.probFirst).toBe(0);
|
||||
expect(p3Result?.probabilities.probSecond).toBe(0);
|
||||
expect(p3Result?.probabilities.probThird).toBe(0);
|
||||
expect(p3Result?.probabilities.probFourth).toBe(0);
|
||||
});
|
||||
|
||||
it("probFifth through probEighth are equal for each participant (QF losers split evenly)", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.participants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
const sim = new WorldCupSimulator(500);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
for (const r of results) {
|
||||
const { probFifth, probSixth, probSeventh, probEighth } = r.probabilities;
|
||||
expect(probFifth).toBeCloseTo(probSixth, 10);
|
||||
expect(probSixth).toBeCloseTo(probSeventh, 10);
|
||||
expect(probSeventh).toBeCloseTo(probEighth, 10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ import { SnookerSimulator } from "./snooker-simulator";
|
|||
import { TennisSimulator } from "./tennis-simulator";
|
||||
import { MLBSimulator } from "./mlb-simulator";
|
||||
import { WNBASimulator } from "./wnba-simulator";
|
||||
import { WorldCupSimulator } from "./world-cup-simulator";
|
||||
|
||||
export const SIMULATOR_TYPES = [
|
||||
"f1_standings",
|
||||
|
|
@ -36,6 +37,7 @@ export const SIMULATOR_TYPES = [
|
|||
"tennis_qualifying_points",
|
||||
"mlb_bracket",
|
||||
"wnba_bracket",
|
||||
"world_cup",
|
||||
] as const;
|
||||
|
||||
export type SimulatorType = typeof SIMULATOR_TYPES[number];
|
||||
|
|
@ -116,6 +118,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
|||
info: { name: "WNBA Playoff Monte Carlo", description: "Projects WNBA regular season seedings and simulates full playoff bracket (R1 best-of-3, Semis best-of-5, Finals best-of-7) using SRS-derived Elo ratings. SRS values sourced from basketball-reference.com." },
|
||||
create: () => new WNBASimulator(),
|
||||
},
|
||||
world_cup: {
|
||||
info: { name: "FIFA World Cup 2026 Monte Carlo", description: "Simulates the full 48-team World Cup: round-robin group stage (12 groups), best-8 third-place selection, R32→R16→QF→SF→3rd place game→Final. Uses blended Elo + futures odds." },
|
||||
create: () => new WorldCupSimulator(),
|
||||
},
|
||||
};
|
||||
|
||||
export function getSimulator(simulatorType: SimulatorType): Simulator {
|
||||
|
|
|
|||
609
app/services/simulations/world-cup-simulator.ts
Normal file
609
app/services/simulations/world-cup-simulator.ts
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
/**
|
||||
* FIFA World Cup Simulator (2026+ 48-team format)
|
||||
*
|
||||
* Monte Carlo simulation covering both the group stage and knockout bracket.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Load participants, futures odds, and bracket/group data from DB
|
||||
* 2. Build per-team Elo: from futures odds if available (blended 70/30 Elo+odds),
|
||||
* otherwise from hardcoded 2026 national team Elo ratings, fallback 1500.
|
||||
* 3. For each of NUM_SIMULATIONS iterations:
|
||||
* a. GROUP STAGE (12 groups × 6 matches each = 72 matches)
|
||||
* - Each match: compute P(win)/P(draw)/P(loss) from Elo difference
|
||||
* - Track pts / GD / GF per team; sort to determine group positions
|
||||
* - Group winner (pos 1) and runner-up (pos 2) always advance
|
||||
* - 3rd-place teams ranked across all 12 groups; top 8 also advance
|
||||
* b. KNOCKOUT (R32 → R16 → QF → SF)
|
||||
* - Standard single-elimination using Elo win probabilities
|
||||
* - Completed knockout matches use actual results
|
||||
* c. THIRD PLACE GAME
|
||||
* - SF loser 1 vs SF loser 2; winner = 3rd, loser = 4th
|
||||
* d. FINAL
|
||||
* - SF winner 1 vs SF winner 2; winner = 1st, loser = 2nd
|
||||
* 4. Accumulate integer placement counts; convert to probabilities.
|
||||
* Placement buckets → SimulationProbabilities mapping:
|
||||
* probFirst = P(champion)
|
||||
* probSecond = P(runner-up)
|
||||
* probThird = P(3rd place game winner)
|
||||
* probFourth = P(3rd place game loser)
|
||||
* probFifth–probEighth = P(QF elimination) / 4
|
||||
* R32/R16 losers → 0 (scoringStartsAtRound = "Quarterfinals")
|
||||
*
|
||||
* Group stage W/D/L probabilities:
|
||||
* pDraw = 0.28 × exp(−0.002 × |eloDiff|) (≈28% when equal, decreases with skill gap)
|
||||
* pWin = (1 − pDraw) × eloWinProb(eloA, eloB)
|
||||
* pLoss = 1 − pWin − pDraw
|
||||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
||||
import { logger } from "~/lib/logger";
|
||||
import type { Simulator, SimulationResult, SimulationProbabilities } from "./types";
|
||||
|
||||
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
|
||||
|
||||
function normalizeTeamName(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// ─── Parameters ──────────────────────────────────────────────────────────────
|
||||
|
||||
const NUM_SIMULATIONS = 50_000;
|
||||
const ELO_WEIGHT = 0.7;
|
||||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
||||
|
||||
/** Base draw rate when teams are evenly matched. Decays with Elo difference. */
|
||||
const BASE_DRAW_RATE = 0.28;
|
||||
const DRAW_DECAY = 0.002;
|
||||
|
||||
// ─── National team Elo ratings (eloratings.net, pre-2026 World Cup) ──────────
|
||||
// These are used when no futures odds are stored for a team.
|
||||
// Update before the tournament starts.
|
||||
|
||||
const NATIONAL_TEAM_ELO: Record<string, number> = {
|
||||
"france": 2083,
|
||||
"england": 2047,
|
||||
"brazil": 2038,
|
||||
"spain": 2031,
|
||||
"belgium": 2003,
|
||||
"argentina": 1997,
|
||||
"portugal": 1993,
|
||||
"netherlands": 1988,
|
||||
"germany": 1978,
|
||||
"italy": 1966,
|
||||
"croatia": 1961,
|
||||
"uruguay": 1955,
|
||||
"colombia": 1950,
|
||||
"mexico": 1945,
|
||||
"usa": 1935,
|
||||
"united states": 1935,
|
||||
"senegal": 1930,
|
||||
"denmark": 1928,
|
||||
"switzerland": 1925,
|
||||
"austria": 1918,
|
||||
"morocco": 1915,
|
||||
"japan": 1912,
|
||||
"south korea": 1905,
|
||||
"ecuador": 1900,
|
||||
"poland": 1898,
|
||||
"australia": 1893,
|
||||
"nigeria": 1888,
|
||||
"iran": 1883,
|
||||
"cameroon": 1878,
|
||||
"ghana": 1875,
|
||||
"canada": 1870,
|
||||
"peru": 1865,
|
||||
"chile": 1862,
|
||||
"venezuela": 1858,
|
||||
"costa rica": 1853,
|
||||
"cote d'ivoire": 1850,
|
||||
"ivory coast": 1850,
|
||||
"egypt": 1848,
|
||||
"hungary": 1845,
|
||||
"turkey": 1842,
|
||||
"ukraine": 1838,
|
||||
"serbia": 1835,
|
||||
"czech republic": 1830,
|
||||
"romania": 1825,
|
||||
"slovakia": 1820,
|
||||
"new zealand": 1790,
|
||||
"saudi arabia": 1785,
|
||||
"qatar": 1780,
|
||||
"honduras": 1778,
|
||||
"panama": 1775,
|
||||
"el salvador": 1770,
|
||||
"guatemala": 1765,
|
||||
"cuba": 1760,
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up a national team's Elo rating using fuzzy name matching.
|
||||
* Priority:
|
||||
* 1. Exact normalized match
|
||||
* 2. Substring containment (either direction)
|
||||
* 3. Word-overlap (≥50% shared significant words)
|
||||
* Logs a warning when no match is found so mismatches are visible in server logs.
|
||||
*/
|
||||
function getTeamElo(name: string, fallback = 1500): number {
|
||||
const normalized = normalizeTeamName(name);
|
||||
const entries = Object.entries(NATIONAL_TEAM_ELO);
|
||||
|
||||
// 1. Exact match
|
||||
const exact = entries.find(([k]) => k === normalized);
|
||||
if (exact) return exact[1];
|
||||
|
||||
// 2. Substring containment (handles "Ivory Coast" ↔ "Cote d'Ivoire" aliases already in map,
|
||||
// and things like "United States" matching "usa")
|
||||
const contains = entries.find(([k]) => k.includes(normalized) || normalized.includes(k));
|
||||
if (contains) return contains[1];
|
||||
|
||||
// 3. Word-overlap (≥50% shared words longer than 2 chars)
|
||||
const inputWords = normalized.split(" ").filter((w) => w.length > 2);
|
||||
if (inputWords.length > 0) {
|
||||
const overlap = entries.find(([k]) => {
|
||||
const kWords = k.split(" ").filter((w) => w.length > 2);
|
||||
const shared = inputWords.filter((w) => kWords.includes(w));
|
||||
return shared.length > 0 && shared.length >= Math.min(inputWords.length, kWords.length) * 0.5;
|
||||
});
|
||||
if (overlap) return overlap[1];
|
||||
}
|
||||
|
||||
logger.warn(`[WorldCupSimulator] No Elo found for team "${name}" (normalized: "${normalized}") — using fallback ${fallback}`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ─── Group stage helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Simulate a single group stage match.
|
||||
* Returns "win" (team A wins), "draw", or "loss" (team B wins).
|
||||
*/
|
||||
export function simGroupMatch(eloA: number, eloB: number): "win" | "draw" | "loss" {
|
||||
const eloDiff = eloA - eloB;
|
||||
const pDraw = BASE_DRAW_RATE * Math.exp(-DRAW_DECAY * Math.abs(eloDiff));
|
||||
const eloWin = eloWinProbability(eloA, eloB);
|
||||
const pWin = (1 - pDraw) * eloWin;
|
||||
const rand = Math.random();
|
||||
if (rand < pWin) return "win";
|
||||
if (rand < pWin + pDraw) return "draw";
|
||||
return "loss";
|
||||
}
|
||||
|
||||
interface TeamStats {
|
||||
id: string;
|
||||
pts: number;
|
||||
gd: number;
|
||||
gf: number;
|
||||
}
|
||||
|
||||
interface GroupMatchResult {
|
||||
participant1Id: string;
|
||||
participant2Id: string;
|
||||
participant1Score: number | null;
|
||||
participant2Score: number | null;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a full round-robin group of 4 teams.
|
||||
* Completed matches (isComplete=true with real scores) are replayed with their
|
||||
* actual results; remaining matches are simulated via Elo.
|
||||
* This correctly handles partial group completion (e.g. 4/6 matches done).
|
||||
*/
|
||||
function simGroup(
|
||||
teamIds: string[],
|
||||
eloFn: (id: string) => number,
|
||||
completedMatches?: GroupMatchResult[]
|
||||
): TeamStats[] {
|
||||
const stats = new Map<string, TeamStats>(
|
||||
teamIds.map((id) => [id, { id, pts: 0, gd: 0, gf: 0 }])
|
||||
);
|
||||
|
||||
// Build set of completed pairings keyed "minId:maxId" so we can skip them
|
||||
const completedPairs = new Set<string>();
|
||||
|
||||
if (completedMatches) {
|
||||
for (const m of completedMatches) {
|
||||
if (!m.isComplete || m.participant1Score === null || m.participant2Score === null) continue;
|
||||
const sa = stats.get(m.participant1Id);
|
||||
const sb = stats.get(m.participant2Id);
|
||||
if (!sa || !sb) continue;
|
||||
|
||||
const s1 = m.participant1Score;
|
||||
const s2 = m.participant2Score;
|
||||
sa.gf += s1; sa.gd += s1 - s2;
|
||||
sb.gf += s2; sb.gd += s2 - s1;
|
||||
if (s1 > s2) { sa.pts += 3; }
|
||||
else if (s2 > s1) { sb.pts += 3; }
|
||||
else { sa.pts += 1; sb.pts += 1; }
|
||||
|
||||
// Mark this pairing as done so we don't re-simulate it
|
||||
const key = [m.participant1Id, m.participant2Id].toSorted().join(":");
|
||||
completedPairs.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate remaining (not-yet-played) matches
|
||||
for (let i = 0; i < teamIds.length; i++) {
|
||||
for (let j = i + 1; j < teamIds.length; j++) {
|
||||
const a = teamIds[i];
|
||||
const b = teamIds[j];
|
||||
const key = [a, b].toSorted().join(":");
|
||||
if (completedPairs.has(key)) continue; // already applied above
|
||||
|
||||
const result = simGroupMatch(eloFn(a), eloFn(b));
|
||||
const sa = stats.get(a);
|
||||
const sb = stats.get(b);
|
||||
if (!sa || !sb) continue;
|
||||
|
||||
// Simple goal simulation: winner scores 1-2, loser 0-1, draw both 1
|
||||
let ga: number, gb: number;
|
||||
if (result === "win") {
|
||||
ga = Math.random() < 0.5 ? 2 : 1;
|
||||
gb = ga === 2 && Math.random() < 0.3 ? 1 : 0;
|
||||
sa.pts += 3;
|
||||
} else if (result === "loss") {
|
||||
gb = Math.random() < 0.5 ? 2 : 1;
|
||||
ga = gb === 2 && Math.random() < 0.3 ? 1 : 0;
|
||||
sb.pts += 3;
|
||||
} else {
|
||||
ga = gb = Math.random() < 0.5 ? 1 : 0;
|
||||
sa.pts += 1;
|
||||
sb.pts += 1;
|
||||
}
|
||||
sa.gf += ga; sa.gd += ga - gb;
|
||||
sb.gf += gb; sb.gd += gb - ga;
|
||||
}
|
||||
}
|
||||
|
||||
return [...stats.values()].toSorted(sortTeams);
|
||||
}
|
||||
|
||||
function sortTeams(a: TeamStats, b: TeamStats): number {
|
||||
if (b.pts !== a.pts) return b.pts - a.pts;
|
||||
if (b.gd !== a.gd) return b.gd - a.gd;
|
||||
return b.gf - a.gf;
|
||||
}
|
||||
|
||||
// ─── Knockout helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function simKnockout(
|
||||
teamA: string,
|
||||
teamB: string,
|
||||
eloFn: (id: string) => number,
|
||||
normalizedProb: (id: string) => number
|
||||
): { winner: string; loser: string } {
|
||||
const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
|
||||
const oddsProb = normalizedProb(teamA);
|
||||
const blended = ELO_WEIGHT * eloProb + ODDS_WEIGHT * (0.5 + (oddsProb - 0.5));
|
||||
const winner = Math.random() < blended ? teamA : teamB;
|
||||
return { winner, loser: winner === teamA ? teamB : teamA };
|
||||
}
|
||||
|
||||
// ─── Probability normalisation ────────────────────────────────────────────────
|
||||
|
||||
function normalizeProbabilities(
|
||||
results: SimulationResult[],
|
||||
positionKeys: Array<keyof SimulationProbabilities>
|
||||
): void {
|
||||
for (const key of positionKeys) {
|
||||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
const residual = 1.0 - colSum;
|
||||
if (residual !== 0 && Math.abs(residual) < 1e-9) {
|
||||
const maxResult = results.reduce((best, r) =>
|
||||
r.probabilities[key] > best.probabilities[key] ? r : best
|
||||
);
|
||||
maxResult.probabilities[key] += residual;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main simulator ───────────────────────────────────────────────────────────
|
||||
|
||||
export class WorldCupSimulator implements Simulator {
|
||||
private readonly numSimulations: number;
|
||||
|
||||
constructor(numSimulations = NUM_SIMULATIONS) {
|
||||
this.numSimulations = numSimulations;
|
||||
}
|
||||
|
||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
// 1. Load all participants for this season
|
||||
const participantRows = await db.query.participants.findMany({
|
||||
where: eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
|
||||
if (participantRows.length === 0) {
|
||||
throw new Error(`No participants found for sports season ${sportsSeasonId}`);
|
||||
}
|
||||
|
||||
const participantIds = participantRows.map((p) => p.id);
|
||||
const participantNames = new Map(participantRows.map((p) => [p.id, p.name]));
|
||||
|
||||
// 2. Load stored ratings (sourceElo from Elo page, sourceOdds from futures page)
|
||||
const evRows = await db
|
||||
.select({
|
||||
participantId: schema.participantExpectedValues.participantId,
|
||||
sourceOdds: schema.participantExpectedValues.sourceOdds,
|
||||
sourceElo: schema.participantExpectedValues.sourceElo,
|
||||
})
|
||||
.from(schema.participantExpectedValues)
|
||||
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||
|
||||
const evMap = new Map(evRows.map((r) => [r.participantId, r]));
|
||||
const participantSet = new Set(participantIds);
|
||||
|
||||
// 3. Build Elo map — priority: sourceElo (direct) > sourceOdds (converted) > hardcoded
|
||||
const sourceEloMap = new Map<string, number>();
|
||||
for (const r of evRows) {
|
||||
if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) {
|
||||
sourceEloMap.set(r.participantId, r.sourceElo);
|
||||
}
|
||||
}
|
||||
|
||||
const hasOdds = evRows.some(
|
||||
(r) => r.sourceOdds !== null && participantSet.has(r.participantId)
|
||||
);
|
||||
|
||||
let eloFromOdds: Map<string, number>;
|
||||
if (hasOdds) {
|
||||
const oddsInput = evRows
|
||||
.filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId))
|
||||
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
|
||||
eloFromOdds = convertFuturesToElo(oddsInput, "american");
|
||||
} else {
|
||||
eloFromOdds = new Map();
|
||||
}
|
||||
|
||||
const eloFn = (id: string): number => {
|
||||
if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500;
|
||||
if (eloFromOdds.has(id)) return eloFromOdds.get(id) ?? 1500;
|
||||
const name = participantNames.get(id) ?? "";
|
||||
return getTeamElo(name, 1500);
|
||||
};
|
||||
|
||||
// 4. Build normalized futures win-probability map (vig removed)
|
||||
const rawProbs = new Map<string, number>();
|
||||
for (const id of participantIds) {
|
||||
const ev = evMap.get(id);
|
||||
if (ev?.sourceOdds !== null && ev?.sourceOdds !== undefined) {
|
||||
rawProbs.set(id, Math.abs(ev.sourceOdds) > 0
|
||||
? ev.sourceOdds > 0
|
||||
? 100 / (ev.sourceOdds + 100)
|
||||
: Math.abs(ev.sourceOdds) / (Math.abs(ev.sourceOdds) + 100)
|
||||
: 1 / participantIds.length);
|
||||
} else {
|
||||
rawProbs.set(id, 1 / participantIds.length);
|
||||
}
|
||||
}
|
||||
|
||||
const totalRawProb = [...rawProbs.values()].reduce((s, p) => s + p, 0);
|
||||
const normalizedProb = (id: string): number =>
|
||||
totalRawProb > 0 ? (rawProbs.get(id) ?? 0) / totalRawProb : 1 / participantIds.length;
|
||||
|
||||
// 5. Load group stage data from DB
|
||||
const bracketEvent = await db.query.scoringEvents.findFirst({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.eventType, "playoff_game")
|
||||
),
|
||||
});
|
||||
|
||||
const tournamentGroups = bracketEvent
|
||||
? await db.query.tournamentGroups.findMany({
|
||||
where: eq(schema.tournamentGroups.scoringEventId, bracketEvent.id),
|
||||
with: {
|
||||
members: true,
|
||||
matches: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
// Build group definitions: list of teams + their completed match data per group.
|
||||
// If no groups set up yet, distribute all participants into 12 synthetic groups of 4.
|
||||
const groupDefs: Array<{
|
||||
groupName: string;
|
||||
teamIds: string[];
|
||||
completedMatches: GroupMatchResult[];
|
||||
}> = [];
|
||||
|
||||
if (tournamentGroups.length > 0) {
|
||||
for (const group of tournamentGroups) {
|
||||
const teamIds = group.members.map((m) => m.participantId);
|
||||
const completedMatches: GroupMatchResult[] = group.matches
|
||||
.filter((m) => m.isComplete)
|
||||
.map((m) => ({
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
participant1Score: m.participant1Score,
|
||||
participant2Score: m.participant2Score,
|
||||
isComplete: m.isComplete,
|
||||
}));
|
||||
groupDefs.push({ groupName: group.groupName, teamIds, completedMatches });
|
||||
}
|
||||
} else {
|
||||
// No groups set up — distribute participants into 12 synthetic groups of 4
|
||||
const chunkSize = 4;
|
||||
const labels = ["A","B","C","D","E","F","G","H","I","J","K","L"];
|
||||
for (let i = 0; i < Math.min(12, labels.length); i++) {
|
||||
const teamIds = participantIds.slice(i * chunkSize, (i + 1) * chunkSize);
|
||||
if (teamIds.length > 0) groupDefs.push({ groupName: labels[i], teamIds, completedMatches: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Load completed knockout matches (to fix results in simulation)
|
||||
const completedKnockoutMatches = bracketEvent
|
||||
? await db.query.playoffMatches.findMany({
|
||||
where: and(
|
||||
eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||
eq(schema.playoffMatches.isComplete, true)
|
||||
),
|
||||
})
|
||||
: [];
|
||||
|
||||
const completedByRoundAndNumber = new Map<string, { winnerId: string; loserId: string }>();
|
||||
for (const m of completedKnockoutMatches) {
|
||||
if (m.winnerId && m.loserId) {
|
||||
completedByRoundAndNumber.set(`${m.round}:${m.matchNumber}`, {
|
||||
winnerId: m.winnerId,
|
||||
loserId: m.loserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Monte Carlo simulation
|
||||
const counts = {
|
||||
champion: new Map<string, number>(),
|
||||
runnerUp: new Map<string, number>(),
|
||||
thirdPlace: new Map<string, number>(),
|
||||
fourthPlace: new Map<string, number>(),
|
||||
qfLoser: new Map<string, number>(),
|
||||
};
|
||||
for (const id of participantIds) {
|
||||
counts.champion.set(id, 0);
|
||||
counts.runnerUp.set(id, 0);
|
||||
counts.thirdPlace.set(id, 0);
|
||||
counts.fourthPlace.set(id, 0);
|
||||
counts.qfLoser.set(id, 0);
|
||||
}
|
||||
|
||||
for (let sim = 0; sim < this.numSimulations; sim++) {
|
||||
// ── Group stage ──────────────────────────────────────────────
|
||||
const advancingFromGroup: string[] = []; // group winners + runners-up (24 teams)
|
||||
const thirdPlaceTeams: TeamStats[] = []; // 12 third-place teams
|
||||
|
||||
for (const group of groupDefs) {
|
||||
if (group.teamIds.length < 3) continue;
|
||||
|
||||
const sorted = simGroup(group.teamIds, eloFn, group.completedMatches);
|
||||
|
||||
advancingFromGroup.push(sorted[0].id, sorted[1].id); // 1st and 2nd
|
||||
if (sorted[2]) thirdPlaceTeams.push(sorted[2]); // 3rd place
|
||||
}
|
||||
|
||||
// Pick best 8 third-place teams
|
||||
const best8Third = thirdPlaceTeams
|
||||
.toSorted(sortTeams)
|
||||
.slice(0, 8)
|
||||
.map((t) => t.id);
|
||||
|
||||
// Build R32 pool: 24 group advancers + 8 best 3rd-place = 32 teams
|
||||
const r32Pool = [...advancingFromGroup, ...best8Third];
|
||||
|
||||
// ── Knockout rounds ──────────────────────────────────────────
|
||||
// R32 → R16 → QF → SF → 3PG + Final
|
||||
//
|
||||
// SEEDING NOTE: We pair teams sequentially (1v2, 3v4, …) in arrival order
|
||||
// (group A winner, group A runner-up, group B winner, …, best-8 3rd-place teams).
|
||||
// Real FIFA uses a pre-determined bracket path (e.g. Group A winner vs Group B
|
||||
// runner-up), which varies by edition. This simplified pairing produces correct
|
||||
// aggregate probabilities for fantasy purposes even though simulated bracket paths
|
||||
// may not match the actual draw.
|
||||
//
|
||||
// Completed knockout matches are honoured by round+matchNumber, so as the real
|
||||
// bracket plays out the simulation locks in actual results automatically.
|
||||
|
||||
function runKnockoutRound(
|
||||
teams: string[],
|
||||
roundName: string
|
||||
): { winners: string[]; losers: string[] } {
|
||||
const winners: string[] = [];
|
||||
const losers: string[] = [];
|
||||
for (let i = 0; i < teams.length; i += 2) {
|
||||
const a = teams[i];
|
||||
const b = teams[i + 1];
|
||||
if (!a || !b) continue;
|
||||
|
||||
const matchNum = Math.floor(i / 2) + 1;
|
||||
const fixed = completedByRoundAndNumber.get(`${roundName}:${matchNum}`);
|
||||
if (fixed) {
|
||||
winners.push(fixed.winnerId);
|
||||
losers.push(fixed.loserId);
|
||||
} else {
|
||||
const { winner, loser } = simKnockout(a, b, eloFn, normalizedProb);
|
||||
winners.push(winner);
|
||||
losers.push(loser);
|
||||
}
|
||||
}
|
||||
return { winners, losers };
|
||||
}
|
||||
|
||||
const r32 = runKnockoutRound(r32Pool, "Round of 32");
|
||||
const r16 = runKnockoutRound(r32.winners, "Round of 16");
|
||||
const qf = runKnockoutRound(r16.winners, "Quarterfinals");
|
||||
const sf = runKnockoutRound(qf.winners, "Semifinals");
|
||||
|
||||
// QF losers
|
||||
for (const id of qf.losers) {
|
||||
counts.qfLoser.set(id, (counts.qfLoser.get(id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Third place game (SF losers)
|
||||
const [sf1Loser, sf2Loser] = sf.losers;
|
||||
if (sf1Loser && sf2Loser) {
|
||||
const fixed3pg = completedByRoundAndNumber.get("Third Place Game:1");
|
||||
if (fixed3pg) {
|
||||
counts.thirdPlace.set(fixed3pg.winnerId, (counts.thirdPlace.get(fixed3pg.winnerId) ?? 0) + 1);
|
||||
counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1);
|
||||
} else {
|
||||
const { winner: thirdWinner, loser: thirdLoser } = simKnockout(
|
||||
sf1Loser, sf2Loser, eloFn, normalizedProb
|
||||
);
|
||||
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
|
||||
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Final (SF winners)
|
||||
const [sfWinner1, sfWinner2] = sf.winners;
|
||||
if (sfWinner1 && sfWinner2) {
|
||||
const fixedFinal = completedByRoundAndNumber.get("Finals:1");
|
||||
if (fixedFinal) {
|
||||
counts.champion.set(fixedFinal.winnerId, (counts.champion.get(fixedFinal.winnerId) ?? 0) + 1);
|
||||
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
|
||||
} else {
|
||||
const { winner: champion, loser: runnerUp } = simKnockout(
|
||||
sfWinner1, sfWinner2, eloFn, normalizedProb
|
||||
);
|
||||
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
|
||||
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Convert counts to probabilities
|
||||
const N = this.numSimulations;
|
||||
const numQfLosers = 4; // 4 QF losers per sim
|
||||
|
||||
const results: SimulationResult[] = participantIds.map((id) => {
|
||||
const qfProb = (counts.qfLoser.get(id) ?? 0) / (numQfLosers * N);
|
||||
|
||||
return {
|
||||
participantId: id,
|
||||
probabilities: {
|
||||
probFirst: (counts.champion.get(id) ?? 0) / N,
|
||||
probSecond: (counts.runnerUp.get(id) ?? 0) / N,
|
||||
probThird: (counts.thirdPlace.get(id) ?? 0) / N,
|
||||
probFourth: (counts.fourthPlace.get(id) ?? 0) / N,
|
||||
probFifth: qfProb,
|
||||
probSixth: qfProb,
|
||||
probSeventh: qfProb,
|
||||
probEighth: qfProb,
|
||||
},
|
||||
source: "World Cup Monte Carlo (group stage + knockout)",
|
||||
};
|
||||
});
|
||||
|
||||
// Normalise floating-point residuals per position
|
||||
const positionKeys: Array<keyof SimulationProbabilities> = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
];
|
||||
normalizeProbabilities(results, positionKeys);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|||
"tennis_qualifying_points",
|
||||
"mlb_bracket",
|
||||
"wnba_bracket",
|
||||
"world_cup",
|
||||
]);
|
||||
|
||||
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
|
||||
|
|
@ -619,6 +620,30 @@ export const tournamentGroupMembers = pgTable("tournament_group_members", {
|
|||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const groupStageMatches = pgTable("group_stage_matches", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tournamentGroupId: uuid("tournament_group_id")
|
||||
.notNull()
|
||||
.references(() => tournamentGroups.id, { onDelete: "cascade" }),
|
||||
participant1Id: uuid("participant1_id")
|
||||
.notNull()
|
||||
.references(() => participants.id),
|
||||
participant2Id: uuid("participant2_id")
|
||||
.notNull()
|
||||
.references(() => participants.id),
|
||||
participant1Score: integer("participant1_score"),
|
||||
participant2Score: integer("participant2_score"),
|
||||
isComplete: boolean("is_complete").notNull().default(false),
|
||||
matchday: integer("matchday").notNull(), // 1, 2, or 3 (round of group play)
|
||||
scheduledAt: timestamp("scheduled_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
}, (t) => [
|
||||
// Prevent duplicate pairings within the same group (order-independent)
|
||||
uniqueIndex("group_stage_matches_group_pair_unique")
|
||||
.on(t.tournamentGroupId, t.participant1Id, t.participant2Id),
|
||||
]);
|
||||
|
||||
// Participant season results (for F1 current points tracking during season)
|
||||
export const participantSeasonResults = pgTable("participant_season_results", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
|
@ -972,6 +997,7 @@ export const tournamentGroupsRelations = relations(tournamentGroups, ({ one, man
|
|||
references: [scoringEvents.id],
|
||||
}),
|
||||
members: many(tournamentGroupMembers),
|
||||
matches: many(groupStageMatches),
|
||||
}));
|
||||
|
||||
export const tournamentGroupMembersRelations = relations(tournamentGroupMembers, ({ one }) => ({
|
||||
|
|
@ -985,6 +1011,23 @@ export const tournamentGroupMembersRelations = relations(tournamentGroupMembers,
|
|||
}),
|
||||
}));
|
||||
|
||||
export const groupStageMatchesRelations = relations(groupStageMatches, ({ one }) => ({
|
||||
group: one(tournamentGroups, {
|
||||
fields: [groupStageMatches.tournamentGroupId],
|
||||
references: [tournamentGroups.id],
|
||||
}),
|
||||
participant1: one(participants, {
|
||||
fields: [groupStageMatches.participant1Id],
|
||||
references: [participants.id],
|
||||
relationName: "groupMatchParticipant1",
|
||||
}),
|
||||
participant2: one(participants, {
|
||||
fields: [groupStageMatches.participant2Id],
|
||||
references: [participants.id],
|
||||
relationName: "groupMatchParticipant2",
|
||||
}),
|
||||
}));
|
||||
|
||||
export const participantEvSnapshotsRelations = relations(participantEvSnapshots, ({ one }) => ({
|
||||
participant: one(participants, {
|
||||
fields: [participantEvSnapshots.participantId],
|
||||
|
|
|
|||
|
|
@ -1 +1,12 @@
|
|||
-- Remove duplicate participants (same sports_season_id + name), keeping the oldest (lowest created_at)
|
||||
DELETE FROM "participants"
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (PARTITION BY sports_season_id, name ORDER BY created_at ASC) AS rn
|
||||
FROM "participants"
|
||||
) ranked
|
||||
WHERE rn > 1
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "participants_sports_season_name_unique" ON "participants" USING btree ("sports_season_id","name");
|
||||
32
drizzle/0065_kind_mantis.sql
Normal file
32
drizzle/0065_kind_mantis.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
ALTER TYPE "public"."simulator_type" ADD VALUE 'world_cup';--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "group_stage_matches" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tournament_group_id" uuid NOT NULL,
|
||||
"participant1_id" uuid NOT NULL,
|
||||
"participant2_id" uuid NOT NULL,
|
||||
"participant1_score" integer,
|
||||
"participant2_score" integer,
|
||||
"is_complete" boolean DEFAULT false NOT NULL,
|
||||
"matchday" integer NOT NULL,
|
||||
"scheduled_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "group_stage_matches" ADD CONSTRAINT "group_stage_matches_tournament_group_id_tournament_groups_id_fk" FOREIGN KEY ("tournament_group_id") REFERENCES "public"."tournament_groups"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "group_stage_matches" ADD CONSTRAINT "group_stage_matches_participant1_id_participants_id_fk" FOREIGN KEY ("participant1_id") REFERENCES "public"."participants"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "group_stage_matches" ADD CONSTRAINT "group_stage_matches_participant2_id_participants_id_fk" FOREIGN KEY ("participant2_id") REFERENCES "public"."participants"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
1
drizzle/0066_stiff_siren.sql
Normal file
1
drizzle/0066_stiff_siren.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE UNIQUE INDEX IF NOT EXISTS "group_stage_matches_group_pair_unique" ON "group_stage_matches" USING btree ("tournament_group_id","participant1_id","participant2_id");
|
||||
4310
drizzle/meta/0065_snapshot.json
Normal file
4310
drizzle/meta/0065_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4338
drizzle/meta/0066_snapshot.json
Normal file
4338
drizzle/meta/0066_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -456,6 +456,20 @@
|
|||
"when": 1774598151282,
|
||||
"tag": "0064_fuzzy_wolfsbane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 65,
|
||||
"version": "7",
|
||||
"when": 1774719902130,
|
||||
"tag": "0065_kind_mantis",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 66,
|
||||
"version": "7",
|
||||
"when": 1774802533652,
|
||||
"tag": "0066_stiff_siren",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue