241 lines
6.5 KiB
TypeScript
241 lines
6.5 KiB
TypeScript
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,
|
|
};
|
|
};
|