predictions

This commit is contained in:
yohlo
2026-07-14 14:15:34 -07:00
parent e6c72a5789
commit 1783f0e6bc
28 changed files with 2085 additions and 89 deletions
+240
View File
@@ -0,0 +1,240 @@
import { Match } from "@/features/matches/types";
import { Team, TeamInfo } from "@/features/teams/types";
import { Tournament } from "@/features/tournaments/types";
import { PicksMap } from "./types";
const teamId = (team?: TeamInfo | Team | string): string | undefined =>
typeof team === "string" ? team : team?.id;
const asTeamInfo = (team?: TeamInfo | Team | string): TeamInfo | undefined =>
typeof team === "string" ? undefined : team;
export interface ResolvedMatch {
lid: number;
home?: TeamInfo;
homeId?: string;
away?: TeamInfo;
awayId?: string;
pickedWinner?: TeamInfo;
pickedWinnerId?: string;
pickedLoser?: TeamInfo;
pickedLoserId?: string;
resetNecessary?: boolean;
}
const isBracketMatch = (match: Match) => match.round !== -1 && !match.bye;
export const getPickableMatches = (
matches: Match[],
picks: PicksMap
): Match[] => {
const resolved = resolvePredictedBracket(matches, picks);
return matches
.filter(
(match) =>
isBracketMatch(match) &&
(!match.reset || resolved.get(match.lid)?.resetNecessary)
)
.sort((a, b) => a.lid - b.lid);
};
export const isPredictionLocked = (matches: Match[]): boolean =>
matches.some(
(match) => match.status === "started" || match.status === "ended"
);
export const isTournamentPredictable = (tournament: Tournament): boolean => {
const matches = tournament.matches || [];
return (
!tournament.regional &&
matches.length > 0 &&
!matches.some((match) => match.round === -1)
);
};
const resolveInternal = (
matches: Match[],
picks: PicksMap,
prune: boolean
): { resolved: Map<number, ResolvedMatch>; picks: PicksMap } => {
const resolved = new Map<number, ResolvedMatch>();
const nextPicks: PicksMap = { ...picks };
const bracketMatches = matches
.filter(isBracketMatch)
.sort((a, b) => a.lid - b.lid);
for (const match of bracketMatches) {
const entry: ResolvedMatch = { lid: match.lid };
if (match.home_from_lid === -1) {
entry.home = asTeamInfo(match.home);
entry.homeId = teamId(match.home);
} else {
const source = resolved.get(match.home_from_lid);
entry.home = match.home_from_loser
? source?.pickedLoser
: source?.pickedWinner;
entry.homeId = match.home_from_loser
? source?.pickedLoserId
: source?.pickedWinnerId;
}
if (match.away_from_lid === -1) {
entry.away = asTeamInfo(match.away);
entry.awayId = teamId(match.away);
} else {
const source = resolved.get(match.away_from_lid);
entry.away = match.away_from_loser
? source?.pickedLoser
: source?.pickedWinner;
entry.awayId = match.away_from_loser
? source?.pickedLoserId
: source?.pickedWinnerId;
}
let pickEligible = true;
if (match.reset) {
const grandFinal = resolved.get(match.home_from_lid);
entry.resetNecessary =
!!grandFinal?.pickedWinnerId &&
grandFinal.pickedWinnerId === grandFinal.awayId;
pickEligible = entry.resetNecessary;
}
const pickId = nextPicks[String(match.lid)];
if (pickEligible && pickId && pickId === entry.homeId) {
entry.pickedWinner = entry.home;
entry.pickedWinnerId = entry.homeId;
entry.pickedLoser = entry.away;
entry.pickedLoserId = entry.awayId;
} else if (pickEligible && pickId && pickId === entry.awayId) {
entry.pickedWinner = entry.away;
entry.pickedWinnerId = entry.awayId;
entry.pickedLoser = entry.home;
entry.pickedLoserId = entry.homeId;
} else if (pickId && prune) {
delete nextPicks[String(match.lid)];
}
resolved.set(match.lid, entry);
}
return { resolved, picks: nextPicks };
};
export const resolvePredictedBracket = (
matches: Match[],
picks: PicksMap
): Map<number, ResolvedMatch> => resolveInternal(matches, picks, false).resolved;
export const setPick = (
matches: Match[],
picks: PicksMap,
lid: number,
pickedTeamId: string
): PicksMap =>
resolveInternal(
matches,
{ ...picks, [String(lid)]: pickedTeamId },
true
).picks;
export const isPredictionComplete = (
matches: Match[],
picks: PicksMap
): boolean => {
const resolved = resolvePredictedBracket(matches, picks);
return getPickableMatches(matches, picks).every(
(match) => resolved.get(match.lid)?.pickedWinnerId
);
};
export const getMatchLabel = (matches: Match[], match: Match): string => {
if (match.reset) return "Bracket Reset";
const winners = matches.filter(
(m) => isBracketMatch(m) && !m.reset && !m.is_losers_bracket
);
const grandFinal = winners.reduce(
(highest: Match | undefined, current) =>
!highest || current.lid > highest.lid ? current : highest,
undefined
);
if (!grandFinal) return `Match ${match.order}`;
const hasLosersBracket = matches.some(
(m) => isBracketMatch(m) && m.is_losers_bracket
);
if (match.lid === grandFinal.lid) return "Final";
if (
hasLosersBracket &&
!match.is_losers_bracket &&
grandFinal.home_from_lid === match.lid
) {
return "Winners Bracket Final";
}
if (match.is_losers_bracket && grandFinal.away_from_lid === match.lid) {
return "Losers Bracket Final";
}
return `Match ${match.order}`;
};
export type PickResult = "correct" | "incorrect" | "pending";
export interface PredictionScore {
points: number;
correct: number;
total: number;
perMatch: Map<number, PickResult>;
predictedChampionId?: string;
}
export const getMatchPoints = (match: Match): number =>
(match.is_losers_bracket ? 5 : 10) * 2 ** match.round;
export const computePredictionScore = (
matches: Match[],
picks: PicksMap
): PredictionScore => {
const pickable = getPickableMatches(matches, picks);
const perMatch = new Map<number, PickResult>();
let points = 0;
let correct = 0;
for (const match of pickable) {
const pickId = picks[String(match.lid)];
if (match.status !== "ended") {
perMatch.set(match.lid, "pending");
continue;
}
const actualWinnerId =
match.home_cups > match.away_cups ? teamId(match.home) : teamId(match.away);
if (pickId && actualWinnerId && pickId === actualWinnerId) {
perMatch.set(match.lid, "correct");
points += getMatchPoints(match);
correct += 1;
} else {
perMatch.set(match.lid, "incorrect");
}
}
const grandFinal = pickable
.filter((match) => !match.is_losers_bracket)
.at(-1);
return {
points,
correct,
total: pickable.length,
perMatch,
predictedChampionId: grandFinal
? picks[String(grandFinal.lid)]
: undefined,
};
};