sendou.ink/app/core/play/utils.ts
2022-04-15 18:22:36 +03:00

353 lines
10 KiB
TypeScript

import { LfgGroupStatus } from "@prisma/client";
import { redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import {
BIT_HIGHER_MMR_LIMIT,
CLOSE_MMR_LIMIT,
HIGHER_MMR_LIMIT,
LFG_GROUP_FULL_SIZE,
LFG_GROUP_INACTIVE_MINUTES,
} from "~/constants";
import * as LFGGroup from "~/models/LFGGroup.server";
import * as LFGMatch from "~/models/LFGMatch.server";
import { PlayFrontPageLoader } from "~/routes/play/index";
import {
LookingLoaderData,
LookingLoaderDataGroup,
} from "~/routes/play/looking";
import { Unpacked } from "~/utils";
import {
sendouQAddPlayersPage,
sendouQFrontPage,
sendouQLookingPage,
sendouQMatchPage,
} from "~/utils/urls";
import { skillArrayToMMR, teamSkillToExactMMR } from "../mmr/utils";
import { canUniteWithGroup } from "./validators";
export interface UniteGroupInfoArg {
id: string;
memberCount: number;
}
export function uniteGroupInfo(
groupA: UniteGroupInfoArg,
groupB: UniteGroupInfoArg
): LFGGroup.UniteGroupsArgs {
const survivingGroupId =
groupA.memberCount > groupB.memberCount ? groupA.id : groupB.id;
const otherGroupId = survivingGroupId === groupA.id ? groupB.id : groupA.id;
return {
survivingGroupId,
otherGroupId,
removeCaptainsFromOther: groupA.memberCount !== groupB.memberCount,
};
}
/** Checks if the reported score is the same as score from the database */
export function scoresAreIdentical({
stages,
winnerIds,
}: {
stages: { winnerGroupId: string | null }[];
winnerIds: string[];
}): boolean {
const stagesWithWinner = stages.filter((stage) => stage.winnerGroupId);
if (stagesWithWinner.length !== winnerIds.length) return false;
for (const [i, stage] of stagesWithWinner.entries()) {
if (!stage.winnerGroupId) break;
if (stage.winnerGroupId !== winnerIds[i]) return false;
}
return true;
}
export function groupsToWinningAndLosingPlayerIds({
winnerGroupIds,
groups,
}: {
winnerGroupIds: string[];
groups: { id: string; members: { user: { id: string } }[] }[];
}): {
winning: string[];
losing: string[];
} {
const occurences: Record<string, number> = {};
for (const groupId of winnerGroupIds) {
if (occurences[groupId]) occurences[groupId]++;
else occurences[groupId] = 1;
}
const winnerGroupId = Object.entries(occurences)
.sort((a, b) => a[1] - b[1])
.pop()?.[0];
invariant(winnerGroupId, "winnerGroupId is undefined");
return groups.reduce(
(acc, group) => {
const ids = group.members.map((m) => m.user.id);
if (group.id === winnerGroupId) acc.winning = ids;
else acc.losing = ids;
return acc;
},
{ winning: [] as string[], losing: [] as string[] }
);
}
/**
* Group dates to compare against for expired status. E.g. if the group
* lastActionAt.getTime() is smaller than that of EXPIRED Date's then
* that group is expired
*/
export function groupExpiredDates(): Record<
"ALMOST_EXPIRED" | "EXPIRED",
Date
> {
const now = new Date();
const thirtyMinutesAgo = new Date(
now.getTime() - 60_000 * LFG_GROUP_INACTIVE_MINUTES
);
const now2 = new Date();
const twentyMinutesAgo = new Date(
now2.getTime() - 60_000 * (LFG_GROUP_INACTIVE_MINUTES - 10)
);
return { EXPIRED: thirtyMinutesAgo, ALMOST_EXPIRED: twentyMinutesAgo };
}
export function groupWillBeInactiveAt(timestamp: number) {
return new Date(timestamp + 60_000 * LFG_GROUP_INACTIVE_MINUTES);
}
export function groupExpirationStatus(lastActionAtTimestamp: number) {
const { EXPIRED: expiredDate, ALMOST_EXPIRED: almostExpiredDate } =
groupExpiredDates();
if (expiredDate.getTime() > lastActionAtTimestamp) return "EXPIRED";
if (almostExpiredDate.getTime() > lastActionAtTimestamp) {
return "ALMOST_EXPIRED";
}
}
export function otherGroupsForResponse({
groups,
likes,
lookingForMatch,
ownGroup,
recentMatch,
user,
}: {
groups: LFGGroup.FindLookingAndOwnActive;
likes: {
given: Set<string>;
received: Set<string>;
};
lookingForMatch: boolean;
ownGroup: Unpacked<LFGGroup.FindLookingAndOwnActive>;
recentMatch: LFGMatch.RecentOfUser;
user: { id: string };
}) {
return (
groups
.filter(
(group) =>
(lookingForMatch && group.members.length === LFG_GROUP_FULL_SIZE) ||
canUniteWithGroup({
ownGroupType: ownGroup.type,
ownGroupSize: ownGroup.members.length,
otherGroupSize: group.members.length,
})
)
.filter((group) => group.id !== ownGroup.id)
.filter(filterExpiredGroups)
// this should not happen.... but sometimes it does :)
.filter((g) => g.members.length > 0)
.map((group): LookingLoaderDataGroup => {
const ranked = () => {
if (lookingForMatch && !ownGroup.ranked) return false;
return group.ranked ?? undefined;
};
return {
id: group.id,
// When looking for a match ranked groups are censored
// and instead we only reveal their approximate skill level
members:
ownGroup.ranked && group.ranked && lookingForMatch
? undefined
: group.members.map((m) => {
return {
miniBio: m.user.miniBio ?? undefined,
discordAvatar: m.user.discordAvatar,
discordId: m.user.discordId,
discordName: m.user.discordName,
discordDiscriminator: m.user.discordDiscriminator,
id: m.user.id,
captain: m.captain,
weapons: m.user.weapons,
MMR: skillArrayToMMR(m.user.skill),
};
}),
ranked: ranked(),
replay: isMatchReplay({ user, group, recentMatch }),
MMRRelation:
ownGroup.ranked &&
group.ranked &&
group.members.length === LFG_GROUP_FULL_SIZE
? resolveMMRRelation({ group, ownGroup })
: undefined,
};
})
.reduce(
(
acc: Omit<
LookingLoaderData,
"ownGroup" | "type" | "isCaptain" | "lastActionAtTimestamp"
>,
group
) => {
// likesReceived first so that if both received like and
// given like then handle this edge case by just displaying the
// group as waiting like back
if (likes.received.has(group.id)) {
acc.likerGroups.push(group);
} else if (likes.given.has(group.id)) {
acc.likedGroups.push(group);
} else {
acc.neutralGroups.push(group);
}
return acc;
},
{ likedGroups: [], neutralGroups: [], likerGroups: [] }
)
);
}
export function isMatchReplay({
recentMatch,
user,
group,
}: {
recentMatch: { groups: { members: { memberId: string }[] }[] } | null;
user: { id: string };
group: { members: { memberId: string }[] };
}): boolean {
if (!recentMatch) return false;
const opponentGroupOfRecent = recentMatch.groups.find((g) =>
g.members.every((m) => m.memberId !== user.id)
);
invariant(
opponentGroupOfRecent,
"Unexpected opponentGroupOfRecent undefined"
);
const memberIdsOfGroup = new Set(group.members.map((m) => m.memberId));
let sameCount = 0;
for (const { memberId } of opponentGroupOfRecent.members) {
if (memberIdsOfGroup.has(memberId)) sameCount++;
}
return sameCount > 2;
}
export function filterExpiredGroups(group: { lastActionAt: Date }) {
const { EXPIRED: expiredDate } = groupExpiredDates();
return group.lastActionAt.getTime() > expiredDate.getTime();
}
export function countGroups(
groups: LFGGroup.FindLookingAndOwnActive
): PlayFrontPageLoader["counts"] {
return groups.filter(filterExpiredGroups).reduce(
(acc: PlayFrontPageLoader["counts"], group) => {
const memberCount = group.members.length;
if (group.type === "QUAD" && memberCount !== 4) {
acc.QUAD += memberCount;
} else if (group.type === "TWIN" && memberCount !== 2) {
acc.TWIN += memberCount;
} else if (group.type === "VERSUS") {
acc["VERSUS"] += memberCount;
}
return acc;
},
{ TWIN: 0, QUAD: 0, VERSUS: 0 }
);
}
function resolveMMRRelation({
group,
ownGroup,
}: {
group: Unpacked<LFGGroup.FindLookingAndOwnActive>;
ownGroup: Unpacked<LFGGroup.FindLookingAndOwnActive>;
}): NonNullable<LookingLoaderDataGroup["MMRRelation"]> {
return calculateDifference({
ourMMR: teamSkillToExactMMR(ownGroup.members),
theirMMR: teamSkillToExactMMR(group.members),
});
}
export function calculateDifference({
ourMMR,
theirMMR,
}: {
ourMMR: number;
theirMMR: number;
}): NonNullable<LookingLoaderDataGroup["MMRRelation"]> {
const difference = Math.abs(ourMMR - theirMMR);
const ownIsBigger = ourMMR > theirMMR;
if (difference <= CLOSE_MMR_LIMIT) return "CLOSE";
if (difference <= BIT_HIGHER_MMR_LIMIT && ownIsBigger) return "BIT_LOWER";
if (difference <= BIT_HIGHER_MMR_LIMIT && !ownIsBigger) return "BIT_HIGHER";
if (difference <= HIGHER_MMR_LIMIT && ownIsBigger) return "LOWER";
if (difference <= HIGHER_MMR_LIMIT && !ownIsBigger) return "HIGHER";
if (ownIsBigger) return "LOT_LOWER";
if (!ownIsBigger) return "LOT_HIGHER";
throw new Error("Unexpected calculateMMRRelation scenario");
}
export function resolveRedirect({
currentStatus = "INACTIVE",
currentPage,
matchId,
}: {
currentStatus?: LfgGroupStatus;
currentPage: LfgGroupStatus;
matchId?: string | null;
}) {
if (currentStatus === currentPage) return;
switch (currentStatus) {
case "INACTIVE": {
return redirect(sendouQFrontPage());
}
case "LOOKING": {
return redirect(sendouQLookingPage());
}
case "MATCH": {
invariant(matchId, "Unexpected no match id for redirect");
return redirect(sendouQMatchPage(matchId));
}
case "PRE_ADD": {
return redirect(sendouQAddPlayersPage());
}
default: {
const exhaustive: never = currentStatus;
throw new Response(`Unknown status: ${JSON.stringify(exhaustive)}`, {
status: 500,
});
}
}
}