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:
Chris Parsons 2026-03-29 10:27:47 -07:00 committed by GitHub
parent 611e1ccf0a
commit 338979e0a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 10858 additions and 83 deletions

View file

@ -49,6 +49,7 @@ jobs:
run: npm run test:run
env:
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
NODE_OPTIONS: --max-old-space-size=4096
typecheck:
name: ʦ TypeScript

View 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>
);
}

View file

@ -14,6 +14,11 @@ export interface BracketRound {
feedsInto: string | null;
/** Whether this round affects fantasy points (false = 0 points per Q20) */
isScoring: boolean;
/**
* Name of the round that *losers* advance to (e.g., "Third Place Game" for SF losers).
* When set, the loser of each match in this round is placed into the target round.
*/
loserFeedsInto?: string | null;
}
export interface GroupStageConfig {
@ -544,6 +549,13 @@ export const FIFA_48: BracketTemplate = {
matchCount: 2,
feedsInto: "Finals",
isScoring: true,
loserFeedsInto: "Third Place Game",
},
{
name: "Third Place Game",
matchCount: 1,
feedsInto: null,
isScoring: true,
},
{
name: "Finals",

View 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();
});
});

View 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,
};
});
}

View file

@ -922,6 +922,30 @@ export async function advanceWinnerTemplate(
// Fill the determined slot
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
// If this round has a loserFeedsInto, also advance the loser to that round
if (currentRound.loserFeedsInto) {
const loserId =
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
if (loserId) {
const loserRound = template.rounds.find((r) => r.name === currentRound.loserFeedsInto);
if (loserRound) {
const loserMatches = await findPlayoffMatchesByEventIdAndRound(
match.scoringEventId,
loserRound.name
);
const loserMatch = loserMatches[0];
if (loserMatch) {
// SF match 1 loser → participant1Id, SF match 2 loser → participant2Id
const loserSlot: "participant1Id" | "participant2Id" =
match.matchNumber === 1 ? "participant1Id" : "participant2Id";
if (!loserMatch[loserSlot]) {
await updatePlayoffMatch(loserMatch.id, { [loserSlot]: loserId });
}
}
}
}
}
}
/**

View file

@ -29,9 +29,14 @@ interface RoundScoringConfig {
loserIsPartial: boolean;
/**
* Guaranteed minimum position for the winner.
* null signals a Finals round: winner is finalized as 1st place (handled inline).
* null signals a finalization round: both winner and loser receive their exact positions.
*/
winnerFloor: number | null;
/**
* When winnerFloor is null, the winner is finalized at this position.
* Defaults to 1 (champion) when omitted set to 3 for a 3rd place game.
*/
winnerPosition?: number;
}
/**
@ -76,6 +81,14 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
afl_10: {
"Semi-Finals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
},
fifa_48: {
// QF winner is guaranteed 4th at worst (lose SF → lose 3PG).
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
// SF loser still has the 3rd place game — provisional 4th until it's played.
Semifinals: { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
// 3rd place game finalizes both positions distinctly.
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
},
};
/**
@ -259,12 +272,13 @@ export async function processPlayoffEvent(
}
}
} else if (config.winnerFloor === null) {
// Finals: finalize both participants — winner gets 1st, loser gets 2nd.
// Finalization round: both participants receive their exact positions.
// winnerPosition defaults to 1 (champion); set to 3 for a 3rd place game.
const finalMatch = matches[0];
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
throw new Error("Finals match is not complete");
}
await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, 1, db);
await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, config.winnerPosition ?? 1, db);
await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
} else {
// All other scoring rounds: assign loser's final (or provisional) position.
@ -376,9 +390,9 @@ export async function processMatchResult(
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
} else if (config.winnerFloor === null) {
// Finals: finalize both participants.
// Finalization round: winner and loser both get their exact positions.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
await upsertParticipantResult(winnerId, sportsSeasonId, 1, db);
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerPosition ?? 1, db);
} else {
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);

View file

@ -116,6 +116,12 @@ export function calculateAveragedPoints(
*/
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]);
/**
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
* (not averaged). Standard brackets average them because both SF losers tie.
*/
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]);
/**
* Calculate fantasy points for a bracket placement, averaging tied positions.
*
@ -139,8 +145,11 @@ export function calculateBracketPoints(
if (finalPosition <= 0) return 0;
if (finalPosition === 1) return rules.pointsFor1st;
if (finalPosition === 2) return rules.pointsFor2nd;
if (finalPosition === 3 || finalPosition === 4)
if (finalPosition === 3 || finalPosition === 4) {
if (bracketTemplateId && DISTINCT_34_TEMPLATE_IDS.has(bracketTemplateId))
return finalPosition === 3 ? rules.pointsFor3rd : rules.pointsFor4th;
return calculateAveragedPoints([3, 4], rules);
}
if (finalPosition >= 5 && finalPosition <= 8) {
if (bracketTemplateId && SPLIT_5678_TEMPLATE_IDS.has(bracketTemplateId)) {
// AFL-style: two separate 2-team tiers within 58

View file

@ -11,7 +11,8 @@ import {
} from '~/models/participant-expected-value';
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
import { calculateEV } from '~/services/ev-calculator';
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
import { recalculateStandings } from '~/models/scoring-calculator';
import { database } from '~/database/context';
import * as schema from '~/database/schema';
@ -30,16 +31,6 @@ import {
import { useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Elo Ratings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];

View file

@ -40,6 +40,13 @@ import {
getAdvancingParticipantIds,
getEliminatedParticipantIds,
} from "~/models/tournament-group";
import {
createGroupStageMatches,
generateRoundRobinPairings,
updateGroupStageMatchResult,
updateGroupStageMatchSchedule,
findMatchesByGroupId,
} from "~/models/group-stage-match";
import { logger } from "~/lib/logger";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -65,8 +72,14 @@ export async function loader({ params }: Route.LoaderArgs) {
const participants = await findParticipantsBySportsSeasonId(params.id);
const matches = await findPlayoffMatchesByEventId(params.eventId);
// Fetch tournament groups if this event has a group-stage template
const tournamentGroups = await findGroupsByEventId(params.eventId);
// Fetch tournament groups with their round-robin matches
const tournamentGroupsRaw = await findGroupsByEventId(params.eventId);
const tournamentGroups = await Promise.all(
tournamentGroupsRaw.map(async (group) => ({
...group,
groupMatches: await findMatchesByGroupId(group.id),
}))
);
// The matches already include participant relations from the model query
return {
@ -554,6 +567,63 @@ export async function action({ request, params }: Route.ActionArgs) {
}
}
if (intent === "recalculate-floors") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
const matches = await findPlayoffMatchesByEventId(params.eventId);
const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId);
if (completed.length === 0) {
return { error: "No completed matches to reprocess" };
}
// Delete ALL results for this sports season and rebuild from scratch.
// Only deleting partial rows leaves stale finalized rows that block
// the "never un-finalize" guard in upsertParticipantResult.
const db = database();
await db
.delete(schema.participantResults)
.where(
eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId)
);
// Replay each completed match in bracket order (earlier rounds first).
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
const sortedMatches = completed.toSorted((a, b) => {
const ai = roundOrder.indexOf(a.round);
const bi = roundOrder.indexOf(b.round);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
for (const match of sortedMatches) {
await processMatchResult(
{
round: match.round,
winnerId: match.winnerId ?? "",
loserId: match.loserId ?? "",
isScoring: match.isScoring ?? true,
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
skipSideEffects: true,
}
);
}
await recalculateAffectedLeagues(event.sportsSeasonId);
return { success: `Recalculated floors from ${sortedMatches.length} completed match(es)` };
} catch (error) {
logger.error("Error recalculating floors:", error);
return {
error: error instanceof Error ? error.message : "Failed to recalculate floors",
};
}
}
if (intent === "reprocess-eliminations") {
try {
// Get the event
@ -735,7 +805,7 @@ export async function action({ request, params }: Route.ActionArgs) {
// Create tournament groups
const groups = await createGroupsForEvent(params.eventId, groupStage.groupLabels);
// Distribute participants into groups (in selection order)
// Distribute participants into groups (in selection order) and create round-robin matches
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const startIdx = groupIndex * groupStage.teamsPerGroup;
const groupParticipantIds = participantIds.slice(
@ -743,6 +813,12 @@ export async function action({ request, params }: Route.ActionArgs) {
startIdx + groupStage.teamsPerGroup
);
await addMembersToGroup(groups[groupIndex].id, groupParticipantIds);
// Create the 6 round-robin match pairings for this group
if (groupParticipantIds.length === 4) {
const pairings = generateRoundRobinPairings(groupParticipantIds);
await createGroupStageMatches(groups[groupIndex].id, pairings);
}
}
// Generate the empty knockout bracket structure
@ -779,6 +855,49 @@ export async function action({ request, params }: Route.ActionArgs) {
}
}
if (intent === "update-group-match") {
const matchId = formData.get("matchId");
const p1Score = formData.get("participant1Score");
const p2Score = formData.get("participant2Score");
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
if (typeof p1Score !== "string" || typeof p2Score !== "string") {
return { error: "Both scores are required" };
}
const s1 = parseInt(p1Score, 10);
const s2 = parseInt(p2Score, 10);
if (isNaN(s1) || isNaN(s2) || s1 < 0 || s2 < 0) {
return { error: "Scores must be non-negative integers" };
}
try {
await updateGroupStageMatchResult(matchId, s1, s2);
return { success: "Match result saved" };
} catch (error) {
logger.error("Error updating group match:", error);
return { error: error instanceof Error ? error.message : "Failed to update match" };
}
}
if (intent === "update-group-match-schedule") {
const matchId = formData.get("matchId");
const scheduledAt = formData.get("scheduledAt");
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
try {
const dateValue = typeof scheduledAt === "string" && scheduledAt
? new Date(scheduledAt)
: null;
await updateGroupStageMatchSchedule(matchId, dateValue);
return { success: "Match time saved" };
} catch (error) {
logger.error("Error updating group match schedule:", error);
return { error: error instanceof Error ? error.message : "Failed to update schedule" };
}
}
if (intent === "populate-knockout") {
try {
const event = await getScoringEventById(params.eventId);

View file

@ -23,6 +23,7 @@ import {
} from "~/components/ui/select";
import { ArrowLeft, Plus, Trophy, X, Check, ChevronDown, ChevronRight, Calendar, TrendingUp, Trash2 } from "lucide-react";
import { getAllBracketTemplates, getBracketTemplate, getOrderedRoundsFromMatches, buildNCAA68SlotMap, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
import { computeGroupStandings } from "~/models/group-stage-match";
import {
Table,
TableBody,
@ -322,6 +323,29 @@ export default function EventBracket({
</div>
)}
{/* Recalculate Floors - Fix guaranteed minimum points for still-alive participants.
Hidden once every match is complete at that point all placements are final
and "finalize bracket" should be used instead. */}
{matches.length > 0 && !matches.every((m) => m.isComplete) && (
<Card>
<CardHeader>
<CardTitle>Recalculate Floors</CardTitle>
<CardDescription>
Recompute guaranteed-minimum placements for participants still in the bracket.
Use this after scoring rule changes or when floor points look wrong.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="recalculate-floors" />
<Button type="submit" variant="outline">
Recalculate Floors
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Reprocess Eliminations - For existing brackets (non-group-stage) */}
{matches.length > 0 && !isGroupStageEvent && (
<Card>
@ -685,55 +709,189 @@ export default function EventBracket({
{/* Group Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tournamentGroups.map((group) => (
<Card key={group.id}>
<CardHeader className="py-3 px-4">
<CardTitle className="text-base">Group {group.groupName}</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{(group.members || []).map((member) => (
<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"
}`}
>
<span
className={`text-sm truncate ${
member.eliminated ? "line-through text-muted-foreground" : ""
}`}
>
{member.participant?.name || "Unknown"}
</span>
<Form method="post" className="shrink-0">
<input type="hidden" name="intent" value="toggle-elimination" />
<input type="hidden" name="memberId" value={member.id} />
<Button
type="submit"
size="sm"
variant={member.eliminated ? "outline" : "destructive"}
className="h-7 text-xs"
{tournamentGroups.map((group) => {
const members = group.members || [];
const groupMatches = group.groupMatches || [];
const standings = computeGroupStandings(
members
.filter((m) => m.participant !== null && m.participant !== undefined)
.map((m) => ({
participantId: m.participant?.id ?? "",
participantName: m.participant?.name ?? "",
})),
groupMatches.map((m) => ({
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
participant1Score: m.participant1Score,
participant2Score: m.participant2Score,
isComplete: m.isComplete,
}))
);
return (
<Card key={group.id}>
<CardHeader className="py-3 px-4">
<CardTitle className="text-base">Group {group.groupName}</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
{/* Standings table */}
{standings.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-muted-foreground">
<th className="text-left pb-1 font-medium">Team</th>
<th className="text-center pb-1 font-medium w-6">P</th>
<th className="text-center pb-1 font-medium w-6">W</th>
<th className="text-center pb-1 font-medium w-6">D</th>
<th className="text-center pb-1 font-medium w-6">L</th>
<th className="text-center pb-1 font-medium w-8">GD</th>
<th className="text-center pb-1 font-medium w-7">Pts</th>
</tr>
</thead>
<tbody>
{standings.map((row, idx) => {
const member = members.find((m) => m.participant?.id === row.participantId);
const isTop2 = idx < 2;
return (
<tr
key={row.participantId}
className={`border-b last:border-0 ${
member?.eliminated
? "text-muted-foreground line-through"
: isTop2
? "text-green-600 dark:text-green-400 font-medium"
: ""
}`}
>
<td className="py-0.5 truncate max-w-[80px]">{row.participantName}</td>
<td className="text-center">{row.played}</td>
<td className="text-center">{row.wins}</td>
<td className="text-center">{row.draws}</td>
<td className="text-center">{row.losses}</td>
<td className="text-center">{row.gd > 0 ? `+${row.gd}` : row.gd}</td>
<td className="text-center font-semibold">{row.points}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Elimination toggles */}
<div className="space-y-1.5">
{members.map((member) => (
<div
key={member.id}
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
member.eliminated
? "bg-destructive/10 border-destructive/20"
: "bg-background border-border"
}`}
>
{member.eliminated ? (
<>
<Check className="h-3 w-3 mr-1" />
Reinstate
</>
) : (
<>
<X className="h-3 w-3 mr-1" />
Eliminate
</>
)}
</Button>
</Form>
<span
className={`text-sm truncate ${
member.eliminated ? "line-through text-muted-foreground" : ""
}`}
>
{member.participant?.name || "Unknown"}
</span>
<Form method="post" className="shrink-0">
<input type="hidden" name="intent" value="toggle-elimination" />
<input type="hidden" name="memberId" value={member.id} />
<Button
type="submit"
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>
))}
</CardContent>
</Card>
))}
{/* Match score entry */}
{groupMatches.length > 0 && (
<div className="space-y-1.5 pt-1 border-t">
<p className="text-xs font-medium text-muted-foreground">Matches</p>
{groupMatches.map((match) => (
<div key={match.id} className="text-xs space-y-1">
{/* Schedule row */}
<Form method="post" className="flex items-center gap-1">
<input type="hidden" name="intent" value="update-group-match-schedule" />
<input type="hidden" name="matchId" value={match.id} />
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
<Input
type="datetime-local"
name="scheduledAt"
defaultValue={
match.scheduledAt
? new Date(match.scheduledAt).toISOString().slice(0, 16)
: ""
}
className="h-6 text-xs px-1 py-0"
/>
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
Save
</Button>
</Form>
{/* Score row */}
<Form method="post" className="flex items-center gap-1">
<input type="hidden" name="intent" value="update-group-match" />
<input type="hidden" name="matchId" value={match.id} />
<span className="truncate max-w-[70px]">
{match.participant1?.name ?? "?"}
</span>
<Input
type="number"
name="participant1Score"
min={0}
defaultValue={match.participant1Score ?? ""}
className={`h-6 w-10 text-center text-xs px-1 py-0 ${match.isComplete ? "bg-muted" : ""}`}
disabled={match.isComplete}
/>
<span className="text-muted-foreground"></span>
<Input
type="number"
name="participant2Score"
min={0}
defaultValue={match.participant2Score ?? ""}
className={`h-6 w-10 text-center text-xs px-1 py-0 ${match.isComplete ? "bg-muted" : ""}`}
disabled={match.isComplete}
/>
<span className="truncate max-w-[70px] text-right">
{match.participant2?.name ?? "?"}
</span>
{!match.isComplete && (
<Button type="submit" size="sm" variant="default" className="h-6 px-1.5 text-xs shrink-0">
Save
</Button>
)}
{match.isComplete && (
<Badge variant="secondary" className="h-5 text-xs px-1 shrink-0">FT</Badge>
)}
</Form>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{/* Knockout Assignment Section */}

View file

@ -17,25 +17,13 @@ import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { getSimulator, type SimulatorType } from "~/services/simulations/registry";
import { calculateEV, type ScoringRules } from "~/services/ev-calculator";
import { calculateEV } from "~/services/ev-calculator";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import { recalculateStandings } from "~/models/scoring-calculator";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
// Default scoring rules — matches the season table defaults.
// Per-league EV is recalculated from probabilities using league-specific rules in standings.
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export async function action({ params }: Route.ActionArgs) {
const sportsSeasonId = params.id;

View file

@ -14,6 +14,7 @@ import {
import { findCurrentSeasonWithSports } from "~/models/season";
import { getSeasonStandings } from "~/models/standings";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
import type { Route } from "./+types/$leagueId";
@ -114,6 +115,9 @@ export async function loader(args: Route.LoaderArgs) {
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
: new Map<string, Array<{ id: string; name: string }>>();
const dateFrom = new Date(dateFromStr + "T00:00:00.000Z");
const dateTo = new Date(dateToStr + "T23:59:59.999Z");
const sportsSeasons = await Promise.all(
rawSportsSeasons.map(async (ss) => {
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
@ -127,6 +131,33 @@ export async function loader(args: Route.LoaderArgs) {
dateToStr
)
: [];
// For group-stage bracket sports, also include scheduled group matches
if (ss.scoringPattern === "playoff_bracket" && draftedParticipants.length > 0) {
const draftedIds = draftedParticipants.map((p) => p.id);
const groupMatches = await getUpcomingGroupStageMatchesForParticipants(
ss.id,
draftedIds,
dateFrom,
dateTo
);
for (const gm of groupMatches) {
const relevant = draftedParticipants.filter(
(p) => p.id === gm.participant1.id || p.id === gm.participant2.id
);
upcomingParticipantEvents.push({
id: gm.matchId,
name: `${gm.participant1.name} vs ${gm.participant2.name}`,
eventDate: gm.scheduledAt ? gm.scheduledAt.split("T")[0] : null,
earliestGameTime: gm.scheduledAt || null,
matchLabel: `Group ${gm.groupName} · MD${gm.matchday}`,
eventType: "group_match",
sportsSeasonId: ss.id,
relevantParticipants: relevant,
});
}
}
return {
id: ss.id,
name: ss.name,

View file

@ -18,6 +18,11 @@ import {
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
import {
findMatchesByGroupIds,
computeGroupStandings,
} from "~/models/group-stage-match";
import { findGroupsByEventId } from "~/models/tournament-group";
import type { PlayoffMatch } from "~/models/playoff-match";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -152,6 +157,15 @@ export async function loader(args: Route.LoaderArgs) {
type QPStanding = Awaited<ReturnType<typeof getQPStandings>>[number];
let qpStandings: QPStanding[] = [];
// Group standings for group-stage events (e.g. FIFA World Cup)
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
type GroupStandingData = {
groupName: string;
standings: ReturnType<typeof computeGroupStandings>;
matches: Array<Omit<RawGroupMatch, "scheduledAt"> & { scheduledAt: string | null }>;
};
let groupStandings: GroupStandingData[] = [];
if (scoringPattern === "playoff_bracket") {
// Fetch playoff matches via scoring events
const events = await db.query.scoringEvents.findMany({
@ -176,6 +190,42 @@ export async function loader(args: Route.LoaderArgs) {
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
const template = templateId ? getBracketTemplate(templateId) : undefined;
playoffRounds = getOrderedRoundsFromMatches(matches, template);
// Load group stage standings if this event uses a group-stage template
if (template?.groupStage) {
const groupStageEventId = events.find((e) => e.bracketTemplateId === templateId)?.id;
if (groupStageEventId) {
const groups = await findGroupsByEventId(groupStageEventId);
// Single batched query for all groups instead of N round-trips
const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id));
groupStandings = groups.map((group) => {
const groupMatches = matchesByGroupId.get(group.id) ?? [];
const members = (group.members ?? [])
.filter((m) => m.participant !== null && m.participant !== undefined)
.map((m) => ({
participantId: m.participant?.id ?? "",
participantName: m.participant?.name ?? "",
}));
return {
groupName: group.groupName,
standings: computeGroupStandings(
members,
groupMatches.map((m) => ({
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
participant1Score: m.participant1Score,
participant2Score: m.participant2Score,
isComplete: m.isComplete,
}))
),
matches: groupMatches.map((m) => ({
...m,
scheduledAt: m.scheduledAt ? m.scheduledAt.toISOString() : null,
})),
};
});
}
}
}
// Fetch group-stage losers and all participant results for bracket scoring
@ -288,5 +338,6 @@ export async function loader(args: Route.LoaderArgs) {
seasonIsFinalized,
regularSeasonStandings,
participantEvs,
groupStandings,
};
}

View file

@ -5,6 +5,7 @@ import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
import { SportSeasonDisplay } from "~/components/scoring/SportSeasonDisplay";
import { EventSchedule } from "~/components/sport-season/EventSchedule";
import { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
@ -65,6 +66,7 @@ export default function SportSeasonDetail({
seasonIsFinalized,
regularSeasonStandings,
participantEvs,
groupStandings,
} = loaderData;
const hasBracket = playoffMatches && playoffMatches.length > 0;
@ -140,6 +142,13 @@ export default function SportSeasonDetail({
</div>
)}
{/* Group stage standings — shown for FIFA-style tournaments before/during group phase */}
{groupStandings.length > 0 && (
<div className="mb-6">
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
</div>
)}
{/* Hide bracket display when standings exist but no bracket matches have been set yet */}
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}

View file

@ -206,7 +206,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
/>
</div>
<div className="text-sm font-medium tabular-nums">
{standing ? standing.totalPoints : 0} pts
{standing ? (standing.actualPoints ?? standing.totalPoints) : 0} pts
</div>
</div>
);

View 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);
}
});
});

View file

@ -20,6 +20,7 @@ import { SnookerSimulator } from "./snooker-simulator";
import { TennisSimulator } from "./tennis-simulator";
import { MLBSimulator } from "./mlb-simulator";
import { WNBASimulator } from "./wnba-simulator";
import { WorldCupSimulator } from "./world-cup-simulator";
export const SIMULATOR_TYPES = [
"f1_standings",
@ -36,6 +37,7 @@ export const SIMULATOR_TYPES = [
"tennis_qualifying_points",
"mlb_bracket",
"wnba_bracket",
"world_cup",
] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -116,6 +118,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
info: { name: "WNBA Playoff Monte Carlo", description: "Projects WNBA regular season seedings and simulates full playoff bracket (R1 best-of-3, Semis best-of-5, Finals best-of-7) using SRS-derived Elo ratings. SRS values sourced from basketball-reference.com." },
create: () => new WNBASimulator(),
},
world_cup: {
info: { name: "FIFA World Cup 2026 Monte Carlo", description: "Simulates the full 48-team World Cup: round-robin group stage (12 groups), best-8 third-place selection, R32→R16→QF→SF→3rd place game→Final. Uses blended Elo + futures odds." },
create: () => new WorldCupSimulator(),
},
};
export function getSimulator(simulatorType: SimulatorType): Simulator {

View 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)
* probFifthprobEighth = 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;
}
}

View file

@ -97,6 +97,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"tennis_qualifying_points",
"mlb_bracket",
"wnba_bracket",
"world_cup",
]);
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
@ -619,6 +620,30 @@ export const tournamentGroupMembers = pgTable("tournament_group_members", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const groupStageMatches = pgTable("group_stage_matches", {
id: uuid("id").primaryKey().defaultRandom(),
tournamentGroupId: uuid("tournament_group_id")
.notNull()
.references(() => tournamentGroups.id, { onDelete: "cascade" }),
participant1Id: uuid("participant1_id")
.notNull()
.references(() => participants.id),
participant2Id: uuid("participant2_id")
.notNull()
.references(() => participants.id),
participant1Score: integer("participant1_score"),
participant2Score: integer("participant2_score"),
isComplete: boolean("is_complete").notNull().default(false),
matchday: integer("matchday").notNull(), // 1, 2, or 3 (round of group play)
scheduledAt: timestamp("scheduled_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => [
// Prevent duplicate pairings within the same group (order-independent)
uniqueIndex("group_stage_matches_group_pair_unique")
.on(t.tournamentGroupId, t.participant1Id, t.participant2Id),
]);
// Participant season results (for F1 current points tracking during season)
export const participantSeasonResults = pgTable("participant_season_results", {
id: uuid("id").primaryKey().defaultRandom(),
@ -972,6 +997,7 @@ export const tournamentGroupsRelations = relations(tournamentGroups, ({ one, man
references: [scoringEvents.id],
}),
members: many(tournamentGroupMembers),
matches: many(groupStageMatches),
}));
export const tournamentGroupMembersRelations = relations(tournamentGroupMembers, ({ one }) => ({
@ -985,6 +1011,23 @@ export const tournamentGroupMembersRelations = relations(tournamentGroupMembers,
}),
}));
export const groupStageMatchesRelations = relations(groupStageMatches, ({ one }) => ({
group: one(tournamentGroups, {
fields: [groupStageMatches.tournamentGroupId],
references: [tournamentGroups.id],
}),
participant1: one(participants, {
fields: [groupStageMatches.participant1Id],
references: [participants.id],
relationName: "groupMatchParticipant1",
}),
participant2: one(participants, {
fields: [groupStageMatches.participant2Id],
references: [participants.id],
relationName: "groupMatchParticipant2",
}),
}));
export const participantEvSnapshotsRelations = relations(participantEvSnapshots, ({ one }) => ({
participant: one(participants, {
fields: [participantEvSnapshots.participantId],

View file

@ -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");

View 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 $$;

View 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");

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -456,6 +456,20 @@
"when": 1774598151282,
"tag": "0064_fuzzy_wolfsbane",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1774719902130,
"tag": "0065_kind_mantis",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1774802533652,
"tag": "0066_stiff_siren",
"breakpoints": true
}
]
}