predictions
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user