sendou.ink/app/features/sendouq/core/SendouQ.server.ts
Kalle 2b5b1b1948
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run
New match page (#3032)
2026-05-04 18:15:10 +03:00

657 lines
18 KiB
TypeScript

import { isWithinInterval, sub } from "date-fns";
import * as R from "remeda";
import type { DBBoolean, ParsedMemento, Tables } from "~/db/tables";
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { defaultOrdinal } from "~/features/mmr/mmr-utils";
import { type TieredSkill, userSkills } from "~/features/mmr/tiered.server";
import type * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as SendouQMatch from "~/features/sendouq-match/core/SendouQMatch";
import type * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import type { SerializeFrom } from "~/utils/remix";
import { FULL_GROUP_SIZE } from "../q-constants";
import type { TierRange } from "../q-types";
import { getTierIndex } from "../q-utils.server";
import { tierDifferenceToRangeOrExact } from "./groups.server";
type DBGroupRow = Awaited<
ReturnType<typeof SQGroupRepository.findCurrentGroups>
>[number];
type DBPrivateNoteRow = Awaited<
ReturnType<typeof PrivateUserNoteRepository.byAuthorUserId>
>[number];
type DBRecentlyFinishedMatchRow = Awaited<
ReturnType<typeof SQGroupRepository.findRecentlyFinishedMatches>
>[number];
type DBMatch = NonNullable<
Awaited<ReturnType<typeof SQMatchRepository.findById>>
>;
export type SQUncensoredGroup = SerializeFrom<
(typeof SendouQClass.prototype.groups)[number]
>;
export type SQGroup = SerializeFrom<
ReturnType<SendouQClass["lookingGroups"]>[number]
>;
export type SQOwnGroup = SerializeFrom<
NonNullable<ReturnType<SendouQClass["findOwnGroup"]>>
>;
export type SQMatch = SerializeFrom<ReturnType<SendouQClass["mapMatch"]>>;
export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"];
export type SQGroupMember = NonNullable<SQGroup["members"]>[number];
const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
const SECONDS_TILL_STALE =
process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800;
class SendouQClass {
groups;
#recentMatches;
#isAccurateTiers;
#userSkills;
#intervals;
/** Array of user IDs currently in the queue */
usersInQueue;
constructor(
groups: DBGroupRow[],
recentMatches: DBRecentlyFinishedMatchRow[],
) {
const season = Seasons.currentOrPrevious();
const {
intervals,
userSkills: calculatedUserSkills,
isAccurateTiers,
} = userSkills(season!.nth);
this.#recentMatches = recentMatches;
this.#isAccurateTiers = isAccurateTiers;
this.#userSkills = calculatedUserSkills;
this.#intervals = intervals;
this.usersInQueue = groups.flatMap((group) =>
group.members.map((member) => member.id),
);
this.groups = groups.map((group) => ({
...group,
noScreen: this.#groupNoScreen(group),
modePreferences: this.#groupModePreferences(group),
tier: this.#groupTier(group) as TieredSkill["tier"] | null,
tierRange: null as TierRange | null,
skillDifference:
undefined as ParsedMemento["groups"][number]["skillDifference"],
isReplay: false,
usersRole: null as Tables["GroupMember"]["role"] | null,
members: group.members.map((member) => {
const skill = calculatedUserSkills[String(member.id)];
return {
...member,
privateNote: null as DBPrivateNoteRow | null,
languages: member.languages?.split(",") || [],
skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill,
mapModePreferences: undefined,
noScreen: undefined,
friendCode: null as string | null,
inGameName: null as string | null,
skillDifference:
undefined as ParsedMemento["users"][number]["skillDifference"],
};
}),
}));
}
/**
* Determines the current view state for a user based on their group status.
*/
currentViewByUserId(
/** The ID of the logged in user */
userId: number,
) {
const ownGroup = this.findOwnGroup(userId);
if (!ownGroup) return "default";
if (ownGroup.status === "PREPARING") return "preparing";
if (ownGroup.matchId) return "match";
return "looking";
}
/**
* Finds the group that a user belongs to.
* @returns The user's group with their role, or undefined if not in a group
*/
findOwnGroup(userId: number) {
const result = this.groups.find((group) =>
group.members.some((member) => member.id === userId),
);
if (!result) return;
const member = result.members.find((m) => m.id === userId)!;
return {
...result,
usersRole: member.role,
};
}
/**
* Finds a group by its ID without censoring sensitive data.
* @returns The uncensored group, or undefined if not found
*/
findUncensoredGroupById(groupId: number) {
return this.groups.find((group) => group.id === groupId);
}
/**
* Finds a group by its invite code.
* @returns The group with matching invite code, or undefined if not found
*/
findGroupByInviteCode(inviteCode: string) {
return this.groups.find((group) => group.inviteCode === inviteCode);
}
/**
* Maps a database match to a format with appropriate censoring based on user permissions.
* Includes private notes for team members and censors sensitive data for non-participants.
* @returns The mapped match with censored data based on user permissions
*/
mapMatch(
/** The database match object to map */
match: DBMatch,
/** The authenticated user viewing the match (if any) */
user?: AuthenticatedUser,
/** Array of private user notes to include */
notes: DBPrivateNoteRow[] = [],
) {
const viewerSide = SendouQMatch.resolveGroupMemberOf({
groupAlpha: match.groupAlpha,
groupBravo: match.groupBravo,
userId: user?.id,
});
const isTeamAlphaMember = viewerSide === "ALPHA";
const isTeamBravoMember = viewerSide === "BRAVO";
const isMatchInsider = viewerSide !== null || user?.roles.includes("STAFF");
const happenedInLastMonth = isWithinInterval(
databaseTimestampToDate(match.createdAt),
{
start: sub(new Date(), { months: 1 }),
end: new Date(),
},
);
const matchGroupCensorer = (
group: DBMatch["groupAlpha"] | DBMatch["groupBravo"],
isTeamMember: boolean,
) => {
return {
...group,
chatCode: isTeamMember ? group.chatCode : undefined,
noScreen: this.#groupNoScreen(group),
tier: match.memento?.groups[group.id]?.tier,
skillDifference: match.memento?.groups[group.id]?.skillDifference,
matchmade: Boolean(group.matchmade),
members: group.members.map((member) => {
return {
...member,
skill: match.memento?.users[member.id]?.skill,
privateNote: null as DBPrivateNoteRow | null,
skillDifference: match.memento?.users[member.id]?.skillDifference,
noScreen: undefined,
isContinuing:
typeof member.isContinuing === "number"
? Boolean(member.isContinuing)
: null,
friendCode:
isMatchInsider && happenedInLastMonth
? member.friendCode
: undefined,
};
}),
};
};
const alphaCensored = matchGroupCensorer(
match.groupAlpha,
isTeamAlphaMember,
);
const bravoCensored = matchGroupCensorer(
match.groupBravo,
isTeamBravoMember,
);
const reportedMapsCount = match.mapList.filter(
(map) => map.winnerGroupId,
).length;
const currentMapRaw = match.mapList.at(reportedMapsCount);
const currentMap = currentMapRaw
? {
...currentMapRaw,
voters: this.#currentMapVoters({
currentMap: currentMapRaw,
groupAlpha: alphaCensored,
groupBravo: bravoCensored,
pools: match.memento?.pools,
}),
}
: undefined;
return {
...match,
chatCode: isMatchInsider ? match.chatCode : undefined,
currentMap,
groupAlpha: this.#getAddMemberPrivateNoteMapper(notes)(alphaCensored),
groupBravo: this.#getAddMemberPrivateNoteMapper(notes)(bravoCensored),
};
}
/**
* Returns all groups with wide tier ranges for preview purposes. Full groups being preview always show the full range (IRON-LEVIATHAN)
* @returns Array of censored groups with preview tier ranges
*/
previewGroups(
/** The ID of the user viewing the preview */
userId: number,
/** Array of private user notes to include */
notes: DBPrivateNoteRow[],
) {
const usersTier = this.#getUserTier(userId);
return this.groups
.filter((group) => this.#isSuitableLookingGroup({ group }))
.map(this.#getAddMemberPrivateNoteMapper(notes))
.sort(this.#getSkillAndNoteSortComparator(usersTier))
.map((group) => this.#addPreviewTierRange(group))
.map((group) => this.#censorGroup(group));
}
/**
* Returns groups that are available for matchmaking for a specific user based on their current group size.
* Filters groups based on member count compatibility, activity status, and excludes stale groups.
* Results are sorted by sentiment (notes), tier difference, and activity.
* @returns Array of compatible groups sorted by relevance, or empty array if user has no group
*/
lookingGroups(
/** The ID of the user looking for groups */
userId: number,
/** Array of private user notes to include */
notes: DBPrivateNoteRow[] = [],
) {
const ownGroup = this.findOwnGroup(userId);
if (!ownGroup) return [];
const currentMemberCountOptions =
ownGroup.members.length === 4
? [4]
: ownGroup.members.length === 3
? [1]
: ownGroup.members.length === 2
? [1, 2]
: [1, 2, 3];
return this.groups
.filter((group) =>
this.#isSuitableLookingGroup({
group,
ownGroupId: ownGroup.id,
currentMemberCountOptions,
}),
)
.map(this.#getGroupReplayMapper(userId))
.map(this.#getAddTierRangeMapper(ownGroup.tier))
.map(this.#getAddMemberPrivateNoteMapper(notes))
.sort(this.#getSkillAndNoteSortComparator(ownGroup.tier))
.map((group) => this.#censorGroup(group));
}
#getGroupReplayMapper(userId: number) {
const recentOpponents = this.#recentMatches.flatMap((match) => {
if (match.groupAlphaMemberIds.includes(userId)) {
return [match.groupBravoMemberIds];
}
if (match.groupBravoMemberIds.includes(userId)) {
return [match.groupAlphaMemberIds];
}
return [];
});
return <T extends (typeof this.groups)[number]>(group: T) => {
if (recentOpponents.length === 0) return group;
if (!this.#groupIsFull(group)) return group;
const isReplay = recentOpponents.some((opponentIds) => {
const duplicateCount =
R.countBy(opponentIds, (id) =>
group.members.some((m) => m.id === id) ? "match" : "no-match",
).match ?? 0;
return duplicateCount >= 3;
});
return {
...group,
isReplay,
};
};
}
#getAddTierRangeMapper(ownTier?: TieredSkill["tier"] | null) {
return <T extends (typeof this.groups)[number]>(group: T) => {
if (!this.#groupIsFull(group)) {
return group;
}
const tierRangeOrExact = tierDifferenceToRangeOrExact({
ourTier: ownTier ?? FALLBACK_TIER,
theirTier: group.tier ?? FALLBACK_TIER,
hasLeviathan: this.#isAccurateTiers,
});
if (tierRangeOrExact.type === "exact") {
return group;
}
return {
...group,
tierRange: R.omit(tierRangeOrExact, ["type"]),
tier: null,
};
};
}
#addPreviewTierRange<T extends (typeof this.groups)[number]>(group: T) {
if (!this.#groupIsFull(group)) {
return group;
}
return {
...group,
tierRange: {
type: "range" as const,
range: [
{ name: "IRON", isPlus: false } as TieredSkill["tier"],
{ name: "LEVIATHAN", isPlus: true } as TieredSkill["tier"],
],
diff: 0,
},
tier: null,
};
}
#censorGroup<T extends (typeof this.groups)[number]>(
group: T,
): Omit<T, "inviteCode" | "chatCode" | "members"> & {
members: T["members"] | undefined;
} {
const {
inviteCode: _inviteCode,
chatCode: _chatCode,
members,
...baseGroup
} = group;
if (this.#groupIsFull(group)) {
return {
...baseGroup,
members: undefined,
};
}
return {
...baseGroup,
members,
};
}
#getUserTier(userId: number): TieredSkill["tier"] | null {
const skill = this.#userSkills[String(userId)];
if (!skill || skill.approximate) {
return null;
}
return skill.tier;
}
#getAddMemberPrivateNoteMapper(notes: DBPrivateNoteRow[]) {
return <T extends { members: { id: number }[] }>(group: T) => {
const membersWithNotes = group.members.map((member) => {
const note = notes.find((n) => n.targetUserId === member.id);
return {
...member,
privateNote: note ?? null,
};
});
return {
...group,
members: membersWithNotes,
};
};
}
#getSkillAndNoteSortComparator(ownTier?: TieredSkill["tier"] | null) {
return <
T extends {
members: { privateNote: DBPrivateNoteRow | null }[];
tierRange: TierRange | null;
tier: TieredSkill["tier"] | null;
latestActionAt: number;
},
>(
a: T,
b: T,
) => {
const aIsFull = this.#groupIsFull(a);
const bIsFull = this.#groupIsFull(b);
if (aIsFull !== bIsFull) {
return aIsFull ? 1 : -1;
}
const getGroupSentimentScore = (group: T) => {
const hasNegative = group.members.some(
(m) => m.privateNote?.sentiment === "NEGATIVE",
);
const hasPositive = group.members.some(
(m) => m.privateNote?.sentiment === "POSITIVE",
);
if (hasNegative) return -1;
if (hasPositive) return 1;
return 0;
};
const scoreA = getGroupSentimentScore(a);
const scoreB = getGroupSentimentScore(b);
if (scoreA !== scoreB) {
return scoreB - scoreA;
}
if (a.tierRange && b.tierRange) {
if (a.tierRange.diff[1] !== b.tierRange.diff[1]) {
return a.tierRange.diff[1] - b.tierRange.diff[1];
}
}
const ownTierIndex = getTierIndex(ownTier, this.#isAccurateTiers);
if (typeof ownTierIndex === "number") {
const diffA = Math.abs(
ownTierIndex - (getTierIndex(a.tier, this.#isAccurateTiers) ?? 999),
);
const diffB = Math.abs(
ownTierIndex - (getTierIndex(b.tier, this.#isAccurateTiers) ?? 999),
);
if (diffA !== diffB) {
return diffA - diffB;
}
}
return b.latestActionAt - a.latestActionAt;
};
}
#groupNoScreen(group: { members: { noScreen: DBBoolean }[] }) {
return this.#groupIsFull(group)
? group.members.some((member) => member.noScreen)
: null;
}
#groupModePreferences(
group: DBGroupRow | DBMatch["groupAlpha"] | DBMatch["groupBravo"],
): ModeShort[] {
const modePreferences: ModeShort[] = [];
for (const mode of modesShort) {
let score = 0;
for (const member of group.members) {
const userModePreferences = member.mapModePreferences?.modes;
if (!userModePreferences) continue;
if (
userModePreferences.some(
(p) => p.mode === mode && p.preference === "PREFER",
)
) {
score += 1;
} else if (
userModePreferences.some(
(p) => p.mode === mode && p.preference === "AVOID",
)
) {
score -= 1;
}
}
if (score > 0) {
modePreferences.push(mode);
}
}
// reasonable default
if (modePreferences.length === 0) {
return ["SZ"];
}
return modePreferences;
}
#groupIsFull(group: { members: unknown[] }) {
return group.members.length === FULL_GROUP_SIZE;
}
#currentMapVoters({
currentMap,
groupAlpha,
groupBravo,
pools,
}: {
currentMap: DBMatch["mapList"][number];
groupAlpha: {
id: number;
members: Array<{
id: number;
username: string;
discordId: string;
discordAvatar: string | null;
}>;
};
groupBravo: {
id: number;
members: Array<{
id: number;
username: string;
discordId: string;
discordAvatar: string | null;
}>;
};
pools: ParsedMemento["pools"] | undefined;
}) {
if (!pools) return [];
const pickerGroups = [groupAlpha, groupBravo].filter(
(g) => currentMap.source === "BOTH" || String(g.id) === currentMap.source,
);
if (pickerGroups.length === 0) return [];
return pickerGroups.flatMap((pickerGroup) =>
pools.flatMap(({ userId, pool }) => {
const member = pickerGroup.members.find((m) => m.id === userId);
if (!member) return [];
const modePool = pool.find((p) => p.mode === currentMap.mode);
if (!modePool?.stages.includes(currentMap.stageId)) return [];
return [
{
id: member.id,
username: member.username,
discordId: member.discordId,
discordAvatar: member.discordAvatar,
},
];
}),
);
}
#groupTier(
group: DBGroupRow | DBMatch["groupAlpha"] | DBMatch["groupBravo"],
): TieredSkill["tier"] | undefined {
if (!group.members) return;
const skills = group.members.map(
(m) => this.#userSkills[String(m.id)] ?? { ordinal: defaultOrdinal() },
);
const averageOrdinal =
skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length;
return (
this.#intervals.find(
(i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal,
) ?? { isPlus: false, name: "IRON" }
);
}
#isSuitableLookingGroup({
group,
ownGroupId,
currentMemberCountOptions,
}: {
group: SendouQClass["groups"][number];
ownGroupId?: number;
currentMemberCountOptions?: number[];
}) {
if (group.status !== "ACTIVE") return false;
if (group.matchId) return false;
if (group.id === ownGroupId) return false;
if (
currentMemberCountOptions &&
!currentMemberCountOptions.includes(group.members.length)
) {
return false;
}
const staleThreshold = sub(new Date(), { seconds: SECONDS_TILL_STALE });
const groupLastAction = databaseTimestampToDate(group.latestActionAt);
return groupLastAction >= staleThreshold;
}
}
const groups = await SQGroupRepository.findCurrentGroups();
const recentMatches = await SQGroupRepository.findRecentlyFinishedMatches();
/** Global instance of the SendouQ manager. Manages all active groups and matchmaking state. */
export let SendouQ = new SendouQClass(groups, recentMatches);
/**
* Refreshes the global SendouQ instance with the latest data from the database.
* Should be called after any database changes that affect groups or matches.
*/
export async function refreshSendouQInstance() {
const groups = await SQGroupRepository.findCurrentGroups();
const recentMatches = await SQGroupRepository.findRecentlyFinishedMatches();
SendouQ = new SendouQClass(groups, recentMatches);
}