mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-11 05:05:07 -05:00
202 lines
5.6 KiB
TypeScript
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: [] }
|
|
);
|
|
}
|