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
|
run: npm run test:run
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
|
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: ʦ TypeScript
|
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;
|
feedsInto: string | null;
|
||||||
/** Whether this round affects fantasy points (false = 0 points per Q20) */
|
/** Whether this round affects fantasy points (false = 0 points per Q20) */
|
||||||
isScoring: boolean;
|
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 {
|
export interface GroupStageConfig {
|
||||||
|
|
@ -544,6 +549,13 @@ export const FIFA_48: BracketTemplate = {
|
||||||
matchCount: 2,
|
matchCount: 2,
|
||||||
feedsInto: "Finals",
|
feedsInto: "Finals",
|
||||||
isScoring: true,
|
isScoring: true,
|
||||||
|
loserFeedsInto: "Third Place Game",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Third Place Game",
|
||||||
|
matchCount: 1,
|
||||||
|
feedsInto: null,
|
||||||
|
isScoring: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Finals",
|
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
|
// Fill the determined slot
|
||||||
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
|
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;
|
loserIsPartial: boolean;
|
||||||
/**
|
/**
|
||||||
* Guaranteed minimum position for the winner.
|
* 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;
|
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: {
|
afl_10: {
|
||||||
"Semi-Finals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
"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) {
|
} 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];
|
const finalMatch = matches[0];
|
||||||
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
|
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
|
||||||
throw new Error("Finals match is not complete");
|
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);
|
await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
|
||||||
} else {
|
} else {
|
||||||
// All other scoring rounds: assign loser's final (or provisional) position.
|
// 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(loserId, sportsSeasonId, 0, db);
|
||||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||||
} else if (config.winnerFloor === null) {
|
} 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(loserId, sportsSeasonId, config.loserPosition, db);
|
||||||
await upsertParticipantResult(winnerId, sportsSeasonId, 1, db);
|
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerPosition ?? 1, db);
|
||||||
} else {
|
} else {
|
||||||
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
|
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"]);
|
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.
|
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||||
*
|
*
|
||||||
|
|
@ -139,8 +145,11 @@ export function calculateBracketPoints(
|
||||||
if (finalPosition <= 0) return 0;
|
if (finalPosition <= 0) return 0;
|
||||||
if (finalPosition === 1) return rules.pointsFor1st;
|
if (finalPosition === 1) return rules.pointsFor1st;
|
||||||
if (finalPosition === 2) return rules.pointsFor2nd;
|
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);
|
return calculateAveragedPoints([3, 4], rules);
|
||||||
|
}
|
||||||
if (finalPosition >= 5 && finalPosition <= 8) {
|
if (finalPosition >= 5 && finalPosition <= 8) {
|
||||||
if (bracketTemplateId && SPLIT_5678_TEMPLATE_IDS.has(bracketTemplateId)) {
|
if (bracketTemplateId && SPLIT_5678_TEMPLATE_IDS.has(bracketTemplateId)) {
|
||||||
// AFL-style: two separate 2-team tiers within 5–8
|
// AFL-style: two separate 2-team tiers within 5–8
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import {
|
||||||
} from '~/models/participant-expected-value';
|
} from '~/models/participant-expected-value';
|
||||||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
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 { recalculateStandings } from '~/models/scoring-calculator';
|
||||||
import { database } from '~/database/context';
|
import { database } from '~/database/context';
|
||||||
import * as schema from '~/database/schema';
|
import * as schema from '~/database/schema';
|
||||||
|
|
@ -30,16 +31,6 @@ import {
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-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 {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Elo Ratings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
return [{ title: `Elo Ratings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,13 @@ import {
|
||||||
getAdvancingParticipantIds,
|
getAdvancingParticipantIds,
|
||||||
getEliminatedParticipantIds,
|
getEliminatedParticipantIds,
|
||||||
} from "~/models/tournament-group";
|
} from "~/models/tournament-group";
|
||||||
|
import {
|
||||||
|
createGroupStageMatches,
|
||||||
|
generateRoundRobinPairings,
|
||||||
|
updateGroupStageMatchResult,
|
||||||
|
updateGroupStageMatchSchedule,
|
||||||
|
findMatchesByGroupId,
|
||||||
|
} from "~/models/group-stage-match";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
@ -65,8 +72,14 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
||||||
|
|
||||||
// Fetch tournament groups if this event has a group-stage template
|
// Fetch tournament groups with their round-robin matches
|
||||||
const tournamentGroups = await findGroupsByEventId(params.eventId);
|
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
|
// The matches already include participant relations from the model query
|
||||||
return {
|
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") {
|
if (intent === "reprocess-eliminations") {
|
||||||
try {
|
try {
|
||||||
// Get the event
|
// Get the event
|
||||||
|
|
@ -735,7 +805,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// Create tournament groups
|
// Create tournament groups
|
||||||
const groups = await createGroupsForEvent(params.eventId, groupStage.groupLabels);
|
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++) {
|
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||||
const startIdx = groupIndex * groupStage.teamsPerGroup;
|
const startIdx = groupIndex * groupStage.teamsPerGroup;
|
||||||
const groupParticipantIds = participantIds.slice(
|
const groupParticipantIds = participantIds.slice(
|
||||||
|
|
@ -743,6 +813,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
startIdx + groupStage.teamsPerGroup
|
startIdx + groupStage.teamsPerGroup
|
||||||
);
|
);
|
||||||
await addMembersToGroup(groups[groupIndex].id, groupParticipantIds);
|
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
|
// 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") {
|
if (intent === "populate-knockout") {
|
||||||
try {
|
try {
|
||||||
const event = await getScoringEventById(params.eventId);
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { ArrowLeft, Plus, Trophy, X, Check, ChevronDown, ChevronRight, Calendar, TrendingUp, Trash2 } from "lucide-react";
|
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 { getAllBracketTemplates, getBracketTemplate, getOrderedRoundsFromMatches, buildNCAA68SlotMap, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||||
|
import { computeGroupStandings } from "~/models/group-stage-match";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -322,6 +323,29 @@ export default function EventBracket({
|
||||||
</div>
|
</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) */}
|
{/* Reprocess Eliminations - For existing brackets (non-group-stage) */}
|
||||||
{matches.length > 0 && !isGroupStageEvent && (
|
{matches.length > 0 && !isGroupStageEvent && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -685,55 +709,189 @@ export default function EventBracket({
|
||||||
|
|
||||||
{/* Group Cards */}
|
{/* Group Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<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) => {
|
||||||
<Card key={group.id}>
|
const members = group.members || [];
|
||||||
<CardHeader className="py-3 px-4">
|
const groupMatches = group.groupMatches || [];
|
||||||
<CardTitle className="text-base">Group {group.groupName}</CardTitle>
|
const standings = computeGroupStandings(
|
||||||
</CardHeader>
|
members
|
||||||
<CardContent className="px-4 pb-4 space-y-2">
|
.filter((m) => m.participant !== null && m.participant !== undefined)
|
||||||
{(group.members || []).map((member) => (
|
.map((m) => ({
|
||||||
<div
|
participantId: m.participant?.id ?? "",
|
||||||
key={member.id}
|
participantName: m.participant?.name ?? "",
|
||||||
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
|
})),
|
||||||
member.eliminated
|
groupMatches.map((m) => ({
|
||||||
? "bg-destructive/10 border-destructive/20"
|
participant1Id: m.participant1Id,
|
||||||
: "bg-background border-border"
|
participant2Id: m.participant2Id,
|
||||||
}`}
|
participant1Score: m.participant1Score,
|
||||||
>
|
participant2Score: m.participant2Score,
|
||||||
<span
|
isComplete: m.isComplete,
|
||||||
className={`text-sm truncate ${
|
}))
|
||||||
member.eliminated ? "line-through text-muted-foreground" : ""
|
);
|
||||||
}`}
|
|
||||||
>
|
return (
|
||||||
{member.participant?.name || "Unknown"}
|
<Card key={group.id}>
|
||||||
</span>
|
<CardHeader className="py-3 px-4">
|
||||||
<Form method="post" className="shrink-0">
|
<CardTitle className="text-base">Group {group.groupName}</CardTitle>
|
||||||
<input type="hidden" name="intent" value="toggle-elimination" />
|
</CardHeader>
|
||||||
<input type="hidden" name="memberId" value={member.id} />
|
<CardContent className="px-4 pb-4 space-y-3">
|
||||||
<Button
|
{/* Standings table */}
|
||||||
type="submit"
|
{standings.length > 0 && (
|
||||||
size="sm"
|
<div className="overflow-x-auto">
|
||||||
variant={member.eliminated ? "outline" : "destructive"}
|
<table className="w-full text-xs">
|
||||||
className="h-7 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 ${
|
||||||
|
member.eliminated
|
||||||
|
? "bg-destructive/10 border-destructive/20"
|
||||||
|
: "bg-background border-border"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{member.eliminated ? (
|
<span
|
||||||
<>
|
className={`text-sm truncate ${
|
||||||
<Check className="h-3 w-3 mr-1" />
|
member.eliminated ? "line-through text-muted-foreground" : ""
|
||||||
Reinstate
|
}`}
|
||||||
</>
|
>
|
||||||
) : (
|
{member.participant?.name || "Unknown"}
|
||||||
<>
|
</span>
|
||||||
<X className="h-3 w-3 mr-1" />
|
<Form method="post" className="shrink-0">
|
||||||
Eliminate
|
<input type="hidden" name="intent" value="toggle-elimination" />
|
||||||
</>
|
<input type="hidden" name="memberId" value={member.id} />
|
||||||
)}
|
<Button
|
||||||
</Button>
|
type="submit"
|
||||||
</Form>
|
size="sm"
|
||||||
|
variant={member.eliminated ? "outline" : "destructive"}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{member.eliminated ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Reinstate
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Eliminate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</CardContent>
|
{/* Match score entry */}
|
||||||
</Card>
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Knockout Assignment Section */}
|
{/* Knockout Assignment Section */}
|
||||||
|
|
|
||||||
|
|
@ -17,25 +17,13 @@ import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||||
import { getSimulator, type SimulatorType } from "~/services/simulations/registry";
|
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 { recalculateStandings } from "~/models/scoring-calculator";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
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) {
|
export async function action({ params }: Route.ActionArgs) {
|
||||||
const sportsSeasonId = params.id;
|
const sportsSeasonId = params.id;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import { findCurrentSeasonWithSports } from "~/models/season";
|
import { findCurrentSeasonWithSports } from "~/models/season";
|
||||||
import { getSeasonStandings } from "~/models/standings";
|
import { getSeasonStandings } from "~/models/standings";
|
||||||
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
||||||
|
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
|
||||||
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
|
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
|
||||||
import type { Route } from "./+types/$leagueId";
|
import type { Route } from "./+types/$leagueId";
|
||||||
|
|
||||||
|
|
@ -114,6 +115,9 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
|
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
|
||||||
: new Map<string, Array<{ id: string; name: string }>>();
|
: 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(
|
const sportsSeasons = await Promise.all(
|
||||||
rawSportsSeasons.map(async (ss) => {
|
rawSportsSeasons.map(async (ss) => {
|
||||||
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
|
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
|
||||||
|
|
@ -127,6 +131,33 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
dateToStr
|
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 {
|
return {
|
||||||
id: ss.id,
|
id: ss.id,
|
||||||
name: ss.name,
|
name: ss.name,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ import {
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
||||||
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
|
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 type { PlayoffMatch } from "~/models/playoff-match";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
@ -152,6 +157,15 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number];
|
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number];
|
||||||
let qpStandings: QPStanding[] = [];
|
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") {
|
if (scoringPattern === "playoff_bracket") {
|
||||||
// Fetch playoff matches via scoring events
|
// Fetch playoff matches via scoring events
|
||||||
const events = await db.query.scoringEvents.findMany({
|
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;
|
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
|
||||||
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
||||||
playoffRounds = getOrderedRoundsFromMatches(matches, template);
|
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
|
// Fetch group-stage losers and all participant results for bracket scoring
|
||||||
|
|
@ -288,5 +338,6 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
seasonIsFinalized,
|
seasonIsFinalized,
|
||||||
regularSeasonStandings,
|
regularSeasonStandings,
|
||||||
participantEvs,
|
participantEvs,
|
||||||
|
groupStandings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
||||||
import { SportSeasonDisplay } from "~/components/scoring/SportSeasonDisplay";
|
import { SportSeasonDisplay } from "~/components/scoring/SportSeasonDisplay";
|
||||||
import { EventSchedule } from "~/components/sport-season/EventSchedule";
|
import { EventSchedule } from "~/components/sport-season/EventSchedule";
|
||||||
import { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
|
import { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
|
||||||
|
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
|
||||||
|
|
@ -65,6 +66,7 @@ export default function SportSeasonDetail({
|
||||||
seasonIsFinalized,
|
seasonIsFinalized,
|
||||||
regularSeasonStandings,
|
regularSeasonStandings,
|
||||||
participantEvs,
|
participantEvs,
|
||||||
|
groupStandings,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
const hasBracket = playoffMatches && playoffMatches.length > 0;
|
||||||
|
|
@ -140,6 +142,13 @@ export default function SportSeasonDetail({
|
||||||
</div>
|
</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 */}
|
{/* Hide bracket display when standings exist but no bracket matches have been set yet */}
|
||||||
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
|
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
|
||||||
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
|
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium tabular-nums">
|
<div className="text-sm font-medium tabular-nums">
|
||||||
{standing ? standing.totalPoints : 0} pts
|
{standing ? (standing.actualPoints ?? standing.totalPoints) : 0} pts
|
||||||
</div>
|
</div>
|
||||||
</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 { TennisSimulator } from "./tennis-simulator";
|
||||||
import { MLBSimulator } from "./mlb-simulator";
|
import { MLBSimulator } from "./mlb-simulator";
|
||||||
import { WNBASimulator } from "./wnba-simulator";
|
import { WNBASimulator } from "./wnba-simulator";
|
||||||
|
import { WorldCupSimulator } from "./world-cup-simulator";
|
||||||
|
|
||||||
export const SIMULATOR_TYPES = [
|
export const SIMULATOR_TYPES = [
|
||||||
"f1_standings",
|
"f1_standings",
|
||||||
|
|
@ -36,6 +37,7 @@ export const SIMULATOR_TYPES = [
|
||||||
"tennis_qualifying_points",
|
"tennis_qualifying_points",
|
||||||
"mlb_bracket",
|
"mlb_bracket",
|
||||||
"wnba_bracket",
|
"wnba_bracket",
|
||||||
|
"world_cup",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SimulatorType = typeof SIMULATOR_TYPES[number];
|
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." },
|
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(),
|
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 {
|
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",
|
"tennis_qualifying_points",
|
||||||
"mlb_bracket",
|
"mlb_bracket",
|
||||||
"wnba_bracket",
|
"wnba_bracket",
|
||||||
|
"world_cup",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
|
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(),
|
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)
|
// Participant season results (for F1 current points tracking during season)
|
||||||
export const participantSeasonResults = pgTable("participant_season_results", {
|
export const participantSeasonResults = pgTable("participant_season_results", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
@ -972,6 +997,7 @@ export const tournamentGroupsRelations = relations(tournamentGroups, ({ one, man
|
||||||
references: [scoringEvents.id],
|
references: [scoringEvents.id],
|
||||||
}),
|
}),
|
||||||
members: many(tournamentGroupMembers),
|
members: many(tournamentGroupMembers),
|
||||||
|
matches: many(groupStageMatches),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const tournamentGroupMembersRelations = relations(tournamentGroupMembers, ({ one }) => ({
|
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 }) => ({
|
export const participantEvSnapshotsRelations = relations(participantEvSnapshots, ({ one }) => ({
|
||||||
participant: one(participants, {
|
participant: one(participants, {
|
||||||
fields: [participantEvSnapshots.participantId],
|
fields: [participantEvSnapshots.participantId],
|
||||||
|
|
|
||||||
|
|
@ -1 +1,12 @@
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS "participants_sports_season_name_unique" ON "participants" USING btree ("sports_season_id","name");
|
-- 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,
|
"when": 1774598151282,
|
||||||
"tag": "0064_fuzzy_wolfsbane",
|
"tag": "0064_fuzzy_wolfsbane",
|
||||||
"breakpoints": true
|
"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