sendou.ink/app/features/sendouq/core/groups.server.ts
Kalle e7bbb565be
SendouQ (#1455)
* Tables

* Clocks

* Maplist preference selector

* Fix SSR

* Nav icon

* RankedOrScrim

* Map pool

* Create group

* Redirect logic

* Persist map pool

* Advance from preparing page

* Rename query

* Fix merge

* Fix migration order

* Seed groups

* Find looking groups SQL

* Renders something

* More UI work

* Back to 30min

* Likes/dislikes

* Always return own group

* Fix like order

* 3 tc/rm/cb -> 2

* Show only 3 weapons

* Pass group size

* Handle both liked and liked by same group

* Fix SQL

* Group preference frontend work

* Morphing

* Styling

* Don't show group controls if not manager

* Give/remove manager

* Leave group

* Leave with confirm

* Delete likes when morphing groups

* Clocks consistency

* Remove bad invariant

* Persist settings to local storage

* Fix initial value flashing

* Fix never resolving loading indicator

* REFRESH_GROUP

* Flip animations

* Tweaks

* Auto refresh logic

* Groups of 4 seed

* Reduce throwing

* Load full groups initial

* Create match

* Match UI initial

* Score reporter initial

* Push footer down on match page

* Score reporter knows when set ended

* Score reporting untested

* Show score after report

* Align better

* Look again with same group functionality

* More migrations

* Team on match page

* Show confirmer before reporting score

* Report weapons

* Report weapos again by admin + skill changing

* Handle no tiebreaker given to MapPool

* Remove unranked

* Remove support for "team id skill"

* no-wrap -> nowrap

* Preparing page work

* Use common GroupCard component

* Add some metas

* MemberAdder in looking page

* Fix GroupCard actions

* Fix SZ only map list including other modes

* Add season info

* Prompt login

* Joining team

* Manage group on preparing page

* Manage group on preparing page

* Seed past matches

* Add to seed

* No map list preference when full group + fix expiry

* Fix skill matchesCount calculation

* Tiers initial work

* Some progress on tiers

* Tiering logic

* MMR in group cards

* Name to challenge

* Team MMR

* Big team rank icons

* Adjust todos

* Match score report with confirm

* Allow regular members to report score

* Handle reporting weapons edge cases

* Add tier images

* Improve GroupCard spacing

* Refactor looking page

* Looking mobile UI

* Calculate skill only for current season

* Divide groups visually when reporting weapons

* Fix match page weapons sorting

* Add cache to user skills+tier calculation

* Admin report match score

* Initial leaderboard

* Cached leaderboard

* Weapon category lb's

* Populate SkillTeamUser in SendouQ

* Team leaderboard filtered down

* Add TODOs

* Seasons initlal

* Season weapons initial

* Weapons stylized

* Show rest weapons as +

* Hide peak if same as current

* Load matches SQL initial

* Season matches UI initial

* Take user id in account

* Add weapons

* Paginated matches

* Fix pages count logic

* Scroll top on data change

* Day headers for matches

* Link from user page to user seasons page

* Summarize maps + ui initial

* Map stats

* Player info tabs

* MMR chart

* Chart adjustments

* Handle basing team MMR on player MMR

* Set initial MMR

* Add info about discord to match page

* Season support to tournaments

* Get tournament skills as well for the graph

* WIP

* New team rating logic + misc other

* tiered -> tiered.server

* Update season starting time

* TODOs

* Add rules page

* Hide elements correctly when off-season

* Fix crash when only one player with skill

* How-to video

* Fix StartRank showing when not logged in

* Make user leaderboard the default

* Make Skill season non-nullable

* Add suggested pass to match

* Add rule

* identifierToUserIds helper

* Fix tiers not showing
2023-08-12 22:42:54 +03:00

203 lines
5.0 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";
export function divideGroups({
groups,
ownGroupId,
likes,
}: {
groups: LookingGroupWithInviteCode[];
ownGroupId: number;
likes: Pick<GroupLike, "likerGroupId" | "targetGroupId">[];
}): DividedGroupsUncensored {
let own: LookingGroupWithInviteCode | null = null;
let neutral: LookingGroupWithInviteCode[] = [];
const likesReceived: LookingGroupWithInviteCode[] = [];
const likesGiven: 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) {
likesGiven.push(group);
unneutralGroupIds.add(group.id);
break;
}
}
}
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,
likesGiven,
likesReceived,
};
}
export function filterOutGroupsWithIncompatibleMapListPreference(
groups: DividedGroupsUncensored
): DividedGroupsUncensored {
if (
groups.own.mapListPreference !== "SZ_ONLY" &&
groups.own.mapListPreference !== "ALL_MODES_ONLY"
) {
return groups;
}
return {
...groups,
neutral: groups.neutral.filter((group) => {
if (
group.mapListPreference !== "SZ_ONLY" &&
group.mapListPreference !== "ALL_MODES_ONLY"
) {
return true;
}
return group.mapListPreference === groups.own.mapListPreference;
}),
};
}
const censorGroupFully = ({
inviteCode: _inviteCode,
...group
}: LookingGroupWithInviteCode): LookingGroup => ({
...group,
members: undefined,
mapListPreference: undefined,
});
const censorGroupPartly = ({
inviteCode: _inviteCode,
...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
),
likesGiven: groups.likesGiven.map(
showMembers ? censorGroupPartly : censorGroupFully
),
likesReceived: groups.likesReceived.map(
showMembers ? censorGroupPartly : censorGroupFully
),
};
}
export function addSkillsToGroups({
groups,
userSkills,
intervals,
}: {
groups: DividedGroupsUncensored;
userSkills: Record<string, TieredSkill>;
intervals: SkillTierInterval[];
}): DividedGroupsUncensored {
const resolveGroupSkill = (
group: LookingGroupWithInviteCode
): TieredSkill["tier"] | undefined => {
if (group.members.length < FULL_GROUP_SIZE) return;
const skills = group.members
.map((m) => userSkills[String(m.id)])
.filter(Boolean);
const averageOrdinal =
skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length;
return (
intervals.find(
(i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal
) ?? { isPlus: false, name: "IRON" }
);
};
const addSkill = (group: LookingGroupWithInviteCode) => ({
...group,
members: group.members?.map((m) => ({
...m,
skill: userSkills[String(m.id)],
})),
tier: resolveGroupSkill(group),
});
return {
own: addSkill(groups.own),
neutral: groups.neutral.map(addSkill),
likesGiven: groups.likesGiven.map(addSkill),
likesReceived: groups.likesReceived.map(addSkill),
};
}
export function membersNeededForFull(currentSize: number) {
return FULL_GROUP_SIZE - currentSize;
}
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;
}