sendou.ink/app/features/sendouq/core/groups.server.ts
2024-02-03 10:20:21 +02:00

344 lines
9.1 KiB
TypeScript

import invariant from "tiny-invariant";
import type { Group, GroupLike } from "~/db/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { FULL_GROUP_SIZE } from "../q-constants";
import type {
DividedGroups,
DividedGroupsUncensored,
LookingGroup,
LookingGroupWithInviteCode,
} from "../q-types";
import type {
SkillTierInterval,
TieredSkill,
} from "~/features/mmr/tiered.server";
import type { RecentMatchPlayer } from "../queries/findRecentMatchPlayersByUserId.server";
import { TIERS } from "~/features/mmr/mmr-constants";
import { mapModePreferencesToModeList } from "./match.server";
import { modesShort } from "~/modules/in-game-lists";
import { defaultOrdinal } from "~/features/mmr/mmr-utils";
export function divideGroups({
groups,
ownGroupId,
likes,
}: {
groups: LookingGroupWithInviteCode[];
ownGroupId: number;
likes: Pick<GroupLike, "likerGroupId" | "targetGroupId">[];
}): DividedGroupsUncensored {
let own: LookingGroupWithInviteCode | null = null;
const neutral: LookingGroupWithInviteCode[] = [];
const likesReceived: LookingGroupWithInviteCode[] = [];
const unneutralGroupIds = new Set<number>();
for (const like of likes) {
for (const group of groups) {
if (group.id === ownGroupId) continue;
// handles edge case where they liked each other
// right after each other so the group didn't morph
// so instead it will look so that the group liked us
// and there is the option to morph
if (unneutralGroupIds.has(group.id)) continue;
if (like.likerGroupId === group.id) {
likesReceived.push(group);
unneutralGroupIds.add(group.id);
break;
}
if (like.targetGroupId === group.id) {
group.isLiked = true;
}
}
}
for (const group of groups) {
if (group.id === ownGroupId) {
own = group;
continue;
}
if (unneutralGroupIds.has(group.id)) continue;
neutral.push(group);
}
invariant(own && own.members, "own group not found");
return {
own,
neutral,
likesReceived,
};
}
const MIN_PLAYERS_FOR_REPLAY = 3;
export function addReplayIndicator({
groups,
recentMatchPlayers,
userId,
}: {
groups: DividedGroupsUncensored;
recentMatchPlayers: RecentMatchPlayer[];
userId: number;
}): DividedGroupsUncensored {
if (!recentMatchPlayers.length) return groups;
const ownGroupId = recentMatchPlayers.find(
(u) => u.userId === userId,
)?.groupId;
invariant(ownGroupId, "own group not found");
const otherGroupId = recentMatchPlayers.find(
(u) => u.groupId !== ownGroupId,
)?.groupId;
invariant(otherGroupId, "other group not found");
const opponentPlayers = recentMatchPlayers
.filter((u) => u.groupId === otherGroupId)
.map((p) => p.userId);
const addReplayIndicatorIfNeeded = (group: LookingGroupWithInviteCode) => {
const samePlayersCount = group.members.reduce(
(acc, cur) => (opponentPlayers.includes(cur.id) ? acc + 1 : acc),
0,
);
return { ...group, isReplay: samePlayersCount >= MIN_PLAYERS_FOR_REPLAY };
};
return {
own: groups.own,
likesReceived: groups.likesReceived.map(addReplayIndicatorIfNeeded),
neutral: groups.neutral.map(addReplayIndicatorIfNeeded),
};
}
export function addFutureMatchModes(
groups: DividedGroupsUncensored,
): DividedGroupsUncensored {
const ownModePreferences = groups.own.mapModePreferences?.map((p) => p.modes);
if (!ownModePreferences) return groups;
const futureMatchModes = (group: LookingGroupWithInviteCode) => {
const theirModePreferences = group.mapModePreferences?.map((p) => p.modes);
if (!theirModePreferences) return;
return mapModePreferencesToModeList(
ownModePreferences,
theirModePreferences,
).sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b));
};
return {
own: groups.own,
likesReceived: groups.likesReceived.map((g) => ({
...g,
futureMatchModes: futureMatchModes(g),
})),
neutral: groups.neutral.map((g) => ({
...g,
futureMatchModes: futureMatchModes(g),
})),
};
}
const censorGroupFully = ({
inviteCode: _inviteCode,
mapModePreferences: _mapModePreferences,
...group
}: LookingGroupWithInviteCode): LookingGroup => ({
...group,
members: undefined,
});
const censorGroupPartly = ({
inviteCode: _inviteCode,
mapModePreferences: _mapModePreferences,
...group
}: LookingGroupWithInviteCode): LookingGroup => group;
export function censorGroups({
groups,
showMembers,
showInviteCode,
}: {
groups: DividedGroupsUncensored;
showMembers: boolean;
showInviteCode: boolean;
}): DividedGroups {
return {
own: showInviteCode ? groups.own : censorGroupPartly(groups.own),
neutral: groups.neutral.map(
showMembers ? censorGroupPartly : censorGroupFully,
),
likesReceived: groups.likesReceived.map(
showMembers ? censorGroupPartly : censorGroupFully,
),
};
}
export function sortGroupsBySkillAndSentiment({
groups,
userSkills,
intervals,
}: {
groups: DividedGroups;
userSkills: Record<string, TieredSkill>;
intervals: SkillTierInterval[];
}): DividedGroups {
const ownGroupTier =
groups.own.tier?.name ??
resolveGroupSkill({
group: groups.own as LookingGroupWithInviteCode,
userSkills,
intervals,
})?.name;
const ownGroupTierIndex = TIERS.findIndex((t) => t.name === ownGroupTier);
const tierDiff = (otherGroupTierName?: string) => {
if (!otherGroupTierName) return 10;
const otherGroupTierIndex = TIERS.findIndex(
(t) => t.name === otherGroupTierName,
);
return Math.abs(ownGroupTierIndex - otherGroupTierIndex);
};
const groupSentiment = (group: LookingGroup) => {
if (group.members?.some((m) => m.privateNote?.sentiment === "NEGATIVE")) {
return "NEGATIVE";
}
if (group.members?.some((m) => m.privateNote?.sentiment === "POSITIVE")) {
return "POSITIVE";
}
return "NEUTRAL";
};
return {
...groups,
neutral: groups.neutral.sort((a, b) => {
const aTier =
a.tier?.name ??
resolveGroupSkill({
group: a as LookingGroupWithInviteCode,
userSkills,
intervals,
})?.name;
const bTier =
b.tier?.name ??
resolveGroupSkill({
group: b as LookingGroupWithInviteCode,
userSkills,
intervals,
})?.name;
const aSentiment = groupSentiment(a);
const bSentiment = groupSentiment(b);
if (aSentiment !== bSentiment) {
if (aSentiment === "NEGATIVE") return 1;
if (bSentiment === "NEGATIVE") return -1;
if (aSentiment === "POSITIVE") return -1;
if (bSentiment === "POSITIVE") return 1;
}
const aTierDiff = tierDiff(aTier);
const bTierDiff = tierDiff(bTier);
// if same tier difference, show newer groups first
if (aTierDiff === bTierDiff) {
return b.createdAt - a.createdAt;
}
// show groups with smaller tier difference first
return aTierDiff - bTierDiff;
}),
};
}
export function addSkillsToGroups({
groups,
userSkills,
intervals,
}: {
groups: DividedGroupsUncensored;
userSkills: Record<string, TieredSkill>;
intervals: SkillTierInterval[];
}): DividedGroupsUncensored {
const addSkill = (group: LookingGroupWithInviteCode) => ({
...group,
members: group.members?.map((m) => {
const skill = userSkills[String(m.id)];
return {
...m,
skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill,
};
}),
tier:
group.members.length === FULL_GROUP_SIZE
? resolveGroupSkill({ group, userSkills, intervals })
: undefined,
});
return {
own: addSkill(groups.own),
neutral: groups.neutral.map(addSkill),
likesReceived: groups.likesReceived.map(addSkill),
};
}
export function membersNeededForFull(currentSize: number) {
return FULL_GROUP_SIZE - currentSize;
}
function resolveGroupSkill({
group,
userSkills,
intervals,
}: {
group: LookingGroupWithInviteCode;
userSkills: Record<string, TieredSkill>;
intervals: SkillTierInterval[];
}): TieredSkill["tier"] | undefined {
const skills = group.members.map(
(m) => userSkills[String(m.id)] ?? { ordinal: defaultOrdinal() },
);
const averageOrdinal =
skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length;
const tier = intervals.find(
(i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal,
) ?? { isPlus: false, name: "IRON" };
// For Leviathan we don't specify if it's plus or not
return tier.name === "LEVIATHAN"
? { name: "LEVIATHAN", isPlus: false }
: { name: tier.name, isPlus: tier.isPlus };
}
export function groupExpiryStatus(
group: Pick<Group, "latestActionAt">,
): null | "EXPIRING_SOON" | "EXPIRED" {
// group expires in 30min without actions performed
const groupExpiresAt =
databaseTimestampToDate(group.latestActionAt).getTime() + 30 * 60 * 1000;
const now = new Date().getTime();
if (now > groupExpiresAt) {
return "EXPIRED";
}
const tenMinutesFromNow = now + 10 * 60 * 1000;
if (tenMinutesFromNow > groupExpiresAt) {
return "EXPIRING_SOON";
}
return null;
}