sendou.ink/app/core/play/utils.ts

202 lines
5.6 KiB
TypeScript

import invariant from "tiny-invariant";
import { LFG_GROUP_FULL_SIZE, LFG_GROUP_INACTIVE_MINUTES } from "~/constants";
import * as LFGGroup from "~/models/LFGGroup.server";
import { LookingLoaderData } from "~/routes/play/looking";
import { Unpacked } from "~/utils";
import {
skillToMMR,
teamHasSkill,
teamSkillToApproximateMMR,
} 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,
}: {
groups: LFGGroup.FindLooking;
likes: {
given: Set<string>;
received: Set<string>;
};
lookingForMatch: boolean;
ownGroup: Unpacked<LFGGroup.FindLooking>;
}) {
const { EXPIRED: expiredDate } = groupExpiredDates();
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((group) => group.lastActionAt.getTime() > expiredDate.getTime())
.map((group) => {
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) => {
const { skill, ...rest } = m.user;
return {
...rest,
MMR: skillToMMR(skill),
};
}),
ranked: ranked(),
teamMMR:
lookingForMatch && group.ranked && teamHasSkill(group.members)
? {
exact: false,
value: teamSkillToApproximateMMR(group.members),
}
: 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: [] }
);
}